import { randomUUID } from 'node:crypto'; import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import type { Sql } from '../db.js'; import type { Chat } from '../types/api.js'; import { getSkillBody, listSkills } from '../services/skills.js'; // Batch 9.6 slash-invoke handlers. Mirrors the MessageHandlers shape in // routes/messages.ts so index.ts can pass thin adapters around broker + // inference runner without skills.ts importing them directly. export interface SkillInvokeHandlers { enqueueInference: ( sessionId: string, chatId: string, assistantMessageId: string, user: string, ) => void; publishUserMessage: ( sessionId: string, chatId: string, userMessageId: string, content: string, ) => void; publishSessionFrame: ( sessionId: string, frame: Record & { type: string }, ) => void; } const SkillInvokeBody = z.object({ skill_name: z.string().min(1), // Optional — server fills in a default if absent or whitespace-only so the // model always has something to act on (matches the spec's "Apply this // skill." filler). user_message: z.string().max(64_000).nullable().optional(), }); const DEFAULT_USER_MESSAGE = 'Apply this skill.'; export function registerSkillsRoutes( app: FastifyInstance, sql: Sql, handlers: SkillInvokeHandlers, ): void { // Debug/admin surface — the model interacts with skills via the three // skill_* tools, not through this endpoint. app.get('/api/skills', async () => { return { skills: await listSkills() }; }); // POST /api/chats/:id/skill_invoke — slash-command entry point. Loads the // skill body server-side (clients never get to forge file content), // persists 4 messages in one transaction (synthetic assistant tool_use, // synthetic tool result, real user message, streaming assistant), and // enqueues inference against the updated history. app.post<{ Params: { id: string } }>( '/api/chats/:id/skill_invoke', async (req, reply) => { const parsed = SkillInvokeBody.safeParse(req.body); if (!parsed.success) { reply.code(400); return { error: 'invalid body', details: parsed.error.flatten() }; } const { skill_name } = parsed.data; const userText = parsed.data.user_message?.trim() ? parsed.data.user_message : DEFAULT_USER_MESSAGE; 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 body = await getSkillBody(skill_name); if (body === null) { reply.code(404); return { error: 'unknown_skill', message: `unknown skill: ${skill_name}` }; } const toolCallId = randomUUID(); const toolCalls = [{ id: toolCallId, name: 'skill_use', args: { name: skill_name } }]; const toolResults = { tool_call_id: toolCallId, output: body, truncated: false }; const result = await sql.begin(async (tx) => { const [synthAssistant] = await tx<{ id: string }[]>` INSERT INTO messages (session_id, chat_id, role, content, tool_calls, status, created_at) VALUES (${sessionId}, ${chat.id}, 'assistant', '', ${sql.json(toolCalls as never)}, 'complete', clock_timestamp()) RETURNING id `; // v1.13.0: dual-write the synthetic assistant message's tool_call. // Single skill_use tool_call, no text content, so one part at seq 0. await tx` INSERT INTO message_parts (message_id, sequence, kind, payload) VALUES (${synthAssistant!.id}, 0, 'tool_call', ${tx.json({ id: toolCallId, name: 'skill_use', args: { name: skill_name }, } as never)}) `; const [toolMsg] = await tx<{ id: string }[]>` INSERT INTO messages (session_id, chat_id, role, content, tool_results, status, created_at) VALUES (${sessionId}, ${chat.id}, 'tool', '', ${sql.json(toolResults as never)}, 'complete', clock_timestamp()) RETURNING id `; // v1.13.0: dual-write the synthetic tool result (the skill body). await tx` INSERT INTO message_parts (message_id, sequence, kind, payload) VALUES (${toolMsg!.id}, 0, 'tool_result', ${tx.json(toolResults as never)}) `; const [userMsg] = await tx<{ id: string }[]>` INSERT INTO messages (session_id, chat_id, role, content, status, created_at) VALUES (${sessionId}, ${chat.id}, 'user', ${userText}, '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 { synth_assistant_id: synthAssistant!.id, tool_message_id: toolMsg!.id, user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id, }; }); // Synthetic frames so useSessionStream's reducer reflects the new // history without a refetch. Frame shapes match the streaming-inference // protocol (see services/inference.ts InferenceFrame). handlers.publishSessionFrame(sessionId, { type: 'message_started', message_id: result.synth_assistant_id, chat_id: chat.id, role: 'assistant', }); handlers.publishSessionFrame(sessionId, { type: 'tool_call', message_id: result.synth_assistant_id, chat_id: chat.id, tool_call: toolCalls[0]!, }); handlers.publishSessionFrame(sessionId, { type: 'message_complete', message_id: result.synth_assistant_id, chat_id: chat.id, }); // The tool_result frame's reducer branch creates the tool-role message // in-place when it doesn't already exist — no separate message_started // is needed for the tool side. handlers.publishSessionFrame(sessionId, { type: 'tool_result', tool_message_id: result.tool_message_id, tool_call_id: toolCallId, chat_id: chat.id, output: body, truncated: false, }); handlers.publishUserMessage(sessionId, chat.id, result.user_message_id, userText); handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default'); reply.code(202); return result; }, ); }