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:
33
apps/server/src/services/__tests__/agents.test.ts
Normal file
33
apps/server/src/services/__tests__/agents.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isAgentRegistryMarkdown, parseAgentsMd } from '../agents.js';
|
||||
|
||||
describe('isAgentRegistryMarkdown', () => {
|
||||
it('rejects Cursor navigation AGENTS.md at repo root', () => {
|
||||
expect(
|
||||
isAgentRegistryMarkdown('# Agent navigation\n\n## Doc map\n'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts the global data/AGENTS.md registry shape', () => {
|
||||
expect(isAgentRegistryMarkdown('# Agents\n\n## Code Reviewer\n---\n')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseAgentsMd', () => {
|
||||
it('does not emit errors for navigation sections when file is skipped upstream', () => {
|
||||
// When isAgentRegistryMarkdown returns false, getAgentsForProject never calls this.
|
||||
// Sanity: a nav-shaped file would produce six "missing fence" errors if parsed.
|
||||
const nav = `# Agent navigation
|
||||
|
||||
## Doc map
|
||||
| Need | Read |
|
||||
|------|------|
|
||||
|
||||
## Task routing
|
||||
Start here
|
||||
`;
|
||||
const r = parseAgentsMd(nav);
|
||||
expect(r.agents).toHaveLength(0);
|
||||
expect(r.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -226,6 +226,76 @@ describe('buildMessagesPayload', async () => {
|
||||
expect(result[4]).toMatchObject({ role: 'assistant', content: 'here it is' });
|
||||
});
|
||||
|
||||
it('strips assistant tool_calls when matching tool results are missing', async () => {
|
||||
const session = makeSession();
|
||||
const project = makeProject();
|
||||
const toolCall: ToolCall = {
|
||||
id: 'call_orphan',
|
||||
name: 'grep',
|
||||
args: { pattern: 'foo' },
|
||||
};
|
||||
const history: Message[] = [
|
||||
makeMessage('user', 'search'),
|
||||
makeMessage('assistant', 'partial answer', { tool_calls: [toolCall] }),
|
||||
makeMessage('assistant', 'final answer'),
|
||||
];
|
||||
const result = await buildMessagesPayload(session, project, history);
|
||||
// tool_calls stripped from the orphan turn; text content kept.
|
||||
expect(result).toHaveLength(4);
|
||||
expect(result[1]).toMatchObject({ role: 'user', content: 'search' });
|
||||
expect(result[2]).toMatchObject({ role: 'assistant', content: 'partial answer' });
|
||||
expect(result[2]!.tool_calls).toBeUndefined();
|
||||
expect(result[3]).toMatchObject({ role: 'assistant', content: 'final answer' });
|
||||
});
|
||||
|
||||
it('drops tool-call-only assistant rows when tool results never arrived', async () => {
|
||||
const session = makeSession();
|
||||
const project = makeProject();
|
||||
const toolCall: ToolCall = {
|
||||
id: 'call_orphan_only',
|
||||
name: 'grep',
|
||||
args: { pattern: 'foo' },
|
||||
};
|
||||
const history: Message[] = [
|
||||
makeMessage('user', 'search'),
|
||||
makeMessage('assistant', '', { tool_calls: [toolCall] }),
|
||||
makeMessage('assistant', 'final answer'),
|
||||
];
|
||||
const result = await buildMessagesPayload(session, project, history);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[2]).toMatchObject({ role: 'assistant', content: 'final answer' });
|
||||
});
|
||||
|
||||
it('skips stray tool rows when the owning assistant tool_calls were stripped', async () => {
|
||||
const session = makeSession();
|
||||
const project = makeProject();
|
||||
const toolCallA: ToolCall = {
|
||||
id: 'call_a',
|
||||
name: 'grep',
|
||||
args: { pattern: 'foo' },
|
||||
};
|
||||
const toolCallB: ToolCall = {
|
||||
id: 'call_b',
|
||||
name: 'read',
|
||||
args: { path: 'x' },
|
||||
};
|
||||
const toolResult: ToolResult = {
|
||||
tool_call_id: 'call_a',
|
||||
output: 'match',
|
||||
truncated: false,
|
||||
};
|
||||
const history: Message[] = [
|
||||
makeMessage('user', 'search'),
|
||||
makeMessage('assistant', '', { tool_calls: [toolCallA, toolCallB] }),
|
||||
makeMessage('tool', '', { tool_results: toolResult }),
|
||||
makeMessage('assistant', 'final answer'),
|
||||
];
|
||||
const result = await buildMessagesPayload(session, project, history);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result.find((m) => m.role === 'tool')).toBeUndefined();
|
||||
expect(result[2]).toMatchObject({ role: 'assistant', content: 'final answer' });
|
||||
});
|
||||
|
||||
it('skips tool rows with no tool_results', async () => {
|
||||
const session = makeSession();
|
||||
const project = makeProject();
|
||||
|
||||
@@ -309,6 +309,14 @@ export function parseAgentsMd(content: string): ParseResult {
|
||||
return { agents, errors };
|
||||
}
|
||||
|
||||
/** True when a file at `<project>/AGENTS.md` is an agent registry, not Cursor/doc nav. */
|
||||
export function isAgentRegistryMarkdown(content: string): boolean {
|
||||
const firstLine = content.trimStart().split('\n')[0]?.trim() ?? '';
|
||||
// BooCode monorepo root AGENTS.md is navigation only; registry is /data/AGENTS.md.
|
||||
if (firstLine === '# Agent navigation') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- mtime-keyed cache + public API ----------------------------------------
|
||||
|
||||
interface CacheEntry {
|
||||
@@ -397,7 +405,7 @@ export async function getAgentsForProject(projectPath: string): Promise<AgentsRe
|
||||
for (const a of r.agents) byName.set(a.name, { ...a, source: 'global' });
|
||||
errors.push(...r.errors);
|
||||
}
|
||||
if (projectContent !== null) {
|
||||
if (projectContent !== null && isAgentRegistryMarkdown(projectContent)) {
|
||||
const r = parseAgentsMd(projectContent);
|
||||
for (const a of r.agents) byName.set(a.name, { ...a, source: 'project' });
|
||||
errors.push(...r.errors);
|
||||
|
||||
@@ -37,6 +37,34 @@ export interface OpenAiMessage {
|
||||
// omit it and exercise the byte-stability surface directly through
|
||||
// buildSystemPromptWithFingerprint. The observer Map in system-prompt.ts
|
||||
// updates regardless of whether log is passed.
|
||||
function toolResultIdsFollowing(history: Message[], assistantIdx: number): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (let j = assistantIdx + 1; j < history.length; j++) {
|
||||
const row = history[j]!;
|
||||
if (row.role === 'user' || row.role === 'assistant') break;
|
||||
if (row.role === 'tool' && row.tool_results?.tool_call_id) {
|
||||
ids.add(row.tool_results.tool_call_id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function findAssistantOwnerForToolCall(history: Message[], toolIdx: number, callId: string): number | null {
|
||||
for (let k = toolIdx - 1; k >= 0; k--) {
|
||||
const row = history[k]!;
|
||||
if (row.role === 'user') break;
|
||||
if (row.role === 'assistant' && row.tool_calls?.some((tc) => tc.id === callId)) return k;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function assistantToolCallsArePayloadComplete(history: Message[], assistantIdx: number): boolean {
|
||||
const assistant = history[assistantIdx]!;
|
||||
if (!assistant.tool_calls?.length) return false;
|
||||
const fulfilled = toolResultIdsFollowing(history, assistantIdx);
|
||||
return assistant.tool_calls.every((tc) => fulfilled.has(tc.id));
|
||||
}
|
||||
|
||||
export async function buildMessagesPayload(
|
||||
session: Session,
|
||||
project: Project,
|
||||
@@ -97,6 +125,10 @@ export async function buildMessagesPayload(
|
||||
if (m.role === 'tool') {
|
||||
const tr = m.tool_results;
|
||||
if (!tr) continue;
|
||||
const ownerIdx = findAssistantOwnerForToolCall(history, i, tr.tool_call_id);
|
||||
if (ownerIdx == null || !assistantToolCallsArePayloadComplete(history, ownerIdx)) {
|
||||
continue;
|
||||
}
|
||||
const outputText = tr.error
|
||||
? `error: ${tr.error}`
|
||||
: typeof tr.output === 'string'
|
||||
@@ -115,11 +147,15 @@ export async function buildMessagesPayload(
|
||||
content: m.content && m.content.length > 0 ? m.content : null,
|
||||
};
|
||||
if (m.tool_calls && m.tool_calls.length > 0) {
|
||||
msg.tool_calls = m.tool_calls.map((tc) => ({
|
||||
id: tc.id,
|
||||
type: 'function' as const,
|
||||
function: { name: tc.name, arguments: JSON.stringify(tc.args) },
|
||||
}));
|
||||
if (assistantToolCallsArePayloadComplete(history, i)) {
|
||||
msg.tool_calls = m.tool_calls.map((tc) => ({
|
||||
id: tc.id,
|
||||
type: 'function' as const,
|
||||
function: { name: tc.name, arguments: JSON.stringify(tc.args) },
|
||||
}));
|
||||
}
|
||||
// Orphaned tool_calls (no matching tool rows) are stripped so the
|
||||
// upstream API does not reject the payload on the next user turn.
|
||||
}
|
||||
// v1.13.1-C: collapse reasoning_parts into a single string. The view
|
||||
// returns them ordered by sequence; multiple reasoning parts on one
|
||||
@@ -127,6 +163,11 @@ export async function buildMessagesPayload(
|
||||
if (m.reasoning_parts && m.reasoning_parts.length > 0) {
|
||||
msg.reasoning = m.reasoning_parts.map((p) => p.text ?? '').join('');
|
||||
}
|
||||
const hasPayload =
|
||||
(msg.content != null && msg.content.trim().length > 0) ||
|
||||
(msg.tool_calls != null && msg.tool_calls.length > 0) ||
|
||||
(msg.reasoning != null && msg.reasoning.length > 0);
|
||||
if (!hasPayload) continue;
|
||||
out.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
148
apps/server/src/services/skill-invoke.ts
Normal file
148
apps/server/src/services/skill-invoke.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Sql } from '../db.js';
|
||||
|
||||
export const DEFAULT_SKILL_USER_MESSAGE = 'Apply this skill.';
|
||||
|
||||
export interface SkillInvokeTransactionResult {
|
||||
synth_assistant_id: string;
|
||||
tool_message_id: string;
|
||||
user_message_id: string;
|
||||
assistant_message_id: string;
|
||||
}
|
||||
|
||||
export interface SkillInvokeToolCall {
|
||||
id: string;
|
||||
name: 'skill_use';
|
||||
args: { name: string };
|
||||
}
|
||||
|
||||
export type SkillInvokeSessionFrame = Record<string, unknown> & { type: string };
|
||||
|
||||
export async function runSkillInvokeTransaction(
|
||||
sql: Sql,
|
||||
args: {
|
||||
sessionId: string;
|
||||
chatId: string;
|
||||
skillName: string;
|
||||
skillBody: string;
|
||||
userText: string;
|
||||
},
|
||||
): Promise<{ result: SkillInvokeTransactionResult; toolCall: SkillInvokeToolCall }> {
|
||||
const toolCallId = randomUUID();
|
||||
const toolCall: SkillInvokeToolCall = {
|
||||
id: toolCallId,
|
||||
name: 'skill_use',
|
||||
args: { name: args.skillName },
|
||||
};
|
||||
const toolResults = {
|
||||
tool_call_id: toolCallId,
|
||||
output: args.skillBody,
|
||||
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 (${args.sessionId}, ${args.chatId}, 'assistant', '', 'complete', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
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: args.skillName },
|
||||
} as never)})
|
||||
`;
|
||||
const [toolMsg] = await tx<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${args.sessionId}, ${args.chatId}, 'tool', '', 'complete', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
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 (${args.sessionId}, ${args.chatId}, 'user', ${args.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 (${args.sessionId}, ${args.chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${args.sessionId}`;
|
||||
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${args.chatId}`;
|
||||
return {
|
||||
synth_assistant_id: synthAssistant!.id,
|
||||
tool_message_id: toolMsg!.id,
|
||||
user_message_id: userMsg!.id,
|
||||
assistant_message_id: assistantMsg!.id,
|
||||
};
|
||||
});
|
||||
|
||||
return { result, toolCall };
|
||||
}
|
||||
|
||||
export function buildSkillInvokeSyntheticFrames(
|
||||
chatId: string,
|
||||
result: SkillInvokeTransactionResult,
|
||||
toolCall: SkillInvokeToolCall,
|
||||
skillBody: string,
|
||||
): SkillInvokeSessionFrame[] {
|
||||
return [
|
||||
{
|
||||
type: 'message_started',
|
||||
message_id: result.synth_assistant_id,
|
||||
chat_id: chatId,
|
||||
role: 'assistant',
|
||||
},
|
||||
{
|
||||
type: 'tool_call',
|
||||
message_id: result.synth_assistant_id,
|
||||
chat_id: chatId,
|
||||
tool_call: toolCall,
|
||||
},
|
||||
{
|
||||
type: 'message_complete',
|
||||
message_id: result.synth_assistant_id,
|
||||
chat_id: chatId,
|
||||
},
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_message_id: result.tool_message_id,
|
||||
tool_call_id: toolCall.id,
|
||||
chat_id: chatId,
|
||||
output: skillBody,
|
||||
truncated: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function buildSkillInvokeUserFrames(
|
||||
chatId: string,
|
||||
userMessageId: string,
|
||||
userText: string,
|
||||
): SkillInvokeSessionFrame[] {
|
||||
return [
|
||||
{
|
||||
type: 'message_started',
|
||||
message_id: userMessageId,
|
||||
chat_id: chatId,
|
||||
role: 'user',
|
||||
},
|
||||
{
|
||||
type: 'delta',
|
||||
message_id: userMessageId,
|
||||
chat_id: chatId,
|
||||
content: userText,
|
||||
},
|
||||
{
|
||||
type: 'message_complete',
|
||||
message_id: userMessageId,
|
||||
chat_id: chatId,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import { pathGuard, PathScopeError } from './path_guard.js';
|
||||
// new skills, per-entry mtime check on body access so a hot-edited SKILL.md
|
||||
// is re-read without a restart. No watcher.
|
||||
|
||||
const SKILLS_ROOT = '/data/skills';
|
||||
const SKILLS_ROOT = process.env.SKILLS_ROOT ?? '/data/skills';
|
||||
const MAX_RESOURCE_BYTES = 5 * 1024 * 1024;
|
||||
const LIST_CACHE_TTL_MS = 60_000;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user