import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import type { Sql } from '../db.js'; import type { Config } from '../config.js'; import type { Broker } from '../services/broker.js'; import type { Chat, Message, Session, ToolCall } from '../types/api.js'; // v1.13.17-cross-repo-reads: grant_read_access resolves the grant root at // decision time (not at request time) so concurrent project changes don't // stale-bind the resolution. import { resolveGrantRoot } from '../services/grant_resolver.js'; const SendBody = z.object({ content: z.string().min(1).max(64_000), }); // v1.8.2: Continue extends an inference loop that hit the tool budget. Caller // passes the sentinel message it's continuing from; server validates shape // and the per-chat hard ceiling before resuming. const ContinueBody = z.object({ sentinel_message_id: z.string().uuid(), }); // Batch 9.7: ask_user_input answer submission. Defensive shape — the question // content is echoed back for traceability but the server does NOT trust it // (the source of truth is the assistant message's tool_calls.args.questions). const AnswerUserInputBody = z.object({ tool_call_id: z.string().min(1), answers: z .array( z.object({ question: z.string(), selected_options: z.array(z.string()), free_text: z.string().nullable(), }), ) .min(1) .max(3), }); // Same shape the model declared via the tool's zod input. Re-derived here so // the route can validate args without depending on services/tools.ts (which // would pull in fs/path_guard for nothing). const AskUserInputArgs = z.object({ questions: z .array( z.object({ question: z.string(), type: z.enum(['single_select', 'multi_select']), options: z.array(z.string()).min(1), }), ) .min(1) .max(3), }); // v1.13.17-cross-repo-reads: grant decision body. tool_call_id is the // model-emitted id (e.g. "call_abc123"), not a UUID. decision is binary. const GrantReadAccessBody = z.object({ tool_call_id: z.string().min(1), decision: z.enum(['allow', 'deny']), }); // Same shape as services/request_read_access.ts RequestReadAccessInput. // Re-derived to avoid the services/tools.ts import (matches the // AskUserInputArgs pattern above). const RequestReadAccessArgs = z.object({ path: z.string().min(1), reason: z.string().min(1).max(500), }); interface MessageHandlers { enqueueInference: (sessionId: string, chatId: string, assistantMessageId: string, user: string) => void; // v1.11: returns a promise that resolves after compaction.process finishes // (await the LLM call). Throws on failure — the route surfaces a 500. // Replaces the v1.10 enqueueCompact (which fired-and-forgot a kind='compact' // streaming row). The new anchored-rolling strategy inserts a single // summary=true assistant row only after the LLM responds. runCompaction: (chatId: string) => Promise; publishUserMessage: ( sessionId: string, chatId: string, userMessageId: string, content: string ) => void; publishMessagesDeleted: (sessionId: string, chatId: string, messageIds: string[]) => void; // Batch 9.7: lets the answer endpoint emit the tool_result frame that the // pause path intentionally skipped. Matches SkillInvokeHandlers in // routes/skills.ts so index.ts can pass the same broker.publish adapter. publishSessionFrame: ( sessionId: string, frame: Record & { type: string } ) => void; cancelInference: (sessionId: string, chatId: string) => Promise; hasActiveInference: (chatId: string) => boolean; } export function registerMessageRoutes( app: FastifyInstance, sql: Sql, config: Config, broker: Broker, handlers: MessageHandlers ): void { app.get<{ Params: { id: string } }>( '/api/sessions/:id/messages', async (req, reply) => { const session = await sql`SELECT id FROM sessions WHERE id = ${req.params.id}`; if (session.length === 0) { reply.code(404); return { error: 'session not found' }; } // v1.11: returns ALL messages including compacted ones. The UI // distinguishes via the new `summary` flag (renders an accordion // SummaryCard) and shows compacted_at-stamped rows inline for context. // Internal inference assembly filters compacted_at IS NULL separately — // see services/inference.ts loadContext + services/compaction.ts. // v1.13.1-B: reads tool_calls/tool_results via the parts-merged view. const rows = await sql` SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq, tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata, summary, tail_start_id, compacted_at, model FROM messages_with_parts WHERE session_id = ${req.params.id} ORDER BY created_at ASC, id ASC `; return rows; } ); app.post<{ Params: { id: string } }>( '/api/chats/:id/messages', async (req, reply) => { const parsed = SendBody.safeParse(req.body); if (!parsed.success) { reply.code(400); return { error: 'invalid body', details: parsed.error.flatten() }; } const chatRows = await sql` SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open' `; if (chatRows.length === 0) { reply.code(404); return { error: 'chat not found' }; } const chat = chatRows[0]!; const sessionId = chat.session_id; const result = await sql.begin(async (tx) => { const [userMsg] = await tx<{ id: string }[]>` INSERT INTO messages (session_id, chat_id, role, content, status, created_at) VALUES (${sessionId}, ${chat.id}, 'user', ${parsed.data.content}, 'complete', clock_timestamp()) RETURNING id `; const [assistantMsg] = await tx<{ id: string }[]>` INSERT INTO messages (session_id, chat_id, role, content, status, created_at) VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp()) RETURNING id `; await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`; await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`; return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id }; }); handlers.publishUserMessage( sessionId, chat.id, result.user_message_id, parsed.data.content ); handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default'); reply.code(202); return result; } ); app.post<{ Params: { id: string; message_id: string } }>( '/api/chats/:id/messages/:message_id/regenerate', async (req, reply) => { const { id: chatId, message_id: targetId } = req.params; const chatRows = await sql` SELECT id, session_id FROM chats WHERE id = ${chatId} `; if (chatRows.length === 0) { reply.code(404); return { error: 'chat not found' }; } const chat = chatRows[0]!; const sessionId = chat.session_id; const target = await sql<{ id: string; role: string; status: string }[]>` SELECT id, role, status FROM messages WHERE chat_id = ${chatId} AND id = ${targetId} `; if (target.length === 0) { reply.code(404); return { error: 'message not found' }; } const targetRow = target[0]!; if (targetRow.role !== 'assistant') { reply.code(400); return { error: 'only assistant messages can be regenerated' }; } if (targetRow.status === 'streaming') { reply.code(409); return { error: 'message is still streaming' }; } const { newAssistantId, deletedIds } = await sql.begin(async (tx) => { const deletedRows = await tx<{ id: string }[]>` DELETE FROM messages WHERE chat_id = ${chatId} AND created_at >= ( SELECT created_at FROM messages WHERE id = ${targetId} ) RETURNING id `; const [row] = await tx<{ id: string }[]>` INSERT INTO messages (session_id, chat_id, role, content, status, created_at) VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp()) RETURNING id `; await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`; await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`; return { newAssistantId: row!.id, deletedIds: deletedRows.map((r) => r.id), }; }); handlers.publishMessagesDeleted(sessionId, chatId, deletedIds); handlers.enqueueInference(sessionId, chatId, newAssistantId, 'default'); reply.code(202); return { assistant_message_id: newAssistantId }; } ); app.delete<{ Params: { id: string; message_id: string } }>( '/api/chats/:id/messages/:message_id', async (req, reply) => { const { id: chatId, message_id: messageId } = req.params; const chatRows = await sql` SELECT id, session_id FROM chats WHERE id = ${chatId} `; if (chatRows.length === 0) { reply.code(404); return { error: 'chat not found' }; } const chat = chatRows[0]!; if (handlers.hasActiveInference(chatId)) { reply.code(409); return { error: 'chat is currently streaming; stop it first' }; } const deletedIds = await sql.begin(async (tx) => { const deletedRows = await tx<{ id: string }[]>` DELETE FROM messages WHERE chat_id = ${chatId} AND created_at >= ( SELECT created_at FROM messages WHERE id = ${messageId} AND chat_id = ${chatId} ) RETURNING id `; if (deletedRows.length > 0) { await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`; } return deletedRows.map((r) => r.id); }); if (deletedIds.length === 0) { reply.code(404); return { error: 'message not found' }; } handlers.publishMessagesDeleted(chat.session_id, chatId, deletedIds); reply.code(204); return null; } ); // v1.11: manual /compact. Was a streaming kind='compact' row inserted by // this handler; now delegates to the anchored-rolling compaction service. // Synchronous (we await the LLM call) — callers either await or rely on // the 'compacted' WS frame to refresh their view. The response carries // no body of interest; the new summary row arrives via the WS frame. app.post<{ Params: { id: string } }>( '/api/chats/:id/compact', async (req, reply) => { const chatRows = await sql<{ id: string }[]>` SELECT id FROM chats WHERE id = ${req.params.id} AND status = 'open' `; if (chatRows.length === 0) { reply.code(404); return { error: 'chat not found' }; } try { await handlers.runCompaction(chatRows[0]!.id); } catch (err) { req.log.error({ err, chatId: chatRows[0]!.id }, 'manual compaction failed'); reply.code(500); return { error: err instanceof Error ? err.message : 'compaction failed' }; } reply.code(200); return { ok: true }; } ); app.post<{ Params: { id: string } }>( '/api/chats/:id/stop', async (req, reply) => { const chatRows = await sql` SELECT id, session_id FROM chats WHERE id = ${req.params.id} `; if (chatRows.length === 0) { reply.code(404); return { error: 'chat not found' }; } const chat = chatRows[0]!; const cancelled = await handlers.cancelInference(chat.session_id, chat.id); if (!cancelled) { reply.code(409); return { error: 'no active generation to stop' }; } reply.code(200); return { stopped: true }; } ); app.post<{ Params: { id: string } }>( '/api/chats/:id/continue', async (req, reply) => { const parsed = ContinueBody.safeParse(req.body); if (!parsed.success) { reply.code(400); return { error: 'invalid body', details: parsed.error.flatten() }; } const chatRows = await sql` SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open' `; if (chatRows.length === 0) { reply.code(404); return { error: 'chat not found' }; } const chat = chatRows[0]!; const sessionId = chat.session_id; // Cap-hit sentinels are only ever inserted after a turn completes, so // there must not be an active inference at this moment. If there is, // the client is racing the cap-hit summary that just emitted the // sentinel — bail rather than enqueue a parallel run. if (handlers.hasActiveInference(chat.id)) { reply.code(409); return { error: 'chat is currently streaming' }; } const sentinel = await sql<{ metadata: { kind?: unknown; can_continue?: unknown } | null }[]>` SELECT metadata FROM messages WHERE id = ${parsed.data.sentinel_message_id} AND chat_id = ${chat.id} AND role = 'system' `; if (sentinel.length === 0) { reply.code(404); return { error: 'sentinel not found' }; } const meta = sentinel[0]!.metadata; if (!meta || meta.kind !== 'cap_hit') { reply.code(400); return { error: 'message is not a cap-hit sentinel' }; } // Server-side hard ceiling check. UI already disables the button when // can_continue is false; defending against a stale tab or a direct // API hit is the only reason this lives on the server too. if (meta.can_continue !== true) { reply.code(409); return { error: 'hard limit reached for this chat' }; } const result = await sql.begin(async (tx) => { const [assistantMsg] = await tx<{ id: string }[]>` INSERT INTO messages (session_id, chat_id, role, content, status, created_at) VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp()) RETURNING id `; await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`; await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`; return { assistant_message_id: assistantMsg!.id }; }); handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default'); reply.code(202); return result; } ); app.post<{ Params: { id: string } }>( '/api/chats/:id/force_send', async (req, reply) => { const parsed = SendBody.safeParse(req.body); if (!parsed.success) { reply.code(400); return { error: 'invalid body', details: parsed.error.flatten() }; } const chatRows = await sql` SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open' `; if (chatRows.length === 0) { reply.code(404); return { error: 'chat not found' }; } const chat = chatRows[0]!; const sessionId = chat.session_id; // Await actual cancellation completion (catch block persists state). // 5s timeout guards against llama-swap stalls; if hit, proceed anyway. await Promise.race([ handlers.cancelInference(sessionId, chat.id).then(() => undefined), new Promise((_, rej) => setTimeout(() => rej(new Error('cancel-timeout')), 5000) ), ]).catch((e: Error) => { if (e.message !== 'cancel-timeout') throw e; req.log.warn({ chatId: chat.id }, 'cancel timeout exceeded, proceeding with force-send'); }); const result = await sql.begin(async (tx) => { const [userMsg] = await tx<{ id: string }[]>` INSERT INTO messages (session_id, chat_id, role, content, status, created_at) VALUES (${sessionId}, ${chat.id}, 'user', ${parsed.data.content}, 'complete', clock_timestamp()) RETURNING id `; const [assistantMsg] = await tx<{ id: string }[]>` INSERT INTO messages (session_id, chat_id, role, content, status, created_at) VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp()) RETURNING id `; await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`; await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`; return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id }; }); handlers.publishUserMessage( sessionId, chat.id, result.user_message_id, parsed.data.content ); handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default'); reply.code(202); return result; } ); // Batch 9.7: resume an ask_user_input pause. Validates the body matches the // question shape the model declared, UPDATEs the pending tool row's // tool_results to the AnswerSet, publishes the deferred tool_result frame, // and enqueues the next assistant turn. Error codes per spec: // 400 invalid_body / mismatched_answer_shape // 404 chat_not_found / unknown_tool_call_id // 409 tool_call_already_answered app.post<{ Params: { id: string } }>( '/api/chats/:id/answer_user_input', async (req, reply) => { const parsed = AnswerUserInputBody.safeParse(req.body); if (!parsed.success) { reply.code(400); return { error: 'invalid_body', details: parsed.error.flatten() }; } const { tool_call_id, answers } = parsed.data; const chatRows = await sql` SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open' `; if (chatRows.length === 0) { reply.code(404); return { error: 'chat_not_found' }; } const chat = chatRows[0]!; const sessionId = chat.session_id; // v1.13.1-C: find the assistant's tool_call by indexing message_parts // directly on payload->>'id'. Scoped by chat_id + role via the JOIN. // Pre-v1.13.0 history has no parts rows — those tool_calls become // unreachable here (404). Acceptable per the dispatch decision: any // pending elicitation from before v1.13.0 is long timed out by now; // promote to a hotfix with a JSON-column fallback if it ever surfaces. const callerRows = await sql<{ message_id: string; payload: { id: string; name: string; args: Record }; }[]>` SELECT p.message_id, p.payload FROM message_parts p JOIN messages m ON m.id = p.message_id WHERE m.chat_id = ${chat.id} AND m.role = 'assistant' AND p.kind = 'tool_call' AND p.payload->>'id' = ${tool_call_id} ORDER BY m.created_at DESC LIMIT 1 `; const callerRow = callerRows[0]; if (!callerRow) { reply.code(404); return { error: 'unknown_tool_call_id' }; } const foundCall: ToolCall = { id: callerRow.payload.id, name: callerRow.payload.name, args: callerRow.payload.args, }; if (foundCall.name !== 'ask_user_input') { reply.code(400); return { error: 'tool_call_not_ask_user_input' }; } // Validate the args themselves — the LLM could have emitted bad JSON. const argsParsed = AskUserInputArgs.safeParse(foundCall.args); if (!argsParsed.success) { reply.code(400); return { error: 'mismatched_answer_shape', detail: 'tool_call args invalid' }; } const questions = argsParsed.data.questions; if (answers.length !== questions.length) { reply.code(400); return { error: 'mismatched_answer_shape', detail: `expected ${questions.length} answer(s), got ${answers.length}`, }; } for (let i = 0; i < questions.length; i++) { const q = questions[i]!; const a = answers[i]!; for (const sel of a.selected_options) { if (!q.options.includes(sel)) { reply.code(400); return { error: 'mismatched_answer_shape', detail: `answer ${i + 1} contains option not in question: ${sel}`, }; } } if (q.type === 'single_select' && a.selected_options.length > 1) { reply.code(400); return { error: 'mismatched_answer_shape', detail: `answer ${i + 1} has multiple selections on single_select`, }; } const hasOpt = a.selected_options.length > 0; const hasText = a.free_text !== null && a.free_text.trim().length > 0; if (!hasOpt && !hasText) { reply.code(400); return { error: 'mismatched_answer_shape', detail: `answer ${i + 1} is empty` }; } } // v1.13.1-C: find the pending tool row via message_parts on // payload->>'tool_call_id'. Same fallback caveat as the caller lookup // above — pre-v1.13.0 rows are unreachable here. const toolRows = await sql<{ message_id: string; payload: { tool_call_id: string; output: unknown }; }[]>` SELECT p.message_id, p.payload FROM message_parts p JOIN messages m ON m.id = p.message_id WHERE m.chat_id = ${chat.id} AND m.role = 'tool' AND p.kind = 'tool_result' AND p.payload->>'tool_call_id' = ${tool_call_id} ORDER BY m.created_at DESC LIMIT 1 `; const toolRow = toolRows[0]; if (!toolRow) { reply.code(404); return { error: 'unknown_tool_call_id', detail: 'tool message not found' }; } if (toolRow.payload && toolRow.payload.output !== null) { reply.code(409); return { error: 'tool_call_already_answered' }; } const answerSet = { answers }; const newToolResults = { tool_call_id, output: answerSet, truncated: false, }; const toolMessageId = toolRow.message_id; const result = await sql.begin(async (tx) => { // v1.13.20: parts-only. Replace the pending tool_result part inserted // at message creation (tool-phase.ts) with the answered one. Delete- // then-insert is simpler than UPDATE because parts are append-style // elsewhere; the UNIQUE (message_id, sequence) constraint blocks // plain insert. await tx`DELETE FROM message_parts WHERE message_id = ${toolMessageId} AND kind = 'tool_result'`; await tx` INSERT INTO message_parts (message_id, sequence, kind, payload) VALUES (${toolMessageId}, 0, 'tool_result', ${tx.json(newToolResults as never)}) `; const [assistantMsg] = await tx<{ id: string }[]>` INSERT INTO messages (session_id, chat_id, role, content, status, created_at) VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp()) RETURNING id `; await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`; await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`; return { tool_message_id: toolMessageId, assistant_message_id: assistantMsg!.id, }; }); // Publish the deferred tool_result frame. useSessionStream's reducer // updates the matching tool_run.result so AskUserInputCard flips into // its read-only "answered" mode without a refetch. handlers.publishSessionFrame(sessionId, { type: 'tool_result', tool_message_id: result.tool_message_id, tool_call_id, chat_id: chat.id, output: answerSet, truncated: false, }); handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default'); reply.code(202); return result; }, ); // v1.13.17-cross-repo-reads: resume an awaiting-grant pause. Mirror shape // of /answer_user_input (validate, look up via message_parts, UPDATE, // publish, enqueue). Differences vs /answer_user_input: // - On 'allow', re-resolves the grant root via grant_resolver (state // may have changed since the prompt fired — concurrent project add, // etc.). Resolution failure auto-falls to a denial with reason text // rather than 500ing. // - On 'allow' with a valid root, appends to sessions.allowed_read_paths // (deduplicated) inside the same transaction. // - On success, also publishes session_updated so an open SettingsPane // refetches the new grant list. // Error codes match /answer: // 400 invalid_body / mismatched_answer_shape (bad args on the tool_call) // 404 chat_not_found / unknown_tool_call_id // 409 tool_call_already_answered app.post<{ Params: { id: string } }>( '/api/chats/:id/grant_read_access', async (req, reply) => { const parsed = GrantReadAccessBody.safeParse(req.body); if (!parsed.success) { reply.code(400); return { error: 'invalid_body', details: parsed.error.flatten() }; } const { tool_call_id, decision } = parsed.data; const chatRows = await sql` SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open' `; if (chatRows.length === 0) { reply.code(404); return { error: 'chat_not_found' }; } const chat = chatRows[0]!; const sessionId = chat.session_id; // Mirror the /answer lookup: assistant tool_call by id via message_parts. const callerRows = await sql<{ message_id: string; payload: { id: string; name: string; args: Record }; }[]>` SELECT p.message_id, p.payload FROM message_parts p JOIN messages m ON m.id = p.message_id WHERE m.chat_id = ${chat.id} AND m.role = 'assistant' AND p.kind = 'tool_call' AND p.payload->>'id' = ${tool_call_id} ORDER BY m.created_at DESC LIMIT 1 `; const callerRow = callerRows[0]; if (!callerRow) { reply.code(404); return { error: 'unknown_tool_call_id' }; } const foundCall: ToolCall = { id: callerRow.payload.id, name: callerRow.payload.name, args: callerRow.payload.args, }; if (foundCall.name !== 'request_read_access') { reply.code(400); return { error: 'tool_call_not_request_read_access' }; } const argsParsed = RequestReadAccessArgs.safeParse(foundCall.args); if (!argsParsed.success) { reply.code(400); return { error: 'mismatched_answer_shape', detail: 'tool_call args invalid' }; } const requestedPath = argsParsed.data.path; // Find the pending tool row. const toolRows = await sql<{ message_id: string; payload: { tool_call_id: string; output: unknown }; }[]>` SELECT p.message_id, p.payload FROM message_parts p JOIN messages m ON m.id = p.message_id WHERE m.chat_id = ${chat.id} AND m.role = 'tool' AND p.kind = 'tool_result' AND p.payload->>'tool_call_id' = ${tool_call_id} ORDER BY m.created_at DESC LIMIT 1 `; const toolRow = toolRows[0]; if (!toolRow) { reply.code(404); return { error: 'unknown_tool_call_id', detail: 'tool message not found' }; } if (toolRow.payload && toolRow.payload.output !== null) { reply.code(409); return { error: 'tool_call_already_answered' }; } // Look up session + project so we can re-resolve the grant root and // append to allowed_read_paths atomically. We don't need agent or // history here — just the project path for the resolver. const sessionRows = await sql<{ id: string; project_id: string; allowed_read_paths: string[]; project_path: string; }[]>` SELECT s.id, s.project_id, s.allowed_read_paths, p.path AS project_path FROM sessions s JOIN projects p ON p.id = s.project_id WHERE s.id = ${sessionId} `; const sessionRow = sessionRows[0]; if (!sessionRow) { reply.code(404); return { error: 'session_not_found' }; } // Decision branch. 'deny' is the easy path: nothing to resolve or // persist. 'allow' resolves the grant root; if resolution fails (e.g. // path was deleted, project removed since prompt) the tool gets a // denial with the resolver's reason text instead of a 500. let resultOutput: string; let grantRoot: string | null = null; if (decision === 'allow') { const resolution = await resolveGrantRoot( sql, requestedPath, sessionRow.project_path, config.PROJECT_ROOT_WHITELIST, ); if (!resolution.ok) { resultOutput = `denied: ${resolution.reason}`; } else { grantRoot = resolution.root; resultOutput = `granted: ${grantRoot}`; } } else { resultOutput = 'denied'; } const newToolResults = { tool_call_id, output: resultOutput, truncated: false, }; const toolMessageId = toolRow.message_id; const dbResult = await sql.begin(async (tx) => { // v1.13.20: parts-only. Same delete+insert dance as /answer — // UNIQUE (message_id, sequence) blocks plain UPDATE on append-style // parts. await tx`DELETE FROM message_parts WHERE message_id = ${toolMessageId} AND kind = 'tool_result'`; await tx` INSERT INTO message_parts (message_id, sequence, kind, payload) VALUES (${toolMessageId}, 0, 'tool_result', ${tx.json(newToolResults as never)}) `; // Persist the grant if we have one. ARRAY-level dedup — append only // when the root isn't already present. The session row gets // touched (updated_at) so the post-update publish below has a // fresh timestamp. let allowedRootsAfter = sessionRow.allowed_read_paths; if (grantRoot !== null) { if (!sessionRow.allowed_read_paths.includes(grantRoot)) { const updated = await tx<{ allowed_read_paths: string[] }[]>` UPDATE sessions SET allowed_read_paths = array_append(allowed_read_paths, ${grantRoot}), updated_at = clock_timestamp() WHERE id = ${sessionId} RETURNING allowed_read_paths `; allowedRootsAfter = updated[0]?.allowed_read_paths ?? sessionRow.allowed_read_paths; } else { // Already present — touch updated_at so any open settings // panel still picks up the no-op via session_updated. await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`; } } const [assistantMsg] = await tx<{ id: string }[]>` INSERT INTO messages (session_id, chat_id, role, content, status, created_at) VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp()) RETURNING id `; await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`; return { tool_message_id: toolMessageId, assistant_message_id: assistantMsg!.id, allowed_roots_after: allowedRootsAfter, }; }); // Publish the deferred tool_result frame so the pending card flips to // its answered view without a refetch. handlers.publishSessionFrame(sessionId, { type: 'tool_result', tool_message_id: dbResult.tool_message_id, tool_call_id, chat_id: chat.id, output: resultOutput, truncated: false, }); // session_updated nudge so any open SettingsPane refetches and sees // the new allowed_read_paths. We publish on the user channel to match // the existing PATCH /api/sessions/:id behavior — frontend refetches // via api.sessions.get on receipt. const nowIso = new Date().toISOString(); broker.publishUserFrame('default', { type: 'session_updated', session_id: sessionId, project_id: sessionRow.project_id, // session name doesn't change on grant; we look it up fresh to // avoid carrying stale state if a rename raced us. name: ( await sql<{ name: string }[]>`SELECT name FROM sessions WHERE id = ${sessionId}` )[0]?.name ?? '', updated_at: nowIso, }); handlers.enqueueInference(sessionId, chat.id, dbResult.assistant_message_id, 'default'); reply.code(202); return { tool_message_id: dbResult.tool_message_id, assistant_message_id: dbResult.assistant_message_id, allowed_read_paths: dbResult.allowed_roots_after, }; }, ); }