Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 1 addition & 18 deletions src/commands/acp/acp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,11 @@
* Basic smoke tests for ACP protocol support
*/

import { describe, it, expect } from 'vitest';
import { describe, expect, it } from 'vitest';
import {
fromACP,
getResultText,
mapApprovalCategory,
safeParseJson,
} from './utils/messageAdapter';

describe('ACP Message Adapter', () => {
Expand Down Expand Up @@ -86,20 +85,4 @@ describe('ACP Message Adapter', () => {
expect(mapApprovalCategory('unknown' as any)).toBe('read');
});
});

describe('safeParseJson', () => {
it('should parse valid JSON', () => {
const result = safeParseJson('{"foo":"bar"}');
expect(result).toEqual({ foo: 'bar' });
});
it('should return empty object for invalid JSON', () => {
const result = safeParseJson('not json');
expect(result).toEqual({});
});

it('should return empty object for empty string', () => {
const result = safeParseJson('');
expect(result).toEqual({});
});
});
});
143 changes: 126 additions & 17 deletions src/commands/acp/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import type {
SessionUpdate,
} from '@agentclientprotocol/sdk';
import type { LanguageModelV2StreamPart } from '@ai-sdk/provider';
import type { MessageBus } from '../../messageBus';
import type { NormalizedMessage } from '../../message';
import type { ApprovalCategory, ToolUse } from '../../tool';
import type { MessageBus } from '../../messageBus';
import type { SlashCommand } from '../../slash-commands/types';
import type { ApprovalCategory, ToolUse } from '../../tool';
import { safeParseJson } from '../../utils/safeParseJson';
import { ToolCallHistory } from './toolCallHistory';
import {
extractToolResultParts,
fromACP,
Expand All @@ -21,17 +23,28 @@ import {
isSlashCommand,
mapApprovalCategory,
parseSlashCommand,
safeParseJson,
toACPToolContent,
} from './utils/messageAdapter';
import { createACPPlugin } from './plugin';

/**
* Permission rule for a specific tool/category combination
*/
type PermissionRule = {
decision: 'allow' | 'reject';
timestamp: number;
};

/**
* ACPSession wraps a Neovate session and handles ACP protocol events
*/
export class ACPSession {
private pendingPrompt: AbortController | null = null;
private readonly defaultCwd: string = process.cwd();
// Store permission rules: key is `${toolName}:${category}`
private permissionRules: Map<string, PermissionRule> = new Map();
// Store tool call history
private toolCallHistory: ToolCallHistory = new ToolCallHistory(100);

constructor(
private readonly id: string,
Expand Down Expand Up @@ -96,6 +109,16 @@ export class ACPSession {
}
}

/**
* Get permission rule key for caching decisions
*/
private getPermissionRuleKey(
toolName: string,
category?: ApprovalCategory,
): string {
return `${toolName}:${category || 'default'}`;
}

/**
* Initialize permission approval handler
*/
Expand All @@ -105,6 +128,9 @@ export class ACPSession {
async (data: { toolUse: ToolUse; category?: ApprovalCategory }) => {
const { toolUse, category } = data;

// Check if there's a stored permission rule for this tool/category
const ruleKey = this.getPermissionRuleKey(toolUse.name, category);
const existingRule = this.permissionRules.get(ruleKey);
const permissionResponse = await this.connection.requestPermission({
sessionId: this.id,
toolCall: {
Expand All @@ -125,19 +151,80 @@ export class ACPSession {
],
});

if (permissionResponse.outcome.outcome === 'cancelled') {
return { approved: false };
if (existingRule) {
// Return cached decision without prompting
return { approved: existingRule.decision === 'allow' };
}
try {
const targetUpdate = this.toolCallHistory.get(toolUse.callId);
const permissionResponse = await this.connection.requestPermission({
sessionId: this.id,
toolCall: {
title: targetUpdate?.title ?? toolUse.name,
toolCallId: toolUse.callId,
content: targetUpdate?.content,
},
options: [
{
kind: 'allow_once',
name: 'Allow once',
optionId: 'allow_once',
},
{
kind: 'allow_always',
name: 'Always allow',
optionId: 'allow_always',
},
{
kind: 'reject_once',
name: 'Reject once',
optionId: 'reject_once',
},
{
kind: 'reject_always',
name: 'Always reject',
optionId: 'reject_always',
},
],
});

switch (permissionResponse.outcome.optionId) {
case 'allow':
return { approved: true };
case 'reject':
if (permissionResponse.outcome.outcome === 'cancelled') {
return { approved: false };
default:
throw new Error(
`Unexpected permission outcome ${permissionResponse.outcome}`,
);
}

const optionId = permissionResponse.outcome.optionId;

// Handle the different option types
switch (optionId) {
case 'allow_once':
return { approved: true };

case 'allow_always':
// Store the rule for future use
this.permissionRules.set(ruleKey, {
decision: 'allow',
timestamp: Date.now(),
});
return { approved: true };

case 'reject_once':
return { approved: false };

case 'reject_always':
// Store the rule for future use
this.permissionRules.set(ruleKey, {
decision: 'reject',
timestamp: Date.now(),
});
return { approved: false };

default:
throw new Error(
`Unexpected permission outcome ${permissionResponse.outcome.optionId}`,
);
}
} catch (e) {
console.log(e);
}
},
);
Expand Down Expand Up @@ -253,9 +340,26 @@ export class ACPSession {
} else if (chunk.toolName === 'write') {
update.title = `write ${inputParams?.file_path ?? ''}`;
update.kind = 'edit';
update.content = [
{
type: 'content',
content: {
type: 'text',
text: inputParams.content,
},
},
];
} else if (chunk.toolName === 'edit') {
update.title = `edit ${inputParams?.file_path ?? ''}`;
update.kind = 'edit';
update.content = [
{
type: 'diff',
newText: inputParams.new_string,
oldText: inputParams.old_string,
path: inputParams.file_path,
},
];
} else if (chunk.toolName === 'glob') {
update.title = `glob ${inputParams?.pattern ?? ''}`;
update.kind = 'search';
Expand All @@ -264,10 +368,15 @@ export class ACPSession {
update.kind = 'search';
}

this.connection.sessionUpdate({
sessionId: this.id,
update,
});
// Store tool call in history
this.toolCallHistory.add(chunk.toolCallId, update);

if (update.kind !== 'edit') {
this.connection.sessionUpdate({
sessionId: this.id,
update,
});
}
}

// Handle text deltas
Expand Down
97 changes: 97 additions & 0 deletions src/commands/acp/toolCallHistory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* Tool Call History Manager
* Maintains a history of tool calls with a maximum size limit (FIFO)
*/

import type { SessionUpdate, ToolCall } from '@agentclientprotocol/sdk';

type ToolCallUpdate = ToolCall & {
sessionUpdate: 'tool_call';
};
export class ToolCallHistory {
private history: Map<string, ToolCallUpdate> = new Map();
private readonly maxSize: number;

/**
* Creates a new ToolCallHistory instance
* @param maxSize Maximum number of tool calls to store (default: 100)
*/
constructor(maxSize: number = 100) {
this.maxSize = maxSize;
}

/**
* Add a tool call to history
* If the history is full, removes the oldest entry (FIFO)
* @param toolCallId The unique identifier for the tool call
* @param title The title/description of the tool call
*/
add(toolCallId: string, update: ToolCallUpdate): void {
// If we've reached the max size, remove the oldest entry
if (this.history.size >= this.maxSize) {
const firstKey = this.history.keys().next().value;
if (firstKey) {
this.history.delete(firstKey);
}
}
this.history.set(toolCallId, update);
}

/**
* Get the title for a specific tool call ID
* @param toolCallId The tool call ID to look up
* @returns The title if found, undefined otherwise
*/
get(toolCallId: string): ToolCallUpdate | undefined {
return this.history.get(toolCallId);
}

/**
* Check if a tool call ID exists in history
* @param toolCallId The tool call ID to check
* @returns True if the ID exists in history
*/
has(toolCallId: string): boolean {
return this.history.has(toolCallId);
}

/**
* Remove a specific tool call from history
* @param toolCallId The tool call ID to remove
* @returns True if the entry was removed, false if it didn't exist
*/
remove(toolCallId: string): boolean {
return this.history.delete(toolCallId);
}

/**
* Clear all tool call history
*/
clear(): void {
this.history.clear();
}

/**
* Get the current size of the history
* @returns The number of entries in history
*/
size(): number {
return this.history.size;
}

/**
* Get all tool call IDs in history
* @returns Array of tool call IDs in insertion order
*/
getAllIds(): string[] {
return Array.from(this.history.keys());
}

/**
* Get all entries in history
* @returns Array of [toolCallId, title] tuples in insertion order
*/
getAllEntries(): Array<[string, ToolCallUpdate]> {
return Array.from(this.history.entries());
}
}
15 changes: 2 additions & 13 deletions src/commands/acp/utils/messageAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ import type {
ToolCallContent,
ToolKind,
} from '@agentclientprotocol/sdk';
import type { ApprovalCategory } from '../../../tool';
import type { ToolResultPart, NormalizedMessage } from '../../../message';
import type { NormalizedMessage, ToolResultPart } from '../../../message';
import {
isSlashCommand as neovateIsSlashCommand,
parseSlashCommand as neovateParseSlashCommand,
} from '../../../slashCommand';
import type { ApprovalCategory } from '../../../tool';

/**
* Convert ACP ContentBlock[] to Neovate message format
Expand Down Expand Up @@ -170,14 +170,3 @@ export const isSlashCommand = neovateIsSlashCommand;
* Reuses Neovate's built-in implementation
*/
export const parseSlashCommand = neovateParseSlashCommand;

/**
* Safe JSON parse
*/
export function safeParseJson(json: string): any {
try {
return JSON.parse(json);
} catch (_error) {
return {};
}
}
Loading