diff --git a/apps/coder/Dockerfile b/apps/coder/Dockerfile index cec82c7..055cea4 100644 --- a/apps/coder/Dockerfile +++ b/apps/coder/Dockerfile @@ -7,7 +7,6 @@ WORKDIR /build COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./ COPY apps/server/package.json ./apps/server/ COPY apps/coder/package.json ./apps/coder/ -COPY apps/coder/web/package.json ./apps/coder/web/ RUN pnpm install --frozen-lockfile @@ -16,7 +15,6 @@ COPY apps/server ./apps/server RUN pnpm -C apps/server build COPY apps/coder ./apps/coder -RUN pnpm -C apps/coder/web build RUN pnpm -C apps/coder build RUN pnpm deploy --filter=@boocode/coder --prod --legacy /out/coder @@ -27,7 +25,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends ripgrep git ope WORKDIR /app COPY --from=builder /out/coder ./ -COPY --from=builder /build/apps/coder/web/dist ./web ENV NODE_ENV=production EXPOSE 3000 diff --git a/apps/coder/package.json b/apps/coder/package.json index dfd18b6..3fd1235 100644 --- a/apps/coder/package.json +++ b/apps/coder/package.json @@ -17,7 +17,6 @@ "@agentclientprotocol/sdk": "^0.22.1", "@anthropic-ai/claude-agent-sdk": "^0.3.159", "@boocode/server": "workspace:*", - "@fastify/static": "^7.0.4", "@fastify/websocket": "^10.0.1", "@modelcontextprotocol/sdk": "^1.29.0", "@opencode-ai/sdk": "~1.15.0", diff --git a/apps/coder/src/index.ts b/apps/coder/src/index.ts index 5166926..7d62dd6 100644 --- a/apps/coder/src/index.ts +++ b/apps/coder/src/index.ts @@ -1,12 +1,5 @@ -import { resolve, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { existsSync } from 'node:fs'; import Fastify from 'fastify'; import fastifyWebsocket from '@fastify/websocket'; -import fastifyStatic from '@fastify/static'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); import { loadConfig } from './config.js'; import { getSql, applySchema, pingDb, closeDb } from './db.js'; import { startMcpServer } from './services/mcp-server.js'; @@ -257,7 +250,7 @@ async function main() { registerPendingRoutes(app, sql); registerCheckpointRoutes(app, sql); registerAgentSessionRoutes(app, sql); - registerTaskRoutes(app, sql, inferenceApi); + registerTaskRoutes(app, sql, inferenceApi, dispatcher.cancelExternalTask); registerInboxRoutes(app, sql); registerStatsRoutes(app, sql); registerArenaRoutes(app, sql); @@ -266,28 +259,6 @@ async function main() { registerLifecycleRoutes(app, sql); registerWebSocket(app, sql, broker); - // Serve static frontend (built web app). In production, the dist/ is - // copied to ../web relative to the dist/ directory at /app/web. In dev, - // check adjacent to the source. - const webRoot = resolve(__dirname, '../web'); - if (existsSync(webRoot)) { - await app.register(fastifyStatic, { - root: webRoot, - prefix: '/', - // Don't intercept /api routes — static only serves files that exist. - wildcard: false, - }); - // SPA fallback: serve index.html for non-API routes that don't match a file. - app.setNotFoundHandler(async (req, reply) => { - if (req.url.startsWith('/api')) { - reply.code(404); - return { error: 'not found' }; - } - return reply.sendFile('index.html'); - }); - app.log.info(`serving frontend from ${webRoot}`); - } - // Graceful shutdown const shutdown = async () => { app.log.info('shutting down'); diff --git a/apps/coder/src/routes/__tests__/tasks-cancel.test.ts b/apps/coder/src/routes/__tests__/tasks-cancel.test.ts new file mode 100644 index 0000000..0f4e843 --- /dev/null +++ b/apps/coder/src/routes/__tests__/tasks-cancel.test.ts @@ -0,0 +1,138 @@ +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); + }); +}); diff --git a/apps/coder/src/routes/tasks.ts b/apps/coder/src/routes/tasks.ts index 11cdf57..a901542 100644 --- a/apps/coder/src/routes/tasks.ts +++ b/apps/coder/src/routes/tasks.ts @@ -8,6 +8,12 @@ interface InferenceApi { cancel: (sessionId: string, chatId: string) => Promise; } +// F1: the dispatcher's reach into an in-flight external-agent run. Narrow by +// design (not the whole dispatcher) — the route only needs to fire the abort. +// Returns true when a controller was registered for the task (an external run was +// in flight), false otherwise (native boocode task, or already finished). +export type ExternalCancelFn = (taskId: string) => boolean; + const CreateBody = z.object({ project_id: z.string().uuid(), input: z.string().min(1).max(64_000), @@ -27,7 +33,12 @@ const ListQuery = z.object({ project_id: z.string().uuid().optional(), }); -export function registerTaskRoutes(app: FastifyInstance, sql: Sql, inference: InferenceApi): void { +export function registerTaskRoutes( + app: FastifyInstance, + sql: Sql, + inference: InferenceApi, + cancelExternal: ExternalCancelFn, +): void { // POST /api/tasks — create a new task app.post('/api/tasks', async (req, reply) => { const parsed = CreateBody.safeParse(req.body); @@ -127,7 +138,14 @@ export function registerTaskRoutes(app: FastifyInstance, sql: Sql, inference: In cancelPendingPermission(taskId); - // If running, try to cancel inference + // F1: abort the in-flight external-agent run (opencode / goose / qwen / claude). + // Idempotent — a double-Stop re-aborts harmlessly; a native boocode task is not + // registered, so this returns false and the inference.cancel path below handles + // it unchanged. The dispatcher's run-function finalizes the streaming assistant + // message as 'cancelled' once the backend honors the signal. + cancelExternal(taskId); + + // If running, try to cancel inference (native boocode path — unchanged). if ((task.state === 'running' || task.state === 'blocked') && task.session_id) { // Find active chat in the task's session const chats = await sql<{ id: string }[]>` diff --git a/apps/coder/src/services/__tests__/cancel-registry.test.ts b/apps/coder/src/services/__tests__/cancel-registry.test.ts new file mode 100644 index 0000000..39b4a90 --- /dev/null +++ b/apps/coder/src/services/__tests__/cancel-registry.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; +import { createCancelRegistry } from '../cancel-registry.js'; + +/** + * F1 — per-task abort wiring. The registry is the missing link between the Stop + * route and the in-flight external run: register an AbortController per task id, + * cancel(taskId) aborts its signal, the run's .finally deletes it. Pure (no DB / + * child / IO) so the abort + idempotency contract is unit-testable in isolation. + */ +describe('CancelRegistry (F1 abort wiring)', () => { + it('register hands back a fresh controller; cancel aborts its signal', () => { + const reg = createCancelRegistry(); + const ac = reg.register('t1'); + expect(ac.signal.aborted).toBe(false); + expect(reg.has('t1')).toBe(true); + + expect(reg.cancel('t1')).toBe(true); + expect(ac.signal.aborted).toBe(true); + }); + + it('cancel on an unknown task returns false (native task / cancel-before-register)', () => { + const reg = createCancelRegistry(); + expect(reg.has('nope')).toBe(false); + expect(reg.cancel('nope')).toBe(false); + }); + + it('double-Stop is idempotent: a second cancel never throws and the signal stays aborted', () => { + const reg = createCancelRegistry(); + const ac = reg.register('t1'); + + expect(reg.cancel('t1')).toBe(true); + // The run-function has not hit its .finally yet, so the entry is still + // present — a rapid second Stop re-aborts (abort() no-ops) without throwing. + expect(() => reg.cancel('t1')).not.toThrow(); + expect(reg.cancel('t1')).toBe(true); + expect(ac.signal.aborted).toBe(true); + }); + + it('cancel after delete returns false (cancel-after-natural-exit is safe)', () => { + const reg = createCancelRegistry(); + reg.register('t1'); + reg.delete('t1'); + expect(reg.has('t1')).toBe(false); + expect(reg.cancel('t1')).toBe(false); + }); + + it('delete of an unknown id is a no-op (never throws)', () => { + const reg = createCancelRegistry(); + expect(() => reg.delete('ghost')).not.toThrow(); + }); +}); diff --git a/apps/coder/src/services/__tests__/finalize-message.test.ts b/apps/coder/src/services/__tests__/finalize-message.test.ts new file mode 100644 index 0000000..9a0ef16 --- /dev/null +++ b/apps/coder/src/services/__tests__/finalize-message.test.ts @@ -0,0 +1,163 @@ +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; + 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 { + 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); + }); +}); diff --git a/apps/coder/src/services/cancel-registry.ts b/apps/coder/src/services/cancel-registry.ts new file mode 100644 index 0000000..e95664d --- /dev/null +++ b/apps/coder/src/services/cancel-registry.ts @@ -0,0 +1,50 @@ +/** + * F1 — per-task abort registry. A Stop on an external-agent task must reach the + * in-flight run and abort its child / prompt. Each external run-function registers + * its per-turn AbortController here keyed by task id; the cancel route calls + * `cancel(taskId)` to fire it; the run-function's `.finally` deletes the entry. + * + * Idempotent by construction: + * - `cancel()` on an already-aborted controller no-ops (AbortController.abort() + * is idempotent) → a rapid double-Stop is safe. + * - `cancel()` on an unknown / already-finished task returns false → a + * cancel-after-natural-exit (entry already deleted) and a Stop on a native + * boocode task (never registered) are both safe no-ops. + * + * Pure (no DB / child / IO) so the abort wiring + idempotency contract is + * unit-testable in isolation — mirrors the turn-guard / lifecycle-decisions + * pure-helper precedent. + */ +export interface CancelRegistry { + /** Create + store an AbortController for this task, returning it for the run. */ + register(taskId: string): AbortController; + /** Abort the task's in-flight run. Returns false when no controller is registered. */ + cancel(taskId: string): boolean; + /** Drop the task's entry (called from the run's `.finally`). No-op if absent. */ + delete(taskId: string): void; + /** Whether a controller is currently registered for this task. */ + has(taskId: string): boolean; +} + +export function createCancelRegistry(): CancelRegistry { + const controllers = new Map(); + return { + register(taskId) { + const ac = new AbortController(); + controllers.set(taskId, ac); + return ac; + }, + cancel(taskId) { + const ac = controllers.get(taskId); + if (!ac) return false; + ac.abort(); + return true; + }, + delete(taskId) { + controllers.delete(taskId); + }, + has(taskId) { + return controllers.has(taskId); + }, + }; +} diff --git a/apps/coder/src/services/dispatcher.ts b/apps/coder/src/services/dispatcher.ts index dfe28a0..c3764dc 100644 --- a/apps/coder/src/services/dispatcher.ts +++ b/apps/coder/src/services/dispatcher.ts @@ -22,6 +22,12 @@ import { shouldUseClaudeSdk } from './backends/claude-sdk-routing.js'; import type { AgentBackend, AgentEvent } from './agent-backend.js'; import { publishAgentStatus } from './agent-status-publish.js'; import type { AgentStatus } from './normalize-agent-status.js'; +import { createCancelRegistry } from './cancel-registry.js'; +import { + finalizeStreamingMessage, + classifyTerminalStatus, + type TerminalMessageStatus, +} from './finalize-message.js'; interface InferenceRunner { enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void; @@ -43,7 +49,11 @@ interface Deps { const POLL_INTERVAL_MS = 2_000; const COMPLETION_POLL_MS = 2_000; -export function createDispatcher(deps: Deps): { start(): void; stop(): Promise } { +export function createDispatcher(deps: Deps): { + cancelExternalTask(taskId: string): boolean; + start(): void; + stop(): Promise; +} { const { sql, inference, broker, log, config } = deps; let timer: ReturnType | null = null; let listener: { unlisten: () => Promise } | null = null; @@ -55,6 +65,13 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise>(); + // F1: per-task abort registry. Each external run-function registers its per-turn + // AbortController here (keyed by task id); the cancel route reaches it through the + // exported `cancelExternalTask`; the run's `.finally` deletes the entry. Native + // boocode tasks are never registered, so a Stop on one returns false and falls + // through to the unchanged inference.cancel path. + const taskControllers = createCancelRegistry(); + // Shared entry point for both the poll timer and the NOTIFY listener. poll()'s // `polling`/`stopping` guard makes this safe to call concurrently — a notify // arriving mid-poll returns immediately and never double-dispatches. @@ -83,6 +100,40 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise { + return finalizeStreamingMessage(sql, broker.publishFrame, { + sessionId, + chatId, + assistantId, + status, + model, + content, + }).catch((err) => { + log.error({ err: err instanceof Error ? err.message : String(err), assistantId }, 'dispatcher: finalizeStreamingMessage failed'); + return false; + }); + } + + // F1: the cancel route's reach into an in-flight external run. Idempotent — a + // double-Stop re-aborts an already-aborted controller (no-op) and a Stop on a + // finished/native task returns false. Aborting only fires the backend's per-turn + // cancel (session.abort / session/cancel / interrupt / child.kill); it never kills + // a warm pool process, so persistent worktrees + pooled backends are preserved. + function cancelExternalTask(taskId: string): boolean { + return taskControllers.cancel(taskId); + } + async function poll(): Promise { // `polling` serializes poll() execution itself (timer + NOTIFY can fire // concurrently) so we never double-select a task. It does NOT serialize task @@ -116,6 +167,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise { inflight.delete(key); + // F1: drop the abort controller once the run settles. After this, a Stop + // on the (now-finished) task returns false — cancel-after-exit is safe. + taskControllers.delete(task.id); }); inflight.set(key, p); } @@ -312,13 +366,16 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise {}); + // F1 (OCE-001): finalize the streaming assistant message — the catch + // previously updated only `tasks` and left the message 'streaming' forever + // (the BooChat 5-min sweep runs in a different process and can't reach it). + await finalizeMessage(sessionId, chatId, assistantId, status, task.model); + // #10: external-agent turn failed/crashed. chatId may be unbound if the throw // preceded its assignment — guard so the status publish never masks the real // error. - if (chatId) emitAgentStatus(sessionId, chatId, agent, 'error', 'failed'); + if (chatId) emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'failed'); // Best-effort cleanup await cleanupWorktree(projectPath, taskId); @@ -652,11 +723,14 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise {}); + // F1 (OCE-001): finalize the streaming message (was left 'streaming'). + await finalizeMessage(sessionId, chatId, assistantId, status, task.model); // #10: turn crashed. - if (chatId) emitAgentStatus(sessionId, chatId, agent, 'error', 'crashed'); + if (chatId) emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed'); clearTaskCommands(taskId); // No worktree cleanup (persistent); backend stays warm for the next turn. } @@ -988,7 +1072,10 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise {}); + // F1 (OCE-001): finalize the streaming message (was left 'streaming'). + await finalizeMessage(sessionId, chatId, assistantId, status, task.model); // #10: turn crashed. - emitAgentStatus(sessionId, chatId, agent, 'error', 'crashed'); + emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed'); clearTaskCommands(taskId); // No worktree cleanup (persistent); backend stays warm for the next turn. } @@ -1245,7 +1342,10 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise {}); + // F1 (OCE-001): finalize the streaming message (was left 'streaming'). + await finalizeMessage(sessionId, chatId, assistantId, status, task.model); // #10: turn crashed. - emitAgentStatus(sessionId, chatId, agent, 'error', 'crashed'); + emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed'); clearTaskCommands(taskId); // No worktree cleanup (persistent); backend stays warm for the next turn. } @@ -1476,6 +1586,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise void, + opts: { + sessionId: string; + chatId: string; + assistantId: string; + status: TerminalMessageStatus; + model: string | null; + /** Partial accumulated text to persist; omit to leave the row's content untouched. */ + content?: string; + }, +): Promise { + const { sessionId, chatId, assistantId, status, model, content } = opts; + if (!assistantId) return false; + + const rows = + content !== undefined + ? await sql<{ id: string }[]>` + UPDATE messages + SET content = ${content}, status = ${status}, finished_at = clock_timestamp() + WHERE id = ${assistantId} AND status = 'streaming' + RETURNING id + ` + : await sql<{ id: string }[]>` + UPDATE messages + SET status = ${status}, finished_at = clock_timestamp() + WHERE id = ${assistantId} AND status = 'streaming' + RETURNING id + `; + + if (rows.length === 0) return false; + + publishFrame(sessionId, { + type: 'message_complete', + message_id: assistantId, + chat_id: chatId, + model, + status, + } as WsFrame); + return true; +} diff --git a/apps/coder/src/services/mcp-server.ts b/apps/coder/src/services/mcp-server.ts index fd23226..4ca361a 100644 --- a/apps/coder/src/services/mcp-server.ts +++ b/apps/coder/src/services/mcp-server.ts @@ -29,6 +29,17 @@ interface ProjectPathRow { path: string; } +interface MessageRow { + id: string; + session_id: string; + chat_id: string | null; + role: string; + content: string; + status: string; + model: string | null; + created_at: Date; +} + function textResult(data: unknown) { return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; } @@ -189,6 +200,56 @@ export async function startMcpServer(sql: Sql): Promise { }, ); + // 6. boocoder.view_session_history + server.tool( + 'boocoder.view_session_history', + 'Retrieve the most-recent N messages of a session chat transcript (role != system) from messages_with_parts, returned in chronological (oldest→newest) order', + { + session_id: z.string().describe('Session UUID'), + chat_id: z.string().optional().describe('Optional chat UUID — narrows to one chat tab'), + limit: z + .number() + .int() + .min(1) + .max(200) + .optional() + .describe('Max messages to return (default 50, max 200)'), + }, + async (args) => { + const effectiveLimit = Math.min(args.limit ?? 50, 200); + let rows: MessageRow[]; + if (args.chat_id) { + rows = await sql` + SELECT id, session_id, chat_id, role, content, status, model, created_at + FROM ( + SELECT id, session_id, chat_id, role, content, status, model, created_at + FROM messages_with_parts + WHERE session_id = ${args.session_id} + AND chat_id = ${args.chat_id} + AND role != 'system' + ORDER BY created_at DESC + LIMIT ${effectiveLimit} + ) sub + ORDER BY created_at ASC + `; + } else { + rows = await sql` + SELECT id, session_id, chat_id, role, content, status, model, created_at + FROM ( + SELECT id, session_id, chat_id, role, content, status, model, created_at + FROM messages_with_parts + WHERE session_id = ${args.session_id} + AND role != 'system' + ORDER BY created_at DESC + LIMIT ${effectiveLimit} + ) sub + ORDER BY created_at ASC + `; + } + return textResult({ session_id: args.session_id, count: rows.length, messages: rows }); + }, + ); + // Connect via stdio const transport = new StdioServerTransport(); await server.connect(transport); diff --git a/apps/coder/web/index.html b/apps/coder/web/index.html deleted file mode 100644 index 79cab15..0000000 --- a/apps/coder/web/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - BooCoder - - -
- - - diff --git a/apps/coder/web/package.json b/apps/coder/web/package.json deleted file mode 100644 index 46b344a..0000000 --- a/apps/coder/web/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@boocode/coder-web", - "version": "2.0.0", - "private": true, - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc -b && vite build", - "typecheck": "tsc -b --noEmit", - "preview": "vite preview" - }, - "dependencies": { - "@boocode/contracts": "workspace:*", - "lucide-react": "^1.16.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-markdown": "^10.1.0", - "react-router-dom": "^6.26.0", - "remark-gfm": "^4.0.1" - }, - "devDependencies": { - "@tailwindcss/postcss": "^4.3.0", - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", - "@vitejs/plugin-react": "^4.3.1", - "tailwindcss": "^4.3.0", - "typescript": "^5.5.0", - "vite": "^5.3.4" - } -} diff --git a/apps/coder/web/postcss.config.js b/apps/coder/web/postcss.config.js deleted file mode 100644 index a34a3d5..0000000 --- a/apps/coder/web/postcss.config.js +++ /dev/null @@ -1,5 +0,0 @@ -export default { - plugins: { - '@tailwindcss/postcss': {}, - }, -}; diff --git a/apps/coder/web/src/App.tsx b/apps/coder/web/src/App.tsx deleted file mode 100644 index c5382a4..0000000 --- a/apps/coder/web/src/App.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { Routes, Route, Navigate } from 'react-router-dom'; -import { Home } from './pages/Home'; -import { Session } from './pages/Session'; - -export function App() { - return ( - - } /> - } /> - } /> - - ); -} diff --git a/apps/coder/web/src/api/client.ts b/apps/coder/web/src/api/client.ts deleted file mode 100644 index 43c7b2e..0000000 --- a/apps/coder/web/src/api/client.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { Project, Session, Chat, Message, PendingChange, AskUserAnswer } from './types'; - -export class ApiError extends Error { - constructor( - public status: number, - public body: unknown, - ) { - super( - typeof body === 'object' && body && 'error' in body - ? String((body as { error: unknown }).error) - : `HTTP ${status}`, - ); - } -} - -async function request(path: string, init: RequestInit = {}): Promise { - const res = await fetch(path, { - ...init, - headers: { - 'Content-Type': 'application/json', - ...(init.headers ?? {}), - }, - }); - if (res.status === 204) return undefined as T; - const text = await res.text(); - const data = text ? JSON.parse(text) : undefined; - if (!res.ok) throw new ApiError(res.status, data); - return data as T; -} - -export const api = { - health: () => request<{ ok: boolean; db: boolean; tools: number }>('/api/health'), - - projects: { - list: (params?: { status?: 'open' | 'archived' }) => - request(`/api/projects${params?.status ? `?status=${params.status}` : ''}`), - }, - - sessions: { - listForProject: (projectId: string, status?: 'open' | 'archived') => - request( - `/api/projects/${projectId}/sessions${status ? `?status=${status}` : ''}`, - ), - get: (id: string) => request(`/api/sessions/${id}`), - }, - - chats: { - listForSession: (sessionId: string) => - request(`/api/sessions/${sessionId}/chats`), - create: (sessionId: string, body?: { name?: string }) => - request(`/api/sessions/${sessionId}/chats`, { - method: 'POST', - body: JSON.stringify(body ?? {}), - }), - answerUserInput: (chatId: string, toolCallId: string, answers: AskUserAnswer[]) => - request<{ tool_message_id: string; assistant_message_id: string }>( - `/api/chats/${chatId}/answer_user_input`, - { - method: 'POST', - body: JSON.stringify({ tool_call_id: toolCallId, answers }), - }, - ), - }, - - messages: { - send: (sessionId: string, chatId: string, content: string) => - request<{ user_message_id: string; assistant_message_id: string }>( - `/api/sessions/${sessionId}/messages`, - { - method: 'POST', - body: JSON.stringify({ content, chat_id: chatId }), - }, - ), - stop: (sessionId: string) => - request<{ cancelled: boolean }>(`/api/sessions/${sessionId}/stop`, { - method: 'POST', - }), - }, - - pending: { - list: (sessionId: string) => - request(`/api/sessions/${sessionId}/pending`), - applyAll: (sessionId: string) => - request<{ results: Array<{ id: string; success: boolean; error?: string }> }>( - `/api/sessions/${sessionId}/pending/apply`, - { method: 'POST' }, - ), - applyOne: (changeId: string) => - request<{ success: boolean; error?: string }>(`/api/pending/${changeId}/apply`, { - method: 'POST', - }), - rejectOne: (changeId: string) => - request<{ ok: boolean }>(`/api/pending/${changeId}/reject`, { - method: 'POST', - }), - rewindOne: (changeId: string) => - request<{ success: boolean; error?: string }>(`/api/pending/${changeId}/rewind`, { - method: 'POST', - }), - }, -}; diff --git a/apps/coder/web/src/api/types.ts b/apps/coder/web/src/api/types.ts deleted file mode 100644 index 79acf91..0000000 --- a/apps/coder/web/src/api/types.ts +++ /dev/null @@ -1,117 +0,0 @@ -// Minimal types for the BooCoder frontend. -// Shared DB entities (same schema as BooChat). -// -// WS wire contracts are single-sourced from @boocode/contracts (the canonical -// Zod-backed schema). The DB entity types below (Project/Session/Chat/Message/ -// ToolCall/ToolResult/PendingChange) are an intentional minimal SPA-local subset -// and are NOT cross-app contracts — they stay defined here. - -import type { WsFrame } from '@boocode/contracts/ws-frames'; - -// Re-export the canonical WebSocket frame union (single source of truth). The -// coder backend publishes the full frame set; this SPA's reducer handles the -// subset it renders and ignores the rest. -export type { WsFrame }; - -// The error frame's `reason`, single-sourced from the canonical schema's -// frame-level reason enum (derived from WsFrame so it cannot drift from the -// wire). Distinct from message-metadata's ErrorReason, which is a different set. -export type ErrorReason = NonNullable['reason']>; - -export interface Project { - id: string; - name: string; - path: string; - status: 'open' | 'archived'; - created_at: string; - updated_at: string; -} - -export interface Session { - id: string; - project_id: string; - name: string | null; - model: string | null; - status: 'open' | 'archived'; - created_at: string; - updated_at: string; -} - -export interface Chat { - id: string; - session_id: string; - name: string | null; - status: 'open' | 'archived'; - created_at: string; - updated_at: string; -} - -export interface ToolCall { - id: string; - name: string; - args: unknown; -} - -export interface ToolResult { - tool_call_id: string; - output: unknown; - truncated?: boolean; - // Canonical wire shape: the failure message string (present only on error), - // not a boolean. ToolResultBubble treats it as truthy → renders error styling. - error?: string; -} - -// Batch 9.7: ask_user_input shapes. The tool_call.args is { questions: AskUserQuestion[] } -// (1-3 entries); the eventual tool_result.output is { answers: AskUserAnswer[] } in the -// same order. AskUserInputCard renders questions and POSTs answers. -export type AskUserQuestionType = 'single_select' | 'multi_select'; - -export interface AskUserQuestion { - question: string; - type: AskUserQuestionType; - options: string[]; -} - -export interface AskUserAnswer { - question: string; - selected_options: string[]; - free_text: string | null; -} - -export interface AskUserAnswerSet { - answers: AskUserAnswer[]; -} - -export interface Message { - id: string; - session_id: string; - chat_id: string; - role: 'user' | 'assistant' | 'tool' | 'system'; - content: string; - kind: string; - tool_calls: ToolCall[] | null; - tool_results: ToolResult | null; - status: 'streaming' | 'complete' | 'failed' | 'cancelled'; - tokens_used: number | null; - ctx_used: number | null; - ctx_max: number | null; - started_at: string | null; - finished_at: string | null; - created_at: string; - metadata: unknown; -} - -export interface PendingChange { - id: string; - session_id: string; - task_id: string | null; - file_path: string; - operation: 'create' | 'edit' | 'delete'; - old_string: string | null; - new_string: string | null; - content: string | null; - diff: string | null; - status: 'pending' | 'applied' | 'rejected' | 'reverted'; - created_at: string; - applied_at: string | null; -} diff --git a/apps/coder/web/src/components/AskUserInputCard.tsx b/apps/coder/web/src/components/AskUserInputCard.tsx deleted file mode 100644 index 0fac253..0000000 --- a/apps/coder/web/src/components/AskUserInputCard.tsx +++ /dev/null @@ -1,323 +0,0 @@ -import { useMemo, useState } from 'react'; -import { Check } from 'lucide-react'; -import { api } from '@/api/client'; -import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; -import { Button } from '@/components/ui/button'; -import type { - AskUserAnswer, - AskUserAnswerSet, - AskUserQuestion, - ToolCall, - ToolResult, -} from '@/api/types'; - -// Batch 9.7: Inline interactive picker. Renders inside MessageList in place of -// the standard ToolCallLine when the assistant emits an ask_user_input tool -// call. While the tool result is null (server pre-stamps a sentinel with -// output=null), shows the form; once the WS tool_result frame arrives with a -// real AnswerSet, flips to read-only review mode. - -interface Props { - toolCall: ToolCall; - toolResult: ToolResult | null; - chatId: string; -} - -function parseQuestions(raw: unknown): AskUserQuestion[] { - if (!raw || typeof raw !== 'object' || !('questions' in raw)) return []; - const arr = (raw as { questions: unknown }).questions; - if (!Array.isArray(arr)) return []; - const out: AskUserQuestion[] = []; - for (const item of arr) { - if (!item || typeof item !== 'object') continue; - const q = item as { question?: unknown; type?: unknown; options?: unknown }; - if (typeof q.question !== 'string') continue; - if (q.type !== 'single_select' && q.type !== 'multi_select') continue; - if (!Array.isArray(q.options)) continue; - const opts = q.options.filter((o): o is string => typeof o === 'string'); - if (opts.length < 2) continue; - out.push({ question: q.question, type: q.type, options: opts }); - } - return out; -} - -function parseAnswerSet(raw: unknown): AskUserAnswerSet | null { - if (!raw || typeof raw !== 'object' || !('answers' in raw)) return null; - const arr = (raw as { answers: unknown }).answers; - if (!Array.isArray(arr)) return null; - const answers: AskUserAnswer[] = []; - for (const item of arr) { - if (!item || typeof item !== 'object') continue; - const a = item as { question?: unknown; selected_options?: unknown; free_text?: unknown }; - if (typeof a.question !== 'string') continue; - if (!Array.isArray(a.selected_options)) continue; - if (a.free_text !== null && typeof a.free_text !== 'string') continue; - const sel = a.selected_options.filter((s): s is string => typeof s === 'string'); - answers.push({ - question: a.question, - selected_options: sel, - free_text: (a.free_text as string | null) ?? null, - }); - } - return { answers }; -} - -export function AskUserInputCard({ toolCall, toolResult, chatId }: Props) { - const questions = useMemo(() => parseQuestions(toolCall.args), [toolCall.args]); - - if (questions.length === 0) { - return ( -
- ask_user_input: malformed tool args -
- ); - } - - // Tool result with a non-null output means the answer is already submitted. - // The pending sentinel uses output=null, so this branch only triggers after - // the real WS tool_result frame lands. - const answered = toolResult && toolResult.output !== null; - if (answered) { - const answerSet = parseAnswerSet(toolResult!.output); - return ; - } - - return ( - - ); -} - -function PendingView({ - questions, - toolCallId, - chatId, -}: { - questions: AskUserQuestion[]; - toolCallId: string; - chatId: string; -}) { - // Per-question selections + free text. Selections are option arrays so the - // multi_select case is uniform; single_select just constrains to length 1. - const [selections, setSelections] = useState(() => questions.map(() => [])); - const [freeTexts, setFreeTexts] = useState(() => questions.map(() => '')); - const [submitting, setSubmitting] = useState(false); - - const singleQuestion = questions.length === 1; - const anyFreeText = freeTexts.some((t) => t.trim().length > 0); - - // Submit button shows when: - // - more than one question (always batched), OR - // - one question and the user has typed free text (committing it needs an - // explicit Submit so an accidental Tab/click doesn't lose it). - // For one question with no free text, clicking an option submits inline. - const showSubmitButton = !singleQuestion || anyFreeText; - - // Every question must have at least one of (option, free text). - const allComplete = questions.every((_, i) => { - return selections[i]!.length > 0 || freeTexts[i]!.trim().length > 0; - }); - - function buildAnswers(): AskUserAnswer[] { - return questions.map((q, i) => { - const freeText = freeTexts[i]!.trim(); - return { - question: q.question, - selected_options: selections[i]!, - free_text: freeText.length > 0 ? freeText : null, - }; - }); - } - - async function submit(answers: AskUserAnswer[]) { - if (submitting) return; - setSubmitting(true); - try { - await api.chats.answerUserInput(chatId, toolCallId, answers); - // Card stays mounted; the incoming WS tool_result frame will flip it - // into AnsweredView via the parent prop change. - } catch (err) { - console.error('ask_user_input submit failed:', err instanceof Error ? err.message : err); - setSubmitting(false); - } - } - - function pickSingle(qIdx: number, option: string) { - setSelections((prev) => prev.map((arr, i) => (i === qIdx ? [option] : arr))); - // Immediate submit for the single-question single-select shortcut. Only - // fires when no free text exists anywhere — once the user typed, the - // Submit button takes over so the typed text isn't silently dropped. - if (singleQuestion && !anyFreeText) { - const answers: AskUserAnswer[] = [ - { - question: questions[0]!.question, - selected_options: [option], - free_text: null, - }, - ]; - void submit(answers); - } - } - - function toggleMulti(qIdx: number, option: string) { - setSelections((prev) => - prev.map((arr, i) => { - if (i !== qIdx) return arr; - return arr.includes(option) ? arr.filter((o) => o !== option) : [...arr, option]; - }), - ); - } - - function setFreeText(qIdx: number, value: string) { - setFreeTexts((prev) => prev.map((t, i) => (i === qIdx ? value : t))); - } - - return ( -
-
- {questions.map((q, i) => ( -
- {questions.length > 1 && ( -
- Question {i + 1} -
- )} -
{q.question}
- {q.type === 'single_select' ? ( - pickSingle(i, v)} - disabled={submitting} - className="gap-1.5" - > - {q.options.map((opt, j) => { - const id = `q${i}-opt${j}`; - return ( - - ); - })} - - ) : ( -
- {q.options.map((opt, j) => { - const id = `q${i}-opt${j}`; - const checked = selections[i]!.includes(opt); - return ( - - ); - })} -
- )} -
-
- Or type a custom answer -
- setFreeText(i, e.target.value)} - className="w-full rounded border border-input bg-background px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:opacity-60" - /> -
-
- ))} -
- {showSubmitButton && ( -
- -
- )} -
- ); -} - -function AnsweredView({ - questions, - answers, -}: { - questions: AskUserQuestion[]; - answers: AskUserAnswerSet | null; -}) { - if (!answers) { - return ( -
- ask_user_input: answers unavailable -
- ); - } - - return ( -
-
- {questions.map((q, i) => { - const a = answers.answers[i]; - if (!a) return null; - return ( -
- {questions.length > 1 && ( -
- Question {i + 1} -
- )} -
{q.question}
-
- {q.options.map((opt, j) => { - const selected = a.selected_options.includes(opt); - return ( -
- - {selected && } - - {opt} -
- ); - })} -
- {a.free_text && ( -
- {a.free_text} -
- )} -
- ); - })} -
-
- ); -} diff --git a/apps/coder/web/src/components/ChatPane.tsx b/apps/coder/web/src/components/ChatPane.tsx deleted file mode 100644 index 7f4f003..0000000 --- a/apps/coder/web/src/components/ChatPane.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { useState, useRef, useEffect } from 'react'; -import { Send, Square } from 'lucide-react'; -import type { Message, ToolResult } from '@/api/types'; -import { api } from '@/api/client'; -import { MessageBubble } from './MessageBubble'; - -interface Props { - sessionId: string; - chatId: string; - messages: Message[]; - isStreaming: boolean; - connected: boolean; -} - -export function ChatPane({ sessionId, chatId, messages, isStreaming, connected }: Props) { - const [input, setInput] = useState(''); - const [sending, setSending] = useState(false); - const messagesEndRef = useRef(null); - const textareaRef = useRef(null); - - // Auto-scroll to bottom when messages change - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [messages]); - - // Auto-resize textarea - useEffect(() => { - const el = textareaRef.current; - if (!el) return; - el.style.height = 'auto'; - el.style.height = Math.min(el.scrollHeight, 200) + 'px'; - }, [input]); - - const handleSend = async () => { - const content = input.trim(); - if (!content || sending || isStreaming) return; - - setInput(''); - setSending(true); - try { - await api.messages.send(sessionId, chatId, content); - } catch (err) { - console.error('send failed:', err); - // Restore input on failure - setInput(content); - } finally { - setSending(false); - } - }; - - const handleStop = async () => { - try { - await api.messages.stop(sessionId); - } catch (err) { - console.error('stop failed:', err); - } - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSend(); - } - }; - - // Filter out system messages for display (sentinels) - const visibleMessages = messages.filter((m) => m.role !== 'system'); - - // Build a lookup map from tool_call_id -> ToolResult for all messages - const toolResultsMap: Record = {}; - for (const msg of messages) { - if (msg.tool_results) { - toolResultsMap[msg.tool_results.tool_call_id] = msg.tool_results; - } - } - - return ( -
- {/* Connection indicator */} -
-
- {connected ? 'Connected' : 'Disconnected'} - {isStreaming && ( - Generating... - )} -
- - {/* Messages list */} -
- {visibleMessages.length === 0 && ( -
-

BooCoder

-

Send a message to start coding.

-
- )} - {visibleMessages.map((msg) => ( - - ))} -
-
- - {/* Input area */} -
-
-