refactor(fs): split filesystem seam into provider ctx.fs + policy ctx.fileContext

Implements the split-the-filesystem-seam RFC. ctx.fs shrinks to a text-storage
provider seam (resolve/stat/readText/streamText/writeText/editText with branded
FsTargetKey/FsVersion and an explicit FsWriteExpectation); the new
dsh-file-context package owns the model-facing policy (read windowing,
observed-state, write/edit freshness) as the concrete ctx.fileContext service.

Authorization is now freshness-based rather than full/partial view: a windowed
read records the file version and authorizes a later edit when the file is
unchanged, removing the dead-end where reading lines 100-150 of a large file
could not edit line 120. editText stays a provider primitive so version guard +
literal match + atomic rewrite remain one critical section, and the stale check
runs before matching so a stale edit reports FS_STALE_VERSION. tool-fs injects
fileContext, never reaching around to ctx.fs (the no-bypass contract).
This commit is contained in:
Dudu-0223
2026-06-26 17:23:18 +08:00
parent c7a197fb5f
commit ef37ce3b9d
42 changed files with 1899 additions and 1466 deletions
+54 -9
View File
@@ -1,8 +1,9 @@
/**
* Integration tests: the real local backend (`dsh-fs-local`) plus the model
* tools (`dsh-tool-fs`), exercised through `ctx.tools.execute()` so nothing
* bypasses the tool registry. These verify the WORLD — files are read back from
* disk and asserted byte-for-byte — not the tool's self-report.
* Integration tests: the real local backend (`dsh-fs-local`) plus the real
* policy layer (`dsh-file-context`) plus the model tools (`dsh-tool-fs`),
* exercised through `ctx.tools.execute()` so nothing bypasses the tool registry.
* These verify the WORLD — files are read back from disk and asserted
* byte-for-byte — not the tool's self-report.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
@@ -14,6 +15,7 @@ import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
import FileContext from '@deepseek-ai/dsh-file-context'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
let dir: string
@@ -28,6 +30,7 @@ beforeEach(async () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(LocalFileSystem, { cwd: dir })
await ctx.plugin(FileContext)
fiber = await ctx.plugin(ToolFs)
})
afterEach(async () => {
@@ -61,7 +64,6 @@ describe('write → disk', () => {
const result = await call('write', { file_path: 'a.txt', content: 'clobber' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
// The world is unchanged.
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('original')
})
@@ -72,6 +74,15 @@ describe('write → disk', () => {
expect(result.isError).toBe(false)
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('replaced')
})
it('rejects a full overwrite when the file changed since the read (stale)', 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 result = await call('write', { file_path: 'a.txt', content: 'replaced' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' })
})
})
describe('read', () => {
@@ -89,6 +100,14 @@ describe('read', () => {
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_NOT_TEXT' })
})
it('paginates a multi-line file with offset/limit', async () => {
await writeFile(join(dir, 'a.txt'), 'one\ntwo\nthree\nfour')
const result = await call('read', { file_path: 'a.txt', offset: 2, limit: 2 })
expect(text(result)).toContain('2: two')
expect(text(result)).toContain('3: three')
expect(text(result)).toContain('(Showing lines 2-3 of 4. Use offset=4 to continue.)')
})
})
describe('edit → disk', () => {
@@ -108,13 +127,27 @@ describe('edit → disk', () => {
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello world')
})
it('rejects an edit after only a partial read, leaving the file untouched', async () => {
await writeFile(join(dir, 'a.txt'), 'hello\nworld')
it('lets a WINDOWED read authorize an edit when the file is unchanged (freshness, not full-view)', async () => {
// A file with more lines than the read window; read only the first line.
const lines = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`)
await writeFile(join(dir, 'a.txt'), lines.join('\n'))
const read = await call('read', { file_path: 'a.txt', offset: 1, limit: 1 })
expect(read.isError).toBe(false)
expect(text(read)).toContain('(Showing lines 1-1 of 20')
// Editing a line OUTSIDE the window is authorized because the file is unchanged.
const result = await call('edit', { file_path: 'a.txt', old_string: 'line 12', new_string: 'LINE 12' })
expect(result.isError).toBe(false)
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe(lines.map(l => l === 'line 12' ? 'LINE 12' : l).join('\n'))
})
it('rejects an edit when the file changed since the windowed read (stale before matching)', async () => {
await writeFile(join(dir, 'a.txt'), 'hello world')
await call('read', { file_path: 'a.txt', offset: 1, limit: 1 })
await writeFile(join(dir, 'a.txt'), 'goodbye') // out-of-band change removes 'world'
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_PARTIAL_OBSERVATION' })
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello\nworld')
expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' })
})
it('rejects an ambiguous match without replace_all', async () => {
@@ -141,3 +174,15 @@ describe('edit → disk', () => {
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('one three')
})
})
describe('no-bypass / escape-hatch contract', () => {
it('a direct ctx.fs.readText records no observed-state, so a later edit rejects', async () => {
await writeFile(join(dir, 'a.txt'), 'hello world')
// Reach AROUND the policy layer — an explicit escape hatch for non-tool consumers.
await ctx.fs.readText(await ctx.fs.resolve('a.txt'))
// The model-facing edit still rejects: the read was not through ctx.fileContext.
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
})
})