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

View File

@@ -0,0 +1,43 @@
# @deepseek-ai/dsh-file-context
The **file-context policy layer**: a concrete `ctx.fileContext` service that owns model-facing read windowing and write/edit freshness on top of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)). This is the policy third of the filesystem stack — it is **not** a swappable seam, but the deferred policy layer that does not belong on the `FileSystem` provider base class.
```ts
import type { Context } from 'cordis'
import FileContext from '@deepseek-ai/dsh-file-context'
declare const ctx: Context
// A ctx.fs provider must already be loaded (e.g. @deepseek-ai/dsh-fs-local);
// FileContext injects `fs` and registers ctx.fileContext. Load
// @deepseek-ai/dsh-tool-fs afterwards to expose read/write/edit to the model.
await ctx.plugin(FileContext)
```
## The four-layer split
| Layer | Package | Role |
|---|---|---|
| tool | `@deepseek-ai/dsh-tool-fs` | model-facing schemas + text rendering |
| policy | `@deepseek-ai/dsh-file-context` (this) | `ctx.fileContext`: observed-state, read windowing, write/edit freshness |
| provider seam | `@deepseek-ai/dsh-fs` | `ctx.fs`: text IO + guarded mutation primitives |
| provider | `@deepseek-ai/dsh-fs-local` | local implementation of `ctx.fs` |
## Service API (`ctx.fileContext`)
| Member | Semantics |
|---|---|
| `read(target, request, exec?, signal?)` | Stats the target, rejects absent/non-regular targets, chooses `readText`/`streamText` by size, builds the requested line window, records the version, and returns the `FileReadOutcome` the tool renders. |
| `write(target, content, exec?, signal?)` | No recorded read → `writeText({ kind: 'createIfAbsent' })` (only new files create blindly); a recorded read → `writeText({ kind: 'replaceIfVersion', version })`. Refreshes recorded state on success. |
| `edit(target, edit, exec?, signal?)` | Requires a recorded read by this owner (else `FS_NOT_OBSERVED`); passes the observed version to `ctx.fs.editText` as the stale guard and refreshes recorded state. |
| `owner(exec?)` | Derives the observed-state owner (`exec.agent.session`) — `undefined` when there is none. |
## Observed state is the read record, freshness is the authorization
Observed state is a `WeakMap<owner, Map<targetKey, { version }>>`. An entry exists **iff** the owner has read that target through `read`, so its presence *is* the read record — there is no `hasRead` flag and no `full`/`partial` view. Authorization is based on version freshness only: a windowed read of lines 100-150 records the file's version, and a later edit of line 120 is authorized as long as the file is unchanged (the provider's stale guard enforces it). State is held weakly and dropped on disposal (HMR safety); persistence across sessions is deferred.
## The no-bypass contract
A model-facing read MUST go through `ctx.fileContext.read`, never `ctx.fs.readText`/`streamText`, so every successful read records observed state before the tool renders. Direct `ctx.fs` calls remain an explicit escape hatch for non-tool consumers: a direct `ctx.fs.readText` records nothing, so a later `ctx.fileContext.edit` rejects with `FS_NOT_OBSERVED` until the file is read through `ctx.fileContext`.
The line-windowing mechanics live in `src/window.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the service wiring and policy.

View File

@@ -0,0 +1,31 @@
{
"name": "@deepseek-ai/dsh-file-context",
"description": "File-context policy layer (ctx.fileContext) for the DeepSeek Harness — read windowing and write/edit freshness over the ctx.fs provider seam",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"exports": {
".": {
"types": "./lib/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-fs": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -0,0 +1,184 @@
/**
* The file-context policy layer (`ctx.fileContext`): a concrete service that
* owns model-facing read windowing and write/edit freshness on top of the
* `ctx.fs` provider seam. It is NOT a swappable seam — it is the previously
* deferred policy layer that does not belong on the `FileSystem` provider base
* class (where a sandboxed/remote backend would otherwise inherit model-facing
* observation policy it has no business carrying).
*
* ## Observed state IS the read record
*
* Observed state lives here as `WeakMap<owner, Map<targetKey, { version }>>`. An
* entry exists iff the owner has read that target through {@link read}, so its
* presence *is* the read record — there is no separate `hasRead` flag. The owner
* is derived structurally from `{ agent?: { session? } }` and held weakly, so a
* collected session frees its state; disposal drops everything (HMR safety).
*
* ## Freshness, not full/partial views
*
* Authorization is based on version freshness only. A windowed read records the
* file's version, and any later write/edit at that version is authorized — a
* model that read lines 100-150 of a large file can still edit line 120 as long
* as the file is unchanged. There is no `full`/`partial` distinction: the bytes
* the edit matches must merely come from the version the model read, which the
* provider's stale guard enforces.
*
* ## The no-bypass contract
*
* A model-facing read MUST go through {@link read} (never `ctx.fs.readText`/
* `streamText` directly), so every successful read records observed state before
* the tool renders. Direct `ctx.fs` calls are allowed for non-tool consumers but
* record nothing, so a later {@link edit} rejects with `FS_NOT_OBSERVED` until
* the file is read through `ctx.fileContext`.
*
* @module @deepseek-ai/dsh-file-context
*/
import { Context, Service } from 'cordis'
import { FsError } from '@deepseek-ai/dsh-fs'
import type { FsTarget, FsVersion, FsEditRequest, FsEditOutcome, FsWriteOutcome } from '@deepseek-ai/dsh-fs'
import { buildWindow } from './window.ts'
import type { FileContextExec, FileReadRequest, FileReadOutcome } from './types.ts'
export type { FileTextLine, ReadWindow, WindowResult } from './window.ts'
export { READ_MAX_BYTES, READ_MAX_LINE_LENGTH, buildWindow } from './window.ts'
export type { FileContextExec, FileReadRequest, FileReadOutcome } from './types.ts'
/** Files at or above this size stream; smaller files read whole into memory. */
export const STREAM_MIN_SIZE = 10 * 1024 * 1024
declare module 'cordis' {
interface Context {
fileContext: FileContext
}
}
/** What an owner has observed about one target: just the version it last saw. */
interface ObservedState {
version: FsVersion
}
/**
* The file-context policy service. Injects `fs`, registers as `ctx.fileContext`,
* and is the only read/write/edit path the model-facing tools use.
*/
export class FileContext extends Service {
static inject = ['fs']
/**
* Observed-file state, keyed first by the owner object (weakly held, so a
* collected session frees its state), then by {@link FsTarget.targetKey}. An
* entry's PRESENCE is the read record.
*/
private observed = new WeakMap<object, Map<string, ObservedState>>()
constructor(ctx: Context) {
super(ctx, 'fileContext')
ctx.effect(() => () => {
// Drop all recorded state on disposal so a reloaded service starts clean
// (HMR safety). The WeakMap itself would be GC'd, but replacing it makes
// the release observable and immediate for tests.
this.observed = new WeakMap()
}, 'fileContext observed-state teardown')
}
/**
* Derive the observed-state owner from an execution context — normally the
* active agent session. `undefined` when no owner can be derived (e.g. a
* direct tool call with no agent); such calls read freely but cannot satisfy
* the write/edit prior-observation policy.
*/
owner(exec?: FileContextExec): object | undefined {
return exec?.agent?.session
}
private getObserved(owner: object, targetKey: string): ObservedState | undefined {
return this.observed.get(owner)?.get(targetKey)
}
private record(owner: object, targetKey: string, version: FsVersion): void {
let byTarget = this.observed.get(owner)
if (!byTarget) {
byTarget = new Map()
this.observed.set(owner, byTarget)
}
byTarget.set(targetKey, { version })
}
/**
* Resolve a path into a stable {@link FsTarget}, delegating to the provider.
* Exposed here so the model-facing tools never need to inject `ctx.fs`
* directly — they resolve and then read/write/edit entirely through
* `ctx.fileContext`.
*/
async resolve(path: string): Promise<FsTarget> {
return this.ctx.fs.resolve(path)
}
/**
* Read a bounded line window from a target. Stats first (rejecting an absent
* target with `FS_NOT_FOUND` and a non-regular one with `FS_NOT_REGULAR_FILE`),
* chooses `readText` vs `streamText` by size, builds the window, and — when an
* owner is derivable — records the version so a later write/edit is authorized.
*/
async read(target: FsTarget, request: FileReadRequest, exec?: FileContextExec, signal?: AbortSignal): Promise<FileReadOutcome> {
const info = await this.ctx.fs.stat(target, signal)
if (!info) throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND')
if (info.type !== 'file') throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
const chunks = info.size !== undefined && info.size >= STREAM_MIN_SIZE
? await this.ctx.fs.streamText(target, signal)
: [await this.ctx.fs.readText(target, signal)]
const window = await buildWindow(chunks, request, target.displayPath)
const owner = this.owner(exec)
if (owner) this.record(owner, target.targetKey, info.version)
return {
offset: request.offset,
limit: request.limit,
lines: window.lines,
totalLines: window.totalLines,
version: info.version,
...window.truncatedByBytes ? { truncatedByBytes: true } : {},
}
}
/**
* Create or fully replace a file. With no recorded read, writes
* `createIfAbsent` (only new files can be created blindly); with a recorded
* read, writes `replaceIfVersion` at the observed version (existing files are
* replaced only if unchanged since the read). Refreshes recorded state from
* the returned version on success.
*/
async write(target: FsTarget, content: string, exec?: FileContextExec, signal?: AbortSignal): Promise<FsWriteOutcome> {
const owner = this.owner(exec)
const prior = owner ? this.getObserved(owner, target.targetKey) : undefined
const outcome = await this.ctx.fs.writeText(
target,
content,
prior ? { kind: 'replaceIfVersion', version: prior.version } : { kind: 'createIfAbsent' },
signal,
)
if (owner) this.record(owner, target.targetKey, outcome.version)
return outcome
}
/**
* Apply a literal edit. Requires a recorded read by this owner (else
* `FS_NOT_OBSERVED`); passes the observed version to `ctx.fs.editText` as the
* stale guard and refreshes recorded state from the returned version. The
* provider owns the mutation critical section and the literal match.
*/
async edit(target: FsTarget, edit: FsEditRequest, exec?: FileContextExec, signal?: AbortSignal): Promise<FsEditOutcome> {
const owner = this.owner(exec)
const prior = owner ? this.getObserved(owner, target.targetKey) : undefined
if (!owner || !prior) {
throw new FsError(`edit requires reading "${target.displayPath}" first`, 'FS_NOT_OBSERVED')
}
const outcome = await this.ctx.fs.editText(target, edit, { version: prior.version }, signal)
this.record(owner, target.targetKey, outcome.version)
return outcome
}
}
export default FileContext

View File

@@ -0,0 +1,56 @@
/**
* Vocabulary for the file-context policy layer (`ctx.fileContext`): the
* minimal execution-context shape used to derive an observed-state owner, the
* resolved read window, and the structured read outcome the model-facing `read`
* tool renders.
*
* The provider vocabulary (`FsTarget`, `FsVersion`, write/edit shapes) is
* re-used from `@deepseek-ai/dsh-fs` — this package owns only the model-facing
* read-windowing and observation policy on top of it.
*
* @module @deepseek-ai/dsh-file-context/types
*/
import type { FsVersion } from '@deepseek-ai/dsh-fs'
import type { FileTextLine } from './window.ts'
/**
* Minimal structural view of a tool execution the policy layer needs to derive
* an observed-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution` satisfies
* this shape, so the consumer passes its `exec` straight through without
* `dsh-file-context` importing `dsh-tools`, `dsh-agent`, or `dsh-session`.
*
* The owner is `agent.session` when present. It is treated as an opaque object
* identity (a `WeakMap` key); this package never reads any of its fields.
*/
export interface FileContextExec {
/** The agent on whose behalf the call runs, when there is one. */
agent?: {
/** The session that owns observed-file state, used as an opaque key. */
session?: object
}
}
/** Resolved read window. The consumer applies its defaults/caps before calling. */
export interface FileReadRequest {
/** 1-based first line to return. */
offset: number
/** Maximum number of lines to return. */
limit: number
}
/** Outcome of a bounded text read — what the model-facing `read` tool renders. */
export interface FileReadOutcome {
/** 1-based first line requested. */
offset: number
/** Maximum number of lines requested. */
limit: number
/** Returned lines, already numbered. */
lines: FileTextLine[]
/** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */
totalLines: number
/** Whether selected output hit the byte cap before EOF or the requested limit. */
truncatedByBytes?: true
/** Opaque version of the file at read time. */
version: FsVersion
}

