- Ember theme (Obsidian charcoal + #ff7a18 orange), now DEFAULT_THEME_ID; server theme_id whitelist gains 'ember' - Brand banner: transparent Westie mascot + >_BooCode wordmark, big/edge-to-edge (flood-filled to transparency + cropped) - Coder panes are multi-tab: + opens a BooCode tab, split opens a pane (shared ChatTabBar via tabKind + createCoderTab; closeOtherTabs/tab-numbering extended to coder) - Model-attribution: new messages.model column stamped at finalizeCompletion (BooChat/native coder) + dispatcher assistant-row creation (external coder); surfaced via view + wire types + live frame; rendered as a subtle shortened-name chip (shortenModelName) - Composer Web toggle moved into a boxed focus-ringed input; glowing accent dot on tool rows - Claude SDK follow-ups (1M context, follow-up-message fix, collapsed thinking/tool chips) + CLAUDE_SDK_BACKEND=1 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
875 lines
33 KiB
TypeScript
875 lines
33 KiB
TypeScript
import type { FastifyInstance } from 'fastify';
|
|
import { z } from 'zod';
|
|
import type { Sql } from '../db.js';
|
|
import type { Config } from '../config.js';
|
|
import type { Broker } from '../services/broker.js';
|
|
import type { Chat, Message, Session, ToolCall } from '../types/api.js';
|
|
// v1.13.17-cross-repo-reads: grant_read_access resolves the grant root at
|
|
// decision time (not at request time) so concurrent project changes don't
|
|
// stale-bind the resolution.
|
|
import { resolveGrantRoot } from '../services/grant_resolver.js';
|
|
|
|
const SendBody = z.object({
|
|
content: z.string().min(1).max(64_000),
|
|
});
|
|
|
|
// v1.8.2: Continue extends an inference loop that hit the tool budget. Caller
|
|
// passes the sentinel message it's continuing from; server validates shape
|
|
// and the per-chat hard ceiling before resuming.
|
|
const ContinueBody = z.object({
|
|
sentinel_message_id: z.string().uuid(),
|
|
});
|
|
|
|
// Batch 9.7: ask_user_input answer submission. Defensive shape — the question
|
|
// content is echoed back for traceability but the server does NOT trust it
|
|
// (the source of truth is the assistant message's tool_calls.args.questions).
|
|
const AnswerUserInputBody = z.object({
|
|
tool_call_id: z.string().min(1),
|
|
answers: z
|
|
.array(
|
|
z.object({
|
|
question: z.string(),
|
|
selected_options: z.array(z.string()),
|
|
free_text: z.string().nullable(),
|
|
}),
|
|
)
|
|
.min(1)
|
|
.max(3),
|
|
});
|
|
|
|
// Same shape the model declared via the tool's zod input. Re-derived here so
|
|
// the route can validate args without depending on services/tools.ts (which
|
|
// would pull in fs/path_guard for nothing).
|
|
const AskUserInputArgs = z.object({
|
|
questions: z
|
|
.array(
|
|
z.object({
|
|
question: z.string(),
|
|
type: z.enum(['single_select', 'multi_select']),
|
|
options: z.array(z.string()).min(1),
|
|
}),
|
|
)
|
|
.min(1)
|
|
.max(3),
|
|
});
|
|
|
|
// v1.13.17-cross-repo-reads: grant decision body. tool_call_id is the
|
|
// model-emitted id (e.g. "call_abc123"), not a UUID. decision is binary.
|
|
const GrantReadAccessBody = z.object({
|
|
tool_call_id: z.string().min(1),
|
|
decision: z.enum(['allow', 'deny']),
|
|
});
|
|
|
|
// Same shape as services/request_read_access.ts RequestReadAccessInput.
|
|
// Re-derived to avoid the services/tools.ts import (matches the
|
|
// AskUserInputArgs pattern above).
|
|
const RequestReadAccessArgs = z.object({
|
|
path: z.string().min(1),
|
|
reason: z.string().min(1).max(500),
|
|
});
|
|
|
|
interface MessageHandlers {
|
|
enqueueInference: (sessionId: string, chatId: string, assistantMessageId: string, user: string) => void;
|
|
// v1.11: returns a promise that resolves after compaction.process finishes
|
|
// (await the LLM call). Throws on failure — the route surfaces a 500.
|
|
// Replaces the v1.10 enqueueCompact (which fired-and-forgot a kind='compact'
|
|
// streaming row). The new anchored-rolling strategy inserts a single
|
|
// summary=true assistant row only after the LLM responds.
|
|
runCompaction: (chatId: string) => Promise<void>;
|
|
publishUserMessage: (
|
|
sessionId: string,
|
|
chatId: string,
|
|
userMessageId: string,
|
|
content: string
|
|
) => void;
|
|
publishMessagesDeleted: (sessionId: string, chatId: string, messageIds: string[]) => void;
|
|
// Batch 9.7: lets the answer endpoint emit the tool_result frame that the
|
|
// pause path intentionally skipped. Matches SkillInvokeHandlers in
|
|
// routes/skills.ts so index.ts can pass the same broker.publish adapter.
|
|
publishSessionFrame: (
|
|
sessionId: string,
|
|
frame: Record<string, unknown> & { type: string }
|
|
) => void;
|
|
cancelInference: (sessionId: string, chatId: string) => Promise<boolean>;
|
|
hasActiveInference: (chatId: string) => boolean;
|
|
}
|
|
|
|
export function registerMessageRoutes(
|
|
app: FastifyInstance,
|
|
sql: Sql,
|
|
config: Config,
|
|
broker: Broker,
|
|
handlers: MessageHandlers
|
|
): void {
|
|
app.get<{ Params: { id: string } }>(
|
|
'/api/sessions/:id/messages',
|
|
async (req, reply) => {
|
|
const session = await sql<Session[]>`SELECT id FROM sessions WHERE id = ${req.params.id}`;
|
|
if (session.length === 0) {
|
|
reply.code(404);
|
|
return { error: 'session not found' };
|
|
}
|
|
// v1.11: returns ALL messages including compacted ones. The UI
|
|
// distinguishes via the new `summary` flag (renders an accordion
|
|
// SummaryCard) and shows compacted_at-stamped rows inline for context.
|
|
// Internal inference assembly filters compacted_at IS NULL separately —
|
|
// see services/inference.ts loadContext + services/compaction.ts.
|
|
// v1.13.1-B: reads tool_calls/tool_results via the parts-merged view.
|
|
const rows = 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, model
|
|
FROM messages_with_parts
|
|
WHERE session_id = ${req.params.id}
|
|
ORDER BY created_at ASC, id ASC
|
|
`;
|
|
return rows;
|
|
}
|
|
);
|
|
|
|
app.post<{ Params: { id: string } }>(
|
|
'/api/chats/:id/messages',
|
|
async (req, reply) => {
|
|
const parsed = SendBody.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
reply.code(400);
|
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
|
}
|
|
|
|
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 result = await sql.begin(async (tx) => {
|
|
const [userMsg] = await tx<{ id: string }[]>`
|
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
|
VALUES (${sessionId}, ${chat.id}, 'user', ${parsed.data.content}, '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 { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };
|
|
});
|
|
|
|
handlers.publishUserMessage(
|
|
sessionId,
|
|
chat.id,
|
|
result.user_message_id,
|
|
parsed.data.content
|
|
);
|
|
handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default');
|
|
|
|
reply.code(202);
|
|
return result;
|
|
}
|
|
);
|
|
|
|
app.post<{ Params: { id: string; message_id: string } }>(
|
|
'/api/chats/:id/messages/:message_id/regenerate',
|
|
async (req, reply) => {
|
|
const { id: chatId, message_id: targetId } = req.params;
|
|
|
|
const chatRows = await sql<Chat[]>`
|
|
SELECT id, session_id FROM chats WHERE id = ${chatId}
|
|
`;
|
|
if (chatRows.length === 0) {
|
|
reply.code(404);
|
|
return { error: 'chat not found' };
|
|
}
|
|
const chat = chatRows[0]!;
|
|
const sessionId = chat.session_id;
|
|
|
|
const target = await sql<{ id: string; role: string; status: string }[]>`
|
|
SELECT id, role, status
|
|
FROM messages
|
|
WHERE chat_id = ${chatId} AND id = ${targetId}
|
|
`;
|
|
if (target.length === 0) {
|
|
reply.code(404);
|
|
return { error: 'message not found' };
|
|
}
|
|
const targetRow = target[0]!;
|
|
if (targetRow.role !== 'assistant') {
|
|
reply.code(400);
|
|
return { error: 'only assistant messages can be regenerated' };
|
|
}
|
|
if (targetRow.status === 'streaming') {
|
|
reply.code(409);
|
|
return { error: 'message is still streaming' };
|
|
}
|
|
|
|
const { newAssistantId, deletedIds } = await sql.begin(async (tx) => {
|
|
const deletedRows = await tx<{ id: string }[]>`
|
|
DELETE FROM messages
|
|
WHERE chat_id = ${chatId}
|
|
AND created_at >= (
|
|
SELECT created_at FROM messages WHERE id = ${targetId}
|
|
)
|
|
RETURNING id
|
|
`;
|
|
const [row] = await tx<{ id: string }[]>`
|
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
|
VALUES (${sessionId}, ${chatId}, '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 = ${chatId}`;
|
|
return {
|
|
newAssistantId: row!.id,
|
|
deletedIds: deletedRows.map((r) => r.id),
|
|
};
|
|
});
|
|
|
|
handlers.publishMessagesDeleted(sessionId, chatId, deletedIds);
|
|
handlers.enqueueInference(sessionId, chatId, newAssistantId, 'default');
|
|
|
|
reply.code(202);
|
|
return { assistant_message_id: newAssistantId };
|
|
}
|
|
);
|
|
|
|
app.delete<{ Params: { id: string; message_id: string } }>(
|
|
'/api/chats/:id/messages/:message_id',
|
|
async (req, reply) => {
|
|
const { id: chatId, message_id: messageId } = req.params;
|
|
|
|
const chatRows = await sql<Chat[]>`
|
|
SELECT id, session_id FROM chats WHERE id = ${chatId}
|
|
`;
|
|
if (chatRows.length === 0) {
|
|
reply.code(404);
|
|
return { error: 'chat not found' };
|
|
}
|
|
const chat = chatRows[0]!;
|
|
|
|
if (handlers.hasActiveInference(chatId)) {
|
|
reply.code(409);
|
|
return { error: 'chat is currently streaming; stop it first' };
|
|
}
|
|
|
|
const deletedIds = await sql.begin(async (tx) => {
|
|
const deletedRows = await tx<{ id: string }[]>`
|
|
DELETE FROM messages
|
|
WHERE chat_id = ${chatId}
|
|
AND created_at >= (
|
|
SELECT created_at FROM messages
|
|
WHERE id = ${messageId} AND chat_id = ${chatId}
|
|
)
|
|
RETURNING id
|
|
`;
|
|
if (deletedRows.length > 0) {
|
|
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
|
|
}
|
|
return deletedRows.map((r) => r.id);
|
|
});
|
|
|
|
if (deletedIds.length === 0) {
|
|
reply.code(404);
|
|
return { error: 'message not found' };
|
|
}
|
|
|
|
handlers.publishMessagesDeleted(chat.session_id, chatId, deletedIds);
|
|
|
|
reply.code(204);
|
|
return null;
|
|
}
|
|
);
|
|
|
|
// v1.11: manual /compact. Was a streaming kind='compact' row inserted by
|
|
// this handler; now delegates to the anchored-rolling compaction service.
|
|
// Synchronous (we await the LLM call) — callers either await or rely on
|
|
// the 'compacted' WS frame to refresh their view. The response carries
|
|
// no body of interest; the new summary row arrives via the WS frame.
|
|
app.post<{ Params: { id: string } }>(
|
|
'/api/chats/:id/compact',
|
|
async (req, reply) => {
|
|
const chatRows = await sql<{ id: string }[]>`
|
|
SELECT id FROM chats WHERE id = ${req.params.id} AND status = 'open'
|
|
`;
|
|
if (chatRows.length === 0) {
|
|
reply.code(404);
|
|
return { error: 'chat not found' };
|
|
}
|
|
try {
|
|
await handlers.runCompaction(chatRows[0]!.id);
|
|
} catch (err) {
|
|
req.log.error({ err, chatId: chatRows[0]!.id }, 'manual compaction failed');
|
|
reply.code(500);
|
|
return { error: err instanceof Error ? err.message : 'compaction failed' };
|
|
}
|
|
reply.code(200);
|
|
return { ok: true };
|
|
}
|
|
);
|
|
|
|
app.post<{ Params: { id: string } }>(
|
|
'/api/chats/:id/stop',
|
|
async (req, reply) => {
|
|
const chatRows = await sql<Chat[]>`
|
|
SELECT id, session_id FROM chats WHERE id = ${req.params.id}
|
|
`;
|
|
if (chatRows.length === 0) {
|
|
reply.code(404);
|
|
return { error: 'chat not found' };
|
|
}
|
|
const chat = chatRows[0]!;
|
|
|
|
const cancelled = await handlers.cancelInference(chat.session_id, chat.id);
|
|
if (!cancelled) {
|
|
reply.code(409);
|
|
return { error: 'no active generation to stop' };
|
|
}
|
|
|
|
reply.code(200);
|
|
return { stopped: true };
|
|
}
|
|
);
|
|
|
|
app.post<{ Params: { id: string } }>(
|
|
'/api/chats/:id/continue',
|
|
async (req, reply) => {
|
|
const parsed = ContinueBody.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
reply.code(400);
|
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
|
}
|
|
|
|
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;
|
|
|
|
// Cap-hit sentinels are only ever inserted after a turn completes, so
|
|
// there must not be an active inference at this moment. If there is,
|
|
// the client is racing the cap-hit summary that just emitted the
|
|
// sentinel — bail rather than enqueue a parallel run.
|
|
if (handlers.hasActiveInference(chat.id)) {
|
|
reply.code(409);
|
|
return { error: 'chat is currently streaming' };
|
|
}
|
|
|
|
const sentinel = await sql<{ metadata: { kind?: unknown; can_continue?: unknown } | null }[]>`
|
|
SELECT metadata
|
|
FROM messages
|
|
WHERE id = ${parsed.data.sentinel_message_id}
|
|
AND chat_id = ${chat.id}
|
|
AND role = 'system'
|
|
`;
|
|
if (sentinel.length === 0) {
|
|
reply.code(404);
|
|
return { error: 'sentinel not found' };
|
|
}
|
|
const meta = sentinel[0]!.metadata;
|
|
if (!meta || meta.kind !== 'cap_hit') {
|
|
reply.code(400);
|
|
return { error: 'message is not a cap-hit sentinel' };
|
|
}
|
|
// Server-side hard ceiling check. UI already disables the button when
|
|
// can_continue is false; defending against a stale tab or a direct
|
|
// API hit is the only reason this lives on the server too.
|
|
if (meta.can_continue !== true) {
|
|
reply.code(409);
|
|
return { error: 'hard limit reached for this chat' };
|
|
}
|
|
|
|
const result = await sql.begin(async (tx) => {
|
|
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 { assistant_message_id: assistantMsg!.id };
|
|
});
|
|
|
|
handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default');
|
|
|
|
reply.code(202);
|
|
return result;
|
|
}
|
|
);
|
|
|
|
app.post<{ Params: { id: string } }>(
|
|
'/api/chats/:id/force_send',
|
|
async (req, reply) => {
|
|
const parsed = SendBody.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
reply.code(400);
|
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
|
}
|
|
|
|
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;
|
|
|
|
// Await actual cancellation completion (catch block persists state).
|
|
// 5s timeout guards against llama-swap stalls; if hit, proceed anyway.
|
|
await Promise.race([
|
|
handlers.cancelInference(sessionId, chat.id).then(() => undefined),
|
|
new Promise<void>((_, rej) =>
|
|
setTimeout(() => rej(new Error('cancel-timeout')), 5000)
|
|
),
|
|
]).catch((e: Error) => {
|
|
if (e.message !== 'cancel-timeout') throw e;
|
|
req.log.warn({ chatId: chat.id }, 'cancel timeout exceeded, proceeding with force-send');
|
|
});
|
|
|
|
const result = await sql.begin(async (tx) => {
|
|
const [userMsg] = await tx<{ id: string }[]>`
|
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
|
VALUES (${sessionId}, ${chat.id}, 'user', ${parsed.data.content}, '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 { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };
|
|
});
|
|
|
|
handlers.publishUserMessage(
|
|
sessionId,
|
|
chat.id,
|
|
result.user_message_id,
|
|
parsed.data.content
|
|
);
|
|
handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default');
|
|
|
|
reply.code(202);
|
|
return result;
|
|
}
|
|
);
|
|
|
|
// Batch 9.7: resume an ask_user_input pause. Validates the body matches the
|
|
// question shape the model declared, UPDATEs the pending tool row's
|
|
// tool_results to the AnswerSet, publishes the deferred tool_result frame,
|
|
// and enqueues the next assistant turn. Error codes per spec:
|
|
// 400 invalid_body / mismatched_answer_shape
|
|
// 404 chat_not_found / unknown_tool_call_id
|
|
// 409 tool_call_already_answered
|
|
app.post<{ Params: { id: string } }>(
|
|
'/api/chats/:id/answer_user_input',
|
|
async (req, reply) => {
|
|
const parsed = AnswerUserInputBody.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
reply.code(400);
|
|
return { error: 'invalid_body', details: parsed.error.flatten() };
|
|
}
|
|
const { tool_call_id, answers } = parsed.data;
|
|
|
|
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;
|
|
|
|
// v1.13.1-C: find the assistant's tool_call by indexing message_parts
|
|
// directly on payload->>'id'. Scoped by chat_id + role via the JOIN.
|
|
// Pre-v1.13.0 history has no parts rows — those tool_calls become
|
|
// unreachable here (404). Acceptable per the dispatch decision: any
|
|
// pending elicitation from before v1.13.0 is long timed out by now;
|
|
// promote to a hotfix with a JSON-column fallback if it ever surfaces.
|
|
const callerRows = await sql<{
|
|
message_id: string;
|
|
payload: { id: string; name: string; args: Record<string, unknown> };
|
|
}[]>`
|
|
SELECT p.message_id, p.payload
|
|
FROM message_parts p
|
|
JOIN messages m ON m.id = p.message_id
|
|
WHERE m.chat_id = ${chat.id}
|
|
AND m.role = 'assistant'
|
|
AND p.kind = 'tool_call'
|
|
AND p.payload->>'id' = ${tool_call_id}
|
|
ORDER BY m.created_at DESC
|
|
LIMIT 1
|
|
`;
|
|
const callerRow = callerRows[0];
|
|
if (!callerRow) {
|
|
reply.code(404);
|
|
return { error: 'unknown_tool_call_id' };
|
|
}
|
|
const foundCall: ToolCall = {
|
|
id: callerRow.payload.id,
|
|
name: callerRow.payload.name,
|
|
args: callerRow.payload.args,
|
|
};
|
|
if (foundCall.name !== 'ask_user_input') {
|
|
reply.code(400);
|
|
return { error: 'tool_call_not_ask_user_input' };
|
|
}
|
|
|
|
// Validate the args themselves — the LLM could have emitted bad JSON.
|
|
const argsParsed = AskUserInputArgs.safeParse(foundCall.args);
|
|
if (!argsParsed.success) {
|
|
reply.code(400);
|
|
return { error: 'mismatched_answer_shape', detail: 'tool_call args invalid' };
|
|
}
|
|
const questions = argsParsed.data.questions;
|
|
if (answers.length !== questions.length) {
|
|
reply.code(400);
|
|
return {
|
|
error: 'mismatched_answer_shape',
|
|
detail: `expected ${questions.length} answer(s), got ${answers.length}`,
|
|
};
|
|
}
|
|
for (let i = 0; i < questions.length; i++) {
|
|
const q = questions[i]!;
|
|
const a = answers[i]!;
|
|
for (const sel of a.selected_options) {
|
|
if (!q.options.includes(sel)) {
|
|
reply.code(400);
|
|
return {
|
|
error: 'mismatched_answer_shape',
|
|
detail: `answer ${i + 1} contains option not in question: ${sel}`,
|
|
};
|
|
}
|
|
}
|
|
if (q.type === 'single_select' && a.selected_options.length > 1) {
|
|
reply.code(400);
|
|
return {
|
|
error: 'mismatched_answer_shape',
|
|
detail: `answer ${i + 1} has multiple selections on single_select`,
|
|
};
|
|
}
|
|
const hasOpt = a.selected_options.length > 0;
|
|
const hasText = a.free_text !== null && a.free_text.trim().length > 0;
|
|
if (!hasOpt && !hasText) {
|
|
reply.code(400);
|
|
return { error: 'mismatched_answer_shape', detail: `answer ${i + 1} is empty` };
|
|
}
|
|
}
|
|
|
|
// v1.13.1-C: find the pending tool row via message_parts on
|
|
// payload->>'tool_call_id'. Same fallback caveat as the caller lookup
|
|
// above — pre-v1.13.0 rows are unreachable here.
|
|
const toolRows = await sql<{
|
|
message_id: string;
|
|
payload: { tool_call_id: string; output: unknown };
|
|
}[]>`
|
|
SELECT p.message_id, p.payload
|
|
FROM message_parts p
|
|
JOIN messages m ON m.id = p.message_id
|
|
WHERE m.chat_id = ${chat.id}
|
|
AND m.role = 'tool'
|
|
AND p.kind = 'tool_result'
|
|
AND p.payload->>'tool_call_id' = ${tool_call_id}
|
|
ORDER BY m.created_at DESC
|
|
LIMIT 1
|
|
`;
|
|
const toolRow = toolRows[0];
|
|
if (!toolRow) {
|
|
reply.code(404);
|
|
return { error: 'unknown_tool_call_id', detail: 'tool message not found' };
|
|
}
|
|
if (toolRow.payload && toolRow.payload.output !== null) {
|
|
reply.code(409);
|
|
return { error: 'tool_call_already_answered' };
|
|
}
|
|
|
|
const answerSet = { answers };
|
|
const newToolResults = {
|
|
tool_call_id,
|
|
output: answerSet,
|
|
truncated: false,
|
|
};
|
|
|
|
const toolMessageId = toolRow.message_id;
|
|
const result = await sql.begin(async (tx) => {
|
|
// 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)
|
|
VALUES (${toolMessageId}, 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())
|
|
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 {
|
|
tool_message_id: toolMessageId,
|
|
assistant_message_id: assistantMsg!.id,
|
|
};
|
|
});
|
|
|
|
// Publish the deferred tool_result frame. useSessionStream's reducer
|
|
// updates the matching tool_run.result so AskUserInputCard flips into
|
|
// its read-only "answered" mode without a refetch.
|
|
handlers.publishSessionFrame(sessionId, {
|
|
type: 'tool_result',
|
|
tool_message_id: result.tool_message_id,
|
|
tool_call_id,
|
|
chat_id: chat.id,
|
|
output: answerSet,
|
|
truncated: false,
|
|
});
|
|
handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default');
|
|
|
|
reply.code(202);
|
|
return result;
|
|
},
|
|
);
|
|
|
|
// v1.13.17-cross-repo-reads: resume an awaiting-grant pause. Mirror shape
|
|
// of /answer_user_input (validate, look up via message_parts, UPDATE,
|
|
// publish, enqueue). Differences vs /answer_user_input:
|
|
// - On 'allow', re-resolves the grant root via grant_resolver (state
|
|
// may have changed since the prompt fired — concurrent project add,
|
|
// etc.). Resolution failure auto-falls to a denial with reason text
|
|
// rather than 500ing.
|
|
// - On 'allow' with a valid root, appends to sessions.allowed_read_paths
|
|
// (deduplicated) inside the same transaction.
|
|
// - On success, also publishes session_updated so an open SettingsPane
|
|
// refetches the new grant list.
|
|
// Error codes match /answer:
|
|
// 400 invalid_body / mismatched_answer_shape (bad args on the tool_call)
|
|
// 404 chat_not_found / unknown_tool_call_id
|
|
// 409 tool_call_already_answered
|
|
app.post<{ Params: { id: string } }>(
|
|
'/api/chats/:id/grant_read_access',
|
|
async (req, reply) => {
|
|
const parsed = GrantReadAccessBody.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
reply.code(400);
|
|
return { error: 'invalid_body', details: parsed.error.flatten() };
|
|
}
|
|
const { tool_call_id, decision } = parsed.data;
|
|
|
|
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;
|
|
|
|
// Mirror the /answer lookup: assistant tool_call by id via message_parts.
|
|
const callerRows = await sql<{
|
|
message_id: string;
|
|
payload: { id: string; name: string; args: Record<string, unknown> };
|
|
}[]>`
|
|
SELECT p.message_id, p.payload
|
|
FROM message_parts p
|
|
JOIN messages m ON m.id = p.message_id
|
|
WHERE m.chat_id = ${chat.id}
|
|
AND m.role = 'assistant'
|
|
AND p.kind = 'tool_call'
|
|
AND p.payload->>'id' = ${tool_call_id}
|
|
ORDER BY m.created_at DESC
|
|
LIMIT 1
|
|
`;
|
|
const callerRow = callerRows[0];
|
|
if (!callerRow) {
|
|
reply.code(404);
|
|
return { error: 'unknown_tool_call_id' };
|
|
}
|
|
const foundCall: ToolCall = {
|
|
id: callerRow.payload.id,
|
|
name: callerRow.payload.name,
|
|
args: callerRow.payload.args,
|
|
};
|
|
if (foundCall.name !== 'request_read_access') {
|
|
reply.code(400);
|
|
return { error: 'tool_call_not_request_read_access' };
|
|
}
|
|
const argsParsed = RequestReadAccessArgs.safeParse(foundCall.args);
|
|
if (!argsParsed.success) {
|
|
reply.code(400);
|
|
return { error: 'mismatched_answer_shape', detail: 'tool_call args invalid' };
|
|
}
|
|
const requestedPath = argsParsed.data.path;
|
|
|
|
// Find the pending tool row.
|
|
const toolRows = await sql<{
|
|
message_id: string;
|
|
payload: { tool_call_id: string; output: unknown };
|
|
}[]>`
|
|
SELECT p.message_id, p.payload
|
|
FROM message_parts p
|
|
JOIN messages m ON m.id = p.message_id
|
|
WHERE m.chat_id = ${chat.id}
|
|
AND m.role = 'tool'
|
|
AND p.kind = 'tool_result'
|
|
AND p.payload->>'tool_call_id' = ${tool_call_id}
|
|
ORDER BY m.created_at DESC
|
|
LIMIT 1
|
|
`;
|
|
const toolRow = toolRows[0];
|
|
if (!toolRow) {
|
|
reply.code(404);
|
|
return { error: 'unknown_tool_call_id', detail: 'tool message not found' };
|
|
}
|
|
if (toolRow.payload && toolRow.payload.output !== null) {
|
|
reply.code(409);
|
|
return { error: 'tool_call_already_answered' };
|
|
}
|
|
|
|
// Look up session + project so we can re-resolve the grant root and
|
|
// append to allowed_read_paths atomically. We don't need agent or
|
|
// history here — just the project path for the resolver.
|
|
const sessionRows = await sql<{
|
|
id: string;
|
|
project_id: string;
|
|
allowed_read_paths: string[];
|
|
project_path: string;
|
|
}[]>`
|
|
SELECT s.id, s.project_id, s.allowed_read_paths, p.path AS project_path
|
|
FROM sessions s
|
|
JOIN projects p ON p.id = s.project_id
|
|
WHERE s.id = ${sessionId}
|
|
`;
|
|
const sessionRow = sessionRows[0];
|
|
if (!sessionRow) {
|
|
reply.code(404);
|
|
return { error: 'session_not_found' };
|
|
}
|
|
|
|
// Decision branch. 'deny' is the easy path: nothing to resolve or
|
|
// persist. 'allow' resolves the grant root; if resolution fails (e.g.
|
|
// path was deleted, project removed since prompt) the tool gets a
|
|
// denial with the resolver's reason text instead of a 500.
|
|
let resultOutput: string;
|
|
let grantRoot: string | null = null;
|
|
if (decision === 'allow') {
|
|
const resolution = await resolveGrantRoot(
|
|
sql,
|
|
requestedPath,
|
|
sessionRow.project_path,
|
|
config.PROJECT_ROOT_WHITELIST,
|
|
);
|
|
if (!resolution.ok) {
|
|
resultOutput = `denied: ${resolution.reason}`;
|
|
} else {
|
|
grantRoot = resolution.root;
|
|
resultOutput = `granted: ${grantRoot}`;
|
|
}
|
|
} else {
|
|
resultOutput = 'denied';
|
|
}
|
|
|
|
const newToolResults = {
|
|
tool_call_id,
|
|
output: resultOutput,
|
|
truncated: false,
|
|
};
|
|
const toolMessageId = toolRow.message_id;
|
|
const dbResult = await sql.begin(async (tx) => {
|
|
// 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)
|
|
VALUES (${toolMessageId}, 0, 'tool_result', ${tx.json(newToolResults as never)})
|
|
`;
|
|
// Persist the grant if we have one. ARRAY-level dedup — append only
|
|
// when the root isn't already present. The session row gets
|
|
// touched (updated_at) so the post-update publish below has a
|
|
// fresh timestamp.
|
|
let allowedRootsAfter = sessionRow.allowed_read_paths;
|
|
if (grantRoot !== null) {
|
|
if (!sessionRow.allowed_read_paths.includes(grantRoot)) {
|
|
const updated = await tx<{ allowed_read_paths: string[] }[]>`
|
|
UPDATE sessions
|
|
SET allowed_read_paths = array_append(allowed_read_paths, ${grantRoot}),
|
|
updated_at = clock_timestamp()
|
|
WHERE id = ${sessionId}
|
|
RETURNING allowed_read_paths
|
|
`;
|
|
allowedRootsAfter = updated[0]?.allowed_read_paths ?? sessionRow.allowed_read_paths;
|
|
} else {
|
|
// Already present — touch updated_at so any open settings
|
|
// panel still picks up the no-op via session_updated.
|
|
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
|
|
}
|
|
}
|
|
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 chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
|
|
return {
|
|
tool_message_id: toolMessageId,
|
|
assistant_message_id: assistantMsg!.id,
|
|
allowed_roots_after: allowedRootsAfter,
|
|
};
|
|
});
|
|
|
|
// Publish the deferred tool_result frame so the pending card flips to
|
|
// its answered view without a refetch.
|
|
handlers.publishSessionFrame(sessionId, {
|
|
type: 'tool_result',
|
|
tool_message_id: dbResult.tool_message_id,
|
|
tool_call_id,
|
|
chat_id: chat.id,
|
|
output: resultOutput,
|
|
truncated: false,
|
|
});
|
|
// session_updated nudge so any open SettingsPane refetches and sees
|
|
// the new allowed_read_paths. We publish on the user channel to match
|
|
// the existing PATCH /api/sessions/:id behavior — frontend refetches
|
|
// via api.sessions.get on receipt.
|
|
const nowIso = new Date().toISOString();
|
|
broker.publishUserFrame('default', {
|
|
type: 'session_updated',
|
|
session_id: sessionId,
|
|
project_id: sessionRow.project_id,
|
|
// session name doesn't change on grant; we look it up fresh to
|
|
// avoid carrying stale state if a rename raced us.
|
|
name:
|
|
(
|
|
await sql<{ name: string }[]>`SELECT name FROM sessions WHERE id = ${sessionId}`
|
|
)[0]?.name ?? '',
|
|
updated_at: nowIso,
|
|
});
|
|
handlers.enqueueInference(sessionId, chat.id, dbResult.assistant_message_id, 'default');
|
|
|
|
reply.code(202);
|
|
return {
|
|
tool_message_id: dbResult.tool_message_id,
|
|
assistant_message_id: dbResult.assistant_message_id,
|
|
allowed_read_paths: dbResult.allowed_roots_after,
|
|
};
|
|
},
|
|
);
|
|
}
|