Files
ycode/scripts/tool-approval-regression.tsx

107 lines
6.1 KiB
TypeScript

import assert from 'node:assert/strict'
import { renderToStaticMarkup } from 'react-dom/server'
import type { ChatInteraction } from '../src/shared/conversation'
import { ToolCallRenderer } from '../src/renderer/src/workbuddy/conversation/ToolCallRenderer'
import {
findToolApproval,
hasSourcePermissionRequest,
isActiveToolApproval,
sourcePermissionMetadata,
toolApprovalOption
} from '../src/renderer/src/workbuddy/conversation/tool-approval'
import type { ConversationToolCall } from '../src/renderer/src/workbuddy/conversation/types'
const permissionOptions = [
{ optionId: 'always', kind: 'allow_always' },
{ optionId: 'once', kind: 'allow_once' },
{ optionId: 'allow', kind: 'allow' },
{ optionId: 'reject', kind: 'reject' },
{ optionId: 'reject-once', kind: 'reject_once' }
]
assert.equal(toolApprovalOption(permissionOptions, 'allow')?.optionId, 'once', 'once takes priority over allow_always and allow')
assert.equal(toolApprovalOption([permissionOptions[0]], 'allow'), undefined, 'allow_always is never used by the source action')
assert.equal(toolApprovalOption([permissionOptions[2]], 'allow')?.optionId, 'allow')
assert.equal(toolApprovalOption(permissionOptions, 'reject')?.optionId, 'reject-once')
assert.equal(toolApprovalOption([permissionOptions[3]], 'reject')?.optionId, 'reject')
const firstApproval = {
id: 'approval-1', runId: 'run', toolCallId: 'file-call', title: 'Authorize file write', status: 'pending',
kind: 'approval', requestId: 'request-1', options: permissionOptions
} satisfies ChatInteraction
const latestApproval = {
...firstApproval, id: 'approval-2', requestId: 'request-2', status: 'responding'
} satisfies ChatInteraction
const unrelatedApproval = { ...firstApproval, id: 'approval-other', toolCallId: 'other-call' } satisfies ChatInteraction
assert.equal(findToolApproval([firstApproval, unrelatedApproval, latestApproval], 'file-call'), latestApproval)
assert.equal(findToolApproval([firstApproval], 'missing'), undefined)
assert.equal(isActiveToolApproval(firstApproval as Extract<ChatInteraction, { kind: 'approval' }>), true)
assert.equal(isActiveToolApproval(latestApproval as Extract<ChatInteraction, { kind: 'approval' }>), true)
assert.equal(isActiveToolApproval({ ...latestApproval, status: 'answered' } as Extract<ChatInteraction, { kind: 'approval' }>), false)
const request = { requestId: 'request-file', permissionOptions: [{ optionId: 'once', name: 'Allow once', kind: 'allow_once' }] }
const fileTool: ConversationToolCall = {
id: 'file-call', name: 'edit_file', status: 'pending', args: { filePath: 'src/file.ts', oldContent: 'before', content: 'after' },
metaData: { acp_request_permission: request }
}
const commandTool: ConversationToolCall = {
id: 'command-call', name: 'execute_command', status: 'pending', args: { command: 'npm test' },
metadata: { acp_request_permission: { ...request, requestId: 'request-command' } }
}
assert.equal(hasSourcePermissionRequest(fileTool), true)
assert.equal(hasSourcePermissionRequest(commandTool), true)
assert.equal(hasSourcePermissionRequest({ ...fileTool, metaData: undefined, metadata: fileTool.metaData }), true)
assert.equal(hasSourcePermissionRequest({ ...fileTool, metaData: { acp_request_permission: { permissionOptions: request.permissionOptions } } }), false)
assert.equal(hasSourcePermissionRequest({ ...fileTool, metaData: { acp_request_permission: { requestId: 'empty-options', permissionOptions: [] } } }), false)
assert.equal(hasSourcePermissionRequest({ ...fileTool, metaData: undefined, metadata: undefined }), false)
const mergedMetadata = sourcePermissionMetadata({ source: 'kept', acp_request_permission: { scope: 'local', requestId: 'old' } }, latestApproval as Extract<ChatInteraction, { kind: 'approval' }>)
assert.deepEqual(mergedMetadata, {
source: 'kept',
acp_request_permission: { scope: 'local', requestId: 'request-2', permissionOptions: permissionOptions }
})
const pendingControl = { status: 'pending' as const, onDecision: () => undefined }
const respondingControl = { status: 'responding' as const, onDecision: () => undefined }
const rendered = (tool: ConversationToolCall, control = pendingControl) => renderToStaticMarkup(<ToolCallRenderer tool={tool} approval={control} />)
const extractActions = (markup: string) => [...markup.matchAll(/<button\b(?=[^>]*data-tool-decision="([^"]+)")(?=[^>]*class="[^"]*cr-tool-btn--([\w-]+)[^"]*")([^>]*)>([\s\S]*?)<\/button>/g)].map((match) => ({
decision: match[1],
variant: match[2],
disabled: /\sdisabled(?:="")?(?:\s|$)/.test(match[3]),
busy: /aria-busy="true"/.test(match[3]),
label: match[4].replace(/<[^>]*>/g, '')
}))
const pendingFile = rendered(fileTool)
assert.deepEqual(extractActions(pendingFile).map(({ decision, variant, label }) => ({ decision, variant, label })), [
{ decision: 'allow', variant: 'primary', label: '接受' },
{ decision: 'reject', variant: 'danger', label: '拒绝' }
])
const pendingCommand = rendered(commandTool)
assert.match(pendingCommand, /确认执行命令?/)
assert.deepEqual(extractActions(pendingCommand).map(({ decision, variant, label }) => ({ decision, variant, label })), [
{ decision: 'allow', variant: 'primary', label: '运行' },
{ decision: 'skip', variant: 'default', label: '跳过' },
{ decision: 'reject', variant: 'danger', label: '拒绝' }
])
for (const tool of [
{ ...fileTool, status: 'running' as const },
{ ...commandTool, status: 'running' as const },
{ ...fileTool, metaData: undefined, metadata: undefined },
{ ...commandTool, metaData: undefined, metadata: undefined },
{ ...fileTool, metaData: { acp_request_permission: { requestId: 'empty', permissionOptions: [] } } }
]) {
assert.deepEqual(extractActions(rendered(tool)).length, 0)
}
const respondingCommand = extractActions(rendered(commandTool, respondingControl))
assert.deepEqual(respondingCommand.map(({ decision, disabled, busy }) => ({ decision, disabled, busy })), [
{ decision: 'allow', disabled: true, busy: true },
{ decision: 'skip', disabled: true, busy: true },
{ decision: 'reject', disabled: true, busy: true }
])
console.log('Tool approval helper and inline source markup regression checks passed.')