import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { calculateDeterministicHash, RunArtifactSchema, summarizeProvenance } from '@mmnto/totem';
import { cleanTmpDir } from './test-utils.js';
import { applyCodeBlindGuard, buildRetrievalGroundingBundle, CODE_BLIND_BANNER, CODE_BLIND_PROMPT_DIRECTIVE, formatLessonSection, formatResults, getSystemPrompt, isCodeBlind, isGlobalConfigPath, loadConfig, loadEnv, partitionLessons, reapOrphanedTempFiles, requireEmbedding, resolveConfigPath, runOrchestrator, sanitize, wrapXml, writeOutput, } from './utils.js';
/** Helper to build a minimal SearchResult for tests. */
function makeResult(overrides) {
    // Default absoluteFilePath to the provided filePath — mirrors
    // rowToSearchResult's fallback when no sourceContext is injected
    // (mmnto/totem#1294 Phase 1).
    return {
        contextPrefix: '',
        type: 'code',
        metadata: {},
        absoluteFilePath: overrides.filePath,
        ...overrides,
    };
}
// ─── sanitize ───────────────────────────────────────────
describe('sanitize', () => {
    it('strips ANSI escape sequences', () => {
        expect(sanitize('\x1b[31mred text\x1b[0m')).toBe('red text');
    });
    it('strips control characters', () => {
        expect(sanitize('hello\x00\x07\x08world')).toBe('helloworld');
    });
    it('preserves normal text with newlines and tabs', () => {
        expect(sanitize('line1\nline2\ttab')).toBe('line1\nline2\ttab');
    });
    it('strips cursor manipulation sequences', () => {
        expect(sanitize('\x1b[2Aup two lines\x1b[K')).toBe('up two lines');
    });
    it('strips carriage returns', () => {
        expect(sanitize('visible\rhidden')).toBe('visiblehidden');
    });
    it('strips OSC sequences terminated by BEL', () => {
        expect(sanitize('\x1b]0;malicious title\x07safe text')).toBe('safe text');
    });
    it('strips OSC sequences terminated by ST', () => {
        expect(sanitize('\x1b]0;malicious title\x1b\\safe text')).toBe('safe text');
    });
    it('strips C1 control characters', () => {
        expect(sanitize('before\x9bafter')).toBe('beforeafter');
        expect(sanitize('test\x9dmore')).toBe('testmore');
    });
    it('strips BiDi control characters', () => {
        expect(sanitize('hello\u202Aworld\u202C')).toBe('helloworld');
        expect(sanitize('text\u2066hidden\u2069end')).toBe('texthiddenend');
    });
});
// ─── wrapXml ────────────────────────────────────────────
describe('wrapXml', () => {
    it('wraps content in XML tags', () => {
        expect(wrapXml('issue_body', 'hello world')).toBe('<issue_body>\nhello world\n</issue_body>');
    });
    it('escapes matching closing tags in content', () => {
        const malicious = 'some text </issue_body> injected instructions';
        expect(wrapXml('issue_body', malicious)).toBe('<issue_body>\nsome text <\\/issue_body> injected instructions\n</issue_body>');
    });
    it('escapes case-variant and whitespace-padded closing tags', () => {
        const content = 'try </ISSUE_BODY> or </ issue_body > to escape';
        expect(wrapXml('issue_body', content)).toBe('<issue_body>\ntry <\\/ISSUE_BODY> or <\\/ issue_body > to escape\n</issue_body>');
    });
    it('does not escape non-matching closing tags', () => {
        const content = 'contains </other_tag> but not the target';
        expect(wrapXml('issue_body', content)).toBe('<issue_body>\ncontains </other_tag> but not the target\n</issue_body>');
    });
    it('wraps empty content', () => {
        expect(wrapXml('git_diff', '')).toBe('<git_diff>\n\n</git_diff>');
    });
    it('preserves multiline content', () => {
        const content = 'line 1\nline 2\nline 3';
        expect(wrapXml('git_status', content)).toBe('<git_status>\nline 1\nline 2\nline 3\n</git_status>');
    });
});
// ─── formatResults ──────────────────────────────────────
describe('formatResults', () => {
    it('returns empty string for empty results', () => {
        expect(formatResults([], 'HEADING')).toBe('');
    });
    it('formats results with heading, label, filePath, and score', () => {
        const results = [
            makeResult({
                label: 'function: foo',
                filePath: 'src/foo.ts',
                score: 0.875,
                content: 'function foo() {}',
            }),
        ];
        const output = formatResults(results, 'CODE');
        expect(output).toContain('=== CODE ===');
        expect(output).toContain('**function: foo**');
        expect(output).toContain('src/foo.ts');
        expect(output).toContain('0.875');
        expect(output).toContain('function foo() {}');
    });
    it('truncates long content at 300 chars', () => {
        const longContent = 'x'.repeat(500);
        const results = [
            makeResult({ label: 'test', filePath: 'test.ts', score: 0.5, content: longContent }),
        ];
        const output = formatResults(results, 'TEST');
        // Should contain exactly 300 x's, not 500
        expect(output).toContain('x'.repeat(300));
        expect(output).not.toContain('x'.repeat(301));
    });
    it('formats multiple results separated by blank lines', () => {
        const results = [
            makeResult({ label: 'first', filePath: 'a.ts', score: 0.9, content: 'aaa' }),
            makeResult({ label: 'second', filePath: 'b.ts', score: 0.8, content: 'bbb' }),
        ];
        const output = formatResults(results, 'RESULTS');
        expect(output).toContain('**first**');
        expect(output).toContain('**second**');
    });
    it('condensed mode truncates content at 80 chars', () => {
        const longContent = 'x'.repeat(200);
        const results = [
            makeResult({ label: 'test', filePath: 'test.ts', score: 0.5, content: longContent }),
        ];
        const output = formatResults(results, 'TEST', true);
        expect(output).toContain('x'.repeat(80));
        expect(output).not.toContain('x'.repeat(81));
        expect(output).toContain('...');
    });
    it('condensed mode omits score', () => {
        const results = [
            makeResult({ label: 'my-func', filePath: 'src/foo.ts', score: 0.9, content: 'hello' }),
        ];
        const output = formatResults(results, 'CODE', true);
        expect(output).toContain('**my-func**');
        expect(output).not.toContain('0.900');
    });
    it('condensed mode replaces newlines with spaces', () => {
        const results = [
            makeResult({ label: 'multi', filePath: 'a.ts', score: 0.5, content: 'line1\nline2\nline3' }),
        ];
        const output = formatResults(results, 'TEST', true);
        expect(output).toContain('line1 line2 line3');
        expect(output).not.toContain('line1\n');
    });
});
// ─── writeOutput ────────────────────────────────────────
describe('writeOutput', () => {
    let tmpDir;
    beforeEach(() => {
        tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-utils-'));
    });
    afterEach(() => {
        cleanTmpDir(tmpDir);
    });
    it('writes content to file when outPath is provided', () => {
        const outPath = path.join(tmpDir, 'output.md');
        writeOutput('hello world', outPath);
        expect(fs.readFileSync(outPath, 'utf-8')).toBe('hello world');
    });
    it('creates directories when they do not exist', () => {
        const outPath = path.join(tmpDir, 'nested', 'dir', 'output.md');
        writeOutput('nested content', outPath);
        expect(fs.readFileSync(outPath, 'utf-8')).toBe('nested content');
    });
    it('writes to stdout when no outPath is given', () => {
        const spy = vi.spyOn(console, 'log').mockImplementation(() => { });
        writeOutput('stdout content');
        expect(spy).toHaveBeenCalledWith('stdout content');
        spy.mockRestore();
    });
});
// ─── resolveConfigPath ───────────────────────────────
describe('resolveConfigPath', () => {
    let tmpDir;
    beforeEach(() => {
        tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-config-'));
    });
    afterEach(() => {
        cleanTmpDir(tmpDir);
    });
    it('returns config path when totem.config.ts exists', () => {
        fs.writeFileSync(path.join(tmpDir, 'totem.config.ts'), 'export default {}', 'utf-8');
        const result = resolveConfigPath(tmpDir);
        expect(result).toBe(path.join(tmpDir, 'totem.config.ts'));
    });
    it('throws when no config file exists', () => {
        const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-home-'));
        try {
            expect(() => resolveConfigPath(tmpDir, fakeHome)).toThrow('No Totem configuration found');
        }
        finally {
            cleanTmpDir(fakeHome);
        }
    });
    it('resolves totem.yaml when totem.config.ts is missing', () => {
        fs.writeFileSync(path.join(tmpDir, 'totem.yaml'), 'targets: []\n');
        expect(resolveConfigPath(tmpDir)).toBe(path.join(tmpDir, 'totem.yaml'));
    });
    it('resolves totem.toml when no ts or yaml exists', () => {
        fs.writeFileSync(path.join(tmpDir, 'totem.toml'), '[embedding]\nprovider = "openai"\n');
        expect(resolveConfigPath(tmpDir)).toBe(path.join(tmpDir, 'totem.toml'));
    });
    it('prioritizes .ts over .yaml when both exist', () => {
        fs.writeFileSync(path.join(tmpDir, 'totem.config.ts'), 'export default {}');
        fs.writeFileSync(path.join(tmpDir, 'totem.yaml'), 'targets: []\n');
        expect(resolveConfigPath(tmpDir)).toBe(path.join(tmpDir, 'totem.config.ts'));
    });
    it('falls back to ~/.totem/ when no local config exists', () => {
        const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-home-'));
        const globalDir = path.join(fakeHome, '.totem');
        fs.mkdirSync(globalDir, { recursive: true });
        fs.writeFileSync(path.join(globalDir, 'totem.config.ts'), 'export default {}', 'utf-8');
        try {
            const result = resolveConfigPath(tmpDir, fakeHome);
            expect(result).toBe(path.join(globalDir, 'totem.config.ts'));
        }
        finally {
            cleanTmpDir(fakeHome);
        }
    });
    it('prefers local config over global', () => {
        const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-home-'));
        const globalDir = path.join(fakeHome, '.totem');
        fs.mkdirSync(globalDir, { recursive: true });
        fs.writeFileSync(path.join(globalDir, 'totem.config.ts'), 'export default {}', 'utf-8');
        // Also create local config
        fs.writeFileSync(path.join(tmpDir, 'totem.yaml'), 'targets: []\n', 'utf-8');
        try {
            const result = resolveConfigPath(tmpDir, fakeHome);
            expect(result).toBe(path.join(tmpDir, 'totem.yaml'));
        }
        finally {
            cleanTmpDir(fakeHome);
        }
    });
    it('throws when neither local nor global config exists with updated hint', () => {
        const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-home-'));
        try {
            expect(() => resolveConfigPath(tmpDir, fakeHome)).toThrow('No Totem configuration found');
            try {
                resolveConfigPath(tmpDir, fakeHome);
            }
            catch (err) {
                expect(err).toHaveProperty('recoveryHint');
                expect(err.recoveryHint).toContain('--global');
            }
        }
        finally {
            cleanTmpDir(fakeHome);
        }
    });
});
describe('isGlobalConfigPath', () => {
    it('returns true for paths under ~/.totem/', () => {
        const fakeHome = '/fake/home';
        expect(isGlobalConfigPath('/fake/home/.totem/totem.config.ts', fakeHome)).toBe(true);
        expect(isGlobalConfigPath('/fake/home/.totem/totem.yaml', fakeHome)).toBe(true);
    });
    it('returns false for local project paths', () => {
        const fakeHome = '/fake/home';
        expect(isGlobalConfigPath('/my/project/totem.config.ts', fakeHome)).toBe(false);
        expect(isGlobalConfigPath('/other/dir/totem.yaml', fakeHome)).toBe(false);
    });
    it('returns false for directories sharing the prefix (e.g. ~/.totem-foo/)', () => {
        const fakeHome = '/fake/home';
        expect(isGlobalConfigPath('/fake/home/.totem-foo/totem.config.ts', fakeHome)).toBe(false);
    });
});
// ─── loadConfig (YAML/TOML) ─────────────────────────
describe('loadConfig', () => {
    let tmpDir;
    beforeEach(() => {
        tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-loadconfig-'));
    });
    afterEach(() => {
        cleanTmpDir(tmpDir);
    });
    it('loads and validates a YAML config', async () => {
        const yaml = `targets:\n  - glob: "**/*.ts"\n    type: code\n    strategy: typescript-ast\n`;
        fs.writeFileSync(path.join(tmpDir, 'totem.yaml'), yaml);
        const config = await loadConfig(path.join(tmpDir, 'totem.yaml'));
        expect(config.targets).toHaveLength(1);
        expect(config.targets[0].glob).toBe('**/*.ts');
    });
    it('loads and validates a TOML config', async () => {
        const toml = `[[targets]]\nglob = "**/*.rs"\ntype = "code"\nstrategy = "typescript-ast"\n`;
        fs.writeFileSync(path.join(tmpDir, 'totem.toml'), toml);
        const config = await loadConfig(path.join(tmpDir, 'totem.toml'));
        expect(config.targets).toHaveLength(1);
        expect(config.targets[0].glob).toBe('**/*.rs');
    });
    it('throws ConfigError for invalid YAML syntax', async () => {
        fs.writeFileSync(path.join(tmpDir, 'totem.yaml'), 'targets: [invalid yaml: {{');
        await expect(loadConfig(path.join(tmpDir, 'totem.yaml'))).rejects.toThrow('Failed to parse');
    });
    it('throws ConfigError for invalid TOML syntax', async () => {
        fs.writeFileSync(path.join(tmpDir, 'totem.toml'), 'targets = [invalid');
        await expect(loadConfig(path.join(tmpDir, 'totem.toml'))).rejects.toThrow('Failed to parse');
    });
    it('formats Zod validation errors with field paths', async () => {
        fs.writeFileSync(path.join(tmpDir, 'totem.yaml'), 'targets: "not an array"\n');
        await expect(loadConfig(path.join(tmpDir, 'totem.yaml'))).rejects.toThrow('Invalid configuration');
    });
});
// ─── loadEnv ─────────────────────────────────────────
describe('loadEnv', () => {
    let tmpDir;
    const TEST_KEY = 'TOTEM_TEST_LOADENV_KEY';
    beforeEach(() => {
        tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-env-'));
        delete process.env[TEST_KEY];
    });
    afterEach(() => {
        cleanTmpDir(tmpDir);
        delete process.env[TEST_KEY];
    });
    it('loads variables from .env file', () => {
        fs.writeFileSync(path.join(tmpDir, '.env'), `${TEST_KEY}=hello`, 'utf-8');
        loadEnv(tmpDir);
        expect(process.env[TEST_KEY]).toBe('hello');
    });
    it('does not override existing env variables', () => {
        process.env[TEST_KEY] = 'existing';
        fs.writeFileSync(path.join(tmpDir, '.env'), `${TEST_KEY}=new`, 'utf-8');
        loadEnv(tmpDir);
        expect(process.env[TEST_KEY]).toBe('existing');
    });
    it('skips comments and blank lines', () => {
        fs.writeFileSync(path.join(tmpDir, '.env'), `# comment\n\n${TEST_KEY}=value`, 'utf-8');
        loadEnv(tmpDir);
        expect(process.env[TEST_KEY]).toBe('value');
    });
    it('does nothing when .env file is missing', () => {
        loadEnv(tmpDir); // should not throw
        expect(process.env[TEST_KEY]).toBeUndefined();
    });
    it('strips surrounding double quotes from values', () => {
        fs.writeFileSync(path.join(tmpDir, '.env'), `${TEST_KEY}="quoted-value"`, 'utf-8');
        loadEnv(tmpDir);
        expect(process.env[TEST_KEY]).toBe('quoted-value');
    });
    it('strips surrounding single quotes from values', () => {
        fs.writeFileSync(path.join(tmpDir, '.env'), `${TEST_KEY}='single-quoted'`, 'utf-8');
        loadEnv(tmpDir);
        expect(process.env[TEST_KEY]).toBe('single-quoted');
    });
    it('handles Windows CRLF line endings', () => {
        fs.writeFileSync(path.join(tmpDir, '.env'), `${TEST_KEY}=crlfval\r\n`, 'utf-8');
        loadEnv(tmpDir);
        expect(process.env[TEST_KEY]).toBe('crlfval');
    });
    it('handles quoted values with CRLF line endings', () => {
        fs.writeFileSync(path.join(tmpDir, '.env'), `${TEST_KEY}="quoted-crlf"\r\n`, 'utf-8');
        loadEnv(tmpDir);
        expect(process.env[TEST_KEY]).toBe('quoted-crlf');
    });
    it('strips inline comments from unquoted values', () => {
        fs.writeFileSync(path.join(tmpDir, '.env'), `${TEST_KEY}=secret # expires tomorrow`, 'utf-8');
        loadEnv(tmpDir);
        expect(process.env[TEST_KEY]).toBe('secret');
    });
    it('strips inline comments with trailing whitespace', () => {
        fs.writeFileSync(path.join(tmpDir, '.env'), `${TEST_KEY}=secret    # comment`, 'utf-8');
        loadEnv(tmpDir);
        expect(process.env[TEST_KEY]).toBe('secret');
    });
    it('preserves hash inside double-quoted values', () => {
        fs.writeFileSync(path.join(tmpDir, '.env'), `${TEST_KEY}="my#secret" # actual comment`, 'utf-8');
        loadEnv(tmpDir);
        expect(process.env[TEST_KEY]).toBe('my#secret');
    });
    it('handles empty values', () => {
        fs.writeFileSync(path.join(tmpDir, '.env'), `${TEST_KEY}=`, 'utf-8');
        loadEnv(tmpDir);
        expect(process.env[TEST_KEY]).toBe('');
    });
});
// ─── getSystemPrompt ──────────────────────────────────
describe('getSystemPrompt', () => {
    let tmpDir;
    const DEFAULT_PROMPT = 'default system prompt';
    beforeEach(() => {
        tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-prompt-'));
    });
    afterEach(() => {
        cleanTmpDir(tmpDir);
    });
    it('returns default when override file does not exist', () => {
        expect(getSystemPrompt('shield', DEFAULT_PROMPT, tmpDir, '.totem')).toBe(DEFAULT_PROMPT);
    });
    it('returns file content when override file exists', () => {
        const promptsDir = path.join(tmpDir, '.totem', 'prompts');
        fs.mkdirSync(promptsDir, { recursive: true });
        fs.writeFileSync(path.join(promptsDir, 'shield.md'), 'custom shield prompt', 'utf-8');
        expect(getSystemPrompt('shield', DEFAULT_PROMPT, tmpDir, '.totem')).toBe('custom shield prompt');
    });
    it('returns default when override file is empty', () => {
        const promptsDir = path.join(tmpDir, '.totem', 'prompts');
        fs.mkdirSync(promptsDir, { recursive: true });
        fs.writeFileSync(path.join(promptsDir, 'spec.md'), '   \n  ', 'utf-8');
        expect(getSystemPrompt('spec', DEFAULT_PROMPT, tmpDir, '.totem')).toBe(DEFAULT_PROMPT);
    });
    it('returns default for invalid command names (path traversal)', () => {
        expect(getSystemPrompt('../../../etc/passwd', DEFAULT_PROMPT, tmpDir, '.totem')).toBe(DEFAULT_PROMPT);
    });
    it('returns default when file is unreadable', () => {
        const promptsDir = path.join(tmpDir, '.totem', 'prompts');
        fs.mkdirSync(promptsDir, { recursive: true });
        // Create a directory with the same name as the expected file — reading it will throw
        fs.mkdirSync(path.join(promptsDir, 'extract.md'));
        expect(getSystemPrompt('extract', DEFAULT_PROMPT, tmpDir, '.totem')).toBe(DEFAULT_PROMPT);
    });
});
// ─── requireEmbedding ────────────────────────────────
describe('requireEmbedding', () => {
    const BASE_CONFIG = {
        targets: [{ glob: '**/*.md', type: 'spec', strategy: 'markdown-heading' }],
        totemDir: '.totem',
        lanceDir: '.lancedb',
        ignorePatterns: [],
        shieldIgnorePatterns: [],
        shieldAutoLearn: false,
        contextWarningThreshold: 40_000,
        review: { sourceExtensions: ['.ts', '.tsx', '.js', '.jsx'] },
    };
    it('returns embedding provider when configured', () => {
        const embedding = { provider: 'openai', model: 'text-embedding-3-small' };
        const config = { ...BASE_CONFIG, embedding };
        const result = requireEmbedding(config);
        expect(result).toEqual(embedding);
    });
    it('throws when embedding is undefined', () => {
        expect(() => requireEmbedding(BASE_CONFIG)).toThrow('No embedding provider configured');
    });
    it('error message mentions Lite tier', () => {
        expect(() => requireEmbedding(BASE_CONFIG)).toThrow('Lite tier');
    });
    it('error recovery hint mentions totem init', () => {
        try {
            requireEmbedding(BASE_CONFIG);
        }
        catch (err) {
            expect(err.recoveryHint).toContain('totem init');
            return;
        }
        throw new Error('Expected requireEmbedding to throw');
    });
});
describe('reapOrphanedTempFiles', () => {
    let tmpRoot;
    beforeEach(() => {
        tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-reap-'));
    });
    afterEach(() => {
        cleanTmpDir(tmpRoot);
    });
    function writeTempFile(name, ageMs) {
        const tempDir = path.join(tmpRoot, '.totem', 'temp');
        fs.mkdirSync(tempDir, { recursive: true });
        const filePath = path.join(tempDir, name);
        fs.writeFileSync(filePath, 'prompt content');
        const past = new Date(Date.now() - ageMs);
        fs.utimesSync(filePath, past, past);
        return filePath;
    }
    it('removes temp files older than maxAge', async () => {
        const old = writeTempFile('totem-shield-abc123.md', 25 * 60 * 60 * 1000);
        const removed = await reapOrphanedTempFiles(tmpRoot, '.totem', 24 * 60 * 60 * 1000);
        expect(removed).toBe(1);
        expect(fs.existsSync(old)).toBe(false);
    });
    it('keeps temp files younger than maxAge', async () => {
        const recent = writeTempFile('totem-spec-def456.md', 1 * 60 * 60 * 1000);
        const removed = await reapOrphanedTempFiles(tmpRoot, '.totem', 24 * 60 * 60 * 1000);
        expect(removed).toBe(0);
        expect(fs.existsSync(recent)).toBe(true);
    });
    it('ignores non-totem files', async () => {
        const other = writeTempFile('random-notes.txt', 48 * 60 * 60 * 1000);
        const removed = await reapOrphanedTempFiles(tmpRoot, '.totem', 24 * 60 * 60 * 1000);
        expect(removed).toBe(0);
        expect(fs.existsSync(other)).toBe(true);
    });
    it('returns 0 when temp directory does not exist', async () => {
        const removed = await reapOrphanedTempFiles(tmpRoot, '.totem');
        expect(removed).toBe(0);
    });
    it('handles mixed old and new files', async () => {
        const old1 = writeTempFile('totem-shield-aaa.md', 30 * 60 * 60 * 1000);
        const old2 = writeTempFile('totem-triage-bbb.md', 48 * 60 * 60 * 1000);
        const recent = writeTempFile('totem-spec-ccc.md', 2 * 60 * 60 * 1000);
        const removed = await reapOrphanedTempFiles(tmpRoot, '.totem', 24 * 60 * 60 * 1000);
        expect(removed).toBe(2);
        expect(fs.existsSync(old1)).toBe(false);
        expect(fs.existsSync(old2)).toBe(false);
        expect(fs.existsSync(recent)).toBe(true);
    });
    it('swallows errors from already-deleted files gracefully', async () => {
        const filePath = writeTempFile('totem-shield-race.md', 48 * 60 * 60 * 1000);
        // Simulate race: delete before reaper runs
        fs.unlinkSync(filePath);
        const removed = await reapOrphanedTempFiles(tmpRoot, '.totem', 24 * 60 * 60 * 1000);
        expect(removed).toBe(0);
    });
});
// ─── runOrchestrator (#243 — cross-provider routing) ──
vi.mock('./orchestrators/orchestrator.js', async (importOriginal) => {
    const actual = await importOriginal();
    const mockInvoke = vi.fn().mockResolvedValue({
        content: 'mock result',
        inputTokens: 100,
        outputTokens: 50,
        durationMs: 500,
    });
    const mockedCreate = vi.fn().mockReturnValue(mockInvoke);
    return {
        ...actual,
        createOrchestrator: mockedCreate,
        // Re-bind resolveOrchestrator to use the mocked createOrchestrator
        // (the real impl captures a closure over the real factory)
        resolveOrchestrator: (rawModel, baseProvider, baseInvoke) => {
            if (rawModel.startsWith('-') || !/^[\w./:_-]+$/.test(rawModel)) {
                throw new Error(`[Totem Error] Invalid model name '${rawModel}'. Model names may only contain word characters, dots, slashes, colons, underscores, and hyphens.`);
            }
            const parsed = actual.parseModelString(rawModel, baseProvider);
            if (parsed.provider === 'shell' && baseProvider !== 'shell') {
                throw new Error(`[Totem Error] Cannot route to 'shell' provider from a '${baseProvider}' config.\n` +
                    `The shell provider requires a 'command' template in the orchestrator config.`);
            }
            if (!parsed.model || parsed.model.startsWith('-')) {
                throw new Error(`[Totem Error] Invalid model name in '${rawModel}'. The model portion must not be empty or start with a hyphen.`);
            }
            const invoke = parsed.provider === baseProvider
                ? baseInvoke
                : mockedCreate({ provider: parsed.provider });
            return { parsed, invoke, qualifiedModel: rawModel };
        },
    };
});
import { createOrchestrator } from './orchestrators/orchestrator.js';
const mockedCreateOrchestrator = vi.mocked(createOrchestrator);
function baseConfig(overrides) {
    return {
        targets: [{ glob: '**/*.ts', type: 'code', strategy: 'typescript-ast' }],
        orchestrator: {
            provider: 'gemini',
            defaultModel: 'gemini-3-flash-preview',
        },
        totemDir: '.totem',
        lanceDir: '.lancedb',
        ignorePatterns: [],
        contextWarningThreshold: 40_000,
        ...overrides,
    };
}
describe('runOrchestrator', { timeout: 15_000 }, () => {
    let tmpDir;
    beforeEach(() => {
        vi.clearAllMocks();
        tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-orch-test-'));
        vi.spyOn(process, 'cwd').mockReturnValue(tmpDir);
        // Re-set the mock return value after clearAllMocks
        const mockInvoke = vi.fn().mockResolvedValue({
            content: 'mock result',
            inputTokens: 100,
            outputTokens: 50,
            durationMs: 500,
        });
        mockedCreateOrchestrator.mockReturnValue(mockInvoke);
    });
    afterEach(() => {
        vi.restoreAllMocks();
        cleanTmpDir(tmpDir);
    });
    it('uses default provider and model when no overrides', async () => {
        const result = await runOrchestrator({
            prompt: 'test prompt',
            tag: 'Spec',
            options: {},
            config: baseConfig(),
            cwd: tmpDir,
        });
        expect(result).toBe('mock result');
        expect(mockedCreateOrchestrator).toHaveBeenCalledWith(expect.objectContaining({ provider: 'gemini' }));
    });
    it('uses per-command override from config', async () => {
        const config = baseConfig({
            orchestrator: {
                provider: 'gemini',
                defaultModel: 'gemini-3-flash-preview',
                overrides: { spec: 'gemini-3.1-pro-preview' },
            },
        });
        await runOrchestrator({
            prompt: 'test',
            tag: 'Spec',
            options: {},
            config,
            cwd: tmpDir,
        });
        // The invoke should receive the override model
        const invoke = mockedCreateOrchestrator.mock.results[0].value;
        expect(invoke).toHaveBeenCalledWith(expect.objectContaining({ model: 'gemini-3.1-pro-preview' }));
    });
    it('--model flag takes priority over overrides', async () => {
        const config = baseConfig({
            orchestrator: {
                provider: 'gemini',
                defaultModel: 'gemini-3-flash-preview',
                overrides: { spec: 'gemini-3.1-pro-preview' },
            },
        });
        await runOrchestrator({
            prompt: 'test',
            tag: 'Spec',
            options: { model: 'custom-model' },
            config,
            cwd: tmpDir,
        });
        const invoke = mockedCreateOrchestrator.mock.results[0].value;
        expect(invoke).toHaveBeenCalledWith(expect.objectContaining({ model: 'custom-model' }));
    });
    it('creates a new orchestrator for cross-provider override', async () => {
        const config = baseConfig({
            orchestrator: {
                provider: 'gemini',
                defaultModel: 'gemini-3-flash-preview',
                overrides: { shield: 'anthropic:claude-sonnet-4-6' },
            },
        });
        await runOrchestrator({
            prompt: 'test',
            tag: 'Shield',
            options: {},
            config,
            cwd: tmpDir,
        });
        // Should create two orchestrators: one for base config, one for cross-provider
        expect(mockedCreateOrchestrator).toHaveBeenCalledTimes(2);
        expect(mockedCreateOrchestrator).toHaveBeenCalledWith(expect.objectContaining({ provider: 'anthropic' }));
    });
    it('throws when cross-routing to shell from an API provider', async () => {
        const config = baseConfig({
            orchestrator: {
                provider: 'gemini',
                defaultModel: 'gemini-3-flash-preview',
                overrides: { spec: 'shell:my-model' },
            },
        });
        await expect(runOrchestrator({ prompt: 'test', tag: 'Spec', options: {}, config, cwd: tmpDir })).rejects.toThrow("Cannot route to 'shell' provider");
    });
    it('throws when model portion is empty (anthropic:)', async () => {
        await expect(runOrchestrator({
            prompt: 'test',
            tag: 'Spec',
            options: { model: 'anthropic:' },
            config: baseConfig(),
            cwd: tmpDir,
        })).rejects.toThrow('must not be empty');
    });
    it('throws when model portion starts with hyphen', async () => {
        await expect(runOrchestrator({
            prompt: 'test',
            tag: 'Spec',
            options: { model: 'anthropic:-bad' },
            config: baseConfig(),
            cwd: tmpDir,
        })).rejects.toThrow('must not be empty or start with a hyphen');
    });
    it('throws when no model is specified anywhere', async () => {
        const config = baseConfig({
            orchestrator: { provider: 'gemini' },
        });
        await expect(runOrchestrator({ prompt: 'test', tag: 'Spec', options: {}, config, cwd: tmpDir })).rejects.toThrow('No model specified');
    });
    it('returns undefined in --raw mode without invoking orchestrator', async () => {
        const result = await runOrchestrator({
            prompt: 'test',
            tag: 'Spec',
            options: { raw: true },
            config: baseConfig(),
            cwd: tmpDir,
        });
        expect(result).toBeUndefined();
        expect(mockedCreateOrchestrator).not.toHaveBeenCalled();
    });
    // ─── Phase 3: prompt cache plumbing (mmnto/totem#1291) ─────
    it('threads systemPrompt to the underlying invoke()', async () => {
        await runOrchestrator({
            prompt: 'per-lesson user prompt',
            systemPrompt: 'persistent compiler template',
            tag: 'Compile',
            options: {},
            config: baseConfig(),
            cwd: tmpDir,
        });
        const invoke = mockedCreateOrchestrator.mock.results[0].value;
        expect(invoke).toHaveBeenCalledWith(expect.objectContaining({
            prompt: 'per-lesson user prompt',
            systemPrompt: 'persistent compiler template',
        }));
    });
    it('omits systemPrompt from invoke() when caller does not provide it (today shape)', async () => {
        await runOrchestrator({
            prompt: 'just a prompt',
            tag: 'Spec',
            options: {},
            config: baseConfig(),
            cwd: tmpDir,
        });
        const invoke = mockedCreateOrchestrator.mock.results[0].value;
        const callArgs = invoke.mock.calls[0]?.[0];
        expect(callArgs).toBeDefined();
        expect(callArgs['systemPrompt']).toBeUndefined();
        expect(callArgs['prompt']).toBe('just a prompt');
    });
    it('threads enableContextCaching from orchestrator config to invoke()', async () => {
        const config = baseConfig({
            orchestrator: {
                provider: 'gemini',
                defaultModel: 'gemini-3-flash-preview',
                enableContextCaching: true,
                cacheTTL: 3600,
            },
        });
        await runOrchestrator({
            prompt: 'q',
            systemPrompt: 's',
            tag: 'Compile',
            options: {},
            config,
            cwd: tmpDir,
        });
        const invoke = mockedCreateOrchestrator.mock.results[0].value;
        expect(invoke).toHaveBeenCalledWith(expect.objectContaining({
            enableContextCaching: true,
            cacheTTL: 3600,
        }));
    });
    it('omits cache opts from invoke() when not configured', async () => {
        await runOrchestrator({
            prompt: 'q',
            tag: 'Spec',
            options: {},
            config: baseConfig(),
            cwd: tmpDir,
        });
        const invoke = mockedCreateOrchestrator.mock.results[0].value;
        const callArgs = invoke.mock.calls[0]?.[0];
        expect(callArgs['enableContextCaching']).toBeUndefined();
        expect(callArgs['cacheTTL']).toBeUndefined();
    });
    it('response cache key includes null-byte delimiters to prevent boundary collisions', async () => {
        // Phase 3 cascade-fix regression test (caught by Shield AI):
        // crypto.createHash().update(prompt).update(systemPrompt) without a
        // delimiter would let `prompt="AB", systemPrompt=""` collide with
        // `prompt="A", systemPrompt="B"` because both concatenate to "AB".
        //
        // Replicate the exact construction from runOrchestrator (utils.ts) and
        // assert the boundary-case inputs produce DIFFERENT hashes. The
        // `\0` delimiter sits between every field — without it, the test below
        // would produce identical hashes and fail.
        const buildKey = (prompt, systemPrompt, model) => crypto
            .createHash('sha256')
            .update(prompt)
            .update('\0')
            .update(systemPrompt)
            .update('\0')
            .update(model)
            .digest('hex')
            .slice(0, 16);
        const a = buildKey('AB', '', 'm');
        const b = buildKey('A', 'B', 'm');
        expect(a).not.toBe(b);
        // Sibling collision: "A", "BC" vs "AB", "C"
        const c = buildKey('A', 'BC', 'm');
        const d = buildKey('AB', 'C', 'm');
        expect(c).not.toBe(d);
        // Same inputs MUST still hash identically (idempotency check)
        const e = buildKey('prompt', 'system', 'model');
        const f = buildKey('prompt', 'system', 'model');
        expect(e).toBe(f);
    });
    it('--raw mode includes systemPrompt in the output when split callers pass both', async () => {
        // mmnto/totem#1291 PR #1292 review fix from CodeRabbit: --raw mode used
        // to write only `prompt`, but Phase 3 callers (compile-lesson) now route
        // the compiler template through `systemPrompt`. Without the fix below,
        // `totem compile --raw` would produce a file that doesn't show what the
        // model would actually receive.
        //
        // We can't test the writeOutput call directly without setting up a tmp
        // file pipeline, but we can verify the OrchestratorRunOptions branch
        // returns undefined and doesn't invoke the orchestrator (--raw never
        // calls invoke). The actual file content shape is asserted via the
        // unit-test below by spying on writeOutput's destination.
        let captured = '';
        const tmpOut = path.join(tmpDir, 'raw-out.md');
        await runOrchestrator({
            prompt: 'lesson body',
            systemPrompt: 'COMPILER_SYSTEM_PROMPT',
            tag: 'Compile',
            options: { raw: true, out: tmpOut },
            config: baseConfig(),
            cwd: tmpDir,
        });
        captured = fs.readFileSync(tmpOut, 'utf-8');
        // GCA round 2: dropped the markdown markers in favor of plain
        // concatenation matching shell-orchestrator.ts and pipe-safety for
        // downstream consumers. Exact byte equality.
        expect(captured).toBe('COMPILER_SYSTEM_PROMPT\n\nlesson body');
    });
    it('--raw mode falls back to user-prompt-only when systemPrompt is undefined (backward compat)', async () => {
        const tmpOut = path.join(tmpDir, 'raw-out-legacy.md');
        await runOrchestrator({
            prompt: 'just the prompt',
            tag: 'Spec',
            options: { raw: true, out: tmpOut },
            config: baseConfig(),
            cwd: tmpDir,
        });
        const captured = fs.readFileSync(tmpOut, 'utf-8');
        expect(captured).toBe('just the prompt');
    });
    it('--raw mode treats empty systemPrompt the same as undefined', async () => {
        // GCA round 2 SAFETY INVARIANT: empty systemPrompt is treated as
        // absent throughout the system to avoid 4xx from providers that
        // reject empty system messages. --raw mode honors the same contract.
        const tmpOut = path.join(tmpDir, 'raw-out-empty-sys.md');
        await runOrchestrator({
            prompt: 'user only',
            systemPrompt: '',
            tag: 'Spec',
            options: { raw: true, out: tmpOut },
            config: baseConfig(),
            cwd: tmpDir,
        });
        const captured = fs.readFileSync(tmpOut, 'utf-8');
        expect(captured).toBe('user only');
    });
    it('passes through cacheReadInputTokens / cacheCreationInputTokens from invoke() result', async () => {
        // Override the default mock to return a result that simulates a cache hit
        const mockInvokeWithCache = vi.fn().mockResolvedValue({
            content: 'cached response',
            inputTokens: 200,
            outputTokens: 75,
            durationMs: 800,
            cacheReadInputTokens: 47_231,
            cacheCreationInputTokens: 0,
        });
        mockedCreateOrchestrator.mockReturnValue(mockInvokeWithCache);
        const result = await runOrchestrator({
            prompt: 'q',
            systemPrompt: 's',
            tag: 'Compile',
            options: {},
            config: baseConfig({
                orchestrator: {
                    provider: 'anthropic',
                    defaultModel: 'claude-sonnet-4-6',
                    enableContextCaching: true,
                },
            }),
            cwd: tmpDir,
        });
        // runOrchestrator returns just the content string today (the cache fields
        // are surfaced via the dim log line, not the return value). The metric is
        // observable via the underlying result object that was forwarded to the
        // log layer — verified by inspecting the mock's call.
        expect(result).toBe('cached response');
        expect(mockInvokeWithCache).toHaveBeenCalled();
    });
});
// ─── partitionLessons ────────────────────────────────────
describe('partitionLessons', () => {
    const makeResult = (filePath, label) => ({
        content: `content for ${label}`,
        contextPrefix: '',
        filePath,
        absoluteFilePath: filePath,
        type: 'spec',
        label,
        score: 0.9,
        metadata: {},
    });
    it('separates lesson results from other specs', () => {
        const allSpecs = [
            { ...makeResult('.totem/lessons/lesson-abc.md', 'Lesson A'), type: 'lesson' },
            makeResult('docs/spec.md', 'Spec B'),
            { ...makeResult('.totem/lessons/lesson-def.md', 'Lesson C'), type: 'lesson' },
            makeResult('docs/reference/architecture.md', 'Arch D'),
        ];
        const { lessons, specs } = partitionLessons(allSpecs, 10, 10);
        expect(lessons).toHaveLength(2);
        expect(specs).toHaveLength(2);
        expect(lessons[0].label).toBe('Lesson A');
        expect(specs[0].label).toBe('Spec B');
    });
    it('respects maxLessons cap', () => {
        const allSpecs = [
            { ...makeResult('.totem/lessons/a.md', 'L1'), type: 'lesson' },
            { ...makeResult('.totem/lessons/b.md', 'L2'), type: 'lesson' },
            { ...makeResult('.totem/lessons/c.md', 'L3'), type: 'lesson' },
        ];
        const { lessons } = partitionLessons(allSpecs, 2, 5);
        expect(lessons).toHaveLength(2);
    });
    it('respects maxSpecs cap', () => {
        const allSpecs = [
            makeResult('docs/a.md', 'A'),
            makeResult('docs/b.md', 'B'),
            makeResult('docs/c.md', 'C'),
        ];
        const { specs } = partitionLessons(allSpecs, 5, 2);
        expect(specs).toHaveLength(2);
    });
    it('returns empty arrays when no results', () => {
        const { lessons, specs } = partitionLessons([], 10, 10);
        expect(lessons).toHaveLength(0);
        expect(specs).toHaveLength(0);
    });
});
// ─── formatLessonSection ─────────────────────────────────
describe('formatLessonSection', () => {
    const makeLesson = (label, content) => ({
        content,
        contextPrefix: '',
        filePath: '.totem/lessons.md',
        absoluteFilePath: '.totem/lessons.md',
        type: 'spec',
        label,
        score: 0.9,
        metadata: {},
    });
    it('returns empty string when no lessons', () => {
        expect(formatLessonSection([])).toBe('');
    });
    it('formats lessons with full bodies and scores', () => {
        const result = formatLessonSection([makeLesson('Test trap', 'Never do X in Y context')]);
        expect(result).toContain('RELEVANT LESSONS (HARD CONSTRAINTS)');
        expect(result).toContain('**Test trap**');
        expect(result).toContain('score: 0.900');
        expect(result).toContain('Never do X in Y context');
    });
    it('skips lessons that exceed remaining char budget', () => {
        const huge = makeLesson('Huge', 'X'.repeat(5000));
        const small = makeLesson('Small', 'A small lesson');
        const result = formatLessonSection([huge, small], 4000);
        expect(result).not.toContain('Huge');
        expect(result).toContain('Small');
    });
    it('returns empty string when all lessons exceed budget', () => {
        const huge = makeLesson('Huge', 'X'.repeat(10000));
        expect(formatLessonSection([huge], 100)).toBe('');
    });
    it('condensed mode truncates content and omits scores', () => {
        const longContent = 'A'.repeat(200);
        const result = formatLessonSection([makeLesson('Trap', longContent)], undefined, true);
        expect(result).toContain('RELEVANT LESSONS (HARD CONSTRAINTS)');
        expect(result).toContain('**Trap**');
        expect(result).toContain('...');
        expect(result).not.toContain('score:');
        // Content should be truncated — full 200-char body should NOT appear
        expect(result).not.toContain('A'.repeat(200));
    });
    it('condensed mode shows full content for short lessons', () => {
        const result = formatLessonSection([makeLesson('Short', 'A tiny lesson')], undefined, true);
        expect(result).toContain('A tiny lesson');
        expect(result).not.toContain('...');
    });
});
// ─── buildRetrievalGroundingBundle (mmnto-ai/totem#2101) ──
describe('buildRetrievalGroundingBundle', () => {
    function result(filePath, sourceRepo) {
        return {
            content: `content of ${filePath}`,
            filePath,
            ...(sourceRepo !== undefined ? { sourceRepo } : {}),
        };
    }
    it('maps every partition with its sourceType — item count equals retrieval totals (invariant 8)', () => {
        const bundle = buildRetrievalGroundingBundle({
            specs: [result('specs/a.md')],
            sessions: [result('journal/s1.md'), result('journal/s2.md')],
            code: [result('src/x.ts'), result('src/y.ts', 'strategy')],
            lessons: [result('lessons/l1.md')],
        });
        expect(bundle.items).toHaveLength(6);
        const byType = (t) => bundle.items.filter((i) => i.sourceType === t).length;
        expect(byType('spec')).toBe(1);
        expect(byType('session_log')).toBe(2);
        expect(byType('code')).toBe(2);
        expect(byType('lesson')).toBe(1);
        expect(bundle.items.every((i) => i.provenance === 'similarity-only')).toBe(true);
        expect(bundle.items.find((i) => i.filePath === 'src/y.ts')?.sourceRepo).toBe('strategy');
    });
    it('empty retrieval yields an empty bundle that summarizes as ungrounded', () => {
        const bundle = buildRetrievalGroundingBundle({
            specs: [],
            sessions: [],
            code: [],
            lessons: [],
        });
        expect(bundle.items).toEqual([]);
        expect(summarizeProvenance(bundle)).toBe('ungrounded');
    });
});
// ─── code-blind grounding guard (mmnto-ai/totem#2106, strategy#474) ──
describe('code-blind grounding guard', () => {
    const withCode = {
        code: [makeResult({ label: 'x', filePath: 'src/x.ts', score: 1, content: 'c' })],
    };
    describe('isCodeBlind', () => {
        it('is true iff zero code chunks were retrieved — keyed strictly on code', () => {
            expect(isCodeBlind({ code: [] })).toBe(true);
            expect(isCodeBlind(withCode)).toBe(false);
        });
    });
    describe('applyCodeBlindGuard', () => {
        const SYS = 'SYSTEM PROMPT';
        it('on 0 code: fires, surfaces the banner, appends the directive to the system prompt', () => {
            const r = applyCodeBlindGuard({ code: [] }, SYS);
            expect(r.codeBlind).toBe(true);
            expect(r.banner).toBe(CODE_BLIND_BANNER);
            // Exact: the original system prompt comes first, directive appended.
            expect(r.systemPrompt).toBe(`${SYS}\n\n${CODE_BLIND_PROMPT_DIRECTIVE}`);
        });
        it('with code: does not fire, no banner, system prompt returned unchanged', () => {
            const r = applyCodeBlindGuard(withCode, SYS);
            expect(r.codeBlind).toBe(false);
            expect(r.banner).toBeUndefined();
            expect(r.systemPrompt).toBe(SYS);
        });
        it('does not throw or disable on 0 code — returns a usable prompt (anti-abort, strategy#474)', () => {
            expect(() => applyCodeBlindGuard({ code: [] }, SYS)).not.toThrow();
            // The command proceeds: a directive-augmented prompt is returned, not an abort.
            expect(applyCodeBlindGuard({ code: [] }, SYS).systemPrompt).toBe(`${SYS}\n\n${CODE_BLIND_PROMPT_DIRECTIVE}`);
        });
        it('banner is advisory-neutral, not error-toned (strategy#474 Q2)', () => {
            expect(CODE_BLIND_BANNER).not.toMatch(/\b(error|fail(ed|ure)?|abort)\b/i);
        });
    });
});
// ─── runOrchestrator artifact emission (mmnto-ai/totem#2100) ──
describe('runOrchestrator artifact emission (#2100)', { timeout: 15_000 }, () => {
    let tmpDir;
    const GROUNDING_HASH = 'b'.repeat(64);
    function artifactRequest(onEmitted) {
        return {
            groundingHash: GROUNDING_HASH,
            provenanceSummary: 'similarity-only',
            ...(onEmitted !== undefined ? { onEmitted } : {}),
        };
    }
    function artifactConfig(overrides) {
        return {
            targets: [{ glob: '**/*.ts', type: 'code', strategy: 'typescript-ast' }],
            orchestrator: {
                provider: 'gemini',
                defaultModel: 'gemini-3-flash-preview',
            },
            totemDir: '.totem',
            lanceDir: '.lancedb',
            ignorePatterns: [],
            contextWarningThreshold: 40_000,
            ...overrides,
        };
    }
    function runsDirPath() {
        return path.join(tmpDir, '.totem', 'artifacts', 'runs');
    }
    beforeEach(() => {
        vi.clearAllMocks();
        tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-artifact-emit-'));
        vi.spyOn(process, 'cwd').mockReturnValue(tmpDir);
        const mockInvoke = vi.fn().mockResolvedValue({
            content: 'mock result',
            inputTokens: 100,
            outputTokens: 50,
            durationMs: 500,
        });
        mockedCreateOrchestrator.mockReturnValue(mockInvoke);
    });
    afterEach(() => {
        vi.restoreAllMocks();
        cleanTmpDir(tmpDir);
    });
    it('emits content-addressed artifact with valid schema after successful orchestrator run', async () => {
        const emitted = [];
        const result = await runOrchestrator({
            prompt: 'test prompt',
            tag: 'Spec',
            options: { fresh: true },
            config: artifactConfig(),
            cwd: tmpDir,
            artifact: artifactRequest((hash, artifactPath) => emitted.push({ hash, artifactPath })),
        });
        expect(result).toBe('mock result');
        expect(emitted).toHaveLength(1);
        const file = path.join(runsDirPath(), `${emitted[0].hash}.json`);
        expect(emitted[0].artifactPath).toBe(file);
        expect(fs.existsSync(file)).toBe(true);
        const artifact = RunArtifactSchema.parse(JSON.parse(fs.readFileSync(file, 'utf-8')));
        expect(artifact.inputBundle.maskedPrompt).toBe('test prompt');
        expect(artifact.grounding).toEqual({
            hash: GROUNDING_HASH,
            provenanceSummary: 'similarity-only',
        });
        expect(artifact.backend.admissionClass).toBe('completion_only');
        expect(artifact.backend.taskProfile).toBe('Spec');
        expect(artifact.backend.provider).toBe('gemini');
        expect(artifact.output.content).toBe('mock result');
        expect(artifact.grounding.bundle).toBeUndefined(); // no bundle supplied → none recorded (never fabricated)
        expect(artifact.output.metrics).toMatchObject({
            inputTokens: 100,
            outputTokens: 50,
            durationMs: 500,
        });
    });
    it('records the grounding bundle verbatim and the attested hash recomputes from it (mmnto-ai/totem#2101)', async () => {
        const bundle = {
            items: [
                {
                    provenance: 'similarity-only',
                    contentHash: 'c'.repeat(64),
                    sourceType: 'code',
                    filePath: 'src/x.ts',
                    sourceRepo: 'strategy',
                },
            ],
        };
        const emitted = [];
        await runOrchestrator({
            prompt: 'bundled run',
            tag: 'Spec',
            options: { fresh: true },
            config: artifactConfig(),
            cwd: tmpDir,
            artifact: {
                groundingHash: calculateDeterministicHash(bundle),
                provenanceSummary: summarizeProvenance(bundle),
                bundle,
                onEmitted: (hash) => emitted.push(hash),
            },
        });
        const file = path.join(runsDirPath(), `${emitted[0]}.json`);
        const artifact = RunArtifactSchema.parse(JSON.parse(fs.readFileSync(file, 'utf-8')));
        expect(artifact.grounding.bundle).toEqual(bundle);
        expect(artifact.grounding.provenanceSummary).toBe('similarity-only:1');
        // Invariant 4 at the emission seam: one enumeration, two readers — the
        // recorded hash is recomputable from the recorded bundle alone.
        expect(artifact.grounding.hash).toBe(calculateDeterministicHash(artifact.grounding.bundle));
    });
    it('records the MASKED prompt — a custom secret never reaches the artifact', async () => {
        const secret = 'SUPER-SECRET-TOKEN-12345';
        const emitted = [];
        await runOrchestrator({
            prompt: `deploy with ${secret} now`,
            tag: 'Spec',
            options: { fresh: true },
            config: artifactConfig(),
            cwd: tmpDir,
            customSecrets: [{ type: 'literal', value: secret }],
            artifact: artifactRequest((hash) => emitted.push(hash)),
        });
        const file = path.join(runsDirPath(), `${emitted[0]}.json`);
        const rawOnDisk = fs.readFileSync(file, 'utf-8');
        expect(rawOnDisk).not.toContain(secret);
        const artifact = RunArtifactSchema.parse(JSON.parse(rawOnDisk));
        expect(artifact.inputBundle.maskedPrompt).not.toContain(secret);
    });
    it('a response-cache hit emits NO artifact (artifacts record actual invokes)', async () => {
        const onEmitted = vi.fn();
        const opts = {
            prompt: 'cacheable prompt',
            tag: 'Spec', // Spec carries a default response-cache TTL
            options: {},
            config: artifactConfig(),
            cwd: tmpDir,
            artifact: { ...artifactRequest(), onEmitted },
        };
        await runOrchestrator(opts);
        expect(onEmitted).toHaveBeenCalledTimes(1);
        expect(fs.readdirSync(runsDirPath())).toHaveLength(1);
        await runOrchestrator(opts); // second run hits the response cache
        expect(onEmitted).toHaveBeenCalledTimes(1); // unchanged
        expect(fs.readdirSync(runsDirPath())).toHaveLength(1); // unchanged
    });
    it('a non-opted caller creates no artifacts directory (byte-identical behavior)', async () => {
        const result = await runOrchestrator({
            prompt: 'plain run',
            tag: 'Spec',
            options: { fresh: true },
            config: artifactConfig(),
            cwd: tmpDir,
        });
        expect(result).toBe('mock result');
        expect(fs.existsSync(path.join(tmpDir, '.totem', 'artifacts'))).toBe(false);
    });
    it('an artifact write failure warns but never fails the run', async () => {
        // Occupy the artifacts path with a FILE so mkdir of artifacts/runs throws.
        fs.mkdirSync(path.join(tmpDir, '.totem'), { recursive: true });
        fs.writeFileSync(path.join(tmpDir, '.totem', 'artifacts'), 'not a directory');
        const onEmitted = vi.fn();
        const result = await runOrchestrator({
            prompt: 'run whose ledger write fails',
            tag: 'Spec',
            options: { fresh: true },
            config: artifactConfig(),
            cwd: tmpDir,
            artifact: { ...artifactRequest(), onEmitted },
        });
        expect(result).toBe('mock result'); // the run is not hostage to its ledger
        expect(onEmitted).not.toHaveBeenCalled();
    });
    it('after a quota fallback the artifact records the RESOLVED model, not the requested one', async () => {
        const quotaErr = new Error('429 quota exhausted');
        quotaErr.name = 'QuotaError';
        const mockInvoke = vi
            .fn()
            .mockRejectedValueOnce(quotaErr)
            .mockResolvedValue({ content: 'fallback result', durationMs: 300 });
        mockedCreateOrchestrator.mockReturnValue(mockInvoke);
        const emitted = [];
        await runOrchestrator({
            prompt: 'quota-bound prompt',
            tag: 'Spec',
            options: { fresh: true },
            config: artifactConfig({
                orchestrator: {
                    provider: 'gemini',
                    defaultModel: 'gemini-3.1-pro-preview',
                    fallbackModel: 'gemini-3-flash-preview',
                },
            }),
            cwd: tmpDir,
            artifact: artifactRequest((hash) => emitted.push(hash)),
        });
        const file = path.join(runsDirPath(), `${emitted[0]}.json`);
        const artifact = RunArtifactSchema.parse(JSON.parse(fs.readFileSync(file, 'utf-8')));
        expect(artifact.backend.qualifiedModel).toBe('gemini-3-flash-preview');
        expect(artifact.output.content).toBe('fallback result');
    });
});
// ─── runOrchestrator admission contract (mmnto-ai/totem#2102) ──
describe('runOrchestrator admission contract (#2102)', { timeout: 15_000 }, () => {
    let tmpDir;
    const GROUNDING_HASH = 'b'.repeat(64);
    function artifactRequest(onEmitted) {
        return {
            groundingHash: GROUNDING_HASH,
            provenanceSummary: 'similarity-only',
            ...(onEmitted !== undefined ? { onEmitted } : {}),
        };
    }
    /** Orchestrator config WITHOUT a declared capability (today's default). */
    function plainConfig(overrides) {
        return {
            targets: [{ glob: '**/*.ts', type: 'code', strategy: 'typescript-ast' }],
            orchestrator: {
                provider: 'gemini',
                defaultModel: 'gemini-3-flash-preview',
            },
            totemDir: '.totem',
            lanceDir: '.lancedb',
            ignorePatterns: [],
            contextWarningThreshold: 40_000,
            ...overrides,
        };
    }
    /** Orchestrator config that DECLARES self_grounding_agent capability. */
    function declaredConfig(orchestratorOverrides) {
        const base = plainConfig();
        return {
            ...base,
            orchestrator: {
                ...base.orchestrator,
                capabilities: { admissionClasses: ['self_grounding_agent'] },
                ...orchestratorOverrides,
            },
        };
    }
    function runsDirPath() {
        return path.join(tmpDir, '.totem', 'artifacts', 'runs');
    }
    function readArtifact(hash) {
        const file = path.join(runsDirPath(), `${hash}.json`);
        return RunArtifactSchema.parse(JSON.parse(fs.readFileSync(file, 'utf-8')));
    }
    beforeEach(() => {
        vi.clearAllMocks();
        tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-admission-'));
        vi.spyOn(process, 'cwd').mockReturnValue(tmpDir);
        const mockInvoke = vi.fn().mockResolvedValue({
            content: 'mock result',
            inputTokens: 100,
            outputTokens: 50,
            durationMs: 500,
        });
        mockedCreateOrchestrator.mockReturnValue(mockInvoke);
    });
    afterEach(() => {
        vi.restoreAllMocks();
        cleanTmpDir(tmpDir);
    });
    it('omitting every new field yields a byte-identical invoke payload and identical artifact backend (invariant 1)', async () => {
        const emitted = [];
        await runOrchestrator({
            prompt: 'test prompt',
            tag: 'Spec',
            options: { fresh: true },
            config: plainConfig(),
            cwd: tmpDir,
            artifact: artifactRequest((hash) => emitted.push(hash)),
        });
        // Byte-identical provider payload: the EXACT pre-#2102 key set, no
        // admission transport keys of any kind.
        const invoke = mockedCreateOrchestrator.mock.results[0].value;
        expect(invoke.mock.calls[0][0]).toStrictEqual({
            prompt: 'test prompt',
            model: 'gemini-3-flash-preview',
            cwd: tmpDir,
            tag: 'Spec',
            totemDir: '.totem',
            temperature: undefined,
        });
        // Identical artifact backend — decided by DEFAULT now, not constant.
        const artifact = readArtifact(emitted[0]);
        expect(artifact.backend.admissionClass).toBe('completion_only');
        expect(artifact.backend.taskProfile).toBe('Spec');
        expect(artifact.admission).toBeUndefined();
    });
    it('a requested-but-undeclared admission class fails loud before any invoke — no tokens, no artifact (invariant 2)', async () => {
        await expect(runOrchestrator({
            prompt: 'elevated request',
            tag: 'Spec',
            options: { fresh: true },
            config: plainConfig(), // no capabilities declared
            cwd: tmpDir,
            backendAdmissionClass: 'self_grounding_agent',
            artifact: artifactRequest(),
        })).rejects.toThrow(/self_grounding_agent/);
        const invoke = mockedCreateOrchestrator.mock.results[0]?.value;
        if (invoke !== undefined) {
            expect(invoke).not.toHaveBeenCalled();
        }
        expect(fs.existsSync(path.join(tmpDir, '.totem', 'artifacts'))).toBe(false);
    });
    it('the admitted class lands in backend.admissionClass verbatim; taskProfile records task ?? tag (invariant 3)', async () => {
        const emitted = [];
        await runOrchestrator({
            prompt: 'admitted run',
            tag: 'Spec',
            options: { fresh: true },
            config: declaredConfig(),
            cwd: tmpDir,
            backendAdmissionClass: 'self_grounding_agent',
            task: 'eval-fixture',
            artifact: artifactRequest((hash) => emitted.push(hash)),
        });
        const artifact = readArtifact(emitted[0]);
        expect(artifact.backend.admissionClass).toBe('self_grounding_agent');
        expect(artifact.backend.taskProfile).toBe('eval-fixture');
    });
    it('inputHash is unaffected by every new contract field (invariant 4)', async () => {
        const emitted = [];
        const shared = {
            prompt: 'identical prompt',
            tag: 'Spec',
            options: { fresh: true },
            config: declaredConfig(),
            cwd: tmpDir,
        };
        await runOrchestrator({
            ...shared,
            artifact: artifactRequest((hash) => emitted.push(hash)),
        });
        await runOrchestrator({
            ...shared,
            task: 'eval-fixture',
            backendAdmissionClass: 'self_grounding_agent',
            contextPolicy: { budget: 8000 },
            outputContract: { citationsRequired: true },
            runMetadata: { caller: 'test' },
            artifact: artifactRequest((hash) => emitted.push(hash)),
        });
        const bare = readArtifact(emitted[0]);
        const hydrated = readArtifact(emitted[1]);
        expect(hydrated.inputHash).toBe(bare.inputHash);
    });
    it('a provider-qualified PRIMARY that resolves cross-provider under an elevated class is denied BEFORE the invoke', async () => {
        // #2148 round-1 (CR major + Greptile P2): the capability declaration is
        // config-level (base-provider scoped), but `resolveOrchestrator` can route
        // a provider-qualified primary model to a DIFFERENT provider — the gate
        // must hold per RESOLVED backend on the primary path too, not just the
        // quota-fallback path.
        const onEmitted = vi.fn();
        await expect(runOrchestrator({
            prompt: 'cross-provider elevated primary',
            tag: 'Spec',
            options: { fresh: true, model: 'anthropic:claude-sonnet-4-6' },
            config: declaredConfig(), // gemini base config WITH self_grounding_agent declared
            cwd: tmpDir,
            backendAdmissionClass: 'self_grounding_agent',
            artifact: { ...artifactRequest(), onEmitted },
        })).rejects.toThrow(/cross-provider routing is not admitted/i);
        // ZERO invokes: every orchestrator the mocked factory handed out stayed idle.
        for (const created of mockedCreateOrchestrator.mock.results) {
            expect(created.value).not.toHaveBeenCalled();
        }
        expect(onEmitted).not.toHaveBeenCalled();
        expect(fs.existsSync(path.join(tmpDir, '.totem', 'artifacts'))).toBe(false);
    });
    it('a declared self_grounding_agent run whose quota fallback resolves cross-provider fails BEFORE the fallback invoke (invariant 5)', async () => {
        const quotaErr = new Error('429 quota exhausted');
        quotaErr.name = 'QuotaError';
        const mockInvoke = vi.fn().mockRejectedValueOnce(quotaErr).mockResolvedValue({
            content: 'fallback result',
            durationMs: 300,
        });
        mockedCreateOrchestrator.mockReturnValue(mockInvoke);
        const onEmitted = vi.fn();
        await expect(runOrchestrator({
            prompt: 'elevated quota-bound run',
            tag: 'Spec',
            options: { fresh: true },
            config: declaredConfig({ fallbackModel: 'anthropic:claude-sonnet-4-6' }),
            cwd: tmpDir,
            backendAdmissionClass: 'self_grounding_agent',
            artifact: { ...artifactRequest(), onEmitted },
        })).rejects.toThrow(/429 quota exhausted[\s\S]*self_grounding_agent/);
        // Exactly ONE invoke: the primary. The cross-provider fallback was never invoked.
        expect(mockInvoke).toHaveBeenCalledTimes(1);
        expect(onEmitted).not.toHaveBeenCalled();
    });
    it('a same-provider quota fallback under an elevated class is admitted', async () => {
        const quotaErr = new Error('429 quota exhausted');
        quotaErr.name = 'QuotaError';
        const mockInvoke = vi.fn().mockRejectedValueOnce(quotaErr).mockResolvedValue({
            content: 'fallback result',
            durationMs: 300,
        });
        mockedCreateOrchestrator.mockReturnValue(mockInvoke);
        const emitted = [];
        const result = await runOrchestrator({
            prompt: 'elevated quota-bound run',
            tag: 'Spec',
            options: { fresh: true },
            config: declaredConfig({
                defaultModel: 'gemini-3.1-pro-preview',
                fallbackModel: 'gemini-3-flash-preview',
            }),
            cwd: tmpDir,
            backendAdmissionClass: 'self_grounding_agent',
            artifact: artifactRequest((hash) => emitted.push(hash)),
        });
        expect(result).toBe('fallback result');
        const artifact = readArtifact(emitted[0]);
        expect(artifact.backend.admissionClass).toBe('self_grounding_agent');
        expect(artifact.backend.qualifiedModel).toBe('gemini-3-flash-preview');
    });
    it('records the admission group only when at least one member is supplied', async () => {
        const emitted = [];
        const admission = {
            outputContract: { citationsRequired: true, verifyFallback: true },
            contextPolicy: { budget: 16_000 },
            runMetadata: { caller: 'test', command: 'spec' },
        };
        await runOrchestrator({
            prompt: 'contract run',
            tag: 'Spec',
            options: { fresh: true },
            config: plainConfig(),
            cwd: tmpDir,
            ...admission,
            artifact: artifactRequest((hash) => emitted.push(hash)),
        });
        // Class-only run: backendAdmissionClass is recorded in backend, NOT the group.
        await runOrchestrator({
            prompt: 'class-only run',
            tag: 'Spec',
            options: { fresh: true },
            config: declaredConfig(),
            cwd: tmpDir,
            backendAdmissionClass: 'self_grounding_agent',
            artifact: artifactRequest((hash) => emitted.push(hash)),
        });
        const withGroup = readArtifact(emitted[0]);
        expect(withGroup.admission).toEqual(admission);
        const classOnlyRaw = fs.readFileSync(path.join(runsDirPath(), `${emitted[1]}.json`), 'utf-8');
        expect(JSON.parse(classOnlyRaw)).not.toHaveProperty('admission');
    });
    it('threads the supplied transport fields to the provider invoke verbatim', async () => {
        await runOrchestrator({
            prompt: 'threaded run',
            tag: 'Spec',
            options: { fresh: true },
            config: declaredConfig(),
            cwd: tmpDir,
            task: 'eval-fixture',
            backendAdmissionClass: 'self_grounding_agent',
            contextPolicy: { budget: 8000 },
            outputContract: { citationsRequired: true },
            runMetadata: { caller: 'test' },
        });
        const invoke = mockedCreateOrchestrator.mock.results[0].value;
        expect(invoke).toHaveBeenCalledWith(expect.objectContaining({
            task: 'eval-fixture',
            backendAdmissionClass: 'self_grounding_agent',
            contextPolicy: { budget: 8000 },
            outputContract: { citationsRequired: true },
            runMetadata: { caller: 'test' },
        }));
    });
    it('mismatched groundingBundle and artifact.bundle is an ambiguous grounding identity — hard error before invoke', async () => {
        const bundleA = {
            items: [
                {
                    provenance: 'similarity-only',
                    contentHash: 'c'.repeat(64),
                    sourceType: 'code',
                    filePath: 'src/a.ts',
                },
            ],
        };
        const bundleB = {
            items: [
                {
                    provenance: 'similarity-only',
                    contentHash: 'd'.repeat(64),
                    sourceType: 'code',
                    filePath: 'src/b.ts',
                },
            ],
        };
        await expect(runOrchestrator({
            prompt: 'ambiguous run',
            tag: 'Spec',
            options: { fresh: true },
            config: plainConfig(),
            cwd: tmpDir,
            groundingBundle: bundleA,
            artifact: {
                groundingHash: calculateDeterministicHash(bundleB),
                provenanceSummary: summarizeProvenance(bundleB),
                bundle: bundleB,
            },
        })).rejects.toThrow(/ambiguous grounding identity/i);
        const invoke = mockedCreateOrchestrator.mock.results[0]?.value;
        if (invoke !== undefined) {
            expect(invoke).not.toHaveBeenCalled();
        }
    });
    it('a supplied groundingBundle flows into the artifact bundle role when artifact.bundle is absent', async () => {
        const bundle = {
            items: [
                {
                    provenance: 'similarity-only',
                    contentHash: 'c'.repeat(64),
                    sourceType: 'code',
                    filePath: 'src/x.ts',
                },
            ],
        };
        const emitted = [];
        await runOrchestrator({
            prompt: 'bundle-flow run',
            tag: 'Spec',
            options: { fresh: true },
            config: plainConfig(),
            cwd: tmpDir,
            groundingBundle: bundle,
            artifact: {
                groundingHash: calculateDeterministicHash(bundle),
                provenanceSummary: summarizeProvenance(bundle),
                onEmitted: (hash) => emitted.push(hash),
            },
        });
        const artifact = readArtifact(emitted[0]);
        expect(artifact.grounding.bundle).toEqual(bundle);
        // #2148 round-1: the recorded hash must recompute from the recorded
        // bundle — the adopted-bundle path verifies the attested hash (never
        // recomputes it) before recording.
        expect(artifact.grounding.hash).toBe(calculateDeterministicHash(artifact.grounding.bundle));
    });
    it('an adopted groundingBundle whose hash mismatches artifact.groundingHash is rejected before invoke (verify-and-reject)', async () => {
        // #2148 round-1: with `artifact.bundle` omitted, the artifact records the
        // ADOPTED `opts.groundingBundle` but used to trust `artifact.groundingHash`
        // verbatim — persisting a record whose grounding.hash does not match its
        // grounding.bundle. The seam records, never re-derives, so the fix is
        // verify-and-reject, not recompute.
        const bundle = {
            items: [
                {
                    provenance: 'similarity-only',
                    contentHash: 'c'.repeat(64),
                    sourceType: 'code',
                    filePath: 'src/x.ts',
                },
            ],
        };
        await expect(runOrchestrator({
            prompt: 'forged grounding hash',
            tag: 'Spec',
            options: { fresh: true },
            config: plainConfig(),
            cwd: tmpDir,
            groundingBundle: bundle,
            artifact: {
                groundingHash: 'e'.repeat(64), // does NOT recompute from the adopted bundle
                provenanceSummary: 'similarity-only:1',
            },
        })).rejects.toThrow(/ambiguous grounding identity/i);
        const invoke = mockedCreateOrchestrator.mock.results[0]?.value;
        if (invoke !== undefined) {
            expect(invoke).not.toHaveBeenCalled();
        }
        expect(fs.existsSync(path.join(tmpDir, '.totem', 'artifacts'))).toBe(false);
    });
    it('a supplied artifact.bundle flows into the invoke-seam groundingBundle role', async () => {
        const bundle = {
            items: [
                {
                    provenance: 'similarity-only',
                    contentHash: 'c'.repeat(64),
                    sourceType: 'code',
                    filePath: 'src/x.ts',
                },
            ],
        };
        await runOrchestrator({
            prompt: 'bundle-flow run',
            tag: 'Spec',
            options: { fresh: true },
            config: plainConfig(),
            cwd: tmpDir,
            artifact: {
                groundingHash: calculateDeterministicHash(bundle),
                provenanceSummary: summarizeProvenance(bundle),
                bundle,
            },
        });
        const invoke = mockedCreateOrchestrator.mock.results[0].value;
        expect(invoke).toHaveBeenCalledWith(expect.objectContaining({ groundingBundle: bundle }));
    });
    it('matching groundingBundle and artifact.bundle reconcile cleanly (no false ambiguity)', async () => {
        const bundle = {
            items: [
                {
                    provenance: 'similarity-only',
                    contentHash: 'c'.repeat(64),
                    sourceType: 'code',
                    filePath: 'src/x.ts',
                },
            ],
        };
        const emitted = [];
        const result = await runOrchestrator({
            prompt: 'reconciled run',
            tag: 'Spec',
            options: { fresh: true },
            config: plainConfig(),
            cwd: tmpDir,
            groundingBundle: bundle,
            artifact: {
                groundingHash: calculateDeterministicHash(bundle),
                provenanceSummary: summarizeProvenance(bundle),
                bundle,
                onEmitted: (hash) => emitted.push(hash),
            },
        });
        expect(result).toBe('mock result');
        expect(readArtifact(emitted[0]).grounding.bundle).toEqual(bundle);
    });
    // ─── Response-cache key carries the contract (#2148 round-1) ──
    it('absent contract fields leave the response-cache key byte-identical to the legacy shape (invariant 1 — legacy keys stay warm)', async () => {
        await runOrchestrator({
            prompt: 'cache key probe',
            tag: 'Spec', // Spec carries a default response-cache TTL
            options: {},
            config: plainConfig(),
            cwd: tmpDir,
        });
        // The exact legacy construction (see the null-byte delimiter test above):
        // prompt, systemPrompt, qualifiedModel — and NOTHING else when every
        // contract field is absent.
        const legacyHash = crypto
            .createHash('sha256')
            .update('cache key probe')
            .update('\0')
            .update('')
            .update('\0')
            .update('gemini-3-flash-preview')
            .digest('hex')
            .slice(0, 16);
        expect(fs.existsSync(path.join(tmpDir, '.totem', 'cache', `spec-${legacyHash}.json`))).toBe(true);
    });
    it('contract fields produce a distinct response-cache key, and differing in ONE contract field differs again (no aliasing)', async () => {
        const shared = {
            prompt: 'aliasing probe',
            tag: 'Spec', // Spec carries a default response-cache TTL
            options: {},
            config: plainConfig(),
            cwd: tmpDir,
        };
        await runOrchestrator(shared); // legacy-shaped
        await runOrchestrator({ ...shared, contextPolicy: { budget: 8000 } });
        await runOrchestrator({ ...shared, contextPolicy: { budget: 16_000 } });
        // Three ACTUAL invokes — a contract-bearing call must never be served the
        // legacy call's cached payload (or another contract's) as a replay.
        const invoke = mockedCreateOrchestrator.mock.results[0].value;
        expect(invoke).toHaveBeenCalledTimes(3);
        // …and three distinct cache keys on disk.
        expect(fs.readdirSync(path.join(tmpDir, '.totem', 'cache'))).toHaveLength(3);
    });
});
//# sourceMappingURL=utils.test.js.map