Skip to content

Commit 47f9aab

Browse files
committed
feat(acp): Add implementation status documentation and enhance tool call lifecycle management #536
1 parent 8404fd8 commit 47f9aab

1 file changed

Lines changed: 234 additions & 30 deletions

File tree

mpp-ui/src/jsMain/typescript/agents/acp/AcpAgentServer.ts

Lines changed: 234 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -182,9 +182,100 @@ class AutoDevAcpAgent implements acp.Agent {
182182
}
183183
}
184184

185+
/**
186+
* Tool kind classification based on ACP protocol.
187+
* Maps tool names to their appropriate kind for better UI treatment.
188+
*/
189+
type ToolKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'think' | 'fetch' | 'switch_mode' | 'other';
190+
191+
/**
192+
* Classify a tool by its name to determine the appropriate ACP ToolKind.
193+
*/
194+
function classifyToolKind(toolName: string): ToolKind {
195+
const name = toolName.toLowerCase();
196+
197+
// Read operations - low risk
198+
if (name.includes('read') || name.includes('list') || name.includes('get') ||
199+
name.includes('view') || name.includes('cat') || name.includes('show') ||
200+
name === 'tree' || name === 'pwd' || name === 'ls') {
201+
return 'read';
202+
}
203+
204+
// Edit operations - medium risk
205+
if (name.includes('write') || name.includes('edit') || name.includes('update') ||
206+
name.includes('create') || name.includes('patch') || name.includes('modify') ||
207+
name.includes('append') || name.includes('insert') || name.includes('replace')) {
208+
return 'edit';
209+
}
210+
211+
// Delete operations - high risk
212+
if (name.includes('delete') || name.includes('remove') || name.includes('rm') ||
213+
name.includes('unlink') || name.includes('drop')) {
214+
return 'delete';
215+
}
216+
217+
// Move/rename operations
218+
if (name.includes('move') || name.includes('rename') || name.includes('mv') ||
219+
name.includes('copy') || name.includes('cp')) {
220+
return 'move';
221+
}
222+
223+
// Search operations
224+
if (name.includes('search') || name.includes('find') || name.includes('grep') ||
225+
name.includes('query') || name.includes('lookup') || name.includes('ripgrep')) {
226+
return 'search';
227+
}
228+
229+
// Execute operations - high risk
230+
if (name.includes('shell') || name.includes('exec') || name.includes('run') ||
231+
name.includes('command') || name.includes('bash') || name.includes('terminal') ||
232+
name.includes('npm') || name.includes('gradle') || name.includes('make')) {
233+
return 'execute';
234+
}
235+
236+
// Think/reasoning operations
237+
if (name.includes('think') || name.includes('reason') || name.includes('plan') ||
238+
name.includes('analyze') || name.includes('reflect')) {
239+
return 'think';
240+
}
241+
242+
// Fetch/network operations
243+
if (name.includes('fetch') || name.includes('http') || name.includes('curl') ||
244+
name.includes('download') || name.includes('api') || name.includes('request')) {
245+
return 'fetch';
246+
}
247+
248+
return 'other';
249+
}
250+
251+
/**
252+
* Extract file paths from tool parameters for the locations field.
253+
*/
254+
function extractLocations(toolName: string, params: Record<string, any>): Array<{ path: string }> {
255+
const locations: Array<{ path: string }> = [];
256+
257+
// Common parameter names that contain file paths
258+
const pathParams = ['path', 'file', 'filepath', 'filename', 'target', 'source',
259+
'destination', 'dir', 'directory', 'cwd', 'workingDirectory'];
260+
261+
for (const [key, value] of Object.entries(params)) {
262+
if (pathParams.some(p => key.toLowerCase().includes(p)) && typeof value === 'string') {
263+
// Skip internal params
264+
if (key.startsWith('_')) continue;
265+
locations.push({ path: value });
266+
}
267+
}
268+
269+
return locations;
270+
}
271+
185272
/**
186273
* Renderer that sends all output as ACP session updates.
187274
* This bridges our internal renderer to the ACP protocol.
275+
*
276+
* Implements proper ACP tool call lifecycle:
277+
* 1. tool_call (status: pending) - when tool execution starts
278+
* 2. tool_call_update (status: completed/failed) - when tool execution ends
188279
*/
189280
class AcpSessionRenderer {
190281
readonly __doNotUseOrImplementIt: any = {};
@@ -193,13 +284,17 @@ class AcpSessionRenderer {
193284
private sessionId: string;
194285
private toolCallCounter = 0;
195286

287+
// Track current tool call for proper update lifecycle
288+
private currentToolCallId: string | null = null;
289+
private currentToolName: string | null = null;
290+
private currentToolParams: Record<string, any> | null = null;
291+
196292
constructor(connection: acp.AgentSideConnection, sessionId: string) {
197293
this.connection = connection;
198294
this.sessionId = sessionId;
199295
}
200296

201297
renderIterationHeader(current: number, max: number): void {
202-
// Send as thought
203298
this.sendThought(`Iteration ${current}/${max}`);
204299
}
205300

@@ -224,16 +319,40 @@ class AcpSessionRenderer {
224319
}
225320

226321
renderToolCall(toolName: string, paramsStr: string): void {
227-
this.toolCallCounter++;
228-
this.sendToolCall(toolName, 'in_progress', paramsStr);
322+
// Parse params string back to object for better handling
323+
const params: Record<string, any> = {};
324+
// Simple parsing - this is a fallback, prefer renderToolCallWithParams
325+
paramsStr.split(/\s+/).forEach(part => {
326+
const match = part.match(/^(\w+)=(.*)$/);
327+
if (match) {
328+
params[match[1]] = match[2];
329+
}
330+
});
331+
this.renderToolCallWithParams(toolName, params);
229332
}
230333

231334
renderToolCallWithParams(toolName: string, params: Record<string, any>): void {
232335
this.toolCallCounter++;
233-
const paramsStr = Object.entries(params)
234-
.map(([k, v]) => `${k}=${v}`)
235-
.join(', ');
236-
this.sendToolCall(toolName, 'in_progress', paramsStr);
336+
this.currentToolCallId = `tc-${this.toolCallCounter}`;
337+
this.currentToolName = toolName;
338+
this.currentToolParams = params;
339+
340+
const kind = classifyToolKind(toolName);
341+
const locations = extractLocations(toolName, params);
342+
343+
// Send initial tool_call with status: pending
344+
this.connection.sessionUpdate({
345+
sessionId: this.sessionId,
346+
update: {
347+
sessionUpdate: 'tool_call',
348+
toolCallId: this.currentToolCallId,
349+
title: toolName,
350+
status: 'pending',
351+
kind,
352+
...(locations.length > 0 ? { locations } : {}),
353+
rawInput: params,
354+
} as any,
355+
}).catch(err => console.error('[ACP Renderer] Failed to send tool call:', err));
237356
}
238357

239358
renderToolResult(
@@ -243,12 +362,32 @@ class AcpSessionRenderer {
243362
fullOutput?: string | null,
244363
metadata?: Record<string, string>
245364
): void {
246-
this.sendToolCall(
247-
toolName,
248-
success ? 'completed' : 'errored',
249-
undefined,
250-
output || fullOutput || undefined
251-
);
365+
// Use tool_call_update to update the existing tool call
366+
const toolCallId = this.currentToolCallId || `tc-${this.toolCallCounter}`;
367+
const status = success ? 'completed' : 'failed';
368+
const outputText = output || fullOutput || '';
369+
370+
this.connection.sessionUpdate({
371+
sessionId: this.sessionId,
372+
update: {
373+
sessionUpdate: 'tool_call_update',
374+
toolCallId,
375+
status,
376+
// Include output as content
377+
content: outputText ? [
378+
{
379+
type: 'content',
380+
content: { type: 'text', text: outputText },
381+
}
382+
] : undefined,
383+
rawOutput: outputText || undefined,
384+
} as any,
385+
}).catch(err => console.error('[ACP Renderer] Failed to send tool call update:', err));
386+
387+
// Reset current tool call tracking
388+
this.currentToolCallId = null;
389+
this.currentToolName = null;
390+
this.currentToolParams = null;
252391
}
253392

254393
renderTaskComplete(executionTimeMs?: number, toolsUsedCount?: number): void {
@@ -272,7 +411,87 @@ class AcpSessionRenderer {
272411
}
273412

274413
renderUserConfirmationRequest(toolName: string, params: Record<string, any>): void {
275-
// Auto-approve in ACP mode
414+
// Send permission request via ACP
415+
// Note: This is currently fire-and-forget since the renderer interface is synchronous.
416+
// The actual permission flow would require architectural changes to make this async.
417+
this.requestPermissionAsync(toolName, params).catch(err => {
418+
console.error('[ACP Renderer] Permission request failed:', err);
419+
});
420+
}
421+
422+
/**
423+
* Request permission from the client for a sensitive tool operation.
424+
* This implements the ACP session/request_permission flow.
425+
*/
426+
private async requestPermissionAsync(toolName: string, params: Record<string, any>): Promise<boolean> {
427+
const kind = classifyToolKind(toolName);
428+
const locations = extractLocations(toolName, params);
429+
430+
// Determine if this is a high-risk operation that needs permission
431+
const isHighRisk = kind === 'execute' || kind === 'delete' || kind === 'edit';
432+
433+
if (!isHighRisk) {
434+
// Low-risk operations don't need permission
435+
return true;
436+
}
437+
438+
// Create a tool call update for the permission request
439+
const toolCallId = this.currentToolCallId || `tc-perm-${Date.now()}`;
440+
441+
try {
442+
const response = await this.connection.requestPermission({
443+
sessionId: this.sessionId,
444+
toolCall: {
445+
toolCallId,
446+
title: toolName,
447+
status: 'pending',
448+
kind,
449+
...(locations.length > 0 ? { locations } : {}),
450+
rawInput: params,
451+
},
452+
options: [
453+
{
454+
optionId: 'allow_once',
455+
kind: 'allow_once',
456+
name: 'Allow this operation',
457+
},
458+
{
459+
optionId: 'allow_always',
460+
kind: 'allow_always',
461+
name: 'Always allow this tool',
462+
},
463+
{
464+
optionId: 'reject_once',
465+
kind: 'reject_once',
466+
name: 'Reject this operation',
467+
},
468+
{
469+
optionId: 'reject_always',
470+
kind: 'reject_always',
471+
name: 'Always reject this tool',
472+
},
473+
],
474+
});
475+
476+
// Check the outcome
477+
if (response.outcome.outcome === 'cancelled') {
478+
console.error('[ACP Renderer] Permission request was cancelled');
479+
return false;
480+
}
481+
482+
const selectedOption = response.outcome.optionId;
483+
const isAllowed = selectedOption === 'allow_once' || selectedOption === 'allow_always';
484+
485+
if (!isAllowed) {
486+
console.error(`[ACP Renderer] Permission denied for tool ${toolName}: ${selectedOption}`);
487+
}
488+
489+
return isAllowed;
490+
} catch (err) {
491+
// If permission request fails (e.g., client doesn't support it), auto-approve
492+
console.error('[ACP Renderer] Permission request error, auto-approving:', err);
493+
return true;
494+
}
276495
}
277496

278497
renderPlanSummary(summary: any): void {
@@ -289,7 +508,7 @@ class AcpSessionRenderer {
289508
}
290509

291510
addLiveTerminal(sessionId: string, command: string, workingDirectory?: string | null, ptyHandle?: any): void {
292-
// No-op in ACP mode
511+
// TODO: Implement terminal integration via ACP
293512
}
294513

295514
// -- Private helpers --
@@ -313,21 +532,6 @@ class AcpSessionRenderer {
313532
},
314533
}).catch(err => console.error('[ACP Renderer] Failed to send thought:', err));
315534
}
316-
317-
private sendToolCall(title: string, status: string, input?: string, output?: string): void {
318-
this.connection.sessionUpdate({
319-
sessionId: this.sessionId,
320-
update: {
321-
sessionUpdate: 'tool_call',
322-
toolCallId: `tc-${this.toolCallCounter}`,
323-
title,
324-
status,
325-
kind: 'other',
326-
...(input ? { rawInput: input } : {}),
327-
...(output ? { rawOutput: output } : {}),
328-
} as any,
329-
}).catch(err => console.error('[ACP Renderer] Failed to send tool call:', err));
330-
}
331535
}
332536

333537
/**

0 commit comments

Comments
 (0)