import { test, expect } from '@playwright/test'; import Database from 'better-sqlite3'; import { readFileSync } from 'node:fs'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; // The tool-approval pause can only be produced naturally by a live LLM (the // agent must call RequestTool). In this LLM-less E2E we seed the engine state // directly into the throwaway DB: a job parked with wait_reason='tool_request' // + a pending tool_request, then drive the inline approval card in the chat. // The DB path is handed off by playwright.config.ts via ui/.e2e-db-path. const __dirname = dirname(fileURLToPath(import.meta.url)); const dbPath = readFileSync(resolve(__dirname, '..', '.e2e-db-path'), 'utf-8').trim(); test('inline tool-approval: the card renders and Approve grants + resolves the request', async ({ page, request }) => { // 1. Create a task (spawns a job). const created = await request.post('/api/local/tasks', { data: { body: 'e2e tool request', piece: 'chat' } }); expect(created.ok()).toBeTruthy(); const taskId = (await created.json()).task.id as number; // 2. Seed: park the spawned job for tool approval + a pending request. const db = new Database(dbPath); db.pragma('busy_timeout = 8000'); let jobId: string | undefined; for (let i = 0; i < 60 && !jobId; i++) { const row = db.prepare(`SELECT id FROM jobs WHERE repo = ? ORDER BY created_at DESC LIMIT 1`) .get(`local/task-${taskId}`) as { id: string } | undefined; if (row) jobId = row.id; else await new Promise((r) => setTimeout(r, 100)); } if (!jobId) throw new Error('spawned job row never appeared'); db.prepare(`UPDATE jobs SET status='waiting_human', wait_reason='tool_request' WHERE id = ?`).run(jobId); const reqId = 'e2e-tool-req-1'; db.prepare( `INSERT INTO tool_requests (id, task_id, job_id, piece_name, movement_name, tool_name, reason, category, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending')`, ).run(reqId, String(taskId), jobId, 'chat', 'respond', 'WebSearch', 'need to search the web', 'requested'); db.close(); // 3. Open the task; the inline approval card must render with tool + reason. await page.goto(`/ui?task=${taskId}`); const card = page.locator('[data-testid="tool-request-WebSearch"]:visible'); await expect(card).toBeVisible(); await expect(card).toContainText('WebSearch'); await expect(card).toContainText('need to search the web'); // While awaiting approval the normal composer is locked — sending a regular // message here would spawn a duplicate parallel job on resume. await expect(page.locator('textarea:visible').first()).toBeDisabled(); // 4. Approve → the card disappears (no longer pending). await page.locator('[data-testid="tool-request-approve-WebSearch"]:visible').click(); await expect(page.getByTestId('tool-request-WebSearch')).toHaveCount(0); // 5. Backend: request approved, tool granted to the task, job re-queued. const verify = new Database(dbPath, { readonly: true }); const tr = verify.prepare(`SELECT status FROM tool_requests WHERE id = ?`).get(reqId) as { status: string }; const task = verify.prepare(`SELECT granted_tools FROM local_tasks WHERE id = ?`).get(taskId) as { granted_tools: string | null }; verify.close(); expect(tr.status).toBe('approved'); expect(task.granted_tools ?? '').toContain('WebSearch'); }); test('inline tool-approval: Deny resolves without granting', async ({ page, request }) => { const created = await request.post('/api/local/tasks', { data: { body: 'e2e deny', piece: 'chat' } }); const taskId = (await created.json()).task.id as number; const db = new Database(dbPath); db.pragma('busy_timeout = 8000'); let jobId: string | undefined; for (let i = 0; i < 60 && !jobId; i++) { const row = db.prepare(`SELECT id FROM jobs WHERE repo = ? ORDER BY created_at DESC LIMIT 1`) .get(`local/task-${taskId}`) as { id: string } | undefined; if (row) jobId = row.id; else await new Promise((r) => setTimeout(r, 100)); } if (!jobId) throw new Error('spawned job row never appeared'); db.prepare(`UPDATE jobs SET status='waiting_human', wait_reason='tool_request' WHERE id = ?`).run(jobId); const reqId = 'e2e-tool-req-2'; db.prepare( `INSERT INTO tool_requests (id, task_id, job_id, piece_name, movement_name, tool_name, reason, category, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending')`, ).run(reqId, String(taskId), jobId, 'chat', 'respond', 'Bash', 'run a command', 'requested'); db.close(); await page.goto(`/ui?task=${taskId}`); await expect(page.locator('[data-testid="tool-request-Bash"]:visible')).toBeVisible(); await page.locator('[data-testid="tool-request-deny-Bash"]:visible').click(); await expect(page.getByTestId('tool-request-Bash')).toHaveCount(0); const verify = new Database(dbPath, { readonly: true }); const tr = verify.prepare(`SELECT status FROM tool_requests WHERE id = ?`).get(reqId) as { status: string }; const task = verify.prepare(`SELECT granted_tools FROM local_tasks WHERE id = ?`).get(taskId) as { granted_tools: string | null }; verify.close(); expect(tr.status).toBe('denied'); expect(task.granted_tools ?? '').not.toContain('Bash'); });