refactor(fs): make dsh-file-context an event-gate plugin, not a method service

Invert the tool↔policy control flow per the file-context event-gate RFC.
dsh-tool-fs becomes the executor — it reads/writes/edits through ctx.fs
directly, owns read windowing, and dispatches fs/write-expectation /
fs/edit-expectation (single-slot waterfalls) plus a contained fs/observed
emit. dsh-file-context drops its ctx.fileContext service and becomes a pure
event-gate plugin (observed-state + read-before-edit + version-guarded
write/edit, decided on those events). The provider's version guard becomes
optional so ctx.fs alone is a complete unconstrained text-storage seam:
removing the policy plugin gracefully loses the policy instead of breaking
the tool at a service-injection boundary.
This commit is contained in:
Dudu-0223
2026-06-28 13:49:02 +08:00
parent d612ebaef1
commit 90dceea0e4
35 changed files with 1229 additions and 793 deletions

View File

@@ -1,15 +1,17 @@
# @deepseek-ai/dsh-tool-fs
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`.
The **model-facing filesystem tools** — `read`, `write`, `edit` — and their **executor**. This is the consumer layer of the filesystem stack: it owns tool names, JSON schemas, argument validation, prompt sections, **read windowing**, and result formatting. It reads/writes/edits through the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)) **directly** — it injects `fs` (plus `tools`/`systemPrompt`), **not** a policy service. The freshness/observation policy is contributed by a separate plugin ([`@deepseek-ai/dsh-file-context`](../file-context)) through the `fs/*` event gate; the tool is not method-coupled to it.
```ts ignore-check
// Load a ctx.fs provider, the policy layer, then the tools.
// Default deployment: a ctx.fs provider, the policy plugin, then the tools.
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) // @deepseek-ai/dsh-fs-local
await ctx.plugin(FileContext) // @deepseek-ai/dsh-file-context
await ctx.plugin(FileContext) // @deepseek-ai/dsh-file-context (policy gate)
await ctx.plugin(ToolFs) // this package — registers read/write/edit
```
Each tool also ships as a subpath plugin for focused deployments:
`@deepseek-ai/dsh-file-context` is **optional**: omit it and the tools run against the bare provider (unconditional write/overwrite/edit, no observed-state). The default product config loads it, so the default behavior stays read-before-write/edit.
Each tool also ships as a subpath plugin for focused deployments (each injects `fs`, not a policy service):
```ts ignore-check
import * as readPlugin from '@deepseek-ai/dsh-tool-fs/read'
@@ -22,17 +24,23 @@ 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` 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. |
| `write` | `file_path`, `content` | Create or fully replace a file. With the policy plugin: overwriting an existing file requires a prior `read` at the unchanged version; creating a new file does not. Without it: unconditional. |
| `edit` | `file_path`, non-empty `old_string`, `new_string`, `replace_all?` | Literal replacement; unique match required unless `replace_all` is true. With the policy plugin: requires a prior `read` (any window) and the file unchanged since. Without it: unconditional. |
Field names are snake_case to match Claude Code and existing harness tool schemas.
## How the read-before-write/edit policy is enforced
## The tool is the executor; policy is an event gate
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 tools do **not** inject a policy service or inspect any cache. Each tool resolves the path via `ctx.fs.resolve()`, then:
## The no-bypass contract
- **read** — one `ctx.fs.stat` (type + size routing + version), then `readText`/`streamText`, then builds the line window, then emits a contained `fs/observed`. (1 stat.)
- **write** — `ctx.waterfall('fs/write-expectation', target, exec, () => undefined)` for the optional guard, then `ctx.fs.writeText(target, content, expectation)`, then `fs/observed`. (0 stat.)
- **edit** — `ctx.waterfall('fs/edit-expectation', target, exec, () => undefined)` for the optional guard, then `ctx.fs.editText(target, edit, expectation)`, then `fs/observed`. (0 stat.)
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`.
The tool passes `exec` (the tool-execution context) as the opaque `actor` on every dispatch. The default thunks return `undefined` (the unconstrained bare provider). When `@deepseek-ai/dsh-file-context` is loaded it occupies the single decision slot — returning `createIfAbsent`/`replaceIfVersion`/`{ version }` or throwing `FS_NOT_OBSERVED` — and records on `fs/observed`. Backend errors (`FsError`) and a thrown `FS_NOT_OBSERVED` flow through `ToolRegistry.execute()` and become `isError` tool results with their `{ name, code }` attached.
Tool schemas reach the system prompt automatically via the tool registry; this package additionally registers short prose guidance through `ctx.systemPrompt.section(...)`.
## `fs/observed` never fails the tool
`fs/observed` fires AFTER the read/write/edit already succeeded, so the tool wraps the emit in a try/catch (`src/observe.ts`) that logs and swallows a synchronous listener bug — otherwise a recording failure would turn a completed mutation into an `isError`. The event contract requires synchronous, side-effect-only listeners; this is the synchronous backstop, not async-error handling.
The line-windowing mechanics live in `src/window.ts` (Cordis-free, independently unit-tested); `src/read.ts`/`write.ts`/`edit.ts` are the tool executors and `src/index.ts` composes them.

View File

@@ -32,7 +32,6 @@
],
"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",

View File

@@ -1,8 +1,14 @@
/**
* 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.fileContext`, which enforces prior observation (the freshness policy)
* and delegates the literal-match + stale-guard critical section to `ctx.fs`.
* literal text, requiring a unique match by default. The tool is the executor:
* it dispatches the `fs/edit-expectation` waterfall to obtain the optional
* version guard, calls `ctx.fs.editText` directly, and emits a contained
* `fs/observed`. The default thunk returns `undefined` (unconditional edit of
* the current content — the bare provider); a policy plugin
* (`@deepseek-ai/dsh-file-context`) occupies the single decision slot, returning
* `{ version: vObserved }` or throwing `FS_NOT_OBSERVED` for an unread file. The
* tool stats ZERO times either way; a missing target is reported by the provider
* as `FS_STALE_VERSION`.
*
* @module @deepseek-ai/dsh-tool-fs/edit
*/
@@ -11,7 +17,9 @@ import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { FsEditOutcome } from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { emitObserved } from './observe.ts'
/** Validated `edit` arguments after defaulting. */
interface EditInput {
@@ -60,13 +68,18 @@ export function apply(ctx: Context): void {
},
async execute(args, exec): Promise<ContentBlock[]> {
const input = parseEditArgs(args)
const target = await ctx.fileContext.resolve(input.filePath)
const outcome = await ctx.fileContext.edit(
const target = await ctx.fs.resolve(input.filePath)
// Single-slot decision: the policy plugin returns { version: vObserved } or
// throws FS_NOT_OBSERVED; the bare default is undefined (unconditional edit).
// No stat — the bare default never manufactures a version basis.
const expectation = await ctx.waterfall('fs/edit-expectation', target, exec, () => undefined)
const outcome = await ctx.fs.editText(
target,
{ oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll },
exec,
expectation,
exec.signal,
)
emitObserved(ctx, target, outcome.version, exec)
return [{ type: 'text', text: formatEditOutput(target.displayPath, outcome) }]
},
}))
@@ -76,7 +89,7 @@ export function apply(ctx: Context): void {
export const name = 'fs-edit'
/** Services required by the `edit` tool plugin. */
export const inject = ['tools', 'fileContext', 'systemPrompt']
export const inject = ['tools', 'fs', 'systemPrompt']
/** Named helper for direct registration in the root plugin and tests. */
export const applyEditTool = apply

View File

@@ -1,15 +1,24 @@
/**
* The model-facing filesystem tool suite (`read`, `write`, `edit`) over the
* `ctx.fileContext` policy layer. This root plugin registers all three tools by
* `ctx.fs` provider 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.
*
* The package owns model-facing concerns only — tool names, JSON schemas,
* argument validation, prompt sections, result formatting. All filesystem
* 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
* ## The tool is the executor; policy is an event gate
*
* The tool reads/writes/edits through `ctx.fs` DIRECTLY and owns model-facing
* concerns only — tool names, JSON schemas, argument validation, prompt
* sections, read windowing, result formatting. It does NOT inject a policy
* service. Instead, on each write/edit it dispatches a single-slot waterfall
* (`fs/write-expectation`/`fs/edit-expectation`) to obtain the OPTIONAL version
* guard, and after every read/write/edit it emits a contained `fs/observed`. A
* policy plugin (`@deepseek-ai/dsh-file-context`, loaded by the default product
* config) occupies the decision slot and listens for `fs/observed` to add
* observed-state + read-before-edit + version-guarded write/edit. With no policy
* plugin the waterfalls fall through to their `undefined` default (the
* unconstrained bare provider) and `fs/observed` is unheard — the tool still
* functions. This package never imports `node:fs`, `node:path`, or an
* `@deepseek-ai/dsh-fs-local` implementation.
*
* @module @deepseek-ai/dsh-tool-fs
@@ -20,15 +29,19 @@ import { applyReadTool } from './read.ts'
import { applyWriteTool } from './write.ts'
import { applyEditTool } from './edit.ts'
export { READ_LIMIT, applyReadTool, formatReadOutput, parseReadArgs } from './read.ts'
export { READ_LIMIT, STREAM_MIN_SIZE, applyReadTool, formatReadOutput, parseReadArgs } from './read.ts'
export { applyWriteTool, formatWriteOutput, parseWriteArgs } from './write.ts'
export { applyEditTool, formatEditOutput, parseEditArgs } from './edit.ts'
export { emitObserved } from './observe.ts'
export type { FileTextLine, ReadWindow, WindowResult } from './window.ts'
export { READ_MAX_BYTES, READ_MAX_LINE_LENGTH, buildWindow } from './window.ts'
export type { FileReadOutcome } from './types.ts'
/** Cordis plugin name used by loader diagnostics. */
export const name = 'tool-fs'
/** Services required by the filesystem tool suite. */
export const inject = ['tools', 'fileContext', 'systemPrompt']
export const inject = ['tools', 'fs', 'systemPrompt']
/** Register the full `read`/`write`/`edit` filesystem tool suite. */
export function apply(ctx: Context): void {

View File

@@ -0,0 +1,34 @@
/**
* The contained `fs/observed` emit shared by the `read`/`write`/`edit` tools.
*
* `fs/observed` fires AFTER a mutation/read already succeeded, so a throwing
* listener must never turn the completed operation into an `isError` result
* (the tool registry catches a tool throw into an error result). The event
* contract requires a synchronous, side-effect-only listener (the policy
* plugin's is a `WeakMap.set`); this try/catch is the synchronous backstop —
* it logs and swallows a listener bug, mirroring the fire-and-forget pattern in
* the agent loop. It is NOT async-error containment: cordis `emit` does not
* await listener promises, so async observation does not belong on this event.
*
* @module @deepseek-ai/dsh-tool-fs/observe
*/
import type { Context } from 'cordis'
import type { FsTarget, FsVersion } from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-fs'
/**
* Emit `fs/observed` for a just-completed read/write/edit, containing any
* synchronous listener throw so the already-successful operation still reports
* success.
*/
export function emitObserved(ctx: Context, target: FsTarget, version: FsVersion, actor: object | undefined): void {
try {
ctx.emit('fs/observed', target, version, actor)
} catch (error: unknown) {
// Contained: the read/write/edit already succeeded. An `fs/observed` listener
// MUST be synchronous and side-effect-only; a synchronous bug is logged and
// swallowed so a recording failure never fails the completed operation.
ctx.logger.warn(`fs/observed listener threw for "${target.displayPath}": ${String(error)}`)
}
}

View File

@@ -1,9 +1,12 @@
/**
* The model-facing `read` tool: inspect a UTF-8 text file and return
* line-numbered content with pagination guidance. Execution goes through
* `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.
* line-numbered content with pagination guidance. The tool is the executor — it
* stats and reads through `ctx.fs` directly, builds the line window
* ({@link module:@deepseek-ai/dsh-tool-fs/window}), and emits a contained
* `fs/observed` so a policy plugin (`@deepseek-ai/dsh-file-context`) can record
* the read. With no policy plugin the emit is simply unheard. This module owns
* the model-facing schema, argument validation, read windowing, and result
* formatting; the freshness/observation policy is not its concern.
*
* @module @deepseek-ai/dsh-tool-fs/read
*/
@@ -11,12 +14,19 @@
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { FileReadOutcome } from '@deepseek-ai/dsh-file-context'
import { FsError } from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { buildWindow } from './window.ts'
import { emitObserved } from './observe.ts'
import type { FileReadOutcome } from './types.ts'
/** Default and maximum number of lines returned by one `read` call. */
export const READ_LIMIT = 2000
/** Files at or above this size stream; smaller files read whole into memory. */
export const STREAM_MIN_SIZE = 10 * 1024 * 1024
/** Validated `read` arguments after defaulting. */
interface ReadInput {
filePath: string
@@ -79,8 +89,32 @@ export function apply(ctx: Context): void {
},
async execute(args, exec): Promise<ContentBlock[]> {
const input = parseReadArgs(args)
const target = await ctx.fileContext.resolve(input.filePath)
const outcome = await ctx.fileContext.read(target, { offset: input.offset, limit: input.limit }, exec, exec.signal)
const target = await ctx.fs.resolve(input.filePath)
// One stat: type check + size routing + the version recorded as observed.
// A writer racing between this stat and the read can at worst make a LATER
// guarded edit spuriously FS_STALE_VERSION (fail-closed: re-read; editText
// re-checks the version in its lock).
const info = await ctx.fs.stat(target, exec.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')
// Stream when the file is large OR size is unknown, so a size-less backend
// never buffers an arbitrarily large file.
const chunks = info.size === undefined || info.size >= STREAM_MIN_SIZE
? await ctx.fs.streamText(target, exec.signal)
: [await ctx.fs.readText(target, exec.signal)]
const window = await buildWindow(chunks, { offset: input.offset, limit: input.limit }, target.displayPath)
const outcome: FileReadOutcome = {
offset: input.offset,
limit: input.limit,
lines: window.lines,
totalLines: window.totalLines,
version: info.version,
...window.truncatedByBytes ? { truncatedByBytes: true } : {},
}
emitObserved(ctx, target, info.version, exec)
return [{ type: 'text', text: formatReadOutput(target.displayPath, outcome) }]
},
}))
@@ -90,7 +124,7 @@ export function apply(ctx: Context): void {
export const name = 'fs-read'
/** Services required by the `read` tool plugin. */
export const inject = ['tools', 'fileContext', 'systemPrompt']
export const inject = ['tools', 'fs', 'systemPrompt']
/** Named helper for direct registration in the root plugin and tests. */
export const applyReadTool = apply

View File

@@ -0,0 +1,32 @@
/**
* Vocabulary for the model-facing filesystem tools (`@deepseek-ai/dsh-tool-fs`):
* the structured read outcome the `read` tool renders. The read window
* (`offset`/`limit`) and per-line shape live in
* {@link module:@deepseek-ai/dsh-tool-fs/window}; this file owns the assembled
* outcome the tool formats.
*
* The provider vocabulary (`FsTarget`, `FsVersion`, write/edit shapes) is
* re-used from `@deepseek-ai/dsh-fs` — this package owns only the model-facing
* read-rendering shape on top of it.
*
* @module @deepseek-ai/dsh-tool-fs/types
*/
import type { FsVersion } from '@deepseek-ai/dsh-fs'
import type { FileTextLine } from './window.ts'
/** 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-tool-fs`. Turning a file's
* decoded text into a bounded, line-numbered window (offset/limit, byte cap,
* per-line truncation) is the model-facing READ-RENDERING detail the tool owns
* now that the tool reads through `ctx.fs` directly — it is not a storage
* primitive and not freshness policy.
*
* 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-tool-fs/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

@@ -1,8 +1,12 @@
/**
* The model-facing `write` tool: create or fully replace a UTF-8 text file.
* 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).
* The model-facing `write` tool: create or fully replace a UTF-8 text file. The
* tool is the executor: it dispatches the `fs/write-expectation` waterfall to
* obtain the optional version guard, calls `ctx.fs.writeText` directly, and
* emits a contained `fs/observed`. The default thunk returns `undefined`
* (unconditional create-or-overwrite — the bare provider); a policy plugin
* (`@deepseek-ai/dsh-file-context`) occupies the single decision slot and
* returns `createIfAbsent`/`replaceIfVersion` instead. The tool stats ZERO
* times either way.
*
* @module @deepseek-ai/dsh-tool-fs/write
*/
@@ -11,7 +15,9 @@ import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { FsWriteOutcome } from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { emitObserved } from './observe.ts'
/** Validate value constraints the schema DSL can't express. */
export function parseWriteArgs(args: { file_path: string; content: string }): { filePath: string; content: string } {
@@ -34,7 +40,7 @@ export function apply(ctx: Context): void {
ctx.systemPrompt.section({
name: 'tool:write',
order: 101,
text: 'Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the backend requires it) and prefer edit for targeted changes.',
text: 'Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default file-context policy requires it) and prefer edit for targeted changes.',
})
ctx.tools.register(defineTool({
@@ -46,8 +52,12 @@ export function apply(ctx: Context): void {
},
async execute(args, exec): Promise<ContentBlock[]> {
const input = parseWriteArgs(args)
const target = await ctx.fileContext.resolve(input.filePath)
const outcome = await ctx.fileContext.write(target, input.content, exec, exec.signal)
const target = await ctx.fs.resolve(input.filePath)
// Single-slot decision: the policy plugin produces createIfAbsent/
// replaceIfVersion; the bare default is undefined (unconditional). No stat.
const expectation = await ctx.waterfall('fs/write-expectation', target, exec, () => undefined)
const outcome = await ctx.fs.writeText(target, input.content, expectation, exec.signal)
emitObserved(ctx, target, outcome.version, exec)
return [{ type: 'text', text: formatWriteOutput(target.displayPath, outcome) }]
},
}))
@@ -57,7 +67,7 @@ export function apply(ctx: Context): void {
export const name = 'fs-write'
/** Services required by the `write` tool plugin. */
export const inject = ['tools', 'fileContext', 'systemPrompt']
export const inject = ['tools', 'fs', 'systemPrompt']
/** Named helper for direct registration in the root plugin and tests. */
export const applyWriteTool = apply

View File

@@ -1,12 +1,20 @@
/**
* 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.
* Integration tests: the real local backend (`dsh-fs-local`) plus the model
* tools (`dsh-tool-fs`) as the executor, exercised through `ctx.tools.execute()`
* so nothing bypasses the tool registry. Two deployments:
*
* - DEFAULT — with the real `dsh-file-context` policy gate plugin: read-before-
* write/edit, version-guarded mutation, FS_NOT_OBSERVED for unread edits.
* - BARE — WITHOUT the policy plugin, loading only SUBPATH plugins: every
* `fs/*` waterfall falls through to its undefined default, so write/edit are
* unconditional. This proves the subpaths (not just the root) carry no policy
* dependency.
*
* 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'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -15,8 +23,11 @@ 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 FileContext from '@deepseek-ai/dsh-file-context'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
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'
let dir: string
let ctx: Context
@@ -24,20 +35,6 @@ let fiber: Awaited<ReturnType<Context['plugin']>>
// A stable session object stands in for an agent session (the file-state owner).
const session = {}
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-'))
ctx = new Context()
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 () => {
await fiber.dispose()
await rm(dir, { recursive: true, force: true })
})
let callCounter = 0
function call(name: string, args: unknown) {
return ctx.tools.execute({
@@ -52,137 +49,260 @@ function text(result: { content: { type: string; text?: string }[] }): string {
return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
}
describe('write → disk', () => {
it('creates a file with exactly the requested bytes', async () => {
const result = await call('write', { file_path: 'new.txt', content: 'line one\nline two\n' })
expect(result.isError).toBe(false)
expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('line one\nline two\n')
afterEach(async () => {
await fiber.dispose()
await rm(dir, { recursive: true, force: true })
})
// --------------------------------------------------------------------------
// DEFAULT deployment: the policy gate plugin is loaded.
// --------------------------------------------------------------------------
describe('default deployment (with dsh-file-context)', () => {
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-'))
ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(LocalFileSystem, { cwd: dir })
await ctx.plugin(FileContext)
fiber = await ctx.plugin(ToolFs)
})
it('rejects overwriting an existing file without reading it first', async () => {
await writeFile(join(dir, 'a.txt'), 'original')
const result = await call('write', { file_path: 'a.txt', content: 'clobber' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('original')
describe('write → disk', () => {
it('creates a file with exactly the requested bytes', async () => {
const result = await call('write', { file_path: 'new.txt', content: 'line one\nline two\n' })
expect(result.isError).toBe(false)
expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('line one\nline two\n')
})
it('rejects overwriting an existing file without reading it first', async () => {
await writeFile(join(dir, 'a.txt'), 'original')
const result = await call('write', { file_path: 'a.txt', content: 'clobber' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('original')
})
it('allows overwriting after a read', async () => {
await writeFile(join(dir, 'a.txt'), 'original')
expect((await call('read', { file_path: 'a.txt' })).isError).toBe(false)
const result = await call('write', { file_path: 'a.txt', content: 'replaced' })
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' })
})
})
it('allows overwriting after a read', async () => {
await writeFile(join(dir, 'a.txt'), 'original')
expect((await call('read', { file_path: 'a.txt' })).isError).toBe(false)
const result = await call('write', { file_path: 'a.txt', content: 'replaced' })
expect(result.isError).toBe(false)
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('replaced')
describe('read', () => {
it('returns line-numbered content', async () => {
await writeFile(join(dir, 'a.txt'), 'alpha\nbeta')
const result = await call('read', { file_path: 'a.txt' })
expect(text(result)).toContain('1: alpha')
expect(text(result)).toContain('2: beta')
expect(text(result)).toContain('(End of file - total 2 lines)')
})
it('reports a binary file as an error', async () => {
await writeFile(join(dir, 'bin'), Buffer.from([0x00, 0x01, 0x02]))
const result = await call('read', { file_path: 'bin' })
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.)')
})
})
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('edit → disk', () => {
it('applies a unique literal replacement after a read', async () => {
await writeFile(join(dir, 'a.txt'), 'hello world')
await call('read', { file_path: 'a.txt' })
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
expect(result.isError).toBe(false)
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there')
})
it('rejects an edit before any read, leaving the file untouched', async () => {
await writeFile(join(dir, 'a.txt'), 'hello 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_NOT_OBSERVED' })
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello world')
})
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_STALE_VERSION' })
})
it('rejects an ambiguous match without replace_all', async () => {
await writeFile(join(dir, 'a.txt'), 'a a a')
await call('read', { file_path: 'a.txt' })
const result = await call('edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_AMBIGUOUS_EDIT' })
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('a a a')
})
it('replaces all matches with replace_all', async () => {
await writeFile(join(dir, 'a.txt'), 'a a a')
await call('read', { file_path: 'a.txt' })
const result = await call('edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b', replace_all: true })
expect(result.isError).toBe(false)
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('b b b')
})
it('supports a full write→edit cycle without an intervening read', async () => {
await call('write', { file_path: 'a.txt', content: 'one two' })
const result = await call('edit', { file_path: 'a.txt', old_string: 'two', new_string: 'three' })
expect(result.isError).toBe(false)
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('one three')
})
})
describe('the gate records only through the events (no method coupling)', () => {
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 tool — 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 did not emit fs/observed.
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' })
})
})
describe('stat budget', () => {
it('read stats once; write and edit never stat in the tool (the gate stats zero too)', async () => {
await writeFile(join(dir, 'a.txt'), 'hello world')
const statSpy = vi.spyOn(ctx.fs, 'stat')
// read: exactly one stat (type + size routing + observed version).
await call('read', { file_path: 'a.txt' })
expect(statSpy).toHaveBeenCalledTimes(1)
// edit (guarded, after the read): the gate supplies vObserved; the tool
// does not stat to manufacture a basis. CAS happens in editText's lock.
statSpy.mockClear()
const edited = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
expect(edited.isError).toBe(false)
expect(statSpy).not.toHaveBeenCalled()
// write (guarded replace, after the edit refreshed observed state): zero stat.
statSpy.mockClear()
const written = await call('write', { file_path: 'a.txt', content: 'fresh' })
expect(written.isError).toBe(false)
expect(statSpy).not.toHaveBeenCalled()
statSpy.mockRestore()
})
})
describe('contained fs/observed recording', () => {
it('a synchronously throwing fs/observed listener does not fail the completed write', async () => {
ctx.on('fs/observed', () => { throw new Error('listener boom') })
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const result = await call('write', { file_path: 'a.txt', content: 'hi' })
// The write succeeded on disk; the listener throw was logged and swallowed.
expect(result.isError).toBe(false)
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hi')
expect(warn).toHaveBeenCalled()
warn.mockRestore()
})
})
})
describe('read', () => {
it('returns line-numbered content', async () => {
// --------------------------------------------------------------------------
// BARE deployment: SUBPATH plugins only, NO policy gate.
// --------------------------------------------------------------------------
describe('bare provider (subpath plugins, no dsh-file-context)', () => {
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-bare-'))
ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(LocalFileSystem, { cwd: dir })
await ctx.plugin(readPlugin)
await ctx.plugin(writePlugin)
fiber = await ctx.plugin(editPlugin)
})
it('read works (it never needed policy)', async () => {
await writeFile(join(dir, 'a.txt'), 'alpha\nbeta')
const result = await call('read', { file_path: 'a.txt' })
expect(result.isError).toBe(false)
expect(text(result)).toContain('1: alpha')
expect(text(result)).toContain('2: beta')
expect(text(result)).toContain('(End of file - total 2 lines)')
})
it('reports a binary file as an error', async () => {
await writeFile(join(dir, 'bin'), Buffer.from([0x00, 0x01, 0x02]))
const result = await call('read', { file_path: 'bin' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_NOT_TEXT' })
it('write unconditionally creates a new file', async () => {
const result = await call('write', { file_path: 'new.txt', content: 'fresh' })
expect(result.isError).toBe(false)
expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('fresh')
})
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.)')
it('write unconditionally OVERWRITES an existing unread file', async () => {
await writeFile(join(dir, 'a.txt'), 'original')
const result = await call('write', { file_path: 'a.txt', content: 'clobbered' })
expect(result.isError).toBe(false)
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('clobbered')
})
})
describe('edit → disk', () => {
it('applies a unique literal replacement after a read', async () => {
it('edit unconditionally edits an UNREAD existing file', async () => {
await writeFile(join(dir, 'a.txt'), 'hello world')
await call('read', { file_path: 'a.txt' })
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
expect(result.isError).toBe(false)
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there')
})
it('rejects an edit before any read, leaving the file untouched', async () => {
await writeFile(join(dir, 'a.txt'), 'hello 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_NOT_OBSERVED' })
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello world')
})
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' })
it('edit of a MISSING target reports FS_STALE_VERSION even on the unguarded path', async () => {
const result = await call('edit', { file_path: 'missing.txt', old_string: 'a', new_string: 'b' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' })
})
it('rejects an ambiguous match without replace_all', async () => {
await writeFile(join(dir, 'a.txt'), 'a a a')
await call('read', { file_path: 'a.txt' })
const result = await call('edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_AMBIGUOUS_EDIT' })
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('a a a')
})
it('replaces all matches with replace_all', async () => {
await writeFile(join(dir, 'a.txt'), 'a a a')
await call('read', { file_path: 'a.txt' })
const result = await call('edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b', replace_all: true })
expect(result.isError).toBe(false)
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('b b b')
})
it('supports a full write→edit cycle without an intervening read', async () => {
await call('write', { file_path: 'a.txt', content: 'one two' })
const result = await call('edit', { file_path: 'a.txt', old_string: 'two', new_string: 'three' })
expect(result.isError).toBe(false)
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 () => {
it('edit still enforces literal-match codes (FS_EDIT_NOT_FOUND), unrelated to freshness', 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' })
const result = await call('edit', { file_path: 'a.txt', old_string: 'absent', new_string: 'x' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
expect(result.error).toMatchObject({ code: 'FS_EDIT_NOT_FOUND' })
})
it('neither write nor edit stats in the tool on the bare path', async () => {
await writeFile(join(dir, 'a.txt'), 'hello world')
const statSpy = vi.spyOn(ctx.fs, 'stat')
expect((await call('write', { file_path: 'a.txt', content: 'x y' })).isError).toBe(false)
expect((await call('edit', { file_path: 'a.txt', old_string: 'y', new_string: 'z' })).isError).toBe(false)
expect(statSpy).not.toHaveBeenCalled()
statSpy.mockRestore()
})
})

View File

@@ -1,7 +1,10 @@
/**
* Tests for the per-tool subpath plugins (`@deepseek-ai/dsh-tool-fs/read`,
* `/write`, `/edit`): each registers exactly one tool, injects the same
* services (`tools`, `fileContext`, `systemPrompt`), and cleans up on disposal.
* `/write`, `/edit`): each registers exactly one tool, injects the same services
* (`tools`, `fs`, `systemPrompt`) — NOT a policy service — and cleans up on
* disposal. They boot over the bare `ctx.fs` provider with NO
* `@deepseek-ai/dsh-file-context`, proving each subpath carries no policy-plugin
* dependency.
*/
import { describe, expect, it } from 'vitest'
@@ -15,7 +18,6 @@ import type {
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'
@@ -46,12 +48,11 @@ async function base() {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(StubFs)
await ctx.plugin(FileContext)
return ctx
}
describe('subpath plugins', () => {
it('each registers exactly its one tool', async () => {
it('each registers exactly its one tool (over the bare provider, no policy plugin)', async () => {
const cases: Array<[unknown, string]> = [
[readPlugin, 'read'],
[writePlugin, 'write'],
@@ -72,7 +73,7 @@ describe('subpath plugins', () => {
expect(ctx.tools.schemas()).toHaveLength(0)
})
it('stays pending without a ctx.fileContext provider', async () => {
it('stays pending without a ctx.fs provider', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)

View File

@@ -1,13 +1,14 @@
/**
* 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.
* Consumer-surface tests for the filesystem tools as the EXECUTOR. They run the
* REAL `@deepseek-ai/dsh-file-context` gate plugin (the genuine policy
* collaborator, per the prefer-the-real-implementation rule) over a fake
* `ctx.fs` provider, so they verify schemas, argument validation, result
* formatting, FsError→isError propagation, and that each tool dispatches the
* `fs/*` waterfalls + records observed-state through the gate (read authorizes a
* later edit) — not just that it moved bytes.
*/
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
@@ -21,15 +22,17 @@ import type {
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 FileContext from '@deepseek-ai/dsh-file-context'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import { formatReadOutput } from '@deepseek-ai/dsh-tool-fs'
import { formatReadOutput, STREAM_MIN_SIZE } from '@deepseek-ai/dsh-tool-fs'
import type { FileReadOutcome } from '@deepseek-ai/dsh-tool-fs'
/** An in-memory fake provider; a test can arm a rejection on any primitive. */
class FakeFs extends FileSystem {
files = new Map<string, string>()
rejectWith?: FsError
writeExpectations: (FsWriteExpectation | undefined)[] = []
editExpectations: ({ version: FsVersion } | undefined)[] = []
private throwIfArmed(): void {
if (this.rejectWith) throw this.rejectWith
@@ -51,14 +54,16 @@ class FakeFs extends FileSystem {
const content = this.files.get(target.targetKey) ?? ''
return (async function* () { yield content })()
}
override async writeText(target: FsTarget, content: string, _expected: FsWriteExpectation): Promise<FsWriteOutcome> {
override async writeText(target: FsTarget, content: string, expected?: FsWriteExpectation): Promise<FsWriteOutcome> {
this.throwIfArmed()
this.writeExpectations.push(expected)
const existed = this.files.has(target.targetKey)
this.files.set(target.targetKey, content)
return { operation: existed ? 'update' : 'create', version: FsVersion('v2') }
}
override async editText(target: FsTarget, edit: FsEditRequest): Promise<FsEditOutcome> {
override async editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }): Promise<FsEditOutcome> {
this.throwIfArmed()
this.editExpectations.push(expected)
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') }
@@ -104,11 +109,11 @@ describe('registration', () => {
expect(prompt).toContain('Use the edit tool')
})
it('stays pending until ctx.fileContext exists (inject)', async () => {
it('stays pending until ctx.fs exists (inject)', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(ToolFs) // no fileContext provider
await ctx.plugin(ToolFs) // no fs provider
expect(ctx.tools.schemas()).toHaveLength(0)
})
@@ -169,6 +174,7 @@ describe('read tool', () => {
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)
expect(fs.editExpectations).toEqual([{ version: 'v1' }])
})
it('propagates FS_NOT_FOUND for an absent file', async () => {
@@ -177,6 +183,48 @@ describe('read tool', () => {
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_NOT_FOUND' })
})
it('rejects a non-regular target', async () => {
const { ctx, fs } = await setup()
fs.files.set('key:d', '')
fs.stat = async () => ({ version: FsVersion('v1'), type: 'directory' })
const result = await call(ctx, 'read', { file_path: 'd' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
})
it('streams a large file (size at/above the cap) instead of reading whole', async () => {
const { ctx, fs } = await setup()
fs.files.set('key:big.txt', 'alpha\nbeta')
const readSpy = vi.spyOn(fs, 'readText')
const streamSpy = vi.spyOn(fs, 'streamText')
fs.stat = async () => ({ version: FsVersion('v1'), type: 'file', size: STREAM_MIN_SIZE })
const result = await call(ctx, 'read', { file_path: 'big.txt' })
expect(result.isError).toBe(false)
expect(text(result)).toContain('1: alpha')
expect(streamSpy).toHaveBeenCalled()
expect(readSpy).not.toHaveBeenCalled()
})
it('streams when the backend reports no size (never buffers a size-less file)', async () => {
const { ctx, fs } = await setup()
fs.files.set('key:a.txt', 'alpha')
const streamSpy = vi.spyOn(fs, 'streamText')
fs.stat = async () => ({ version: FsVersion('v1'), type: 'file' }) // no size
const result = await call(ctx, 'read', { file_path: 'a.txt' })
expect(result.isError).toBe(false)
expect(streamSpy).toHaveBeenCalled()
})
it('surfaces a byte-capped read as a truncated footer', async () => {
const { ctx, fs } = await setup()
// Many long lines so the window hits the byte cap before EOF.
fs.files.set('key:big.txt', Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n'))
const result = await call(ctx, 'read', { file_path: 'big.txt' })
expect(result.isError).toBe(false)
expect(text(result)).toContain('Output capped.')
})
})
describe('formatReadOutput footer variants', () => {
@@ -204,11 +252,12 @@ describe('formatReadOutput footer variants', () => {
})
describe('write tool', () => {
it('formats a create result', async () => {
const { ctx } = await setup()
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' })
it('formats a create result and uses createIfAbsent (unobserved, with the gate)', async () => {
const { ctx, fs } = await setup()
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' }, { session: {} })
expect(result.isError).toBe(false)
expect(text(result)).toContain('Created file')
expect(fs.writeExpectations).toEqual([{ kind: 'createIfAbsent' }])
})
it('rejects a blank file_path', async () => {
@@ -258,7 +307,7 @@ describe('edit tool', () => {
expect(text(result)).toContain('file_path must be a non-empty string')
})
it('propagates FS_NOT_OBSERVED when the file was never read', async () => {
it('propagates FS_NOT_OBSERVED when the file was never read (the gate decides)', async () => {
const { ctx, fs } = await setup()
fs.files.set('key:a.txt', 'hello')
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }, { session: {} })

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-tool-fs'
import type { ReadWindow } from '@deepseek-ai/dsh-tool-fs'
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'])
})
})
})