diff --git a/packages/coding-agent/src/changes.md b/packages/coding-agent/src/changes.md index d4c5a6c9f..9822646cc 100644 --- a/packages/coding-agent/src/changes.md +++ b/packages/coding-agent/src/changes.md @@ -1,3 +1,36 @@ +## Keep long-running compaction recovery progressing (2026-08-03) + +### What changed + +- The automatic-compaction soft cap now resets after each provider turn instead of lasting for the + whole multi-tool agent run. The completed turn's zero-yield recovery still observes its original cap + before the reset, while the absolute session cap remains authoritative for every route. +- Required-compaction failures now resume queued work after an accepted recovery compaction without a + synthetic `continue`, while rejected recovery remains terminal. +- Provenance-confirmed required recovery uses the persisted byte-derived estimate when no valid + provider usage sample exists. +- Deterministic recovery measures the reconstructed suffix instead of stale cumulative assistant usage. + It keeps the prepared boundary when safe and otherwise advances to the latest complete persisted user + turn, including expanded skill text and its chronological suffix, with strict retained-message schemas. + +### Why + +- Long `ulw` runs could complete three valid compactions and then reject every later threshold + compaction as if the whole agent run were one provider turn. +- When summarization then failed, a fitting skill-bearing suffix could be rejected because provider + usage still described the discarded pre-compaction prefix. Repeated continuations surfaced the same + threshold error instead of recovering. + +### Why this cannot be expressed externally + +- The fix depends on internal provider-turn lifecycle state, exact session entry boundaries, compaction + admission, and continuation ownership. + +### Expected merge conflict zones + +- `src/core/agent-session.ts` compaction retry/continuation ownership and upstream telemetry lifecycle. +- `src/core/extensions/builtin/compaction/` admission, fallback, and provider-turn accounting. + ## Compact completed apply_patch result details (2026-08-02) ### What changed diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 0a6d9735d..8b4e905a0 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -610,6 +610,10 @@ export class AgentSession { private readonly _sessionWorkBarrier = new SessionWorkBarrier(); private _overflowRecoveryAttempted = false; private _requiredCompactionAdmissionError: RequiredCompactionError | undefined; + // Preserve provenance across agent-core's conversion of our admission error + // into an assistant error message. Matching provider text alone is not proof + // that AgentSession initiated required-compaction recovery. + private _requiredCompactionTurnError: RequiredCompactionError | undefined; // A retry continuation immediately follows an accepted compaction. Its first // response must not retrigger threshold compaction from stale provider usage. private _skipNextPostRetryCompactionCheck = false; @@ -997,8 +1001,11 @@ export class AgentSession { try { return await this._enforceCompactionBeforeProvider(turn.message, true, "threshold"); } catch (error) { - if (error instanceof RequiredCompactionError && this.agent.hasQueuedMessages()) { - this._requiredCompactionAdmissionError = error; + if (error instanceof RequiredCompactionError) { + this._requiredCompactionTurnError = error; + if (this.agent.hasQueuedMessages()) { + this._requiredCompactionAdmissionError = error; + } } throw error; } @@ -1451,16 +1458,17 @@ export class AgentSession { const messages = filterContextExcludedMessages(this.agent.state.messages); const estimate = estimateContextTokens(messages); if (estimate.lastUsageIndex === null) { - return undefined; - } - const compactionEntry = getLatestCompactionEntry(this.sessionManager.getBranch()); - const usageMessage = messages[estimate.lastUsageIndex]; - if ( - compactionEntry && - usageMessage?.role === "assistant" && - this._isAssistantFromBeforeLatestCompaction(usageMessage) - ) { - return undefined; + if (!this._isRequiredCompactionError(message)) return undefined; + } else { + const compactionEntry = getLatestCompactionEntry(this.sessionManager.getBranch()); + const usageMessage = messages[estimate.lastUsageIndex]; + if ( + compactionEntry && + usageMessage?.role === "assistant" && + this._isAssistantFromBeforeLatestCompaction(usageMessage) + ) { + return undefined; + } } contextTokens = estimate.tokens; } @@ -1528,13 +1536,19 @@ export class AgentSession { } private _willRetryAfterAgentEnd(messages: AgentMessage[]): boolean { - const settings = this.settingsManager.getRetrySettings(); - if (!settings.enabled) { + const lastAssistant = this._lastAssistantMessage ?? this._findLastAssistantInMessages(messages); + if (!lastAssistant) { return false; } + if ( + this._isRequiredCompactionError(lastAssistant) && + this._getRequiredAutoCompactionReason(lastAssistant) !== undefined + ) { + return true; + } - const lastAssistant = this._lastAssistantMessage ?? this._findLastAssistantInMessages(messages); - if (!lastAssistant) { + const settings = this.settingsManager.getRetrySettings(); + if (!settings.enabled) { return false; } @@ -1567,7 +1581,18 @@ export class AgentSession { return this._retryFallback.canTryFallback(); } + private _isRequiredCompactionError(message: AssistantMessage): boolean { + return ( + this._requiredCompactionTurnError !== undefined && + message.stopReason === "error" && + message.errorMessage === this._requiredCompactionTurnError.message + ); + } + private async _processAgentEvent(event: AgentEvent, signal: AbortSignal): Promise { + if (event.type === "agent_start") { + this._requiredCompactionTurnError = undefined; + } // When a user message starts, check if it's from either queue and remove it BEFORE emitting // This ensures the UI sees the updated queue state if (event.type === "message_start" && event.message.role === "user") { @@ -1684,6 +1709,8 @@ export class AgentSession { this._lastAssistantMessage = undefined; this._skipNextPostRetryCompactionCheck = false; const requiredAutoCompaction = this._getRequiredAutoCompactionReason(msg); + const retryAfterRequiredCompaction = + requiredAutoCompaction !== undefined && this._isRequiredCompactionError(msg); // Retry transient failures normally and eligible hard errors only through a fallback. const retryableError = this._isRetryableError(msg); @@ -1718,7 +1745,7 @@ export class AgentSession { this._scheduleContinuationAfterCurrentEvent(); launchedContinuation = true; } else { - launchedContinuation = await this._checkCompaction(msg); + launchedContinuation = await this._checkCompaction(msg, true, undefined, retryAfterRequiredCompaction); allowsPostCompactionUsageExemptContinuation = this._postCompactionUsageExemptAssistants.has(msg); if (allowsPostCompactionUsageExemptContinuation) { this._flushPostCompactionDeferredMessages(); @@ -4640,17 +4667,20 @@ export class AgentSession { } else { const messages = filterContextExcludedMessages(this.agent.state.messages); const estimate = estimateContextTokens(messages); - if (estimate.lastUsageIndex === null) return false; // No usage data at all - // Verify the usage source is post-compaction. Kept pre-compaction messages - // have stale usage reflecting the old (larger) context and would falsely - // trigger compaction right after one just finished. - const usageMsg = messages[estimate.lastUsageIndex]; - if ( - compactionEntry && - usageMsg.role === "assistant" && - this._isAssistantFromBeforeLatestCompaction(usageMsg) - ) { - return false; + if (estimate.lastUsageIndex === null) { + if (!this._isRequiredCompactionError(assistantMessage)) return false; + } else { + // Verify the usage source is post-compaction. Kept pre-compaction messages + // have stale usage reflecting the old (larger) context and would falsely + // trigger compaction right after one just finished. + const usageMsg = messages[estimate.lastUsageIndex]; + if ( + compactionEntry && + usageMsg.role === "assistant" && + this._isAssistantFromBeforeLatestCompaction(usageMsg) + ) { + return false; + } } contextTokens = estimate.tokens; } @@ -4664,7 +4694,7 @@ export class AgentSession { retryAfterCompaction, ); } else { - const compacted = await this._runAutoCompaction("threshold", false); + const compacted = await this._runAutoCompaction("threshold", retryAfterCompaction); if ( !compacted && this._compactionLifecycle.state.status === "failed" && diff --git a/packages/coding-agent/src/core/changes.md b/packages/coding-agent/src/core/changes.md index efbf66c99..106c41386 100644 --- a/packages/coding-agent/src/core/changes.md +++ b/packages/coding-agent/src/core/changes.md @@ -21,6 +21,32 @@ - LOW: `agent-session.ts` compaction start/end logging correlation and `test/session-log-routes.test.ts` lifecycle telemetry coverage. +## Required-compaction continuation recovery (2026-08-03) + +### What changed + +- `AgentSession` marks only provenance-confirmed required-compaction admission errors as retrying. +- Accepted post-turn threshold compaction resumes the exact interrupted continuation, including queued + steering input, without fabricating a user `continue`. +- A locally proven required-compaction error can use the persisted byte estimate when every provider + usage sample is missing or zero. +- Rejected recovery stays terminal, provider errors with the same text do not gain retry provenance, + and one recovery sequence persists one threshold error. + +### Why + +- Required admission previously surfaced as a terminal provider failure before the recovery compaction + finished, leaving active work idle even after a successful compaction. + +### Why this cannot be expressed externally + +- Only the session runtime owns the interrupted continuation, compaction lifecycle, provider-admission + ordering, and queued-input precedence. + +### Expected merge conflict zones + +- `agent-session.ts` required-compaction provenance, `_runAutoCompaction()`, and upstream request-ID telemetry. + ## Prefer configured client fallback chains over server substitutions (2026-08-03) ### What changed diff --git a/packages/coding-agent/src/core/extensions/builtin/compaction/changes.md b/packages/coding-agent/src/core/extensions/builtin/compaction/changes.md index e89421da1..f859df334 100644 --- a/packages/coding-agent/src/core/extensions/builtin/compaction/changes.md +++ b/packages/coding-agent/src/core/extensions/builtin/compaction/changes.md @@ -1,5 +1,38 @@ # Builtin compaction extension changes +## Reset the cap per provider turn and retain a safe deterministic suffix (2026-08-03) + +### What changed + +- `turn_end` now resets the soft compaction counters after the completed turn's degradation and + zero-yield recovery checks. `agent_end` keeps its existing final reset, and the absolute session cap + is checked before every manual, extension, or automatic route. +- Deterministic required recovery projects the exact post-compaction context and ignores cumulative + assistant usage that refers to the discarded prefix. +- The fallback prefers the prepared boundary, then tries the latest meaningful persisted user boundary + once and retains every following message in order. +- Recovery remains fail-closed for oversized suffixes, images, provider-native blocks, opaque replay + signatures, branch summaries, malformed message envelopes or known block schemas, and empty or + default-ignorable user boundaries. + +### Why + +- The previous “per-turn” counter lasted for an entire multi-tool agent run, so the fourth valid + compaction was rejected even after three separate provider turns. +- A loaded skill is ordinary user text, but stale assistant usage could make that small suffix appear + larger than the input cap, and the fallback had no later safe boundary to try. + +### Why this cannot be expressed externally + +- The behavior depends on builtin lifecycle state, canonical session reconstruction, and internal + replay-safety metadata. + +### Expected merge conflict zones + +- `index.ts` `turn_end`/`agent_end` lifecycle accounting and blocking-compaction admission. +- `deterministic-fallback.ts` retained-suffix projection and metadata. +- `retained-message-safety.ts` normalized replay-envelope and content validation. + ## Idle warm-up retries transient failures while the session stays idle (2026-08-03) ### What changed diff --git a/packages/coding-agent/src/core/extensions/builtin/compaction/deterministic-fallback.ts b/packages/coding-agent/src/core/extensions/builtin/compaction/deterministic-fallback.ts index 69b509846..683d620cd 100644 --- a/packages/coding-agent/src/core/extensions/builtin/compaction/deterministic-fallback.ts +++ b/packages/coding-agent/src/core/extensions/builtin/compaction/deterministic-fallback.ts @@ -1,7 +1,9 @@ -import { type CompactionPreparation, type CompactionResult, estimateContextTokens } from "../../../compaction/index.ts"; +import { type CompactionPreparation, type CompactionResult, estimateTokens } from "../../../compaction/index.ts"; import { StreamDurationBudgetError, StreamIdleTimeoutError } from "../../../compaction/stream-watchdog.ts"; +import { filterContextExcludedMessages } from "../../../messages.ts"; import { buildSessionContext, type CompactionEntry, type SessionEntry } from "../../../session-manager.ts"; import { SummarizationOverflowExhaustedError } from "./overflow-retry.ts"; +import { hasUnsafeRetainedContent } from "./retained-message-safety.ts"; import { SummaryRequestError } from "./speculative.ts"; import { capUtf8Bytes } from "./task-intent.ts"; @@ -20,7 +22,36 @@ interface DeterministicFallbackDetails { schema: "senpi.compaction.deterministic-fallback.v1"; origin: "required-compaction-recovery"; failureKind: RequiredCompactionFallbackFailure; - retainedSuffix?: "prepared"; + taskIntent?: string; + retainedSuffix?: "prepared" | "latest-user-turn"; +} + +const NON_VISIBLE_USER_TEXT = /[\p{White_Space}\p{Default_Ignorable_Code_Point}]/gu; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function hasMeaningfulUserText(entry: SessionEntry): boolean { + if (entry.type !== "message" || !isRecord(entry.message) || entry.message.role !== "user") return false; + const content = entry.message.content; + const hasVisibleText = (text: unknown): boolean => + typeof text === "string" && text.normalize("NFKC").replace(NON_VISIBLE_USER_TEXT, "").length > 0; + if (typeof content === "string") return hasVisibleText(content); + if (!Array.isArray(content)) return false; + return content.some((block) => isRecord(block) && block.type === "text" && hasVisibleText(block.text)); +} + +function estimateConservativeTokens(messages: ReturnType): number { + try { + return messages.reduce((tokens, message) => { + const serialized = JSON.stringify(message); + if (serialized === undefined) return Number.POSITIVE_INFINITY; + return tokens + Math.max(estimateTokens(message), Buffer.byteLength(serialized)); + }, 0); + } catch { + return Number.POSITIVE_INFINITY; + } } export function classifyRequiredCompactionFallbackFailure( @@ -45,7 +76,8 @@ export function createRequiredCompactionFallback( metadata: RecoveryMetadata, branchEntries: SessionEntry[] = [], ): CompactionResult | undefined { - if (!preparation.firstKeptEntryId || !branchEntries.some((entry) => entry.id === preparation.firstKeptEntryId)) { + const preparedBoundaryIndex = branchEntries.findIndex((entry) => entry.id === preparation.firstKeptEntryId); + if (!preparation.firstKeptEntryId || preparedBoundaryIndex === -1) { return undefined; } @@ -75,30 +107,49 @@ export function createRequiredCompactionFallback( failureKind, ...(taskIntent ? { taskIntent } : {}), }; - const result: CompactionResult = { - summary, - firstKeptEntryId: preparation.firstKeptEntryId, - tokensBefore: preparation.tokensBefore, - details: baseDetails, - }; - const syntheticCompaction: CompactionEntry = { - type: "compaction", - id: "__senpi_deterministic_fallback_preview__", - parentId: branchEntries.at(-1)?.id ?? null, - timestamp: new Date(0).toISOString(), - summary: result.summary, - firstKeptEntryId: result.firstKeptEntryId, - tokensBefore: result.tokensBefore, - details: result.details, - fromHook: true, - }; - const retainedTokens = estimateContextTokens( - buildSessionContext([...branchEntries, syntheticCompaction]).messages, - ).tokens; - if (retainedTokens > contextWindow - preparation.settings.reserveTokens) return undefined; - return { - ...result, - estimatedTokensAfter: retainedTokens, - details: { ...baseDetails, retainedSuffix: "prepared" }, + const projectCandidate = ( + firstKeptEntryId: string, + retainedSuffix: NonNullable, + ): CompactionResult | undefined => { + const details = { ...baseDetails, retainedSuffix }; + const result: CompactionResult = { + summary, + firstKeptEntryId, + tokensBefore: preparation.tokensBefore, + details, + }; + const syntheticCompaction: CompactionEntry = { + type: "compaction", + id: "__senpi_deterministic_fallback_preview__", + parentId: branchEntries.at(-1)?.id ?? null, + timestamp: new Date(0).toISOString(), + summary: result.summary, + firstKeptEntryId: result.firstKeptEntryId, + tokensBefore: result.tokensBefore, + details: result.details, + fromHook: true, + }; + let retainedMessages: ReturnType; + try { + retainedMessages = filterContextExcludedMessages( + buildSessionContext([...branchEntries, syntheticCompaction]).messages, + ); + } catch { + return undefined; + } + if (hasUnsafeRetainedContent(retainedMessages)) return undefined; + const retainedTokens = estimateConservativeTokens(retainedMessages); + if (retainedTokens > contextWindow - preparation.settings.reserveTokens) return undefined; + return { ...result, estimatedTokensAfter: retainedTokens }; }; + + const prepared = projectCandidate(preparation.firstKeptEntryId, "prepared"); + if (prepared) return prepared; + + for (let index = branchEntries.length - 1; index > preparedBoundaryIndex; index--) { + const entry = branchEntries[index]; + if (!hasMeaningfulUserText(entry)) continue; + return projectCandidate(entry.id, "latest-user-turn"); + } + return undefined; } diff --git a/packages/coding-agent/src/core/extensions/builtin/compaction/index.ts b/packages/coding-agent/src/core/extensions/builtin/compaction/index.ts index 4d114ce15..7434c7a6e 100644 --- a/packages/coding-agent/src/core/extensions/builtin/compaction/index.ts +++ b/packages/coding-agent/src/core/extensions/builtin/compaction/index.ts @@ -767,11 +767,15 @@ export default function compactionExtension( }); pi.on("turn_end", async (_event, ctx) => { - if (lanePolicy.disablesSenpiCompaction(ctx)) return; - handleTurnEnd(degradationState); - if (degradationState.recoveryTriggeredThisCycle) return; - if (state.lastYield && state.lastYield.savedTokens <= 0) { - void applyBlockingCompaction(ctx, RECOVERY_INSTRUCTIONS).catch(() => {}); + try { + if (lanePolicy.disablesSenpiCompaction(ctx)) return; + handleTurnEnd(degradationState); + if (degradationState.recoveryTriggeredThisCycle) return; + if (state.lastYield && state.lastYield.savedTokens <= 0) { + void applyBlockingCompaction(ctx, RECOVERY_INSTRUCTIONS).catch(() => {}); + } + } finally { + state = resetTurnCounter(state, ""); } }); diff --git a/packages/coding-agent/src/core/extensions/builtin/compaction/per-turn-cap.ts b/packages/coding-agent/src/core/extensions/builtin/compaction/per-turn-cap.ts index fc2a79d68..8a6eec7db 100644 --- a/packages/coding-agent/src/core/extensions/builtin/compaction/per-turn-cap.ts +++ b/packages/coding-agent/src/core/extensions/builtin/compaction/per-turn-cap.ts @@ -36,9 +36,12 @@ export function shouldRejectByCap( state: CompactionExtensionState, opts?: ShouldRejectByCapOptions, ): { cancel: boolean } { + if (isOverHardCap(state)) { + return { cancel: true }; + } const bypass = opts?.manual === true || opts?.reason === "manual" || opts?.reason === "extension"; if (bypass) { - return { cancel: isOverHardCap(state) }; + return { cancel: false }; } if (isOverSoftCap(state)) { return { cancel: true }; diff --git a/packages/coding-agent/src/core/extensions/builtin/compaction/retained-message-safety.ts b/packages/coding-agent/src/core/extensions/builtin/compaction/retained-message-safety.ts new file mode 100644 index 000000000..524c5a713 --- /dev/null +++ b/packages/coding-agent/src/core/extensions/builtin/compaction/retained-message-safety.ts @@ -0,0 +1,168 @@ +import type { AgentMessage } from "@earendil-works/pi-agent-core"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function hasTextOnlyContent(content: unknown, allowString: boolean): boolean { + if (typeof content === "string") return allowString; + if (!Array.isArray(content)) return false; + return content.every((block) => isRecord(block) && block.type === "text" && typeof block.text === "string"); +} + +function isUsage(value: unknown): boolean { + if (!isRecord(value) || !isRecord(value.cost)) return false; + for (const field of ["input", "output", "cacheRead", "cacheWrite", "totalTokens"] as const) { + if (!isFiniteNumber(value[field])) return false; + } + for (const field of ["input", "output", "cacheRead", "cacheWrite", "total"] as const) { + if (!isFiniteNumber(value.cost[field])) return false; + } + return ( + (value.cacheWrite1h === undefined || isFiniteNumber(value.cacheWrite1h)) && + (value.reasoning === undefined || isFiniteNumber(value.reasoning)) + ); +} + +function hasSafeAssistantContent(content: unknown): boolean { + if (!Array.isArray(content)) return false; + for (const block of content) { + if (!isRecord(block) || typeof block.type !== "string") return false; + switch (block.type) { + case "text": + if (typeof block.text !== "string" || block.textSignature !== undefined) return false; + break; + case "thinking": + if ( + typeof block.thinking !== "string" || + (block.startedAt !== undefined && !isFiniteNumber(block.startedAt)) || + (block.endedAt !== undefined && !isFiniteNumber(block.endedAt)) || + (block.redacted !== undefined && typeof block.redacted !== "boolean") || + block.redacted === true || + block.thinkingSignature !== undefined + ) { + return false; + } + break; + case "toolCall": + if ( + typeof block.id !== "string" || + typeof block.name !== "string" || + !isRecord(block.arguments) || + (block.incomplete !== undefined && block.incomplete !== true) || + (block.errorMessage !== undefined && typeof block.errorMessage !== "string") || + block.thoughtSignature !== undefined + ) { + return false; + } + break; + default: + return false; + } + } + return true; +} + +function hasSafeAssistantEnvelope(message: Record): boolean { + const stopReason = message.stopReason; + const stopDetails = message.stopDetails; + const safeStopDetails = + stopDetails === undefined || + (isRecord(stopDetails) && + (stopDetails.type === "sensitive" || + (stopDetails.type === "refusal" && + (stopDetails.explanation === undefined || typeof stopDetails.explanation === "string")))); + return ( + typeof message.api === "string" && + typeof message.provider === "string" && + typeof message.model === "string" && + isUsage(message.usage) && + (stopReason === "pending" || + stopReason === "stop" || + stopReason === "length" || + stopReason === "toolUse" || + stopReason === "error" || + stopReason === "aborted") && + safeStopDetails && + isFiniteNumber(message.timestamp) && + (message.responseModel === undefined || typeof message.responseModel === "string") && + (message.responseId === undefined || typeof message.responseId === "string") && + (message.diagnostics === undefined || Array.isArray(message.diagnostics)) && + (message.errorMessage === undefined || typeof message.errorMessage === "string") && + (message.rawStopReason === undefined || typeof message.rawStopReason === "string") && + hasSafeAssistantContent(message.content) + ); +} + +function hasSafeToolResultEnvelope(message: Record): boolean { + return ( + typeof message.toolCallId === "string" && + message.toolCallId.length > 0 && + typeof message.toolName === "string" && + message.toolName.length > 0 && + hasTextOnlyContent(message.content, false) && + typeof message.isError === "boolean" && + isFiniteNumber(message.timestamp) && + (message.usage === undefined || isUsage(message.usage)) && + (message.addedToolNames === undefined || + (Array.isArray(message.addedToolNames) && message.addedToolNames.every((name) => typeof name === "string"))) + ); +} + +/** Reject any retained message that cannot be safely replayed through normalized provider conversion. */ +export function hasUnsafeRetainedContent(messages: AgentMessage[]): boolean { + for (const value of messages as unknown[]) { + if (!isRecord(value) || typeof value.role !== "string") return true; + switch (value.role) { + case "user": + if (!isFiniteNumber(value.timestamp) || !hasTextOnlyContent(value.content, true)) return true; + break; + case "assistant": + if (!hasSafeAssistantEnvelope(value)) return true; + break; + case "toolResult": + if (!hasSafeToolResultEnvelope(value)) return true; + break; + case "custom": + if ( + typeof value.customType !== "string" || + typeof value.display !== "boolean" || + !isFiniteNumber(value.timestamp) || + !hasTextOnlyContent(value.content, true) + ) { + return true; + } + break; + case "bashExecution": + if ( + typeof value.command !== "string" || + typeof value.output !== "string" || + (value.exitCode !== undefined && !isFiniteNumber(value.exitCode)) || + typeof value.cancelled !== "boolean" || + typeof value.truncated !== "boolean" || + (value.fullOutputPath !== undefined && typeof value.fullOutputPath !== "string") || + (value.excludeFromContext !== undefined && typeof value.excludeFromContext !== "boolean") || + !isFiniteNumber(value.timestamp) + ) { + return true; + } + break; + case "compactionSummary": + if ( + typeof value.summary !== "string" || + !isFiniteNumber(value.tokensBefore) || + !isFiniteNumber(value.timestamp) + ) { + return true; + } + break; + default: + return true; + } + } + return false; +} diff --git a/packages/coding-agent/test/compaction/blocking-compaction-route-guards.test.ts b/packages/coding-agent/test/compaction/blocking-compaction-route-guards.test.ts index 72de5aeae..8181587d9 100644 --- a/packages/coding-agent/test/compaction/blocking-compaction-route-guards.test.ts +++ b/packages/coding-agent/test/compaction/blocking-compaction-route-guards.test.ts @@ -89,6 +89,30 @@ describe("blocking compaction route guards (issue #527)", () => { expect((harness.ctx.applyCompaction as ReturnType).mock.calls.length).toBe(softCap); }); + it("admits compaction again after the provider turn ends", async () => { + const handlers = captureHandlers(); + const beforeAgentStart = handlers.get("before_agent_start"); + const sessionCompact = handlers.get("session_compact"); + const turnEnd = handlers.get("turn_end"); + expect(beforeAgentStart).toBeDefined(); + expect(sessionCompact).toBeDefined(); + expect(turnEnd).toBeDefined(); + const harness = createBlockingContext({ usageTokens: 9_950 }); + registrations.push(harness.registration); + harness.registration.setResponses( + Array.from({ length: softCap + 1 }, () => fauxAssistantMessage("## Goal\ncompact summary")), + ); + + for (let round = 0; round < softCap; round++) { + await beforeAgentStart?.(createBeforeAgentStartEvent() as never, harness.ctx); + await sessionCompact?.(acceptedCompactionEvent(round, 8_000) as never, harness.ctx); + } + await turnEnd?.({ type: "turn_end" } as never, harness.ctx); + await beforeAgentStart?.(createBeforeAgentStartEvent() as never, harness.ctx); + + expect((harness.ctx.applyCompaction as ReturnType).mock.calls.length).toBe(softCap + 1); + }); + it("counts zero-yield attempts before admitting turn-end recovery", async () => { const handlers = captureHandlers(); const turnEnd = handlers.get("turn_end"); diff --git a/packages/coding-agent/test/compaction/per-turn-cap.test.ts b/packages/coding-agent/test/compaction/per-turn-cap.test.ts index 786715771..56287b3dc 100644 --- a/packages/coding-agent/test/compaction/per-turn-cap.test.ts +++ b/packages/coding-agent/test/compaction/per-turn-cap.test.ts @@ -17,7 +17,10 @@ interface FutureCapState { } type IncrementAcceptedFn = (state: FutureCapState) => FutureCapState; -type ShouldRejectByCapFn = (state: FutureCapState, opts?: { manual?: boolean }) => { cancel: boolean }; +type ShouldRejectByCapFn = ( + state: FutureCapState, + opts?: { manual?: boolean; reason?: "manual" | "extension" }, +) => { cancel: boolean }; type ResetTurnCounterFn = (state: FutureCapState) => FutureCapState; const incrementAcceptedFuture = incrementAccepted as unknown as IncrementAcceptedFn; @@ -123,6 +126,23 @@ describe("compaction per-turn cap", () => { }); }); + describe("Given accepted compactions reach the absolute cap across provider turns", () => { + it("Then the next automatic compaction is rejected after each soft counter reset", () => { + let state = createInitialCapState(); + for (let accepted = 0; accepted < EXPECTED_HARD_CAP; accepted++) { + state = incrementAcceptedFuture(state); + state = resetTurnCounterFuture(state); + } + + expect(state.acceptedThisTurn).toBe(0); + expect(state.acceptedAbsolute).toBe(EXPECTED_HARD_CAP); + expect(shouldRejectByCapFuture(state)).toEqual({ cancel: true }); + expect(shouldRejectByCapFuture(state, { manual: true })).toEqual({ cancel: true }); + expect(shouldRejectByCapFuture(state, { reason: "manual" })).toEqual({ cancel: true }); + expect(shouldRejectByCapFuture(state, { reason: "extension" })).toEqual({ cancel: true }); + }); + }); + describe("Given the soft cap was reached and the session is reloaded with fresh in-memory state", () => { describe("When the per-turn counter is read on the reloaded state", () => { it("Then the counter is 0 and the next compaction is accepted", () => { diff --git a/packages/coding-agent/test/compaction/required-compaction-deterministic-fallback.test.ts b/packages/coding-agent/test/compaction/required-compaction-deterministic-fallback.test.ts index 17bae7938..24981ad95 100644 --- a/packages/coding-agent/test/compaction/required-compaction-deterministic-fallback.test.ts +++ b/packages/coding-agent/test/compaction/required-compaction-deterministic-fallback.test.ts @@ -11,7 +11,7 @@ import type { CompactionReason } from "../../src/core/extensions/types.ts"; import { createBlockingContext, createCompactionHandlers } from "../helpers/blocking-compaction-harness.ts"; describe("required compaction deterministic fallback", () => { - it("cancels when the prepared suffix cannot fit without dropping the latest request", async () => { + it("advances to the latest user boundary when the prepared suffix cannot fit", async () => { const handlers = createCompactionHandlers(); const harness = createBlockingContext({ usageTokens: 9_900 }); harness.registration.setResponses([ @@ -37,15 +37,78 @@ describe("required compaction deterministic fallback", () => { harness.ctx, ); + const latestRequest = branchEntries.at(-1); + if (latestRequest?.type !== "message" || latestRequest.message.role !== "user") { + throw new Error("Expected the latest persisted entry to be the user request"); + } + if (!result) throw new Error("Expected a compaction handler result"); expect(result).toMatchObject({ - cancel: true, - reason: "deterministic compaction fallback cannot retain the prepared suffix", + compaction: { + firstKeptEntryId: latestRequest.id, + details: { retainedSuffix: "latest-user-turn" }, + }, }); - expect(result).not.toHaveProperty("compaction"); - expect(JSON.stringify(harness.sessionManager.buildSessionContext().messages)).toContain("Keep latest request"); + expect(result).not.toHaveProperty("cancel"); + const compaction = result.compaction; + if (!compaction) throw new Error("Expected deterministic recovery compaction"); + harness.sessionManager.appendCompaction( + compaction.summary, + compaction.firstKeptEntryId, + compaction.tokensBefore, + compaction.details, + true, + ); + const retainedContext = JSON.stringify(harness.sessionManager.buildSessionContext().messages); + expect(retainedContext.match(/Keep latest request/g)).toHaveLength(1); + expect(retainedContext).not.toContain("Old assistant context"); expect(harness.registration.getCallLog()).toHaveLength(1); }); + it("keeps a skill-bearing prepared suffix when only retained provider usage is stale", () => { + const harness = createBlockingContext({ usageTokens: 9_900 }); + const preparedBoundaryId = harness.sessionManager.appendMessage({ + ...fauxAssistantMessage("", { timestamp: 4, stopReason: "toolUse" }), + content: [{ type: "toolCall", id: "read-skill", name: "read", arguments: { path: "SKILL.md" } }], + usage: { + input: 30_000, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 30_000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }); + harness.sessionManager.appendMessage({ + role: "toolResult", + toolCallId: "read-skill", + toolName: "read", + content: [{ type: "text", text: "skill loaded" }], + isError: false, + timestamp: 5, + }); + harness.sessionManager.appendMessage({ + role: "user", + content: `${"mutation contract ".repeat(300)}`, + timestamp: 6, + }); + const branchEntries = harness.sessionManager.getBranch(); + const preparation = prepareCompaction(branchEntries, harness.ctx.getCompactionSettings(), true); + expect(preparation).toBeDefined(); + + const result = createRequiredCompactionFallback( + { ...preparation!, firstKeptEntryId: preparedBoundaryId }, + 10_000, + "summarization-timeout", + {}, + branchEntries, + ); + + expect(result).toMatchObject({ + firstKeptEntryId: preparedBoundaryId, + details: { retainedSuffix: "prepared" }, + }); + }); + it("does not recover manual, aborted, or unrelated failures", async () => { for (const testCase of [ { reason: "manual" as const, message: "upstream_stream_truncated", aborted: false, refusal: false }, @@ -149,7 +212,7 @@ describe("required compaction deterministic fallback", () => { ).toBe("upstream-stream-truncated"); }); - it("requires a real retained suffix, keeps only canonical detail metadata, and uses UTF-8-safe bounds", () => { + it("requires a real suffix and preserves bounded task intent and prior checkpoint text", () => { const harness = createBlockingContext({ usageTokens: 9_900 }); const branchEntries = harness.ctx.sessionManager.getBranch(); const preparation = prepareCompaction(branchEntries, harness.ctx.getCompactionSettings(), true); @@ -182,12 +245,19 @@ describe("required compaction deterministic fallback", () => { expect(result).toBeDefined(); expect(result!.summary).not.toContain("�"); - expect(result!.details).toMatchObject({ + expect(result!.summary).toContain("Finish the current repair"); + expect(result!.summary).toContain("Previous checkpoint:"); + expect(result!.summary).toContain("[Older checkpoint truncated]"); + expect(Buffer.byteLength(result!.summary)).toBeLessThanOrEqual(40_000); + expect(result!.summary).not.toContain("verify recovery"); + expect(result!.summary).not.toContain("agent-session.ts"); + expect(result!.details).toEqual({ + schema: "senpi.compaction.deterministic-fallback.v1", + origin: "required-compaction-recovery", + failureKind: "summarization-timeout", taskIntent: "Finish the current repair", retainedSuffix: "prepared", }); - expect(result!.details).not.toHaveProperty("todoSnapshot"); - expect(result!.details).not.toHaveProperty("checkpoint"); harness.sessionManager.appendCompaction( result!.summary, result!.firstKeptEntryId, @@ -198,6 +268,118 @@ describe("required compaction deterministic fallback", () => { expect(JSON.stringify(harness.sessionManager.buildSessionContext().messages)).toContain("Keep latest request"); }); + it("fails closed instead of throwing on malformed retained content blocks", () => { + for (const malformedMessage of [ + { role: "user", content: [null], timestamp: 4 }, + { role: "user", content: [{ type: "text" }], timestamp: 4 }, + { role: "user", content: [{ type: "text", text: 42 }], timestamp: 4 }, + { role: "user", content: "missing timestamp" }, + { role: "toolResult", toolCallId: "tool", toolName: "read", content: "text", isError: false, timestamp: 4 }, + { + role: "toolResult", + toolName: "read", + content: [{ type: "text", text: "missing tool call id" }], + isError: false, + timestamp: 4, + }, + { + role: "custom", + customType: "test", + content: "missing display", + timestamp: 4, + }, + { + role: "bashExecution", + command: "pwd", + output: "/tmp", + exitCode: 0, + cancelled: false, + timestamp: 4, + }, + { role: "assistant", content: [{ type: "text", text: "missing envelope" }], timestamp: 4 }, + { ...fauxAssistantMessage("", { timestamp: 4 }), content: [{ type: "text" }] }, + { ...fauxAssistantMessage("", { timestamp: 4 }), content: [{ type: "thinking" }] }, + { ...fauxAssistantMessage("", { timestamp: 4 }), content: [{ type: "toolCall" }] }, + ]) { + const harness = createBlockingContext({ usageTokens: 9_900 }); + const validBranch = harness.sessionManager.getBranch(); + const preparation = prepareCompaction(validBranch, harness.ctx.getCompactionSettings(), true); + expect(preparation).toBeDefined(); + const malformedId = harness.sessionManager.appendMessage(malformedMessage as never); + const branchEntries = harness.sessionManager.getBranch(); + let result: ReturnType; + + expect(() => { + result = createRequiredCompactionFallback( + { ...preparation!, firstKeptEntryId: malformedId }, + 100_000, + "summarization-timeout", + {}, + branchEntries, + ); + }).not.toThrow(); + expect(result!).toBeUndefined(); + } + }); + + it("fails closed on malformed retained message envelopes", () => { + const harness = createBlockingContext({ usageTokens: 9_900 }); + const branchEntries = harness.sessionManager.getBranch(); + const preparation = prepareCompaction(branchEntries, harness.ctx.getCompactionSettings(), true)!; + const malformedBoundary = branchEntries.at(-1)!; + const malformedBranch = branchEntries.map((entry) => + entry.id === malformedBoundary.id ? { ...entry, message: null } : entry, + ) as never; + let result: ReturnType; + + expect(() => { + result = createRequiredCompactionFallback( + { ...preparation, firstKeptEntryId: malformedBoundary.id }, + 100_000, + "summarization-timeout", + {}, + malformedBranch, + ); + }).not.toThrow(); + expect(result!).toBeUndefined(); + }); + + it("projects only the prepared and latest meaningful user fallback candidates", () => { + const harness = createBlockingContext({ usageTokens: 9_900 }); + for (let index = 0; index < 4; index++) { + harness.sessionManager.appendMessage({ + role: "user", + content: `later request ${index}`, + timestamp: 4 + index, + }); + } + const branchEntries = harness.sessionManager.getBranch(); + const preparation = prepareCompaction(branchEntries, harness.ctx.getCompactionSettings(), true)!; + let projectionCount = 0; + const observedBranch = new Proxy(branchEntries, { + get(target, property, receiver) { + if (property === Symbol.iterator) { + return function* () { + projectionCount++; + yield* target; + }; + } + return Reflect.get(target, property, receiver); + }, + }); + + expect( + createRequiredCompactionFallback( + { ...preparation, firstKeptEntryId: branchEntries[0].id }, + preparation.settings.reserveTokens + 1, + "summarization-timeout", + {}, + observedBranch, + ), + ).toBeUndefined(); + expect(projectionCount).toBe(2); + }); + it("accepts the reconstructed retained context exactly at the input cap and rejects one token below", () => { const harness = createBlockingContext({ usageTokens: 9_900 }); const branchEntries = harness.ctx.sessionManager.getBranch(); diff --git a/packages/coding-agent/test/suite/regressions/post-compaction-recovery-bounds.test.ts b/packages/coding-agent/test/suite/regressions/post-compaction-recovery-bounds.test.ts new file mode 100644 index 000000000..a37f56372 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/post-compaction-recovery-bounds.test.ts @@ -0,0 +1,176 @@ +import type { AgentTool } from "@earendil-works/pi-agent-core"; +import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai"; +import { Type } from "typebox"; +import { afterEach, describe, expect, it } from "vitest"; +import { createHarness, getAssistantTexts, getUserTexts, type Harness } from "../harness.ts"; + +const REQUIRED_COMPACTION_ERROR = "Context remains above the compaction threshold because compaction did not complete"; + +function textResultTool(repetitions: number): AgentTool { + return { + name: "large_result", + label: "Large Result", + description: "Return enough persisted output to require compaction", + parameters: Type.Object({}), + execute: async () => ({ + content: [{ type: "text", text: "large persisted tool result ".repeat(repetitions) }], + details: {}, + }), + }; +} + +describe("required-compaction recovery bounds", () => { + const harnesses: Harness[] = []; + + afterEach(() => { + for (const harness of harnesses.splice(0)) harness.cleanup(); + }); + + it("resumes the interrupted tool continuation without a valid usage anchor", async () => { + let compactionRequest = 0; + const harness = await createHarness({ + models: [{ id: "required-recovery-continuation", contextWindow: 5_000, maxTokens: 1_000 }], + settings: { + compaction: { enabled: true, keepRecentTokens: 1, reserveTokens: 1_000 }, + retry: { enabled: false, maxRetries: 0, baseDelayMs: 1 }, + }, + tools: [textResultTool(2_000)], + extensionFactories: [ + (pi) => { + pi.on("session_before_compact", (event) => { + compactionRequest++; + if (compactionRequest === 1) { + return { + cancel: true, + rejectionCause: "cancelled-by-extension", + reason: "reject inline compaction once", + } as const; + } + return { + compaction: { + summary: "effective required recovery summary", + firstKeptEntryId: event.preparation.firstKeptEntryId, + tokensBefore: event.preparation.tokensBefore, + details: {}, + }, + }; + }); + }, + ], + }); + const zeroUsage = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; + harness.session.subscribe((event) => { + if ( + event.type === "message_end" && + event.message.role === "assistant" && + event.message.stopReason === "toolUse" + ) { + event.message.usage = zeroUsage; + } + }); + harnesses.push(harness); + harness.setResponses([ + fauxAssistantMessage(fauxToolCall("large_result", {}), { stopReason: "toolUse" }), + fauxAssistantMessage("interrupted continuation resumed"), + ]); + + await harness.session.prompt("start required recovery"); + await harness.session.waitForSettledSessionWork(); + + expect( + harness + .eventsOfType("agent_end") + .some( + (event) => + event.willRetry === true && + event.messages.some( + (message) => message.role === "assistant" && message.errorMessage === REQUIRED_COMPACTION_ERROR, + ), + ), + ).toBe(true); + expect(harness.eventsOfType("compaction_end")).toContainEqual( + expect.objectContaining({ + reason: "threshold", + accepted: true, + willRetry: true, + }), + ); + expect(harness.faux.state.callCount).toBe(2); + expect(getAssistantTexts(harness)).toContain("interrupted continuation resumed"); + expect(harness.eventsOfType("auto_retry_start")).toEqual([]); + }); + + it("continues once after recovery clears the threshold", async () => { + let compactionRequest = 0; + const queuedMarker = "queued while effective recovery finishes"; + const harness = await createHarness({ + models: [{ id: "effective-required-recovery", contextWindow: 5_000, maxTokens: 1_000 }], + settings: { + compaction: { enabled: true, keepRecentTokens: 1, reserveTokens: 1_000 }, + retry: { enabled: false, maxRetries: 0, baseDelayMs: 1 }, + }, + tools: [textResultTool(2_000)], + extensionFactories: [ + (pi) => { + pi.on("session_before_compact", (event) => { + compactionRequest++; + if (compactionRequest === 1) { + return { + cancel: true, + rejectionCause: "cancelled-by-extension", + reason: "reject inline compaction once", + } as const; + } + pi.sendUserMessage(queuedMarker, { deliverAs: "steer" }); + return { + compaction: { + summary: "effective recovery summary", + firstKeptEntryId: event.preparation.firstKeptEntryId, + tokensBefore: event.preparation.tokensBefore, + details: {}, + }, + }; + }); + }, + ], + }); + harnesses.push(harness); + harness.setResponses([ + fauxAssistantMessage(fauxToolCall("large_result", {}), { stopReason: "toolUse" }), + fauxAssistantMessage("queued input handled after effective recovery"), + ]); + + await harness.session.prompt("start effective required recovery"); + await harness.session.waitForSettledSessionWork(); + + expect(harness.eventsOfType("compaction_end")).toContainEqual( + expect.objectContaining({ + reason: "threshold", + accepted: true, + willRetry: true, + }), + ); + expect(harness.faux.state.callCount).toBe(3); + expect(getAssistantTexts(harness)).toContain("queued input handled after effective recovery"); + expect(getUserTexts(harness).filter((text) => text === queuedMarker)).toHaveLength(1); + expect( + harness.sessionManager + .getEntries() + .filter( + (entry) => + entry.type === "message" && + entry.message.role === "assistant" && + entry.message.errorMessage === REQUIRED_COMPACTION_ERROR, + ), + ).toHaveLength(1); + expect(harness.session.getSteeringMessages()).toEqual([]); + expect(harness.eventsOfType("auto_retry_start")).toEqual([]); + }); +}); diff --git a/packages/coding-agent/test/suite/regressions/post-compaction-recovery-guards.test.ts b/packages/coding-agent/test/suite/regressions/post-compaction-recovery-guards.test.ts new file mode 100644 index 000000000..b31d4b779 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/post-compaction-recovery-guards.test.ts @@ -0,0 +1,150 @@ +import type { AgentTool } from "@earendil-works/pi-agent-core"; +import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai"; +import { Type } from "typebox"; +import { afterEach, describe, expect, it } from "vitest"; +import { createHarness, getAssistantTexts, getUserTexts, type Harness } from "../harness.ts"; + +const REQUIRED_COMPACTION_ERROR = "Context remains above the compaction threshold because compaction did not complete"; + +function largeResultTool(): AgentTool { + return { + name: "large_result", + label: "Large Result", + description: "Return enough persisted output to require compaction", + parameters: Type.Object({}), + execute: async () => ({ + content: [{ type: "text", text: "large persisted tool result ".repeat(2_000) }], + details: {}, + }), + }; +} + +describe("post-compaction recovery guards", () => { + const harnesses: Harness[] = []; + + afterEach(() => { + for (const harness of harnesses.splice(0)) harness.cleanup(); + }); + + it("does not retry a provider error that copies the required-compaction text", async () => { + const harness = await createHarness({ + models: [{ id: "provider-lookalike-compaction-error", contextWindow: 5_000, maxTokens: 1_000 }], + settings: { + compaction: { enabled: true, keepRecentTokens: 1, reserveTokens: 1_000 }, + retry: { enabled: false, maxRetries: 0, baseDelayMs: 1 }, + }, + }); + harnesses.push(harness); + const providerError = fauxAssistantMessage("provider supplied error", { + stopReason: "error", + errorMessage: REQUIRED_COMPACTION_ERROR, + }); + const classifyRequiredCompactionError: unknown = Reflect.get(harness.session, "_isRequiredCompactionError"); + expect(typeof classifyRequiredCompactionError).toBe("function"); + if (typeof classifyRequiredCompactionError !== "function") { + throw new Error("Required-compaction provenance classifier is unavailable"); + } + expect(Reflect.apply(classifyRequiredCompactionError, harness.session, [providerError])).toBe(false); + harness.setResponses([providerError, fauxAssistantMessage("must not continue")]); + + await harness.session.prompt("return a provider error"); + await harness.session.waitForSettledSessionWork(); + + const matchingAgentEnds = harness + .eventsOfType("agent_end") + .filter((event) => + event.messages.some( + (message) => message.role === "assistant" && message.errorMessage === REQUIRED_COMPACTION_ERROR, + ), + ); + expect(matchingAgentEnds).toHaveLength(1); + expect(matchingAgentEnds[0]?.willRetry).toBe(false); + expect(harness.faux.state.callCount).toBe(1); + expect(getAssistantTexts(harness)).not.toContain("must not continue"); + expect(harness.eventsOfType("auto_retry_start")).toEqual([]); + }); + + it("retains queued input when required recovery compaction is rejected", async () => { + let compactionRequest = 0; + let queuedAtErrorEnd = false; + const rejectedSteer = "steer retained after rejected recovery"; + const rejectedFollowUp = "follow-up retained after rejected recovery"; + const harness = await createHarness({ + models: [{ id: "rejected-required-recovery", contextWindow: 5_000, maxTokens: 1_000 }], + settings: { + compaction: { enabled: true, keepRecentTokens: 1, reserveTokens: 1_000 }, + retry: { enabled: false, maxRetries: 0, baseDelayMs: 1 }, + }, + tools: [largeResultTool()], + extensionFactories: [ + (pi) => { + pi.on("session_before_compact", () => { + compactionRequest++; + return { + cancel: true, + rejectionCause: "cancelled-by-extension", + reason: + compactionRequest === 1 ? "reject inline compaction" : "reject required recovery compaction", + } as const; + }); + }, + (pi) => { + pi.on("agent_end", (event) => { + if ( + queuedAtErrorEnd || + !event.messages.some( + (message) => message.role === "assistant" && message.errorMessage === REQUIRED_COMPACTION_ERROR, + ) + ) { + return; + } + queuedAtErrorEnd = true; + pi.sendUserMessage(rejectedSteer, { deliverAs: "steer" }); + pi.sendUserMessage(rejectedFollowUp, { deliverAs: "followUp" }); + }); + }, + ], + }); + harnesses.push(harness); + harness.setResponses([ + fauxAssistantMessage(fauxToolCall("large_result", {}), { stopReason: "toolUse" }), + fauxAssistantMessage("must not continue after rejected recovery"), + ]); + + const admissionError = await harness.session.prompt("start rejected required recovery").then( + () => undefined, + (error: unknown) => error, + ); + expect(admissionError instanceof Error ? admissionError.message : String(admissionError)).toBe( + REQUIRED_COMPACTION_ERROR, + ); + await harness.session.waitForSettledSessionWork(); + + const compactionEnds = harness.eventsOfType("compaction_end"); + const requiredErrorAgentEndIndex = harness.events.findIndex( + (event) => + event.type === "agent_end" && + event.willRetry === true && + event.messages.some( + (message) => message.role === "assistant" && message.errorMessage === REQUIRED_COMPACTION_ERROR, + ), + ); + const rejectedRecoveryIndex = harness.events.findIndex( + (event, index) => + index > requiredErrorAgentEndIndex && + event.type === "compaction_end" && + event.reason === "threshold" && + event.accepted === false, + ); + expect(compactionEnds).toHaveLength(2); + expect(requiredErrorAgentEndIndex).toBeGreaterThanOrEqual(0); + expect(rejectedRecoveryIndex).toBeGreaterThan(requiredErrorAgentEndIndex); + expect(harness.faux.state.callCount).toBe(1); + expect(getAssistantTexts(harness)).not.toContain("must not continue after rejected recovery"); + expect(harness.session.getSteeringMessages()).toEqual([rejectedSteer]); + expect(harness.session.getFollowUpMessages()).toEqual([rejectedFollowUp]); + expect(harness.session.agent.hasQueuedMessages()).toBe(true); + expect(getUserTexts(harness).some((text) => text.trim().toLowerCase() === "continue")).toBe(false); + expect(harness.eventsOfType("auto_retry_start")).toEqual([]); + }); +});