import { __awaiter } from "tslib";
import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
import { clearMetaEventRegistry, getMetaEvents } from '../../state/debug-event-log';
import { messageUsage, sumUsage } from '../../utils/sum-usage';
// Side-effect import — MUST come before `./chat-driver` so the driver subclasses
// jsdom's EventTarget rather than Node's native one. Mirrors chat-driver.test.ts.
import './align-event-globals';
import { ChatDriver } from './chat-driver';
// ---------------------------------------------------------------------------
// Per-call usage on the turn snapshots — the four token buckets plus USD priced
// onto each `kind: 'turn'` entry of the exported debug log.
//
// The snapshot is captured BEFORE the model call (that is where the prompt, tool
// surface and agent state line up with what the model saw), so usage has to be
// back-filled when the response lands. Two things follow, and both are asserted
// here: a completed call's turn agrees with the message it produced, and a call
// that produced NO message (a blank response, retried) is still priced — that
// spend appears nowhere in the transcript.
// ---------------------------------------------------------------------------
const makeRegistry = (provider) => ({
    get: () => provider,
    default: () => provider,
    defaultName: () => 'test',
    names: () => ['test'],
    getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return null; }),
    listStatuses: () => __awaiter(void 0, void 0, void 0, function* () { return []; }),
});
/** Answers from a queue, then ends the turn with a plain unpriced reply. */
const scriptedProvider = (responses) => {
    const queue = [...responses];
    return {
        chat: () => __awaiter(void 0, void 0, void 0, function* () { var _a; return (_a = queue.shift()) !== null && _a !== void 0 ? _a : { role: 'assistant', content: 'done' }; }),
    };
};
const agent = (overrides) => (Object.assign({ description: 'test agent' }, overrides));
const makeDriver = (config, provider) => {
    const driver = new ChatDriver(makeRegistry(provider), {
        maxToolIterations: 50,
        maxFoldOperations: 5,
        sessionKey: '',
    });
    driver.applyAgent(config);
    return driver;
};
const def = (name) => ({
    name,
    description: `${name} tool`,
    parameters: { type: 'object', properties: {} },
});
/** One priced response: a 1000-token prompt that was mostly a cache hit. */
const priced = (over = {}) => (Object.assign({ role: 'assistant', content: 'answer', cost: 0.1, inputTokens: 1000, cacheReadTokens: 900, cacheWriteTokens: 50, outputTokens: 20 }, over));
const suite = createLogicSuite('ChatDriver per-turn usage');
suite('prices each turn snapshot with the four buckets and USD of its own call', () => __awaiter(void 0, void 0, void 0, function* () {
    const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([priced()]));
    yield driver.sendMessage('go');
    const [turn] = driver.getTurnSnapshots();
    assert.equal(turn.usage, {
        costUsd: 0.1,
        // The prompt split into disjoint buckets: 1000 total, 900 read + 50 written,
        // so 50 uncached. A reader of the log gets this without re-deriving it.
        uncachedInputTokens: 50,
        cacheReadTokens: 900,
        cacheWriteTokens: 50,
        outputTokens: 20,
    }, 'the turn carries its own call’s usage');
    // The turn and the message it produced are the SAME charge, so they must agree
    // exactly — a log whose two views of one call disagree is worse than one view.
    assert.equal(turn.usage, sumUsage(driver.getHistory()), 'turn agrees with the transcript total');
}));
suite('prices every model call in a tool loop separately', () => __awaiter(void 0, void 0, void 0, function* () {
    const driver = makeDriver(agent({
        name: 'a',
        toolDefinitions: [def('work')],
        toolHandlers: { work: () => __awaiter(void 0, void 0, void 0, function* () { return 'worked'; }) },
    }), scriptedProvider([
        priced({ content: '', cost: 0.02, toolCalls: [{ id: 'w1', name: 'work', args: {} }] }),
        priced({ cost: 0.03 }),
    ]));
    yield driver.sendMessage('go');
    const snapshots = driver.getTurnSnapshots();
    assert.is(snapshots.length, 2, 'one snapshot per model call, not per user turn');
    assert.equal(snapshots.map((s) => { var _a; return (_a = s.usage) === null || _a === void 0 ? void 0 : _a.costUsd; }), [0.02, 0.03]);
}));
suite('prices a blank response that produced no message', () => __awaiter(void 0, void 0, void 0, function* () {
    var _a;
    // A blank turn is billed and then discarded before the retry, so the transcript can
    // never account for it. The snapshot is the only record — which is the whole reason
    // usage is stamped before the empty-response branch runs.
    const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([
        priced({ content: '', cost: 0.07, outputTokens: 0 }),
        priced({ cost: 0.03 }),
    ]));
    yield driver.sendMessage('go');
    const snapshots = driver.getTurnSnapshots();
    assert.is(snapshots.length, 2, 'the retried attempt has its own snapshot');
    assert.is((_a = snapshots[0].usage) === null || _a === void 0 ? void 0 : _a.costUsd, 0.07, 'the discarded attempt is still priced');
    assert.is(sumUsage(driver.getHistory()).costUsd, 0.03, 'and is genuinely absent from the transcript — that is the gap the snapshot fills');
}));
suite('leaves usage undefined when the provider reports none', () => __awaiter(void 0, void 0, void 0, function* () {
    // Absent, not zeroed: "this provider reports no usage" (e.g. Chrome's built-in model)
    // must not read as a free call.
    const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([]));
    yield driver.sendMessage('go');
    assert.is(driver.getTurnSnapshots()[0].usage, undefined);
}));
suite.run();
// ---------------------------------------------------------------------------
// The reasoning/narration/answer split must not multiply the cache buckets.
//
// One model response becomes up to three messages; only the last carries usage.
// The clearing list was written when usage meant cost + input + output, so when the
// cache buckets arrived they kept riding along on the display-only copies — and every
// bucket aggregate (session totals, usage rows, the exported log) counted a split
// turn's cache volume two or three times. The cost total hid it, since that comes
// from `cost`, which WAS cleared.
// ---------------------------------------------------------------------------
const split = createLogicSuite('ChatDriver response split usage');
split('counts a reasoning + answer split once, not twice', () => __awaiter(void 0, void 0, void 0, function* () {
    const response = priced({ reasoning: 'thinking…' });
    const driver = makeDriver(agent({ name: 'a' }), scriptedProvider([response]));
    yield driver.sendMessage('go');
    const history = driver.getHistory();
    assert.is(history.filter((m) => m.role === 'assistant').length, 2, 'the response did split into a reasoning message and an answer');
    assert.equal(sumUsage(history), messageUsage(response), 'the split totals exactly one call’s usage');
    const reasoning = history.find((m) => m.category === 'reasoning');
    assert.is(reasoning.cacheReadTokens, undefined, 'the display-only copy carries no cache volume');
    assert.is(reasoning.cacheWriteTokens, undefined);
}));
split('counts a reasoning + narration + answer split once', () => __awaiter(void 0, void 0, void 0, function* () {
    // The three-way case: content alongside a tool call is interstitial narration, so this
    // response produces reasoning + narration + the tool-call message. Worst case for the
    // old behaviour — cache volume counted three times.
    const first = priced({
        content: 'let me look that up',
        reasoning: 'thinking…',
        cost: 0.02,
        toolCalls: [{ id: 'w1', name: 'work', args: {} }],
    });
    const driver = makeDriver(agent({
        name: 'a',
        toolDefinitions: [def('work')],
        toolHandlers: { work: () => __awaiter(void 0, void 0, void 0, function* () { return 'worked'; }) },
    }), scriptedProvider([first, priced({ cost: 0.03 })]));
    yield driver.sendMessage('go');
    const total = sumUsage(driver.getHistory());
    assert.is(total.costUsd, 0.05, 'two calls, each counted once');
    assert.is(total.cacheReadTokens, 1800, '900 per call — not 2700 with the narration copies');
    assert.is(total.cacheWriteTokens, 100);
    assert.is(total.uncachedInputTokens, 100);
}));
split.run();
// ---------------------------------------------------------------------------
// Per-turn model attribution across a tier switch.
//
// The shape this exists for is an agent whose `provider` selector varies by state —
// e.g. the showcase trade-operations agent: `({state}) => isPlanning(state) ? High : Low`,
// planning on sonnet and executing on haiku. Each call has to name the model that ran
// it, on the turn itself: joining a turn to the message after it works for a normal
// step but not for a call that produced no message, and `provider.selected` fires only
// when the slot CHANGES, so the nearest event can be many turns back.
//
// The ordering trap: the snapshot is recorded BEFORE the provider is resolved for that
// call, so stamping `lastResolvedModel` at creation time yields the previous call's
// model — wrong on exactly the turn the tier changes.
// ---------------------------------------------------------------------------
const tiers = createLogicSuite('ChatDriver per-turn model attribution');
/** A tiered registry: named slots, each reporting its own model via `getStatus`. */
const tieredRegistry = (slots, defaultName) => ({
    get: (name) => slots[name],
    default: () => slots[defaultName],
    defaultName: () => defaultName,
    names: () => Object.keys(slots),
    getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return null; }),
    listStatuses: () => __awaiter(void 0, void 0, void 0, function* () { return []; }),
});
const tierProvider = (model, responses) => {
    const queue = [...responses];
    return {
        getStatus: () => __awaiter(void 0, void 0, void 0, function* () { return ({ model, provider: 'anthropic' }); }),
        chat: () => __awaiter(void 0, void 0, void 0, function* () { var _a; return (_a = queue.shift()) !== null && _a !== void 0 ? _a : { role: 'assistant', content: 'done' }; }),
    };
};
tiers('attributes each step to the tier that ran it when the agent switches mid-flow', () => __awaiter(void 0, void 0, void 0, function* () {
    // Responses deliberately carry NO `model` of their own — the common case for a
    // transport that leaves attribution to the driver, and the one where a stale
    // `lastResolved*` read would go unnoticed.
    const slots = {
        high: tierProvider('claude-sonnet-4-6', [
            priced({
                content: '',
                cost: 0.05,
                toolCalls: [{ id: 'p1', name: 'finish_planning', args: {} }],
            }),
        ]),
        low: tierProvider('claude-haiku-4-5-20251001', [priced({ content: 'Booked.', cost: 0.001 })]),
    };
    // The "state" the selector reads, advanced by the tool — as a flow agent's machine does.
    let planning = true;
    const driver = new ChatDriver(tieredRegistry(slots, 'low'), {
        maxToolIterations: 20,
        maxFoldOperations: 5,
        sessionKey: 'tiers',
    });
    driver.applyAgent(agent({
        name: 'Trade Operations',
        provider: () => (planning ? 'high' : 'low'),
        toolDefinitions: [def('finish_planning')],
        toolHandlers: {
            finish_planning: () => __awaiter(void 0, void 0, void 0, function* () {
                planning = false;
                return 'planned';
            }),
        },
    }));
    yield driver.sendMessage('book me a trade');
    const snapshots = driver.getTurnSnapshots();
    assert.equal(snapshots.map((s) => { var _a; return [s.providerName, s.model, (_a = s.usage) === null || _a === void 0 ? void 0 : _a.costUsd]; }), [
        ['high', 'claude-sonnet-4-6', 0.05],
        ['low', 'claude-haiku-4-5-20251001', 0.001],
    ], 'the planning call is attributed to the high tier and the execution call to the low one');
    assert.equal(snapshots.map((s) => s.provider), ['anthropic', 'anthropic'], 'the vendor behind each slot is recorded too');
    // A turn and the message it produced must never disagree about which model ran.
    const assistantModels = driver
        .getHistory()
        .filter((m) => m.role === 'assistant' && m.cost != null)
        .map((m) => m.model);
    assert.equal(assistantModels, snapshots.map((s) => s.model));
}));
tiers('records the SERVING model when a fallback answers on another model', () => __awaiter(void 0, void 0, void 0, function* () {
    // A server-side fallback chain answers on a different model than the one requested.
    // The transport stamps the real one; the turn must follow it rather than relabel the
    // call as the tier's configured model — that would misattribute the spend.
    const slots = {
        high: tierProvider('claude-fable-5', [
            priced({ content: 'answered by the fallback', model: 'claude-opus-4-8' }),
        ]),
    };
    const driver = new ChatDriver(tieredRegistry(slots, 'high'), {
        maxToolIterations: 20,
        maxFoldOperations: 5,
        sessionKey: 'fallback',
    });
    driver.applyAgent(agent({ name: 'a', provider: 'high' }));
    yield driver.sendMessage('go');
    const [turn] = driver.getTurnSnapshots();
    assert.is(turn.model, 'claude-opus-4-8', 'the model that answered, not the one asked for');
    assert.is(turn.providerName, 'high', 'the slot asked for is still recorded');
}));
tiers('names the model and vendor on the provider.selected event, not just the slot', () => __awaiter(void 0, void 0, void 0, function* () {
    var _a, _b, _c;
    clearMetaEventRegistry();
    const slots = {
        high: tierProvider('claude-sonnet-4-6', [priced({ content: 'hi' })]),
    };
    const driver = new ChatDriver(tieredRegistry(slots, 'high'), {
        maxToolIterations: 20,
        maxFoldOperations: 5,
        sessionKey: 'selected',
    });
    driver.applyAgent(agent({ name: 'a', provider: 'high' }));
    yield driver.sendMessage('go');
    const selected = getMetaEvents('selected').find((e) => e.type === 'provider.selected');
    assert.ok(selected, 'a provider.selected event is recorded');
    assert.is((_a = selected.detail) === null || _a === void 0 ? void 0 : _a.provider, 'high', 'the registry slot');
    assert.is((_b = selected.detail) === null || _b === void 0 ? void 0 : _b.model, 'claude-sonnet-4-6', 'and the model behind it');
    assert.is((_c = selected.detail) === null || _c === void 0 ? void 0 : _c.vendor, 'anthropic');
}));
tiers.run();
