/**
 * Session compaction logic.
 *
 * Extracted from AgentSession to be independently testable.
 * Handles two compaction triggers:
 * 1. Overflow: LLM returned context overflow → compact → auto-retry
 * 2. Threshold: Context over threshold → compact → user continues manually
 *
 * The actual LLM summarization lives in compaction/index.ts.
 * This module owns the decision logic and orchestration.
 */
import { calculateContextTokens, compact, DEFAULT_COMPACTION_KEEP_RECENT_TOKENS, prepareCompaction, shouldCompact } from './compaction/index.js';
import { isContextOverflow } from './context-overflow.js';
export function findLatestCompactionEntry(entries) {
    for (let i = entries.length - 1; i >= 0; i--) {
        if (entries[i].type === 'compaction') {
            return entries[i];
        }
    }
    return undefined;
}
/**
 * Decide whether compaction is needed based on the last assistant message.
 * Pure function — no side effects.
 */
export function checkCompactionNeeded(message, ctx, skipAbortedCheck) {
    if (skipAbortedCheck && message.stopReason === 'aborted')
        return { action: 'none' };
    const contextWindow = ctx.contextWindowOverride ?? ctx.currentModel?.contextWindow ?? 0;
    const sameModel = ctx.currentModel &&
        message.provider === ctx.currentModel.provider &&
        message.model === ctx.currentModel.id;
    const compactionEntry = findLatestCompactionEntry(ctx.branchEntries);
    const errorIsFromBeforeCompaction = compactionEntry !== undefined &&
        message.timestamp < new Date(compactionEntry.timestamp).getTime();
    if (sameModel && !errorIsFromBeforeCompaction && isContextOverflow(message, contextWindow)) {
        return { action: 'overflow' };
    }
    if (message.stopReason === 'error' || !message.usage)
        return { action: 'none' };
    const contextTokens = calculateContextTokens(message.usage);
    if (shouldCompact(contextTokens, contextWindow)) {
        return { action: 'threshold' };
    }
    return { action: 'none' };
}
class CompactionCancelledError extends Error {
    constructor(message = 'Compaction cancelled') {
        super(message);
        this.name = 'CompactionCancelledError';
    }
}
class AutoCompactionTimeoutError extends Error {
    constructor(timeoutMs) {
        super(`Compaction timed out after ${timeoutMs / 1000}s`);
        this.name = 'AutoCompactionTimeoutError';
    }
}
export class SessionCompaction {
    _complete;
    _manualAbortController = undefined;
    _autoAbortController = undefined;
    _listener = undefined;
    _consecutiveFailures = 0;
    static MAX_CONSECUTIVE_FAILURES = 3;
    /** Wall-clock ceiling for one auto-compaction attempt. */
    static AUTO_COMPACTION_TIMEOUT_MS = 2 * 60 * 1000;
    constructor(options = {}) {
        this._complete = options.complete;
    }
    get isCompacting() {
        return this._manualAbortController !== undefined || this._autoAbortController !== undefined;
    }
    /** Set the single event listener (the session facade). */
    onEvent(listener) {
        this._listener = listener;
    }
    _emit(event) {
        this._listener?.(event);
    }
    abortAll() {
        this._manualAbortController?.abort();
        this._autoAbortController?.abort();
    }
    /**
     * Run manual compaction. Caller is responsible for disconnecting/reconnecting the agent.
     */
    async runManual(model, apiKey, branchEntries, customInstructions, actions) {
        if (this.isCompacting)
            throw new Error('Compaction already in progress');
        this._manualAbortController = new AbortController();
        try {
            const preparation = prepareCompaction(branchEntries, DEFAULT_COMPACTION_KEEP_RECENT_TOKENS);
            if (!preparation) {
                const lastEntry = branchEntries[branchEntries.length - 1];
                throw new Error(lastEntry?.type === 'compaction'
                    ? 'Already compacted'
                    : 'Nothing to compact (session too small)');
            }
            const result = await this._executeCompaction(preparation, branchEntries, model, apiKey, customInstructions, this._manualAbortController.signal, actions);
            if (this._manualAbortController.signal.aborted) {
                throw new Error('Compaction cancelled');
            }
            await actions.persistCompaction(result.compactionResult, result.fromExtension);
            this._consecutiveFailures = 0;
            return result.compactionResult;
        }
        finally {
            this._manualAbortController = undefined;
        }
    }
    /**
     * Run auto-compaction (triggered by overflow or threshold).
     *
     * `keepRecentTokens` defaults to `DEFAULT_COMPACTION_KEEP_RECENT_TOKENS`.
     * Error recovery passes a smaller value on retry to compact more aggressively.
     */
    async runAuto(reason, willRetry, model, apiKey, branchEntries, actions, keepRecentTokens = DEFAULT_COMPACTION_KEEP_RECENT_TOKENS, 
    /**
     * Per-call heartbeat wrap. Must be captured synchronously by the caller
     * (e.g. compaction-wiring.ts) BEFORE awaiting the agent loop's finally,
     * which clears the runtime's _activeWrap. Without this, _executeAndPersist
     * would read undefined and the actual LLM compaction would emit no
     * compaction_started/completed telemetry.
     */
    heartbeatWrap) {
        if (this._consecutiveFailures >= SessionCompaction.MAX_CONSECUTIVE_FAILURES) {
            this._emit({
                type: 'auto_compaction_end',
                result: undefined,
                aborted: false,
                willRetry: false,
                errorMessage: `Compaction disabled: ${this._consecutiveFailures} consecutive failures. Manual compaction still available.`
            });
            return;
        }
        this._emit({ type: 'auto_compaction_start', reason });
        const autoAbortController = new AbortController();
        this._autoAbortController = autoAbortController;
        let timeoutHandle;
        let timedOut = false;
        let abortHandler;
        const timeout = new Promise((_, reject) => {
            timeoutHandle = setTimeout(() => {
                timedOut = true;
                autoAbortController.abort();
                reject(new AutoCompactionTimeoutError(SessionCompaction.AUTO_COMPACTION_TIMEOUT_MS));
            }, SessionCompaction.AUTO_COMPACTION_TIMEOUT_MS);
        });
        timeout.catch(() => undefined);
        const cancel = new Promise((_, reject) => {
            abortHandler = () => {
                if (!timedOut)
                    reject(new CompactionCancelledError());
            };
            autoAbortController.signal.addEventListener('abort', abortHandler, { once: true });
        });
        cancel.catch(() => undefined);
        try {
            if (!model || (!apiKey && !this._complete)) {
                this._emit({
                    type: 'auto_compaction_end',
                    result: undefined,
                    aborted: false,
                    willRetry: false
                });
                return;
            }
            const preparation = prepareCompaction(branchEntries, keepRecentTokens);
            if (!preparation) {
                this._emit({
                    type: 'auto_compaction_end',
                    result: undefined,
                    aborted: false,
                    willRetry: false
                });
                return;
            }
            // Heartbeat-wrap is applied ONLY now, after every short-circuit above.
            // Trigger-check no-ops (decision='none' in compaction-bridge) and the
            // early returns above never reach this line, so they emit no
            // compaction_started/completed telemetry. The wrap covers the
            // LLM call + persist; that's the only span we want percentiles for.
            const work = this._executeAndPersist(preparation, branchEntries, model, apiKey ?? '', actions, heartbeatWrap, autoAbortController.signal);
            work.catch(() => undefined);
            const compactionResult = await Promise.race([work, timeout, cancel]);
            this._consecutiveFailures = 0;
            this._emit({
                type: 'auto_compaction_end',
                result: compactionResult,
                aborted: false,
                willRetry
            });
            if (willRetry) {
                actions.removeLastAssistantMessage();
                actions.continueAgent();
            }
            else if (actions.hasQueuedMessages()) {
                actions.continueAgent();
            }
        }
        catch (error) {
            // A user/host cancel is intentional: emit cancellation and do not trip
            // the circuit breaker. Timeouts and provider/persist errors are failures.
            const baseMessage = error instanceof Error ? error.message : 'compaction failed';
            if (error instanceof CompactionCancelledError && !timedOut) {
                this._emit({
                    type: 'auto_compaction_end',
                    result: undefined,
                    aborted: true,
                    willRetry: false
                });
                return;
            }
            this._consecutiveFailures++;
            const finalMessage = timedOut
                ? `Compaction timed out after ${SessionCompaction.AUTO_COMPACTION_TIMEOUT_MS / 1000}s`
                : error instanceof AutoCompactionTimeoutError
                    ? error.message
                    : reason === 'overflow'
                        ? `Context overflow recovery failed: ${baseMessage}`
                        : `Auto-compaction failed: ${baseMessage}`;
            this._emit({
                type: 'auto_compaction_end',
                result: undefined,
                aborted: false,
                willRetry: false,
                errorMessage: finalMessage
            });
            // Re-throw so heartbeat.wrap's catch fires: it emits an OpEnd with this
            // errorMessage, which becomes a compaction_failed (or compaction_timeout)
            // row in ClickHouse with data.user_message set. Callers (agent-session.ts)
            // swallow this — failures are already logged via the session event and
            // telemetry; they must not crash the agent_end hook.
            throw new Error(finalMessage);
        }
        finally {
            if (timeoutHandle !== undefined)
                clearTimeout(timeoutHandle);
            if (abortHandler !== undefined) {
                autoAbortController.signal.removeEventListener('abort', abortHandler);
            }
            this._autoAbortController = undefined;
        }
    }
    /**
     * Run the LLM compaction + persist as one heartbeat-tracked op.
     *
     * This is the only span in the auto-compaction flow that emits
     * compaction_started/completed/failed/cancelled telemetry. Everything before
     * it (circuit-breaker check, model/preparation early returns) is bookkeeping
     * that should stay invisible to observability. Cancellation is represented
     * with `CompactionCancelledError` so callers do not infer control flow from
     * provider error text.
     */
    async _executeAndPersist(preparation, branchEntries, model, apiKey, actions, heartbeatWrap, signal) {
        const work = async () => {
            try {
                const execResult = await this._executeCompaction(preparation, branchEntries, model, apiKey, undefined, signal, actions);
                if (signal.aborted)
                    throw new CompactionCancelledError();
                await actions.persistCompaction(execResult.compactionResult, execResult.fromExtension);
                return execResult.compactionResult;
            }
            catch (error) {
                if (signal.aborted && !(error instanceof CompactionCancelledError)) {
                    throw new CompactionCancelledError();
                }
                throw error;
            }
        };
        return heartbeatWrap
            ? heartbeatWrap(work(), 'compaction', { label: 'Compacting context…' })
            : work();
    }
    async _executeCompaction(preparation, branchEntries, model, apiKey, customInstructions, signal, actions) {
        const ext = await actions.tryExtensionCompaction(preparation, branchEntries, customInstructions, signal);
        if (ext.cancelled)
            throw new CompactionCancelledError();
        if (ext.result)
            return { compactionResult: ext.result, fromExtension: true };
        const compactionResult = await compact(preparation, model, apiKey, customInstructions, signal, this._complete);
        return { compactionResult, fromExtension: false };
    }
}
//# sourceMappingURL=session-compaction.js.map