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>
164 lines
6.2 KiB
TypeScript
164 lines
6.2 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import postgres from 'postgres';
|
|
import type { WsFrame } from '@boocode/contracts/ws-frames';
|
|
import { classifyTerminalStatus, finalizeStreamingMessage } from '../finalize-message.js';
|
|
|
|
/**
|
|
* F1 (D-7 / OCE-001 / OCE-002) — finalizing a Stop'd or errored external turn.
|
|
*
|
|
* `classifyTerminalStatus` is the pure D-7 decision (user Stop / AbortError →
|
|
* cancelled, genuine error → failed). `finalizeStreamingMessage` writes that
|
|
* terminal state onto the streaming assistant row and publishes the matching
|
|
* message_complete frame — idempotently, guarded by `WHERE status='streaming'`,
|
|
* so a double-Stop or an abort-then-catch settles the message exactly once and
|
|
* never clobbers a row that already finished cleanly.
|
|
*/
|
|
describe('classifyTerminalStatus (pure, D-7)', () => {
|
|
it('maps a fired abort signal to cancelled (user Stop)', () => {
|
|
expect(classifyTerminalStatus({ aborted: true })).toBe('cancelled');
|
|
});
|
|
|
|
it('maps a thrown AbortError to cancelled', () => {
|
|
const e = new Error('the operation was aborted');
|
|
e.name = 'AbortError';
|
|
expect(classifyTerminalStatus({ aborted: false, error: e })).toBe('cancelled');
|
|
});
|
|
|
|
it('maps a genuine thrown error to failed', () => {
|
|
expect(classifyTerminalStatus({ aborted: false, error: new Error('boom') })).toBe('failed');
|
|
});
|
|
|
|
it('defaults a no-abort / no-error catch to failed', () => {
|
|
expect(classifyTerminalStatus({ aborted: false })).toBe('failed');
|
|
});
|
|
});
|
|
|
|
describe.runIf(!!process.env.DATABASE_URL)('finalizeStreamingMessage (DB)', () => {
|
|
let sql: ReturnType<typeof postgres>;
|
|
let projectId: string;
|
|
let sessionId: string;
|
|
let chatId: string;
|
|
|
|
beforeAll(async () => {
|
|
sql = postgres(process.env.DATABASE_URL!, { max: 3 });
|
|
// Server schema owns messages/sessions/chats (FK targets); coder schema after.
|
|
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-finalize', '/tmp/f1-finalize', '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;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (!sql) return;
|
|
await sql`DELETE FROM messages WHERE session_id = ${sessionId}`.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 insertStreaming(): Promise<string> {
|
|
const [m] = await sql<{ id: string }[]>`
|
|
INSERT INTO messages (session_id, chat_id, role, content, status)
|
|
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming') RETURNING id
|
|
`;
|
|
return m!.id;
|
|
}
|
|
|
|
it('finalizes a streaming row to cancelled, persists partial content, publishes one frame', async () => {
|
|
const id = await insertStreaming();
|
|
const frames: WsFrame[] = [];
|
|
const did = await finalizeStreamingMessage(sql, (_s, f) => frames.push(f), {
|
|
sessionId,
|
|
chatId,
|
|
assistantId: id,
|
|
status: 'cancelled',
|
|
model: 'qwen',
|
|
content: 'partial answer',
|
|
});
|
|
|
|
expect(did).toBe(true);
|
|
const [row] = await sql<{ status: string; content: string; finished_at: Date | null }[]>`
|
|
SELECT status, content, finished_at FROM messages WHERE id = ${id}
|
|
`;
|
|
expect(row!.status).toBe('cancelled');
|
|
expect(row!.content).toBe('partial answer');
|
|
expect(row!.finished_at).not.toBeNull();
|
|
expect(frames).toHaveLength(1);
|
|
expect(frames[0]!.type).toBe('message_complete');
|
|
expect((frames[0] as { status?: string }).status).toBe('cancelled');
|
|
});
|
|
|
|
it('is idempotent for a double-Stop: second call updates nothing and re-publishes nothing', async () => {
|
|
const id = await insertStreaming();
|
|
const frames: WsFrame[] = [];
|
|
const push = (_s: string, f: WsFrame): void => {
|
|
frames.push(f);
|
|
};
|
|
|
|
expect(
|
|
await finalizeStreamingMessage(sql, push, { sessionId, chatId, assistantId: id, status: 'cancelled', model: null }),
|
|
).toBe(true);
|
|
expect(
|
|
await finalizeStreamingMessage(sql, push, { sessionId, chatId, assistantId: id, status: 'cancelled', model: null }),
|
|
).toBe(false);
|
|
|
|
expect(frames).toHaveLength(1);
|
|
const [row] = await sql<{ status: string }[]>`SELECT status FROM messages WHERE id = ${id}`;
|
|
expect(row!.status).toBe('cancelled');
|
|
});
|
|
|
|
it('never clobbers a row that already finished cleanly (abort raced a clean finish)', async () => {
|
|
const [m] = await sql<{ id: string }[]>`
|
|
INSERT INTO messages (session_id, chat_id, role, content, status)
|
|
VALUES (${sessionId}, ${chatId}, 'assistant', 'done', 'complete') RETURNING id
|
|
`;
|
|
const id = m!.id;
|
|
const frames: WsFrame[] = [];
|
|
|
|
const did = await finalizeStreamingMessage(sql, (_s, f) => frames.push(f), {
|
|
sessionId,
|
|
chatId,
|
|
assistantId: id,
|
|
status: 'cancelled',
|
|
model: null,
|
|
});
|
|
|
|
expect(did).toBe(false);
|
|
expect(frames).toHaveLength(0);
|
|
const [row] = await sql<{ status: string; content: string }[]>`
|
|
SELECT status, content FROM messages WHERE id = ${id}
|
|
`;
|
|
expect(row!.status).toBe('complete');
|
|
expect(row!.content).toBe('done');
|
|
});
|
|
|
|
it('no-ops on an empty assistantId (throw happened before the row was created)', async () => {
|
|
const frames: WsFrame[] = [];
|
|
const did = await finalizeStreamingMessage(sql, (_s, f) => frames.push(f), {
|
|
sessionId,
|
|
chatId,
|
|
assistantId: '',
|
|
status: 'failed',
|
|
model: null,
|
|
});
|
|
expect(did).toBe(false);
|
|
expect(frames).toHaveLength(0);
|
|
});
|
|
});
|