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.
This commit is contained in:
ZiyaZhang
2026-08-08 11:49:42 -07:00
parent 3f3c8e31e9
commit 9a299f9827
13 changed files with 281 additions and 43 deletions
@@ -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
@@ -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.
@@ -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`;读取路由与覆写展示是不同策略,无需共享数值。
+16
View File
@@ -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 },
@@ -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
@@ -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
@@ -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." }
]
}
@@ -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":"<path>{{cwd}}/data.txt</path>\n<type>file</type>\n<content>\n1: original contents\n\n(End of file - total 1 lines)\n</content>"}],"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":"<path>{{cwd}}/data.txt</path>\n<type>file</type>\n<content>\nUpdated file\n</content>"}],"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"}}}
@@ -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"}}
@@ -0,0 +1 @@
original contents
+48 -38
View File
@@ -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<string | null> {
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
}
}
+58
View File
@@ -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<typeof import('node:fs/promises')>()
return {
...actual,
async open(...args: Parameters<typeof actual.open>) {
const handle = await actual.open(...args)
return {
close: handle.close.bind(handle),
read: handle.read.bind(handle),
async stat(...statArgs: Parameters<typeof handle.stat>) {
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<typeof import('node:fs/promises')>()
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]))
+26 -1
View File
@@ -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) },
},
},
})
}