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:
@@ -1,11 +1,12 @@
|
||||
# @deepseek-ai/dsh-tool-fs
|
||||
|
||||
The **model-facing filesystem tools** — `read`, `write`, `edit` — over the `ctx.fs` seam ([`@deepseek-ai/dsh-fs`](../fs)). This is the consumer third of the filesystem capability; it owns tool names, JSON schemas, argument validation, prompt sections, and result formatting, and **never** touches filesystem I/O (no `node:fs`/`node:path`, no implementation import).
|
||||
The **model-facing filesystem tools** — `read`, `write`, `edit` — over the `ctx.fileContext` policy layer ([`@deepseek-ai/dsh-file-context`](../file-context)). This is the consumer layer of the filesystem stack; it owns tool names, JSON schemas, argument validation, prompt sections, and result formatting, and **never** touches filesystem I/O (no `node:fs`/`node:path`, no implementation import) or reaches around the policy layer to `ctx.fs`.
|
||||
|
||||
```ts ignore-check
|
||||
// Load a ctx.fs provider first, then the tools.
|
||||
// Load a ctx.fs provider, the policy layer, then the tools.
|
||||
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) // @deepseek-ai/dsh-fs-local
|
||||
await ctx.plugin(ToolFs) // this package — registers read/write/edit
|
||||
await ctx.plugin(FileContext) // @deepseek-ai/dsh-file-context
|
||||
await ctx.plugin(ToolFs) // this package — registers read/write/edit
|
||||
```
|
||||
|
||||
Each tool also ships as a subpath plugin for focused deployments:
|
||||
@@ -21,13 +22,17 @@ import * as editPlugin from '@deepseek-ai/dsh-tool-fs/edit'
|
||||
| Tool | Arguments | Behavior |
|
||||
|---|---|---|
|
||||
| `read` | `file_path`, `offset?`, `limit?` | Line-numbered UTF-8 content with a pagination footer. `offset` is 1-based; `limit` defaults to and caps at 2000 lines. |
|
||||
| `write` | `file_path`, `content` | Create or fully replace a file. Overwriting an existing file requires a prior `read` (the backend enforces it); creating a new file does not. |
|
||||
| `edit` | `file_path`, non-empty `old_string`, `new_string`, `replace_all?` | Literal replacement; unique match required unless `replace_all` is true. Requires a prior `read`. |
|
||||
| `write` | `file_path`, `content` | Create or fully replace a file. Overwriting an existing file requires a prior `read` at the unchanged version; creating a new file does not. |
|
||||
| `edit` | `file_path`, non-empty `old_string`, `new_string`, `replace_all?` | Literal replacement; unique match required unless `replace_all` is true. Requires a prior `read` (any window) and the file unchanged since. |
|
||||
|
||||
Field names are snake_case to match Claude Code and existing harness tool schemas.
|
||||
|
||||
## How the read-before-write policy is enforced
|
||||
## How the read-before-write/edit policy is enforced
|
||||
|
||||
The tools do **not** check whether a `read` ran or inspect any cache. Each tool resolves the path via `ctx.fs.resolve()`, then calls `ctx.fs.read/write/edit(target, …, exec)` — passing the current tool execution context straight through. `ctx.fs` derives the file-state owner (normally the agent session) from that context and owns the prior-observation and stale-version policy. Backend errors (`FsError`) flow through `ToolRegistry.execute()` and become `isError` tool results with their `{ name, code }` attached.
|
||||
The tools do **not** check whether a `read` ran or inspect any cache. Each tool resolves the path via `ctx.fileContext.resolve()`, then calls `ctx.fileContext.read/write/edit(target, …, exec)` — passing the current tool execution context straight through. `ctx.fileContext` derives the observed-state owner (normally the agent session) from that context and owns the freshness policy: a recorded read at the file's current version authorizes a write/edit, and any windowed read counts (authorization is freshness, not a full-view requirement). Backend errors (`FsError`) flow through `ToolRegistry.execute()` and become `isError` tool results with their `{ name, code }` attached.
|
||||
|
||||
## 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 rendering — which is why the tools inject `fileContext`, not `fs`. Direct `ctx.fs` calls remain an explicit escape hatch for non-tool consumers: a direct `ctx.fs.readText` records nothing, so a later `edit` rejects with `FS_NOT_OBSERVED` until the file is read through `ctx.fileContext`.
|
||||
|
||||
Tool schemas reach the system prompt automatically via the tool registry; this package additionally registers short prose guidance through `ctx.systemPrompt.section(...)`.
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-file-context": "^0.0.1",
|
||||
"@deepseek-ai/dsh-fs": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
@@ -40,6 +41,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-file-context": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* The model-facing `edit` tool: update an existing UTF-8 text file by replacing
|
||||
* literal text, requiring a unique match by default. Execution goes through
|
||||
* `ctx.fs`, which enforces prior observation and the stale-version guard and
|
||||
* owns the literal-match semantics.
|
||||
* `ctx.fileContext`, which enforces prior observation (the freshness policy)
|
||||
* and delegates the literal-match + stale-guard critical section to `ctx.fs`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs/edit
|
||||
*/
|
||||
@@ -60,8 +60,8 @@ export function apply(ctx: Context): void {
|
||||
},
|
||||
async execute(args, exec): Promise<ContentBlock[]> {
|
||||
const input = parseEditArgs(args)
|
||||
const target = await ctx.fs.resolve(input.filePath)
|
||||
const outcome = await ctx.fs.edit(
|
||||
const target = await ctx.fileContext.resolve(input.filePath)
|
||||
const outcome = await ctx.fileContext.edit(
|
||||
target,
|
||||
{ oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll },
|
||||
exec,
|
||||
@@ -76,7 +76,7 @@ export function apply(ctx: Context): void {
|
||||
export const name = 'fs-edit'
|
||||
|
||||
/** Services required by the `edit` tool plugin. */
|
||||
export const inject = ['tools', 'fs', 'systemPrompt']
|
||||
export const inject = ['tools', 'fileContext', 'systemPrompt']
|
||||
|
||||
/** Named helper for direct registration in the root plugin and tests. */
|
||||
export const applyEditTool = apply
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
/**
|
||||
* The model-facing filesystem tool suite (`read`, `write`, `edit`) over the
|
||||
* `ctx.fs` seam. This root plugin registers all three tools by composing the
|
||||
* per-tool registration helpers; each tool is also exposed as a subpath plugin
|
||||
* (`@deepseek-ai/dsh-tool-fs/read`, `/write`, `/edit`) for focused deployments.
|
||||
* `ctx.fileContext` policy layer. This root plugin registers all three tools by
|
||||
* composing the per-tool registration helpers; each tool is also exposed as a
|
||||
* subpath plugin (`@deepseek-ai/dsh-tool-fs/read`, `/write`, `/edit`) for focused
|
||||
* deployments.
|
||||
*
|
||||
* The package owns model-facing concerns only — tool names, JSON schemas,
|
||||
* argument validation, prompt sections, result formatting. All filesystem
|
||||
* execution goes through `ctx.fs`; this package never imports `node:fs`,
|
||||
* `node:path`, or an `@deepseek-ai/dsh-fs-local` implementation.
|
||||
* execution goes through `ctx.fileContext` (never directly around it to
|
||||
* `ctx.fs`), so every model read records observed-state before rendering; this
|
||||
* package never imports `node:fs`, `node:path`, or an
|
||||
* `@deepseek-ai/dsh-fs-local` implementation.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs
|
||||
*/
|
||||
@@ -25,7 +28,7 @@ export { applyEditTool, formatEditOutput, parseEditArgs } from './edit.ts'
|
||||
export const name = 'tool-fs'
|
||||
|
||||
/** Services required by the filesystem tool suite. */
|
||||
export const inject = ['tools', 'fs', 'systemPrompt']
|
||||
export const inject = ['tools', 'fileContext', 'systemPrompt']
|
||||
|
||||
/** Register the full `read`/`write`/`edit` filesystem tool suite. */
|
||||
export function apply(ctx: Context): void {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* The model-facing `read` tool: inspect a UTF-8 text file and return
|
||||
* line-numbered content with pagination guidance. Execution goes through
|
||||
* `ctx.fs` — this module owns only the model-facing schema, argument
|
||||
* validation, and result formatting, never filesystem I/O.
|
||||
* `ctx.fileContext` (which records observed state and owns read windowing) —
|
||||
* this module owns only the model-facing schema, argument validation, and
|
||||
* result formatting, never filesystem I/O.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs/read
|
||||
*/
|
||||
@@ -10,7 +11,7 @@
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { FsReadOutcome } from '@deepseek-ai/dsh-fs'
|
||||
import type { FileReadOutcome } from '@deepseek-ai/dsh-file-context'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
/** Default and maximum number of lines returned by one `read` call. */
|
||||
@@ -40,7 +41,7 @@ export function parseReadArgs(args: { file_path: string; offset?: number; limit?
|
||||
}
|
||||
|
||||
/** Format a read outcome as one OpenCode-style line-numbered text block body. */
|
||||
export function formatReadOutput(displayPath: string, outcome: FsReadOutcome): string {
|
||||
export function formatReadOutput(displayPath: string, outcome: FileReadOutcome): string {
|
||||
const endLine = outcome.lines.at(-1)?.number ?? Math.max(0, outcome.offset - 1)
|
||||
let footer: string
|
||||
if (outcome.truncatedByBytes) {
|
||||
@@ -78,8 +79,8 @@ export function apply(ctx: Context): void {
|
||||
},
|
||||
async execute(args, exec): Promise<ContentBlock[]> {
|
||||
const input = parseReadArgs(args)
|
||||
const target = await ctx.fs.resolve(input.filePath)
|
||||
const outcome = await ctx.fs.read(target, { offset: input.offset, limit: input.limit }, exec, exec.signal)
|
||||
const target = await ctx.fileContext.resolve(input.filePath)
|
||||
const outcome = await ctx.fileContext.read(target, { offset: input.offset, limit: input.limit }, exec, exec.signal)
|
||||
return [{ type: 'text', text: formatReadOutput(target.displayPath, outcome) }]
|
||||
},
|
||||
}))
|
||||
@@ -89,7 +90,7 @@ export function apply(ctx: Context): void {
|
||||
export const name = 'fs-read'
|
||||
|
||||
/** Services required by the `read` tool plugin. */
|
||||
export const inject = ['tools', 'fs', 'systemPrompt']
|
||||
export const inject = ['tools', 'fileContext', 'systemPrompt']
|
||||
|
||||
/** Named helper for direct registration in the root plugin and tests. */
|
||||
export const applyReadTool = apply
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* The model-facing `write` tool: create or fully replace a UTF-8 text file.
|
||||
* Execution goes through `ctx.fs`, which enforces the read-before-overwrite
|
||||
* policy (updating an existing file requires a prior read in the same
|
||||
* execution context; creating a new file does not).
|
||||
* Execution goes through `ctx.fileContext`, which enforces the freshness policy
|
||||
* (creating a new file needs no prior read; replacing an existing file requires
|
||||
* a prior read in the same execution context at the unchanged version).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs/write
|
||||
*/
|
||||
@@ -46,8 +46,8 @@ export function apply(ctx: Context): void {
|
||||
},
|
||||
async execute(args, exec): Promise<ContentBlock[]> {
|
||||
const input = parseWriteArgs(args)
|
||||
const target = await ctx.fs.resolve(input.filePath)
|
||||
const outcome = await ctx.fs.write(target, input.content, exec, exec.signal)
|
||||
const target = await ctx.fileContext.resolve(input.filePath)
|
||||
const outcome = await ctx.fileContext.write(target, input.content, exec, exec.signal)
|
||||
return [{ type: 'text', text: formatWriteOutput(target.displayPath, outcome) }]
|
||||
},
|
||||
}))
|
||||
@@ -57,7 +57,7 @@ export function apply(ctx: Context): void {
|
||||
export const name = 'fs-write'
|
||||
|
||||
/** Services required by the `write` tool plugin. */
|
||||
export const inject = ['tools', 'fs', 'systemPrompt']
|
||||
export const inject = ['tools', 'fileContext', 'systemPrompt']
|
||||
|
||||
/** Named helper for direct registration in the root plugin and tests. */
|
||||
export const applyWriteTool = apply
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,36 +1,43 @@
|
||||
/**
|
||||
* Tests for the per-tool subpath plugins (`@deepseek-ai/dsh-tool-fs/read`,
|
||||
* `/write`, `/edit`): each registers exactly one tool, injects the same
|
||||
* services, and cleans up on disposal.
|
||||
* services (`tools`, `fileContext`, `systemPrompt`), and cleans up on disposal.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import { FileSystem } from '@deepseek-ai/dsh-fs'
|
||||
import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type {
|
||||
FsEditOutcome,
|
||||
FsReadOutcome,
|
||||
FsInfo,
|
||||
FsTarget,
|
||||
FsWriteOutcome,
|
||||
} from '@deepseek-ai/dsh-fs'
|
||||
import FileContext from '@deepseek-ai/dsh-file-context'
|
||||
import * as readPlugin from '@deepseek-ai/dsh-tool-fs/read'
|
||||
import * as writePlugin from '@deepseek-ai/dsh-tool-fs/write'
|
||||
import * as editPlugin from '@deepseek-ai/dsh-tool-fs/edit'
|
||||
|
||||
class StubFs extends FileSystem {
|
||||
override async resolve(path: string): Promise<FsTarget> {
|
||||
return { inputPath: path, targetKey: path, displayPath: path }
|
||||
return { inputPath: path, targetKey: FsTargetKey(path), displayPath: path }
|
||||
}
|
||||
override async readPage(): Promise<FsReadOutcome> {
|
||||
return { offset: 1, limit: 1, lines: [], totalLines: 0, version: 'v', view: 'full' }
|
||||
override async stat(): Promise<FsInfo | undefined> {
|
||||
return { version: FsVersion('v'), type: 'file', size: 0 }
|
||||
}
|
||||
override async createOrReplace(): Promise<FsWriteOutcome> {
|
||||
return { operation: 'create', version: 'v' }
|
||||
override async readText(): Promise<string> {
|
||||
return ''
|
||||
}
|
||||
override async applyEdit(): Promise<FsEditOutcome> {
|
||||
return { replacements: 1, replaceAll: false, version: 'v' }
|
||||
override async streamText(): Promise<AsyncIterable<string>> {
|
||||
return (async function* () { yield '' })()
|
||||
}
|
||||
override async writeText(): Promise<FsWriteOutcome> {
|
||||
return { operation: 'create', version: FsVersion('v') }
|
||||
}
|
||||
override async editText(): Promise<FsEditOutcome> {
|
||||
return { replacements: 1, replaceAll: false, version: FsVersion('v') }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +46,7 @@ async function base() {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(StubFs)
|
||||
await ctx.plugin(FileContext)
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -64,7 +72,7 @@ describe('subpath plugins', () => {
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('stays pending without a ctx.fs provider', async () => {
|
||||
it('stays pending without a ctx.fileContext provider', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* Consumer-surface tests for the filesystem tools using a fake `ctx.fs` that
|
||||
* records the execution context it received and returns canned outcomes. These
|
||||
* verify schemas, argument validation, result formatting, FsError→isError
|
||||
* propagation, and that each tool passes `exec` straight through to `ctx.fs`.
|
||||
* Consumer-surface tests for the filesystem tools. They run the REAL
|
||||
* `ctx.fileContext` policy service over a fake `ctx.fs` provider (the genuine
|
||||
* collaborator, per the prefer-the-real-implementation rule), so they verify
|
||||
* schemas, argument validation, result formatting, FsError→isError propagation,
|
||||
* and that each tool records observed-state through `ctx.fileContext` (the
|
||||
* no-bypass contract) — not just that it moved bytes.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@@ -10,65 +12,56 @@ import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import { FileSystem, FsError } from '@deepseek-ai/dsh-fs'
|
||||
import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type {
|
||||
FsEditOutcome,
|
||||
FsEditRequest,
|
||||
FsExecContext,
|
||||
FsReadOutcome,
|
||||
FsReadRequest,
|
||||
FsInfo,
|
||||
FsTarget,
|
||||
FsWriteExpectation,
|
||||
FsWriteOutcome,
|
||||
} from '@deepseek-ai/dsh-fs'
|
||||
import FileContext from '@deepseek-ai/dsh-file-context'
|
||||
import type { FileReadOutcome } from '@deepseek-ai/dsh-file-context'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import { formatReadOutput } from '@deepseek-ai/dsh-tool-fs'
|
||||
|
||||
/**
|
||||
* Records the public-API calls (and the exec each received) and returns canned
|
||||
* outcomes; lets a test arm a rejection. Overrides the public methods directly
|
||||
* (not the primitives) so we observe exactly what the tool passed.
|
||||
*/
|
||||
/** An in-memory fake provider; a test can arm a rejection on any primitive. */
|
||||
class FakeFs extends FileSystem {
|
||||
calls: Array<{ op: string; exec: FsExecContext | undefined; target: FsTarget }> = []
|
||||
files = new Map<string, string>()
|
||||
rejectWith?: FsError
|
||||
|
||||
private throwIfArmed(): void {
|
||||
if (this.rejectWith) throw this.rejectWith
|
||||
}
|
||||
|
||||
override async resolve(path: string): Promise<FsTarget> {
|
||||
return { inputPath: path, targetKey: `key:${path}`, displayPath: `/abs/${path}` }
|
||||
return { inputPath: path, targetKey: FsTargetKey(`key:${path}`), displayPath: `/abs/${path}` }
|
||||
}
|
||||
|
||||
override async readPage(): Promise<FsReadOutcome> {
|
||||
throw new Error('not used: tool tests override read()')
|
||||
override async stat(target: FsTarget): Promise<FsInfo | undefined> {
|
||||
this.throwIfArmed()
|
||||
const content = this.files.get(target.targetKey)
|
||||
if (content === undefined) return undefined
|
||||
return { version: FsVersion('v1'), type: 'file', size: content.length }
|
||||
}
|
||||
override async createOrReplace(): Promise<FsWriteOutcome> {
|
||||
throw new Error('not used')
|
||||
override async readText(target: FsTarget): Promise<string> {
|
||||
return this.files.get(target.targetKey) ?? ''
|
||||
}
|
||||
override async applyEdit(): Promise<FsEditOutcome> {
|
||||
throw new Error('not used')
|
||||
override async streamText(target: FsTarget): Promise<AsyncIterable<string>> {
|
||||
const content = this.files.get(target.targetKey) ?? ''
|
||||
return (async function* () { yield content })()
|
||||
}
|
||||
|
||||
override async read(target: FsTarget, _request: FsReadRequest, exec?: FsExecContext): Promise<FsReadOutcome> {
|
||||
this.calls.push({ op: 'read', exec, target })
|
||||
if (this.rejectWith) throw this.rejectWith
|
||||
return {
|
||||
offset: 1,
|
||||
limit: 2000,
|
||||
lines: [{ number: 1, text: 'hello' }, { number: 2, text: 'world' }],
|
||||
totalLines: 2,
|
||||
version: 'v1',
|
||||
view: 'full',
|
||||
}
|
||||
override async writeText(target: FsTarget, content: string, _expected: FsWriteExpectation): Promise<FsWriteOutcome> {
|
||||
this.throwIfArmed()
|
||||
const existed = this.files.has(target.targetKey)
|
||||
this.files.set(target.targetKey, content)
|
||||
return { operation: existed ? 'update' : 'create', version: FsVersion('v2') }
|
||||
}
|
||||
|
||||
override async write(target: FsTarget, _content: string, exec?: FsExecContext): Promise<FsWriteOutcome> {
|
||||
this.calls.push({ op: 'write', exec, target })
|
||||
if (this.rejectWith) throw this.rejectWith
|
||||
return { operation: 'create', version: 'v1' }
|
||||
}
|
||||
|
||||
override async edit(target: FsTarget, _edit: FsEditRequest, exec?: FsExecContext): Promise<FsEditOutcome> {
|
||||
this.calls.push({ op: 'edit', exec, target })
|
||||
if (this.rejectWith) throw this.rejectWith
|
||||
return { replacements: 1, replaceAll: false, version: 'v1' }
|
||||
override async editText(target: FsTarget, edit: FsEditRequest): Promise<FsEditOutcome> {
|
||||
this.throwIfArmed()
|
||||
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: FsVersion('v3') }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +70,7 @@ async function setup() {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(FakeFs)
|
||||
await ctx.plugin(FileContext)
|
||||
await ctx.plugin(ToolFs)
|
||||
const fs = ctx.fs as FakeFs
|
||||
return { ctx, fs }
|
||||
@@ -110,11 +104,11 @@ describe('registration', () => {
|
||||
expect(prompt).toContain('Use the edit tool')
|
||||
})
|
||||
|
||||
it('stays pending until ctx.fs exists (inject)', async () => {
|
||||
it('stays pending until ctx.fileContext exists (inject)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(ToolFs) // no fs provider
|
||||
await ctx.plugin(ToolFs) // no fileContext provider
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
})
|
||||
|
||||
@@ -123,6 +117,7 @@ describe('registration', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(FakeFs)
|
||||
await ctx.plugin(FileContext)
|
||||
const fiber = await ctx.plugin(ToolFs)
|
||||
expect(ctx.tools.schemas()).toHaveLength(3)
|
||||
await fiber.dispose()
|
||||
@@ -132,7 +127,8 @@ describe('registration', () => {
|
||||
|
||||
describe('read tool', () => {
|
||||
it('formats line-numbered content with a footer', async () => {
|
||||
const { ctx } = await setup()
|
||||
const { ctx, fs } = await setup()
|
||||
fs.files.set('key:a.txt', 'hello\nworld')
|
||||
const result = await call(ctx, 'read', { file_path: 'a.txt' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toBe(`<path>/abs/a.txt</path>
|
||||
@@ -166,18 +162,25 @@ describe('read tool', () => {
|
||||
expect(text(result)).toContain('file_path must be a non-empty string')
|
||||
})
|
||||
|
||||
it('passes the execution context through to ctx.fs', async () => {
|
||||
it('records observed state so a follow-up edit by the same session is authorized', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
const session = {}
|
||||
await call(ctx, 'read', { file_path: 'a.txt' }, { session })
|
||||
expect(fs.calls).toHaveLength(1)
|
||||
expect(fs.calls[0]?.op).toBe('read')
|
||||
expect(fs.calls[0]?.exec?.agent?.session).toBe(session)
|
||||
fs.files.set('key:a.txt', 'hello')
|
||||
expect((await call(ctx, 'read', { file_path: 'a.txt' }, { session })).isError).toBe(false)
|
||||
const edited = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'hello', new_string: 'bye' }, { session })
|
||||
expect(edited.isError).toBe(false)
|
||||
})
|
||||
|
||||
it('propagates FS_NOT_FOUND for an absent file', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'read', { file_path: 'missing.txt' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatReadOutput footer variants', () => {
|
||||
const base = { offset: 1, limit: 2000, lines: [{ number: 1, text: 'x' }], totalLines: 1, version: 'v', view: 'full' as const }
|
||||
const base: FileReadOutcome = { offset: 1, limit: 2000, lines: [{ number: 1, text: 'x' }], totalLines: 1, version: FsVersion('v') }
|
||||
|
||||
it('reports a byte-capped read', () => {
|
||||
const out = formatReadOutput('/f', { ...base, totalLines: 99, truncatedByBytes: true })
|
||||
@@ -225,9 +228,12 @@ describe('write tool', () => {
|
||||
})
|
||||
|
||||
describe('edit tool', () => {
|
||||
it('formats a single-replacement success', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' })
|
||||
it('formats a single-replacement success after a read', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
const session = {}
|
||||
fs.files.set('key:a.txt', 'a')
|
||||
await call(ctx, 'read', { file_path: 'a.txt' }, { session })
|
||||
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }, { session })
|
||||
expect(text(result)).toBe('The file /abs/a.txt has been updated successfully.')
|
||||
})
|
||||
|
||||
@@ -252,19 +258,11 @@ describe('edit tool', () => {
|
||||
expect(text(result)).toContain('file_path must be a non-empty string')
|
||||
})
|
||||
|
||||
it('propagates FS_NOT_OBSERVED from the backend', async () => {
|
||||
it('propagates FS_NOT_OBSERVED when the file was never read', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
fs.rejectWith = new FsError('read first', 'FS_NOT_OBSERVED')
|
||||
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' })
|
||||
fs.files.set('key:a.txt', 'hello')
|
||||
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }, { session: {} })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
|
||||
it('propagates FS_PARTIAL_OBSERVATION from the backend', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
fs.rejectWith = new FsError('read fully first', 'FS_PARTIAL_OBSERVATION')
|
||||
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_PARTIAL_OBSERVATION' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../../core/tools" },
|
||||
{ "path": "../../core/system-prompt" },
|
||||
{ "path": "../fs" }
|
||||
{ "path": "../fs" },
|
||||
{ "path": "../file-context" }
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user