Removes the dual-write into messages.tool_calls / messages.tool_results JSON columns and drops the columns. message_parts is now the only source of truth for tool calls and tool results. 10 dual-write sites stripped (5 in tool-phase.ts, 2 in routes/skills.ts, 2 in routes/messages.ts, 1 in routes/chats.ts fork-clone). The recon-driven grep caught 2 sites beyond the original v1.13.2 roadmap inventory and an extra fixture file (tool_cost_stats.test.ts) with a direct legacy-column INSERT. messages_with_parts view rewritten to parts-only subselects (COALESCE fallbacks gone). View runs via CREATE OR REPLACE so it lands before the column DROPs in startup DDL — Postgres rejects column-drop on view-referenced cols. v1.12.1 cleanup DO block (DROP CONSTRAINT messages_status_check / messages_role_check) removed; those one-shots have done their work. Adversarial review caught a runtime bug the green test suite missed: the discard_stale endpoint (chats.ts) had a RETURNING ... tool_calls, tool_results clause that would have crashed on every 60s-no-token-activity recovery in production. Fixed by switching to two-step UPDATE returning id, then SELECT from messages_with_parts so parts-synthesized fields keep flowing on the wire. Message API type retains tool_calls? / tool_results? — the view synthesizes those keys from parts so the wire shape is unchanged; frontend reads need no update. Override on the original v1.13.2 plan, captured in the openspec proposal. 339/339 server tests passing (including 7 DB-integration tests that applied the schema migration to a live DB and ran the parts-only view end-to-end). tsc + web build clean. Pairs with v1.13.0-ai-sdk-v6 (introduced the dual-write) and v1.13.1-B (moved the read path to messages_with_parts). Umbrella v1.13 tag ships on this same commit, marking the strangler-fig closed. CLAUDE.md picks up Sam's pre-existing edits documenting tag-naming and CHANGELOG conventions — both already in use by v1.13.19 / v1.13.20. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
443 lines
15 KiB
TypeScript
443 lines
15 KiB
TypeScript
import type { FastifyInstance } from 'fastify';
|
|
import { z } from 'zod';
|
|
import type { Sql } from '../db.js';
|
|
import type { Broker } from '../services/broker.js';
|
|
import type { Chat, Message } from '../types/api.js';
|
|
import { getModelContext } from '../services/model-context.js';
|
|
|
|
const CreateBody = z.object({
|
|
name: z.string().min(1).max(200).optional(),
|
|
});
|
|
|
|
const PatchBody = z.object({
|
|
name: z.string().min(1).max(200),
|
|
});
|
|
|
|
const ForkBody = z.object({
|
|
message_id: z.string().uuid(),
|
|
name: z.string().min(1).max(200).optional(),
|
|
});
|
|
|
|
const DiscardStaleBody = z.object({
|
|
message_id: z.string().uuid(),
|
|
});
|
|
|
|
const STALE_MIN_AGE_SECONDS = 60;
|
|
|
|
export function registerChatRoutes(
|
|
app: FastifyInstance,
|
|
sql: Sql,
|
|
broker: Broker
|
|
): void {
|
|
app.get<{ Params: { id: string }; Querystring: { status?: string } }>(
|
|
'/api/sessions/:id/chats',
|
|
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' };
|
|
}
|
|
const status = req.query.status === 'archived' ? 'archived' : 'open';
|
|
// Enriched list: computed per-chat fields via LATERAL joins.
|
|
const rows = await sql<Chat[]>`
|
|
SELECT
|
|
c.id, c.session_id, c.name, c.status, c.created_at, c.updated_at,
|
|
COALESCE(mc.cnt, 0)::int AS message_count,
|
|
lp.preview AS last_message_preview,
|
|
ec.tokens AS effective_context_tokens
|
|
FROM chats c
|
|
LEFT JOIN LATERAL (
|
|
SELECT COUNT(*) AS cnt FROM messages WHERE chat_id = c.id
|
|
) mc ON TRUE
|
|
LEFT JOIN LATERAL (
|
|
SELECT LEFT(BTRIM(REGEXP_REPLACE(content, E'[\\n\\r]+', ' ', 'g')), 80) AS preview
|
|
FROM messages
|
|
WHERE chat_id = c.id AND kind = 'message' AND content <> ''
|
|
ORDER BY created_at DESC
|
|
LIMIT 1
|
|
) lp ON TRUE
|
|
LEFT JOIN LATERAL (
|
|
SELECT ctx_used AS tokens
|
|
FROM messages
|
|
WHERE chat_id = c.id AND kind = 'message' AND role = 'assistant'
|
|
AND status = 'complete' AND ctx_used IS NOT NULL
|
|
ORDER BY created_at DESC
|
|
LIMIT 1
|
|
) ec ON TRUE
|
|
WHERE c.session_id = ${req.params.id} AND c.status = ${status}
|
|
ORDER BY c.updated_at DESC
|
|
`;
|
|
// v1.11.5: enrich each chat with its model's context window so the
|
|
// ContextBar can render a zero-state (and the auto-compaction threshold
|
|
// tooltip) before the first assistant message lands. All chats in a
|
|
// session share the session's model, so we do ONE getModelContext
|
|
// lookup and apply the result to the whole list. Failed lookups
|
|
// (model unknown, llama-swap down) yield null and the frontend falls
|
|
// through to the "model context unknown" placeholder.
|
|
const sessRow = await sql<{ model: string | null }[]>`
|
|
SELECT model FROM sessions WHERE id = ${req.params.id}
|
|
`;
|
|
const sessionModel = sessRow[0]?.model ?? null;
|
|
const mctx = sessionModel ? await getModelContext(sessionModel) : null;
|
|
const modelContextLimit = mctx?.n_ctx ?? null;
|
|
return rows.map((r) => ({ ...r, model_context_limit: modelContextLimit }));
|
|
}
|
|
);
|
|
|
|
app.post<{ Params: { id: string } }>(
|
|
'/api/sessions/:id/chats',
|
|
async (req, reply) => {
|
|
const parsed = CreateBody.safeParse(req.body ?? {});
|
|
if (!parsed.success) {
|
|
reply.code(400);
|
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
|
}
|
|
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' };
|
|
}
|
|
const [chat] = await sql<Chat[]>`
|
|
INSERT INTO chats (session_id, name, status)
|
|
VALUES (${req.params.id}, ${parsed.data.name ?? null}, 'open')
|
|
RETURNING id, session_id, name, status, created_at, updated_at
|
|
`;
|
|
broker.publishUserFrame('default', {
|
|
type: 'chat_created',
|
|
chat: chat!,
|
|
session_id: req.params.id,
|
|
});
|
|
reply.code(201);
|
|
return chat;
|
|
}
|
|
);
|
|
|
|
app.patch<{ Params: { id: string } }>(
|
|
'/api/chats/: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 rows = await sql<Chat[]>`
|
|
UPDATE chats
|
|
SET name = ${parsed.data.name},
|
|
updated_at = clock_timestamp()
|
|
WHERE id = ${req.params.id}
|
|
RETURNING id, session_id, name, status, created_at, updated_at
|
|
`;
|
|
if (rows.length === 0) {
|
|
reply.code(404);
|
|
return { error: 'chat not found' };
|
|
}
|
|
const chat = rows[0]!;
|
|
broker.publishUserFrame('default', {
|
|
type: 'chat_updated',
|
|
chat_id: chat.id,
|
|
session_id: chat.session_id,
|
|
name: chat.name,
|
|
updated_at: chat.updated_at,
|
|
});
|
|
return chat;
|
|
}
|
|
);
|
|
|
|
// v1.9: bulk-archive every open chat in a session. Mirrors the single
|
|
// /chats/:id/archive shape — N chat_archived frames published, useSidebar
|
|
// reducer handles each via the existing case.
|
|
app.post<{ Params: { id: string } }>(
|
|
'/api/sessions/:id/chats/archive-all',
|
|
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' };
|
|
}
|
|
const rows = await sql<{ id: string }[]>`
|
|
UPDATE chats
|
|
SET status = 'archived', updated_at = clock_timestamp()
|
|
WHERE session_id = ${req.params.id} AND status = 'open'
|
|
RETURNING id
|
|
`;
|
|
const ids = rows.map((r) => r.id);
|
|
for (const id of ids) {
|
|
broker.publishUserFrame('default', {
|
|
type: 'chat_archived',
|
|
chat_id: id,
|
|
session_id: req.params.id,
|
|
});
|
|
}
|
|
return { archived: ids.length, ids };
|
|
}
|
|
);
|
|
|
|
// v1.9: count helper for the confirm dialog.
|
|
app.get<{ Params: { id: string } }>(
|
|
'/api/sessions/:id/chats/open-count',
|
|
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' };
|
|
}
|
|
const rows = await sql<{ count: number }[]>`
|
|
SELECT COUNT(*)::int AS count
|
|
FROM chats
|
|
WHERE session_id = ${req.params.id} AND status = 'open'
|
|
`;
|
|
return { count: rows[0]?.count ?? 0 };
|
|
}
|
|
);
|
|
|
|
app.post<{ Params: { id: string } }>(
|
|
'/api/chats/:id/archive',
|
|
async (req, reply) => {
|
|
const rows = await sql<{ id: string; session_id: string }[]>`
|
|
UPDATE chats SET status = 'archived', updated_at = clock_timestamp()
|
|
WHERE id = ${req.params.id} AND status = 'open'
|
|
RETURNING id, session_id
|
|
`;
|
|
if (rows.length === 0) {
|
|
reply.code(404);
|
|
return { error: 'chat not found or already archived' };
|
|
}
|
|
const row = rows[0]!;
|
|
broker.publishUserFrame('default', {
|
|
type: 'chat_archived',
|
|
chat_id: row.id,
|
|
session_id: row.session_id,
|
|
});
|
|
reply.code(204);
|
|
return null;
|
|
}
|
|
);
|
|
|
|
app.post<{ Params: { id: string } }>(
|
|
'/api/chats/:id/unarchive',
|
|
async (req, reply) => {
|
|
const rows = await sql<Chat[]>`
|
|
UPDATE chats SET status = 'open', updated_at = clock_timestamp()
|
|
WHERE id = ${req.params.id} AND status = 'archived'
|
|
RETURNING id, session_id, name, status, created_at, updated_at
|
|
`;
|
|
if (rows.length === 0) {
|
|
reply.code(404);
|
|
return { error: 'chat not found or not archived' };
|
|
}
|
|
const chat = rows[0]!;
|
|
broker.publishUserFrame('default', { type: 'chat_unarchived', chat });
|
|
return chat;
|
|
}
|
|
);
|
|
|
|
app.delete<{ Params: { id: string } }>(
|
|
'/api/chats/:id',
|
|
async (req, reply) => {
|
|
const result = await sql<{ id: string; session_id: string }[]>`
|
|
DELETE FROM chats WHERE id = ${req.params.id}
|
|
RETURNING id, session_id
|
|
`;
|
|
if (result.length === 0) {
|
|
reply.code(404);
|
|
return { error: 'chat not found' };
|
|
}
|
|
const row = result[0]!;
|
|
broker.publishUserFrame('default', {
|
|
type: 'chat_deleted',
|
|
chat_id: row.id,
|
|
session_id: row.session_id,
|
|
});
|
|
reply.code(204);
|
|
return null;
|
|
}
|
|
);
|
|
|
|
app.post<{ Params: { id: string } }>(
|
|
'/api/chats/:id/fork',
|
|
async (req, reply) => {
|
|
const parsed = ForkBody.safeParse(req.body ?? {});
|
|
if (!parsed.success) {
|
|
reply.code(400);
|
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
|
}
|
|
|
|
const sourceRows = await sql<Chat[]>`
|
|
SELECT id, session_id, name, status, created_at, updated_at
|
|
FROM chats WHERE id = ${req.params.id}
|
|
`;
|
|
if (sourceRows.length === 0) {
|
|
reply.code(404);
|
|
return { error: 'chat not found' };
|
|
}
|
|
const source = sourceRows[0]!;
|
|
|
|
const targetRows = await sql<{ created_at: string; status: string }[]>`
|
|
SELECT created_at, status FROM messages
|
|
WHERE chat_id = ${source.id} AND id = ${parsed.data.message_id}
|
|
`;
|
|
if (targetRows.length === 0) {
|
|
reply.code(404);
|
|
return { error: 'message not found in chat' };
|
|
}
|
|
const target = targetRows[0]!;
|
|
if (target.status !== 'complete') {
|
|
reply.code(400);
|
|
return { error: 'can only fork from completed messages' };
|
|
}
|
|
|
|
const newName = parsed.data.name ?? `${source.name ?? 'Chat'} (fork)`;
|
|
|
|
const newChat = await sql.begin(async (tx) => {
|
|
const [chat] = await tx<Chat[]>`
|
|
INSERT INTO chats (session_id, name, status)
|
|
VALUES (${source.session_id}, ${newName}, 'open')
|
|
RETURNING id, session_id, name, status, created_at, updated_at
|
|
`;
|
|
await tx`
|
|
INSERT INTO messages (
|
|
session_id, chat_id, role, content, kind,
|
|
status, tokens_used, ctx_used, ctx_max, started_at, finished_at,
|
|
created_at, metadata
|
|
)
|
|
SELECT
|
|
${source.session_id}, ${chat!.id}, role, content, kind,
|
|
status,
|
|
tokens_used, ctx_used, ctx_max, started_at, finished_at,
|
|
clock_timestamp() + (
|
|
ROW_NUMBER() OVER (ORDER BY created_at ASC, id ASC) * INTERVAL '1 microsecond'
|
|
),
|
|
metadata
|
|
FROM messages
|
|
WHERE chat_id = ${source.id}
|
|
AND created_at <= ${target.created_at}::timestamptz
|
|
AND status = 'complete'
|
|
`;
|
|
// v1.13.0: clone message_parts for the forked messages. Source and
|
|
// destination preserve ordering (the INSERT above orders by created_at,
|
|
// id) so a ROW_NUMBER pairing maps source.id → dest.id deterministically.
|
|
await tx`
|
|
WITH src AS (
|
|
SELECT id, ROW_NUMBER() OVER (ORDER BY created_at ASC, id ASC) AS rn
|
|
FROM messages
|
|
WHERE chat_id = ${source.id}
|
|
AND created_at <= ${target.created_at}::timestamptz
|
|
AND status = 'complete'
|
|
),
|
|
dst AS (
|
|
SELECT id, ROW_NUMBER() OVER (ORDER BY created_at ASC, id ASC) AS rn
|
|
FROM messages
|
|
WHERE chat_id = ${chat!.id}
|
|
)
|
|
INSERT INTO message_parts (message_id, sequence, kind, payload)
|
|
SELECT dst.id, p.sequence, p.kind, p.payload
|
|
FROM message_parts p
|
|
JOIN src ON p.message_id = src.id
|
|
JOIN dst ON dst.rn = src.rn
|
|
`;
|
|
return chat!;
|
|
});
|
|
|
|
broker.publishUserFrame('default', {
|
|
type: 'chat_created',
|
|
chat: newChat,
|
|
session_id: source.session_id,
|
|
});
|
|
reply.code(201);
|
|
return newChat;
|
|
}
|
|
);
|
|
|
|
// v1.12.3: explicit recovery from a stuck-streaming assistant row. The
|
|
// frontend gates this behind a 60s no-token-activity timer; the server
|
|
// re-checks the age and current status for safety. Non-streaming rows
|
|
// return 409 (frontend race; idempotent retry is fine).
|
|
app.post<{ Params: { id: string } }>(
|
|
'/api/chats/:id/discard_stale',
|
|
async (req, reply) => {
|
|
const parsed = DiscardStaleBody.safeParse(req.body ?? {});
|
|
if (!parsed.success) {
|
|
reply.code(400);
|
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
|
}
|
|
const rows = await sql<{
|
|
id: string;
|
|
session_id: string;
|
|
chat_id: string;
|
|
status: string;
|
|
age_seconds: number;
|
|
}[]>`
|
|
SELECT id, session_id, chat_id, status,
|
|
EXTRACT(EPOCH FROM (clock_timestamp() - created_at))::int AS age_seconds
|
|
FROM messages
|
|
WHERE id = ${parsed.data.message_id} AND chat_id = ${req.params.id}
|
|
`;
|
|
if (rows.length === 0) {
|
|
reply.code(404);
|
|
return { error: 'message not found in chat' };
|
|
}
|
|
const msg = rows[0]!;
|
|
if (msg.status !== 'streaming') {
|
|
reply.code(409);
|
|
return { error: 'message is no longer streaming', current_status: msg.status };
|
|
}
|
|
if (msg.age_seconds < STALE_MIN_AGE_SECONDS) {
|
|
reply.code(409);
|
|
return { error: 'message is not stale yet', age_seconds: msg.age_seconds };
|
|
}
|
|
const updated = await sql<{ id: string }[]>`
|
|
UPDATE messages
|
|
SET status = 'failed',
|
|
content = COALESCE(content, ''),
|
|
finished_at = clock_timestamp()
|
|
WHERE id = ${msg.id} AND status = 'streaming'
|
|
RETURNING id
|
|
`;
|
|
if (updated.length === 0) {
|
|
// Race: the row flipped out of 'streaming' between our SELECT and UPDATE.
|
|
reply.code(409);
|
|
return { error: 'message status changed mid-request' };
|
|
}
|
|
// v1.13.20: re-fetch via messages_with_parts so the returned shape
|
|
// carries parts-synthesized tool_calls / tool_results. The dropped
|
|
// legacy columns can no longer be selected directly.
|
|
const refreshed = await sql<Message[]>`
|
|
SELECT * FROM messages_with_parts WHERE id = ${msg.id}
|
|
`;
|
|
broker.publishUserFrame('default', {
|
|
type: 'chat_status',
|
|
chat_id: msg.chat_id,
|
|
status: 'idle',
|
|
at: new Date().toISOString(),
|
|
});
|
|
broker.publishFrame(msg.session_id, {
|
|
type: 'message_complete',
|
|
message_id: msg.id,
|
|
chat_id: msg.chat_id,
|
|
});
|
|
return refreshed[0];
|
|
}
|
|
);
|
|
|
|
app.get<{ Params: { id: string } }>(
|
|
'/api/chats/:id/messages',
|
|
async (req, reply) => {
|
|
const chat = await sql`SELECT id FROM chats WHERE id = ${req.params.id}`;
|
|
if (chat.length === 0) {
|
|
reply.code(404);
|
|
return { error: 'chat not found' };
|
|
}
|
|
// v1.13.1-B: reads tool_calls/tool_results via the parts-merged view.
|
|
const rows = await sql<Message[]>`
|
|
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
|
|
FROM messages_with_parts
|
|
WHERE chat_id = ${req.params.id}
|
|
ORDER BY created_at ASC, id ASC
|
|
`;
|
|
return rows;
|
|
}
|
|
);
|
|
}
|