fix(fs): address review — rename to dsh-fs-policy, fs/*-intent events, RFC currency, ENOTDIR

Rename per review naming decisions:
- package dsh-file-context → dsh-fs-policy (dir, package name, plugin name,
  tsconfig refs, importers, type-equiv manifest, generated catalog + module-graph)
- events fs/write-expectation → fs/write-intent, fs/edit-expectation → fs/edit-intent
  (fs/observed unchanged); type FsWriteExpectation → FsWriteIntent, "expectation"
  wording → "intent" throughout
- exported FileContextExec → FsPolicyExec

Make the implemented RFCs describe what shipped, not the superseded designs:
the 2026-06-17 capability-seam + tool-schemas RFCs no longer place policy on
ctx.fs or use full/partial-view authorization, and the fsspec RFC's ctx.fileContext
service prose is rewritten to the fs/* event-gate reality (freshness-based auth).
Sharpen docs/rfc/implemented/AGENTS.md: a rename is a fact to fix IN PLACE — the
"new RFC" escape hatch is for macro decision reversals only, not renames.

Code fixes from review:
- fsio.ts resolveLocalTarget/probe translate ENOTDIR (a parent path segment is a
  file) into the structured FsError taxonomy instead of leaking a raw Node error;
  resolve reports FS_NOT_FOUND, probe reports absent. Regression tests proven to
  fail on the unfixed code.
- tool-fs HMR test now asserts prompt sections (not just tool schemas) are
  withdrawn on disposal.
- fs/observed is a plain (unguarded) ctx.emit: correct the fs-policy comment,
  filesystem.md, and tool-fs module doc that wrongly claimed the tool "contains"
  a throwing listener; a throw surfaces as the tool's isError result.
- drop the false "loaded by the default product config" claim (no config wires
  the fs tools yet), the duplicate ctx.bash service-map row, the stale
  FileReadRequest catalog link-map entry, and the fs/fs README EOF blank line;
  correct the dsh-fs package.json description.
This commit is contained in:
Tianyi Cui
2026-07-02 03:12:38 +08:00
parent e4b4eaaf38
commit 30c1863755
42 changed files with 360 additions and 323 deletions

View File

@@ -38,7 +38,7 @@ dsh-bash-local ← dsh-bash (BashExecutor impl)
dsh-tool-bash ← dsh-bash, dsh-tools (bash tool schemas)
dsh-fs ← dsh-llm, dsh-brand (filesystem provider seam + fs/* events)
dsh-fs-local ← dsh-fs (FileSystem impl)
dsh-file-context ← dsh-fs (observed-state + freshness policy gate, no service)
dsh-fs-policy ← dsh-fs (observed-state + freshness policy gate, no service)
dsh-tool-fs ← dsh-fs, dsh-tools (file tools + executor)
dsh-llm-deepseek ← dsh-llm (DeepSeek adapter)
dsh-llm-pi-ai ← dsh-llm (pi-ai-backed adapter)
@@ -78,7 +78,7 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop
| `tool-bash/` | `bash` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) |
| `fs/` | `fs` | Filesystem provider seam: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` events | `ctx.fs` |
| `fs-local/` | `fs` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) |
| `file-context/` | `fs` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit via the `fs/*` event gate | (no service — `fs/*` listeners) |
| `fs-policy/` | `fs` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit via the `fs/*` event gate | (no service — `fs/*` listeners) |
| `tool-fs/` | `fs` | Model-facing `read`/`write`/`edit` tools + executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`) | (registers on `ctx.tools`) |
| `compact/` | `compact` | Abstract compaction seam + `compact/*` events + `CompactionResult` | `ctx.compact` |
| `compact-basic/` | `compact` | A backend: char/4 estimation + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) |

View File

@@ -6,7 +6,7 @@ The filesystem stack: a provider seam (text IO + atomic mutation with an optiona
|---|---|---|
| `fs/` | Provider seam: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` policy events | `ctx.fs` |
| `fs-local/` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) |
| `file-context/` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit, via the `fs/*` event gate | (no service — `fs/*` listeners) |
| `fs-policy/` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit, via the `fs/*` event gate | (no service — `fs/*` listeners) |
| `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`) | (registers on `ctx.tools`) |
The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas. The policy (`file-context/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it.
The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas. The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it.

View File

@@ -6,7 +6,7 @@ The **local-filesystem implementation** of the `ctx.fs` provider seam ([`@deepse
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
// ctx.fs is now the local backend; load @deepseek-ai/dsh-file-context for the
// ctx.fs is now the local backend; load @deepseek-ai/dsh-fs-policy for the
// freshness policy gate and @deepseek-ai/dsh-tool-fs to expose read/write/edit.
```

View File

@@ -5,7 +5,7 @@
*
* This is the PROVIDER layer: it hands back decoded whole-file text (validated
* UTF-8, binary rejected) — never line windows or numbered lines, which are
* model-facing read policy owned by `@deepseek-ai/dsh-file-context`. Large files
* model-facing read policy owned by `@deepseek-ai/dsh-fs-policy`. Large files
* stream their text in chunks so a huge file never has to be held whole in
* memory; the binary/NUL sample and cross-chunk UTF-8 decoding stay here.
*
@@ -35,6 +35,16 @@ function isENOENT(error: unknown): boolean {
return error instanceof Error && 'code' in error && error.code === 'ENOENT'
}
/**
* A path component that is expected to be a directory is a regular file (e.g.
* resolving `afile/child.txt` when `afile` is a file). Like `ENOENT`, the target
* cannot exist — so the resolution/probe paths treat it as "absent" rather than
* letting a raw Node error escape without the structured `FsError` taxonomy.
*/
function isENOTDIR(error: unknown): boolean {
return error instanceof Error && 'code' in error && error.code === 'ENOTDIR'
}
function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError'
}
@@ -119,6 +129,10 @@ export async function resolveLocalTarget(cwd: string, path: string): Promise<Loc
// Prefer the file's own realpath (resolves a symlinked file to its target).
return { displayPath, targetKey: FsTargetKey(await realpath(displayPath)) }
} catch (error: unknown) {
// A path component is a file, not a directory (e.g. "afile/child.txt" where
// "afile" is a regular file): the target can neither exist nor be created,
// so surface the structured taxonomy instead of a raw Node ENOTDIR.
if (isENOTDIR(error)) throw new FsError(`cannot resolve "${displayPath}": a parent path segment is not a directory`, 'FS_NOT_FOUND')
/* v8 ignore next -- non-ENOENT realpath failure needs a permission/IO fault; ENOENT falls through to ancestor resolution. */
if (!isENOENT(error)) throw error
}
@@ -150,8 +164,10 @@ export async function probe(absolutePath: string): Promise<PathInfo | null> {
const type = info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other'
return { version: versionOf(info), mode: info.mode & 0o777, type, size: info.size }
} catch (error: unknown) {
/* v8 ignore next 2 -- a non-ENOENT stat failure needs a permission/IO fault; surface it. */
if (!isENOENT(error)) throw error
// ENOENT (no such file) and ENOTDIR (a parent segment is a file) both mean
// the target is absent; any other stat failure is a real permission/IO fault.
/* v8 ignore next -- a non-ENOENT/ENOTDIR stat failure needs a permission/IO fault; surface it. */
if (!isENOENT(error) && !isENOTDIR(error)) throw error
return null
}
}

