import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import Fastify, { type FastifyInstance } from 'fastify'; import postgres from 'postgres'; import { registerTaskRoutes } from '../tasks.js'; /** * F1 — POST /api/tasks/:id/cancel route wiring. * * The route's job: reach the in-flight external run via `cancelExternal(taskId)` * (the new abort hook), keep cancelling native inference for open chats unchanged, * and land the task row in 'cancelled'. The streaming assistant message is * finalized by the dispatcher's run-function, not here — that path is covered by * finalize-message.test.ts. This suite pins the route's behavior against a real DB. */ describe.runIf(!!process.env.DATABASE_URL)('POST /api/tasks/:id/cancel (route, F1)', () => { let sql: ReturnType; let app: FastifyInstance; let projectId: string; let sessionId: string; let chatId: string; const externalCancelCalls: string[] = []; const inferenceCancelCalls: Array<[string, string]> = []; let externalReturns = true; beforeAll(async () => { sql = postgres(process.env.DATABASE_URL!, { max: 3 }); const serverSchema = resolve(__dirname, '../../../../server/src/schema.sql'); const coderSchema = resolve(__dirname, '../../schema.sql'); await sql.unsafe(readFileSync(serverSchema, 'utf8')); await sql.unsafe(readFileSync(coderSchema, 'utf8')); const [p] = await sql<{ id: string }[]>` INSERT INTO projects (name, path, status) VALUES ('f1-cancel-route', '/tmp/f1-cancel-route', 'open') RETURNING id `; projectId = p!.id; const [s] = await sql<{ id: string }[]>` INSERT INTO sessions (project_id, name, model, status) VALUES (${projectId}, 'f1', 'm', 'open') RETURNING id `; sessionId = s!.id; const [c] = await sql<{ id: string }[]>` INSERT INTO chats (session_id, name, status) VALUES (${sessionId}, 'tab', 'open') RETURNING id `; chatId = c!.id; app = Fastify(); registerTaskRoutes( app, sql, { cancel: async (sid: string, cid: string) => { inferenceCancelCalls.push([sid, cid]); return false; }, }, (taskId: string) => { externalCancelCalls.push(taskId); return externalReturns; }, ); await app.ready(); }); afterAll(async () => { if (app) await app.close(); if (!sql) return; await sql`DELETE FROM messages WHERE session_id = ${sessionId}`.catch(() => {}); await sql`DELETE FROM tasks WHERE project_id = ${projectId}`.catch(() => {}); await sql`DELETE FROM chats WHERE id = ${chatId}`.catch(() => {}); await sql`DELETE FROM sessions WHERE id = ${sessionId}`.catch(() => {}); await sql`DELETE FROM projects WHERE id = ${projectId}`.catch(() => {}); await sql.end({ timeout: 5 }); }); async function insertTask(agent: string | null, state: string): Promise { const [t] = await sql<{ id: string }[]>` INSERT INTO tasks (project_id, input, agent, session_id, state, started_at) VALUES (${projectId}, 'do a thing', ${agent}, ${sessionId}, ${state}, clock_timestamp()) RETURNING id `; return t!.id; } it('reaches cancelExternal and lands the task cancelled for a running external task', async () => { externalReturns = true; externalCancelCalls.length = 0; const taskId = await insertTask('opencode', 'running'); const res = await app.inject({ method: 'POST', url: `/api/tasks/${taskId}/cancel` }); expect(res.statusCode).toBe(200); expect(res.json()).toEqual({ cancelled: true }); expect(externalCancelCalls).toContain(taskId); const [row] = await sql<{ state: string; ended_at: Date | null }[]>` SELECT state, ended_at FROM tasks WHERE id = ${taskId} `; expect(row!.state).toBe('cancelled'); expect(row!.ended_at).not.toBeNull(); }); it('still cancels a native boocode task (cancelExternal returns false → inference.cancel path unchanged)', async () => { externalReturns = false; // native task: no controller registered externalCancelCalls.length = 0; inferenceCancelCalls.length = 0; const taskId = await insertTask(null, 'running'); const res = await app.inject({ method: 'POST', url: `/api/tasks/${taskId}/cancel` }); expect(res.statusCode).toBe(200); // The route calls cancelExternal unconditionally (cheap, returns false here)... expect(externalCancelCalls).toContain(taskId); // ...and the native inference.cancel path still fires for the open chat. expect(inferenceCancelCalls).toContainEqual([sessionId, chatId]); const [row] = await sql<{ state: string }[]>`SELECT state FROM tasks WHERE id = ${taskId}`; expect(row!.state).toBe('cancelled'); }); it('rejects cancelling an already-terminal task with 409 and never touches the abort hook', async () => { externalCancelCalls.length = 0; const taskId = await insertTask('opencode', 'completed'); const res = await app.inject({ method: 'POST', url: `/api/tasks/${taskId}/cancel` }); expect(res.statusCode).toBe(409); expect(externalCancelCalls).not.toContain(taskId); }); it('returns 404 for an unknown task', async () => { const res = await app.inject({ method: 'POST', url: `/api/tasks/00000000-0000-0000-0000-000000000000/cancel`, }); expect(res.statusCode).toBe(404); }); });