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 { Session } from '../types/api.js'; import { getSetting } from './settings.js'; const CreateBody = z.object({ name: z.string().min(1).max(200).optional(), model: z.string().min(1).max(200).optional(), system_prompt: z.string().max(8000).optional(), agent_id: z.string().min(1).max(200).nullable().optional(), }); const PatchBody = z.object({ name: z.string().min(1).max(200).optional(), model: z.string().min(1).max(200).optional(), system_prompt: z.string().max(8000).optional(), agent_id: z.string().min(1).max(200).nullable().optional(), // v1.9: null = inherit from project default; true/false = explicit override. web_search_enabled: z.boolean().nullable().optional(), }); async function resolveDefaultModel(sql: Sql, config: Config): Promise { const fromDb = await getSetting(sql, 'default_model'); if (typeof fromDb === 'string' && fromDb.length > 0) return fromDb; return config.DEFAULT_MODEL; } export function registerSessionRoutes( app: FastifyInstance, sql: Sql, config: Config, broker: Broker ): void { app.get<{ Params: { id: string }; Querystring: { status?: string } }>( '/api/projects/:id/sessions', async (req, reply) => { const project = await sql`SELECT id FROM projects WHERE id = ${req.params.id}`; if (project.length === 0) { reply.code(404); return { error: 'project not found' }; } const status = req.query.status === 'archived' ? 'archived' : 'open'; const rows = await sql` SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled FROM sessions WHERE project_id = ${req.params.id} AND status = ${status} ORDER BY updated_at DESC `; return rows; } ); app.post<{ Params: { id: string } }>( '/api/projects/:id/sessions', async (req, reply) => { const parsed = CreateBody.safeParse(req.body ?? {}); if (!parsed.success) { reply.code(400); return { error: 'invalid body', details: parsed.error.flatten() }; } const project = await sql<{ id: string }[]>` SELECT id FROM projects WHERE id = ${req.params.id} `; if (project.length === 0) { reply.code(404); return { error: 'project not found' }; } let model = parsed.data.model; if (!model) { const lastUsed = await sql<{ model: string }[]>` SELECT model FROM sessions WHERE project_id = ${req.params.id} ORDER BY created_at DESC LIMIT 1 `; model = lastUsed[0]?.model ?? (await resolveDefaultModel(sql, config)); } const name = parsed.data.name ?? 'New session'; const systemPrompt = parsed.data.system_prompt ?? ''; // v1.11.5.2: default is null (no agent / raw chat) when the client // omits agent_id. Sam can still pick one from the AgentPicker after // the session loads. Was: first agent in the project's effective list // (alphabetically — usually "Code Reviewer"), which felt presumptuous. const agentId = parsed.data.agent_id ?? null; const row = await sql.begin(async (tx) => { const [session] = await tx` INSERT INTO sessions (project_id, name, model, system_prompt, agent_id) VALUES (${req.params.id}, ${name}, ${model}, ${systemPrompt}, ${agentId}) RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled `; await tx` INSERT INTO chats (session_id, name, status) VALUES (${session!.id}, NULL, 'open') `; return session!; }); broker.publishUser('default', { type: 'session_created', session: row, project_id: row.project_id, }); reply.code(201); return row; } ); app.get<{ Params: { id: string } }>('/api/sessions/:id', async (req, reply) => { const rows = await sql` SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled FROM sessions WHERE id = ${req.params.id} `; if (rows.length === 0) { reply.code(404); return { error: 'session not found' }; } return rows[0]; }); app.patch<{ Params: { id: string } }>( '/api/sessions/:id', async (req, reply) => { const parsed = PatchBody.safeParse(req.body); if (!parsed.success) { reply.code(400); return { error: 'invalid body', details: parsed.error.flatten() }; } const { name, model, system_prompt } = parsed.data; // agent_id and web_search_enabled are both tri-state on the wire: omitted // = no change, null = clear/inherit, value = set. CASE WHEN inside SET // handles all three atomically. const agentIdProvided = parsed.data.agent_id !== undefined; const newAgentId = parsed.data.agent_id ?? null; const wseProvided = parsed.data.web_search_enabled !== undefined; const newWse = parsed.data.web_search_enabled ?? null; // Read the prior name so the post-update publish can skip no-op renames // (PATCH { name: "Foo" } where the session is already "Foo"). The window // between SELECT and UPDATE is sub-millisecond in the same request handler; // a concurrent rename in that gap would just mean one stale publish, which // existing clients dedup by id. const before = await sql<{ name: string }[]>` SELECT name FROM sessions WHERE id = ${req.params.id} `; const priorName = before[0]?.name; const rows = await sql` UPDATE sessions SET name = COALESCE(${name ?? null}, name), model = COALESCE(${model ?? null}, model), system_prompt = COALESCE(${system_prompt ?? null}, system_prompt), agent_id = CASE WHEN ${agentIdProvided} THEN ${newAgentId} ELSE agent_id END, web_search_enabled = CASE WHEN ${wseProvided} THEN ${newWse} ELSE web_search_enabled END, updated_at = clock_timestamp() WHERE id = ${req.params.id} RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled `; if (rows.length === 0) { reply.code(404); return { error: 'session not found' }; } const session = rows[0]!; if (name !== undefined && session.name !== priorName) { broker.publishUser('default', { type: 'session_renamed', session_id: session.id, name: session.name, }); } // v1.9: any successful PATCH broadcasts session_updated so listeners // (notably the SettingsPane open in another tab) can refetch and pick // up the new fields. Frame stays lean (decision d) — payload is just // ids + name + updated_at, the client refetches via api.sessions.get. broker.publishUser('default', { type: 'session_updated', session_id: session.id, project_id: session.project_id, name: session.name, updated_at: session.updated_at, }); return session; } ); // v1.9: bulk-archive every open session in a project. Mirrors the // single-archive shape (same broker frame type) so the existing useSidebar // reducer cases handle it without changes — just N frames instead of 1. app.post<{ Params: { id: string } }>( '/api/projects/:id/sessions/archive-all', async (req, reply) => { const project = await sql`SELECT id FROM projects WHERE id = ${req.params.id}`; if (project.length === 0) { reply.code(404); return { error: 'project not found' }; } const rows = await sql<{ id: string }[]>` UPDATE sessions SET status = 'archived', updated_at = clock_timestamp() WHERE project_id = ${req.params.id} AND status = 'open' RETURNING id `; const ids = rows.map((r) => r.id); for (const id of ids) { broker.publishUser('default', { type: 'session_archived', session_id: id, project_id: req.params.id, }); } return { archived: ids.length, ids }; } ); // v1.9: count helper for the confirm dialog. Cheap COUNT(*) — the settings // pane calls it on click, not on render. app.get<{ Params: { id: string } }>( '/api/projects/:id/sessions/open-count', async (req, reply) => { const project = await sql`SELECT id FROM projects WHERE id = ${req.params.id}`; if (project.length === 0) { reply.code(404); return { error: 'project not found' }; } const rows = await sql<{ count: number }[]>` SELECT COUNT(*)::int AS count FROM sessions WHERE project_id = ${req.params.id} AND status = 'open' `; return { count: rows[0]?.count ?? 0 }; } ); app.post<{ Params: { id: string } }>( '/api/sessions/:id/archive', async (req, reply) => { const rows = await sql<{ id: string; project_id: string }[]>` UPDATE sessions SET status = 'archived', updated_at = clock_timestamp() WHERE id = ${req.params.id} AND status = 'open' RETURNING id, project_id `; if (rows.length === 0) { reply.code(404); return { error: 'session not found or already archived' }; } broker.publishUser('default', { type: 'session_archived', session_id: rows[0]!.id, project_id: rows[0]!.project_id, }); reply.code(204); return null; } ); app.post<{ Params: { id: string } }>( '/api/sessions/:id/unarchive', async (req, reply) => { const rows = await sql` UPDATE sessions SET status = 'open', updated_at = clock_timestamp() WHERE id = ${req.params.id} AND status = 'archived' RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled `; if (rows.length === 0) { reply.code(404); return { error: 'session not found or not archived' }; } const session = rows[0]!; broker.publishUser('default', { type: 'session_created', session: session, project_id: session.project_id, }); reply.code(200); return session; } ); app.delete<{ Params: { id: string } }>( '/api/sessions/:id', async (req, reply) => { const id = req.params.id; const deleted = await sql<{ project_id: string }[]>` DELETE FROM sessions WHERE id = ${id} RETURNING project_id `; if (deleted.length === 0) { reply.code(404); return { error: 'not found' }; } const project_id = deleted[0]!.project_id; broker.publishUser('default', { type: 'session_deleted', session_id: id, project_id }); reply.code(204); return null; } ); }