From 9a299f9827e6d34158ce3e50d4ab5062c886f9a7 Mon Sep 17 00:00:00 2001
From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com>
Date: Sat, 8 Aug 2026 11:49:42 -0700
Subject: [PATCH] fix(fs-local): degrade basis I/O failures to null
Descriptor-phase errnos in readTextForDiff fold to before: null so a file
deleted or made unreadable after the caller's preflight cannot fail the
committed write; cancellation and non-errno faults still propagate. Drops
the now-covered isFile v8 ignore, extends llm-replay with catalog
capability parity (defaultMaxTokens/reasoningEfforts), and records the
fs-write-overwrite-bounded keyless snapshot pinning the over-limit
whole-file fallback through the real acp-agent composition.
---
...-30-bounded-overwrite-diff-basis.i18n.yaml | 4 +-
...2026-07-30-bounded-overwrite-diff-basis.md | 2 +-
...6-07-30-bounded-overwrite-diff-basis.zh.md | 2 +-
examples/acp-agent/tests/acp.snapshot.ts | 16 ++++
.../tests/fs-diff-bound.cordis.snapshot.yml | 46 ++++++++++
.../acp-agent/tests/fs-diff-bound.cordis.yml | 29 +++++++
.../fs-write-overwrite-bounded/input.json | 7 ++
.../fs-write-overwrite-bounded/session.jsonl | 42 +++++++++
.../stdout.expected.jsonl | 4 +
.../workspace/data.txt | 1 +
packages/fs/fs-local/src/fsio.ts | 86 +++++++++++--------
packages/fs/fs-local/tests/fsio.spec.ts | 58 +++++++++++++
packages/support/llm-replay/src/index.ts | 27 +++++-
13 files changed, 281 insertions(+), 43 deletions(-)
create mode 100644 examples/acp-agent/tests/fs-diff-bound.cordis.snapshot.yml
create mode 100644 examples/acp-agent/tests/fs-diff-bound.cordis.yml
create mode 100644 examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/input.json
create mode 100644 examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/session.jsonl
create mode 100644 examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/stdout.expected.jsonl
create mode 100644 examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/workspace/data.txt
diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.i18n.yaml
index f7361eda32..da4e330119 100644
--- a/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.i18n.yaml
+++ b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.i18n.yaml
@@ -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 .agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.md
-2026-07-30-bounded-overwrite-diff-basis.md: 353b538a12b8cf48dfa3a561c62d6d8ab9a8bfcf
-2026-07-30-bounded-overwrite-diff-basis.zh.md: e2b473a21d16dc47b5b8bf781b8ddffdf443955b
+2026-07-30-bounded-overwrite-diff-basis.md: 7a09934bd1798059de43a092f338d37aa9ccbd9a
+2026-07-30-bounded-overwrite-diff-basis.zh.md: 1d6bdd1068d119aae859132d9f8216ca29d0dc11
diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.md b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.md
index 353b538a12..7a09934bd1 100644
--- a/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.md
+++ b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.md
@@ -10,7 +10,7 @@ English | [中文](2026-07-30-bounded-overwrite-diff-basis.zh.md)
## Decision
-`LocalFileSystem.Config.diffBasisMaxBytes` is a positive safe-integer deployment setting no greater than the runtime's Buffer-allocation and string-decoding limits, with a 10 MiB default. An overwrite supplies `before` only when the UTF-8 replacement is strictly below that limit and the prior file opened for the basis also ends below it. The prior read opens a descriptor, checks that descriptor, and reads at most the configured byte count in cancellation-aware chunks; reaching the boundary returns `null`. A size change after descriptor stat also returns `null`, even if the final size remains below the limit, because a partial prefix would be an incorrect diff basis. Binary or invalid UTF-8 prior content likewise returns `null`. These outcomes do not block the atomic write.
+`LocalFileSystem.Config.diffBasisMaxBytes` is a positive safe-integer deployment setting no greater than the runtime's Buffer-allocation and string-decoding limits, with a 10 MiB default. An overwrite supplies `before` only when the UTF-8 replacement is strictly below that limit and the prior file opened for the basis also ends below it. The prior read opens a descriptor, checks that descriptor, and reads at most the configured byte count in cancellation-aware chunks; reaching the boundary returns `null`. A size change after descriptor stat also returns `null`, even if the final size remains below the limit, because a partial prefix would be an incorrect diff basis. Binary or invalid UTF-8 prior content likewise returns `null`, as does any descriptor-phase errno — a prior file deleted or made unreadable between the caller's preflight and the basis open cannot fail a write the caller already committed to; only cancellation and non-errno faults propagate. These outcomes do not block the atomic write.
The local provider owns this decision because `before` is its optional, best-effort basis: it can avoid acquiring prior content that the configured pair limit has already made ineligible. `tool-fs` continues to own diff computation, retention, and presentation. The setting is independent of `tool-fs.readStreamMinSize`; read routing and overwrite presentation are different policies and need not share a value.
diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.zh.md
index e2b473a21d..1d6bdd1068 100644
--- a/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.zh.md
+++ b/.agents/notes/implemented/bug-fix/2026-07-30-bounded-overwrite-diff-basis.zh.md
@@ -10,7 +10,7 @@ Status: implemented
## Decision
-`LocalFileSystem.Config.diffBasisMaxBytes` 是一个不超过运行时 Buffer 分配和字符串解码上限的正安全整数部署配置,默认 10 MiB。只有当 UTF-8 替换内容严格低于该上限,且为生成基础而打开的旧文件最终也低于该上限时,覆写才提供 `before`。旧文件读取会打开文件描述符、检查该描述符,并按可响应取消的分块最多读取配置的字节数;一旦到达边界便返回 `null`。描述符 stat 后发生大小变化时同样返回 `null`,即使最终大小仍低于上限,因为部分前缀会成为错误的 diff 基础。旧内容为二进制或无效 UTF-8 时也返回 `null`。这些结果都不会阻止原子写入。
+`LocalFileSystem.Config.diffBasisMaxBytes` 是一个不超过运行时 Buffer 分配和字符串解码上限的正安全整数部署配置,默认 10 MiB。只有当 UTF-8 替换内容严格低于该上限,且为生成基础而打开的旧文件最终也低于该上限时,覆写才提供 `before`。旧文件读取会打开文件描述符、检查该描述符,并按可响应取消的分块最多读取配置的字节数;一旦到达边界便返回 `null`。描述符 stat 后发生大小变化时同样返回 `null`,即使最终大小仍低于上限,因为部分前缀会成为错误的 diff 基础。旧内容为二进制或无效 UTF-8 时也返回 `null`;描述符阶段的任何 errno 同样如此——旧文件在调用方预检之后、基础读取打开之前被删除或变得不可读,不能让调用方已经提交的写入失败;只有取消和非 errno 故障会继续向上传播。这些结果都不会阻止原子写入。
本地提供方拥有该决策,因为 `before` 是它提供的可选、尽力而为的基础:当配置的成对上限已使替换内容不合格时,它可以避免获取旧内容。`tool-fs` 继续拥有 diff 计算、保留与展示。该配置独立于 `tool-fs.readStreamMinSize`;读取路由与覆写展示是不同策略,无需共享数值。
diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts
index c09dd7d2ed..79c63fb642 100644
--- a/examples/acp-agent/tests/acp.snapshot.ts
+++ b/examples/acp-agent/tests/acp.snapshot.ts
@@ -52,6 +52,7 @@ const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url))
const FS_SEARCH_CONFIG = fileURLToPath(new URL('./fs-search.cordis.yml', import.meta.url))
const PARTIAL_LANDLOCK_CONFIG = fileURLToPath(new URL('../partial-landlock.cordis.yml', import.meta.url))
const PWSH_CONFIG = fileURLToPath(new URL('./pwsh.cordis.yml', import.meta.url))
+const FS_DIFF_BOUND_CONFIG = fileURLToPath(new URL('./fs-diff-bound.cordis.yml', import.meta.url))
const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots')
const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny'
@@ -253,6 +254,21 @@ const SCENARIOS: Scenario[] = [
{ name: 'fs-write', hasModelTurn: true, recorded: true },
{ name: 'fs-edit', hasModelTurn: true, recorded: true },
{ name: 'fs-write-overwrite', hasModelTurn: true, recorded: true },
+ // An overwrite whose replacement is at/above the configured diff-basis bound:
+ // the persisted result meta carries no contextual hunks and presentation
+ // falls back to the whole-file diff. The overlay leaves the prompt and tool
+ // sequence identical to text-turn, but the freshly recorded header carries
+ // the current adapter capability fields, so the scenario pins its own class.
+ {
+ name: 'fs-write-overwrite-bounded',
+ hasModelTurn: true,
+ recorded: true,
+ pinsHeader: true,
+ headerClass: 'fs-diff-bound',
+ systemPromptSource: 'text-turn',
+ toolSchemasSource: 'text-turn',
+ configPath: FS_DIFF_BOUND_CONFIG,
+ },
{ name: 'fs-read-window', hasModelTurn: true, recorded: true },
{ name: 'fs-policy-reject', hasModelTurn: true, recorded: true },
{ name: 'multi-turn', hasModelTurn: true, recorded: true },
diff --git a/examples/acp-agent/tests/fs-diff-bound.cordis.snapshot.yml b/examples/acp-agent/tests/fs-diff-bound.cordis.snapshot.yml
new file mode 100644
index 0000000000..a216a34cfd
--- /dev/null
+++ b/examples/acp-agent/tests/fs-diff-bound.cordis.snapshot.yml
@@ -0,0 +1,46 @@
+# Keyless replay counterpart to fs-diff-bound.cordis.yml. Replay patches apply
+# directly against the live cordis.yml because include patches cannot target
+# entries behind a nested include; the acp-agent restatement keeps the recorded
+# deepseek-v4-flash model and raw JSONL persistence for the harness's harvest.
+- id: base
+ name: '@cordisjs/plugin-include'
+ config:
+ path: ../cordis.yml
+ patches:
+ - id: llm-deepseek
+ name: '@deepseek-ai/dsh-llm-deepseek'
+ disabled: true
+ - id: acp-agent
+ name: '@deepseek-ai/dsh-acp-demo'
+ config:
+ provider: deepseek-official
+ model: deepseek-v4-flash
+ persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
+ persistenceCompression: none
+ workspaceContext:
+ maxBytes: 65536
+ persona: |
+ You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug.
+
+ Verify your work by running the code or tests. Keep answers brief and factual.
+ - id: fs-sandbox
+ name: '@deepseek-ai/dsh-fs-sandbox'
+ config:
+ cwd: !!js process.cwd()
+ diffBasisMaxBytes: 64
+ - insert:
+ - id: llm-replay
+ name: '@deepseek-ai/dsh-llm-replay'
+ config:
+ providers:
+ - id: deepseek-official
+ name: DeepSeek
+ models:
+ # Capability parity with the live adapter so replay
+ # reconstructs the freshly recorded request header.
+ - id: deepseek-v4-flash
+ contextWindow: 1000000
+ defaultMaxTokens: 256000
+ reasoningEfforts: ['off', 'high', 'max']
+ defaultReasoningEffort: max
+ - id: deepseek-v4-pro
diff --git a/examples/acp-agent/tests/fs-diff-bound.cordis.yml b/examples/acp-agent/tests/fs-diff-bound.cordis.yml
new file mode 100644
index 0000000000..9be25cdb25
--- /dev/null
+++ b/examples/acp-agent/tests/fs-diff-bound.cordis.yml
@@ -0,0 +1,29 @@
+# Live counterpart for the bounded-overwrite-diff snapshot: the base stack with
+# the fs backend's overwrite diff-basis limit shrunk so a modest replacement
+# crosses the exclusive bound and the write result falls back to a whole-file
+# diff. A config patch replaces the row's whole config, so `cwd` is restated
+# verbatim, and the acp-agent restatement re-pins `deepseek-v4-flash` to match
+# the recorded corpus and its pinned request headers.
+- id: base
+ name: '@cordisjs/plugin-include'
+ config:
+ path: ../cordis.yml
+ patches:
+ - id: acp-agent
+ name: '@deepseek-ai/dsh-acp-demo'
+ config:
+ provider: deepseek-official
+ model: deepseek-v4-flash
+ persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
+ persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'"
+ workspaceContext:
+ maxBytes: 65536
+ persona: |
+ You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug.
+
+ Verify your work by running the code or tests. Keep answers brief and factual.
+ - id: fs-sandbox
+ name: '@deepseek-ai/dsh-fs-sandbox'
+ config:
+ cwd: !!js process.cwd()
+ diffBasisMaxBytes: 64
diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/input.json b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/input.json
new file mode 100644
index 0000000000..480c37827a
--- /dev/null
+++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/input.json
@@ -0,0 +1,7 @@
+{
+ "steps": [
+ { "op": "initialize" },
+ { "op": "newSession" },
+ { "op": "prompt", "text": "First use the read tool to read data.txt in the current directory. Then use the write tool (NOT bash) to replace its entire contents with exactly this single line: The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound. Then reply with exactly the single word DONE." }
+ ]
+}
diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/session.jsonl
new file mode 100644
index 0000000000..688bf9cb70
--- /dev/null
+++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/session.jsonl
@@ -0,0 +1,42 @@
+{"type":"session","version":0,"id":"14b14f51-2428-43a0-bcc5-5f392d4faa19","createdAt":1786204699215,"cwd":"{{cwd}}","delegationDepth":0}
+{"type":"agent/inbox/spliced","seq":0,"time":1786204699218,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"First use the read tool to read data.txt in the current directory. Then use the write tool (NOT bash) to replace its entire contents with exactly this single line: The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"41d72cfe-0e37-474f-83dc-2b15bacf9c0d"}]}}
+{"type":"turn/start","seq":1,"time":1786204699219,"data":{"turn":1}}
+{"type":"agent/inbox/spliced","seq":2,"time":1786204699220,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
+{"type":"step/start","seq":3,"time":1786204699259,"data":{"turn":1,"step":1}}
+{"type":"user/message","seq":4,"time":1786204699259,"data":{"content":[{"type":"text","text":"First use the read tool to read data.txt in the current directory. Then use the write tool (NOT bash) to replace its entire contents with exactly this single line: The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound. Then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"41d72cfe-0e37-474f-83dc-2b15bacf9c0d"},"surfaceOp":"append"}
+{"type":"user/message","seq":5,"time":1786204699260,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"e374fb32-1cad-4e2d-9cd3-66ac8fcf9588"},"surfaceOp":"append"}
+{"type":"session/title","seq":6,"time":1786204699261,"data":{"title":"First use the read tool","messageSeqs":[4],"source":{"kind":"fallback"}}}
+{"type":"request/header","seq":7,"time":1786204699262,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"max"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
+{"type":"request/context","seq":8,"time":1786204699262,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}}
+{"type":"assistant/chunk","seq":9,"time":1786204701601,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
+{"type":"reasoning-chunks","seq0":10,"time0":1786204701602,"data":{"turn":1,"step":1,"index":0,"dt":[60,22,2,1,0,1,0,19,2,1,1,17,21,2,0,20,2,1,21,0,0,0,1,21,2],"texts":["The"," user"," wants"," me"," to"," read"," data",".txt"," first",","," then"," write"," to"," replace"," its"," contents"," with"," the"," exact"," line",","," then"," reply"," D","ONE","."]}}
+{"type":"assistant/chunk","seq":36,"time":1786204701863,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
+{"type":"tool-call-chunks","seq0":37,"time0":1786204701864,"data":{"turn":1,"step":1,"index":1,"dt":[21,2,0,21,2,1,0,26,1,0,17],"id":"call_00_Jxz49JNt6i4oaDnzes2I0794","name":"read","args":["","{","\"","file","_path","\"",": ","\"","data",".txt","\"","}"]}}
+{"type":"assistant/chunk","seq":49,"time":1786204701981,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to read data.txt first, then write to replace its contents with the exact line, then reply DONE."}}}}
+{"type":"assistant/chunk","seq":50,"time":1786204701982,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Jxz49JNt6i4oaDnzes2I0794","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}}}}
+{"type":"assistant/chunk","seq":51,"time":1786204701982,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":5803,"outputTokens":71,"cacheReadTokens":0,"reasoningTokens":26}}}}
+{"type":"assistant/chunk","seq":52,"time":1786204701982,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
+{"type":"assistant/message","seq":53,"time":1786204701988,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to read data.txt first, then write to replace its contents with the exact line, then reply DONE."},{"type":"tool-call","id":"call_00_Jxz49JNt6i4oaDnzes2I0794","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9060e190-9971-4838-81bf-48c3e3888609"},"usage":{"inputTokens":5803,"outputTokens":71,"cacheReadTokens":0,"reasoningTokens":26}},"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],"surfaceOp":"append"}
+{"type":"tool/call","seq":54,"time":1786204701990,"data":{"turn":1,"step":1,"callId":"call_00_Jxz49JNt6i4oaDnzes2I0794","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}}
+{"type":"tool/result","seq":55,"time":1786204702006,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Jxz49JNt6i4oaDnzes2I0794"},"content":[{"type":"tool-result","toolCallId":"call_00_Jxz49JNt6i4oaDnzes2I0794","content":[{"type":"text","text":"{{cwd}}/data.txt\nfile\n\n1: original contents\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"1406fd7d-f181-41d0-b0db-ef196010f620"},"meta":{"path":"{{cwd}}/data.txt","offset":1,"lines":[{"number":1,"text":"original contents"}],"totalLines":1}},"sourceEventSeqs":[54],"surfaceOp":"append"}
+{"type":"step/end","seq":56,"time":1786204702006,"data":{"turn":1,"step":1}}
+{"type":"step/start","seq":57,"time":1786204702016,"data":{"turn":1,"step":2}}
+{"type":"assistant/chunk","seq":58,"time":1786204703539,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
+{"type":"tool-call-chunks","seq0":59,"time0":1786204703539,"data":{"turn":1,"step":2,"index":0,"dt":[0,1,0,0,0,1,0,0,1,0,12,2,0,0,0,22,35,1,0,0,0,0,0,1,0,8,2,0,0,71,1,0,0,1,0],"id":"call_00_ET_7mLiYX652hJA9GW6d1bl4653","name":"write","args":["","{","\"","file","_path","\"",": ","\"","data",".txt","\"",", ","\"","content","\"",": ","\"","The"," replacement"," line"," is"," deliberately"," longer"," than"," the"," configured"," sixty","-four"," byte"," diff","-b","asis"," bound",".","\"","}"]}}
+{"type":"assistant/chunk","seq":95,"time":1786204703720,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_00_ET_7mLiYX652hJA9GW6d1bl4653","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound.\"}"}}}}
+{"type":"assistant/chunk","seq":96,"time":1786204703720,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":202,"outputTokens":76,"cacheReadTokens":5760,"reasoningTokens":0}}}}
+{"type":"assistant/chunk","seq":97,"time":1786204703720,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
+{"type":"assistant/message","seq":98,"time":1786204703722,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_00_ET_7mLiYX652hJA9GW6d1bl4653","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"46d3792a-eded-45e7-8151-00ca0584f10b"},"usage":{"inputTokens":202,"outputTokens":76,"cacheReadTokens":5760,"reasoningTokens":0}},"sourceEventSeqs":[58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97],"surfaceOp":"append"}
+{"type":"tool/call","seq":99,"time":1786204703722,"data":{"turn":1,"step":2,"callId":"call_00_ET_7mLiYX652hJA9GW6d1bl4653","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound.\"}"}}
+{"type":"tool/result","seq":100,"time":1786204703740,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_ET_7mLiYX652hJA9GW6d1bl4653"},"content":[{"type":"tool-result","toolCallId":"call_00_ET_7mLiYX652hJA9GW6d1bl4653","content":[{"type":"text","text":"{{cwd}}/data.txt\nfile\n\nUpdated file\n"}],"isError":false}],"role":"user","id":"98c41fc1-6ce6-445f-94f7-32aa7e1c6ea7"},"meta":{"diffs":[]}},"sourceEventSeqs":[99],"surfaceOp":"append"}
+{"type":"step/end","seq":101,"time":1786204703740,"data":{"turn":1,"step":2}}
+{"type":"step/start","seq":102,"time":1786204703749,"data":{"turn":1,"step":3}}
+{"type":"assistant/chunk","seq":103,"time":1786204705029,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
+{"type":"assistant/chunk","seq":104,"time":1786204705029,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"D"}}}
+{"type":"assistant/chunk","seq":105,"time":1786204705053,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"ONE"}}}
+{"type":"assistant/chunk","seq":106,"time":1786204705055,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}}
+{"type":"assistant/chunk","seq":107,"time":1786204705055,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":100,"outputTokens":3,"cacheReadTokens":6016,"reasoningTokens":0}}}}
+{"type":"assistant/chunk","seq":108,"time":1786204705056,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
+{"type":"assistant/message","seq":109,"time":1786204705057,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ddf50859-b0b9-404d-a71c-a1f11ff53341"},"usage":{"inputTokens":100,"outputTokens":3,"cacheReadTokens":6016,"reasoningTokens":0}},"sourceEventSeqs":[103,104,105,106,107,108],"surfaceOp":"append"}
+{"type":"step/end","seq":110,"time":1786204705057,"data":{"turn":1,"step":3}}
+{"type":"turn/end","seq":111,"time":1786204705058,"data":{"turn":1,"reason":{"kind":"completed"}}}
diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/stdout.expected.jsonl
new file mode 100644
index 0000000000..82ae8907ca
--- /dev/null
+++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/stdout.expected.jsonl
@@ -0,0 +1,4 @@
+{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
+{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
+{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}}
+{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/workspace/data.txt b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/workspace/data.txt
new file mode 100644
index 0000000000..3359a4b8d9
--- /dev/null
+++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/workspace/data.txt
@@ -0,0 +1 @@
+original contents
\ No newline at end of file
diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts
index b47d6f2bcd..cba29e873c 100644
--- a/packages/fs/fs-local/src/fsio.ts
+++ b/packages/fs/fs-local/src/fsio.ts
@@ -565,15 +565,16 @@ export async function readForEdit(
}
/**
- * Best-effort overwrite diff basis. Binary, invalid UTF-8, or a file at/above the byte limit
- * returns `null` so the write still succeeds and presentation falls back to a whole-file diff.
- * The bound is enforced on the opened descriptor rather than a prior path stat, so concurrent
- * external replacement or size changes cannot make this helper buffer more than `maxBytes`.
- * @param absolutePath - the file to read (typically a target key); it must exist.
+ * Best-effort overwrite diff basis. Binary, invalid UTF-8, a file at/above the byte limit,
+ * or a file deleted/made unreadable after the caller's preflight returns `null` so the write
+ * still succeeds and presentation falls back to a whole-file diff. The bound is enforced on
+ * the opened descriptor rather than a prior path stat, so concurrent external replacement or
+ * size changes cannot make this helper buffer more than `maxBytes`.
+ * @param absolutePath - the file to read (typically a target key).
* @param maxBytes - exclusive upper bound for bytes held as the contextual-diff basis.
- * @param signal - aborts the read (`FS_ABORTED`).
+ * @param signal - aborts the read (`FS_ABORTED`); cancellation propagates, unlike I/O failure.
* @returns the LF-normalized text, or null for a non-regular, at/above-limit, binary, non-UTF-8,
- * or descriptor-size-changed file.
+ * descriptor-size-changed, or unreadable file.
*/
export async function readTextForDiff(
absolutePath: string,
@@ -581,41 +582,50 @@ export async function readTextForDiff(
signal?: AbortSignal,
): Promise {
throwIfAborted(signal, 'read')
- const handle = await open(absolutePath, 'r')
- let buffer: Buffer
- let total = 0
- let openedSize = 0
try {
- throwIfAborted(signal, 'read')
- const info = await handle.stat()
- throwIfAborted(signal, 'read')
- /* v8 ignore next -- requires a post-preflight replacement with a non-file;
- * direct coverage is not portable to Windows. */
- if (!info.isFile()) return null
- if (info.size >= maxBytes) return null
- openedSize = info.size
- // One extra byte detects growth after stat without retaining per-read backing buffers.
- buffer = Buffer.allocUnsafe(openedSize + 1)
- while (total < buffer.length) {
+ const handle = await open(absolutePath, 'r')
+ let buffer: Buffer
+ let total = 0
+ let openedSize = 0
+ try {
throwIfAborted(signal, 'read')
- const length = Math.min(buffer.length - total, DIFF_BASIS_READ_CHUNK_BYTES)
- const { bytesRead } = await handle.read(buffer, total, length, null)
- if (bytesRead === 0) break
- total += bytesRead
+ const info = await handle.stat()
+ throwIfAborted(signal, 'read')
+ if (!info.isFile()) return null
+ if (info.size >= maxBytes) return null
+ openedSize = info.size
+ // One extra byte detects growth after stat without retaining per-read backing buffers.
+ buffer = Buffer.allocUnsafe(openedSize + 1)
+ while (total < buffer.length) {
+ throwIfAborted(signal, 'read')
+ const length = Math.min(buffer.length - total, DIFF_BASIS_READ_CHUNK_BYTES)
+ const { bytesRead } = await handle.read(buffer, total, length, null)
+ if (bytesRead === 0) break
+ total += bytesRead
+ }
+ } finally {
+ await handle.close()
+ }
+ throwIfAborted(signal, 'read')
+ if (total !== openedSize) return null
+ const basis = buffer.subarray(0, total)
+ if (basis.includes(0)) return null
+ try {
+ return normalizeLineEndings(new TextDecoder('utf-8', { fatal: true }).decode(basis))
+ } catch (error: unknown) {
+ /* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes;
+ * any other throw is an unreachable runtime fault. */
+ if (!(error instanceof TypeError)) throw error
+ return null
}
- } finally {
- await handle.close()
- }
- throwIfAborted(signal, 'read')
- if (total !== openedSize) return null
- const basis = buffer.subarray(0, total)
- if (basis.includes(0)) return null
- try {
- return normalizeLineEndings(new TextDecoder('utf-8', { fatal: true }).decode(basis))
} catch (error: unknown) {
- /* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes; any other throw is an unreachable runtime fault. */
- if (!(error instanceof TypeError)) throw error
- return null
+ // Cancellation is the caller's intent and still propagates.
+ if (error instanceof FsError) throw error
+ // A descriptor-phase errno — deleted or made unreadable after the caller's
+ // preflight, or a faulted read — costs only the optional basis: a committed
+ // write must not fail for a presentation-only pre-read.
+ if (error instanceof Error && 'code' in error) return null
+ throw error
}
}
diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts
index d800997d0f..a30c171fd9 100644
--- a/packages/fs/fs-local/tests/fsio.spec.ts
+++ b/packages/fs/fs-local/tests/fsio.spec.ts
@@ -398,6 +398,64 @@ describe('readTextForDiff', () => {
}
})
+ it('returns null when the file vanishes before the basis open (deletion race)', async () => {
+ expect(await readTextForDiff(join(dir, 'deleted-after-preflight.txt'), 32)).toBeNull()
+ })
+
+ it('returns null when the opened descriptor is no longer a regular file', async () => {
+ const file = join(dir, 'swapped.txt')
+ await writeFile(file, 'abcdef')
+ vi.resetModules()
+ vi.doMock('node:fs/promises', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ async open(...args: Parameters) {
+ const handle = await actual.open(...args)
+ return {
+ close: handle.close.bind(handle),
+ read: handle.read.bind(handle),
+ async stat(...statArgs: Parameters) {
+ const info = await handle.stat(...statArgs)
+ return Object.assign(info, { isFile: () => false })
+ },
+ }
+ },
+ }
+ })
+
+ try {
+ const { readTextForDiff: isolatedReadTextForDiff } = await import('../src/fsio.ts')
+ expect(await isolatedReadTextForDiff(file, 32)).toBeNull()
+ } finally {
+ vi.doUnmock('node:fs/promises')
+ vi.resetModules()
+ }
+ })
+
+ it('propagates a non-errno fault instead of masking it as a null basis', async () => {
+ const file = join(dir, 'faulted.txt')
+ await writeFile(file, 'abcdef')
+ vi.resetModules()
+ vi.doMock('node:fs/promises', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ async open() {
+ throw new TypeError('forged programming fault')
+ },
+ }
+ })
+
+ try {
+ const { readTextForDiff: isolatedReadTextForDiff } = await import('../src/fsio.ts')
+ await expect(isolatedReadTextForDiff(file, 32)).rejects.toThrow('forged programming fault')
+ } finally {
+ vi.doUnmock('node:fs/promises')
+ vi.resetModules()
+ }
+ })
+
it('returns null for binary and invalid UTF-8 without blocking the caller write', async () => {
await writeFile(join(dir, 'bin'), Buffer.from([0x68, 0x00, 0x69]))
await writeFile(join(dir, 'bad'), Buffer.from([0x68, 0xff, 0x69]))
diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts
index 9af72bbbec..de43d19e96 100644
--- a/packages/support/llm-replay/src/index.ts
+++ b/packages/support/llm-replay/src/index.ts
@@ -24,7 +24,7 @@ import type {
StreamChunk,
TokenUsage,
} from '@deepseek-ai/dsh-llm'
-import { LlmAdapter, LlmError, assertNever, resolveRetryPolicy } from '@deepseek-ai/dsh-llm'
+import { LlmAdapter, LlmError, ReasoningEffortId, assertNever, resolveRetryPolicy } from '@deepseek-ai/dsh-llm'
/**
* One recorded model call. `throw` may replay prefix chunks before failing;
@@ -51,6 +51,18 @@ export interface ReplayModelConfig {
description?: string
/** Optional positive integer context capacity published by the replay adapter. */
contextWindow?: number
+ /**
+ * Optional per-request output cap the replay route materializes when callers
+ * omit one, so replay reconstructs the request header a live catalog produced.
+ */
+ defaultMaxTokens?: number
+ /** Optional reasoning-effort ids the replay route accepts, in display order. */
+ reasoningEfforts?: string[]
+ /**
+ * Optional effort materialized when callers omit one; must appear in
+ * {@link reasoningEfforts} or call resolution rejects the route.
+ */
+ defaultReasoningEffort?: string
}
/** One provider route exposed by the replay adapter. */
@@ -585,6 +597,19 @@ class ReplayAdapter extends LlmAdapter {
...configuredModel?.contextWindow === undefined
? {}
: { context: { contextWindow: configuredModel.contextWindow } },
+ ...configuredModel?.defaultMaxTokens === undefined
+ ? {}
+ : { defaultMaxTokens: configuredModel.defaultMaxTokens },
+ ...configuredModel?.reasoningEfforts === undefined
+ ? {}
+ : {
+ reasoning: {
+ efforts: configuredModel.reasoningEfforts.map(id => ({ id: ReasoningEffortId(id), name: id })),
+ ...configuredModel.defaultReasoningEffort === undefined
+ ? {}
+ : { defaultEffort: ReasoningEffortId(configuredModel.defaultReasoningEffort) },
+ },
+ },
})
}