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>
172 lines
6.6 KiB
TypeScript
172 lines
6.6 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import type { FastifyInstance } from 'fastify';
|
|
import { z } from 'zod';
|
|
import type { Sql } from '../db.js';
|
|
import type { Chat } from '../types/api.js';
|
|
import { getSkillBody, listSkills } from '../services/skills.js';
|
|
|
|
// Batch 9.6 slash-invoke handlers. Mirrors the MessageHandlers shape in
|
|
// routes/messages.ts so index.ts can pass thin adapters around broker +
|
|
// inference runner without skills.ts importing them directly.
|
|
export interface SkillInvokeHandlers {
|
|
enqueueInference: (
|
|
sessionId: string,
|
|
chatId: string,
|
|
assistantMessageId: string,
|
|
user: string,
|
|
) => void;
|
|
publishUserMessage: (
|
|
sessionId: string,
|
|
chatId: string,
|
|
userMessageId: string,
|
|
content: string,
|
|
) => void;
|
|
publishSessionFrame: (
|
|
sessionId: string,
|
|
frame: Record<string, unknown> & { type: string },
|
|
) => void;
|
|
}
|
|
|
|
const SkillInvokeBody = z.object({
|
|
skill_name: z.string().min(1),
|
|
// Optional — server fills in a default if absent or whitespace-only so the
|
|
// model always has something to act on (matches the spec's "Apply this
|
|
// skill." filler).
|
|
user_message: z.string().max(64_000).nullable().optional(),
|
|
});
|
|
|
|
const DEFAULT_USER_MESSAGE = 'Apply this skill.';
|
|
|
|
export function registerSkillsRoutes(
|
|
app: FastifyInstance,
|
|
sql: Sql,
|
|
handlers: SkillInvokeHandlers,
|
|
): void {
|
|
// Debug/admin surface — the model interacts with skills via the three
|
|
// skill_* tools, not through this endpoint.
|
|
app.get('/api/skills', async () => {
|
|
return { skills: await listSkills() };
|
|
});
|
|
|
|
// POST /api/chats/:id/skill_invoke — slash-command entry point. Loads the
|
|
// skill body server-side (clients never get to forge file content),
|
|
// persists 4 messages in one transaction (synthetic assistant tool_use,
|
|
// synthetic tool result, real user message, streaming assistant), and
|
|
// enqueues inference against the updated history.
|
|
app.post<{ Params: { id: string } }>(
|
|
'/api/chats/:id/skill_invoke',
|
|
async (req, reply) => {
|
|
const parsed = SkillInvokeBody.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
reply.code(400);
|
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
|
}
|
|
const { skill_name } = parsed.data;
|
|
const userText = parsed.data.user_message?.trim() ? parsed.data.user_message : DEFAULT_USER_MESSAGE;
|
|
|
|
const chatRows = await sql<Chat[]>`
|
|
SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
|
|
`;
|
|
if (chatRows.length === 0) {
|
|
reply.code(404);
|
|
return { error: 'chat not found' };
|
|
}
|
|
const chat = chatRows[0]!;
|
|
const sessionId = chat.session_id;
|
|
|
|
const body = await getSkillBody(skill_name);
|
|
if (body === null) {
|
|
reply.code(404);
|
|
return { error: 'unknown_skill', message: `unknown skill: ${skill_name}` };
|
|
}
|
|
|
|
const toolCallId = randomUUID();
|
|
const toolCalls = [{ id: toolCallId, name: 'skill_use', args: { name: skill_name } }];
|
|
const toolResults = { tool_call_id: toolCallId, output: body, truncated: false };
|
|
|
|
const result = await sql.begin(async (tx) => {
|
|
const [synthAssistant] = await tx<{ id: string }[]>`
|
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
|
VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'complete', clock_timestamp())
|
|
RETURNING id
|
|
`;
|
|
// 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({
|
|
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, status, created_at)
|
|
VALUES (${sessionId}, ${chat.id}, 'tool', '', 'complete', clock_timestamp())
|
|
RETURNING id
|
|
`;
|
|
// 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)})
|
|
`;
|
|
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())
|
|
RETURNING id
|
|
`;
|
|
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())
|
|
RETURNING id
|
|
`;
|
|
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
|
|
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
|
|
return {
|
|
synth_assistant_id: synthAssistant!.id,
|
|
tool_message_id: toolMsg!.id,
|
|
user_message_id: userMsg!.id,
|
|
assistant_message_id: assistantMsg!.id,
|
|
};
|
|
});
|
|
|
|
// Synthetic frames so useSessionStream's reducer reflects the new
|
|
// history without a refetch. Frame shapes match the streaming-inference
|
|
// protocol (see services/inference.ts InferenceFrame).
|
|
handlers.publishSessionFrame(sessionId, {
|
|
type: 'message_started',
|
|
message_id: result.synth_assistant_id,
|
|
chat_id: chat.id,
|
|
role: 'assistant',
|
|
});
|
|
handlers.publishSessionFrame(sessionId, {
|
|
type: 'tool_call',
|
|
message_id: result.synth_assistant_id,
|
|
chat_id: chat.id,
|
|
tool_call: toolCalls[0]!,
|
|
});
|
|
handlers.publishSessionFrame(sessionId, {
|
|
type: 'message_complete',
|
|
message_id: result.synth_assistant_id,
|
|
chat_id: chat.id,
|
|
});
|
|
// The tool_result frame's reducer branch creates the tool-role message
|
|
// in-place when it doesn't already exist — no separate message_started
|
|
// is needed for the tool side.
|
|
handlers.publishSessionFrame(sessionId, {
|
|
type: 'tool_result',
|
|
tool_message_id: result.tool_message_id,
|
|
tool_call_id: toolCallId,
|
|
chat_id: chat.id,
|
|
output: body,
|
|
truncated: false,
|
|
});
|
|
handlers.publishUserMessage(sessionId, chat.id, result.user_message_id, userText);
|
|
handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default');
|
|
|
|
reply.code(202);
|
|
return result;
|
|
},
|
|
);
|
|
}
|