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:
2026-05-23 13:03:51 +00:00
parent ad45b28250
commit 211e903620
11 changed files with 317 additions and 122 deletions

View File

@@ -296,13 +296,13 @@ export function registerChatRoutes(
`;
await tx`
INSERT INTO messages (
session_id, chat_id, role, content, kind, tool_calls, tool_results,
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,
tool_calls, tool_results, status,
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'
@@ -385,21 +385,25 @@ export function registerChatRoutes(
reply.code(409);
return { error: 'message is not stale yet', age_seconds: msg.age_seconds };
}
const updated = await sql<Message[]>`
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, 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
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,
@@ -411,7 +415,7 @@ export function registerChatRoutes(
message_id: msg.id,
chat_id: msg.chat_id,
});
return updated[0];
return refreshed[0];
}
);

View File

@@ -605,15 +605,11 @@ export function registerMessageRoutes(
const toolMessageId = toolRow.message_id;
const result = await sql.begin(async (tx) => {
await tx`
UPDATE messages
SET tool_results = ${tx.json(newToolResults as never)}
WHERE id = ${toolMessageId}
`;
// 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.
// v1.13.20: parts-only. 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 = ${toolMessageId} AND kind = 'tool_result'`;
await tx`
INSERT INTO message_parts (message_id, sequence, kind, payload)
@@ -796,13 +792,9 @@ export function registerMessageRoutes(
};
const toolMessageId = toolRow.message_id;
const dbResult = await sql.begin(async (tx) => {
await tx`
UPDATE messages
SET tool_results = ${tx.json(newToolResults as never)}
WHERE id = ${toolMessageId}
`;
// Same delete+insert dance as /answer — UNIQUE (message_id, sequence)
// blocks plain UPDATE on append-style parts.
// v1.13.20: parts-only. Same delete+insert dance as /answer —
// UNIQUE (message_id, sequence) blocks plain UPDATE on append-style
// parts.
await tx`DELETE FROM message_parts WHERE message_id = ${toolMessageId} AND kind = 'tool_result'`;
await tx`
INSERT INTO message_parts (message_id, sequence, kind, payload)

View File

@@ -86,12 +86,12 @@ export function registerSkillsRoutes(
const result = await sql.begin(async (tx) => {
const [synthAssistant] = await tx<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, tool_calls, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'assistant', '', ${sql.json(toolCalls as never)}, 'complete', clock_timestamp())
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'assistant', '', '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.
// v1.13.20: parts-only write. 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({
@@ -101,11 +101,11 @@ export function registerSkillsRoutes(
} 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())
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chat.id}, 'tool', '', 'complete', clock_timestamp())
RETURNING id
`;
// v1.13.0: dual-write the synthetic tool result (the skill body).
// v1.13.20: parts-only write of the synthetic tool result (skill body).
await tx`
INSERT INTO message_parts (message_id, sequence, kind, payload)
VALUES (${toolMsg!.id}, 0, 'tool_result', ${tx.json(toolResults as never)})

View File

@@ -97,49 +97,42 @@ END $$;
-- v1.13.1-B: read-path view. Read sites SELECT FROM messages_with_parts
-- instead of messages so tool_calls / tool_results / reasoning_parts come
-- from the granular message_parts table. The COALESCE means pre-v1.13.0
-- history (no parts rows) still resolves via the legacy JSON columns; the
-- dual-write from v1.13.0 keeps both in sync for all rows written since.
-- Writes continue to target `messages` directly — the view is read-only.
-- Shapes match the in-memory ToolCall / ToolResult types: tool_calls is a
-- jsonb array of {id, name, args}, tool_results is a single jsonb object
-- {tool_call_id, output, truncated, error?}. reasoning_parts is new — only
-- consumed by the inference history fetch (payload.ts) so v1.13.1-C can
-- wire reasoning into the model payload. Not surfaced in external APIs yet.
-- from the granular message_parts table.
-- v1.13.20: post column-drop. The legacy COALESCE fallback over
-- messages.tool_calls / messages.tool_results was removed because those
-- columns no longer exist on the table (see the ALTER TABLE DROP COLUMN
-- statements below). Writes continue to target `messages` directly — the
-- view is read-only. Shapes match the in-memory ToolCall / ToolResult
-- types: tool_calls is a jsonb array of {id, name, args}, tool_results is
-- a single jsonb object {tool_call_id, output, truncated, error?}.
-- reasoning_parts is consumed by the inference history fetch (payload.ts)
-- for v1.13.1-C reasoning round-tripping. Not surfaced in external APIs.
CREATE OR REPLACE VIEW messages_with_parts AS
SELECT
m.id, m.session_id, m.chat_id, m.role, m.content, m.kind, m.status,
m.last_seq, m.tokens_used, m.ctx_used, m.ctx_max,
m.started_at, m.finished_at, m.created_at, m.metadata,
m.summary, m.tail_start_id, m.compacted_at,
-- v1.13.4: prune semantics need to distinguish "no parts row exists"
-- (pre-v1.13.0 fallback to legacy column) from "all parts hidden"
-- (prune intended — return null/empty so the row drops from the model
-- payload). A naive COALESCE would fall back to the legacy column when
-- every part is hidden, undoing the prune. CASE on EXISTS(any kind)
-- splits the two cases.
CASE
WHEN EXISTS (SELECT 1 FROM message_parts pp
WHERE pp.message_id = m.id AND pp.kind = 'tool_call')
THEN (SELECT jsonb_agg(p.payload ORDER BY p.sequence)
FROM message_parts p
WHERE p.message_id = m.id AND p.kind = 'tool_call' AND p.hidden_at IS NULL)
ELSE m.tool_calls
END AS tool_calls,
CASE
WHEN EXISTS (SELECT 1 FROM message_parts pp
WHERE pp.message_id = m.id AND pp.kind = 'tool_result')
THEN (SELECT p.payload
FROM message_parts p
WHERE p.message_id = m.id AND p.kind = 'tool_result' AND p.hidden_at IS NULL
ORDER BY p.sequence LIMIT 1)
ELSE m.tool_results
END AS tool_results,
(SELECT jsonb_agg(p.payload ORDER BY p.sequence)
FROM message_parts p
WHERE p.message_id = m.id AND p.kind = 'tool_call' AND p.hidden_at IS NULL) AS tool_calls,
(SELECT p.payload
FROM message_parts p
WHERE p.message_id = m.id AND p.kind = 'tool_result' AND p.hidden_at IS NULL
ORDER BY p.sequence LIMIT 1) AS tool_results,
(SELECT jsonb_agg(p.payload ORDER BY p.sequence)
FROM message_parts p
WHERE p.message_id = m.id AND p.kind = 'reasoning' AND p.hidden_at IS NULL) AS reasoning_parts
FROM messages m;
-- v1.13.20: drop legacy tool_calls/tool_results columns. Reads have routed
-- through messages_with_parts since v1.13.1-B; dual-writes removed in this
-- batch. The view above was simplified to remove COALESCE fallbacks before
-- this drop (Postgres rejects column-drop on view-referenced columns).
-- Idempotent via IF EXISTS.
ALTER TABLE messages DROP COLUMN IF EXISTS tool_calls;
ALTER TABLE messages DROP COLUMN IF EXISTS tool_results;
-- v1.13.10: per-tool token cost rolling window. Derives from
-- messages_with_parts (the v1.13.1-B view that COALESCEs message_parts over
-- the legacy JSON column) so this works whether the chat predates v1.13.0
@@ -290,19 +283,6 @@ BEGIN
END IF;
END $$;
-- v1.12.1: drop stale inline CHECK constraints that were superseded by the
-- named *_chk variants above. messages_status_check missed 'cancelled' and
-- messages_role_check missed 'system' — both narrower than what's in use.
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'messages_status_check') THEN
ALTER TABLE messages DROP CONSTRAINT messages_status_check;
END IF;
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'messages_role_check') THEN
ALTER TABLE messages DROP CONSTRAINT messages_role_check;
END IF;
END $$;
-- v1.2-project-ux: projects.status + projects.gitea_remote
-- KEEP IN SYNC: apps/server/src/types/api.ts PROJECT_STATUSES
ALTER TABLE projects ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open';

View File

@@ -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
`;

View File

@@ -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) => ({