v2.2-paseo-providers: Paseo provider stack + v2.2.1 pane-scoped chat fixes
Ship Paseo-equivalent provider snapshot, AgentComposerBar, ACP dispatch rewrite with streaming/persist, permission prompts, and agent commands. Follow-up: pane-scoped chat resolution, CoderMessageList tool timeline, WS user-delta replace, and inference orphan tool_call stripping. Archive openspec v2-2; update CHANGELOG and CURRENT. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -12,6 +12,8 @@ import type { Sql } from '../db.js';
|
||||
const ContestantSchema = z.object({
|
||||
agent: 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(),
|
||||
});
|
||||
|
||||
const CreateArenaBody = z.object({
|
||||
@@ -24,6 +26,8 @@ interface TaskRow {
|
||||
id: string;
|
||||
agent: string | null;
|
||||
model: string | null;
|
||||
mode_id: string | null;
|
||||
thinking_option_id: string | null;
|
||||
state: string;
|
||||
}
|
||||
|
||||
@@ -42,9 +46,17 @@ export function registerArenaRoutes(app: FastifyInstance, sql: Sql): void {
|
||||
const tasks: TaskRow[] = [];
|
||||
for (const contestant of contestants) {
|
||||
const [task] = await sql<TaskRow[]>`
|
||||
INSERT INTO tasks (project_id, input, agent, model, arena_id)
|
||||
VALUES (${project_id}, ${input}, ${contestant.agent ?? null}, ${contestant.model ?? null}, ${arenaId})
|
||||
RETURNING id, agent, model, state
|
||||
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, arena_id)
|
||||
VALUES (
|
||||
${project_id},
|
||||
${input},
|
||||
${contestant.agent ?? null},
|
||||
${contestant.model ?? null},
|
||||
${contestant.mode_id ?? null},
|
||||
${contestant.thinking_option_id ?? null},
|
||||
${arenaId}
|
||||
)
|
||||
RETURNING id, agent, model, mode_id, thinking_option_id, state
|
||||
`;
|
||||
tasks.push(task!);
|
||||
}
|
||||
@@ -52,10 +64,12 @@ export function registerArenaRoutes(app: FastifyInstance, sql: Sql): void {
|
||||
reply.code(201);
|
||||
return {
|
||||
arena_id: arenaId,
|
||||
tasks: tasks.map(t => ({
|
||||
tasks: tasks.map((t) => ({
|
||||
id: t.id,
|
||||
agent: t.agent,
|
||||
model: t.model,
|
||||
mode_id: t.mode_id,
|
||||
thinking_option_id: t.thinking_option_id,
|
||||
state: t.state,
|
||||
})),
|
||||
};
|
||||
@@ -73,7 +87,7 @@ export function registerArenaRoutes(app: FastifyInstance, sql: Sql): void {
|
||||
}
|
||||
|
||||
const tasks = await sql`
|
||||
SELECT id, project_id, state, input, output_summary, agent, model, execution_path, session_id, started_at, ended_at, created_at, arena_id
|
||||
SELECT id, project_id, state, input, output_summary, agent, model, mode_id, thinking_option_id, execution_path, session_id, started_at, ended_at, created_at, arena_id
|
||||
FROM tasks
|
||||
WHERE arena_id = ${arena_id}
|
||||
ORDER BY created_at
|
||||
|
||||
81
apps/coder/src/routes/chat-resolve.ts
Normal file
81
apps/coder/src/routes/chat-resolve.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { Sql } from '../db.js';
|
||||
|
||||
interface WorkspacePaneRow {
|
||||
id: string;
|
||||
kind: string;
|
||||
chatId?: string;
|
||||
chatIds?: string[];
|
||||
activeChatIdx?: number;
|
||||
}
|
||||
|
||||
function chatNameForKind(kind: string): string {
|
||||
if (kind === 'coder' || kind === 'agent') return 'BooCoder';
|
||||
if (kind === 'terminal') return 'Terminal';
|
||||
return 'Chat';
|
||||
}
|
||||
|
||||
function activeChatIdForPane(pane: WorkspacePaneRow): string | undefined {
|
||||
const chatIds = pane.chatIds ?? [];
|
||||
const idx = pane.activeChatIdx ?? 0;
|
||||
if (idx >= 0 && idx < chatIds.length) return chatIds[idx];
|
||||
return pane.chatId;
|
||||
}
|
||||
|
||||
/** Resolve the active chat for a workspace pane; auto-seed when empty. */
|
||||
export async function resolveChatId(
|
||||
sql: Sql,
|
||||
sessionId: string,
|
||||
paneId: string,
|
||||
): Promise<string | null> {
|
||||
return sql.begin(async (tx) => {
|
||||
const sessionRows = await tx<{ workspace_panes: WorkspacePaneRow[] }[]>`
|
||||
SELECT workspace_panes FROM sessions WHERE id = ${sessionId} FOR UPDATE
|
||||
`;
|
||||
if (sessionRows.length === 0) return null;
|
||||
|
||||
const panes = sessionRows[0]!.workspace_panes ?? [];
|
||||
const paneIdx = panes.findIndex((p) => p.id === paneId);
|
||||
if (paneIdx < 0) return null;
|
||||
|
||||
const pane = panes[paneIdx]!;
|
||||
const existingChatId = activeChatIdForPane(pane);
|
||||
if (existingChatId) {
|
||||
const chatRows = await tx<{ id: string }[]>`
|
||||
SELECT id FROM chats
|
||||
WHERE id = ${existingChatId}
|
||||
AND session_id = ${sessionId}
|
||||
AND status = 'open'
|
||||
`;
|
||||
if (chatRows.length > 0) return existingChatId;
|
||||
}
|
||||
|
||||
const [newChat] = await tx<{ id: string }[]>`
|
||||
INSERT INTO chats (session_id, name, status)
|
||||
VALUES (${sessionId}, ${chatNameForKind(pane.kind)}, 'open')
|
||||
RETURNING id
|
||||
`;
|
||||
if (!newChat) return null;
|
||||
|
||||
const nextChatIds = [...(pane.chatIds ?? []), newChat.id];
|
||||
const nextActiveIdx = nextChatIds.length - 1;
|
||||
const nextPanes = panes.map((p, i) =>
|
||||
i === paneIdx
|
||||
? {
|
||||
...p,
|
||||
chatIds: nextChatIds,
|
||||
activeChatIdx: nextActiveIdx,
|
||||
chatId: newChat.id,
|
||||
}
|
||||
: p,
|
||||
);
|
||||
|
||||
await tx`
|
||||
UPDATE sessions
|
||||
SET workspace_panes = ${tx.json(nextPanes as never)},
|
||||
updated_at = clock_timestamp()
|
||||
WHERE id = ${sessionId}
|
||||
`;
|
||||
|
||||
return newChat.id;
|
||||
});
|
||||
}
|
||||
@@ -3,12 +3,16 @@ 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 { resolveChatId } from './chat-resolve.js';
|
||||
|
||||
const SendBody = z.object({
|
||||
content: z.string().min(1).max(64_000),
|
||||
pane_id: z.string().min(1).max(200),
|
||||
chat_id: z.string().uuid().optional(),
|
||||
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 {
|
||||
@@ -17,12 +21,100 @@ interface InferenceApi {
|
||||
hasActive: (chatId: string) => boolean;
|
||||
}
|
||||
|
||||
interface MessageRow {
|
||||
id: string;
|
||||
role: string;
|
||||
content: string | null;
|
||||
status: string | null;
|
||||
tool_calls: Array<{ id: string; name: string; args?: Record<string, unknown> }> | null;
|
||||
tool_results: {
|
||||
tool_call_id: string;
|
||||
output: unknown;
|
||||
truncated?: boolean;
|
||||
error?: string;
|
||||
} | null;
|
||||
reasoning_parts: Array<{ text?: string }> | null;
|
||||
}
|
||||
|
||||
function mapCoderMessageRow(row: MessageRow) {
|
||||
if (row.role === 'tool') {
|
||||
if (!row.tool_results?.tool_call_id) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
role: 'tool' as const,
|
||||
tool_results: row.tool_results,
|
||||
};
|
||||
}
|
||||
if (row.role !== 'user' && row.role !== 'assistant' && row.role !== 'system') {
|
||||
return null;
|
||||
}
|
||||
const tool_calls = row.tool_calls?.map((tc) => ({
|
||||
id: tc.id,
|
||||
function: {
|
||||
name: tc.name,
|
||||
arguments: JSON.stringify(tc.args ?? {}),
|
||||
},
|
||||
}));
|
||||
const reasoningText = row.reasoning_parts?.map((p) => p.text ?? '').join('') ?? '';
|
||||
return {
|
||||
id: row.id,
|
||||
role: row.role as 'user' | 'assistant' | 'system',
|
||||
content: row.content ?? '',
|
||||
status: (row.status ?? 'complete') as 'streaming' | 'complete' | 'failed',
|
||||
...(reasoningText ? { reasoning_text: reasoningText } : {}),
|
||||
...(tool_calls?.length ? { tool_calls } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function registerMessageRoutes(
|
||||
app: FastifyInstance,
|
||||
sql: Sql,
|
||||
broker: Broker,
|
||||
inference: InferenceApi,
|
||||
): void {
|
||||
// GET /api/sessions/:sessionId/messages — hydrate CoderPane on load / reconnect
|
||||
app.get<{ Params: { sessionId: string }; Querystring: { chat_id?: string } }>(
|
||||
'/api/sessions/:sessionId/messages',
|
||||
async (req, reply) => {
|
||||
const sessionId = req.params.sessionId;
|
||||
const chatId = req.query.chat_id;
|
||||
const sessionRows = await sql<{ id: string }[]>`
|
||||
SELECT id FROM sessions WHERE id = ${sessionId}
|
||||
`;
|
||||
if (sessionRows.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'session not found' };
|
||||
}
|
||||
|
||||
if (chatId) {
|
||||
const chatRows = await sql<{ id: string }[]>`
|
||||
SELECT id FROM chats
|
||||
WHERE id = ${chatId} AND session_id = ${sessionId} AND status = 'open'
|
||||
`;
|
||||
if (chatRows.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'chat not found or not open in this session' };
|
||||
}
|
||||
}
|
||||
|
||||
const rows = chatId
|
||||
? await sql<MessageRow[]>`
|
||||
SELECT id, role, content, status, tool_calls, tool_results, reasoning_parts
|
||||
FROM messages_with_parts
|
||||
WHERE session_id = ${sessionId} AND chat_id = ${chatId}
|
||||
ORDER BY created_at ASC, id ASC
|
||||
`
|
||||
: await sql<MessageRow[]>`
|
||||
SELECT id, role, content, status, tool_calls, tool_results, reasoning_parts
|
||||
FROM messages_with_parts
|
||||
WHERE session_id = ${sessionId}
|
||||
ORDER BY created_at ASC, id ASC
|
||||
`;
|
||||
|
||||
return rows.map(mapCoderMessageRow).filter((m) => m !== null);
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/sessions/:sessionId/messages — send a user message + kick off inference
|
||||
app.post<{ Params: { sessionId: string } }>(
|
||||
'/api/sessions/:sessionId/messages',
|
||||
@@ -34,7 +126,8 @@ export function registerMessageRoutes(
|
||||
}
|
||||
|
||||
const sessionId = req.params.sessionId;
|
||||
const { content, chat_id: explicitChatId, provider, model } = parsed.data;
|
||||
const { content, pane_id, chat_id: explicitChatId, provider, model, mode_id, thinking_option_id } =
|
||||
parsed.data;
|
||||
const isExternal = provider && provider !== 'boocode';
|
||||
|
||||
// Validate session exists
|
||||
@@ -46,8 +139,13 @@ export function registerMessageRoutes(
|
||||
return { error: 'session not found' };
|
||||
}
|
||||
|
||||
// Resolve chat_id: use explicit value or find/create a default chat
|
||||
let chatId: string;
|
||||
const resolved = await resolveChatId(sql, sessionId, pane_id);
|
||||
if (!resolved) {
|
||||
reply.code(404);
|
||||
return { error: 'pane not found' };
|
||||
}
|
||||
|
||||
let chatId = resolved;
|
||||
if (explicitChatId) {
|
||||
const chatRows = await sql<{ id: string }[]>`
|
||||
SELECT id FROM chats WHERE id = ${explicitChatId} AND session_id = ${sessionId} AND status = 'open'
|
||||
@@ -57,20 +155,6 @@ export function registerMessageRoutes(
|
||||
return { error: 'chat not found or not open in this session' };
|
||||
}
|
||||
chatId = explicitChatId;
|
||||
} else {
|
||||
const existing = await sql<{ id: string }[]>`
|
||||
SELECT id FROM chats WHERE session_id = ${sessionId} AND status = 'open' ORDER BY created_at LIMIT 1
|
||||
`;
|
||||
if (existing.length > 0) {
|
||||
chatId = existing[0]!.id;
|
||||
} else {
|
||||
const [newChat] = await sql<{ id: string }[]>`
|
||||
INSERT INTO chats (session_id, name, status)
|
||||
VALUES (${sessionId}, 'Chat', 'open')
|
||||
RETURNING id
|
||||
`;
|
||||
chatId = newChat!.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isExternal) {
|
||||
@@ -113,8 +197,8 @@ export function registerMessageRoutes(
|
||||
// External provider: create a task for the dispatcher
|
||||
const projectId = sessionRows[0]!.project_id;
|
||||
const [task] = await sql<{ id: string; state: string }[]>`
|
||||
INSERT INTO tasks (project_id, input, agent, model, session_id)
|
||||
VALUES (${projectId}, ${content}, ${provider}, ${model ?? null}, ${sessionId})
|
||||
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, session_id)
|
||||
VALUES (${projectId}, ${content}, ${provider}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null}, ${sessionId})
|
||||
RETURNING id, state
|
||||
`;
|
||||
reply.code(202);
|
||||
|
||||
@@ -1,80 +1,17 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Sql } from '../db.js';
|
||||
import type { Config } from '../config.js';
|
||||
import { PROVIDERS } from '../services/provider-registry.js';
|
||||
|
||||
interface ProviderModel {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ProviderResponse {
|
||||
name: string;
|
||||
label: string;
|
||||
transport: string;
|
||||
installed: boolean;
|
||||
models: ProviderModel[];
|
||||
}
|
||||
|
||||
interface LlamaSwapModel {
|
||||
id: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
async function fetchLlamaSwapModels(config: Config): Promise<ProviderModel[]> {
|
||||
try {
|
||||
const res = await fetch(`${config.LLAMA_SWAP_URL}/v1/models`);
|
||||
if (!res.ok) return [];
|
||||
const parsed = (await res.json()) as { data?: LlamaSwapModel[] };
|
||||
return (parsed.data ?? []).map((m) => ({ id: m.id, label: m.id }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
import { getProviderSnapshot, clearProviderSnapshotCache } from '../services/provider-snapshot.js';
|
||||
|
||||
export function registerProviderRoutes(app: FastifyInstance, sql: Sql, config: Config): void {
|
||||
app.get('/api/providers', async (_req, _reply) => {
|
||||
const llamaModels = await fetchLlamaSwapModels(config);
|
||||
app.get<{ Querystring: { cwd?: string } }>('/api/providers/snapshot', async (req, _reply) => {
|
||||
const cwd = req.query.cwd;
|
||||
return getProviderSnapshot(sql, config, cwd);
|
||||
});
|
||||
|
||||
const agents = await sql<{ name: string; models: ProviderModel[]; label: string | null; transport: string | null; supports_acp: boolean }[]>`
|
||||
SELECT name, models, label, transport, supports_acp FROM available_agents
|
||||
`;
|
||||
const agentMap = new Map(agents.map((a) => [a.name, a]));
|
||||
|
||||
const result: ProviderResponse[] = [];
|
||||
|
||||
for (const provider of PROVIDERS) {
|
||||
const isNative = provider.name === 'boocode';
|
||||
const agentRow = agentMap.get(provider.name);
|
||||
const installed = isNative || !!agentRow;
|
||||
|
||||
if (!installed) continue;
|
||||
|
||||
let models: ProviderModel[];
|
||||
if (provider.modelSource === 'llama-swap') {
|
||||
models = llamaModels;
|
||||
} else if (agentRow?.models && agentRow.models.length > 0) {
|
||||
models = agentRow.models;
|
||||
} else if (provider.staticModels) {
|
||||
models = provider.staticModels;
|
||||
} else {
|
||||
models = [];
|
||||
}
|
||||
|
||||
let transport: string = provider.transport;
|
||||
if (agentRow) {
|
||||
transport = provider.transport === 'acp' && !agentRow.supports_acp ? 'pty' : provider.transport;
|
||||
}
|
||||
|
||||
result.push({
|
||||
name: provider.name,
|
||||
label: agentRow?.label ?? provider.label,
|
||||
transport,
|
||||
installed,
|
||||
models,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
app.post('/api/providers/refresh', async (_req, _reply) => {
|
||||
clearProviderSnapshotCache();
|
||||
const entries = await getProviderSnapshot(sql, config, undefined, true);
|
||||
return { refreshed: entries.length };
|
||||
});
|
||||
}
|
||||
|
||||
93
apps/coder/src/routes/skills.ts
Normal file
93
apps/coder/src/routes/skills.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
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(),
|
||||
});
|
||||
|
||||
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 } = parsed.data;
|
||||
const sessionRows = await sql<{ id: string }[]>`
|
||||
SELECT 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}` };
|
||||
}
|
||||
|
||||
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;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { Sql } from '../db.js';
|
||||
import { getPendingPermission, respondToPermission, cancelPendingPermission } from '../services/permission-waiter.js';
|
||||
import { getTaskCommands } from '../services/agent-commands-cache.js';
|
||||
|
||||
interface InferenceApi {
|
||||
cancel: (sessionId: string, chatId: string) => Promise<boolean>;
|
||||
@@ -11,6 +13,12 @@ const CreateBody = z.object({
|
||||
input: z.string().min(1).max(64_000),
|
||||
agent: 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(),
|
||||
});
|
||||
|
||||
const PermissionBody = z.object({
|
||||
option_id: z.string().max(200).nullable(),
|
||||
});
|
||||
|
||||
const ListQuery = z.object({
|
||||
@@ -27,11 +35,11 @@ export function registerTaskRoutes(app: FastifyInstance, sql: Sql, inference: In
|
||||
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||
}
|
||||
|
||||
const { project_id, input, agent, model } = parsed.data;
|
||||
const { project_id, input, agent, model, mode_id, thinking_option_id } = parsed.data;
|
||||
|
||||
const [task] = await sql<{ id: string; state: string }[]>`
|
||||
INSERT INTO tasks (project_id, input, agent, model)
|
||||
VALUES (${project_id}, ${input}, ${agent ?? null}, ${model ?? null})
|
||||
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id)
|
||||
VALUES (${project_id}, ${input}, ${agent ?? null}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null})
|
||||
RETURNING id, state
|
||||
`;
|
||||
|
||||
@@ -111,13 +119,15 @@ export function registerTaskRoutes(app: FastifyInstance, sql: Sql, inference: In
|
||||
}
|
||||
|
||||
const task = rows[0]!;
|
||||
if (task.state !== 'pending' && task.state !== 'running') {
|
||||
if (task.state !== 'pending' && task.state !== 'running' && task.state !== 'blocked') {
|
||||
reply.code(409);
|
||||
return { error: `cannot cancel task in state '${task.state}'` };
|
||||
}
|
||||
|
||||
cancelPendingPermission(taskId);
|
||||
|
||||
// If running, try to cancel inference
|
||||
if (task.state === 'running' && task.session_id) {
|
||||
if ((task.state === 'running' || task.state === 'blocked') && task.session_id) {
|
||||
// Find active chat in the task's session
|
||||
const chats = await sql<{ id: string }[]>`
|
||||
SELECT id FROM chats WHERE session_id = ${task.session_id} AND status = 'open'
|
||||
@@ -130,9 +140,45 @@ export function registerTaskRoutes(app: FastifyInstance, sql: Sql, inference: In
|
||||
await sql`
|
||||
UPDATE tasks
|
||||
SET state = 'cancelled', ended_at = clock_timestamp()
|
||||
WHERE id = ${taskId} AND state IN ('pending', 'running')
|
||||
WHERE id = ${taskId} AND state IN ('pending', 'running', 'blocked')
|
||||
`;
|
||||
|
||||
return { cancelled: true };
|
||||
});
|
||||
|
||||
// GET /api/tasks/:id/permission — pending permission prompt (if any)
|
||||
app.get<{ Params: { id: string } }>('/api/tasks/:id/permission', async (req, reply) => {
|
||||
const prompt = getPendingPermission(req.params.id);
|
||||
if (!prompt) {
|
||||
reply.code(404);
|
||||
return { error: 'no pending permission' };
|
||||
}
|
||||
return prompt;
|
||||
});
|
||||
|
||||
// POST /api/tasks/:id/permission — respond to a pending permission prompt
|
||||
app.post<{ Params: { id: string } }>('/api/tasks/:id/permission', async (req, reply) => {
|
||||
const parsed = PermissionBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400);
|
||||
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||
}
|
||||
|
||||
const ok = respondToPermission(req.params.id, parsed.data.option_id);
|
||||
if (!ok) {
|
||||
reply.code(404);
|
||||
return { error: 'no pending permission' };
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// GET /api/tasks/:id/commands — cached ACP slash commands (if any)
|
||||
app.get<{ Params: { id: string } }>('/api/tasks/:id/commands', async (req, reply) => {
|
||||
const commands = getTaskCommands(req.params.id);
|
||||
if (!commands?.length) {
|
||||
reply.code(404);
|
||||
return { error: 'no commands cached' };
|
||||
}
|
||||
return { taskId: req.params.id, commands };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export function registerWebSocket(
|
||||
|
||||
// Send snapshot of existing messages so client can hydrate
|
||||
const messages = await sql<Record<string, unknown>[]>`
|
||||
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
|
||||
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, reasoning_parts, status, last_seq,
|
||||
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata,
|
||||
summary, tail_start_id, compacted_at
|
||||
FROM messages_with_parts
|
||||
|
||||
Reference in New Issue
Block a user