import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { runWorker, pickModel } from './secretary-worker.mjs'; import { enqueueSpan, readCursor } from './secretary-queue.mjs'; import { EMPTY_PROTOCOL } from './secretary-protocol.mjs'; let dir, secdir, workDir; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'secw-')); secdir = join(dir, 'docs', 'secretary'); workDir = join(secdir, 'тема'); mkdirSync(join(secdir, 'raw'), { recursive: true }); mkdirSync(workDir, { recursive: true }); writeFileSync(join(workDir, 'protocol.json'), JSON.stringify(EMPTY_PROTOCOL())); writeFileSync(join(secdir, 'raw', 's.log'), '=== ХОД turn=1 ===\n[ЮЗЕР]\nпривет\n[АССИСТЕНТ]\nответ\n=== КОНЕЦ ХОДА ===\n'); }); afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); const deps = (overrides = {}) => ({ secdir, distill: async (proto) => ({ ...proto, subject: 'разобрано', steps: [{ turn: 1, text: 'шаг' }] }), render: () => '# протокол', now: () => 1000, ...overrides, }); describe('runWorker', () => { it('drains one queued span: writes protocol, renders md, advances cursor', async () => { enqueueSpan(workDir, { session: 's', span: { start: 1, end: 1, index: 0, note: '' }, kind: 'span' }); await runWorker(workDir, deps()); const proto = JSON.parse(readFileSync(join(workDir, 'protocol.json'), 'utf-8')); expect(proto.subject).toBe('разобрано'); expect(readFileSync(join(workDir, 'protocol.md'), 'utf-8')).toContain('протокол'); expect(readCursor(workDir)).toBe(0); }); it('skips a job whose index <= cursor (idempotent re-enqueue)', async () => { let distillCalls = 0; enqueueSpan(workDir, { session: 's', span: { start: 1, end: 1, index: 0, note: '' }, kind: 'span' }); enqueueSpan(workDir, { session: 's', span: { start: 1, end: 1, index: 0, note: '' }, kind: 'span' }); await runWorker(workDir, deps({ distill: async (p) => { distillCalls++; return p; } })); expect(distillCalls).toBe(1); }); it('exits immediately if the theme lock is held', async () => { let distillCalls = 0; enqueueSpan(workDir, { session: 's', span: { start: 1, end: 1, index: 0, note: '' }, kind: 'span' }); await runWorker(workDir, deps({ acquire: async () => null, distill: async (p) => { distillCalls++; return p; } })); expect(distillCalls).toBe(0); }); it('degrades on distill throw: cursor still advances, protocol not corrupted', async () => { enqueueSpan(workDir, { session: 's', span: { start: 1, end: 1, index: 0, note: '' }, kind: 'span' }); await runWorker(workDir, deps({ distill: async () => { throw new Error('context window exceeded'); } })); expect(readCursor(workDir)).toBe(0); expect(existsSync(join(workDir, 'protocol.json'))).toBe(true); }); }); describe('pickModel (per-role model forwarding)', () => { it('uses req.model (per-role) when present', () => { expect(pickModel({ system: 's', user: 'u', model: 'role-model' }, { SECRETARY_LLM_MODEL: 'global' })).toBe('role-model'); }); it('falls back to SECRETARY_LLM_MODEL when req has no model', () => { expect(pickModel({ system: 's', user: 'u' }, { SECRETARY_LLM_MODEL: 'global' })).toBe('global'); }); it('returns undefined when neither present (callAnthropicAPI default applies)', () => { expect(pickModel({ system: 's', user: 'u' }, {})).toBe(undefined); }); it('handles a string prompt (old audit path) → falls back to env', () => { expect(pickModel('just a string', { SECRETARY_LLM_MODEL: 'global' })).toBe('global'); }); });