Merge pull request #1343 from deepseek-harness/feat/fs-tool-error-remedy

feat(fs): append recovery remedy to guarded-mutation errors
This commit is contained in:
_Kerman
2026-08-07 14:15:08 +08:00
committed by GitHub
16 changed files with 244 additions and 16 deletions
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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 .agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.md
2026-08-03-fs-tool-error-remedy.md: f227c31365725652b130e097d70c79d3daab3684
2026-08-03-fs-tool-error-remedy.zh.md: 11acd0cf48924833ced91591d5ea1424735969cd
@@ -0,0 +1,32 @@
# Agent Note: Guarded-mutation errors append the recovery instruction at the model boundary
Status: implemented
English | [中文](2026-08-03-fs-tool-error-remedy.zh.md)
## Problem
Guarded `write` and `edit` failures reach the model with messages that state the condition but not the only correct recovery: `FS_STALE_VERSION` ("file changed since it was read") and `FS_NOT_OBSERVED` ("edit requires reading … first"). The model must guess that the recovery is a re-read (or a first read) followed by a retry, and the retry/permission/UI layers that route on the structured code see the same message text. The provider-owned messages are part of the storage seam's machine-oriented vocabulary ([filesystem capability seam](../architecture/2026-06-17-filesystem-capability-seam.md)), so the remedy cannot live there without leaking model-facing wording into every consumer of `FsError`.
## Decision
`dsh-tool-fs` owns a model-facing error wrapper, `remediateFsError` in `src/error.ts`, applied in `write.ts` and `edit.ts` after the sandbox denial mapping. It appends the recovery instruction to the two guarded-mutation codes and passes everything else through untouched:
- `FS_STALE_VERSION` (including a missing edit target, which shares the stale code) gains `— re-read the file, then retry`.
- `FS_NOT_OBSERVED` gains `— read the file, then retry`.
The structured `FsError` code is preserved so retry/permission/UI layers keep routing on it, and the original error chains as `cause`. Provider messages stay machine-oriented and unchanged.
In `edit.ts` the `fs/edit-intent` waterfall now sits inside the same `try` as the provider mutation, so the policy plugin's `FS_NOT_OBSERVED` refusal thrown from the intent slot also receives the remedy — both refusal paths reach the model with the same recovery wording.
## Alternatives considered
- **Append the remedy to the provider messages in `dsh-fs` / `dsh-fs-local`.** Rejected because those messages are machine-oriented seam vocabulary consumed by retry, permission, and UI layers as well as the model surface; model-facing wording belongs at the model boundary, where `dsh-tool-fs` already owns result formatting ([filesystem capability seam](../architecture/2026-06-17-filesystem-capability-seam.md)).
- **Add the recovery to prompt guidance instead.** Rejected because the failure arrives mid-task; a static instruction does not reliably reach the retry decision, while the error message is present exactly when the model must act.
- **Signal the remedy with a new `FsError` code.** Rejected because the two failures are the same conditions retry layers already handle; splitting the code would fork routing on identical semantics.
## Consequences
Model-visible text for the two codes changes; the `fs-policy-reject` keyless snapshot is re-recorded, and the READMEs of `dsh-tool-fs` and `dsh-fs-policy` pin the exact appended text. Unit tests cover the wrapper directly (remedy text, code preservation, cause chaining, passthrough of other codes and non-`FsError` values) and the assembled tool paths assert the remedy reaches the model for both codes.
The remedy is not a promise: a deleted observed target cannot be unblocked, because re-reading a missing file fails with `FS_NOT_FOUND` and records no observation. That dead end is pinned fail-closed in the integration tests — the retried mutation fails identically until the target exists again and is freshly observed.
@@ -0,0 +1,32 @@
# Agent Note: Guarded-mutation errors append the recovery instruction at the model boundary
Status: implemented
[English](2026-08-03-fs-tool-error-remedy.md) | 中文
## Problem
受防护的 `write``edit` 失败以只陈述条件、不给出唯一正确恢复方式的消息到达模型:`FS_STALE_VERSION`"file changed since it was read")与 `FS_NOT_OBSERVED`"edit requires reading … first")。模型必须自行猜测恢复方式是重新读取(或首次读取)后重试,而基于结构化错误码路由的重试/权限/UI 层看到的也是同一段消息文本。提供方拥有的消息属于存储接缝的面向机器词汇([filesystem capability seam](../architecture/2026-06-17-filesystem-capability-seam.md)),因此恢复指令不能放在那里,否则会把面向模型的措辞泄漏给 `FsError` 的每个消费者。
## Decision
`dsh-tool-fs` 拥有一个面向模型的错误包装 `remediateFsError`(位于 `src/error.ts`),在 `write.ts``edit.ts` 中于沙箱拒绝映射之后应用。它为两个受防护变更错误码追加恢复指令,其余错误原样透传:
- `FS_STALE_VERSION`(包括缺失的编辑目标——它与陈旧错误共用同一错误码)追加 `— re-read the file, then retry`
- `FS_NOT_OBSERVED` 追加 `— read the file, then retry`
结构化 `FsError` 错误码保持不变,使重试/权限/UI 层继续基于它路由;原始错误作为 `cause` 链入。提供方消息保持面向机器且不变。
`edit.ts` 中,`fs/edit-intent` waterfall 现在与提供方变更位于同一个 `try` 内,因此策略插件从 intent 槽抛出的 `FS_NOT_OBSERVED` 拒绝也会获得恢复指令——两条拒绝路径都以相同的恢复措辞到达模型。
## Alternatives considered
- **在 `dsh-fs` / `dsh-fs-local` 的提供方消息中追加恢复指令。** 被拒绝:这些消息是面向机器的接缝词汇,除模型表面外还被重试、权限与 UI 层消费;面向模型的措辞应位于模型边界,即 `dsh-tool-fs` 已经拥有结果格式化之处([filesystem capability seam](../architecture/2026-06-17-filesystem-capability-seam.md))。
- **改为在提示词引导中加入恢复方式。** 被拒绝:失败发生在任务中途;静态指令无法可靠地影响重试决策,而错误消息恰好在模型必须行动时出现。
- **用新的 `FsError` 错误码表达恢复指令。** 被拒绝:这两种失败本就是重试层已处理的相同条件;拆分错误码会让语义相同的路由分叉。
## Consequences
两个错误码的模型可见文本发生变化;`fs-policy-reject` 无密钥快照被重新录制,`dsh-tool-fs``dsh-fs-policy` 的 README 逐字固定追加后的文本。单元测试直接覆盖包装器(恢复指令文本、错误码保留、cause 链、其他错误码与非 `FsError` 值的透传),组装后的工具路径断言两个错误码的恢复指令都到达模型。
恢复指令不是承诺:已删除的观察目标无法被解除阻塞,因为重新读取缺失文件会以 `FS_NOT_FOUND` 失败且不记录观察。这一死胡同在集成测试中以 fail-closed 方式固定——在目标重新存在并被新鲜观察之前,重试的变更以相同方式失败。
@@ -18,7 +18,7 @@
{"type":"assistant/chunk","seq":80,"time":1785730437884,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":81,"time":1785730437884,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the edit tool to replace \"blue\" with \"green\" in settings.txt without reading the file first, and then reply with just \"DONE\"."},{"type":"tool-call","id":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"fc73e1c1-7ff3-4722-9f4a-b245d8fdc040"},"usage":{"inputTokens":3132,"outputTokens":115,"cacheReadTokens":0,"reasoningTokens":36}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80],"surfaceOp":"append"}
{"type":"tool/call","seq":82,"time":1785730437885,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}}
{"type":"tool/result","seq":83,"time":1785730437894,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119"},"content":[{"type":"tool-result","toolCallId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"{{cwd}}/settings.txt\" first"}],"isError":true}],"role":"user","id":"5d9bc635-9fc4-4810-a49d-a627b23122e4"},"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[82],"surfaceOp":"append"}
{"type":"tool/result","seq":83,"time":1785730437894,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119"},"content":[{"type":"tool-result","toolCallId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"{{cwd}}/settings.txt\" first — read the file, then retry"}],"isError":true}],"role":"user","id":"5d9bc635-9fc4-4810-a49d-a627b23122e4"},"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[82],"surfaceOp":"append"}
{"type":"step/end","seq":84,"time":1785730437894,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":85,"time":1785730437903,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":86,"time":1783611704931,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
+2 -2
View File
@@ -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/fs-policy/README.md
README.md: dc4e9377793570c80b8d71ec84196bebe7fe583a
README.zh.md: 499192d2623765f214af7556d23beabbe2129ea1
README.md: f6b3292bdc6e5565df0393a59c50d4e594921401
README.zh.md: 2ebd2f054ece0472c7147f6f9e740987b11c6031
+1 -1
View File
@@ -55,7 +55,7 @@ Because the plugin influences the world only through events, removing it does no
#### What the model sees
This plugin adds no prompt or schema. It rejects an edit without a prior read with code `FS_NOT_OBSERVED` and exact message `edit requires reading "<path>" first`. Guarded mutations whose observed version is stale propagate the provider-owned `FS_STALE_VERSION` error. [`dsh-tool-fs`](../tool-fs/README.md) owns the model-facing error wrapper; observation state is never shown.
This plugin adds no prompt or schema. It rejects an edit without a prior read with code `FS_NOT_OBSERVED` and exact message `edit requires reading "<path>" first`. Guarded mutations whose observed version is stale propagate the provider-owned `FS_STALE_VERSION` error. [`dsh-tool-fs`](../tool-fs/README.md) owns the model-facing error wrapper, which appends the recovery instruction to `FS_STALE_VERSION` (`— re-read the file, then retry`) and `FS_NOT_OBSERVED` (`— read the file, then retry`) messages while preserving the code; observation state is never shown.
#### Token effect
+1 -1
View File
@@ -55,7 +55,7 @@ await ctx.plugin(FsPolicy)
#### 模型看到的内容
该插件不添加提示词或 schema。编辑前未读取时,它会以代码 `FS_NOT_OBSERVED` 和精确消息 `edit requires reading "<path>" first` 拒绝。观察版本陈旧的防护变更会传播由提供方拥有的 `FS_STALE_VERSION` 错误。[`dsh-tool-fs`](../tool-fs/README.md)拥有面向模型的错误包装;观察状态绝不会显示。
该插件不添加提示词或 schema。编辑前未读取时,它会以代码 `FS_NOT_OBSERVED` 和精确消息 `edit requires reading "<path>" first` 拒绝。观察版本陈旧的防护变更会传播由提供方拥有的 `FS_STALE_VERSION` 错误。[`dsh-tool-fs`](../tool-fs/README.md)拥有面向模型的错误包装,会为 `FS_STALE_VERSION` 消息追加恢复指令(`— re-read the file, then retry`)、为 `FS_NOT_OBSERVED` 消息追加恢复指令(`— read the file, then retry`),同时保留错误码;观察状态绝不会显示。
#### Token 影响
+2 -2
View File
@@ -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-fs/README.md
README.md: 1ecfa0d013e1208b7d9058b4a254990f16f118e0
README.zh.md: 9d4de4c219e7818c83c5ac326d2f2d385a30ece7
README.md: 28880860dc6c89745eb21fbf732d04f596f9b07f
README.zh.md: 8af0aec51e71681211bbd5a4f582be8b8b0271b8
+1 -1
View File
@@ -136,7 +136,7 @@ Append-only; newly visible content follows the reusable request prefix and does
#### What the model sees
Failures are normalized as `Error: <message>`. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to <max>`, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "<path>": not found`, `cannot read "<path>": not a regular file`, and `offset <offset> is out of range for "<path>" (<total> lines)`; provider and policy templates are quoted in their package READMEs.
Failures are normalized as `Error: <message>`. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to <max>`, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "<path>": not found`, `cannot read "<path>": not a regular file`, and `offset <offset> is out of range for "<path>" (<total> lines)`; provider and policy templates are quoted in their package READMEs. Guarded-mutation failures additionally carry their recovery instruction in the message, appended by this package's model-facing error wrapper: `FS_STALE_VERSION` (including a missing edit target) gets `— re-read the file, then retry`, `FS_NOT_OBSERVED` gets `— read the file, then retry`; the structured code is preserved.
#### Token effect
+1 -1
View File
@@ -136,7 +136,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces
#### 模型看到的内容
失败会规范化为 `Error: <message>`。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to <max>`、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "<path>": not found`、`cannot read "<path>": not a regular file` 和 `offset <offset> is out of range for "<path>" (<total> lines)`;提供方和策略模板在各自包的 README 中逐字列出。
失败会规范化为 `Error: <message>`。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to <max>`、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "<path>": not found`、`cannot read "<path>": not a regular file` 和 `offset <offset> is out of range for "<path>" (<total> lines)`;提供方和策略模板在各自包的 README 中逐字列出。防护变更失败还会在消息中携带恢复指令,由本包面向模型的错误包装追加:`FS_STALE_VERSION`(包括编辑目标缺失)追加 `— re-read the file, then retry``FS_NOT_OBSERVED` 追加 `— read the file, then retry`;结构化错误码保持不变。
#### Token 影响
+10 -4
View File
@@ -11,6 +11,7 @@ import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh-
import type {} from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { computeHunkDiffs, diffsFromMeta } from './diff.ts'
import { remediateFsError } from './error.ts'
import { sessionResolveOptions } from './session-cwd.ts'
import type { FsSandboxSurface } from './sandbox.ts'
@@ -116,10 +117,13 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void {
const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, input.filePath, sandboxPolicy?.workspaceRoot))
// Single-slot decision: the policy plugin returns { version: vObserved } or
// throws FS_NOT_OBSERVED; the bare default is undefined (unconditional edit).
// No stat — the bare default never manufactures a version basis.
const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)
// No stat — the bare default never manufactures a version basis. The intent
// slot itself can throw FS_NOT_OBSERVED for an unread target, so it sits
// inside the try: both that refusal and the provider's guarded-mutation
// failure get the model-facing remedy below.
let outcome
try {
const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)
outcome = await ctx.fs.editText(
target,
{ oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll },
@@ -128,8 +132,10 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void {
sandboxPolicy,
)
} catch (error: unknown) {
// A sandbox denial becomes the shared [sandbox: …] marker; any other error passes through.
throw sandbox.mapError(error, sandboxPolicy)
// A sandbox denial becomes the shared [sandbox: …] marker (the model
// recognizes it from bash); stale/not-observed failures gain their
// model-facing remedy; anything else passes through.
throw remediateFsError(sandbox.mapError(error, sandboxPolicy))
}
// Record the observed version (a no-op when no policy plugin listens).
ctx.emit('fs/observed', target, outcome.version, exec)
+34
View File
@@ -0,0 +1,34 @@
/**
* Model-facing remediation for guarded-mutation failures. The provider's
* `FS_STALE_VERSION` and `FS_NOT_OBSERVED` messages state the condition but
* not the only correct recovery (re-read / read the file), so this package
* appends the remedy at the model boundary; provider messages stay
* machine-oriented and unchanged.
* @module @deepseek-ai/dsh-tool-fs/src/error
*/
import { FsError } from '@deepseek-ai/dsh-fs'
import type { FsErrorCode } from '@deepseek-ai/dsh-fs'
/** The remedy appended to each remediable failure code's message. */
const REMEDIES: Partial<Record<FsErrorCode, string>> = {
FS_STALE_VERSION: 're-read the file, then retry',
FS_NOT_OBSERVED: 'read the file, then retry',
}
/**
* Append the correct recovery instruction to a guarded-mutation failure's
* message. `FS_STALE_VERSION` (the file changed since this session's last
* observation, including a missing target) recovers only by re-reading;
* `FS_NOT_OBSERVED` (no prior read by this session) by reading. The `FsError`
* code is preserved so retry/permission/UI layers keep routing on it, and the
* original error chains as `cause`. Anything else passes through untouched.
* @param error - the caught value from a write/edit execution.
* @returns a remediated `FsError` for the two guarded-mutation codes, else the original value.
*/
export function remediateFsError(error: unknown): unknown {
if (!(error instanceof FsError)) return error
const remedy = REMEDIES[error.code]
if (!remedy) return error
return new FsError(`${error.message}${remedy}`, error.code, { cause: error })
}
+4 -2
View File
@@ -12,6 +12,7 @@ import type { FsWriteOutcome } from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { computeHunkDiffs, diffsFromMeta } from './diff.ts'
import { remediateFsError } from './error.ts'
import { sessionResolveOptions } from './session-cwd.ts'
import type { FsSandboxSurface } from './sandbox.ts'
@@ -113,8 +114,9 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void {
outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal, sandboxPolicy)
} catch (error: unknown) {
// A sandbox denial becomes the shared [sandbox: …] marker (the model
// recognizes it from bash); any other error passes through.
throw sandbox.mapError(error, sandboxPolicy)
// recognizes it from bash); stale/not-observed failures gain their
// model-facing remedy; anything else passes through.
throw remediateFsError(sandbox.mapError(error, sandboxPolicy))
}
// Record the observed version (a no-op when no policy plugin listens).
ctx.emit('fs/observed', target, outcome.version, exec)
+35
View File
@@ -0,0 +1,35 @@
/**
* Unit tests for the model-facing error remediation: the remedy appended to
* guarded-mutation failures, code preservation, and passthrough behavior.
*/
import { describe, expect, it } from 'vitest'
import { FsError } from '@deepseek-ai/dsh-fs'
import { remediateFsError } from '../src/error.ts'
describe('remediateFsError', () => {
it('appends the re-read remedy to FS_STALE_VERSION, preserving the code and chaining the cause', () => {
const original = new FsError('cannot edit "x": file changed since it was read', 'FS_STALE_VERSION')
const remedied = remediateFsError(original) as FsError
expect(remedied).toBeInstanceOf(FsError)
expect(remedied.message).toBe('cannot edit "x": file changed since it was read — re-read the file, then retry')
expect(remedied.code).toBe('FS_STALE_VERSION')
expect(remedied.cause).toBe(original)
})
it('appends the read remedy to FS_NOT_OBSERVED', () => {
const remedied = remediateFsError(new FsError('edit requires reading "x" first', 'FS_NOT_OBSERVED')) as FsError
expect(remedied.message).toBe('edit requires reading "x" first — read the file, then retry')
expect(remedied.code).toBe('FS_NOT_OBSERVED')
})
it('leaves other FsError codes untouched', () => {
const original = new FsError('no match anywhere', 'FS_EDIT_NOT_FOUND')
expect(remediateFsError(original)).toBe(original)
})
it('leaves non-FsError values untouched', () => {
const original = new Error('boom')
expect(remediateFsError(original)).toBe(original)
})
})
@@ -71,6 +71,9 @@ describe('default deployment (with dsh-fs-policy)', () => {
const result = await call('write', { file_path: 'a.txt', content: 'clobber' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } })
// The model-facing text names the remedy, not just the condition.
expect(text(result)).toContain('without reading it first')
expect(text(result)).toContain('read the file, then retry')
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('original')
})
@@ -89,6 +92,23 @@ describe('default deployment (with dsh-fs-policy)', () => {
const result = await call('write', { file_path: 'a.txt', content: 'replaced' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
// The model-facing text names the remedy, not just the condition.
expect(text(result)).toContain('file changed since it was read')
expect(text(result)).toContain('re-read the file, then retry')
})
it('the stale remedy is actionable: re-reading the changed file unblocks the retried write', async () => {
await writeFile(join(dir, 'a.txt'), 'original')
await call('read', { file_path: 'a.txt' })
await writeFile(join(dir, 'a.txt'), 'changed-externally') // out-of-band change
const stale = await call('write', { file_path: 'a.txt', content: 'replaced' })
expect(stale.isError).toBe(true)
expect(stale.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
// Follow the remedy: re-read (refreshes the observed version), then retry.
expect((await call('read', { file_path: 'a.txt' })).isError).toBe(false)
const retried = await call('write', { file_path: 'a.txt', content: 'replaced' })
expect(retried.isError).toBe(false)
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('replaced')
})
})
@@ -131,6 +151,9 @@ describe('default deployment (with dsh-fs-policy)', () => {
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } })
// The policy's refusal reaches the model with the read remedy appended.
expect(text(result)).toContain('edit requires reading')
expect(text(result)).toContain('read the file, then retry')
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello world')
})
@@ -155,6 +178,23 @@ describe('default deployment (with dsh-fs-policy)', () => {
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
// The model-facing text names the remedy, not just the condition.
expect(text(result)).toContain('file changed since it was read')
expect(text(result)).toContain('re-read the file, then retry')
})
it('the stale remedy is actionable: re-reading the changed file unblocks the retried edit', async () => {
await writeFile(join(dir, 'a.txt'), 'hello world')
await call('read', { file_path: 'a.txt' })
await writeFile(join(dir, 'a.txt'), 'hello brave world') // out-of-band change
const stale = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
expect(stale.isError).toBe(true)
expect(stale.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
// Follow the remedy: re-read (refreshes the observed version), then retry.
expect((await call('read', { file_path: 'a.txt' })).isError).toBe(false)
const retried = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
expect(retried.isError).toBe(false)
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello brave there')
})
it('rejects an ambiguous match without replace_all', async () => {
@@ -194,6 +234,43 @@ describe('default deployment (with dsh-fs-policy)', () => {
})
})
describe('deleted observed target (fail-closed corner)', () => {
it('a deleted observed file stays un-writable and un-editable in-session: the remedy cannot unblock it', async () => {
await writeFile(join(dir, 'a.txt'), 'original')
await call('read', { file_path: 'a.txt' })
await rm(join(dir, 'a.txt')) // out-of-band deletion
// Edit of the missing target: stale (the missing-target path shares the
// stale code and the re-read remedy).
const edit = await call('edit', { file_path: 'a.txt', old_string: 'original', new_string: 'x' })
expect(edit.isError).toBe(true)
expect(edit.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
// Re-reading the missing file FAILS with FS_NOT_FOUND and records no
// observation, so the retried edit fails identically: the observed entry
// is never cleared for a deleted target.
const reread = await call('read', { file_path: 'a.txt' })
expect(reread.isError).toBe(true)
expect(reread.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } })
const retriedEdit = await call('edit', { file_path: 'a.txt', old_string: 'original', new_string: 'x' })
expect(retriedEdit.isError).toBe(true)
expect(retriedEdit.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
// Write cannot recreate it either: the stale observation still forces
// replaceIfVersion, which rejects a missing target ("file no longer exists").
const write = await call('write', { file_path: 'a.txt', content: 'fresh' })
expect(write.isError).toBe(true)
expect(write.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
// The dead end lifts once the file exists again and is freshly observed.
await writeFile(join(dir, 'a.txt'), 'restored')
expect((await call('read', { file_path: 'a.txt' })).isError).toBe(false)
const recovered = await call('write', { file_path: 'a.txt', content: 'fresh' })
expect(recovered.isError).toBe(false)
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('fresh')
})
})
describe('stat budget', () => {
it('read stats once; write and edit never stat in the tool (the gate stats zero too)', async () => {
await writeFile(join(dir, 'a.txt'), 'hello world')
@@ -264,6 +341,9 @@ describe('bare provider (no dsh-fs-policy)', () => {
const result = await call('edit', { file_path: 'missing.txt', old_string: 'a', new_string: 'b' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ info: { code: 'FS_STALE_VERSION' } })
// Even without policy, the stale text carries the re-read remedy.
expect(text(result)).toContain('file changed since it was read')
expect(text(result)).toContain('re-read the file, then retry')
})
it('edit still enforces literal-match codes (FS_EDIT_NOT_FOUND), unrelated to freshness', async () => {
+2 -1
View File
@@ -397,12 +397,13 @@ describe('write tool', () => {
expect(text(result)).toContain('file_path must be a non-empty string')
})
it('propagates a backend FsError as an isError result carrying its code', async () => {
it('propagates a backend FsError as an isError result carrying its code and remedy', async () => {
const { ctx, fs } = await setup()
fs.rejectWith = new FsError('blocked', 'FS_STALE_VERSION')
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ info: { name: 'FsError', code: 'FS_STALE_VERSION' } })
expect(text(result)).toContain('re-read the file, then retry')
})
})