The tab (a chat) is the context unit: two opencode tabs in one session are two independent agent contexts sharing one worktree. agent_sessions re-keys from (session_id, agent) to (chat_id, agent) — chat_id FK ON DELETE CASCADE (closing a tab ends its context); worktree_id and session_id become informational SET NULL columns. New worktrees table (one-per-session, survives session delete via session_id SET NULL) supersedes session_worktrees, which is defanged (CASCADE dropped) not yet removed. chat_id is threaded end-to-end: tasks.chat_id added, written by the coder message + skills routes from the frontend tab, read by runOpenCodeServerTask which falls back to resolve-or-create a chat for session-less creators (arena/MCP/new_task/generic) so ensureSession never gets a null key. Idempotent migration with a backfill-verify gate (0-row assertion after the test session was deleted). config_hash fingerprint logic preserved; one-worktree-per-session unchanged; runExternalAgent untouched. Column rename worktree_path -> path repointed at all five readers (server delete-guard, risk/stash endpoints, ensureSessionWorktree). Supersedes the earlier (worktree_id) draft. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
125 lines
4.9 KiB
TypeScript
125 lines
4.9 KiB
TypeScript
import type { FastifyInstance } from 'fastify';
|
|
import { z } from 'zod';
|
|
import type { Sql } from '../db.js';
|
|
import type { Broker } from '@boocode/server/broker';
|
|
import type { WsFrame } from '@boocode/server/ws-frames';
|
|
import { getSkillBody } from '@boocode/server/skills';
|
|
import {
|
|
buildSkillInvokeSyntheticFrames,
|
|
buildSkillInvokeUserFrames,
|
|
DEFAULT_SKILL_USER_MESSAGE,
|
|
runSkillInvokeTransaction,
|
|
} from '@boocode/server/skill-invoke';
|
|
import { resolveChatId } from './chat-resolve.js';
|
|
|
|
const SkillInvokeBody = z.object({
|
|
pane_id: z.string().min(1).max(200),
|
|
skill_name: z.string().min(1),
|
|
user_message: z.string().max(64_000).nullable().optional(),
|
|
// v2.5.9: when set to an external provider, the skill runs UNDER that agent —
|
|
// its body is injected into a dispatched task instead of native inference.
|
|
provider: z.string().max(100).optional(),
|
|
model: z.string().max(200).optional(),
|
|
mode_id: z.string().max(200).optional(),
|
|
thinking_option_id: z.string().max(200).optional(),
|
|
});
|
|
|
|
interface InferenceApi {
|
|
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
|
|
hasActive: (chatId: string) => boolean;
|
|
}
|
|
|
|
export function registerSkillRoutes(
|
|
app: FastifyInstance,
|
|
sql: Sql,
|
|
broker: Broker,
|
|
inference: InferenceApi,
|
|
): void {
|
|
app.post<{ Params: { sessionId: string } }>(
|
|
'/api/sessions/:sessionId/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 sessionId = req.params.sessionId;
|
|
const { pane_id, skill_name, provider, model, mode_id, thinking_option_id } = parsed.data;
|
|
const sessionRows = await sql<{ id: string; project_id: string }[]>`
|
|
SELECT id, project_id FROM sessions WHERE id = ${sessionId}
|
|
`;
|
|
if (sessionRows.length === 0) {
|
|
reply.code(404);
|
|
return { error: 'session not found' };
|
|
}
|
|
|
|
const chatId = await resolveChatId(sql, sessionId, pane_id);
|
|
if (!chatId) {
|
|
reply.code(404);
|
|
return { error: 'pane not found' };
|
|
}
|
|
|
|
if (inference.hasActive(chatId)) {
|
|
reply.code(409);
|
|
return { error: 'inference already running on this chat' };
|
|
}
|
|
|
|
const userText = parsed.data.user_message?.trim()
|
|
? parsed.data.user_message
|
|
: DEFAULT_SKILL_USER_MESSAGE;
|
|
|
|
const body = await getSkillBody(skill_name);
|
|
if (body === null) {
|
|
reply.code(404);
|
|
return { error: 'unknown_skill', message: `unknown skill: ${skill_name}` };
|
|
}
|
|
|
|
// v2.5.9: external agent → run the skill UNDER that agent. The skill body
|
|
// stays server-side (like the native path's tool message) and is injected
|
|
// into a dispatched task; the agent receives the skill instructions + the
|
|
// user's text. Mirrors the messages-route external-provider dispatch.
|
|
if (provider && provider !== 'boocode') {
|
|
const [userMsg] = await sql<{ id: string }[]>`
|
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
|
VALUES (${sessionId}, ${chatId}, 'user', ${userText}, 'complete', clock_timestamp())
|
|
RETURNING id
|
|
`;
|
|
broker.publishFrame(sessionId, { type: 'message_started', message_id: userMsg!.id, chat_id: chatId, role: 'user' } as WsFrame);
|
|
broker.publishFrame(sessionId, { type: 'delta', message_id: userMsg!.id, chat_id: chatId, content: userText } as WsFrame);
|
|
broker.publishFrame(sessionId, { type: 'message_complete', message_id: userMsg!.id, chat_id: chatId } as WsFrame);
|
|
|
|
const taskInput = `${body}\n\n---\n\n${userText}`;
|
|
const [task] = await sql<{ id: string; state: string }[]>`
|
|
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, session_id, chat_id)
|
|
VALUES (${sessionRows[0]!.project_id}, ${taskInput}, ${provider}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null}, ${sessionId}, ${chatId})
|
|
RETURNING id, state
|
|
`;
|
|
await sql`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
|
|
reply.code(202);
|
|
return { user_message_id: userMsg!.id, task_id: task!.id, dispatched: true };
|
|
}
|
|
|
|
const { result, toolCall } = await runSkillInvokeTransaction(sql, {
|
|
sessionId,
|
|
chatId,
|
|
skillName: skill_name,
|
|
skillBody: body,
|
|
userText,
|
|
});
|
|
|
|
for (const frame of buildSkillInvokeSyntheticFrames(chatId, result, toolCall, body)) {
|
|
broker.publishFrame(sessionId, frame as WsFrame);
|
|
}
|
|
for (const frame of buildSkillInvokeUserFrames(chatId, result.user_message_id, userText)) {
|
|
broker.publishFrame(sessionId, frame as WsFrame);
|
|
}
|
|
|
|
inference.enqueue(sessionId, chatId, result.assistant_message_id, 'default');
|
|
|
|
reply.code(202);
|
|
return result;
|
|
},
|
|
);
|
|
}
|