View File

@@ -21,7 +21,7 @@ import type {
FsEditRequest,
FsInfo,
FsTarget,
FsWriteExpectation,
FsWriteIntent,
FsWriteOutcome,
} from '@deepseek-ai/dsh-fs'
import {
@@ -120,7 +120,7 @@ export class LocalFileSystem extends FileSystem {
override async writeText(
target: FsTarget,
content: string,
expected?: FsWriteExpectation,
expected?: FsWriteIntent,
signal?: AbortSignal,
): Promise<FsWriteOutcome> {
return this.withLock(target.targetKey, async () => {

View File

@@ -3,7 +3,7 @@
* file/streamed text reads, atomic guarded writes (createIfAbsent /
* replaceIfVersion), version-guarded literal edits, concurrency races, symlink
* identity, and HMR/disposal. Read WINDOWING is policy and lives in
* `dsh-file-context`, so it is not exercised here.
* `dsh-fs-policy`, so it is not exercised here.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'

View File

@@ -2,7 +2,7 @@
* Cordis-free tests for the raw local-filesystem I/O: path resolution, probe,
* whole-file/streamed text reads, binary/UTF-8 rejection, atomic-write temp
* safety, literal edit matching, and line-ending handling. Line WINDOWING is
* policy and lives in `dsh-file-context`, so it is not tested here.
* policy and lives in `dsh-fs-policy`, so it is not tested here.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
@@ -21,7 +21,7 @@ import {
writeFileAtomic,
} from '@deepseek-ai/dsh-fs-local'
import type { LocalTarget } from '@deepseek-ai/dsh-fs-local'
import { FsTargetKey } from '@deepseek-ai/dsh-fs'
import { FsError, FsTargetKey } from '@deepseek-ai/dsh-fs'
let dir: string
beforeEach(async () => {
@@ -88,6 +88,16 @@ describe('resolveLocalTarget', () => {
it('rejects a blank path', async () => {
await expect(resolveLocalTarget(dir, ' ')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
})
it('rejects a path whose ancestor is a file with a structured FsError (ENOTDIR)', async () => {
// "afile" is a regular file, so "afile/child.txt" hits ENOTDIR on realpath;
// the raw Node error must be translated into the FsError taxonomy so the tool
// result keeps its { name, code } metadata.
await writeFile(join(dir, 'afile'), 'i am a file')
const err = await resolveLocalTarget(dir, 'afile/child.txt').then(() => undefined, (e: unknown) => e)
expect(err).toBeInstanceOf(FsError)
expect(err).toMatchObject({ code: 'FS_NOT_FOUND' })
})
})
describe('probe', () => {
@@ -128,6 +138,11 @@ describe('probe', () => {
await new Promise<void>((resolve) => { server.close(() => { resolve() }) })
}
})
it('returns null when an ancestor path segment is a file (ENOTDIR), not a raw throw', async () => {
await writeFile(join(dir, 'afile'), 'i am a file')
expect(await probe(join(dir, 'afile', 'child.txt'))).toBeNull()
})
})
describe('readWholeText', () => {

View File

@@ -1,10 +1,10 @@
# @deepseek-ai/dsh-file-context
# @deepseek-ai/dsh-fs-policy
The **file-context policy plugin**: it adds observed-state, read-before-edit, and version-guarded write/edit on top of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)) — through the `fs/*` event gate, **NOT** through a method service. This plugin registers **no** `ctx.fileContext` service and has no public `read`/`write`/`edit`/`resolve` methods. It is the policy third of the filesystem stack: not a swappable seam, but the policy that does not belong on the `FileSystem` provider base class.
The **fs-policy plugin**: it adds observed-state, read-before-edit, and version-guarded write/edit on top of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)) — through the `fs/*` event gate, **NOT** through a method service. This plugin registers **no** `ctx.fsPolicy` service and has no public `read`/`write`/`edit`/`resolve` methods. It is the policy third of the filesystem stack: not a swappable seam, but the policy that does not belong on the `FileSystem` provider base class.
```ts
import type { Context } from 'cordis'
import * as FileContext from '@deepseek-ai/dsh-file-context'
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
declare const ctx: Context
@@ -12,8 +12,8 @@ declare const ctx: Context
// Load it alongside a ctx.fs provider (e.g. @deepseek-ai/dsh-fs-local) and the
// @deepseek-ai/dsh-tool-fs tools; the tools dispatch the fs/* events this plugin
// decides. Order does not matter for resolution (no inject), but the policy
// listener should be the first decider registered for the fs/*-expectation slots.
await ctx.plugin(FileContext)
// listener should be the first decider registered for the fs/*-intent slots.
await ctx.plugin(FsPolicy)
```
## The four-layer split
@@ -21,7 +21,7 @@ await ctx.plugin(FileContext)
| Layer | Package | Role |
|---|---|---|
| tool / executor | `@deepseek-ai/dsh-tool-fs` | model-facing schemas + read windowing + text rendering; reads/writes/edits via `ctx.fs`, dispatches the `fs/*` events |
| policy | `@deepseek-ai/dsh-file-context` (this) | observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate (no service) |
| policy | `@deepseek-ai/dsh-fs-policy` (this) | observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate (no service) |
| provider seam | `@deepseek-ai/dsh-fs` | `ctx.fs`: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` event vocabulary |
| provider | `@deepseek-ai/dsh-fs-local` | local implementation of `ctx.fs` |
@@ -31,8 +31,8 @@ Three `fs/*` events (declared by `@deepseek-ai/dsh-fs`, dispatched by `@deepseek
| Event | This plugin's listener |
|---|---|
| `fs/write-expectation` | No prior observation → `{ kind: 'createIfAbsent' }`; a prior observation → `{ kind: 'replaceIfVersion', version: vObserved }`. Single-slot decision; does NOT call `next()`. |
| `fs/edit-expectation` | Requires a prior observation by this owner (else throws `FS_NOT_OBSERVED`); returns `{ version: vObserved }` as the CAS basis. Single-slot decision; does NOT call `next()`. |
| `fs/write-intent` | No prior observation → `{ kind: 'createIfAbsent' }`; a prior observation → `{ kind: 'replaceIfVersion', version: vObserved }`. Single-slot decision; does NOT call `next()`. |
| `fs/edit-intent` | Requires a prior observation by this owner (else throws `FS_NOT_OBSERVED`); returns `{ version: vObserved }` as the CAS basis. Single-slot decision; does NOT call `next()`. |
| `fs/observed` | Records `{ version }` for this owner+target. Synchronous, side-effect-only `WeakMap.set`. |
## Observed state is the prior-observation record; freshness is provider CAS
@@ -41,7 +41,7 @@ Observed state is a `WeakMap<owner, Map<targetKey, FsVersion>>`. An entry exists
## Single-slot, first-wins
The `fs/write-expectation`/`fs/edit-expectation` slots hold exactly one decider — this plugin fully decides and does not call `next()`. The slot is first-wins by registration order; this plugin owning it is the default-deployment convention, not an event-enforced invariant (a decider registered before / `prepend`ed would win instead). This is not a composable authorization chain — layered permission/audit/sandbox interception belongs on `tools/execute`.
The `fs/write-intent`/`fs/edit-intent` slots hold exactly one decider — this plugin fully decides and does not call `next()`. The slot is first-wins by registration order; this plugin owning it is the default-deployment convention, not an event-enforced invariant (a decider registered before / `prepend`ed would win instead). This is not a composable authorization chain — layered permission/audit/sandbox interception belongs on `tools/execute`.
## No method coupling

View File

@@ -1,5 +1,5 @@
{
"name": "@deepseek-ai/dsh-file-context",
"name": "@deepseek-ai/dsh-fs-policy",
"description": "File-context policy plugin for the DeepSeek Harness — observed-state, read-before-edit, and version-guarded write/edit added over the ctx.fs provider seam through the fs/* event gate (no service surface)",
"version": "0.0.1",
"private": true,

View File

@@ -1,10 +1,10 @@
/**
* The file-context policy PLUGIN: observed-state, read-before-edit, and
* The fs-policy PLUGIN: observed-state, read-before-edit, and
* "write/edit must be based on the version you read" added on top of the
* `ctx.fs` provider seam through the `fs/*` event gate, NOT through a method
* service. This plugin registers NO `ctx.fileContext` service and exposes no
* service. This plugin registers NO `ctx.fsPolicy` service and exposes no
* `read`/`write`/`edit`/`resolve` methods; it influences the world only by
* deciding the `fs/write-expectation`/`fs/edit-expectation` waterfalls and
* deciding the `fs/write-intent`/`fs/edit-intent` waterfalls and
* recording on `fs/observed`. That is what keeps `@deepseek-ai/dsh-tool-fs`
* (the executor) free of any method coupling to the policy layer removing
* this plugin gracefully loses the policy and leaves the unconstrained bare
@@ -33,22 +33,22 @@
*
* ## Single-slot, first-wins
*
* The `fs/write-expectation`/`fs/edit-expectation` listeners do NOT call
* The `fs/write-intent`/`fs/edit-intent` listeners do NOT call
* `next()`: each fully decides its single slot. The slot is first-wins by
* registration order this plugin owning it is the default-deployment
* convention, not an event-enforced invariant (a decider registered before /
* `prepend`ed would win instead). This is not a composable authorization chain;
* layered permission/audit/sandbox interception belongs on `tools/execute`.
*
* @module @deepseek-ai/dsh-file-context
* @module @deepseek-ai/dsh-fs-policy
*/
import type { Context } from 'cordis'
import { FsError } from '@deepseek-ai/dsh-fs'
import type { FsTarget, FsVersion, FsWriteExpectation } from '@deepseek-ai/dsh-fs'
import type { FileContextExec } from './types.ts'
import type { FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs'
import type { FsPolicyExec } from './types.ts'
export type { FileContextExec } from './types.ts'
export type { FsPolicyExec } from './types.ts'
/**
* Per-context observed-file state and the three `fs/*` decisions over it. One
@@ -69,7 +69,7 @@ class ObservedStateGate {
* the write/edit prior-observation policy.
*/
private owner(actor: object | undefined): object | undefined {
return (actor as FileContextExec | undefined)?.agent?.session
return (actor as FsPolicyExec | undefined)?.agent?.session
}
private get(owner: object, targetKey: string): FsVersion | undefined {
@@ -91,11 +91,11 @@ class ObservedStateGate {
}
/**
* Decide the write expectation: no prior observation `createIfAbsent` (only
* Decide the write intent: no prior observation `createIfAbsent` (only
* new files can be created blindly); a prior observation `replaceIfVersion`
* at the observed version (existing files replaced only if unchanged).
*/
writeExpectation(target: FsTarget, actor: object | undefined): FsWriteExpectation {
writeIntent(target: FsTarget, actor: object | undefined): FsWriteIntent {
const owner = this.owner(actor)
const prior = owner ? this.get(owner, target.targetKey) : undefined
return prior ? { kind: 'replaceIfVersion', version: prior } : { kind: 'createIfAbsent' }
@@ -105,7 +105,7 @@ class ObservedStateGate {
* Decide the edit version guard: requires a prior observation by this owner
* (else `FS_NOT_OBSERVED`); returns the observed version as the CAS basis.
*/
editExpectation(target: FsTarget, actor: object | undefined): { version: FsVersion } {
editIntent(target: FsTarget, actor: object | undefined): { version: FsVersion } {
const owner = this.owner(actor)
const prior = owner ? this.get(owner, target.targetKey) : undefined
if (!owner || !prior) {
@@ -122,7 +122,7 @@ class ObservedStateGate {
}
/** Cordis plugin name used by loader diagnostics. */
export const name = 'file-context'
export const name = 'fs-policy'
/**
* Register the three `fs/*` listeners. No `inject` this plugin reads no
@@ -138,21 +138,22 @@ export function apply(ctx: Context): void {
// (HMR safety). The WeakMap itself would be GC'd, but replacing it makes the
// release observable and immediate for tests.
gate.clear()
}, 'file-context observed-state teardown')
}, 'fs-policy observed-state teardown')
// fs/write-expectation: occupy the single decision slot — do NOT call next().
// fs/write-intent: occupy the single decision slot — do NOT call next().
// Deferred through Promise.resolve().then so the declared Promise return type
// holds (a throw rejects, never escapes synchronously through the waterfall).
ctx.on('fs/write-expectation', (target, actor) => Promise.resolve().then(() => gate.writeExpectation(target, actor)))
ctx.on('fs/write-intent', (target, actor) => Promise.resolve().then(() => gate.writeIntent(target, actor)))
// fs/edit-expectation: occupy the single decision slot — do NOT call next().
// fs/edit-intent: occupy the single decision slot — do NOT call next().
// Deferred the same way so an FS_NOT_OBSERVED throw becomes a rejected promise
// the edit tool's `await ctx.waterfall(...)` surfaces as its isError result.
ctx.on('fs/edit-expectation', (target, actor) => Promise.resolve().then(() => gate.editExpectation(target, actor)))
ctx.on('fs/edit-intent', (target, actor) => Promise.resolve().then(() => gate.editIntent(target, actor)))
// fs/observed: synchronous, side-effect-only WeakMap write (cannot throw under
// normal operation); the tool contains any throw so a record bug never fails
// the already-completed mutation.
// fs/observed: synchronous, side-effect-only WeakMap write. The tool emits
// this with a plain (unguarded) ctx.emit, so this listener MUST NOT throw —
// a throw would surface as the tool's isError result for a mutation that
// already succeeded. A WeakMap.set honors that contract.
ctx.on('fs/observed', (target, version, actor) => {
gate.observe(target, version, actor)
})

View File

@@ -1,5 +1,5 @@
/**
* Vocabulary for the file-context policy plugin: the minimal execution-context
* Vocabulary for the fs-policy plugin: the minimal execution-context
* shape used to derive an observed-state owner by narrowing the opaque `object`
* actor the `fs/*` events carry.
*
@@ -7,7 +7,7 @@
* re-used from `@deepseek-ai/dsh-fs`; this package owns only the observed-state
* owner structure on top of it.
*
* @module @deepseek-ai/dsh-file-context/types
* @module @deepseek-ai/dsh-fs-policy/types
*/
/**
@@ -20,7 +20,7 @@
* The owner is `agent.session` when present. It is treated as an opaque object
* identity (a `WeakMap` key); this package never reads any of its fields.
*/
export interface FileContextExec {
export interface FsPolicyExec {
/** The agent on whose behalf the call runs, when there is one. */
agent?: {
/** The session that owns observed-file state, used as an opaque key. */

View File

@@ -1,5 +1,5 @@
/**
* Tests for the file-context policy PLUGIN: it registers no service, only the
* Tests for the fs-policy PLUGIN: it registers no service, only the
* three `fs/*` listeners. We dispatch those events directly (the unbound
* waterfalls the tool would dispatch, and the `fs/observed` emit) and assert the
* decisions: createIfAbsent vs replaceIfVersion, FS_NOT_OBSERVED for an unread
@@ -7,87 +7,87 @@
* multi-owner isolation, single-slot first-wins, and disposal/HMR release.
*
* No `ctx.fs` provider is needed the plugin does no filesystem I/O; it only
* decides expectations and records versions on its own WeakMap.
* decides intents and records versions on its own WeakMap.
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
import type { FsTarget, FsWriteExpectation } from '@deepseek-ai/dsh-fs'
import * as FileContext from '@deepseek-ai/dsh-file-context'
import type { FileContextExec } from '@deepseek-ai/dsh-file-context'
import type { FsTarget, FsWriteIntent } from '@deepseek-ai/dsh-fs'
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
import type { FsPolicyExec } from '@deepseek-ai/dsh-fs-policy'
function target(path: string): FsTarget {
return { inputPath: path, targetKey: FsTargetKey(path), displayPath: path }
}
const ownerExec = (session: object): FileContextExec => ({ agent: { session } })
const ownerExec = (session: object): FsPolicyExec => ({ agent: { session } })
/** Dispatch the write-expectation waterfall with the bare default thunk. */
function writeExpectation(ctx: Context, t: FsTarget, actor: object | undefined): Promise<FsWriteExpectation | undefined> {
return ctx.waterfall('fs/write-expectation', t, actor, () => undefined)
/** Dispatch the write-intent waterfall with the bare default thunk. */
function writeIntent(ctx: Context, t: FsTarget, actor: object | undefined): Promise<FsWriteIntent | undefined> {
return ctx.waterfall('fs/write-intent', t, actor, () => undefined)
}
/** Dispatch the edit-expectation waterfall with the bare default thunk. */
function editExpectation(ctx: Context, t: FsTarget, actor: object | undefined): Promise<{ version: FsVersion } | undefined> {
return ctx.waterfall('fs/edit-expectation', t, actor, () => undefined)
/** Dispatch the edit-intent waterfall with the bare default thunk. */
function editIntent(ctx: Context, t: FsTarget, actor: object | undefined): Promise<{ version: FsVersion } | undefined> {
return ctx.waterfall('fs/edit-intent', t, actor, () => undefined)
}
async function setup() {
const ctx = new Context()
const fiber = await ctx.plugin(FileContext)
const fiber = await ctx.plugin(FsPolicy)
return { ctx, fiber }
}
describe('registration / disposal', () => {
it('registers no service surface (it is a plugin, not ctx.fileContext)', async () => {
it('registers no service surface (it is a plugin, not ctx.fsPolicy)', async () => {
const { ctx } = await setup()
expect((ctx as Context & { fileContext?: unknown }).fileContext).toBeUndefined()
expect((ctx as Context & { fsPolicy?: unknown }).fsPolicy).toBeUndefined()
})
it('mounts with no inject (reads no services)', async () => {
// It mounts immediately even with nothing else in the context.
const ctx = new Context()
await ctx.plugin(FileContext)
await ctx.plugin(FsPolicy)
// The listener is live: an unobserved write decides createIfAbsent.
expect(await writeExpectation(ctx, target('a.txt'), undefined)).toEqual({ kind: 'createIfAbsent' })
expect(await writeIntent(ctx, target('a.txt'), undefined)).toEqual({ kind: 'createIfAbsent' })
})
})
describe('write-expectation decision', () => {
describe('write-intent decision', () => {
it('an unobserved target decides createIfAbsent', async () => {
const { ctx } = await setup()
expect(await writeExpectation(ctx, target('a.txt'), ownerExec({}))).toEqual({ kind: 'createIfAbsent' })
expect(await writeIntent(ctx, target('a.txt'), ownerExec({}))).toEqual({ kind: 'createIfAbsent' })
})
it('a no-owner actor decides createIfAbsent', async () => {
const { ctx } = await setup()
expect(await writeExpectation(ctx, target('a.txt'), undefined)).toEqual({ kind: 'createIfAbsent' })
expect(await writeExpectation(ctx, target('a.txt'), {})).toEqual({ kind: 'createIfAbsent' })
expect(await writeIntent(ctx, target('a.txt'), undefined)).toEqual({ kind: 'createIfAbsent' })
expect(await writeIntent(ctx, target('a.txt'), {})).toEqual({ kind: 'createIfAbsent' })
})
it('an observed target decides replaceIfVersion at the observed version', async () => {
const { ctx } = await setup()
const exec = ownerExec({})
ctx.emit('fs/observed', target('a.txt'), FsVersion('v7'), exec)
expect(await writeExpectation(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v7' })
expect(await writeIntent(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v7' })
})
})
describe('edit-expectation decision', () => {
describe('edit-intent decision', () => {
it('rejects an unread edit with FS_NOT_OBSERVED', async () => {
const { ctx } = await setup()
await expect(editExpectation(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
await expect(editIntent(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
})
it('rejects an edit with no owner (cannot prove prior observation)', async () => {
const { ctx } = await setup()
await expect(editExpectation(ctx, target('a.txt'), undefined)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
await expect(editIntent(ctx, target('a.txt'), undefined)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
})
it('returns the observed version as the CAS basis after an observation', async () => {
const { ctx } = await setup()
const exec = ownerExec({})
ctx.emit('fs/observed', target('a.txt'), FsVersion('v3'), exec)
expect(await editExpectation(ctx, target('a.txt'), exec)).toEqual({ version: 'v3' })
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v3' })
})
})
@@ -96,7 +96,7 @@ describe('observed-state is the prior-observation record', () => {
const { ctx } = await setup()
const exec = ownerExec({})
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec) // a read
expect(await writeExpectation(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v0' })
expect(await writeIntent(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v0' })
})
it('a write/edit observation refreshes the basis, so the next edit needs no re-read', async () => {
@@ -104,17 +104,17 @@ describe('observed-state is the prior-observation record', () => {
const exec = ownerExec({})
// A create records v1; the follow-up edit guards against v1 with no read.
ctx.emit('fs/observed', target('a.txt'), FsVersion('v1'), exec)
expect(await editExpectation(ctx, target('a.txt'), exec)).toEqual({ version: 'v1' })
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v1' })
// The edit records v2; a second edit guards against v2.
ctx.emit('fs/observed', target('a.txt'), FsVersion('v2'), exec)
expect(await editExpectation(ctx, target('a.txt'), exec)).toEqual({ version: 'v2' })
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v2' })
})
it('a no-owner observation records nothing', async () => {
const { ctx } = await setup()
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), undefined)
// Still unobserved for any owner.
await expect(editExpectation(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
await expect(editIntent(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
})
})
@@ -124,8 +124,8 @@ describe('multi-owner isolation', () => {
const a = ownerExec({})
const b = ownerExec({})
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), a)
await expect(editExpectation(ctx, target('a.txt'), b)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
expect(await editExpectation(ctx, target('a.txt'), a)).toEqual({ version: 'v0' })
await expect(editIntent(ctx, target('a.txt'), b)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
expect(await editIntent(ctx, target('a.txt'), a)).toEqual({ version: 'v0' })
})
it('each owner records its own observed version independently', async () => {
@@ -134,8 +134,8 @@ describe('multi-owner isolation', () => {
const b = ownerExec({})
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), a) // A observed v0
// B never observed → createIfAbsent; A still holds v0 → replaceIfVersion.
expect(await writeExpectation(ctx, target('a.txt'), b)).toEqual({ kind: 'createIfAbsent' })
expect(await writeExpectation(ctx, target('a.txt'), a)).toEqual({ kind: 'replaceIfVersion', version: 'v0' })
expect(await writeIntent(ctx, target('a.txt'), b)).toEqual({ kind: 'createIfAbsent' })
expect(await writeIntent(ctx, target('a.txt'), a)).toEqual({ kind: 'replaceIfVersion', version: 'v0' })
})
})
@@ -143,27 +143,27 @@ describe('single-slot, first-wins', () => {
it('fully decides the slot without calling next() (the bare default is unreached)', async () => {
const { ctx } = await setup()
let defaultRan = false
const expectation = await ctx.waterfall('fs/write-expectation', target('a.txt'), ownerExec({}), () => {
const intent = await ctx.waterfall('fs/write-intent', target('a.txt'), ownerExec({}), () => {
defaultRan = true
return undefined
})
expect(expectation).toEqual({ kind: 'createIfAbsent' })
expect(intent).toEqual({ kind: 'createIfAbsent' })
expect(defaultRan).toBe(false)
})
it('a SECOND decider registered AFTER file-context is not reached (first-wins short-circuit)', async () => {
it('a SECOND decider registered AFTER fs-policy is not reached (first-wins short-circuit)', async () => {
const { ctx } = await setup()
let secondRan = false
// Registered after file-context, so it dispatches second; file-context does
// Registered after fs-policy, so it dispatches second; fs-policy does
// not call next(), so this never runs. (A decider registered BEFORE — or with
// prepend — would instead win: first-wins is by convention, not enforced.)
ctx.on('fs/edit-expectation', () => {
ctx.on('fs/edit-intent', () => {
secondRan = true
return Promise.resolve(undefined)
})
const exec = ownerExec({})
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec)
await editExpectation(ctx, target('a.txt'), exec)
await editIntent(ctx, target('a.txt'), exec)
expect(secondRan).toBe(false)
})
})
@@ -172,21 +172,21 @@ describe('disposal releases recorded state (HMR safety)', () => {
it('a fresh plugin after disposal starts with no inherited state', async () => {
const ctx = new Context()
const exec = ownerExec({})
const fiber = await ctx.plugin(FileContext)
const fiber = await ctx.plugin(FsPolicy)
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec)
expect(await editExpectation(ctx, target('a.txt'), exec)).toEqual({ version: 'v0' })
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v0' })
await fiber.dispose()
await ctx.plugin(FileContext)
await ctx.plugin(FsPolicy)
// Same owner object, but state was released on disposal.
await expect(editExpectation(ctx, target('a.txt'), exec)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
await expect(editIntent(ctx, target('a.txt'), exec)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
})
it('no listeners remain after disposal (the gate no longer decides)', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(FileContext)
const fiber = await ctx.plugin(FsPolicy)
await fiber.dispose()
// With no listener, the waterfall falls through to the bare default.
expect(await writeExpectation(ctx, target('a.txt'), ownerExec({}))).toBeUndefined()
expect(await writeIntent(ctx, target('a.txt'), ownerExec({}))).toBeUndefined()
})
})

View File

@@ -7,7 +7,7 @@ This package is the provider-seam layer of the four-layer filesystem stack, spli
| Layer | Package | Role |
|---|---|---|
| tool / executor | `@deepseek-ai/dsh-tool-fs` | model-facing `read`/`write`/`edit` schemas + read windowing + text rendering; reads/writes/edits via `ctx.fs`, dispatches the `fs/*` events |
| policy | `@deepseek-ai/dsh-file-context` | observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate (no service) |
| policy | `@deepseek-ai/dsh-fs-policy` | observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate (no service) |
| provider seam | `@deepseek-ai/dsh-fs` (this) | `ctx.fs`: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` event vocabulary |
| provider | `@deepseek-ai/dsh-fs-local` | the host-filesystem implementation |
@@ -23,22 +23,21 @@ A backend subclasses `FileSystem` and implements six primitives.
| `stat(target, signal?)` | Return `FsInfo` metadata (`version`, `type`, optional `size`), or `undefined` when the target is absent. Never content. |
| `readText(target, signal?)` | Read the whole regular text file as one decoded string. Owns regular-file checks, UTF-8 decoding, binary/NUL rejection (`FS_NOT_TEXT`). |
| `streamText(target, signal?)` | Stream the same text as decoded chunks for large files (cross-chunk UTF-8 decoding stays here). |
| `writeText(target, content, expected?, signal?)` | Atomic create/replace. `expected` is OPTIONAL: omit ⇒ unconditional create-or-overwrite; supply an `FsWriteExpectation` (`createIfAbsent`/`replaceIfVersion`) to guard. |
| `writeText(target, content, expected?, signal?)` | Atomic create/replace. `expected` is OPTIONAL: omit ⇒ unconditional create-or-overwrite; supply an `FsWriteIntent` (`createIfAbsent`/`replaceIfVersion`) to guard. |
| `editText(target, edit, expected?, signal?)` | Literal edit. `expected` is OPTIONAL: omit ⇒ unconditional edit of the current content; supply `{ version }` to guard (verified BEFORE matching). A missing target reports `FS_STALE_VERSION` either way. Applies and writes atomically — one mutation critical section. |
The mutation runs inside the backend's per-target lock either way, so an unconditional write/edit is still atomic — "unconditional" drops the *version* precondition, not the atomicity.
## The `fs/*` policy events
This package declares three events (see the generated [catalog](../../../docs/cordis-catalog/events-and-services.md)) so the emitter (`@deepseek-ai/dsh-tool-fs`) and the policy listener (`@deepseek-ai/dsh-file-context`) share a vocabulary without the emitter depending on the policy plugin. `fs/write-expectation` and `fs/edit-expectation` are single-slot decision waterfalls (the listener fully decides, never calling `next()`); `fs/observed` is a fire-and-forget recording event. They carry only `dsh-fs` vocabulary plus an opaque `object` actor — no model-facing concepts and no agent/session owner structure.
This package declares three events (see the generated [catalog](../../../docs/cordis-catalog/events-and-services.md)) so the emitter (`@deepseek-ai/dsh-tool-fs`) and the policy listener (`@deepseek-ai/dsh-fs-policy`) share a vocabulary without the emitter depending on the policy plugin. `fs/write-intent` and `fs/edit-intent` are single-slot decision waterfalls (the listener fully decides, never calling `next()`); `fs/observed` is a fire-and-forget recording event. They carry only `dsh-fs` vocabulary plus an opaque `object` actor — no model-facing concepts and no agent/session owner structure.
## A provider seam, not the policy layer
`ctx.fs` is deliberately close to fsspec-style storage primitives — half a level above byte-level `cat`/`open`, because it decodes text and rejects binaries so the policy layer never touches raw bytes. It owns UTF-8 decoding, binary rejection, atomic writes, and the literal-edit critical section. It does **not** own line windows, numbered lines, rendered footers, or observed-state. Observed-state, read-before-edit, and version-guarded write/edit are policy a plugin (`@deepseek-ai/dsh-file-context`) ADDS by supplying the optional guard — not provider behavior — so a sandboxed/remote backend inherits no model-facing observation policy.
`ctx.fs` is deliberately close to fsspec-style storage primitives — half a level above byte-level `cat`/`open`, because it decodes text and rejects binaries so the policy layer never touches raw bytes. It owns UTF-8 decoding, binary rejection, atomic writes, and the literal-edit critical section. It does **not** own line windows, numbered lines, rendered footers, or observed-state. Observed-state, read-before-edit, and version-guarded write/edit are policy a plugin (`@deepseek-ai/dsh-fs-policy`) ADDS by supplying the optional guard — not provider behavior — so a sandboxed/remote backend inherits no model-facing observation policy.
`editText` stays on this seam (not composed in the policy layer from a read plus a write) because version guard + literal match + atomic rewrite must stay inside one critical section for correct error attribution and one-wins/one-stale concurrency, and a remote backend may implement it as a native compare-and-edit.
## Vocabulary
`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids RFC](../../../docs/rfc/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsWriteExpectation` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy RFC](../../../docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts.
`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids RFC](../../../docs/rfc/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy RFC](../../../docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts.

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-fs",
"description": "Abstract filesystem capability seam (ctx.fs) for the DeepSeek Harness — vocabulary types, the FileSystem service, and the read-before-write/edit file-state contract",
"description": "Abstract filesystem capability seam (ctx.fs) for the DeepSeek Harness — vocabulary types, the FileSystem service (text IO + optional version-guarded atomic mutations), and the fs/* policy event vocabulary",
"version": "0.0.1",
"private": true,
"type": "module",

View File

@@ -2,7 +2,7 @@
* The filesystem provider seam (`ctx.fs`): an abstract service defining the
* text-storage primitives a backend provides — resolve a path into a stable
* target, stat its metadata, read/stream its text, write it atomically with an
* explicit expectation, and apply a guarded literal edit — without saying HOW.
* explicit intent, and apply a guarded literal edit — without saying HOW.
* Implementations subclass {@link FileSystem} and register themselves as the
* `fs` service; `@deepseek-ai/dsh-fs-local` (the host filesystem) is the first.
* Future implementations swap in sandboxed, remote, virtual, or project-scoped
@@ -20,7 +20,7 @@
* literal-edit critical section — but NOT line windows, numbered lines,
* rendered footers, or observed-state. Read windowing lives in the model-facing
* tool (`@deepseek-ai/dsh-tool-fs`); observed-state and read-before-write/edit
* are policy a plugin (`@deepseek-ai/dsh-file-context`) adds through the `fs/*`
* are policy a plugin (`@deepseek-ai/dsh-fs-policy`) adds through the `fs/*`
* event gate. So a sandboxed/remote backend inherits no model-facing observation
* policy it has no business carrying.
*
@@ -41,14 +41,14 @@
* unconditional write/edit is still atomic; "unconditional" drops the *version*
* precondition, not the atomicity. Observed-state, read-before-edit, and
* version-guarded write/edit are NOT provider behavior — they are policy a
* plugin (`@deepseek-ai/dsh-file-context`) adds on top by supplying the guard.
* plugin (`@deepseek-ai/dsh-fs-policy`) adds on top by supplying the guard.
*
* ## The fs policy events live here, not in the policy plugin
*
* This package owns the `fs/write-expectation`, `fs/edit-expectation`, and
* This package owns the `fs/write-intent`, `fs/edit-intent`, and
* `fs/observed` event vocabulary (see {@link Events}). The emitter is
* `@deepseek-ai/dsh-tool-fs` and the default listener is
* `@deepseek-ai/dsh-file-context`; the events live in the one package both
* `@deepseek-ai/dsh-fs-policy`; the events live in the one package both
* already depend on, so the emitter shares a vocabulary with the policy listener
* without depending on the policy plugin. The events carry only `dsh-fs`
* vocabulary plus an opaque `object` actor — no model-facing concepts (line
@@ -64,7 +64,7 @@ import type {
FsInfo,
FsTarget,
FsVersion,
FsWriteExpectation,
FsWriteIntent,
FsWriteOutcome,
} from './types.ts'
@@ -79,7 +79,7 @@ export type {
FsErrorCode,
FsInfo,
FsTarget,
FsWriteExpectation,
FsWriteIntent,
FsWriteOutcome,
} from './types.ts'
@@ -90,11 +90,11 @@ declare module 'cordis' {
interface Events {
/**
* Single-slot decision: produce the write expectation for the next
* Single-slot decision: produce the write intent for the next
* {@link FileSystem.writeText}. The tool dispatches this as an unbound
* waterfall (no `this`) and supplies a default thunk returning `undefined`
* (unconditional create-or-overwrite — the bare provider). The
* `@deepseek-ai/dsh-file-context` policy listener returns `createIfAbsent`
* `@deepseek-ai/dsh-fs-policy` policy listener returns `createIfAbsent`
* (unobserved actor) or `{ kind: 'replaceIfVersion', version: vObserved }`
* (observed) and does NOT call `next()` — one decision, not a composable
* chain. The slot is first-wins: the first non-`next()` decider (registration
@@ -102,23 +102,23 @@ declare module 'cordis' {
* not layering. `actor` is the opaque tool-execution context, never read here.
* @mode waterfall
*/
'fs/write-expectation'(target: FsTarget, actor: object | undefined, next: () => FsWriteExpectation | undefined | Promise<FsWriteExpectation | undefined>): Promise<FsWriteExpectation | undefined>
'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise<FsWriteIntent | undefined>): Promise<FsWriteIntent | undefined>
/**
* Single-slot decision: produce the optional version guard for the next
* {@link FileSystem.editText}. The tool dispatches this as an unbound
* waterfall and supplies a default thunk returning `undefined` (unconditional
* edit of the current content — the bare provider; no `stat`). The
* `@deepseek-ai/dsh-file-context` policy listener returns
* `@deepseek-ai/dsh-fs-policy` policy listener returns
* `{ version: vObserved }`, or throws `FS_NOT_OBSERVED` if the actor is unset
* or has not observed the target. Does NOT call `next()`: one decision,
* first-wins (see {@link Events.'fs/write-expectation'}).
* first-wins (see {@link Events.'fs/write-intent'}).
* @mode waterfall
*/
'fs/edit-expectation'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined>
'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined>
/**
* Record that an actor observed a target at a version, after a successful
* read/write/edit. Fire-and-forget (plain `emit`). A listener MUST be a
* synchronous, side-effect-only recorder (`@deepseek-ai/dsh-file-context`'s
* synchronous, side-effect-only recorder (`@deepseek-ai/dsh-fs-policy`'s
* is a `WeakMap.set`): the tool does not guard the emit, so a listener that
* throws surfaces as the tool's `isError` result, and cordis `emit` does not
* await listener promises — async or fallible audit/telemetry does not
@@ -147,7 +147,7 @@ declare module 'cordis' {
* binary/NUL rejection, and `FS_NOT_TEXT`.
* - {@link writeText} is atomic temp-file + rename. `expected` is OPTIONAL:
* omit it for an unconditional create-or-overwrite (the bare-provider default),
* or supply a {@link FsWriteExpectation} to guard the write.
* or supply a {@link FsWriteIntent} to guard the write.
* - {@link editText} verifies `expected.version` BEFORE literal matching (so a
* stale edit reports `FS_STALE_VERSION`, not `FS_EDIT_NOT_FOUND`/
* `FS_AMBIGUOUS_EDIT` against newer content), then applies literal replacement
@@ -188,7 +188,7 @@ export abstract class FileSystem extends Service {
* unconditional create-or-overwrite (the bare provider — no version guard, no
* read-first requirement). Atomic either way.
*/
abstract writeText(target: FsTarget, content: string, expected?: FsWriteExpectation, signal?: AbortSignal): Promise<FsWriteOutcome>
abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise<FsWriteOutcome>
/**
* Apply a literal edit to an existing UTF-8 text file. When `expected` is

View File

@@ -1,12 +1,12 @@
/**
* Vocabulary for the filesystem provider seam (`ctx.fs`): the opaque
* target/version identities, the metadata `stat` returns, the write-expectation
* target/version identities, the metadata `stat` returns, the write-intent
* and outcome shapes, the literal-edit request/outcome, and the typed error
* taxonomy.
*
* These types are shared by every backend (`@deepseek-ai/dsh-fs-local` and
* future sandboxed/remote backends) and by the policy layer
* (`@deepseek-ai/dsh-file-context`). They are deliberately a *text-storage*
* (`@deepseek-ai/dsh-fs-policy`). They are deliberately a *text-storage*
* vocabulary half a level above byte-level fsspec: `readText`/`streamText` hand
* back decoded text, never raw bytes. Host-path assumptions stay out — `targetKey`
* and `version` are opaque branded tokens, and `displayPath` is the only field a
@@ -14,7 +14,7 @@
*
* Model-facing concepts (line windows, numbered lines, observed-state) do NOT
* live here; they belong to the consumer tool and the policy plugin
* (`@deepseek-ai/dsh-tool-fs` / `@deepseek-ai/dsh-file-context`).
* (`@deepseek-ai/dsh-tool-fs` / `@deepseek-ai/dsh-fs-policy`).
*
* @module @deepseek-ai/dsh-fs/types
*/
@@ -91,7 +91,7 @@ export interface FsInfo {
* is expressed by omission, so the write and edit mutations share one symmetric
* shape (`expected?`: omit = unconditional, present = guarded).
*/
export type FsWriteExpectation =
export type FsWriteIntent =
| { kind: 'createIfAbsent' }
| { kind: 'replaceIfVersion'; version: FsVersion }

View File

@@ -1,7 +1,7 @@
/**
* Tests for the filesystem provider seam itself: registration, duplicate-service
* behavior, disposal, and the branded id factories. The provider primitives and
* policy live in `dsh-fs-local` and `dsh-file-context`; this seam owns only the
* policy live in `dsh-fs-local` and `dsh-fs-policy`; this seam owns only the
* abstract service contract, so a minimal fake backend exercises it.
*/
@@ -13,7 +13,7 @@ import type {
FsEditRequest,
FsInfo,
FsTarget,
FsWriteExpectation,
FsWriteIntent,
FsWriteOutcome,
} from '@deepseek-ai/dsh-fs'
@@ -38,7 +38,7 @@ class FakeFileSystem extends FileSystem {
const content = await this.readText(target)
return (async function* () { yield content })()
}
override async writeText(target: FsTarget, content: string, _expected?: FsWriteExpectation): Promise<FsWriteOutcome> {
override async writeText(target: FsTarget, content: string, _expected?: FsWriteIntent): Promise<FsWriteOutcome> {
const existed = this.files.has(target.targetKey)
this.files.set(target.targetKey, content)
return { operation: existed ? 'update' : 'create', version: FsVersion('v2') }

View File

@@ -1,15 +1,15 @@
# @deepseek-ai/dsh-tool-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.
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-fs-policy`](../fs-policy)) through the `fs/*` event gate; the tool is not method-coupled to it.
```ts ignore-check
// 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 (policy gate)
await ctx.plugin(FsPolicy) // @deepseek-ai/dsh-fs-policy (policy gate)
await ctx.plugin(ToolFs) // this package — registers read/write/edit
```
`@deepseek-ai/dsh-file-context` is **optional**: omit it and the tools run against the bare provider (unconditional write/overwrite/edit, no observed-state). A deployment that loads these tools is expected to also load it, so the behavior is read-before-write/edit.
`@deepseek-ai/dsh-fs-policy` is **optional**: omit it and the tools run against the bare provider (unconditional write/overwrite/edit, no observed-state). A deployment that loads these tools is expected to also load it, so the behavior is read-before-write/edit.
## Tools (schemas per [the filesystem tool schemas RFC](../../../docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md))
@@ -26,13 +26,13 @@ Field names are snake_case to match Claude Code and existing harness tool schema
The tools do **not** inject a policy service or inspect any cache. Each tool resolves the path via `ctx.fs.resolve()`, then:
- **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.)
- **write** — `ctx.waterfall('fs/write-intent', target, exec, () => undefined)` for the optional guard, then `ctx.fs.writeText(target, content, intent)`, then `fs/observed`. (0 stat.)
- **edit** — `ctx.waterfall('fs/edit-intent', target, exec, () => undefined)` for the optional guard, then `ctx.fs.editText(target, edit, intent)`, then `fs/observed`. (0 stat.)
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.
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-fs-policy` 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.
## `fs/observed` is fire-and-forget
`fs/observed` fires AFTER the read/write/edit already succeeded, via a plain `ctx.emit`. A listener is contractually a synchronous, side-effect-only recorder (`@deepseek-ai/dsh-file-context`'s is a `WeakMap.set`); the tool does not guard the emit, so a listener that throws would surface as the tool's `isError` result — async or fallible observation does not belong on this event.
`fs/observed` fires AFTER the read/write/edit already succeeded, via a plain `ctx.emit`. A listener is contractually a synchronous, side-effect-only recorder (`@deepseek-ai/dsh-fs-policy`'s is a `WeakMap.set`); the tool does not guard the emit, so a listener that throws would surface as the tool's `isError` result — async or fallible observation does not belong on this event.
The read rendering (line windowing + output formatting) lives in `src/read-render.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

@@ -30,7 +30,7 @@
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-file-context": "workspace:^",
"@deepseek-ai/dsh-fs-policy": "workspace:^",
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",

View File

@@ -1,10 +1,10 @@
/**
* The model-facing `edit` tool: update an existing UTF-8 text file by replacing
* literal text, requiring a unique match by default. The tool is the executor:
* it dispatches the `fs/edit-expectation` waterfall to obtain the optional
* it dispatches the `fs/edit-intent` waterfall to obtain the optional
* version guard, calls `ctx.fs.editText` directly, and emits `fs/observed`. The
* default thunk returns `undefined` (unconditional edit of the current content
* — the bare provider); a policy plugin (`@deepseek-ai/dsh-file-context`)
* — the bare provider); a policy plugin (`@deepseek-ai/dsh-fs-policy`)
* 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`.
@@ -52,7 +52,7 @@ export function applyEditTool(ctx: Context): void {
ctx.systemPrompt.section({
name: 'tool:edit',
order: 102,
text: 'Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default file-context policy requires it), unless you just created or edited it in this session.',
text: 'Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.',
})
ctx.tools.register(defineTool({
@@ -70,11 +70,11 @@ export function applyEditTool(ctx: Context): void {
// 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 intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)
const outcome = await ctx.fs.editText(
target,
{ oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll },
expectation,
intent,
exec.signal,
)
// Record the observed version (a no-op when no policy plugin listens).

View File

@@ -8,15 +8,16 @@
* 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.
* (`fs/write-intent`/`fs/edit-intent`) to obtain the OPTIONAL version guard, and
* after every read/write/edit it emits `fs/observed` with a plain (unguarded)
* `ctx.emit`. A policy plugin (`@deepseek-ai/dsh-fs-policy`) occupies the
* decision slot and listens for `fs/observed` to add observed-state +
* read-before-edit + version-guarded write/edit; a deployment that loads these
* tools is expected to also load it. 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
*/

View File

@@ -3,7 +3,7 @@
* 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/read-render}), and emits `fs/observed`
* so a policy plugin (`@deepseek-ai/dsh-file-context`) can record the read. With
* so a policy plugin (`@deepseek-ai/dsh-fs-policy`) can record the read. With
* no policy plugin the emit is simply unheard. This module owns the
* model-facing schema, argument validation, and the read I/O; the rendering
* (windowing + formatting) lives in `read-render.ts` and the

View File

@@ -1,10 +1,10 @@
/**
* 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
* tool is the executor: it dispatches the `fs/write-intent` waterfall to
* obtain the optional version guard, calls `ctx.fs.writeText` directly, and
* emits `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
* (`@deepseek-ai/dsh-fs-policy`) occupies the single decision slot and
* returns `createIfAbsent`/`replaceIfVersion` instead. The tool stats ZERO
* times either way.
*
@@ -39,7 +39,7 @@ export function applyWriteTool(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 default file-context policy 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 fs-policy requires it) and prefer edit for targeted changes.',
})
ctx.tools.register(defineTool({
@@ -54,8 +54,8 @@ export function applyWriteTool(ctx: Context): void {
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)
const intent = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)
const outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal)
// Record the observed version (a no-op when no policy plugin listens).
ctx.emit('fs/observed', target, outcome.version, exec)
return [{ type: 'text', text: formatWriteOutput(target.displayPath, outcome) }]

View File

@@ -3,7 +3,7 @@
* 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-
* - DEFAULT — with the real `dsh-fs-policy` policy gate plugin: read-before-
* write/edit, version-guarded mutation, FS_NOT_OBSERVED for unread edits.
* - BARE — WITHOUT the policy plugin: every `fs/*` waterfall falls through to
* its undefined default, so write/edit are unconditional. This proves the
@@ -22,7 +22,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 * as FileContext from '@deepseek-ai/dsh-file-context'
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
let dir: string
@@ -53,14 +53,14 @@ afterEach(async () => {
// --------------------------------------------------------------------------
// DEFAULT deployment: the policy gate plugin is loaded.
// --------------------------------------------------------------------------
describe('default deployment (with dsh-file-context)', () => {
describe('default deployment (with dsh-fs-policy)', () => {
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)
await ctx.plugin(FsPolicy)
fiber = await ctx.plugin(ToolFs)
})
@@ -228,7 +228,7 @@ describe('default deployment (with dsh-file-context)', () => {
// --------------------------------------------------------------------------
// BARE deployment: the tool suite WITHOUT the policy gate.
// --------------------------------------------------------------------------
describe('bare provider (no dsh-file-context)', () => {
describe('bare provider (no dsh-fs-policy)', () => {
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-bare-'))
ctx = new Context()

View File

@@ -1,6 +1,6 @@
/**
* Consumer-surface tests for the filesystem tools as the EXECUTOR. They run the
* REAL `@deepseek-ai/dsh-file-context` gate plugin (the genuine policy
* REAL `@deepseek-ai/dsh-fs-policy` 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
@@ -19,10 +19,10 @@ import type {
FsEditRequest,
FsInfo,
FsTarget,
FsWriteExpectation,
FsWriteIntent,
FsWriteOutcome,
} from '@deepseek-ai/dsh-fs'
import * as FileContext from '@deepseek-ai/dsh-file-context'
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
import * as ToolFs 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'
@@ -31,8 +31,8 @@ import type { FileReadOutcome } from '@deepseek-ai/dsh-tool-fs'
class FakeFs extends FileSystem {
files = new Map<string, string>()
rejectWith?: FsError
writeExpectations: (FsWriteExpectation | undefined)[] = []
editExpectations: ({ version: FsVersion } | undefined)[] = []
writeIntents: (FsWriteIntent | undefined)[] = []
editIntents: ({ version: FsVersion } | undefined)[] = []
private throwIfArmed(): void {
if (this.rejectWith) throw this.rejectWith
@@ -54,16 +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?: FsWriteIntent): Promise<FsWriteOutcome> {
this.throwIfArmed()
this.writeExpectations.push(expected)
this.writeIntents.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, expected?: { version: FsVersion }): Promise<FsEditOutcome> {
this.throwIfArmed()
this.editExpectations.push(expected)
this.editIntents.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') }
@@ -75,7 +75,7 @@ async function setup() {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(FakeFs)
await ctx.plugin(FileContext)
await ctx.plugin(FsPolicy)
await ctx.plugin(ToolFs)
const fs = ctx.fs as FakeFs
return { ctx, fs }
@@ -122,11 +122,16 @@ describe('registration', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(FakeFs)
await ctx.plugin(FileContext)
await ctx.plugin(FsPolicy)
const fiber = await ctx.plugin(ToolFs)
// Each tool contributes BOTH a schema and a prompt section; disposal must
// withdraw both, not just the schemas.
expect(ctx.tools.schemas()).toHaveLength(3)
const sectionNames = (a: { sections: { name: string }[] }) => a.sections.map(s => s.name).sort()
expect(sectionNames(await ctx.systemPrompt.assemble())).toEqual(['tool:edit', 'tool:read', 'tool:write'])
await fiber.dispose()
expect(ctx.tools.schemas()).toHaveLength(0)
expect((await ctx.systemPrompt.assemble()).sections).toHaveLength(0)
})
})
@@ -174,7 +179,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' }])
expect(fs.editIntents).toEqual([{ version: 'v1' }])
})
it('propagates FS_NOT_FOUND for an absent file', async () => {
@@ -257,7 +262,7 @@ describe('write tool', () => {
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' }])
expect(fs.writeIntents).toEqual([{ kind: 'createIfAbsent' }])
})
it('rejects a blank file_path', async () => {

View File

@@ -12,6 +12,6 @@
{ "path": "../../core/tools" },
{ "path": "../../core/system-prompt" },
{ "path": "../fs" },
{ "path": "../file-context" }
{ "path": "../fs-policy" }
]
}