- schema.sql: new messages_with_parts view. tool_calls aggregates parts
with kind='tool_call' as a jsonb array of {id, name, args}; tool_results
picks the single sequence=0 part with kind='tool_result' as a jsonb
{tool_call_id, output, truncated, error?}. COALESCE against the legacy
jsonb columns means pre-v1.13.0 history (no parts rows) still reads
correctly via the fallback, and fresh inserts (where parts dual-write
follows the row INSERT) hit the legacy columns until the parts land.
- reasoning_parts column added to the view but not selected by any caller
yet — v1.13.1-C extends the Message type and pulls it into the model
payload alongside the type extension.
- Read sites switched to FROM messages_with_parts:
- routes/chats.ts:427 (chat history GET)
- routes/messages.ts:95 (session history GET)
- routes/ws.ts:27 (WS snapshot on session connect, resume path)
- services/inference/payload.ts (loadContext for model assembly)
- services/compaction.ts (compaction's payload assembly)
- chats.ts:394 (discard_stale UPDATE RETURNING) unchanged — UPDATEs target
messages directly and the returned shape is for a freshly-modified row
where the legacy column is dual-written and correct.
- messages.ts:478/549 (ask_user_input correlation) intentionally not
migrated — those query a different shape, ported in v1.13.1-C.
- Writes still target `messages` directly; the view is read-only.
Smoke verified against the live container:
- Equivalence: 5/5 messages with both legacy column and parts row return
identical tool_calls jsonb between FROM messages and FROM messages_with_parts.
- Perf: EXPLAIN ANALYZE on the 42-message stress chat returns in ~1ms
(50ms threshold). Bitmap Index Scan on message_parts_msg_seq_idx
carries the parts lookups.
- API contract: GET /api/chats/:id/messages returns identical
{id, name, args} tool_calls and {tool_call_id, output, truncated, error}
tool_results shapes to frontend consumers — no UI changes needed.
- Inference path: sent a view_file prompt; assistant turn 1 emitted the
tool_call, tool message captured the result, follow-up assistant turn
read the result back via loadContext (now view-backed) and answered
correctly. End-to-end loop intact.
v1.13.2 drops the dual-write + the JSON columns + simplifies the view
to just SELECT FROM message_parts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
65 lines
2.3 KiB
TypeScript
65 lines
2.3 KiB
TypeScript
import type { FastifyInstance } from 'fastify';
|
|
import type { Sql } from '../db.js';
|
|
import type { Broker } from '../services/broker.js';
|
|
import type { Message } from '../types/api.js';
|
|
|
|
export function registerWebSocket(
|
|
app: FastifyInstance,
|
|
sql: Sql,
|
|
broker: Broker
|
|
): void {
|
|
app.get<{ Params: { id: string } }>(
|
|
'/api/ws/sessions/:id',
|
|
{ websocket: true },
|
|
async (socket, req) => {
|
|
const sessionId = req.params.id;
|
|
|
|
const session = await sql<{ id: string }[]>`SELECT id FROM sessions WHERE id = ${sessionId}`;
|
|
if (session.length === 0) {
|
|
socket.send(JSON.stringify({ type: 'error', error: 'session not found' }));
|
|
socket.close(1008, 'session not found');
|
|
return;
|
|
}
|
|
|
|
// v1.11: snapshot includes compaction fields so MessageBubble can
|
|
// render the SummaryCard for summary=true rows on first connect.
|
|
// v1.13.1-B: reads tool_calls/tool_results via the parts-merged view.
|
|
const messages = 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 session_id = ${sessionId}
|
|
ORDER BY created_at ASC, id ASC
|
|
`;
|
|
socket.send(JSON.stringify({ type: 'snapshot', messages }));
|
|
|
|
const unsubscribe = broker.subscribe(sessionId, (frame) => {
|
|
if (socket.readyState !== socket.OPEN) return;
|
|
try {
|
|
socket.send(JSON.stringify(frame));
|
|
} catch (err) {
|
|
app.log.warn({ err, sessionId }, 'ws send failed');
|
|
}
|
|
});
|
|
|
|
socket.on('close', () => unsubscribe());
|
|
socket.on('error', () => unsubscribe());
|
|
}
|
|
);
|
|
|
|
app.get('/api/ws/user', { websocket: true }, async (socket) => {
|
|
const user = 'default';
|
|
const unsubscribe = broker.subscribeUser(user, (frame) => {
|
|
if (socket.readyState !== socket.OPEN) return;
|
|
try {
|
|
socket.send(JSON.stringify(frame));
|
|
} catch (err) {
|
|
app.log.warn({ err, user }, 'user ws send failed');
|
|
}
|
|
});
|
|
socket.on('close', () => unsubscribe());
|
|
socket.on('error', () => unsubscribe());
|
|
});
|
|
}
|