View File

@@ -0,0 +1,139 @@
/**
* Cordis-free line-windowing for `@deepseek-ai/dsh-file-context`. Relocated
* from the local backend: turning a file's decoded text into a bounded,
* line-numbered window (offset/limit, byte cap, per-line truncation) is
* model-facing READ POLICY, not a storage primitive, so it lives in the policy
* layer rather than in every `ctx.fs` backend.
*
* The provider (`ctx.fs.readText`/`streamText`) hands back already-decoded text
* (UTF-8 validated, binary rejected); this module only scans that text for
* newlines and builds the requested window. A capped line buffer means a
* newline-free giant line can never balloon memory even when streamed.
*
* @module @deepseek-ai/dsh-file-context/window
*/
import { FsError } from '@deepseek-ai/dsh-fs'
/** Maximum characters returned for a single line. */
export const READ_MAX_LINE_LENGTH = 2000
/** Maximum bytes returned for selected file lines. */
export const READ_MAX_BYTES = 50 * 1024
const READ_MAX_LINE_SUFFIX = `... (line truncated to ${READ_MAX_LINE_LENGTH} chars)`
const LINE_BUFFER_CAP = READ_MAX_LINE_LENGTH + 1
/** Resolved read window. The consumer applies its defaults/caps before calling. */
export interface ReadWindow {
/** 1-based first line to return. */
offset: number
/** Maximum number of lines to return. */
limit: number
}
/** One line returned from a text file. */
export interface FileTextLine {
/** 1-based line number in the file. */
number: number
/** Line text without its trailing newline. */
text: string
}
/** The windowed result this module builds from a file's decoded text. */
export interface WindowResult {
/** Returned lines, already numbered. */
lines: FileTextLine[]
/** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */
totalLines: number
/** Whether selected output hit the byte cap before EOF or the requested limit. */
truncatedByBytes: boolean
}
interface WindowAccumulator {
lines: FileTextLine[]
totalLines: number
outputBytes: number
truncatedByBytes: boolean
done: boolean
}
function newAccumulator(): WindowAccumulator {
return { lines: [], totalLines: 0, outputBytes: 0, truncatedByBytes: false, done: false }
}
function truncateLine(line: string): string {
return line.length > READ_MAX_LINE_LENGTH ? `${line.substring(0, READ_MAX_LINE_LENGTH)}${READ_MAX_LINE_SUFFIX}` : line
}
function lineByteSize(line: string, currentLineCount: number): number {
return Buffer.byteLength(line, 'utf8') + (currentLineCount > 0 ? 1 : 0)
}
function consumeLine(acc: WindowAccumulator, rawLine: string, request: ReadWindow): void {
acc.totalLines += 1
if (acc.totalLines < request.offset || acc.lines.length >= request.limit) return
const text = truncateLine(rawLine)
const bytes = lineByteSize(text, acc.lines.length)
if (acc.outputBytes + bytes > READ_MAX_BYTES) {
acc.truncatedByBytes = true
acc.done = true
return
}
acc.outputBytes += bytes
acc.lines.push({ number: acc.totalLines, text })
}
function stripCarriageReturn(line: string): string {
return line.endsWith('\r') ? line.slice(0, -1) : line
}
function finish(acc: WindowAccumulator, request: ReadWindow, displayPath: string): WindowResult {
if (!acc.truncatedByBytes && request.offset > acc.totalLines && !(acc.totalLines === 0 && request.offset === 1)) {
throw new FsError(`offset ${request.offset} is out of range for "${displayPath}" (${acc.totalLines} lines)`, 'FS_NOT_FOUND')
}
return { lines: acc.lines, totalLines: acc.totalLines, truncatedByBytes: acc.truncatedByBytes }
}
/**
* Build a bounded, line-numbered window from a file's decoded text chunks.
* Accepts an `AsyncIterable<string>` (a chunked `streamText`) or an
* `Iterable<string>` (a whole-file `readText` wrapped as `[text]`), so one code
* path serves both. Scans for newlines with a capped line buffer (a newline-free
* giant line is truncated, never buffered past {@link READ_MAX_LINE_LENGTH}),
* enforces the byte cap, and throws `FS_NOT_FOUND` for an offset past EOF.
*/
export async function buildWindow(
chunks: AsyncIterable<string> | Iterable<string>,
request: ReadWindow,
displayPath: string,
): Promise<WindowResult> {
const acc = newAccumulator()
let lineBuffer = ''
function appendToLineBuffer(segment: string): void {
if (lineBuffer.length >= LINE_BUFFER_CAP) return
lineBuffer += segment
if (lineBuffer.length > LINE_BUFFER_CAP) lineBuffer = lineBuffer.slice(0, LINE_BUFFER_CAP)
}
function flushLine(): void {
consumeLine(acc, stripCarriageReturn(lineBuffer), request)
lineBuffer = ''
}
for await (const chunk of chunks) {
let startPos = 0
let newlinePos: number
while ((newlinePos = chunk.indexOf('\n', startPos)) !== -1) {
appendToLineBuffer(chunk.slice(startPos, newlinePos))
flushLine()
startPos = newlinePos + 1
if (acc.done) return finish(acc, request, displayPath)
}
appendToLineBuffer(chunk.slice(startPos))
}
if (lineBuffer.length > 0) flushLine()
return finish(acc, request, displayPath)
}

