import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
import { PERSISTED_SESSION_VERSION, createPersistedSession, isRestorableSession, reconcileAgentSnapshots, resolveNonResumableNotice, toLoadSessionPayload, keepThroughLastUserMessage, trimToRestorableBoundary, unknownAgentSnapshots, } from './session-snapshot';
const asMsg = (o) => o;
// GENC-1351 §5.1: the PersistedSession schema + its pure (de)serialize helpers.
// These have no DOM/storage deps, so they run straight in Node.
const Suite = createLogicSuite('persisted-session-schema');
const base = () => createPersistedSession({
    sessionKey: 'k',
    host: 'app.example',
    messages: [],
    pinnedAgentName: 'A',
    flowOwnerAgentName: null,
    sessionCostUsd: 2,
    contextTokens: 10,
    contextLimit: 100,
    activeModel: 'm',
    activeProviderName: 'p',
    activeAgentName: 'A',
    agentSnapshots: { A: { machine: { state: 'x', context: {} } } },
});
Suite('createPersistedSession stamps version + savedAt and keeps agentSnapshots', () => {
    const s = base();
    assert.is(s.version, PERSISTED_SESSION_VERSION);
    assert.is(typeof s.savedAt, 'string');
    assert.ok(s.savedAt.length > 0);
    assert.equal(s.agentSnapshots, { A: { machine: { state: 'x', context: {} } } });
});
Suite('createPersistedSession defaults agentSnapshots to {}', () => {
    const s = createPersistedSession({
        sessionKey: 'k',
        host: 'h',
        messages: [],
        pinnedAgentName: null,
        flowOwnerAgentName: null,
        sessionCostUsd: 0,
    });
    assert.equal(s.agentSnapshots, {});
});
Suite('isRestorableSession accepts a matching version + host', () => {
    assert.ok(isRestorableSession(base(), { host: 'app.example' }));
});
Suite('isRestorableSession rejects version mismatch, cross-host, and malformed input', () => {
    assert.ok(!isRestorableSession(Object.assign(Object.assign({}, base()), { version: 999 }), { host: 'app.example' }));
    assert.ok(!isRestorableSession(base(), { host: 'other.example' }));
    assert.ok(!isRestorableSession(null, { host: 'h' }));
    assert.ok(!isRestorableSession({ version: PERSISTED_SESSION_VERSION, host: 'h' }, { host: 'h' }), 'missing messages array → not restorable');
});
Suite('toLoadSessionPayload projects only the redux-restorable fields', () => {
    const p = toLoadSessionPayload(base());
    assert.is(p.pinnedAgentName, 'A');
    assert.is(p.flowOwnerAgentName, null);
    // Cost is deliberately NOT projected — the slice re-derives it (and the token
    // buckets) from `messages`, so a stored counter would be a second, divergeable
    // source of truth.
    assert.is(p.sessionCostUsd, undefined);
    assert.is(p.contextTokens, 10);
    assert.is(p.contextLimit, 100);
    assert.is(p.activeModel, 'm');
    assert.is(p.activeProviderName, 'p');
    assert.equal(p.messages, []);
    // agentSnapshots + activeAgentName are applied by the host, not via the slice.
    assert.ok(!('agentSnapshots' in p));
    assert.ok(!('activeAgentName' in p));
});
Suite('trimToRestorableBoundary returns a clean transcript unchanged (same ref)', () => {
    const clean = [
        asMsg({ role: 'user', content: 'hi' }),
        asMsg({ role: 'assistant', content: 'hello' }),
    ];
    assert.is(trimToRestorableBoundary(clean), clean);
    assert.is(trimToRestorableBoundary([]).length, 0);
});
Suite('trimToRestorableBoundary drops a trailing unresolved interaction, keeps a resolved one', () => {
    const user = asMsg({ role: 'user', content: 'hi' });
    const pending = asMsg({
        role: 'assistant',
        content: '',
        interaction: { interactionId: '2', componentName: 'x', data: {} },
    });
    const resolved = asMsg({
        role: 'assistant',
        content: '',
        interaction: {
            interactionId: '1',
            componentName: 'x',
            data: {},
            resolved: { status: 'approved' },
        },
    });
    assert.is(trimToRestorableBoundary([user, pending]).length, 1);
    assert.is(trimToRestorableBoundary([user, resolved]).length, 2);
});
Suite('trimToRestorableBoundary drops dangling + partially-answered tool calls', () => {
    const user = asMsg({ role: 'user', content: 'do x' });
    const callAB = asMsg({
        role: 'assistant',
        content: '',
        toolCalls: [
            { id: 'a', name: 'f', args: {} },
            { id: 'b', name: 'g', args: {} },
        ],
    });
    const resultA = asMsg({
        role: 'tool',
        content: '',
        toolResult: { toolCallId: 'a', content: 'ra' },
    });
    const resultB = asMsg({
        role: 'tool',
        content: '',
        toolResult: { toolCallId: 'b', content: 'rb' },
    });
    // No results → drop the whole round back to [user].
    assert.is(trimToRestorableBoundary([user, callAB]).length, 1);
    // Only A answered → B orphaned → drop the round back to [user].
    assert.is(trimToRestorableBoundary([user, callAB, resultA]).length, 1);
    // Both answered → kept (ends on a balanced tool result).
    assert.is(trimToRestorableBoundary([user, callAB, resultA, resultB]).length, 4);
});
Suite('trimToRestorableBoundary drops a trailing empty assistant placeholder', () => {
    const user = asMsg({ role: 'user', content: 'hi' });
    const placeholder = asMsg({ role: 'assistant', content: '' });
    assert.is(trimToRestorableBoundary([user, placeholder]).length, 1);
});
// GENC-1351: orphaned agent snapshots (dynamic agent arrays — e.g. genesis-create).
Suite('unknownAgentSnapshots flags saved agents missing from the configured set', () => {
    const snap = {
        agentSnapshots: { 'Guided Booking': {}, Bootstrap: {} },
    };
    // Bootstrap gone from the current set → flagged; Guided Booking is known.
    assert.equal(unknownAgentSnapshots(snap, ['Guided Booking', 'Trades']), ['Bootstrap']);
    // All known → none flagged.
    assert.equal(unknownAgentSnapshots(snap, ['Guided Booking', 'Bootstrap']), []);
    // Empty known set → can't classify yet → none (avoids false positives mid-init).
    assert.equal(unknownAgentSnapshots(snap, []), []);
});
// GENC-1351 §6 — `reconcileAgentSnapshots`: the save-time guard against a
// post-restore autosave wiping a mid-flow machine. The bug (regression #1): a
// restore reads a good snapshot, then the restore's own history change fires an
// autosave BEFORE the agent re-activates, so the live capture is empty and, left
// unguarded, `{}` overwrites the machine → the flow resets on the next reload.
const MACHINE = { machine: { value: 'pickInstrument', context: { counterparty: { id: 'c1' } } } };
Suite('reconcileAgentSnapshots: REGRESSION — carries the flow-owner forward when live capture is empty', () => {
    // Post-restore, pre-activation: getActiveAgent() is undefined → live is {}, but
    // a flow is owned and the good image sits in the cache (seeded on restore).
    const { snapshots, cache } = reconcileAgentSnapshots({}, 'Guided Booking', {
        'Guided Booking': MACHINE,
    });
    assert.equal(snapshots['Guided Booking'], MACHINE, 'the flow-owner machine is preserved, NOT wiped to {}');
    assert.equal(cache['Guided Booking'], MACHINE, 'cache keeps the good image');
});
Suite('reconcileAgentSnapshots: a live capture wins and advances the cache', () => {
    const FRESH = { machine: { value: 'enterDetails', context: {} } };
    const { snapshots, cache } = reconcileAgentSnapshots({ 'Guided Booking': FRESH }, 'Guided Booking', {
        'Guided Booking': MACHINE, // stale cached value
    });
    assert.equal(snapshots['Guided Booking'], FRESH, 'live image is used (never the stale cache)');
    assert.equal(cache['Guided Booking'], FRESH, 'cache advances to the freshly-captured image');
});
Suite('reconcileAgentSnapshots: no flow owner → empty stays empty (no stale carry-forward)', () => {
    // Idle / stateless save: nothing to carry, even if the cache holds an old image.
    const { snapshots, cache } = reconcileAgentSnapshots({}, null, { 'Guided Booking': MACHINE });
    assert.equal(snapshots, {}, 'nothing carried forward without a flow owner');
    assert.equal(cache['Guided Booking'], MACHINE, 'an empty save never erases the cache');
});
Suite('reconcileAgentSnapshots: flow owner set but nothing cached → cannot fabricate state', () => {
    const { snapshots } = reconcileAgentSnapshots({}, 'Guided Booking', {});
    assert.is(snapshots['Guided Booking'], undefined, 'no image to carry → left absent, not invented');
});
Suite('reconcileAgentSnapshots: carries the flow owner while keeping a co-captured other agent', () => {
    const OTHER = { machine: { value: 'x' } };
    const { snapshots } = reconcileAgentSnapshots({ Other: OTHER }, 'Guided Booking', {
        'Guided Booking': MACHINE,
    });
    assert.equal(snapshots.Other, OTHER, 'the live non-owner image is kept');
    assert.equal(snapshots['Guided Booking'], MACHINE, 'the missing flow-owner is carried forward');
});
Suite('reconcileAgentSnapshots: does not mutate its inputs', () => {
    const live = {};
    const cacheIn = { 'Guided Booking': MACHINE };
    reconcileAgentSnapshots(live, 'Guided Booking', cacheIn);
    assert.equal(live, {}, 'live input untouched');
    assert.equal(Object.keys(cacheIn), ['Guided Booking'], 'cache input untouched');
});
// GENC-1351 §5.9: the pure notice-resolution helper (message + pre-fill prompt).
// The freeze/marker mechanics live on the element (integration), not here.
Suite('resolveNonResumableNotice: custom message wins, blank falls back to default', () => {
    const msgs = [
        asMsg({ role: 'user', content: 'first' }),
        asMsg({ role: 'assistant', content: 'x' }),
    ];
    assert.is(resolveNonResumableNotice({
        notResumableMessage: 'custom',
        messages: msgs,
        defaultMessage: 'D',
    }).resumeMessage, 'custom');
    assert.is(resolveNonResumableNotice({ notResumableMessage: '   ', messages: msgs, defaultMessage: 'D' })
        .resumeMessage, 'D', 'a blank custom message falls back to the default');
    assert.is(resolveNonResumableNotice({ messages: msgs, defaultMessage: 'D' }).resumeMessage, 'D', 'no custom message → default');
});
Suite('resolveNonResumableNotice: prompt prefers the captured flow prompt, else last user msg', () => {
    const msgs = [
        asMsg({ role: 'user', content: 'earlier' }),
        asMsg({ role: 'assistant', content: 'x' }),
        asMsg({ role: 'user', content: 'latest user' }),
    ];
    assert.is(resolveNonResumableNotice({
        flowActivationPrompt: 'the flow prompt',
        messages: msgs,
        defaultMessage: 'D',
    }).resumePrompt, 'the flow prompt', 'captured flow-activating prompt takes precedence');
    assert.is(resolveNonResumableNotice({ flowActivationPrompt: null, messages: msgs, defaultMessage: 'D' })
        .resumePrompt, 'latest user', 'no flow prompt → falls back to the last user message');
    assert.is(resolveNonResumableNotice({ messages: [], defaultMessage: 'D' }).resumePrompt, undefined, 'no flow prompt and no user message → undefined');
});
// GENC-1351 §5.9: the freeze fallback keeps the transcript through the last user
// message, dropping the agent's in-flight turns (issue #3).
Suite('keepThroughLastUserMessage drops trailing agent turns, keeps through the last user msg', () => {
    const u1 = asMsg({ role: 'user', content: 'book a flight' });
    const a1 = asMsg({ role: 'assistant', content: 'ok' });
    const u2 = asMsg({ role: 'user', content: 'show fills for T1' });
    const plan = asMsg({
        role: 'assistant',
        content: '',
        toolCalls: [{ id: 'a', name: 'f', args: {} }],
    });
    const res = asMsg({ role: 'tool', content: '' });
    // Trailing agent turns after the last user message → dropped.
    assert.equal(keepThroughLastUserMessage([u1, a1, u2, plan, res]), [u1, a1, u2]);
    // Already ends on a user message → unchanged (same ref).
    const endsUser = [u1, a1, u2];
    assert.is(keepThroughLastUserMessage(endsUser), endsUser);
    // No user message at all → unchanged (nothing to trim to).
    const noUser = [a1, res];
    assert.is(keepThroughLastUserMessage(noUser), noUser);
    assert.equal(keepThroughLastUserMessage([]).length, 0);
});
Suite.run();
