Merge pull request #96 from deepseek-harness/fs-tool-clean

Add filesystem capability seam and tools
This commit is contained in:
Dudu-0223
2026-07-03 10:07:00 +08:00
committed by GitHub
93 changed files with 6405 additions and 45 deletions
+6 -4
View File
@@ -115,10 +115,11 @@ examples/ Runnable demos (not workspaces; see examples/AGENTS.md). Each is a
teaching plugins. The app package bundles the agent-core spine +
front-door cluster + boot glue (a bin). No start.ts. echo-agent =
mock model + echo tool on dsh-stdio-agent (pnpm run demo:echo, no
key). coding-agent = the real thing: DeepSeek V4 + bash tools +
subagent + todo_write on the same app (pnpm run demo:coding, needs
DEEPSEEK_API_KEY). acp-agent = the coding agent as an ACP server on
dsh-acp-agent (pnpm run demo:acp, needs DEEPSEEK_API_KEY).
key). coding-agent = the real thing: DeepSeek V4 + fs tools
(read/write/edit) + bash tools + subagent + todo_write on the same
app (pnpm run demo:coding, needs DEEPSEEK_API_KEY). acp-agent = the
coding agent as an ACP server on dsh-acp-agent (pnpm run demo:acp,
needs DEEPSEEK_API_KEY).
cordis.snapshot.yml = the acp leaf with llm-replay for keyless
snapshot replay.
docs/ architecture.md — the design doc. module-graph.md — generated
@@ -240,6 +241,7 @@ Dev/test/demo run **unbuilt** via tsx + the source `paths` map in the root `tsco
- **Plugins, not loop changes**: new behavior goes into a plugin on the documented extension seams (see the plugin sanity checklist in docs/architecture.md). Changing `agent-loop` requires updating that doc.
- **Capability seams are three packages**: when adding a swappable capability (an execution backend, a provider integration, …), split it into *interface* (abstract service + vocabulary types, e.g. `bash/`), *implementation* (a concrete subclass, e.g. `bash-local/`), and *consumer* (what the model/plugins see, e.g. `tool-bash/`). Implementations and consumers then evolve independently — a sandboxed executor replaces `bash-local` without touching tool schemas. The LLM seam follows the same shape (`llm/` is interface + consumer surface; adapters are implementations). See docs/architecture.md § "Capability seams" for when NOT to split.
- **Explicit > implicit at package seams**: interface/vocabulary types spell out every field a consumer must supply — no optional field that the implementation silently fills with a hidden `?? default`. Put defaulting in the owning implementation as an explicit step (a `resolve(request): Spec` method that turns the optional-field request into the required-field spec), not smuggled inside `run()`/`start()`. Example: `dsh-bash` splits `BashExecRequest` (optional `workdir`/`timeoutMs`, model-facing) from `BashExecSpec` (required, what `run`/`start` act on); the tool layer calls `ctx.bash.resolve()` between them. The reader of a `BashExecSpec` never has to wonder where the working directory came from.
- **Opaque cross-boundary ids are branded, never bare `string`**: an identity that crosses a package seam and that a consumer must store-and-return but never parse (a backend-defined version token, a target key, a task/session/call id) is a `Branded<B>` from `@deepseek-ai/dsh-brand` with a same-named cast factory in the owning package — a zero-cost compile-time guard so semantically-distinct strings stop being interchangeable. Not every string needs it: author-readable names (`ToolName`) and closed code unions (`ErrorCode`) don't. See [Branded IDs everywhere they belong](docs/rfc/implemented/architecture/2026-06-20-branded-ids.md).
- **An empty `catch` must name what it swallows and why nothing else can hit it**: a bare `catch {}` hides bugs. When you deliberately ignore a throw, the comment must (a) name the single expected failure, (b) say why ignoring it is correct — usually because the useful state was already captured *before* the `try` — and (c) make clear nothing else of consequence can reach the catch (ideally the `try` wraps a single statement). Example: the error-body `response.json()` parse in `dsh-llm-deepseek`'s adapter sets `code` + HTTP `status` from the status line before the `try`, so a malformed provider body can only cost a richer message, never the real error.
- **Symmetry is usually more correct**: when two related values play parallel roles (a test fixture and its expected output, a request shape and its response shape, a buggy input and the test that checks the fix), give them parallel form — both named consts, or both inline, not one each way. Asymmetry is a smell that usually points at a missed extraction.
- **Merging PRs**: always merge with a **merge commit** (`gh pr merge --merge`), never squash or rebase. The per-PR commit history is intentional — review-fix commits, regression-test commits, and the reasoning in each message are part of the record — and squashing flattens it away.
+7
View File
@@ -24,6 +24,9 @@ For a catalog of the **data structures** this architecture moves around — the
│ @deepseek-ai/dsh-agent-loop (the ONE concrete plugin) │
│ @deepseek-ai/dsh-bash-local (bash impl) │
│ @deepseek-ai/dsh-tool-bash (bash tool schemas) │
│ @deepseek-ai/dsh-fs-local (filesystem impl) │
│ @deepseek-ai/dsh-fs-policy (filesystem policy gate) │
│ @deepseek-ai/dsh-tool-fs (filesystem tools+executor)│
│ @deepseek-ai/dsh-subagent-* (subagent providers) │
│ @deepseek-ai/dsh-session-persistence-jsonl (persistence impl)│
├─────────────────────────────────────────────────────────────┤
@@ -34,6 +37,7 @@ For a catalog of the **data structures** this architecture moves around — the
│ @deepseek-ai/dsh-session-persistence (persistence seam) │
│ @deepseek-ai/dsh-llm (abstract model service) │
│ @deepseek-ai/dsh-bash (abstract bash executor) │
│ @deepseek-ai/dsh-fs (filesystem provider seam) │
│ @deepseek-ai/dsh-compact (abstract compaction seam) │
│ @deepseek-ai/dsh-subagent (provider registry seam) │
├─────────────────────────────────────────────────────────────┤
@@ -56,6 +60,7 @@ Dependency rule: **extension** plugins depend on interface packages, never on `d
| `ctx.agents` | `AgentRegistry` | dsh-agent | live `Agent` handles + the create/resume factory seam (returns an `AgentHandle` = `{ agent, dispose() }` for owned per-agent teardown) |
| `ctx.agentLoop` | `AgentLoop` | dsh-agent-loop | creates `ReactLoopAgent`s and drives their loops |
| `ctx.bash` | `BashExecutor` (abstract) | dsh-bash | bash execution seam: foreground runs + background tasks |
| `ctx.fs` | `FileSystem` (abstract) | dsh-fs | filesystem provider seam: path resolution, stat, text read/stream, atomic writes/edits (optional version guard); owns the `fs/*` policy events |
| `ctx.compact` | `CompactService` (abstract) | dsh-compact | compaction seam: decide when history is too large, summarize an older range into a single surface node |
| `ctx.subagents` | `SubagentService` | dsh-subagent | named provider registry for delegating a task to child agents |
@@ -73,6 +78,8 @@ Swappable capabilities are split into **three packages** so each part evolves in
The LLM seam has the same topology folded differently: `dsh-llm` carries the interface (`LlmAdapter`) AND the consumer surface (`ctx.llm.stream()`), with adapters as implementation packages — there the consumer is the loop itself, not a swappable schema surface. Use the full three-package split when the consumer is independently replaceable; keep interface + consumer together when they are one concern. Don't split preemptively: a capability with one conceivable implementation and one consumer stays one package until proven otherwise.
The filesystem capability follows the bash topology with a fourth layer, but the policy is contributed through an **event gate**, not a method service: `dsh-fs` owns the abstract `ctx.fs` provider seam (text IO + atomic mutation primitives whose version guard is optional) and the `fs/*` policy event vocabulary, `dsh-fs-local` provides the local backend, `dsh-tool-fs` is the model-facing `read`/`write`/`edit` tools AND the executor (it reads/writes/edits through `ctx.fs` directly, owns read windowing, dispatches the `fs/*` events), and `dsh-fs-policy` is a policy PLUGIN (no service) that decides the `fs/write-intent`/`fs/edit-intent` waterfalls and records on `fs/observed` to add observed-state + read-before-edit + version-guarded write/edit. Because the tool is not method-coupled to the policy, dropping `dsh-fs-policy` gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool at a service-injection boundary. The demo agents (`coding-agent`, `acp-agent`) wire the full stack — `dsh-fs-local` + `dsh-fs-policy` + `dsh-tool-fs` — so `read`/`write`/`edit` are the default file surface (bash stays for shell/tests/search); the tools resolve a relative path against the caller's session cwd, matching bash ([the per-session cwd RFC](rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md)). See [the fs-policy event-gate RFC](rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md).
> **"Capability" — two unrelated meanings.** (1) The *seam pattern* above ("one plugin provides a capability, another needs it") is realized by plain Cordis **services + `inject`**: a provider registers a service (`ctx.bash`, declared in `interface Context`); a consumer declares `inject: ['bash']` and its fiber stays pending until the service exists, tearing down via HMR if it later vanishes. No extra library is needed. (2) `@cordisjs/plugin-capability` is a different axis entirely — a **permission/capability-security** service (named permissions with inheritance/dependency, tested against a session via `ctx.capability.test`). It is a candidate for the deferred permissions/sandbox work (the `tools/execute` veto seam), NOT a mechanism for swapping implementations.
## The vocabulary (dsh-llm)
+64 -1
View File
@@ -197,6 +197,44 @@ Types: [Agent](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:163`](../../packages/core/agent/src/types.ts)
### `fs/*`
#### `fs/edit-intent` — waterfall
Single-slot decision: produce the optional version guard for the next 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-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 Events.'fs/write-intent').
```ts cordis-catalog
'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined>
```
Types: [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md)
Source: [`packages/fs/fs/src/index.ts:117`](../../packages/fs/fs/src/index.ts)
#### `fs/observed` — emit
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-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 belong here. No listener ⇒ nothing recorded. `actor` is the opaque tool-execution context.
```ts cordis-catalog
'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void
```
Types: [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md)
Source: [`packages/fs/fs/src/index.ts:129`](../../packages/fs/fs/src/index.ts)
#### `fs/write-intent` — waterfall
Single-slot decision: produce the write intent for the next 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-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 order, or `prepend`) occupies it; a second decider is a misconfiguration, not layering. `actor` is the opaque tool-execution context, never read here.
```ts cordis-catalog
'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise<FsWriteIntent | undefined>): Promise<FsWriteIntent | undefined>
```
Types: [FsTarget](../core-data-structures/filesystem.md) · [FsWriteIntent](../core-data-structures/filesystem.md)
Source: [`packages/fs/fs/src/index.ts:105`](../../packages/fs/fs/src/index.ts)
### `llm/*`
#### `llm/stream` — waterfall
@@ -393,6 +431,31 @@ abstract compactRegion( session: Session, start: number, end: number, agent: Com
Source: [`packages/compact/compact/src/index.ts:63`](../../packages/compact/compact/src/index.ts)
### `ctx.fs` — `FileSystem` (abstract seam)
Abstract filesystem provider service. Subclass, implement the six text-storage primitives, and load the subclass as a plugin — it registers as `ctx.fs` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior).
Semantics every backend must honor:
- resolve returns a stable FsTarget; the same underlying file reached by different input paths must yield the same `targetKey` so stale guards and target lookup agree across paths (e.g. through symlinks).
- stat returns FsInfo metadata (never content) or `undefined` when the target is absent.
- readText/streamText read the whole regular text file (the stream for large files); both own regular-file checks, UTF-8 decoding, binary/NUL rejection, and `FS_NOT_TEXT`.
- writeText is atomic temp-file + rename. `expected` is OPTIONAL: omit it for an unconditional create-or-overwrite (the bare-provider default), or supply a FsWriteIntent to guard the write.
- 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 and writes atomically — all inside one mutation critical section. `expected` is OPTIONAL: omit it for an unconditional edit of the current content (a missing target still reports `FS_STALE_VERSION`).
```ts cordis-catalog
abstract resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget>
abstract stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined>
abstract readText(target: FsTarget, signal?: AbortSignal): Promise<string>
abstract streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>>
abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise<FsWriteOutcome>
abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome>
```
Types: [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsInfo](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) · [FsWriteIntent](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md)
Source: [`packages/fs/fs/src/index.ts:158`](../../packages/fs/fs/src/index.ts)
### `ctx.llm` — `LlmService`
The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall.
@@ -484,7 +547,7 @@ async execute(exec: ToolExecution): Promise<ToolExecutionResult>
Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
Source: [`packages/core/tools/src/index.ts:277`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:287`](../../packages/core/tools/src/index.ts)
## Inherited tier (cordis core + loader/hmr/timer)
+1
View File
@@ -20,6 +20,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t
| [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` |
| [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, the `tools/execute` waterfall |
| [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashTask`s |
| [filesystem.md](filesystem.md) | the filesystem seam: `FsTarget`, read/write/edit outcomes, observed-file state, `FsErrorCode` |
| [compaction.md](compaction.md) | the compaction seam: the `compact/*` session events, `CompactionResult`, the `CompactService` interface |
| [subagent.md](subagent.md) | the subagent seam: the named-provider registry, `SubagentStartRequest`/`Result`/`Run`, the start-time-vs-runtime capability split |
+133
View File
@@ -0,0 +1,133 @@
# Filesystem
The filesystem stack is split across four packages: a provider seam ([dsh-fs](../../packages/fs/fs), `ctx.fs`, text IO + atomic mutation primitives whose version guard is optional), a local implementation ([dsh-fs-local](../../packages/fs/fs-local), local disk), a policy plugin ([dsh-fs-policy](../../packages/fs/fs-policy), observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate — NO service), and a consumer ([dsh-tool-fs](../../packages/fs/tool-fs), the model-facing `read`/`write`/`edit` tools, which is also the EXECUTOR — it reads/writes/edits through `ctx.fs` directly and owns read windowing). Filesystem access is an optional capability, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). A sandboxed, remote, virtual, or project-scoped backend can implement the same `FileSystem` service without changing the policy plugin or the tool schemas.
The model is **additive, not subtractive**: `ctx.fs` alone is a complete, unconstrained text-storage seam (`write` unconditionally creates-or-overwrites, `edit` unconditionally replaces literal text). `dsh-fs-policy` is a plugin that *adds* policy on top by deciding the `fs/*` waterfalls; removing it leaves the bare provider rather than breaking the tool, because the tool is not method-coupled to the policy. A deployment that loads `dsh-tool-fs` is expected to also load `dsh-fs-policy` so the default behavior is read-before-write/edit.
Provider source: [`packages/fs/fs/src/types.ts`](../../packages/fs/fs/src/types.ts) and [`packages/fs/fs/src/index.ts`](../../packages/fs/fs/src/index.ts). Policy source: [`packages/fs/fs-policy/src/types.ts`](../../packages/fs/fs-policy/src/types.ts). Read-rendering source: [`packages/fs/tool-fs/src/read-render.ts`](../../packages/fs/tool-fs/src/read-render.ts).
## Target identity and metadata (provider seam)
Every operation resolves a user-supplied path to an opaque backend target first. Consumers may display `displayPath`, but must not parse `targetKey` (a branded opaque id) or assume it is a local absolute path.
```ts type-equiv
interface FsTarget {
inputPath: string
targetKey: FsTargetKey
displayPath: string
}
```
The backend owns file-version tokens — the freshness token a write/edit guards against. The policy plugin stores them for stale checks; consumers do not interpret them. Both ids are branded opaque strings.
```ts type-equiv
type FsTargetKey = Branded<'FsTargetKey'>
```
```ts type-equiv
type FsVersion = Branded<'FsVersion'>
```
`stat` returns metadata (never content), or `undefined` when the target is absent. `type` lets the tool reject directories/special files before reading, and `size` lets it choose `readText` vs `streamText` without probing by failure.
```ts type-equiv
interface FsInfo {
version: FsVersion
type: 'file' | 'directory' | 'other'
size?: number
}
```
## Write and edit guards (provider seam)
Both `writeText` and `editText` take their version guard OPTIONALLY: omit it for an unconditional (bare-provider) mutation, supply it to guard. `writeText`'s guard is an `FsWriteIntent` — `createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only when the target exists at the observed version, else `FS_STALE_VERSION`. Omitting `expected` unconditionally creates-or-overwrites. The union itself carries only the two guarded intents; "no guard" is expressed by omission, so write and edit share one symmetric `expected?` shape.
```ts type-equiv
type FsWriteIntent =
| { kind: 'createIfAbsent' }
| { kind: 'replaceIfVersion'; version: FsVersion }
```
```ts type-equiv
interface FsWriteOutcome {
operation: 'create' | 'update'
version: FsVersion
}
```
`editText` is a provider-level mutation, not a `read` plus `write` composed elsewhere. When guarded it verifies the expected version BEFORE literal matching (so a stale edit reports `FS_STALE_VERSION`, not a match failure against newer content); unguarded it edits the current content. Either way it applies the replacement and writes atomically — keeping matching, line-ending handling, the stale check, and atomic replacement inside one mutation critical section — and a missing target reports `FS_STALE_VERSION` on both paths.
```ts type-equiv
interface FsEditRequest {
oldString: string
newString: string
replaceAll: boolean
}
```
```ts type-equiv
interface FsEditOutcome {
replacements: number
replaceAll: boolean
version: FsVersion
}
```
## The fs policy events (provider-seam vocabulary)
`dsh-fs` owns three events the tool dispatches and the policy plugin listens for, so the emitter (`dsh-tool-fs`) and the listener (`dsh-fs-policy`) share a vocabulary without the emitter depending on the policy plugin. They carry only `dsh-fs` vocabulary plus an opaque `object` actor — no model-facing concepts and no agent/session owner structure.
`fs/write-intent` and `fs/edit-intent` are **single-slot decision waterfalls**: the tool dispatches each with a default thunk returning `undefined` (the bare provider), and a listener fully decides without calling `next()`. The slot is first-wins by registration order — the policy plugin owning it is a deployment convention, not an enforced invariant. `fs/observed` is a fire-and-forget recording event dispatched with a plain `ctx.emit`; its listener MUST be synchronous and side-effect-only, because the tool does NOT guard the emit — a throwing listener would surface as the tool's `isError` result for a mutation that already succeeded. The generated catalog shows the exact signatures on [events-and-services.md](../cordis-catalog/events-and-services.md).
## Execution context (policy plugin)
The policy plugin needs just enough execution context to derive the observed-state owner by narrowing the opaque `object` actor the `fs/*` events carry. `ToolExecution` satisfies this shape, so `dsh-tool-fs` passes its execution object through as the actor without making `dsh-fs-policy` import the tool, agent, or session packages.
```ts type-equiv
interface FsPolicyExec {
agent?: {
session?: object
}
}
```
## Read outcome (consumer / read rendering)
A text read is bounded by line window, byte cap, and backend limits. The outcome the model-facing `read` tool renders carries the file's version at read time; there is no `full`/`partial` view — authorization is freshness-based, so any windowed read can authorize a later write/edit when the file is unchanged. Read windowing and this outcome shape live in `dsh-tool-fs` (the executor that owns the read), not in the policy plugin.
```ts type-equiv
interface FileReadOutcome {
offset: number
limit: number
lines: FileTextLine[]
totalLines: number
truncatedByBytes?: true
version: FsVersion
}
```
## Observed-file state (policy plugin)
Observed state is a `WeakMap<owner, Map<targetKey, { version }>>` held inside the `dsh-fs-policy` plugin. An entry exists **iff** the owner has read, written, OR edited that target (every success emits `fs/observed`), so its presence is the prior-observation record — there is no separate `hasRead` flag and no view distinction. The owner is derived from the event actor (normally `exec.agent.session`), treated as opaque and never read. A successful read/write/edit refreshes the recorded version for that owner; disposal drops everything (HMR safety).
## Error taxonomy (provider seam)
Filesystem failures use stable `FsErrorCode` strings carried by `FsError` (`HarnessError`). The tool registry preserves `{ name, code }` on error results, so retry, permission, and UI layers can branch without parsing text.
```ts type-equiv
type 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'
```
`FS_NOT_OBSERVED` means the policy plugin has no prior-observation record for this owner (or a `createIfAbsent` hit an existing file). `FS_STALE_VERSION` means the backend version no longer matches the observed one (or an edit hit a missing target). Freshness authorization has no partial/full distinction, so there is no `FS_PARTIAL_OBSERVATION`.
## The service and the plugin
`FileSystem` (`ctx.fs`, abstract) owns the provider primitives: `resolve`, `stat`, `readText`, `streamText`, `writeText`, and `editText`. `dsh-fs-policy` registers **no service** — it is a plugin that adds policy through the `fs/*` event gate: it decides the write/edit intent waterfalls (supplying `createIfAbsent`/`replaceIfVersion`/`{ version }` or throwing `FS_NOT_OBSERVED`) and records on `fs/observed`. The executor is `dsh-tool-fs`: it reads/writes/edits through `ctx.fs`, dispatches the waterfalls, and emits the recording event. The generated wiring catalog shows the exact `ctx.fs` signatures on [events-and-services.md](../cordis-catalog/events-and-services.md#ctxfs--filesystem-abstract-seam).
+1 -1
View File
@@ -105,7 +105,7 @@ A waterfall listener receives `(exec, next)`: call `next()` to proceed (possibly
## Tool-presentation UI vocabulary
How a tool wants its call shown in a UI (an editor tool-call card, a CLI log line), provider-neutral so a tool describes itself without depending on any client protocol. `presentCall` returns a `ToolCallPresentation` (pending state: `title`, `kind`, `rawInput`, `content`, optional `terminal`); `presentResult` returns a `ToolResultPresentation` (completed state: replacement `title`, reformatted `content`, terminal `output`/exit). `ToolCallKind` (`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`) picks an icon. A `ToolTerminal` asks a capable UI to render the call as a terminal card (cwd header, output, exit-status pill).
How a tool wants its call shown in a UI (an editor tool-call card, a CLI log line), provider-neutral so a tool describes itself without depending on any client protocol. `presentCall` returns a `ToolCallPresentation` (pending state: `title`, `kind`, `rawInput`, `content`, `locations` — `{ path, line? }[]` files the call reads/modifies, for editor follow-along — and optional `terminal`); `presentResult` returns a `ToolResultPresentation` (completed state: replacement `title`, reformatted `content`, terminal `output`/exit). `ToolCallKind` (`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`) picks an icon. A `ToolTerminal` asks a capable UI to render the call as a terminal card (cwd header, output, exit-status pill).
> These shapes carry a `FIXME(tool-presentation)` in source: they grew incrementally and the call-vs-result terminal split is muddy. Before more tools/UIs depend on them, they will be redesigned (a tagged union over card kinds) and pinned in an RFC, migrating `dsh-tool-bash` and the ACP bridge together. Treat the field-level shapes here as provisional; the source is authoritative.
+12
View File
@@ -10,6 +10,8 @@ graph TD
bash --> brand
llm --> brand
bash-local --> bash
fs --> brand
fs --> llm
llm-deepseek --> llm
llm-pi-ai --> llm
session --> brand
@@ -20,6 +22,8 @@ graph TD
agent --> session
compact --> llm
compact --> session
fs-local --> fs
fs-policy --> fs
llm-replay --> llm
llm-replay --> session
session-persistence --> session
@@ -58,6 +62,10 @@ graph TD
tool-bash --> bash
tool-bash --> llm
tool-bash --> tools
tool-fs --> fs
tool-fs --> llm
tool-fs --> system-prompt
tool-fs --> tools
tool-todo --> agent
tool-todo --> session
tool-todo --> tools
@@ -105,12 +113,15 @@ graph TD
| `bash` | `brand` |
| `llm` | `brand` |
| `bash-local` | `bash` |
| `fs` | `brand`, `llm` |
| `llm-deepseek` | `llm` |
| `llm-pi-ai` | `llm` |
| `session` | `brand`, `llm` |
| `system-prompt` | `llm` |
| `agent` | `brand`, `llm`, `session` |
| `compact` | `llm`, `session` |
| `fs-local` | `fs` |
| `fs-policy` | `fs` |
| `llm-replay` | `llm`, `session` |
| `session-persistence` | `session` |
| `compact-basic` | `agent`, `compact`, `llm`, `session` |
@@ -123,6 +134,7 @@ graph TD
| `agent-loop` | `agent`, `llm`, `session`, `session-persistence`, `system-prompt`, `tools` |
| `subagent` | `agent`, `llm`, `tools` |
| `tool-bash` | `agent`, `bash`, `llm`, `tools` |
| `tool-fs` | `fs`, `llm`, `system-prompt`, `tools` |
| `tool-todo` | `agent`, `session`, `tools` |
| `agent-core` | `agent`, `agent-loop`, `invariants`, `llm`, `session`, `system-prompt`, `tool-bash`, `tools` |
| `subagent-acp` | `agent`, `llm`, `subagent` |
+5
View File
@@ -81,6 +81,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
| Title | First proposed |
|---|---|
| [Filesystem tool schemas — model-facing read/write/edit shapes](implemented/feature/2026-06-17-filesystem-tool-schemas.md) | 2026-06-17 |
| [Rich ACP bash rendering — the terminal card (`_meta`) and command classification](implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) | 2026-06-18 |
| [Compaction as a capability seam (abstract contract + basic backend)](implemented/feature/2026-06-18-compaction-capability-seam.md) | 2026-06-18 |
| [Subagent capability seam](implemented/feature/2026-06-21-subagent-capability-seam.md) | 2026-06-21 |
@@ -97,6 +98,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
| [Prune dead methods from the persistence seam](implemented/simplification/2026-06-20-prune-dead-seam-methods.md) | 2026-06-20 |
| [Keep one public stop primitive](implemented/simplification/2026-06-20-public-agent-stop-surface.md) | 2026-06-20 |
| [Fold trace-only session facts into load-bearing events](implemented/simplification/2026-06-20-collapse-trace-only-session-events.md) | 2026-06-20 |
| [Split the filesystem seam — provider text mutations plus the `dsh-fs-policy` plugin](implemented/simplification/2026-06-26-fsspec-style-fs-seam.md) | 2026-06-26 |
### Architecture
@@ -114,12 +116,15 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
| [Two LLM adapters as a design-verification twin](implemented/architecture/2026-06-13-twin-llm-adapters.md) | 2026-06-13 |
| [Session persistence as an abstract service over `SessionEvent`](implemented/architecture/2026-06-14-session-persistence.md) | 2026-06-14 |
| [Every session event is enclosed in a turn](implemented/architecture/2026-06-15-turn-enclosure-invariant.md) | 2026-06-15 |
| [Filesystem capability seam — ctx.fs, local backend, and model-facing filesystem tools](implemented/architecture/2026-06-17-filesystem-capability-seam.md) | 2026-06-17 |
| [Shared persistence write coordinator](implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) | 2026-06-18 |
| [Agent lifecycle and ownership seams](implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md) | 2026-06-18 |
| [Session surface — a linked list over the event log for LLM message derivation](implemented/architecture/2026-06-18-session-surface.md) | 2026-06-18 |
| [Reorganize packages into a modular hierarchy](implemented/architecture/2026-06-20-package-hierarchy.md) | 2026-06-20 |
| [Branded IDs everywhere they belong](implemented/architecture/2026-06-20-branded-ids.md) | 2026-06-20 |
| [Extract example apps into packages](implemented/architecture/2026-06-20-extract-example-app-packages.md) | 2026-06-20 |
| [Make `dsh-fs-policy` an event-gate plugin, not a method interface](implemented/architecture/2026-06-26-file-context-as-event-gate.md) | 2026-06-26 |
| [Resolve filesystem paths against the caller's session cwd](implemented/architecture/2026-07-02-fs-per-session-cwd.md) | 2026-07-02 |
### Process
+1 -1
View File
@@ -10,6 +10,6 @@ Update it **in place** to state the current truth. Do **not** leave the outdated
### This is not a license to rewrite the *decision*
Keeping the shipped-state description current is about **facts** (paths, names, structure, defaults) — not about silently flipping the **decision and its rationale** into a different one. If the underlying choice itself is reversed or materially changed (not just relocated), that is a new decision: write a new RFC and cross-link, per [rfc/README.md](../README.md) ("An RFC is never edited into a different decision"). The line: a refactor that moves where the decision is *realized* → edit this RFC to match; a reversal of *what was decided* → a new RFC.
Keeping the shipped-state description current is about **facts** (paths, names, structure, defaults) — not about silently flipping the **decision and its rationale** into a different one. The "new RFC" escape hatch is for **macro** changes — a genuine reversal of *what was decided* or its rationale — NOT for renames, moves, or structural relocations. A rename is always a fact to fix **in place**: leaving a package/symbol/path at its old name (even with a "was renamed to…" aside) only confuses a reader who greps the current tree for a name that no longer exists. So: the package was renamed, a symbol changed, a plugin moved, the decision is now realized through a different mechanism → edit this RFC to state the current names and structure. Only a reversal of *what was decided* → a new RFC and cross-link, per [rfc/README.md](../README.md) ("An RFC is never edited into a different decision").
When in doubt, ask whether a reader following this RFC to the code would land on something real. If not, it needs updating.
@@ -0,0 +1,183 @@
# RFC: Filesystem capability seam — ctx.fs, local backend, and model-facing filesystem tools
Status: implemented
## Problem
The harness has a concrete `bash` capability seam (`dsh-bash` / `dsh-bash-local` / `dsh-tool-bash`), but filesystem operations are about to be added as model-facing tools without an equivalent seam. If `read`, `write`, and `edit` directly use `node:fs`, the model-facing tool package will own filesystem execution policy, local path resolution, atomic write behavior, text decoding, symlink behavior, and edit semantics all at once.
That couples three concerns that change independently:
1. The filesystem contract: what operations plugins can ask for.
2. The backend: local disk now, sandboxed/remote/project-scoped filesystem later.
3. The consumer surface: model-facing `read` / `write` / `edit` schemas and result formatting.
Without a `ctx.fs` interface, swapping local filesystem access for a sandboxed or remote backend would churn the tool schemas, demos, and prompt guidance even when the model-facing contract should stay stable. It also makes permission/sandbox boundaries harder to reason about: a `cwd` option can look like a sandbox even though it is only a base path unless an explicit backend or `tools/execute` policy enforces containment.
We need the filesystem tools to land in the same capability-seam shape as bash before they become a public package surface.
## Proposal
Introduce filesystem access as a first-class capability seam following [the capability-seam RFC](../../implemented/architecture/2026-06-13-capability-seams.md):
1. `@deepseek-ai/dsh-fs` (`packages/fs/fs`) owns the abstract `ctx.fs` service, the filesystem vocabulary types, and the `fs/*` policy event vocabulary.
2. `@deepseek-ai/dsh-fs-local` (`packages/fs/fs-local`) provides the first implementation, backed by the local filesystem.
3. `@deepseek-ai/dsh-tool-fs` (`packages/fs/tool-fs`) provides the model-facing `read`, `write`, and `edit` tools over `ctx.fs`, and is the executor that dispatches the `fs/*` events.
The consumer package depends only on the interface package, never on `dsh-fs-local`. A deployment that wants a different backend loads a different provider for `ctx.fs` without changing the tool schemas or model-facing prompt guidance.
The read-before-write/edit and observed-state policy is a fourth package, `@deepseek-ai/dsh-fs-policy` (`packages/fs/fs-policy`), contributed through the `fs/*` event gate rather than living on `ctx.fs`. This RFC established the three-package seam; the split of policy off the provider base class is decided by [the split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md), and its realization as an event-gate plugin (not a method service) by [the event-gate RFC](2026-06-26-file-context-as-event-gate.md). This document is updated to describe that landed four-package shape.
The first backend is deliberately local-only: `dsh-fs-local` implements `ctx.fs` against the host filesystem. Future sibling backends can provide sandboxed, remote, virtual, or project-scoped filesystems behind the same interface.
The first consumer is deliberately text-file-only: `dsh-tool-fs` exposes model-facing `read`, `write`, and `edit` tools for UTF-8 text files. Future consumers can add directory listing, search/glob, binary-safe operations, file watching, or higher-level project operations without changing the local backend package, as long as the needed capability exists on `ctx.fs`.
Filesystem permissions and sandboxing are not implied by this split. The local backend resolves relative paths from its configured base directory, but containment policy is a separate decision: either a stricter `ctx.fs` implementation enforces it, or a permission/sandbox plugin wraps `tools/execute` and vetoes calls before they reach the consumer.
Read-before-write/edit and observed-state are policy, contributed by the `dsh-fs-policy` plugin through the `fs/*` event gate — NOT stored on `ctx.fs`. The provider seam offers an optional version guard on its mutations (`writeText`/`editText` take an optional expectation); the policy plugin decides that guard by listening on `fs/write-intent`/`fs/edit-intent` and records observed versions on `fs/observed`. The executor (`dsh-tool-fs`) passes the current tool execution context as the opaque event actor; the policy plugin derives the observed-state owner from it, normally `exec.agent.session`. `dsh-fs` treats the actor as opaque and never reads it; `dsh-tool-fs` never reaches into the policy plugin. Authorization is version freshness: any read records the file's version, and a later write/edit is authorized as long as the file is unchanged. (This RFC first placed the observed-state store on `ctx.fs`; the split to `dsh-fs-policy` on the `fs/*` event gate is decided by [the split-fs-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) and [event-gate](2026-06-26-file-context-as-event-gate.md) RFCs.)
## Package topology
The filesystem seam uses the same dependency direction as the bash trio:
```text
@deepseek-ai/dsh-tool-fs --depends on--> @deepseek-ai/dsh-fs <--depends on-- @deepseek-ai/dsh-fs-local
consumer interface implementation
```
`@deepseek-ai/dsh-fs` depends only on `cordis` plus the repo-wide `HarnessError` base from `@deepseek-ai/dsh-llm`. It declares the `ctx.fs` key, the abstract `FileSystem` service, the vocabulary types shared by backends and consumers, the filesystem error vocabulary, and the `fs/*` policy event vocabulary. It carries no observed-state store and no owner-derivation shape; the events pass an opaque `object` actor that the provider never reads, and the `dsh-fs-policy` plugin owns the owner-derivation shape and the observed-state store on top of those events.
`@deepseek-ai/dsh-fs-local` depends on `@deepseek-ai/dsh-fs` and `cordis`. It subclasses `FileSystem`, registers itself as `ctx.fs`, owns local-backend configuration such as the base directory, and contains all direct `node:fs` / `node:path` access. It holds no observed-state store — freshness is a version token the backend mints and the policy plugin records.
`@deepseek-ai/dsh-tool-fs` depends on `@deepseek-ai/dsh-fs`, `@deepseek-ai/dsh-tools`, `@deepseek-ai/dsh-system-prompt`, and `cordis`. It registers model-facing tools and prompt sections. It must not import `node:fs`, `node:path`, or `@deepseek-ai/dsh-fs-local`; filesystem execution always goes through `ctx.fs`. If the implementation needs concrete agent or session helper types, those dependencies belong in `tool-fs`; they must not leak back into `dsh-fs`.
The root `tool-fs` plugin registers the full filesystem tool suite (`read`, `write`, and `edit`) by composing the per-tool registration helpers. It injects `fs` and never imports an implementation package.
## `ctx.fs` contract
`@deepseek-ai/dsh-fs` owns a semantic filesystem service. It is higher-level than `readFile` / `writeFile` so `tool-fs` does not reimplement path resolution, versioning, text decoding, binary rejection, pagination, atomic replacement, symlink behavior, or literal edit semantics.
The exact TypeScript signatures are implementation details for the PR, but the interface must cover four semantic operations:
- Resolve a model/plugin-supplied path into a backend-defined target.
- Read a bounded UTF-8 text page from a target.
- Create or replace a UTF-8 text file.
- Edit an existing UTF-8 text file by literal replacement.
The provider seam also carries the freshness hooks that policy builds on — but the observed-state store and owner derivation live in the `dsh-fs-policy` plugin, not on `ctx.fs`:
- The backend mints an opaque `version` token per target (in `stat` and in every read/mutation outcome).
- `writeText`/`editText` take an OPTIONAL version expectation: omit it for an unconditional bare-provider mutation, or supply it to guard the mutation inside the backend's atomic critical section.
- The `dsh-fs-policy` plugin decides that expectation on `fs/write-intent`/`fs/edit-intent` and records observed versions on `fs/observed`, keyed by an owner it derives from the opaque event actor (normally `exec.agent.session`).
Authorization is version freshness, not a full/partial view distinction: any read records the target's version, and a later write/edit is authorized as long as the file is still at that version — so a windowed read of lines 100-150 authorizes an edit of line 120. The observed-state store is a `WeakMap<owner, Map<targetKey, version>>` inside `dsh-fs-policy`; `dsh-fs` holds none of it and treats the actor as opaque. (This RFC first modeled a `FileState` cache with `full`/`partial` views on `ctx.fs`; the split-fs-seam and event-gate RFCs replaced that with the freshness-based policy plugin described here.)
Path resolution should be explicit and allowed to be async. Local resolution may only normalize a path, but sandboxed/remote/project-scoped backends may need I/O to resolve a user-supplied path into a stable target identity.
Resolved targets must expose at least three concepts:
- The original input path, for diagnostics.
- An opaque `targetKey`, used for stale guards and file-state lookup. The local backend might use a realpath-like key; a remote backend might use a workspace URI or file id. Consumers must not parse or assume this is a local absolute path.
- A `displayPath`, used for model/UI-facing output. It may be a local absolute path, workspace-relative path, or remote URI depending on the backend.
Read and mutation results must include an opaque file `version`. A local backend can use mtime/size or a hash-like token; a remote backend can use a revision id. `ctx.fs` records versions in its file-state store for stale checks; consumers may display related metadata but must not interpret the version token.
The provider hands back decoded text: `readText` returns a whole regular text file, `streamText` streams the same text semantics for large files. Both own regular-file checks, bounded line/output handling is NOT theirs — line windowing, numbered-line rendering, and total-line accounting live in the executor (`dsh-tool-fs`), which reads through `ctx.fs` and renders the model-facing window. The provider owns UTF-8 decoding and binary/NUL rejection; it does not know about line windows or views.
Observed-state recording is not on `ctx.fs`: after a successful read the executor emits `fs/observed`, and the `dsh-fs-policy` plugin records `{ version }` for the deriving owner. There is no `full`/`partial` view — a read at any window records the version, and freshness (not view completeness) authorizes a later write/edit.
Full-file writes create or replace UTF-8 text files. Backends may create parent directories when that behavior is supported and documented. Existing non-regular targets are rejected. `writeText` takes an optional expectation: `createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED` (the path the policy uses for an unobserved owner); `replaceIfVersion` replaces only when the target exists at the observed version, else `FS_STALE_VERSION`; omitting the expectation is the unconditional bare-provider create-or-overwrite. The policy plugin chooses which expectation to supply from the owner's observed state.
Literal edit is a provider primitive (`editText`), not composed in `tool-fs` from a read plus write. Literal matching, duplicate-match rejection, CRLF preservation, binary rejection, optional stale-version checking, and atomic read-modify-write must stay together inside the backend's mutation critical section. `editText` takes the same optional version expectation; the stale check runs before literal matching so an edit against an old read reports `FS_STALE_VERSION`. A remote backend may implement edit as a native compare-and-edit operation; the consumer should not force local-style composition.
The policy plugin, not `ctx.fs`, gates on prior observation: an `edit` requires a prior observation by the owner (else `FS_NOT_OBSERVED`), and the recorded version is passed to `editText` as the CAS basis. With the policy plugin absent, `ctx.fs` alone is a complete unconstrained seam (unconditional write/edit); the tool is never method-coupled to the policy.
Filesystem contract failures are thrown as `FsError extends HarnessError`, and the tool registry converts them into `isError` tool results with structured `{ name, code }` metadata. `dsh-fs` owns this vocabulary rather than each tool inventing messages. The codes are `FS_NOT_FOUND`, `FS_NOT_TEXT`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_NOT_REGULAR_FILE`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, and `FS_ABORTED`. (An earlier draft included `FS_PARTIAL_OBSERVATION`; freshness-based authorization has no partial/full distinction, so it was dropped.)
## Tool consumer behavior
`@deepseek-ai/dsh-tool-fs` is the model-facing consumer. It owns tool names, JSON schemas, argument validation at the model boundary, prompt sections, and result formatting. It does not own filesystem execution.
The first tool suite contains:
- `read`: inspect a UTF-8 text file and return line-numbered content with pagination guidance.
- `write`: create or fully replace a UTF-8 text file.
- `edit`: update an existing UTF-8 text file by replacing literal text, requiring a unique match by default and allowing an explicit replace-all mode.
Each tool follows the same execution shape:
1. Validate and normalize model arguments.
2. Call the appropriate `ctx.fs` operation.
3. Format the result as `ContentBlock[]` for the model.
4. Let thrown backend/tool errors flow through `ToolRegistry.execute()`, which converts them into `isError` tool results.
The package registers prompt guidance through `ctx.systemPrompt.section(...)` and registers schemas through `ctx.tools.register(...)`. Tool schemas still flow into the normal prompt assembly path via `SystemPrompt.assemble()` and `ToolRegistry.schemas()`; no agent-loop changes are required.
The tool package must keep model-facing contracts stable when backends change. A local backend and a remote backend may resolve paths differently internally, but the `read` / `write` / `edit` schemas should not change solely because the backend changes.
The default deployment requires a prior `read` before updating an existing file with `write` or `edit`. `tool-fs` does not implement this by checking whether a tool named `read` ran: it dispatches the `fs/write-intent`/`fs/edit-intent` events (passing the execution context as the opaque actor), and the `dsh-fs-policy` plugin derives the owner, gates on prior observation, and supplies the version expectation. Any windowed read authorizes a later write/edit as long as the file is unchanged. Creating a new file with `write` does not require prior observation.
The root plugin registers the full suite by composing the per-tool registration helpers. It injects `fs`, `tools`, and `systemPrompt`.
## Migration plan
This RFC starts from `origin/master`, where no filesystem tool package exists yet. The landed implementation adds the new three-package topology directly:
1. Add `packages/fs/fs` with the `ctx.fs` abstract service and vocabulary types.
2. Add `packages/fs/fs-local` with the local backend implementation and backend-level tests.
3. Add `packages/fs/tool-fs` with the model-facing `read`, `write`, and `edit` tools over `ctx.fs`.
4. Update `docs/architecture.md`, `packages/README.md`, package READMEs, build/typecheck config, and aggregate maintenance scripts such as `scripts/publint-all.ts`.
This RFC's first landing kept the observed-state store behind `ctx.fs`. The split-fs-seam and event-gate RFCs then moved it into the standalone `@deepseek-ai/dsh-fs-policy` plugin on the `fs/*` event gate, which is the shipped shape; a deployment loading `dsh-tool-fs` also loads `dsh-fs-policy` to get read-before-write/edit.
Example leaf configs stay bash-only in this landing. Wiring `examples/coding-agent` or `examples/acp-agent` to `dsh-fs-local` + `dsh-tool-fs` changes the model prompt, visible tool schemas, and ACP snapshot transcript, so it should land as a follow-up UX/example change with prompt and snapshot updates in the same PR.
If this work is split into multiple PRs, they should follow the seam order:
1. Interface PR: `dsh-fs` only, with service registration and contract tests.
2. Implementation PR: `dsh-fs-local`, with real filesystem behavior tests.
3. Consumer PR: `dsh-tool-fs`, docs, and integration tests; example wiring follows in a separate prompt/snapshot PR.
The earlier combined package name `@deepseek-ai/dsh-fs-tools` should not become part of the new public surface.
## Tests
Tests should follow the package boundary, not only the user-visible tools.
`dsh-fs` tests cover the service seam itself: a provider registers `ctx.fs`, duplicate providers follow Cordis service behavior, disposal removes the service, and any shared contract helpers or type-level utilities behave as documented.
`dsh-fs-local` tests cover real filesystem behavior through the `ctx.fs` interface, not through model tools. They should include path resolution, absolute paths, `..` segments, symlinks inside and outside the configured base directory, reading small and large text files, streaming, binary-file rejection, invalid-UTF-8 rejection, abort handling, unconditional and version-guarded full-file writes, `createIfAbsent`/`replaceIfVersion` semantics, parent-directory creation, non-regular target rejection, literal edit success/failure, unique-match enforcement, replace-all behavior, line-ending preservation, stale-version rejection (guarded edit against an old version), and structured `FsError` codes. The observed-state/owner-derivation policy is NOT here — it lives in `dsh-fs-policy` and is tested there.
Beyond the happy/sad paths above, `dsh-fs-local` tests must cover the defensive-pattern classes this repo has been bitten by:
- **Atomic-write temp-file safety**, not just cleanup. The atomic replace must write its temp file into a private (`0700`) directory, with a random name and an exclusive owner-only (`'wx'`, `0o600`) open, mirroring the bash spill-file rules — predictable world-readable temp paths invite symlink races and disclosure. Assert the temp file's permissions and that a pre-existing temp path does not get clobbered, alongside the existing cleanup-on-failure path.
- **Implementation requirement:** `dsh-fs-local` write/edit use the same private-temp primitive: a random `0700` staging directory next to the target, an exclusive `0o600` temp file, cleanup on failure, and a final atomic rename. Do not move this RFC to `implemented/` if that primitive regresses or is deliberately revised.
- **`targetKey` identity through symlinks.** Two different input paths that resolve to the same realpath must share one file-state entry: a `read` via path A must satisfy the read-before-edit guard for an `edit` via symlink path B, and a stale write through one path must be detected through the other. This is the contract that makes the stale guard correct, so test it directly.
- **Concurrency / stale races.** The RFC names edit as race-prone (see Risks). Test that two concurrent write/edit operations against the same target settle deterministically: one succeeds and the other is rejected with `FS_STALE_VERSION` rather than silently overwriting, and that a successful edit refreshes recorded state so an immediately-following edit by the same owner proceeds.
- **HMR safety and disposal.** `dsh-fs-local` registers `ctx.fs` and owns the in-memory file-state store, so it needs its own HMR-safety test (register the backend on a fiber, dispose it, assert the `ctx.fs` provider is withdrawn and the file-state store is released — a later provider starts with no inherited state).
`dsh-tool-fs` tests cover the consumer surface against the real `dsh-fs-local` provider (mock only the model/clock, not the collaborator). They should verify tool schemas, argument validation, prompt-section registration, formatting of successful results, propagation of backend `FsError` codes into `isError` tool results through `ctx.tools.execute()`, that read/write/edit dispatch the `fs/*` events (passing the execution context as the actor), root-plugin suite registration, and HMR cleanup of both tool schemas and prompt sections.
Integration tests should load `dsh-fs-local` plus `dsh-tool-fs` (and, for the default deployment, `dsh-fs-policy`) and execute `read`, `write`, and `edit` through `ctx.tools.execute()` to prove the packages work together without bypassing the tool registry — including a bare-provider path (no `dsh-fs-policy`) where an unread edit/overwrite succeeds. They must verify the world, not the tool's self-report: after a `write`/`edit`, read the file back from disk and assert byte-identical content (and that untouched files are unchanged), rather than trusting the returned `ContentBlock[]`. Each integration/e2e test owns its resources — create the harness in the test, run against a per-test temporary directory, and dispose the harness and remove the directory in `afterEach` even on failure or timeout.
Repo gates for the implementation include the focused vitest suites, `pnpm run typecheck`, `pnpm run test:coverage` for runtime code, and build/publint coverage after adding package entrypoints.
## Risks
**`cwd` can be mistaken for a sandbox.** The local backend's base directory is a resolution default, not automatically a containment boundary. If containment is required, it must be enforced by the backend contract or by a permission/sandbox plugin on `tools/execute`.
**The interface can become too local.** Returning fields such as `absolutePath` from `ctx.fs` would make remote, sandboxed, or virtual backends awkward. The contract should expose display metadata without requiring consumers to understand host paths.
**The interface can become too thin.** If `ctx.fs` only mirrors `node:fs` primitives, `tool-fs` will reimplement binary detection, pagination, atomic writes, and edit semantics. That recreates the coupling this RFC is trying to avoid.
**Edit semantics are race-prone.** Literal edit is a read-modify-write operation. Without a stale-content guard or backend-level atomic edit primitive, concurrent edits can overwrite each other. The first implementation should document its guarantees clearly; stronger compare-and-swap semantics can be added later if needed.
**Observed state does not belong on `ctx.fs`.** Recording what an execution context has seen is workflow policy, not raw filesystem I/O. This RFC first placed it inside the filesystem seam; the split-fs-seam RFC then established that a sandboxed/remote backend should not inherit model-facing observation policy, and moved it into the `dsh-fs-policy` plugin. The provider seam keeps only what write/edit safety genuinely needs at the storage layer — a backend-minted version token and an optional version-guarded mutation — while the policy plugin owns owner derivation, observed-state, and read-before-edit gating over the `fs/*` events.
**The `resolve`-then-operate shape costs an extra round-trip per call.** Each tool may resolve a path to an `FsTarget` and then issue the read/write/edit as a separate `ctx.fs` call. For the local backend this is negligible (resolution is in-memory path normalization), but a remote/sandboxed backend may turn each step into its own request, so a single `read` can become two network round-trips. Backends where the round-trip matters can cache or fold resolution internally while preserving the observable contract.
**File-state persistence is deferred.** The first implementation can keep file state in memory. Resumed sessions should conservatively require files to be read again before write/edit tools accept updates until a future session-event or persistence mechanism makes file state replayable.
**Error codes become part of the seam.** `FsError` codes make stale-version and observation failures machine-routable through the existing structured error taxonomy. The cost is that `dsh-fs` imports the shared `HarnessError` base from `dsh-llm`; that dependency is intentional and should stay limited to the error vocabulary.
**Package churn is front-loaded.** The three-package split adds boilerplate before there is more than one backend. This is intentional: filesystem access is a likely sandbox/remote boundary, and changing the package surface after shipping model-facing tools would be more expensive.
@@ -0,0 +1,175 @@
# RFC: Make `dsh-fs-policy` an event-gate plugin, not a method interface
Status: implemented
## Problem
[The split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md) put `ctx.fileContext` between the model-facing tools and the `ctx.fs` provider: `dsh-tool-fs` injects `fileContext` and routes every `read`/`write`/`edit` through its methods. That makes `fileContext` **in-path and mandatory**. The tool cannot reach `ctx.fs` without it, the policy layer owns the fs I/O and the read windowing, and a deployment that does not want observed-state policy cannot simply drop the package — `dsh-tool-fs` would fail to resolve `ctx.fileContext`.
This couples three things that should be separable:
1. **What the tool does** — resolve a path, read a window, write/edit a file. This is the tool's job and needs only `ctx.fs`.
2. **The freshness/observation policy** — "edit requires a prior read", "write/edit must be based on the version you read". This is the `dsh-fs-policy` plugin's job.
3. **The recording of observed state** — a side effect that should never block the tool from functioning.
Because the tool calls `fileContext` methods, removing the policy layer is a breaking change rather than a graceful loss of an *add-on*. The policy is load-bearing for the tool to even run, not an opt-in tightening.
## Decision
Invert the control flow. **`dsh-tool-fs` becomes the executor and calls `ctx.fs` directly**; **`dsh-fs-policy` becomes a gate + recorder plugin** that participates through events, never through a method the tool calls and never by registering a `ctx.fileContext` service.
```text
tool dsh-tool-fs executor: resolves, reads windows, writes/edits via ctx.fs;
emits fs policy events; renders results
policy dsh-fs-policy plugin: listens to fs/write-intent +
fs/edit-intent (single-slot waterfall) and fs/observed
(emit) events; adds observed-state + freshness.
provider seam dsh-fs ctx.fs: text IO + ATOMIC mutation primitives whose version
guard is OPTIONAL; owns the fs policy event vocabulary
provider dsh-fs-local local implementation of ctx.fs
```
The model is **additive, not subtractive**: `ctx.fs` on its own is a complete, unconstrained text-storage seam — `read` reads, `write` unconditionally creates-or-overwrites, `edit` unconditionally replaces literal text in the current content. There is no "先读后写", no version check, nothing to remove; the bare provider just does the I/O atomically. `dsh-fs-policy` is a plugin that *adds* constraints on top: observed-state, read-before-edit, and "write/edit must be based on the version you read". So removing `dsh-fs-policy` does not break `dsh-tool-fs` at the service-injection boundary; it removes the policy gate and leaves the bare provider behavior. The intended deployment stance is that a config loading the fs tools also loads `dsh-fs-policy`, so the user-facing behavior and prompt discipline are read-before-write/edit (the `coding-agent` and `acp-agent` demos wire the full stack). The bare-provider mode exists because the tool should not be method-coupled to the policy plugin, not because an unconstrained filesystem is the normal product stance.
`dsh-tool-fs` no longer injects `fileContext`. It injects `fs` and `tools`/`systemPrompt`.
## The policy is enforced by provider CAS, not by `dsh-fs-policy` stat
`dsh-fs-policy` enforces "you must write/edit based on the version you read" **without ever calling `stat` or comparing versions itself**. It supplies the observed version as the CAS basis and lets the provider's mutation critical section detect staleness:
- "Have you read this file?" is the one thing `dsh-fs-policy` decides locally — a `WeakMap` lookup, no I/O. No record ⇒ `FS_NOT_OBSERVED`.
- "Is the version you read still current?" is decided **inside `ctx.fs.editText`/`writeText`**, in the same atomic lock that performs the read-match-rename. `dsh-fs-policy` passes `vObserved` as the expectation; the provider raises `FS_STALE_VERSION` if the file has moved on.
This is deliberate. If `dsh-fs-policy` stat-ed and compared versions in its waterfall handler, there would be a TOCTOU gap between that check and the tool's actual write — the file could change in between, so the check would be a false guarantee that the provider's lock has to back up anyway. Putting the version check in the provider's critical section is both race-free and zero extra `stat`. So `dsh-fs-policy` does **no** filesystem I/O; the "must be based on the latest read" guarantee is *realized* by CAS, and `dsh-fs-policy` only chooses the basis (`vObserved`) and gates on prior observation.
## Provider contract change: the version guard is optional
For the bare provider to be unconstrained, the version guard on its two mutations becomes **optional** — present ⇒ guarded, absent ⇒ unconditional:
```ts ignore-check
// writeText: expected is now optional. The FsWriteIntent union is UNCHANGED.
writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise<FsWriteOutcome>
// undefined → unconditionally create-or-overwrite (bare default)
// createIfAbsent → create only, reject an existing file (dsh-fs-policy, unobserved) [unchanged]
// replaceIfVersion → overwrite only at the observed version, else FS_STALE_VERSION [unchanged]
// editText: expected becomes optional (was the required { version: FsVersion }).
editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome>
// undefined → unconditionally replace literal text in the current content (bare default);
// a missing target still reports FS_STALE_VERSION
// { version } → edit only at that version, else FS_STALE_VERSION (the current behavior)
```
The `FsWriteIntent` union itself does not change — the third "unconditional" state is expressed by *omitting* `expected`, so both mutations share one symmetric shape (`expected?`: omit = no guard, present = guarded). This keeps full backward compatibility for the guarded paths `dsh-fs-policy` uses; only the previously-impossible "no guard" case is new, and it is the bare-provider default. The mutation still runs inside the backend's per-target lock either way, so an unconditional write/edit is still atomic (no torn files); "unconditional" drops the *version* precondition, not the atomicity. `editText` reports a missing target as `FS_STALE_VERSION` on both guarded and unguarded paths, preserving one edit failure code for "the target cannot be edited at this moment".
## Event vocabulary (owned by `dsh-fs`)
The events live in `@deepseek-ai/dsh-fs`, not in `dsh-fs-policy`. This is forced by the decoupling contract: `dsh-tool-fs` is the emitter, so it must reference the event types, and it must keep compiling even though `dsh-fs-policy` no longer provides a method service. `dsh-fs` is the package both `dsh-tool-fs` and `dsh-fs-policy` already depend on, so it is the only home that lets the emitter and the policy listener share a vocabulary without the emitter depending on the policy plugin.
These events carry existing `dsh-fs` vocabulary (`FsTarget`, `FsVersion`, `FsWriteIntent`) plus an opaque actor — not model-facing concepts (no line windows, numbered lines, or rendered footers leak down).
**The two `fs/*` decision events are single-slot decision points, NOT a composable interception chain.** A waterfall listener that does not call `next()` short-circuits the rest of the chain (verified in [vendor/cordis/src/events.ts](../../../../vendor/cordis/src/events.ts) — `waterfall` runs listeners around the final `next` thunk, and a listener that returns without calling `next()` reaches neither later listeners nor the tool's default thunk). `dsh-fs-policy` fully decides the write/edit expectation and does not call `next()`, so it occupies that one decision slot in the default deployment. This is deliberate: "what version basis does this mutation guard against" is a single decision, not an accumulation. The names (`fs/write-intent`, `fs/edit-intent`) say "produce the value", not "authorize", so they do not imply a stackable authorization chain. Genuinely composable interception (permission, audit, sandbox) belongs on the existing `tools/execute` waterfall, which every tool call already flows through — not on this fs version-decision slot.
**The occupant is decided by registration order — first-registered (or `prepend`ed) wins.** cordis dispatches waterfall listeners in registration order (`push`, or `unshift` for `prepend` — [vendor/cordis/src/events.ts](../../../../vendor/cordis/src/events.ts)), and the first non-`next()` decider short-circuits the rest. So the slot is **first-wins**, and `dsh-fs-policy` owning it rests on the default deployment convention: it is the decider registered for these events. The event shape does NOT itself guarantee "an unread edit is rejected" — a plugin that registers a looser `fs/edit-intent` decider BEFORE `dsh-fs-policy` (or with `prepend`) would decide first and bypass the `FS_NOT_OBSERVED` gate. That is the inherent property of a first-wins single slot, stated here so it is not mistaken for an enforced invariant. This RFC does not add a multi-policy composition mechanism; the implementation requirement is that `dsh-tool-fs` dispatches these waterfalls on every write/edit path and that a config wiring the fs tools loads `dsh-fs-policy` as the policy decider.
The actor is typed `object` in `dsh-fs` — a pure opaque carrier the provider seam never reads or narrows. The owner-derivation (`actor.agent?.session`) and the `{ agent?: { session? } }` structural shape stay entirely inside `dsh-fs-policy`, which narrows the `object` actor to that shape in its listeners. `dsh-fs` owns the event names and the fs vocabulary; it does NOT own the policy layer's runtime owner structure.
```ts
import type { FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs'
interface Events {
/**
* Single-slot decision: produce the write expectation for the next
* ctx.fs.writeText. The default returns undefined (unconditional create-or-
* overwrite — the bare provider). The policy listener returns createIfAbsent
* (unobserved) or { kind: 'replaceIfVersion', version: vObserved } (observed).
* The listener does NOT call next(): one decision, not a composable chain. @mode waterfall
*/
'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
* ctx.fs.editText. The default returns undefined (unconditional edit of the
* current content — the bare provider; no stat). The 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. @mode waterfall
*/
'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). Listeners MUST be
* synchronous, side-effect-only recorders (`dsh-fs-policy`'s is a WeakMap
* write); the tool does not guard the emit, so a throwing listener surfaces as
* the tool's isError result. No listener ⇒ nothing recorded.
* @mode emit
*/
'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void
}
```
The `fs/*` decision events are **unbound waterfalls dispatched by the tool** (like `agent/request`, which the loop dispatches with no `this`), not service-bound waterfalls (like `llm/stream`). The dispatcher is the `dsh-tool-fs` plugin, which is not a service.
## Tool contract (`dsh-tool-fs`)
The tool keeps its model-facing schemas (`read`/`write`/`edit`, byte-for-byte unchanged) and prompt sections. The prompt guidance stays policy-first because a deployment loading the fs tools is expected to also load `dsh-fs-policy`: the model is still told to read before overwriting or editing, and any wording that says the "backend" requires that should be corrected to say the fs-policy plugin requires it. The bare-provider fallback does not change the prompt stance.
`dsh-tool-fs` gains the executor responsibilities relocated from the old `fileContext` method service, including **read rendering** (`read-render.ts`: `buildWindow` + `formatReadOutput`, `READ_MAX_BYTES`, `READ_MAX_LINE_LENGTH`, `FileReadOutcome`/`FileTextLine`, plus `STREAM_MIN_SIZE` in `read.ts`), which is the tool's rendering detail now that the tool owns the read. Those read-rendering types and helpers move into `dsh-tool-fs`; the policy plugin must not remain a type dependency for the tool.
`dsh-tool-fs` is a single root plugin that registers all three tools (`read`/`write`/`edit`), mirroring `dsh-tool-bash`. It injects `fs` (plus `tools`/`systemPrompt`), never `fileContext`. (The original proposal also exposed each tool as a `/read`/`/write`/`/edit` subpath plugin for focused deployments; that was dropped on implementation — no consumer needed a single-tool deployment, and the subpath publishing forced bespoke `tsdown`/`tsconfig`/`files`/workspace-constraint handling no sibling tool package carries. The per-tool registration helpers (`applyReadTool`/`applyWriteTool`/`applyEditTool`) remain internal modules the root plugin composes.)
`stat` budget is minimized by letting the waterfall produce the expectation lazily — the bare default returns `undefined` (no guard) and never stats:
- **read** — one `stat` (type + size routing + version), then `readText`/`streamText`, then `buildWindow`, then an `emit('fs/observed', target, info.version, exec)`. The post-read confirming `stat` from the old `fileContext.read` is dropped; a writer racing between the routing stat and the read can at worst make a *later* guarded edit spuriously `FS_STALE_VERSION` (fail-closed: the model re-reads, never writes against the wrong version, since `editText` re-checks in its lock).
- **write** — `expectation = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)`, then `ctx.fs.writeText(target, content, expectation)`, then an `emit('fs/observed', target, outcome.version, exec)`. **Zero stat in the tool** with or without `dsh-fs-policy`.
- **edit** — `expectation = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)`, then `ctx.fs.editText(target, edit, expectation)`, then an `emit('fs/observed', target, outcome.version, exec)`. **Zero stat in the tool** in both cases: the bare default is `undefined` (unconditional edit), so the tool never stats to manufacture a basis. If the target is absent, the provider reports `FS_STALE_VERSION` even on the unguarded path.
The tool passes `exec` (the tool-execution context) as the `actor` argument on every dispatch, so `dsh-fs-policy` can derive its observed-state owner. The tool does not know whether the policy plugin is present: it always provides the bare default behavior in the `next` thunk, and `dsh-fs-policy` short-circuits the thunk before it runs in the default deployment.
**`fs/observed` fires AFTER the mutation already succeeded**, via a plain `ctx.emit`. The event contract is intentionally narrow: an `fs/observed` listener MUST be synchronous and side-effect-only — `dsh-fs-policy`'s listener is a `WeakMap.set`, which cannot throw under normal operation and returns no promise. The tool does not guard the emit, so a listener that violates the contract by throwing would surface as the tool's `isError` result ([tools/index.ts](../../../../packages/core/tools/src/index.ts) — `ToolRegistry.execute` catches a tool throw into an error result) — reporting failure for a write/edit that actually happened. That is the price of keeping the event a plain fire-and-forget recorder: cordis `emit` does not await listener promises, so async or fallible audit/telemetry/listener work does not belong on this event. If layered or async observation is ever wanted, that is a new event with its own dispatch story.
## Policy plugin contract (`dsh-fs-policy`)
`dsh-fs-policy` is a plugin, not a service. It does not register `ctx.fileContext`, has no public method surface, and exposes no `read`/`write`/`edit`/`resolve` methods. It attaches three listeners via `ctx.on()` registrations (each returning a disposer for HMR). It keeps the observed-state `WeakMap<owner, Map<targetKey, { version }>>` and the structural owner derivation (narrowing the event's opaque `object` actor to its own `{ agent?: { session? } }` shape), but does not inject `fs` — every handler operates only on its own `WeakMap`, never on `ctx.fs`.
- `fs/write-intent` listener: `prior = getObserved(owner, key)`; return `prior ? { kind: 'replaceIfVersion', version: prior.version } : { kind: 'createIfAbsent' }`. It does NOT call `next()`: it fully owns the single decision slot.
- `fs/edit-intent` listener: `prior = getObserved(owner, key)`; if no `owner` or no `prior`, throw `FS_NOT_OBSERVED`; else return `{ version: prior.version }`. Also does not call `next()`.
- `fs/observed` listener: `record(owner, key, version)`.
An observed-state entry is the **prior-observation record**: a successful `read`, `write`, OR `edit` all emit `fs/observed` and record `{ version }`, so the entry's presence means "this owner has observed this target at this version", not narrowly "has read it". This is what lets a create-then-edit or edit-then-edit sequence work without an intervening re-read: the mutation refreshes the recorded version to its own result, so the next edit's basis is the version it just produced. `FS_NOT_OBSERVED` rejects only an edit with NO prior observation of any kind. The owner is derived structurally from `{ agent?: { session? } }`; disposal drops all state (HMR safety).
`dsh-fs-policy` is now a pure policy/recording plugin with no service surface — it influences the world only through the event seam. That is what removes the method coupling from `dsh-tool-fs`.
## Bare-provider behavior (no `dsh-fs-policy`)
This is not the intended deployment stance — a config loading the fs tools is expected to also load `dsh-fs-policy`. It is the unconstrained provider floor that exists once the tool is no longer coupled to a policy method service. With `dsh-fs-policy` absent, every `fs/*` waterfall falls through to its `undefined` default and `fs/observed` has no listener:
- **read** is identical (it never needed policy; it only emits a now-unheard `fs/observed`).
- **write** unconditionally creates-or-overwrites: `expected` is `undefined`, so `writeText` writes whether or not the file exists and whatever its current version. No read-first requirement, no version check.
- **edit** unconditionally replaces literal text in the file's current content: `expected` is `undefined`, so `editText` matches and rewrites without a version guard or a read-first requirement (`FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` still apply — those are about the literal match, not freshness). A missing target still reports `FS_STALE_VERSION`, matching the guarded edit path's "cannot edit this target now" code.
Both mutations are still atomic (the backend's per-target lock is unconditional). What is simply *absent*, not lost, is the policy `dsh-fs-policy` would add: observed-state, read-before-edit, and version-guarded write/edit. Loading `dsh-fs-policy` layers those constraints on by having its listeners return guarded `expected` values instead of `undefined`; nothing in the bare provider changes.
## Supersedes
This amends — does not reverse — [the split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md). The four-layer split, the provider contract, and the freshness *policy* are all kept. What changes is the **coupling between the tool and the policy layer**: a mandatory method service became a plugin-owned event gate, and the fs I/O + read windowing moved from `fileContext` up into `dsh-tool-fs`. The split-fs-seam RFC's description of `dsh-tool-fs` injecting `fileContext` and of `fileContext` owning `read`/`write`/`edit` was updated to match in the same change.
## Acceptance Criteria
- The `dsh-tool-fs` root plugin injects `fs` (+ `tools`/`systemPrompt`), not `fileContext`; it calls `ctx.fs` directly and dispatches the `fs/write-intent`/`fs/edit-intent` waterfalls (passing `exec` as the actor) and the `fs/observed` emit. Read rendering lives in `dsh-tool-fs`. (No subpath plugins — see the Tool contract above.)
- `dsh-fs` declares the three events with `@mode` tags and an opaque `object` actor argument (no agent/session structure leaks into the provider vocabulary); the generated cordis catalog is regenerated.
- `dsh-fs-policy` is a plugin, not a service: it does not register `ctx.fileContext`, has no public `read`/`write`/`edit`/`resolve` methods, and does not inject `fs`; it registers the three listeners, keeps observed-state, and has HMR/disposal coverage (dispose the fiber, assert the gate no longer rewrites).
- **Bare-provider test**: a config WITHOUT `dsh-fs-policy` boots the `dsh-tool-fs` root plugin, and `read`/`write`(create AND overwrite)/`edit` work against the real `dsh-fs-local`; an `edit` of an unread existing file and an overwrite of an existing unread file both succeed (unconditional bare-provider behavior), proving the tool carries no `fileContext` dependency. A bare-provider edit of a missing target reports `FS_STALE_VERSION`. With `dsh-fs-policy` present, the same unread `edit` is rejected `FS_NOT_OBSERVED` and the same unread overwrite uses `createIfAbsent` (rejected on an existing file).
- **Single-slot semantics**: a test registers a second `fs/edit-intent` listener AFTER `dsh-fs-policy` and asserts it is NOT reached (first-wins short-circuit), and documents in a comment that a decider registered before/`prepend`ed would instead win — the slot is first-wins by convention, not an enforced invariant.
- **Fire-and-forget recording**: `fs/observed` is emitted via a plain `ctx.emit` after the mutation succeeds; a listener is contractually synchronous and side-effect-only, so the tool does not guard it.
- `dsh-fs` `writeText`/`editText` make `expected` optional (omit ⇒ unconditional); the `FsWriteIntent` union is unchanged, and `dsh-fs-policy`'s guarded paths (`createIfAbsent`/`replaceIfVersion`/`{ version }`) behave exactly as today. A bare-provider test exercises an unconditional overwrite, an unconditional edit, and a missing-target edit reporting `FS_STALE_VERSION`.
- Freshness is enforced by provider CAS when guarded: an edit after a stale read reports `FS_STALE_VERSION` (regression test); `dsh-fs-policy` performs no `stat`.
- `stat` budget: read = 1, write = 0, edit = 0 — in the tool, with or without `dsh-fs-policy` (the bare default returns `undefined`, never stats). A test asserts neither write nor edit stats in the tool on either path.
- Model-facing schemas stay byte-for-byte unchanged; snapshot transcript goldens are unaffected (or the diff is reviewed and re-recorded with justification).
- Docs/artifacts updated in the same change: `docs/architecture.md`, fs package READMEs, `docs/core-data-structures/filesystem.md`, the split-fs-seam RFC's now-amended description, type-equiv blocks + manifest, cordis catalog, module graph. Gates green: `doc-sync`, `knip`, `test:coverage` (100% per-file).
## Risks
- **Event indirection over a method call.** A waterfall + emit is less direct than `await ctx.fileContext.edit(...)`. The payoff is removing the tool-to-policy method dependency while keeping the default policy plugin; the cost is one more event vocabulary to learn. Mitigated by keeping the three events narrow and documenting the default-thunk semantics on each.
- **Policy events in the storage seam.** `dsh-fs` gains two version-decision events plus a recording event though it is "just storage". This is the price of decoupling (the emitter cannot depend on the policy plugin). The events carry only `dsh-fs` vocabulary plus an opaque `object` actor and no model-facing concepts, so the seam stays free of line-window/observation policy types and of the agent/session owner structure.
- **Single policy occupant, first-wins by convention.** The `fs/write-intent`/`fs/edit-intent` slots hold exactly one decider; the first-registered (or `prepend`ed) listener wins and the rest are short-circuited. `dsh-fs-policy` owning the slot is a deployment convention, not an event-enforced invariant — a second decider registered first would bypass it. This is acceptable because a second fs-version-policy decider is a misconfiguration, not a feature. If a future need for *layered* fs version policy appears, it is a new RFC (a composable value-passing seam), not a silent second listener on these events. Layered permission/audit/sandbox interception already has its home on `tools/execute`.
- **Dropping the post-read confirming stat** makes a follow-up *guarded* edit occasionally fail-closed (`FS_STALE_VERSION` → re-read) under a read/write race. This is a UX nicety lost, never a correctness hole; the provider lock still prevents wrong-version writes.
- **The bare provider does no read-before-write/edit and no version check.** A deployment without `dsh-fs-policy` lets the model overwrite or edit any existing file unconditionally. This is the deliberate meaning of keeping the tool independent of a policy service: the safety disciplines live in the `dsh-fs-policy` plugin. A deployment that omits it is opting into an unconstrained filesystem on purpose; that is not the intended stance for a config that ships the fs tools.
@@ -0,0 +1,30 @@
# RFC: Resolve filesystem paths against the caller's session cwd
Status: implemented
## Problem
The ACP bridge gives every session its own workspace: `session/new` records the editor's project directory as `SessionHeader.cwd`, and `dsh-tool-bash` defaults each bash call's `workdir` to the calling agent's `session.header.cwd` (see [the per-session cwd RFC work in `packages/ui/acp`](../../../../packages/ui/acp) and `resolveWorkdir` in `dsh-tool-bash`). So a bash command in session A runs in A's project, and in session B runs in B's — one server process, N workspaces.
The filesystem tools did NOT honor this. `ctx.fs.resolve(path)` took no caller context, and `dsh-fs-local` resolved every relative path against a single `config.cwd` fixed at plugin load (`process.cwd()`). In the ACP demo that means `write foo.txt` and `bash cat foo.txt` resolve `foo.txt` against **different** directories — the fs tools against the server's launch dir, bash against the session's project dir. The two tools disagree about what "the current directory" is, which is a correctness bug the moment an editor opens any project other than the server's launch dir. It only appeared to work in the snapshot harness because that harness launches the child process in the same temp dir it passes as the session cwd, so the two coincide.
## Decision
Thread the caller's session cwd into path resolution, exactly as `dsh-tool-bash` already does for `workdir`. The **caller** (the tool) supplies the cwd; the provider does not read a session or agent.
- `FileSystem.resolve` widens to `resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget>`. `opts.cwd` is the base a RELATIVE `path` resolves against; an absolute `path` ignores it; omitting `opts.cwd` uses the backend's own default. An options object (not a positional `cwd?`) leaves room for future resolution hints without another signature change.
- `dsh-fs-local.resolve` uses `resolveLocalTarget(opts?.cwd ?? this.config.cwd, path)`. `config.cwd` stays the default for a caller that supplies none (non-ACP / no-session use, and the single-session stdio demo where `process.cwd()` IS the workspace).
- `dsh-tool-fs`'s `read`/`write`/`edit` derive the session cwd through a shared `sessionCwd(exec)` helper (`exec.agent?.session.header.cwd`, mirroring bash's `resolveWorkdir`) and pass it to `resolve`. A non-agent / headerless caller yields `undefined`, so the backend applies its default.
## Why the caller supplies the cwd (not the provider)
The provider seam must not depend on `dsh-agent` / `dsh-session` — it is a text-storage backend that a sandboxed or remote implementation also satisfies, and those have no notion of an "agent session". The tool already receives the `ToolExecution` (`exec`), which carries the agent, so the tool is the right place to project `exec → cwd` and hand the provider a plain string. This is the "explicit > implicit at package seams" convention: the base directory arrives as an explicit argument the provider acts on, not smuggled in by having the provider reach into a session it should not know about. It also matches `dsh-tool-bash` one-to-one, so the two model-facing file surfaces resolve paths identically.
The default lives in ONE place — the provider's `config.cwd`. `sessionCwd` returns `undefined` rather than `process.cwd()` when there is no session, so the tool never manufactures a base the provider would otherwise choose.
## Consequences
- In the ACP demo the fs tools and bash now agree on each session's workspace; an editor can open any project folder and both tool families act on it.
- No change to `FsTarget` identity: `targetKey` is still the realpath of the resolved absolute path, so observed-state keying and symlink identity are unaffected — a correct per-session cwd produces the same key bash targets.
- Backward compatible: every existing `resolve(path)` call (all in tests) keeps working; the new argument is optional.
- The single-session stdio demo is unaffected: it supplies no session cwd (its agent's session has no `cwd`), so resolution falls back to `config.cwd = process.cwd()`, which is the workspace.
@@ -0,0 +1,113 @@
# RFC: Filesystem tool schemas — model-facing read/write/edit shapes
Status: implemented
## Problem
[The filesystem capability-seam RFC](../architecture/2026-06-17-filesystem-capability-seam.md) defines the filesystem capability seam (`ctx.fs`), the package split (`dsh-fs`, `dsh-fs-local`, `dsh-tool-fs`, plus the `dsh-fs-policy` policy plugin), and the observed-file/stale-version policy for read-before-write/edit checks — which the [split-fs-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) and [event-gate](../architecture/2026-06-26-file-context-as-event-gate.md) RFCs moved off `ctx.fs` into the `dsh-fs-policy` plugin on the `fs/*` event gate. The remaining decision for the first filesystem tool delivery is the model-facing schema surface: what arguments the model sees for `read`, `write`, and `edit`.
The schema should be small enough to implement in the first `dsh-tool-fs` pass, but stable enough that future local/remote/sandboxed filesystem backends do not require model-facing churn. It should also avoid importing every option from reference systems. Claude Code and OpenCode expose similar core file tools but differ in naming style and extra flags; this RFC chooses the minimal shared surface for the prototype.
## Proposal
`@deepseek-ai/dsh-tool-fs` exposes these three model-facing tools in the first filesystem suite:
| Tool | Our schema | Claude Code | OpenCode | Notes | Part of prototype |
|---|---|---|---|---|---|
| `read` | `read(file_path, offset?, limit?)` | `Read(file_path, offset?, limit?, pages?)` | `read(filePath, offset?, limit?)` | Files only; 1-indexed `offset`; no image/PDF/multimodal support in the first pass. | YES |
| `write` | `write(file_path, content)` | `Write(file_path, content)` | `write(content, filePath)` | Creates or overwrites UTF-8 text. Under the default fs-policy, updates to existing files require a prior observation; new-file creates do not. | YES |
| `edit` | `edit(file_path, old_string, new_string, replace_all?)` | `Edit(file_path, old_string, new_string, replace_all?)` | `edit(filePath, oldString, newString, replaceAll?)` | Literal string replacement; unique match required by default; under the default fs-policy requires a prior observation (any windowed read counts). | YES |
The schema uses snake_case field names (`file_path`, `old_string`, `new_string`, `replace_all`) to align with Claude Code and with existing DeepSeek Harness tool-schema examples. The consumer package translates these model-facing names into `ctx.fs` calls and `fs/*` event dispatches.
## Tool schemas
### `read`
`read` inspects a UTF-8 text file and returns line-numbered content.
Arguments:
- `file_path: string` — required. Path to read, resolved by `ctx.fs`.
- `offset?: number` — optional. 1-based first line to return. Defaults to the first line.
- `limit?: number` — optional. Maximum number of lines to return. Defaults and caps are implementation details of `dsh-tool-fs` / `ctx.fs`.
Non-goals for the first pass:
- No PDF `pages` argument.
- No image or multimodal file reads.
- No directory listing through `read`; if needed, listing becomes a separate future tool.
### `write`
`write` creates or fully replaces a UTF-8 text file.
Arguments:
- `file_path: string` — required. Path to write, resolved by `ctx.fs`.
- `content: string` — required. Full UTF-8 text content to write.
Under the default fs-policy, updating an existing file with `write` requires a prior observation (a read/write/edit) of that file by the same execution context; the `dsh-fs-policy` plugin supplies the observed version as the stale guard on `fs/write-intent`. Creating a new file does not require a prior observation. With the policy plugin absent, `write` is an unconditional bare-provider create-or-overwrite.
The schema does not expose `expected_hash`, `expected_version`, or `create_only` as model-facing parameters. Stale-version checks are driven by backend-produced versions and the policy plugin's observed state, not by asking the model to copy version tokens through the schema.
### `edit`
`edit` updates an existing UTF-8 text file by replacing literal text.
Arguments:
- `file_path: string` — required. Path to edit, resolved by `ctx.fs`.
- `old_string: string` — required. Literal text to replace. Empty strings are invalid in the first pass.
- `new_string: string` — required. Literal replacement text; an empty string deletes the match.
- `replace_all?: boolean` — optional. Defaults to false. When false, `old_string` must identify exactly one match.
`edit` requires a prior observation of the file in the same execution context (any windowed read counts — authorization is version freshness, not a full-view requirement), or a prior write/edit by that context. The `dsh-fs-policy` policy plugin derives the owner and supplies the recorded version as the stale guard; the provider's mutation lock enforces it.
The first pass rejects Codex-style patch grammars and multi-mode edit APIs. It uses one strict literal replacement mode so the model-facing contract stays simple and the backend can own exact-match, duplicate-match, line-ending, and stale-version semantics.
## Result shape
The first implementation returns `ContentBlock[]` through the existing `ToolDefinition.execute()` contract. `ctx.fs` returns structured filesystem results and owns file-state recording/refreshing; `tool-fs` formats those results into the model projection.
Default native projections:
| Tool | Structured `ctx.fs` outcome consumed by `tool-fs` | Default model projection |
|---|---|---|
| `read` | returned lines, returned line count, total line count, target display path, file version, partial-view flag | line-numbered text plus pagination footer |
| `write` | create/update operation, target display path, new file version | concise create/update success text |
| `edit` | replacement count, replace-all flag, target display path, new file version | concise edit success text |
The structured outcome should not restate model arguments such as `file_path`, `old_string`, or `content` unless the backend has resolved them into new information such as `displayPath`, `targetKey`, or a new version. Token-conscious truncation is part of the model projection, not the backend's canonical result.
## Deferred
The following are deliberately out of scope for the first filesystem schema pass:
- Model-facing `expected_hash`, `expected_version`, or `create_only` parameters.
- Directory listing, glob, grep, and search tools.
- Binary-safe read/write operations.
- PDF/image/multimodal `read`.
- Code Mode projection values for filesystem tools.
- A canonical edit diff format.
## Tests
`dsh-tool-fs` schema tests should assert:
- `read` requires `file_path` and accepts optional positive integer `offset` / `limit`.
- `write` requires `file_path` and `content`.
- `edit` requires `file_path`, `old_string`, and `new_string`, accepts optional boolean `replace_all`, rejects empty `old_string`, and defaults `replace_all` to false.
- The registered JSON schemas use the snake_case field names in this RFC.
- The tool descriptions accurately describe that, under the default fs-policy, existing-file `write` and `edit` require a prior observation (any windowed read counts) in the same execution context, while new-file `write` does not.
- The `tool-fs` root plugin registers all three schemas.
Integration tests should execute `read`, `write`, and `edit` through `ctx.tools.execute()` against the real `dsh-fs-local` provider and verify that model arguments are translated into the expected `ctx.fs` calls and `fs/*` dispatches.
## Risks
**The first schema is intentionally smaller than Claude Code's.** Dropping PDF pages, multimodal read, rich grep/list flags, and expected hash fields keeps the first implementation focused, but users may ask for those quickly. They should be added as separate RFCs or focused follow-ups rather than overloaded into the initial schema.
**No explicit model-facing stale guard in v1.** The schema does not ask the model to provide an expected hash/version. That is intentional: stale checks come from backend-produced versions and the `dsh-fs-policy` plugin's observed state, not from fragile model-copied tokens. Filesystem safety failures surface through structured `FsError` codes owned by `dsh-fs`, not through model-supplied version fields.
**Naming becomes public surface.** Once shipped, changing `file_path` to `filePath` or `old_string` to `oldString` would churn prompts, examples, and downstream clients. This RFC chooses snake_case up front and treats it as the stable model-facing contract.
@@ -0,0 +1,124 @@
# RFC: Split the filesystem seam — provider text mutations plus the `dsh-fs-policy` plugin
Status: implemented
## Problem
The filesystem capability from [filesystem-capability-seam](../../implemented/architecture/2026-06-17-filesystem-capability-seam.md) currently makes one abstract `FileSystem` service own two different jobs:
1. **Provider operations** — resolving targets, stat/version metadata, text reads/streams, atomic writes, and guarded literal edits.
2. **Agent-facing policy** — line windows, literal edit semantics, and read-before-write/edit observed-state.
That makes every future backend reimplement model-facing read semantics and observation policy. `readPage` returns numbered lines and view metadata; the base service stores per-owner file state and distinguishes `full` from `partial` reads. Those are useful policies, but they are not filesystem-provider primitives. Literal text mutation is different: version guard, literal match, ambiguity detection, and atomic rewrite must stay together inside the provider mutation boundary, but the current `applyEdit` name and surrounding seam tie that provider operation to the old read-before-edit policy shape.
This also creates a real UX dead-end: a windowed read records `view: partial`, and partial views cannot authorize `edit`. A model that reads lines 100-150 of a large file therefore cannot edit line 120 unless it first gets a `full` read, which may be impossible for a file past the read cap. Literal edit only needs freshness: the bytes being matched must still be from the version the model read.
The old RFC already deferred a separate `@deepseek-ai/dsh-fs-policy` package. This RFC builds that layer and keeps `ctx.fs` close to fsspec-style storage primitives (`info`/`cat`/`open`), without turning it into full fsspec.
## Decision
Split the stack into four layers:
```text
tool dsh-tool-fs model-facing schemas + read windowing + text rendering; the EXECUTOR (reads/writes/edits via ctx.fs, dispatches the fs/* events)
policy dsh-fs-policy observed-state + read-before-edit + write/edit freshness, contributed through the fs/* event gate (no service)
provider seam dsh-fs ctx.fs: text IO + atomic mutation primitives (optional version guard)
provider dsh-fs-local local implementation of ctx.fs
```
`dsh-tool-fs` keeps the same model-facing `read`/`write`/`edit` schemas. It is the executor: it injects `fs` (not a policy service) and reaches `ctx.fs` directly, owns read windowing, and dispatches the `fs/*` events so `dsh-fs-policy` can gate and record.
This RFC decided the four-layer split, the provider contract, and the freshness policy. The tool↔policy COUPLING was then refined by [the event-gate RFC](../architecture/2026-06-26-file-context-as-event-gate.md): `dsh-fs-policy` is a gate PLUGIN that participates through the `fs/*` events rather than a `ctx.fileContext` method service, so the tool is not method-coupled to it and read windowing + the fs I/O live in `dsh-tool-fs`. This document describes that landed event-gate shape; the provider's version guard is optional (omit = unconditional bare provider).
## Provider Contract
`@deepseek-ai/dsh-fs` shrinks to provider text IO plus guarded text mutation:
```ts ignore-check
abstract resolve(path: string): Promise<FsTarget>
abstract stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined>
abstract readText(target: FsTarget, signal?: AbortSignal): Promise<string>
abstract streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>>
abstract writeText(target: FsTarget, content: string, expected: FsWriteIntent, signal?: AbortSignal): Promise<FsWriteOutcome>
abstract editText(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome>
interface FsInfo {
version: FsVersion
type: 'file' | 'directory' | 'other'
size?: number
}
type FsWriteIntent =
| { kind: 'createIfAbsent' }
| { kind: 'replaceIfVersion'; version: FsVersion }
```
`stat` returns metadata, not content. `version` is the freshness token; `type` lets the executor reject directories/special files before reading; `size` lets the `read` tool choose `readText` vs `streamText` without probing by failure. `undefined` means absent.
`readText` reads the whole regular text file. `streamText` streams the same text semantics for large files. Both provider primitives own regular-file checks, UTF-8 decoding, binary/NUL rejection, and `FS_NOT_TEXT`; the policy layer never handles raw bytes or reimplements cross-chunk decoding. `readText` is the small-file/direct whole-file primitive, while large model-facing reads use `streamText`.
`writeText` is atomic temp-file + rename with an explicit write expectation. `createIfAbsent` creates a missing target and rejects an existing target with `FS_NOT_OBSERVED`; it is the path used when the owner has no prior read. `replaceIfVersion` replaces only when the target exists at the observed version; a missing target or version mismatch throws `FS_STALE_VERSION`.
`editText` is a provider-level guarded text mutation. When guarded it first verifies the target still exists at `expected.version`, then reads the current text, applies literal replacement, and writes atomically. The stale check must happen before literal matching so an edit based on an old read reports `FS_STALE_VERSION`, not `FS_EDIT_NOT_FOUND` or `FS_AMBIGUOUS_EDIT` from matching against newer content. Keeping this primitive on the provider seam preserves backend-local locking and lets a future remote backend implement native compare-and-edit without forcing the policy layer to pull the whole file through it.
This is a *text-storage* seam, deliberately half a level above byte-level fsspec (`cat`/`open` hand back raw bytes). UTF-8 decoding, binary/NUL rejection, guarded full-file writes, and guarded literal text edits live in the provider so the policy layer never touches raw bytes, reimplements cross-chunk decoding, or separates stale checks from the mutation critical section. Model-facing concepts still stay out of the provider: no line windows, numbered lines, rendered footers, or observed-state store leak down.
Deleted from `dsh-fs`: `readPage`, `FsExpectation`, `FsView`, `FsStateSource`, `FsReadRequest`, `FsTextLine`, line/window constants, `formatReadBody`, and the observed-state `WeakMap`. `applyEdit` is replaced by the narrower provider primitive `editText`, whose contract is version-guarded literal text mutation rather than policy-layer read authorization. The `FS_PARTIAL_OBSERVATION` code also leaves the `FsErrorCode` taxonomy: freshness authorization has no partial/full distinction, so nothing can raise it. `FsTargetKey` and `FsVersion` become branded opaque ids under the existing [branded-ids RFC](../../implemented/architecture/2026-06-20-branded-ids.md).
## Policy Contract
`@deepseek-ai/dsh-fs-policy` is a plugin, not a service: it registers no `ctx.*` key and injects nothing. It owns the write/edit freshness policy and observed-state that do not belong on the `FileSystem` provider base class (where a sandboxed/remote backend would otherwise inherit model-facing observation policy it has no business carrying). It contributes that policy through the `fs/*` event gate the executor dispatches. (This RFC originally proposed a concrete `ctx.fileContext` service with `read`/`write`/`edit` methods; [the event-gate RFC](../architecture/2026-06-26-file-context-as-event-gate.md) refined it into the plugin described here so the tool is never method-coupled to the policy.)
Observed state lives here as `WeakMap<owner, Map<targetKey, FsVersion>>`. An entry exists iff the owner has read, written, OR edited that target (every success emits `fs/observed`), so its presence *is* the prior-observation record — there is no separate `hasRead` flag. The owner is derived structurally from the opaque event actor (`{ agent?: { session? } }`), a shape that lives in `dsh-fs-policy`, not `dsh-fs`.
The plugin decides three `fs/*` events:
- `fs/write-intent` — no prior observation ⇒ `{ kind: 'createIfAbsent' }` (only new files can be created blindly); a prior observation ⇒ `{ kind: 'replaceIfVersion', version: vObserved }` (existing files replaced only if unchanged since the observation). Single-slot decision; does not call `next()`.
- `fs/edit-intent` — requires a prior observation by the owner (else `FS_NOT_OBSERVED`); returns `{ version: vObserved }` as the CAS basis. It does not implement literal replacement — it authorizes and supplies the version, and the provider's mutation critical section applies the guard, so concurrent edits based on the same observed version remain one-wins/one-stale.
- `fs/observed` — records `{ version }` for this owner+target after a successful read/write/edit. Synchronous, side-effect-only `WeakMap.set`.
The plugin does NO filesystem I/O: "have you observed this file?" is a `WeakMap` lookup, and "is the version you read still current?" is decided inside `ctx.fs.editText`/`writeText` in the same atomic lock that performs the mutation — the plugin only supplies `vObserved` as the basis.
## Tool Contract
`dsh-tool-fs` keeps the same schemas and prompt surface. `read` still exposes `file_path`, `offset`, and `limit`; `write` and `edit` are unchanged. It is the executor: it validates model args, reads/writes/edits through `ctx.fs` directly, owns line windowing and result rendering (`N: text`, footer, `<path>/<content>` envelope), and dispatches the `fs/*` events.
Each mutation dispatches its intent waterfall with an `undefined` bare-provider default, then calls `ctx.fs`, then emits `fs/observed`: e.g. `write` does `ctx.waterfall('fs/write-intent', target, exec, () => undefined)` → `ctx.fs.writeText(target, content, intent)` → `ctx.emit('fs/observed', …)`. A `read` stats once, reads/streams, builds the window, and emits `fs/observed`. Passing `exec` as the actor lets `dsh-fs-policy` derive the owner without the tool reaching into the policy.
Because the policy is contributed through events with an `undefined` default, `dsh-tool-fs` is not method-coupled to `dsh-fs-policy`: with the plugin absent, every intent waterfall falls through to `undefined` (unconditional bare-provider write/edit) and `fs/observed` has no listener. Loading the plugin back layers the read-before-write/edit policy on.
## Concurrency Boundary
In-process updates are safe: the local backend keeps the existing per-target mutation lock, so version-check-then-rename is serialized and a losing update sees `FS_STALE_VERSION`.
In-process creates are guarded by the same per-target mutation lock: two callers racing with `createIfAbsent` serialize, one creates, and the next sees the target exists and receives `FS_NOT_OBSERVED`. Cross-process creates are best-effort only; a local stat-then-rename guard cannot make portable create-exclusive guarantees across all future backends.
Cross-process writes are best-effort freshness plus atomic replacement: `mtime:size` usually catches editor saves, but same-tick same-size writes can miss; atomic temp+rename prevents torn files but not every lost update.
## Supersedes
This RFC reverses two decisions from [filesystem-capability-seam](../../implemented/architecture/2026-06-17-filesystem-capability-seam.md) and narrows a third:
- Read-before-write/edit policy moves out of `ctx.fs` and into the `dsh-fs-policy` plugin (on the `fs/*` event gate).
- Text reads no longer return backend-numbered line records or `full`/`partial` views; authorization is based on version freshness, so a windowed read can authorize edit when the file is unchanged.
- Literal edit no longer sits behind the old `applyEdit` API that mixed backend mutation with seam-owned observation policy. It remains a provider primitive as `editText`, because version guard + literal match + atomic rewrite must stay inside the provider's mutation critical section.
It keeps the interface/implementation/consumer discipline, consumer-never-imports-backend rule, backend-defined target/version/display metadata, atomic local writes, and the shared `FsError` taxonomy.
## Acceptance Criteria
- `dsh-fs` exposes exactly `resolve`/`stat`/`readText`/`streamText`/`writeText`/`editText`; `stat` returns `FsInfo | undefined`; `writeText` uses `FsWriteIntent` (`createIfAbsent` or `replaceIfVersion`); removed types/primitives are gone, and the old `applyEdit` API is replaced by `editText`.
- `dsh-fs-policy` adds the observed-state + `read`/`write`/`edit` freshness policy and has HMR/disposal coverage. (It does so as a gate PLUGIN on the `fs/*` events with no `ctx.fileContext` service, per [the event-gate RFC](../architecture/2026-06-26-file-context-as-event-gate.md) — the original service form this RFC proposed was reworked.)
- `dsh-tool-fs` reaches the policy decisions and model-facing schemas stay byte-for-byte unchanged; the observation contract (a read records observed-state; a direct `ctx.fs` read does not) is documented and tested. (The tool injects `fs` and dispatches the `fs/*` events rather than injecting a `fileContext` service, per the event-gate RFC.)
- Windowed read authorizing edit is shown to fail on the pre-refit code and pass after the refit. Existing version-CAS behavior is preserved with a regression test; it is not claimed as a pre-refit failure. An edit based on a stale read must report `FS_STALE_VERSION` before attempting literal matching.
- `dsh-fs-local` carries no line, view, or `formatReadBody` logic; it does carry provider-level `editText` logic.
- Docs and generated artifacts are updated: `docs/architecture.md`, `packages/README.md`, fs package READMEs, `docs/core-data-structures/filesystem.md`, affected `type-equiv` blocks and `scripts/type-equiv.manifest.json`, Cordis catalog, module graph, and doc references.
- Gates stay green: normal `doc-sync`, `pnpm run knip`, and `pnpm run test:coverage` with 100% per-file coverage.
## Risks
- Adds a fourth fs package and a new service. This is intentional: it is the previously deferred policy layer, not a second abstract backend seam.
- Direct `ctx.fs` use bypasses the policy: a direct `ctx.fs.readText` emits no `fs/observed`, so under the default policy a later `edit` rejects with `FS_NOT_OBSERVED` until the file is read through the `read` tool. The failure is explicit and documented.
- Large-file line windowing moves from the backend to the `read` tool in `dsh-tool-fs`; text decoding and binary rejection stay in `ctx.fs.streamText`, so this is relocation of windowing only, not a second text-IO implementation.
- Keeping `editText` in the provider seam means every backend must implement the literal replacement contract. This is intentional: the operation is not pure storage, but stale guard + literal match + atomic rewrite is the unit that must stay together for correct error attribution and concurrency behavior. The contract should stay narrow and text-only so future backends can implement it natively or by whole-file rewrite.
- Freshness permits full-file `write` after a windowed read. That is weaker than the old view check, but avoids making large files impossible to edit; prompt guidance should still discourage blind full replaces.
+94
View File
@@ -91,6 +91,100 @@ Read new output from a background bash task started with `bash` + `run_in_backgr
Source: [`packages/bash/tool-bash/src/index.ts`](../../packages/bash/tool-bash/src/index.ts)
## `@deepseek-ai/dsh-tool-fs`
### `edit`
Edit an existing UTF-8 text file by replacing literal text.
```json
{
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Path to edit, resolved by the filesystem backend."
},
"old_string": {
"type": "string",
"description": "Literal text to replace. Must match exactly."
},
"new_string": {
"type": "string",
"description": "Literal replacement text. Use an empty string to delete the match."
},
"replace_all": {
"type": "boolean",
"description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once."
}
},
"required": [
"file_path",
"old_string",
"new_string"
]
}
```
Source: [`packages/fs/tool-fs/src/index.ts`](../../packages/fs/tool-fs/src/index.ts)
### `read`
Read a UTF-8 text file and return line-numbered content.
```json
{
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Path to read, resolved by the filesystem backend."
},
"offset": {
"type": "number",
"description": "1-based first line to return. Defaults to 1."
},
"limit": {
"type": "number",
"description": "Maximum number of lines to return. Defaults to 2000."
}
},
"required": [
"file_path"
]
}
```
Source: [`packages/fs/tool-fs/src/index.ts`](../../packages/fs/tool-fs/src/index.ts)
### `write`
Create or fully replace a UTF-8 text file.
```json
{
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Path to write, resolved by the filesystem backend."
},
"content": {
"type": "string",
"description": "Full UTF-8 text content to write."
}
},
"required": [
"file_path",
"content"
]
}
```
Source: [`packages/fs/tool-fs/src/index.ts`](../../packages/fs/tool-fs/src/index.ts)
The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin.
## `@deepseek-ai/dsh-tool-subagent`
### `subagent`
+1 -1
View File
@@ -15,7 +15,7 @@ Run with: `pnpm run demo:echo`. When prompted, type "echo <something>" to trigge
## coding-agent
The real thing: DeepSeek V4 + the bash tool suite, `subagent` delegation, and the `todo_write` task tracker on the same `@deepseek-ai/dsh-stdio-agent` app. Where echo-agent proves the skeleton with mocks, this is a usable coding assistant.
The real thing: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite, `subagent` delegation, and the `todo_write` task tracker on the same `@deepseek-ai/dsh-stdio-agent` app. Where echo-agent proves the skeleton with mocks, this is a usable coding assistant.
Run with: `pnpm run demo:coding` (needs `DEEPSEEK_API_KEY` in the environment or a gitignored repo-root `.env`). See [coding-agent/README.md](coding-agent/README.md) for details.
+2 -2
View File
@@ -6,7 +6,7 @@ The DeepSeek Harness coding agent exposed as an **Agent Client Protocol (ACP)**
pnpm run demo:acp # needs DEEPSEEK_API_KEY (repo-root .env or env)
```
This example is just a leaf `cordis.yml`: it loads the [`@deepseek-ai/dsh-acp-agent`](../../packages/ui/acp-agent) app (which bundles the [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) spine, JSONL session persistence, and the `@deepseek-ai/dsh-acp` bridge — with **no pre-created agents**, since ACP `session/new` creates them on demand), the swappable DeepSeek and bash backends, and the optional model-facing `subagent`/`subagent_fork`/`todo_write` tool entries. The app package bakes in the no-stdout-logger cluster, so a leaf has no logger entry to get wrong by default — keeping stdout pure for JSON-RPC.
This example is just a leaf `cordis.yml`: it loads the [`@deepseek-ai/dsh-acp-agent`](../../packages/ui/acp-agent) app (which bundles the [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) spine, JSONL session persistence, and the `@deepseek-ai/dsh-acp` bridge — with **no pre-created agents**, since ACP `session/new` creates them on demand), the swappable DeepSeek, bash, and filesystem backends, and the model-facing `read`/`write`/`edit`/`subagent`/`subagent_fork`/`todo_write` tool entries. The app package bakes in the no-stdout-logger cluster, so a leaf has no logger entry to get wrong by default — keeping stdout pure for JSON-RPC.
## stdout is the protocol
@@ -28,7 +28,7 @@ Add to your Zed `settings.json` under `agent_servers`:
}
```
The editor sets each session's `cwd` to the project it opens; the agent's bash tools run there (see the per-session `cwd` note in `packages/ui/acp`), so launch the server from the harness repo with `pnpm --dir …` and let ACP carry the workspace path per session.
The editor sets each session's `cwd` to the project it opens; both the agent's bash tools and the `read`/`write`/`edit` filesystem tools resolve relative paths against that per-session workspace (see the per-session `cwd` note in `packages/ui/acp` and [the per-session cwd RFC](../../docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md)), so the server can be launched anywhere and each session still acts on its own project directory.
## Snapshot tests (record-once / replay-deterministic)
+21 -7
View File
@@ -18,7 +18,7 @@
# Local bash executor for agent-core's tool-bash schema.
# FIXME(config-comments): keep this executor note from implying bash is the
# whole tool set; subagent and todo_write are loaded below.
# whole tool set; filesystem, subagent, and todo_write are loaded below.
- id: bash
name: '@deepseek-ai/dsh-bash-local'
config:
@@ -33,12 +33,13 @@
systemPrompt: |
You are a coding assistant driven over the Agent Client Protocol.
Your tools are bash (plus bash_output/bash_kill for background tasks)
and subagent. Do ALL file operations through bash: read with
cat/sed/head, search with grep, write with heredocs (cat <<'EOF' >
file), edit with sed or a rewrite. Each bash call runs in a fresh
shell — pass workdir instead of cd. Check the [exit code: N] marker;
verify your work. Keep answers brief and factual.
Your tools are read/write/edit for file operations, bash (plus
bash_output/bash_kill for background tasks), and subagent. Use read to
inspect UTF-8 text files, write to create or replace files, and edit for
targeted literal replacements. Use bash for shell commands, tests,
searches, and operations that are not ordinary file reads or edits. Each
bash call runs in a fresh shell — pass workdir instead of cd. Check the
[exit code: N] marker; verify your work. Keep answers brief and factual.
Use the subagent tool to delegate a focused, self-contained subtask to
a fresh child agent (it works in its own context and returns only its
@@ -85,3 +86,16 @@
# replayed todo_write tool call resolves to a real tool during snapshot replay.
- id: tool-todo
name: '@deepseek-ai/dsh-tool-todo'
# Filesystem capability stack — identical to cordis.yml's wiring, so replayed
# read/write/edit tool calls resolve to the real tools during snapshot replay.
- id: fs-local
name: '@deepseek-ai/dsh-fs-local'
config:
cwd: !!js process.cwd()
- id: fs-policy
name: '@deepseek-ai/dsh-fs-policy'
- id: tool-fs
name: '@deepseek-ai/dsh-tool-fs'
+26 -9
View File
@@ -1,8 +1,9 @@
# The acp-agent plugin tree: the ACP server. Also the snapshot RECORD config
# (the dsh-acp-agent bin selects it for DSH_SNAPSHOT=record): a real llm-deepseek
# run whose persisted log the snapshot harness harvests. The swappable DeepSeek
# adapter and local bash executor, the ACP server app (@deepseek-ai/dsh-acp-agent),
# and the optional model-facing subagent/todo tools loaded below.
# adapter, local bash/filesystem executors, the ACP server app
# (@deepseek-ai/dsh-acp-agent), and the optional model-facing fs/subagent/todo
# tools loaded below.
#
# CRITICAL: this tree loads NO stdout logger and NO hmr — stdout is reserved for
# the ACP JSON-RPC protocol (see packages/ui/acp). That guarantee is now a
@@ -24,7 +25,7 @@
# Local bash executor for agent-core's tool-bash schema.
# FIXME(config-comments): keep this executor note from implying bash is the
# whole tool set; subagent and todo_write are loaded below.
# whole tool set; filesystem, subagent, and todo_write are loaded below.
- id: bash
name: '@deepseek-ai/dsh-bash-local'
config:
@@ -41,12 +42,13 @@
systemPrompt: |
You are a coding assistant driven over the Agent Client Protocol.
Your tools are bash (plus bash_output/bash_kill for background tasks)
and subagent. Do ALL file operations through bash: read with
cat/sed/head, search with grep, write with heredocs (cat <<'EOF' >
file), edit with sed or a rewrite. Each bash call runs in a fresh
shell — pass workdir instead of cd. Check the [exit code: N] marker;
verify your work. Keep answers brief and factual.
Your tools are read/write/edit for file operations, bash (plus
bash_output/bash_kill for background tasks), and subagent. Use read to
inspect UTF-8 text files, write to create or replace files, and edit for
targeted literal replacements. Use bash for shell commands, tests,
searches, and operations that are not ordinary file reads or edits. Each
bash call runs in a fresh shell — pass workdir instead of cd. Check the
[exit code: N] marker; verify your work. Keep answers brief and factual.
Use the subagent tool to delegate a focused, self-contained subtask to
a fresh child agent (it works in its own context and returns only its
@@ -95,3 +97,18 @@
# session log (todo/write), surfaced to the ACP client as a `plan` update.
- id: tool-todo
name: '@deepseek-ai/dsh-tool-todo'
# Filesystem capability stack: local provider, read-before-write/edit policy
# gate, then the model-facing read/write/edit tools. Relative filesystem paths
# resolve from the server launch cwd; the documented Zed setup launches this
# demo from the harness checkout with `pnpm --dir`.
- id: fs-local
name: '@deepseek-ai/dsh-fs-local'
config:
cwd: !!js process.cwd()
- id: fs-policy
name: '@deepseek-ai/dsh-fs-policy'
- id: tool-fs
name: '@deepseek-ai/dsh-tool-fs'
+5
View File
@@ -54,6 +54,11 @@ const SCENARIOS: Scenario[] = [
{ name: 'tool-call-turn', hasModelTurn: true, recorded: true },
{ name: 'todo-plan', hasModelTurn: true, recorded: true },
{ name: 'workspace-edit', hasModelTurn: true, recorded: true },
{ name: 'fs-read', hasModelTurn: true, recorded: true },
{ name: 'fs-write', hasModelTurn: true, recorded: true },
{ name: 'fs-edit', hasModelTurn: true, recorded: true },
{ name: 'fs-write-overwrite', hasModelTurn: true, recorded: true },
{ name: 'fs-read-window', hasModelTurn: true, recorded: true },
{ name: 'multi-turn', hasModelTurn: true, recorded: true },
{ name: 'error-finish', hasModelTurn: true, recorded: false },
{ name: 'cancel', hasModelTurn: true, recorded: false },
@@ -0,0 +1,7 @@
{
"steps": [
{ "op": "initialize" },
{ "op": "newSession" },
{ "op": "prompt", "text": "First use the read tool to read config.txt in the current directory. Then use the edit tool (NOT bash) to replace the literal text DEBUG with RELEASE in that file. Then reply with exactly the single word DONE." }
]
}
@@ -0,0 +1,147 @@
{"type":"session","version":0,"id":"2d43b6e7-859c-4e20-9145-3bcfe4c29836","createdAt":1782993777165,"cwd":"/tmp/acp-snap-cwd-yl8qhJ"}
{"type":"turn/start","seq":0,"time":1782993777170,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":1782993777170,"data":{"content":[{"type":"text","text":"First use the read tool to read config.txt in the current directory. Then use the edit tool (NOT bash) to replace the literal text DEBUG with RELEASE in that file. Then reply with exactly the single word DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":1782993777171,"data":{"turn":1,"step":1}}
{"type":"assistant/chunk","seq":3,"time":1782993777573,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"assistant/chunk","seq":4,"time":1782993777573,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}}
{"type":"assistant/chunk","seq":5,"time":1782993777707,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}
{"type":"assistant/chunk","seq":6,"time":1782993777734,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" start"}}}
{"type":"assistant/chunk","seq":7,"time":1782993777734,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}}
{"type":"assistant/chunk","seq":8,"time":1782993777735,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reading"}}}
{"type":"assistant/chunk","seq":9,"time":1782993777735,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}}
{"type":"assistant/chunk","seq":10,"time":1782993777735,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" config"}}}
{"type":"assistant/chunk","seq":11,"time":1782993777762,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}}
{"type":"assistant/chunk","seq":12,"time":1782993777762,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}}
{"type":"assistant/chunk","seq":13,"time":1782993777762,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}
{"type":"assistant/chunk","seq":14,"time":1782993777762,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" see"}}}
{"type":"assistant/chunk","seq":15,"time":1782993777763,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" its"}}}
{"type":"assistant/chunk","seq":16,"time":1782993777763,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" contents"}}}
{"type":"assistant/chunk","seq":17,"time":1782993777789,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}
{"type":"assistant/chunk","seq":18,"time":1782993777845,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":19,"time":1782993777846,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","argumentsDelta":""}}}
{"type":"assistant/chunk","seq":20,"time":1782993777873,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","argumentsDelta":"{"}}}
{"type":"assistant/chunk","seq":21,"time":1782993777873,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":22,"time":1782993777873,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","argumentsDelta":"file"}}}
{"type":"assistant/chunk","seq":23,"time":1782993777873,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","argumentsDelta":"_path"}}}
{"type":"assistant/chunk","seq":24,"time":1782993777904,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":25,"time":1782993777904,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","argumentsDelta":": "}}}
{"type":"assistant/chunk","seq":26,"time":1782993777904,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":27,"time":1782993777904,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","argumentsDelta":"config"}}}
{"type":"assistant/chunk","seq":28,"time":1782993777931,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","argumentsDelta":".txt"}}}
{"type":"assistant/chunk","seq":29,"time":1782993777931,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":30,"time":1782993777960,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","argumentsDelta":"}"}}}
{"type":"assistant/chunk","seq":31,"time":1782993777989,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Let me start by reading the config.txt file to see its contents."}}}}
{"type":"assistant/chunk","seq":32,"time":1782993777989,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","arguments":"{\"file_path\": \"config.txt\"}"}}}}
{"type":"assistant/chunk","seq":33,"time":1782993777989,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":123,"outputTokens":59,"cacheReadTokens":2176,"reasoningTokens":14}}}}
{"type":"assistant/chunk","seq":34,"time":1782993777989,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":35,"time":1782993777991,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"Let me start by reading the config.txt file to see its contents."},{"type":"tool-call","id":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","arguments":"{\"file_path\": \"config.txt\"}"}],"usage":{"inputTokens":123,"outputTokens":59,"cacheReadTokens":2176,"reasoningTokens":14}},"sourceEventSeqs":[3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34],"surfaceOp":"append"}
{"type":"tool/call","seq":36,"time":1782993777991,"data":{"turn":1,"step":1,"callId":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","name":"read","arguments":"{\"file_path\": \"config.txt\"}"}}
{"type":"tool/result","seq":37,"time":1782993777996,"data":{"turn":1,"step":1,"callId":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","content":[{"type":"text","text":"<path>/tmp/acp-snap-cwd-yl8qhJ/config.txt</path>\n<type>file</type>\n<content>\n1: mode=DEBUG\n2: level=info\n\n(End of file - total 2 lines)\n</content>"}],"isError":false},"sourceEventSeqs":[36],"surfaceOp":"append"}
{"type":"step/end","seq":38,"time":1782993777996,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":39,"time":1782993777996,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":40,"time":1782993778611,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"assistant/chunk","seq":41,"time":1782993778611,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}
{"type":"assistant/chunk","seq":42,"time":1782993778711,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}}
{"type":"assistant/chunk","seq":43,"time":1782993778739,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" contains"}}}
{"type":"assistant/chunk","seq":44,"time":1782993778739,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
{"type":"assistant/chunk","seq":45,"time":1782993778739,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"mode"}}}
{"type":"assistant/chunk","seq":46,"time":1782993778740,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"="}}}
{"type":"assistant/chunk","seq":47,"time":1782993778767,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"DEBUG"}}}
{"type":"assistant/chunk","seq":48,"time":1782993778767,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}}
{"type":"assistant/chunk","seq":49,"time":1782993778768,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" on"}}}
{"type":"assistant/chunk","seq":50,"time":1782993778768,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" line"}}}
{"type":"assistant/chunk","seq":51,"time":1782993778768,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}}
{"type":"assistant/chunk","seq":52,"time":1782993778768,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}}
{"type":"assistant/chunk","seq":53,"time":1782993778795,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}
{"type":"assistant/chunk","seq":54,"time":1782993778795,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}}
{"type":"assistant/chunk","seq":55,"time":1782993778795,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}}
{"type":"assistant/chunk","seq":56,"time":1782993778796,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}}
{"type":"assistant/chunk","seq":57,"time":1782993778796,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}
{"type":"assistant/chunk","seq":58,"time":1782993778796,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" replace"}}}
{"type":"assistant/chunk","seq":59,"time":1782993778822,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}}
{"type":"assistant/chunk","seq":60,"time":1782993778823,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" literal"}}}
{"type":"assistant/chunk","seq":61,"time":1782993778823,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" text"}}}
{"type":"assistant/chunk","seq":62,"time":1782993778851,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
{"type":"assistant/chunk","seq":63,"time":1782993778851,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"DEBUG"}}}
{"type":"assistant/chunk","seq":64,"time":1782993778851,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}}
{"type":"assistant/chunk","seq":65,"time":1782993778851,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}
{"type":"assistant/chunk","seq":66,"time":1782993778851,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
{"type":"assistant/chunk","seq":67,"time":1782993778852,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"RE"}}}
{"type":"assistant/chunk","seq":68,"time":1782993778878,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"LEASE"}}}
{"type":"assistant/chunk","seq":69,"time":1782993778878,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}}
{"type":"assistant/chunk","seq":70,"time":1782993778878,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}}
{"type":"assistant/chunk","seq":71,"time":1782993778878,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}}
{"type":"assistant/chunk","seq":72,"time":1782993778878,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" edit"}}}
{"type":"assistant/chunk","seq":73,"time":1782993778878,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}}
{"type":"assistant/chunk","seq":74,"time":1782993778905,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}
{"type":"assistant/chunk","seq":75,"time":1782993778962,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":76,"time":1782993778962,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":""}}}
{"type":"assistant/chunk","seq":77,"time":1782993778989,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"{"}}}
{"type":"assistant/chunk","seq":78,"time":1782993778989,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":79,"time":1782993778989,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"file"}}}
{"type":"assistant/chunk","seq":80,"time":1782993778989,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"_path"}}}
{"type":"assistant/chunk","seq":81,"time":1782993779022,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":82,"time":1782993779022,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":": "}}}
{"type":"assistant/chunk","seq":83,"time":1782993779022,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":84,"time":1782993779022,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"config"}}}
{"type":"assistant/chunk","seq":85,"time":1782993779044,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":".txt"}}}
{"type":"assistant/chunk","seq":86,"time":1782993779044,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":87,"time":1782993779073,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":", "}}}
{"type":"assistant/chunk","seq":88,"time":1782993779073,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":89,"time":1782993779073,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"old"}}}
{"type":"assistant/chunk","seq":90,"time":1782993779073,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"_string"}}}
{"type":"assistant/chunk","seq":91,"time":1782993779100,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":92,"time":1782993779100,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":": "}}}
{"type":"assistant/chunk","seq":93,"time":1782993779100,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":94,"time":1782993779100,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"DEBUG"}}}
{"type":"assistant/chunk","seq":95,"time":1782993779128,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":96,"time":1782993779156,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":", "}}}
{"type":"assistant/chunk","seq":97,"time":1782993779156,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":98,"time":1782993779157,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"new"}}}
{"type":"assistant/chunk","seq":99,"time":1782993779157,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"_string"}}}
{"type":"assistant/chunk","seq":100,"time":1782993779157,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":101,"time":1782993779186,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":": "}}}
{"type":"assistant/chunk","seq":102,"time":1782993779186,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":103,"time":1782993779186,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"RE"}}}
{"type":"assistant/chunk","seq":104,"time":1782993779186,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"LEASE"}}}
{"type":"assistant/chunk","seq":105,"time":1782993779213,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":106,"time":1782993779213,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","argumentsDelta":"}"}}}
{"type":"assistant/chunk","seq":107,"time":1782993779276,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file contains \"mode=DEBUG\" on line 1. Now I need to replace the literal text \"DEBUG\" with \"RELEASE\" using the edit tool."}}}}
{"type":"assistant/chunk","seq":108,"time":1782993779276,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}}}}
{"type":"assistant/chunk","seq":109,"time":1782993779276,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":246,"outputTokens":114,"cacheReadTokens":2176,"reasoningTokens":34}}}}
{"type":"assistant/chunk","seq":110,"time":1782993779276,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":111,"time":1782993779276,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The file contains \"mode=DEBUG\" on line 1. Now I need to replace the literal text \"DEBUG\" with \"RELEASE\" using the edit tool."},{"type":"tool-call","id":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}],"usage":{"inputTokens":246,"outputTokens":114,"cacheReadTokens":2176,"reasoningTokens":34}},"sourceEventSeqs":[40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110],"surfaceOp":"append"}
{"type":"tool/call","seq":112,"time":1782993779276,"data":{"turn":1,"step":2,"callId":"call_00_OwPhDMqS06VEbY7rO4Rv1204","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}}
{"type":"tool/result","seq":113,"time":1782993779282,"data":{"turn":1,"step":2,"callId":"call_00_OwPhDMqS06VEbY7rO4Rv1204","content":[{"type":"text","text":"The file /tmp/acp-snap-cwd-yl8qhJ/config.txt has been updated successfully."}],"isError":false},"sourceEventSeqs":[112],"surfaceOp":"append"}
{"type":"step/end","seq":114,"time":1782993779282,"data":{"turn":1,"step":2}}
{"type":"step/start","seq":115,"time":1782993779282,"data":{"turn":1,"step":3}}
{"type":"assistant/chunk","seq":116,"time":1782993779871,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"assistant/chunk","seq":117,"time":1782993779871,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}
{"type":"assistant/chunk","seq":118,"time":1782993779945,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" edit"}}}
{"type":"assistant/chunk","seq":119,"time":1782993779978,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}}
{"type":"assistant/chunk","seq":120,"time":1782993779979,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" successful"}}}
{"type":"assistant/chunk","seq":121,"time":1782993779979,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}
{"type":"assistant/chunk","seq":122,"time":1782993779979,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}}
{"type":"assistant/chunk","seq":123,"time":1782993779979,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}}
{"type":"assistant/chunk","seq":124,"time":1782993780001,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}}
{"type":"assistant/chunk","seq":125,"time":1782993780001,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}
{"type":"assistant/chunk","seq":126,"time":1782993780001,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}
{"type":"assistant/chunk","seq":127,"time":1782993780002,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}
{"type":"assistant/chunk","seq":128,"time":1782993780002,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}
{"type":"assistant/chunk","seq":129,"time":1782993780029,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}}
{"type":"assistant/chunk","seq":130,"time":1782993780030,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}}
{"type":"assistant/chunk","seq":131,"time":1782993780030,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}}
{"type":"assistant/chunk","seq":132,"time":1782993780030,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}}
{"type":"assistant/chunk","seq":133,"time":1782993780030,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}}
{"type":"assistant/chunk","seq":134,"time":1782993780030,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}}
{"type":"assistant/chunk","seq":135,"time":1782993780063,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}
{"type":"assistant/chunk","seq":136,"time":1782993780063,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
{"type":"assistant/chunk","seq":137,"time":1782993780063,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"D"}}}
{"type":"assistant/chunk","seq":138,"time":1782993780063,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}}
{"type":"assistant/chunk","seq":139,"time":1782993780063,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The edit was successful. The user asked me to reply with exactly the single word DONE."}}}}
{"type":"assistant/chunk","seq":140,"time":1782993780064,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
{"type":"assistant/chunk","seq":141,"time":1782993780064,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":263,"outputTokens":22,"cacheReadTokens":2304,"reasoningTokens":19}}}}
{"type":"assistant/chunk","seq":142,"time":1782993780064,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":143,"time":1782993780064,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The edit was successful. The user asked me to reply with exactly the single word DONE."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":263,"outputTokens":22,"cacheReadTokens":2304,"reasoningTokens":19}},"sourceEventSeqs":[116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142],"surfaceOp":"append"}
{"type":"step/end","seq":144,"time":1782993780064,"data":{"turn":1,"step":3}}
{"type":"turn/end","seq":145,"time":1782993780064,"data":{"turn":1,"reason":{"kind":"completed"}}}
@@ -0,0 +1,76 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Let"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" start"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" by"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reading"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" config"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".txt"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" see"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" its"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" contents"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","title":"Read config.txt","kind":"read","status":"in_progress","locations":[{"path":"config.txt"}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","status":"completed","content":[{"type":"content","content":{"type":"text","text":"<path>{{cwd}}/config.txt</path>\n<type>file</type>\n<content>\n1: mode=DEBUG\n2: level=info\n\n(End of file - total 2 lines)\n</content>"}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" contains"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"mode"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"="}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"DEBUG"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" on"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" line"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" "}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"1"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Now"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" replace"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" literal"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" text"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"DEBUG"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"RE"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"LEASE"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" using"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" edit"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_OwPhDMqS06VEbY7rO4Rv1204","title":"Edit config.txt","kind":"edit","status":"in_progress","rawInput":"\"DEBUG\" → \"RELEASE\"","locations":[{"path":"config.txt"}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_OwPhDMqS06VEbY7rO4Rv1204","status":"completed","content":[{"type":"content","content":{"type":"text","text":"The file {{cwd}}/config.txt has been updated successfully."}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" edit"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" was"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" successful"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" D"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"D"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ONE"}}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
@@ -0,0 +1,2 @@
mode=DEBUG
level=info
@@ -0,0 +1,7 @@
{
"steps": [
{ "op": "initialize" },
{ "op": "newSession" },
{ "op": "prompt", "text": "Use the read tool (NOT bash) with offset 5 and limit 4 to read lines 5 through 8 of big.txt in the current directory. Then reply with exactly the single word DONE." }
]
}
@@ -0,0 +1,102 @@
{"type":"session","version":0,"id":"b9dfbc86-c33f-45ca-869a-49b62a94ea77","createdAt":1782993880851,"cwd":"/tmp/acp-snap-cwd-2yWjlu"}
{"type":"turn/start","seq":0,"time":1782993880856,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":1782993880856,"data":{"content":[{"type":"text","text":"Use the read tool (NOT bash) with offset 5 and limit 4 to read lines 5 through 8 of big.txt in the current directory. Then reply with exactly the single word DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":1782993880857,"data":{"turn":1,"step":1}}
{"type":"assistant/chunk","seq":3,"time":1782993881466,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"assistant/chunk","seq":4,"time":1782993881466,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}}
{"type":"assistant/chunk","seq":5,"time":1782993881583,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}
{"type":"assistant/chunk","seq":6,"time":1782993881612,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}}
{"type":"assistant/chunk","seq":7,"time":1782993881613,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" lines"}}}
{"type":"assistant/chunk","seq":8,"time":1782993881613,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}}
{"type":"assistant/chunk","seq":9,"time":1782993881614,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"5"}}}
{"type":"assistant/chunk","seq":10,"time":1782993881638,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-"}}}
{"type":"assistant/chunk","seq":11,"time":1782993881669,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"8"}}}
{"type":"assistant/chunk","seq":12,"time":1782993881670,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" of"}}}
{"type":"assistant/chunk","seq":13,"time":1782993881670,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" big"}}}
{"type":"assistant/chunk","seq":14,"time":1782993881670,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}}
{"type":"assistant/chunk","seq":15,"time":1782993881670,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}}
{"type":"assistant/chunk","seq":16,"time":1782993881671,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}}
{"type":"assistant/chunk","seq":17,"time":1782993881697,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}}
{"type":"assistant/chunk","seq":18,"time":1782993881697,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}}
{"type":"assistant/chunk","seq":19,"time":1782993881697,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}
{"type":"assistant/chunk","seq":20,"time":1782993881697,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" offset"}}}
{"type":"assistant/chunk","seq":21,"time":1782993881698,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}}
{"type":"assistant/chunk","seq":22,"time":1782993881724,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"5"}}}
{"type":"assistant/chunk","seq":23,"time":1782993881724,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}}
{"type":"assistant/chunk","seq":24,"time":1782993881724,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" limit"}}}
{"type":"assistant/chunk","seq":25,"time":1782993881724,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}}
{"type":"assistant/chunk","seq":26,"time":1782993881724,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"4"}}}
{"type":"assistant/chunk","seq":27,"time":1782993881725,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}
{"type":"assistant/chunk","seq":28,"time":1782993881808,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":29,"time":1782993881808,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":""}}}
{"type":"assistant/chunk","seq":30,"time":1782993881838,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"{"}}}
{"type":"assistant/chunk","seq":31,"time":1782993881839,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":32,"time":1782993881839,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"file"}}}
{"type":"assistant/chunk","seq":33,"time":1782993881839,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"_path"}}}
{"type":"assistant/chunk","seq":34,"time":1782993881839,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":35,"time":1782993881863,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":": "}}}
{"type":"assistant/chunk","seq":36,"time":1782993881863,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":37,"time":1782993881863,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"big"}}}
{"type":"assistant/chunk","seq":38,"time":1782993881863,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":".txt"}}}
{"type":"assistant/chunk","seq":39,"time":1782993881891,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":40,"time":1782993881919,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":", "}}}
{"type":"assistant/chunk","seq":41,"time":1782993881920,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":42,"time":1782993881920,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"offset"}}}
{"type":"assistant/chunk","seq":43,"time":1782993881920,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":44,"time":1782993881920,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":": "}}}
{"type":"assistant/chunk","seq":45,"time":1782993881946,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"5"}}}
{"type":"assistant/chunk","seq":46,"time":1782993882002,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":", "}}}
{"type":"assistant/chunk","seq":47,"time":1782993882002,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":48,"time":1782993882002,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"limit"}}}
{"type":"assistant/chunk","seq":49,"time":1782993882002,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":50,"time":1782993882002,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":": "}}}
{"type":"assistant/chunk","seq":51,"time":1782993882029,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"4"}}}
{"type":"assistant/chunk","seq":52,"time":1782993882058,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","argumentsDelta":"}"}}}
{"type":"assistant/chunk","seq":53,"time":1782993882087,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Let me read lines 5-8 of big.txt using the read tool with offset 5 and limit 4."}}}}
{"type":"assistant/chunk","seq":54,"time":1782993882087,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","arguments":"{\"file_path\": \"big.txt\", \"offset\": 5, \"limit\": 4}"}}}}
{"type":"assistant/chunk","seq":55,"time":1782993882087,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":119,"outputTokens":101,"cacheReadTokens":2176,"reasoningTokens":24}}}}
{"type":"assistant/chunk","seq":56,"time":1782993882087,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":57,"time":1782993882089,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"Let me read lines 5-8 of big.txt using the read tool with offset 5 and limit 4."},{"type":"tool-call","id":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","arguments":"{\"file_path\": \"big.txt\", \"offset\": 5, \"limit\": 4}"}],"usage":{"inputTokens":119,"outputTokens":101,"cacheReadTokens":2176,"reasoningTokens":24}},"sourceEventSeqs":[3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56],"surfaceOp":"append"}
{"type":"tool/call","seq":58,"time":1782993882089,"data":{"turn":1,"step":1,"callId":"call_00_0htYNlUzC9b8aH2gHN8h2706","name":"read","arguments":"{\"file_path\": \"big.txt\", \"offset\": 5, \"limit\": 4}"}}
{"type":"tool/result","seq":59,"time":1782993882094,"data":{"turn":1,"step":1,"callId":"call_00_0htYNlUzC9b8aH2gHN8h2706","content":[{"type":"text","text":"<path>/tmp/acp-snap-cwd-2yWjlu/big.txt</path>\n<type>file</type>\n<content>\n5: line five\n6: line six\n7: line seven\n8: line eight\n\n(Showing lines 5-8 of 10. Use offset=9 to continue.)\n</content>"}],"isError":false},"sourceEventSeqs":[58],"surfaceOp":"append"}
{"type":"step/end","seq":60,"time":1782993882095,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":61,"time":1782993882095,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":62,"time":1782993882552,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"assistant/chunk","seq":63,"time":1782993882552,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}
{"type":"assistant/chunk","seq":64,"time":1782993882625,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}}
{"type":"assistant/chunk","seq":65,"time":1782993882653,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}}
{"type":"assistant/chunk","seq":66,"time":1782993882653,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}
{"type":"assistant/chunk","seq":67,"time":1782993882653,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}
{"type":"assistant/chunk","seq":68,"time":1782993882653,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}}
{"type":"assistant/chunk","seq":69,"time":1782993882653,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" lines"}}}
{"type":"assistant/chunk","seq":70,"time":1782993882654,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}}
{"type":"assistant/chunk","seq":71,"time":1782993882680,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"5"}}}
{"type":"assistant/chunk","seq":72,"time":1782993882680,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"-"}}}
{"type":"assistant/chunk","seq":73,"time":1782993882681,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"8"}}}
{"type":"assistant/chunk","seq":74,"time":1782993882681,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" of"}}}
{"type":"assistant/chunk","seq":75,"time":1782993882707,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" big"}}}
{"type":"assistant/chunk","seq":76,"time":1782993882708,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}}
{"type":"assistant/chunk","seq":77,"time":1782993882708,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}}
{"type":"assistant/chunk","seq":78,"time":1782993882708,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}}
{"type":"assistant/chunk","seq":79,"time":1782993882708,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}
{"type":"assistant/chunk","seq":80,"time":1782993882735,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}
{"type":"assistant/chunk","seq":81,"time":1782993882735,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}}
{"type":"assistant/chunk","seq":82,"time":1782993882735,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
{"type":"assistant/chunk","seq":83,"time":1782993882735,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}}
{"type":"assistant/chunk","seq":84,"time":1782993882735,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}}
{"type":"assistant/chunk","seq":85,"time":1782993882736,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}}
{"type":"assistant/chunk","seq":86,"time":1782993882767,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}}
{"type":"assistant/chunk","seq":87,"time":1782993882767,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'ve"}}}
{"type":"assistant/chunk","seq":88,"time":1782993882767,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" done"}}}
{"type":"assistant/chunk","seq":89,"time":1782993882767,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}}
{"type":"assistant/chunk","seq":90,"time":1782993882790,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}
{"type":"assistant/chunk","seq":91,"time":1782993882818,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
{"type":"assistant/chunk","seq":92,"time":1782993882818,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}}
{"type":"assistant/chunk","seq":93,"time":1782993882818,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}}
{"type":"assistant/chunk","seq":94,"time":1782993882819,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to read lines 5-8 of big.txt and then reply with exactly \"DONE\". I've done that."}}}}
{"type":"assistant/chunk","seq":95,"time":1782993882819,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
{"type":"assistant/chunk","seq":96,"time":1782993882819,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":303,"outputTokens":31,"cacheReadTokens":2176,"reasoningTokens":28}}}}
{"type":"assistant/chunk","seq":97,"time":1782993882819,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":98,"time":1782993882820,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user asked me to read lines 5-8 of big.txt and then reply with exactly \"DONE\". I've done that."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":303,"outputTokens":31,"cacheReadTokens":2176,"reasoningTokens":28}},"sourceEventSeqs":[62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97],"surfaceOp":"append"}
{"type":"step/end","seq":99,"time":1782993882820,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":100,"time":1782993882820,"data":{"turn":1,"reason":{"kind":"completed"}}}
@@ -0,0 +1,59 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Let"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" read"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" lines"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" "}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"5"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"-"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"8"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" of"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" big"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".txt"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" using"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" read"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" offset"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" "}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"5"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" limit"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" "}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"4"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_0htYNlUzC9b8aH2gHN8h2706","title":"Read big.txt","kind":"read","status":"in_progress","rawInput":"offset 5, limit 4","locations":[{"path":"big.txt","line":5}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_0htYNlUzC9b8aH2gHN8h2706","status":"completed","content":[{"type":"content","content":{"type":"text","text":"<path>{{cwd}}/big.txt</path>\n<type>file</type>\n<content>\n5: line five\n6: line six\n7: line seven\n8: line eight\n\n(Showing lines 5-8 of 10. Use offset=9 to continue.)\n</content>"}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" read"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" lines"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" "}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"5"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"-"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"8"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" of"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" big"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".txt"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" then"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"D"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'ve"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" done"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"D"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ONE"}}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
@@ -0,0 +1,10 @@
line one
line two
line three
line four
line five
line six
line seven
line eight
line nine
line ten
@@ -0,0 +1,7 @@
{
"steps": [
{ "op": "initialize" },
{ "op": "newSession" },
{ "op": "prompt", "text": "Use the read tool (NOT bash) to read the file greeting.txt in the current directory, then reply with exactly the single word DONE." }
]
}
@@ -0,0 +1,93 @@
{"type":"session","version":0,"id":"01de71a7-68ef-469f-8a73-de9c1d7c55cf","createdAt":1782993863844,"cwd":"/tmp/acp-snap-cwd-WE9Cx4"}
{"type":"turn/start","seq":0,"time":1782993863849,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":1782993863849,"data":{"content":[{"type":"text","text":"Use the read tool (NOT bash) to read the file greeting.txt in the current directory, then reply with exactly the single word DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":1782993863850,"data":{"turn":1,"step":1}}
{"type":"assistant/chunk","seq":3,"time":1782993864293,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"assistant/chunk","seq":4,"time":1782993864294,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}
{"type":"assistant/chunk","seq":5,"time":1782993864378,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}}
{"type":"assistant/chunk","seq":6,"time":1782993864407,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}}
{"type":"assistant/chunk","seq":7,"time":1782993864408,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}
{"type":"assistant/chunk","seq":8,"time":1782993864408,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}
{"type":"assistant/chunk","seq":9,"time":1782993864408,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}}
{"type":"assistant/chunk","seq":10,"time":1782993864408,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}}
{"type":"assistant/chunk","seq":11,"time":1782993864435,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" greeting"}}}
{"type":"assistant/chunk","seq":12,"time":1782993864435,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}}
{"type":"assistant/chunk","seq":13,"time":1782993864464,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}}
{"type":"assistant/chunk","seq":14,"time":1782993864464,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}}
{"type":"assistant/chunk","seq":15,"time":1782993864464,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}}
{"type":"assistant/chunk","seq":16,"time":1782993864464,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}}
{"type":"assistant/chunk","seq":17,"time":1782993864464,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}}
{"type":"assistant/chunk","seq":18,"time":1782993864465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}}
{"type":"assistant/chunk","seq":19,"time":1782993864493,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}}
{"type":"assistant/chunk","seq":20,"time":1782993864494,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}}
{"type":"assistant/chunk","seq":21,"time":1782993864519,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}}
{"type":"assistant/chunk","seq":22,"time":1782993864520,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}}
{"type":"assistant/chunk","seq":23,"time":1782993864548,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}}
{"type":"assistant/chunk","seq":24,"time":1782993864548,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}
{"type":"assistant/chunk","seq":25,"time":1782993864549,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}
{"type":"assistant/chunk","seq":26,"time":1782993864549,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}}
{"type":"assistant/chunk","seq":27,"time":1782993864577,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
{"type":"assistant/chunk","seq":28,"time":1782993864577,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}}
{"type":"assistant/chunk","seq":29,"time":1782993864577,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}}
{"type":"assistant/chunk","seq":30,"time":1782993864577,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}}
{"type":"assistant/chunk","seq":31,"time":1782993864662,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":32,"time":1782993864663,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":""}}}
{"type":"assistant/chunk","seq":33,"time":1782993864691,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":"{"}}}
{"type":"assistant/chunk","seq":34,"time":1782993864692,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":35,"time":1782993864692,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":"file"}}}
{"type":"assistant/chunk","seq":36,"time":1782993864692,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":"_path"}}}
{"type":"assistant/chunk","seq":37,"time":1782993864692,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":38,"time":1782993864692,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":": "}}}
{"type":"assistant/chunk","seq":39,"time":1782993864720,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":40,"time":1782993864721,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":"gre"}}}
{"type":"assistant/chunk","seq":41,"time":1782993864721,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":"eting"}}}
{"type":"assistant/chunk","seq":42,"time":1782993864721,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":".txt"}}}
{"type":"assistant/chunk","seq":43,"time":1782993864755,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":44,"time":1782993864755,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","argumentsDelta":"}"}}}
{"type":"assistant/chunk","seq":45,"time":1782993864807,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to read the greeting.txt file using the read tool, not bash, and then reply with exactly \"DONE\"."}}}}
{"type":"assistant/chunk","seq":46,"time":1782993864807,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}}}}
{"type":"assistant/chunk","seq":47,"time":1782993864807,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":106,"outputTokens":73,"cacheReadTokens":2176,"reasoningTokens":27}}}}
{"type":"assistant/chunk","seq":48,"time":1782993864808,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":49,"time":1782993864810,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to read the greeting.txt file using the read tool, not bash, and then reply with exactly \"DONE\"."},{"type":"tool-call","id":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}],"usage":{"inputTokens":106,"outputTokens":73,"cacheReadTokens":2176,"reasoningTokens":27}},"sourceEventSeqs":[3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48],"surfaceOp":"append"}
{"type":"tool/call","seq":50,"time":1782993864810,"data":{"turn":1,"step":1,"callId":"call_00_6cBhaXfexPCkwewPFfJd4624","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}}
{"type":"tool/result","seq":51,"time":1782993864815,"data":{"turn":1,"step":1,"callId":"call_00_6cBhaXfexPCkwewPFfJd4624","content":[{"type":"text","text":"<path>/tmp/acp-snap-cwd-WE9Cx4/greeting.txt</path>\n<type>file</type>\n<content>\n1: hello\n\n(End of file - total 1 lines)\n</content>"}],"isError":false},"sourceEventSeqs":[50],"surfaceOp":"append"}
{"type":"step/end","seq":52,"time":1782993864816,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":53,"time":1782993864816,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":54,"time":1782993866091,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"assistant/chunk","seq":55,"time":1782993866091,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}
{"type":"assistant/chunk","seq":56,"time":1782993866187,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}}
{"type":"assistant/chunk","seq":57,"time":1782993866215,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" contains"}}}
{"type":"assistant/chunk","seq":58,"time":1782993866216,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
{"type":"assistant/chunk","seq":59,"time":1782993866216,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"hello"}}}
{"type":"assistant/chunk","seq":60,"time":1782993866244,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}}
{"type":"assistant/chunk","seq":61,"time":1782993866244,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" on"}}}
{"type":"assistant/chunk","seq":62,"time":1782993866244,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" line"}}}
{"type":"assistant/chunk","seq":63,"time":1782993866244,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}}
{"type":"assistant/chunk","seq":64,"time":1782993866277,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}}
{"type":"assistant/chunk","seq":65,"time":1782993866277,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}
{"type":"assistant/chunk","seq":66,"time":1782993866277,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}}
{"type":"assistant/chunk","seq":67,"time":1782993866277,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}}
{"type":"assistant/chunk","seq":68,"time":1782993866277,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}}
{"type":"assistant/chunk","seq":69,"time":1782993866302,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}
{"type":"assistant/chunk","seq":70,"time":1782993866303,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}
{"type":"assistant/chunk","seq":71,"time":1782993866303,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}}
{"type":"assistant/chunk","seq":72,"time":1782993866330,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}}
{"type":"assistant/chunk","seq":73,"time":1782993866330,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}}
{"type":"assistant/chunk","seq":74,"time":1782993866330,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}}
{"type":"assistant/chunk","seq":75,"time":1782993866331,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}
{"type":"assistant/chunk","seq":76,"time":1782993866331,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}
{"type":"assistant/chunk","seq":77,"time":1782993866358,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}}
{"type":"assistant/chunk","seq":78,"time":1782993866359,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
{"type":"assistant/chunk","seq":79,"time":1782993866359,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}}
{"type":"assistant/chunk","seq":80,"time":1782993866359,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}}
{"type":"assistant/chunk","seq":81,"time":1782993866359,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}}
{"type":"assistant/chunk","seq":82,"time":1782993866387,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
{"type":"assistant/chunk","seq":83,"time":1782993866387,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}}
{"type":"assistant/chunk","seq":84,"time":1782993866388,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}}
{"type":"assistant/chunk","seq":85,"time":1782993866388,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file contains \"hello\" on line 1. The user asked me to read it and then reply with exactly \"DONE\"."}}}}
{"type":"assistant/chunk","seq":86,"time":1782993866388,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
{"type":"assistant/chunk","seq":87,"time":1782993866388,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":239,"outputTokens":30,"cacheReadTokens":2176,"reasoningTokens":27}}}}
{"type":"assistant/chunk","seq":88,"time":1782993866388,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":89,"time":1782993866388,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The file contains \"hello\" on line 1. The user asked me to read it and then reply with exactly \"DONE\"."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":239,"outputTokens":30,"cacheReadTokens":2176,"reasoningTokens":27}},"sourceEventSeqs":[54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88],"surfaceOp":"append"}
{"type":"step/end","seq":90,"time":1782993866388,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":91,"time":1782993866389,"data":{"turn":1,"reason":{"kind":"completed"}}}
@@ -0,0 +1,61 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" read"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" greeting"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".txt"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" using"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" read"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" not"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" then"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"D"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_6cBhaXfexPCkwewPFfJd4624","title":"Read greeting.txt","kind":"read","status":"in_progress","locations":[{"path":"greeting.txt"}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_6cBhaXfexPCkwewPFfJd4624","status":"completed","content":[{"type":"content","content":{"type":"text","text":"<path>{{cwd}}/greeting.txt</path>\n<type>file</type>\n<content>\n1: hello\n\n(End of file - total 1 lines)\n</content>"}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" contains"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"hello"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" on"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" line"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" "}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"1"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" read"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" then"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"D"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"D"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ONE"}}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
@@ -0,0 +1 @@
hello
@@ -0,0 +1,7 @@
{
"steps": [
{ "op": "initialize" },
{ "op": "newSession" },
{ "op": "prompt", "text": "First use the read tool to read data.txt in the current directory. Then use the write tool (NOT bash) to replace its entire contents with exactly the single line: replaced. Then reply with exactly the single word DONE." }
]
}
@@ -0,0 +1,132 @@
{"type":"session","version":0,"id":"2b08a4bd-62f1-4846-b57f-7c62d4101673","createdAt":1782993794495,"cwd":"/tmp/acp-snap-cwd-X0UUW6"}
{"type":"turn/start","seq":0,"time":1782993794499,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":1782993794499,"data":{"content":[{"type":"text","text":"First use the read tool to read data.txt in the current directory. Then use the write tool (NOT bash) to replace its entire contents with exactly the single line: replaced. Then reply with exactly the single word DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":1782993794500,"data":{"turn":1,"step":1}}
{"type":"assistant/chunk","seq":3,"time":1782993794915,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"assistant/chunk","seq":4,"time":1782993794915,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}
{"type":"assistant/chunk","seq":5,"time":1782993795030,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}}
{"type":"assistant/chunk","seq":6,"time":1782993795057,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}}
{"type":"assistant/chunk","seq":7,"time":1782993795057,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}
{"type":"assistant/chunk","seq":8,"time":1782993795057,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}
{"type":"assistant/chunk","seq":9,"time":1782993795058,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}}
{"type":"assistant/chunk","seq":10,"time":1782993795087,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}}
{"type":"assistant/chunk","seq":11,"time":1782993795088,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" data"}}}
{"type":"assistant/chunk","seq":12,"time":1782993795088,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}}
{"type":"assistant/chunk","seq":13,"time":1782993795088,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}}
{"type":"assistant/chunk","seq":14,"time":1782993795088,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}}
{"type":"assistant/chunk","seq":15,"time":1782993795113,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" replace"}}}
{"type":"assistant/chunk","seq":16,"time":1782993795114,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" its"}}}
{"type":"assistant/chunk","seq":17,"time":1782993795114,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" entire"}}}
{"type":"assistant/chunk","seq":18,"time":1782993795141,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" contents"}}}
{"type":"assistant/chunk","seq":19,"time":1782993795141,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}
{"type":"assistant/chunk","seq":20,"time":1782993795141,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
{"type":"assistant/chunk","seq":21,"time":1782993795168,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"re"}}}
{"type":"assistant/chunk","seq":22,"time":1782993795168,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"placed"}}}
{"type":"assistant/chunk","seq":23,"time":1782993795168,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\","}}}
{"type":"assistant/chunk","seq":24,"time":1782993795168,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}}
{"type":"assistant/chunk","seq":25,"time":1782993795198,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}}
{"type":"assistant/chunk","seq":26,"time":1782993795198,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}
{"type":"assistant/chunk","seq":27,"time":1782993795198,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}
{"type":"assistant/chunk","seq":28,"time":1782993795198,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
{"type":"assistant/chunk","seq":29,"time":1782993795198,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}}
{"type":"assistant/chunk","seq":30,"time":1782993795199,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}}
{"type":"assistant/chunk","seq":31,"time":1782993795230,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}}
{"type":"assistant/chunk","seq":32,"time":1782993795313,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":33,"time":1782993795314,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","argumentsDelta":""}}}
{"type":"assistant/chunk","seq":34,"time":1782993795343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","argumentsDelta":"{"}}}
{"type":"assistant/chunk","seq":35,"time":1782993795343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":36,"time":1782993795343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","argumentsDelta":"file"}}}
{"type":"assistant/chunk","seq":37,"time":1782993795343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","argumentsDelta":"_path"}}}
{"type":"assistant/chunk","seq":38,"time":1782993795343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":39,"time":1782993795343,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","argumentsDelta":": "}}}
{"type":"assistant/chunk","seq":40,"time":1782993795372,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":41,"time":1782993795373,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","argumentsDelta":"data"}}}
{"type":"assistant/chunk","seq":42,"time":1782993795373,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","argumentsDelta":".txt"}}}
{"type":"assistant/chunk","seq":43,"time":1782993795404,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":44,"time":1782993795404,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","argumentsDelta":"}"}}}
{"type":"assistant/chunk","seq":45,"time":1782993795466,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to first read data.txt, then replace its entire contents with \"replaced\", and then reply with \"DONE\"."}}}}
{"type":"assistant/chunk","seq":46,"time":1782993795466,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}}}}
{"type":"assistant/chunk","seq":47,"time":1782993795466,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":123,"outputTokens":73,"cacheReadTokens":2176,"reasoningTokens":28}}}}
{"type":"assistant/chunk","seq":48,"time":1782993795466,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":49,"time":1782993795468,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to first read data.txt, then replace its entire contents with \"replaced\", and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}],"usage":{"inputTokens":123,"outputTokens":73,"cacheReadTokens":2176,"reasoningTokens":28}},"sourceEventSeqs":[3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48],"surfaceOp":"append"}
{"type":"tool/call","seq":50,"time":1782993795468,"data":{"turn":1,"step":1,"callId":"call_00_GtqlR9riew6wgdQzLbbu6019","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}}
{"type":"tool/result","seq":51,"time":1782993795473,"data":{"turn":1,"step":1,"callId":"call_00_GtqlR9riew6wgdQzLbbu6019","content":[{"type":"text","text":"<path>/tmp/acp-snap-cwd-X0UUW6/data.txt</path>\n<type>file</type>\n<content>\n1: original contents\n\n(End of file - total 1 lines)\n</content>"}],"isError":false},"sourceEventSeqs":[50],"surfaceOp":"append"}
{"type":"step/end","seq":52,"time":1782993795473,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":53,"time":1782993795473,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":54,"time":1782993796122,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"assistant/chunk","seq":55,"time":1782993796122,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Now"}}}
{"type":"assistant/chunk","seq":56,"time":1782993796250,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}}
{"type":"assistant/chunk","seq":57,"time":1782993796281,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}}
{"type":"assistant/chunk","seq":58,"time":1782993796281,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}
{"type":"assistant/chunk","seq":59,"time":1782993796282,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" replace"}}}
{"type":"assistant/chunk","seq":60,"time":1782993796282,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}}
{"type":"assistant/chunk","seq":61,"time":1782993796309,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" entire"}}}
{"type":"assistant/chunk","seq":62,"time":1782993796309,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" contents"}}}
{"type":"assistant/chunk","seq":63,"time":1782993796309,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" of"}}}
{"type":"assistant/chunk","seq":64,"time":1782993796310,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" data"}}}
{"type":"assistant/chunk","seq":65,"time":1782993796338,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}}
{"type":"assistant/chunk","seq":66,"time":1782993796338,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}
{"type":"assistant/chunk","seq":67,"time":1782993796338,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}}
{"type":"assistant/chunk","seq":68,"time":1782993796339,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}}
{"type":"assistant/chunk","seq":69,"time":1782993796367,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}}
{"type":"assistant/chunk","seq":70,"time":1782993796367,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" line"}}}
{"type":"assistant/chunk","seq":71,"time":1782993796368,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
{"type":"assistant/chunk","seq":72,"time":1782993796368,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"re"}}}
{"type":"assistant/chunk","seq":73,"time":1782993796368,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"placed"}}}
{"type":"assistant/chunk","seq":74,"time":1782993796368,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}}
{"type":"assistant/chunk","seq":75,"time":1782993796452,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":76,"time":1782993796452,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":""}}}
{"type":"assistant/chunk","seq":77,"time":1782993796480,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"{"}}}
{"type":"assistant/chunk","seq":78,"time":1782993796480,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":79,"time":1782993796480,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"file"}}}
{"type":"assistant/chunk","seq":80,"time":1782993796480,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"_path"}}}
{"type":"assistant/chunk","seq":81,"time":1782993796480,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":82,"time":1782993796508,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":": "}}}
{"type":"assistant/chunk","seq":83,"time":1782993796508,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":84,"time":1782993796508,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"data"}}}
{"type":"assistant/chunk","seq":85,"time":1782993796508,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":".txt"}}}
{"type":"assistant/chunk","seq":86,"time":1782993796536,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":87,"time":1782993796565,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":", "}}}
{"type":"assistant/chunk","seq":88,"time":1782993796565,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":89,"time":1782993796566,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"content"}}}
{"type":"assistant/chunk","seq":90,"time":1782993796566,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":91,"time":1782993796566,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":": "}}}
{"type":"assistant/chunk","seq":92,"time":1782993796593,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":93,"time":1782993796593,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"re"}}}
{"type":"assistant/chunk","seq":94,"time":1782993796593,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"placed"}}}
{"type":"assistant/chunk","seq":95,"time":1782993796621,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":96,"time":1782993796621,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","argumentsDelta":"}"}}}
{"type":"assistant/chunk","seq":97,"time":1782993796681,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Now I need to replace the entire contents of data.txt with exactly the single line \"replaced\"."}}}}
{"type":"assistant/chunk","seq":98,"time":1782993796681,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}}}}
{"type":"assistant/chunk","seq":99,"time":1782993796681,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":254,"outputTokens":82,"cacheReadTokens":2176,"reasoningTokens":20}}}}
{"type":"assistant/chunk","seq":100,"time":1782993796681,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":101,"time":1782993796681,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Now I need to replace the entire contents of data.txt with exactly the single line \"replaced\"."},{"type":"tool-call","id":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}],"usage":{"inputTokens":254,"outputTokens":82,"cacheReadTokens":2176,"reasoningTokens":20}},"sourceEventSeqs":[54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100],"surfaceOp":"append"}
{"type":"tool/call","seq":102,"time":1782993796681,"data":{"turn":1,"step":2,"callId":"call_00_CkV4RzKjuERcr4NdJbtY3226","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}}
{"type":"tool/result","seq":103,"time":1782993796688,"data":{"turn":1,"step":2,"callId":"call_00_CkV4RzKjuERcr4NdJbtY3226","content":[{"type":"text","text":"<path>/tmp/acp-snap-cwd-X0UUW6/data.txt</path>\n<type>file</type>\n<content>\nUpdated file\n</content>"}],"isError":false},"sourceEventSeqs":[102],"surfaceOp":"append"}
{"type":"step/end","seq":104,"time":1782993796689,"data":{"turn":1,"step":2}}
{"type":"step/start","seq":105,"time":1782993796689,"data":{"turn":1,"step":3}}
{"type":"assistant/chunk","seq":106,"time":1782993797188,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"assistant/chunk","seq":107,"time":1782993797189,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"Done"}}}
{"type":"assistant/chunk","seq":108,"time":1782993797260,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}
{"type":"assistant/chunk","seq":109,"time":1782993797289,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}}
{"type":"assistant/chunk","seq":110,"time":1782993797290,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}}
{"type":"assistant/chunk","seq":111,"time":1782993797290,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}
{"type":"assistant/chunk","seq":112,"time":1782993797290,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}
{"type":"assistant/chunk","seq":113,"time":1782993797317,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}
{"type":"assistant/chunk","seq":114,"time":1782993797317,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}}
{"type":"assistant/chunk","seq":115,"time":1782993797317,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}}
{"type":"assistant/chunk","seq":116,"time":1782993797354,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}}
{"type":"assistant/chunk","seq":117,"time":1782993797355,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}}
{"type":"assistant/chunk","seq":118,"time":1782993797355,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}}
{"type":"assistant/chunk","seq":119,"time":1782993797355,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}}
{"type":"assistant/chunk","seq":120,"time":1782993797355,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}
{"type":"assistant/chunk","seq":121,"time":1782993797384,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
{"type":"assistant/chunk","seq":122,"time":1782993797384,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"D"}}}
{"type":"assistant/chunk","seq":123,"time":1782993797385,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}}
{"type":"assistant/chunk","seq":124,"time":1782993797385,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Done. I need to reply with exactly the single word DONE."}}}}
{"type":"assistant/chunk","seq":125,"time":1782993797385,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
{"type":"assistant/chunk","seq":126,"time":1782993797385,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":254,"outputTokens":17,"cacheReadTokens":2304,"reasoningTokens":14}}}}
{"type":"assistant/chunk","seq":127,"time":1782993797385,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":128,"time":1782993797386,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"Done. I need to reply with exactly the single word DONE."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":254,"outputTokens":17,"cacheReadTokens":2304,"reasoningTokens":14}},"sourceEventSeqs":[106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127],"surfaceOp":"append"}
{"type":"step/end","seq":129,"time":1782993797386,"data":{"turn":1,"step":3}}
{"type":"turn/end","seq":130,"time":1782993797386,"data":{"turn":1,"reason":{"kind":"completed"}}}
@@ -0,0 +1,71 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" first"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" read"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" data"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".txt"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" then"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" replace"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" its"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" entire"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" contents"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"re"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"placed"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\","}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" then"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"D"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_GtqlR9riew6wgdQzLbbu6019","title":"Read data.txt","kind":"read","status":"in_progress","locations":[{"path":"data.txt"}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_GtqlR9riew6wgdQzLbbu6019","status":"completed","content":[{"type":"content","content":{"type":"text","text":"<path>{{cwd}}/data.txt</path>\n<type>file</type>\n<content>\n1: original contents\n\n(End of file - total 1 lines)\n</content>"}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Now"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" replace"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" entire"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" contents"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" of"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" data"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".txt"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" line"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"re"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"placed"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_CkV4RzKjuERcr4NdJbtY3226","title":"Write data.txt","kind":"edit","status":"in_progress","locations":[{"path":"data.txt"}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_CkV4RzKjuERcr4NdJbtY3226","status":"completed","content":[{"type":"content","content":{"type":"text","text":"<path>{{cwd}}/data.txt</path>\n<type>file</type>\n<content>\nUpdated file\n</content>"}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Done"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" single"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" D"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"D"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ONE"}}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
@@ -0,0 +1 @@
original contents
@@ -0,0 +1,7 @@
{
"steps": [
{ "op": "initialize" },
{ "op": "newSession" },
{ "op": "prompt", "text": "Use the write tool (NOT bash) to create a file named notes.txt in the current directory containing exactly the single line: hello world. Then reply with exactly the single word DONE." }
]
}
@@ -0,0 +1,95 @@
{"type":"session","version":0,"id":"5475c102-9aaa-4952-8a48-d5c3444eb322","createdAt":1782993761947,"cwd":"/tmp/acp-snap-cwd-v8qbp7"}
{"type":"turn/start","seq":0,"time":1782993761951,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":1782993761952,"data":{"content":[{"type":"text","text":"Use the write tool (NOT bash) to create a file named notes.txt in the current directory containing exactly the single line: hello world. Then reply with exactly the single word DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":1782993761953,"data":{"turn":1,"step":1}}
{"type":"assistant/chunk","seq":3,"time":1782993762528,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"assistant/chunk","seq":4,"time":1782993762529,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}
{"type":"assistant/chunk","seq":5,"time":1782993762648,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}}
{"type":"assistant/chunk","seq":6,"time":1782993762676,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}}
{"type":"assistant/chunk","seq":7,"time":1782993762676,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}
{"type":"assistant/chunk","seq":8,"time":1782993762677,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}
{"type":"assistant/chunk","seq":9,"time":1782993762677,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" create"}}}
{"type":"assistant/chunk","seq":10,"time":1782993762677,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}}
{"type":"assistant/chunk","seq":11,"time":1782993762677,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}}
{"type":"assistant/chunk","seq":12,"time":1782993762704,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" named"}}}
{"type":"assistant/chunk","seq":13,"time":1782993762731,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" notes"}}}
{"type":"assistant/chunk","seq":14,"time":1782993762732,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}}
{"type":"assistant/chunk","seq":15,"time":1782993762732,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}
{"type":"assistant/chunk","seq":16,"time":1782993762773,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}}
{"type":"assistant/chunk","seq":17,"time":1782993762773,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" content"}}}
{"type":"assistant/chunk","seq":18,"time":1782993762787,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
{"type":"assistant/chunk","seq":19,"time":1782993762788,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"hello"}}}
{"type":"assistant/chunk","seq":20,"time":1782993762788,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" world"}}}
{"type":"assistant/chunk","seq":21,"time":1782993762788,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}}
{"type":"assistant/chunk","seq":22,"time":1782993762788,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}}
{"type":"assistant/chunk","seq":23,"time":1782993762788,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}}
{"type":"assistant/chunk","seq":24,"time":1782993762815,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}}
{"type":"assistant/chunk","seq":25,"time":1782993762815,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}}
{"type":"assistant/chunk","seq":26,"time":1782993762815,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}}
{"type":"assistant/chunk","seq":27,"time":1782993762815,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}}
{"type":"assistant/chunk","seq":28,"time":1782993762843,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}}
{"type":"assistant/chunk","seq":29,"time":1782993762843,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}
{"type":"assistant/chunk","seq":30,"time":1782993762843,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}
{"type":"assistant/chunk","seq":31,"time":1782993762843,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
{"type":"assistant/chunk","seq":32,"time":1782993762843,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}}
{"type":"assistant/chunk","seq":33,"time":1782993762844,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}}
{"type":"assistant/chunk","seq":34,"time":1782993762871,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}}
{"type":"assistant/chunk","seq":35,"time":1782993762926,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":36,"time":1782993762927,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":""}}}
{"type":"assistant/chunk","seq":37,"time":1782993762953,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"{"}}}
{"type":"assistant/chunk","seq":38,"time":1782993762954,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":39,"time":1782993762954,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"file"}}}
{"type":"assistant/chunk","seq":40,"time":1782993762954,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"_path"}}}
{"type":"assistant/chunk","seq":41,"time":1782993762987,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":42,"time":1782993762988,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":": "}}}
{"type":"assistant/chunk","seq":43,"time":1782993762988,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":44,"time":1782993762988,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"notes"}}}
{"type":"assistant/chunk","seq":45,"time":1782993763013,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":".txt"}}}
{"type":"assistant/chunk","seq":46,"time":1782993763013,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":47,"time":1782993763040,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":", "}}}
{"type":"assistant/chunk","seq":48,"time":1782993763041,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":49,"time":1782993763041,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"content"}}}
{"type":"assistant/chunk","seq":50,"time":1782993763041,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":51,"time":1782993763068,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":": "}}}
{"type":"assistant/chunk","seq":52,"time":1782993763069,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":53,"time":1782993763069,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"hello"}}}
{"type":"assistant/chunk","seq":54,"time":1782993763069,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":" world"}}}
{"type":"assistant/chunk","seq":55,"time":1782993763097,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"\""}}}
{"type":"assistant/chunk","seq":56,"time":1782993763097,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","argumentsDelta":"}"}}}
{"type":"assistant/chunk","seq":57,"time":1782993763155,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to create a file named notes.txt with the content \"hello world\" using the write tool, and then reply with \"DONE\"."}}}}
{"type":"assistant/chunk","seq":58,"time":1782993763155,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}}}}
{"type":"assistant/chunk","seq":59,"time":1782993763155,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":115,"outputTokens":93,"cacheReadTokens":2176,"reasoningTokens":31}}}}
{"type":"assistant/chunk","seq":60,"time":1782993763155,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":61,"time":1782993763157,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to create a file named notes.txt with the content \"hello world\" using the write tool, and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}],"usage":{"inputTokens":115,"outputTokens":93,"cacheReadTokens":2176,"reasoningTokens":31}},"sourceEventSeqs":[3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60],"surfaceOp":"append"}
{"type":"tool/call","seq":62,"time":1782993763157,"data":{"turn":1,"step":1,"callId":"call_00_OxAUP9dIc6I1B6Coo5vs8586","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}}
{"type":"tool/result","seq":63,"time":1782993763164,"data":{"turn":1,"step":1,"callId":"call_00_OxAUP9dIc6I1B6Coo5vs8586","content":[{"type":"text","text":"<path>/tmp/acp-snap-cwd-v8qbp7/notes.txt</path>\n<type>file</type>\n<content>\nCreated file\n</content>"}],"isError":false},"sourceEventSeqs":[62],"surfaceOp":"append"}
{"type":"step/end","seq":64,"time":1782993763164,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":65,"time":1782993763165,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":66,"time":1782993763769,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"assistant/chunk","seq":67,"time":1782993763769,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}
{"type":"assistant/chunk","seq":68,"time":1782993763841,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}}
{"type":"assistant/chunk","seq":69,"time":1782993763869,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}}
{"type":"assistant/chunk","seq":70,"time":1782993763869,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" created"}}}
{"type":"assistant/chunk","seq":71,"time":1782993763869,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}}
{"type":"assistant/chunk","seq":72,"time":1782993763869,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}
{"type":"assistant/chunk","seq":73,"time":1782993763869,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}}
{"type":"assistant/chunk","seq":74,"time":1782993763900,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}}
{"type":"assistant/chunk","seq":75,"time":1782993763901,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}}
{"type":"assistant/chunk","seq":76,"time":1782993763901,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}}
{"type":"assistant/chunk","seq":77,"time":1782993763901,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}
{"type":"assistant/chunk","seq":78,"time":1782993763901,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}
{"type":"assistant/chunk","seq":79,"time":1782993763930,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}
{"type":"assistant/chunk","seq":80,"time":1782993763931,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}
{"type":"assistant/chunk","seq":81,"time":1782993763931,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}}
{"type":"assistant/chunk","seq":82,"time":1782993763931,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}}
{"type":"assistant/chunk","seq":83,"time":1782993763931,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}}
{"type":"assistant/chunk","seq":84,"time":1782993763957,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
{"type":"assistant/chunk","seq":85,"time":1782993763957,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}}
{"type":"assistant/chunk","seq":86,"time":1782993763958,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}}
{"type":"assistant/chunk","seq":87,"time":1782993763958,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file was created successfully. Now I just need to reply with \"DONE\"."}}}}
{"type":"assistant/chunk","seq":88,"time":1782993763958,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
{"type":"assistant/chunk","seq":89,"time":1782993763958,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":255,"outputTokens":20,"cacheReadTokens":2176,"reasoningTokens":17}}}}
{"type":"assistant/chunk","seq":90,"time":1782993763958,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":91,"time":1782993763958,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The file was created successfully. Now I just need to reply with \"DONE\"."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":255,"outputTokens":20,"cacheReadTokens":2176,"reasoningTokens":17}},"sourceEventSeqs":[66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90],"surfaceOp":"append"}
{"type":"step/end","seq":92,"time":1782993763958,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":93,"time":1782993763959,"data":{"turn":1,"reason":{"kind":"completed"}}}
@@ -0,0 +1,55 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" create"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" named"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" notes"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".txt"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" content"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"hello"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" world"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" using"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" write"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" then"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"D"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_OxAUP9dIc6I1B6Coo5vs8586","title":"Write notes.txt","kind":"edit","status":"in_progress","locations":[{"path":"notes.txt"}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_OxAUP9dIc6I1B6Coo5vs8586","status":"completed","content":[{"type":"content","content":{"type":"text","text":"<path>{{cwd}}/notes.txt</path>\n<type>file</type>\n<content>\nCreated file\n</content>"}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" was"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" created"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" successfully"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Now"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" just"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"D"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"D"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ONE"}}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
+3 -2
View File
@@ -1,6 +1,6 @@
# coding-agent
The real stdio coding-agent wiring: DeepSeek V4 + the bash tool suite + subagent delegation + `todo_write` + stdio chat + JSONL persistence, loaded from `cordis.yml`. Where echo-agent proves the skeleton with mocks, this example is a usable coding assistant.
The real stdio coding-agent wiring: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite + subagent delegation + `todo_write` + stdio chat + JSONL persistence, loaded from `cordis.yml`. Where echo-agent proves the skeleton with mocks, this example is a usable coding assistant.
## Run it
@@ -11,7 +11,7 @@ The real stdio coding-agent wiring: DeepSeek V4 + the bash tool suite + subagent
pnpm run demo:coding
```
Type a coding task. The agent works through `bash` (+ `bash_output` / `bash_kill` for background tasks): file reads, writes, searches, and test runs all happen through shell commands, each in a fresh `bash -c` (the system prompt tells the model to pass `workdir` instead of `cd`). It can also delegate with `subagent`/`subagent_fork` and track multi-step work with `todo_write` (a whole-list task tracker rendered as a checklist). Reasoning streams dimmed; tool calls/results render inline.
Type a coding task. The agent works through the `read`/`write`/`edit` filesystem tools for ordinary file operations and `bash` (+ `bash_output` / `bash_kill` for background tasks) for shell commands, searches, and test runs, each in a fresh `bash -c` (the system prompt tells the model to pass `workdir` instead of `cd`). Both the fs tools and bash resolve relative paths against the session workspace. It can also delegate with `subagent`/`subagent_fork` and track multi-step work with `todo_write` (a whole-list task tracker rendered as a checklist). Reasoning streams dimmed; tool calls/results render inline.
```
> fix the failing test in /path/to/project
@@ -44,6 +44,7 @@ This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads
| `subagent`, `subagent-spawn`, `subagent-fork` | the subagent provider registry plus the two in-process backends: a fresh child and a child seeded with the parent's completed-turn prefix |
| `tool-subagent`, `tool-subagent-fork` | two model-facing `dsh-tool-subagent` loads, each bound to a different provider and exposed under a distinct tool name (`subagent`, `subagent_fork`) |
| `tool-todo` | the model-facing `todo_write` tool; writes the whole task list to the session log and renders as a checklist in stdio |
| `fs-local`, `fs-policy`, `tool-fs` | the filesystem stack: the local `ctx.fs` provider, the read-before-write/edit policy gate (on the `fs/*` event gate), and the model-facing `read`/`write`/`edit` tools. Relative paths resolve against the session workspace |
## End-to-end tests (`pnpm run test:e2e`, key-gated)
+24 -9
View File
@@ -25,12 +25,12 @@
apiKey: !!js process.env.DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_BASE_URL
models:
- deepseek-v4-flash
- deepseek-v4-pro
- deepseek-v4-flash
# Local bash executor for agent-core's tool-bash schema.
# FIXME(config-comments): keep this executor note from implying bash is the
# whole tool set; subagent and todo_write are loaded below.
# whole tool set; filesystem, subagent, and todo_write are loaded below.
- id: bash
name: '@deepseek-ai/dsh-bash-local'
config:
@@ -46,16 +46,17 @@
# under ./.sessions); unset starts a fresh session each run.
resumeSessionId: !!js process.env.RESUME_SESSION_ID
persistenceRoot: './.sessions'
welcome: 'coding-agent ready. Give it a coding task (its tools are bash, subagent, and todo_write).'
welcome: 'coding-agent ready. Give it a coding task (its tools are read, write, edit, bash, subagent, and todo_write).'
systemPrompt: |
You are coding-agent, a CLI coding assistant.
Your tools are bash (plus bash_output/bash_kill for background
tasks) and subagent. Do ALL file operations through bash: read with
cat/sed/head, search with grep, write with heredocs (cat <<'EOF' >
file), edit with sed or a rewrite. Each bash call runs in a fresh
shell — pass workdir instead of cd, and never rely on shell state
between calls.
Your tools are read/write/edit for file operations, bash (plus
bash_output/bash_kill for background tasks), and subagent. Use read to
inspect UTF-8 text files, write to create or replace files, and edit for
targeted literal replacements. Use bash for shell commands, tests,
searches, and operations that are not ordinary file reads or edits. Each
bash call runs in a fresh shell — pass workdir instead of cd, and never
rely on shell state between calls.
Use the subagent tool to delegate a focused, self-contained subtask
to a fresh child agent (it works in its own context and returns only
@@ -123,3 +124,17 @@
# session log (todo/write), rendered as a stdio checklist / ACP plan.
- id: tool-todo
name: '@deepseek-ai/dsh-tool-todo'
# Filesystem capability stack: local provider, read-before-write/edit policy
# gate, then the model-facing read/write/edit tools. stdio-agent is a single
# session, so relative paths resolve from the process cwd (the workspace).
- id: fs-local
name: '@deepseek-ai/dsh-fs-local'
config:
cwd: !!js process.cwd()
- id: fs-policy
name: '@deepseek-ai/dsh-fs-policy'
- id: tool-fs
name: '@deepseek-ai/dsh-tool-fs'
+4
View File
@@ -44,6 +44,10 @@
"packages/subagent/subagent-acp": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts", "tests/mock-acp-server.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
},
"packages/fs/tool-fs": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
}
}
}
+9
View File
@@ -11,6 +11,7 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
| [`core/`](core/README.md) | Product API spine: session, system-prompt, tools, agent, and the concrete loop | Product — stable surface |
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
| [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface |
| [`fs/`](fs/README.md) | Filesystem capability family: the abstract seam, a local impl, and the model-facing file tools | Product — stable surface |
| [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface |
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface |
| [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool (whole-list task tracking on the session log) | Product — stable surface |
@@ -35,6 +36,10 @@ dsh-compact-basic ← dsh-compact, dsh-session, dsh-llm, dsh-agent (char/4 + to
dsh-tools ← dsh-llm, dsh-system-prompt, dsh-agent
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-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)
dsh-agent-loop ← dsh-llm, dsh-session, dsh-session-persistence, dsh-system-prompt, dsh-tools, dsh-agent
@@ -71,6 +76,10 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop
| `bash/` | `bash` | Abstract bash executor seam (interface + vocabulary) | `ctx.bash` |
| `bash-local/` | `bash` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) |
| `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`) |
| `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`) |
| `llm-deepseek/` | `llm` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) |
+1 -1
View File
@@ -72,7 +72,7 @@ See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, an
A tool owns how ITS calls render in a UI (an editor's tool-call card, a CLI log line) — a UI plugin must NOT special-case tool names. A `ToolDefinition` may declare two optional, pure, display-only methods:
- `presentCall(args): ToolCallPresentation | undefined` — the PENDING state: a human-readable `title` (always-visible label), an optional `kind` (`read`/`edit`/`execute`/… for icon/treatment, default `other`), an optional `rawInput` (the salient input to show in a detail view — e.g. a shell command as a string, NOT the whole args object), an optional `content` (UI content shown alongside the title/card — e.g. a bash `description` as a text block above the terminal card), and an optional `terminal` (a neutral `{ cwd? }` asking a capable UI to render this call as a TERMINAL, e.g. for `bash`).
- `presentCall(args): ToolCallPresentation | undefined` — the PENDING state: a human-readable `title` (always-visible label), an optional `kind` (`read`/`edit`/`execute`/… for icon/treatment, default `other`), an optional `rawInput` (the salient input to show in a detail view — e.g. a shell command as a string, NOT the whole args object), an optional `content` (UI content shown alongside the title/card — e.g. a bash `description` as a text block above the terminal card), an optional `locations` (`{ path, line? }[]` — the files this call reads/modifies, so a capable UI can follow along / jump to them; the ACP bridge forwards them as `tool_call.locations`), and an optional `terminal` (a neutral `{ cwd? }` asking a capable UI to render this call as a TERMINAL, e.g. for `bash`).
- `presentResult(args, result): ToolResultPresentation | undefined` — the COMPLETED state, given the same `args` and the `{ content, isError }` result: an optional replacement `title`, reformatted `content` (e.g. wrap command output in a fenced ` ```console ` block — a UI-only affordance that must NOT appear in the model-facing `execute` result), and an optional `terminal` (the `{ output?, exitCode?, signal? }` for a terminal-rendered call). The `ToolTerminal` shape is provider-neutral; a UI bridge (the ACP bridge) maps it to a terminal card (with an exit-status pill) and a UI that can't ignores it and uses `content`.
Returning `undefined` (or omitting a method) tells a UI to fall back to a generic presentation (title = tool name, raw args as input, raw result content). Both methods must be **pure and side-effect-free**: a UI may call them during live streaming AND during a session-log replay, so they depend only on their arguments. With `defineTool`, `args` is the typed `InferArgs<S>` shape; the helper soft-validates before calling (a malformed/older logged arg shape yields `undefined` rather than throwing, since display must never crash a replay). The shapes are provider-neutral — the ACP bridge (`dsh-acp`) maps them to ACP `tool_call`/`tool_call_update` wire fields, and `dsh-tool-bash` is the reference implementation.
+10
View File
@@ -109,6 +109,16 @@ export interface ToolCallPresentation {
* {@link terminal} block (if any) as a terminal card.
*/
content?: ContentBlock[]
/**
* Files this call reads or modifies, so a capable UI can "follow along" —
* highlight or jump to the file (and line) as the tool runs. Provider-neutral
* `{ path, line? }` pairs; a UI bridge maps them to its own affordance (the ACP
* bridge forwards them as `tool_call.locations`). `path` is what the tool
* operated on (the model-facing path); `line` is an optional 1-based line to
* focus (e.g. a read's offset). Omit for a call that touches no file (e.g.
* `bash`).
*/
locations?: { path: string; line?: number }[]
/**
* Ask a capable UI to render this call as a TERMINAL (a command running in a
* working directory), not a generic tool card — set by a tool whose call IS a
@@ -35,7 +35,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
const catalog = await collectToolCatalog()
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
expect(names).toEqual(['bash', 'bash_kill', 'bash_output', 'subagent', 'todo_write'])
expect(names).toEqual(['bash', 'bash_kill', 'bash_output', 'edit', 'read', 'subagent', 'todo_write', 'write'])
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
for (const entry of catalog) {
for (const schema of entry.schemas) {
+12
View File
@@ -0,0 +1,12 @@
# fs/ - filesystem capability family
The filesystem stack: a provider seam (text IO + atomic mutation with an optional version guard), a local implementation, a policy gate plugin (observed-state + read-before-edit + version-guarded write/edit), and the model-facing file tools + executor. All **product** packages.
| Package | Role | ctx key |
|---|---|---|
| `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`) |
| `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 (`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.
+25
View File
@@ -0,0 +1,25 @@
# @deepseek-ai/dsh-fs-local
The **local-filesystem implementation** of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)). Backs the six `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`.
```ts ignore-check
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-fs-policy for the
// freshness policy gate and @deepseek-ai/dsh-tool-fs to expose read/write/edit.
```
## Behavior
- **`resolve(path, opts?)`** — a relative `path` resolves against `opts.cwd` when the caller supplies one (the model-facing tools pass the calling agent's session cwd — see [the per-session cwd RFC](../../../docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md)), else `config.cwd` (default `process.cwd()`); an absolute `path` ignores both. The `targetKey` is the file's `realpath`, so two input paths reaching the same file through symlinks share one identity, and writes/edits land on the link target (preserving the link). A not-yet-existing path uses the realpathed parent directory plus basename when the parent exists; only an unresolvable parent falls back to the absolute path. `displayPath` is the absolute (un-resolved) path.
- **`stat`** — returns `FsInfo` (`version` = `mtimeMs:size`, `type` of `file`/`directory`/`other`, byte `size`) or `undefined` when the target is absent.
- **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` streams it in chunks (cross-chunk decoding) so a huge file never has to be held whole in memory. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) decides which to call by size and owns the line windowing.
- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`. The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`).
- **`editText`** — atomic literal read-modify-write over the same primitive, serialized per target by a mutation lock. The `expected` guard is OPTIONAL: when supplied it verifies the version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content); omitting it edits the current content unconditionally. A missing target reports `FS_STALE_VERSION` either way. LF-normalizes for matching, restores the file's dominant CRLF/LF style, and rejects empty `oldString` / zero matches (`FS_EDIT_NOT_FOUND`) or ambiguous multi-matches without `replace_all` (`FS_AMBIGUOUS_EDIT`).
## `cwd` is not a sandbox
`config.cwd` is a resolution default, not a containment boundary — absolute paths and `..` escape it. Enforce containment with a stricter `ctx.fs` backend or a permission plugin on the `tools/execute` waterfall. See [the filesystem capability-seam RFC's Risks section](../../../docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md#risks).
The raw I/O lives in `src/fsio.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the thin service wiring.
+36
View File
@@ -0,0 +1,36 @@
{
"name": "@deepseek-ai/dsh-fs-local",
"description": "Local-filesystem implementation of the DeepSeek Harness filesystem seam (ctx.fs)",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-fs": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}
+413
View File
@@ -0,0 +1,413 @@
/**
* Cordis-free local-filesystem I/O for `@deepseek-ai/dsh-fs-local`. Kept
* separate from the service class (mirroring `dsh-bash-local`'s `run.ts`) so
* the raw stat/read/write/edit mechanics can be unit-tested without a Context.
*
* 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-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.
*
* Writes are atomic: content goes to a temp file opened exclusively (`wx`,
* `0o600`, so a pre-existing path can never be clobbered and write-in-progress
* bytes stay owner-only) inside a randomly-named private staging directory
* (`0o700`) next to the target, then `rename`d over the target. Edits are
* read-modify-write over the same atomic primitive.
*
* @module @deepseek-ai/dsh-fs-local/fsio
*/
import { randomUUID } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { chmod, mkdir, open, readFile, realpath, rename, rm, stat } from 'node:fs/promises'
import type { Stats } from 'node:fs'
import { basename, dirname, join, resolve } from 'node:path'
import { TextDecoder } from 'node:util'
import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
/** Files at or above this size stream their text; smaller files read whole. */
export const STREAM_MIN_SIZE = 10 * 1024 * 1024
const BINARY_SAMPLE_BYTES = 8192
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'
}
/* v8 ignore start -- composes secondary cleanup-failure messages, which require a filesystem/kernel fault after the primary failure. */
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/* v8 ignore stop */
function throwIfAborted(signal: AbortSignal | undefined, verb: string): void {
if (signal?.aborted) throw new FsError(`${verb} aborted`, 'FS_ABORTED')
}
/**
* `readFile` with the supplied signal, translating a mid-read `AbortError` into
* the seam's structured `FsError('FS_ABORTED')` (Node rejects an aborted
* `readFile` with a bare `AbortError`, which would otherwise escape the seam's
* error taxonomy — the streaming/write paths translate it the same way).
*/
async function readFileAbortable(absolutePath: string, verb: 'read' | 'edit', signal?: AbortSignal): Promise<Buffer> {
try {
return await readFile(absolutePath, signal ? { signal } : {})
} catch (error: unknown) {
/* v8 ignore next 2 -- a non-abort readFile rejection needs a permission/IO fault racing an open file. */
if (!isAbortError(error)) throw error
throw new FsError(`${verb} aborted`, 'FS_ABORTED')
}
}
/** Opaque version token from a stat: mtime (ns precision) + size. */
function versionOf(info: Stats): FsVersion {
return FsVersion(`${info.mtimeMs}:${info.size}`)
}
/**
* Test seam: lets specs force the streaming read path (via a small
* `streamMinSize`) and pin the temp-file name (to prove exclusive-open
* behavior) without a 10 MB fixture or a name race.
*/
export interface FsIoInternals {
/** Override {@link STREAM_MIN_SIZE} for read routing. */
streamMinSize?: number
/** Override the generated private staging-dir name (relative to the target dir). */
tempDirName?: (writePath: string) => string
/** Override the generated temp-file name (relative to the private staging dir). */
tempName?: (writePath: string) => string
/** Test hook after the temp file is written/synced but before final chmod+rename. */
inspectTemp?: (paths: { stagingDir: string; tempPath: string }) => void | Promise<void>
}
/** A resolved local path: the absolute path shown to callers and its realpath identity. */
export interface LocalTarget {
/** Absolute path (symlinks not resolved) — used for display. */
displayPath: string
/** Realpath identity — used as the stable target key and the I/O path. */
targetKey: FsTargetKey
}
/** Result of probing a path: null when it does not exist. */
export interface PathInfo {
version: FsVersion
mode: number
type: 'file' | 'directory' | 'other'
size: number
}
/**
* Resolve a path to its absolute display path and realpath identity. Relative
* paths are based on `cwd`. When the file itself does not yet exist, the
* `targetKey` realpaths the nearest EXISTING ancestor directory and re-appends
* the still-missing suffix, so a not-yet-created file gets the same stable key
* it will have after creation — even when an ancestor (e.g. `cwd`) is a symlink
* and intermediate directories are created by the write. Two input paths
* reaching the same file via symlinks share one key. Falls back to the absolute
* path only when no ancestor (not even the filesystem root) can be resolved.
*/
export async function resolveLocalTarget(cwd: string, path: string): Promise<LocalTarget> {
if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND')
const displayPath = resolve(cwd, path)
try {
// 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
}
// File absent: realpath the nearest existing ancestor and re-append the
// missing suffix (the file basename plus any not-yet-created intermediate
// dirs), so the key is stable across creation of those dirs.
const missing = [basename(displayPath)]
let ancestor = dirname(displayPath)
while (true) {
try {
const realAncestor = await realpath(ancestor)
return { displayPath, targetKey: FsTargetKey(join(realAncestor, ...missing)) }
} catch (error: unknown) {
/* v8 ignore next -- a non-ENOENT realpath failure needs a permission/IO fault. */
if (!isENOENT(error)) throw error
const parent = dirname(ancestor)
/* v8 ignore next -- the filesystem root always realpaths, so the walk terminates before parent === ancestor. */
if (parent === ancestor) return { displayPath, targetKey: FsTargetKey(displayPath) }
missing.unshift(basename(ancestor))
ancestor = parent
}
}
}
/** Probe a path for its version, mode, type, and size. Null if absent. */
export async function probe(absolutePath: string): Promise<PathInfo | null> {
try {
const info = await stat(absolutePath)
const type = info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other'
return { version: versionOf(info), mode: info.mode & 0o777, type, size: info.size }
} catch (error: unknown) {
// 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
}
}
// --- Reading ---
function notTextError(verb: 'read' | 'edit', displayPath: string): FsError {
return new FsError(`cannot ${verb} "${displayPath}": invalid UTF-8 text`, 'FS_NOT_TEXT')
}
function decodeUtf8(buffer: Uint8Array, verb: 'read' | 'edit', displayPath: string): string {
try {
return new TextDecoder('utf-8', { fatal: true }).decode(buffer)
} catch (error: unknown) {
/* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes; any other throw is an unreachable runtime fault. */
if (!(error instanceof TypeError)) throw error
throw notTextError(verb, displayPath)
}
}
function decodeUtf8Stream(
decoder: TextDecoder,
chunk: Uint8Array | undefined,
verb: 'read' | 'edit',
displayPath: string,
): string {
try {
return chunk ? decoder.decode(chunk, { stream: true }) : decoder.decode()
} catch (error: unknown) {
/* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes; any other throw is an unreachable runtime fault. */
if (!(error instanceof TypeError)) throw error
throw notTextError(verb, displayPath)
}
}
async function statRegularFile(target: LocalTarget, verb: 'read', signal?: AbortSignal): Promise<Stats> {
throwIfAborted(signal, verb)
let info: Stats
try {
info = await stat(target.targetKey)
} catch (error: unknown) {
/* v8 ignore next 2 -- a non-ENOENT stat failure needs a permission/IO fault; only the not-found path is reachable in tests. */
if (!isENOENT(error)) throw error
throw new FsError(`cannot ${verb} "${target.displayPath}": not found`, 'FS_NOT_FOUND')
}
if (!info.isFile()) throw new FsError(`cannot ${verb} "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
return info
}
/**
* Read a whole regular UTF-8 text file into a single decoded string. Rejects
* non-regular files, invalid UTF-8, and NUL-byte binary samples.
*/
export async function readWholeText(target: LocalTarget, signal?: AbortSignal): Promise<string> {
await statRegularFile(target, 'read', signal)
const raw = await readFileAbortable(target.targetKey, 'read', signal)
throwIfAborted(signal, 'read')
if (raw.subarray(0, BINARY_SAMPLE_BYTES).includes(0)) {
throw new FsError(`cannot read "${target.displayPath}": binary file`, 'FS_NOT_TEXT')
}
return decodeUtf8(raw, 'read', target.displayPath)
}
/**
* Stream a whole regular UTF-8 text file as decoded text chunks. Same text
* semantics as {@link readWholeText} (regular-file check, binary/NUL rejection,
* cross-chunk UTF-8 decoding), but never holds the whole file in memory.
*/
export async function* streamWholeText(target: LocalTarget, signal?: AbortSignal): AsyncIterable<string> {
await statRegularFile(target, 'read', signal)
const stream = createReadStream(target.targetKey, signal ? { signal } : {})
const decoder = new TextDecoder('utf-8', { fatal: true })
let sampledBytes = 0
function scanBinarySample(chunk: Buffer): void {
if (sampledBytes >= BINARY_SAMPLE_BYTES) return
const sample = chunk.subarray(0, Math.min(chunk.length, BINARY_SAMPLE_BYTES - sampledBytes))
if (sample.includes(0)) {
throw new FsError(`cannot read "${target.displayPath}": binary file`, 'FS_NOT_TEXT')
}
sampledBytes += sample.length
}
try {
for await (const chunk of stream as AsyncIterable<Buffer>) {
scanBinarySample(chunk)
yield decodeUtf8Stream(decoder, chunk, 'read', target.displayPath)
}
yield decodeUtf8Stream(decoder, undefined, 'read', target.displayPath)
} catch (error: unknown) {
/* v8 ignore next 4 -- mid-stream errors need an abort/IO fault racing the loop; pre-abort is caught by throwIfAborted. */
if (isAbortError(error)) throw new FsError('read aborted', 'FS_ABORTED')
throw error
}
}
// --- Writing ---
async function removeStagingDirOrThrow(stagingDir: string, originalError: unknown): Promise<never> {
try {
await rm(stagingDir, { recursive: true, force: true })
} catch (cleanupError: unknown) {
/* v8 ignore next 1 -- cleanup failure here needs a second filesystem fault after the primary write failure. */
throw new FsError(`write failed (${errorMessage(originalError)}) and temp cleanup failed (${errorMessage(cleanupError)})`, 'FS_NOT_FOUND', { cause: originalError })
}
throw originalError
}
/**
* Atomically write `content` to `absolutePath`: create parent dirs, write to a
* randomly-named temp file opened exclusively (`wx`, `0o600`) inside a private
* (`0o700`) staging directory, fsync, optionally chmod to the final mode while
* still private, then rename over the target. `mode` (when given) preserves an
* existing file's permissions across the replace.
*/
export async function writeFileAtomic(
absolutePath: string,
content: string,
mode: number | undefined,
signal: AbortSignal | undefined,
internals: FsIoInternals = {},
): Promise<void> {
throwIfAborted(signal, 'write')
const directory = dirname(absolutePath)
await mkdir(directory, { recursive: true })
throwIfAborted(signal, 'write')
const stagingDirName = internals.tempDirName?.(absolutePath) ?? `.${basename(absolutePath)}.${process.pid}.${randomUUID()}.tmpdir`
const stagingDir = join(directory, stagingDirName)
const tempName = internals.tempName?.(absolutePath) ?? `${basename(absolutePath)}.tmp`
const tempPath = join(stagingDir, tempName)
let handle: Awaited<ReturnType<typeof open>> | undefined
let stagingCreated = false
try {
await mkdir(stagingDir, { mode: 0o700 })
stagingCreated = true
await chmod(stagingDir, 0o700)
handle = await open(tempPath, 'wx', 0o600)
await handle.chmod(0o600)
await handle.writeFile(content, { encoding: 'utf8', ...signal ? { signal } : {} })
await handle.sync()
await internals.inspectTemp?.({ stagingDir, tempPath })
if (mode !== undefined) await handle.chmod(mode)
await handle.close()
handle = undefined
throwIfAborted(signal, 'write')
await rename(tempPath, absolutePath)
await rm(stagingDir, { recursive: true, force: true })
} catch (error: unknown) {
/* v8 ignore next -- abort-mid-write needs a writeFile/signal race; the non-abort (rename/open) side is tested. */
let failure: unknown = isAbortError(error) ? new FsError('write aborted', 'FS_ABORTED') : error
/* v8 ignore next 8 -- reached only if writeFile/sync throws with the handle open (IO fault); close-failure is a double fault. */
if (handle) {
try {
await handle.close()
} catch (closeError: unknown) {
failure = new FsError(`write failed (${errorMessage(failure)}) and temp close failed (${errorMessage(closeError)})`, 'FS_NOT_FOUND', { cause: failure })
}
}
if (!stagingCreated) throw failure
return removeStagingDirOrThrow(stagingDir, failure)
}
}
// --- Editing ---
/** Line ending style detected before LF normalization. */
export type LineEndings = 'LF' | 'CRLF'
function normalizeLineEndings(content: string): string {
return content.replaceAll('\r\n', '\n')
}
function detectLineEndings(raw: string): LineEndings {
const sample = raw.slice(0, 4096)
const crlfCount = sample.split('\r\n').length - 1
const lfCount = sample.split('\n').length - 1 - crlfCount
return crlfCount > lfCount ? 'CRLF' : 'LF'
}
function restoreLineEndings(content: string, lineEndings: LineEndings): string {
return lineEndings === 'LF' ? content : normalizeLineEndings(content).split('\n').join('\r\n')
}
function countOccurrences(content: string, needle: string): number {
let count = 0
let index = 0
while (true) {
const found = content.indexOf(needle, index)
if (found === -1) return count
count += 1
index = found + needle.length
}
}
/**
* Read and decode a file for editing: rejects binaries, returns LF-normalized
* content plus the original line-ending style for write-back.
*/
export async function readForEdit(
absolutePath: string,
displayPath: string,
signal?: AbortSignal,
): Promise<{ content: string; lineEndings: LineEndings }> {
throwIfAborted(signal, 'edit')
const buffer = await readFileAbortable(absolutePath, 'edit', signal)
throwIfAborted(signal, 'edit')
if (buffer.includes(0)) throw new FsError(`cannot edit "${displayPath}": binary file`, 'FS_NOT_TEXT')
const raw = decodeUtf8(buffer, 'edit', displayPath)
return { content: normalizeLineEndings(raw), lineEndings: detectLineEndings(raw) }
}
/**
* Apply a literal replacement to LF-normalized content. Throws
* `FS_EDIT_NOT_FOUND` on empty `oldString` or zero matches and
* `FS_AMBIGUOUS_EDIT` on multiple matches when `replaceAll` is false. Returns
* the edited content (still LF-normalized) and the replacement count.
*/
export function applyLiteralEdit(
content: string,
oldString: string,
newString: string,
replaceAll: boolean,
displayPath: string,
): { content: string; replacements: number } {
const oldNorm = normalizeLineEndings(oldString)
if (oldNorm.length === 0) {
throw new FsError('old_string must be a non-empty string', 'FS_EDIT_NOT_FOUND')
}
const newNorm = normalizeLineEndings(newString)
const replacements = countOccurrences(content, oldNorm)
if (replacements === 0) {
throw new FsError(`old_string was not found in "${displayPath}"`, 'FS_EDIT_NOT_FOUND')
}
if (!replaceAll && replacements > 1) {
throw new FsError(`old_string matched ${replacements} times in "${displayPath}"; provide a more specific old_string or set replace_all to true`, 'FS_AMBIGUOUS_EDIT')
}
return { content: content.split(oldNorm).join(newNorm), replacements }
}
export { restoreLineEndings }
+198
View File
@@ -0,0 +1,198 @@
/**
* Local-filesystem implementation of the `ctx.fs` provider seam.
* {@link LocalFileSystem} subclasses {@link FileSystem} and backs the six
* text-storage primitives with the host filesystem via
* {@link module:@deepseek-ai/dsh-fs-local/fsio}. Path resolution uses
* `realpath`, so the stable `targetKey` is the real file identity (two input
* paths reaching the same file through symlinks share one key, and writes land
* on the link target — preserving the link).
*
* Future sandboxed/remote/virtual backends are sibling packages implementing
* the same interface; loading this one populates `ctx.fs`.
*
* @module @deepseek-ai/dsh-fs-local
*/
import { Context } from 'cordis'
import z from 'schemastery'
import { FileSystem, FsError, FsVersion } from '@deepseek-ai/dsh-fs'
import type {
FsEditOutcome,
FsEditRequest,
FsInfo,
FsTarget,
FsWriteIntent,
FsWriteOutcome,
} from '@deepseek-ai/dsh-fs'
import {
applyLiteralEdit,
probe,
readForEdit,
readWholeText,
resolveLocalTarget,
restoreLineEndings,
streamWholeText,
writeFileAtomic,
} from './fsio.ts'
import type { FsIoInternals } from './fsio.ts'
export {
STREAM_MIN_SIZE,
applyLiteralEdit,
probe,
readForEdit,
readWholeText,
resolveLocalTarget,
restoreLineEndings,
streamWholeText,
writeFileAtomic,
} from './fsio.ts'
export type { FsIoInternals, LineEndings, LocalTarget, PathInfo } from './fsio.ts'
/** Configuration for the local filesystem backend. */
export interface Config {
/** Base directory for relative paths. Defaults to `process.cwd()`. */
cwd?: string
}
type ResolvedConfig = Required<Config>
/**
* The host-filesystem backend. Reads resolve relative paths from {@link Config.cwd}
* (a resolution default, NOT a containment boundary — see the filesystem
* capability-seam RFC); enforce
* containment with a stricter backend or a `tools/execute` permission plugin.
*/
export class LocalFileSystem extends FileSystem {
static Config: z<Config> = z.object({
cwd: z.string().default(process.cwd()),
})
readonly config: ResolvedConfig
/** Test seam forwarded to fsio (force streaming path, pin temp names). */
internals: FsIoInternals = {}
/** Per-targetKey tail promise: serializes mutating ops so the read→guard→write
* window can't interleave, making concurrent writes/edits deterministically
* ordered (one wins, the rest see the new version and reject as stale). */
private locks = new Map<string, Promise<unknown>>()
constructor(ctx: Context, config: Config) {
super(ctx)
this.config = config as ResolvedConfig
}
/** Run `op` with exclusive access to `targetKey` (FIFO per key). */
private async withLock<T>(targetKey: string, op: () => Promise<T>): Promise<T> {
const prior = this.locks.get(targetKey) ?? Promise.resolve()
const run = prior.then(op, op)
// Keep the chain alive but swallow this op's result/throw for the *next* waiter.
const tail = run.then(() => undefined, () => undefined)
this.locks.set(targetKey, tail)
try {
return await run
} finally {
if (this.locks.get(targetKey) === tail) {
this.locks.delete(targetKey)
}
}
}
override async resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget> {
const local = await resolveLocalTarget(opts?.cwd ?? this.config.cwd, path)
return { inputPath: path, targetKey: local.targetKey, displayPath: local.displayPath }
}
override async stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> {
if (signal?.aborted) throw new FsError('stat aborted', 'FS_ABORTED')
const info = await probe(target.targetKey)
if (!info) return undefined
return { version: info.version, type: info.type, size: info.size }
}
override async readText(target: FsTarget, signal?: AbortSignal): Promise<string> {
return readWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal)
}
override streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> {
return Promise.resolve(streamWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal))
}
override async writeText(
target: FsTarget,
content: string,
expected?: FsWriteIntent,
signal?: AbortSignal,
): Promise<FsWriteOutcome> {
return this.withLock(target.targetKey, async () => {
const existing = await probe(target.targetKey)
if (existing && existing.type !== 'file') {
throw new FsError(`cannot write "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
}
if (expected?.kind === 'replaceIfVersion') {
// Stale guard: the file must still exist at the version the owner observed.
if (!existing) throw new FsError(`cannot write "${target.displayPath}": file no longer exists`, 'FS_STALE_VERSION')
if (existing.version !== expected.version) {
throw new FsError(`cannot write "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
}
} else if (expected?.kind === 'createIfAbsent' && existing) {
// createIfAbsent onto an existing file: a blind overwrite — require a read first.
throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, 'FS_NOT_OBSERVED')
}
// expected === undefined: unconditional create-or-overwrite (the bare
// provider) — no version guard, no read-first requirement. Still atomic
// (the per-target lock is unconditional), so the write is never torn.
await writeFileAtomic(target.targetKey, content, existing?.mode, signal, this.internals)
const after = await probe(target.targetKey)
return {
operation: existing ? 'update' : 'create',
version: this.versionAfterWrite(after, target),
}
})
}
override async editText(
target: FsTarget,
edit: FsEditRequest,
expected?: { version: FsVersion },
signal?: AbortSignal,
): Promise<FsEditOutcome> {
return this.withLock(target.targetKey, async () => {
const existing = await probe(target.targetKey)
// Stale guard BEFORE literal matching: an edit based on an old read reports
// FS_STALE_VERSION, not FS_EDIT_NOT_FOUND/FS_AMBIGUOUS_EDIT against newer content.
// A missing target reports FS_STALE_VERSION on BOTH paths (guarded and
// unconditional) — one "cannot edit this target now" code.
if (!existing) throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
if (existing.type !== 'file') throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
// expected === undefined: unconditional edit of the current content — no
// version guard. Still inside the per-target lock, so the read→match→write
// window is serialized and atomic.
if (expected && existing.version !== expected.version) {
throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
}
const original = await readForEdit(target.targetKey, target.displayPath, signal)
const edited = applyLiteralEdit(original.content, edit.oldString, edit.newString, edit.replaceAll, target.displayPath)
const content = restoreLineEndings(edited.content, original.lineEndings)
await writeFileAtomic(target.targetKey, content, existing.mode, signal, this.internals)
const after = await probe(target.targetKey)
return {
replacements: edited.replacements,
replaceAll: edit.replaceAll,
version: this.versionAfterWrite(after, target),
}
})
}
/* v8 ignore next 5 -- the post-write probe finding the file absent requires a
* concurrent unlink between rename and stat; fall back to a sentinel version. */
private versionAfterWrite(after: { version: FsVersion } | null, target: FsTarget): FsVersion {
if (after) return after.version
return FsVersion(`missing:${target.targetKey}`)
}
}
export default LocalFileSystem
@@ -0,0 +1,405 @@
/**
* Tests for the local backend through the `ctx.fs` provider seam: stat, whole-
* 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-fs-policy`, so it is not exercised here.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdtemp, readFile, rm, stat, symlink, writeFile, unlink } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
import { FsVersion } from '@deepseek-ai/dsh-fs'
import type { FsTarget } from '@deepseek-ai/dsh-fs'
let dir: string
let ctx: Context
let fs: LocalFileSystem
let fiber: Awaited<ReturnType<Context['plugin']>>
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'dsh-fs-'))
ctx = new Context()
fiber = await ctx.plugin(LocalFileSystem, { cwd: dir })
fs = ctx.fs as LocalFileSystem
})
afterEach(async () => {
await fiber.dispose()
await rm(dir, { recursive: true, force: true })
})
function lockCount(localFs: LocalFileSystem): number {
return (localFs as unknown as { locks: Map<string, Promise<unknown>> }).locks.size
}
/** The version the backend currently reports for a resolved target. */
async function versionOf(target: FsTarget): Promise<FsVersion> {
const info = await fs.stat(target)
if (!info) throw new Error('expected target to exist')
return info.version
}
describe('registration', () => {
it('registers LocalFileSystem as ctx.fs with a default cwd', async () => {
const bare = new Context()
const bareFiber = await bare.plugin(LocalFileSystem)
expect((bare.fs as LocalFileSystem).config.cwd).toBe(process.cwd())
await bareFiber.dispose()
})
})
describe('resolve', () => {
it('resolves a relative path against opts.cwd, not config.cwd', async () => {
// config.cwd is `dir`; a call supplying a DIFFERENT cwd bases the relative
// path there (the per-session-workspace seam — mirrors tool-bash workdir).
const other = await mkdtemp(join(tmpdir(), 'dsh-fs-other-'))
try {
await writeFile(join(other, 'x.txt'), 'in other')
const viaOther = await fs.resolve('x.txt', { cwd: other })
expect(await fs.readText(viaOther)).toBe('in other')
// Same relative path with no opts falls back to config.cwd (= dir), where
// x.txt does not exist.
await expect(fs.readText(await fs.resolve('x.txt'))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
} finally {
await rm(other, { recursive: true, force: true })
}
})
it('ignores opts.cwd for an ABSOLUTE path', async () => {
await writeFile(join(dir, 'abs.txt'), 'absolute')
const target = await fs.resolve(join(dir, 'abs.txt'), { cwd: '/nonexistent-base' })
expect(await fs.readText(target)).toBe('absolute')
})
})
describe('stat', () => {
it('returns file metadata, directory type, and undefined for absent', async () => {
await writeFile(join(dir, 'a.txt'), 'hello')
const fileInfo = await fs.stat(await fs.resolve('a.txt'))
expect(fileInfo?.type).toBe('file')
expect(fileInfo?.size).toBe(5)
expect(typeof fileInfo?.version).toBe('string')
expect((await fs.stat(await fs.resolve('.')))?.type).toBe('directory')
expect(await fs.stat(await fs.resolve('missing.txt'))).toBeUndefined()
})
it('honors a pre-aborted signal', async () => {
await expect(fs.stat(await fs.resolve('a.txt'), AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
})
})
describe('readText / streamText', () => {
it('reads whole-file text', async () => {
await writeFile(join(dir, 'a.txt'), 'one\ntwo\nthree')
expect(await fs.readText(await fs.resolve('a.txt'))).toBe('one\ntwo\nthree')
})
it('streams the same text', async () => {
await writeFile(join(dir, 'a.txt'), 'one\ntwo\nthree')
const target = await fs.resolve('a.txt')
let streamed = ''
for await (const chunk of await fs.streamText(target)) streamed += chunk
expect(streamed).toBe('one\ntwo\nthree')
})
it('rejects a missing file, a directory, binary, and invalid UTF-8', async () => {
await expect(fs.readText(await fs.resolve('nope'))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
await expect(fs.readText(await fs.resolve('.'))).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
await writeFile(join(dir, 'bin'), Buffer.from([0x68, 0x00, 0x69]))
await expect(fs.readText(await fs.resolve('bin'))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
await writeFile(join(dir, 'bad'), Buffer.from([0x68, 0xff, 0x69]))
await expect(fs.readText(await fs.resolve('bad'))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
})
})
describe('writeText', () => {
it('createIfAbsent creates a new file', async () => {
const target = await fs.resolve('new.txt')
const outcome = await fs.writeText(target, 'fresh', { kind: 'createIfAbsent' })
expect(outcome.operation).toBe('create')
expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('fresh')
})
it('createIfAbsent rejects an existing file as FS_NOT_OBSERVED', async () => {
await writeFile(join(dir, 'a.txt'), 'old')
const target = await fs.resolve('a.txt')
await expect(fs.writeText(target, 'new', { kind: 'createIfAbsent' }))
.rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('old')
})
it('replaceIfVersion replaces when the version matches', async () => {
await writeFile(join(dir, 'a.txt'), 'old')
const target = await fs.resolve('a.txt')
const outcome = await fs.writeText(target, 'new', { kind: 'replaceIfVersion', version: await versionOf(target) })
expect(outcome.operation).toBe('update')
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('new')
})
it('replaceIfVersion rejects a stale version', async () => {
await writeFile(join(dir, 'a.txt'), 'v1')
const target = await fs.resolve('a.txt')
const stale = await versionOf(target)
await writeFile(join(dir, 'a.txt'), 'changed-externally')
await expect(fs.writeText(target, 'v2', { kind: 'replaceIfVersion', version: stale }))
.rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
})
it('replaceIfVersion rejects a deleted target as stale, without recreating it', async () => {
const path = join(dir, 'a.txt')
await writeFile(path, 'v1')
const target = await fs.resolve('a.txt')
const version = await versionOf(target)
await unlink(path)
await expect(fs.writeText(target, 'v2', { kind: 'replaceIfVersion', version }))
.rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' })
})
it('rejects writing onto a directory', async () => {
const target = await fs.resolve('.')
await expect(fs.writeText(target, 'x', { kind: 'createIfAbsent' }))
.rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
})
it('unconditionally creates a new file with no expectation (bare provider)', async () => {
const target = await fs.resolve('new.txt')
const outcome = await fs.writeText(target, 'fresh')
expect(outcome.operation).toBe('create')
expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('fresh')
})
it('unconditionally OVERWRITES an existing file with no expectation (bare provider)', async () => {
await writeFile(join(dir, 'a.txt'), 'old')
const target = await fs.resolve('a.txt')
const outcome = await fs.writeText(target, 'clobbered')
expect(outcome.operation).toBe('update')
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('clobbered')
})
it('rejects writing onto a directory even with no expectation', async () => {
const target = await fs.resolve('.')
await expect(fs.writeText(target, 'x')).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
})
it('releases per-target mutation locks after success and failure', async () => {
const target = await fs.resolve('a.txt')
await fs.writeText(target, 'created', { kind: 'createIfAbsent' })
expect(lockCount(fs)).toBe(0)
await expect(fs.writeText(target, 'again', { kind: 'createIfAbsent' }))
.rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
expect(lockCount(fs)).toBe(0)
})
it('replaceIfVersion returns the post-write version (matches a fresh stat)', async () => {
await writeFile(join(dir, 'a.txt'), 'v1')
const target = await fs.resolve('a.txt')
const before = await versionOf(target)
// Change the byte length so the mtimeMs:size token provably differs (a
// same-size same-tick rewrite can collide — the documented version-token
// limitation; not what this test is about).
const outcome = await fs.writeText(target, 'a much longer replacement body', { kind: 'replaceIfVersion', version: before })
expect(outcome.version).not.toBe(before)
expect(outcome.version).toBe(await versionOf(target))
})
it('honors a pre-aborted signal without creating the file', async () => {
const target = await fs.resolve('aborted.txt')
await expect(fs.writeText(target, 'x', undefined, AbortSignal.abort()))
.rejects.toMatchObject({ code: 'FS_ABORTED' })
await expect(stat(join(dir, 'aborted.txt'))).rejects.toMatchObject({ code: 'ENOENT' })
expect(lockCount(fs)).toBe(0)
})
it('two concurrent guarded writes: one updates, the other is rejected as stale', async () => {
await writeFile(join(dir, 'a.txt'), 'base')
const target = await fs.resolve('a.txt')
const version = await versionOf(target)
const results = await Promise.allSettled([
fs.writeText(target, 'one', { kind: 'replaceIfVersion', version }),
fs.writeText(target, 'two', { kind: 'replaceIfVersion', version }),
])
expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(1)
const rejected = results.filter(r => r.status === 'rejected')
expect(rejected).toHaveLength(1)
expect((rejected[0] as PromiseRejectedResult).reason).toMatchObject({ code: 'FS_STALE_VERSION' })
expect(lockCount(fs)).toBe(0)
})
})
describe('editText', () => {
it('applies a literal edit at the matching version', async () => {
await writeFile(join(dir, 'a.txt'), 'hello world')
const target = await fs.resolve('a.txt')
const outcome = await fs.editText(target, { oldString: 'world', newString: 'there', replaceAll: false }, { version: await versionOf(target) })
expect(outcome.replacements).toBe(1)
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there')
})
it('checks the stale version BEFORE literal matching', async () => {
await writeFile(join(dir, 'a.txt'), 'hello world')
const target = await fs.resolve('a.txt')
const stale = await versionOf(target)
// Change the file so 'world' is gone — a stale edit must report STALE, not NOT_FOUND.
await writeFile(join(dir, 'a.txt'), 'goodbye')
await expect(fs.editText(target, { oldString: 'world', newString: 'there', replaceAll: false }, { version: stale }))
.rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
})
it('unconditionally edits the current content with no expectation (bare provider)', async () => {
await writeFile(join(dir, 'a.txt'), 'hello world')
const target = await fs.resolve('a.txt')
// No version guard: any current content is edited, regardless of version.
const outcome = await fs.editText(target, { oldString: 'world', newString: 'there', replaceAll: false })
expect(outcome.replacements).toBe(1)
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there')
})
it('reports a missing target as FS_STALE_VERSION even with no expectation (bare provider)', async () => {
const target = await fs.resolve('missing.txt')
await expect(fs.editText(target, { oldString: 'a', newString: 'b', replaceAll: false }))
.rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
})
it('still reports literal-match codes with no expectation (FS_EDIT_NOT_FOUND, unrelated to freshness)', async () => {
await writeFile(join(dir, 'a.txt'), 'hello world')
const target = await fs.resolve('a.txt')
await expect(fs.editText(target, { oldString: 'absent', newString: 'x', replaceAll: false }))
.rejects.toMatchObject({ code: 'FS_EDIT_NOT_FOUND' })
})
it('rejects a deleted target as stale (before matching)', async () => {
await writeFile(join(dir, 'a.txt'), 'hello')
const target = await fs.resolve('a.txt')
const version = await versionOf(target)
await unlink(join(dir, 'a.txt'))
await expect(fs.editText(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, { version }))
.rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
})
it('rejects a non-regular target', async () => {
const target = await fs.resolve('.')
await expect(fs.editText(target, { oldString: 'a', newString: 'b', replaceAll: false }, { version: FsVersion('v') }))
.rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
})
it('rejects zero matches and ambiguous matches at the right version', async () => {
await writeFile(join(dir, 'a.txt'), 'a a a')
const target = await fs.resolve('a.txt')
const version = await versionOf(target)
await expect(fs.editText(target, { oldString: 'z', newString: 'X', replaceAll: false }, { version }))
.rejects.toMatchObject({ code: 'FS_EDIT_NOT_FOUND' })
await expect(fs.editText(target, { oldString: 'a', newString: 'X', replaceAll: false }, { version }))
.rejects.toMatchObject({ code: 'FS_AMBIGUOUS_EDIT' })
})
it('replaces all matches with replaceAll', async () => {
await writeFile(join(dir, 'a.txt'), 'a a a')
const target = await fs.resolve('a.txt')
const outcome = await fs.editText(target, { oldString: 'a', newString: 'b', replaceAll: true }, { version: await versionOf(target) })
expect(outcome.replacements).toBe(3)
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('b b b')
})
it('rejects invalid UTF-8 without rewriting the file', async () => {
const path = join(dir, 'bad.txt')
const bytes = Buffer.from([0x68, 0xff, 0x69])
await writeFile(path, bytes)
const target = await fs.resolve('bad.txt')
const version = await versionOf(target)
await expect(fs.editText(target, { oldString: 'h', newString: 'H', replaceAll: false }, { version }))
.rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
expect(await readFile(path)).toEqual(bytes)
})
it('two concurrent edits: one wins, the other is rejected as stale', async () => {
await writeFile(join(dir, 'a.txt'), 'base')
const target = await fs.resolve('a.txt')
const version = await versionOf(target)
const results = await Promise.allSettled([
fs.editText(target, { oldString: 'base', newString: 'one', replaceAll: false }, { version }),
fs.editText(target, { oldString: 'base', newString: 'two', replaceAll: false }, { version }),
])
expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(1)
const rejected = results.filter(r => r.status === 'rejected')
expect(rejected).toHaveLength(1)
expect((rejected[0] as PromiseRejectedResult).reason).toMatchObject({ code: 'FS_STALE_VERSION' })
expect(lockCount(fs)).toBe(0)
})
it('honors a pre-aborted signal without rewriting the file', async () => {
await writeFile(join(dir, 'a.txt'), 'keep')
const target = await fs.resolve('a.txt')
await expect(fs.editText(target, { oldString: 'keep', newString: 'x', replaceAll: false }, undefined, AbortSignal.abort()))
.rejects.toMatchObject({ code: 'FS_ABORTED' })
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('keep')
expect(lockCount(fs)).toBe(0)
})
it('a successful edit refreshes the version so an immediate follow-up edit proceeds', async () => {
await writeFile(join(dir, 'a.txt'), 'one two')
const target = await fs.resolve('a.txt')
const first = await fs.editText(target, { oldString: 'one', newString: 'ONE', replaceAll: false }, { version: await versionOf(target) })
// The version the first edit returned is a valid guard for a second edit —
// no intervening re-stat needed.
const second = await fs.editText(target, { oldString: 'two', newString: 'TWO', replaceAll: false }, { version: first.version })
expect(second.replacements).toBe(1)
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('ONE TWO')
})
it('concurrent write vs edit at the same version: one wins, the other is stale', async () => {
await writeFile(join(dir, 'a.txt'), 'base')
const target = await fs.resolve('a.txt')
const version = await versionOf(target)
const results = await Promise.allSettled([
fs.writeText(target, 'written', { kind: 'replaceIfVersion', version }),
fs.editText(target, { oldString: 'base', newString: 'edited', replaceAll: false }, { version }),
])
expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(1)
const rejected = results.filter(r => r.status === 'rejected')
expect(rejected).toHaveLength(1)
expect((rejected[0] as PromiseRejectedResult).reason).toMatchObject({ code: 'FS_STALE_VERSION' })
expect(lockCount(fs)).toBe(0)
})
})
describe('symlink targetKey identity', () => {
it('two paths to the same file via a symlink share one version and write the real target', async () => {
await writeFile(join(dir, 'real.txt'), 'hello')
await symlink(join(dir, 'real.txt'), join(dir, 'link.txt'))
const viaReal = await fs.resolve('real.txt')
const viaLink = await fs.resolve('link.txt')
expect(viaLink.targetKey).toBe(viaReal.targetKey)
const version = await versionOf(viaReal)
await fs.editText(viaLink, { oldString: 'hello', newString: 'bye', replaceAll: false }, { version })
expect(await readFile(join(dir, 'real.txt'), 'utf8')).toBe('bye') // link preserved
})
it('a stale change is detected across both paths', async () => {
await writeFile(join(dir, 'real.txt'), 'hello')
await symlink(join(dir, 'real.txt'), join(dir, 'link.txt'))
const viaReal = await fs.resolve('real.txt')
const stale = await versionOf(viaReal)
await writeFile(join(dir, 'real.txt'), 'changed')
const viaLink = await fs.resolve('link.txt')
await expect(fs.editText(viaLink, { oldString: 'hello', newString: 'bye', replaceAll: false }, { version: stale }))
.rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
})
})
describe('HMR / disposal', () => {
it('disposing the fiber withdraws ctx.fs', async () => {
const local = new Context()
const localFiber = await local.plugin(LocalFileSystem, { cwd: dir })
expect(local.fs).toBeDefined()
await localFiber.dispose()
expect(local.fs).toBeUndefined()
})
})
+363
View File
@@ -0,0 +1,363 @@
/**
* 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-fs-policy`, so it is not tested here.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdtemp, readFile, rm, stat, symlink, writeFile, mkdir, readdir, realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createServer } from 'node:net'
import {
applyLiteralEdit,
probe,
readForEdit,
readWholeText,
resolveLocalTarget,
restoreLineEndings,
streamWholeText,
writeFileAtomic,
} from '@deepseek-ai/dsh-fs-local'
import type { LocalTarget } from '@deepseek-ai/dsh-fs-local'
import { FsError, FsTargetKey } from '@deepseek-ai/dsh-fs'
let dir: string
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'dsh-fsio-'))
})
afterEach(async () => {
await rm(dir, { recursive: true, force: true })
})
const localTarget = (path: string): LocalTarget => ({ displayPath: path, targetKey: FsTargetKey(path) })
async function collect(chunks: AsyncIterable<string>): Promise<string> {
let out = ''
for await (const chunk of chunks) out += chunk
return out
}
describe('resolveLocalTarget', () => {
it('resolves a relative path from cwd and realpaths it', async () => {
const file = join(dir, 'a.txt')
await writeFile(file, 'hi')
const target = await resolveLocalTarget(dir, 'a.txt')
expect(target.displayPath).toBe(file)
expect(target.targetKey).toBe(await realpath(file))
})
it('uses the realpathed parent + basename when the file does not exist (stable across create)', async () => {
const target = await resolveLocalTarget(dir, 'missing.txt')
expect(target.targetKey).toBe(join(await realpath(dir), 'missing.txt'))
})
it('two paths to the same file via a symlink share one targetKey', async () => {
const real = join(dir, 'real.txt')
await writeFile(real, 'hi')
const link = join(dir, 'link.txt')
await symlink(real, link)
const viaReal = await resolveLocalTarget(dir, 'real.txt')
const viaLink = await resolveLocalTarget(dir, 'link.txt')
expect(viaLink.targetKey).toBe(viaReal.targetKey)
expect(viaLink.displayPath).toBe(link)
})
it('realpaths the nearest existing ancestor when intermediate dirs are missing', async () => {
const target = await resolveLocalTarget(dir, 'no-such-dir/child.txt')
expect(target.targetKey).toBe(join(await realpath(dir), 'no-such-dir', 'child.txt'))
})
it('keeps the key stable across create when an ancestor is a symlink', async () => {
// A symlinked workspace root with a not-yet-created subdirectory: the
// pre-create key (via the symlink, missing parent) must equal the
// post-create key (file exists, realpathed) so observed-state survives.
const realRoot = join(dir, 'real-root')
await mkdir(realRoot)
const linkRoot = join(dir, 'link-root')
await symlink(realRoot, linkRoot)
const before = await resolveLocalTarget(linkRoot, 'sub/file.txt')
await mkdir(join(realRoot, 'sub'), { recursive: true })
await writeFile(join(realRoot, 'sub', 'file.txt'), 'hi') // create through the real path
const after = await resolveLocalTarget(linkRoot, 'sub/file.txt')
expect(before.targetKey).toBe(after.targetKey)
})
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', () => {
it('returns null for a missing path and metadata for a file', async () => {
expect(await probe(join(dir, 'nope'))).toBeNull()
const file = join(dir, 'a.txt')
await writeFile(file, 'hi')
const info = await probe(file)
expect(info?.type).toBe('file')
expect(info?.size).toBe(2)
expect(typeof info?.version).toBe('string')
})
it('reports a directory and a non-regular type', async () => {
const sub = join(dir, 'sub')
await mkdir(sub)
expect((await probe(sub))?.type).toBe('directory')
})
it('reports a socket/special file as type "other"', async () => {
const sockPath = join(dir, 'sock')
const server = createServer()
try {
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(sockPath, () => { resolve() })
})
} catch (error: unknown) {
// A restricted sandbox may forbid unix-domain sockets; that is an
// environment limit, not a filesystem regression — skip rather than fail.
const code = (error as NodeJS.ErrnoException).code
if (code === 'EPERM' || code === 'EACCES' || code === 'ENOTSUP') return
throw error
}
try {
expect((await probe(sockPath))?.type).toBe('other')
} finally {
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', () => {
it('reads a small file', async () => {
const file = join(dir, 'a.txt')
await writeFile(file, 'one\ntwo\nthree')
expect(await readWholeText(localTarget(file))).toBe('one\ntwo\nthree')
})
it('rejects a missing file and a directory', async () => {
await expect(readWholeText(localTarget(join(dir, 'nope')))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
await expect(readWholeText(localTarget(dir))).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
})
it('rejects binary and invalid UTF-8', async () => {
await writeFile(join(dir, 'bin'), Buffer.from([0x68, 0x00, 0x69]))
await expect(readWholeText(localTarget(join(dir, 'bin')))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
await writeFile(join(dir, 'bad'), Buffer.from([0x68, 0xff, 0x69]))
await expect(readWholeText(localTarget(join(dir, 'bad')))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
})
it('honors a pre-aborted signal', async () => {
const file = join(dir, 'a.txt')
await writeFile(file, 'one')
await expect(readWholeText(localTarget(file), AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
})
it('passes a live (non-aborted) signal through', async () => {
const file = join(dir, 'a.txt')
await writeFile(file, 'one\ntwo')
expect(await readWholeText(localTarget(file), new AbortController().signal)).toBe('one\ntwo')
})
it('translates a mid-read AbortError into FS_ABORTED', async () => {
const file = join(dir, 'a.txt')
await writeFile(file, 'one\ntwo')
const ac = new AbortController()
// Abort after the synchronous entry check but before readFile runs (the
// stat await yields control back here), so readFile rejects AbortError.
const pending = readWholeText(localTarget(file), ac.signal)
ac.abort()
await expect(pending).rejects.toMatchObject({ code: 'FS_ABORTED' })
})
})
describe('streamWholeText', () => {
it('streams the whole file as decoded text', async () => {
const file = join(dir, 'a.txt')
await writeFile(file, 'one\ntwo\nthree')
expect(await collect(streamWholeText(localTarget(file)))).toBe('one\ntwo\nthree')
})
it('streams a large multi-chunk file correctly', async () => {
const file = join(dir, 'big.txt')
const content = Array.from({ length: 50 }, (_, i) => `line ${i}: ${'x'.repeat(3000)}`).join('\n')
await writeFile(file, content)
expect(await collect(streamWholeText(localTarget(file)))).toBe(content)
})
it('rejects a missing file, directory, binary, and invalid UTF-8', async () => {
await expect(collect(streamWholeText(localTarget(join(dir, 'nope'))))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
await expect(collect(streamWholeText(localTarget(dir)))).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
await writeFile(join(dir, 'bin'), Buffer.from([0x68, 0x00, 0x69]))
await expect(collect(streamWholeText(localTarget(join(dir, 'bin'))))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
await writeFile(join(dir, 'bad'), Buffer.from([0x68, 0xff, 0x69]))
await expect(collect(streamWholeText(localTarget(join(dir, 'bad'))))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
})
it('honors a pre-aborted signal', async () => {
const file = join(dir, 'a.txt')
await writeFile(file, 'one')
await expect(collect(streamWholeText(localTarget(file), AbortSignal.abort()))).rejects.toMatchObject({ code: 'FS_ABORTED' })
})
it('passes a live (non-aborted) signal through the stream', async () => {
const file = join(dir, 'a.txt')
await writeFile(file, 'one\ntwo')
expect(await collect(streamWholeText(localTarget(file), new AbortController().signal))).toBe('one\ntwo')
})
it('translates a mid-stream abort into FS_ABORTED', async () => {
// A multi-chunk file so the stream yields more than once; abort after the
// first chunk and assert the structured code, not a raw AbortError.
const file = join(dir, 'big.txt')
await writeFile(file, 'x'.repeat(256 * 1024))
const ac = new AbortController()
const run = async (): Promise<void> => {
let seen = 0
for await (const _chunk of streamWholeText(localTarget(file), ac.signal)) {
seen += 1
if (seen === 1) ac.abort()
}
}
await expect(run()).rejects.toMatchObject({ code: 'FS_ABORTED' })
})
})
describe('writeFileAtomic — temp-file safety', () => {
it('writes through a private staging dir and owner-only temp file', async () => {
const file = join(dir, 'a.txt')
let inspected = false
await writeFileAtomic(file, 'hello', 0o640, undefined, {
inspectTemp: async ({ stagingDir, tempPath }) => {
inspected = true
expect((await stat(stagingDir)).mode & 0o777).toBe(0o700)
expect((await stat(tempPath)).mode & 0o777).toBe(0o600)
},
})
expect(inspected).toBe(true)
expect(await readFile(file, 'utf8')).toBe('hello')
expect((await stat(file)).mode & 0o777).toBe(0o640)
expect((await readdir(dir)).filter(n => n.includes('.tmp'))).toEqual([])
})
it('creates new files owner-only by default', async () => {
const file = join(dir, 'a.txt')
await writeFileAtomic(file, 'hello', undefined, undefined)
expect((await stat(file)).mode & 0o777).toBe(0o600)
})
it('opens staging paths exclusively — a pre-existing path is never clobbered', async () => {
const file = join(dir, 'a.txt')
const tempDirName = '.fixed-temp.tmpdir'
await mkdir(join(dir, tempDirName))
await writeFile(join(dir, tempDirName, 'PRECIOUS'), 'keep')
await expect(
writeFileAtomic(file, 'hello', undefined, undefined, { tempDirName: () => tempDirName }),
).rejects.toMatchObject({ code: 'EEXIST' })
expect(await readFile(join(dir, tempDirName, 'PRECIOUS'), 'utf8')).toBe('keep')
await expect(stat(file)).rejects.toMatchObject({ code: 'ENOENT' })
})
it('creates parent directories as needed', async () => {
const file = join(dir, 'nested', 'deep', 'a.txt')
await writeFileAtomic(file, 'hi', undefined, undefined)
expect(await readFile(file, 'utf8')).toBe('hi')
})
it('passes a live (non-aborted) signal through the write', async () => {
const file = join(dir, 'a.txt')
await writeFileAtomic(file, 'hi', undefined, new AbortController().signal)
expect(await readFile(file, 'utf8')).toBe('hi')
})
it('aborts before writing when the signal is already aborted', async () => {
const file = join(dir, 'a.txt')
await expect(writeFileAtomic(file, 'hi', undefined, AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
await expect(stat(file)).rejects.toMatchObject({ code: 'ENOENT' })
})
it('cleans up the temp file when the final rename fails', async () => {
const sub = join(dir, 'occupied')
await mkdir(sub)
await expect(writeFileAtomic(sub, 'hi', undefined, undefined)).rejects.toBeInstanceOf(Error)
expect((await readdir(dir)).filter(n => n.includes('.tmp'))).toEqual([])
})
})
describe('applyLiteralEdit', () => {
it('replaces a unique match', () => {
expect(applyLiteralEdit('a b c', 'b', 'X', false, 'f')).toEqual({ content: 'a X c', replacements: 1 })
})
it('rejects zero matches', () => {
expect(() => applyLiteralEdit('a b c', 'z', 'X', false, 'f')).toThrow(expect.objectContaining({ code: 'FS_EDIT_NOT_FOUND' }))
})
it('rejects an empty oldString without scanning forever', () => {
expect(() => applyLiteralEdit('a b c', '', 'X', false, 'f')).toThrow(expect.objectContaining({ code: 'FS_EDIT_NOT_FOUND' }))
})
it('rejects multiple matches without replaceAll', () => {
expect(() => applyLiteralEdit('a a a', 'a', 'X', false, 'f')).toThrow(expect.objectContaining({ code: 'FS_AMBIGUOUS_EDIT' }))
})
it('replaces all matches with replaceAll', () => {
expect(applyLiteralEdit('a a a', 'a', 'X', true, 'f')).toEqual({ content: 'X X X', replacements: 3 })
})
it('matches across normalized line endings', () => {
expect(applyLiteralEdit('one\ntwo', 'one\ntwo', 'x', false, 'f').replacements).toBe(1)
})
})
describe('readForEdit + restoreLineEndings', () => {
it('round-trips CRLF: matches on LF, writes back CRLF', async () => {
const file = join(dir, 'crlf.txt')
await writeFile(file, 'one\r\ntwo\r\n')
const original = await readForEdit(file, file)
expect(original.lineEndings).toBe('CRLF')
const edited = applyLiteralEdit(original.content, 'two', 'TWO', false, file)
expect(restoreLineEndings(edited.content, original.lineEndings)).toBe('one\r\nTWO\r\n')
})
it('rejects a binary file and invalid UTF-8', async () => {
await writeFile(join(dir, 'bin'), Buffer.from([0x00, 0x01]))
await expect(readForEdit(join(dir, 'bin'), join(dir, 'bin'))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
await writeFile(join(dir, 'bad'), Buffer.from([0x68, 0xff, 0x69]))
await expect(readForEdit(join(dir, 'bad'), join(dir, 'bad'))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
})
it('passes a live (non-aborted) signal through the read', async () => {
const file = join(dir, 'a.txt')
await writeFile(file, 'one\ntwo')
const original = await readForEdit(file, file, new AbortController().signal)
expect(original.content).toBe('one\ntwo')
})
it('translates a mid-read AbortError into FS_ABORTED', async () => {
const file = join(dir, 'a.txt')
await writeFile(file, 'one\ntwo')
const ac = new AbortController()
// Abort after the synchronous entry check, while readFile is pending.
const pending = readForEdit(file, file, ac.signal)
ac.abort()
await expect(pending).rejects.toMatchObject({ code: 'FS_ABORTED' })
})
})
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
{ "path": "../../llm/llm" },
{ "path": "../fs" }
]
}
+48
View File
@@ -0,0 +1,48 @@
# @deepseek-ai/dsh-fs-policy
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 FsPolicy from '@deepseek-ai/dsh-fs-policy'
declare const ctx: Context
// No service to inject — this plugin only registers the three fs/* listeners.
// 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/*-intent slots.
await ctx.plugin(FsPolicy)
```
## The four-layer split
| 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-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` |
## How the gate participates
Three `fs/*` events (declared by `@deepseek-ai/dsh-fs`, dispatched by `@deepseek-ai/dsh-tool-fs`):
| Event | This plugin's listener |
|---|---|
| `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
Observed state is a `WeakMap<owner, Map<targetKey, FsVersion>>`. An entry exists **iff** the owner has read, written, OR edited that target (every success emits `fs/observed`), so its presence is the prior-observation record — there is no `hasRead` flag and no `full`/`partial` view. This plugin does **no** filesystem I/O: "have you observed this file?" is a `WeakMap` lookup, and "is the version you read still current?" is decided inside `ctx.fs.editText`/`writeText` in the same atomic lock that performs the mutation — this plugin only supplies `vObserved` as the basis. A windowed read of lines 100-150 records the file's version, and a later edit of line 120 is authorized as long as the file is unchanged. State is held weakly and dropped on disposal (HMR safety); persistence across sessions is deferred.
## Single-slot, first-wins
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
Because the plugin influences the world only through events, removing it does not break `@deepseek-ai/dsh-tool-fs` at a service-injection boundary: the tool falls through to the bare `ctx.fs` provider (unconditional write/edit, no observed-state). Loading it back layers the policy on. That graceful add/remove is the whole point of the event gate over a mandatory method service.
+33
View File
@@ -0,0 +1,33 @@
{
"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,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-fs": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}
+160
View File
@@ -0,0 +1,160 @@
/**
* 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.fsPolicy` service and exposes no
* `read`/`write`/`edit`/`resolve` methods; it influences the world only by
* 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
* provider, rather than breaking the tool at a service-injection boundary.
*
* ## Observed state IS the prior-observation record
*
* State lives here as `WeakMap<owner, Map<targetKey, { version }>>`. An entry
* exists iff the owner has read, written, OR edited that target (every success
* emits `fs/observed`), so its presence means "this owner has observed this
* target at this version". This is what lets a create-then-edit or
* edit-then-edit sequence work without an intervening re-read: the mutation
* refreshes the recorded version to its own result. The owner is derived
* structurally from `{ agent?: { session? } }` and held weakly, so a collected
* session frees its state; disposal drops everything (HMR safety).
*
* ## Freshness via provider CAS, not stat
*
* This plugin does NO filesystem I/O. "Have you observed this file?" is a
* `WeakMap` lookup (no record ⇒ `FS_NOT_OBSERVED`). "Is the version you read
* still current?" is decided INSIDE `ctx.fs.editText`/`writeText`, in the same
* atomic lock that performs the mutation — this plugin only supplies the
* observed version as the CAS basis. Stat-ing and comparing here would open a
* TOCTOU gap the provider lock has to back up anyway, so it is deliberately
* avoided.
*
* ## Single-slot, first-wins
*
* 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-fs-policy
*/
import type { Context } from 'cordis'
import { FsError } from '@deepseek-ai/dsh-fs'
import type { FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs'
import type { FsPolicyExec } from './types.ts'
export type { FsPolicyExec } from './types.ts'
/**
* Per-context observed-file state and the three `fs/*` decisions over it. One
* instance is created per `apply()` so disposal can drop all state for HMR.
*/
class ObservedStateGate {
/**
* Observed-file state, keyed first by the owner object (weakly held, so a
* collected session frees its state), then by {@link FsTarget.targetKey}. An
* entry's PRESENCE is the prior-observation record.
*/
private observed = new WeakMap<object, Map<string, FsVersion>>()
/**
* Derive the observed-state owner from the opaque event actor — normally the
* active agent session. `undefined` when no owner can be derived (e.g. a
* direct tool call with no agent); such calls read freely but cannot satisfy
* the write/edit prior-observation policy.
*/
private owner(actor: object | undefined): object | undefined {
return (actor as FsPolicyExec | undefined)?.agent?.session
}
private get(owner: object, targetKey: string): FsVersion | undefined {
return this.observed.get(owner)?.get(targetKey)
}
private set(owner: object, targetKey: string, version: FsVersion): void {
let byTarget = this.observed.get(owner)
if (!byTarget) {
byTarget = new Map()
this.observed.set(owner, byTarget)
}
byTarget.set(targetKey, version)
}
/** Drop all recorded state (HMR safety / disposal). */
clear(): void {
this.observed = new WeakMap()
}
/**
* 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).
*/
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' }
}
/**
* Decide the edit version guard: requires a prior observation by this owner
* (else `FS_NOT_OBSERVED`); returns the observed version as the CAS basis.
*/
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) {
throw new FsError(`edit requires reading "${target.displayPath}" first`, 'FS_NOT_OBSERVED')
}
return { version: prior }
}
/** Record a successful read/write/edit: this owner observed this target at this version. */
observe(target: FsTarget, version: FsVersion, actor: object | undefined): void {
const owner = this.owner(actor)
if (owner) this.set(owner, target.targetKey, version)
}
}
/** Cordis plugin name used by loader diagnostics. */
export const name = 'fs-policy'
/**
* Register the three `fs/*` listeners. No `inject` — this plugin reads no
* services; it operates only on its own `WeakMap`. The waterfalls are unbound
* (the tool dispatches them with no `this`), so the listeners take the raw
* `(target, actor, next)` arguments.
*/
export function apply(ctx: Context): void {
const gate = new ObservedStateGate()
ctx.effect(() => () => {
// Drop all recorded state on disposal so a reloaded plugin starts clean
// (HMR safety). The WeakMap itself would be GC'd, but replacing it makes the
// release observable and immediate for tests.
gate.clear()
}, 'fs-policy observed-state teardown')
// 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-intent', (target, actor) => Promise.resolve().then(() => gate.writeIntent(target, actor)))
// 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-intent', (target, actor) => Promise.resolve().then(() => gate.editIntent(target, actor)))
// 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)
})
}
+29
View File
@@ -0,0 +1,29 @@
/**
* 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.
*
* The provider vocabulary (`FsTarget`, `FsVersion`, write/edit shapes) is
* re-used from `@deepseek-ai/dsh-fs`; this package owns only the observed-state
* owner structure on top of it.
*
* @module @deepseek-ai/dsh-fs-policy/types
*/
/**
* Minimal structural view of a tool execution the policy plugin needs to derive
* an observed-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution` satisfies
* this shape, so the tool passes its `exec` straight through as the opaque
* `object` actor on the `fs/*` events; this plugin narrows that actor to this
* shape without importing `dsh-tools`, `dsh-agent`, or `dsh-session`.
*
* The owner is `agent.session` when present. It is treated as an opaque object
* identity (a `WeakMap` key); this package never reads any of its fields.
*/
export interface 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. */
session?: object
}
}
+215
View File
@@ -0,0 +1,215 @@
/**
* 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
* edit, observed-state-as-prior-observation (read/write/edit all record),
* 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 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, 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): FsPolicyExec => ({ agent: { session } })
/** 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-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(FsPolicy)
return { ctx, fiber }
}
describe('registration / disposal', () => {
it('registers no service surface (it is a plugin, not ctx.fsPolicy)', async () => {
const { ctx } = await setup()
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(FsPolicy)
// The listener is live: an unobserved write decides createIfAbsent.
expect(await writeIntent(ctx, target('a.txt'), undefined)).toEqual({ kind: 'createIfAbsent' })
})
})
describe('write-intent decision', () => {
it('an unobserved target decides createIfAbsent', async () => {
const { ctx } = await setup()
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 writeIntent(ctx, target('a.txt'), undefined)).toEqual({ kind: 'createIfAbsent' })
expect(await writeIntent(ctx, target('a.txt'), {})).toEqual({ kind: 'createIfAbsent' })
})
it('an actor with an agent but no session has no owner (createIfAbsent)', async () => {
// The middle optional-chain rung: agent present, session undefined ⇒ owner
// undefined ⇒ unobservable, so a write can only be a blind create.
const { ctx } = await setup()
expect(await writeIntent(ctx, target('a.txt'), { agent: {} })).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 writeIntent(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v7' })
})
})
describe('edit-intent decision', () => {
it('rejects an unread edit with FS_NOT_OBSERVED', async () => {
const { ctx } = await setup()
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(editIntent(ctx, target('a.txt'), undefined)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
})
it('rejects an edit whose actor has an agent but no session (no owner)', async () => {
const { ctx } = await setup()
await expect(editIntent(ctx, target('a.txt'), { agent: {} })).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 editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v3' })
})
})
describe('observed-state is the prior-observation record', () => {
it('a read observation authorizes an in-place write at that version', async () => {
const { ctx } = await setup()
const exec = ownerExec({})
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec) // a read
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 () => {
const { ctx } = await setup()
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 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 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(editIntent(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
})
})
describe('multi-owner isolation', () => {
it('owner A observing does not grant owner B edit authority', async () => {
const { ctx } = await setup()
const a = ownerExec({})
const b = ownerExec({})
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), a)
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 () => {
const { ctx } = await setup()
const a = ownerExec({})
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 writeIntent(ctx, target('a.txt'), b)).toEqual({ kind: 'createIfAbsent' })
expect(await writeIntent(ctx, target('a.txt'), a)).toEqual({ kind: 'replaceIfVersion', version: 'v0' })
})
})
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 intent = await ctx.waterfall('fs/write-intent', target('a.txt'), ownerExec({}), () => {
defaultRan = true
return undefined
})
expect(intent).toEqual({ kind: 'createIfAbsent' })
expect(defaultRan).toBe(false)
})
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 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-intent', () => {
secondRan = true
return Promise.resolve(undefined)
})
const exec = ownerExec({})
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec)
await editIntent(ctx, target('a.txt'), exec)
expect(secondRan).toBe(false)
})
it('a SECOND write-intent decider registered AFTER fs-policy is not reached', async () => {
const { ctx } = await setup()
let secondRan = false
ctx.on('fs/write-intent', () => {
secondRan = true
return Promise.resolve(undefined)
})
await writeIntent(ctx, target('a.txt'), ownerExec({}))
expect(secondRan).toBe(false)
})
})
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(FsPolicy)
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec)
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v0' })
await fiber.dispose()
await ctx.plugin(FsPolicy)
// Same owner object, but state was released on disposal.
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(FsPolicy)
await fiber.dispose()
// With no listener, the waterfall falls through to the bare default.
expect(await writeIntent(ctx, target('a.txt'), ownerExec({}))).toBeUndefined()
})
})
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../llm/llm" },
{ "path": "../fs" }
]
}
+43
View File
@@ -0,0 +1,43 @@
# @deepseek-ai/dsh-fs
The **filesystem provider seam**: an abstract `FileSystem` service (`ctx.fs`) defining the text-storage primitives a backend provides — resolve a path, stat metadata, read/stream text, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained text-storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for.
This package is the provider-seam layer of the four-layer filesystem stack, split so each concern can evolve (and be swapped) independently (see [the capability-seam RFC](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md), [the filesystem capability-seam RFC](../../../docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md), [the split-the-filesystem-seam RFC](../../../docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md), and [the file-context event-gate RFC](../../../docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md)):
| 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-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 |
A future sandboxed, virtual, or remote backend implements this interface and the policy/tool layers don't change.
## Service API (`ctx.fs`)
A backend subclasses `FileSystem` and implements six primitives.
| Member | Semantics |
|---|---|
| `resolve(path, opts?)` | Resolve a path into a stable `FsTarget` (`inputPath`, opaque `targetKey`, `displayPath`). `opts.cwd` is the base a relative `path` resolves against (a caller supplies its session workspace; absolute paths ignore it; omitted ⇒ the backend default). Async — a remote backend may need I/O. The same file via different paths must yield the same `targetKey`. |
| `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 `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-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-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. `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.
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@deepseek-ai/dsh-fs",
"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",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}
+211
View File
@@ -0,0 +1,211 @@
/**
* 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 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
* backends without touching the model-facing tool schemas
* (`@deepseek-ai/dsh-tool-fs`).
*
* The split mirrors the bash seam (`BashExecutor`/`LocalBashExecutor`). See the
* capability-seam RFC for why a swappable capability is three (here four)
* packages.
*
* ## This is a provider seam, not the policy layer
*
* `ctx.fs` is deliberately close to fsspec-style storage primitives. It owns
* UTF-8 decoding, binary/NUL rejection, atomic full-file writes, and the
* 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-fs-policy`) adds through the `fs/*`
* event gate. So a sandboxed/remote backend inherits no model-facing observation
* policy it has no business carrying.
*
* `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 mutation 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.
*
* ## The version guard is OPTIONAL — additive policy, not subtractive
*
* `ctx.fs` on its own is a complete, unconstrained text-storage seam: `read`
* reads, `write` unconditionally creates-or-overwrites, `edit` unconditionally
* replaces literal text in the current content. Both mutations take their
* version guard as an OPTIONAL argument — omit it for the unconstrained
* bare-provider behavior, supply it to guard against a concurrent change. 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. Observed-state, read-before-edit, and
* version-guarded write/edit are NOT provider behavior — they are policy a
* 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-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-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
* windows, numbered lines) and no agent/session owner structure leak down.
*
* @module @deepseek-ai/dsh-fs
*/
import { Context, Service } from 'cordis'
import type {
FsEditOutcome,
FsEditRequest,
FsInfo,
FsTarget,
FsVersion,
FsWriteIntent,
FsWriteOutcome,
} from './types.ts'
export {
FsError,
FsTargetKey,
FsVersion,
} from './types.ts'
export type {
FsEditOutcome,
FsEditRequest,
FsErrorCode,
FsInfo,
FsTarget,
FsWriteIntent,
FsWriteOutcome,
} from './types.ts'
declare module 'cordis' {
interface Context {
fs: FileSystem
}
interface Events {
/**
* 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-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
* order, or `prepend`) occupies it; a second decider is a misconfiguration,
* not layering. `actor` is the opaque tool-execution context, never read here.
* @mode waterfall
*/
'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-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-intent'}).
* @mode waterfall
*/
'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-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
* belong here. No listener ⇒ nothing recorded. `actor` is the opaque
* tool-execution context.
* @mode emit
*/
'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void
}
}
/**
* Abstract filesystem provider service. Subclass, implement the six text-storage
* primitives, and load the subclass as a plugin — it registers as `ctx.fs` (one
* implementation per context; loading a second throws, cordis' standard
* duplicate-service behavior).
*
* Semantics every backend must honor:
* - {@link resolve} returns a stable {@link FsTarget}; the same underlying file
* reached by different input paths must yield the same `targetKey` so stale
* guards and target lookup agree across paths (e.g. through symlinks).
* - {@link stat} returns {@link FsInfo} metadata (never content) or `undefined`
* when the target is absent.
* - {@link readText}/{@link streamText} read the whole regular text file (the
* stream for large files); both own regular-file checks, UTF-8 decoding,
* 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 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
* and writes atomically — all inside one mutation critical section. `expected`
* is OPTIONAL: omit it for an unconditional edit of the current content (a
* missing target still reports `FS_STALE_VERSION`).
*/
export abstract class FileSystem extends Service {
constructor(ctx: Context) {
super(ctx, 'fs')
}
/**
* Resolve a model/plugin-supplied path into a stable {@link FsTarget}. May
* perform I/O (a remote/sandboxed backend may need a round-trip to map a path
* to a stable identity), hence async even though the local backend only
* normalizes + realpaths.
*
* `opts.cwd` is the base directory a RELATIVE `path` resolves against; an
* absolute `path` ignores it. Omitted ⇒ the backend's own default base (the
* local backend uses its configured `cwd`). The CALLER supplies this — the
* seam does not read a session or agent — so a tool can resolve against the
* caller's per-session workspace (`exec.agent.session.header.cwd`) without the
* provider depending on `dsh-agent`/`dsh-session`. Mirrors how `dsh-tool-bash`
* defaults a bash `workdir` to the session cwd.
*/
abstract resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget>
/** Return target metadata, or `undefined` when the target does not exist. */
abstract stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined>
/** Read the whole regular text file as a single decoded string. */
abstract readText(target: FsTarget, signal?: AbortSignal): Promise<string>
/**
* Stream the whole regular text file as decoded text chunks (same text
* semantics as {@link readText}, for large files). The backend owns
* cross-chunk UTF-8 decoding and binary rejection so the policy layer never
* touches raw bytes.
*/
abstract streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>>
/**
* Create or fully replace a UTF-8 text file atomically. `expected` is the
* create-vs-replace decision and stale guard when supplied; OMITTING it is an
* unconditional create-or-overwrite (the bare provider — no version guard, no
* read-first requirement). Atomic either way.
*/
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
* supplied, verifies `expected.version` as the stale guard BEFORE literal
* matching; OMITTING it edits the current content unconditionally (no version
* guard). Either way applies the replacement and writes atomically — one
* mutation critical section — and a missing target reports `FS_STALE_VERSION`.
*/
abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome>
}
export default FileSystem
+154
View File
@@ -0,0 +1,154 @@
/**
* Vocabulary for the filesystem provider seam (`ctx.fs`): the opaque
* 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-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
* consumer may show.
*
* 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-fs-policy`).
*
* @module @deepseek-ai/dsh-fs/types
*/
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { Branded } from '@deepseek-ai/dsh-brand'
/**
* Opaque key for stale guards and target lookup. The local backend uses a
* realpath-like string; a remote backend might use a workspace URI or file id.
* Consumers MUST NOT parse it or assume it is a local absolute path.
*/
export type FsTargetKey = Branded<'FsTargetKey'>
/** Brand a string as an {@link FsTargetKey}. */
export function FsTargetKey(key: string): FsTargetKey {
return key as FsTargetKey
}
/**
* Opaque file-version token — the freshness token a write/edit guards against.
* The local backend derives it from mtime+size; a remote backend might use a
* revision id. The policy layer records it for stale checks; consumers may
* display related metadata but MUST NOT interpret this token.
*/
export type FsVersion = Branded<'FsVersion'>
/** Brand a string as an {@link FsVersion}. */
export function FsVersion(v: string): FsVersion {
return v as FsVersion
}
/**
* A path resolved by a backend into a stable identity. `resolve()` produces
* this; every other operation takes it.
*/
export interface FsTarget {
/** The original model/plugin-supplied path, for diagnostics only. */
inputPath: string
/** Opaque key for stale guards and target lookup. */
targetKey: FsTargetKey
/**
* Path for model/UI-facing output. May be a local absolute path,
* workspace-relative path, or remote URI depending on the backend.
*/
displayPath: string
}
/**
* Metadata about a target — what {@link FileSystem.stat} returns. Lets the
* policy layer reject directories/special files before reading and choose
* `readText` vs `streamText` from `size` without probing by failure. `version`
* is the freshness token. `undefined` from `stat` means the target is absent.
*/
export interface FsInfo {
/** Opaque freshness token of the target right now. */
version: FsVersion
/** Whether the target is a regular file, a directory, or something else. */
type: 'file' | 'directory' | 'other'
/** Byte size of a regular file, when the backend can report it. */
size?: number
}
/**
* The explicit intent of a guarded {@link FileSystem.writeText} call.
* `createIfAbsent` creates a missing target and rejects an existing one with
* `FS_NOT_OBSERVED` (the path the policy plugin uses when the owner has no prior
* read). `replaceIfVersion` replaces only when the target exists at the observed
* version; a missing target or a version mismatch throws `FS_STALE_VERSION`.
*
* `writeText` takes this OPTIONALLY: omitting `expected` is the third,
* unconstrained state — an unconditional create-or-overwrite (the bare
* provider). The union itself carries only the two GUARDED intents; "no guard"
* is expressed by omission, so the write and edit mutations share one symmetric
* shape (`expected?`: omit = unconditional, present = guarded).
*/
export type FsWriteIntent =
| { kind: 'createIfAbsent' }
| { kind: 'replaceIfVersion'; version: FsVersion }
/** Outcome of a full-file write. */
export interface FsWriteOutcome {
/** Whether the write created a new file or replaced an existing one. */
operation: 'create' | 'update'
/** Opaque version of the file after the write. */
version: FsVersion
}
/** A literal-replacement edit request. */
export interface FsEditRequest {
/** Literal non-empty text to replace. Must match exactly (after line-ending normalization). */
oldString: string
/** Literal replacement text. An empty string deletes the matched text. */
newString: string
/** Replace every match instead of requiring exactly one. */
replaceAll: boolean
}
/** Outcome of a literal edit. */
export interface FsEditOutcome {
/** Number of literal replacements applied. */
replacements: number
/** Whether every match was replaced. */
replaceAll: boolean
/** Opaque version of the file after the edit. */
version: FsVersion
}
/**
* Stable, machine-routable codes for filesystem failures. Carried on
* {@link FsError}; the tool registry surfaces `{ name, code }` on `isError`
* results so retry/permission/UI layers can branch without parsing messages.
*/
export type 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'
/**
* Typed filesystem error. Extends {@link HarnessError} so it carries a stable
* {@link FsErrorCode} and chains `cause`. `dsh-fs` owns this vocabulary so
* backends and the policy layer raise the same codes instead of each inventing
* message strings.
*/
export class FsError extends HarnessError {
override readonly code: FsErrorCode
constructor(message: string, code: FsErrorCode, options?: ErrorOptions) {
super(message, code, options)
this.code = code
}
}
+118
View File
@@ -0,0 +1,118 @@
/**
* 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-fs-policy`; this seam owns only the
* abstract service contract, so a minimal fake backend exercises it.
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
import type {
FsEditOutcome,
FsEditRequest,
FsInfo,
FsTarget,
FsWriteIntent,
FsWriteOutcome,
} from '@deepseek-ai/dsh-fs'
/** A minimal in-memory fake implementing the six provider primitives. */
class FakeFileSystem extends FileSystem {
files = new Map<string, string>()
override async resolve(path: string): Promise<FsTarget> {
return { inputPath: path, targetKey: FsTargetKey(path), displayPath: path }
}
override async stat(target: FsTarget): Promise<FsInfo | undefined> {
const content = this.files.get(target.targetKey)
if (content === undefined) return undefined
return { version: FsVersion('v1'), type: 'file', size: content.length }
}
override async readText(target: FsTarget): Promise<string> {
const content = this.files.get(target.targetKey)
if (content === undefined) throw new FsError(`not found: ${target.displayPath}`, 'FS_NOT_FOUND')
return content
}
override async streamText(target: FsTarget): Promise<AsyncIterable<string>> {
const content = await this.readText(target)
return (async function* () { yield content })()
}
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') }
}
override async editText(target: FsTarget, edit: FsEditRequest): Promise<FsEditOutcome> {
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') }
}
}
describe('FileSystem provider seam', () => {
it('registers as ctx.fs and serves the primitives', async () => {
const ctx = new Context()
await ctx.plugin(FakeFileSystem)
const fs = ctx.fs as FakeFileSystem
fs.files.set('a.txt', 'hi')
const target = await fs.resolve('a.txt')
expect((await fs.stat(target))?.type).toBe('file')
expect(await fs.readText(target)).toBe('hi')
})
it('throws when a second implementation is loaded (duplicate service)', async () => {
const ctx = new Context()
await ctx.plugin(FakeFileSystem)
await expect(ctx.plugin(FakeFileSystem)).rejects.toThrow()
})
it('removes the service when the providing fiber is disposed', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(FakeFileSystem)
expect(ctx.fs).toBeDefined()
await fiber.dispose()
expect(ctx.fs).toBeUndefined()
})
it('streamText yields the same text readText returns', async () => {
const ctx = new Context()
await ctx.plugin(FakeFileSystem)
const fs = ctx.fs as FakeFileSystem
fs.files.set('a.txt', 'one\ntwo')
const target = await fs.resolve('a.txt')
let streamed = ''
for await (const chunk of await fs.streamText(target)) streamed += chunk
expect(streamed).toBe(await fs.readText(target))
})
it('stat returns undefined for an absent target', async () => {
const ctx = new Context()
await ctx.plugin(FakeFileSystem)
const fs = ctx.fs as FakeFileSystem
expect(await fs.stat(await fs.resolve('missing.txt'))).toBeUndefined()
})
})
describe('branded id factories', () => {
it('FsTargetKey and FsVersion brand a string at compile time (identity at runtime)', () => {
expect(FsTargetKey('k')).toBe('k')
expect(FsVersion('v')).toBe('v')
})
})
describe('FsError', () => {
it('carries a stable code and HarnessError name', () => {
const error = new FsError('nope', 'FS_NOT_FOUND')
expect(error.code).toBe('FS_NOT_FOUND')
expect(error.name).toBe('FsError')
expect(error).toBeInstanceOf(Error)
})
it('chains an underlying cause through ErrorOptions', () => {
const root = new Error('EACCES')
const error = new FsError('cannot read', 'FS_ABORTED', { cause: root })
expect(error.cause).toBe(root)
expect(error.code).toBe('FS_ABORTED')
})
})
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../util/brand" },
{ "path": "../../llm/llm" }
]
}
+38
View File
@@ -0,0 +1,38 @@
# @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-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(FsPolicy) // @deepseek-ai/dsh-fs-policy (policy gate)
await ctx.plugin(ToolFs) // this package — registers read/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))
| 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. 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.
## The tool is the executor; policy is an event gate
The tools do **not** inject a policy service or inspect any cache. Each tool resolves the path via `ctx.fs.resolve(path, { cwd })` — passing the calling agent's session cwd (`exec.agent.session.header.cwd`) so a relative path resolves against the session's workspace, matching `dsh-tool-bash` (see [the per-session cwd RFC](../../../docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md)) — then:
- **read** — one `ctx.fs.stat` (type + size routing + version), then `readText`/`streamText`, then builds the line window, then emits `fs/observed` with a plain `ctx.emit`. (1 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-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-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.
+44
View File
@@ -0,0 +1,44 @@
{
"name": "@deepseek-ai/dsh-tool-fs",
"description": "Model-facing filesystem tools (read, write, edit) over the DeepSeek Harness filesystem seam (ctx.fs)",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-fs": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-fs-policy": "workspace:^",
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}
+100
View File
@@ -0,0 +1,100 @@
/**
* 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-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-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`.
*
* @module @deepseek-ai/dsh-tool-fs/src/edit
*/
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 { sessionCwd } from './session-cwd.ts'
/** Validated `edit` arguments after defaulting. */
interface EditInput {
filePath: string
oldString: string
newString: string
replaceAll: boolean
}
/** Validate value constraints the schema DSL can't express. */
export function parseEditArgs(args: { file_path: string; old_string: string; new_string: string; replace_all?: boolean }): EditInput {
if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string')
if (args.old_string.length === 0) throw new Error('old_string must be a non-empty string')
if (args.old_string === args.new_string) throw new Error('old_string and new_string must differ')
return {
filePath: args.file_path,
oldString: args.old_string,
newString: args.new_string,
replaceAll: args.replace_all ?? false,
}
}
/** Format an edit outcome as a Claude-style model-facing success message. */
export function formatEditOutput(displayPath: string, outcome: FsEditOutcome): string {
return outcome.replaceAll
? `The file ${displayPath} has been updated. All occurrences were successfully replaced.`
: `The file ${displayPath} has been updated successfully.`
}
/** Register the `edit` tool and its system-prompt guidance. */
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 fs-policy requires it), unless you just created or edited it in this session.',
})
ctx.tools.register(defineTool({
name: 'edit',
description: 'Edit an existing UTF-8 text file by replacing literal text.',
parameters: {
file_path: { type: 'string', required: true, description: 'Path to edit, resolved by the filesystem backend.' },
old_string: { type: 'string', required: true, description: 'Literal text to replace. Must match exactly.' },
new_string: { type: 'string', required: true, description: 'Literal replacement text. Use an empty string to delete the match.' },
replace_all: { type: 'boolean', description: 'Replace all matches. Defaults to false; when false, old_string must appear exactly once.' },
},
async execute(args, exec): Promise<ContentBlock[]> {
const input = parseEditArgs(args)
const cwd = sessionCwd(exec)
const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined)
// 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 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 },
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: formatEditOutput(target.displayPath, outcome) }]
},
// Pure display: `edit` kind, a location for editor follow-along, and a short
// old→new summary as rawInput (truncated so a large replacement stays a
// readable card). The replacement COUNT is not available here — presentResult
// only sees `{ content, isError }`, not the outcome — so the title is static.
presentCall(args) {
const clip = (s: string): string => (s.length > 40 ? `${s.slice(0, 40)}` : s)
return {
title: `Edit ${args.file_path}`,
kind: 'edit',
rawInput: `${JSON.stringify(clip(args.old_string))}${JSON.stringify(clip(args.new_string))}`,
locations: [{ path: args.file_path }],
}
},
}))
}
+47
View File
@@ -0,0 +1,47 @@
/**
* The model-facing filesystem tool suite (`read`, `write`, `edit`) over the
* `ctx.fs` provider seam. This single plugin registers all three tools.
*
* ## 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-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
*/
import type { Context } from 'cordis'
import { applyReadTool } from './read.ts'
import { applyWriteTool } from './write.ts'
import { applyEditTool } from './edit.ts'
export { READ_LIMIT, STREAM_MIN_SIZE, applyReadTool, parseReadArgs } from './read.ts'
export { applyWriteTool, formatWriteOutput, parseWriteArgs } from './write.ts'
export { applyEditTool, formatEditOutput, parseEditArgs } from './edit.ts'
export { READ_MAX_BYTES, READ_MAX_LINE_LENGTH, buildWindow, formatReadOutput } from './read-render.ts'
export type { FileReadOutcome, FileTextLine, ReadWindow, WindowResult } from './read-render.ts'
/** Cordis plugin name used by loader diagnostics. */
export const name = 'tool-fs'
/** Services required by the filesystem tool suite. */
export const inject = ['tools', 'fs', 'systemPrompt']
/** Register the full `read`/`write`/`edit` filesystem tool suite. */
export function apply(ctx: Context): void {
applyReadTool(ctx)
applyWriteTool(ctx)
applyEditTool(ctx)
}
+180
View File
@@ -0,0 +1,180 @@
/**
* Cordis-free read rendering for `@deepseek-ai/dsh-tool-fs`: turn a file's
* decoded text into a bounded, line-numbered window (offset/limit, byte cap,
* per-line truncation) and format it as the model-facing text block. This is
* the `read` tool's RENDERING detail — not a storage primitive, not freshness
* policy — so it lives apart from the tool's I/O and event wiring as a pure,
* independently-testable module (no cordis, no filesystem).
*
* The provider (`ctx.fs.readText`/`streamText`) hands back already-decoded text
* (UTF-8 validated, binary rejected); {@link buildWindow} 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.
* {@link formatReadOutput} turns the resulting {@link FileReadOutcome} into the
* `<path>/<content>` envelope the model sees.
*
* @module @deepseek-ai/dsh-tool-fs/read-render
*/
import { FsError } from '@deepseek-ai/dsh-fs'
import type { FsVersion } 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 {@link buildWindow} produces 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
}
/** Outcome of a bounded text read — what {@link formatReadOutput} 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
}
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)
}
/** Format a read outcome as one OpenCode-style line-numbered text block body. */
export function formatReadOutput(displayPath: string, outcome: FileReadOutcome): string {
const endLine = outcome.lines.at(-1)?.number ?? Math.max(0, outcome.offset - 1)
let footer: string
if (outcome.truncatedByBytes) {
footer = `(Output capped. Showing lines ${outcome.offset}-${endLine}. Use offset=${endLine + 1} to continue.)`
} else if (endLine < outcome.totalLines) {
footer = `(Showing lines ${outcome.offset}-${endLine} of ${outcome.totalLines}. Use offset=${endLine + 1} to continue.)`
} else {
footer = `(End of file - total ${outcome.totalLines} lines)`
}
const body = outcome.lines.length > 0
? `${outcome.lines.map(line => `${line.number}: ${line.text}`).join('\n')}\n\n${footer}`
: footer
return `<path>${displayPath}</path>
<type>file</type>
<content>
${body}
</content>`
}
+120
View File
@@ -0,0 +1,120 @@
/**
* The model-facing `read` tool: inspect a UTF-8 text file and return
* 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-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
* freshness/observation policy is not its concern.
*
* @module @deepseek-ai/dsh-tool-fs/src/read
*/
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { FsError } from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { buildWindow, formatReadOutput } from './read-render.ts'
import type { FileReadOutcome } from './read-render.ts'
import { sessionCwd } from './session-cwd.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
offset: number
limit: number
}
function parsePositiveInteger(value: number, name: string): number {
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1) {
throw new Error(`${name} must be a positive integer`)
}
return value
}
/** Validate value constraints the schema DSL can't express. */
export function parseReadArgs(args: { file_path: string; offset?: number; limit?: number }): ReadInput {
if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string')
const offset = args.offset === undefined ? 1 : parsePositiveInteger(args.offset, 'offset')
const limit = args.limit === undefined ? READ_LIMIT : parsePositiveInteger(args.limit, 'limit')
if (limit > READ_LIMIT) throw new Error(`limit must be less than or equal to ${READ_LIMIT}`)
return { filePath: args.file_path, offset, limit }
}
/** Register the `read` tool and its system-prompt guidance. */
export function applyReadTool(ctx: Context): void {
ctx.systemPrompt.section({
name: 'tool:read',
order: 100,
text: 'Use the read tool to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.',
})
ctx.tools.register(defineTool({
name: 'read',
description: 'Read a UTF-8 text file and return line-numbered content.',
parameters: {
file_path: { type: 'string', required: true, description: 'Path to read, resolved by the filesystem backend.' },
offset: { type: 'number', description: '1-based first line to return. Defaults to 1.' },
limit: { type: 'number', description: `Maximum number of lines to return. Defaults to ${READ_LIMIT}.` },
},
async execute(args, exec): Promise<ContentBlock[]> {
const input = parseReadArgs(args)
const cwd = sessionCwd(exec)
const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined)
// 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 } : {},
}
// Record the observed version (a no-op when no policy plugin listens). The
// read already succeeded; an fs/observed listener is contractually a
// synchronous, side-effect-only recorder.
ctx.emit('fs/observed', target, info.version, exec)
return [{ type: 'text', text: formatReadOutput(target.displayPath, outcome) }]
},
// Pure display: a UI card titled by the file, `read` kind (icon), and a
// location so an editor can follow along to the file (and the read's offset
// line). `rawInput` surfaces offset/limit when the model narrowed the read.
presentCall(args) {
const detail = [
...args.offset !== undefined ? [`offset ${args.offset}`] : [],
...args.limit !== undefined ? [`limit ${args.limit}`] : [],
].join(', ')
return {
title: `Read ${args.file_path}`,
kind: 'read',
locations: [{ path: args.file_path, ...args.offset !== undefined ? { line: args.offset } : {} }],
...detail.length > 0 ? { rawInput: detail } : {},
}
},
}))
}
+24
View File
@@ -0,0 +1,24 @@
/**
* Derive the working directory a filesystem tool resolves relative paths
* against: the calling agent's per-session workspace
* (`exec.agent.session.header.cwd`), so each ACP session's `read`/`write`/`edit`
* act on ITS workspace, not the server's launch dir — mirroring how
* `dsh-tool-bash` defaults a bash `workdir` to the session cwd.
*
* The `agent` is optional-chained — a non-agent caller yields `undefined`, and
* the tool then calls `ctx.fs.resolve(path)` with no base so the backend applies
* its own configured default (preserving the non-ACP / no-session behavior).
* `session`/`header` are non-optional on a real `Agent`, so only `agent` needs
* the guard (mirroring `dsh-tool-bash`'s `resolveWorkdir`). Returning `undefined`
* rather than reading `process.cwd()` here keeps the default in ONE place (the
* provider), per the "explicit > implicit at seams" convention.
*
* @module @deepseek-ai/dsh-tool-fs/session-cwd
*/
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
/** The session workspace cwd for this call, or `undefined` when none applies. */
export function sessionCwd(exec: ToolExecution): string | undefined {
return exec.agent?.session.header.cwd
}
+73
View File
@@ -0,0 +1,73 @@
/**
* The model-facing `write` tool: create or fully replace a UTF-8 text file. The
* 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-fs-policy`) occupies the single decision slot and
* returns `createIfAbsent`/`replaceIfVersion` instead. The tool stats ZERO
* times either way.
*
* @module @deepseek-ai/dsh-tool-fs/src/write
*/
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 { sessionCwd } from './session-cwd.ts'
/** Validate value constraints the schema DSL can't express. */
export function parseWriteArgs(args: { file_path: string; content: string }): { filePath: string; content: string } {
if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string')
return { filePath: args.file_path, content: args.content }
}
/** Format a write outcome as one model-facing text block body. */
export function formatWriteOutput(displayPath: string, outcome: FsWriteOutcome): string {
const verb = outcome.operation === 'create' ? 'Created' : 'Updated'
return `<path>${displayPath}</path>
<type>file</type>
<content>
${verb} file
</content>`
}
/** Register the `write` tool and its system-prompt guidance. */
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 fs-policy requires it) and prefer edit for targeted changes.',
})
ctx.tools.register(defineTool({
name: 'write',
description: 'Create or fully replace a UTF-8 text file.',
parameters: {
file_path: { type: 'string', required: true, description: 'Path to write, resolved by the filesystem backend.' },
content: { type: 'string', required: true, description: 'Full UTF-8 text content to write.' },
},
async execute(args, exec): Promise<ContentBlock[]> {
const input = parseWriteArgs(args)
const cwd = sessionCwd(exec)
const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined)
// Single-slot decision: the policy plugin produces createIfAbsent/
// replaceIfVersion; the bare default is undefined (unconditional). No stat.
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) }]
},
// Pure display: `edit` kind (an editor treats create/replace as an edit) and
// a location so the UI can follow along to the written file. The create-vs-
// overwrite fact lives in the model-facing result text; `presentResult` only
// sees `{ content, isError }` (not the outcome), so the title stays static.
presentCall(args) {
return { title: `Write ${args.file_path}`, kind: 'edit', locations: [{ path: args.file_path }] }
},
}))
}
+84
View File
@@ -0,0 +1,84 @@
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import type { Context } from 'cordis'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import { fsHarness, waitForIdle } from './harness.ts'
/**
* With-key smoke for the filesystem tools: a REAL model drives the REAL
* read/write/edit tools (over the real local backend + policy gate), and we
* verify the WORLD — the file on disk — not the agent's self-report. This is the
* "green units, broken product" guard: mocks prove the plumbing, only a real
* model proves the tools actually work end-to-end. Key-gated (self-skips without
* DEEPSEEK_API_KEY).
*/
let ctx: Context | undefined
let workdir: string | undefined
afterEach(async () => {
await ctx?.fiber.dispose()
ctx = undefined
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
workdir = undefined
})
const SYSTEM = 'You are a coding assistant. Use the write tool to create files, the read tool to inspect '
+ 'them, and the edit tool for literal replacements. Read a file before editing it. Keep replies terse.'
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () => {
it('creates, reads, then edits a file — verified on disk', async () => {
workdir = await mkdtemp(join(tmpdir(), 'dsh-fs-e2e-'))
ctx = await fsHarness(workdir)
// agentLoop.create prepares a session with no cwd, so the provider default
// (config.cwd = workdir) is the workspace.
const agent = ctx.agentLoop.create(AgentId('fs-e2e'), { model: 'deepseek-v4-flash', systemPrompt: SYSTEM })
agent.send([{ type: 'text', text:
'Create a file named note.txt containing exactly the line: status: draft. '
+ 'Then read it back, then edit it to replace the literal word draft with final. '
+ 'Tell me when done.' }])
await waitForIdle(ctx, agent)
// Verify the WORLD: the edit landed on disk.
const content = await readFile(join(workdir, 'note.txt'), 'utf8')
expect(content).toContain('status: final')
expect(content).not.toContain('draft')
// The log records real read/write/edit tool calls (not bash).
const calls = [...agent.session.events].filter(e => e.type === 'tool/call').map(e => e.data.name)
expect(calls).toContain('write')
expect(calls).toContain('read')
expect(calls).toContain('edit')
}, 180_000)
it('resolves a relative path against the per-session cwd (factory meta.cwd)', async () => {
// config.cwd is the harness workdir, but the agent's SESSION cwd is a
// different dir; the write must land in the SESSION dir, proving the tool
// passes the per-session cwd (not the backend default).
const configDir = await mkdtemp(join(tmpdir(), 'dsh-fs-e2e-cfg-'))
workdir = configDir
const sessionDir = await mkdtemp(join(tmpdir(), 'dsh-fs-e2e-session-'))
try {
ctx = await fsHarness(configDir)
const handle = ctx.agents.create({
agentId: AgentId('fs-e2e-cwd'),
sessionId: SessionId(`fs-e2e-cwd-${Date.now()}`),
meta: { cwd: sessionDir },
agentOptions: { model: 'deepseek-v4-flash', systemPrompt: SYSTEM },
})
handle.agent.send([{ type: 'text', text:
'Use the write tool to create a file named where.txt containing exactly the line: here. Tell me when done.' }])
await waitForIdle(ctx, handle.agent)
// The file is in the SESSION dir, not the config dir.
expect(await readFile(join(sessionDir, 'where.txt'), 'utf8')).toContain('here')
await expect(readFile(join(configDir, 'where.txt'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
} finally {
await rm(sessionDir, { recursive: true, force: true })
}
}, 180_000)
})
+47
View File
@@ -0,0 +1,47 @@
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
/**
* Shared harness for the fs-tools with-key e2e: a minimal real agent stack (the
* DeepSeek adapter + the real fs provider + the read-before-write/edit policy +
* the model-facing read/write/edit tools). Lives outside the *.e2e.ts pattern so
* importing it never re-registers another file's tests.
*
* `fsCwd` is the local backend's default base; a per-session cwd (set via a
* session header) overrides it, but this harness creates agents without a
* session cwd, so the provider default IS the workspace.
*/
export async function fsHarness(fsCwd: string): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] })
await ctx.plugin(LocalFileSystem, { cwd: fsCwd })
await ctx.plugin(FsPolicy)
await ctx.plugin(ToolFs)
return ctx
}
export function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
dispose()
resolve()
}
})
})
}
@@ -0,0 +1,410 @@
/**
* 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-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
* tool carries no dependency on the policy plugin.
*
* 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, vi } from 'vitest'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
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 FsPolicy from '@deepseek-ai/dsh-fs-policy'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
let dir: string
let ctx: Context
let fiber: Awaited<ReturnType<Context['plugin']>>
// A stable session object stands in for an agent session (the file-state
// owner). It carries a `header` (no `cwd`) so `sessionCwd(exec)` resolves to
// `undefined` and the backend falls back to its configured cwd (= `dir`).
const session = { header: {} }
let callCounter = 0
function call(name: string, args: unknown) {
return ctx.tools.execute({
callId: CallId(`call-${++callCounter}`),
name,
arguments: args,
agent: { session } as never,
})
}
function text(result: { content: { type: string; text?: string }[] }): string {
return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
}
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-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(FsPolicy)
fiber = await ctx.plugin(ToolFs)
})
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' })
})
})
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.)')
})
})
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()
})
})
})
// --------------------------------------------------------------------------
// BARE deployment: the tool suite WITHOUT the policy gate.
// --------------------------------------------------------------------------
describe('bare provider (no dsh-fs-policy)', () => {
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 })
fiber = await ctx.plugin(ToolFs)
})
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')
})
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('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')
})
it('edit unconditionally edits an UNREAD existing file', 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(false)
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello 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('edit still enforces literal-match codes (FS_EDIT_NOT_FOUND), unrelated to freshness', async () => {
await writeFile(join(dir, 'a.txt'), 'hello world')
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_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()
})
})
// --------------------------------------------------------------------------
// Per-session cwd: a relative file_path resolves against the CALLING session's
// workspace (`exec.agent.session.header.cwd`), NOT the backend's config.cwd —
// so an ACP editor's per-session dir wins, matching dsh-tool-bash. The regression
// this guards: before the seam fix the tool passed no cwd, so a relative write
// landed in config.cwd instead of the session dir.
// --------------------------------------------------------------------------
describe('per-session cwd', () => {
let sessionDir: string
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-cfg-'))
sessionDir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-session-'))
ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(LocalFileSystem, { cwd: dir }) // config.cwd = dir, NOT sessionDir
await ctx.plugin(FsPolicy)
fiber = await ctx.plugin(ToolFs)
})
afterEach(async () => { await rm(sessionDir, { recursive: true, force: true }) })
const callIn = (sessionObj: object, name: string, args: unknown) =>
ctx.tools.execute({
callId: CallId(`call-${++callCounter}`),
name,
arguments: args,
agent: { session: sessionObj } as never,
})
it('writes a relative path into the SESSION cwd, not config.cwd', async () => {
const result = await callIn({ header: { cwd: sessionDir } }, 'write', { file_path: 'note.txt', content: 'hi' })
expect(result.isError).toBe(false)
// Verify the WORLD: the file is in the session dir, and NOT in config.cwd.
expect(await readFile(join(sessionDir, 'note.txt'), 'utf8')).toBe('hi')
await expect(readFile(join(dir, 'note.txt'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
})
it('read + edit both resolve against the session cwd (end-to-end)', async () => {
// ONE session object across both calls — observed-state keys by owner
// identity, so read must record under the same owner the edit reads.
const session = { header: { cwd: sessionDir } }
await writeFile(join(sessionDir, 'code.txt'), 'alpha')
expect((await callIn(session, 'read', { file_path: 'code.txt' })).isError).toBe(false)
const edited = await callIn(session, 'edit', { file_path: 'code.txt', old_string: 'alpha', new_string: 'beta' })
expect(edited.isError).toBe(false)
expect(await readFile(join(sessionDir, 'code.txt'), 'utf8')).toBe('beta')
})
})
// --------------------------------------------------------------------------
// Abort-through-the-tool, tool-tier concurrency, and the fs/observed contract —
// all through ctx.tools.execute() against the REAL backend + policy.
// --------------------------------------------------------------------------
describe('signal, concurrency, and the fs/observed contract', () => {
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(FsPolicy)
fiber = await ctx.plugin(ToolFs)
})
const session = { header: {} }
const callSig = (signal: AbortSignal, name: string, args: unknown) =>
ctx.tools.execute({ callId: CallId(`c-${++callCounter}`), name, arguments: args, agent: { session } as never, signal })
const callOwned = (name: string, args: unknown) =>
ctx.tools.execute({ callId: CallId(`c-${++callCounter}`), name, arguments: args, agent: { session } as never })
it('a pre-aborted signal makes read/write/edit return isError FS_ABORTED', async () => {
await writeFile(join(dir, 'a.txt'), 'hello')
const read = await callSig(AbortSignal.abort(), 'read', { file_path: 'a.txt' })
expect(read.isError).toBe(true)
expect(read.error).toMatchObject({ code: 'FS_ABORTED' })
const write = await callSig(AbortSignal.abort(), 'write', { file_path: 'new.txt', content: 'x' })
expect(write.isError).toBe(true)
expect(write.error).toMatchObject({ code: 'FS_ABORTED' })
await expect(readFile(join(dir, 'new.txt'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
// Read first (un-aborted, SAME session owner) so the edit clears the
// observation gate; then the aborted edit fails on the signal, not on
// FS_NOT_OBSERVED.
expect((await callOwned('read', { file_path: 'a.txt' })).isError).toBe(false)
const edit = await callSig(AbortSignal.abort(), 'edit', { file_path: 'a.txt', old_string: 'hello', new_string: 'bye' })
expect(edit.isError).toBe(true)
expect(edit.error).toMatchObject({ code: 'FS_ABORTED' })
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello') // unchanged
})
it('two concurrent edits of the same file, same session: one wins, one FS_STALE_VERSION', async () => {
await writeFile(join(dir, 'a.txt'), 'base value here')
// One read establishes the observed version both edits guard against; then
// race two edits so both carry the SAME observed version (the barrier).
expect((await callOwned('read', { file_path: 'a.txt' })).isError).toBe(false)
const [one, two] = await Promise.all([
callOwned('edit', { file_path: 'a.txt', old_string: 'base', new_string: 'ONE', replaceAll: false }),
callOwned('edit', { file_path: 'a.txt', old_string: 'value', new_string: 'TWO', replaceAll: false }),
])
const errors = [one, two].filter(r => r.isError)
expect(errors).toHaveLength(1)
expect(errors[0]?.error).toMatchObject({ code: 'FS_STALE_VERSION' })
// The world is consistent: exactly one edit landed.
const onDisk = await readFile(join(dir, 'a.txt'), 'utf8')
expect(onDisk === 'ONE value here' || onDisk === 'base TWO here').toBe(true)
})
it('a throwing fs/observed listener surfaces as isError, but the mutation already hit disk', async () => {
// fs/observed is a plain ctx.emit AFTER the write succeeded; a throwing
// listener cannot roll the write back — it only turns the tool result into
// isError. The file must still carry the written bytes.
ctx.on('fs/observed', () => { throw new Error('recording bug') })
const result = await callOwned('write', { file_path: 'w.txt', content: 'durable' })
expect(result.isError).toBe(true)
expect(await readFile(join(dir, 'w.txt'), 'utf8')).toBe('durable')
})
})
@@ -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'])
})
})
})
+385
View File
@@ -0,0 +1,385 @@
/**
* Consumer-surface tests for the filesystem tools as the EXECUTOR. They run the
* 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
* `fs/*` waterfalls + records observed-state through the gate (read authorizes a
* later edit) — not just that it moved bytes.
*/
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'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
import type {
FsEditOutcome,
FsEditRequest,
FsInfo,
FsTarget,
FsWriteIntent,
FsWriteOutcome,
} from '@deepseek-ai/dsh-fs'
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'
/** 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
writeIntents: (FsWriteIntent | undefined)[] = []
editIntents: ({ version: FsVersion } | undefined)[] = []
private throwIfArmed(): void {
if (this.rejectWith) throw this.rejectWith
}
override async resolve(path: string): Promise<FsTarget> {
return { inputPath: path, targetKey: FsTargetKey(`key:${path}`), displayPath: `/abs/${path}` }
}
override async stat(target: FsTarget): Promise<FsInfo | undefined> {
this.throwIfArmed()
const content = this.files.get(target.targetKey)
if (content === undefined) return undefined
return { version: FsVersion('v1'), type: 'file', size: content.length }
}
override async readText(target: FsTarget): Promise<string> {
return this.files.get(target.targetKey) ?? ''
}
override async streamText(target: FsTarget): Promise<AsyncIterable<string>> {
const content = this.files.get(target.targetKey) ?? ''
return (async function* () { yield content })()
}
override async writeText(target: FsTarget, content: string, expected?: FsWriteIntent): Promise<FsWriteOutcome> {
this.throwIfArmed()
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.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') }
}
}
async function setup() {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(FakeFs)
await ctx.plugin(FsPolicy)
await ctx.plugin(ToolFs)
const fs = ctx.fs as FakeFs
return { ctx, fs }
}
let callCounter = 0
function call(ctx: Context, name: string, args: unknown, agent?: object) {
return ctx.tools.execute({
callId: CallId(`call-${++callCounter}`),
name,
arguments: args,
...agent ? { agent: agent as never } : {},
})
}
function text(result: { content: { type: string; text?: string }[] }): string {
return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
}
describe('registration', () => {
it('registers read, write, and edit', async () => {
const { ctx } = await setup()
expect(ctx.tools.schemas().map(s => s.name).sort()).toEqual(['edit', 'read', 'write'])
})
it('registers prompt sections for each tool', async () => {
const { ctx } = await setup()
const prompt = renderPrompt(await ctx.systemPrompt.assemble())
expect(prompt).toContain('Use the read tool')
expect(prompt).toContain('Use the write tool')
expect(prompt).toContain('Use the edit tool')
})
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 fs provider
expect(ctx.tools.schemas()).toHaveLength(0)
})
it('unregisters everything on fiber disposal (HMR safety)', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(FakeFs)
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)
})
})
describe('read tool', () => {
it('formats line-numbered content with a footer', async () => {
const { ctx, fs } = await setup()
fs.files.set('key:a.txt', 'hello\nworld')
const result = await call(ctx, 'read', { file_path: 'a.txt' })
expect(result.isError).toBe(false)
expect(text(result)).toBe(`<path>/abs/a.txt</path>
<type>file</type>
<content>
1: hello
2: world
(End of file - total 2 lines)
</content>`)
})
it('rejects a non-positive offset via arg validation', async () => {
const { ctx } = await setup()
const result = await call(ctx, 'read', { file_path: 'a.txt', offset: 0 })
expect(result.isError).toBe(true)
expect(text(result)).toContain('offset must be a positive integer')
})
it('rejects a fractional or NaN offset, and a zero/negative limit', async () => {
const { ctx } = await setup()
for (const args of [
{ file_path: 'a.txt', offset: 1.5 },
{ file_path: 'a.txt', offset: Number.NaN },
{ file_path: 'a.txt', limit: 0 },
{ file_path: 'a.txt', limit: -3 },
]) {
const result = await call(ctx, 'read', args)
expect(result.isError, JSON.stringify(args)).toBe(true)
expect(text(result)).toMatch(/must be a positive integer/)
}
})
it('rejects a limit above the cap', async () => {
const { ctx } = await setup()
const result = await call(ctx, 'read', { file_path: 'a.txt', limit: 99999 })
expect(result.isError).toBe(true)
expect(text(result)).toContain('less than or equal to 2000')
})
it('rejects a blank file_path', async () => {
const { ctx } = await setup()
const result = await call(ctx, 'read', { file_path: ' ' })
expect(result.isError).toBe(true)
expect(text(result)).toContain('file_path must be a non-empty string')
})
it('records observed state so a follow-up edit by the same session is authorized', async () => {
const { ctx, fs } = await setup()
const session = { header: {} }
fs.files.set('key:a.txt', 'hello')
expect((await call(ctx, 'read', { file_path: 'a.txt' }, { session })).isError).toBe(false)
const edited = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'hello', new_string: 'bye' }, { session })
expect(edited.isError).toBe(false)
expect(fs.editIntents).toEqual([{ version: 'v1' }])
})
it('propagates FS_NOT_FOUND for an absent file', async () => {
const { ctx } = await setup()
const result = await call(ctx, 'read', { file_path: 'missing.txt' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_NOT_FOUND' })
})
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', () => {
const base: FileReadOutcome = { offset: 1, limit: 2000, lines: [{ number: 1, text: 'x' }], totalLines: 1, version: FsVersion('v') }
it('reports a byte-capped read', () => {
const out = formatReadOutput('/f', { ...base, totalLines: 99, truncatedByBytes: true })
expect(out).toContain('(Output capped. Showing lines 1-1. Use offset=2 to continue.)')
})
it('reports a more-remaining page', () => {
const out = formatReadOutput('/f', { ...base, totalLines: 99 })
expect(out).toContain('(Showing lines 1-1 of 99. Use offset=2 to continue.)')
})
it('reports end-of-file', () => {
expect(formatReadOutput('/f', base)).toContain('(End of file - total 1 lines)')
})
it('renders an empty file as just the footer', () => {
const out = formatReadOutput('/f', { ...base, lines: [], totalLines: 0 })
expect(out).toContain('(End of file - total 0 lines)')
expect(out).not.toContain(': ')
})
})
describe('write tool', () => {
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: { header: {} } })
expect(result.isError).toBe(false)
expect(text(result)).toContain('Created file')
expect(fs.writeIntents).toEqual([{ kind: 'createIfAbsent' }])
})
it('rejects a blank file_path', async () => {
const { ctx } = await setup()
const result = await call(ctx, 'write', { file_path: ' ', content: 'hi' })
expect(result.isError).toBe(true)
expect(text(result)).toContain('file_path must be a non-empty string')
})
it('propagates a backend FsError as an isError result carrying its code', async () => {
const { ctx, fs } = await setup()
fs.rejectWith = new FsError('blocked', 'FS_STALE_VERSION')
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ name: 'FsError', code: 'FS_STALE_VERSION' })
})
})
describe('edit tool', () => {
it('formats a single-replacement success after a read', async () => {
const { ctx, fs } = await setup()
const session = { header: {} }
fs.files.set('key:a.txt', 'a')
await call(ctx, 'read', { file_path: 'a.txt' }, { session })
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }, { session })
expect(text(result)).toBe('The file /abs/a.txt has been updated successfully.')
})
it('formats the replace_all success message distinctly', async () => {
const { ctx, fs } = await setup()
const session = { header: {} }
fs.files.set('key:a.txt', 'a a a')
await call(ctx, 'read', { file_path: 'a.txt' }, { session })
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b', replace_all: true }, { session })
expect(text(result)).toBe('The file /abs/a.txt has been updated. All occurrences were successfully replaced.')
})
it('rejects identical old/new strings', async () => {
const { ctx } = await setup()
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'x', new_string: 'x' })
expect(result.isError).toBe(true)
expect(text(result)).toContain('must differ')
})
it('rejects an empty old_string', async () => {
const { ctx } = await setup()
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: '', new_string: 'x' })
expect(result.isError).toBe(true)
expect(text(result)).toContain('old_string must be a non-empty string')
})
it('rejects a blank file_path', async () => {
const { ctx } = await setup()
const result = await call(ctx, 'edit', { file_path: ' ', old_string: 'a', new_string: 'b' })
expect(result.isError).toBe(true)
expect(text(result)).toContain('file_path must be a non-empty string')
})
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: { header: {} } })
expect(result.isError).toBe(true)
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
})
})
describe('tool-owned presentation (pure presentCall)', () => {
// presentCall is a pure display function of args (no I/O); it drives the ACP
// card's title/kind and the `locations` an editor follows along to.
const presentCall = async (name: string, args: unknown) => {
const { ctx } = await setup()
return ctx.tools.get(name)?.presentCall?.(args)
}
it('read: titles by file, read kind, location with the offset line', async () => {
expect(await presentCall('read', { file_path: 'src/a.ts', offset: 12, limit: 40 })).toEqual({
title: 'Read src/a.ts', kind: 'read', rawInput: 'offset 12, limit 40',
locations: [{ path: 'src/a.ts', line: 12 }],
})
})
it('read: omits rawInput and the location line when offset/limit are unset', async () => {
expect(await presentCall('read', { file_path: 'a.txt' })).toEqual({
title: 'Read a.txt', kind: 'read', locations: [{ path: 'a.txt' }],
})
})
it('write: titles by file, edit kind, location', async () => {
expect(await presentCall('write', { file_path: 'out.txt', content: 'x' })).toEqual({
title: 'Write out.txt', kind: 'edit', locations: [{ path: 'out.txt' }],
})
})
it('edit: titles by file, edit kind, an old→new rawInput summary, location', async () => {
expect(await presentCall('edit', { file_path: 'a.txt', old_string: 'foo', new_string: 'bar' })).toEqual({
title: 'Edit a.txt', kind: 'edit', rawInput: '"foo" → "bar"', locations: [{ path: 'a.txt' }],
})
})
it('edit: clips a long old/new string in the rawInput summary', async () => {
const long = 'a'.repeat(60)
const p = await presentCall('edit', { file_path: 'a.txt', old_string: long, new_string: 'b' })
expect((p as { rawInput: string }).rawInput).toBe(`${JSON.stringify(`${'a'.repeat(40)}`)}${JSON.stringify('b')}`)
})
})
+17
View File
@@ -0,0 +1,17 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../llm/llm" },
{ "path": "../../core/tools" },
{ "path": "../../core/system-prompt" },
{ "path": "../fs" },
{ "path": "../fs-policy" }
]
}
+2 -2
View File
@@ -28,7 +28,7 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline `
| `session/load` | `ctx.agents.resume(...)` | replays the persisted event log to the client as `session/update` — the USER side (`user/message``user_message_chunk`), assistant text/reasoning (`assistant/chunk`), and tool calls/results (`tool/call` + `tool/result`). Re-loading an already-live id is rejected; the id's load slot is reserved (`loadingIds`) BEFORE the async resume so a pipelined load of the SAME id can't leak a second agent (distinct ids load concurrently). The resumed session keeps its PERSISTED header `cwd`, so its bash tools run in the original workspace; the requested `cwd` must be absolute and match the persisted `cwd`. After the async resume a `closed` re-check refuses to install a record if the bridge tore down mid-load |
| `session/prompt` | `agent.send()` | supports ACP `text` and `resource_link` blocks; rejects image/audio/embedded resource and empty prompts; one in-flight prompt PER session (independent); settles on the OWNING turn's end (a turn that ends in `error` rejects the RPC) |
| `session/cancel` | `agent.cancel()` | the queue-aware cancel: aborts a running step, clears queued + steering work, and drops a turn about to start, then settles the prompt `cancelled` — for ONLY that session (a cancel never touches another session's stream or prompt) |
| `session/update` | `session/event` | `agent_message_chunk` (text-delta), `agent_thought_chunk` (reasoning-delta), `user_message_chunk` (load replay), `tool_call`/`tool_call_update` (title/kind/rawInput/content owned by the TOOL via `presentCall`/`presentResult` — see Tool-call presentation) |
| `session/update` | `session/event` | `agent_message_chunk` (text-delta), `agent_thought_chunk` (reasoning-delta), `user_message_chunk` (load replay), `tool_call`/`tool_call_update` (title/kind/rawInput/content/locations owned by the TOOL via `presentCall`/`presentResult` — see Tool-call presentation) |
## Multi-session
@@ -42,7 +42,7 @@ Each session runs in its own workspace, recorded as the session's `SessionHeader
## Tool-call presentation
How a tool call renders in the editor is owned by the TOOL, not the bridge — the bridge never special-cases tool names. Each tool may declare `presentCall(args)` (pending state: a human-readable `title`, a `kind` for the icon, the salient `rawInput` to show in a detail view, and optional `content` blocks shown alongside) and `presentResult(args, result)` (completed state: an optional replacement `title` and reformatted `content`) on its `dsh-tools` definition. The bridge looks the definition up by name in `ctx.tools` and maps the neutral `ToolCallPresentation`/`ToolResultPresentation` to the ACP `tool_call`/`tool_call_update` wire shapes. A tool that declares neither gets a generic fallback (title = tool name, raw parsed args as `rawInput`, kind inferred from the name). For example `dsh-tool-bash` sets the title to the exact `command` ("ls -la src"), `kind: 'execute'`, the `command` as `rawInput`, the model `description` as a `content` text block, and wraps the completed output in a fenced ` ```console ` block. (The command is the title because an editor hides `rawInput` for execute-kind cards — Zed renders it only for non-terminal tools — and the reference adapters likewise use the command as an execute tool's title.)
How a tool call renders in the editor is owned by the TOOL, not the bridge — the bridge never special-cases tool names. Each tool may declare `presentCall(args)` (pending state: a human-readable `title`, a `kind` for the icon, the salient `rawInput` to show in a detail view, optional `content` blocks shown alongside, and optional `locations``{ path, line? }[]` files the call reads/modifies, forwarded as `tool_call.locations` so an editor can follow along) and `presentResult(args, result)` (completed state: an optional replacement `title` and reformatted `content`) on its `dsh-tools` definition. The bridge looks the definition up by name in `ctx.tools` and maps the neutral `ToolCallPresentation`/`ToolResultPresentation` to the ACP `tool_call`/`tool_call_update` wire shapes. A tool that declares neither gets a generic fallback (title = tool name, raw parsed args as `rawInput`, kind inferred from the name). For example `dsh-tool-bash` sets the title to the exact `command` ("ls -la src"), `kind: 'execute'`, the `command` as `rawInput`, the model `description` as a `content` text block, and wraps the completed output in a fenced ` ```console ` block; the `dsh-tool-fs` `read`/`write`/`edit` tools set a `Read/Write/Edit <path>` title, a `read`/`edit` kind, and a `locations` entry for the file. (The command is the title because an editor hides `rawInput` for execute-kind cards — Zed renders it only for non-terminal tools — and the reference adapters likewise use the command as an execute tool's title.)
The `tool/result` session event carries only `{ callId, content, isError }` — not the tool name or args — so to call a tool's `presentResult` the bridge keeps a small per-session map from `callId` to the in-flight call's `(name, args)`, populated on `tool/call` and removed as each result is presented (it holds only currently-in-flight calls, never finished ones). This is bridge-local state — NOT a change to the event schema or a core service. The map lives on the `SessionRecord`, so two concurrent sessions never cross their in-flight tool state; a `session/load` replay uses a throwaway presenter that pairs each `tool/call` with its `tool/result` as the log replays in order, so replayed tool cards render identically to live ones.
+2 -2
View File
@@ -101,7 +101,7 @@ Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult
| `content` blocks | S | ✅ | ✅ | ✅ | Text content; the description renders above the card. |
| `diff` content | S | ❌ | ✅ | ✅ | No structured diff rendering for edits (would need a diffing edit tool + presenter). |
| `terminal` content | S | ✅ | ✅ | ✅ | Via the Zed `_meta` terminal convention (see below), not the spec `terminal/*` sub-protocol. |
| `locations` (follow-along) | S | | ✅ | ✅ | No file-location hints emitted. |
| `locations` (follow-along) | S | | ✅ | ✅ | The `read`/`write`/`edit` tools emit `{ path, line? }` file-location hints via `presentCall`. |
| `rawInput` | S | ✅ | ⚠️ | ✅ | Parsed tool args surfaced as `rawInput`. |
| `rawOutput` | S | ❌ | ⚠️ | ✅ | Not emitted. |
@@ -147,7 +147,7 @@ Ranked by how commonly the reference adapters ship them and how much UX they unl
5. **Slash commands** (`available_commands_update`).
6. **MCP passthrough** (`mcpServers` on `session/new` + `mcpCapabilities`).
7. **Richer prompt content** — image / embedded `resource` blocks (needs a multimodal model path).
8. **Diff + location tool rendering**`diff` content and `locations` for edit tools.
8. **Diff tool rendering** structured `diff` content for edit tools (the `locations` follow-along hint already ships on `read`/`write`/`edit`).
9. **Usage reporting** (`usage_update`) — the harness already records token usage internally (on `assistant/message`).
10. **Editor filesystem delegation** (`fs/read_text_file` / `fs/write_text_file`) — lets the agent see unsaved buffers; lower priority since the harness has direct disk access.
+3
View File
@@ -38,12 +38,15 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-fs-policy": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tool-bash": "workspace:^",
"@deepseek-ai/dsh-tool-fs": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
+4
View File
@@ -836,6 +836,7 @@ export function streamSessionEventUpdate(
kind: present.kind,
status: 'in_progress',
...present.rawInput !== undefined ? { rawInput: present.rawInput } : {},
...present.locations !== undefined ? { locations: present.locations } : {},
...callContent.length > 0 ? { content: callContent } : {},
...asTerminal
? { _meta: { terminal_info: { terminal_id: event.data.callId, cwd: terminalCwd(present.terminal, terminal.cwd) } } }
@@ -926,6 +927,8 @@ interface ResolvedCallPresentation {
rawInput?: unknown
/** UI content shown on the pending call (e.g. a bash description text block above the card). */
content?: ContentBlock[]
/** Files this call reads/modifies (mapped to ACP `tool_call.locations`), for editor follow-along. */
locations?: { path: string; line?: number }[]
/** Tool's request to render as a terminal (the pending side carries the cwd). */
terminal?: ToolTerminal
}
@@ -1005,6 +1008,7 @@ export class ToolPresenter {
kind: present.kind ?? 'other',
rawInput: present.rawInput,
...present.content !== undefined ? { content: present.content } : {},
...present.locations !== undefined ? { locations: present.locations } : {},
...present.terminal !== undefined ? { terminal: present.terminal } : {},
}
}
+17
View File
@@ -19,7 +19,10 @@ import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
import {
ClientSideConnection,
@@ -165,6 +168,15 @@ export async function makeBridgeHarness(options: {
* tool + the bridge's own todo/write→plan mapping, not a stand-in.
*/
withTodo?: boolean
/**
* Plug the REAL filesystem stack (`dsh-fs-local` + `dsh-fs-policy` +
* `dsh-tool-fs`) so a test can drive `read`/`write`/`edit` through the bridge
* and assert their tool-owned presentation (title/kind/`locations`) on the
* wire — the shipping tools, not a stand-in. `fsCwd` sets the local backend's
* base directory (default: `storageDir`).
*/
withFs?: boolean
fsCwd?: string
} = { storageDir: '' }): Promise<BridgeHarness> {
const adapter = new MockAdapter(options.script ?? [])
@@ -183,6 +195,11 @@ export async function makeBridgeHarness(options: {
if (options.withTodo) {
await ctx.plugin(ToolTodo)
}
if (options.withFs) {
await ctx.plugin(LocalFileSystem, { cwd: options.fsCwd ?? options.storageDir })
await ctx.plugin(FsPolicy)
await ctx.plugin(ToolFs)
}
ctx.llm.registerAdapter(['mock'], adapter)
// Two identity byte pipes cross-wired into the two ndJsonStreams: bytes the
+39 -2
View File
@@ -1,8 +1,13 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import type { SessionNotification } from '@agentclientprotocol/sdk'
import type { ToolDefinition, ToolRegistry } from '@deepseek-ai/dsh-tools'
import type { ToolDefinition, ToolRegistry as ToolRegistryType } from '@deepseek-ai/dsh-tools'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import FsLocal from '@deepseek-ai/dsh-fs-local'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import { streamSessionEventUpdate, agentOptions, todosToPlan, ToolPresenter } from '../src/index.ts'
/** Collect the updates a single event produces (no presenter → generic fallback). */
@@ -20,7 +25,7 @@ function liveUpdatesFor(event: SessionEvent): SessionNotification['update'][] {
}
/** A tiny tool registry stub exposing just `get` for {@link ToolPresenter}. */
function registryOf(...tools: ToolDefinition[]): Pick<ToolRegistry, 'get'> {
function registryOf(...tools: ToolDefinition[]): Pick<ToolRegistryType, 'get'> {
const map = new Map(tools.map(t => [t.name, t]))
return { get: name => map.get(name) }
}
@@ -330,6 +335,38 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
expect(updates[0]).toMatchObject({ sessionUpdate: 'tool_call', title: 'boom' })
expect(updates[1]).toMatchObject({ sessionUpdate: 'tool_call_update', content: [{ type: 'content', content: { type: 'text', text: 'raw' } }] })
})
it('forwards a tool-owned `locations` onto the wire tool_call (REAL fs read/edit tools)', async () => {
// Use the SHIPPING fs tools (not a stand-in), booted through their real
// plugins, so the wire tool_call carries the actual presentCall output —
// including `locations` for editor follow-along. (AGENTS.md "prefer the real
// implementation over a mock".)
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(FsLocal)
await ctx.plugin(ToolFs)
const presenter = new ToolPresenter(ctx.tools)
const [readCall] = updatesWith(presenter, evt('tool/call', {
turn: 1, step: 1, callId: CallId('r1'), name: 'read',
arguments: JSON.stringify({ file_path: 'src/a.ts', offset: 12 }),
}))
expect(readCall).toMatchObject({
sessionUpdate: 'tool_call', toolCallId: 'r1', title: 'Read src/a.ts', kind: 'read',
rawInput: 'offset 12', locations: [{ path: 'src/a.ts', line: 12 }],
})
const [editCall] = updatesWith(presenter, evt('tool/call', {
turn: 1, step: 1, callId: CallId('e1'), name: 'edit',
arguments: JSON.stringify({ file_path: 'src/b.ts', old_string: 'x', new_string: 'y' }),
}))
expect(editCall).toMatchObject({
sessionUpdate: 'tool_call', toolCallId: 'e1', title: 'Edit src/b.ts', kind: 'edit',
locations: [{ path: 'src/b.ts' }],
})
await ctx.fiber.dispose()
})
})
describe('terminal-card mapping (capability-gated)', () => {
+85
View File
@@ -278,6 +278,82 @@ importers:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
packages/fs/fs:
devDependencies:
'@deepseek-ai/dsh-brand':
specifier: workspace:^
version: link:../../util/brand
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
cordis:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
packages/fs/fs-local:
dependencies:
schemastery:
specifier: ^3.18.0
version: 3.18.0
devDependencies:
'@deepseek-ai/dsh-fs':
specifier: workspace:^
version: link:../fs
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
cordis:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
packages/fs/fs-policy:
devDependencies:
'@deepseek-ai/dsh-fs':
specifier: workspace:^
version: link:../fs
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
cordis:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
packages/fs/tool-fs:
devDependencies:
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
'@deepseek-ai/dsh-agent-loop':
specifier: workspace:^
version: link:../../core/agent-loop
'@deepseek-ai/dsh-fs':
specifier: workspace:^
version: link:../fs
'@deepseek-ai/dsh-fs-local':
specifier: workspace:^
version: link:../fs-local
'@deepseek-ai/dsh-fs-policy':
specifier: workspace:^
version: link:../fs-policy
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
'@deepseek-ai/dsh-llm-deepseek':
specifier: workspace:^
version: link:../../llm/llm-deepseek
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../../core/system-prompt
'@deepseek-ai/dsh-tools':
specifier: workspace:^
version: link:../../core/tools
cordis:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
packages/llm/llm:
devDependencies:
'@deepseek-ai/dsh-brand':
@@ -669,6 +745,12 @@ importers:
'@deepseek-ai/dsh-bash-local':
specifier: workspace:^
version: link:../../bash/bash-local
'@deepseek-ai/dsh-fs-local':
specifier: workspace:^
version: link:../../fs/fs-local
'@deepseek-ai/dsh-fs-policy':
specifier: workspace:^
version: link:../../fs/fs-policy
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
@@ -687,6 +769,9 @@ importers:
'@deepseek-ai/dsh-tool-bash':
specifier: workspace:^
version: link:../../bash/tool-bash
'@deepseek-ai/dsh-tool-fs':
specifier: workspace:^
version: link:../../fs/tool-fs
'@deepseek-ai/dsh-tool-todo':
specifier: workspace:^
version: link:../../todo/tool-todo
+9
View File
@@ -75,6 +75,15 @@ const LINK_MAP: Record<string, string> = {
BashRunResult: 'bash.md',
BashTask: 'bash.md',
BashTaskRead: 'bash.md',
FsEditOutcome: 'filesystem.md',
FsEditRequest: 'filesystem.md',
FsInfo: 'filesystem.md',
FsTarget: 'filesystem.md',
FsVersion: 'filesystem.md',
FsWriteIntent: 'filesystem.md',
FsWriteOutcome: 'filesystem.md',
FsPolicyExec: 'filesystem.md',
FileReadOutcome: 'filesystem.md',
}
/** One harness event, extracted from an `interface Events` block. */
+16
View File
@@ -40,9 +40,11 @@ import type { ToolSchema } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import LocalBashExecutor from '@deepseek-ai/dsh-bash-local'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import SubagentService from '@deepseek-ai/dsh-subagent'
import * as SubagentMock from '@deepseek-ai/dsh-subagent-mock'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent'
@@ -97,6 +99,20 @@ const TOOL_PACKAGES: ToolPackage[] = [
await ctx.plugin(ToolBash)
},
},
{
pkg: '@deepseek-ai/dsh-tool-fs',
dir: 'tool-fs',
source: 'packages/fs/tool-fs/src/index.ts',
async mount(ctx) {
// The tool injects `fs`; boot the local backend to satisfy it. The schemas
// do not depend on the policy plugin (an event gate that changes behavior,
// not tool shape), so the bare provider is enough to harvest them.
await ctx.plugin(LocalFileSystem)
await ctx.plugin(ToolFs)
},
note:
'The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin.',
},
{
pkg: '@deepseek-ai/dsh-tool-subagent',
dir: 'tool-subagent',
+12
View File
@@ -42,6 +42,18 @@
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashTask", "source": "packages/bash/bash/src/types.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashTaskRead", "source": "packages/bash/bash/src/types.ts" },
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTarget", "source": "packages/fs/fs/src/types.ts" },
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTargetKey", "source": "packages/fs/fs/src/types.ts" },
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsVersion", "source": "packages/fs/fs/src/types.ts" },
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsInfo", "source": "packages/fs/fs/src/types.ts" },
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteIntent", "source": "packages/fs/fs/src/types.ts" },
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteOutcome", "source": "packages/fs/fs/src/types.ts" },
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsEditRequest", "source": "packages/fs/fs/src/types.ts" },
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsEditOutcome", "source": "packages/fs/fs/src/types.ts" },
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsErrorCode", "source": "packages/fs/fs/src/types.ts" },
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsPolicyExec", "source": "packages/fs/fs-policy/src/types.ts" },
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FileReadOutcome", "source": "packages/fs/tool-fs/src/read-render.ts" },
{ "doc": "docs/core-data-structures/compaction.md", "symbol": "CompactionResult", "source": "packages/compact/compact/src/types.ts" },
{ "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentCapabilities", "source": "packages/subagent/subagent/src/types.ts" },
+1
View File
@@ -43,6 +43,7 @@
"./packages/core/*/src",
"./packages/llm/*/src",
"./packages/bash/*/src",
"./packages/fs/*/src",
"./packages/compact/*/src",
"./packages/subagent/*/src",
"./packages/todo/*/src",
+4
View File
@@ -28,6 +28,10 @@
{ "path": "./packages/llm/llm-pi-ai" },
{ "path": "./packages/bash/bash-local" },
{ "path": "./packages/bash/tool-bash" },
{ "path": "./packages/fs/fs" },
{ "path": "./packages/fs/fs-local" },
{ "path": "./packages/fs/fs-policy" },
{ "path": "./packages/fs/tool-fs" },
{ "path": "./packages/support/invariants" },
{ "path": "./packages/ui/acp" },
{ "path": "./packages/ui/acp-agent" },
+4
View File
@@ -37,6 +37,10 @@
{ "path": "./packages/llm/llm-pi-ai" },
{ "path": "./packages/bash/bash-local" },
{ "path": "./packages/bash/tool-bash" },
{ "path": "./packages/fs/fs" },
{ "path": "./packages/fs/fs-local" },
{ "path": "./packages/fs/fs-policy" },
{ "path": "./packages/fs/tool-fs" },
{ "path": "./packages/compact/compact" },
{ "path": "./packages/compact/compact-basic" },
{ "path": "./packages/support/invariants" },