v1.13.20-drop-legacy-cols: final phase of v1.13.0 strangler-fig
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>
This commit is contained in:
@@ -78,16 +78,18 @@ describeFn('tool_cost_stats view (v1.13.10)', () => {
|
||||
args: {},
|
||||
}));
|
||||
const created = opts.createdAt ?? new Date();
|
||||
// v1.13.20: parts-only. messages.tool_calls column was dropped; the
|
||||
// tool_cost_stats view reads through messages_with_parts which derives
|
||||
// tool_calls from message_parts rows.
|
||||
const rows = await sql<{ id: string }[]>`
|
||||
INSERT INTO messages (
|
||||
session_id, chat_id, role, content, kind, status,
|
||||
tool_calls, tokens_used, ctx_used,
|
||||
tokens_used, ctx_used,
|
||||
metadata, created_at
|
||||
)
|
||||
VALUES (
|
||||
${sessionId}, ${chatId}, 'assistant', '', 'message',
|
||||
${opts.status ?? 'complete'},
|
||||
${sql.json(toolCalls as never)},
|
||||
${opts.tokensUsed},
|
||||
${opts.ctxUsed},
|
||||
${opts.metadata ? sql.json(opts.metadata as never) : null},
|
||||
@@ -95,7 +97,14 @@ describeFn('tool_cost_stats view (v1.13.10)', () => {
|
||||
)
|
||||
RETURNING id
|
||||
`;
|
||||
return rows[0]!.id;
|
||||
const messageId = rows[0]!.id;
|
||||
for (let i = 0; i < toolCalls.length; i++) {
|
||||
await sql`
|
||||
INSERT INTO message_parts (message_id, sequence, kind, payload)
|
||||
VALUES (${messageId}, ${i}, 'tool_call', ${sql.json(toolCalls[i] as never)})
|
||||
`;
|
||||
}
|
||||
return messageId;
|
||||
}
|
||||
|
||||
it('returns empty when no tool calls exist for a tool name', async () => {
|
||||
@@ -197,18 +206,17 @@ describeFn('tool_cost_stats view (v1.13.10)', () => {
|
||||
|
||||
it('reads tool_calls via messages_with_parts (parts-authoritative)', async () => {
|
||||
const t = tname('parts');
|
||||
// Insert an assistant row with messages.tool_calls=NULL but a
|
||||
// message_parts row carrying the tool_call. The view reads via
|
||||
// messages_with_parts, which COALESCEs the parts table over the legacy
|
||||
// column — so this row should still aggregate.
|
||||
// v1.13.20: post-column-drop the only source for tool_calls is
|
||||
// message_parts. This test asserts the same path the view always took
|
||||
// (parts-derived), now that the legacy column COALESCE fallback is gone.
|
||||
const rows = await sql<{ id: string }[]>`
|
||||
INSERT INTO messages (
|
||||
session_id, chat_id, role, content, kind, status,
|
||||
tool_calls, tokens_used, ctx_used
|
||||
tokens_used, ctx_used
|
||||
)
|
||||
VALUES (
|
||||
${sessionId}, ${chatId}, 'assistant', '', 'message', 'complete',
|
||||
NULL, 200, 5000
|
||||
200, 5000
|
||||
)
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
@@ -110,7 +110,6 @@ export async function executeToolPhase(
|
||||
UPDATE messages
|
||||
SET content = ${content},
|
||||
status = 'complete',
|
||||
tool_calls = ${ctx.sql.json(toolCalls as never)},
|
||||
tokens_used = ${completionTokens},
|
||||
ctx_used = ${promptTokens},
|
||||
ctx_max = ${nCtx},
|
||||
@@ -118,15 +117,11 @@ export async function executeToolPhase(
|
||||
WHERE id = ${assistantMessageId}
|
||||
RETURNING tokens_used, ctx_used, ctx_max, finished_at
|
||||
`;
|
||||
// v1.13.0: dual-write to message_parts. v1.13.1-B made parts authoritative
|
||||
// for reads via the messages_with_parts view; the JSON column write above
|
||||
// remains for v1.13.1 fallback compatibility (dropped in v1.13.2).
|
||||
// v1.13.20: message_parts is the sole source of truth for tool_calls.
|
||||
// Legacy messages.tool_calls column was dropped; reads route through the
|
||||
// messages_with_parts view.
|
||||
// v1.13.1-C: include result.reasoning so models with separate reasoning
|
||||
// channels (qwen3.6) get a kind='reasoning' part at sequence 0.
|
||||
// TODO(v1.13.1): wrap the UPDATE above and this insertParts in a single
|
||||
// sql.begin before flipping read authority to message_parts. Without the
|
||||
// transaction, a crash between the two leaves an orphan message that
|
||||
// becomes invisible in the parts-authoritative read path.
|
||||
await insertParts(
|
||||
ctx.sql,
|
||||
partsFromAssistantMessage({
|
||||
@@ -192,16 +187,9 @@ export async function executeToolPhase(
|
||||
if (tc.name === 'ask_user_input') {
|
||||
pausingForUserInput = true;
|
||||
const sentinel = { tool_call_id: tc.id, output: null, truncated: false };
|
||||
await ctx.sql`
|
||||
UPDATE messages
|
||||
SET tool_results = ${ctx.sql.json(sentinel as never)}
|
||||
WHERE id = ${toolMessageId}
|
||||
`;
|
||||
// v1.13.0: mirror the pending sentinel into message_parts. The
|
||||
// answer-endpoint UPDATE later (messages.ts:576) will delete and
|
||||
// re-insert this part when the user submits their answer.
|
||||
// TODO(v1.13.1): wrap the INSERT + UPDATE + insertParts triple in
|
||||
// a per-iteration sql.begin before flipping read authority.
|
||||
// v1.13.20: parts-only. The answer-endpoint UPDATE later
|
||||
// (messages.ts) will delete and re-insert this part when the user
|
||||
// submits their answer.
|
||||
await insertParts(
|
||||
ctx.sql,
|
||||
partsFromToolMessage({ tool_results: sentinel }).map((p) => ({
|
||||
@@ -234,11 +222,7 @@ export async function executeToolPhase(
|
||||
output: `denied: ${resolution.reason}`,
|
||||
truncated: false,
|
||||
};
|
||||
await ctx.sql`
|
||||
UPDATE messages
|
||||
SET tool_results = ${ctx.sql.json(stored as never)}
|
||||
WHERE id = ${toolMessageId}
|
||||
`;
|
||||
// v1.13.20: parts-only write.
|
||||
await insertParts(
|
||||
ctx.sql,
|
||||
partsFromToolMessage({ tool_results: stored }).map((p) => ({
|
||||
@@ -261,11 +245,7 @@ export async function executeToolPhase(
|
||||
// (state may have changed in the meantime) so we don't stash it here.
|
||||
pausingForUserInput = true;
|
||||
const sentinel = { tool_call_id: tc.id, output: null, truncated: false };
|
||||
await ctx.sql`
|
||||
UPDATE messages
|
||||
SET tool_results = ${ctx.sql.json(sentinel as never)}
|
||||
WHERE id = ${toolMessageId}
|
||||
`;
|
||||
// v1.13.20: parts-only write.
|
||||
await insertParts(
|
||||
ctx.sql,
|
||||
partsFromToolMessage({ tool_results: sentinel }).map((p) => ({
|
||||
@@ -285,14 +265,7 @@ export async function executeToolPhase(
|
||||
truncated: tres.truncated,
|
||||
...(tres.error ? { error: tres.error } : {}),
|
||||
};
|
||||
await ctx.sql`
|
||||
UPDATE messages
|
||||
SET tool_results = ${ctx.sql.json(stored as never)}
|
||||
WHERE id = ${toolMessageId}
|
||||
`;
|
||||
// v1.13.0: dual-write the tool_result part.
|
||||
// TODO(v1.13.1): wrap the INSERT + UPDATE + insertParts triple in a
|
||||
// per-iteration sql.begin before flipping read authority.
|
||||
// v1.13.20: parts-only write. Reads route through messages_with_parts.
|
||||
await insertParts(
|
||||
ctx.sql,
|
||||
partsFromToolMessage({ tool_results: stored }).map((p) => ({
|
||||
|
||||
Reference in New Issue
Block a user