v1.13.0: message_parts table + dual-write at every tool_calls/tool_results site

Adds a granular message_parts table (one row per text/tool_call/tool_result
chunk) without changing any read path. Old messages.content / tool_calls /
tool_results columns remain authoritative for v1.13.0; this dispatch is
write-only mirroring so the AI SDK migration in v1.13.1 can flip read
authority without a backfill window.

Schema:
  CREATE TABLE message_parts (id, message_id FK ON DELETE CASCADE,
    sequence int, kind text CHECK (text|tool_call|tool_result|reasoning|step_start),
    payload jsonb, created_at, UNIQUE (message_id, sequence))

New module services/inference/parts.ts with two pure derive helpers
(partsFromAssistantMessage, partsFromToolMessage) and insertParts that
fan-outs a multi-row INSERT via postgres-js.

Wired dual-write at every site that writes tool_calls or tool_results:
- tool-phase.ts: assistant finalize UPDATE, executed-tool UPDATE,
  ask_user_input sentinel UPDATE
- messages.ts answer flow: DELETE pending tool_result part + INSERT
  answered one inside the existing sql.begin
- skills.ts: synthetic assistant + tool INSERTs both inside existing tx
- chats.ts fork: CTE clones parts via ROW_NUMBER pairing (source→dest
  message id mapping in one statement, no N+1)
- error-handler.ts finalizeCompletion: text part for plain text-only
  assistant turns

Deviation: tool-phase.ts finalize UPDATEs and finalizeCompletion text-part
write are not wrapped in fresh sql.begin transactions. Safe in v1.13.0
because JSON columns are authoritative for reads. v1.13.1 must wrap these
sites before flipping read authority — TODO comments added at each
unwrapped site referencing v1.13.1.

Tests: 8 new unit tests for the derive helpers in
services/__tests__/parts.test.ts. Existing 162 tests untouched. 170 total.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 05:46:29 +00:00
parent ca64bf9f0a
commit 1cb6eee24c
8 changed files with 290 additions and 0 deletions

View File

@@ -313,6 +313,28 @@ export function registerChatRoutes(
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!;
});

View File

@@ -576,6 +576,15 @@ export function registerMessageRoutes(
SET tool_results = ${tx.json(newToolResults as never)}
WHERE id = ${toolRow.id}
`;
// v1.13.0: 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 = ${toolRow.id} AND kind = 'tool_result'`;
await tx`
INSERT INTO message_parts (message_id, sequence, kind, payload)
VALUES (${toolRow.id}, 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())

View File

@@ -90,11 +90,26 @@ export function registerSkillsRoutes(
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())