@@ -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 */
189280class 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