auto approve title tool for codex

This commit is contained in:
weishu
2026-03-31 18:36:31 +08:00
parent 2c20b04bec
commit ca7cb9ac90
6 changed files with 371 additions and 40 deletions
@@ -30,6 +30,46 @@ afterEach(() => {
});
describe('AcpSdkBackend', () => {
it('allows the permission handler to resolve requests immediately', async () => {
const backend = new AcpSdkBackend({ command: 'opencode' });
let capturedRequestId: string | null = null;
backend.onPermissionRequest((request) => {
capturedRequestId = request.id;
void backend.respondToPermission(request.sessionId, request, {
outcome: 'selected',
optionId: 'allow-once'
});
});
const backendInternal = backend as unknown as {
handlePermissionRequest: (params: unknown, requestId: string | number | null) => Promise<unknown>;
};
await expect(backendInternal.handlePermissionRequest({
sessionId: 'session-1',
toolCall: {
toolCallId: 'tool-approve',
title: 'hapi_change_title',
rawInput: { title: 'Rename chat' }
},
options: [
{
optionId: 'allow-once',
name: 'Allow once',
kind: 'allow_once'
}
]
}, null)).resolves.toEqual({
outcome: {
outcome: 'selected',
optionId: 'allow-once'
}
});
expect(capturedRequestId).toBe('tool-approve');
});
it('emits turn_complete after trailing tool updates from the same turn', async () => {
backendStatics.UPDATE_QUIET_PERIOD_MS = 8;
backendStatics.UPDATE_DRAIN_TIMEOUT_MS = 200;
+12 -4
View File
@@ -331,16 +331,24 @@ export class AcpSdkBackend implements AgentBackend {
options
};
const responsePromise = new Promise((resolve) => {
this.pendingPermissions.set(toolCallId, { resolve });
});
if (this.permissionHandler) {
this.permissionHandler(request);
try {
this.permissionHandler(request);
} catch (error) {
this.pendingPermissions.delete(toolCallId);
throw error;
}
} else {
logger.debug('[ACP] No permission handler registered; cancelling request');
this.pendingPermissions.delete(toolCallId);
return { outcome: { outcome: 'cancelled' } };
}
return await new Promise((resolve) => {
this.pendingPermissions.set(toolCallId, { resolve });
});
return await responsePromise;
}
private notifyResponseComplete(): void {
+196
View File
@@ -0,0 +1,196 @@
import { describe, expect, it } from 'vitest';
import type { ApiSessionClient } from '@/api/apiSession';
import type { AgentBackend, PermissionRequest, PermissionResponse } from './types';
import { PermissionAdapter } from './permissionAdapter';
type FakeAgentState = {
requests: Record<string, unknown>;
completedRequests: Record<string, unknown>;
};
type Harness = ReturnType<typeof createHarness>;
function createHarness() {
let agentState: FakeAgentState = {
requests: {},
completedRequests: {}
};
const rpcHandlers = new Map<string, (params: unknown) => Promise<unknown> | unknown>();
let permissionHandler: ((request: PermissionRequest) => void) | null = null;
const respondCalls: Array<{
sessionId: string;
request: PermissionRequest;
response: PermissionResponse;
}> = [];
const session = {
rpcHandlerManager: {
registerHandler(method: string, handler: (params: unknown) => Promise<unknown> | unknown) {
rpcHandlers.set(method, handler);
}
},
updateAgentState(handler: (state: FakeAgentState) => FakeAgentState) {
agentState = handler(agentState);
}
} as unknown as ApiSessionClient;
const backend: AgentBackend = {
async initialize() {},
async newSession() {
return 'agent-session';
},
async prompt() {},
async cancelPrompt() {},
async respondToPermission(sessionId, request, response) {
respondCalls.push({ sessionId, request, response });
},
onPermissionRequest(handler) {
permissionHandler = handler;
},
async disconnect() {}
};
new PermissionAdapter(session, backend);
return {
rpcHandlers,
respondCalls,
getAgentState: () => agentState,
emitPermissionRequest(request: PermissionRequest) {
if (!permissionHandler) {
throw new Error('Permission handler was not registered');
}
permissionHandler(request);
}
};
}
async function flushAsyncWork(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
function buildRequest(overrides?: Partial<PermissionRequest>): PermissionRequest {
return {
id: 'perm-1',
sessionId: 'session-1',
toolCallId: 'perm-1',
title: 'Read',
rawInput: { path: 'README.md' },
options: [
{
optionId: 'allow-once',
name: 'Allow once',
kind: 'allow_once'
},
{
optionId: 'allow-always',
name: 'Allow always',
kind: 'allow_always'
}
],
...overrides
};
}
describe('PermissionAdapter', () => {
it('auto-approves change_title permissions without queueing them', async () => {
const harness = createHarness();
harness.emitPermissionRequest(buildRequest({
id: 'perm-title',
toolCallId: 'perm-title',
title: 'hapi_change_title',
rawInput: { title: 'Rename chat' }
}));
await flushAsyncWork();
expect(harness.respondCalls).toEqual([
{
sessionId: 'session-1',
request: expect.objectContaining({
id: 'perm-title',
title: 'hapi_change_title'
}),
response: { outcome: 'selected', optionId: 'allow-once' }
}
]);
expect(harness.getAgentState().requests).toEqual({});
expect(harness.getAgentState().completedRequests).toMatchObject({
'perm-title': {
tool: 'hapi_change_title',
status: 'approved',
decision: 'approved'
}
});
});
it('auto-approves change_title aliases detected from the tool call id', async () => {
const harness = createHarness();
harness.emitPermissionRequest(buildRequest({
id: 'mcp__hapi__change_title-1',
toolCallId: 'mcp__hapi__change_title-1',
title: undefined,
rawInput: { title: 'Rename chat' }
}));
await flushAsyncWork();
expect(harness.respondCalls).toHaveLength(1);
expect(harness.getAgentState().requests).toEqual({});
expect(harness.getAgentState().completedRequests).toMatchObject({
'mcp__hapi__change_title-1': {
status: 'approved',
decision: 'approved'
}
});
});
it('keeps non-title permissions pending until the hub responds', async () => {
const harness = createHarness();
harness.emitPermissionRequest(buildRequest({
id: 'perm-read',
toolCallId: 'perm-read',
title: 'Read'
}));
expect(harness.respondCalls).toEqual([]);
expect(harness.getAgentState().requests).toMatchObject({
'perm-read': {
tool: 'Read'
}
});
const permissionRpc = harness.rpcHandlers.get('permission');
expect(permissionRpc).toBeTypeOf('function');
await permissionRpc?.({
id: 'perm-read',
approved: true,
decision: 'approved'
});
expect(harness.respondCalls).toEqual([
{
sessionId: 'session-1',
request: expect.objectContaining({
id: 'perm-read',
title: 'Read'
}),
response: { outcome: 'selected', optionId: 'allow-once' }
}
]);
expect(harness.getAgentState().requests).toEqual({});
expect(harness.getAgentState().completedRequests).toMatchObject({
'perm-read': {
tool: 'Read',
status: 'approved',
decision: 'approved'
}
});
});
});
+64 -3
View File
@@ -3,6 +3,10 @@ import type { AgentState } from '@/api/types';
import type { ApiSessionClient } from '@/api/apiSession';
import { logger } from '@/ui/logger';
import { deriveToolName } from '@/agent/utils';
import {
resolveToolAutoApprovalDecision,
type AutoApprovalDecision
} from '@/modules/common/permission/BasePermissionHandler';
interface PermissionResponseMessage {
id: string;
@@ -17,11 +21,18 @@ function deriveToolInput(request: PermissionRequest): unknown {
return request.rawOutput;
}
function pickOptionId(request: PermissionRequest, preferredKinds: string[]): string | null {
function pickOptionId(
request: PermissionRequest,
preferredKinds: string[],
options?: { fallbackToFirst?: boolean }
): string | null {
for (const kind of preferredKinds) {
const match = request.options.find((option) => option.kind === kind);
if (match) return match.optionId;
}
if (options?.fallbackToFirst === false) {
return null;
}
return request.options.length > 0 ? request.options[0].optionId : null;
}
@@ -42,14 +53,20 @@ export class PermissionAdapter {
}
private handlePermissionRequest(request: PermissionRequest): void {
this.pendingRequests.set(request.id, request);
const toolName = deriveToolName({
title: request.title,
kind: request.kind,
rawInput: request.rawInput
});
const input = deriveToolInput(request);
const autoDecision = resolveToolAutoApprovalDecision(undefined, toolName, request.toolCallId);
if (autoDecision) {
void this.autoApproveRequest(request, toolName, input, autoDecision);
return;
}
this.pendingRequests.set(request.id, request);
this.session.updateAgentState((currentState) => ({
...currentState,
@@ -66,6 +83,50 @@ export class PermissionAdapter {
logger.debug(`[ACP] Permission request queued: ${toolName} (${request.id})`);
}
private async autoApproveRequest(
request: PermissionRequest,
toolName: string,
input: unknown,
decision: AutoApprovalDecision
): Promise<void> {
const optionId = pickOptionId(
request,
decision === 'approved_for_session'
? ['allow_always', 'allow_once']
: ['allow_once', 'allow_always'],
{ fallbackToFirst: false }
);
const outcome: PermissionResponse = optionId
? { outcome: 'selected', optionId }
: { outcome: 'cancelled' };
await this.backend.respondToPermission(request.sessionId, request, outcome);
const timestamp = Date.now();
const status = outcome.outcome === 'selected' ? 'approved' : 'canceled';
this.session.updateAgentState((currentState) => ({
...currentState,
completedRequests: {
...currentState.completedRequests,
[request.id]: {
tool: toolName,
arguments: input,
createdAt: timestamp,
completedAt: timestamp,
status,
decision: outcome.outcome === 'selected' ? decision : 'abort'
}
}
} satisfies AgentState));
logger.debug(
`[ACP] Auto-${outcome.outcome === 'selected' ? 'approved' : 'cancelled'} ` +
`${toolName} (${request.id}) with decision=${decision}`
);
}
private async handlePermissionResponse(response: PermissionResponseMessage): Promise<void> {
const pending = this.pendingRequests.get(response.id);
if (!pending) {
@@ -35,6 +35,23 @@ function createHarness(mode: 'default' | 'read-only' | 'safe-yolo' | 'yolo') {
}
describe('CodexPermissionHandler', () => {
it('auto-approves change_title tools in default mode', async () => {
const { handler, getAgentState } = createHarness('default');
await expect(handler.handleToolCall('perm-1', 'mcp__hapi__change_title', { title: 'Rename' })).resolves.toEqual({
decision: 'approved'
});
expect(getAgentState().requests).toEqual({});
expect(getAgentState().completedRequests).toMatchObject({
'perm-1': {
tool: 'mcp__hapi__change_title',
status: 'approved',
decision: 'approved'
}
});
});
it('auto-approves yolo requests for the session', async () => {
const { handler, getAgentState } = createHarness('yolo');
@@ -10,7 +10,7 @@ type RpcHandlerManagerLike = {
export type AutoApprovalDecision = 'approved' | 'approved_for_session';
type AutoApprovalRuleSet = {
export type AutoApprovalRuleSet = {
alwaysToolNameHints?: string[];
alwaysToolIdHints?: string[];
writeToolNameHints?: string[];
@@ -28,6 +28,46 @@ const AUTO_APPROVE_TOOL_NAME_HINTS = [
const AUTO_APPROVE_TOOL_ID_HINTS = ['change_title', 'save_memory'];
const AUTO_APPROVE_WRITE_TOOL_HINTS = ['write', 'edit', 'create', 'delete', 'patch', 'fs-edit'];
export function resolveToolAutoApprovalDecision(
mode: PermissionMode | undefined,
toolName: string,
toolCallId: string,
ruleOverrides?: AutoApprovalRuleSet
): AutoApprovalDecision | null {
const rules = {
alwaysToolNameHints: ruleOverrides?.alwaysToolNameHints ?? AUTO_APPROVE_TOOL_NAME_HINTS,
alwaysToolIdHints: ruleOverrides?.alwaysToolIdHints ?? AUTO_APPROVE_TOOL_ID_HINTS,
writeToolNameHints: ruleOverrides?.writeToolNameHints ?? AUTO_APPROVE_WRITE_TOOL_HINTS
};
const lowerTool = toolName.toLowerCase();
const lowerId = toolCallId.toLowerCase();
const decisionForMode: AutoApprovalDecision = mode === 'yolo' ? 'approved_for_session' : 'approved';
if (rules.alwaysToolNameHints.some((name) => lowerTool.includes(name))) {
return decisionForMode;
}
if (rules.alwaysToolIdHints.some((name) => lowerId.includes(name))) {
return decisionForMode;
}
if (mode === 'yolo') {
return 'approved_for_session';
}
if (mode === 'safe-yolo') {
return 'approved';
}
if (mode === 'read-only') {
const isWriteTool = rules.writeToolNameHints.some((name) => lowerTool.includes(name));
return isWriteTool ? null : 'approved';
}
return null;
}
export type PermissionHandlerClient = {
rpcHandlerManager: RpcHandlerManagerLike;
updateAgentState: (handler: (state: AgentState) => AgentState) => void;
@@ -83,38 +123,7 @@ export abstract class BasePermissionHandler<TResponse extends { id: string }, TR
toolCallId: string,
ruleOverrides?: AutoApprovalRuleSet
): AutoApprovalDecision | null {
const rules = {
alwaysToolNameHints: ruleOverrides?.alwaysToolNameHints ?? AUTO_APPROVE_TOOL_NAME_HINTS,
alwaysToolIdHints: ruleOverrides?.alwaysToolIdHints ?? AUTO_APPROVE_TOOL_ID_HINTS,
writeToolNameHints: ruleOverrides?.writeToolNameHints ?? AUTO_APPROVE_WRITE_TOOL_HINTS
};
const lowerTool = toolName.toLowerCase();
const lowerId = toolCallId.toLowerCase();
const decisionForMode: AutoApprovalDecision = mode === 'yolo' ? 'approved_for_session' : 'approved';
if (rules.alwaysToolNameHints.some((name) => lowerTool.includes(name))) {
return decisionForMode;
}
if (rules.alwaysToolIdHints.some((name) => lowerId.includes(name))) {
return decisionForMode;
}
if (mode === 'yolo') {
return 'approved_for_session';
}
if (mode === 'safe-yolo') {
return 'approved';
}
if (mode === 'read-only') {
const isWriteTool = rules.writeToolNameHints.some((name) => lowerTool.includes(name));
return isWriteTool ? null : 'approved';
}
return null;
return resolveToolAutoApprovalDecision(mode, toolName, toolCallId, ruleOverrides);
}
protected addPendingRequest(