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:
2026-05-26 15:18:31 +00:00
parent 04673eaf59
commit 93d3f86c2b
96 changed files with 6694 additions and 1329 deletions

View File

@@ -0,0 +1,91 @@
import type { FastifyInstance } from 'fastify';
import WebSocket from 'ws';
function boocoderWsUrl(origin: string, path: string): string {
const u = new URL(origin);
u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:';
u.pathname = path;
u.search = '';
return u.toString();
}
/**
* Reverse-proxy BooCoder HTTP + WebSocket through BooChat's single origin.
* WS must be registered before the HTTP catch-all — fetch() cannot upgrade.
*/
export function registerCoderProxy(app: FastifyInstance, boocoderOrigin: string): void {
app.get<{ Params: { sessionId: string } }>(
'/api/coder/ws/sessions/:sessionId',
{ websocket: true },
(clientSocket, req) => {
const sessionId = req.params.sessionId;
const target = boocoderWsUrl(boocoderOrigin, `/api/ws/sessions/${sessionId}`);
const upstream = new WebSocket(target);
upstream.on('open', () => {
app.log.debug({ sessionId }, 'coder ws proxy: upstream connected');
});
upstream.on('message', (data, isBinary) => {
if (clientSocket.readyState !== clientSocket.OPEN) return;
clientSocket.send(data, { binary: isBinary });
});
upstream.on('close', (code, reason) => {
if (clientSocket.readyState === clientSocket.OPEN) {
clientSocket.close(code, reason.toString());
}
});
upstream.on('error', (err) => {
app.log.warn({ err, sessionId, target }, 'coder ws proxy: upstream error');
if (clientSocket.readyState === clientSocket.OPEN) {
clientSocket.close(1011, 'upstream error');
}
});
clientSocket.on('message', (data, isBinary) => {
if (upstream.readyState !== WebSocket.OPEN) return;
upstream.send(data, { binary: isBinary });
});
clientSocket.on('close', () => {
if (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING) {
upstream.close();
}
});
clientSocket.on('error', () => {
if (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING) {
upstream.close();
}
});
},
);
app.all('/api/coder/*', async (req, reply) => {
const targetPath = req.url.replace('/api/coder', '/api');
const targetUrl = `${boocoderOrigin}${targetPath}`;
const headers: Record<string, string> = {};
if (req.headers['content-type']) headers['content-type'] = req.headers['content-type'] as string;
if (req.headers['authorization']) headers['authorization'] = req.headers['authorization'] as string;
try {
const res = await fetch(targetUrl, {
method: req.method as string,
headers,
body: req.method !== 'GET' && req.method !== 'HEAD' ? JSON.stringify(req.body) : undefined,
});
reply.code(res.status);
for (const [key, value] of res.headers) {
if (key === 'transfer-encoding') continue;
reply.header(key, value);
}
const body = await res.text();
return reply.send(body);
} catch (err) {
app.log.error({ err, targetUrl }, 'coder proxy error');
reply.code(502).send({ error: 'boocoder backend unavailable' });
}
});
}

View File

@@ -33,7 +33,8 @@ const WorkspacePaneZ = z.object({
kind: z.enum([
'chat',
'terminal',
'agent',
'coder',
'agent', // legacy alias — normalized to coder on write
'empty',
'settings',
'markdown_artifact',
@@ -307,9 +308,12 @@ export function registerSessionRoutes(
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const workspacePanes = parsed.data.workspace_panes.map((pane) =>
pane.kind === 'agent' ? { ...pane, kind: 'coder' as const } : pane,
);
const rows = await sql<Session[]>`
UPDATE sessions
SET workspace_panes = ${sql.json(parsed.data.workspace_panes as never)},
SET workspace_panes = ${sql.json(workspacePanes as never)},
updated_at = clock_timestamp()
WHERE id = ${req.params.id}
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,

View File

@@ -1,9 +1,13 @@
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';
import {
buildSkillInvokeSyntheticFrames,
DEFAULT_SKILL_USER_MESSAGE,
runSkillInvokeTransaction,
} from '../services/skill-invoke.js';
// Batch 9.6 slash-invoke handlers. Mirrors the MessageHandlers shape in
// routes/messages.ts so index.ts can pass thin adapters around broker +
@@ -35,8 +39,6 @@ const SkillInvokeBody = z.object({
user_message: z.string().max(64_000).nullable().optional(),
});
const DEFAULT_USER_MESSAGE = 'Apply this skill.';
export function registerSkillsRoutes(
app: FastifyInstance,
sql: Sql,
@@ -62,7 +64,9 @@ export function registerSkillsRoutes(
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 userText = parsed.data.user_message?.trim()
? parsed.data.user_message
: DEFAULT_SKILL_USER_MESSAGE;
const chatRows = await sql<Chat[]>`
SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
@@ -80,87 +84,20 @@ export function registerSkillsRoutes(
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,
};
const { result, toolCall } = await runSkillInvokeTransaction(sql, {
sessionId,
chatId: chat.id,
skillName: skill_name,
skillBody: body,
userText,
});
// 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,
});
for (const frame of buildSkillInvokeSyntheticFrames(chat.id, result, toolCall, body)) {
handlers.publishSessionFrame(sessionId, frame);
}
handlers.publishUserMessage(sessionId, chat.id, result.user_message_id, userText);
handlers.enqueueInference(sessionId, chat.id, result.assistant_message_id, 'default');