feat: improve direct model workspace tools
Direct models now use bundled ripgrep for workspace discovery, read files through resumable pages, and edit multiple files with apply_patch. Ask can search and read while Execute retains write access, and fixed total input limits no longer constrain these tools. Release note: 直连模型现可使用随包 ripgrep 搜索项目、分页读取任意大小的文本,并通过多文件补丁修改代码;设置中的相关能力统一显示为工作区文件和开发工具。
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { LocalWorkspaceAccess } from '../workspace'
|
||||
import { applyWorkspacePatch } from './workspace-apply-patch'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((path) =>
|
||||
rm(path, { recursive: true, force: true })
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
async function createWorkspace(): Promise<string> {
|
||||
const path = await mkdtemp(join(tmpdir(), 'goodbuddy-apply-patch-'))
|
||||
temporaryDirectories.push(path)
|
||||
return path
|
||||
}
|
||||
|
||||
describe('workspace apply patch', () => {
|
||||
it('validates and applies add, update, and delete operations', async () => {
|
||||
const root = await createWorkspace()
|
||||
await mkdir(join(root, 'src'))
|
||||
await writeFile(join(root, 'src', 'app.ts'), 'const old = 1\n', 'utf8')
|
||||
await writeFile(join(root, 'obsolete.txt'), 'remove me\n', 'utf8')
|
||||
|
||||
const result = await applyWorkspacePatch(
|
||||
[
|
||||
'*** Begin Patch',
|
||||
'*** Update File: src/app.ts',
|
||||
'@@',
|
||||
'-const old = 1',
|
||||
'+const current = 2',
|
||||
'*** Add File: nested/new.ts',
|
||||
'+export const added = true',
|
||||
'*** Delete File: obsolete.txt',
|
||||
'*** End Patch'
|
||||
].join('\n'),
|
||||
new LocalWorkspaceAccess(root),
|
||||
new AbortController().signal
|
||||
)
|
||||
|
||||
expect(result).toContain('3 file(s)')
|
||||
await expect(readFile(join(root, 'src', 'app.ts'), 'utf8')).resolves.toBe(
|
||||
'const current = 2\n'
|
||||
)
|
||||
await expect(readFile(join(root, 'nested', 'new.ts'), 'utf8')).resolves.toBe(
|
||||
'export const added = true'
|
||||
)
|
||||
await expect(readFile(join(root, 'obsolete.txt'), 'utf8')).rejects.toMatchObject({
|
||||
code: 'ENOENT'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not modify any file when validation fails', async () => {
|
||||
const root = await createWorkspace()
|
||||
await writeFile(join(root, 'first.txt'), 'original\n', 'utf8')
|
||||
|
||||
await expect(
|
||||
applyWorkspacePatch(
|
||||
[
|
||||
'*** Begin Patch',
|
||||
'*** Update File: first.txt',
|
||||
'@@',
|
||||
'-original',
|
||||
'+changed',
|
||||
'*** Update File: missing.txt',
|
||||
'@@',
|
||||
'-missing',
|
||||
'+changed',
|
||||
'*** End Patch'
|
||||
].join('\n'),
|
||||
new LocalWorkspaceAccess(root),
|
||||
new AbortController().signal
|
||||
)
|
||||
).rejects.toThrow('补丁目标不存在')
|
||||
await expect(readFile(join(root, 'first.txt'), 'utf8')).resolves.toBe(
|
||||
'original\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects traversal before creating files', async () => {
|
||||
const root = await createWorkspace()
|
||||
await expect(
|
||||
applyWorkspacePatch(
|
||||
'*** Begin Patch\n*** Add File: ../outside.txt\n+blocked\n*** End Patch',
|
||||
new LocalWorkspaceAccess(root),
|
||||
new AbortController().signal
|
||||
)
|
||||
).rejects.toThrow('不能超出工作区')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user