View File

@@ -0,0 +1,305 @@
/**
* 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' })
})
})

View File

@@ -0,0 +1,102 @@
/**
* Cordis-free tests for the line-windowing module: offset/limit windows, byte
* caps, per-line truncation, CRLF stripping, offset-past-EOF rejection, and the
* capped line buffer for newline-free giant lines — all over an async-iterable
* of decoded text chunks (so one code path serves whole-file and streamed reads).
*/
import { describe, expect, it } from 'vitest'
import { buildWindow, READ_MAX_LINE_LENGTH } from '@deepseek-ai/dsh-file-context'
import type { ReadWindow } from '@deepseek-ai/dsh-file-context'
const READ_ALL: ReadWindow = { offset: 1, limit: 2000 }
/** Yield `text` as one chunk (whole-file read shape). */
async function* whole(text: string): AsyncIterable<string> {
yield text
}
/** Yield `text` split into fixed-size chunks (streamed read shape). */
async function* chunked(text: string, size: number): AsyncIterable<string> {
for (let i = 0; i < text.length; i += size) yield text.slice(i, i + size)
}
describe('buildWindow', () => {
it('numbers lines and reports total for a whole-file read', async () => {
const result = await buildWindow(whole('one\ntwo\nthree'), READ_ALL, 'f')
expect(result.lines).toEqual([
{ number: 1, text: 'one' },
{ number: 2, text: 'two' },
{ number: 3, text: 'three' },
])
expect(result.totalLines).toBe(3)
expect(result.truncatedByBytes).toBe(false)
})
it('applies offset/limit', async () => {
const result = await buildWindow(whole('one\ntwo\nthree\nfour'), { offset: 2, limit: 2 }, 'f')
expect(result.lines.map(l => l.number)).toEqual([2, 3])
expect(result.totalLines).toBe(4)
})
it('strips CRLF', async () => {
const result = await buildWindow(whole('one\r\ntwo\r\n'), READ_ALL, 'f')
expect(result.lines.map(l => l.text)).toEqual(['one', 'two'])
})
it('truncates an over-long line', async () => {
const result = await buildWindow(whole('x'.repeat(3000)), READ_ALL, 'f')
expect(result.lines[0]?.text).toContain(`... (line truncated to ${READ_MAX_LINE_LENGTH} chars)`)
})
it('caps output bytes and reports truncatedByBytes', async () => {
const big = Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n')
const result = await buildWindow(whole(big), READ_ALL, 'f')
expect(result.truncatedByBytes).toBe(true)
})
it('reads an empty file at offset 1 as zero lines', async () => {
const result = await buildWindow(whole(''), READ_ALL, 'f')
expect(result.lines).toEqual([])
expect(result.totalLines).toBe(0)
})
it('rejects an offset past EOF', async () => {
await expect(buildWindow(whole('one\ntwo'), { offset: 9, limit: 1 }, 'f')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
})
it('flushes a final line with no trailing newline', async () => {
const result = await buildWindow(whole('one\ntwo'), READ_ALL, 'f')
expect(result.lines.map(l => l.text)).toEqual(['one', 'two'])
})
it('handles a trailing newline (no dangling empty line)', async () => {
const result = await buildWindow(whole('one\ntwo\n'), READ_ALL, 'f')
expect(result.lines.map(l => l.text)).toEqual(['one', 'two'])
expect(result.totalLines).toBe(2)
})
describe('chunked input (streamed read shape)', () => {
it('windows identically when text arrives in small chunks', async () => {
const result = await buildWindow(chunked('one\ntwo\nthree', 2), { offset: 2, limit: 1 }, 'f')
expect(result.lines).toEqual([{ number: 2, text: 'two' }])
expect(result.totalLines).toBe(3)
})
it('caps a newline-free giant line split across chunks without unbounded buffering', async () => {
const result = await buildWindow(chunked('z'.repeat(5000), 256), READ_ALL, 'f')
expect(result.lines[0]?.text).toContain(`... (line truncated to ${READ_MAX_LINE_LENGTH} chars)`)
})
it('caps output bytes mid-stream', async () => {
const big = Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n')
const result = await buildWindow(chunked(big, 512), READ_ALL, 'f')
expect(result.truncatedByBytes).toBe(true)
})
it('flushes a final newline-terminated line across a chunk boundary', async () => {
const result = await buildWindow(chunked('one\ntwo\n', 3), READ_ALL, 'f')
expect(result.lines.map(l => l.text)).toEqual(['one', 'two'])
})
})
})

View File

@@ -0,0 +1,14 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib"
},
"include": ["src"],
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../llm/llm" },
{ "path": "../fs" }
]
}