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:
11
apps/web/src/lib/apply-user-delta.ts
Normal file
11
apps/web/src/lib/apply-user-delta.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/** User messages are inserted atomically — never stream-append like assistant deltas. */
|
||||
export function applyMessageDelta(
|
||||
role: 'user' | 'assistant' | 'system' | 'tool',
|
||||
existingContent: string,
|
||||
chunk: string,
|
||||
): string {
|
||||
if (role === 'user') {
|
||||
return chunk || existingContent;
|
||||
}
|
||||
return existingContent + chunk;
|
||||
}
|
||||
18
apps/web/src/lib/coder-session.ts
Normal file
18
apps/web/src/lib/coder-session.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/** Sessions created for BooCoder work (sidebar / project list icons). */
|
||||
export function isCoderSessionName(name: string | null | undefined): boolean {
|
||||
if (!name) return false;
|
||||
if (name === 'New BooCode') return true;
|
||||
if (name.startsWith('Task [')) return true;
|
||||
if (name.startsWith('Coder:')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Optimistic coder pane shell before scoped chat id arrives from the server. */
|
||||
export function defaultCoderWorkspacePane(id: string = crypto.randomUUID()) {
|
||||
return {
|
||||
id,
|
||||
kind: 'coder' as const,
|
||||
chatIds: [] as string[],
|
||||
activeChatIdx: -1,
|
||||
};
|
||||
}
|
||||
68
apps/web/src/lib/coder-tools.ts
Normal file
68
apps/web/src/lib/coder-tools.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { ToolCall, ToolResult } from '@/api/types';
|
||||
import type { ToolRun } from '@/components/ToolCallLine';
|
||||
|
||||
export interface AcpWireMeta {
|
||||
status?: 'running' | 'completed' | 'failed' | 'canceled';
|
||||
kind?: string | null;
|
||||
title?: string;
|
||||
output?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CoderToolCallWire {
|
||||
id: string;
|
||||
function: { name: string; arguments: string };
|
||||
}
|
||||
|
||||
function parseArgs(raw: string): Record<string, unknown> {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function wireToolCallToRun(wire: CoderToolCallWire): ToolRun {
|
||||
const args = parseArgs(wire.function.arguments);
|
||||
const acp = args._acp as AcpWireMeta | undefined;
|
||||
const { _acp: _ignored, ...rest } = args;
|
||||
const lifecycle = acp?.status ?? 'running';
|
||||
const call: ToolCall = {
|
||||
id: wire.id,
|
||||
name: wire.function.name,
|
||||
args: rest,
|
||||
};
|
||||
if (lifecycle === 'running') {
|
||||
return { call, result: null };
|
||||
}
|
||||
const result: ToolResult = {
|
||||
tool_call_id: wire.id,
|
||||
output: acp?.output ?? null,
|
||||
truncated: false,
|
||||
...(acp?.error ? { error: acp.error } : {}),
|
||||
};
|
||||
return { call, result };
|
||||
}
|
||||
|
||||
export function mergeWireToolCall(
|
||||
existing: CoderToolCallWire[] | undefined,
|
||||
incoming: { id: string; name: string; args: Record<string, unknown> },
|
||||
): CoderToolCallWire[] {
|
||||
const entry: CoderToolCallWire = {
|
||||
id: incoming.id,
|
||||
function: { name: incoming.name, arguments: JSON.stringify(incoming.args) },
|
||||
};
|
||||
const list = existing ?? [];
|
||||
const idx = list.findIndex((tc) => tc.id === incoming.id);
|
||||
if (idx >= 0) {
|
||||
const next = [...list];
|
||||
next[idx] = entry;
|
||||
return next;
|
||||
}
|
||||
return [...list, entry];
|
||||
}
|
||||
|
||||
export function wireToolCallsToRuns(wires: CoderToolCallWire[] | undefined): ToolRun[] {
|
||||
return (wires ?? []).map(wireToolCallToRun);
|
||||
}
|
||||
29
apps/web/src/lib/slash-command.ts
Normal file
29
apps/web/src/lib/slash-command.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export interface SlashCommandItem {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** True while the user is still typing the command name after `/`. */
|
||||
export function isSlashCommandToken(value: string): boolean {
|
||||
return /^\/[^\s]*$/.test(value);
|
||||
}
|
||||
|
||||
export function slashQuery(value: string): string {
|
||||
return value.slice(1);
|
||||
}
|
||||
|
||||
export function parseSlashInput(text: string): { cmdName: string; args: string } | null {
|
||||
const match = text.match(/^\/(\S+)\s*([\s\S]*)$/);
|
||||
if (!match) return null;
|
||||
return { cmdName: match[1]!, args: (match[2] ?? '').trim() };
|
||||
}
|
||||
|
||||
export function mergeCommandsByName(...lists: SlashCommandItem[][]): SlashCommandItem[] {
|
||||
const byName = new Map<string, SlashCommandItem>();
|
||||
for (const list of lists) {
|
||||
for (const cmd of list) {
|
||||
byName.set(cmd.name, cmd);
|
||||
}
|
||||
}
|
||||
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
Reference in New Issue
Block a user