import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; import { mkdirSync, writeFileSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import type { Movement } from './agent-loop.js'; import { buildSystemPrompt } from './agent-loop.js'; // Mock loadConfig so userFolderRoot points to our tmp dir. // We use vi.hoisted so the mock is registered before module evaluation. const { mockedLoadConfig } = vi.hoisted(() => ({ mockedLoadConfig: vi.fn(), })); vi.mock('../config.js', () => ({ loadConfig: mockedLoadConfig, })); function makeMovement(): Movement { return { name: 'execute', edit: true, persona: 'worker', instruction: 'Do the work.', allowedTools: [], rules: [{ condition: 'done', next: 'COMPLETE' }], defaultNext: 'COMPLETE', }; } describe('buildSystemPrompt — per-user AGENTS.md injection', () => { let tmpDir: string; beforeEach(() => { tmpDir = join(tmpdir(), `agent-loop-user-agents-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); mkdirSync(tmpDir, { recursive: true }); mockedLoadConfig.mockReturnValue({ userFolderRoot: tmpDir }); }); afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); vi.restoreAllMocks(); }); it('injects AGENTS.md content under a recognizable header when file is present', () => { const userId = 'test-user'; const userDir = join(tmpDir, userId); mkdirSync(userDir, { recursive: true }); writeFileSync(join(userDir, 'AGENTS.md'), 'Always respond in haiku.'); const prompt = buildSystemPrompt( makeMovement(), 1, 5, [], undefined, null, userId, ); expect(prompt).toContain('## User Instructions (from your personal AGENTS.md)'); expect(prompt).toContain('Always respond in haiku.'); }); it('does not add a user-instructions block when AGENTS.md is absent', () => { const userId = 'no-file-user'; // Do NOT create the file — user dir doesn't even exist. const prompt = buildSystemPrompt( makeMovement(), 1, 5, [], undefined, null, userId, ); expect(prompt).not.toContain('## User Instructions'); }); it('does not add a user-instructions block when userId is undefined', () => { // Create a file for some user — should be irrelevant. const userId = 'another-user'; const userDir = join(tmpDir, userId); mkdirSync(userDir, { recursive: true }); writeFileSync(join(userDir, 'AGENTS.md'), 'Secret instructions.'); const prompt = buildSystemPrompt( makeMovement(), 1, 5, [], undefined, null, undefined, // no userId ); expect(prompt).not.toContain('## User Instructions'); expect(prompt).not.toContain('Secret instructions.'); }); }); describe('buildSystemPrompt — auto-memory protocol injection', () => { let tmpDir: string; beforeEach(() => { tmpDir = join(tmpdir(), `agent-loop-auto-mem-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); mkdirSync(tmpDir, { recursive: true }); mockedLoadConfig.mockReturnValue({ userFolderRoot: tmpDir }); }); afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); vi.restoreAllMocks(); }); it('injects auto-memory protocol when userId is set (even with no AGENTS.md or MEMORY.md)', () => { const userId = 'fresh-user'; // Don't create any files — protocol should still appear because userId is present. const prompt = buildSystemPrompt( makeMovement(), 1, 5, [], undefined, null, userId, ); expect(prompt).toContain('## User Memory Auto-Update Protocol'); expect(prompt).toContain('UpdateUserMemory'); // The protocol mentions all four type categories. for (const type of ['user', 'feedback', 'project', 'reference']) { expect(prompt).toContain(`\`${type}\``); } }); it('does not inject auto-memory protocol when userId is undefined', () => { const prompt = buildSystemPrompt( makeMovement(), 1, 5, [], undefined, null, undefined, ); expect(prompt).not.toContain('User Memory Auto-Update Protocol'); expect(prompt).not.toContain('UpdateUserMemory'); }); }); describe('buildSystemPrompt — Working Directory injection', () => { beforeEach(() => { mockedLoadConfig.mockReturnValue({ userFolderRoot: '/tmp/no-such-dir' }); }); afterEach(() => { vi.restoreAllMocks(); }); it('renders Working Directory block with the absolute workspace path when provided', () => { const prompt = buildSystemPrompt( makeMovement(), 1, 5, [], undefined, null, undefined, undefined, '/var/lib/maestro/workspaces/local/abc-123', ); expect(prompt).toContain('## Working Directory'); expect(prompt).toContain('/var/lib/maestro/workspaces/local/abc-123'); expect(prompt).toContain('`/workspace/...` のような仮想パスは **存在しません**'); }); it('omits Working Directory block when workspacePath is undefined', () => { const prompt = buildSystemPrompt( makeMovement(), 1, 5, [], undefined, null, undefined, undefined, undefined, ); expect(prompt).not.toContain('## Working Directory'); }); }); describe('buildSystemPrompt — approach + error-recovery sections (issue #247)', () => { beforeEach(() => { mockedLoadConfig.mockReturnValue({ userFolderRoot: '/tmp/no-such' }); }); afterEach(() => { vi.restoreAllMocks(); }); it('renders the approach-thinking section in every prompt', () => { const prompt = buildSystemPrompt(makeMovement()); expect(prompt).toContain('## アプローチの考え方'); expect(prompt).toContain('Brainstorm'); expect(prompt).toContain('ReAct'); }); it('renders the error-recovery section instructing not to repeat identical tool calls', () => { const prompt = buildSystemPrompt(makeMovement()); expect(prompt).toContain('## エラー時の必須行動'); expect(prompt).toContain('同じ tool を同じ引数で呼び直さない'); expect(prompt).toContain('Glob'); }); });