fix(fs): observe absence before guarded recreation
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/fs/tool-str-replace-editor/README.md
|
||||
README.md: 97e9e0ab9ade7c7241c1aac3e2489e055d01ff8f
|
||||
README.zh.md: a71f040f3b1383604fa1f151f797906dd343c49a
|
||||
README.md: 1d6ce6fd801997dd21973fde1ffded84dbee1b5a
|
||||
README.zh.md: 62728659fa8c8173ac42a42610f1a710aec6e386
|
||||
|
||||
@@ -13,7 +13,7 @@ Standalone model-facing `str_replace_editor` over `ctx.fs`. It can be composed w
|
||||
|
||||
## Tool
|
||||
|
||||
The schema provides `view`, `create`, `str_replace`, and `insert` over absolute paths. File views use one-based line numbers and preserve content tabs, so displayed text remains valid literal replacement input; directory views omit hidden, dependency, and Python-cache entries and descend two levels. Replacement requires one unique literal match and reports errors only in the public `old_str` vocabulary. Insert follows the selected zero-based insertion boundary without adding an implicit trailing newline. Mutations preserve tabs outside the requested edit.
|
||||
The schema provides `view`, `create`, `str_replace`, and `insert` over absolute paths. File views use one-based line numbers and preserve content tabs, so displayed text remains valid literal replacement input; directory views omit hidden, dependency, and Python-cache entries and descend two levels. A missing view records confirmed absence before returning `FS_NOT_FOUND`, so a later `create` can recover an externally deleted path through the mounted policy's guarded-create flow; absence never authorizes `str_replace` or `insert`. Replacement requires one unique literal match and reports errors only in the public `old_str` vocabulary. Insert follows the selected zero-based insertion boundary without adding an implicit trailing newline. Mutations preserve tabs outside the requested edit.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
## 工具
|
||||
|
||||
schema 提供针对绝对路径的 `view`、`create`、`str_replace` 与 `insert`。文件查看使用从 1 开始的行号,并保留内容中的制表符,因此显示的文本仍可作为有效的字面量替换输入;目录查看忽略隐藏、依赖与 Python 缓存条目并下探两层。替换要求字面量唯一匹配,错误只使用公开的 `old_str` 词汇。插入遵循所选的零基插入边界,不会隐式补尾换行。修改操作会保留请求编辑范围之外的制表符。
|
||||
schema 提供针对绝对路径的 `view`、`create`、`str_replace` 与 `insert`。文件查看使用从 1 开始的行号,并保留内容中的制表符,因此显示的文本仍可作为有效的字面量替换输入;目录查看忽略隐藏、依赖与 Python 缓存条目并下探两层。查看缺失目标时,工具会在返回 `FS_NOT_FOUND` 前记录确认缺失,因此后续 `create` 可以通过已挂载策略的防护创建流程恢复外部删除的路径;缺失状态绝不会授权 `str_replace` 或 `insert`。替换要求字面量唯一匹配,错误只使用公开的 `old_str` 词汇。插入遵循所选的零基插入边界,不会隐式补尾换行。修改操作会保留请求编辑范围之外的制表符。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -105,6 +105,7 @@ async function statExisting(
|
||||
): Promise<FsInfo> {
|
||||
const info = await ctx.fs.stat(target, exec.signal)
|
||||
if (info === undefined) {
|
||||
ctx.emit('fs/observed', target, { kind: 'absent' }, exec)
|
||||
throw new FsError(
|
||||
`The path ${target.displayPath} does not exist. Please provide a valid path.`,
|
||||
'FS_NOT_FOUND',
|
||||
@@ -231,7 +232,7 @@ async function viewPath(
|
||||
throw new FsError(`cannot view "${target.displayPath}": not a regular file or directory`, 'FS_NOT_REGULAR_FILE')
|
||||
}
|
||||
const content = await ctx.fs.readText(target, exec.signal)
|
||||
ctx.emit('fs/observed', target, info.version, exec)
|
||||
ctx.emit('fs/observed', target, { kind: 'present', version: info.version }, exec)
|
||||
return formatFileView(target.displayPath, content, maxOutputChars, viewRange)
|
||||
}
|
||||
|
||||
@@ -266,7 +267,7 @@ async function createFile(
|
||||
} catch (error: unknown) {
|
||||
throw policy.mapError(error, sandboxPolicy)
|
||||
}
|
||||
ctx.emit('fs/observed', target, outcome.version, exec)
|
||||
ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)
|
||||
return `New file created successfully at: ${target.displayPath}`
|
||||
}
|
||||
|
||||
@@ -317,7 +318,7 @@ async function replaceInFile(
|
||||
} catch (error: unknown) {
|
||||
throw policy.mapError(error, sandboxPolicy)
|
||||
}
|
||||
ctx.emit('fs/observed', target, outcome.version, exec)
|
||||
ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)
|
||||
return `The file ${target.displayPath} has been edited successfully.`
|
||||
}
|
||||
|
||||
@@ -359,7 +360,7 @@ async function insertInFile(
|
||||
} catch (error: unknown) {
|
||||
throw policy.mapError(error, sandboxPolicy)
|
||||
}
|
||||
ctx.emit('fs/observed', target, outcome.version, exec)
|
||||
ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)
|
||||
return `The file ${target.displayPath} has been edited successfully.`
|
||||
}
|
||||
|
||||
|
||||
@@ -196,6 +196,35 @@ describe('tool-str-replace-editor', () => {
|
||||
expect(await readFile(sample, 'utf8')).toBe('one\nbetween\n\nthree\n')
|
||||
})
|
||||
|
||||
it('a failed view records absence so create can recover after external deletion', async () => {
|
||||
const { ctx, root, owner } = await setup({}, { fsPolicy: true })
|
||||
const sample = join(root, 'deleted.txt')
|
||||
await writeFile(sample, 'original')
|
||||
expect((await call(ctx, owner, { command: 'view', path: sample })).isError).toBe(false)
|
||||
await rm(sample)
|
||||
|
||||
const missing = await call(ctx, owner, { command: 'view', path: sample })
|
||||
expect(missing.isError).toBe(true)
|
||||
expect(missing.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } })
|
||||
|
||||
const edit = await call(ctx, owner, {
|
||||
command: 'str_replace',
|
||||
path: sample,
|
||||
old_str: 'original',
|
||||
new_str: 'edited',
|
||||
})
|
||||
expect(edit.isError).toBe(true)
|
||||
expect(edit.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } })
|
||||
|
||||
const created = await call(ctx, owner, {
|
||||
command: 'create',
|
||||
path: sample,
|
||||
file_text: 'fresh',
|
||||
})
|
||||
expect(created.isError).toBe(false)
|
||||
expect(await readFile(sample, 'utf8')).toBe('fresh')
|
||||
})
|
||||
|
||||
it('writes replacement text literally', async () => {
|
||||
const { ctx, root, owner } = await setup()
|
||||
const sample = join(root, 'literal.txt')
|
||||
|
||||
Reference in New Issue
Block a user