Files
deepseek-harness/packages/fs/file-context/tests/policy.spec.ts
Dudu-0223 ef37ce3b9d 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).
2026-06-26 17:23:18 +08:00

306 lines
12 KiB
TypeScript

/**
* Tests for the file-context policy layer: registration/disposal/HMR, owner
* derivation, observed-state-as-read-record, read windowing over a fake
* provider, freshness-based write/edit authorization (including the key
* windowed-read-authorizes-edit behavior), the read→streamText size routing,
* and multi-owner isolation. The provider is a fake `ctx.fs` recording the
* expectations it was handed.
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
import type {
FsEditOutcome,
FsEditRequest,
FsInfo,
FsTarget,
FsWriteExpectation,
FsWriteOutcome,
} from '@deepseek-ai/dsh-fs'
import FileContext, { STREAM_MIN_SIZE } from '@deepseek-ai/dsh-file-context'
import type { FileContextExec, FileReadRequest } from '@deepseek-ai/dsh-file-context'
/** A fake provider: in-memory files, recording every expectation/version it is handed. */
class FakeFs extends FileSystem {
files = new Map<string, string>()
versions = new Map<string, number>()
/** Size to report from stat (lets a test push read onto the streaming path). */
reportSize?: number
/** Whether streamText was used for the last read (vs readText). */
lastReadStreamed = false
writeExpectations: FsWriteExpectation[] = []
editExpectedVersions: string[] = []
private ver(key: string): FsVersion {
return FsVersion(`v${this.versions.get(key) ?? 0}`)
}
private bump(key: string): FsVersion {
const next = (this.versions.get(key) ?? 0) + 1
this.versions.set(key, next)
return FsVersion(`v${next}`)
}
override async resolve(path: string): Promise<FsTarget> {
return { inputPath: path, targetKey: FsTargetKey(path), displayPath: path }
}
override async stat(target: FsTarget): Promise<FsInfo | undefined> {
const content = this.files.get(target.targetKey)
if (content === undefined) return undefined
return { version: this.ver(target.targetKey), type: 'file', size: this.reportSize ?? content.length }
}
override async readText(target: FsTarget): Promise<string> {
this.lastReadStreamed = false
return this.files.get(target.targetKey) ?? ''
}
override async streamText(target: FsTarget): Promise<AsyncIterable<string>> {
this.lastReadStreamed = true
const content = this.files.get(target.targetKey) ?? ''
return (async function* () { yield content })()
}
override async writeText(target: FsTarget, content: string, expected: FsWriteExpectation): Promise<FsWriteOutcome> {
this.writeExpectations.push(expected)
const existed = this.files.has(target.targetKey)
this.files.set(target.targetKey, content)
return { operation: existed ? 'update' : 'create', version: this.bump(target.targetKey) }
}
override async editText(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }): Promise<FsEditOutcome> {
this.editExpectedVersions.push(expected.version)
const content = this.files.get(target.targetKey) ?? ''
this.files.set(target.targetKey, content.split(edit.oldString).join(edit.newString))
return { replacements: 1, replaceAll: edit.replaceAll, version: this.bump(target.targetKey) }
}
}
async function setup() {
const ctx = new Context()
await ctx.plugin(FakeFs)
await ctx.plugin(FileContext)
const fs = ctx.fs as FakeFs
const fileContext = ctx.fileContext
return { ctx, fs, fileContext }
}
const READ_ALL: FileReadRequest = { offset: 1, limit: 2000 }
const ownerExec = (session: object): FileContextExec => ({ agent: { session } })
describe('registration / disposal', () => {
it('registers as ctx.fileContext and injects fs', async () => {
const { fileContext } = await setup()
expect(fileContext).toBeDefined()
})
it('stays pending until ctx.fs exists', async () => {
const ctx = new Context()
await ctx.plugin(FileContext) // no fs provider
expect(ctx.fileContext).toBeUndefined()
})
it('withdraws ctx.fileContext when its fiber is disposed (HMR safety)', async () => {
const ctx = new Context()
await ctx.plugin(FakeFs)
const fiber = await ctx.plugin(FileContext)
expect(ctx.fileContext).toBeDefined()
await fiber.dispose()
expect(ctx.fileContext).toBeUndefined()
})
})
describe('owner derivation', () => {
it('derives the owner from exec.agent.session', async () => {
const { fileContext } = await setup()
const session = {}
expect(fileContext.owner(ownerExec(session))).toBe(session)
})
it('returns undefined with no exec, no agent, or no session', async () => {
const { fileContext } = await setup()
expect(fileContext.owner()).toBeUndefined()
expect(fileContext.owner({})).toBeUndefined()
expect(fileContext.owner({ agent: {} })).toBeUndefined()
})
})
describe('read', () => {
it('returns a windowed outcome and rejects an absent target', async () => {
const { fs, fileContext } = await setup()
fs.files.set('a.txt', 'one\ntwo')
const outcome = await fileContext.read(await fs.resolve('a.txt'), READ_ALL)
expect(outcome.lines).toEqual([{ number: 1, text: 'one' }, { number: 2, text: 'two' }])
expect(outcome.version).toBe('v0')
await expect(fileContext.read(await fs.resolve('missing.txt'), READ_ALL))
.rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
})
it('rejects a non-regular target', async () => {
const { fs, fileContext } = await setup()
fs.files.set('d', '')
const target = await fs.resolve('d')
// Force stat to report a directory.
fs.stat = async () => ({ version: FsVersion('v0'), type: 'directory' })
await expect(fileContext.read(target, READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
})
it('reads small files whole and large files via streamText', async () => {
const { fs, fileContext } = await setup()
fs.files.set('a.txt', 'one\ntwo')
await fileContext.read(await fs.resolve('a.txt'), READ_ALL)
expect(fs.lastReadStreamed).toBe(false)
fs.reportSize = STREAM_MIN_SIZE
await fileContext.read(await fs.resolve('a.txt'), READ_ALL)
expect(fs.lastReadStreamed).toBe(true)
})
it('surfaces truncatedByBytes when the window hits the byte cap', async () => {
const { fs, fileContext } = await setup()
fs.files.set('big.txt', Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n'))
const outcome = await fileContext.read(await fs.resolve('big.txt'), READ_ALL)
expect(outcome.truncatedByBytes).toBe(true)
})
})
describe('observed-state is the read record', () => {
it('a read authorizes a later in-place write at the observed version', async () => {
const { fs, fileContext } = await setup()
const exec = ownerExec({})
fs.files.set('a.txt', 'hello')
const target = await fs.resolve('a.txt')
await fileContext.read(target, READ_ALL, exec)
await fileContext.write(target, 'goodbye', exec)
expect(fs.writeExpectations).toEqual([{ kind: 'replaceIfVersion', version: 'v0' }])
})
it('a windowed (partial) read still authorizes edit — freshness, not full/partial', async () => {
const { fs, fileContext } = await setup()
const exec = ownerExec({})
fs.files.set('a.txt', 'one\ntwo\nthree\nfour')
const target = await fs.resolve('a.txt')
// Read only lines 2-3 — a partial window.
const outcome = await fileContext.read(target, { offset: 2, limit: 2 }, exec)
expect(outcome.lines.map(l => l.number)).toEqual([2, 3])
// Edit is authorized anyway: the file is unchanged since the read.
await fileContext.edit(target, { oldString: 'one', newString: 'X', replaceAll: false }, exec)
expect(fs.editExpectedVersions).toEqual(['v0'])
})
it('skips recording when there is no owner, so write is createIfAbsent', async () => {
const { fs, fileContext } = await setup()
fs.files.set('a.txt', 'hello')
const target = await fs.resolve('a.txt')
await fileContext.read(target, READ_ALL) // no exec
// No recorded read → createIfAbsent → the provider rejects an existing target.
fs.writeText = async () => { throw new FsError('exists', 'FS_NOT_OBSERVED') }
await expect(fileContext.write(target, 'x')).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
})
})
describe('write policy', () => {
it('a create (no prior read) uses createIfAbsent', async () => {
const { fs, fileContext } = await setup()
const exec = ownerExec({})
const target = await fs.resolve('new.txt')
const outcome = await fileContext.write(target, 'fresh', exec)
expect(outcome.operation).toBe('create')
expect(fs.writeExpectations).toEqual([{ kind: 'createIfAbsent' }])
})
it('refreshes state after a write, so a follow-up edit needs no re-read', async () => {
const { fs, fileContext } = await setup()
const exec = ownerExec({})
const target = await fs.resolve('a.txt')
await fileContext.write(target, 'one', exec) // create → state now at v1
await fileContext.edit(target, { oldString: 'one', newString: 'two', replaceAll: false }, exec)
expect(fs.editExpectedVersions).toEqual(['v1'])
})
})
describe('edit policy', () => {
it('rejects with FS_NOT_OBSERVED when the file was never read', async () => {
const { fs, fileContext } = await setup()
const exec = ownerExec({})
fs.files.set('a.txt', 'hello')
const target = await fs.resolve('a.txt')
await expect(fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec))
.rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
})
it('rejects when there is no owner (cannot prove prior observation)', async () => {
const { fs, fileContext } = await setup()
fs.files.set('a.txt', 'hello')
const target = await fs.resolve('a.txt')
await expect(fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }))
.rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
})
it('passes the recorded version as the stale guard after a read', async () => {
const { fs, fileContext } = await setup()
const exec = ownerExec({})
fs.files.set('a.txt', 'hello')
fs.versions.set('a.txt', 7)
const target = await fs.resolve('a.txt')
await fileContext.read(target, READ_ALL, exec)
await fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec)
expect(fs.editExpectedVersions).toEqual(['v7'])
})
})
describe('multi-owner isolation', () => {
it('owner A reading does not grant owner B edit authority', async () => {
const { fs, fileContext } = await setup()
const a = ownerExec({})
const b = ownerExec({})
fs.files.set('a.txt', 'hello')
const target = await fs.resolve('a.txt')
await fileContext.read(target, READ_ALL, a)
await expect(fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, b))
.rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
await expect(fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, a))
.resolves.toMatchObject({ replacements: 1 })
})
it('each owner records its own observed version independently', async () => {
const { fs, fileContext } = await setup()
const a = ownerExec({})
const b = ownerExec({})
fs.files.set('a.txt', 'hello')
const target = await fs.resolve('a.txt')
await fileContext.read(target, READ_ALL, a) // A sees v0
await fileContext.write(target, 'mid', b) // B has no read → createIfAbsent
await fileContext.write(target, 'late', a) // A still holds its v0 observation
expect(fs.writeExpectations).toEqual([
{ kind: 'createIfAbsent' },
{ kind: 'replaceIfVersion', version: 'v0' },
])
})
})
describe('disposal releases recorded state', () => {
it('a fresh service after disposal starts with no inherited state', async () => {
const ctx = new Context()
await ctx.plugin(FakeFs)
const fs = ctx.fs as FakeFs
const fiber = await ctx.plugin(FileContext)
const exec = ownerExec({})
fs.files.set('a.txt', 'hello')
await ctx.fileContext.read(await fs.resolve('a.txt'), READ_ALL, exec)
await fiber.dispose()
await ctx.plugin(FileContext)
const target = await fs.resolve('a.txt')
// Same owner object, but state was released on disposal.
await expect(ctx.fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec))
.rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
})
})