Five independent items from the post-review backlog. F1: Stop on an external agent task now aborts the running child via a per-task AbortController registry reachable from the cancel route, and finalizes the assistant message as cancelled (fixing two latent bugs — catch blocks left the message streaming, and warm success-paths wrote complete on an aborted turn); warm pools/worktrees are preserved and the native path is unchanged. F2/F3: prune the tool-call parser to its two load-bearing exports (unexport eight zero-caller symbols, add a gate test for the <invoke>-as-text fallback) and route placeholder-rejection logging through pino. F6: a 90s per-chunk stall-timeout wraps native inference's fullStream via AbortSignal.any so a hung stream finalizes the message instead of hanging — no retry (a pure classifyStreamError helper is added). F7: a read-only view_session_history MCP tool (newest-N, chronological). F9: retire the unused apps/coder/web :9502 fallback SPA, keeping every API/WS/health/MCP route. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
139 lines
5.4 KiB
TypeScript
139 lines
5.4 KiB
TypeScript
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<typeof postgres>;
|
|
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<string> {
|
|
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);
|
|
});
|
|
});
|