From 5e01564afbbfa0bcc634e78d24e58f97a7337c86 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 22 Jun 2026 10:48:41 +0800 Subject: [PATCH 01/75] Add filesystem capability seam and tools --- docs/architecture.md | 6 + docs/cordis-catalog/events-and-services.md | 28 +- docs/module-graph.md | 9 + docs/rfc/README.md | 2 + .../2026-06-17-filesystem-capability-seam.md | 182 +++++++ .../2026-06-17-filesystem-tool-schemas.md | 113 +++++ packages/README.md | 7 + packages/fs/README.md | 11 + packages/fs/fs-local/README.md | 23 + packages/fs/fs-local/package.json | 34 ++ packages/fs/fs-local/src/fsio.ts | 470 ++++++++++++++++++ packages/fs/fs-local/src/index.ts | 197 ++++++++ packages/fs/fs-local/tests/filesystem.spec.ts | 268 ++++++++++ packages/fs/fs-local/tests/fsio.spec.ts | 364 ++++++++++++++ packages/fs/fs-local/tsconfig.json | 15 + packages/fs/fs/README.md | 38 ++ packages/fs/fs/package.json | 30 ++ packages/fs/fs/src/index.ts | 256 ++++++++++ packages/fs/fs/src/types.ts | 194 ++++++++ packages/fs/fs/tests/service.spec.ts | 313 ++++++++++++ packages/fs/fs/tsconfig.json | 13 + packages/fs/tool-fs/README.md | 33 ++ packages/fs/tool-fs/package.json | 51 ++ packages/fs/tool-fs/src/edit.ts | 82 +++ packages/fs/tool-fs/src/index.ts | 35 ++ packages/fs/tool-fs/src/read.ts | 95 ++++ packages/fs/tool-fs/src/write.ts | 63 +++ packages/fs/tool-fs/tests/integration.spec.ts | 143 ++++++ packages/fs/tool-fs/tests/subpaths.spec.ts | 74 +++ packages/fs/tool-fs/tests/tools.spec.ts | 270 ++++++++++ packages/fs/tool-fs/tsconfig.json | 16 + packages/fs/tool-fs/tsdown.config.ts | 18 + pnpm-lock.yaml | 52 ++ tsconfig.base.json | 4 + tsconfig.build.json | 3 + tsconfig.typecheck.json | 4 + 36 files changed, 3515 insertions(+), 1 deletion(-) create mode 100644 docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md create mode 100644 docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md create mode 100644 packages/fs/README.md create mode 100644 packages/fs/fs-local/README.md create mode 100644 packages/fs/fs-local/package.json create mode 100644 packages/fs/fs-local/src/fsio.ts create mode 100644 packages/fs/fs-local/src/index.ts create mode 100644 packages/fs/fs-local/tests/filesystem.spec.ts create mode 100644 packages/fs/fs-local/tests/fsio.spec.ts create mode 100644 packages/fs/fs-local/tsconfig.json create mode 100644 packages/fs/fs/README.md create mode 100644 packages/fs/fs/package.json create mode 100644 packages/fs/fs/src/index.ts create mode 100644 packages/fs/fs/src/types.ts create mode 100644 packages/fs/fs/tests/service.spec.ts create mode 100644 packages/fs/fs/tsconfig.json create mode 100644 packages/fs/tool-fs/README.md create mode 100644 packages/fs/tool-fs/package.json create mode 100644 packages/fs/tool-fs/src/edit.ts create mode 100644 packages/fs/tool-fs/src/index.ts create mode 100644 packages/fs/tool-fs/src/read.ts create mode 100644 packages/fs/tool-fs/src/write.ts create mode 100644 packages/fs/tool-fs/tests/integration.spec.ts create mode 100644 packages/fs/tool-fs/tests/subpaths.spec.ts create mode 100644 packages/fs/tool-fs/tests/tools.spec.ts create mode 100644 packages/fs/tool-fs/tsconfig.json create mode 100644 packages/fs/tool-fs/tsdown.config.ts diff --git a/docs/architecture.md b/docs/architecture.md index 2b05e613b1..8f78a0b086 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -24,6 +24,8 @@ 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-tool-fs (filesystem tool schemas) │ │ @deepseek-ai/dsh-session-persistence-jsonl (persistence impl)│ ├─────────────────────────────────────────────────────────────┤ │ @deepseek-ai/dsh-agent (vocabulary + registry) │ @@ -33,6 +35,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 (abstract filesystem) │ ├─────────────────────────────────────────────────────────────┤ │ vendor/: cordis, loader, include, group, timer, hmr, │ │ logger-console, cosmokit, schemastery │ @@ -53,6 +56,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 seam: path resolution, text reads, writes, edits, and observed-file policy | All registrations (`registerAdapter`, `section`, `tools`, `register`, …) go through `ctx.effect()` and return disposers, so plugin hot-reload (vendored HMR) and fiber disposal clean up automatically. @@ -68,6 +72,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: `dsh-fs` owns the abstract `ctx.fs` service and observed-file policy, `dsh-fs-local` provides the local backend, and `dsh-tool-fs` exposes the model-facing `read`/`write`/`edit` schemas over the interface. + > **"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) diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 083d3295f6..a64e962098 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -279,7 +279,7 @@ Source: [`packages/core/tools/src/index.ts:43`](../../packages/core/tools/src/in ## Services -The 8 `ctx.` services the harness provides. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against. +The 9 `ctx.` services the harness provides. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against. ### `ctx.agentLoop` — `AgentLoop` @@ -339,6 +339,32 @@ Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../c Source: [`packages/bash/bash/src/index.ts:59`](../../packages/bash/bash/src/index.ts) +### `ctx.fs` — `FileSystem` (abstract seam) + +Abstract filesystem service. Subclass, implement the four backend primitives (resolve, readPage, createOrReplace, applyEdit), 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). + +Consumers call the concrete public API (read/write/ edit), which derives the file-state owner, enforces the read-before-write/edit policy, and refreshes recorded state — then delegates the actual I/O to the backend primitives. + +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 file-state lookup agree across paths (e.g. through symlinks). +- readPage returns line-numbered UTF-8 content with a `version` and a `view` (`full` only when the page covered the whole file). +- createOrReplace honors the FsExpectation: `observed` rejects with `FS_STALE_VERSION` if the file changed since `version`; `partial` rejects existing targets because the owner saw only a non-editable view; `unobserved` creates iff the target is absent and otherwise rejects. +- applyEdit verifies the expected version (stale guard) and is atomic (read-modify-write must not interleave with a concurrent edit). + +```ts cordis-catalog +abstract resolve(path: string): Promise +abstract readPage(target: FsTarget, request: FsReadRequest, signal?: AbortSignal): Promise +abstract createOrReplace(target: FsTarget, content: string, expected: FsExpectation, signal?: AbortSignal): Promise +abstract applyEdit(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, signal?: AbortSignal): Promise +owner(exec?: FsExecContext): object | undefined +async read(target: FsTarget, request: FsReadRequest, exec?: FsExecContext, signal?: AbortSignal): Promise +async write(target: FsTarget, content: string, exec?: FsExecContext, signal?: AbortSignal): Promise +async edit(target: FsTarget, edit: FsEditRequest, exec?: FsExecContext, signal?: AbortSignal): Promise +``` + +Source: [`packages/fs/fs/src/index.ts:94`](../../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. diff --git a/docs/module-graph.md b/docs/module-graph.md index 9efe6e9669..5a69585ef8 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -10,6 +10,7 @@ graph TD bash --> brand llm --> brand bash-local --> bash + fs --> llm llm-deepseek --> llm llm-pi-ai --> llm session --> brand @@ -18,6 +19,7 @@ graph TD agent --> brand agent --> llm agent --> session + fs-local --> fs llm-replay --> llm llm-replay --> session session-persistence --> session @@ -49,6 +51,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 agent-core --> agent agent-core --> agent-loop agent-core --> invariants @@ -73,11 +79,13 @@ graph TD | `bash` | `brand` | | `llm` | `brand` | | `bash-local` | `bash` | +| `fs` | `llm` | | `llm-deepseek` | `llm` | | `llm-pi-ai` | `llm` | | `session` | `brand`, `llm` | | `system-prompt` | `llm` | | `agent` | `brand`, `llm`, `session` | +| `fs-local` | `fs` | | `llm-replay` | `llm`, `session` | | `session-persistence` | `session` | | `invariants` | `agent`, `llm`, `session` | @@ -88,6 +96,7 @@ graph TD | `acp` | `agent`, `llm`, `session`, `session-persistence`, `tools` | | `agent-loop` | `agent`, `llm`, `session`, `session-persistence`, `system-prompt`, `tools` | | `tool-bash` | `agent`, `bash`, `llm`, `tools` | +| `tool-fs` | `fs`, `llm`, `system-prompt`, `tools` | | `agent-core` | `agent`, `agent-loop`, `invariants`, `llm`, `session`, `system-prompt`, `tool-bash`, `tools` | | `acp-agent` | `acp`, `agent-core`, `session-persistence-jsonl` | | `stdio-agent` | `agent`, `agent-core`, `session`, `session-persistence-jsonl`, `ui-stdio` | diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 4eb7900276..7f56757be0 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -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 | ### Simplification @@ -110,6 +111,7 @@ 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 | | [Reorganize packages into a modular hierarchy](implemented/architecture/2026-06-20-package-hierarchy.md) | 2026-06-20 | diff --git a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md new file mode 100644 index 0000000000..55f5efe14b --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md @@ -0,0 +1,182 @@ +# 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, filesystem vocabulary types, and file-state tracking contract. +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`. + +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 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 is part of the filesystem seam, not a separate service. `ctx.fs` records which file states the current execution context has seen and validates write-like operations against that state. The first `tool-fs` consumer passes the current tool execution context, or a structural projection of it, through to `ctx.fs`; `ctx.fs` derives the file-state owner from that context, normally `exec.agent.session`. `tool-fs` does not know the cache shape, the owner key, or the `read` tool name/schema. + +## 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 file-state contract. The interface defines a minimal structural execution context shape rather than importing `dsh-tools`, `dsh-agent`, or `dsh-session`; the implementation derives a file-state owner from that shape when one is available. The owner object is opaque to `dsh-fs`: `tool-fs` may pass the `ToolExecution` it already receives, or a projected object containing only the owner-bearing fields, without making `dsh-fs` depend on the tool or agent packages. + +`@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, contains all direct `node:fs` / `node:path` access, and provides the in-memory file-state store for the local backend. + +`@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 by composing the per-tool registration helpers (`read`, `write`, and `edit`). The same helpers are exposed as subpath plugins such as `@deepseek-ai/dsh-tool-fs/read`, `@deepseek-ai/dsh-tool-fs/write`, and `@deepseek-ai/dsh-tool-fs/edit` for focused deployments. Root and subpath plugins follow the same rule: they inject `fs` and never import 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 interface must also cover file state: + +- Derive a file-state owner from the current execution context, normally the active agent session. +- Record that the owner saw a target at a backend-defined version. +- Determine whether that owner has a full editable view of a target. +- Use the recorded version as the stale guard for write/edit operations that require prior observation. +- Refresh the recorded state after a successful write/edit so follow-up modifications can proceed without forcing another read. + +The in-memory shape is conceptually a weakly-owned cache: file state is keyed first by the derived owner object, then by the backend `targetKey`. The owner is usually `exec.agent.session`, but `dsh-fs` treats it as opaque and does not import `dsh-session`. Each cached `FileState` records the `targetKey`, `displayPath`, backend `version`, current view (`full` or `partial`), update time, and source (`read`, `write`, `edit`, or a future seed path). Only a `full` view authorizes write/edit. A `partial` view records useful context (paged read, truncated read, injected context) but does not grant edit authority. + +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. + +Text reads return structured UTF-8 line records or ranges with pagination metadata. `tool-fs` owns line-numbered model text rendering; the backend owns bounded line length, bounded output bytes, binary-file rejection, total-line accounting, and whether the returned content is a partial view of the file. + +When a read has a file-state owner, `ctx.fs` records the target, version, display path, view metadata, timestamp, and source. Partial views are useful context but do not authorize write/edit unless a future operation can prove the model saw the raw editable content. + +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. For updates to existing files, `ctx.fs` should require a full prior file state for the current owner and reject absent or partial state. The backend then compares the current file version to the recorded version and rejects stale writes. If the recorded target no longer exists, the write is stale rather than a create. A create is expressed as a write to a target with no existing file and does not require prior state or a file-state owner. + +Literal edit is part of `ctx.fs`, not composed in `tool-fs` from a read plus write. Literal matching, duplicate-match rejection, CRLF preservation, binary rejection, prior-file-state checking, stale-version checking, and atomic read-modify-write are filesystem/backend semantics. A remote backend may implement edit as a native compare-and-edit operation; the consumer should not force local-style composition. + +Direct tool executions without a derivable file-state owner can still exercise lower-level helpers in tests. Production `write`/`edit` tool calls should reject without an owner when they update an existing target, because those operations require prior state. Owner-less `write` may still create a new file when the backend confirms that the target does not already exist. + +Filesystem contract failures are thrown as `FsError extends HarnessError` in the first implementation, 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. Initial codes should include `FS_NOT_FOUND`, `FS_NOT_TEXT`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_PARTIAL_OBSERVATION`, `FS_NOT_REGULAR_FILE`, `FS_AMBIGUOUS_EDIT`, and `FS_EDIT_NOT_FOUND`. + +## 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 first implementation requires a prior full `read` before updating an existing file with `write` or `edit`. `tool-fs` does not implement this by checking whether a tool named `read` ran or by reading the file-state cache. It passes the current execution context to `ctx.fs`, and `ctx.fs` derives the file-state owner and enforces file-state/stale-version policy. Creating a new file with `write` does not require prior state or an owner. + +The root plugin registers the full suite by composing the per-tool registration helpers. The subpath plugins register one tool each for focused deployments and tests. Both forms inject `fs`, `tools`, and `systemPrompt`. + +## Migration plan + +This RFC starts from `origin/master`, where no filesystem tool package exists yet. The final implementation should add 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. Wire examples by loading a `ctx.fs` provider first (`dsh-fs-local`), then the consumer (`dsh-tool-fs` or one of its subpath plugins). +5. Update `docs/architecture.md`, `packages/README.md`, package READMEs, build/typecheck config, and aggregate maintenance scripts such as `scripts/publint-all.ts`. + +This first pass does not add a separate `@deepseek-ai/dsh-file-context` package. The file-state store lives behind `ctx.fs` so root and subpath `tool-fs` plugins share the same read-before-write/edit policy automatically. + +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`, examples, docs, and integration tests. + +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, pagination, output caps, binary-file rejection, abort handling, full-file create/update writes, owner-less creates, owner-less update rejection, parent-directory creation, non-regular target rejection, literal edit success/failure, unique-match enforcement, replace-all behavior, line-ending preservation, file-state recording after reads, session/owner isolation, read-before-update rejection, stale-version rejection, partial-view rejection, structured `FsError` codes, and file-state refresh after successful writes/edits. + +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 with a fake `ctx.fs` implementation. 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 pass the current execution context or structural projection through to `ctx.fs`, root-plugin suite registration, subpath plugin registration, and HMR cleanup. + +Integration tests should load `dsh-fs-local` plus `dsh-tool-fs` and execute `read`, `write`, and `edit` through `ctx.tools.execute()` to prove the three packages work together without bypassing the tool registry. 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, `yarn typecheck`, `yarn 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. + +**File state inside `ctx.fs` can blur concerns.** Recording what an execution context has seen is workflow state, not raw filesystem I/O. This RFC still keeps it inside the filesystem seam because write/edit safety depends on backend-defined target identity and version tokens, and because putting it in `tool-fs` would couple write/edit to the read tool implementation. The boundary is narrow: `ctx.fs` derives the file-state owner, records file state, and checks stale versions, while `tool-fs` owns only model-facing schemas and formatting. + +**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. diff --git a/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md new file mode 100644 index 0000000000..45928e9871 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md @@ -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 three-package split (`dsh-fs`, `dsh-fs-local`, `dsh-tool-fs`), and the observed-file/stale-version policy for read-before-write/edit checks. 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. Updates to existing files require prior observation through `ctx.fs`; 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; requires prior full observation through `ctx.fs`. | 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 internal `ctx.fs` requests. + +## 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. + +For existing files, `write` requires prior full file state derived from a previous read in the same execution context. `ctx.fs` derives the file-state owner and uses the recorded version as the stale guard. Creating a new file does not require prior state or an owner. + +The schema does not expose `expected_hash`, `expected_version`, or `create_only` as model-facing parameters. Stale-version checks are driven by `ctx.fs` file state and backend-produced versions, 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 prior full file state derived from a previous read in the same execution context. `ctx.fs` derives the file-state owner and uses the recorded version as the stale guard. + +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 existing-file `write` and `edit` require a prior full read in the same execution context, while new-file `write` does not. +- The root plugin and subpath plugins register the same schemas. + +Integration tests should execute `read`, `write`, and `edit` through `ctx.tools.execute()` with a fake or local `ctx.fs` provider and verify that model arguments are translated into the expected `ctx.fs` calls. + +## 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 `ctx.fs` observed-file 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. diff --git a/packages/README.md b/packages/README.md index f39d7b70a1..dd418f97ca 100644 --- a/packages/README.md +++ b/packages/README.md @@ -11,6 +11,7 @@ Packages are grouped by modular role at `packages///`. 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 | | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface | | [`ui/`](ui/README.md) | Editor/client integration surfaces (the ACP bridge) | Product — stable surface | | [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, stdio UI, replay adapter) | Support — lower compatibility expectations | @@ -30,6 +31,9 @@ dsh-agent ← dsh-llm, dsh-session, dsh-brand 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 (abstract filesystem seam) +dsh-fs-local ← dsh-fs (FileSystem impl) +dsh-tool-fs ← dsh-fs, dsh-tools (file tool schemas) 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-system-prompt, dsh-tools, dsh-agent @@ -58,6 +62,9 @@ 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` | Abstract filesystem seam (interface + vocabulary + observed-file policy) | `ctx.fs` | +| `fs-local/` | `fs` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) | +| `tool-fs/` | `fs` | Model-facing `read`/`write`/`edit` tool schemas | (registers on `ctx.tools`) | | `llm-deepseek/` | `llm` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) | | `llm-pi-ai/` | `llm` | DeepSeek adapter via `@earendil-works/pi-ai` (design twin) | (registers on `ctx.llm`) | | `session-persistence/` | `session-persistence` | Persistence seam + write coordinator | `ctx.sessionPersistence` | diff --git a/packages/fs/README.md b/packages/fs/README.md new file mode 100644 index 0000000000..15757698b2 --- /dev/null +++ b/packages/fs/README.md @@ -0,0 +1,11 @@ +# fs/ - filesystem capability family + +The filesystem capability seam: an abstract filesystem interface, a local implementation, and the model-facing file tools. All **product** packages. + +| Package | Role | ctx key | +|---|---|---| +| `fs/` | Abstract filesystem seam (interface + vocabulary + observed-file policy) | `ctx.fs` | +| `fs-local/` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) | +| `tool-fs/` | Model-facing `read`/`write`/`edit` tool schemas | (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 interface or model-facing tool schemas. diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md new file mode 100644 index 0000000000..52dfa2e38b --- /dev/null +++ b/packages/fs/fs-local/README.md @@ -0,0 +1,23 @@ +# @deepseek-ai/dsh-fs-local + +The **local-filesystem implementation** of the `ctx.fs` seam ([`@deepseek-ai/dsh-fs`](../fs)). Backs the four `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-tool-fs to expose read/write/edit to the model. +``` + +## Behavior + +- **`resolve(path)`** — relative paths resolve from `config.cwd` (default `process.cwd()`). 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 keeps its absolute path as the key so creates still get a stable identity. `displayPath` is the absolute (un-resolved) path. +- **`readPage`** — UTF-8 only. A fast path (`readFile`) handles files under `FAST_PATH_MAX_SIZE` (10 MB); larger files stream with a capped line buffer so a newline-free giant file can't exhaust memory. NUL-byte samples are rejected (`FS_NOT_TEXT`). Output is bounded to `READ_LIMIT` (2000) lines, `READ_MAX_BYTES` (50 KB), and `READ_MAX_LINE_LENGTH` (2000) chars per line. The `version` is `mtimeMs:size`. +- **`createOrReplace`** — 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`. Honors the `FsExpectation`: an `observed` write must match the recorded version (else `FS_STALE_VERSION`); a `partial` write onto an existing file is rejected (`FS_PARTIAL_OBSERVATION`); an `unobserved` write onto an existing file is rejected (`FS_NOT_OBSERVED`). +- **`applyEdit`** — atomic literal read-modify-write over the same primitive. Verifies the expected version, 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. diff --git a/packages/fs/fs-local/package.json b/packages/fs/fs-local/package.json new file mode 100644 index 0000000000..8c5591398e --- /dev/null +++ b/packages/fs/fs-local/package.json @@ -0,0 +1,34 @@ +{ + "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/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-fs": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts new file mode 100644 index 0000000000..8c94abee24 --- /dev/null +++ b/packages/fs/fs-local/src/fsio.ts @@ -0,0 +1,470 @@ +/** + * 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 read/write/edit mechanics can be unit-tested without a Context. + * + * The reader uses two code paths so a single huge line can never balloon + * memory: a **fast path** (`readFile` + in-memory split) for files under + * {@link FAST_PATH_MAX_SIZE}, and a **streaming path** (manual newline scan + * with a capped line buffer) for larger files. Both reject NUL-byte binary + * samples and keep only the requested page in memory. + * + * 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 { FsError } from '@deepseek-ai/dsh-fs' +import type { FsReadRequest, FsTextLine, FsView } from '@deepseek-ai/dsh-fs' + +/** Default and maximum number of lines returned by one read. */ +export const READ_LIMIT = 2000 + +/** 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 + +/** Files smaller than this use the in-memory fast path; larger files stream. */ +export const FAST_PATH_MAX_SIZE = 10 * 1024 * 1024 + +const READ_MAX_BYTES_LABEL = `${READ_MAX_BYTES / 1024} KB` +const READ_MAX_LINE_SUFFIX = `... (line truncated to ${READ_MAX_LINE_LENGTH} chars)` +const BINARY_SAMPLE_BYTES = 8192 +const NUL_CHAR = String.fromCharCode(0) +const LINE_BUFFER_CAP = READ_MAX_LINE_LENGTH + 1 + +/** + * Test seam: lets specs force the streaming path (via a small + * `fastPathMaxSize`) 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 FAST_PATH_MAX_SIZE} for routing. */ + fastPathMaxSize?: 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 +} + +/** 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: string +} + +/** Result of probing a path: null when it does not exist. */ +export interface PathInfo { + version: string + mode: number + isFile: boolean +} + +function isENOENT(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} + +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') +} + +/** Opaque version token from a stat: mtime (ns precision) + size. */ +function versionOf(info: Stats): string { + return `${info.mtimeMs}:${info.size}` +} + +/** + * Resolve a path to its absolute display path and realpath identity. Relative + * paths are based on `cwd`. The `targetKey` realpaths the parent directory and + * re-appends the basename, so a not-yet-created file gets the same stable key + * it will have after creation (the directory exists even when the file does + * not). Two input paths reaching the same file via symlinks share one key. + * Falls back to the absolute path when even the parent cannot be resolved. + */ +export async function resolveLocalTarget(cwd: string, path: string): Promise { + 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: await realpath(displayPath) } + } catch (error: unknown) { + /* v8 ignore next -- non-ENOENT realpath failure needs a permission/IO fault; ENOENT falls through to parent-dir resolution. */ + if (!isENOENT(error)) throw error + } + try { + // File absent: realpath the parent dir + basename so creates get a stable key. + return { displayPath, targetKey: join(await realpath(dirname(displayPath)), basename(displayPath)) } + } catch (error: unknown) { + /* v8 ignore next -- parent-dir realpath failing needs the dir itself to be missing/unreadable; fall back to the absolute path. */ + if (!isENOENT(error)) throw error + return { displayPath, targetKey: displayPath } + } +} + +/** Probe a path for its version, mode, and regular-file status. Null if absent. */ +export async function probe(absolutePath: string): Promise { + try { + const info = await stat(absolutePath) + return { version: versionOf(info), mode: info.mode & 0o777, isFile: info.isFile() } + } catch (error: unknown) { + /* v8 ignore next 2 -- a non-ENOENT stat failure needs a permission/IO fault; surface it. */ + if (!isENOENT(error)) throw error + return null + } +} + +// --- Reading --- + +interface PageAccumulator { + lines: FsTextLine[] + totalLines: number + outputBytes: number + truncatedByBytes: boolean + done: boolean +} + +function newAccumulator(): PageAccumulator { + return { lines: [], totalLines: 0, outputBytes: 0, truncatedByBytes: false, done: false } +} + +function truncateReadLine(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: PageAccumulator, rawLine: string, request: FsReadRequest): void { + acc.totalLines += 1 + if (acc.totalLines < request.offset || acc.lines.length >= request.limit) return + + const text = truncateReadLine(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 +} + +/** The outcome shape `readTextPage` returns (minus the offset/limit echo, which the caller adds). */ +export interface ReadPageResult { + lines: FsTextLine[] + totalLines: number + truncatedByBytes: boolean + view: FsView + version: string +} + +function buildResult(acc: PageAccumulator, request: FsReadRequest, version: string, displayPath: string): ReadPageResult { + 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') + } + const endLine = acc.lines.at(-1)?.number ?? Math.max(0, request.offset - 1) + const view: FsView = request.offset === 1 && !acc.truncatedByBytes && endLine >= acc.totalLines ? 'full' : 'partial' + return { lines: acc.lines, totalLines: acc.totalLines, truncatedByBytes: acc.truncatedByBytes, view, version } +} + +/** + * Read a bounded UTF-8 text-file page. Rejects non-regular files and NUL-byte + * binary samples; dispatches to the fast or streaming path by file size. + */ +export async function readTextPage( + target: LocalTarget, + request: FsReadRequest, + signal?: AbortSignal, + internals: FsIoInternals = {}, +): Promise { + throwIfAborted(signal, 'read') + const absolutePath = target.targetKey + let info: Stats + try { + info = await stat(absolutePath) + } 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 read "${target.displayPath}": not found`, 'FS_NOT_FOUND') + } + if (!info.isFile()) throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') + + const version = versionOf(info) + const fastPathMax = internals.fastPathMaxSize ?? FAST_PATH_MAX_SIZE + return info.size < fastPathMax + ? readTextPageFast(target, request, version, signal) + : readTextPageStreaming(target, request, version, signal) +} + +async function readTextPageFast( + target: LocalTarget, + request: FsReadRequest, + version: string, + signal?: AbortSignal, +): Promise { + const raw = await readFile(target.targetKey, signal ? { 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') + } + + const text = raw.toString('utf8') + const acc = newAccumulator() + let startPos = 0 + let newlinePos: number + while ((newlinePos = text.indexOf('\n', startPos)) !== -1) { + consumeLine(acc, stripCarriageReturn(text.slice(startPos, newlinePos)), request) + if (acc.done) break + startPos = newlinePos + 1 + } + if (!acc.done && startPos < text.length) { + consumeLine(acc, stripCarriageReturn(text.slice(startPos)), request) + } + return buildResult(acc, request, version, target.displayPath) +} + +async function readTextPageStreaming( + target: LocalTarget, + request: FsReadRequest, + version: string, + signal?: AbortSignal, +): Promise { + const stream = createReadStream(target.targetKey, { encoding: 'utf8', ...signal ? { signal } : {} }) + const acc = newAccumulator() + let lineBuffer = '' + let firstChunk = true + + 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 = '' + } + + try { + for await (const chunk of stream as AsyncIterable) { + if (firstChunk) { + firstChunk = false + if (chunk.slice(0, BINARY_SAMPLE_BYTES).includes(NUL_CHAR)) { + throw new FsError(`cannot read "${target.displayPath}": binary file`, 'FS_NOT_TEXT') + } + } + 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 buildResult(acc, request, version, target.displayPath) + } + appendToLineBuffer(chunk.slice(startPos)) + } + } 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 + } + + if (lineBuffer.length > 0) flushLine() + return buildResult(acc, request, version, target.displayPath) +} + +/** Format the line-numbered body + pagination footer for a read page. */ +export function formatReadBody(result: ReadPageResult, offset: number): string { + const endLine = result.lines.at(-1)?.number ?? Math.max(0, offset - 1) + let footer: string + if (result.truncatedByBytes) { + footer = `(Output capped at ${READ_MAX_BYTES_LABEL}. Showing lines ${offset}-${endLine}. Use offset=${endLine + 1} to continue.)` + } else if (endLine < result.totalLines) { + footer = `(Showing lines ${offset}-${endLine} of ${result.totalLines}. Use offset=${endLine + 1} to continue.)` + } else { + footer = `(End of file - total ${result.totalLines} lines)` + } + return result.lines.length > 0 + ? `${result.lines.map(line => `${line.number}: ${line.text}`).join('\n')}\n\n${footer}` + : footer +} + +// --- Writing --- + +async function removeStagingDirOrThrow(stagingDir: string, originalError: unknown): Promise { + 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 { + 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> | 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 readFile(absolutePath, signal ? { signal } : {}) + throwIfAborted(signal, 'edit') + if (buffer.includes(0)) throw new FsError(`cannot edit "${displayPath}": binary file`, 'FS_NOT_TEXT') + const raw = buffer.toString('utf8') + 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 } diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts new file mode 100644 index 0000000000..0184a8a323 --- /dev/null +++ b/packages/fs/fs-local/src/index.ts @@ -0,0 +1,197 @@ +/** + * Local-filesystem implementation of the `ctx.fs` seam. {@link LocalFileSystem} + * subclasses {@link FileSystem} and backs the four 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 } from '@deepseek-ai/dsh-fs' +import type { + FsEditOutcome, + FsEditRequest, + FsExpectation, + FsReadOutcome, + FsReadRequest, + FsTarget, + FsVersion, + FsWriteOutcome, +} from '@deepseek-ai/dsh-fs' +import { + applyLiteralEdit, + probe, + readForEdit, + readTextPage, + resolveLocalTarget, + restoreLineEndings, + writeFileAtomic, +} from './fsio.ts' +import type { FsIoInternals } from './fsio.ts' + +export { + FAST_PATH_MAX_SIZE, + READ_LIMIT, + READ_MAX_BYTES, + READ_MAX_LINE_LENGTH, + applyLiteralEdit, + formatReadBody, + probe, + readForEdit, + readTextPage, + resolveLocalTarget, + restoreLineEndings, + writeFileAtomic, +} from './fsio.ts' +export type { FsIoInternals, LineEndings, LocalTarget, PathInfo, ReadPageResult } 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 + +/** + * 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 = 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>() + + 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(targetKey: string, op: () => Promise): Promise { + 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): Promise { + const local = await resolveLocalTarget(this.config.cwd, path) + return { inputPath: path, targetKey: local.targetKey, displayPath: local.displayPath } + } + + override async readPage(target: FsTarget, request: FsReadRequest, signal?: AbortSignal): Promise { + const result = await readTextPage( + { displayPath: target.displayPath, targetKey: target.targetKey }, + request, + signal, + this.internals, + ) + return { + offset: request.offset, + limit: request.limit, + lines: result.lines, + totalLines: result.totalLines, + version: result.version, + view: result.view, + ...result.truncatedByBytes ? { truncatedByBytes: true } : {}, + } + } + + override async createOrReplace( + target: FsTarget, + content: string, + expected: FsExpectation, + signal?: AbortSignal, + ): Promise { + return this.withLock(target.targetKey, async () => { + const existing = await probe(target.targetKey) + if (existing && !existing.isFile) { + throw new FsError(`cannot write "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') + } + + if (expected.kind === 'observed') { + // Stale guard: the file must still be 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 === 'partial') { + if (!existing) throw new FsError(`cannot write "${target.displayPath}": file no longer exists`, 'FS_STALE_VERSION') + throw new FsError(`cannot overwrite existing "${target.displayPath}" after only a partial read`, 'FS_PARTIAL_OBSERVATION') + } else if (existing) { + // Unobserved write 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') + } + + 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 applyEdit( + target: FsTarget, + edit: FsEditRequest, + expected: { version: FsVersion }, + signal?: AbortSignal, + ): Promise { + return this.withLock(target.targetKey, async () => { + const existing = await probe(target.targetKey) + if (!existing) throw new FsError(`cannot edit "${target.displayPath}": not found`, 'FS_NOT_FOUND') + if (!existing.isFile) throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') + if (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: string } | null, target: FsTarget): string { + if (after) return after.version + return `missing:${target.targetKey}` + } +} + +export default LocalFileSystem diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts new file mode 100644 index 0000000000..ae1605892a --- /dev/null +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -0,0 +1,268 @@ +/** + * Tests for the local backend through the `ctx.fs` service: the full + * read→write→edit lifecycle with the read-before-write policy, stale-version + * guards, concurrency races, symlink identity, and HMR/disposal. + */ + +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 type { FsExecContext } from '@deepseek-ai/dsh-fs' + +let dir: string +let ctx: Context +let fs: LocalFileSystem +let fiber: Awaited> + +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 }) +}) + +const READ_ALL = { offset: 1, limit: 2000 } +const exec = (): FsExecContext => ({ agent: { session: {} } }) +function lockCount(localFs: LocalFileSystem): number { + return (localFs as unknown as { locks: Map> }).locks.size +} + +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('read → write → edit lifecycle', () => { + it('creates a new file without a prior read', async () => { + const target = await fs.resolve('new.txt') + const outcome = await fs.write(target, 'fresh', exec()) + expect(outcome.operation).toBe('create') + expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('fresh') + }) + + it('updates an existing file after reading it', async () => { + await writeFile(join(dir, 'a.txt'), 'old') + const owner = exec() + const target = await fs.resolve('a.txt') + await fs.read(target, READ_ALL, owner) + const outcome = await fs.write(target, 'new', owner) + expect(outcome.operation).toBe('update') + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('new') + }) + + it('edits an existing file after reading it', async () => { + await writeFile(join(dir, 'a.txt'), 'hello world') + const owner = exec() + const target = await fs.resolve('a.txt') + await fs.read(target, READ_ALL, owner) + const outcome = await fs.edit(target, { oldString: 'world', newString: 'there', replaceAll: false }, owner) + expect(outcome.replacements).toBe(1) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there') + }) + + it('rejects an empty edit oldString through ctx.fs without hanging or changing the file', async () => { + await writeFile(join(dir, 'a.txt'), 'hello world') + const owner = exec() + const target = await fs.resolve('a.txt') + await fs.read(target, READ_ALL, owner) + + await expect(fs.edit(target, { oldString: '', newString: 'boom', replaceAll: false }, owner)) + .rejects.toMatchObject({ code: 'FS_EDIT_NOT_FOUND' }) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello world') + }) + + it('propagates truncatedByBytes from a byte-capped read', async () => { + await writeFile(join(dir, 'big.txt'), Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n')) + const outcome = await fs.read(await fs.resolve('big.txt'), READ_ALL, exec()) + expect(outcome.truncatedByBytes).toBe(true) + expect(outcome.view).toBe('partial') + }) + + it('allows a follow-up edit without re-reading (write/edit refresh state)', async () => { + await writeFile(join(dir, 'a.txt'), 'a b') + const owner = exec() + const target = await fs.resolve('a.txt') + await fs.read(target, READ_ALL, owner) + await fs.edit(target, { oldString: 'a', newString: 'X', replaceAll: false }, owner) + await fs.edit(target, { oldString: 'b', newString: 'Y', replaceAll: false }, owner) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('X Y') + }) + + it('releases per-target mutation locks after success and failure', async () => { + const target = await fs.resolve('a.txt') + await fs.write(target, 'created', exec()) + expect(lockCount(fs)).toBe(0) + + await expect(fs.write(target, 'blind overwrite', exec())).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + expect(lockCount(fs)).toBe(0) + }) +}) + +describe('read-before-write policy', () => { + it('rejects a blind overwrite of an existing file (no prior read)', async () => { + await writeFile(join(dir, 'a.txt'), 'old') + const target = await fs.resolve('a.txt') + await expect(fs.write(target, 'new', exec())).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) + + it('rejects a write after only a partial read', async () => { + await writeFile(join(dir, 'a.txt'), 'one\ntwo') + const owner = exec() + const target = await fs.resolve('a.txt') + await fs.read(target, { offset: 1, limit: 1 }, owner) + await expect(fs.write(target, 'new', owner)).rejects.toMatchObject({ code: 'FS_PARTIAL_OBSERVATION' }) + }) + + it('rejects a write after a partial read when the file was deleted, without recreating it', async () => { + const path = join(dir, 'a.txt') + await writeFile(path, 'one\ntwo') + const owner = exec() + const target = await fs.resolve('a.txt') + await fs.read(target, { offset: 1, limit: 1 }, owner) + await unlink(path) + + await expect(fs.write(target, 'new', owner)).rejects.toMatchObject({ code: 'FS_STALE_VERSION' }) + await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('rejects an edit with no prior read (FS_NOT_OBSERVED)', async () => { + await writeFile(join(dir, 'a.txt'), 'old') + const target = await fs.resolve('a.txt') + await expect(fs.edit(target, { oldString: 'old', newString: 'new', replaceAll: false }, exec())) + .rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) +}) + +describe('stale-version guard + concurrency (defensive class B)', () => { + it('rejects a write when the file changed since it was read', async () => { + await writeFile(join(dir, 'a.txt'), 'v1') + const owner = exec() + const target = await fs.resolve('a.txt') + await fs.read(target, READ_ALL, owner) + // An out-of-band change after the read. + await writeFile(join(dir, 'a.txt'), 'changed-externally') + await expect(fs.write(target, 'v2', owner)).rejects.toMatchObject({ code: 'FS_STALE_VERSION' }) + }) + + it('rejects an observed write when the file was deleted after the read', async () => { + await writeFile(join(dir, 'a.txt'), 'v1') + const owner = exec() + const target = await fs.resolve('a.txt') + await fs.read(target, READ_ALL, owner) + await unlink(join(dir, 'a.txt')) // file vanishes; observed write must fail (not silently create) + await expect(fs.write(target, 'v2', owner)).rejects.toMatchObject({ code: 'FS_STALE_VERSION' }) + }) + + it('two concurrent edits: one wins, the other is rejected as stale', async () => { + await writeFile(join(dir, 'a.txt'), 'base') + const owner = exec() + const target = await fs.resolve('a.txt') + await fs.read(target, READ_ALL, owner) + // Both edits captured the same recorded version; only one rename can match it. + const results = await Promise.allSettled([ + fs.edit(target, { oldString: 'base', newString: 'one', replaceAll: false }, owner), + fs.edit(target, { oldString: 'base', newString: 'two', replaceAll: false }, owner), + ]) + const fulfilled = results.filter(r => r.status === 'fulfilled') + const rejected = results.filter(r => r.status === 'rejected') + expect(fulfilled).toHaveLength(1) + expect(rejected).toHaveLength(1) + expect((rejected[0] as PromiseRejectedResult).reason).toMatchObject({ code: 'FS_STALE_VERSION' }) + expect(lockCount(fs)).toBe(0) + }) +}) + +describe('symlink targetKey identity (defensive class F)', () => { + it('a read via the real path authorizes an edit via the symlink path', async () => { + await writeFile(join(dir, 'real.txt'), 'hello') + await symlink(join(dir, 'real.txt'), join(dir, 'link.txt')) + const owner = exec() + await fs.read(await fs.resolve('real.txt'), READ_ALL, owner) + // Edit through the link: same realpath → same targetKey → prior read counts. + const linkTarget = await fs.resolve('link.txt') + const outcome = await fs.edit(linkTarget, { oldString: 'hello', newString: 'bye', replaceAll: false }, owner) + expect(outcome.replacements).toBe(1) + expect(await readFile(join(dir, 'real.txt'), 'utf8')).toBe('bye') // link preserved, target written + }) + + it('write through a symlink preserves the link and writes the real target', async () => { + await writeFile(join(dir, 'real.txt'), 'hello') + await symlink(join(dir, 'real.txt'), join(dir, 'link.txt')) + const owner = exec() + const linkTarget = await fs.resolve('link.txt') + await fs.read(linkTarget, READ_ALL, owner) + await fs.write(linkTarget, 'replaced', owner) + expect(await readFile(join(dir, 'real.txt'), 'utf8')).toBe('replaced') + }) + + 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 owner = exec() + await fs.read(await fs.resolve('real.txt'), READ_ALL, owner) + await writeFile(join(dir, 'real.txt'), 'changed') // out-of-band via real path + const linkTarget = await fs.resolve('link.txt') + await expect(fs.edit(linkTarget, { oldString: 'hello', newString: 'bye', replaceAll: false }, owner)) + .rejects.toMatchObject({ code: 'FS_STALE_VERSION' }) + }) +}) + +describe('non-regular targets', () => { + it('rejects writing onto a directory', async () => { + const target = await fs.resolve('.') // the cwd dir + await expect(fs.write(target, 'x', exec())).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) + }) + + it('applyEdit rejects a target that vanished after the read', async () => { + await writeFile(join(dir, 'a.txt'), 'hello') + const owner = exec() + const target = await fs.resolve('a.txt') + const version = (await fs.read(target, READ_ALL, owner)).version + await unlink(join(dir, 'a.txt')) + await expect(fs.applyEdit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, { version })) + .rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) + }) + + it('applyEdit rejects a non-regular target', async () => { + const target = await fs.resolve('.') + await expect(fs.applyEdit(target, { oldString: 'a', newString: 'b', replaceAll: false }, { version: 'v' })) + .rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) + }) +}) + +describe('HMR / disposal (defensive class D)', () => { + it('disposing the fiber withdraws ctx.fs', async () => { + const local = new Context() + const fiber = await local.plugin(LocalFileSystem, { cwd: dir }) + expect(local.fs).toBeDefined() + await fiber.dispose() + expect(local.fs).toBeUndefined() + }) + + it('a fresh provider does not inherit recorded file state', async () => { + await writeFile(join(dir, 'a.txt'), 'hello') + const local = new Context() + const owner = exec() + const fiber = await local.plugin(LocalFileSystem, { cwd: dir }) + await (local.fs as LocalFileSystem).read(await local.fs.resolve('a.txt'), READ_ALL, owner) + await fiber.dispose() + + await local.plugin(LocalFileSystem, { cwd: dir }) + const fs2 = local.fs as LocalFileSystem + const target = await fs2.resolve('a.txt') + // Same owner object, but state was released on disposal. + await expect(fs2.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, owner)) + .rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) +}) diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts new file mode 100644 index 0000000000..77b8d8ccba --- /dev/null +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -0,0 +1,364 @@ +/** + * Cordis-free tests for the raw local-filesystem I/O: path resolution, + * fast/streaming reads, pagination/caps, binary rejection, atomic-write temp + * safety, literal edit matching, and line-ending handling. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtemp, readFile, rm, stat, symlink, writeFile, mkdir, readdir } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + applyLiteralEdit, + formatReadBody, + probe, + readForEdit, + readTextPage, + resolveLocalTarget, + restoreLineEndings, + writeFileAtomic, +} from '@deepseek-ai/dsh-fs-local' +import type { LocalTarget } from '@deepseek-ai/dsh-fs-local' + +let dir: string +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'dsh-fsio-')) +}) +afterEach(async () => { + await rm(dir, { recursive: true, force: true }) +}) + +const READ_ALL = { offset: 1, limit: 2000 } +const localTarget = (path: string): LocalTarget => ({ displayPath: path, targetKey: path }) + +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 (await import('node:fs/promises')).realpath(file)) + }) + + it('uses the realpathed parent + basename when the file does not exist (stable across create)', async () => { + const { realpath } = await import('node:fs/promises') + 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('falls back to the absolute path when even the parent dir is absent', async () => { + const target = await resolveLocalTarget(dir, 'no-such-dir/child.txt') + expect(target.targetKey).toBe(join(dir, 'no-such-dir', 'child.txt')) + }) + + it('rejects a blank path', async () => { + await expect(resolveLocalTarget(dir, ' ')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) + }) +}) + +describe('readTextPage', () => { + it('reads a small file with line numbers and full view', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'one\ntwo\nthree') + const result = await readTextPage(localTarget(file), READ_ALL) + expect(result.lines).toEqual([ + { number: 1, text: 'one' }, + { number: 2, text: 'two' }, + { number: 3, text: 'three' }, + ]) + expect(result.totalLines).toBe(3) + expect(result.view).toBe('full') + }) + + it('paginates with offset/limit and reports a partial view', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'one\ntwo\nthree\nfour') + const result = await readTextPage(localTarget(file), { offset: 2, limit: 2 }) + expect(result.lines.map(l => l.number)).toEqual([2, 3]) + expect(result.view).toBe('partial') + expect(formatReadBody(result, 2)).toContain('(Showing lines 2-3 of 4. Use offset=4 to continue.)') + }) + + it('a whole-file read from offset 1 is a full view; offset>1 is partial', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'one\ntwo') + expect((await readTextPage(localTarget(file), { offset: 1, limit: 10 })).view).toBe('full') + expect((await readTextPage(localTarget(file), { offset: 2, limit: 10 })).view).toBe('partial') + }) + + it('truncates an over-long line', async () => { + const file = join(dir, 'long.txt') + await writeFile(file, 'x'.repeat(3000)) + const result = await readTextPage(localTarget(file), READ_ALL) + expect(result.lines[0]?.text).toContain('... (line truncated to 2000 chars)') + }) + + it('caps output bytes and reports truncatedByBytes', async () => { + const file = join(dir, 'big.txt') + const lines = Array.from({ length: 2000 }, () => 'y'.repeat(100)) + await writeFile(file, lines.join('\n')) + const result = await readTextPage(localTarget(file), READ_ALL) + expect(result.truncatedByBytes).toBe(true) + expect(formatReadBody(result, 1)).toContain('Output capped at 50 KB') + }) + + it('strips CRLF so a Windows file reads like LF', async () => { + const file = join(dir, 'crlf.txt') + await writeFile(file, 'one\r\ntwo\r\n') + const result = await readTextPage(localTarget(file), READ_ALL) + expect(result.lines.map(l => l.text)).toEqual(['one', 'two']) + }) + + it('reads an empty file at offset 1', async () => { + const file = join(dir, 'empty.txt') + await writeFile(file, '') + const result = await readTextPage(localTarget(file), READ_ALL) + expect(result.lines).toEqual([]) + expect(result.totalLines).toBe(0) + expect(formatReadBody(result, 1)).toBe('(End of file - total 0 lines)') + }) + + it('rejects an offset past EOF', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'one\ntwo') + await expect(readTextPage(localTarget(file), { offset: 9, limit: 1 })).rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) + }) + + it('rejects a binary file (fast path)', async () => { + const file = join(dir, 'bin') + await writeFile(file, Buffer.from([0x68, 0x00, 0x69])) + await expect(readTextPage(localTarget(file), READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) + }) + + it('rejects a missing file and a directory', async () => { + await expect(readTextPage(localTarget(join(dir, 'nope')), READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) + await expect(readTextPage(localTarget(dir), READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) + }) + + it('honors a pre-aborted signal', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'one') + await expect(readTextPage(localTarget(file), READ_ALL, AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' }) + }) + + it('passes a live (non-aborted) signal through the fast path', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'one\ntwo') + const result = await readTextPage(localTarget(file), READ_ALL, new AbortController().signal) + expect(result.totalLines).toBe(2) + }) + + describe('streaming path (forced via a tiny fastPathMaxSize)', () => { + const stream = { fastPathMaxSize: 1 } + + it('reads and paginates large files the same way', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'one\ntwo\nthree') + const result = await readTextPage(localTarget(file), { offset: 2, limit: 1 }, undefined, stream) + expect(result.lines).toEqual([{ number: 2, text: 'two' }]) + expect(result.totalLines).toBe(3) + }) + + it('rejects a binary file on the streaming path', async () => { + const file = join(dir, 'bin') + await writeFile(file, Buffer.from([0x68, 0x00, 0x69])) + await expect(readTextPage(localTarget(file), READ_ALL, undefined, stream)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) + }) + + it('caps a newline-free giant line without unbounded buffering', async () => { + const file = join(dir, 'one-line.txt') + await writeFile(file, 'z'.repeat(5000)) + const result = await readTextPage(localTarget(file), READ_ALL, undefined, stream) + expect(result.lines[0]?.text).toContain('... (line truncated to 2000 chars)') + }) + + it('honors abort on the streaming path', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'one\ntwo') + await expect(readTextPage(localTarget(file), READ_ALL, AbortSignal.abort(), stream)).rejects.toMatchObject({ code: 'FS_ABORTED' }) + }) + + it('caps output bytes mid-stream', async () => { + const file = join(dir, 'big.txt') + await writeFile(file, Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n')) + const result = await readTextPage(localTarget(file), READ_ALL, undefined, stream) + expect(result.truncatedByBytes).toBe(true) + }) + + it('flushes a final line with no trailing newline', async () => { + const file = join(dir, 'no-nl.txt') + await writeFile(file, 'one\ntwo') // no trailing \n + const result = await readTextPage(localTarget(file), READ_ALL, undefined, stream) + expect(result.lines.map(l => l.text)).toEqual(['one', 'two']) + }) + + it('handles a trailing newline (no dangling buffer at EOF)', async () => { + const file = join(dir, 'nl.txt') + await writeFile(file, 'one\ntwo\n') // trailing \n → empty buffer at end + const result = await readTextPage(localTarget(file), READ_ALL, undefined, stream) + expect(result.lines.map(l => l.text)).toEqual(['one', 'two']) + expect(result.totalLines).toBe(2) + }) + + it('passes a live (non-aborted) signal through to the stream', async () => { + const file = join(dir, 'a.txt') + await writeFile(file, 'one\ntwo') + const result = await readTextPage(localTarget(file), READ_ALL, new AbortController().signal, stream) + expect(result.totalLines).toBe(2) + }) + + it('scans across multiple stream chunks', async () => { + // A file well past the default 64 KB stream highWaterMark yields multiple chunks, + // exercising the non-first-chunk branch and the line-buffer cap across appends. + const file = join(dir, 'multi.txt') + const lines = Array.from({ length: 50 }, (_, i) => `line ${i}: ${'x'.repeat(3000)}`) + await writeFile(file, lines.join('\n')) + const result = await readTextPage(localTarget(file), { offset: 1, limit: 3 }, undefined, stream) + expect(result.lines[0]?.text.startsWith('line 0:')).toBe(true) + expect(result.lines[0]?.text).toContain('... (line truncated to 2000 chars)') + expect(result.totalLines).toBeGreaterThanOrEqual(3) + }) + }) +}) + +describe('writeFileAtomic — temp-file safety (defensive class A)', () => { + 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') + const info = await stat(file) + expect(info.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' }) + // The pre-existing staging dir is intact and the target was not created. + 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) // rename(temp, sub) fails because sub is a non-empty/dir target + await expect(writeFileAtomic(sub, 'hi', undefined, undefined)).rejects.toBeInstanceOf(Error) + // No leftover staging dirs in the directory. + 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', async () => { + const file = join(dir, 'bin') + await writeFile(file, Buffer.from([0x00, 0x01])) + await expect(readForEdit(file, file)).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') + }) +}) + +describe('probe', () => { + it('returns null for a missing path and info 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?.isFile).toBe(true) + expect(typeof info?.version).toBe('string') + }) + + it('marks a directory as not a regular file', async () => { + const sub = join(dir, 'sub') + await mkdir(sub) + expect((await probe(sub))?.isFile).toBe(false) + }) +}) diff --git a/packages/fs/fs-local/tsconfig.json b/packages/fs/fs-local/tsconfig.json new file mode 100644 index 0000000000..895a46ef55 --- /dev/null +++ b/packages/fs/fs-local/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, + { "path": "../../llm/llm" }, + { "path": "../fs" } + ] +} diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md new file mode 100644 index 0000000000..856ec95076 --- /dev/null +++ b/packages/fs/fs/README.md @@ -0,0 +1,38 @@ +# @deepseek-ai/dsh-fs + +The **filesystem seam**: an abstract `FileSystem` service (`ctx.fs`) defining WHAT a filesystem backend does — resolve paths, read bounded text pages, create/replace files, apply literal edits — without saying HOW. + +This package is one third of the filesystem capability, 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) and [the filesystem capability-seam RFC](../../../docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md)): + +| Package | Role | +|---|---| +| `@deepseek-ai/dsh-fs` (this) | the interface: abstract service + vocabulary types + read-before-write/edit policy | +| `@deepseek-ai/dsh-fs-local` | an implementation: the host filesystem | +| `@deepseek-ai/dsh-tool-fs` | the model-facing `read`/`write`/`edit` tool schemas over `ctx.fs` | + +A future sandboxed, virtual, or remote backend implements this interface and the tool schemas don't change. + +## Service API (`ctx.fs`) + +Consumers call the concrete public API; backends implement the four primitives. + +| Member | Kind | Semantics | +|---|---|---| +| `resolve(path)` | primitive | Resolve a path into a stable `FsTarget` (`inputPath`, opaque `targetKey`, `displayPath`). Async — a remote backend may need I/O. The same file via different paths must yield the same `targetKey`. | +| `readPage(target, request, signal?)` | primitive | Read a bounded UTF-8 text page. Returns line-numbered content, `totalLines`, an opaque `version`, and a `view` (`full` only when the page covered the whole file). | +| `createOrReplace(target, content, expected, signal?)` | primitive | Create/replace a file honoring the `FsExpectation` stale guard. | +| `applyEdit(target, edit, expected, signal?)` | primitive | Atomic literal read-modify-write, verifying the expected version. `oldString` must be non-empty. | +| `read(target, request, exec?, signal?)` | public | Calls `readPage`, then records observed state for the derived owner. | +| `write(target, content, exec?, signal?)` | public | Builds the `FsExpectation` from recorded state, calls `createOrReplace`, refreshes state to `full`. Updating an existing file needs a prior `full` read; a create does not. | +| `edit(target, edit, exec?, signal?)` | public | Requires a prior `full` read by this owner (else `FS_NOT_OBSERVED` / `FS_PARTIAL_OBSERVATION`), rejects empty `oldString`, calls `applyEdit`, refreshes state. | +| `owner(exec?)` | helper | Derives the file-state owner (`exec.agent.session`) — `undefined` when there is none. | + +## Read-before-write/edit lives in the seam + +Write/edit safety depends on backend-defined target identity and version tokens, so `ctx.fs` — not the tool layer — records what each owner has observed (keyed by an opaque owner object, normally the agent session, then by `targetKey`) and enforces the policy. The base class owns owner derivation, the file-state store, and *which* `FsExpectation` to hand the backend; the backend owns version comparison and I/O. Only a `full` view authorizes write/edit; a `partial` view (paged/truncated read) records context but does not. + +State is held in a `WeakMap` keyed by the owner object and dropped on disposal (HMR safety). Persistence across sessions is deferred — a resumed session must read files again before write/edit. + +## Vocabulary + +`FsTarget` / `FsVersion` are opaque — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. 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_PARTIAL_OBSERVATION`, `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. diff --git a/packages/fs/fs/package.json b/packages/fs/fs/package.json new file mode 100644 index 0000000000..a0bce4940a --- /dev/null +++ b/packages/fs/fs/package.json @@ -0,0 +1,30 @@ +{ + "name": "@deepseek-ai/dsh-fs", + "description": "Abstract filesystem capability seam (ctx.fs) for the DeepSeek Harness — vocabulary types, the FileSystem service, and the read-before-write/edit file-state contract", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-llm": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-llm": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts new file mode 100644 index 0000000000..b23d7dd90a --- /dev/null +++ b/packages/fs/fs/src/index.ts @@ -0,0 +1,256 @@ +/** + * The filesystem seam (`ctx.fs`): an abstract service defining WHAT a + * filesystem backend does — resolve paths into stable targets, read bounded + * text pages, create/replace files, and apply literal edits — 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 tool schemas that consume them + * (`@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 packages. + * + * ## Read-before-write/edit lives here, not in the tools + * + * Write/edit safety depends on backend-defined target identity and version + * tokens, so the seam — not the consumer — records what each owner has observed + * and enforces the policy. The base class owns owner derivation, the file-state + * store, and the decision of *which* {@link FsExpectation} to hand a backend; + * the backend owns version comparison and the actual I/O. A consumer passes its + * execution context through {@link read}/{@link write}/{@link edit} and never + * touches the cache, owner key, or version tokens. + * + * @module @deepseek-ai/dsh-fs + */ + +import { Context, Service } from 'cordis' +import { FsError } from './types.ts' +import type { + FsEditOutcome, + FsEditRequest, + FsExecContext, + FsExpectation, + FsReadOutcome, + FsReadRequest, + FsTarget, + FsVersion, + FsWriteOutcome, + FileState, +} from './types.ts' + +export { + FsError, +} from './types.ts' +export type { + FsEditOutcome, + FsEditRequest, + FsErrorCode, + FsExecContext, + FsExpectation, + FsReadOutcome, + FsReadRequest, + FsStateSource, + FsTarget, + FsTextLine, + FsVersion, + FsView, + FsWriteOutcome, + FileState, +} from './types.ts' + +declare module 'cordis' { + interface Context { + fs: FileSystem + } +} + +/** + * Abstract filesystem service. Subclass, implement the four backend primitives + * ({@link resolve}, {@link readPage}, {@link createOrReplace}, + * {@link applyEdit}), 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). + * + * Consumers call the concrete public API ({@link read}/{@link write}/ + * {@link edit}), which derives the file-state owner, enforces the + * read-before-write/edit policy, and refreshes recorded state — then delegates + * the actual I/O to the backend primitives. + * + * 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 file-state lookup agree across paths (e.g. through symlinks). + * - {@link readPage} returns line-numbered UTF-8 content with a `version` and a + * `view` (`full` only when the page covered the whole file). + * - {@link createOrReplace} honors the {@link FsExpectation}: `observed` + * rejects with `FS_STALE_VERSION` if the file changed since `version`; + * `partial` rejects existing targets because the owner saw only a + * non-editable view; `unobserved` creates iff the target is absent and + * otherwise rejects. + * - {@link applyEdit} verifies the expected version (stale guard) and is atomic + * (read-modify-write must not interleave with a concurrent edit). + */ +export abstract class FileSystem extends Service { + /** + * Observed-file state, keyed first by the owner object (weakly held, so a + * collected session frees its state), then by {@link FsTarget.targetKey}. + */ + private fileStates = new WeakMap>() + + constructor(ctx: Context) { + super(ctx, 'fs') + ctx.effect(() => () => { + // Drop all recorded state on disposal so a reloaded backend starts clean + // (HMR safety). The WeakMap itself would be GC'd, but replacing it makes + // the release observable and immediate for tests. + this.fileStates = new WeakMap() + }, 'fs file-state teardown') + } + + // --- Backend primitives (subclass implements; all backend I/O lives here) --- + + /** + * 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. + */ + abstract resolve(path: string): Promise + + /** Read a bounded UTF-8 text page from a target. */ + abstract readPage(target: FsTarget, request: FsReadRequest, signal?: AbortSignal): Promise + + /** + * Create or fully replace a UTF-8 text file, honoring `expected` as the + * stale guard / create-vs-update decision. + */ + abstract createOrReplace(target: FsTarget, content: string, expected: FsExpectation, signal?: AbortSignal): Promise + + /** + * Apply a literal edit to an existing UTF-8 text file, verifying + * `expected.version` as the stale guard. Atomic read-modify-write. + */ + abstract applyEdit(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, signal?: AbortSignal): Promise + + // --- Owner + file-state machinery (shared by all backends) --- + + /** + * Derive the file-state owner from an execution context — normally the active + * agent session. Returns `undefined` when no owner can be derived (e.g. a + * direct tool call with no agent); such calls read freely but cannot satisfy + * the write/edit prior-observation policy. + */ + owner(exec?: FsExecContext): object | undefined { + return exec?.agent?.session + } + + /** Look up recorded state for an owner+target, if any. */ + protected getState(owner: object, targetKey: string): FileState | undefined { + return this.fileStates.get(owner)?.get(targetKey) + } + + /** Record (or replace) one owner's observed state for a target. */ + protected recordState(owner: object, state: FileState): void { + let byTarget = this.fileStates.get(owner) + if (!byTarget) { + byTarget = new Map() + this.fileStates.set(owner, byTarget) + } + byTarget.set(state.targetKey, state) + } + + // --- Concrete public API (orchestration; consumers call these) --- + + /** + * Read a bounded text page and, when an owner is derivable, record the + * observed state (a `full` view authorizes later write/edit; a `partial` view + * does not). + */ + async read(target: FsTarget, request: FsReadRequest, exec?: FsExecContext, signal?: AbortSignal): Promise { + const outcome = await this.readPage(target, request, signal) + const owner = this.owner(exec) + if (owner) { + this.recordState(owner, { + targetKey: target.targetKey, + displayPath: target.displayPath, + version: outcome.version, + view: outcome.view, + updatedAt: this.now(), + source: 'read', + }) + } + return outcome + } + + /** + * Create or fully replace a file. Updating an existing file requires a `full` + * prior observation by this owner; a create (no prior state, target absent) + * does not. After a successful write the recorded state refreshes to `full` + * at the new version so a follow-up modification needs no re-read. + */ + async write(target: FsTarget, content: string, exec?: FsExecContext, signal?: AbortSignal): Promise { + const owner = this.owner(exec) + const prior = owner ? this.getState(owner, target.targetKey) : undefined + const expected: FsExpectation = prior + ? prior.view === 'full' + ? { kind: 'observed', version: prior.version } + : { kind: 'partial', version: prior.version } + : { kind: 'unobserved' } + + const outcome = await this.createOrReplace(target, content, expected, signal) + if (owner) { + this.recordState(owner, { + targetKey: target.targetKey, + displayPath: target.displayPath, + version: outcome.version, + view: 'full', + updatedAt: this.now(), + source: 'write', + }) + } + return outcome + } + + /** + * Apply a literal edit. Always requires a `full` prior observation by this + * owner. No owner or absent state rejects with `FS_NOT_OBSERVED`; a partial + * view rejects with `FS_PARTIAL_OBSERVATION`; an empty `oldString` rejects + * before backend I/O. There is no "create via edit". Refreshes recorded + * state to `full` at the new version on success. + */ + async edit(target: FsTarget, edit: FsEditRequest, exec?: FsExecContext, signal?: AbortSignal): Promise { + if (edit.oldString.length === 0) { + throw new FsError('old_string must be a non-empty string', 'FS_EDIT_NOT_FOUND') + } + const owner = this.owner(exec) + const prior = owner ? this.getState(owner, target.targetKey) : undefined + if (!owner || !prior) { + throw new FsError(`edit requires reading "${target.displayPath}" first`, 'FS_NOT_OBSERVED') + } + if (prior.view !== 'full') { + throw new FsError(`edit requires a full read of "${target.displayPath}" first`, 'FS_PARTIAL_OBSERVATION') + } + + const outcome = await this.applyEdit(target, edit, { version: prior.version }, signal) + this.recordState(owner, { + targetKey: target.targetKey, + displayPath: target.displayPath, + version: outcome.version, + view: 'full', + updatedAt: this.now(), + source: 'edit', + }) + return outcome + } + + /** + * Wall-clock now (ms). A protected seam so tests can use deterministic + * timestamps; production uses `Date.now()`. + */ + protected now(): number { + return Date.now() + } +} + +export default FileSystem diff --git a/packages/fs/fs/src/types.ts b/packages/fs/fs/src/types.ts new file mode 100644 index 0000000000..f08723731e --- /dev/null +++ b/packages/fs/fs/src/types.ts @@ -0,0 +1,194 @@ +/** + * Vocabulary for the filesystem capability seam (`ctx.fs`): the request/outcome + * shapes backends produce and consumers format, the opaque target/version + * identities, the per-owner file-state record, 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 model-facing consumer + * (`@deepseek-ai/dsh-tool-fs`). They deliberately avoid host-path assumptions: + * `targetKey` and `version` are opaque tokens, and `displayPath` is the only + * field a consumer may show. + * + * @module @deepseek-ai/dsh-fs/types + */ + +import { HarnessError } from '@deepseek-ai/dsh-llm' + +/** + * Minimal structural view of a tool execution the filesystem seam needs to + * derive a file-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution` + * satisfies this shape, so the consumer passes its `exec` straight through + * without `dsh-fs` 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); `dsh-fs` never reads any of its fields. + */ +export interface FsExecContext { + /** 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 + } +} + +/** + * 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 file-state 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. + */ + targetKey: string + /** + * Path for model/UI-facing output. May be a local absolute path, + * workspace-relative path, or remote URI depending on the backend. + */ + displayPath: string +} + +/** + * Opaque file-version token. The local backend derives it from mtime+size; a + * remote backend might use a revision id. `ctx.fs` records it for stale checks; + * consumers may display related metadata but MUST NOT interpret this token. + */ +export type FsVersion = string + +/** Resolved read window. The consumer applies its defaults/caps before calling. */ +export interface FsReadRequest { + /** 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 FsTextLine { + /** 1-based line number in the file. */ + number: number + /** Line text without its trailing newline. */ + text: string +} + +/** Whether a recorded/returned view covers the whole file or only part of it. */ +export type FsView = 'full' | 'partial' + +/** Outcome of a bounded text read. */ +export interface FsReadOutcome { + /** 1-based first line requested. */ + offset: number + /** Maximum number of lines requested. */ + limit: number + /** Returned lines, already numbered. */ + lines: FsTextLine[] + /** 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 + /** + * Whether this read saw the whole file (`full`) or only part of it + * (`partial`). Only a `full` view authorizes a later write/edit. + */ + view: FsView +} + +/** + * The read-before-write decision the base service hands to a backend for a + * full-file write. `observed` means the owner has a `full` view recorded at + * `version` (the backend rejects if the file has since changed); `partial` + * means the owner saw only a non-editable view of that target; `unobserved` + * means there is no prior view (the backend may create iff the target is + * absent, else rejects as not observed). + */ +export type FsExpectation = + | { kind: 'observed'; version: FsVersion } + | { kind: 'partial'; version: FsVersion } + | { kind: 'unobserved' } + +/** 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 +} + +/** Source that last touched a recorded {@link FileState}. */ +export type FsStateSource = 'read' | 'write' | 'edit' + +/** + * What an owner has observed about one target. Keyed (inside the service) first + * by the owner object, then by {@link FsTarget.targetKey}. Only a `full` view + * authorizes write/edit. + */ +export interface FileState { + /** Backend target identity this state describes. */ + targetKey: string + /** Display path captured when the state was recorded. */ + displayPath: string + /** Opaque version the owner last saw. */ + version: FsVersion + /** Whether the owner saw the whole file or only part of it. */ + view: FsView + /** Wall-clock time the state was last updated (ms since epoch). */ + updatedAt: number + /** Operation that produced this state. */ + source: FsStateSource +} + +/** + * 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_PARTIAL_OBSERVATION' + | '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 + } +} diff --git a/packages/fs/fs/tests/service.spec.ts b/packages/fs/fs/tests/service.spec.ts new file mode 100644 index 0000000000..84f84cbe0b --- /dev/null +++ b/packages/fs/fs/tests/service.spec.ts @@ -0,0 +1,313 @@ +/** + * Tests for the filesystem service seam itself: registration/disposal, owner + * derivation, and the read-before-write/edit policy the base class enforces + * (which `FsExpectation` it hands the backend, multi-owner isolation, and + * state refresh) — all exercised through a fake in-memory backend that records + * the expectations it received. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { FileSystem, FsError } from '@deepseek-ai/dsh-fs' +import type { + FsEditOutcome, + FsEditRequest, + FsExpectation, + FsReadOutcome, + FsReadRequest, + FsTarget, + FsView, + FsWriteOutcome, +} from '@deepseek-ai/dsh-fs' + +/** A fake backend: an in-memory file table, recording every expectation it is handed. */ +class FakeFileSystem extends FileSystem { + files = new Map() + versions = new Map() + /** View the next `readPage` should report (tests flip this for partial reads). */ + nextReadView: FsView = 'full' + /** Expectations handed to `createOrReplace`, in call order. */ + writeExpectations: FsExpectation[] = [] + /** Versions handed to `applyEdit`, in call order. */ + editExpectedVersions: string[] = [] + + private bump(key: string): string { + const next = (this.versions.get(key) ?? 0) + 1 + this.versions.set(key, next) + return `v${next}` + } + + override async resolve(path: string): Promise { + return { inputPath: path, targetKey: path, displayPath: path } + } + + override async readPage(target: FsTarget, request: FsReadRequest): Promise { + const content = this.files.get(target.targetKey) + if (content === undefined) throw new FsError(`not found: ${target.displayPath}`, 'FS_NOT_FOUND') + const allLines = content.split('\n') + const lines = allLines + .slice(request.offset - 1, request.offset - 1 + request.limit) + .map((text, i) => ({ number: request.offset + i, text })) + return { + offset: request.offset, + limit: request.limit, + lines, + totalLines: allLines.length, + version: `v${this.versions.get(target.targetKey) ?? 0}`, + view: this.nextReadView, + } + } + + override async createOrReplace(target: FsTarget, content: string, expected: FsExpectation): Promise { + this.writeExpectations.push(expected) + const existed = this.files.has(target.targetKey) + this.files.set(target.targetKey, content) + return { operation: existed ? 'update' : 'create', version: this.bump(target.targetKey) } + } + + override async applyEdit(target: FsTarget, edit: FsEditRequest, expected: { version: string }): Promise { + this.editExpectedVersions.push(expected.version) + const content = this.files.get(target.targetKey) ?? '' + this.files.set(target.targetKey, content.split(edit.oldString).join(edit.newString)) + return { replacements: 1, replaceAll: edit.replaceAll, version: this.bump(target.targetKey) } + } +} + +async function setup() { + const ctx = new Context() + await ctx.plugin(FakeFileSystem) + const fs = ctx.fs as FakeFileSystem + return { ctx, fs } +} + +const READ_ALL: FsReadRequest = { offset: 1, limit: 2000 } +const ownerExec = (session: object) => ({ agent: { session } }) + +describe('FileSystem service seam', () => { + it('registers as ctx.fs and serves the API', async () => { + const { fs } = await setup() + fs.files.set('a.txt', 'hi') + const outcome = await fs.read(await fs.resolve('a.txt'), READ_ALL) + expect(outcome.lines).toEqual([{ number: 1, text: 'hi' }]) + }) + + it('throws when a second implementation is loaded (duplicate service)', async () => { + const { ctx } = await setup() + 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() + }) +}) + +describe('owner derivation', () => { + it('derives the owner from exec.agent.session', async () => { + const { fs } = await setup() + const session = {} + expect(fs.owner(ownerExec(session))).toBe(session) + }) + + it('returns undefined with no exec, no agent, or no session', async () => { + const { fs } = await setup() + expect(fs.owner()).toBeUndefined() + expect(fs.owner({})).toBeUndefined() + expect(fs.owner({ agent: {} })).toBeUndefined() + }) +}) + +describe('read records observed state', () => { + it('a full read authorizes a later in-place write (observed expectation)', async () => { + const { fs } = await setup() + const exec = ownerExec({}) + fs.files.set('a.txt', 'hello') + const target = await fs.resolve('a.txt') + + await fs.read(target, READ_ALL, exec) + await fs.write(target, 'goodbye', exec) + + expect(fs.writeExpectations).toEqual([{ kind: 'observed', version: 'v0' }]) + }) + + it('a partial read does NOT authorize a write (passes a partial expectation)', async () => { + const { fs } = await setup() + const exec = ownerExec({}) + fs.files.set('a.txt', 'hello') + fs.nextReadView = 'partial' + const target = await fs.resolve('a.txt') + + await fs.read(target, { offset: 1, limit: 1 }, exec) + await fs.write(target, 'goodbye', exec) + + expect(fs.writeExpectations).toEqual([{ kind: 'partial', version: 'v0' }]) + }) + + it('skips recording when there is no owner', async () => { + const { fs } = await setup() + fs.files.set('a.txt', 'hello') + const target = await fs.resolve('a.txt') + + await fs.read(target, READ_ALL) // no exec + await fs.write(target, 'goodbye') // no exec → cannot be observed + + expect(fs.writeExpectations).toEqual([{ kind: 'unobserved' }]) + }) +}) + +describe('write policy', () => { + it('a create (no prior state) is unobserved', async () => { + const { fs } = await setup() + const exec = ownerExec({}) + const target = await fs.resolve('new.txt') + + const outcome = await fs.write(target, 'fresh', exec) + + expect(outcome.operation).toBe('create') + expect(fs.writeExpectations).toEqual([{ kind: 'unobserved' }]) + }) + + it('refreshes state to full after a write, so a follow-up edit needs no re-read', async () => { + const { fs } = await setup() + const exec = ownerExec({}) + const target = await fs.resolve('a.txt') + + await fs.write(target, 'one', exec) // create → state now full at v1 + await fs.edit(target, { oldString: 'one', newString: 'two', replaceAll: false }, exec) + + expect(fs.editExpectedVersions).toEqual(['v1']) + }) +}) + +describe('edit policy', () => { + it('rejects with FS_NOT_OBSERVED when the file was never read', async () => { + const { fs } = await setup() + const exec = ownerExec({}) + fs.files.set('a.txt', 'hello') + const target = await fs.resolve('a.txt') + + await expect( + fs.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec), + ).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) + + it('rejects with FS_PARTIAL_OBSERVATION when only a partial view was recorded', async () => { + const { fs } = await setup() + const exec = ownerExec({}) + fs.files.set('a.txt', 'hello') + fs.nextReadView = 'partial' + const target = await fs.resolve('a.txt') + await fs.read(target, { offset: 1, limit: 1 }, exec) + + await expect( + fs.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec), + ).rejects.toMatchObject({ code: 'FS_PARTIAL_OBSERVATION' }) + }) + + it('rejects an empty oldString before calling the backend primitive', async () => { + const { fs } = await setup() + const exec = ownerExec({}) + fs.files.set('a.txt', 'hello') + const target = await fs.resolve('a.txt') + await fs.read(target, READ_ALL, exec) + + await expect( + fs.edit(target, { oldString: '', newString: 'bye', replaceAll: false }, exec), + ).rejects.toMatchObject({ code: 'FS_EDIT_NOT_FOUND' }) + expect(fs.editExpectedVersions).toEqual([]) + }) + + it('rejects when there is no owner (cannot prove prior observation)', async () => { + const { fs } = await setup() + fs.files.set('a.txt', 'hello') + const target = await fs.resolve('a.txt') + + await expect( + fs.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }), + ).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) + + it('proceeds after a full read, passing the recorded version as the stale guard', async () => { + const { fs } = await setup() + const exec = ownerExec({}) + fs.files.set('a.txt', 'hello') + fs.versions.set('a.txt', 7) // distinguishable version + const target = await fs.resolve('a.txt') + await fs.read(target, READ_ALL, exec) + + await fs.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec) + + expect(fs.editExpectedVersions).toEqual(['v7']) + }) +}) + +describe('multi-owner isolation', () => { + it('owner A reading does not grant owner B edit authority', async () => { + const { fs } = await setup() + const a = ownerExec({}) + const b = ownerExec({}) + fs.files.set('a.txt', 'hello') + const target = await fs.resolve('a.txt') + + await fs.read(target, READ_ALL, a) + + // B never read it → B's edit must be rejected. + await expect( + fs.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, b), + ).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + // A still may edit. + await expect( + fs.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, a), + ).resolves.toMatchObject({ replacements: 1 }) + }) + + it('each owner records its own observed version independently', async () => { + const { fs } = await setup() + const a = ownerExec({}) + const b = ownerExec({}) + fs.files.set('a.txt', 'hello') + const target = await fs.resolve('a.txt') + + await fs.read(target, READ_ALL, a) // A sees v0 + await fs.write(target, 'mid', b) // B writes unobserved → file now v1 + await fs.write(target, 'late', a) // A still holds its v0 observation + + expect(fs.writeExpectations).toEqual([ + { kind: 'unobserved' }, + { kind: 'observed', version: 'v0' }, + ]) + }) +}) + +describe('disposal releases recorded state', () => { + it('a fresh provider after disposal starts with no inherited state', async () => { + const ctx = new Context() + const fiber = await ctx.plugin(FakeFileSystem) + const fs1 = ctx.fs as FakeFileSystem + const exec = ownerExec({}) + fs1.files.set('a.txt', 'hello') + await fs1.read(await fs1.resolve('a.txt'), READ_ALL, exec) + await fiber.dispose() + + await ctx.plugin(FakeFileSystem) + const fs2 = ctx.fs as FakeFileSystem + fs2.files.set('a.txt', 'hello') + const target = await fs2.resolve('a.txt') + // Reusing the same exec/owner object: state must NOT carry over. + await expect( + fs2.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec), + ).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) +}) + +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) + }) +}) diff --git a/packages/fs/fs/tsconfig.json b/packages/fs/fs/tsconfig.json new file mode 100644 index 0000000000..7b250a29c4 --- /dev/null +++ b/packages/fs/fs/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../llm/llm" } + ] +} diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md new file mode 100644 index 0000000000..2beb45be9c --- /dev/null +++ b/packages/fs/tool-fs/README.md @@ -0,0 +1,33 @@ +# @deepseek-ai/dsh-tool-fs + +The **model-facing filesystem tools** — `read`, `write`, `edit` — over the `ctx.fs` seam ([`@deepseek-ai/dsh-fs`](../fs)). This is the consumer third of the filesystem capability; it owns tool names, JSON schemas, argument validation, prompt sections, and result formatting, and **never** touches filesystem I/O (no `node:fs`/`node:path`, no implementation import). + +```ts ignore-check +// Load a ctx.fs provider first, then the tools. +await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) // @deepseek-ai/dsh-fs-local +await ctx.plugin(ToolFs) // this package — registers read/write/edit +``` + +Each tool also ships as a subpath plugin for focused deployments: + +```ts ignore-check +import * as readPlugin from '@deepseek-ai/dsh-tool-fs/read' +import * as writePlugin from '@deepseek-ai/dsh-tool-fs/write' +import * as editPlugin from '@deepseek-ai/dsh-tool-fs/edit' +``` + +## 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. Overwriting an existing file requires a prior `read` (the backend enforces it); creating a new file does not. | +| `edit` | `file_path`, non-empty `old_string`, `new_string`, `replace_all?` | Literal replacement; unique match required unless `replace_all` is true. Requires a prior `read`. | + +Field names are snake_case to match Claude Code and existing harness tool schemas. + +## How the read-before-write policy is enforced + +The tools do **not** check whether a `read` ran or inspect any cache. Each tool resolves the path via `ctx.fs.resolve()`, then calls `ctx.fs.read/write/edit(target, …, exec)` — passing the current tool execution context straight through. `ctx.fs` derives the file-state owner (normally the agent session) from that context and owns the prior-observation and stale-version policy. Backend errors (`FsError`) flow through `ToolRegistry.execute()` and become `isError` tool results with their `{ name, code }` attached. + +Tool schemas reach the system prompt automatically via the tool registry; this package additionally registers short prose guidance through `ctx.systemPrompt.section(...)`. diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json new file mode 100644 index 0000000000..744a41736d --- /dev/null +++ b/packages/fs/tool-fs/package.json @@ -0,0 +1,51 @@ +{ + "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/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./read": { + "types": "./lib/read.d.ts", + "default": "./lib/read.js" + }, + "./write": { + "types": "./lib/write.d.ts", + "default": "./lib/write.js" + }, + "./edit": { + "types": "./lib/edit.d.ts", + "default": "./lib/edit.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "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-fs": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts new file mode 100644 index 0000000000..3f65f5660d --- /dev/null +++ b/packages/fs/tool-fs/src/edit.ts @@ -0,0 +1,82 @@ +/** + * The model-facing `edit` tool: update an existing UTF-8 text file by replacing + * literal text, requiring a unique match by default. Execution goes through + * `ctx.fs`, which enforces prior observation and the stale-version guard and + * owns the literal-match semantics. + * + * @module @deepseek-ai/dsh-tool-fs/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-system-prompt' + +/** 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 apply(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.', + }) + + 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 { + const input = parseEditArgs(args) + const target = await ctx.fs.resolve(input.filePath) + const outcome = await ctx.fs.edit( + target, + { oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll }, + exec, + exec.signal, + ) + return [{ type: 'text', text: formatEditOutput(target.displayPath, outcome) }] + }, + })) +} + +/** Cordis plugin name used by loader diagnostics. */ +export const name = 'fs-edit' + +/** Services required by the `edit` tool plugin. */ +export const inject = ['tools', 'fs', 'systemPrompt'] + +/** Named helper for direct registration in the root plugin and tests. */ +export const applyEditTool = apply diff --git a/packages/fs/tool-fs/src/index.ts b/packages/fs/tool-fs/src/index.ts new file mode 100644 index 0000000000..437c16b5dd --- /dev/null +++ b/packages/fs/tool-fs/src/index.ts @@ -0,0 +1,35 @@ +/** + * The model-facing filesystem tool suite (`read`, `write`, `edit`) over the + * `ctx.fs` seam. This root plugin registers all three tools by composing the + * per-tool registration helpers; each tool is also exposed as a subpath plugin + * (`@deepseek-ai/dsh-tool-fs/read`, `/write`, `/edit`) for focused deployments. + * + * The package owns model-facing concerns only — tool names, JSON schemas, + * argument validation, prompt sections, result formatting. All filesystem + * execution goes through `ctx.fs`; this package never imports `node:fs`, + * `node:path`, or an `@deepseek-ai/dsh-fs-local` implementation. + * + * @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, applyReadTool, formatReadOutput, parseReadArgs } from './read.ts' +export { applyWriteTool, formatWriteOutput, parseWriteArgs } from './write.ts' +export { applyEditTool, formatEditOutput, parseEditArgs } from './edit.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) +} diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts new file mode 100644 index 0000000000..bfa67a588f --- /dev/null +++ b/packages/fs/tool-fs/src/read.ts @@ -0,0 +1,95 @@ +/** + * The model-facing `read` tool: inspect a UTF-8 text file and return + * line-numbered content with pagination guidance. Execution goes through + * `ctx.fs` — this module owns only the model-facing schema, argument + * validation, and result formatting, never filesystem I/O. + * + * @module @deepseek-ai/dsh-tool-fs/read + */ + +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { FsReadOutcome } from '@deepseek-ai/dsh-fs' +import type {} from '@deepseek-ai/dsh-system-prompt' + +/** Default and maximum number of lines returned by one `read` call. */ +export const READ_LIMIT = 2000 + +/** 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 } +} + +/** Format a read outcome as one OpenCode-style line-numbered text block body. */ +export function formatReadOutput(displayPath: string, outcome: FsReadOutcome): 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 `${displayPath} +file + +${body} +` +} + +/** Register the `read` tool and its system-prompt guidance. */ +export function apply(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 { + const input = parseReadArgs(args) + const target = await ctx.fs.resolve(input.filePath) + const outcome = await ctx.fs.read(target, { offset: input.offset, limit: input.limit }, exec, exec.signal) + return [{ type: 'text', text: formatReadOutput(target.displayPath, outcome) }] + }, + })) +} + +/** Cordis plugin name used by loader diagnostics. */ +export const name = 'fs-read' + +/** Services required by the `read` tool plugin. */ +export const inject = ['tools', 'fs', 'systemPrompt'] + +/** Named helper for direct registration in the root plugin and tests. */ +export const applyReadTool = apply diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts new file mode 100644 index 0000000000..ff66d10127 --- /dev/null +++ b/packages/fs/tool-fs/src/write.ts @@ -0,0 +1,63 @@ +/** + * The model-facing `write` tool: create or fully replace a UTF-8 text file. + * Execution goes through `ctx.fs`, which enforces the read-before-overwrite + * policy (updating an existing file requires a prior read in the same + * execution context; creating a new file does not). + * + * @module @deepseek-ai/dsh-tool-fs/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-system-prompt' + +/** 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 `${displayPath} +file + +${verb} file +` +} + +/** Register the `write` tool and its system-prompt guidance. */ +export function apply(ctx: Context): void { + ctx.systemPrompt.section({ + name: 'tool:write', + order: 101, + text: 'Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the backend requires it) and prefer edit for targeted changes.', + }) + + 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 { + const input = parseWriteArgs(args) + const target = await ctx.fs.resolve(input.filePath) + const outcome = await ctx.fs.write(target, input.content, exec, exec.signal) + return [{ type: 'text', text: formatWriteOutput(target.displayPath, outcome) }] + }, + })) +} + +/** Cordis plugin name used by loader diagnostics. */ +export const name = 'fs-write' + +/** Services required by the `write` tool plugin. */ +export const inject = ['tools', 'fs', 'systemPrompt'] + +/** Named helper for direct registration in the root plugin and tests. */ +export const applyWriteTool = apply diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts new file mode 100644 index 0000000000..6f81763241 --- /dev/null +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -0,0 +1,143 @@ +/** + * Integration tests: the real local backend (`dsh-fs-local`) plus the model + * tools (`dsh-tool-fs`), exercised through `ctx.tools.execute()` so nothing + * bypasses the tool registry. These verify the WORLD — files are read back from + * disk and asserted byte-for-byte — not the tool's self-report. + */ + +import { afterEach, beforeEach, describe, expect, it } 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 ToolFs from '@deepseek-ai/dsh-tool-fs' + +let dir: string +let ctx: Context +let fiber: Awaited> +// A stable session object stands in for an agent session (the file-state owner). +const session = {} + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-')) + ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalFileSystem, { cwd: dir }) + fiber = await ctx.plugin(ToolFs) +}) +afterEach(async () => { + await fiber.dispose() + await rm(dir, { recursive: true, force: true }) +}) + +let callCounter = 0 +function call(name: string, args: unknown) { + return ctx.tools.execute({ + 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('') +} + +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' }) + // The world is unchanged. + 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') + }) +}) + +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' }) + }) +}) + +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('rejects an edit after only a partial read, leaving the file untouched', async () => { + await writeFile(join(dir, 'a.txt'), 'hello\nworld') + await call('read', { file_path: 'a.txt', offset: 1, limit: 1 }) + const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ code: 'FS_PARTIAL_OBSERVATION' }) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello\nworld') + }) + + 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') + }) +}) diff --git a/packages/fs/tool-fs/tests/subpaths.spec.ts b/packages/fs/tool-fs/tests/subpaths.spec.ts new file mode 100644 index 0000000000..ac35babe04 --- /dev/null +++ b/packages/fs/tool-fs/tests/subpaths.spec.ts @@ -0,0 +1,74 @@ +/** + * Tests for the per-tool subpath plugins (`@deepseek-ai/dsh-tool-fs/read`, + * `/write`, `/edit`): each registers exactly one tool, injects the same + * services, and cleans up on disposal. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import { FileSystem } from '@deepseek-ai/dsh-fs' +import type { + FsEditOutcome, + FsReadOutcome, + FsTarget, + FsWriteOutcome, +} from '@deepseek-ai/dsh-fs' +import * as readPlugin from '@deepseek-ai/dsh-tool-fs/read' +import * as writePlugin from '@deepseek-ai/dsh-tool-fs/write' +import * as editPlugin from '@deepseek-ai/dsh-tool-fs/edit' + +class StubFs extends FileSystem { + override async resolve(path: string): Promise { + return { inputPath: path, targetKey: path, displayPath: path } + } + override async readPage(): Promise { + return { offset: 1, limit: 1, lines: [], totalLines: 0, version: 'v', view: 'full' } + } + override async createOrReplace(): Promise { + return { operation: 'create', version: 'v' } + } + override async applyEdit(): Promise { + return { replacements: 1, replaceAll: false, version: 'v' } + } +} + +async function base() { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(StubFs) + return ctx +} + +describe('subpath plugins', () => { + it('each registers exactly its one tool', async () => { + const cases: Array<[unknown, string]> = [ + [readPlugin, 'read'], + [writePlugin, 'write'], + [editPlugin, 'edit'], + ] + for (const [plugin, toolName] of cases) { + const ctx = await base() + await ctx.plugin(plugin as Parameters[0]) + expect(ctx.tools.schemas().map(s => s.name)).toEqual([toolName]) + } + }) + + it('cleans up on disposal (HMR safety)', async () => { + const ctx = await base() + const fiber = await ctx.plugin(readPlugin as Parameters[0]) + expect(ctx.tools.schemas()).toHaveLength(1) + await fiber.dispose() + expect(ctx.tools.schemas()).toHaveLength(0) + }) + + it('stays pending without a ctx.fs provider', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(writePlugin as Parameters[0]) + expect(ctx.tools.schemas()).toHaveLength(0) + }) +}) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts new file mode 100644 index 0000000000..594a07dbbd --- /dev/null +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -0,0 +1,270 @@ +/** + * Consumer-surface tests for the filesystem tools using a fake `ctx.fs` that + * records the execution context it received and returns canned outcomes. These + * verify schemas, argument validation, result formatting, FsError→isError + * propagation, and that each tool passes `exec` straight through to `ctx.fs`. + */ + +import { describe, expect, it } 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 } from '@deepseek-ai/dsh-fs' +import type { + FsEditOutcome, + FsEditRequest, + FsExecContext, + FsReadOutcome, + FsReadRequest, + FsTarget, + FsWriteOutcome, +} from '@deepseek-ai/dsh-fs' +import * as ToolFs from '@deepseek-ai/dsh-tool-fs' +import { formatReadOutput } from '@deepseek-ai/dsh-tool-fs' + +/** + * Records the public-API calls (and the exec each received) and returns canned + * outcomes; lets a test arm a rejection. Overrides the public methods directly + * (not the primitives) so we observe exactly what the tool passed. + */ +class FakeFs extends FileSystem { + calls: Array<{ op: string; exec: FsExecContext | undefined; target: FsTarget }> = [] + rejectWith?: FsError + + override async resolve(path: string): Promise { + return { inputPath: path, targetKey: `key:${path}`, displayPath: `/abs/${path}` } + } + + override async readPage(): Promise { + throw new Error('not used: tool tests override read()') + } + override async createOrReplace(): Promise { + throw new Error('not used') + } + override async applyEdit(): Promise { + throw new Error('not used') + } + + override async read(target: FsTarget, _request: FsReadRequest, exec?: FsExecContext): Promise { + this.calls.push({ op: 'read', exec, target }) + if (this.rejectWith) throw this.rejectWith + return { + offset: 1, + limit: 2000, + lines: [{ number: 1, text: 'hello' }, { number: 2, text: 'world' }], + totalLines: 2, + version: 'v1', + view: 'full', + } + } + + override async write(target: FsTarget, _content: string, exec?: FsExecContext): Promise { + this.calls.push({ op: 'write', exec, target }) + if (this.rejectWith) throw this.rejectWith + return { operation: 'create', version: 'v1' } + } + + override async edit(target: FsTarget, _edit: FsEditRequest, exec?: FsExecContext): Promise { + this.calls.push({ op: 'edit', exec, target }) + if (this.rejectWith) throw this.rejectWith + return { replacements: 1, replaceAll: false, version: 'v1' } + } +} + +async function setup() { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(FakeFs) + 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) + const fiber = await ctx.plugin(ToolFs) + expect(ctx.tools.schemas()).toHaveLength(3) + await fiber.dispose() + expect(ctx.tools.schemas()).toHaveLength(0) + }) +}) + +describe('read tool', () => { + it('formats line-numbered content with a footer', async () => { + const { ctx } = await setup() + const result = await call(ctx, 'read', { file_path: 'a.txt' }) + expect(result.isError).toBe(false) + expect(text(result)).toBe(`/abs/a.txt +file + +1: hello +2: world + +(End of file - total 2 lines) +`) + }) + + 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 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('passes the execution context through to ctx.fs', async () => { + const { ctx, fs } = await setup() + const session = {} + await call(ctx, 'read', { file_path: 'a.txt' }, { session }) + expect(fs.calls).toHaveLength(1) + expect(fs.calls[0]?.op).toBe('read') + expect(fs.calls[0]?.exec?.agent?.session).toBe(session) + }) +}) + +describe('formatReadOutput footer variants', () => { + const base = { offset: 1, limit: 2000, lines: [{ number: 1, text: 'x' }], totalLines: 1, version: 'v', view: 'full' as const } + + 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', async () => { + const { ctx } = await setup() + const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' }) + expect(result.isError).toBe(false) + expect(text(result)).toContain('Created file') + }) + + 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', async () => { + const { ctx } = await setup() + const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }) + expect(text(result)).toBe('The file /abs/a.txt has been updated successfully.') + }) + + 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 from the backend', async () => { + const { ctx, fs } = await setup() + fs.rejectWith = new FsError('read first', 'FS_NOT_OBSERVED') + const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) + + it('propagates FS_PARTIAL_OBSERVATION from the backend', async () => { + const { ctx, fs } = await setup() + fs.rejectWith = new FsError('read fully first', 'FS_PARTIAL_OBSERVATION') + const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ code: 'FS_PARTIAL_OBSERVATION' }) + }) +}) diff --git a/packages/fs/tool-fs/tsconfig.json b/packages/fs/tool-fs/tsconfig.json new file mode 100644 index 0000000000..ee5a853c91 --- /dev/null +++ b/packages/fs/tool-fs/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../llm/llm" }, + { "path": "../../core/tools" }, + { "path": "../../core/system-prompt" }, + { "path": "../fs" } + ] +} diff --git a/packages/fs/tool-fs/tsdown.config.ts b/packages/fs/tool-fs/tsdown.config.ts new file mode 100644 index 0000000000..131735d482 --- /dev/null +++ b/packages/fs/tool-fs/tsdown.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'tsdown' + +/** + * tool-fs exposes one package root plus one entry per tool plugin, so each tool + * can be loaded or replaced independently as a subpath plugin + * (`@deepseek-ai/dsh-tool-fs/read`, `/write`, `/edit`). The root tsdown config + * only auto-discovers `src/index.ts`, so the subpath entries are declared here. + */ +export default defineConfig({ + entry: ['src/index.ts', 'src/read.ts', 'src/write.ts', 'src/edit.ts'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3bca330308..cae3a42202 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -236,6 +236,58 @@ 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-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/tool-fs: + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../fs + '@deepseek-ai/dsh-fs-local': + specifier: workspace:^ + version: link:../fs-local + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@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': diff --git a/tsconfig.base.json b/tsconfig.base.json index 8e2070fe28..ad08d09468 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -34,6 +34,9 @@ "@cordisjs/plugin-timer": ["./vendor/timer/src"], "@cordisjs/plugin-hmr": ["./vendor/hmr/src"], "@cordisjs/plugin-logger-console": ["./vendor/logger-console/src"], + "@deepseek-ai/dsh-tool-fs/read": ["./packages/fs/tool-fs/src/read.ts"], + "@deepseek-ai/dsh-tool-fs/write": ["./packages/fs/tool-fs/src/write.ts"], + "@deepseek-ai/dsh-tool-fs/edit": ["./packages/fs/tool-fs/src/edit.ts"], // One wildcard maps every @deepseek-ai/dsh- to its source. Package // dir names are unique across groups, so first-on-disk-wins resolution is // unambiguous; adding a package under an existing group needs no edit @@ -43,6 +46,7 @@ "./packages/core/*/src", "./packages/llm/*/src", "./packages/bash/*/src", + "./packages/fs/*/src", "./packages/session-persistence/*/src", "./packages/ui/*/src", "./packages/util/*/src", diff --git a/tsconfig.build.json b/tsconfig.build.json index 27a17a3f17..ea3882b873 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -26,6 +26,9 @@ { "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/tool-fs" }, { "path": "./packages/support/invariants" }, { "path": "./packages/ui/acp" }, { "path": "./packages/ui/acp-agent" }, diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json index 54769bdbc0..0522b9649c 100644 --- a/tsconfig.typecheck.json +++ b/tsconfig.typecheck.json @@ -16,10 +16,14 @@ "@cordisjs/plugin-timer": ["./vendor/timer/lib"], "@cordisjs/plugin-hmr": ["./vendor/hmr/lib"], "@cordisjs/plugin-logger-console": ["./vendor/logger-console/lib/shared"], + "@deepseek-ai/dsh-tool-fs/read": ["./packages/fs/tool-fs/src/read.ts"], + "@deepseek-ai/dsh-tool-fs/write": ["./packages/fs/tool-fs/src/write.ts"], + "@deepseek-ai/dsh-tool-fs/edit": ["./packages/fs/tool-fs/src/edit.ts"], "@deepseek-ai/dsh-*": [ "./packages/core/*/src", "./packages/llm/*/src", "./packages/bash/*/src", + "./packages/fs/*/src", "./packages/session-persistence/*/src", "./packages/ui/*/src", "./packages/util/*/src", From ac28e1a1d3952c47942951fb5c6f99889dced282 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 22 Jun 2026 14:53:36 +0800 Subject: [PATCH 02/75] docs: add filesystem data structures catalog --- docs/cordis-catalog/events-and-services.md | 2 + docs/core-data-structures/core.md | 1 + docs/core-data-structures/filesystem.md | 145 +++++++++++++++++++++ scripts/gen-cordis-catalog.ts | 9 ++ scripts/type-equiv.manifest.json | 17 ++- 5 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 docs/core-data-structures/filesystem.md diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index a64e962098..0d0cd7b90b 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -363,6 +363,8 @@ async write(target: FsTarget, content: string, exec?: FsExecContext, signal?: Ab async edit(target: FsTarget, edit: FsEditRequest, exec?: FsExecContext, signal?: AbortSignal): Promise ``` +Types: [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsExecContext](../core-data-structures/filesystem.md) · [FsExpectation](../core-data-structures/filesystem.md) · [FsReadOutcome](../core-data-structures/filesystem.md) · [FsReadRequest](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) + Source: [`packages/fs/fs/src/index.ts:94`](../../packages/fs/fs/src/index.ts) ### `ctx.llm` — `LlmService` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index b50c3483e4..e6aa7f177f 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -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` | > Type definitions on this page are pasted **verbatim** from source and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)). Inline JSDoc is omitted for readability; follow the source link for the full contracts. diff --git a/docs/core-data-structures/filesystem.md b/docs/core-data-structures/filesystem.md new file mode 100644 index 0000000000..fc80891ef6 --- /dev/null +++ b/docs/core-data-structures/filesystem.md @@ -0,0 +1,145 @@ +# Filesystem + +The filesystem execution seam is split across three packages: interface ([dsh-fs](../../packages/fs/fs), `ctx.fs`), implementation ([dsh-fs-local](../../packages/fs/fs-local), local disk), and consumer ([dsh-tool-fs](../../packages/fs/tool-fs), the model-facing `read`/`write`/`edit` tools). 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 tool schemas. + +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) + +## Execution context and target identity + +The filesystem seam needs just enough execution context to derive the observed-file owner. `ToolExecution` satisfies this shape, so `dsh-tool-fs` passes its execution object through without making `dsh-fs` import the tool, agent, or session packages. + +```ts type-equiv +interface FsExecContext { + agent?: { + session?: object + } +} +``` + +Every operation resolves a user-supplied path to an opaque backend target first. Consumers may display `displayPath`, but must not parse `targetKey` or assume it is a local absolute path. + +```ts type-equiv +interface FsTarget { + inputPath: string + targetKey: string + displayPath: string +} +``` + +The backend also owns file-version tokens. `ctx.fs` stores them for stale checks; consumers do not interpret them. + +```ts type-equiv +type FsVersion = string +``` + +## Reads and editable views + +A text read is bounded by line window, byte cap, and backend limits. The returned view records whether the owner saw the whole file or only a partial page; only a `full` view authorizes later write/edit. + +```ts type-equiv +interface FsReadRequest { + offset: number + limit: number +} +``` + +```ts type-equiv +interface FsTextLine { + number: number + text: string +} +``` + +```ts type-equiv +type FsView = 'full' | 'partial' +``` + +```ts type-equiv +interface FsReadOutcome { + offset: number + limit: number + lines: FsTextLine[] + totalLines: number + truncatedByBytes?: true + version: FsVersion + view: FsView +} +``` + +## Write and edit guards + +The base `FileSystem` service converts recorded state into an `FsExpectation` before calling the backend. `observed` carries the stale guard, `partial` means the owner saw a non-editable view, and `unobserved` allows create-if-absent but rejects blind overwrite. + +```ts type-equiv +type FsExpectation = + | { kind: 'observed'; version: FsVersion } + | { kind: 'partial'; version: FsVersion } + | { kind: 'unobserved' } +``` + +```ts type-equiv +interface FsWriteOutcome { + operation: 'create' | 'update' + version: FsVersion +} +``` + +Literal edit is a backend operation, not a `read` plus `write` composed in the tool wrapper. That keeps matching, line-ending handling, stale checks, and atomic replacement inside the filesystem seam. + +```ts type-equiv +interface FsEditRequest { + oldString: string + newString: string + replaceAll: boolean +} +``` + +```ts type-equiv +interface FsEditOutcome { + replacements: number + replaceAll: boolean + version: FsVersion +} +``` + +## Observed-file state + +Observed state is keyed inside the service by owner object and `FsTarget.targetKey`. The owner is normally `exec.agent.session`, but `dsh-fs` treats it as opaque and never reads its fields. A successful read/write/edit refreshes this state for that owner. + +```ts type-equiv +type FsStateSource = 'read' | 'write' | 'edit' +``` + +```ts type-equiv +interface FileState { + targetKey: string + displayPath: string + version: FsVersion + view: FsView + updatedAt: number + source: FsStateSource +} +``` + +## Error taxonomy + +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_PARTIAL_OBSERVATION' + | 'FS_AMBIGUOUS_EDIT' + | 'FS_EDIT_NOT_FOUND' + | 'FS_ABORTED' +``` + +`FS_NOT_OBSERVED` means no usable prior observation exists. `FS_PARTIAL_OBSERVATION` means the owner saw only a partial read. `FS_STALE_VERSION` means there was a prior full observation, but the backend version no longer matches. + +## The service + +`FileSystem` (`ctx.fs`, abstract) owns the shared orchestration: `resolve`, `readPage`, `createOrReplace`, and `applyEdit` are backend primitives; public `read`, `write`, and `edit` derive/record owner state and enforce the read-before-write/edit policy before delegating to the backend. The generated wiring catalog shows the exact service signatures on [events-and-services.md](../cordis-catalog/events-and-services.md#ctxfs--filesystem-abstract-seam). diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 7479f5306c..41f8830335 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -75,6 +75,15 @@ const LINK_MAP: Record = { BashRunResult: 'bash.md', BashTask: 'bash.md', BashTaskRead: 'bash.md', + FsEditOutcome: 'filesystem.md', + FsEditRequest: 'filesystem.md', + FsExecContext: 'filesystem.md', + FsExpectation: 'filesystem.md', + FsReadOutcome: 'filesystem.md', + FsReadRequest: 'filesystem.md', + FsTarget: 'filesystem.md', + FsVersion: 'filesystem.md', + FsWriteOutcome: 'filesystem.md', } /** One harness event, extracted from an `interface Events` block. */ diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index f46c4fca6b..d9df0302ab 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -35,6 +35,21 @@ { "doc": "docs/core-data-structures/bash.md", "symbol": "BashRunResult", "source": "packages/bash/bash/src/types.ts" }, { "doc": "docs/core-data-structures/bash.md", "symbol": "CollectedOutput", "source": "packages/bash/bash/src/types.ts" }, { "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/bash.md", "symbol": "BashTaskRead", "source": "packages/bash/bash/src/types.ts" }, + + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsExecContext", "source": "packages/fs/fs/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": "FsVersion", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsReadRequest", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTextLine", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsView", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsReadOutcome", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsExpectation", "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": "FsStateSource", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FileState", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsErrorCode", "source": "packages/fs/fs/src/types.ts" } ] } From c7a197fb5f19edd71a2e3b59c1aa1d3010cd4f0d Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 22 Jun 2026 18:51:30 +0800 Subject: [PATCH 03/75] fix(fs-local): reject unsafe text observations --- .../2026-06-17-filesystem-capability-seam.md | 11 +- packages/fs/fs-local/README.md | 4 +- packages/fs/fs-local/src/fsio.ts | 107 ++++++++++++------ packages/fs/fs-local/tests/filesystem.spec.ts | 31 ++++- packages/fs/fs-local/tests/fsio.spec.ts | 20 ++++ 5 files changed, 133 insertions(+), 40 deletions(-) diff --git a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md index 55f5efe14b..4faae84d83 100644 --- a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md +++ b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md @@ -121,21 +121,22 @@ The root plugin registers the full suite by composing the per-tool registration ## Migration plan -This RFC starts from `origin/master`, where no filesystem tool package exists yet. The final implementation should add the new three-package topology directly: +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. Wire examples by loading a `ctx.fs` provider first (`dsh-fs-local`), then the consumer (`dsh-tool-fs` or one of its subpath plugins). -5. Update `docs/architecture.md`, `packages/README.md`, package READMEs, build/typecheck config, and aggregate maintenance scripts such as `scripts/publint-all.ts`. +4. Update `docs/architecture.md`, `packages/README.md`, package READMEs, build/typecheck config, and aggregate maintenance scripts such as `scripts/publint-all.ts`. This first pass does not add a separate `@deepseek-ai/dsh-file-context` package. The file-state store lives behind `ctx.fs` so root and subpath `tool-fs` plugins share the same read-before-write/edit policy automatically. +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`, examples, docs, and integration 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. @@ -159,7 +160,7 @@ Beyond the happy/sad paths above, `dsh-fs-local` tests must cover the defensive- Integration tests should load `dsh-fs-local` plus `dsh-tool-fs` and execute `read`, `write`, and `edit` through `ctx.tools.execute()` to prove the three packages work together without bypassing the tool registry. 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, `yarn typecheck`, `yarn test:coverage` for runtime code, and build/publint coverage after adding package entrypoints. +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 diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index 52dfa2e38b..794239ed2d 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -11,8 +11,8 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) ## Behavior -- **`resolve(path)`** — relative paths resolve from `config.cwd` (default `process.cwd()`). 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 keeps its absolute path as the key so creates still get a stable identity. `displayPath` is the absolute (un-resolved) path. -- **`readPage`** — UTF-8 only. A fast path (`readFile`) handles files under `FAST_PATH_MAX_SIZE` (10 MB); larger files stream with a capped line buffer so a newline-free giant file can't exhaust memory. NUL-byte samples are rejected (`FS_NOT_TEXT`). Output is bounded to `READ_LIMIT` (2000) lines, `READ_MAX_BYTES` (50 KB), and `READ_MAX_LINE_LENGTH` (2000) chars per line. The `version` is `mtimeMs:size`. +- **`resolve(path)`** — relative paths resolve from `config.cwd` (default `process.cwd()`). 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. +- **`readPage`** — UTF-8 only. A fast path (`readFile`) handles files under `FAST_PATH_MAX_SIZE` (10 MB); larger files stream with a capped line buffer so a newline-free giant file can't exhaust memory. Invalid UTF-8 and NUL-byte samples are rejected (`FS_NOT_TEXT`). Output is bounded to `READ_LIMIT` (2000) lines, `READ_MAX_BYTES` (50 KB), and `READ_MAX_LINE_LENGTH` (2000) chars per line; hitting any bound records a `partial` view. The `version` is `mtimeMs:size`. - **`createOrReplace`** — 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`. Honors the `FsExpectation`: an `observed` write must match the recorded version (else `FS_STALE_VERSION`); a `partial` write onto an existing file is rejected (`FS_PARTIAL_OBSERVATION`); an `unobserved` write onto an existing file is rejected (`FS_NOT_OBSERVED`). - **`applyEdit`** — atomic literal read-modify-write over the same primitive. Verifies the expected version, 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`). diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index 8c94abee24..a7e124db15 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -6,8 +6,8 @@ * The reader uses two code paths so a single huge line can never balloon * memory: a **fast path** (`readFile` + in-memory split) for files under * {@link FAST_PATH_MAX_SIZE}, and a **streaming path** (manual newline scan - * with a capped line buffer) for larger files. Both reject NUL-byte binary - * samples and keep only the requested page in memory. + * with a capped line buffer) for larger files. Both reject invalid UTF-8 and + * NUL-byte binary samples, and keep only the requested page in memory. * * 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 @@ -23,6 +23,7 @@ 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 } from '@deepseek-ai/dsh-fs' import type { FsReadRequest, FsTextLine, FsView } from '@deepseek-ai/dsh-fs' @@ -41,7 +42,6 @@ export const FAST_PATH_MAX_SIZE = 10 * 1024 * 1024 const READ_MAX_BYTES_LABEL = `${READ_MAX_BYTES / 1024} KB` const READ_MAX_LINE_SUFFIX = `... (line truncated to ${READ_MAX_LINE_LENGTH} chars)` const BINARY_SAMPLE_BYTES = 8192 -const NUL_CHAR = String.fromCharCode(0) const LINE_BUFFER_CAP = READ_MAX_LINE_LENGTH + 1 /** @@ -145,17 +145,18 @@ interface PageAccumulator { totalLines: number outputBytes: number truncatedByBytes: boolean + truncatedByLine: boolean done: boolean } function newAccumulator(): PageAccumulator { - return { lines: [], totalLines: 0, outputBytes: 0, truncatedByBytes: false, done: false } + return { lines: [], totalLines: 0, outputBytes: 0, truncatedByBytes: false, truncatedByLine: false, done: false } } -function truncateReadLine(line: string): string { +function truncateReadLine(line: string): { text: string; truncated: boolean } { return line.length > READ_MAX_LINE_LENGTH - ? `${line.substring(0, READ_MAX_LINE_LENGTH)}${READ_MAX_LINE_SUFFIX}` - : line + ? { text: `${line.substring(0, READ_MAX_LINE_LENGTH)}${READ_MAX_LINE_SUFFIX}`, truncated: true } + : { text: line, truncated: false } } function lineByteSize(line: string, currentLineCount: number): number { @@ -166,7 +167,8 @@ function consumeLine(acc: PageAccumulator, rawLine: string, request: FsReadReque acc.totalLines += 1 if (acc.totalLines < request.offset || acc.lines.length >= request.limit) return - const text = truncateReadLine(rawLine) + const { text, truncated } = truncateReadLine(rawLine) + if (truncated) acc.truncatedByLine = true const bytes = lineByteSize(text, acc.lines.length) if (acc.outputBytes + bytes > READ_MAX_BYTES) { acc.truncatedByBytes = true @@ -195,13 +197,41 @@ function buildResult(acc: PageAccumulator, request: FsReadRequest, version: stri throw new FsError(`offset ${request.offset} is out of range for "${displayPath}" (${acc.totalLines} lines)`, 'FS_NOT_FOUND') } const endLine = acc.lines.at(-1)?.number ?? Math.max(0, request.offset - 1) - const view: FsView = request.offset === 1 && !acc.truncatedByBytes && endLine >= acc.totalLines ? 'full' : 'partial' + const view: FsView = request.offset === 1 && !acc.truncatedByBytes && !acc.truncatedByLine && endLine >= acc.totalLines ? 'full' : 'partial' return { lines: acc.lines, totalLines: acc.totalLines, truncatedByBytes: acc.truncatedByBytes, view, version } } +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) { + if (error instanceof TypeError) throw notTextError(verb, displayPath) + throw error + } +} + +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) { + if (error instanceof TypeError) throw notTextError(verb, displayPath) + throw error + } +} + /** - * Read a bounded UTF-8 text-file page. Rejects non-regular files and NUL-byte - * binary samples; dispatches to the fast or streaming path by file size. + * Read a bounded UTF-8 text-file page. Rejects non-regular files, invalid + * UTF-8, and NUL-byte binary samples; dispatches to the fast or streaming path + * by file size. */ export async function readTextPage( target: LocalTarget, @@ -240,7 +270,7 @@ async function readTextPageFast( throw new FsError(`cannot read "${target.displayPath}": binary file`, 'FS_NOT_TEXT') } - const text = raw.toString('utf8') + const text = decodeUtf8(raw, 'read', target.displayPath) const acc = newAccumulator() let startPos = 0 let newlinePos: number @@ -261,10 +291,11 @@ async function readTextPageStreaming( version: string, signal?: AbortSignal, ): Promise { - const stream = createReadStream(target.targetKey, { encoding: 'utf8', ...signal ? { signal } : {} }) + const stream = createReadStream(target.targetKey, signal ? { signal } : {}) const acc = newAccumulator() let lineBuffer = '' - let firstChunk = true + let sampledBytes = 0 + const decoder = new TextDecoder('utf-8', { fatal: true }) function appendToLineBuffer(segment: string): void { if (lineBuffer.length >= LINE_BUFFER_CAP) return @@ -277,24 +308,36 @@ async function readTextPageStreaming( lineBuffer = '' } - try { - for await (const chunk of stream as AsyncIterable) { - if (firstChunk) { - firstChunk = false - if (chunk.slice(0, BINARY_SAMPLE_BYTES).includes(NUL_CHAR)) { - throw new FsError(`cannot read "${target.displayPath}": binary file`, 'FS_NOT_TEXT') - } - } - 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 buildResult(acc, request, version, target.displayPath) - } - appendToLineBuffer(chunk.slice(startPos)) + 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 + } + + function consumeChunk(chunk: string): ReadPageResult | undefined { + 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 buildResult(acc, request, version, target.displayPath) + } + appendToLineBuffer(chunk.slice(startPos)) + return undefined + } + + try { + for await (const chunk of stream as AsyncIterable) { + scanBinarySample(chunk) + const result = consumeChunk(decodeUtf8Stream(decoder, chunk, 'read', target.displayPath)) + if (result) return result + } + const finalResult = consumeChunk(decodeUtf8Stream(decoder, undefined, 'read', target.displayPath)) + if (finalResult) return finalResult } 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') @@ -435,7 +478,7 @@ export async function readForEdit( const buffer = await readFile(absolutePath, signal ? { signal } : {}) throwIfAborted(signal, 'edit') if (buffer.includes(0)) throw new FsError(`cannot edit "${displayPath}": binary file`, 'FS_NOT_TEXT') - const raw = buffer.toString('utf8') + const raw = decodeUtf8(buffer, 'edit', displayPath) return { content: normalizeLineEndings(raw), lineEndings: detectLineEndings(raw) } } diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts index ae1605892a..eb675472af 100644 --- a/packages/fs/fs-local/tests/filesystem.spec.ts +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -9,7 +9,7 @@ import { mkdtemp, readFile, rm, stat, symlink, writeFile, unlink } from 'node:fs import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from 'cordis' -import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' +import { LocalFileSystem, probe } from '@deepseek-ai/dsh-fs-local' import type { FsExecContext } from '@deepseek-ai/dsh-fs' let dir: string @@ -89,6 +89,19 @@ describe('read → write → edit lifecycle', () => { expect(outcome.view).toBe('partial') }) + it('records an over-long-line read as partial, so write/edit stay blocked', async () => { + await writeFile(join(dir, 'long.txt'), 'x'.repeat(3000)) + const owner = exec() + const target = await fs.resolve('long.txt') + const outcome = await fs.read(target, READ_ALL, owner) + + expect(outcome.view).toBe('partial') + await expect(fs.write(target, 'new', owner)).rejects.toMatchObject({ code: 'FS_PARTIAL_OBSERVATION' }) + await expect( + fs.edit(target, { oldString: 'x', newString: 'y', replaceAll: false }, owner), + ).rejects.toMatchObject({ code: 'FS_PARTIAL_OBSERVATION' }) + }) + it('allows a follow-up edit without re-reading (write/edit refresh state)', async () => { await writeFile(join(dir, 'a.txt'), 'a b') const owner = exec() @@ -142,6 +155,22 @@ describe('read-before-write policy', () => { await expect(fs.edit(target, { oldString: 'old', newString: 'new', replaceAll: false }, exec())) .rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) }) + + it('rejects invalid UTF-8 reads and edits without rewriting the file', async () => { + const path = join(dir, 'invalid-utf8.txt') + const bytes = Buffer.from([0x68, 0xff, 0x69]) + await writeFile(path, bytes) + const owner = exec() + const target = await fs.resolve('invalid-utf8.txt') + + await expect(fs.read(target, READ_ALL, owner)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) + const existing = await probe(target.targetKey) + if (!existing) throw new Error('expected invalid UTF-8 fixture to exist') + await expect( + fs.applyEdit(target, { oldString: 'h', newString: 'H', replaceAll: false }, { version: existing.version }), + ).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) + expect(await readFile(path)).toEqual(bytes) + }) }) describe('stale-version guard + concurrency (defensive class B)', () => { diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index 77b8d8ccba..f25f9e9a0b 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -102,6 +102,7 @@ describe('readTextPage', () => { await writeFile(file, 'x'.repeat(3000)) const result = await readTextPage(localTarget(file), READ_ALL) expect(result.lines[0]?.text).toContain('... (line truncated to 2000 chars)') + expect(result.view).toBe('partial') }) it('caps output bytes and reports truncatedByBytes', async () => { @@ -141,6 +142,12 @@ describe('readTextPage', () => { await expect(readTextPage(localTarget(file), READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) }) + it('rejects invalid UTF-8 bytes (fast path)', async () => { + const file = join(dir, 'invalid-utf8.txt') + await writeFile(file, Buffer.from([0x68, 0xff, 0x69])) + await expect(readTextPage(localTarget(file), READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) + }) + it('rejects a missing file and a directory', async () => { await expect(readTextPage(localTarget(join(dir, 'nope')), READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) await expect(readTextPage(localTarget(dir), READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) @@ -181,6 +188,13 @@ describe('readTextPage', () => { await writeFile(file, 'z'.repeat(5000)) const result = await readTextPage(localTarget(file), READ_ALL, undefined, stream) expect(result.lines[0]?.text).toContain('... (line truncated to 2000 chars)') + expect(result.view).toBe('partial') + }) + + it('rejects invalid UTF-8 bytes on the streaming path', async () => { + const file = join(dir, 'invalid-utf8.txt') + await writeFile(file, Buffer.from([0x68, 0xff, 0x69])) + await expect(readTextPage(localTarget(file), READ_ALL, undefined, stream)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) }) it('honors abort on the streaming path', async () => { @@ -338,6 +352,12 @@ describe('readForEdit + restoreLineEndings', () => { await expect(readForEdit(file, file)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) }) + it('rejects invalid UTF-8 bytes', async () => { + const file = join(dir, 'invalid-utf8.txt') + await writeFile(file, Buffer.from([0x68, 0xff, 0x69])) + await expect(readForEdit(file, file)).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') From ef37ce3b9dea48a63e41706fe82a2ffb12086914 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 26 Jun 2026 17:23:18 +0800 Subject: [PATCH 04/75] refactor(fs): split filesystem seam into provider ctx.fs + policy ctx.fileContext Implements the split-the-filesystem-seam RFC. ctx.fs shrinks to a text-storage provider seam (resolve/stat/readText/streamText/writeText/editText with branded FsTargetKey/FsVersion and an explicit FsWriteExpectation); the new dsh-file-context package owns the model-facing policy (read windowing, observed-state, write/edit freshness) as the concrete ctx.fileContext service. Authorization is now freshness-based rather than full/partial view: a windowed read records the file version and authorizes a later edit when the file is unchanged, removing the dead-end where reading lines 100-150 of a large file could not edit line 120. editText stays a provider primitive so version guard + literal match + atomic rewrite remain one critical section, and the stale check runs before matching so a stale edit reports FS_STALE_VERSION. tool-fs injects fileContext, never reaching around to ctx.fs (the no-bypass contract). --- AGENTS.md | 1 + docs/architecture.md | 8 +- docs/cordis-catalog/events-and-services.md | 47 ++- docs/core-data-structures/filesystem.md | 119 +++--- docs/module-graph.md | 8 +- docs/rfc/README.md | 1 + .../2026-06-26-fsspec-style-fs-seam.md | 120 ++++++ examples/coding-agent/cordis.yml | 2 +- packages/README.md | 8 +- packages/fs/README.md | 7 +- packages/fs/file-context/README.md | 43 +++ packages/fs/file-context/package.json | 31 ++ packages/fs/file-context/src/index.ts | 184 ++++++++++ packages/fs/file-context/src/types.ts | 56 +++ packages/fs/file-context/src/window.ts | 139 +++++++ packages/fs/file-context/tests/policy.spec.ts | 305 +++++++++++++++ packages/fs/file-context/tests/window.spec.ts | 102 ++++++ packages/fs/file-context/tsconfig.json | 14 + packages/fs/fs-local/README.md | 12 +- packages/fs/fs-local/src/fsio.ts | 301 ++++----------- packages/fs/fs-local/src/index.ts | 91 +++-- packages/fs/fs-local/tests/filesystem.spec.ts | 346 ++++++++---------- packages/fs/fs-local/tests/fsio.spec.ts | 310 ++++++---------- packages/fs/fs/README.md | 45 ++- packages/fs/fs/package.json | 2 + packages/fs/fs/src/index.ts | 255 ++++--------- packages/fs/fs/src/types.ts | 157 +++----- packages/fs/fs/tests/service.spec.ts | 312 +++------------- packages/fs/fs/tsconfig.json | 1 + packages/fs/tool-fs/README.md | 19 +- packages/fs/tool-fs/package.json | 2 + packages/fs/tool-fs/src/edit.ts | 10 +- packages/fs/tool-fs/src/index.ts | 15 +- packages/fs/tool-fs/src/read.ts | 15 +- packages/fs/tool-fs/src/write.ts | 12 +- packages/fs/tool-fs/tests/integration.spec.ts | 63 +++- packages/fs/tool-fs/tests/subpaths.spec.ts | 30 +- packages/fs/tool-fs/tests/tools.spec.ts | 134 ++++--- packages/fs/tool-fs/tsconfig.json | 3 +- pnpm-lock.yaml | 18 + scripts/type-equiv.manifest.json | 16 +- tsconfig.build.json | 1 + 42 files changed, 1899 insertions(+), 1466 deletions(-) create mode 100644 docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md create mode 100644 packages/fs/file-context/README.md create mode 100644 packages/fs/file-context/package.json create mode 100644 packages/fs/file-context/src/index.ts create mode 100644 packages/fs/file-context/src/types.ts create mode 100644 packages/fs/file-context/src/window.ts create mode 100644 packages/fs/file-context/tests/policy.spec.ts create mode 100644 packages/fs/file-context/tests/window.spec.ts create mode 100644 packages/fs/file-context/tsconfig.json diff --git a/AGENTS.md b/AGENTS.md index a68e13ccef..04ce3b49fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -199,6 +199,7 @@ Dev/test/demo run **unbuilt** via tsx + the `paths` map in the root `tsconfig.js - **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` 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. diff --git a/docs/architecture.md b/docs/architecture.md index 8f78a0b086..796026bb10 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,6 +25,7 @@ For a catalog of the **data structures** this architecture moves around — the │ @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-file-context (filesystem policy) │ │ @deepseek-ai/dsh-tool-fs (filesystem tool schemas) │ │ @deepseek-ai/dsh-session-persistence-jsonl (persistence impl)│ ├─────────────────────────────────────────────────────────────┤ @@ -35,7 +36,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 (abstract filesystem) │ +│ @deepseek-ai/dsh-fs (filesystem provider seam) │ ├─────────────────────────────────────────────────────────────┤ │ vendor/: cordis, loader, include, group, timer, hmr, │ │ logger-console, cosmokit, schemastery │ @@ -56,7 +57,8 @@ 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 seam: path resolution, text reads, writes, edits, and observed-file policy | +| `ctx.fs` | `FileSystem` (abstract) | dsh-fs | filesystem provider seam: path resolution, stat, text read/stream, guarded writes/edits | +| `ctx.fileContext` | `FileContext` | dsh-file-context | filesystem policy: read windowing, observed-state, write/edit freshness over `ctx.fs` | All registrations (`registerAdapter`, `section`, `tools`, `register`, …) go through `ctx.effect()` and return disposers, so plugin hot-reload (vendored HMR) and fiber disposal clean up automatically. @@ -72,7 +74,7 @@ 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: `dsh-fs` owns the abstract `ctx.fs` service and observed-file policy, `dsh-fs-local` provides the local backend, and `dsh-tool-fs` exposes the model-facing `read`/`write`/`edit` schemas over the interface. +The filesystem capability follows the bash topology with a fourth layer: `dsh-fs` owns the abstract `ctx.fs` provider seam (text IO + guarded mutation primitives), `dsh-fs-local` provides the local backend, `dsh-file-context` is a concrete `ctx.fileContext` policy service (read windowing + observed-state + write/edit freshness, injecting `fs`), and `dsh-tool-fs` exposes the model-facing `read`/`write`/`edit` schemas over `ctx.fileContext`. The policy layer is a concrete service, not a second swappable seam — it owns the model-facing observation policy a sandboxed/remote backend has no business carrying. > **"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. diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 0d0cd7b90b..c4896ec0ad 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -279,7 +279,7 @@ Source: [`packages/core/tools/src/index.ts:43`](../../packages/core/tools/src/in ## Services -The 9 `ctx.` services the harness provides. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against. +The 10 `ctx.` services the harness provides. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against. ### `ctx.agentLoop` — `AgentLoop` @@ -339,33 +339,46 @@ Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../c Source: [`packages/bash/bash/src/index.ts:59`](../../packages/bash/bash/src/index.ts) +### `ctx.fileContext` — `FileContext` + +The file-context policy service. Injects `fs`, registers as `ctx.fileContext`, and is the only read/write/edit path the model-facing tools use. + +```ts cordis-catalog +owner(exec?: FileContextExec): object | undefined +async resolve(path: string): Promise +async read(target: FsTarget, request: FileReadRequest, exec?: FileContextExec, signal?: AbortSignal): Promise +async write(target: FsTarget, content: string, exec?: FileContextExec, signal?: AbortSignal): Promise +async edit(target: FsTarget, edit: FsEditRequest, exec?: FileContextExec, signal?: AbortSignal): Promise +``` + +Types: [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) + +Source: [`packages/fs/file-context/src/index.ts:65`](../../packages/fs/file-context/src/index.ts) + ### `ctx.fs` — `FileSystem` (abstract seam) -Abstract filesystem service. Subclass, implement the four backend primitives (resolve, readPage, createOrReplace, applyEdit), 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). - -Consumers call the concrete public API (read/write/ edit), which derives the file-state owner, enforces the read-before-write/edit policy, and refreshes recorded state — then delegates the actual I/O to the backend primitives. +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 file-state lookup agree across paths (e.g. through symlinks). -- readPage returns line-numbered UTF-8 content with a `version` and a `view` (`full` only when the page covered the whole file). -- createOrReplace honors the FsExpectation: `observed` rejects with `FS_STALE_VERSION` if the file changed since `version`; `partial` rejects existing targets because the owner saw only a non-editable view; `unobserved` creates iff the target is absent and otherwise rejects. -- applyEdit verifies the expected version (stale guard) and is atomic (read-modify-write must not interleave with a concurrent edit). +- 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 honoring the FsWriteExpectation. +- 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. ```ts cordis-catalog abstract resolve(path: string): Promise -abstract readPage(target: FsTarget, request: FsReadRequest, signal?: AbortSignal): Promise -abstract createOrReplace(target: FsTarget, content: string, expected: FsExpectation, signal?: AbortSignal): Promise -abstract applyEdit(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, signal?: AbortSignal): Promise -owner(exec?: FsExecContext): object | undefined -async read(target: FsTarget, request: FsReadRequest, exec?: FsExecContext, signal?: AbortSignal): Promise -async write(target: FsTarget, content: string, exec?: FsExecContext, signal?: AbortSignal): Promise -async edit(target: FsTarget, edit: FsEditRequest, exec?: FsExecContext, signal?: AbortSignal): Promise +abstract stat(target: FsTarget, signal?: AbortSignal): Promise +abstract readText(target: FsTarget, signal?: AbortSignal): Promise +abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> +abstract writeText(target: FsTarget, content: string, expected: FsWriteExpectation, signal?: AbortSignal): Promise +abstract editText(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, signal?: AbortSignal): Promise ``` -Types: [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsExecContext](../core-data-structures/filesystem.md) · [FsExpectation](../core-data-structures/filesystem.md) · [FsReadOutcome](../core-data-structures/filesystem.md) · [FsReadRequest](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) +Types: [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) -Source: [`packages/fs/fs/src/index.ts:94`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts:90`](../../packages/fs/fs/src/index.ts) ### `ctx.llm` — `LlmService` diff --git a/docs/core-data-structures/filesystem.md b/docs/core-data-structures/filesystem.md index fc80891ef6..99d073c6d4 100644 --- a/docs/core-data-structures/filesystem.md +++ b/docs/core-data-structures/filesystem.md @@ -1,80 +1,49 @@ # Filesystem -The filesystem execution seam is split across three packages: interface ([dsh-fs](../../packages/fs/fs), `ctx.fs`), implementation ([dsh-fs-local](../../packages/fs/fs-local), local disk), and consumer ([dsh-tool-fs](../../packages/fs/tool-fs), the model-facing `read`/`write`/`edit` tools). 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 tool schemas. +The filesystem stack is split across four packages: a provider seam ([dsh-fs](../../packages/fs/fs), `ctx.fs`, text IO + guarded mutation), a local implementation ([dsh-fs-local](../../packages/fs/fs-local), local disk), a policy layer ([dsh-file-context](../../packages/fs/file-context), `ctx.fileContext`, read windowing + write/edit freshness), and a consumer ([dsh-tool-fs](../../packages/fs/tool-fs), the model-facing `read`/`write`/`edit` tools). 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 layer or the tool schemas. -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) +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/file-context/src/types.ts`](../../packages/fs/file-context/src/types.ts). -## Execution context and target identity +## Target identity and metadata (provider seam) -The filesystem seam needs just enough execution context to derive the observed-file owner. `ToolExecution` satisfies this shape, so `dsh-tool-fs` passes its execution object through without making `dsh-fs` import the tool, agent, or session packages. - -```ts type-equiv -interface FsExecContext { - agent?: { - session?: object - } -} -``` - -Every operation resolves a user-supplied path to an opaque backend target first. Consumers may display `displayPath`, but must not parse `targetKey` or assume it is a local absolute path. +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: string + targetKey: FsTargetKey displayPath: string } ``` -The backend also owns file-version tokens. `ctx.fs` stores them for stale checks; consumers do not interpret them. +The backend owns file-version tokens — the freshness token a write/edit guards against. The policy layer stores them for stale checks; consumers do not interpret them. Both ids are branded opaque strings. ```ts type-equiv -type FsVersion = string -``` - -## Reads and editable views - -A text read is bounded by line window, byte cap, and backend limits. The returned view records whether the owner saw the whole file or only a partial page; only a `full` view authorizes later write/edit. - -```ts type-equiv -interface FsReadRequest { - offset: number - limit: number -} +type FsTargetKey = Branded<'FsTargetKey'> ``` ```ts type-equiv -interface FsTextLine { - number: number - text: string -} +type FsVersion = Branded<'FsVersion'> ``` -```ts type-equiv -type FsView = 'full' | 'partial' -``` +`stat` returns metadata (never content), or `undefined` when the target is absent. `type` lets the policy layer reject directories/special files before reading, and `size` lets it choose `readText` vs `streamText` without probing by failure. ```ts type-equiv -interface FsReadOutcome { - offset: number - limit: number - lines: FsTextLine[] - totalLines: number - truncatedByBytes?: true +interface FsInfo { version: FsVersion - view: FsView + type: 'file' | 'directory' | 'other' + size?: number } ``` -## Write and edit guards +## Write and edit guards (provider seam) -The base `FileSystem` service converts recorded state into an `FsExpectation` before calling the backend. `observed` carries the stale guard, `partial` means the owner saw a non-editable view, and `unobserved` allows create-if-absent but rejects blind overwrite. +`writeText` takes an explicit write expectation rather than inferring intent. `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`. ```ts type-equiv -type FsExpectation = - | { kind: 'observed'; version: FsVersion } - | { kind: 'partial'; version: FsVersion } - | { kind: 'unobserved' } +type FsWriteExpectation = + | { kind: 'createIfAbsent' } + | { kind: 'replaceIfVersion'; version: FsVersion } ``` ```ts type-equiv @@ -84,7 +53,7 @@ interface FsWriteOutcome { } ``` -Literal edit is a backend operation, not a `read` plus `write` composed in the tool wrapper. That keeps matching, line-ending handling, stale checks, and atomic replacement inside the filesystem seam. +`editText` is a provider-level guarded mutation, not a `read` plus `write` composed in the policy layer. It verifies the expected version BEFORE literal matching (so a stale edit reports `FS_STALE_VERSION`, not a match failure against newer content), then applies the replacement and writes atomically — keeping matching, line-ending handling, stale checks, and atomic replacement inside one mutation critical section. ```ts type-equiv interface FsEditRequest { @@ -102,26 +71,43 @@ interface FsEditOutcome { } ``` -## Observed-file state +## Execution context and read outcome (policy layer) -Observed state is keyed inside the service by owner object and `FsTarget.targetKey`. The owner is normally `exec.agent.session`, but `dsh-fs` treats it as opaque and never reads its fields. A successful read/write/edit refreshes this state for that owner. +The policy layer needs just enough execution context to derive the observed-state owner. `ToolExecution` satisfies this shape, so `dsh-tool-fs` passes its execution object through without making `dsh-file-context` import the tool, agent, or session packages. ```ts type-equiv -type FsStateSource = 'read' | 'write' | 'edit' -``` - -```ts type-equiv -interface FileState { - targetKey: string - displayPath: string - version: FsVersion - view: FsView - updatedAt: number - source: FsStateSource +interface FileContextExec { + agent?: { + session?: object + } } ``` -## Error taxonomy +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. + +```ts type-equiv +interface FileReadRequest { + offset: number + limit: number +} +``` + +```ts type-equiv +interface FileReadOutcome { + offset: number + limit: number + lines: FileTextLine[] + totalLines: number + truncatedByBytes?: true + version: FsVersion +} +``` + +## Observed-file state (policy layer) + +Observed state is a `WeakMap>` inside `ctx.fileContext`. An entry exists **iff** the owner has read that target through `ctx.fileContext.read`, so its presence *is* the read record — there is no separate `hasRead` flag and no view distinction. The owner is normally `exec.agent.session`, but the policy layer treats it as opaque and never reads its fields. 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. @@ -132,14 +118,13 @@ type FsErrorCode = | 'FS_NOT_REGULAR_FILE' | 'FS_STALE_VERSION' | 'FS_NOT_OBSERVED' - | 'FS_PARTIAL_OBSERVATION' | 'FS_AMBIGUOUS_EDIT' | 'FS_EDIT_NOT_FOUND' | 'FS_ABORTED' ``` -`FS_NOT_OBSERVED` means no usable prior observation exists. `FS_PARTIAL_OBSERVATION` means the owner saw only a partial read. `FS_STALE_VERSION` means there was a prior full observation, but the backend version no longer matches. +`FS_NOT_OBSERVED` means no recorded read exists for this owner (or a `createIfAbsent` hit an existing file). `FS_STALE_VERSION` means the backend version no longer matches the observed one. Freshness authorization has no partial/full distinction, so there is no `FS_PARTIAL_OBSERVATION`. -## The service +## The services -`FileSystem` (`ctx.fs`, abstract) owns the shared orchestration: `resolve`, `readPage`, `createOrReplace`, and `applyEdit` are backend primitives; public `read`, `write`, and `edit` derive/record owner state and enforce the read-before-write/edit policy before delegating to the backend. The generated wiring catalog shows the exact service signatures on [events-and-services.md](../cordis-catalog/events-and-services.md#ctxfs--filesystem-abstract-seam). +`FileSystem` (`ctx.fs`, abstract) owns the provider primitives: `resolve`, `stat`, `readText`, `streamText`, `writeText`, and `editText`. `FileContext` (`ctx.fileContext`, concrete) injects `fs` and owns the model-facing policy: `read` windows text and records observed state, `write`/`edit` derive the freshness expectation and refresh state. The generated wiring catalog shows the exact service signatures on [events-and-services.md](../cordis-catalog/events-and-services.md#ctxfs--filesystem-abstract-seam). diff --git a/docs/module-graph.md b/docs/module-graph.md index 5a69585ef8..ad3d7f7b89 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -10,6 +10,7 @@ graph TD bash --> brand llm --> brand bash-local --> bash + fs --> brand fs --> llm llm-deepseek --> llm llm-pi-ai --> llm @@ -19,6 +20,7 @@ graph TD agent --> brand agent --> llm agent --> session + file-context --> fs fs-local --> fs llm-replay --> llm llm-replay --> session @@ -51,6 +53,7 @@ graph TD tool-bash --> bash tool-bash --> llm tool-bash --> tools + tool-fs --> file-context tool-fs --> fs tool-fs --> llm tool-fs --> system-prompt @@ -79,12 +82,13 @@ graph TD | `bash` | `brand` | | `llm` | `brand` | | `bash-local` | `bash` | -| `fs` | `llm` | +| `fs` | `brand`, `llm` | | `llm-deepseek` | `llm` | | `llm-pi-ai` | `llm` | | `session` | `brand`, `llm` | | `system-prompt` | `llm` | | `agent` | `brand`, `llm`, `session` | +| `file-context` | `fs` | | `fs-local` | `fs` | | `llm-replay` | `llm`, `session` | | `session-persistence` | `session` | @@ -96,7 +100,7 @@ graph TD | `acp` | `agent`, `llm`, `session`, `session-persistence`, `tools` | | `agent-loop` | `agent`, `llm`, `session`, `session-persistence`, `system-prompt`, `tools` | | `tool-bash` | `agent`, `bash`, `llm`, `tools` | -| `tool-fs` | `fs`, `llm`, `system-prompt`, `tools` | +| `tool-fs` | `file-context`, `fs`, `llm`, `system-prompt`, `tools` | | `agent-core` | `agent`, `agent-loop`, `invariants`, `llm`, `session`, `system-prompt`, `tool-bash`, `tools` | | `acp-agent` | `acp`, `agent-core`, `session-persistence-jsonl` | | `stdio-agent` | `agent`, `agent-core`, `session`, `session-persistence-jsonl`, `ui-stdio` | diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 7f56757be0..cf9c343206 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -94,6 +94,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 policy `ctx.fileContext`](implemented/simplification/2026-06-26-fsspec-style-fs-seam.md) | 2026-06-26 | ### Architecture diff --git a/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md new file mode 100644 index 0000000000..25ff66816f --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md @@ -0,0 +1,120 @@ +# RFC: Split the filesystem seam — provider text mutations plus policy `ctx.fileContext` + +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-file-context` 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 + text rendering +policy dsh-file-context ctx.fileContext (concrete service): observed-state, read windowing, write/edit freshness +provider seam dsh-fs ctx.fs: text IO + guarded mutation primitives +provider dsh-fs-local local implementation of ctx.fs +``` + +`dsh-tool-fs` keeps the same model-facing `read`/`write`/`edit` schemas. It injects `fileContext`, not `fs`, and never reaches around the policy layer for model reads/writes/edits. + +## Provider Contract + +`@deepseek-ai/dsh-fs` shrinks to provider text IO plus guarded text mutation: + +```ts ignore-check +abstract resolve(path: string): Promise +abstract stat(target: FsTarget, signal?: AbortSignal): Promise +abstract readText(target: FsTarget, signal?: AbortSignal): Promise +abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> +abstract writeText(target: FsTarget, content: string, expected: FsWriteExpectation, signal?: AbortSignal): Promise +abstract editText(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, signal?: AbortSignal): Promise + +interface FsInfo { + version: FsVersion + type: 'file' | 'directory' | 'other' + size?: number +} + +type FsWriteExpectation = + | { kind: 'createIfAbsent' } + | { kind: 'replaceIfVersion'; version: FsVersion } +``` + +`stat` returns metadata, not content. `version` is the freshness token; `type` lets the policy reject directories/special files before reading; `size` lets `ctx.fileContext.read` 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. 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 also preserves backend-local locking and lets a future remote backend implement native compare-and-edit without forcing `ctx.fileContext` to pull the whole file through the policy layer. + +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-file-context` registers concrete service `ctx.fileContext` and injects `fs`. It is a concrete service, not a seam: it owns the read-windowing and write/edit freshness policy that does not belong on the `FileSystem` provider base class (where a sandboxed/remote backend would otherwise inherit model-facing observation policy it has no business carrying). + +Observed state lives here as `WeakMap>`. An entry exists iff the owner has read that target through `ctx.fileContext.read`, so its presence *is* the read record — there is no separate `hasRead` flag. The owner is still derived structurally from `{ agent?: { session? } }`, but that shape no longer belongs to `dsh-fs`. + +`read(target, request, exec?, signal?)` is the only read path used by the model-facing `read` tool. It stats the target, rejects absent/non-regular targets, chooses `readText` or `streamText` from `FsInfo`, builds the requested line window from text chunks, records `{ version: info.version }`, and returns the structured outcome that the tool renders. + +`write(target, content, exec?, signal?)` uses freshness policy: no recorded read calls `writeText({ kind: 'createIfAbsent' })`, so only new files can be created blindly; a recorded read calls `writeText({ kind: 'replaceIfVersion', version: vObserved })`, so existing files are replaced only if they are unchanged since the read. A successful write refreshes recorded state from the returned outcome or a post-write `stat`. + +`edit(target, edit, exec?, signal?)` requires a recorded read at `vObserved`, then calls `ctx.fs.editText(target, edit, { version: vObserved })` and refreshes recorded state from the returned version. `ctx.fileContext` does not implement literal replacement itself; it authorizes the operation and passes the observed version to the provider. The provider owns the mutation critical section, so concurrent edits based on the same observed version remain one-wins/one-stale rather than being merged or re-applied. If a backend needs a defensive whole-file edit cap, it should surface that as the same filesystem error taxonomy, but large model-facing reads should stream instead of failing just because the file is large. + +## 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. + +The tool package only validates model args, calls `ctx.fileContext`, and renders results (`N: text`, footer, `/` envelope). The no-bypass rule is part of the contract: a model-facing `read` must call `ctx.fileContext.read`, never `ctx.fs.readText` or `ctx.fs.streamText`, so every successful read records observed-state before rendering. + +Direct `ctx.fs` calls are still allowed for non-tool consumers. They are explicit escape hatches: a direct `ctx.fs.readText` records no observed-state, so a later `ctx.fileContext.edit` rejects with `FS_NOT_OBSERVED` until the file is read through `ctx.fileContext`. + +## 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 `ctx.fileContext`. +- 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 `FsWriteExpectation` (`createIfAbsent` or `replaceIfVersion`); removed types/primitives are gone, and the old `applyEdit` API is replaced by `editText`. +- `dsh-file-context` registers `ctx.fileContext`, owns observed-state plus `read`/`write`/`edit` policy, injects `fs`, and has HMR/disposal coverage. +- `dsh-tool-fs` injects `fileContext`; model-facing schemas stay byte-for-byte unchanged; the no-bypass contract and escape-hatch contract are documented and tested. +- 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 can surprise callers who later use `ctx.fileContext`. The failure is explicit (`FS_NOT_OBSERVED`) and documented. +- Large-file line windowing moves from the backend to `ctx.fileContext.read`; 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. diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index 497aa8896a..57c1e3dd2f 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -25,8 +25,8 @@ 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 (the model's only tool, via agent-core's tool-bash schema). - id: bash diff --git a/packages/README.md b/packages/README.md index dd418f97ca..265fdd8676 100644 --- a/packages/README.md +++ b/packages/README.md @@ -31,9 +31,10 @@ dsh-agent ← dsh-llm, dsh-session, dsh-brand 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 (abstract filesystem seam) +dsh-fs ← dsh-llm, dsh-brand (filesystem provider seam) dsh-fs-local ← dsh-fs (FileSystem impl) -dsh-tool-fs ← dsh-fs, dsh-tools (file tool schemas) +dsh-file-context ← dsh-fs (read windowing + write/edit freshness policy) +dsh-tool-fs ← dsh-file-context, dsh-fs, dsh-tools (file tool schemas) 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-system-prompt, dsh-tools, dsh-agent @@ -62,8 +63,9 @@ 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` | Abstract filesystem seam (interface + vocabulary + observed-file policy) | `ctx.fs` | +| `fs/` | `fs` | Filesystem provider seam: text IO + guarded mutation primitives | `ctx.fs` | | `fs-local/` | `fs` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) | +| `file-context/` | `fs` | Policy layer: read windowing, observed-state, write/edit freshness | `ctx.fileContext` | | `tool-fs/` | `fs` | Model-facing `read`/`write`/`edit` tool schemas | (registers on `ctx.tools`) | | `llm-deepseek/` | `llm` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) | | `llm-pi-ai/` | `llm` | DeepSeek adapter via `@earendil-works/pi-ai` (design twin) | (registers on `ctx.llm`) | diff --git a/packages/fs/README.md b/packages/fs/README.md index 15757698b2..a793a94b4b 100644 --- a/packages/fs/README.md +++ b/packages/fs/README.md @@ -1,11 +1,12 @@ # fs/ - filesystem capability family -The filesystem capability seam: an abstract filesystem interface, a local implementation, and the model-facing file tools. All **product** packages. +The filesystem stack: a provider seam (text IO + guarded mutation), a local implementation, a policy layer (read windowing + write/edit freshness), and the model-facing file tools. All **product** packages. | Package | Role | ctx key | |---|---|---| -| `fs/` | Abstract filesystem seam (interface + vocabulary + observed-file policy) | `ctx.fs` | +| `fs/` | Provider seam: text IO + guarded mutation primitives | `ctx.fs` | | `fs-local/` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) | +| `file-context/` | Policy layer: observed-state, read windowing, write/edit freshness | `ctx.fileContext` | | `tool-fs/` | Model-facing `read`/`write`/`edit` tool schemas | (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 interface or model-facing tool schemas. +The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy layer, or the model-facing tool schemas. The policy layer (`file-context/`) is a concrete service, not a swappable seam — it owns the model-facing observation policy that does not belong on a provider backend. diff --git a/packages/fs/file-context/README.md b/packages/fs/file-context/README.md new file mode 100644 index 0000000000..373fb2759b --- /dev/null +++ b/packages/fs/file-context/README.md @@ -0,0 +1,43 @@ +# @deepseek-ai/dsh-file-context + +The **file-context policy layer**: a concrete `ctx.fileContext` service that owns model-facing read windowing and write/edit freshness on top of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)). This is the policy third of the filesystem stack — it is **not** a swappable seam, but the deferred policy layer that does not belong on the `FileSystem` provider base class. + +```ts +import type { Context } from 'cordis' +import FileContext from '@deepseek-ai/dsh-file-context' + +declare const ctx: Context + +// A ctx.fs provider must already be loaded (e.g. @deepseek-ai/dsh-fs-local); +// FileContext injects `fs` and registers ctx.fileContext. Load +// @deepseek-ai/dsh-tool-fs afterwards to expose read/write/edit to the model. +await ctx.plugin(FileContext) +``` + +## The four-layer split + +| Layer | Package | Role | +|---|---|---| +| tool | `@deepseek-ai/dsh-tool-fs` | model-facing schemas + text rendering | +| policy | `@deepseek-ai/dsh-file-context` (this) | `ctx.fileContext`: observed-state, read windowing, write/edit freshness | +| provider seam | `@deepseek-ai/dsh-fs` | `ctx.fs`: text IO + guarded mutation primitives | +| provider | `@deepseek-ai/dsh-fs-local` | local implementation of `ctx.fs` | + +## Service API (`ctx.fileContext`) + +| Member | Semantics | +|---|---| +| `read(target, request, exec?, signal?)` | Stats the target, rejects absent/non-regular targets, chooses `readText`/`streamText` by size, builds the requested line window, records the version, and returns the `FileReadOutcome` the tool renders. | +| `write(target, content, exec?, signal?)` | No recorded read → `writeText({ kind: 'createIfAbsent' })` (only new files create blindly); a recorded read → `writeText({ kind: 'replaceIfVersion', version })`. Refreshes recorded state on success. | +| `edit(target, edit, exec?, signal?)` | Requires a recorded read by this owner (else `FS_NOT_OBSERVED`); passes the observed version to `ctx.fs.editText` as the stale guard and refreshes recorded state. | +| `owner(exec?)` | Derives the observed-state owner (`exec.agent.session`) — `undefined` when there is none. | + +## Observed state is the read record, freshness is the authorization + +Observed state is a `WeakMap>`. An entry exists **iff** the owner has read that target through `read`, so its presence *is* the read record — there is no `hasRead` flag and no `full`/`partial` view. Authorization is based on version freshness only: a windowed read of lines 100-150 records the file's version, and a later edit of line 120 is authorized as long as the file is unchanged (the provider's stale guard enforces it). State is held weakly and dropped on disposal (HMR safety); persistence across sessions is deferred. + +## The no-bypass contract + +A model-facing read MUST go through `ctx.fileContext.read`, never `ctx.fs.readText`/`streamText`, so every successful read records observed state before the tool renders. Direct `ctx.fs` calls remain an explicit escape hatch for non-tool consumers: a direct `ctx.fs.readText` records nothing, so a later `ctx.fileContext.edit` rejects with `FS_NOT_OBSERVED` until the file is read through `ctx.fileContext`. + +The line-windowing mechanics live in `src/window.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the service wiring and policy. diff --git a/packages/fs/file-context/package.json b/packages/fs/file-context/package.json new file mode 100644 index 0000000000..77c905703b --- /dev/null +++ b/packages/fs/file-context/package.json @@ -0,0 +1,31 @@ +{ + "name": "@deepseek-ai/dsh-file-context", + "description": "File-context policy layer (ctx.fileContext) for the DeepSeek Harness — read windowing and write/edit freshness over the ctx.fs provider seam", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-fs": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/fs/file-context/src/index.ts b/packages/fs/file-context/src/index.ts new file mode 100644 index 0000000000..6dbb33d1d2 --- /dev/null +++ b/packages/fs/file-context/src/index.ts @@ -0,0 +1,184 @@ +/** + * The file-context policy layer (`ctx.fileContext`): a concrete service that + * owns model-facing read windowing and write/edit freshness on top of the + * `ctx.fs` provider seam. It is NOT a swappable seam — it is the previously + * deferred policy layer that does not belong on the `FileSystem` provider base + * class (where a sandboxed/remote backend would otherwise inherit model-facing + * observation policy it has no business carrying). + * + * ## Observed state IS the read record + * + * Observed state lives here as `WeakMap>`. An + * entry exists iff the owner has read that target through {@link read}, so its + * presence *is* the read record — there is no separate `hasRead` flag. The owner + * is derived structurally from `{ agent?: { session? } }` and held weakly, so a + * collected session frees its state; disposal drops everything (HMR safety). + * + * ## Freshness, not full/partial views + * + * Authorization is based on version freshness only. A windowed read records the + * file's version, and any later write/edit at that version is authorized — a + * model that read lines 100-150 of a large file can still edit line 120 as long + * as the file is unchanged. There is no `full`/`partial` distinction: the bytes + * the edit matches must merely come from the version the model read, which the + * provider's stale guard enforces. + * + * ## The no-bypass contract + * + * A model-facing read MUST go through {@link read} (never `ctx.fs.readText`/ + * `streamText` directly), so every successful read records observed state before + * the tool renders. Direct `ctx.fs` calls are allowed for non-tool consumers but + * record nothing, so a later {@link edit} rejects with `FS_NOT_OBSERVED` until + * the file is read through `ctx.fileContext`. + * + * @module @deepseek-ai/dsh-file-context + */ + +import { Context, Service } from 'cordis' +import { FsError } from '@deepseek-ai/dsh-fs' +import type { FsTarget, FsVersion, FsEditRequest, FsEditOutcome, FsWriteOutcome } from '@deepseek-ai/dsh-fs' +import { buildWindow } from './window.ts' +import type { FileContextExec, FileReadRequest, FileReadOutcome } from './types.ts' + +export type { FileTextLine, ReadWindow, WindowResult } from './window.ts' +export { READ_MAX_BYTES, READ_MAX_LINE_LENGTH, buildWindow } from './window.ts' +export type { FileContextExec, FileReadRequest, FileReadOutcome } from './types.ts' + +/** Files at or above this size stream; smaller files read whole into memory. */ +export const STREAM_MIN_SIZE = 10 * 1024 * 1024 + +declare module 'cordis' { + interface Context { + fileContext: FileContext + } +} + +/** What an owner has observed about one target: just the version it last saw. */ +interface ObservedState { + version: FsVersion +} + +/** + * The file-context policy service. Injects `fs`, registers as `ctx.fileContext`, + * and is the only read/write/edit path the model-facing tools use. + */ +export class FileContext extends Service { + static inject = ['fs'] + + /** + * Observed-file state, keyed first by the owner object (weakly held, so a + * collected session frees its state), then by {@link FsTarget.targetKey}. An + * entry's PRESENCE is the read record. + */ + private observed = new WeakMap>() + + constructor(ctx: Context) { + super(ctx, 'fileContext') + ctx.effect(() => () => { + // Drop all recorded state on disposal so a reloaded service starts clean + // (HMR safety). The WeakMap itself would be GC'd, but replacing it makes + // the release observable and immediate for tests. + this.observed = new WeakMap() + }, 'fileContext observed-state teardown') + } + + /** + * Derive the observed-state owner from an execution context — normally the + * active agent session. `undefined` when no owner can be derived (e.g. a + * direct tool call with no agent); such calls read freely but cannot satisfy + * the write/edit prior-observation policy. + */ + owner(exec?: FileContextExec): object | undefined { + return exec?.agent?.session + } + + private getObserved(owner: object, targetKey: string): ObservedState | undefined { + return this.observed.get(owner)?.get(targetKey) + } + + private record(owner: object, targetKey: string, version: FsVersion): void { + let byTarget = this.observed.get(owner) + if (!byTarget) { + byTarget = new Map() + this.observed.set(owner, byTarget) + } + byTarget.set(targetKey, { version }) + } + + /** + * Resolve a path into a stable {@link FsTarget}, delegating to the provider. + * Exposed here so the model-facing tools never need to inject `ctx.fs` + * directly — they resolve and then read/write/edit entirely through + * `ctx.fileContext`. + */ + async resolve(path: string): Promise { + return this.ctx.fs.resolve(path) + } + + /** + * Read a bounded line window from a target. Stats first (rejecting an absent + * target with `FS_NOT_FOUND` and a non-regular one with `FS_NOT_REGULAR_FILE`), + * chooses `readText` vs `streamText` by size, builds the window, and — when an + * owner is derivable — records the version so a later write/edit is authorized. + */ + async read(target: FsTarget, request: FileReadRequest, exec?: FileContextExec, signal?: AbortSignal): Promise { + const info = await this.ctx.fs.stat(target, signal) + if (!info) throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND') + if (info.type !== 'file') throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') + + const chunks = info.size !== undefined && info.size >= STREAM_MIN_SIZE + ? await this.ctx.fs.streamText(target, signal) + : [await this.ctx.fs.readText(target, signal)] + const window = await buildWindow(chunks, request, target.displayPath) + + const owner = this.owner(exec) + if (owner) this.record(owner, target.targetKey, info.version) + return { + offset: request.offset, + limit: request.limit, + lines: window.lines, + totalLines: window.totalLines, + version: info.version, + ...window.truncatedByBytes ? { truncatedByBytes: true } : {}, + } + } + + /** + * Create or fully replace a file. With no recorded read, writes + * `createIfAbsent` (only new files can be created blindly); with a recorded + * read, writes `replaceIfVersion` at the observed version (existing files are + * replaced only if unchanged since the read). Refreshes recorded state from + * the returned version on success. + */ + async write(target: FsTarget, content: string, exec?: FileContextExec, signal?: AbortSignal): Promise { + const owner = this.owner(exec) + const prior = owner ? this.getObserved(owner, target.targetKey) : undefined + const outcome = await this.ctx.fs.writeText( + target, + content, + prior ? { kind: 'replaceIfVersion', version: prior.version } : { kind: 'createIfAbsent' }, + signal, + ) + if (owner) this.record(owner, target.targetKey, outcome.version) + return outcome + } + + /** + * Apply a literal edit. Requires a recorded read by this owner (else + * `FS_NOT_OBSERVED`); passes the observed version to `ctx.fs.editText` as the + * stale guard and refreshes recorded state from the returned version. The + * provider owns the mutation critical section and the literal match. + */ + async edit(target: FsTarget, edit: FsEditRequest, exec?: FileContextExec, signal?: AbortSignal): Promise { + const owner = this.owner(exec) + const prior = owner ? this.getObserved(owner, target.targetKey) : undefined + if (!owner || !prior) { + throw new FsError(`edit requires reading "${target.displayPath}" first`, 'FS_NOT_OBSERVED') + } + const outcome = await this.ctx.fs.editText(target, edit, { version: prior.version }, signal) + this.record(owner, target.targetKey, outcome.version) + return outcome + } +} + +export default FileContext diff --git a/packages/fs/file-context/src/types.ts b/packages/fs/file-context/src/types.ts new file mode 100644 index 0000000000..842d9a08c7 --- /dev/null +++ b/packages/fs/file-context/src/types.ts @@ -0,0 +1,56 @@ +/** + * Vocabulary for the file-context policy layer (`ctx.fileContext`): the + * minimal execution-context shape used to derive an observed-state owner, the + * resolved read window, and the structured read outcome the model-facing `read` + * tool renders. + * + * The provider vocabulary (`FsTarget`, `FsVersion`, write/edit shapes) is + * re-used from `@deepseek-ai/dsh-fs` — this package owns only the model-facing + * read-windowing and observation policy on top of it. + * + * @module @deepseek-ai/dsh-file-context/types + */ + +import type { FsVersion } from '@deepseek-ai/dsh-fs' +import type { FileTextLine } from './window.ts' + +/** + * Minimal structural view of a tool execution the policy layer needs to derive + * an observed-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution` satisfies + * this shape, so the consumer passes its `exec` straight through without + * `dsh-file-context` importing `dsh-tools`, `dsh-agent`, or `dsh-session`. + * + * The owner is `agent.session` when present. It is treated as an opaque object + * identity (a `WeakMap` key); this package never reads any of its fields. + */ +export interface FileContextExec { + /** The agent on whose behalf the call runs, when there is one. */ + agent?: { + /** The session that owns observed-file state, used as an opaque key. */ + session?: object + } +} + +/** Resolved read window. The consumer applies its defaults/caps before calling. */ +export interface FileReadRequest { + /** 1-based first line to return. */ + offset: number + /** Maximum number of lines to return. */ + limit: number +} + +/** Outcome of a bounded text read — what the model-facing `read` tool renders. */ +export interface FileReadOutcome { + /** 1-based first line requested. */ + offset: number + /** Maximum number of lines requested. */ + limit: number + /** Returned lines, already numbered. */ + lines: FileTextLine[] + /** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */ + totalLines: number + /** Whether selected output hit the byte cap before EOF or the requested limit. */ + truncatedByBytes?: true + /** Opaque version of the file at read time. */ + version: FsVersion +} diff --git a/packages/fs/file-context/src/window.ts b/packages/fs/file-context/src/window.ts new file mode 100644 index 0000000000..97e51e2ee4 --- /dev/null +++ b/packages/fs/file-context/src/window.ts @@ -0,0 +1,139 @@ +/** + * Cordis-free line-windowing for `@deepseek-ai/dsh-file-context`. Relocated + * from the local backend: turning a file's decoded text into a bounded, + * line-numbered window (offset/limit, byte cap, per-line truncation) is + * model-facing READ POLICY, not a storage primitive, so it lives in the policy + * layer rather than in every `ctx.fs` backend. + * + * The provider (`ctx.fs.readText`/`streamText`) hands back already-decoded text + * (UTF-8 validated, binary rejected); this module only scans that text for + * newlines and builds the requested window. A capped line buffer means a + * newline-free giant line can never balloon memory even when streamed. + * + * @module @deepseek-ai/dsh-file-context/window + */ + +import { FsError } from '@deepseek-ai/dsh-fs' + +/** Maximum characters returned for a single line. */ +export const READ_MAX_LINE_LENGTH = 2000 + +/** Maximum bytes returned for selected file lines. */ +export const READ_MAX_BYTES = 50 * 1024 + +const READ_MAX_LINE_SUFFIX = `... (line truncated to ${READ_MAX_LINE_LENGTH} chars)` +const LINE_BUFFER_CAP = READ_MAX_LINE_LENGTH + 1 + +/** Resolved read window. The consumer applies its defaults/caps before calling. */ +export interface ReadWindow { + /** 1-based first line to return. */ + offset: number + /** Maximum number of lines to return. */ + limit: number +} + +/** One line returned from a text file. */ +export interface FileTextLine { + /** 1-based line number in the file. */ + number: number + /** Line text without its trailing newline. */ + text: string +} + +/** The windowed result this module builds from a file's decoded text. */ +export interface WindowResult { + /** Returned lines, already numbered. */ + lines: FileTextLine[] + /** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */ + totalLines: number + /** Whether selected output hit the byte cap before EOF or the requested limit. */ + truncatedByBytes: boolean +} + +interface WindowAccumulator { + lines: FileTextLine[] + totalLines: number + outputBytes: number + truncatedByBytes: boolean + done: boolean +} + +function newAccumulator(): WindowAccumulator { + return { lines: [], totalLines: 0, outputBytes: 0, truncatedByBytes: false, done: false } +} + +function truncateLine(line: string): string { + return line.length > READ_MAX_LINE_LENGTH ? `${line.substring(0, READ_MAX_LINE_LENGTH)}${READ_MAX_LINE_SUFFIX}` : line +} + +function lineByteSize(line: string, currentLineCount: number): number { + return Buffer.byteLength(line, 'utf8') + (currentLineCount > 0 ? 1 : 0) +} + +function consumeLine(acc: WindowAccumulator, rawLine: string, request: ReadWindow): void { + acc.totalLines += 1 + if (acc.totalLines < request.offset || acc.lines.length >= request.limit) return + + const text = truncateLine(rawLine) + const bytes = lineByteSize(text, acc.lines.length) + if (acc.outputBytes + bytes > READ_MAX_BYTES) { + acc.truncatedByBytes = true + acc.done = true + return + } + acc.outputBytes += bytes + acc.lines.push({ number: acc.totalLines, text }) +} + +function stripCarriageReturn(line: string): string { + return line.endsWith('\r') ? line.slice(0, -1) : line +} + +function finish(acc: WindowAccumulator, request: ReadWindow, displayPath: string): WindowResult { + if (!acc.truncatedByBytes && request.offset > acc.totalLines && !(acc.totalLines === 0 && request.offset === 1)) { + throw new FsError(`offset ${request.offset} is out of range for "${displayPath}" (${acc.totalLines} lines)`, 'FS_NOT_FOUND') + } + return { lines: acc.lines, totalLines: acc.totalLines, truncatedByBytes: acc.truncatedByBytes } +} + +/** + * Build a bounded, line-numbered window from a file's decoded text chunks. + * Accepts an `AsyncIterable` (a chunked `streamText`) or an + * `Iterable` (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 | Iterable, + request: ReadWindow, + displayPath: string, +): Promise { + 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) +} diff --git a/packages/fs/file-context/tests/policy.spec.ts b/packages/fs/file-context/tests/policy.spec.ts new file mode 100644 index 0000000000..e15398eb54 --- /dev/null +++ b/packages/fs/file-context/tests/policy.spec.ts @@ -0,0 +1,305 @@ +/** + * Tests for the file-context policy layer: registration/disposal/HMR, owner + * derivation, observed-state-as-read-record, read windowing over a fake + * provider, freshness-based write/edit authorization (including the key + * windowed-read-authorizes-edit behavior), the read→streamText size routing, + * and multi-owner isolation. The provider is a fake `ctx.fs` recording the + * expectations it was handed. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' +import type { + FsEditOutcome, + FsEditRequest, + FsInfo, + FsTarget, + FsWriteExpectation, + FsWriteOutcome, +} from '@deepseek-ai/dsh-fs' +import FileContext, { STREAM_MIN_SIZE } from '@deepseek-ai/dsh-file-context' +import type { FileContextExec, FileReadRequest } from '@deepseek-ai/dsh-file-context' + +/** A fake provider: in-memory files, recording every expectation/version it is handed. */ +class FakeFs extends FileSystem { + files = new Map() + versions = new Map() + /** Size to report from stat (lets a test push read onto the streaming path). */ + reportSize?: number + /** Whether streamText was used for the last read (vs readText). */ + lastReadStreamed = false + writeExpectations: FsWriteExpectation[] = [] + editExpectedVersions: string[] = [] + + private ver(key: string): FsVersion { + return FsVersion(`v${this.versions.get(key) ?? 0}`) + } + private bump(key: string): FsVersion { + const next = (this.versions.get(key) ?? 0) + 1 + this.versions.set(key, next) + return FsVersion(`v${next}`) + } + + override async resolve(path: string): Promise { + return { inputPath: path, targetKey: FsTargetKey(path), displayPath: path } + } + override async stat(target: FsTarget): Promise { + const content = this.files.get(target.targetKey) + if (content === undefined) return undefined + return { version: this.ver(target.targetKey), type: 'file', size: this.reportSize ?? content.length } + } + override async readText(target: FsTarget): Promise { + this.lastReadStreamed = false + return this.files.get(target.targetKey) ?? '' + } + override async streamText(target: FsTarget): Promise> { + this.lastReadStreamed = true + const content = this.files.get(target.targetKey) ?? '' + return (async function* () { yield content })() + } + override async writeText(target: FsTarget, content: string, expected: FsWriteExpectation): Promise { + this.writeExpectations.push(expected) + const existed = this.files.has(target.targetKey) + this.files.set(target.targetKey, content) + return { operation: existed ? 'update' : 'create', version: this.bump(target.targetKey) } + } + override async editText(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }): Promise { + this.editExpectedVersions.push(expected.version) + const content = this.files.get(target.targetKey) ?? '' + this.files.set(target.targetKey, content.split(edit.oldString).join(edit.newString)) + return { replacements: 1, replaceAll: edit.replaceAll, version: this.bump(target.targetKey) } + } +} + +async function setup() { + const ctx = new Context() + await ctx.plugin(FakeFs) + await ctx.plugin(FileContext) + const fs = ctx.fs as FakeFs + const fileContext = ctx.fileContext + return { ctx, fs, fileContext } +} + +const READ_ALL: FileReadRequest = { offset: 1, limit: 2000 } +const ownerExec = (session: object): FileContextExec => ({ agent: { session } }) + +describe('registration / disposal', () => { + it('registers as ctx.fileContext and injects fs', async () => { + const { fileContext } = await setup() + expect(fileContext).toBeDefined() + }) + + it('stays pending until ctx.fs exists', async () => { + const ctx = new Context() + await ctx.plugin(FileContext) // no fs provider + expect(ctx.fileContext).toBeUndefined() + }) + + it('withdraws ctx.fileContext when its fiber is disposed (HMR safety)', async () => { + const ctx = new Context() + await ctx.plugin(FakeFs) + const fiber = await ctx.plugin(FileContext) + expect(ctx.fileContext).toBeDefined() + await fiber.dispose() + expect(ctx.fileContext).toBeUndefined() + }) +}) + +describe('owner derivation', () => { + it('derives the owner from exec.agent.session', async () => { + const { fileContext } = await setup() + const session = {} + expect(fileContext.owner(ownerExec(session))).toBe(session) + }) + + it('returns undefined with no exec, no agent, or no session', async () => { + const { fileContext } = await setup() + expect(fileContext.owner()).toBeUndefined() + expect(fileContext.owner({})).toBeUndefined() + expect(fileContext.owner({ agent: {} })).toBeUndefined() + }) +}) + +describe('read', () => { + it('returns a windowed outcome and rejects an absent target', async () => { + const { fs, fileContext } = await setup() + fs.files.set('a.txt', 'one\ntwo') + const outcome = await fileContext.read(await fs.resolve('a.txt'), READ_ALL) + expect(outcome.lines).toEqual([{ number: 1, text: 'one' }, { number: 2, text: 'two' }]) + expect(outcome.version).toBe('v0') + + await expect(fileContext.read(await fs.resolve('missing.txt'), READ_ALL)) + .rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) + }) + + it('rejects a non-regular target', async () => { + const { fs, fileContext } = await setup() + fs.files.set('d', '') + const target = await fs.resolve('d') + // Force stat to report a directory. + fs.stat = async () => ({ version: FsVersion('v0'), type: 'directory' }) + await expect(fileContext.read(target, READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) + }) + + it('reads small files whole and large files via streamText', async () => { + const { fs, fileContext } = await setup() + fs.files.set('a.txt', 'one\ntwo') + + await fileContext.read(await fs.resolve('a.txt'), READ_ALL) + expect(fs.lastReadStreamed).toBe(false) + + fs.reportSize = STREAM_MIN_SIZE + await fileContext.read(await fs.resolve('a.txt'), READ_ALL) + expect(fs.lastReadStreamed).toBe(true) + }) + + it('surfaces truncatedByBytes when the window hits the byte cap', async () => { + const { fs, fileContext } = await setup() + fs.files.set('big.txt', Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n')) + const outcome = await fileContext.read(await fs.resolve('big.txt'), READ_ALL) + expect(outcome.truncatedByBytes).toBe(true) + }) +}) + +describe('observed-state is the read record', () => { + it('a read authorizes a later in-place write at the observed version', async () => { + const { fs, fileContext } = await setup() + const exec = ownerExec({}) + fs.files.set('a.txt', 'hello') + const target = await fs.resolve('a.txt') + + await fileContext.read(target, READ_ALL, exec) + await fileContext.write(target, 'goodbye', exec) + + expect(fs.writeExpectations).toEqual([{ kind: 'replaceIfVersion', version: 'v0' }]) + }) + + it('a windowed (partial) read still authorizes edit — freshness, not full/partial', async () => { + const { fs, fileContext } = await setup() + const exec = ownerExec({}) + fs.files.set('a.txt', 'one\ntwo\nthree\nfour') + const target = await fs.resolve('a.txt') + + // Read only lines 2-3 — a partial window. + const outcome = await fileContext.read(target, { offset: 2, limit: 2 }, exec) + expect(outcome.lines.map(l => l.number)).toEqual([2, 3]) + + // Edit is authorized anyway: the file is unchanged since the read. + await fileContext.edit(target, { oldString: 'one', newString: 'X', replaceAll: false }, exec) + expect(fs.editExpectedVersions).toEqual(['v0']) + }) + + it('skips recording when there is no owner, so write is createIfAbsent', async () => { + const { fs, fileContext } = await setup() + fs.files.set('a.txt', 'hello') + const target = await fs.resolve('a.txt') + + await fileContext.read(target, READ_ALL) // no exec + // No recorded read → createIfAbsent → the provider rejects an existing target. + fs.writeText = async () => { throw new FsError('exists', 'FS_NOT_OBSERVED') } + await expect(fileContext.write(target, 'x')).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) +}) + +describe('write policy', () => { + it('a create (no prior read) uses createIfAbsent', async () => { + const { fs, fileContext } = await setup() + const exec = ownerExec({}) + const target = await fs.resolve('new.txt') + const outcome = await fileContext.write(target, 'fresh', exec) + expect(outcome.operation).toBe('create') + expect(fs.writeExpectations).toEqual([{ kind: 'createIfAbsent' }]) + }) + + it('refreshes state after a write, so a follow-up edit needs no re-read', async () => { + const { fs, fileContext } = await setup() + const exec = ownerExec({}) + const target = await fs.resolve('a.txt') + await fileContext.write(target, 'one', exec) // create → state now at v1 + await fileContext.edit(target, { oldString: 'one', newString: 'two', replaceAll: false }, exec) + expect(fs.editExpectedVersions).toEqual(['v1']) + }) +}) + +describe('edit policy', () => { + it('rejects with FS_NOT_OBSERVED when the file was never read', async () => { + const { fs, fileContext } = await setup() + const exec = ownerExec({}) + fs.files.set('a.txt', 'hello') + const target = await fs.resolve('a.txt') + await expect(fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec)) + .rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) + + it('rejects when there is no owner (cannot prove prior observation)', async () => { + const { fs, fileContext } = await setup() + fs.files.set('a.txt', 'hello') + const target = await fs.resolve('a.txt') + await expect(fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false })) + .rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) + + it('passes the recorded version as the stale guard after a read', async () => { + const { fs, fileContext } = await setup() + const exec = ownerExec({}) + fs.files.set('a.txt', 'hello') + fs.versions.set('a.txt', 7) + const target = await fs.resolve('a.txt') + await fileContext.read(target, READ_ALL, exec) + await fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec) + expect(fs.editExpectedVersions).toEqual(['v7']) + }) +}) + +describe('multi-owner isolation', () => { + it('owner A reading does not grant owner B edit authority', async () => { + const { fs, fileContext } = await setup() + const a = ownerExec({}) + const b = ownerExec({}) + fs.files.set('a.txt', 'hello') + const target = await fs.resolve('a.txt') + + await fileContext.read(target, READ_ALL, a) + await expect(fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, b)) + .rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + await expect(fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, a)) + .resolves.toMatchObject({ replacements: 1 }) + }) + + it('each owner records its own observed version independently', async () => { + const { fs, fileContext } = await setup() + const a = ownerExec({}) + const b = ownerExec({}) + fs.files.set('a.txt', 'hello') + const target = await fs.resolve('a.txt') + + await fileContext.read(target, READ_ALL, a) // A sees v0 + await fileContext.write(target, 'mid', b) // B has no read → createIfAbsent + await fileContext.write(target, 'late', a) // A still holds its v0 observation + + expect(fs.writeExpectations).toEqual([ + { kind: 'createIfAbsent' }, + { kind: 'replaceIfVersion', version: 'v0' }, + ]) + }) +}) + +describe('disposal releases recorded state', () => { + it('a fresh service after disposal starts with no inherited state', async () => { + const ctx = new Context() + await ctx.plugin(FakeFs) + const fs = ctx.fs as FakeFs + const fiber = await ctx.plugin(FileContext) + const exec = ownerExec({}) + fs.files.set('a.txt', 'hello') + await ctx.fileContext.read(await fs.resolve('a.txt'), READ_ALL, exec) + await fiber.dispose() + + await ctx.plugin(FileContext) + const target = await fs.resolve('a.txt') + // Same owner object, but state was released on disposal. + await expect(ctx.fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec)) + .rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) +}) diff --git a/packages/fs/file-context/tests/window.spec.ts b/packages/fs/file-context/tests/window.spec.ts new file mode 100644 index 0000000000..6b1a8b5b93 --- /dev/null +++ b/packages/fs/file-context/tests/window.spec.ts @@ -0,0 +1,102 @@ +/** + * Cordis-free tests for the line-windowing module: offset/limit windows, byte + * caps, per-line truncation, CRLF stripping, offset-past-EOF rejection, and the + * capped line buffer for newline-free giant lines — all over an async-iterable + * of decoded text chunks (so one code path serves whole-file and streamed reads). + */ + +import { describe, expect, it } from 'vitest' +import { buildWindow, READ_MAX_LINE_LENGTH } from '@deepseek-ai/dsh-file-context' +import type { ReadWindow } from '@deepseek-ai/dsh-file-context' + +const READ_ALL: ReadWindow = { offset: 1, limit: 2000 } + +/** Yield `text` as one chunk (whole-file read shape). */ +async function* whole(text: string): AsyncIterable { + yield text +} + +/** Yield `text` split into fixed-size chunks (streamed read shape). */ +async function* chunked(text: string, size: number): AsyncIterable { + 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']) + }) + }) +}) diff --git a/packages/fs/file-context/tsconfig.json b/packages/fs/file-context/tsconfig.json new file mode 100644 index 0000000000..dc4518f7f0 --- /dev/null +++ b/packages/fs/file-context/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../llm/llm" }, + { "path": "../fs" } + ] +} diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index 794239ed2d..a4bb087aea 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -1,20 +1,22 @@ # @deepseek-ai/dsh-fs-local -The **local-filesystem implementation** of the `ctx.fs` seam ([`@deepseek-ai/dsh-fs`](../fs)). Backs the four `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`. +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-tool-fs to expose read/write/edit to the model. +// ctx.fs is now the local backend; load @deepseek-ai/dsh-file-context for policy +// and @deepseek-ai/dsh-tool-fs to expose read/write/edit to the model. ``` ## Behavior - **`resolve(path)`** — relative paths resolve from `config.cwd` (default `process.cwd()`). 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. -- **`readPage`** — UTF-8 only. A fast path (`readFile`) handles files under `FAST_PATH_MAX_SIZE` (10 MB); larger files stream with a capped line buffer so a newline-free giant file can't exhaust memory. Invalid UTF-8 and NUL-byte samples are rejected (`FS_NOT_TEXT`). Output is bounded to `READ_LIMIT` (2000) lines, `READ_MAX_BYTES` (50 KB), and `READ_MAX_LINE_LENGTH` (2000) chars per line; hitting any bound records a `partial` view. The `version` is `mtimeMs:size`. -- **`createOrReplace`** — 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`. Honors the `FsExpectation`: an `observed` write must match the recorded version (else `FS_STALE_VERSION`); a `partial` write onto an existing file is rejected (`FS_PARTIAL_OBSERVATION`); an `unobserved` write onto an existing file is rejected (`FS_NOT_OBSERVED`). -- **`applyEdit`** — atomic literal read-modify-write over the same primitive. Verifies the expected version, 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`). +- **`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 policy layer (`ctx.fileContext`) 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`. Honors the `FsWriteExpectation`: `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. Verifies the expected version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content), 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 diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index a7e124db15..6a2b5a28cc 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -1,13 +1,13 @@ /** * 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 read/write/edit mechanics can be unit-tested without a Context. + * the raw stat/read/write/edit mechanics can be unit-tested without a Context. * - * The reader uses two code paths so a single huge line can never balloon - * memory: a **fast path** (`readFile` + in-memory split) for files under - * {@link FAST_PATH_MAX_SIZE}, and a **streaming path** (manual newline scan - * with a capped line buffer) for larger files. Both reject invalid UTF-8 and - * NUL-byte binary samples, and keep only the requested page in memory. + * This is the PROVIDER layer: it hands back decoded whole-file text (validated + * UTF-8, binary rejected) — never line windows or numbered lines, which are + * model-facing read policy owned by `@deepseek-ai/dsh-file-context`. Large files + * 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 @@ -24,56 +24,12 @@ import { chmod, mkdir, open, readFile, realpath, rename, rm, stat } from 'node:f import type { Stats } from 'node:fs' import { basename, dirname, join, resolve } from 'node:path' import { TextDecoder } from 'node:util' -import { FsError } from '@deepseek-ai/dsh-fs' -import type { FsReadRequest, FsTextLine, FsView } from '@deepseek-ai/dsh-fs' +import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' -/** Default and maximum number of lines returned by one read. */ -export const READ_LIMIT = 2000 +/** Files at or above this size stream their text; smaller files read whole. */ +export const STREAM_MIN_SIZE = 10 * 1024 * 1024 -/** 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 - -/** Files smaller than this use the in-memory fast path; larger files stream. */ -export const FAST_PATH_MAX_SIZE = 10 * 1024 * 1024 - -const READ_MAX_BYTES_LABEL = `${READ_MAX_BYTES / 1024} KB` -const READ_MAX_LINE_SUFFIX = `... (line truncated to ${READ_MAX_LINE_LENGTH} chars)` const BINARY_SAMPLE_BYTES = 8192 -const LINE_BUFFER_CAP = READ_MAX_LINE_LENGTH + 1 - -/** - * Test seam: lets specs force the streaming path (via a small - * `fastPathMaxSize`) 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 FAST_PATH_MAX_SIZE} for routing. */ - fastPathMaxSize?: 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 -} - -/** 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: string -} - -/** Result of probing a path: null when it does not exist. */ -export interface PathInfo { - version: string - mode: number - isFile: boolean -} function isENOENT(error: unknown): boolean { return error instanceof Error && 'code' in error && error.code === 'ENOENT' @@ -94,8 +50,40 @@ function throwIfAborted(signal: AbortSignal | undefined, verb: string): void { } /** Opaque version token from a stat: mtime (ns precision) + size. */ -function versionOf(info: Stats): string { - return `${info.mtimeMs}:${info.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 +} + +/** 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 } /** @@ -111,26 +99,27 @@ export async function resolveLocalTarget(cwd: string, path: string): Promise { try { const info = await stat(absolutePath) - return { version: versionOf(info), mode: info.mode & 0o777, isFile: info.isFile() } + const type = info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other' + return { version: versionOf(info), mode: info.mode & 0o777, type, size: info.size } } catch (error: unknown) { /* v8 ignore next 2 -- a non-ENOENT stat failure needs a permission/IO fault; surface it. */ if (!isENOENT(error)) throw error @@ -140,67 +129,6 @@ export async function probe(absolutePath: string): Promise { // --- Reading --- -interface PageAccumulator { - lines: FsTextLine[] - totalLines: number - outputBytes: number - truncatedByBytes: boolean - truncatedByLine: boolean - done: boolean -} - -function newAccumulator(): PageAccumulator { - return { lines: [], totalLines: 0, outputBytes: 0, truncatedByBytes: false, truncatedByLine: false, done: false } -} - -function truncateReadLine(line: string): { text: string; truncated: boolean } { - return line.length > READ_MAX_LINE_LENGTH - ? { text: `${line.substring(0, READ_MAX_LINE_LENGTH)}${READ_MAX_LINE_SUFFIX}`, truncated: true } - : { text: line, truncated: false } -} - -function lineByteSize(line: string, currentLineCount: number): number { - return Buffer.byteLength(line, 'utf8') + (currentLineCount > 0 ? 1 : 0) -} - -function consumeLine(acc: PageAccumulator, rawLine: string, request: FsReadRequest): void { - acc.totalLines += 1 - if (acc.totalLines < request.offset || acc.lines.length >= request.limit) return - - const { text, truncated } = truncateReadLine(rawLine) - if (truncated) acc.truncatedByLine = true - 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 -} - -/** The outcome shape `readTextPage` returns (minus the offset/limit echo, which the caller adds). */ -export interface ReadPageResult { - lines: FsTextLine[] - totalLines: number - truncatedByBytes: boolean - view: FsView - version: string -} - -function buildResult(acc: PageAccumulator, request: FsReadRequest, version: string, displayPath: string): ReadPageResult { - 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') - } - const endLine = acc.lines.at(-1)?.number ?? Math.max(0, request.offset - 1) - const view: FsView = request.offset === 1 && !acc.truncatedByBytes && !acc.truncatedByLine && endLine >= acc.totalLines ? 'full' : 'partial' - return { lines: acc.lines, totalLines: acc.totalLines, truncatedByBytes: acc.truncatedByBytes, view, version } -} - function notTextError(verb: 'read' | 'edit', displayPath: string): FsError { return new FsError(`cannot ${verb} "${displayPath}": invalid UTF-8 text`, 'FS_NOT_TEXT') } @@ -209,8 +137,9 @@ function decodeUtf8(buffer: Uint8Array, verb: 'read' | 'edit', displayPath: stri try { return new TextDecoder('utf-8', { fatal: true }).decode(buffer) } catch (error: unknown) { - if (error instanceof TypeError) throw notTextError(verb, displayPath) - throw error + /* 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) } } @@ -223,90 +152,50 @@ function decodeUtf8Stream( try { return chunk ? decoder.decode(chunk, { stream: true }) : decoder.decode() } catch (error: unknown) { - if (error instanceof TypeError) throw notTextError(verb, displayPath) - throw error + /* 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) } } -/** - * Read a bounded UTF-8 text-file page. Rejects non-regular files, invalid - * UTF-8, and NUL-byte binary samples; dispatches to the fast or streaming path - * by file size. - */ -export async function readTextPage( - target: LocalTarget, - request: FsReadRequest, - signal?: AbortSignal, - internals: FsIoInternals = {}, -): Promise { - throwIfAborted(signal, 'read') - const absolutePath = target.targetKey +async function statRegularFile(target: LocalTarget, verb: 'read', signal?: AbortSignal): Promise { + throwIfAborted(signal, verb) let info: Stats try { - info = await stat(absolutePath) + 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 read "${target.displayPath}": not found`, 'FS_NOT_FOUND') + throw new FsError(`cannot ${verb} "${target.displayPath}": not found`, 'FS_NOT_FOUND') } - if (!info.isFile()) throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') - - const version = versionOf(info) - const fastPathMax = internals.fastPathMaxSize ?? FAST_PATH_MAX_SIZE - return info.size < fastPathMax - ? readTextPageFast(target, request, version, signal) - : readTextPageStreaming(target, request, version, signal) + if (!info.isFile()) throw new FsError(`cannot ${verb} "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') + return info } -async function readTextPageFast( - target: LocalTarget, - request: FsReadRequest, - version: string, - signal?: AbortSignal, -): Promise { +/** + * 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 { + await statRegularFile(target, 'read', signal) const raw = await readFile(target.targetKey, signal ? { 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') } - - const text = decodeUtf8(raw, 'read', target.displayPath) - const acc = newAccumulator() - let startPos = 0 - let newlinePos: number - while ((newlinePos = text.indexOf('\n', startPos)) !== -1) { - consumeLine(acc, stripCarriageReturn(text.slice(startPos, newlinePos)), request) - if (acc.done) break - startPos = newlinePos + 1 - } - if (!acc.done && startPos < text.length) { - consumeLine(acc, stripCarriageReturn(text.slice(startPos)), request) - } - return buildResult(acc, request, version, target.displayPath) + return decodeUtf8(raw, 'read', target.displayPath) } -async function readTextPageStreaming( - target: LocalTarget, - request: FsReadRequest, - version: string, - signal?: AbortSignal, -): Promise { +/** + * 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 { + await statRegularFile(target, 'read', signal) const stream = createReadStream(target.targetKey, signal ? { signal } : {}) - const acc = newAccumulator() - let lineBuffer = '' - let sampledBytes = 0 const decoder = new TextDecoder('utf-8', { fatal: true }) - - 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 = '' - } + let sampledBytes = 0 function scanBinarySample(chunk: Buffer): void { if (sampledBytes >= BINARY_SAMPLE_BYTES) return @@ -317,51 +206,17 @@ async function readTextPageStreaming( sampledBytes += sample.length } - function consumeChunk(chunk: string): ReadPageResult | undefined { - 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 buildResult(acc, request, version, target.displayPath) - } - appendToLineBuffer(chunk.slice(startPos)) - return undefined - } - try { for await (const chunk of stream as AsyncIterable) { scanBinarySample(chunk) - const result = consumeChunk(decodeUtf8Stream(decoder, chunk, 'read', target.displayPath)) - if (result) return result + yield decodeUtf8Stream(decoder, chunk, 'read', target.displayPath) } - const finalResult = consumeChunk(decodeUtf8Stream(decoder, undefined, 'read', target.displayPath)) - if (finalResult) return finalResult + 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 } - - if (lineBuffer.length > 0) flushLine() - return buildResult(acc, request, version, target.displayPath) -} - -/** Format the line-numbered body + pagination footer for a read page. */ -export function formatReadBody(result: ReadPageResult, offset: number): string { - const endLine = result.lines.at(-1)?.number ?? Math.max(0, offset - 1) - let footer: string - if (result.truncatedByBytes) { - footer = `(Output capped at ${READ_MAX_BYTES_LABEL}. Showing lines ${offset}-${endLine}. Use offset=${endLine + 1} to continue.)` - } else if (endLine < result.totalLines) { - footer = `(Showing lines ${offset}-${endLine} of ${result.totalLines}. Use offset=${endLine + 1} to continue.)` - } else { - footer = `(End of file - total ${result.totalLines} lines)` - } - return result.lines.length > 0 - ? `${result.lines.map(line => `${line.number}: ${line.text}`).join('\n')}\n\n${footer}` - : footer } // --- Writing --- diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index 0184a8a323..3c8ba61f78 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -1,10 +1,11 @@ /** - * Local-filesystem implementation of the `ctx.fs` seam. {@link LocalFileSystem} - * subclasses {@link FileSystem} and backs the four 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). + * 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`. @@ -14,43 +15,39 @@ import { Context } from 'cordis' import z from 'schemastery' -import { FileSystem, FsError } from '@deepseek-ai/dsh-fs' +import { FileSystem, FsError, FsVersion } from '@deepseek-ai/dsh-fs' import type { FsEditOutcome, FsEditRequest, - FsExpectation, - FsReadOutcome, - FsReadRequest, + FsInfo, FsTarget, - FsVersion, + FsWriteExpectation, FsWriteOutcome, } from '@deepseek-ai/dsh-fs' import { applyLiteralEdit, probe, readForEdit, - readTextPage, + readWholeText, resolveLocalTarget, restoreLineEndings, + streamWholeText, writeFileAtomic, } from './fsio.ts' import type { FsIoInternals } from './fsio.ts' export { - FAST_PATH_MAX_SIZE, - READ_LIMIT, - READ_MAX_BYTES, - READ_MAX_LINE_LENGTH, + STREAM_MIN_SIZE, applyLiteralEdit, - formatReadBody, probe, readForEdit, - readTextPage, + readWholeText, resolveLocalTarget, restoreLineEndings, + streamWholeText, writeFileAtomic, } from './fsio.ts' -export type { FsIoInternals, LineEndings, LocalTarget, PathInfo, ReadPageResult } from './fsio.ts' +export type { FsIoInternals, LineEndings, LocalTarget, PathInfo } from './fsio.ts' /** Configuration for the local filesystem backend. */ export interface Config { @@ -105,47 +102,41 @@ export class LocalFileSystem extends FileSystem { return { inputPath: path, targetKey: local.targetKey, displayPath: local.displayPath } } - override async readPage(target: FsTarget, request: FsReadRequest, signal?: AbortSignal): Promise { - const result = await readTextPage( - { displayPath: target.displayPath, targetKey: target.targetKey }, - request, - signal, - this.internals, - ) - return { - offset: request.offset, - limit: request.limit, - lines: result.lines, - totalLines: result.totalLines, - version: result.version, - view: result.view, - ...result.truncatedByBytes ? { truncatedByBytes: true } : {}, - } + override async stat(target: FsTarget, signal?: AbortSignal): Promise { + 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 createOrReplace( + override async readText(target: FsTarget, signal?: AbortSignal): Promise { + return readWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal) + } + + override streamText(target: FsTarget, signal?: AbortSignal): Promise> { + return Promise.resolve(streamWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal)) + } + + override async writeText( target: FsTarget, content: string, - expected: FsExpectation, + expected: FsWriteExpectation, signal?: AbortSignal, ): Promise { return this.withLock(target.targetKey, async () => { const existing = await probe(target.targetKey) - if (existing && !existing.isFile) { + if (existing && existing.type !== 'file') { throw new FsError(`cannot write "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') } - if (expected.kind === 'observed') { - // Stale guard: the file must still be at the version the owner observed. + 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 === 'partial') { - if (!existing) throw new FsError(`cannot write "${target.displayPath}": file no longer exists`, 'FS_STALE_VERSION') - throw new FsError(`cannot overwrite existing "${target.displayPath}" after only a partial read`, 'FS_PARTIAL_OBSERVATION') } else if (existing) { - // Unobserved write onto an existing file: a blind overwrite — require a read first. + // 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') } @@ -158,7 +149,7 @@ export class LocalFileSystem extends FileSystem { }) } - override async applyEdit( + override async editText( target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, @@ -166,8 +157,10 @@ export class LocalFileSystem extends FileSystem { ): Promise { return this.withLock(target.targetKey, async () => { const existing = await probe(target.targetKey) - if (!existing) throw new FsError(`cannot edit "${target.displayPath}": not found`, 'FS_NOT_FOUND') - if (!existing.isFile) throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') + // 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. + 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') if (existing.version !== expected.version) { throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION') } @@ -188,9 +181,9 @@ export class LocalFileSystem extends FileSystem { /* 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: string } | null, target: FsTarget): string { + private versionAfterWrite(after: { version: FsVersion } | null, target: FsTarget): FsVersion { if (after) return after.version - return `missing:${target.targetKey}` + return FsVersion(`missing:${target.targetKey}`) } } diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts index eb675472af..20c5cfa21f 100644 --- a/packages/fs/fs-local/tests/filesystem.spec.ts +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -1,7 +1,9 @@ /** - * Tests for the local backend through the `ctx.fs` service: the full - * read→write→edit lifecycle with the read-before-write policy, stale-version - * guards, concurrency races, symlink identity, and HMR/disposal. + * 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-file-context`, so it is not exercised here. */ import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -9,8 +11,9 @@ import { mkdtemp, readFile, rm, stat, symlink, writeFile, unlink } from 'node:fs import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from 'cordis' -import { LocalFileSystem, probe } from '@deepseek-ai/dsh-fs-local' -import type { FsExecContext } from '@deepseek-ai/dsh-fs' +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 @@ -28,12 +31,17 @@ afterEach(async () => { await rm(dir, { recursive: true, force: true }) }) -const READ_ALL = { offset: 1, limit: 2000 } -const exec = (): FsExecContext => ({ agent: { session: {} } }) function lockCount(localFs: LocalFileSystem): number { return (localFs as unknown as { locks: Map> }).locks.size } +/** The version the backend currently reports for a resolved target. */ +async function versionOf(target: FsTarget): Promise { + 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() @@ -43,255 +51,219 @@ describe('registration', () => { }) }) -describe('read → write → edit lifecycle', () => { - it('creates a new file without a prior read', async () => { +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.write(target, 'fresh', exec()) + 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('updates an existing file after reading it', async () => { + it('createIfAbsent rejects an existing file as FS_NOT_OBSERVED', async () => { await writeFile(join(dir, 'a.txt'), 'old') - const owner = exec() const target = await fs.resolve('a.txt') - await fs.read(target, READ_ALL, owner) - const outcome = await fs.write(target, 'new', owner) + 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('edits an existing file after reading it', async () => { - await writeFile(join(dir, 'a.txt'), 'hello world') - const owner = exec() + it('replaceIfVersion rejects a stale version', async () => { + await writeFile(join(dir, 'a.txt'), 'v1') const target = await fs.resolve('a.txt') - await fs.read(target, READ_ALL, owner) - const outcome = await fs.edit(target, { oldString: 'world', newString: 'there', replaceAll: false }, owner) - expect(outcome.replacements).toBe(1) - expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there') + 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('rejects an empty edit oldString through ctx.fs without hanging or changing the file', async () => { - await writeFile(join(dir, 'a.txt'), 'hello world') - const owner = exec() + 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') - await fs.read(target, READ_ALL, owner) - - await expect(fs.edit(target, { oldString: '', newString: 'boom', replaceAll: false }, owner)) - .rejects.toMatchObject({ code: 'FS_EDIT_NOT_FOUND' }) - expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello world') + 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('propagates truncatedByBytes from a byte-capped read', async () => { - await writeFile(join(dir, 'big.txt'), Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n')) - const outcome = await fs.read(await fs.resolve('big.txt'), READ_ALL, exec()) - expect(outcome.truncatedByBytes).toBe(true) - expect(outcome.view).toBe('partial') - }) - - it('records an over-long-line read as partial, so write/edit stay blocked', async () => { - await writeFile(join(dir, 'long.txt'), 'x'.repeat(3000)) - const owner = exec() - const target = await fs.resolve('long.txt') - const outcome = await fs.read(target, READ_ALL, owner) - - expect(outcome.view).toBe('partial') - await expect(fs.write(target, 'new', owner)).rejects.toMatchObject({ code: 'FS_PARTIAL_OBSERVATION' }) - await expect( - fs.edit(target, { oldString: 'x', newString: 'y', replaceAll: false }, owner), - ).rejects.toMatchObject({ code: 'FS_PARTIAL_OBSERVATION' }) - }) - - it('allows a follow-up edit without re-reading (write/edit refresh state)', async () => { - await writeFile(join(dir, 'a.txt'), 'a b') - const owner = exec() - const target = await fs.resolve('a.txt') - await fs.read(target, READ_ALL, owner) - await fs.edit(target, { oldString: 'a', newString: 'X', replaceAll: false }, owner) - await fs.edit(target, { oldString: 'b', newString: 'Y', replaceAll: false }, owner) - expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('X Y') + 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('releases per-target mutation locks after success and failure', async () => { const target = await fs.resolve('a.txt') - await fs.write(target, 'created', exec()) + await fs.writeText(target, 'created', { kind: 'createIfAbsent' }) expect(lockCount(fs)).toBe(0) - - await expect(fs.write(target, 'blind overwrite', exec())).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + await expect(fs.writeText(target, 'again', { kind: 'createIfAbsent' })) + .rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) expect(lockCount(fs)).toBe(0) }) }) -describe('read-before-write policy', () => { - it('rejects a blind overwrite of an existing file (no prior read)', async () => { - await writeFile(join(dir, 'a.txt'), 'old') +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') - await expect(fs.write(target, 'new', exec())).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + 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('rejects a write after only a partial read', async () => { - await writeFile(join(dir, 'a.txt'), 'one\ntwo') - const owner = exec() + it('checks the stale version BEFORE literal matching', async () => { + await writeFile(join(dir, 'a.txt'), 'hello world') const target = await fs.resolve('a.txt') - await fs.read(target, { offset: 1, limit: 1 }, owner) - await expect(fs.write(target, 'new', owner)).rejects.toMatchObject({ code: 'FS_PARTIAL_OBSERVATION' }) + 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('rejects a write after a partial read when the file was deleted, without recreating it', async () => { - const path = join(dir, 'a.txt') - await writeFile(path, 'one\ntwo') - const owner = exec() + it('rejects a deleted target as stale (before matching)', async () => { + await writeFile(join(dir, 'a.txt'), 'hello') const target = await fs.resolve('a.txt') - await fs.read(target, { offset: 1, limit: 1 }, owner) - await unlink(path) - - await expect(fs.write(target, 'new', owner)).rejects.toMatchObject({ code: 'FS_STALE_VERSION' }) - await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' }) + 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 an edit with no prior read (FS_NOT_OBSERVED)', async () => { - await writeFile(join(dir, 'a.txt'), 'old') - const target = await fs.resolve('a.txt') - await expect(fs.edit(target, { oldString: 'old', newString: 'new', replaceAll: false }, exec())) - .rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + 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 invalid UTF-8 reads and edits without rewriting the file', async () => { - const path = join(dir, 'invalid-utf8.txt') + 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 owner = exec() - const target = await fs.resolve('invalid-utf8.txt') - - await expect(fs.read(target, READ_ALL, owner)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) - const existing = await probe(target.targetKey) - if (!existing) throw new Error('expected invalid UTF-8 fixture to exist') - await expect( - fs.applyEdit(target, { oldString: 'h', newString: 'H', replaceAll: false }, { version: existing.version }), - ).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) + 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) }) -}) - -describe('stale-version guard + concurrency (defensive class B)', () => { - it('rejects a write when the file changed since it was read', async () => { - await writeFile(join(dir, 'a.txt'), 'v1') - const owner = exec() - const target = await fs.resolve('a.txt') - await fs.read(target, READ_ALL, owner) - // An out-of-band change after the read. - await writeFile(join(dir, 'a.txt'), 'changed-externally') - await expect(fs.write(target, 'v2', owner)).rejects.toMatchObject({ code: 'FS_STALE_VERSION' }) - }) - - it('rejects an observed write when the file was deleted after the read', async () => { - await writeFile(join(dir, 'a.txt'), 'v1') - const owner = exec() - const target = await fs.resolve('a.txt') - await fs.read(target, READ_ALL, owner) - await unlink(join(dir, 'a.txt')) // file vanishes; observed write must fail (not silently create) - await expect(fs.write(target, 'v2', owner)).rejects.toMatchObject({ code: 'FS_STALE_VERSION' }) - }) it('two concurrent edits: one wins, the other is rejected as stale', async () => { await writeFile(join(dir, 'a.txt'), 'base') - const owner = exec() const target = await fs.resolve('a.txt') - await fs.read(target, READ_ALL, owner) - // Both edits captured the same recorded version; only one rename can match it. + const version = await versionOf(target) const results = await Promise.allSettled([ - fs.edit(target, { oldString: 'base', newString: 'one', replaceAll: false }, owner), - fs.edit(target, { oldString: 'base', newString: 'two', replaceAll: false }, owner), + fs.editText(target, { oldString: 'base', newString: 'one', replaceAll: false }, { version }), + fs.editText(target, { oldString: 'base', newString: 'two', replaceAll: false }, { version }), ]) - const fulfilled = results.filter(r => r.status === 'fulfilled') + expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(1) const rejected = results.filter(r => r.status === 'rejected') - expect(fulfilled).toHaveLength(1) expect(rejected).toHaveLength(1) expect((rejected[0] as PromiseRejectedResult).reason).toMatchObject({ code: 'FS_STALE_VERSION' }) expect(lockCount(fs)).toBe(0) }) }) -describe('symlink targetKey identity (defensive class F)', () => { - it('a read via the real path authorizes an edit via the symlink path', async () => { +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 owner = exec() - await fs.read(await fs.resolve('real.txt'), READ_ALL, owner) - // Edit through the link: same realpath → same targetKey → prior read counts. - const linkTarget = await fs.resolve('link.txt') - const outcome = await fs.edit(linkTarget, { oldString: 'hello', newString: 'bye', replaceAll: false }, owner) - expect(outcome.replacements).toBe(1) - expect(await readFile(join(dir, 'real.txt'), 'utf8')).toBe('bye') // link preserved, target written - }) + const viaReal = await fs.resolve('real.txt') + const viaLink = await fs.resolve('link.txt') + expect(viaLink.targetKey).toBe(viaReal.targetKey) - it('write through a symlink preserves the link and writes the real target', async () => { - await writeFile(join(dir, 'real.txt'), 'hello') - await symlink(join(dir, 'real.txt'), join(dir, 'link.txt')) - const owner = exec() - const linkTarget = await fs.resolve('link.txt') - await fs.read(linkTarget, READ_ALL, owner) - await fs.write(linkTarget, 'replaced', owner) - expect(await readFile(join(dir, 'real.txt'), 'utf8')).toBe('replaced') + 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 owner = exec() - await fs.read(await fs.resolve('real.txt'), READ_ALL, owner) - await writeFile(join(dir, 'real.txt'), 'changed') // out-of-band via real path - const linkTarget = await fs.resolve('link.txt') - await expect(fs.edit(linkTarget, { oldString: 'hello', newString: 'bye', replaceAll: false }, owner)) + 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('non-regular targets', () => { - it('rejects writing onto a directory', async () => { - const target = await fs.resolve('.') // the cwd dir - await expect(fs.write(target, 'x', exec())).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) - }) - - it('applyEdit rejects a target that vanished after the read', async () => { - await writeFile(join(dir, 'a.txt'), 'hello') - const owner = exec() - const target = await fs.resolve('a.txt') - const version = (await fs.read(target, READ_ALL, owner)).version - await unlink(join(dir, 'a.txt')) - await expect(fs.applyEdit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, { version })) - .rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) - }) - - it('applyEdit rejects a non-regular target', async () => { - const target = await fs.resolve('.') - await expect(fs.applyEdit(target, { oldString: 'a', newString: 'b', replaceAll: false }, { version: 'v' })) - .rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) - }) -}) - -describe('HMR / disposal (defensive class D)', () => { +describe('HMR / disposal', () => { it('disposing the fiber withdraws ctx.fs', async () => { const local = new Context() - const fiber = await local.plugin(LocalFileSystem, { cwd: dir }) + const localFiber = await local.plugin(LocalFileSystem, { cwd: dir }) expect(local.fs).toBeDefined() - await fiber.dispose() + await localFiber.dispose() expect(local.fs).toBeUndefined() }) - - it('a fresh provider does not inherit recorded file state', async () => { - await writeFile(join(dir, 'a.txt'), 'hello') - const local = new Context() - const owner = exec() - const fiber = await local.plugin(LocalFileSystem, { cwd: dir }) - await (local.fs as LocalFileSystem).read(await local.fs.resolve('a.txt'), READ_ALL, owner) - await fiber.dispose() - - await local.plugin(LocalFileSystem, { cwd: dir }) - const fs2 = local.fs as LocalFileSystem - const target = await fs2.resolve('a.txt') - // Same owner object, but state was released on disposal. - await expect(fs2.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, owner)) - .rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) - }) }) diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index f25f9e9a0b..13ab9ed860 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -1,24 +1,27 @@ /** - * Cordis-free tests for the raw local-filesystem I/O: path resolution, - * fast/streaming reads, pagination/caps, binary rejection, atomic-write temp - * safety, literal edit matching, and line-ending handling. + * Cordis-free tests for the raw local-filesystem I/O: path resolution, probe, + * whole-file/streamed text reads, binary/UTF-8 rejection, atomic-write temp + * safety, literal edit matching, and line-ending handling. Line WINDOWING is + * policy and lives in `dsh-file-context`, so it is not tested here. */ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { mkdtemp, readFile, rm, stat, symlink, writeFile, mkdir, readdir } from 'node:fs/promises' +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, - formatReadBody, probe, readForEdit, - readTextPage, + readWholeText, resolveLocalTarget, restoreLineEndings, + streamWholeText, writeFileAtomic, } from '@deepseek-ai/dsh-fs-local' import type { LocalTarget } from '@deepseek-ai/dsh-fs-local' +import { FsTargetKey } from '@deepseek-ai/dsh-fs' let dir: string beforeEach(async () => { @@ -28,8 +31,13 @@ afterEach(async () => { await rm(dir, { recursive: true, force: true }) }) -const READ_ALL = { offset: 1, limit: 2000 } -const localTarget = (path: string): LocalTarget => ({ displayPath: path, targetKey: path }) +const localTarget = (path: string): LocalTarget => ({ displayPath: path, targetKey: FsTargetKey(path) }) + +async function collect(chunks: AsyncIterable): Promise { + 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 () => { @@ -37,11 +45,10 @@ describe('resolveLocalTarget', () => { await writeFile(file, 'hi') const target = await resolveLocalTarget(dir, 'a.txt') expect(target.displayPath).toBe(file) - expect(target.targetKey).toBe(await (await import('node:fs/promises')).realpath(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 { realpath } = await import('node:fs/promises') const target = await resolveLocalTarget(dir, 'missing.txt') expect(target.targetKey).toBe(join(await realpath(dir), 'missing.txt')) }) @@ -67,186 +74,104 @@ describe('resolveLocalTarget', () => { }) }) -describe('readTextPage', () => { - it('reads a small file with line numbers and full view', async () => { +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() + await new Promise((resolve) => { server.listen(sockPath, () => { resolve() }) }) + try { + expect((await probe(sockPath))?.type).toBe('other') + } finally { + await new Promise((resolve) => { server.close(() => { resolve() }) }) + } + }) +}) + +describe('readWholeText', () => { + it('reads a small file', async () => { const file = join(dir, 'a.txt') await writeFile(file, 'one\ntwo\nthree') - const result = await readTextPage(localTarget(file), READ_ALL) - expect(result.lines).toEqual([ - { number: 1, text: 'one' }, - { number: 2, text: 'two' }, - { number: 3, text: 'three' }, - ]) - expect(result.totalLines).toBe(3) - expect(result.view).toBe('full') - }) - - it('paginates with offset/limit and reports a partial view', async () => { - const file = join(dir, 'a.txt') - await writeFile(file, 'one\ntwo\nthree\nfour') - const result = await readTextPage(localTarget(file), { offset: 2, limit: 2 }) - expect(result.lines.map(l => l.number)).toEqual([2, 3]) - expect(result.view).toBe('partial') - expect(formatReadBody(result, 2)).toContain('(Showing lines 2-3 of 4. Use offset=4 to continue.)') - }) - - it('a whole-file read from offset 1 is a full view; offset>1 is partial', async () => { - const file = join(dir, 'a.txt') - await writeFile(file, 'one\ntwo') - expect((await readTextPage(localTarget(file), { offset: 1, limit: 10 })).view).toBe('full') - expect((await readTextPage(localTarget(file), { offset: 2, limit: 10 })).view).toBe('partial') - }) - - it('truncates an over-long line', async () => { - const file = join(dir, 'long.txt') - await writeFile(file, 'x'.repeat(3000)) - const result = await readTextPage(localTarget(file), READ_ALL) - expect(result.lines[0]?.text).toContain('... (line truncated to 2000 chars)') - expect(result.view).toBe('partial') - }) - - it('caps output bytes and reports truncatedByBytes', async () => { - const file = join(dir, 'big.txt') - const lines = Array.from({ length: 2000 }, () => 'y'.repeat(100)) - await writeFile(file, lines.join('\n')) - const result = await readTextPage(localTarget(file), READ_ALL) - expect(result.truncatedByBytes).toBe(true) - expect(formatReadBody(result, 1)).toContain('Output capped at 50 KB') - }) - - it('strips CRLF so a Windows file reads like LF', async () => { - const file = join(dir, 'crlf.txt') - await writeFile(file, 'one\r\ntwo\r\n') - const result = await readTextPage(localTarget(file), READ_ALL) - expect(result.lines.map(l => l.text)).toEqual(['one', 'two']) - }) - - it('reads an empty file at offset 1', async () => { - const file = join(dir, 'empty.txt') - await writeFile(file, '') - const result = await readTextPage(localTarget(file), READ_ALL) - expect(result.lines).toEqual([]) - expect(result.totalLines).toBe(0) - expect(formatReadBody(result, 1)).toBe('(End of file - total 0 lines)') - }) - - it('rejects an offset past EOF', async () => { - const file = join(dir, 'a.txt') - await writeFile(file, 'one\ntwo') - await expect(readTextPage(localTarget(file), { offset: 9, limit: 1 })).rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) - }) - - it('rejects a binary file (fast path)', async () => { - const file = join(dir, 'bin') - await writeFile(file, Buffer.from([0x68, 0x00, 0x69])) - await expect(readTextPage(localTarget(file), READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) - }) - - it('rejects invalid UTF-8 bytes (fast path)', async () => { - const file = join(dir, 'invalid-utf8.txt') - await writeFile(file, Buffer.from([0x68, 0xff, 0x69])) - await expect(readTextPage(localTarget(file), READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) + expect(await readWholeText(localTarget(file))).toBe('one\ntwo\nthree') }) it('rejects a missing file and a directory', async () => { - await expect(readTextPage(localTarget(join(dir, 'nope')), READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) - await expect(readTextPage(localTarget(dir), READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) + 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(readTextPage(localTarget(file), READ_ALL, AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' }) + await expect(readWholeText(localTarget(file), AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' }) }) - it('passes a live (non-aborted) signal through the fast path', async () => { + it('passes a live (non-aborted) signal through', async () => { const file = join(dir, 'a.txt') await writeFile(file, 'one\ntwo') - const result = await readTextPage(localTarget(file), READ_ALL, new AbortController().signal) - expect(result.totalLines).toBe(2) - }) - - describe('streaming path (forced via a tiny fastPathMaxSize)', () => { - const stream = { fastPathMaxSize: 1 } - - it('reads and paginates large files the same way', async () => { - const file = join(dir, 'a.txt') - await writeFile(file, 'one\ntwo\nthree') - const result = await readTextPage(localTarget(file), { offset: 2, limit: 1 }, undefined, stream) - expect(result.lines).toEqual([{ number: 2, text: 'two' }]) - expect(result.totalLines).toBe(3) - }) - - it('rejects a binary file on the streaming path', async () => { - const file = join(dir, 'bin') - await writeFile(file, Buffer.from([0x68, 0x00, 0x69])) - await expect(readTextPage(localTarget(file), READ_ALL, undefined, stream)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) - }) - - it('caps a newline-free giant line without unbounded buffering', async () => { - const file = join(dir, 'one-line.txt') - await writeFile(file, 'z'.repeat(5000)) - const result = await readTextPage(localTarget(file), READ_ALL, undefined, stream) - expect(result.lines[0]?.text).toContain('... (line truncated to 2000 chars)') - expect(result.view).toBe('partial') - }) - - it('rejects invalid UTF-8 bytes on the streaming path', async () => { - const file = join(dir, 'invalid-utf8.txt') - await writeFile(file, Buffer.from([0x68, 0xff, 0x69])) - await expect(readTextPage(localTarget(file), READ_ALL, undefined, stream)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) - }) - - it('honors abort on the streaming path', async () => { - const file = join(dir, 'a.txt') - await writeFile(file, 'one\ntwo') - await expect(readTextPage(localTarget(file), READ_ALL, AbortSignal.abort(), stream)).rejects.toMatchObject({ code: 'FS_ABORTED' }) - }) - - it('caps output bytes mid-stream', async () => { - const file = join(dir, 'big.txt') - await writeFile(file, Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n')) - const result = await readTextPage(localTarget(file), READ_ALL, undefined, stream) - expect(result.truncatedByBytes).toBe(true) - }) - - it('flushes a final line with no trailing newline', async () => { - const file = join(dir, 'no-nl.txt') - await writeFile(file, 'one\ntwo') // no trailing \n - const result = await readTextPage(localTarget(file), READ_ALL, undefined, stream) - expect(result.lines.map(l => l.text)).toEqual(['one', 'two']) - }) - - it('handles a trailing newline (no dangling buffer at EOF)', async () => { - const file = join(dir, 'nl.txt') - await writeFile(file, 'one\ntwo\n') // trailing \n → empty buffer at end - const result = await readTextPage(localTarget(file), READ_ALL, undefined, stream) - expect(result.lines.map(l => l.text)).toEqual(['one', 'two']) - expect(result.totalLines).toBe(2) - }) - - it('passes a live (non-aborted) signal through to the stream', async () => { - const file = join(dir, 'a.txt') - await writeFile(file, 'one\ntwo') - const result = await readTextPage(localTarget(file), READ_ALL, new AbortController().signal, stream) - expect(result.totalLines).toBe(2) - }) - - it('scans across multiple stream chunks', async () => { - // A file well past the default 64 KB stream highWaterMark yields multiple chunks, - // exercising the non-first-chunk branch and the line-buffer cap across appends. - const file = join(dir, 'multi.txt') - const lines = Array.from({ length: 50 }, (_, i) => `line ${i}: ${'x'.repeat(3000)}`) - await writeFile(file, lines.join('\n')) - const result = await readTextPage(localTarget(file), { offset: 1, limit: 3 }, undefined, stream) - expect(result.lines[0]?.text.startsWith('line 0:')).toBe(true) - expect(result.lines[0]?.text).toContain('... (line truncated to 2000 chars)') - expect(result.totalLines).toBeGreaterThanOrEqual(3) - }) + expect(await readWholeText(localTarget(file), new AbortController().signal)).toBe('one\ntwo') }) }) -describe('writeFileAtomic — temp-file safety (defensive class A)', () => { +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') + }) +}) + +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 @@ -259,8 +184,7 @@ describe('writeFileAtomic — temp-file safety (defensive class A)', () => { }) expect(inspected).toBe(true) expect(await readFile(file, 'utf8')).toBe('hello') - const info = await stat(file) - expect(info.mode & 0o777).toBe(0o640) + expect((await stat(file)).mode & 0o777).toBe(0o640) expect((await readdir(dir)).filter(n => n.includes('.tmp'))).toEqual([]) }) @@ -278,7 +202,6 @@ describe('writeFileAtomic — temp-file safety (defensive class A)', () => { await expect( writeFileAtomic(file, 'hello', undefined, undefined, { tempDirName: () => tempDirName }), ).rejects.toMatchObject({ code: 'EEXIST' }) - // The pre-existing staging dir is intact and the target was not created. expect(await readFile(join(dir, tempDirName, 'PRECIOUS'), 'utf8')).toBe('keep') await expect(stat(file)).rejects.toMatchObject({ code: 'ENOENT' }) }) @@ -303,9 +226,8 @@ describe('writeFileAtomic — temp-file safety (defensive class A)', () => { it('cleans up the temp file when the final rename fails', async () => { const sub = join(dir, 'occupied') - await mkdir(sub) // rename(temp, sub) fails because sub is a non-empty/dir target + await mkdir(sub) await expect(writeFileAtomic(sub, 'hi', undefined, undefined)).rejects.toBeInstanceOf(Error) - // No leftover staging dirs in the directory. expect((await readdir(dir)).filter(n => n.includes('.tmp'))).toEqual([]) }) }) @@ -346,16 +268,11 @@ describe('readForEdit + restoreLineEndings', () => { expect(restoreLineEndings(edited.content, original.lineEndings)).toBe('one\r\nTWO\r\n') }) - it('rejects a binary file', async () => { - const file = join(dir, 'bin') - await writeFile(file, Buffer.from([0x00, 0x01])) - await expect(readForEdit(file, file)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) - }) - - it('rejects invalid UTF-8 bytes', async () => { - const file = join(dir, 'invalid-utf8.txt') - await writeFile(file, Buffer.from([0x68, 0xff, 0x69])) - await expect(readForEdit(file, file)).rejects.toMatchObject({ code: 'FS_NOT_TEXT' }) + 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 () => { @@ -365,20 +282,3 @@ describe('readForEdit + restoreLineEndings', () => { expect(original.content).toBe('one\ntwo') }) }) - -describe('probe', () => { - it('returns null for a missing path and info 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?.isFile).toBe(true) - expect(typeof info?.version).toBe('string') - }) - - it('marks a directory as not a regular file', async () => { - const sub = join(dir, 'sub') - await mkdir(sub) - expect((await probe(sub))?.isFile).toBe(false) - }) -}) diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md index 856ec95076..1599ac25cc 100644 --- a/packages/fs/fs/README.md +++ b/packages/fs/fs/README.md @@ -1,38 +1,37 @@ # @deepseek-ai/dsh-fs -The **filesystem seam**: an abstract `FileSystem` service (`ctx.fs`) defining WHAT a filesystem backend does — resolve paths, read bounded text pages, create/replace files, apply literal edits — without saying HOW. +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 guarded literal edit — without saying HOW. -This package is one third of the filesystem capability, 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) and [the filesystem capability-seam RFC](../../../docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md)): +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), and [the split-the-filesystem-seam RFC](../../../docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md)): -| Package | Role | -|---|---| -| `@deepseek-ai/dsh-fs` (this) | the interface: abstract service + vocabulary types + read-before-write/edit policy | -| `@deepseek-ai/dsh-fs-local` | an implementation: the host filesystem | -| `@deepseek-ai/dsh-tool-fs` | the model-facing `read`/`write`/`edit` tool schemas over `ctx.fs` | +| Layer | Package | Role | +|---|---|---| +| tool | `@deepseek-ai/dsh-tool-fs` | model-facing `read`/`write`/`edit` schemas + text rendering | +| policy | `@deepseek-ai/dsh-file-context` | `ctx.fileContext`: observed-state, read windowing, write/edit freshness | +| provider seam | `@deepseek-ai/dsh-fs` (this) | `ctx.fs`: text IO + guarded mutation primitives | +| provider | `@deepseek-ai/dsh-fs-local` | the host-filesystem implementation | -A future sandboxed, virtual, or remote backend implements this interface and the tool schemas don't change. +A future sandboxed, virtual, or remote backend implements this interface and the policy/tool layers don't change. ## Service API (`ctx.fs`) -Consumers call the concrete public API; backends implement the four primitives. +A backend subclasses `FileSystem` and implements six primitives. -| Member | Kind | Semantics | -|---|---|---| -| `resolve(path)` | primitive | Resolve a path into a stable `FsTarget` (`inputPath`, opaque `targetKey`, `displayPath`). Async — a remote backend may need I/O. The same file via different paths must yield the same `targetKey`. | -| `readPage(target, request, signal?)` | primitive | Read a bounded UTF-8 text page. Returns line-numbered content, `totalLines`, an opaque `version`, and a `view` (`full` only when the page covered the whole file). | -| `createOrReplace(target, content, expected, signal?)` | primitive | Create/replace a file honoring the `FsExpectation` stale guard. | -| `applyEdit(target, edit, expected, signal?)` | primitive | Atomic literal read-modify-write, verifying the expected version. `oldString` must be non-empty. | -| `read(target, request, exec?, signal?)` | public | Calls `readPage`, then records observed state for the derived owner. | -| `write(target, content, exec?, signal?)` | public | Builds the `FsExpectation` from recorded state, calls `createOrReplace`, refreshes state to `full`. Updating an existing file needs a prior `full` read; a create does not. | -| `edit(target, edit, exec?, signal?)` | public | Requires a prior `full` read by this owner (else `FS_NOT_OBSERVED` / `FS_PARTIAL_OBSERVATION`), rejects empty `oldString`, calls `applyEdit`, refreshes state. | -| `owner(exec?)` | helper | Derives the file-state owner (`exec.agent.session`) — `undefined` when there is none. | +| Member | Semantics | +|---|---| +| `resolve(path)` | Resolve a path into a stable `FsTarget` (`inputPath`, opaque `targetKey`, `displayPath`). 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 honoring the `FsWriteExpectation` (`createIfAbsent` or `replaceIfVersion`). | +| `editText(target, edit, expected, signal?)` | Version-guarded literal edit. Verifies `expected.version` BEFORE matching, then applies the replacement and writes atomically — one mutation critical section. | -## Read-before-write/edit lives in the seam +## A provider seam, not the policy layer -Write/edit safety depends on backend-defined target identity and version tokens, so `ctx.fs` — not the tool layer — records what each owner has observed (keyed by an opaque owner object, normally the agent session, then by `targetKey`) and enforces the policy. The base class owns owner derivation, the file-state store, and *which* `FsExpectation` to hand the backend; the backend owns version comparison and I/O. Only a `full` view authorizes write/edit; a `partial` view (paged/truncated read) records context but does not. +`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 version-guarded literal-edit critical section. It does **not** own line windows, numbered lines, rendered footers, or observed-state — those model-facing read-windowing and read-before-write/edit policies live one layer up in `ctx.fileContext` ([`@deepseek-ai/dsh-file-context`](../file-context)), so a sandboxed/remote backend inherits no model-facing observation policy. -State is held in a `WeakMap` keyed by the owner object and dropped on disposal (HMR safety). Persistence across sessions is deferred — a resumed session must read files again before write/edit. +`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 -`FsTarget` / `FsVersion` are opaque — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. 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_PARTIAL_OBSERVATION`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts. +`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids RFC](../../../docs/rfc/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsWriteExpectation` is the explicit 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`). 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. diff --git a/packages/fs/fs/package.json b/packages/fs/fs/package.json index a0bce4940a..7520956310 100644 --- a/packages/fs/fs/package.json +++ b/packages/fs/fs/package.json @@ -20,10 +20,12 @@ ], "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" } diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts index b23d7dd90a..70675b01c2 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -1,62 +1,62 @@ /** - * The filesystem seam (`ctx.fs`): an abstract service defining WHAT a - * filesystem backend does — resolve paths into stable targets, read bounded - * text pages, create/replace files, and apply literal edits — 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 tool schemas that consume them + * The filesystem provider seam (`ctx.fs`): an abstract service defining the + * text-storage primitives a backend provides — resolve a path into a stable + * target, stat its metadata, read/stream its text, write it atomically with an + * explicit expectation, and apply a guarded literal edit — without saying HOW. + * 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 packages. + * The split mirrors the bash seam (`BashExecutor`/`LocalBashExecutor`). See the + * capability-seam RFC for why a swappable capability is three (here four) + * packages. * - * ## Read-before-write/edit lives here, not in the tools + * ## This is a provider seam, not the policy layer * - * Write/edit safety depends on backend-defined target identity and version - * tokens, so the seam — not the consumer — records what each owner has observed - * and enforces the policy. The base class owns owner derivation, the file-state - * store, and the decision of *which* {@link FsExpectation} to hand a backend; - * the backend owns version comparison and the actual I/O. A consumer passes its - * execution context through {@link read}/{@link write}/{@link edit} and never - * touches the cache, owner key, or version tokens. + * `ctx.fs` is deliberately close to fsspec-style storage primitives. It owns + * UTF-8 decoding, binary/NUL rejection, atomic full-file writes, and the + * version-guarded literal-edit critical section — but NOT line windows, + * numbered lines, rendered footers, or observed-state. Those model-facing + * read-windowing and read-before-write/edit policies live one layer up in the + * concrete `ctx.fileContext` service (`@deepseek-ai/dsh-file-context`), 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. * * @module @deepseek-ai/dsh-fs */ import { Context, Service } from 'cordis' -import { FsError } from './types.ts' import type { FsEditOutcome, FsEditRequest, - FsExecContext, - FsExpectation, - FsReadOutcome, - FsReadRequest, + FsInfo, FsTarget, FsVersion, + FsWriteExpectation, FsWriteOutcome, - FileState, } from './types.ts' export { FsError, + FsTargetKey, + FsVersion, } from './types.ts' export type { FsEditOutcome, FsEditRequest, FsErrorCode, - FsExecContext, - FsExpectation, - FsReadOutcome, - FsReadRequest, - FsStateSource, + FsInfo, FsTarget, - FsTextLine, - FsVersion, - FsView, + FsWriteExpectation, FsWriteOutcome, - FileState, } from './types.ts' declare module 'cordis' { @@ -66,50 +66,32 @@ declare module 'cordis' { } /** - * Abstract filesystem service. Subclass, implement the four backend primitives - * ({@link resolve}, {@link readPage}, {@link createOrReplace}, - * {@link applyEdit}), 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). - * - * Consumers call the concrete public API ({@link read}/{@link write}/ - * {@link edit}), which derives the file-state owner, enforces the - * read-before-write/edit policy, and refreshes recorded state — then delegates - * the actual I/O to the backend primitives. + * 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 file-state lookup agree across paths (e.g. through symlinks). - * - {@link readPage} returns line-numbered UTF-8 content with a `version` and a - * `view` (`full` only when the page covered the whole file). - * - {@link createOrReplace} honors the {@link FsExpectation}: `observed` - * rejects with `FS_STALE_VERSION` if the file changed since `version`; - * `partial` rejects existing targets because the owner saw only a - * non-editable view; `unobserved` creates iff the target is absent and - * otherwise rejects. - * - {@link applyEdit} verifies the expected version (stale guard) and is atomic - * (read-modify-write must not interleave with a concurrent edit). + * 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 honoring the + * {@link FsWriteExpectation}. + * - {@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. */ export abstract class FileSystem extends Service { - /** - * Observed-file state, keyed first by the owner object (weakly held, so a - * collected session frees its state), then by {@link FsTarget.targetKey}. - */ - private fileStates = new WeakMap>() - constructor(ctx: Context) { super(ctx, 'fs') - ctx.effect(() => () => { - // Drop all recorded state on disposal so a reloaded backend starts clean - // (HMR safety). The WeakMap itself would be GC'd, but replacing it makes - // the release observable and immediate for tests. - this.fileStates = new WeakMap() - }, 'fs file-state teardown') } - // --- Backend primitives (subclass implements; all backend I/O lives here) --- - /** * 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 @@ -118,139 +100,32 @@ export abstract class FileSystem extends Service { */ abstract resolve(path: string): Promise - /** Read a bounded UTF-8 text page from a target. */ - abstract readPage(target: FsTarget, request: FsReadRequest, signal?: AbortSignal): Promise + /** Return target metadata, or `undefined` when the target does not exist. */ + abstract stat(target: FsTarget, signal?: AbortSignal): Promise + + /** Read the whole regular text file as a single decoded string. */ + abstract readText(target: FsTarget, signal?: AbortSignal): Promise /** - * Create or fully replace a UTF-8 text file, honoring `expected` as the - * stale guard / create-vs-update decision. + * 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 createOrReplace(target: FsTarget, content: string, expected: FsExpectation, signal?: AbortSignal): Promise + abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> /** - * Apply a literal edit to an existing UTF-8 text file, verifying - * `expected.version` as the stale guard. Atomic read-modify-write. + * Create or fully replace a UTF-8 text file atomically, honoring `expected` + * as the create-vs-replace decision and stale guard. */ - abstract applyEdit(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, signal?: AbortSignal): Promise - - // --- Owner + file-state machinery (shared by all backends) --- + abstract writeText(target: FsTarget, content: string, expected: FsWriteExpectation, signal?: AbortSignal): Promise /** - * Derive the file-state owner from an execution context — normally the active - * agent session. Returns `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. + * Apply a literal edit to an existing UTF-8 text file. Verifies + * `expected.version` as the stale guard BEFORE literal matching, then applies + * the replacement and writes atomically — one mutation critical section. */ - owner(exec?: FsExecContext): object | undefined { - return exec?.agent?.session - } - - /** Look up recorded state for an owner+target, if any. */ - protected getState(owner: object, targetKey: string): FileState | undefined { - return this.fileStates.get(owner)?.get(targetKey) - } - - /** Record (or replace) one owner's observed state for a target. */ - protected recordState(owner: object, state: FileState): void { - let byTarget = this.fileStates.get(owner) - if (!byTarget) { - byTarget = new Map() - this.fileStates.set(owner, byTarget) - } - byTarget.set(state.targetKey, state) - } - - // --- Concrete public API (orchestration; consumers call these) --- - - /** - * Read a bounded text page and, when an owner is derivable, record the - * observed state (a `full` view authorizes later write/edit; a `partial` view - * does not). - */ - async read(target: FsTarget, request: FsReadRequest, exec?: FsExecContext, signal?: AbortSignal): Promise { - const outcome = await this.readPage(target, request, signal) - const owner = this.owner(exec) - if (owner) { - this.recordState(owner, { - targetKey: target.targetKey, - displayPath: target.displayPath, - version: outcome.version, - view: outcome.view, - updatedAt: this.now(), - source: 'read', - }) - } - return outcome - } - - /** - * Create or fully replace a file. Updating an existing file requires a `full` - * prior observation by this owner; a create (no prior state, target absent) - * does not. After a successful write the recorded state refreshes to `full` - * at the new version so a follow-up modification needs no re-read. - */ - async write(target: FsTarget, content: string, exec?: FsExecContext, signal?: AbortSignal): Promise { - const owner = this.owner(exec) - const prior = owner ? this.getState(owner, target.targetKey) : undefined - const expected: FsExpectation = prior - ? prior.view === 'full' - ? { kind: 'observed', version: prior.version } - : { kind: 'partial', version: prior.version } - : { kind: 'unobserved' } - - const outcome = await this.createOrReplace(target, content, expected, signal) - if (owner) { - this.recordState(owner, { - targetKey: target.targetKey, - displayPath: target.displayPath, - version: outcome.version, - view: 'full', - updatedAt: this.now(), - source: 'write', - }) - } - return outcome - } - - /** - * Apply a literal edit. Always requires a `full` prior observation by this - * owner. No owner or absent state rejects with `FS_NOT_OBSERVED`; a partial - * view rejects with `FS_PARTIAL_OBSERVATION`; an empty `oldString` rejects - * before backend I/O. There is no "create via edit". Refreshes recorded - * state to `full` at the new version on success. - */ - async edit(target: FsTarget, edit: FsEditRequest, exec?: FsExecContext, signal?: AbortSignal): Promise { - if (edit.oldString.length === 0) { - throw new FsError('old_string must be a non-empty string', 'FS_EDIT_NOT_FOUND') - } - const owner = this.owner(exec) - const prior = owner ? this.getState(owner, target.targetKey) : undefined - if (!owner || !prior) { - throw new FsError(`edit requires reading "${target.displayPath}" first`, 'FS_NOT_OBSERVED') - } - if (prior.view !== 'full') { - throw new FsError(`edit requires a full read of "${target.displayPath}" first`, 'FS_PARTIAL_OBSERVATION') - } - - const outcome = await this.applyEdit(target, edit, { version: prior.version }, signal) - this.recordState(owner, { - targetKey: target.targetKey, - displayPath: target.displayPath, - version: outcome.version, - view: 'full', - updatedAt: this.now(), - source: 'edit', - }) - return outcome - } - - /** - * Wall-clock now (ms). A protected seam so tests can use deterministic - * timestamps; production uses `Date.now()`. - */ - protected now(): number { - return Date.now() - } + abstract editText(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, signal?: AbortSignal): Promise } export default FileSystem diff --git a/packages/fs/fs/src/types.ts b/packages/fs/fs/src/types.ts index f08723731e..62ba52b52f 100644 --- a/packages/fs/fs/src/types.ts +++ b/packages/fs/fs/src/types.ts @@ -1,34 +1,49 @@ /** - * Vocabulary for the filesystem capability seam (`ctx.fs`): the request/outcome - * shapes backends produce and consumers format, the opaque target/version - * identities, the per-owner file-state record, and the typed error taxonomy. + * Vocabulary for the filesystem provider seam (`ctx.fs`): the opaque + * target/version identities, the metadata `stat` returns, the write-expectation + * 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 model-facing consumer - * (`@deepseek-ai/dsh-tool-fs`). They deliberately avoid host-path assumptions: - * `targetKey` and `version` are opaque tokens, and `displayPath` is the only - * field a consumer may show. + * future sandboxed/remote backends) and by the policy layer + * (`@deepseek-ai/dsh-file-context`). 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 policy layer (`ctx.fileContext`). * * @module @deepseek-ai/dsh-fs/types */ import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { Branded } from '@deepseek-ai/dsh-brand' /** - * Minimal structural view of a tool execution the filesystem seam needs to - * derive a file-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution` - * satisfies this shape, so the consumer passes its `exec` straight through - * without `dsh-fs` 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); `dsh-fs` never reads any of its fields. + * 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 interface FsExecContext { - /** 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 - } +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 } /** @@ -38,12 +53,8 @@ export interface FsExecContext { export interface FsTarget { /** The original model/plugin-supplied path, for diagnostics only. */ inputPath: string - /** - * Opaque key for stale guards and file-state 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. - */ - targetKey: 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. @@ -52,64 +63,30 @@ export interface FsTarget { } /** - * Opaque file-version token. The local backend derives it from mtime+size; a - * remote backend might use a revision id. `ctx.fs` records it for stale checks; - * consumers may display related metadata but MUST NOT interpret this token. + * 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 type FsVersion = string - -/** Resolved read window. The consumer applies its defaults/caps before calling. */ -export interface FsReadRequest { - /** 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 FsTextLine { - /** 1-based line number in the file. */ - number: number - /** Line text without its trailing newline. */ - text: string -} - -/** Whether a recorded/returned view covers the whole file or only part of it. */ -export type FsView = 'full' | 'partial' - -/** Outcome of a bounded text read. */ -export interface FsReadOutcome { - /** 1-based first line requested. */ - offset: number - /** Maximum number of lines requested. */ - limit: number - /** Returned lines, already numbered. */ - lines: FsTextLine[] - /** 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. */ +export interface FsInfo { + /** Opaque freshness token of the target right now. */ version: FsVersion - /** - * Whether this read saw the whole file (`full`) or only part of it - * (`partial`). Only a `full` view authorizes a later write/edit. - */ - view: FsView + /** 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 read-before-write decision the base service hands to a backend for a - * full-file write. `observed` means the owner has a `full` view recorded at - * `version` (the backend rejects if the file has since changed); `partial` - * means the owner saw only a non-editable view of that target; `unobserved` - * means there is no prior view (the backend may create iff the target is - * absent, else rejects as not observed). + * The explicit intent of a {@link FileSystem.writeText} call. `createIfAbsent` + * creates a missing target and rejects an existing one with `FS_NOT_OBSERVED` + * (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 a + * version mismatch throws `FS_STALE_VERSION`. */ -export type FsExpectation = - | { kind: 'observed'; version: FsVersion } - | { kind: 'partial'; version: FsVersion } - | { kind: 'unobserved' } +export type FsWriteExpectation = + | { kind: 'createIfAbsent' } + | { kind: 'replaceIfVersion'; version: FsVersion } /** Outcome of a full-file write. */ export interface FsWriteOutcome { @@ -139,29 +116,6 @@ export interface FsEditOutcome { version: FsVersion } -/** Source that last touched a recorded {@link FileState}. */ -export type FsStateSource = 'read' | 'write' | 'edit' - -/** - * What an owner has observed about one target. Keyed (inside the service) first - * by the owner object, then by {@link FsTarget.targetKey}. Only a `full` view - * authorizes write/edit. - */ -export interface FileState { - /** Backend target identity this state describes. */ - targetKey: string - /** Display path captured when the state was recorded. */ - displayPath: string - /** Opaque version the owner last saw. */ - version: FsVersion - /** Whether the owner saw the whole file or only part of it. */ - view: FsView - /** Wall-clock time the state was last updated (ms since epoch). */ - updatedAt: number - /** Operation that produced this state. */ - source: FsStateSource -} - /** * Stable, machine-routable codes for filesystem failures. Carried on * {@link FsError}; the tool registry surfaces `{ name, code }` on `isError` @@ -173,7 +127,6 @@ export type FsErrorCode = | 'FS_NOT_REGULAR_FILE' | 'FS_STALE_VERSION' | 'FS_NOT_OBSERVED' - | 'FS_PARTIAL_OBSERVATION' | 'FS_AMBIGUOUS_EDIT' | 'FS_EDIT_NOT_FOUND' | 'FS_ABORTED' diff --git a/packages/fs/fs/tests/service.spec.ts b/packages/fs/fs/tests/service.spec.ts index 84f84cbe0b..e06cfa9ee6 100644 --- a/packages/fs/fs/tests/service.spec.ts +++ b/packages/fs/fs/tests/service.spec.ts @@ -1,98 +1,69 @@ /** - * Tests for the filesystem service seam itself: registration/disposal, owner - * derivation, and the read-before-write/edit policy the base class enforces - * (which `FsExpectation` it hands the backend, multi-owner isolation, and - * state refresh) — all exercised through a fake in-memory backend that records - * the expectations it received. + * Tests for the filesystem provider seam itself: registration, duplicate-service + * behavior, disposal, and the branded id factories. The provider primitives and + * policy live in `dsh-fs-local` and `dsh-file-context`; this seam owns only the + * abstract service contract, so a minimal fake backend exercises it. */ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { FileSystem, FsError } from '@deepseek-ai/dsh-fs' +import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import type { FsEditOutcome, FsEditRequest, - FsExpectation, - FsReadOutcome, - FsReadRequest, + FsInfo, FsTarget, - FsView, + FsWriteExpectation, FsWriteOutcome, } from '@deepseek-ai/dsh-fs' -/** A fake backend: an in-memory file table, recording every expectation it is handed. */ +/** A minimal in-memory fake implementing the six provider primitives. */ class FakeFileSystem extends FileSystem { files = new Map() - versions = new Map() - /** View the next `readPage` should report (tests flip this for partial reads). */ - nextReadView: FsView = 'full' - /** Expectations handed to `createOrReplace`, in call order. */ - writeExpectations: FsExpectation[] = [] - /** Versions handed to `applyEdit`, in call order. */ - editExpectedVersions: string[] = [] - - private bump(key: string): string { - const next = (this.versions.get(key) ?? 0) + 1 - this.versions.set(key, next) - return `v${next}` - } override async resolve(path: string): Promise { - return { inputPath: path, targetKey: path, displayPath: path } + return { inputPath: path, targetKey: FsTargetKey(path), displayPath: path } } - - override async readPage(target: FsTarget, request: FsReadRequest): Promise { + override async stat(target: FsTarget): Promise { + 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 { const content = this.files.get(target.targetKey) if (content === undefined) throw new FsError(`not found: ${target.displayPath}`, 'FS_NOT_FOUND') - const allLines = content.split('\n') - const lines = allLines - .slice(request.offset - 1, request.offset - 1 + request.limit) - .map((text, i) => ({ number: request.offset + i, text })) - return { - offset: request.offset, - limit: request.limit, - lines, - totalLines: allLines.length, - version: `v${this.versions.get(target.targetKey) ?? 0}`, - view: this.nextReadView, - } + return content } - - override async createOrReplace(target: FsTarget, content: string, expected: FsExpectation): Promise { - this.writeExpectations.push(expected) + override async streamText(target: FsTarget): Promise> { + const content = await this.readText(target) + return (async function* () { yield content })() + } + override async writeText(target: FsTarget, content: string, _expected: FsWriteExpectation): Promise { const existed = this.files.has(target.targetKey) this.files.set(target.targetKey, content) - return { operation: existed ? 'update' : 'create', version: this.bump(target.targetKey) } + return { operation: existed ? 'update' : 'create', version: FsVersion('v2') } } - - override async applyEdit(target: FsTarget, edit: FsEditRequest, expected: { version: string }): Promise { - this.editExpectedVersions.push(expected.version) + override async editText(target: FsTarget, edit: FsEditRequest): Promise { const content = this.files.get(target.targetKey) ?? '' this.files.set(target.targetKey, content.split(edit.oldString).join(edit.newString)) - return { replacements: 1, replaceAll: edit.replaceAll, version: this.bump(target.targetKey) } + return { replacements: 1, replaceAll: edit.replaceAll, version: FsVersion('v3') } } } -async function setup() { - const ctx = new Context() - await ctx.plugin(FakeFileSystem) - const fs = ctx.fs as FakeFileSystem - return { ctx, fs } -} - -const READ_ALL: FsReadRequest = { offset: 1, limit: 2000 } -const ownerExec = (session: object) => ({ agent: { session } }) - -describe('FileSystem service seam', () => { - it('registers as ctx.fs and serves the API', async () => { - const { fs } = await setup() +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 outcome = await fs.read(await fs.resolve('a.txt'), READ_ALL) - expect(outcome.lines).toEqual([{ number: 1, text: '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 } = await setup() + const ctx = new Context() + await ctx.plugin(FakeFileSystem) await expect(ctx.plugin(FakeFileSystem)).rejects.toThrow() }) @@ -103,203 +74,30 @@ describe('FileSystem service seam', () => { await fiber.dispose() expect(ctx.fs).toBeUndefined() }) -}) -describe('owner derivation', () => { - it('derives the owner from exec.agent.session', async () => { - const { fs } = await setup() - const session = {} - expect(fs.owner(ownerExec(session))).toBe(session) - }) - - it('returns undefined with no exec, no agent, or no session', async () => { - const { fs } = await setup() - expect(fs.owner()).toBeUndefined() - expect(fs.owner({})).toBeUndefined() - expect(fs.owner({ agent: {} })).toBeUndefined() - }) -}) - -describe('read records observed state', () => { - it('a full read authorizes a later in-place write (observed expectation)', async () => { - const { fs } = await setup() - const exec = ownerExec({}) - fs.files.set('a.txt', 'hello') - const target = await fs.resolve('a.txt') - - await fs.read(target, READ_ALL, exec) - await fs.write(target, 'goodbye', exec) - - expect(fs.writeExpectations).toEqual([{ kind: 'observed', version: 'v0' }]) - }) - - it('a partial read does NOT authorize a write (passes a partial expectation)', async () => { - const { fs } = await setup() - const exec = ownerExec({}) - fs.files.set('a.txt', 'hello') - fs.nextReadView = 'partial' - const target = await fs.resolve('a.txt') - - await fs.read(target, { offset: 1, limit: 1 }, exec) - await fs.write(target, 'goodbye', exec) - - expect(fs.writeExpectations).toEqual([{ kind: 'partial', version: 'v0' }]) - }) - - it('skips recording when there is no owner', async () => { - const { fs } = await setup() - fs.files.set('a.txt', 'hello') - const target = await fs.resolve('a.txt') - - await fs.read(target, READ_ALL) // no exec - await fs.write(target, 'goodbye') // no exec → cannot be observed - - expect(fs.writeExpectations).toEqual([{ kind: 'unobserved' }]) - }) -}) - -describe('write policy', () => { - it('a create (no prior state) is unobserved', async () => { - const { fs } = await setup() - const exec = ownerExec({}) - const target = await fs.resolve('new.txt') - - const outcome = await fs.write(target, 'fresh', exec) - - expect(outcome.operation).toBe('create') - expect(fs.writeExpectations).toEqual([{ kind: 'unobserved' }]) - }) - - it('refreshes state to full after a write, so a follow-up edit needs no re-read', async () => { - const { fs } = await setup() - const exec = ownerExec({}) - const target = await fs.resolve('a.txt') - - await fs.write(target, 'one', exec) // create → state now full at v1 - await fs.edit(target, { oldString: 'one', newString: 'two', replaceAll: false }, exec) - - expect(fs.editExpectedVersions).toEqual(['v1']) - }) -}) - -describe('edit policy', () => { - it('rejects with FS_NOT_OBSERVED when the file was never read', async () => { - const { fs } = await setup() - const exec = ownerExec({}) - fs.files.set('a.txt', 'hello') - const target = await fs.resolve('a.txt') - - await expect( - fs.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec), - ).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) - }) - - it('rejects with FS_PARTIAL_OBSERVATION when only a partial view was recorded', async () => { - const { fs } = await setup() - const exec = ownerExec({}) - fs.files.set('a.txt', 'hello') - fs.nextReadView = 'partial' - const target = await fs.resolve('a.txt') - await fs.read(target, { offset: 1, limit: 1 }, exec) - - await expect( - fs.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec), - ).rejects.toMatchObject({ code: 'FS_PARTIAL_OBSERVATION' }) - }) - - it('rejects an empty oldString before calling the backend primitive', async () => { - const { fs } = await setup() - const exec = ownerExec({}) - fs.files.set('a.txt', 'hello') - const target = await fs.resolve('a.txt') - await fs.read(target, READ_ALL, exec) - - await expect( - fs.edit(target, { oldString: '', newString: 'bye', replaceAll: false }, exec), - ).rejects.toMatchObject({ code: 'FS_EDIT_NOT_FOUND' }) - expect(fs.editExpectedVersions).toEqual([]) - }) - - it('rejects when there is no owner (cannot prove prior observation)', async () => { - const { fs } = await setup() - fs.files.set('a.txt', 'hello') - const target = await fs.resolve('a.txt') - - await expect( - fs.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }), - ).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) - }) - - it('proceeds after a full read, passing the recorded version as the stale guard', async () => { - const { fs } = await setup() - const exec = ownerExec({}) - fs.files.set('a.txt', 'hello') - fs.versions.set('a.txt', 7) // distinguishable version - const target = await fs.resolve('a.txt') - await fs.read(target, READ_ALL, exec) - - await fs.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec) - - expect(fs.editExpectedVersions).toEqual(['v7']) - }) -}) - -describe('multi-owner isolation', () => { - it('owner A reading does not grant owner B edit authority', async () => { - const { fs } = await setup() - const a = ownerExec({}) - const b = ownerExec({}) - fs.files.set('a.txt', 'hello') - const target = await fs.resolve('a.txt') - - await fs.read(target, READ_ALL, a) - - // B never read it → B's edit must be rejected. - await expect( - fs.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, b), - ).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) - // A still may edit. - await expect( - fs.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, a), - ).resolves.toMatchObject({ replacements: 1 }) - }) - - it('each owner records its own observed version independently', async () => { - const { fs } = await setup() - const a = ownerExec({}) - const b = ownerExec({}) - fs.files.set('a.txt', 'hello') - const target = await fs.resolve('a.txt') - - await fs.read(target, READ_ALL, a) // A sees v0 - await fs.write(target, 'mid', b) // B writes unobserved → file now v1 - await fs.write(target, 'late', a) // A still holds its v0 observation - - expect(fs.writeExpectations).toEqual([ - { kind: 'unobserved' }, - { kind: 'observed', version: 'v0' }, - ]) - }) -}) - -describe('disposal releases recorded state', () => { - it('a fresh provider after disposal starts with no inherited state', async () => { + it('streamText yields the same text readText returns', async () => { const ctx = new Context() - const fiber = await ctx.plugin(FakeFileSystem) - const fs1 = ctx.fs as FakeFileSystem - const exec = ownerExec({}) - fs1.files.set('a.txt', 'hello') - await fs1.read(await fs1.resolve('a.txt'), READ_ALL, exec) - await fiber.dispose() - await ctx.plugin(FakeFileSystem) - const fs2 = ctx.fs as FakeFileSystem - fs2.files.set('a.txt', 'hello') - const target = await fs2.resolve('a.txt') - // Reusing the same exec/owner object: state must NOT carry over. - await expect( - fs2.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec), - ).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + 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') }) }) diff --git a/packages/fs/fs/tsconfig.json b/packages/fs/fs/tsconfig.json index 7b250a29c4..1ed5f54447 100644 --- a/packages/fs/fs/tsconfig.json +++ b/packages/fs/fs/tsconfig.json @@ -8,6 +8,7 @@ "references": [ { "path": "../../../vendor/cosmokit" }, { "path": "../../../vendor/cordis" }, + { "path": "../../util/brand" }, { "path": "../../llm/llm" } ] } diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index 2beb45be9c..f751ecb051 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -1,11 +1,12 @@ # @deepseek-ai/dsh-tool-fs -The **model-facing filesystem tools** — `read`, `write`, `edit` — over the `ctx.fs` seam ([`@deepseek-ai/dsh-fs`](../fs)). This is the consumer third of the filesystem capability; it owns tool names, JSON schemas, argument validation, prompt sections, and result formatting, and **never** touches filesystem I/O (no `node:fs`/`node:path`, no implementation import). +The **model-facing filesystem tools** — `read`, `write`, `edit` — over the `ctx.fileContext` policy layer ([`@deepseek-ai/dsh-file-context`](../file-context)). This is the consumer layer of the filesystem stack; it owns tool names, JSON schemas, argument validation, prompt sections, and result formatting, and **never** touches filesystem I/O (no `node:fs`/`node:path`, no implementation import) or reaches around the policy layer to `ctx.fs`. ```ts ignore-check -// Load a ctx.fs provider first, then the tools. +// Load a ctx.fs provider, the policy layer, then the tools. await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) // @deepseek-ai/dsh-fs-local -await ctx.plugin(ToolFs) // this package — registers read/write/edit +await ctx.plugin(FileContext) // @deepseek-ai/dsh-file-context +await ctx.plugin(ToolFs) // this package — registers read/write/edit ``` Each tool also ships as a subpath plugin for focused deployments: @@ -21,13 +22,17 @@ import * as editPlugin from '@deepseek-ai/dsh-tool-fs/edit' | Tool | Arguments | Behavior | |---|---|---| | `read` | `file_path`, `offset?`, `limit?` | Line-numbered UTF-8 content with a pagination footer. `offset` is 1-based; `limit` defaults to and caps at 2000 lines. | -| `write` | `file_path`, `content` | Create or fully replace a file. Overwriting an existing file requires a prior `read` (the backend enforces it); creating a new file does not. | -| `edit` | `file_path`, non-empty `old_string`, `new_string`, `replace_all?` | Literal replacement; unique match required unless `replace_all` is true. Requires a prior `read`. | +| `write` | `file_path`, `content` | Create or fully replace a file. Overwriting an existing file requires a prior `read` at the unchanged version; creating a new file does not. | +| `edit` | `file_path`, non-empty `old_string`, `new_string`, `replace_all?` | Literal replacement; unique match required unless `replace_all` is true. Requires a prior `read` (any window) and the file unchanged since. | Field names are snake_case to match Claude Code and existing harness tool schemas. -## How the read-before-write policy is enforced +## How the read-before-write/edit policy is enforced -The tools do **not** check whether a `read` ran or inspect any cache. Each tool resolves the path via `ctx.fs.resolve()`, then calls `ctx.fs.read/write/edit(target, …, exec)` — passing the current tool execution context straight through. `ctx.fs` derives the file-state owner (normally the agent session) from that context and owns the prior-observation and stale-version policy. Backend errors (`FsError`) flow through `ToolRegistry.execute()` and become `isError` tool results with their `{ name, code }` attached. +The tools do **not** check whether a `read` ran or inspect any cache. Each tool resolves the path via `ctx.fileContext.resolve()`, then calls `ctx.fileContext.read/write/edit(target, …, exec)` — passing the current tool execution context straight through. `ctx.fileContext` derives the observed-state owner (normally the agent session) from that context and owns the freshness policy: a recorded read at the file's current version authorizes a write/edit, and any windowed read counts (authorization is freshness, not a full-view requirement). Backend errors (`FsError`) flow through `ToolRegistry.execute()` and become `isError` tool results with their `{ name, code }` attached. + +## The no-bypass contract + +A model-facing read MUST go through `ctx.fileContext.read`, never `ctx.fs.readText`/`streamText`, so every successful read records observed-state before rendering — which is why the tools inject `fileContext`, not `fs`. Direct `ctx.fs` calls remain an explicit escape hatch for non-tool consumers: a direct `ctx.fs.readText` records nothing, so a later `edit` rejects with `FS_NOT_OBSERVED` until the file is read through `ctx.fileContext`. Tool schemas reach the system prompt automatically via the tool registry; this package additionally registers short prose guidance through `ctx.systemPrompt.section(...)`. diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index 744a41736d..f158142fca 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -32,6 +32,7 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-file-context": "^0.0.1", "@deepseek-ai/dsh-fs": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", @@ -40,6 +41,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-file-context": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index 3f65f5660d..d54fe3045b 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -1,8 +1,8 @@ /** * The model-facing `edit` tool: update an existing UTF-8 text file by replacing * literal text, requiring a unique match by default. Execution goes through - * `ctx.fs`, which enforces prior observation and the stale-version guard and - * owns the literal-match semantics. + * `ctx.fileContext`, which enforces prior observation (the freshness policy) + * and delegates the literal-match + stale-guard critical section to `ctx.fs`. * * @module @deepseek-ai/dsh-tool-fs/edit */ @@ -60,8 +60,8 @@ export function apply(ctx: Context): void { }, async execute(args, exec): Promise { const input = parseEditArgs(args) - const target = await ctx.fs.resolve(input.filePath) - const outcome = await ctx.fs.edit( + const target = await ctx.fileContext.resolve(input.filePath) + const outcome = await ctx.fileContext.edit( target, { oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll }, exec, @@ -76,7 +76,7 @@ export function apply(ctx: Context): void { export const name = 'fs-edit' /** Services required by the `edit` tool plugin. */ -export const inject = ['tools', 'fs', 'systemPrompt'] +export const inject = ['tools', 'fileContext', 'systemPrompt'] /** Named helper for direct registration in the root plugin and tests. */ export const applyEditTool = apply diff --git a/packages/fs/tool-fs/src/index.ts b/packages/fs/tool-fs/src/index.ts index 437c16b5dd..5509c7980b 100644 --- a/packages/fs/tool-fs/src/index.ts +++ b/packages/fs/tool-fs/src/index.ts @@ -1,13 +1,16 @@ /** * The model-facing filesystem tool suite (`read`, `write`, `edit`) over the - * `ctx.fs` seam. This root plugin registers all three tools by composing the - * per-tool registration helpers; each tool is also exposed as a subpath plugin - * (`@deepseek-ai/dsh-tool-fs/read`, `/write`, `/edit`) for focused deployments. + * `ctx.fileContext` policy layer. This root plugin registers all three tools by + * composing the per-tool registration helpers; each tool is also exposed as a + * subpath plugin (`@deepseek-ai/dsh-tool-fs/read`, `/write`, `/edit`) for focused + * deployments. * * The package owns model-facing concerns only — tool names, JSON schemas, * argument validation, prompt sections, result formatting. All filesystem - * execution goes through `ctx.fs`; this package never imports `node:fs`, - * `node:path`, or an `@deepseek-ai/dsh-fs-local` implementation. + * execution goes through `ctx.fileContext` (never directly around it to + * `ctx.fs`), so every model read records observed-state before rendering; this + * package never imports `node:fs`, `node:path`, or an + * `@deepseek-ai/dsh-fs-local` implementation. * * @module @deepseek-ai/dsh-tool-fs */ @@ -25,7 +28,7 @@ export { applyEditTool, formatEditOutput, parseEditArgs } from './edit.ts' export const name = 'tool-fs' /** Services required by the filesystem tool suite. */ -export const inject = ['tools', 'fs', 'systemPrompt'] +export const inject = ['tools', 'fileContext', 'systemPrompt'] /** Register the full `read`/`write`/`edit` filesystem tool suite. */ export function apply(ctx: Context): void { diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index bfa67a588f..8a6ae8d609 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -1,8 +1,9 @@ /** * The model-facing `read` tool: inspect a UTF-8 text file and return * line-numbered content with pagination guidance. Execution goes through - * `ctx.fs` — this module owns only the model-facing schema, argument - * validation, and result formatting, never filesystem I/O. + * `ctx.fileContext` (which records observed state and owns read windowing) — + * this module owns only the model-facing schema, argument validation, and + * result formatting, never filesystem I/O. * * @module @deepseek-ai/dsh-tool-fs/read */ @@ -10,7 +11,7 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { FsReadOutcome } from '@deepseek-ai/dsh-fs' +import type { FileReadOutcome } from '@deepseek-ai/dsh-file-context' import type {} from '@deepseek-ai/dsh-system-prompt' /** Default and maximum number of lines returned by one `read` call. */ @@ -40,7 +41,7 @@ export function parseReadArgs(args: { file_path: string; offset?: number; limit? } /** Format a read outcome as one OpenCode-style line-numbered text block body. */ -export function formatReadOutput(displayPath: string, outcome: FsReadOutcome): string { +export function formatReadOutput(displayPath: string, outcome: FileReadOutcome): string { const endLine = outcome.lines.at(-1)?.number ?? Math.max(0, outcome.offset - 1) let footer: string if (outcome.truncatedByBytes) { @@ -78,8 +79,8 @@ export function apply(ctx: Context): void { }, async execute(args, exec): Promise { const input = parseReadArgs(args) - const target = await ctx.fs.resolve(input.filePath) - const outcome = await ctx.fs.read(target, { offset: input.offset, limit: input.limit }, exec, exec.signal) + const target = await ctx.fileContext.resolve(input.filePath) + const outcome = await ctx.fileContext.read(target, { offset: input.offset, limit: input.limit }, exec, exec.signal) return [{ type: 'text', text: formatReadOutput(target.displayPath, outcome) }] }, })) @@ -89,7 +90,7 @@ export function apply(ctx: Context): void { export const name = 'fs-read' /** Services required by the `read` tool plugin. */ -export const inject = ['tools', 'fs', 'systemPrompt'] +export const inject = ['tools', 'fileContext', 'systemPrompt'] /** Named helper for direct registration in the root plugin and tests. */ export const applyReadTool = apply diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index ff66d10127..8c242c5256 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -1,8 +1,8 @@ /** * The model-facing `write` tool: create or fully replace a UTF-8 text file. - * Execution goes through `ctx.fs`, which enforces the read-before-overwrite - * policy (updating an existing file requires a prior read in the same - * execution context; creating a new file does not). + * Execution goes through `ctx.fileContext`, which enforces the freshness policy + * (creating a new file needs no prior read; replacing an existing file requires + * a prior read in the same execution context at the unchanged version). * * @module @deepseek-ai/dsh-tool-fs/write */ @@ -46,8 +46,8 @@ export function apply(ctx: Context): void { }, async execute(args, exec): Promise { const input = parseWriteArgs(args) - const target = await ctx.fs.resolve(input.filePath) - const outcome = await ctx.fs.write(target, input.content, exec, exec.signal) + const target = await ctx.fileContext.resolve(input.filePath) + const outcome = await ctx.fileContext.write(target, input.content, exec, exec.signal) return [{ type: 'text', text: formatWriteOutput(target.displayPath, outcome) }] }, })) @@ -57,7 +57,7 @@ export function apply(ctx: Context): void { export const name = 'fs-write' /** Services required by the `write` tool plugin. */ -export const inject = ['tools', 'fs', 'systemPrompt'] +export const inject = ['tools', 'fileContext', 'systemPrompt'] /** Named helper for direct registration in the root plugin and tests. */ export const applyWriteTool = apply diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index 6f81763241..d61bdb5cac 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -1,8 +1,9 @@ /** - * Integration tests: the real local backend (`dsh-fs-local`) plus the model - * tools (`dsh-tool-fs`), exercised through `ctx.tools.execute()` so nothing - * bypasses the tool registry. These verify the WORLD — files are read back from - * disk and asserted byte-for-byte — not the tool's self-report. + * Integration tests: the real local backend (`dsh-fs-local`) plus the real + * policy layer (`dsh-file-context`) plus the model tools (`dsh-tool-fs`), + * exercised through `ctx.tools.execute()` so nothing bypasses the tool registry. + * These verify the WORLD — files are read back from disk and asserted + * byte-for-byte — not the tool's self-report. */ import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -14,6 +15,7 @@ import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' +import FileContext from '@deepseek-ai/dsh-file-context' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' let dir: string @@ -28,6 +30,7 @@ beforeEach(async () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(LocalFileSystem, { cwd: dir }) + await ctx.plugin(FileContext) fiber = await ctx.plugin(ToolFs) }) afterEach(async () => { @@ -61,7 +64,6 @@ describe('write → disk', () => { const result = await call('write', { file_path: 'a.txt', content: 'clobber' }) expect(result.isError).toBe(true) expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' }) - // The world is unchanged. expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('original') }) @@ -72,6 +74,15 @@ describe('write → disk', () => { expect(result.isError).toBe(false) expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('replaced') }) + + it('rejects a full overwrite when the file changed since the read (stale)', async () => { + await writeFile(join(dir, 'a.txt'), 'original') + await call('read', { file_path: 'a.txt' }) + await writeFile(join(dir, 'a.txt'), 'changed-externally') // out-of-band change + const result = await call('write', { file_path: 'a.txt', content: 'replaced' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' }) + }) }) describe('read', () => { @@ -89,6 +100,14 @@ describe('read', () => { expect(result.isError).toBe(true) expect(result.error).toMatchObject({ code: 'FS_NOT_TEXT' }) }) + + it('paginates a multi-line file with offset/limit', async () => { + await writeFile(join(dir, 'a.txt'), 'one\ntwo\nthree\nfour') + const result = await call('read', { file_path: 'a.txt', offset: 2, limit: 2 }) + expect(text(result)).toContain('2: two') + expect(text(result)).toContain('3: three') + expect(text(result)).toContain('(Showing lines 2-3 of 4. Use offset=4 to continue.)') + }) }) describe('edit → disk', () => { @@ -108,13 +127,27 @@ describe('edit → disk', () => { expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello world') }) - it('rejects an edit after only a partial read, leaving the file untouched', async () => { - await writeFile(join(dir, 'a.txt'), 'hello\nworld') + it('lets a WINDOWED read authorize an edit when the file is unchanged (freshness, not full-view)', async () => { + // A file with more lines than the read window; read only the first line. + const lines = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`) + await writeFile(join(dir, 'a.txt'), lines.join('\n')) + const read = await call('read', { file_path: 'a.txt', offset: 1, limit: 1 }) + expect(read.isError).toBe(false) + expect(text(read)).toContain('(Showing lines 1-1 of 20') + + // Editing a line OUTSIDE the window is authorized because the file is unchanged. + const result = await call('edit', { file_path: 'a.txt', old_string: 'line 12', new_string: 'LINE 12' }) + expect(result.isError).toBe(false) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe(lines.map(l => l === 'line 12' ? 'LINE 12' : l).join('\n')) + }) + + it('rejects an edit when the file changed since the windowed read (stale before matching)', async () => { + await writeFile(join(dir, 'a.txt'), 'hello world') await call('read', { file_path: 'a.txt', offset: 1, limit: 1 }) + await writeFile(join(dir, 'a.txt'), 'goodbye') // out-of-band change removes 'world' const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_PARTIAL_OBSERVATION' }) - expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello\nworld') + expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' }) }) it('rejects an ambiguous match without replace_all', async () => { @@ -141,3 +174,15 @@ describe('edit → disk', () => { expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('one three') }) }) + +describe('no-bypass / escape-hatch contract', () => { + it('a direct ctx.fs.readText records no observed-state, so a later edit rejects', async () => { + await writeFile(join(dir, 'a.txt'), 'hello world') + // Reach AROUND the policy layer — an explicit escape hatch for non-tool consumers. + await ctx.fs.readText(await ctx.fs.resolve('a.txt')) + // The model-facing edit still rejects: the read was not through ctx.fileContext. + const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) +}) diff --git a/packages/fs/tool-fs/tests/subpaths.spec.ts b/packages/fs/tool-fs/tests/subpaths.spec.ts index ac35babe04..7243955969 100644 --- a/packages/fs/tool-fs/tests/subpaths.spec.ts +++ b/packages/fs/tool-fs/tests/subpaths.spec.ts @@ -1,36 +1,43 @@ /** * Tests for the per-tool subpath plugins (`@deepseek-ai/dsh-tool-fs/read`, * `/write`, `/edit`): each registers exactly one tool, injects the same - * services, and cleans up on disposal. + * services (`tools`, `fileContext`, `systemPrompt`), and cleans up on disposal. */ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import { FileSystem } from '@deepseek-ai/dsh-fs' +import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import type { FsEditOutcome, - FsReadOutcome, + FsInfo, FsTarget, FsWriteOutcome, } from '@deepseek-ai/dsh-fs' +import FileContext from '@deepseek-ai/dsh-file-context' import * as readPlugin from '@deepseek-ai/dsh-tool-fs/read' import * as writePlugin from '@deepseek-ai/dsh-tool-fs/write' import * as editPlugin from '@deepseek-ai/dsh-tool-fs/edit' class StubFs extends FileSystem { override async resolve(path: string): Promise { - return { inputPath: path, targetKey: path, displayPath: path } + return { inputPath: path, targetKey: FsTargetKey(path), displayPath: path } } - override async readPage(): Promise { - return { offset: 1, limit: 1, lines: [], totalLines: 0, version: 'v', view: 'full' } + override async stat(): Promise { + return { version: FsVersion('v'), type: 'file', size: 0 } } - override async createOrReplace(): Promise { - return { operation: 'create', version: 'v' } + override async readText(): Promise { + return '' } - override async applyEdit(): Promise { - return { replacements: 1, replaceAll: false, version: 'v' } + override async streamText(): Promise> { + return (async function* () { yield '' })() + } + override async writeText(): Promise { + return { operation: 'create', version: FsVersion('v') } + } + override async editText(): Promise { + return { replacements: 1, replaceAll: false, version: FsVersion('v') } } } @@ -39,6 +46,7 @@ async function base() { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(StubFs) + await ctx.plugin(FileContext) return ctx } @@ -64,7 +72,7 @@ describe('subpath plugins', () => { expect(ctx.tools.schemas()).toHaveLength(0) }) - it('stays pending without a ctx.fs provider', async () => { + it('stays pending without a ctx.fileContext provider', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 594a07dbbd..ef1490b5ce 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -1,8 +1,10 @@ /** - * Consumer-surface tests for the filesystem tools using a fake `ctx.fs` that - * records the execution context it received and returns canned outcomes. These - * verify schemas, argument validation, result formatting, FsError→isError - * propagation, and that each tool passes `exec` straight through to `ctx.fs`. + * Consumer-surface tests for the filesystem tools. They run the REAL + * `ctx.fileContext` policy service over a fake `ctx.fs` provider (the genuine + * collaborator, per the prefer-the-real-implementation rule), so they verify + * schemas, argument validation, result formatting, FsError→isError propagation, + * and that each tool records observed-state through `ctx.fileContext` (the + * no-bypass contract) — not just that it moved bytes. */ import { describe, expect, it } from 'vitest' @@ -10,65 +12,56 @@ import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import { FileSystem, FsError } from '@deepseek-ai/dsh-fs' +import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import type { FsEditOutcome, FsEditRequest, - FsExecContext, - FsReadOutcome, - FsReadRequest, + FsInfo, FsTarget, + FsWriteExpectation, FsWriteOutcome, } from '@deepseek-ai/dsh-fs' +import FileContext from '@deepseek-ai/dsh-file-context' +import type { FileReadOutcome } from '@deepseek-ai/dsh-file-context' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import { formatReadOutput } from '@deepseek-ai/dsh-tool-fs' -/** - * Records the public-API calls (and the exec each received) and returns canned - * outcomes; lets a test arm a rejection. Overrides the public methods directly - * (not the primitives) so we observe exactly what the tool passed. - */ +/** An in-memory fake provider; a test can arm a rejection on any primitive. */ class FakeFs extends FileSystem { - calls: Array<{ op: string; exec: FsExecContext | undefined; target: FsTarget }> = [] + files = new Map() rejectWith?: FsError + private throwIfArmed(): void { + if (this.rejectWith) throw this.rejectWith + } + override async resolve(path: string): Promise { - return { inputPath: path, targetKey: `key:${path}`, displayPath: `/abs/${path}` } + return { inputPath: path, targetKey: FsTargetKey(`key:${path}`), displayPath: `/abs/${path}` } } - - override async readPage(): Promise { - throw new Error('not used: tool tests override read()') + override async stat(target: FsTarget): Promise { + this.throwIfArmed() + const content = this.files.get(target.targetKey) + if (content === undefined) return undefined + return { version: FsVersion('v1'), type: 'file', size: content.length } } - override async createOrReplace(): Promise { - throw new Error('not used') + override async readText(target: FsTarget): Promise { + return this.files.get(target.targetKey) ?? '' } - override async applyEdit(): Promise { - throw new Error('not used') + override async streamText(target: FsTarget): Promise> { + const content = this.files.get(target.targetKey) ?? '' + return (async function* () { yield content })() } - - override async read(target: FsTarget, _request: FsReadRequest, exec?: FsExecContext): Promise { - this.calls.push({ op: 'read', exec, target }) - if (this.rejectWith) throw this.rejectWith - return { - offset: 1, - limit: 2000, - lines: [{ number: 1, text: 'hello' }, { number: 2, text: 'world' }], - totalLines: 2, - version: 'v1', - view: 'full', - } + override async writeText(target: FsTarget, content: string, _expected: FsWriteExpectation): Promise { + this.throwIfArmed() + const existed = this.files.has(target.targetKey) + this.files.set(target.targetKey, content) + return { operation: existed ? 'update' : 'create', version: FsVersion('v2') } } - - override async write(target: FsTarget, _content: string, exec?: FsExecContext): Promise { - this.calls.push({ op: 'write', exec, target }) - if (this.rejectWith) throw this.rejectWith - return { operation: 'create', version: 'v1' } - } - - override async edit(target: FsTarget, _edit: FsEditRequest, exec?: FsExecContext): Promise { - this.calls.push({ op: 'edit', exec, target }) - if (this.rejectWith) throw this.rejectWith - return { replacements: 1, replaceAll: false, version: 'v1' } + override async editText(target: FsTarget, edit: FsEditRequest): Promise { + this.throwIfArmed() + const content = this.files.get(target.targetKey) ?? '' + this.files.set(target.targetKey, content.split(edit.oldString).join(edit.newString)) + return { replacements: 1, replaceAll: edit.replaceAll, version: FsVersion('v3') } } } @@ -77,6 +70,7 @@ async function setup() { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(FakeFs) + await ctx.plugin(FileContext) await ctx.plugin(ToolFs) const fs = ctx.fs as FakeFs return { ctx, fs } @@ -110,11 +104,11 @@ describe('registration', () => { expect(prompt).toContain('Use the edit tool') }) - it('stays pending until ctx.fs exists (inject)', async () => { + it('stays pending until ctx.fileContext exists (inject)', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - await ctx.plugin(ToolFs) // no fs provider + await ctx.plugin(ToolFs) // no fileContext provider expect(ctx.tools.schemas()).toHaveLength(0) }) @@ -123,6 +117,7 @@ describe('registration', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(FakeFs) + await ctx.plugin(FileContext) const fiber = await ctx.plugin(ToolFs) expect(ctx.tools.schemas()).toHaveLength(3) await fiber.dispose() @@ -132,7 +127,8 @@ describe('registration', () => { describe('read tool', () => { it('formats line-numbered content with a footer', async () => { - const { ctx } = await setup() + const { ctx, fs } = await setup() + fs.files.set('key:a.txt', 'hello\nworld') const result = await call(ctx, 'read', { file_path: 'a.txt' }) expect(result.isError).toBe(false) expect(text(result)).toBe(`/abs/a.txt @@ -166,18 +162,25 @@ describe('read tool', () => { expect(text(result)).toContain('file_path must be a non-empty string') }) - it('passes the execution context through to ctx.fs', async () => { + it('records observed state so a follow-up edit by the same session is authorized', async () => { const { ctx, fs } = await setup() const session = {} - await call(ctx, 'read', { file_path: 'a.txt' }, { session }) - expect(fs.calls).toHaveLength(1) - expect(fs.calls[0]?.op).toBe('read') - expect(fs.calls[0]?.exec?.agent?.session).toBe(session) + fs.files.set('key:a.txt', 'hello') + expect((await call(ctx, 'read', { file_path: 'a.txt' }, { session })).isError).toBe(false) + const edited = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'hello', new_string: 'bye' }, { session }) + expect(edited.isError).toBe(false) + }) + + it('propagates FS_NOT_FOUND for an absent file', async () => { + const { ctx } = await setup() + const result = await call(ctx, 'read', { file_path: 'missing.txt' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ code: 'FS_NOT_FOUND' }) }) }) describe('formatReadOutput footer variants', () => { - const base = { offset: 1, limit: 2000, lines: [{ number: 1, text: 'x' }], totalLines: 1, version: 'v', view: 'full' as const } + const base: FileReadOutcome = { offset: 1, limit: 2000, lines: [{ number: 1, text: 'x' }], totalLines: 1, version: FsVersion('v') } it('reports a byte-capped read', () => { const out = formatReadOutput('/f', { ...base, totalLines: 99, truncatedByBytes: true }) @@ -225,9 +228,12 @@ describe('write tool', () => { }) describe('edit tool', () => { - it('formats a single-replacement success', async () => { - const { ctx } = await setup() - const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }) + it('formats a single-replacement success after a read', async () => { + const { ctx, fs } = await setup() + const session = {} + fs.files.set('key:a.txt', 'a') + await call(ctx, 'read', { file_path: 'a.txt' }, { session }) + const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }, { session }) expect(text(result)).toBe('The file /abs/a.txt has been updated successfully.') }) @@ -252,19 +258,11 @@ describe('edit tool', () => { expect(text(result)).toContain('file_path must be a non-empty string') }) - it('propagates FS_NOT_OBSERVED from the backend', async () => { + it('propagates FS_NOT_OBSERVED when the file was never read', async () => { const { ctx, fs } = await setup() - fs.rejectWith = new FsError('read first', 'FS_NOT_OBSERVED') - const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }) + fs.files.set('key:a.txt', 'hello') + const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }, { session: {} }) expect(result.isError).toBe(true) expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' }) }) - - it('propagates FS_PARTIAL_OBSERVATION from the backend', async () => { - const { ctx, fs } = await setup() - fs.rejectWith = new FsError('read fully first', 'FS_PARTIAL_OBSERVATION') - const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }) - expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_PARTIAL_OBSERVATION' }) - }) }) diff --git a/packages/fs/tool-fs/tsconfig.json b/packages/fs/tool-fs/tsconfig.json index ee5a853c91..b8bd0b2148 100644 --- a/packages/fs/tool-fs/tsconfig.json +++ b/packages/fs/tool-fs/tsconfig.json @@ -11,6 +11,7 @@ { "path": "../../llm/llm" }, { "path": "../../core/tools" }, { "path": "../../core/system-prompt" }, - { "path": "../fs" } + { "path": "../fs" }, + { "path": "../file-context" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cae3a42202..1095777517 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -236,8 +236,23 @@ 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/file-context: + 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: devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -266,6 +281,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-file-context': + specifier: workspace:^ + version: link:../file-context '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../fs diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index d9df0302ab..1c5ec2430e 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -37,19 +37,17 @@ { "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": "FsExecContext", "source": "packages/fs/fs/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": "FsReadRequest", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTextLine", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsView", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsReadOutcome", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsExpectation", "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": "FsWriteExpectation", "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": "FsStateSource", "source": "packages/fs/fs/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FileState", "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": "FsErrorCode", "source": "packages/fs/fs/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FileContextExec", "source": "packages/fs/file-context/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FileReadRequest", "source": "packages/fs/file-context/src/types.ts" }, + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FileReadOutcome", "source": "packages/fs/file-context/src/types.ts" } ] } diff --git a/tsconfig.build.json b/tsconfig.build.json index ea3882b873..d32b663b9a 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -28,6 +28,7 @@ { "path": "./packages/bash/tool-bash" }, { "path": "./packages/fs/fs" }, { "path": "./packages/fs/fs-local" }, + { "path": "./packages/fs/file-context" }, { "path": "./packages/fs/tool-fs" }, { "path": "./packages/support/invariants" }, { "path": "./packages/ui/acp" }, From 1409e2ed154de0f22fe9666e7dfa83bb3f58648e Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 26 Jun 2026 17:45:54 +0800 Subject: [PATCH 05/75] fix: address codex review round 1 Translate a mid-read AbortError from readFile into the seam's structured FsError('FS_ABORTED') in readWholeText and readForEdit (the streaming/write paths already did), and make the socket-type probe test reject on a listen error instead of hanging where unix-domain sockets are unavailable. --- packages/fs/fs-local/src/fsio.ts | 20 +++++++++++++++++-- packages/fs/fs-local/tests/fsio.spec.ts | 26 ++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index 6a2b5a28cc..a22097699a 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -49,6 +49,22 @@ 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 { + 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}`) @@ -178,7 +194,7 @@ async function statRegularFile(target: LocalTarget, verb: 'read', signal?: Abort */ export async function readWholeText(target: LocalTarget, signal?: AbortSignal): Promise { await statRegularFile(target, 'read', signal) - const raw = await readFile(target.targetKey, signal ? { 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') @@ -330,7 +346,7 @@ export async function readForEdit( signal?: AbortSignal, ): Promise<{ content: string; lineEndings: LineEndings }> { throwIfAborted(signal, 'edit') - const buffer = await readFile(absolutePath, signal ? { signal } : {}) + 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) diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index 13ab9ed860..cb469c7466 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -94,7 +94,10 @@ describe('probe', () => { it('reports a socket/special file as type "other"', async () => { const sockPath = join(dir, 'sock') const server = createServer() - await new Promise((resolve) => { server.listen(sockPath, () => { resolve() }) }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(sockPath, () => { resolve() }) + }) try { expect((await probe(sockPath))?.type).toBe('other') } finally { @@ -133,6 +136,17 @@ describe('readWholeText', () => { 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', () => { @@ -281,4 +295,14 @@ describe('readForEdit + restoreLineEndings', () => { 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' }) + }) }) From b80291206710e0fbd2165ea24f33972168579df3 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 26 Jun 2026 17:59:40 +0800 Subject: [PATCH 06/75] fix: address codex review round 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve targetKey by realpathing the nearest EXISTING ancestor and re-appending the missing suffix, so a not-yet-created file under a symlinked ancestor with missing intermediate dirs gets the same key before and after creation — keeping observed-state intact across a write→edit cycle. Make the socket-type probe test skip (not fail) when a sandbox forbids unix-domain sockets. --- packages/fs/fs-local/src/fsio.ts | 39 ++++++++++++++++--------- packages/fs/fs-local/tests/fsio.spec.ts | 36 +++++++++++++++++++---- 2 files changed, 56 insertions(+), 19 deletions(-) diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index a22097699a..61cc3fee1a 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -104,11 +104,13 @@ export interface PathInfo { /** * Resolve a path to its absolute display path and realpath identity. Relative - * paths are based on `cwd`. The `targetKey` realpaths the parent directory and - * re-appends the basename, so a not-yet-created file gets the same stable key - * it will have after creation (the directory exists even when the file does - * not). Two input paths reaching the same file via symlinks share one key. - * Falls back to the absolute path when even the parent cannot be resolved. + * 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 { if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND') @@ -117,16 +119,27 @@ export async function resolveLocalTarget(cwd: string, path: string): Promise { expect(viaLink.displayPath).toBe(link) }) - it('falls back to the absolute path when even the parent dir is absent', async () => { + 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(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 () => { @@ -94,10 +110,18 @@ describe('probe', () => { it('reports a socket/special file as type "other"', async () => { const sockPath = join(dir, 'sock') const server = createServer() - await new Promise((resolve, reject) => { - server.once('error', reject) - server.listen(sockPath, () => { resolve() }) - }) + try { + await new Promise((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 { From d612ebaef15cf2ac1e5182489262d16b3ddfc6ee Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 26 Jun 2026 18:14:30 +0800 Subject: [PATCH 07/75] fix: address codex review round 3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the freshness token observed AFTER the read (re-stat post-read, falling back to the routing stat if the file vanished) so the version returned/recorded matches the bytes returned — a writer racing between the routing stat and the read can no longer make a follow-up edit spuriously stale. Stream reads when the backend reports no size, so a size-less backend never buffers a large file whole. Update the cordis-catalog link map to the current filesystem API symbols (FileContextExec/FileReadRequest/FileReadOutcome/FsInfo/FsWriteExpectation). --- docs/cordis-catalog/events-and-services.md | 4 +- packages/fs/file-context/src/index.ts | 19 ++++++-- packages/fs/file-context/tests/policy.spec.ts | 46 ++++++++++++++++++- scripts/gen-cordis-catalog.ts | 9 ++-- 4 files changed, 66 insertions(+), 12 deletions(-) diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index c4896ec0ad..78f9f44324 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -351,7 +351,7 @@ async write(target: FsTarget, content: string, exec?: FileContextExec, signal?: async edit(target: FsTarget, edit: FsEditRequest, exec?: FileContextExec, signal?: AbortSignal): Promise ``` -Types: [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) +Types: [FileContextExec](../core-data-structures/filesystem.md) · [FileReadOutcome](../core-data-structures/filesystem.md) · [FileReadRequest](../core-data-structures/filesystem.md) · [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) Source: [`packages/fs/file-context/src/index.ts:65`](../../packages/fs/file-context/src/index.ts) @@ -376,7 +376,7 @@ abstract writeText(target: FsTarget, content: string, expected: FsWriteExpectati abstract editText(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, signal?: AbortSignal): Promise ``` -Types: [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) +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) · [FsWriteExpectation](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) Source: [`packages/fs/fs/src/index.ts:90`](../../packages/fs/fs/src/index.ts) diff --git a/packages/fs/file-context/src/index.ts b/packages/fs/file-context/src/index.ts index 6dbb33d1d2..c8ed44dde1 100644 --- a/packages/fs/file-context/src/index.ts +++ b/packages/fs/file-context/src/index.ts @@ -118,27 +118,36 @@ export class FileContext extends Service { /** * Read a bounded line window from a target. Stats first (rejecting an absent * target with `FS_NOT_FOUND` and a non-regular one with `FS_NOT_REGULAR_FILE`), - * chooses `readText` vs `streamText` by size, builds the window, and — when an - * owner is derivable — records the version so a later write/edit is authorized. + * chooses `readText` vs `streamText` by size — streaming when the size is + * large OR unknown so a size-less backend never buffers an arbitrarily large + * file — builds the window, then records the version observed AFTER the read + * so the recorded freshness token corresponds to the bytes actually returned + * (a writer racing between the routing stat and the read can't make a + * follow-up edit spuriously stale against a pre-read version). */ async read(target: FsTarget, request: FileReadRequest, exec?: FileContextExec, signal?: AbortSignal): Promise { const info = await this.ctx.fs.stat(target, signal) if (!info) throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND') if (info.type !== 'file') throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') - const chunks = info.size !== undefined && info.size >= STREAM_MIN_SIZE + const chunks = info.size === undefined || info.size >= STREAM_MIN_SIZE ? await this.ctx.fs.streamText(target, signal) : [await this.ctx.fs.readText(target, signal)] const window = await buildWindow(chunks, request, target.displayPath) + // The version that matches the bytes just read: a stat taken after the read + // (falling back to the routing stat if the file vanished in the interim). + const after = await this.ctx.fs.stat(target, signal) + const version = after?.version ?? info.version + const owner = this.owner(exec) - if (owner) this.record(owner, target.targetKey, info.version) + if (owner) this.record(owner, target.targetKey, version) return { offset: request.offset, limit: request.limit, lines: window.lines, totalLines: window.totalLines, - version: info.version, + version, ...window.truncatedByBytes ? { truncatedByBytes: true } : {}, } } diff --git a/packages/fs/file-context/tests/policy.spec.ts b/packages/fs/file-context/tests/policy.spec.ts index e15398eb54..317776d5b3 100644 --- a/packages/fs/file-context/tests/policy.spec.ts +++ b/packages/fs/file-context/tests/policy.spec.ts @@ -27,6 +27,8 @@ class FakeFs extends FileSystem { versions = new Map() /** Size to report from stat (lets a test push read onto the streaming path). */ reportSize?: number + /** When true, stat omits `size` entirely (a size-less backend). */ + omitSize = false /** Whether streamText was used for the last read (vs readText). */ lastReadStreamed = false writeExpectations: FsWriteExpectation[] = [] @@ -47,7 +49,7 @@ class FakeFs extends FileSystem { override async stat(target: FsTarget): Promise { const content = this.files.get(target.targetKey) if (content === undefined) return undefined - return { version: this.ver(target.targetKey), type: 'file', size: this.reportSize ?? content.length } + return { version: this.ver(target.targetKey), type: 'file', ...this.omitSize ? {} : { size: this.reportSize ?? content.length } } } override async readText(target: FsTarget): Promise { this.lastReadStreamed = false @@ -154,6 +156,48 @@ describe('read', () => { expect(fs.lastReadStreamed).toBe(true) }) + it('streams when the backend reports no size (never buffers a size-less file)', async () => { + const { fs, fileContext } = await setup() + fs.files.set('a.txt', 'one\ntwo') + fs.omitSize = true + await fileContext.read(await fs.resolve('a.txt'), READ_ALL) + expect(fs.lastReadStreamed).toBe(true) + }) + + it('records the version observed after the read, not the routing stat', async () => { + const { fs, fileContext } = await setup() + const exec = ownerExec({}) + fs.files.set('a.txt', 'hello') + fs.versions.set('a.txt', 1) + const target = await fs.resolve('a.txt') + // A writer bumps the version after the routing stat but before the post-read stat. + const realReadText = fs.readText.bind(fs) + fs.readText = async (t) => { + const text = await realReadText(t) + fs.versions.set('a.txt', 5) // file changed during the read + return text + } + const outcome = await fileContext.read(target, READ_ALL, exec) + expect(outcome.version).toBe('v5') + // The recorded (post-read) version authorizes an edit without going stale. + await fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec) + expect(fs.editExpectedVersions).toEqual(['v5']) + }) + + it('falls back to the routing-stat version if the file vanishes after the read', async () => { + const { fs, fileContext } = await setup() + fs.files.set('a.txt', 'hello') + const target = await fs.resolve('a.txt') + const realReadText = fs.readText.bind(fs) + fs.readText = async (t) => { + const text = await realReadText(t) + fs.files.delete('a.txt') // vanishes → post-read stat returns undefined + return text + } + const outcome = await fileContext.read(target, READ_ALL) + expect(outcome.version).toBe('v0') // the routing-stat version + }) + it('surfaces truncatedByBytes when the window hits the byte cap', async () => { const { fs, fileContext } = await setup() fs.files.set('big.txt', Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n')) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 41f8830335..4d2598155b 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -77,13 +77,14 @@ const LINK_MAP: Record = { BashTaskRead: 'bash.md', FsEditOutcome: 'filesystem.md', FsEditRequest: 'filesystem.md', - FsExecContext: 'filesystem.md', - FsExpectation: 'filesystem.md', - FsReadOutcome: 'filesystem.md', - FsReadRequest: 'filesystem.md', + FsInfo: 'filesystem.md', FsTarget: 'filesystem.md', FsVersion: 'filesystem.md', + FsWriteExpectation: 'filesystem.md', FsWriteOutcome: 'filesystem.md', + FileContextExec: 'filesystem.md', + FileReadRequest: 'filesystem.md', + FileReadOutcome: 'filesystem.md', } /** One harness event, extracted from an `interface Events` block. */ From a4091daa3d7bf3f9f9a958969ae45878e57e5d83 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 25 Jun 2026 13:59:34 +0800 Subject: [PATCH 08/75] docs: propose web capability seam --- docs/rfc/README.md | 1 + .../2026-06-24-web-capability-seam.md | 385 ++++++++++++++++++ 2 files changed, 386 insertions(+) create mode 100644 docs/rfc/proposed/architecture/2026-06-24-web-capability-seam.md diff --git a/docs/rfc/README.md b/docs/rfc/README.md index a731aa8ff9..73a2675caa 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -59,6 +59,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r |---|---| | [Runtime schemas for the event vocabulary (Zod vs the merge-extensible-map pattern)](proposed/architecture/2026-06-16-typed-event-schemas.md) | 2026-06-16 | | [Extract a generic long-running tool runtime](proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 | +| [Web capability seam - provider registry and model-facing web tools](proposed/architecture/2026-06-24-web-capability-seam.md) | 2026-06-24 | ### Process diff --git a/docs/rfc/proposed/architecture/2026-06-24-web-capability-seam.md b/docs/rfc/proposed/architecture/2026-06-24-web-capability-seam.md new file mode 100644 index 0000000000..e97e8380da --- /dev/null +++ b/docs/rfc/proposed/architecture/2026-06-24-web-capability-seam.md @@ -0,0 +1,385 @@ +# RFC: Web capability seam - stable tools over multiple providers + +Status: proposed + +## Problem + +The harness needs model-facing web tools without binding the model contract to one vendor's API shape. Search is the immediate pressure point: the first version should support at least Exa search and Perplexity search — two deliberately different provider shapes (Exa returns a flat `results[]` of `{title, url, highlights, publishedDate}`; Perplexity returns a generated answer plus citations), which is what proves the normalized seam does not just mirror one vendor. Fetch is a separate capability: an anonymous public HTTP(S) fetch backend has transport, security, redirect, decoding, and size-limit concerns that are not the same as provider-backed search. + +The model-facing surface should stay stable while backends change. A search provider swap should not change how the model asks for a query, and a fetch implementation swap should not change how the model asks for a URL. Conversely, a provider package should not expose its own model-facing tool schema just because it has extra provider-specific knobs. + +Putting search and fetch directly in `dsh-tool-web` would make the model-facing tool own provider selection, backend request mapping, transport policy, result normalization, prompt guidance, presentation, and schema registration at once. Letting each provider register its own tool has the opposite problem: tool availability, names, descriptions, and parameters would depend on whichever provider packages happen to load, and provider-specific fields would leak into the model contract. + +There is also a provider-selection question. Existing `tool-bash` and `tool-fs` can rely on Cordis `inject` because there is one backend service key. Web has two independent capabilities (`search` and `fetch`) and potentially multiple providers per capability. `inject: ['web']` proves the seam exists; it does not prove a usable search or fetch provider exists, and it does not define which provider should win when several are registered. + +## Proposal + +Introduce web access as a first-class capability seam following [the capability-seam RFC](../../implemented/architecture/2026-06-13-capability-seams.md): + +1. `@deepseek-ai/dsh-web` (`packages/web/web`) owns `ctx.web`, provider registration, provider selection, shared request/result vocabulary, and web-specific errors. +2. Provider packages implement concrete backends and register capabilities with `ctx.web`, for example `@deepseek-ai/dsh-web-search-exa`, `@deepseek-ai/dsh-web-search-perplexity`, and `@deepseek-ai/dsh-web-fetch-local`. +3. `@deepseek-ai/dsh-tool-web` (`packages/web/tool-web`) owns the model-facing `web_search` and `web_fetch` tool schemas, prompt sections, argument validation, result formatting, and tool-owned presentation over `ctx.web`. + +Providers do not register tools. Providers register capabilities. `dsh-tool-web` is the only owner of model-facing names, descriptions, prompt guidance, JSON schemas, and presentation. + +Search and fetch are separate capabilities and separate model-facing tools, but they are deliberately one seam. `ctx.web` is a single web-access middle layer between provider packages on one side and the tool consumer on the other: one service to inject, one provider-selection policy owner, one abort/error vocabulary, one place a product configures "how this harness reaches the web." The two halves do not share a request schema and have no shared business logic — search normalizes provider-backed discovery into a portable result with optional answer text and citeable sources, while fetch retrieves a concrete public HTTP(S) URL and returns a status code plus bounded decoded content — but they are parallel registries on one capability surface, not two surfaces. The cost is a `WebService` whose registry/status/exec methods come in `Search`/`Fetch` pairs; that parallelism is intentional, not a missed extraction. Splitting into `dsh-search` and `dsh-fetch` is the rejected alternative below. + +`dsh-tool-web` should register model-facing web tools when the product has enabled those tools and the `ctx.web` seam is present. Backend availability is an execution-time concern, not a schema-registration concern: + +- Register `web_search` when web search is enabled for the product/app. +- Register `web_fetch` when web fetch is enabled for the product/app. +- Do not unregister a tool merely because its selected provider is missing, misconfigured, missing credentials, ambiguous, or temporarily unavailable. +- Resolve the provider at execution time, and return a structured `WebError` when the selected capability cannot run. + +This keeps the model schema stable without making plugin load order, credential state, or HMR timing part of the model-facing contract. If web search is enabled but no usable search provider exists, `web_search` remains visible and execution fails with a structured `WebError` such as `WEB_PROVIDER_UNAVAILABLE` or `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. If a provider appears after `dsh-tool-web`, the next execution can use it without changing the schema. If a provider disappears mid-call, execution fails with a structured `WebError` instead of silently choosing another provider or falling through to `UNKNOWN_TOOL`. + +The first version's provider-change signal is intentionally small. `web/providers-change` has no payload, carries no capability graph, and does not expose provider metadata. It means only "the provider registry changed; observers may recompute status from `ctx.web`." `searchStatus()` and `fetchStatus()` remain derived, not stored, and they are diagnostics plus execution-resolution inputs rather than tool-schema visibility switches. + +## Package topology + +The three-package interface/implementation/consumer split follows bash and filesystem, but the *interface* package is closer to the LLM seam. `LlmService` (`packages/llm/llm/src/index.ts`) is a name-keyed provider registry: `registerAdapter(models, adapter)` stores adapters in a `Map`, returns a disposer, throws `DUPLICATE_ADAPTER` on duplicate keys, and throws `NO_ADAPTER` at resolution time. `ctx.web` follows that registry shape, but has two capability kinds and one small selection-status layer so diagnostics and execution can explain why a search or fetch capability can or cannot run. + +The dependency direction mirrors bash and filesystem: + +```text +@deepseek-ai/dsh-tool-web --depends on--> @deepseek-ai/dsh-web <--depends on-- @deepseek-ai/dsh-web-search-exa + consumer interface implementation + <--depends on-- @deepseek-ai/dsh-web-search-perplexity + implementation + <--depends on-- @deepseek-ai/dsh-web-fetch-local + implementation +``` + +At runtime, provider packages register capabilities with `ctx.web`; `tool-web` reads capability status and registers stable tools with `ctx.tools`: + +```mermaid +flowchart LR + exa["@deepseek-ai/dsh-web-search-exa"] -->|registerSearchProvider| web["@deepseek-ai/dsh-web / ctx.web"] + perplexity["@deepseek-ai/dsh-web-search-perplexity"] -->|registerSearchProvider| web + fetchLocal["@deepseek-ai/dsh-web-fetch-local"] -->|registerFetchProvider| web + toolWeb["@deepseek-ai/dsh-tool-web"] -->|searchStatus/fetchStatus| web + toolWeb -->|ctx.tools.register| webSearch["tool: web_search"] + toolWeb -->|ctx.tools.register| webFetch["tool: web_fetch"] +``` + +`@deepseek-ai/dsh-web` depends only on Cordis and low-level harness support. It declares `ctx.web`, provider interfaces, request/result types, status types, and error codes. It does not import tool, agent, session, LLM, or provider packages. + +Provider packages depend on `@deepseek-ai/dsh-web` and Cordis. They own credentials, endpoint config, provider-specific request mapping, provider-specific response parsing, and provider-specific error translation into `WebError`. They issue network requests with the platform-native `fetch` (Node 24), mirroring `@deepseek-ai/dsh-llm-deepseek`'s adapter, NOT a cordis HTTP-client service (`ctx.http`/`@cordisjs/plugin-http`) — even where a Perplexity provider's request is shaped like an OpenAI-compatible chat completion, that wire shape is a provider-private detail and does not make the provider depend on `ctx.llm`. + +`@deepseek-ai/dsh-tool-web` depends on `@deepseek-ai/dsh-web`, `@deepseek-ai/dsh-tools`, `@deepseek-ai/dsh-system-prompt`, and Cordis. It never imports concrete provider packages. + +## `ctx.web` contract + +`ctx.web` is a provider registry plus a provider-selecting execution surface. The registry half should stay close to `LlmService`: a `Map` per capability kind, `registerSearchProvider` / `registerFetchProvider` methods that return disposers, duplicate ids that throw `WebError`, and execution-time resolution that throws when the selected provider is absent or unusable. The exact TypeScript signatures belong to the implementation PR, but the seam should expose this shape: + +```ts +interface WebSearchProvider { + readonly id: string + status(): WebProviderStatus + search(request: WebSearchRequest, exec?: WebExecContext): Promise +} + +interface WebFetchProvider { + readonly id: string + status(): WebProviderStatus + fetch(request: WebFetchRequest, exec?: WebExecContext): Promise +} + +interface WebService { + registerSearchProvider(provider: WebSearchProvider): () => void + registerFetchProvider(provider: WebFetchProvider): () => void + + searchStatus(): WebCapabilityStatus + fetchStatus(): WebCapabilityStatus + + search(request: WebSearchRequest, exec?: WebExecContext): Promise + fetch(request: WebFetchRequest, exec?: WebExecContext): Promise +} + +interface WebExecContext { + readonly signal?: AbortSignal +} +``` + +`WebExecContext` is execution control, not business input. The first version should carry only `signal` so `tool-web` can propagate turn cancellation, tool timeout, and agent disposal into provider network requests, SSE readers, and expensive decoding. It should not pass `ToolExecution` through the seam, because that would make `dsh-web` depend on `dsh-tools`. + +`@deepseek-ai/dsh-web` should also declare a Cordis event named `web/providers-change`. Provider ids are stable strings and unique within their capability kind. Registering a duplicate search provider id or duplicate fetch provider id should fail rather than silently replace the old provider. Provider registration returns a disposer, emits `web/providers-change` after successful registration, and emits it again when the provider is disposed. The registry should follow the existing `ctx.tools.register()` / `ctx.systemPrompt.section()` pattern: wrap the mutation in `ctx.effect()`, install the rollback disposer before emitting `web/providers-change`, and let a throwing registration-time change listener roll back the just-added provider instead of leaking it into the registry. + +## Provider status and selection + +Provider status and capability selection are separate concepts, but both stay minimal. A provider reports only whether that concrete implementation is usable by cheap local checks such as credential presence or parseable endpoint config. A provider `status()` must not make network calls. The service reports whether the capability has a selected usable provider, or why execution would fail. + +`LlmService` has no status type at all: availability is expressed as registry membership plus a resolution-time throw. `ctx.web` needs a small status answer because product apps, diagnostics, tests, and execution can report precise provider-selection failures without probing individual providers from the tool layer. Status must be derived from the configured provider id, registered providers, and each provider's cheap local `status()` on each call; it must not be stored as mutable service state. + +`WebCapabilityStatus` stays intentionally small: `available` plus a `reason` discriminant, and the selected `providerId` on the available branch so diagnostics can report which provider won. It does NOT carry the per-reason payload (the unavailable provider id, the ambiguous candidate set, the underlying provider-unavailable reason). That branchable detail lives in the structured `WebError` thrown at execution time, which is the surface callers route on; duplicating it into the status union would give the same fact two homes that can disagree. `searchStatus()` / `fetchStatus()` answer "can this capability run, and if not, in which broad category does it fail" — enough for startup diagnostics and the execution-resolution decision — and the thrown error answers "exactly which provider/ids/reason." + +`WebProviderStatus` is an input to selection, not a health system. `tool-web` reads only the aggregated `searchStatus()` / `fetchStatus()`, never each provider's `status()` directly, so selection policy has one owner. + +```ts +type WebProviderStatus = + | { readonly available: true } + | { readonly available: false; readonly reason: 'missing-credential' | 'misconfigured' } + +type WebCapabilityStatus = + | { readonly available: true; readonly providerId: string } + | { readonly available: false; readonly reason: 'none' | 'configured-missing' | 'configured-unavailable' | 'ambiguous' } +``` + +Selection must not depend on registration order. Cordis load order, config ordering, and HMR timing are not product semantics. + +| Situation | Status / behavior | +|---|---| +| A configured provider id is registered and `status().available === true` | `available: true` for that provider | +| A configured provider id is not registered | `configured-missing`; execution fails with `WEB_PROVIDER_CONFIGURED_MISSING` | +| A configured provider id is registered but unavailable | `configured-unavailable`; execution fails with `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` | +| No provider id is configured and exactly one provider for that kind is registered and available | `available: true` for that single provider | +| No provider id is configured and no provider for that kind is registered | `none`; execution fails with `WEB_PROVIDER_UNAVAILABLE` | +| No provider id is configured and multiple usable providers for that kind are registered | `ambiguous`; execution fails with `WEB_PROVIDER_AMBIGUOUS` rather than choosing by registration order | +| No provider id is configured and providers exist but none are usable | `none`; execution fails with `WEB_PROVIDER_UNAVAILABLE` | + +The "single provider auto-selects" rule is for tests, demos, and simple deployments. Product configs should set explicit provider ids: + +```yaml +- id: web + name: '@deepseek-ai/dsh-web' + config: + searchProvider: exa + fetchProvider: local-http + +- id: web-search-exa + name: '@deepseek-ai/dsh-web-search-exa' + +- id: web-search-perplexity + name: '@deepseek-ai/dsh-web-search-perplexity' + +- id: web-fetch-local + name: '@deepseek-ai/dsh-web-fetch-local' + +- id: tool-web + name: '@deepseek-ai/dsh-tool-web' +``` + +Operational overrides such as environment variables may exist, but they must feed the same explicit selection path. For example, `DSH_WEB_SEARCH_PROVIDER=perplexity` is equivalent to config `searchProvider: perplexity`; it is not a hidden priority chain inside `dsh-tool-web`. + +`ctx.web.search()` and `ctx.web.fetch()` resolve the provider at execution time using the same rules as the status query. If the selected capability is unavailable, they throw `WebError` with a structured code such as `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_CONFIGURED_MISSING`, `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, or `WEB_PROVIDER_AMBIGUOUS`. If no provider is explicitly configured and no usable provider exists, the status and execution error are both the generic `none` / `WEB_PROVIDER_UNAVAILABLE` case; the first version should not add a diagnostic summary of every unavailable provider. + +## Search request and result schema + +The first `web_search` model-facing tool should be small. The only model-facing argument is: + +- `query`: required string. + +`max_results` is NOT exposed to the model in the first version. It is a `dsh-tool-web`-layer decision: the tool sets the result bound — a default of `8` (aligning with OpenCode's Exa default), as an exported constant mirroring `dsh-tool-fs`'s `READ_LIMIT` / `GREP_LIMIT` — and passes it to the seam as `maxResults` on the `WebSearchRequest`. Keeping it off the model schema means the model just asks a question and the product controls how much context comes back; the field can be promoted to a model-facing argument later without breaking the seam. + +`maxResults` flows tool → seam → provider, and the bound is enforced on the way back: + +- `dsh-tool-web` owns the value and puts it on `WebSearchRequest.maxResults`. +- `ctx.web` passes the request through to the selected provider unchanged. +- A provider should apply `maxResults` at the request layer when its API supports it (Exa's `numResults`), as a cost/latency optimization. +- `ctx.web` enforces the bound on the result: if a provider returns more than `maxResults` sources — because its API has no result-count control (Perplexity) or ignored the hint — the seam truncates `sources[]` to `maxResults` and sets `WebSearchResult.truncated` to `true` before returning. This makes the bound a single cross-provider guarantee the model-facing layer can rely on, rather than something each provider must remember to honor. + +The seam request should not include provider-specific controls such as Perplexity model selection, search recency, domain filters, Exa `livecrawl`, Exa `type`, regional hints, generated-answer budgets, or search depth in the first version. Those fields should be added only when they have provider-neutral semantics that both the tool schema and selected providers can honor honestly. + +```ts +interface WebSearchRequest { + readonly query: string + /** Upper bound on returned sources; the seam truncates to it. Omitted = no bound. `dsh-tool-web` always sets it. */ + readonly maxResults?: number +} + +interface WebSearchResult { + readonly providerId: string + readonly query: string + readonly content?: string + readonly sources: readonly WebSearchSource[] + readonly truncated: boolean +} + +interface WebSearchSource { + readonly url: string + readonly title?: string + readonly snippet?: string + readonly publishedAt?: string +} +``` + +`content` is optional provider-generated answer text, search context, or summary. `sources[]` is the portable citation surface. A source always has a URL; title, snippet, and `publishedAt` are optional because not every provider returns them. `title` should not be required: Perplexity-style citations may provide only URLs, and forcing adapters to invent titles would make the seam lie. `dsh-tool-web` can render `title ?? hostname(url)` for display. `publishedAt` is an optional publication/crawl timestamp as an ISO-8601 string — Exa returns it as `publishedDate` on each result and Perplexity returns a `date` on search results, so it is real provider data, not derived; the seam carries it as a string and leaves date parsing to the consumer. + +Exa search should map each entry of the provider's flat `results[]` into a `WebSearchSource`: `url` ← `url`, `title` ← `title`, `snippet` ← the first `highlights[]` entry (an entry with no highlight has no portable snippet and is dropped), `publishedAt` ← `publishedDate`. Exa returns no provider-generated answer, so `content` is omitted. Perplexity search should map `choices[0].message.content` to `content` and prefer the structured top-level `search_results[]` for `sources[]` — `url` ← `url`, `title` ← `title`, `snippet` ← `snippet` (often empty), `publishedAt` ← `date` — falling back to the URL-only `citations[]` array only when `search_results` is absent (those sources carry just a `url`). If a provider returns fewer structured fields than the seam supports, the adapter omits those optional fields. + +Full page retrieval remains the job of `web_fetch(url)`. Search snippets are discovery context, not fetched page bodies. + +## Fetch request and result schema + +The first `web_fetch` implementation should be an anonymous public HTTP(S) fetch provider, likely `local-http`. It should fetch bytes from a concrete URL, apply the basic transport hygiene below (http/https-only, credential rejection, byte/time caps, cross-origin redirect blocking), decode textual content, and return only the minimal model-useful result: final URL, status code, body, and truncation. It should not carry browser cookies, editor credentials, git credentials, internal auth tokens, or implicit access to private services. (Full SSRF / private-network blocking is deferred — see [Deferred work](#deferred-work).) + +The first seam request should stay smaller than OpenCode's model-facing tool: + +- `url`: required HTTP(S) URL. +- `timeoutMs`: optional positive number capped by the provider. + +The seam request deliberately does not include `format`, `prompt`, or provider-specific extraction controls. `format` is a presentation decision over a fetched resource; `prompt` is a higher-level LLM summarization instruction; extraction APIs such as Firecrawl, Exa, Tavily, or Parallel may not expose a concrete HTTP response. If the product later needs provider-backed page extraction, add a separate `web_extract` capability or explicitly widen this RFC before implementation. Do not smuggle extract semantics into `web_fetch` by making every HTTP field optional. + +HTTP status is part of the fetched resource state, not automatically a tool failure. A successful network fetch of a `404` or `500` response should return `WebFetchResult` with the status code and a bounded decoded body when the content type is supported. `WebError` is for failures to safely retrieve or represent the resource: invalid or blocked URL, redirect policy violation, timeout, abort, response too large, unsupported content type, provider failure, or network failure. + +```ts +interface WebFetchRequest { + readonly url: string + readonly timeoutMs?: number +} + +interface WebFetchResult { + readonly providerId: string + readonly url: string + readonly statusCode: number + readonly body: WebFetchBody + readonly truncated: boolean +} + +type WebFetchBody = + | { readonly kind: 'html'; readonly content: string } + | { readonly kind: 'text'; readonly content: string } +``` + +`WebFetchResult.url` is the final URL after allowed redirects. The request URL is already present in `WebFetchRequest`, so the first version should not add separate `requestedUrl` and `finalUrl` fields. + +`WebFetchBody` is a CLOSED discriminated union owned by `dsh-web`, not a merge-extensible map. The merge-extensible pattern (`ContentBlockMap`) exists for variants that independent plugins introduce and the seam cannot foresee; body kinds are not that — `dsh-web` declares the kind, the fetch provider decodes it, and `dsh-tool-web` renders it, so a new kind is a coordinated change across three known packages, not a plugin extension. Keeping it closed buys compile-time exhaustiveness: consumers `switch` on `kind` ending in `default: assertNever(body, …)`, so adding a kind breaks compilation at every consumer that must render it (e.g. `tool-web`'s `html`→markdown vs `text` passthrough) until that arm is written. Each arm stays its own object literal even when the fields coincide today, leaving room for arm-specific fields (a future `pdf` body's `pageCount`, a `json` body's parsed value) without reshaping the type. Since the harness is unreleased, extending this closed union later is free (no migration, no compat shim). + +The provider owns safe resource retrieval: URL validation, HTTP transport, redirect policy, timeout, abort propagation, byte caps, charset decoding, content-type classification, and binary rejection. `dsh-tool-web` owns presentation: HTML-to-markdown, HTML-to-text, truncation formatting for the model, and future summaries. + +The fetch provider must define resource controls before the tool ships: + +- Accept only `http:` and `https:` URLs. +- Reject credentials in URLs. +- Enforce maximum URL length, response byte cap, decoded body character cap, timeout, and redirect hop cap. +- Propagate abort signals through network fetches and expensive decoding. +- Automatically follow only same-origin redirects. +- Fail cross-origin redirects with `WEB_REDIRECT_BLOCKED`, requiring a fresh tool call and therefore a fresh provider/permission decision. (Claude Code's WebFetch uses this same model — it does not auto-follow a cross-host redirect; it returns the redirect target to the model for a fresh call.) +- Use an explicit product user agent rather than silently impersonating a browser by default. + +SSRF / private-network protection (blocking private, loopback, link-local, multicast, and otherwise non-public destinations, with DNS-resolve-then-validate to defeat rebinding and per-hop re-validation on redirects) is **deferred** — see [Deferred work](#deferred-work). Until it lands, `web_fetch` is an SSRF primitive and must not be enabled in a deployment that can reach sensitive internal network targets. + +## Tool consumer behavior + +`dsh-tool-web` owns two `ToolDefinition`s: `web_search` and `web_fetch`. It owns model-facing JSON schemas, snake_case argument names, prompt sections, result rendering to `ContentBlock[]`, `presentCall`, and `presentResult`. + +`dsh-tool-web` must not enumerate providers or call provider `status()` directly. Its execution path is `ctx.web.search()` / `ctx.web.fetch()`, and any optional startup diagnostics should read only `ctx.web.searchStatus()` / `ctx.web.fetchStatus()`. That keeps provider selection in one layer; otherwise the tool package could decide one provider is usable while execution resolves a different state. + +Tool registration in the first version is a minimal stable sync: + +1. On plugin startup, read the product/app config that enables or disables web search and web fetch. +2. If web search is enabled, register `web_search` and keep that tool's disposer. +3. If web fetch is enabled, register `web_fetch` and keep that tool's disposer. +4. Do not dispose either tool merely because `ctx.web.searchStatus()` or `ctx.web.fetchStatus()` is unavailable. +5. Dispose registered tools when the `tool-web` fiber is disposed. + +Provider status changes affect execution results and diagnostics, not whether the model-facing schema exists. If a product wants no web tools at all, it disables `dsh-tool-web` or the individual web tool in config; if it wants web tools but the backend is misconfigured, the model sees a structured tool error at execution time. + +Prompt guidance should explain the semantic split: use `web_search` for discovery and current information, then use `web_fetch` when the model needs the content of a specific URL. The prompt and tool result should tell the model to cite relevant URLs with markdown links. + +The model-facing output should be text-first because current tool results are `ContentBlock[]`, but the seam outcome should stay structured so UI presentation and future adapters do not have to scrape rendered text. + +## Errors + +`dsh-web` should define `WebError extends HarnessError` with stable codes. Initial codes should include only states that callers may reasonably branch on: + +- `WEB_PROVIDER_UNAVAILABLE` +- `WEB_PROVIDER_CONFIGURED_MISSING` +- `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` +- `WEB_PROVIDER_AMBIGUOUS` +- `WEB_DUPLICATE_PROVIDER` +- `WEB_INVALID_URL` +- `WEB_BLOCKED_URL` +- `WEB_REDIRECT_BLOCKED` +- `WEB_FETCH_TOO_LARGE` +- `WEB_FETCH_TIMEOUT` +- `WEB_ABORTED` +- `WEB_UNSUPPORTED_CONTENT_TYPE` +- `WEB_PROVIDER_ERROR` + +`WEB_DUPLICATE_PROVIDER` is thrown synchronously from `registerSearchProvider` / `registerFetchProvider` when an id is already registered for that capability kind (the analogue of `LlmService`'s `DUPLICATE_ADAPTER`); it is a registration-time programming error, not an execution outcome, but shares the `WebError` code space so callers see one taxonomy. `WEB_PROVIDER_ERROR` is the catch-all for a provider's own failure surfaced through the seam, including network/transport failure in `web-fetch-local` (DNS, connection refused, TLS); the first version does not split out a separate `WEB_NETWORK` code, but the provider should set a descriptive message so the model and logs can tell a network failure from a provider API failure. + +Tool execution should let these errors flow through `ToolRegistry.execute()`, which already converts `HarnessError` into an error tool result with structured metadata. The model gets a readable error message; hooks, tests, and UI code can route on the stable code. + +## Tests + +Tests should prove the seam contract without turning this RFC into an implementation checklist. + +`dsh-web` tests cover provider registration and disposal, duplicate provider ids, `web/providers-change` emission, rollback when a registration-time `web/providers-change` listener throws, `searchStatus()` and `fetchStatus()` for the selection table above, execution-time provider resolution, `maxResults` truncation of `sources[]` with `truncated` set when a provider over-returns, abort propagation through `WebExecContext.signal`, and structured `WebError` codes. + +Search provider tests cover request mapping, response parsing into `content` plus `sources[]`, missing credentials, provider errors, timeout/abort, truncation, and a self-skipping with-key smoke test for each real provider. Perplexity fixtures must include URL-only citations so the optional source fields stay honest. + +`dsh-web-fetch-local` tests cover real HTTP behavior using a local test server: valid text and HTML fetches, non-2xx HTTP responses returned as results, byte/decoded-body caps, timeout, abort, invalid URLs, credential-in-URL rejection, cross-origin redirect blocking, unsupported content types, and product user agent. (Private-destination/SSRF blocking tests come with that deferred work.) + +`dsh-tool-web` tests execute through the real tool registry. They verify schema registration follows product/app tool enablement rather than provider availability, unavailable or ambiguous providers produce structured execution errors, argument validation, formatting of successful search/fetch results, structured error propagation, and cleanup on disposal. + +Integration tests should load the real seam, provider, and tool packages together and execute through `ctx.tools.execute()` rather than calling providers directly. If wiring the tools into an ACP-facing example changes editor-visible transcripts, add or update the relevant snapshot scenario in the same change. + +At least one test must drive these packages through their REAL cordis Loader/export path, not a hand-built `ctx.plugin({...})` mount, so a broken export shape is caught (see [docs/postmortem/0001](../../../postmortem/0001-acp-default-export-drops-inject.md) and `packages/AGENTS.md` § plugin-export-shape). The two shapes need different guards: `dsh-web` and the provider packages are **services** (`export default` the class) and a stray extra export would surface as a missing service; `dsh-tool-web` is a **namespace plugin** (named `name`/`inject`/`apply`, NO default), and because it has `inject`, a stray `export default apply` makes `unwrapExports` drop the `inject` and the plugin throws `cannot get property … without inject` the moment it loads — so a Loader smoke that boots tool-web over `ctx.web` catches it. Prove the guard bites: add `export default apply` to `tool-web`, watch the smoke go red, revert. + +## Migration plan + +This is new capability work, so no compatibility migration is required while the harness is unreleased. + +Land the work in seam order: + +1. Add `packages/web/web` with `ctx.web`, provider registration, provider status, capability status, selection, request/result/error types, and contract tests. +2. Add `packages/web/web-search-exa` with parser/unit tests and a self-skipping real-provider smoke test. +3. Add `packages/web/web-search-perplexity` with parser/unit tests and a self-skipping real-provider smoke test. +4. Add `packages/web/web-fetch-local` with local HTTP behavior tests. +5. Add `packages/web/tool-web` with config-driven tool registration, prompt sections, model formatting, presentation, and tool-registry tests. +6. Wire product app/example configs only after package behavior is stable, because tool schemas and prompt sections affect agent behavior and snapshots. +7. Update `docs/architecture.md`, `packages/README.md`, package READMEs, generated Cordis catalogs if new events/services are added, and maintenance scripts. + +## Alternatives considered + +### Let each provider register its own model-facing tool + +This matches the most flexible provider-plugin systems: every provider can expose its full native schema. It is rejected for the harness because it gives provider packages ownership of model-facing names, descriptions, prompt guidance, and result formatting. Multiple search providers would produce duplicate tool names or provider-specific tool names, and the model would learn backend details instead of a stable product capability. + +### Put provider dispatch directly in `dsh-tool-web` + +This resembles OpenCode's local web search: one stable `websearch` tool dispatches to Exa or Parallel internally. It is acceptable for a small product path but wrong as a harness foundation. The tool package would own provider selection, credentials, request mapping, transport, response parsing, and presentation, making it hard to add Exa and Perplexity without baking their differences into the tool schema. + +### Split search and fetch into two seams (`dsh-search`, `dsh-fetch`) + +Tempting because the two halves share no request schema and no business logic, so each would map cleanly onto the bash/fs three-package template, and the `Search`/`Fetch` method-pair duplication on `WebService` would disappear. Rejected because the shared machinery — provider-id registry, registration-order-independent selection policy, abort propagation, the `WebError` taxonomy, and the product-facing "how this harness reaches the web" config surface — is real and would otherwise be duplicated across two near-identical seams. One `ctx.web` middle layer gives the product a single thing to inject and configure and gives provider selection one owner. The price is the parallel `searchX`/`fetchX` method pairs, which is accepted deliberately. + +### Choose the first registered provider + +Rejected. Registration order is not a product policy. It can change with config order, plugin loading, HMR, or refactors. Provider selection must be explicit, or automatic only when exactly one usable provider exists. + +### Treat Firecrawl/Exa/Tavily/Parallel extraction as fetch + +Rejected for the first version. Those providers often return extracted or summarized content rather than a concrete HTTP response. If the product needs extraction, design `web_extract` or deliberately widen the fetch seam later. + +### Mirror Claude Code's `url + prompt` WebFetch shape + +Rejected for the seam. `prompt` turns fetch into LLM summarization and couples public-web retrieval to a model provider. The harness seam should fetch and decode deterministically; `dsh-tool-web` can later offer summaries as a presentation mode without making `ctx.web` depend on `ctx.llm`. + +## Risks + +**The search schema may be too thin.** Exa and Perplexity both expose useful provider-specific controls. The first version should resist adding them until they can be defined provider-neutrally and enforced honestly by both tool registration and provider execution. + +**Perplexity citations may be sparse.** A citation may be only a URL. Making `title` and `snippet` optional keeps the seam truthful but means `tool-web` must render useful fallback labels. + +**Stable tool registration can defer misconfiguration to execution.** Keeping the tool visible is correct when the product enabled web access, but product apps that expect web search should surface `configured-missing`, `configured-unavailable`, and `ambiguous` loudly during startup diagnostics so users do not discover setup problems only after the model calls the tool. + +**Provider state can change after startup.** A tool can be visible in the request assembled at step start and lose its provider before execution. The execution path must resolve again and fail with a structured error. + +**Fetch is a network boundary, not just a read-only tool.** `web_fetch` can still reach sensitive network targets or exfiltrate data through URLs. The first version ships only the basic transport hygiene (http/https-only, credential rejection, byte/time caps, cross-origin redirect blocking); SSRF / private-network blocking is deferred (see [Deferred work](#deferred-work)), so until it lands `web_fetch` must not be enabled where it can reach internal targets. + +**Large web content can damage context quality.** Providers must enforce byte/character caps and report `truncated`; `tool-web` must format bounded model output with clear continuation or follow-up guidance. + +## Deferred work + +- SSRF / private-network protection for `web_fetch`: block private, loopback, link-local, multicast, and otherwise non-public destinations so `web_fetch` is not an SSRF primitive. Doing it correctly is more than a URL-string check — it needs DNS-resolve-then-connect-to-the-validated-IP (to defeat DNS rebinding / TOCTOU), per-hop re-validation across redirects, and IPv6 edge handling (private ranges, IPv4-mapped addresses). Neither reference implementation surveyed does IP-level blocking (OpenCode does a prefix check then fetches; Claude Code relies on a centralized hostname blocklist plus a "private URLs will fail" prompt), so there is no implementation to copy and this is the harness's only SSRF defense — it warrants its own focused design/spike. Until it lands, `web_fetch` must only be enabled in deployments that cannot reach sensitive internal targets. +- A `pdf` `WebFetchBody` kind: the `local-http` provider decodes text-extractable PDFs (best-effort, capped, `truncated`) into a `{ kind: 'pdf'; content; pageCount? }` arm, and `tool-web` renders it. This is fetch, not `web_extract` — PDF retrieval is a concrete HTTP 200 plus deterministic local decoding, not provider-side extraction of a non-HTTP resource. Adding it is a coordinated change across `dsh-web` (declare the arm), the provider (decode + narrow "binary rejection" to "reject binary except text-extractable PDF"; scanned/image PDFs needing OCR stay out of scope), and `tool-web` (render). The closed `WebFetchBody` union makes the consumer side fail to compile until the new arm is handled. +- Provider-backed extraction as a separate `web_extract` capability, rather than widening `web_fetch` silently. +- Permission policy integration once the deferred permission system lands. +- Provider-neutral search controls beyond `query` and `maxResults`, once Exa and Perplexity can both honor them honestly. + +## Open questions + +- Should product app packages treat `configured-missing`, `configured-unavailable`, and `ambiguous` as fatal startup errors when web is explicitly configured, or should `dsh-web` only report status and let apps decide? +- Where should permission policy for public web access live once the deferred permission system lands: a dedicated web permission plugin on `tools/execute`, provider config, or both? From d01f5f73b7866b457f00ffbe60b78af39273fc7a Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 25 Jun 2026 15:04:12 +0800 Subject: [PATCH 09/75] Add web capability seam: ctx.web, search/fetch providers, web tools Introduce web access as a first-class capability seam so the model-facing web tools stay stable while backends change. dsh-web owns ctx.web as a provider registry with registration-order-independent selection and the WebError taxonomy; dsh-web-search-exa, dsh-web-search-perplexity, and dsh-web-fetch-local register capabilities into it; dsh-tool-web is the sole owner of the model-facing web_search/web_fetch schemas, prompt sections, and HTML-to-markdown presentation. Search and fetch are deliberately one seam. Providers ship as namespace plugins that register into ctx.web (like an LlmAdapter into ctx.llm), not key-owning services, since multiple search providers cannot each own the key. Tool registration follows product enablement, not backend availability, so load order/credentials never enter the model contract; the seam resolves the provider at execution time and surfaces a structured WebError otherwise. Moves the RFC to implemented/ amended to match what shipped. Example/app configs are intentionally not wired yet (RFC migration step 6). --- .gitignore | 3 + docs/architecture.md | 7 + docs/rfc/README.md | 2 +- .../2026-06-24-web-capability-seam.md | 14 +- knip.json | 8 + packages/README.md | 11 + packages/web/README.md | 15 + packages/web/tool-web/README.md | 30 ++ packages/web/tool-web/package.json | 42 +++ packages/web/tool-web/src/fetch.ts | 87 ++++++ packages/web/tool-web/src/html.ts | 85 ++++++ packages/web/tool-web/src/index.ts | 59 ++++ packages/web/tool-web/src/search.ts | 105 +++++++ .../web/tool-web/tests/integration.spec.ts | 99 ++++++ packages/web/tool-web/tests/load-path.spec.ts | 49 +++ packages/web/tool-web/tests/tool-web.spec.ts | 281 ++++++++++++++++++ packages/web/tool-web/tsconfig.json | 17 ++ packages/web/tool-web/tsdown.config.ts | 18 ++ packages/web/web-fetch-local/README.md | 34 +++ packages/web/web-fetch-local/package.json | 33 ++ packages/web/web-fetch-local/src/index.ts | 77 +++++ packages/web/web-fetch-local/src/policy.ts | 59 ++++ packages/web/web-fetch-local/src/provider.ts | 233 +++++++++++++++ .../web-fetch-local/tests/fetch-local.spec.ts | 239 +++++++++++++++ packages/web/web-fetch-local/tsconfig.json | 24 ++ packages/web/web-search-exa/README.md | 23 ++ packages/web/web-search-exa/package.json | 33 ++ packages/web/web-search-exa/src/index.ts | 48 +++ packages/web/web-search-exa/src/provider.ts | 130 ++++++++ packages/web/web-search-exa/src/types.ts | 36 +++ packages/web/web-search-exa/tests/exa.e2e.ts | 19 ++ packages/web/web-search-exa/tests/exa.spec.ts | 193 ++++++++++++ packages/web/web-search-exa/tsconfig.json | 24 ++ packages/web/web-search-perplexity/README.md | 24 ++ .../web/web-search-perplexity/package.json | 33 ++ .../web/web-search-perplexity/src/index.ts | 52 ++++ .../web/web-search-perplexity/src/provider.ts | 138 +++++++++ .../web/web-search-perplexity/src/types.ts | 41 +++ .../tests/perplexity.e2e.ts | 23 ++ .../tests/perplexity.spec.ts | 192 ++++++++++++ .../web/web-search-perplexity/tsconfig.json | 24 ++ packages/web/web/README.md | 45 +++ packages/web/web/package.json | 33 ++ packages/web/web/src/index.ts | 270 +++++++++++++++++ packages/web/web/src/types.ts | 225 ++++++++++++++ packages/web/web/tests/web.spec.ts | 263 ++++++++++++++++ packages/web/web/tsconfig.json | 24 ++ pnpm-lock.yaml | 86 ++++++ tsconfig.base.json | 3 + tsconfig.build.json | 5 + 50 files changed, 3610 insertions(+), 8 deletions(-) rename docs/rfc/{proposed => implemented}/architecture/2026-06-24-web-capability-seam.md (96%) create mode 100644 packages/web/README.md create mode 100644 packages/web/tool-web/README.md create mode 100644 packages/web/tool-web/package.json create mode 100644 packages/web/tool-web/src/fetch.ts create mode 100644 packages/web/tool-web/src/html.ts create mode 100644 packages/web/tool-web/src/index.ts create mode 100644 packages/web/tool-web/src/search.ts create mode 100644 packages/web/tool-web/tests/integration.spec.ts create mode 100644 packages/web/tool-web/tests/load-path.spec.ts create mode 100644 packages/web/tool-web/tests/tool-web.spec.ts create mode 100644 packages/web/tool-web/tsconfig.json create mode 100644 packages/web/tool-web/tsdown.config.ts create mode 100644 packages/web/web-fetch-local/README.md create mode 100644 packages/web/web-fetch-local/package.json create mode 100644 packages/web/web-fetch-local/src/index.ts create mode 100644 packages/web/web-fetch-local/src/policy.ts create mode 100644 packages/web/web-fetch-local/src/provider.ts create mode 100644 packages/web/web-fetch-local/tests/fetch-local.spec.ts create mode 100644 packages/web/web-fetch-local/tsconfig.json create mode 100644 packages/web/web-search-exa/README.md create mode 100644 packages/web/web-search-exa/package.json create mode 100644 packages/web/web-search-exa/src/index.ts create mode 100644 packages/web/web-search-exa/src/provider.ts create mode 100644 packages/web/web-search-exa/src/types.ts create mode 100644 packages/web/web-search-exa/tests/exa.e2e.ts create mode 100644 packages/web/web-search-exa/tests/exa.spec.ts create mode 100644 packages/web/web-search-exa/tsconfig.json create mode 100644 packages/web/web-search-perplexity/README.md create mode 100644 packages/web/web-search-perplexity/package.json create mode 100644 packages/web/web-search-perplexity/src/index.ts create mode 100644 packages/web/web-search-perplexity/src/provider.ts create mode 100644 packages/web/web-search-perplexity/src/types.ts create mode 100644 packages/web/web-search-perplexity/tests/perplexity.e2e.ts create mode 100644 packages/web/web-search-perplexity/tests/perplexity.spec.ts create mode 100644 packages/web/web-search-perplexity/tsconfig.json create mode 100644 packages/web/web/README.md create mode 100644 packages/web/web/package.json create mode 100644 packages/web/web/src/index.ts create mode 100644 packages/web/web/src/types.ts create mode 100644 packages/web/web/tests/web.spec.ts create mode 100644 packages/web/web/tsconfig.json diff --git a/.gitignore b/.gitignore index b52f86cd61..2788817b23 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ examples/*/.sessions/ coverage/ .doc-typecheck-*/ .humanize/ +tmp/ +.claude/commands/ +.claude/settings.json .vscode/ .DS_Store .idea diff --git a/docs/architecture.md b/docs/architecture.md index c76d8f7ba4..dbaf76cd3f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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-web-search-exa (web search impl) │ +│ @deepseek-ai/dsh-web-fetch-local (web fetch impl) │ +│ @deepseek-ai/dsh-tool-web (web tool schemas) │ │ @deepseek-ai/dsh-session-persistence-jsonl (persistence impl)│ ├─────────────────────────────────────────────────────────────┤ │ @deepseek-ai/dsh-agent (vocabulary + registry) │ @@ -33,6 +36,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-web (abstract web access) │ ├─────────────────────────────────────────────────────────────┤ │ vendor/: cordis, loader, include, group, timer, hmr, │ │ logger-console, cosmokit, schemastery │ @@ -54,6 +58,7 @@ Dependency rule: **extension** plugins depend on interface packages, never on `d | `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.compact` | `CompactService` (abstract) | dsh-compact | compaction seam: decide when history is too large, summarize an older range into a single surface node | +| `ctx.web` | `WebService` | dsh-web | web access seam: search/fetch provider registries, registration-order-independent selection, the `WebError` taxonomy | All registrations (`registerAdapter`, `section`, `tools`, `register`, …) go through `ctx.effect()` and return disposers, so plugin hot-reload (vendored HMR) and fiber disposal clean up automatically. @@ -69,6 +74,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 web capability uses the same three-package split but folds two capabilities onto one seam: `dsh-web` owns the abstract `ctx.web` service, which is a provider REGISTRY (`registerSearchProvider`/`registerFetchProvider`, registration-order-independent selection, the `WebError` taxonomy) rather than a single backend. Providers register capabilities, not tools — `dsh-web-search-exa`, `dsh-web-search-perplexity`, and `dsh-web-fetch-local` each register into `ctx.web` the way an `LlmAdapter` registers into `ctx.llm`, so they are namespace plugins (`inject: ['web']`), not key-owning services. `dsh-tool-web` is the single consumer that owns the model-facing `web_search`/`web_fetch` schemas, prompt sections, and presentation; it reads only the aggregated `ctx.web.searchStatus()`/`fetchStatus()` and executes through `ctx.web.search()`/`fetch()`, so provider selection has one owner. Search and fetch are deliberately one seam (one thing to inject and configure, one selection policy, one abort/error vocabulary) despite sharing no request schema — see the [web capability seam RFC](rfc/implemented/architecture/2026-06-24-web-capability-seam.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) diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 73a2675caa..ab3e97e4bc 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -59,7 +59,6 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r |---|---| | [Runtime schemas for the event vocabulary (Zod vs the merge-extensible-map pattern)](proposed/architecture/2026-06-16-typed-event-schemas.md) | 2026-06-16 | | [Extract a generic long-running tool runtime](proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 | -| [Web capability seam - provider registry and model-facing web tools](proposed/architecture/2026-06-24-web-capability-seam.md) | 2026-06-24 | ### Process @@ -120,6 +119,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [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 | +| [Web capability seam — provider registry and model-facing web tools](implemented/architecture/2026-06-24-web-capability-seam.md) | 2026-06-24 | ### Process diff --git a/docs/rfc/proposed/architecture/2026-06-24-web-capability-seam.md b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md similarity index 96% rename from docs/rfc/proposed/architecture/2026-06-24-web-capability-seam.md rename to docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md index e97e8380da..3b9fb166d0 100644 --- a/docs/rfc/proposed/architecture/2026-06-24-web-capability-seam.md +++ b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md @@ -1,6 +1,6 @@ # RFC: Web capability seam - stable tools over multiple providers -Status: proposed +Status: implemented ## Problem @@ -64,7 +64,7 @@ flowchart LR `@deepseek-ai/dsh-web` depends only on Cordis and low-level harness support. It declares `ctx.web`, provider interfaces, request/result types, status types, and error codes. It does not import tool, agent, session, LLM, or provider packages. -Provider packages depend on `@deepseek-ai/dsh-web` and Cordis. They own credentials, endpoint config, provider-specific request mapping, provider-specific response parsing, and provider-specific error translation into `WebError`. They issue network requests with the platform-native `fetch` (Node 24), mirroring `@deepseek-ai/dsh-llm-deepseek`'s adapter, NOT a cordis HTTP-client service (`ctx.http`/`@cordisjs/plugin-http`) — even where a Perplexity provider's request is shaped like an OpenAI-compatible chat completion, that wire shape is a provider-private detail and does not make the provider depend on `ctx.llm`. +Provider packages depend on `@deepseek-ai/dsh-web` and Cordis. They own credentials, endpoint config, provider-specific request mapping, provider-specific response parsing, and provider-specific error translation into `WebError`. They issue network requests with the platform-native `fetch` (Node 24), mirroring `@deepseek-ai/dsh-llm-deepseek`'s adapter, NOT a cordis HTTP-client service (`ctx.http`/`@cordisjs/plugin-http`) — even where a Perplexity provider's request is shaped like an OpenAI-compatible chat completion, that wire shape is a provider-private detail and does not make the provider depend on `ctx.llm`. A provider does NOT own the `ctx.web` key (two search providers cannot both own it): like `dsh-llm-deepseek`, each provider package is a function/namespace plugin (`inject: ['web']`) whose `apply` constructs the backend and calls `ctx.web.registerSearchProvider` / `registerFetchProvider`. `@deepseek-ai/dsh-web` is the `export default` service that owns the key. `@deepseek-ai/dsh-tool-web` depends on `@deepseek-ai/dsh-web`, `@deepseek-ai/dsh-tools`, `@deepseek-ai/dsh-system-prompt`, and Cordis. It never imports concrete provider packages. @@ -267,11 +267,11 @@ SSRF / private-network protection (blocking private, loopback, link-local, multi Tool registration in the first version is a minimal stable sync: -1. On plugin startup, read the product/app config that enables or disables web search and web fetch. -2. If web search is enabled, register `web_search` and keep that tool's disposer. -3. If web fetch is enabled, register `web_fetch` and keep that tool's disposer. +1. On plugin startup, read the `dsh-tool-web` `Config` (`search?: boolean`, `fetch?: boolean`, both default `true`) that enables or disables each web tool. +2. If web search is enabled, register `web_search` (its disposer is fiber-scoped via the effect-based registry). +3. If web fetch is enabled, register `web_fetch` (likewise fiber-scoped). 4. Do not dispose either tool merely because `ctx.web.searchStatus()` or `ctx.web.fetchStatus()` is unavailable. -5. Dispose registered tools when the `tool-web` fiber is disposed. +5. Disposing the `tool-web` fiber tears down its registrations automatically. Provider status changes affect execution results and diagnostics, not whether the model-facing schema exists. If a product wants no web tools at all, it disables `dsh-tool-web` or the individual web tool in config; if it wants web tools but the backend is misconfigured, the model sees a structured tool error at execution time. @@ -315,7 +315,7 @@ Search provider tests cover request mapping, response parsing into `content` plu Integration tests should load the real seam, provider, and tool packages together and execute through `ctx.tools.execute()` rather than calling providers directly. If wiring the tools into an ACP-facing example changes editor-visible transcripts, add or update the relevant snapshot scenario in the same change. -At least one test must drive these packages through their REAL cordis Loader/export path, not a hand-built `ctx.plugin({...})` mount, so a broken export shape is caught (see [docs/postmortem/0001](../../../postmortem/0001-acp-default-export-drops-inject.md) and `packages/AGENTS.md` § plugin-export-shape). The two shapes need different guards: `dsh-web` and the provider packages are **services** (`export default` the class) and a stray extra export would surface as a missing service; `dsh-tool-web` is a **namespace plugin** (named `name`/`inject`/`apply`, NO default), and because it has `inject`, a stray `export default apply` makes `unwrapExports` drop the `inject` and the plugin throws `cannot get property … without inject` the moment it loads — so a Loader smoke that boots tool-web over `ctx.web` catches it. Prove the guard bites: add `export default apply` to `tool-web`, watch the smoke go red, revert. +At least one test must drive these packages through their REAL cordis Loader/export path, not a hand-built `ctx.plugin({...})` mount, so a broken export shape is caught (see [docs/postmortem/0001](../../../postmortem/0001-acp-default-export-drops-inject.md) and `packages/AGENTS.md` § plugin-export-shape). The two shapes need different guards: `dsh-web` is a **service** (`export default` the class) and a stray extra export would surface as a missing service; the provider packages and `dsh-tool-web` are **namespace plugins** (named `name`/`inject`/`apply`, NO default), and because each has `inject`, a stray `export default apply` makes `unwrapExports` drop the `inject` and the plugin throws `cannot get property … without inject` the moment it loads — so a Loader smoke that boots tool-web over `ctx.web` catches it (and each provider's registration test mounts it the real way and asserts no default export). Prove the guard bites: add `export default apply` to `tool-web`, watch the smoke go red, revert. ## Migration plan diff --git a/knip.json b/knip.json index 67d99a861d..3f0a56097c 100644 --- a/knip.json +++ b/knip.json @@ -29,6 +29,14 @@ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, + "packages/web/web-search-exa": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, + "packages/web/web-search-perplexity": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, "packages/ui/acp-agent": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] diff --git a/packages/README.md b/packages/README.md index 11cace9017..ab81df0a66 100644 --- a/packages/README.md +++ b/packages/README.md @@ -13,6 +13,7 @@ Packages are grouped by modular role at `packages///`. The group dir | [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface | | [`compact/`](compact/README.md) | Compaction capability family: the abstract seam (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 | +| [`web/`](web/README.md) | Web capability family: the abstract seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface | | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface | | [`ui/`](ui/README.md) | Editor/client integration surfaces (the ACP bridge) | Product — stable surface | | [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, stdio UI, replay adapter) | Support — lower compatibility expectations | @@ -33,6 +34,11 @@ dsh-compact ← dsh-session, dsh-llm (abstract compaction s 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-web ← dsh-llm (abstract web seam; search/fetch registries, WebError) +dsh-web-search-exa ← dsh-web (Exa WebSearchProvider) +dsh-web-search-perplexity ← dsh-web (Perplexity WebSearchProvider) +dsh-web-fetch-local ← dsh-web (anonymous public HTTP(S) WebFetchProvider) +dsh-tool-web ← dsh-web, dsh-tools, dsh-system-prompt (web tool schemas) 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-system-prompt, dsh-tools, dsh-agent @@ -68,6 +74,11 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop | `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`) | | `compact/` | `compact` | Abstract compaction seam + `compact/*` events + `CompactionResult` | `ctx.compact` | +| `web/` | `web` | Abstract web seam (search/fetch provider registries + selection + vocabulary + `WebError`) | `ctx.web` | +| `web-search-exa/` | `web` | Exa-backed `WebSearchProvider` | (registers on `ctx.web`) | +| `web-search-perplexity/` | `web` | Perplexity-backed `WebSearchProvider` | (registers on `ctx.web`) | +| `web-fetch-local/` | `web` | Anonymous public HTTP(S) `WebFetchProvider` | (registers on `ctx.web`) | +| `tool-web/` | `web` | Model-facing `web_search`/`web_fetch` tool schemas | (registers on `ctx.tools`) | | `llm-deepseek/` | `llm` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) | | `llm-pi-ai/` | `llm` | DeepSeek adapter via `@earendil-works/pi-ai` (design twin) | (registers on `ctx.llm`) | | `session-persistence/` | `session-persistence` | Persistence seam + write coordinator | `ctx.sessionPersistence` | diff --git a/packages/web/README.md b/packages/web/README.md new file mode 100644 index 0000000000..0742d9c2cc --- /dev/null +++ b/packages/web/README.md @@ -0,0 +1,15 @@ +# web/ - web capability family + +The web access capability seam: an abstract web interface, search/fetch provider implementations, and the model-facing web tools. All **product** packages. + +| Package | Role | ctx key | +|---|---|---| +| `web/` | Abstract web seam (search/fetch provider registries + selection + vocabulary + `WebError`) | `ctx.web` | +| `web-search-exa/` | Exa-backed `WebSearchProvider` | (registers on `ctx.web`) | +| `web-search-perplexity/` | Perplexity-backed `WebSearchProvider` | (registers on `ctx.web`) | +| `web-fetch-local/` | Anonymous public HTTP(S) `WebFetchProvider` | (registers on `ctx.web`) | +| `tool-web/` | Model-facing `web_search`/`web_fetch` tool schemas | (registers on `ctx.tools`) | + +The interface lives at `web/web/`. Unlike bash/fs, the seam spans **two capabilities** (search and fetch) with potentially multiple providers each: `ctx.web` is one web-access middle layer with one provider-selection policy, one abort/error vocabulary, and one product-facing "how this harness reaches the web" config surface. Providers register **capabilities**, not tools; `tool-web` is the only owner of model-facing names, schemas, prompt guidance, and presentation. A search provider swap does not change how the model asks for a query, and a fetch implementation swap does not change how the model asks for a URL. + +See the [web capability seam RFC](../../docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md) for the design rationale, including why search and fetch are deliberately one seam and why `web_fetch`'s SSRF protection is deferred. diff --git a/packages/web/tool-web/README.md b/packages/web/tool-web/README.md new file mode 100644 index 0000000000..762bfe0189 --- /dev/null +++ b/packages/web/tool-web/README.md @@ -0,0 +1,30 @@ +# @deepseek-ai/dsh-tool-web + +The model-facing web tool suite — `web_search` and `web_fetch` — over the [web capability seam](../web/README.md) (`ctx.web`). It owns model-facing concerns only: tool names, JSON schemas, snake_case argument names, prompt sections, the result-count bound, result formatting, HTML→markdown presentation, and `presentCall`. All web access goes through `ctx.web`; this package never imports a concrete provider. + +Each tool is also a subpath plugin (`@deepseek-ai/dsh-tool-web/search`, `/fetch`) for focused deployments. + +## Tools + +| Tool | Args | Behavior | +|---|---|---| +| `web_search` | `query` (string) | Discovery. Returns an optional answer plus source URLs. `max_results` is **not** model-facing — the tool sets the bound (`WEB_SEARCH_MAX_RESULTS = 8`) and passes it to the seam. | +| `web_fetch` | `url` (string), `timeout_ms` (number, optional) | Retrieves a specific URL. HTML bodies are rendered to markdown-ish text; text bodies pass through. A non-2xx status is reported, not an error. | + +## Config + +| Key | Default | Meaning | +|---|---|---| +| `search` | `true` | Register `web_search`. | +| `fetch` | `true` | Register `web_fetch`. | + +```yaml +- id: tool-web + name: '@deepseek-ai/dsh-tool-web' +``` + +## Stable registration + +Tool registration follows product **enablement**, not backend availability. A tool stays visible even when its selected provider is missing, misconfigured, ambiguous, or temporarily unavailable; the seam resolves the provider at execution time and execution fails with a structured `WebError` (e.g. `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_AMBIGUOUS`), which `ToolRegistry.execute()` turns into an error tool result the model can read and hooks/UI can route on. This keeps the model schema stable without making plugin load order, credential state, or HMR timing part of the model-facing contract. To remove a web tool entirely, disable it here in config. + +The tool reads only the aggregated `ctx.web.searchStatus()` / `fetchStatus()` for diagnostics — never each provider's `status()` directly — so provider selection has one owner. diff --git a/packages/web/tool-web/package.json b/packages/web/tool-web/package.json new file mode 100644 index 0000000000..8d46faa157 --- /dev/null +++ b/packages/web/tool-web/package.json @@ -0,0 +1,42 @@ +{ + "name": "@deepseek-ai/dsh-tool-web", + "description": "Model-facing web tools (web_search, web_fetch) over the DeepSeek Harness web capability seam (ctx.web)", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { "types": "./lib/index.d.ts", "default": "./lib/index.js" }, + "./search": { "types": "./lib/search.d.ts", "default": "./lib/search.js" }, + "./fetch": { "types": "./lib/fetch.d.ts", "default": "./lib/fetch.js" }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "@deepseek-ai/dsh-web": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-web": "workspace:^", + "@deepseek-ai/dsh-web-fetch-local": "workspace:^", + "@deepseek-ai/dsh-web-search-exa": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/web/tool-web/src/fetch.ts b/packages/web/tool-web/src/fetch.ts new file mode 100644 index 0000000000..a48ad41414 --- /dev/null +++ b/packages/web/tool-web/src/fetch.ts @@ -0,0 +1,87 @@ +/** + * The model-facing `web_fetch` tool: retrieve the content of a specific URL. + * Execution goes through `ctx.web` — this module owns the model-facing schema, + * argument validation, and PRESENTATION (HTML→markdown, truncation formatting), + * while the fetch provider owns safe retrieval (transport, redirects, caps). + * + * @module @deepseek-ai/dsh-tool-web/fetch + */ + +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { ToolCallPresentation } from '@deepseek-ai/dsh-tools' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { WebFetchBody, WebFetchResult } from '@deepseek-ai/dsh-web' +import { assertNever } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-system-prompt' +import { htmlToMarkdown } from './html.ts' + +/** Validate value constraints the schema DSL can't express. */ +export function parseFetchArgs(args: { url: string; timeout_ms?: number }): { url: string; timeoutMs?: number } { + if (args.url.trim().length === 0) throw new Error('url must be a non-empty string') + if (args.timeout_ms !== undefined && (!Number.isFinite(args.timeout_ms) || args.timeout_ms <= 0)) { + throw new Error('timeout_ms must be a positive number') + } + return { url: args.url, ...args.timeout_ms !== undefined ? { timeoutMs: args.timeout_ms } : {} } +} + +/** Render a fetched body to model-facing markdown text. */ +export function renderBody(body: WebFetchBody): string { + switch (body.kind) { + case 'html': + return htmlToMarkdown(body.content) + case 'text': + return body.content + /* v8 ignore next 2 -- WebFetchBody is a closed union; this arm is unreachable and only makes adding a kind a compile error. */ + default: + return assertNever(body, 'unhandled web fetch body kind') + } +} + +/** Format a fetch result as one model-facing text block. */ +export function formatFetchOutput(result: WebFetchResult): string { + const header = `Fetched ${result.url} (HTTP ${result.statusCode})` + const footer = result.truncated ? '\n\n(Content truncated. Fetch a more specific URL or section for the full text.)' : '' + return `${header}\n\n${renderBody(result.body)}${footer}` +} + +/** Pending-call presentation: a fetch card titled by the URL. */ +export function presentFetchCall(args: { url: string; timeout_ms?: number }): ToolCallPresentation { + return { title: args.url, kind: 'fetch', rawInput: args.url } +} + +/** Register the `web_fetch` tool and its system-prompt guidance. */ +export function apply(ctx: Context): void { + ctx.systemPrompt.section({ + name: 'tool:web_fetch', + order: 111, + text: 'Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for example a result from web_search). It returns the page content decoded to text. Cite the URL as a markdown link when you use its content.', + }) + + ctx.tools.register(defineTool({ + name: 'web_fetch', + description: 'Fetch the content of a specific HTTP(S) URL and return it decoded to text.', + parameters: { + url: { type: 'string', required: true, description: 'The HTTP(S) URL to fetch.' }, + timeout_ms: { type: 'number', description: 'Optional fetch timeout in milliseconds (capped by the provider).' }, + }, + async execute(args, exec): Promise { + const input = parseFetchArgs(args) + const result = await ctx.web.fetch( + { url: input.url, ...input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {} }, + exec.signal ? { signal: exec.signal } : undefined, + ) + return [{ type: 'text', text: formatFetchOutput(result) }] + }, + presentCall: presentFetchCall, + })) +} + +/** Cordis plugin name used by loader diagnostics. */ +export const name = 'web-fetch' + +/** Services required by the `web_fetch` tool plugin. */ +export const inject = ['tools', 'web', 'systemPrompt'] + +/** Named helper for direct registration in the root plugin and tests. */ +export const applyWebFetchTool = apply diff --git a/packages/web/tool-web/src/html.ts b/packages/web/tool-web/src/html.ts new file mode 100644 index 0000000000..622be86fd5 --- /dev/null +++ b/packages/web/tool-web/src/html.ts @@ -0,0 +1,85 @@ +/** + * Minimal, dependency-free HTML→markdown-ish text conversion for `web_fetch` + * presentation. This is intentionally NOT a full HTML parser: it strips + * script/style/noscript, drops tags, decodes the common named/numeric entities, + * and collapses whitespace into a readable plain-text approximation with a few + * markdown affordances (headings, list bullets, links). A heavier converter can + * replace this without touching the seam or the tool schema. + * + * @module @deepseek-ai/dsh-tool-web/html + */ + +/** Decode the handful of HTML entities common in textual content. */ +function decodeEntities(text: string): string { + return text + .replace(/&(#[xX][0-9a-fA-F]+|#[0-9]+|[a-zA-Z]+);/g, (match, entity: string) => { + if (entity.startsWith('#x') || entity.startsWith('#X')) { + const code = Number.parseInt(entity.slice(2), 16) + return safeFromCodePoint(code, match) + } + if (entity.startsWith('#')) { + const code = Number.parseInt(entity.slice(1), 10) + return safeFromCodePoint(code, match) + } + return NAMED_ENTITIES[entity] ?? match + }) +} + +const NAMED_ENTITIES: Record = { + amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', + copy: '©', reg: '®', trade: '™', hellip: '…', mdash: '—', ndash: '–', +} + +function safeFromCodePoint(code: number, fallback: string): string { + try { + return String.fromCodePoint(code) + } catch { + // An out-of-range code point (RangeError) is the only failure here; keep the + // original entity text rather than throwing out of pure presentation. + return fallback + } +} + +/** + * Convert an HTML document to a readable markdown-ish text approximation. + * Best-effort and lossy by design — fidelity is the job of a future heavier + * converter, not this fallback. + */ +export function htmlToMarkdown(html: string): string { + let text = html + // Drop non-content elements entirely (including their contents). + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/]*>[\s\S]*?<\/noscript>/gi, '') + .replace(//g, '') + + // Convert links to markdown before stripping tags. + text = text.replace(/]*\bhref\s*=\s*["']([^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi, (_match, href: string, label: string) => { + const cleanLabel = label.replace(/<[^>]+>/g, '').trim() + return cleanLabel.length > 0 ? `[${cleanLabel}](${href})` : href + }) + + // Headings → markdown hashes. + text = text.replace(/]*>([\s\S]*?)<\/h\1>/gi, (_match, level: string, body: string) => { + const hashes = '#'.repeat(Number(level)) + return `\n\n${hashes} ${body.replace(/<[^>]+>/g, '').trim()}\n\n` + }) + + // List items → bullets. + text = text.replace(/]*>([\s\S]*?)<\/li>/gi, (_match, body: string) => `\n- ${body.replace(/<[^>]+>/g, '').trim()}`) + + // Block-level breaks become paragraph breaks. + text = text + .replace(/<\/(p|div|section|article|header|footer|tr|table|ul|ol|blockquote)>/gi, '\n\n') + .replace(//gi, '\n') + + // Drop all remaining tags, decode entities, collapse whitespace. + text = text.replace(/<[^>]+>/g, '') + text = decodeEntities(text) + text = text + .replace(/[ \t\f\v]+/g, ' ') + .replace(/ *\n */g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim() + return text +} diff --git a/packages/web/tool-web/src/index.ts b/packages/web/tool-web/src/index.ts new file mode 100644 index 0000000000..031d7fe4cb --- /dev/null +++ b/packages/web/tool-web/src/index.ts @@ -0,0 +1,59 @@ +/** + * The model-facing web tool suite (`web_search`, `web_fetch`) over the `ctx.web` + * seam. This root plugin registers the tools the product has ENABLED, composing + * the per-tool registration helpers; each tool is also exposed as a subpath + * plugin (`@deepseek-ai/dsh-tool-web/search`, `/fetch`) for focused deployments. + * + * The package owns model-facing concerns only — tool names, JSON schemas, + * argument validation, prompt sections, result-cap constants, result formatting, + * HTML→markdown presentation. All web access goes through `ctx.web`; this + * package never imports a concrete provider package. + * + * Tool registration follows product/app ENABLEMENT, not backend availability: a + * tool stays visible even when its selected provider is missing/misconfigured, + * and execution fails with a structured `WebError` (resolved by the seam at call + * time). That keeps the model schema stable without making plugin load order, + * credential state, or HMR timing part of the model-facing contract. + * + * @module @deepseek-ai/dsh-tool-web + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import type {} from '@deepseek-ai/dsh-web' +import { applyWebSearchTool } from './search.ts' +import { applyWebFetchTool } from './fetch.ts' + +export { WEB_SEARCH_MAX_RESULTS, applyWebSearchTool, formatSearchOutput, parseSearchArgs, presentSearchCall } from './search.ts' +export { applyWebFetchTool, formatFetchOutput, parseFetchArgs, presentFetchCall, renderBody } from './fetch.ts' +export { htmlToMarkdown } from './html.ts' + +/** Cordis plugin name used by loader diagnostics. */ +export const name = 'tool-web' + +/** Services required by the web tool suite. */ +export const inject = ['tools', 'web', 'systemPrompt'] + +export interface Config { + /** Register `web_search`. Defaults to true. */ + search?: boolean + /** Register `web_fetch`. Defaults to true. */ + fetch?: boolean +} + +export const Config: z = z.object({ + search: z.boolean().default(true), + fetch: z.boolean().default(true), +}) + +/** + * Register the enabled web tools. `search`/`fetch` default to true; a product + * that wants only one disables the other in config. The tools' disposers are + * fiber-scoped (the effect-based registries clean up on dispose), so no manual + * teardown is needed. + */ +export function apply(ctx: Context, config: Config): void { + if (config.search !== false) applyWebSearchTool(ctx) + if (config.fetch !== false) applyWebFetchTool(ctx) +} + diff --git a/packages/web/tool-web/src/search.ts b/packages/web/tool-web/src/search.ts new file mode 100644 index 0000000000..6ed9991903 --- /dev/null +++ b/packages/web/tool-web/src/search.ts @@ -0,0 +1,105 @@ +/** + * The model-facing `web_search` tool: discover current information on the web. + * Execution goes through `ctx.web` — this module owns only the model-facing + * schema, argument validation, the result-count bound, and result formatting, + * never provider selection or network access. + * + * @module @deepseek-ai/dsh-tool-web/search + */ + +import type { Context } from 'cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { ToolCallPresentation } from '@deepseek-ai/dsh-tools' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { WebSearchResult } from '@deepseek-ai/dsh-web' +import type {} from '@deepseek-ai/dsh-system-prompt' + +/** + * Default upper bound on returned sources. Owned by the consumer (not the + * provider or model), mirroring `dsh-tool-fs`'s `READ_LIMIT`/`GREP_LIMIT`. The + * model just asks a question; the product controls how much context returns. + * The default `8` aligns with OpenCode's Exa default. + */ +export const WEB_SEARCH_MAX_RESULTS = 8 + +/** Validate value constraints the schema DSL can't express. */ +export function parseSearchArgs(args: { query: string }): { query: string } { + if (args.query.trim().length === 0) throw new Error('query must be a non-empty string') + return { query: args.query } +} + +/** Display label for a source: its title, else its hostname. */ +function sourceLabel(url: string, title: string | undefined): string { + if (title !== undefined && title.length > 0) return title + try { + return new URL(url).hostname + } catch { + // A provider should return a valid URL, but never let a malformed one throw + // out of pure formatting — fall back to the raw string. + return url + } +} + +/** Format a search result as one model-facing text block. */ +export function formatSearchOutput(result: WebSearchResult): string { + const parts: string[] = [] + if (result.content !== undefined && result.content.length > 0) parts.push(result.content) + + if (result.sources.length > 0) { + const lines = result.sources.map((source) => { + const label = sourceLabel(source.url, source.title) + const meta: string[] = [] + if (source.snippet !== undefined && source.snippet.length > 0) meta.push(source.snippet) + if (source.publishedAt !== undefined && source.publishedAt.length > 0) meta.push(`(${source.publishedAt})`) + const suffix = meta.length > 0 ? ` — ${meta.join(' ')}` : '' + return `- [${label}](${source.url})${suffix}` + }) + parts.push(`Sources:\n${lines.join('\n')}`) + } else if (result.content === undefined || result.content.length === 0) { + parts.push('No results found.') + } + + if (result.truncated) parts.push(`(Showing the first ${result.sources.length} sources. Refine the query for more.)`) + parts.push('Cite the relevant URLs above as markdown links in your answer.') + return parts.join('\n\n') +} + +/** Pending-call presentation: a search card titled by the query. */ +export function presentSearchCall(args: { query: string }): ToolCallPresentation { + return { title: args.query, kind: 'search', rawInput: args.query } +} + +/** Register the `web_search` tool and its system-prompt guidance. */ +export function apply(ctx: Context): void { + ctx.systemPrompt.section({ + name: 'tool:web_search', + order: 110, + text: 'Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links.', + }) + + ctx.tools.register(defineTool({ + name: 'web_search', + description: 'Search the web for current information. Returns an optional summary answer and a list of source URLs.', + parameters: { + query: { type: 'string', required: true, description: 'The search query.' }, + }, + async execute(args, exec): Promise { + const input = parseSearchArgs(args) + const result = await ctx.web.search( + { query: input.query, maxResults: WEB_SEARCH_MAX_RESULTS }, + exec.signal ? { signal: exec.signal } : undefined, + ) + return [{ type: 'text', text: formatSearchOutput(result) }] + }, + presentCall: presentSearchCall, + })) +} + +/** Cordis plugin name used by loader diagnostics. */ +export const name = 'web-search' + +/** Services required by the `web_search` tool plugin. */ +export const inject = ['tools', 'web', 'systemPrompt'] + +/** Named helper for direct registration in the root plugin and tests. */ +export const applyWebSearchTool = apply diff --git a/packages/web/tool-web/tests/integration.spec.ts b/packages/web/tool-web/tests/integration.spec.ts new file mode 100644 index 0000000000..18604190c6 --- /dev/null +++ b/packages/web/tool-web/tests/integration.spec.ts @@ -0,0 +1,99 @@ +/** + * Integration: the real fetch backend (`dsh-web-fetch-local`) + a real search + * provider (`dsh-web-search-exa`) + the real seam (`dsh-web`) + the model tool + * (`dsh-tool-web`), exercised through `ctx.tools.execute()` — nothing bypasses + * the tool registry. Fetch hits a real loopback HTTP server (verifying the + * WORLD); search runs the real Exa provider over a stubbed global `fetch` (the + * network is the one boundary we mock). + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' +import { AddressInfo } from 'node:net' +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 WebService from '@deepseek-ai/dsh-web' +import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-local' +import * as WebSearchExa from '@deepseek-ai/dsh-web-search-exa' +import * as ToolWeb from '@deepseek-ai/dsh-tool-web' + +type Handler = (req: IncomingMessage, res: ServerResponse) => void + +let server: Server +let base: string +let handler: Handler +let ctx: Context +let fiber: Awaited> + +beforeEach(async () => { + handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/html' }); res.end('

Hello

World

') } + server = createServer((req, res) => { handler(req, res) }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}` + + ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(WebService, { searchProvider: WebSearchExa.EXA_PROVIDER_ID, fetchProvider: WebFetchLocal.LOCAL_FETCH_PROVIDER_ID }) + await ctx.plugin(WebFetchLocal, {}) + await ctx.plugin(WebSearchExa, { apiKey: 'exa-key', baseURL: 'https://api.exa.test' }) + fiber = await ctx.plugin(ToolWeb) +}) + +afterEach(async () => { + await fiber.dispose() + vi.unstubAllGlobals() + await new Promise(resolve => server.close(() => { resolve() })) +}) + +let counter = 0 +type ToolResult = { isError: boolean; content: { type: string; text?: string }[]; error?: { code: string } } +function call(name: string, args: unknown): Promise { + return ctx.tools.execute({ callId: CallId(`call-${++counter}`), name, arguments: args }) +} + +describe('web_fetch integration over the real backend', () => { + it('fetches an html page and renders it to markdown', async () => { + const out = await call('web_fetch', { url: base }) + expect(out.isError).toBe(false) + const text = out.content.map(b => b.text).join('') + expect(text).toContain(`Fetched ${base}`) + expect(text).toContain('# Hello') + expect(text).toContain('World') + }) + + it('reports a 404 as a result, not an error', async () => { + handler = (_req, res) => { res.writeHead(404, { 'content-type': 'text/plain' }); res.end('missing') } + const out = await call('web_fetch', { url: base }) + expect(out.isError).toBe(false) + expect(out.content.map(b => b.text).join('')).toContain('HTTP 404') + }) + + it('surfaces WEB_INVALID_URL as a structured tool error', async () => { + const out = await call('web_fetch', { url: 'ftp://example.com' }) + expect(out.isError).toBe(true) + expect(out.error?.code).toBe('WEB_INVALID_URL') + }) + + it('surfaces a blocked cross-origin redirect as WEB_REDIRECT_BLOCKED', async () => { + handler = (_req, res) => { res.writeHead(302, { location: 'https://example.com/' }); res.end() } + const out = await call('web_fetch', { url: base }) + expect(out.isError).toBe(true) + expect(out.error?.code).toBe('WEB_REDIRECT_BLOCKED') + }) +}) + +describe('web_search integration over the real Exa provider', () => { + it('runs web_search end-to-end and formats the provider result', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ results: [{ url: 'https://result.test', title: 'Result', highlights: ['a highlight'] }] }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ))) + const out = await call('web_search', { query: 'deepseek' }) + expect(out.isError).toBe(false) + expect(out.content.map(b => b.text).join('')).toContain('[Result](https://result.test)') + }) +}) + diff --git a/packages/web/tool-web/tests/load-path.spec.ts b/packages/web/tool-web/tests/load-path.spec.ts new file mode 100644 index 0000000000..5c47f3ce59 --- /dev/null +++ b/packages/web/tool-web/tests/load-path.spec.ts @@ -0,0 +1,49 @@ +/** + * Real-load-path guard for @deepseek-ai/dsh-tool-web. `tool-web` is a NAMESPACE + * plugin with `inject` — so a stray `export default apply` would make the cordis + * Loader's `unwrapExports` (`exports.default ?? exports`) collapse the module to + * the bare `apply` function, DROPPING `inject`. The plugin would then read + * `ctx.web` without having injected it and throw `cannot get property … without + * inject` the moment it loads (postmortem 0001). + * + * A hand-built `ctx.plugin({ apply, inject })` mount CANNOT catch that — it + * bypasses `unwrapExports`. So this test unwraps the module through the REAL + * `Loader.prototype.unwrapExports` and mounts the result over `ctx.web`, + * exercising the exact path the Loader uses. Prove the guard bites: add + * `export default apply` to `src/index.ts`, watch this go red, revert. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import WebService from '@deepseek-ai/dsh-web' +import * as toolWeb from '@deepseek-ai/dsh-tool-web' + +describe('dsh-tool-web real-load-path guard', () => { + it('has no default export and keeps name/inject/Config through unwrapExports', () => { + expect('default' in toolWeb).toBe(false) + + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(toolWeb) as Record + expect(unwrapped).toBe(toolWeb) + expect(unwrapped.name).toBe('tool-web') + expect(unwrapped.inject).toEqual(['tools', 'web', 'systemPrompt']) + expect(typeof unwrapped.apply).toBe('function') + }) + + it('boots over ctx.web through the unwrapped module without an inject error', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(WebService, {}) + + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(toolWeb) as Parameters[0] + // A collapsed export shape (dropped inject) would throw "without inject" here. + const fiber = await ctx.plugin(unwrapped) + expect(ctx.tools.schemas().map(s => s.name)).toEqual(expect.arrayContaining(['web_search', 'web_fetch'])) + await fiber.dispose() + }) +}) diff --git a/packages/web/tool-web/tests/tool-web.spec.ts b/packages/web/tool-web/tests/tool-web.spec.ts new file mode 100644 index 0000000000..2422c32ce3 --- /dev/null +++ b/packages/web/tool-web/tests/tool-web.spec.ts @@ -0,0 +1,281 @@ +import { describe, expect, it } from 'vitest' +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 WebService from '@deepseek-ai/dsh-web' +import type { WebSearchProvider, WebSearchResult, WebProviderStatus } from '@deepseek-ai/dsh-web' +import * as ToolWeb from '@deepseek-ai/dsh-tool-web' +import { + formatSearchOutput, + formatFetchOutput, + parseSearchArgs, + parseFetchArgs, + presentSearchCall, + presentFetchCall, + renderBody, + htmlToMarkdown, +} from '@deepseek-ai/dsh-tool-web' + +const available: WebProviderStatus = { available: true } + +function searchProvider(result: WebSearchResult, status: WebProviderStatus = available): WebSearchProvider { + return { id: 'stub-search', status: () => status, search: () => Promise.resolve(result) } +} + +/** Mount the real registry, seam, and tool-web; return an executor helper. */ +async function mountTools(opts: { + config?: ToolWeb.Config + webConfig?: ConstructorParameters[1] + search?: WebSearchProvider + fetchProvider?: import('@deepseek-ai/dsh-web').WebFetchProvider +} = {}): Promise<{ ctx: Context; fiber: Awaited>; call: (name: string, args: unknown) => Promise<{ isError: boolean; content: { type: string; text?: string }[]; error?: { code: string } }> }> { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(WebService, opts.webConfig ?? {}) + if (opts.search) ctx.web.registerSearchProvider(opts.search) + if (opts.fetchProvider) ctx.web.registerFetchProvider(opts.fetchProvider) + const fiber = await ctx.plugin(ToolWeb, opts.config ?? {}) + let counter = 0 + const call = (name: string, args: unknown) => ctx.tools.execute({ callId: CallId(`call-${++counter}`), name, arguments: args }) as never + return { ctx, fiber, call } +} + +describe('search formatting', () => { + it('renders content, sources with titles/hostnames, snippets, and a citation reminder', () => { + const out = formatSearchOutput({ + providerId: 'p', query: 'q', content: 'an answer', truncated: false, + sources: [ + { url: 'https://a.test/x', title: 'A', snippet: 'about a', publishedAt: '2026-01-01' }, + { url: 'https://b.test/y' }, + ], + }) + expect(out).toContain('an answer') + expect(out).toContain('[A](https://a.test/x) — about a (2026-01-01)') + expect(out).toContain('[b.test](https://b.test/y)') + expect(out).toContain('Cite the relevant URLs') + }) + + it('reports no results when there is neither content nor sources', () => { + expect(formatSearchOutput({ providerId: 'p', query: 'q', sources: [], truncated: false })) + .toContain('No results found.') + }) + + it('renders content alone when there are no sources', () => { + const out = formatSearchOutput({ providerId: 'p', query: 'q', content: 'just an answer', sources: [], truncated: false }) + expect(out).toContain('just an answer') + expect(out).not.toContain('No results found.') + expect(out).not.toContain('Sources:') + }) + + it('notes truncation', () => { + const out = formatSearchOutput({ providerId: 'p', query: 'q', sources: [{ url: 'https://a.test' }], truncated: true }) + expect(out).toContain('Showing the first 1 sources') + }) + + it('validates the query', () => { + expect(() => parseSearchArgs({ query: ' ' })).toThrow('non-empty') + expect(parseSearchArgs({ query: 'hi' })).toEqual({ query: 'hi' }) + }) + + it('presents a search call as a search-kind card titled by the query', () => { + expect(presentSearchCall({ query: 'find me' })).toEqual({ title: 'find me', kind: 'search', rawInput: 'find me' }) + }) +}) + +describe('fetch formatting', () => { + it('renders an html body to markdown text with a status header', () => { + const out = formatFetchOutput({ + providerId: 'p', url: 'https://a.test', statusCode: 200, truncated: false, + body: { kind: 'html', content: '

Title

Body text

' }, + }) + expect(out).toContain('Fetched https://a.test (HTTP 200)') + expect(out).toContain('# Title') + expect(out).toContain('Body text') + }) + + it('passes a text body through and notes truncation', () => { + const out = formatFetchOutput({ + providerId: 'p', url: 'https://a.test', statusCode: 200, truncated: true, + body: { kind: 'text', content: 'plain' }, + }) + expect(out).toContain('plain') + expect(out).toContain('Content truncated') + }) + + it('renderBody dispatches on kind', () => { + expect(renderBody({ kind: 'text', content: 'x' })).toBe('x') + expect(renderBody({ kind: 'html', content: '

y

' })).toBe('y') + }) + + it('validates url and timeout', () => { + expect(() => parseFetchArgs({ url: ' ' })).toThrow('non-empty') + expect(() => parseFetchArgs({ url: 'https://a.test', timeout_ms: -1 })).toThrow('positive') + expect(parseFetchArgs({ url: 'https://a.test', timeout_ms: 5 })).toEqual({ url: 'https://a.test', timeoutMs: 5 }) + }) + + it('presents a fetch call as a fetch-kind card titled by the url', () => { + expect(presentFetchCall({ url: 'https://a.test' })).toEqual({ title: 'https://a.test', kind: 'fetch', rawInput: 'https://a.test' }) + }) +}) + +describe('htmlToMarkdown', () => { + it('drops scripts/styles, keeps text, decodes entities, converts links', () => { + const md = htmlToMarkdown('

Tom & Jerry

link') + expect(md).not.toContain('bad()') + expect(md).not.toContain('.x{}') + expect(md).toContain('Tom & Jerry') + expect(md).toContain('[link](https://a.test)') + }) + + it('decodes numeric entities and collapses whitespace', () => { + expect(htmlToMarkdown('

a'b

')).toBe("a'b") + expect(htmlToMarkdown('
x
\n\n\n
y
')).toBe('x\n\ny') + }) + + it('decodes hex entities and named entities, and leaves unknown/out-of-range ones intact', () => { + expect(htmlToMarkdown('

AB

')).toBe('AB') + expect(htmlToMarkdown('

© —

')).toBe('© —') + expect(htmlToMarkdown('

¬areal;

')).toBe('¬areal;') + // An out-of-range code point keeps the original entity text (fromCodePoint fallback). + expect(htmlToMarkdown('

')).toBe('�') + expect(htmlToMarkdown('

')).toBe('�') + }) + + it('renders a link with an empty label as its bare href', () => { + expect(htmlToMarkdown('')).toBe('https://a.test') + }) + + it('converts headings and list items to markdown', () => { + expect(htmlToMarkdown('

Heading

after

')).toContain('## Heading') + const list = htmlToMarkdown('
  • one
  • two
') + expect(list).toContain('- one') + expect(list).toContain('- two') + }) + + it('falls back to the raw URL as a source label when the URL is unparseable', () => { + const out = formatSearchOutput({ providerId: 'p', query: 'q', truncated: false, sources: [{ url: 'not a url' }] }) + expect(out).toContain('[not a url](not a url)') + }) +}) + +describe('tool-web registration', () => { + it('registers both tools by default', async () => { + const { fiber, ctx } = await mountTools() + const names = ctx.tools.schemas().map(s => s.name) + expect(names).toContain('web_search') + expect(names).toContain('web_fetch') + await fiber.dispose() + expect(ctx.tools.schemas().map(s => s.name)).not.toContain('web_search') + }) + + it('registers only enabled tools', async () => { + const { fiber, ctx } = await mountTools({ config: { search: true, fetch: false } }) + const names = ctx.tools.schemas().map(s => s.name) + expect(names).toContain('web_search') + expect(names).not.toContain('web_fetch') + await fiber.dispose() + }) + + it('registers only web_fetch when search is disabled', async () => { + const { fiber, ctx } = await mountTools({ config: { search: false, fetch: true } }) + const names = ctx.tools.schemas().map(s => s.name) + expect(names).not.toContain('web_search') + expect(names).toContain('web_fetch') + await fiber.dispose() + }) + + it('registers web_search even when no provider is available (schema follows enablement, not availability)', async () => { + const { fiber, ctx } = await mountTools() + expect(ctx.tools.schemas().map(s => s.name)).toContain('web_search') + expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'none' }) + await fiber.dispose() + }) + + it('contributes prompt sections for the enabled tools', async () => { + const { fiber, ctx } = await mountTools() + const prompt = await ctx.systemPrompt.assemble() + const text = prompt.sections.map(s => (typeof s.text === 'function' ? s.text() : s.text)).join('\n') + expect(text).toContain('web_search') + expect(text).toContain('web_fetch') + await fiber.dispose() + }) +}) + +describe('tool-web execution through the real registry', () => { + it('executes web_search and formats the result', async () => { + const result: WebSearchResult = { + providerId: 'stub-search', query: 'q', content: 'answer', truncated: false, + sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip' }], + } + const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: searchProvider(result) }) + const out = await call('web_search', { query: 'q' }) + expect(out.isError).toBe(false) + expect(out.content.map(b => b.text).join('')).toContain('[A](https://a.test)') + await fiber.dispose() + }) + + it('surfaces a structured WebError when no provider is available', async () => { + const { fiber, call } = await mountTools() + const out = await call('web_search', { query: 'q' }) + expect(out.isError).toBe(true) + expect(out.error?.code).toBe('WEB_PROVIDER_UNAVAILABLE') + await fiber.dispose() + }) + + it('surfaces WEB_PROVIDER_AMBIGUOUS for multiple unconfigured providers', async () => { + const { ctx, fiber, call } = await mountTools({ search: searchProvider({ providerId: 'stub-search', query: 'q', sources: [], truncated: false }) }) + ctx.web.registerSearchProvider({ id: 'other', status: () => available, search: () => Promise.resolve({ providerId: 'other', query: 'q', sources: [], truncated: false }) }) + const out = await call('web_search', { query: 'q' }) + expect(out.isError).toBe(true) + expect(out.error?.code).toBe('WEB_PROVIDER_AMBIGUOUS') + await fiber.dispose() + }) + + it('rejects invalid arguments with a structured INVALID_ARGS error', async () => { + const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: searchProvider({ providerId: 'stub-search', query: 'q', sources: [], truncated: false }) }) + const out = await call('web_search', { query: 123 }) + expect(out.isError).toBe(true) + expect(out.error?.code).toBe('INVALID_ARGS') + await fiber.dispose() + }) + + it('has no default export (namespace plugin export shape)', () => { + expect('default' in ToolWeb).toBe(false) + }) + + it('executes web_fetch, forwarding timeout_ms and the abort signal to the seam', async () => { + const seen: { request?: { url: string; timeoutMs?: number }; signal?: AbortSignal | undefined } = {} + const fetchProvider = { + id: 'stub-fetch', + status: () => available, + fetch: (request: { url: string; timeoutMs?: number }, exec?: { signal?: AbortSignal }) => { + seen.request = request + seen.signal = exec?.signal + return Promise.resolve({ providerId: 'stub-fetch', url: request.url, statusCode: 200, body: { kind: 'text' as const, content: 'ok' }, truncated: false }) + }, + } + const { ctx, fiber } = await mountTools({ webConfig: { fetchProvider: 'stub-fetch' }, fetchProvider }) + const controller = new AbortController() + const out = await ctx.tools.execute({ callId: CallId('fetch-1'), name: 'web_fetch', arguments: { url: 'https://a.test', timeout_ms: 1234 }, signal: controller.signal }) + expect(out.isError).toBe(false) + expect(seen.request).toEqual({ url: 'https://a.test', timeoutMs: 1234 }) + expect(seen.signal).toBe(controller.signal) + await fiber.dispose() + }) + + it('executes web_search, forwarding the abort signal to the seam', async () => { + const seen: { signal?: AbortSignal | undefined } = {} + const provider: WebSearchProvider = { + id: 'stub-search', + status: () => available, + search: (_request, exec) => { seen.signal = exec?.signal; return Promise.resolve({ providerId: 'stub-search', query: 'q', sources: [], truncated: false }) }, + } + const { ctx, fiber } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider }) + const controller = new AbortController() + await ctx.tools.execute({ callId: CallId('search-1'), name: 'web_search', arguments: { query: 'q' }, signal: controller.signal }) + expect(seen.signal).toBe(controller.signal) + await fiber.dispose() + }) +}) diff --git a/packages/web/tool-web/tsconfig.json b/packages/web/tool-web/tsconfig.json new file mode 100644 index 0000000000..b4121a6c14 --- /dev/null +++ b/packages/web/tool-web/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, + { "path": "../../llm/llm" }, + { "path": "../../core/tools" }, + { "path": "../../core/system-prompt" }, + { "path": "../web" } + ] +} diff --git a/packages/web/tool-web/tsdown.config.ts b/packages/web/tool-web/tsdown.config.ts new file mode 100644 index 0000000000..c4849939db --- /dev/null +++ b/packages/web/tool-web/tsdown.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'tsdown' + +/** + * tool-web exposes one package root plus one entry per tool plugin, so each tool + * can be loaded or replaced independently as a subpath plugin + * (`@deepseek-ai/dsh-tool-web/search`, `/fetch`). The root tsdown config only + * auto-discovers `src/index.ts`, so the subpath entries are declared here. + */ +export default defineConfig({ + entry: ['src/index.ts', 'src/search.ts', 'src/fetch.ts'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, +}) diff --git a/packages/web/web-fetch-local/README.md b/packages/web/web-fetch-local/README.md new file mode 100644 index 0000000000..fd9150e46f --- /dev/null +++ b/packages/web/web-fetch-local/README.md @@ -0,0 +1,34 @@ +# @deepseek-ai/dsh-web-fetch-local + +An anonymous public HTTP(S) `WebFetchProvider` for the harness [web capability seam](../web/README.md) (`ctx.web`). It retrieves a concrete URL and returns a status code plus bounded decoded content. + +This is an **implementation** package: it registers a provider into `ctx.web`, it does not own the key and it does not register a model-facing tool. It is a function/namespace plugin (`inject: ['web']`). + +## Responsibility split + +The provider owns **safe resource retrieval**: URL validation, HTTP transport, redirect policy, timeout, abort propagation, byte caps, charset decoding, content-type classification, and binary rejection. `@deepseek-ai/dsh-tool-web` owns **presentation** (HTML→markdown, truncation formatting). A non-2xx HTTP response is a *result* (status code + decoded body), not an error; `WebError` is reserved for failures to safely retrieve or represent the resource. + +## Transport hygiene + +- Accepts only `http:` and `https:` URLs; rejects credentials in URLs (`WEB_BLOCKED_URL`) and over-long/malformed URLs (`WEB_INVALID_URL`). +- Enforces a max URL length, response byte cap (`WEB_FETCH_TOO_LARGE`), decoded body character cap, timeout (`WEB_FETCH_TIMEOUT`), and redirect hop cap. +- Propagates the caller's abort signal (`WEB_ABORTED`) into the network request and the streaming read. +- Follows only **same-origin** redirects; a cross-origin redirect fails with `WEB_REDIRECT_BLOCKED`, requiring a fresh tool call (the model of Claude Code's WebFetch). +- Sends an explicit product `User-Agent`, never a browser disguise. +- Rejects unsupported (e.g. binary) content types with `WEB_UNSUPPORTED_CONTENT_TYPE`. + +## Config + +| Key | Default | Meaning | +|---|---|---| +| `maxUrlLength` | `2048` | Maximum accepted request URL length. | +| `maxResponseBytes` | `5_000_000` | Maximum response body size in bytes. | +| `maxBodyChars` | `100_000` | Maximum decoded body length in characters. | +| `timeoutMs` | `30_000` | Default fetch timeout. | +| `maxTimeoutMs` | `120_000` | Upper bound for a per-request timeout override. | +| `maxRedirects` | `5` | Maximum same-origin redirect hops. | +| `userAgent` | `deepseek-harness/…` | `User-Agent` header. | + +## Security note + +SSRF / private-network protection (blocking private, loopback, link-local, multicast, and otherwise non-public destinations, with DNS-resolve-then-validate and per-hop re-validation) is **deferred** — see the [web capability seam RFC](../../../docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md). Until it lands, this provider is an SSRF primitive and **must not be enabled** in a deployment that can reach sensitive internal network targets. diff --git a/packages/web/web-fetch-local/package.json b/packages/web/web-fetch-local/package.json new file mode 100644 index 0000000000..7249697ec3 --- /dev/null +++ b/packages/web/web-fetch-local/package.json @@ -0,0 +1,33 @@ +{ + "name": "@deepseek-ai/dsh-web-fetch-local", + "description": "Anonymous public HTTP(S) fetch provider for the DeepSeek Harness web capability seam (ctx.web)", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-web": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-web": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/web/web-fetch-local/src/index.ts b/packages/web/web-fetch-local/src/index.ts new file mode 100644 index 0000000000..1d57410d68 --- /dev/null +++ b/packages/web/web-fetch-local/src/index.ts @@ -0,0 +1,77 @@ +/** + * `@deepseek-ai/dsh-web-fetch-local`: registers an anonymous public HTTP(S) + * `WebFetchProvider` with `ctx.web`. A function/namespace plugin (NOT a + * default-export service): it registers INTO the seam's fetch registry, like the + * search providers register into the search registry. + * + * @module @deepseek-ai/dsh-web-fetch-local + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import type {} from '@deepseek-ai/dsh-web' +import { LocalFetchProvider } from './provider.ts' +import type { LocalFetchLimits } from './provider.ts' + +export { + LOCAL_FETCH_PROVIDER_ID, + LocalFetchProvider, +} from './provider.ts' +export type { LocalFetchLimits } from './provider.ts' +export { classifyContentType, isSameOrigin, validateFetchUrl } from './policy.ts' +export type { FetchableKind } from './policy.ts' + +/** Default `User-Agent`: an explicit product agent, never a browser disguise. */ +export const DEFAULT_USER_AGENT = 'deepseek-harness/0.0.1 (+https://github.com/deepseek-ai)' + +/** Cordis plugin name used by loader diagnostics. */ +export const name = 'web-fetch-local' + +/** The web seam this provider registers into. */ +export const inject = ['web'] + +export interface Config { + /** Maximum accepted request URL length. */ + maxUrlLength?: number + /** Maximum response body size in bytes. */ + maxResponseBytes?: number + /** Maximum decoded body length in characters. */ + maxBodyChars?: number + /** Default fetch timeout in milliseconds. */ + timeoutMs?: number + /** Upper bound for a per-request timeout override. */ + maxTimeoutMs?: number + /** Maximum number of same-origin redirect hops to follow. */ + maxRedirects?: number + /** `User-Agent` header sent on every request. */ + userAgent?: string +} + +export const Config: z = z.object({ + maxUrlLength: z.number().default(2048), + maxResponseBytes: z.number().default(5_000_000), + maxBodyChars: z.number().default(100_000), + timeoutMs: z.number().default(30_000), + maxTimeoutMs: z.number().default(120_000), + maxRedirects: z.number().default(5), + userAgent: z.string().default(DEFAULT_USER_AGENT), +}) + +/** The shape after schemastery applies its defaults to every field. */ +type ResolvedConfig = Required + +/** Register the local HTTP(S) fetch provider with `ctx.web`. */ +export function apply(ctx: Context, config: Config): void { + // schemastery (Config) has already filled every defaulted field. + const resolved = config as ResolvedConfig + const limits: LocalFetchLimits = { + maxUrlLength: resolved.maxUrlLength, + maxResponseBytes: resolved.maxResponseBytes, + maxBodyChars: resolved.maxBodyChars, + timeoutMs: resolved.timeoutMs, + maxTimeoutMs: resolved.maxTimeoutMs, + maxRedirects: resolved.maxRedirects, + userAgent: resolved.userAgent, + } + ctx.web.registerFetchProvider(new LocalFetchProvider(limits)) +} diff --git a/packages/web/web-fetch-local/src/policy.ts b/packages/web/web-fetch-local/src/policy.ts new file mode 100644 index 0000000000..8261c5c1ed --- /dev/null +++ b/packages/web/web-fetch-local/src/policy.ts @@ -0,0 +1,59 @@ +/** + * URL validation and content-type classification for the local HTTP(S) fetch + * provider — the pure, network-free half. The provider's `fetch()` composes + * these with transport (redirect following, byte caps, decoding). + * + * @module @deepseek-ai/dsh-web-fetch-local/policy + */ + +import { WebError } from '@deepseek-ai/dsh-web' + +/** The body kinds this provider decodes. */ +export type FetchableKind = 'html' | 'text' + +/** + * Validate a request URL against the basic transport hygiene the provider + * enforces before any network access: http(s) only, no embedded credentials, + * bounded length. Returns the parsed `URL`. Throws {@link WebError} otherwise. + * (SSRF / private-network blocking is deferred — see the package RFC.) + */ +export function validateFetchUrl(input: string, maxUrlLength: number): URL { + if (input.length > maxUrlLength) { + throw new WebError(`URL exceeds the maximum length of ${maxUrlLength}`, 'WEB_INVALID_URL') + } + let url: URL + try { + url = new URL(input) + } catch (error: unknown) { + throw new WebError(`invalid URL: ${input}`, 'WEB_INVALID_URL', { cause: error }) + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new WebError(`unsupported URL scheme "${url.protocol}" (only http and https are allowed)`, 'WEB_INVALID_URL') + } + if (url.username.length > 0 || url.password.length > 0) { + throw new WebError('credentials in URLs are not allowed', 'WEB_BLOCKED_URL') + } + return url +} + +/** + * Two URLs are same-origin when scheme, hostname, and port match. A redirect + * that crosses origins is refused so each new origin requires a fresh tool call + * (and thus a fresh provider/permission decision). + */ +export function isSameOrigin(a: URL, b: URL): boolean { + return a.protocol === b.protocol && a.hostname === b.hostname && a.port === b.port +} + +/** + * Classify a response `Content-Type` into a decodable body kind, or `undefined` + * for an unsupported (e.g. binary) type. `text/html` and `application/xhtml+xml` + * are `html`; other `text/*` plus a few structured text types are `text`. + */ +export function classifyContentType(contentType: string | null): FetchableKind | undefined { + const mime = (contentType ?? '').replace(/;.*$/s, '').trim().toLowerCase() + if (mime === 'text/html' || mime === 'application/xhtml+xml') return 'html' + if (mime.startsWith('text/')) return 'text' + if (mime === 'application/json' || mime === 'application/xml' || mime.endsWith('+json') || mime.endsWith('+xml')) return 'text' + return undefined +} diff --git a/packages/web/web-fetch-local/src/provider.ts b/packages/web/web-fetch-local/src/provider.ts new file mode 100644 index 0000000000..0622030af0 --- /dev/null +++ b/packages/web/web-fetch-local/src/provider.ts @@ -0,0 +1,233 @@ +/** + * `LocalFetchProvider`: a `WebFetchProvider` that retrieves a concrete public + * HTTP(S) URL with the platform-native `fetch` (Node 24) and returns a status + * code plus bounded decoded content. It owns SAFE RESOURCE RETRIEVAL — URL + * validation, redirect policy, timeout, abort, byte caps, charset decoding, + * content-type classification, binary rejection — but NOT presentation + * (HTML→markdown lives in `@deepseek-ai/dsh-tool-web`). + * + * Redirects are followed manually (`redirect: 'manual'`) so the provider can + * enforce a same-origin-only policy: a cross-origin redirect is refused with + * `WEB_REDIRECT_BLOCKED`, requiring a fresh tool call (Claude Code's WebFetch + * uses the same model). It does NOT carry browser cookies, editor/git + * credentials, or implicit access to private services. + * + * SSRF / private-network protection is DEFERRED (see the package RFC); until it + * lands this provider is an SSRF primitive and must not be enabled where it can + * reach sensitive internal targets. + * + * @module @deepseek-ai/dsh-web-fetch-local/provider + */ + +import { WebError } from '@deepseek-ai/dsh-web' +import type { WebFetchBody, WebFetchProvider, WebFetchRequest, WebFetchResult, WebProviderStatus } from '@deepseek-ai/dsh-web' +import { classifyContentType, isSameOrigin, validateFetchUrl } from './policy.ts' + +/** Resolved provider limits (the plugin's schemastery Config supplies defaults). */ +export interface LocalFetchLimits { + /** Maximum accepted request URL length. */ + maxUrlLength: number + /** Maximum response body size in bytes (read is aborted past this). */ + maxResponseBytes: number + /** Maximum decoded body length in characters (truncated past this). */ + maxBodyChars: number + /** Default fetch timeout in milliseconds. */ + timeoutMs: number + /** Upper bound for a per-request timeout override. */ + maxTimeoutMs: number + /** Maximum number of (same-origin) redirect hops to follow. */ + maxRedirects: number + /** `User-Agent` header sent on every request. */ + userAgent: string +} + +/** Stable id this provider registers under. */ +export const LOCAL_FETCH_PROVIDER_ID = 'local-http' + +/** The anonymous public HTTP(S) fetch provider. */ +export class LocalFetchProvider implements WebFetchProvider { + readonly id = LOCAL_FETCH_PROVIDER_ID + + constructor(private readonly limits: LocalFetchLimits) {} + + /** No credentials to check — an anonymous public fetcher is always usable. */ + status(): WebProviderStatus { + return { available: true } + } + + async fetch(request: WebFetchRequest, exec?: { readonly signal?: AbortSignal }): Promise { + const timeoutMs = request.timeoutMs !== undefined + ? Math.min(request.timeoutMs, this.limits.maxTimeoutMs) + : this.limits.timeoutMs + + // One controller drives both the caller's abort and our own timeout, so the + // network request and the streaming read both stop on either. + const controller = new AbortController() + const onAbort = (): void => { controller.abort() } + if (exec?.signal !== undefined) { + if (exec.signal.aborted) throw new WebError('web fetch aborted', 'WEB_ABORTED') + exec.signal.addEventListener('abort', onAbort, { once: true }) + } + const timer = setTimeout(() => { controller.abort(new WebError('web fetch timed out', 'WEB_FETCH_TIMEOUT')) }, timeoutMs) + + try { + return await this.followAndRead(request.url, controller, timeoutMs) + } finally { + clearTimeout(timer) + if (exec?.signal !== undefined) exec.signal.removeEventListener('abort', onAbort) + } + } + + /** Follow same-origin redirects up to the hop cap, then read the final response. */ + private async followAndRead(initialUrl: string, controller: AbortController, timeoutMs: number): Promise { + let currentUrl = validateFetchUrl(initialUrl, this.limits.maxUrlLength) + + for (let hop = 0; hop <= this.limits.maxRedirects; hop++) { + const response = await this.requestOnce(currentUrl, controller, timeoutMs) + + if (isRedirectStatus(response.status)) { + const location = response.headers.get('location') + if (location === null) { + // A redirect status with no Location is not a usable resource. + throw new WebError(`redirect response (HTTP ${response.status}) without a Location header`, 'WEB_PROVIDER_ERROR') + } + const target = resolveRedirect(location, currentUrl) + if (!isSameOrigin(target, currentUrl)) { + throw new WebError( + `cross-origin redirect to ${target.origin} is not followed automatically; retry against that URL directly`, + 'WEB_REDIRECT_BLOCKED', + ) + } + await response.body?.cancel() + currentUrl = target + continue + } + + return await this.readBody(response, currentUrl) + } + + throw new WebError(`exceeded the maximum of ${this.limits.maxRedirects} redirects`, 'WEB_REDIRECT_BLOCKED') + } + + private async requestOnce(url: URL, controller: AbortController, _timeoutMs: number): Promise { + try { + return await fetch(url, { + method: 'GET', + redirect: 'manual', + headers: { 'user-agent': this.limits.userAgent, 'accept': 'text/html,application/xhtml+xml,text/*;q=0.9,application/json;q=0.8' }, + signal: controller.signal, + }) + } catch (error: unknown) { + throw translateAbortOrNetwork(error) + } + } + + /** Read, byte-cap, classify, and decode the final response body. */ + private async readBody(response: Response, finalUrl: URL): Promise { + const kind = classifyContentType(response.headers.get('content-type')) + if (kind === undefined) { + await response.body?.cancel() + throw new WebError(`unsupported content type "${response.headers.get('content-type') ?? 'unknown'}"`, 'WEB_UNSUPPORTED_CONTENT_TYPE') + } + + const { bytes, truncatedByBytes } = await this.readCapped(response) + const decoded = new TextDecoder('utf-8').decode(bytes) + const truncatedByChars = decoded.length > this.limits.maxBodyChars + const content = truncatedByChars ? decoded.slice(0, this.limits.maxBodyChars) : decoded + const body: WebFetchBody = kind === 'html' ? { kind: 'html', content } : { kind: 'text', content } + + return { + providerId: this.id, + url: finalUrl.toString(), + statusCode: response.status, + body, + truncated: truncatedByBytes || truncatedByChars, + } + } + + /** + * Read the response stream up to `maxResponseBytes`. A `Content-Length` over + * the cap rejects immediately with `WEB_FETCH_TOO_LARGE`; a stream that grows + * past the cap is cut short (`truncatedByBytes`) rather than rejected, so a + * server that under-reports still yields a bounded usable body. + */ + private async readCapped(response: Response): Promise<{ bytes: Uint8Array; truncatedByBytes: boolean }> { + const declared = response.headers.get('content-length') + if (declared !== null) { + const length = Number(declared) + if (Number.isFinite(length) && length > this.limits.maxResponseBytes) { + await response.body?.cancel() + throw new WebError(`response exceeds the maximum of ${this.limits.maxResponseBytes} bytes`, 'WEB_FETCH_TOO_LARGE') + } + } + + /* v8 ignore next -- a 2xx Response from fetch always exposes a body stream; the null guard is defensive. */ + if (response.body === null) return { bytes: new Uint8Array(0), truncatedByBytes: false } + + const chunks: Uint8Array[] = [] + let total = 0 + let truncatedByBytes = false + const reader = response.body.getReader() + try { + for (;;) { + const { done, value } = await reader.read() + if (done) break + const remaining = this.limits.maxResponseBytes - total + if (value.byteLength >= remaining) { + chunks.push(value.subarray(0, remaining)) + total += remaining + truncatedByBytes = true + break + } + chunks.push(value) + total += value.byteLength + } + } catch (error: unknown) { + /* v8 ignore next -- mid-stream read fault needs a network drop after headers; translate path covered by request-phase tests. */ + throw translateAbortOrNetwork(error) + } finally { + /* v8 ignore next 4 -- cancel() after a completed/broken read settles without rejecting; unobserved best-effort cleanup. */ + await reader.cancel().catch(() => { + // Cancel after a successful read (or after we broke past the cap) is + // best-effort cleanup; the bytes we need are already collected. + }) + } + + const bytes = new Uint8Array(total) + let offset = 0 + for (const chunk of chunks) { + bytes.set(chunk, offset) + offset += chunk.byteLength + } + return { bytes, truncatedByBytes } + } +} + +/** HTTP redirect status codes that carry a `Location`. */ +function isRedirectStatus(status: number): boolean { + return status === 301 || status === 302 || status === 303 || status === 307 || status === 308 +} + +/** Resolve a (possibly relative) `Location` against the current URL. */ +function resolveRedirect(location: string, base: URL): URL { + try { + return new URL(location, base) + } catch (error: unknown) { + /* v8 ignore next 2 -- URL resolution against a valid absolute base effectively never throws; defensive guard. */ + throw new WebError(`invalid redirect Location "${location}"`, 'WEB_PROVIDER_ERROR', { cause: error }) + } +} + +/** + * Translate a thrown fetch/stream error into a `WebError`. Our own + * `WEB_FETCH_TIMEOUT` (passed to `controller.abort(reason)`) and any other + * already-typed `WebError` pass through; an `AbortError` becomes `WEB_ABORTED`; + * anything else is a transport/network failure (`WEB_PROVIDER_ERROR`). + */ +function translateAbortOrNetwork(error: unknown): WebError { + if (error instanceof WebError) return error + if (error instanceof DOMException && error.name === 'AbortError') { + return new WebError('web fetch aborted', 'WEB_ABORTED', { cause: error }) + } + return new WebError(`web fetch failed: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) +} diff --git a/packages/web/web-fetch-local/tests/fetch-local.spec.ts b/packages/web/web-fetch-local/tests/fetch-local.spec.ts new file mode 100644 index 0000000000..3ca48150e1 --- /dev/null +++ b/packages/web/web-fetch-local/tests/fetch-local.spec.ts @@ -0,0 +1,239 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' +import { AddressInfo } from 'node:net' +import { Context } from 'cordis' +import WebService from '@deepseek-ai/dsh-web' +import { LocalFetchProvider, LOCAL_FETCH_PROVIDER_ID, classifyContentType, isSameOrigin, validateFetchUrl } from '@deepseek-ai/dsh-web-fetch-local' +import type { LocalFetchLimits } from '@deepseek-ai/dsh-web-fetch-local' +import * as fetchPlugin from '@deepseek-ai/dsh-web-fetch-local' + +const limits: LocalFetchLimits = { + maxUrlLength: 2048, + maxResponseBytes: 5_000_000, + maxBodyChars: 100_000, + timeoutMs: 5_000, + maxTimeoutMs: 10_000, + maxRedirects: 5, + userAgent: 'test-agent/1.0', +} + +type Handler = (req: IncomingMessage, res: ServerResponse) => void + +let server: Server +let base: string +let handler: Handler + +beforeEach(async () => { + handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('default') } + server = createServer((req, res) => { handler(req, res) }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as AddressInfo + base = `http://127.0.0.1:${port}` +}) + +afterEach(async () => { + await new Promise(resolve => server.close(() => { resolve() })) +}) + +function provider(overrides: Partial = {}): LocalFetchProvider { + return new LocalFetchProvider({ ...limits, ...overrides }) +} + +describe('policy helpers', () => { + it('validates scheme, credentials, and length', () => { + expect(validateFetchUrl('https://example.com/x', 2048).hostname).toBe('example.com') + expect(() => validateFetchUrl('ftp://example.com', 2048)).toThrow(expect.objectContaining({ code: 'WEB_INVALID_URL' })) + expect(() => validateFetchUrl('not a url', 2048)).toThrow(expect.objectContaining({ code: 'WEB_INVALID_URL' })) + expect(() => validateFetchUrl('https://user:pass@example.com', 2048)).toThrow(expect.objectContaining({ code: 'WEB_BLOCKED_URL' })) + expect(() => validateFetchUrl(`https://example.com/${'a'.repeat(3000)}`, 2048)).toThrow(expect.objectContaining({ code: 'WEB_INVALID_URL' })) + }) + + it('classifies content types', () => { + expect(classifyContentType('text/html; charset=utf-8')).toBe('html') + expect(classifyContentType('application/xhtml+xml')).toBe('html') + expect(classifyContentType('text/plain')).toBe('text') + expect(classifyContentType('application/json')).toBe('text') + expect(classifyContentType('image/png')).toBeUndefined() + expect(classifyContentType(null)).toBeUndefined() + }) + + it('compares origins', () => { + expect(isSameOrigin(new URL('https://a.com/x'), new URL('https://a.com/y'))).toBe(true) + expect(isSameOrigin(new URL('https://a.com'), new URL('https://b.com'))).toBe(false) + expect(isSameOrigin(new URL('http://a.com'), new URL('https://a.com'))).toBe(false) + }) +}) + +describe('LocalFetchProvider success', () => { + it('fetches a text body', async () => { + handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('hello world') } + const result = await provider().fetch({ url: base }) + expect(result.providerId).toBe(LOCAL_FETCH_PROVIDER_ID) + expect(result.statusCode).toBe(200) + expect(result.body).toEqual({ kind: 'text', content: 'hello world' }) + expect(result.truncated).toBe(false) + }) + + it('fetches an html body and classifies it as html', async () => { + handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/html' }); res.end('

hi

') } + const result = await provider().fetch({ url: base }) + expect(result.body).toEqual({ kind: 'html', content: '

hi

' }) + }) + + it('sends the configured user agent', async () => { + let seen: string | undefined + handler = (req, res) => { seen = req.headers['user-agent']; res.writeHead(200, { 'content-type': 'text/plain' }); res.end('ok') } + await provider().fetch({ url: base }) + expect(seen).toBe('test-agent/1.0') + }) + + it('returns a non-2xx response as a result, not an error', async () => { + handler = (_req, res) => { res.writeHead(404, { 'content-type': 'text/plain' }); res.end('nope') } + const result = await provider().fetch({ url: base }) + expect(result.statusCode).toBe(404) + expect(result.body).toEqual({ kind: 'text', content: 'nope' }) + }) +}) + +describe('LocalFetchProvider caps', () => { + it('rejects an over-cap Content-Length with WEB_FETCH_TOO_LARGE', async () => { + handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain', 'content-length': '999999' }); res.end('x'.repeat(999999)) } + await expect(provider({ maxResponseBytes: 10 }).fetch({ url: base })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_FETCH_TOO_LARGE' })) + }) + + it('truncates a stream that grows past the byte cap', async () => { + handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('abcdefghij') } + const result = await provider({ maxResponseBytes: 4 }).fetch({ url: base }) + expect(result.body.content).toBe('abcd') + expect(result.truncated).toBe(true) + }) + + it('truncates a decoded body past the character cap', async () => { + handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('abcdefghij') } + const result = await provider({ maxBodyChars: 3 }).fetch({ url: base }) + expect(result.body.content).toBe('abc') + expect(result.truncated).toBe(true) + }) + + it('rejects an unsupported content type', async () => { + handler = (_req, res) => { res.writeHead(200, { 'content-type': 'image/png' }); res.end('binary') } + await expect(provider().fetch({ url: base })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_UNSUPPORTED_CONTENT_TYPE' })) + }) + + it('rejects a response with no content type at all', async () => { + handler = (_req, res) => { res.writeHead(200); res.end('no type') } + await expect(provider().fetch({ url: base })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_UNSUPPORTED_CONTENT_TYPE' })) + }) + + it('accepts a declared content-length within the cap', async () => { + handler = (_req, res) => { const body = 'sized'; res.writeHead(200, { 'content-type': 'text/plain', 'content-length': String(body.length) }); res.end(body) } + const result = await provider().fetch({ url: base }) + expect(result.body.content).toBe('sized') + }) +}) + +describe('LocalFetchProvider redirects', () => { + it('follows a same-origin redirect and reports the final URL', async () => { + handler = (req, res) => { + if (req.url === '/start') { res.writeHead(302, { location: '/end' }); res.end() } + else { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('arrived') } + } + const result = await provider().fetch({ url: `${base}/start` }) + expect(result.body.content).toBe('arrived') + expect(result.url).toBe(`${base}/end`) + }) + + it('blocks a cross-origin redirect with WEB_REDIRECT_BLOCKED', async () => { + handler = (_req, res) => { res.writeHead(302, { location: 'https://example.com/' }); res.end() } + await expect(provider().fetch({ url: base })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED' })) + }) + + it('rejects exceeding the redirect hop cap', async () => { + handler = (req, res) => { + const n = Number(new URL(req.url ?? '/', base).searchParams.get('n') ?? '0') + res.writeHead(302, { location: `/?n=${n + 1}` }) + res.end() + } + await expect(provider({ maxRedirects: 2 }).fetch({ url: `${base}/?n=0` })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED' })) + }) + + it('treats a redirect without a Location header as a provider error', async () => { + handler = (_req, res) => { res.writeHead(302); res.end() } + await expect(provider().fetch({ url: base })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) + }) + + it('follows a relative same-origin redirect', async () => { + handler = (req, res) => { + if (req.url === '/a') { res.writeHead(301, { location: 'b' }); res.end() } + else { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('landed') } + } + const result = await provider().fetch({ url: `${base}/a` }) + expect(result.body.content).toBe('landed') + }) +}) + +describe('LocalFetchProvider invalid URLs and abort', () => { + it('rejects a non-http scheme before any network access', async () => { + await expect(provider().fetch({ url: 'ftp://example.com' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_INVALID_URL' })) + }) + + it('rejects credentials in the URL', async () => { + await expect(provider().fetch({ url: 'http://user:pass@127.0.0.1/' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_BLOCKED_URL' })) + }) + + it('honors a pre-aborted signal', async () => { + const controller = new AbortController() + controller.abort() + await expect(provider().fetch({ url: base }, { signal: controller.signal })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) + }) + + it('aborts an in-flight fetch via the signal', async () => { + handler = (_req, _res) => { /* never responds */ } + const controller = new AbortController() + const promise = provider().fetch({ url: base }, { signal: controller.signal }) + controller.abort() + await expect(promise).rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) + }) + + it('times out a slow response with WEB_FETCH_TIMEOUT', async () => { + handler = (_req, _res) => { /* never responds */ } + await expect(provider({ timeoutMs: 50 }).fetch({ url: base })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_FETCH_TIMEOUT' })) + }) + + it('maps a connection failure to WEB_PROVIDER_ERROR', async () => { + // Port 1 on loopback is not listening: a real connection failure (not abort). + await expect(provider().fetch({ url: 'http://127.0.0.1:1/' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) + }) + + it('caps the per-request timeout at maxTimeoutMs', async () => { + handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('ok') } + const result = await provider({ maxTimeoutMs: 10_000 }).fetch({ url: base, timeoutMs: 999_999 }) + expect(result.statusCode).toBe(200) + }) +}) + +describe('web-fetch-local plugin registration', () => { + it('registers the provider into ctx.web (HMR-safe)', async () => { + const ctx = new Context() + await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID }) + const fiber = await ctx.plugin(fetchPlugin, {}) + expect(ctx.web.fetchStatus()).toEqual({ available: true, providerId: LOCAL_FETCH_PROVIDER_ID }) + await fiber.dispose() + expect(ctx.web.fetchStatus()).toEqual({ available: false, reason: 'configured-missing' }) + }) + + it('has no default export (namespace plugin export shape)', () => { + expect('default' in fetchPlugin).toBe(false) + }) +}) diff --git a/packages/web/web-fetch-local/tsconfig.json b/packages/web/web-fetch-local/tsconfig.json new file mode 100644 index 0000000000..cb9eb44552 --- /dev/null +++ b/packages/web/web-fetch-local/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../web" + } + ] +} diff --git a/packages/web/web-search-exa/README.md b/packages/web/web-search-exa/README.md new file mode 100644 index 0000000000..7f39356e81 --- /dev/null +++ b/packages/web/web-search-exa/README.md @@ -0,0 +1,23 @@ +# @deepseek-ai/dsh-web-search-exa + +An [Exa](https://exa.ai)-backed `WebSearchProvider` for the harness [web capability seam](../web/README.md) (`ctx.web`). It calls Exa's `POST /search` endpoint with highlight contents and maps the flat `results[]` into the seam's normalized `WebSearchResult`. + +This is an **implementation** package: it registers a provider into `ctx.web`, it does not own the `ctx.web` key and it does not register a model-facing tool (that is `@deepseek-ai/dsh-tool-web`). Like `@deepseek-ai/dsh-llm-deepseek`, it is a function/namespace plugin (`inject: ['web']`) that registers its backend, not a default-export service. + +## Config + +| Key | Default | Meaning | +|---|---|---| +| `apiKey` | `$EXA_API_KEY` | Exa API key. Empty/absent → provider `status()` reports `missing-credential` (the seam reports `configured-unavailable`/`none`). | +| `baseURL` | `https://api.exa.ai` | Endpoint base; `/search` is appended. | + +```yaml +- id: web-search-exa + name: '@deepseek-ai/dsh-web-search-exa' + config: + apiKey: !!js process.env.EXA_API_KEY +``` + +## Mapping + +Exa returns a flat `results[]` and no generated answer, so `content` is omitted. Each result maps to a `WebSearchSource`: `url` ← `url`, `title` ← `title`, `snippet` ← the first non-empty `highlights[]` entry (a result with no highlight has no portable snippet and is dropped), `publishedAt` ← `publishedDate`. The provider passes `maxResults` through as Exa's `numResults` for a cost/latency optimization; the final bound is enforced by the seam. Provider failures (HTTP errors, network failure, unparseable bodies) surface as `WebError` `WEB_PROVIDER_ERROR`; an aborted request surfaces as `WEB_ABORTED`. diff --git a/packages/web/web-search-exa/package.json b/packages/web/web-search-exa/package.json new file mode 100644 index 0000000000..fe79af8ba8 --- /dev/null +++ b/packages/web/web-search-exa/package.json @@ -0,0 +1,33 @@ +{ + "name": "@deepseek-ai/dsh-web-search-exa", + "description": "Exa-backed search provider for the DeepSeek Harness web capability seam (ctx.web)", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-web": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-web": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/web/web-search-exa/src/index.ts b/packages/web/web-search-exa/src/index.ts new file mode 100644 index 0000000000..f266474708 --- /dev/null +++ b/packages/web/web-search-exa/src/index.ts @@ -0,0 +1,48 @@ +/** + * `@deepseek-ai/dsh-web-search-exa`: registers an Exa-backed `WebSearchProvider` + * with `ctx.web`. A function/namespace plugin (NOT a default-export service): + * a search provider does not own the `ctx.web` key — it registers INTO the + * seam's provider registry, exactly as `@deepseek-ai/dsh-llm-deepseek` + * registers an adapter into `ctx.llm`. The key is owned by `@deepseek-ai/dsh-web`. + * + * @module @deepseek-ai/dsh-web-search-exa + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import type {} from '@deepseek-ai/dsh-web' +import { ExaSearchProvider, EXA_DEFAULT_BASE_URL } from './provider.ts' + +export { + EXA_DEFAULT_BASE_URL, + EXA_PROVIDER_ID, + ExaSearchProvider, + mapExaResponse, + mapExaResult, +} from './provider.ts' +export type { ExaSearchProviderOptions } from './provider.ts' + +/** Cordis plugin name used by loader diagnostics. */ +export const name = 'web-search-exa' + +/** The web seam this provider registers into. */ +export const inject = ['web'] + +export interface Config { + /** Exa API key. Falls back to `$EXA_API_KEY`. Empty → provider unavailable. */ + apiKey?: string + /** Endpoint base; `/search` is appended. Defaults to the public API. */ + baseURL?: string +} + +export const Config: z = z.object({ + apiKey: z.string(), + baseURL: z.string(), +}) + +/** Register the Exa search provider with `ctx.web`. */ +export function apply(ctx: Context, config: Config): void { + const apiKey = config.apiKey ?? process.env.EXA_API_KEY ?? '' + const baseURL = config.baseURL ?? EXA_DEFAULT_BASE_URL + ctx.web.registerSearchProvider(new ExaSearchProvider({ apiKey, baseURL })) +} diff --git a/packages/web/web-search-exa/src/provider.ts b/packages/web/web-search-exa/src/provider.ts new file mode 100644 index 0000000000..d7c464059a --- /dev/null +++ b/packages/web/web-search-exa/src/provider.ts @@ -0,0 +1,130 @@ +/** + * `ExaSearchProvider`: a `WebSearchProvider` backed by the Exa search API + * (`POST /search` with highlight contents). Maps Exa's flat `results[]` into the + * seam's normalized `WebSearchResult`. Exa returns no provider-generated answer, + * so `content` is omitted; each result maps to a `WebSearchSource` with `url`, + * `title`, the first highlight as `snippet`, and `publishedDate` as + * `publishedAt`. + * + * Network requests use platform-native `fetch` (Node 24), mirroring + * `@deepseek-ai/dsh-llm-deepseek`'s adapter — not a cordis HTTP-client service. + * + * @module @deepseek-ai/dsh-web-search-exa/provider + */ + +import { WebError } from '@deepseek-ai/dsh-web' +import type { + WebProviderStatus, + WebSearchProvider, + WebSearchRequest, + WebSearchResult, + WebSearchSource, +} from '@deepseek-ai/dsh-web' +import type { ExaError, ExaResult, ExaSearchResponse } from './types.ts' + +/** Stable id this provider registers under. */ +export const EXA_PROVIDER_ID = 'exa' + +/** Default Exa search endpoint; `/search` is the operation. */ +export const EXA_DEFAULT_BASE_URL = 'https://api.exa.ai' + +/** Attribution header sent on every request. Bump with the package version. */ +const USER_AGENT = 'deepseek-harness/0.0.1' + +export interface ExaSearchProviderOptions { + /** Exa API key. Empty/absent → `status()` reports `missing-credential`. */ + apiKey: string + /** Endpoint base; `/search` is appended. */ + baseURL: string +} + +/** + * Map one Exa result to a normalized source, or `undefined` when it carries no + * portable snippet (an entry with no highlight is dropped — the seam has no + * other field to derive a snippet from, and inventing one would lie). + */ +export function mapExaResult(result: ExaResult): WebSearchSource | undefined { + const snippet = result.highlights?.find(highlight => highlight.trim().length > 0) + if (snippet === undefined) return undefined + return { + url: result.url, + ...result.title != null && result.title.length > 0 ? { title: result.title } : {}, + snippet, + ...result.publishedDate != null && result.publishedDate.length > 0 ? { publishedAt: result.publishedDate } : {}, + } +} + +/** Map an Exa response envelope to a normalized search result. */ +export function mapExaResponse(query: string, response: ExaSearchResponse): WebSearchResult { + const sources = (response.results ?? []) + .map(mapExaResult) + .filter((source): source is WebSearchSource => source !== undefined) + // Exa returns no generated answer, so `content` is omitted. The seam owns the + // final `maxResults` truncation, so this provider reports `truncated: false`. + return { providerId: EXA_PROVIDER_ID, query, sources, truncated: false } +} + +/** The Exa-backed search provider. */ +export class ExaSearchProvider implements WebSearchProvider { + readonly id = EXA_PROVIDER_ID + + constructor(private readonly options: ExaSearchProviderOptions) {} + + status(): WebProviderStatus { + if (this.options.apiKey.length === 0) return { available: false, reason: 'missing-credential' } + return { available: true } + } + + async search(request: WebSearchRequest, exec?: { readonly signal?: AbortSignal }): Promise { + let response: Response + try { + response = await fetch(`${this.options.baseURL}/search`, { + method: 'POST', + headers: { + 'authorization': `Bearer ${this.options.apiKey}`, + 'content-type': 'application/json', + 'accept': 'application/json', + 'user-agent': USER_AGENT, + }, + body: JSON.stringify({ + query: request.query, + contents: { highlights: true }, + ...request.maxResults !== undefined ? { numResults: request.maxResults } : {}, + }), + ...exec?.signal ? { signal: exec.signal } : {}, + }) + } catch (error: unknown) { + if (isAbortError(error)) throw new WebError('Exa search aborted', 'WEB_ABORTED', { cause: error }) + throw new WebError(`Exa search request failed: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) + } + + if (!response.ok) { + const status = response.status + let message = `Exa API error (HTTP ${status})` + try { + const parsed = await response.json() as ExaError + const detail = parsed.error ?? parsed.message + if (detail !== undefined && detail.length > 0) message = detail + } catch { + // The HTTP status is already captured in `message` above; a malformed or + // non-JSON error body (normal for gateway 5xx/429s) can only cost a + // richer provider message, never the real error. `response.json()` is + // the sole statement and nothing else of consequence reaches here. + } + throw new WebError(message, 'WEB_PROVIDER_ERROR') + } + + let payload: ExaSearchResponse + try { + payload = await response.json() as ExaSearchResponse + } catch (error: unknown) { + throw new WebError(`Exa returned an unparseable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) + } + return mapExaResponse(request.query, payload) + } +} + +/** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */ +function isAbortError(error: unknown): boolean { + return error instanceof DOMException && error.name === 'AbortError' +} diff --git a/packages/web/web-search-exa/src/types.ts b/packages/web/web-search-exa/src/types.ts new file mode 100644 index 0000000000..a0bda5f768 --- /dev/null +++ b/packages/web/web-search-exa/src/types.ts @@ -0,0 +1,36 @@ +/** + * Wire types for the Exa search API (`POST https://api.exa.ai/search`). Types + * only — no runtime code. Exa returns a flat `results[]`; each entry carries a + * URL, optional title, optional `publishedDate`, and (when highlights are + * requested) a `highlights[]` array of salient sentences. + * + * @module @deepseek-ai/dsh-web-search-exa/types + */ + +/** Request body sent to Exa's search endpoint. */ +export interface ExaSearchRequest { + query: string + /** Exa's result-count control; the seam still enforces the bound on return. */ + numResults?: number + /** Ask Exa to return highlight sentences per result. */ + contents: { highlights: true } +} + +/** One entry of Exa's flat `results[]`. */ +export interface ExaResult { + url: string + title?: string | null + publishedDate?: string | null + highlights?: string[] +} + +/** Exa's search response envelope. */ +export interface ExaSearchResponse { + results?: ExaResult[] +} + +/** Exa's error response envelope (best-effort; fields vary by failure). */ +export interface ExaError { + error?: string + message?: string +} diff --git a/packages/web/web-search-exa/tests/exa.e2e.ts b/packages/web/web-search-exa/tests/exa.e2e.ts new file mode 100644 index 0000000000..78f11940e5 --- /dev/null +++ b/packages/web/web-search-exa/tests/exa.e2e.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { ExaSearchProvider, EXA_DEFAULT_BASE_URL } from '@deepseek-ai/dsh-web-search-exa' + +/** + * Real-API smoke for the Exa search provider. Self-skips without `$EXA_API_KEY` + * (CI has no secrets), per the with-key e2e policy in AGENTS.md § Secrets. + */ +const apiKey = process.env.EXA_API_KEY +const maybe = apiKey !== undefined && apiKey.length > 0 ? describe : describe.skip + +maybe('ExaSearchProvider real API', () => { + it('returns sources for a live query', async () => { + const provider = new ExaSearchProvider({ apiKey: apiKey!, baseURL: process.env.EXA_BASE_URL ?? EXA_DEFAULT_BASE_URL }) + const result = await provider.search({ query: 'DeepSeek coding agent', maxResults: 5 }) + expect(result.providerId).toBe('exa') + expect(result.sources.length).toBeGreaterThan(0) + for (const source of result.sources) expect(source.url).toMatch(/^https?:\/\//) + }, 30_000) +}) diff --git a/packages/web/web-search-exa/tests/exa.spec.ts b/packages/web/web-search-exa/tests/exa.spec.ts new file mode 100644 index 0000000000..3fdd878180 --- /dev/null +++ b/packages/web/web-search-exa/tests/exa.spec.ts @@ -0,0 +1,193 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import WebService from '@deepseek-ai/dsh-web' +import { ExaSearchProvider, mapExaResponse, mapExaResult, EXA_PROVIDER_ID } from '@deepseek-ai/dsh-web-search-exa' +import * as exaPlugin from '@deepseek-ai/dsh-web-search-exa' + +const options = { apiKey: 'exa-key', baseURL: 'https://api.exa.test' } + +function jsonResponse(body: unknown, init: ResponseInit = {}): Response { + return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' }, ...init }) +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('Exa result mapping', () => { + it('maps a full result entry', () => { + expect(mapExaResult({ + url: 'https://a.test', + title: 'A', + publishedDate: '2026-01-01', + highlights: ['salient sentence', 'second'], + })).toEqual({ url: 'https://a.test', title: 'A', snippet: 'salient sentence', publishedAt: '2026-01-01' }) + }) + + it('drops a result with no usable highlight', () => { + expect(mapExaResult({ url: 'https://a.test', highlights: [] })).toBeUndefined() + expect(mapExaResult({ url: 'https://a.test' })).toBeUndefined() + expect(mapExaResult({ url: 'https://a.test', highlights: [' '] })).toBeUndefined() + }) + + it('omits null/empty optional fields rather than emitting them', () => { + expect(mapExaResult({ url: 'https://a.test', title: null, publishedDate: null, highlights: ['hi'] })) + .toEqual({ url: 'https://a.test', snippet: 'hi' }) + expect(mapExaResult({ url: 'https://a.test', title: '', publishedDate: '', highlights: ['hi'] })) + .toEqual({ url: 'https://a.test', snippet: 'hi' }) + }) + + it('maps a response to a result with no content and filtered sources', () => { + const result = mapExaResponse('q', { + results: [ + { url: 'https://a.test', highlights: ['one'] }, + { url: 'https://b.test' }, + { url: 'https://c.test', title: 'C', highlights: ['three'] }, + ], + }) + expect(result).toEqual({ + providerId: EXA_PROVIDER_ID, + query: 'q', + sources: [ + { url: 'https://a.test', snippet: 'one' }, + { url: 'https://c.test', title: 'C', snippet: 'three' }, + ], + truncated: false, + }) + expect(result.content).toBeUndefined() + }) + + it('tolerates a missing results array', () => { + expect(mapExaResponse('q', {}).sources).toEqual([]) + }) +}) + +describe('ExaSearchProvider status', () => { + it('is unavailable without a key', () => { + expect(new ExaSearchProvider({ apiKey: '', baseURL: options.baseURL }).status()) + .toEqual({ available: false, reason: 'missing-credential' }) + }) + + it('is available with a key', () => { + expect(new ExaSearchProvider(options).status()).toEqual({ available: true }) + }) +}) + +describe('ExaSearchProvider request mapping', () => { + it('sends query, highlights, numResults and bearer auth', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ results: [{ url: 'https://a.test', highlights: ['hi'] }] })) + vi.stubGlobal('fetch', fetchMock) + + const provider = new ExaSearchProvider(options) + await provider.search({ query: 'hello', maxResults: 5 }) + + expect(fetchMock).toHaveBeenCalledOnce() + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe('https://api.exa.test/search') + expect((init.headers as Record)['authorization']).toBe('Bearer exa-key') + expect(JSON.parse(init.body as string)).toEqual({ query: 'hello', contents: { highlights: true }, numResults: 5 }) + }) + + it('omits numResults when maxResults is absent', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ results: [] })) + vi.stubGlobal('fetch', fetchMock) + await new ExaSearchProvider(options).search({ query: 'q' }) + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(JSON.parse(init.body as string)).not.toHaveProperty('numResults') + }) + + it('forwards the abort signal', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ results: [] })) + vi.stubGlobal('fetch', fetchMock) + const controller = new AbortController() + await new ExaSearchProvider(options).search({ query: 'q' }, { signal: controller.signal }) + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(init.signal).toBe(controller.signal) + }) +}) + +describe('ExaSearchProvider error handling', () => { + it('maps an HTTP error to WEB_PROVIDER_ERROR with the provider message', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: 'bad key' }, { status: 401 }))) + await expect(new ExaSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR', message: 'bad key' })) + }) + + it('keeps a status-line message when the error body is not JSON', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('gateway down', { status: 502 }))) + await expect(new ExaSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR', message: 'Exa API error (HTTP 502)' })) + }) + + it('keeps the status-line message when the JSON error body carries no detail', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({}, { status: 500 }))) + await expect(new ExaSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ message: 'Exa API error (HTTP 500)' })) + }) + + it('maps a network failure to WEB_PROVIDER_ERROR', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new TypeError('connection refused')))) + await expect(new ExaSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) + }) + + it('maps an abort to WEB_ABORTED', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new DOMException('aborted', 'AbortError')))) + await expect(new ExaSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) + }) + + it('maps an unparseable success body to WEB_PROVIDER_ERROR', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('not json', { status: 200 }))) + await expect(new ExaSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) + }) +}) + +describe('web-search-exa plugin registration', () => { + it('registers the provider into ctx.web (HMR-safe)', async () => { + const ctx = new Context() + await ctx.plugin(WebService, { searchProvider: EXA_PROVIDER_ID }) + const fiber = await ctx.plugin(exaPlugin, { apiKey: 'exa-key' }) + expect(ctx.web.searchStatus()).toEqual({ available: true, providerId: EXA_PROVIDER_ID }) + await fiber.dispose() + expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'configured-missing' }) + }) + + it('has no default export (namespace plugin export shape)', () => { + expect('default' in exaPlugin).toBe(false) + }) + + it('falls back to $EXA_API_KEY and the default base URL when config omits them', async () => { + const prev = process.env.EXA_API_KEY + process.env.EXA_API_KEY = 'env-key' + try { + const fetchMock = vi.fn(async () => jsonResponse({ results: [] })) + vi.stubGlobal('fetch', fetchMock) + const ctx = new Context() + await ctx.plugin(WebService, { searchProvider: EXA_PROVIDER_ID }) + const fiber = await ctx.plugin(exaPlugin, {}) + expect(ctx.web.searchStatus()).toEqual({ available: true, providerId: EXA_PROVIDER_ID }) + await ctx.web.search({ query: 'q' }) + const [url] = fetchMock.mock.calls[0] as unknown as [string] + expect(url).toBe('https://api.exa.ai/search') + await fiber.dispose() + } finally { + if (prev === undefined) delete process.env.EXA_API_KEY + else process.env.EXA_API_KEY = prev + } + }) + + it('is unavailable when neither config nor env supplies a key', async () => { + const prev = process.env.EXA_API_KEY + delete process.env.EXA_API_KEY + try { + const ctx = new Context() + await ctx.plugin(WebService, { searchProvider: EXA_PROVIDER_ID }) + await ctx.plugin(exaPlugin, {}) + expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'configured-unavailable' }) + } finally { + if (prev !== undefined) process.env.EXA_API_KEY = prev + } + }) +}) diff --git a/packages/web/web-search-exa/tsconfig.json b/packages/web/web-search-exa/tsconfig.json new file mode 100644 index 0000000000..cb9eb44552 --- /dev/null +++ b/packages/web/web-search-exa/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../web" + } + ] +} diff --git a/packages/web/web-search-perplexity/README.md b/packages/web/web-search-perplexity/README.md new file mode 100644 index 0000000000..3850e5e9c1 --- /dev/null +++ b/packages/web/web-search-perplexity/README.md @@ -0,0 +1,24 @@ +# @deepseek-ai/dsh-web-search-perplexity + +A [Perplexity](https://perplexity.ai)-backed `WebSearchProvider` for the harness [web capability seam](../web/README.md) (`ctx.web`). It calls Perplexity's OpenAI-compatible `POST /chat/completions` endpoint and maps the generated answer plus citations into the seam's normalized `WebSearchResult`. + +This is an **implementation** package: it registers a provider into `ctx.web`, it does not own the key and it does not register a model-facing tool. Like `@deepseek-ai/dsh-llm-deepseek`, it is a function/namespace plugin (`inject: ['web']`). The OpenAI-compatible wire shape is a provider-private detail — it does **not** make this provider depend on `ctx.llm`. + +## Config + +| Key | Default | Meaning | +|---|---|---| +| `apiKey` | `$PERPLEXITY_API_KEY` | Perplexity API key. Empty/absent → provider `status()` reports `missing-credential`. | +| `baseURL` | `https://api.perplexity.ai` | Endpoint base; `/chat/completions` is appended. | +| `model` | `sonar` | Search model name. | + +```yaml +- id: web-search-perplexity + name: '@deepseek-ai/dsh-web-search-perplexity' + config: + apiKey: !!js process.env.PERPLEXITY_API_KEY +``` + +## Mapping + +`content` ← `choices[0].message.content` (the generated answer). `sources[]` prefers the structured `search_results[]` (`url`, `title`, `snippet`, `publishedAt` ← `date`), falling back to the URL-only `citations[]` array only when `search_results` is absent — those sources carry just a `url`, which is why `title`/`snippet`/`publishedAt` are optional on the seam. Provider failures surface as `WebError` `WEB_PROVIDER_ERROR`; an aborted request surfaces as `WEB_ABORTED`. Perplexity has no result-count control, so `maxResults` is enforced by the seam (truncating `sources[]` and setting `truncated`). diff --git a/packages/web/web-search-perplexity/package.json b/packages/web/web-search-perplexity/package.json new file mode 100644 index 0000000000..1d6229eae4 --- /dev/null +++ b/packages/web/web-search-perplexity/package.json @@ -0,0 +1,33 @@ +{ + "name": "@deepseek-ai/dsh-web-search-perplexity", + "description": "Perplexity-backed search provider for the DeepSeek Harness web capability seam (ctx.web)", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-web": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-web": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/web/web-search-perplexity/src/index.ts b/packages/web/web-search-perplexity/src/index.ts new file mode 100644 index 0000000000..0fd46ffb71 --- /dev/null +++ b/packages/web/web-search-perplexity/src/index.ts @@ -0,0 +1,52 @@ +/** + * `@deepseek-ai/dsh-web-search-perplexity`: registers a Perplexity-backed + * `WebSearchProvider` with `ctx.web`. A function/namespace plugin (NOT a + * default-export service): it registers INTO the seam's provider registry, like + * `@deepseek-ai/dsh-llm-deepseek` registers an adapter into `ctx.llm`. + * + * @module @deepseek-ai/dsh-web-search-perplexity + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import type {} from '@deepseek-ai/dsh-web' +import { PerplexitySearchProvider, PERPLEXITY_DEFAULT_BASE_URL, PERPLEXITY_DEFAULT_MODEL } from './provider.ts' + +export { + PERPLEXITY_DEFAULT_BASE_URL, + PERPLEXITY_DEFAULT_MODEL, + PERPLEXITY_PROVIDER_ID, + PerplexitySearchProvider, + mapPerplexityResponse, + mapPerplexityResult, +} from './provider.ts' +export type { PerplexitySearchProviderOptions } from './provider.ts' + +/** Cordis plugin name used by loader diagnostics. */ +export const name = 'web-search-perplexity' + +/** The web seam this provider registers into. */ +export const inject = ['web'] + +export interface Config { + /** Perplexity API key. Falls back to `$PERPLEXITY_API_KEY`. Empty → unavailable. */ + apiKey?: string + /** Endpoint base; `/chat/completions` is appended. Defaults to the public API. */ + baseURL?: string + /** Search model name. Defaults to `sonar`. */ + model?: string +} + +export const Config: z = z.object({ + apiKey: z.string(), + baseURL: z.string(), + model: z.string(), +}) + +/** Register the Perplexity search provider with `ctx.web`. */ +export function apply(ctx: Context, config: Config): void { + const apiKey = config.apiKey ?? process.env.PERPLEXITY_API_KEY ?? '' + const baseURL = config.baseURL ?? PERPLEXITY_DEFAULT_BASE_URL + const model = config.model ?? PERPLEXITY_DEFAULT_MODEL + ctx.web.registerSearchProvider(new PerplexitySearchProvider({ apiKey, baseURL, model })) +} diff --git a/packages/web/web-search-perplexity/src/provider.ts b/packages/web/web-search-perplexity/src/provider.ts new file mode 100644 index 0000000000..2b596414dd --- /dev/null +++ b/packages/web/web-search-perplexity/src/provider.ts @@ -0,0 +1,138 @@ +/** + * `PerplexitySearchProvider`: a `WebSearchProvider` backed by the Perplexity + * search API (an OpenAI-compatible `POST /chat/completions`). Maps the generated + * answer (`choices[0].message.content`) into `content`, and prefers the + * structured `search_results[]` for `sources[]`, falling back to the URL-only + * `citations[]` when `search_results` is absent. + * + * Network requests use platform-native `fetch` (Node 24), mirroring + * `@deepseek-ai/dsh-llm-deepseek`'s adapter. The OpenAI-compatible request shape + * is a provider-private detail and does NOT make this provider depend on + * `ctx.llm`. + * + * @module @deepseek-ai/dsh-web-search-perplexity/provider + */ + +import { WebError } from '@deepseek-ai/dsh-web' +import type { + WebProviderStatus, + WebSearchProvider, + WebSearchRequest, + WebSearchResult, + WebSearchSource, +} from '@deepseek-ai/dsh-web' +import type { PerplexityError, PerplexityResponse, PerplexitySearchResult } from './types.ts' + +/** Stable id this provider registers under. */ +export const PERPLEXITY_PROVIDER_ID = 'perplexity' + +/** Default Perplexity endpoint; `/chat/completions` is the operation. */ +export const PERPLEXITY_DEFAULT_BASE_URL = 'https://api.perplexity.ai' + +/** Default search model. */ +export const PERPLEXITY_DEFAULT_MODEL = 'sonar' + +/** Attribution header sent on every request. Bump with the package version. */ +const USER_AGENT = 'deepseek-harness/0.0.1' + +export interface PerplexitySearchProviderOptions { + /** Perplexity API key. Empty/absent → `status()` reports `missing-credential`. */ + apiKey: string + /** Endpoint base; `/chat/completions` is appended. */ + baseURL: string + /** Search model name. */ + model: string +} + +/** Map one structured Perplexity search result to a normalized source. */ +export function mapPerplexityResult(result: PerplexitySearchResult): WebSearchSource { + return { + url: result.url, + ...result.title != null && result.title.length > 0 ? { title: result.title } : {}, + ...result.snippet != null && result.snippet.length > 0 ? { snippet: result.snippet } : {}, + ...result.date != null && result.date.length > 0 ? { publishedAt: result.date } : {}, + } +} + +/** + * Map a Perplexity response envelope to a normalized search result. Prefers + * structured `search_results[]`; falls back to URL-only `citations[]` (those + * sources carry just a `url`) only when `search_results` is absent. + */ +export function mapPerplexityResponse(query: string, response: PerplexityResponse): WebSearchResult { + const content = response.choices?.[0]?.message?.content + const sources: WebSearchSource[] = response.search_results !== undefined + ? response.search_results.map(mapPerplexityResult) + : (response.citations ?? []).map(url => ({ url })) + return { + providerId: PERPLEXITY_PROVIDER_ID, + query, + ...content != null && content.length > 0 ? { content } : {}, + sources, + truncated: false, + } +} + +/** The Perplexity-backed search provider. */ +export class PerplexitySearchProvider implements WebSearchProvider { + readonly id = PERPLEXITY_PROVIDER_ID + + constructor(private readonly options: PerplexitySearchProviderOptions) {} + + status(): WebProviderStatus { + if (this.options.apiKey.length === 0) return { available: false, reason: 'missing-credential' } + return { available: true } + } + + async search(request: WebSearchRequest, exec?: { readonly signal?: AbortSignal }): Promise { + let response: Response + try { + response = await fetch(`${this.options.baseURL}/chat/completions`, { + method: 'POST', + headers: { + 'authorization': `Bearer ${this.options.apiKey}`, + 'content-type': 'application/json', + 'accept': 'application/json', + 'user-agent': USER_AGENT, + }, + body: JSON.stringify({ + model: this.options.model, + messages: [{ role: 'user', content: request.query }], + }), + ...exec?.signal ? { signal: exec.signal } : {}, + }) + } catch (error: unknown) { + if (isAbortError(error)) throw new WebError('Perplexity search aborted', 'WEB_ABORTED', { cause: error }) + throw new WebError(`Perplexity search request failed: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) + } + + if (!response.ok) { + const status = response.status + let message = `Perplexity API error (HTTP ${status})` + try { + const parsed = await response.json() as PerplexityError + const detail = typeof parsed.error === 'string' ? parsed.error : parsed.error?.message ?? parsed.message + if (detail !== undefined && detail.length > 0) message = detail + } catch { + // The HTTP status is already captured in `message` above; a malformed or + // non-JSON error body (normal for gateway 5xx/429s) can only cost a + // richer provider message, never the real error. `response.json()` is + // the sole statement and nothing else of consequence reaches here. + } + throw new WebError(message, 'WEB_PROVIDER_ERROR') + } + + let payload: PerplexityResponse + try { + payload = await response.json() as PerplexityResponse + } catch (error: unknown) { + throw new WebError(`Perplexity returned an unparseable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) + } + return mapPerplexityResponse(request.query, payload) + } +} + +/** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */ +function isAbortError(error: unknown): boolean { + return error instanceof DOMException && error.name === 'AbortError' +} diff --git a/packages/web/web-search-perplexity/src/types.ts b/packages/web/web-search-perplexity/src/types.ts new file mode 100644 index 0000000000..7b1f2e32b0 --- /dev/null +++ b/packages/web/web-search-perplexity/src/types.ts @@ -0,0 +1,41 @@ +/** + * Wire types for the Perplexity search API + * (`POST https://api.perplexity.ai/chat/completions`, an OpenAI-compatible chat + * shape). Types only — no runtime code. Perplexity returns a generated answer in + * `choices[0].message.content` plus citation surfaces: a structured + * `search_results[]` (preferred) and a URL-only `citations[]` fallback. + * + * The OpenAI-compatible wire shape is a provider-private detail; it does not make + * this provider depend on `ctx.llm`. + * + * @module @deepseek-ai/dsh-web-search-perplexity/types + */ + +/** Request body sent to Perplexity's chat-completions endpoint. */ +export interface PerplexityRequest { + model: string + messages: { role: 'user'; content: string }[] +} + +/** One structured search result (the preferred citation surface). */ +export interface PerplexitySearchResult { + url: string + title?: string | null + snippet?: string | null + date?: string | null +} + +/** Perplexity's response envelope. */ +export interface PerplexityResponse { + choices?: { message?: { content?: string | null } }[] + /** Structured citation surface (preferred). */ + search_results?: PerplexitySearchResult[] + /** URL-only citation fallback. */ + citations?: string[] +} + +/** Perplexity's error response envelope (best-effort; fields vary). */ +export interface PerplexityError { + error?: { message?: string } | string + message?: string +} diff --git a/packages/web/web-search-perplexity/tests/perplexity.e2e.ts b/packages/web/web-search-perplexity/tests/perplexity.e2e.ts new file mode 100644 index 0000000000..a546acab70 --- /dev/null +++ b/packages/web/web-search-perplexity/tests/perplexity.e2e.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { PerplexitySearchProvider, PERPLEXITY_DEFAULT_BASE_URL, PERPLEXITY_DEFAULT_MODEL } from '@deepseek-ai/dsh-web-search-perplexity' + +/** + * Real-API smoke for the Perplexity search provider. Self-skips without + * `$PERPLEXITY_API_KEY`, per the with-key e2e policy in AGENTS.md § Secrets. + */ +const apiKey = process.env.PERPLEXITY_API_KEY +const maybe = apiKey !== undefined && apiKey.length > 0 ? describe : describe.skip + +maybe('PerplexitySearchProvider real API', () => { + it('returns a generated answer and sources for a live query', async () => { + const provider = new PerplexitySearchProvider({ + apiKey: apiKey!, + baseURL: process.env.PERPLEXITY_BASE_URL ?? PERPLEXITY_DEFAULT_BASE_URL, + model: process.env.PERPLEXITY_MODEL ?? PERPLEXITY_DEFAULT_MODEL, + }) + const result = await provider.search({ query: 'What is the DeepSeek coding agent?', maxResults: 5 }) + expect(result.providerId).toBe('perplexity') + expect(result.content ?? '').not.toBe('') + for (const source of result.sources) expect(source.url).toMatch(/^https?:\/\//) + }, 30_000) +}) diff --git a/packages/web/web-search-perplexity/tests/perplexity.spec.ts b/packages/web/web-search-perplexity/tests/perplexity.spec.ts new file mode 100644 index 0000000000..16557af888 --- /dev/null +++ b/packages/web/web-search-perplexity/tests/perplexity.spec.ts @@ -0,0 +1,192 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import WebService from '@deepseek-ai/dsh-web' +import { + PerplexitySearchProvider, + mapPerplexityResponse, + PERPLEXITY_PROVIDER_ID, +} from '@deepseek-ai/dsh-web-search-perplexity' +import * as perplexityPlugin from '@deepseek-ai/dsh-web-search-perplexity' + +const options = { apiKey: 'pplx-key', baseURL: 'https://api.perplexity.test', model: 'sonar' } + +function jsonResponse(body: unknown, init: ResponseInit = {}): Response { + return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' }, ...init }) +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('Perplexity response mapping', () => { + it('maps the answer and prefers structured search_results', () => { + const result = mapPerplexityResponse('q', { + choices: [{ message: { content: 'the answer' } }], + search_results: [ + { url: 'https://a.test', title: 'A', snippet: 'snip', date: '2026-02-02' }, + { url: 'https://b.test' }, + ], + citations: ['https://ignored.test'], + }) + expect(result).toEqual({ + providerId: PERPLEXITY_PROVIDER_ID, + query: 'q', + content: 'the answer', + sources: [ + { url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-02-02' }, + { url: 'https://b.test' }, + ], + truncated: false, + }) + }) + + it('falls back to URL-only citations when search_results is absent', () => { + const result = mapPerplexityResponse('q', { + choices: [{ message: { content: 'answer' } }], + citations: ['https://a.test', 'https://b.test'], + }) + expect(result.sources).toEqual([{ url: 'https://a.test' }, { url: 'https://b.test' }]) + }) + + it('omits content when the answer is empty or missing', () => { + expect(mapPerplexityResponse('q', { citations: [] }).content).toBeUndefined() + expect(mapPerplexityResponse('q', { choices: [{ message: { content: '' } }] }).content).toBeUndefined() + expect(mapPerplexityResponse('q', { choices: [{ message: { content: null } }] }).content).toBeUndefined() + }) + + it('omits null/empty optional source fields', () => { + const result = mapPerplexityResponse('q', { + search_results: [{ url: 'https://a.test', title: null, snippet: '', date: null }], + }) + expect(result.sources).toEqual([{ url: 'https://a.test' }]) + }) + + it('yields no sources when neither search_results nor citations are present', () => { + expect(mapPerplexityResponse('q', { choices: [{ message: { content: 'a' } }] }).sources).toEqual([]) + }) +}) + +describe('PerplexitySearchProvider status', () => { + it('is unavailable without a key', () => { + expect(new PerplexitySearchProvider({ ...options, apiKey: '' }).status()) + .toEqual({ available: false, reason: 'missing-credential' }) + }) + + it('is available with a key', () => { + expect(new PerplexitySearchProvider(options).status()).toEqual({ available: true }) + }) +}) + +describe('PerplexitySearchProvider request mapping', () => { + it('sends a chat-completions request with the query as a user message', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ choices: [{ message: { content: 'a' } }], citations: [] })) + vi.stubGlobal('fetch', fetchMock) + await new PerplexitySearchProvider(options).search({ query: 'hello' }) + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe('https://api.perplexity.test/chat/completions') + expect((init.headers as Record)['authorization']).toBe('Bearer pplx-key') + expect(JSON.parse(init.body as string)).toEqual({ model: 'sonar', messages: [{ role: 'user', content: 'hello' }] }) + }) + + it('forwards the abort signal', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ citations: [] })) + vi.stubGlobal('fetch', fetchMock) + const controller = new AbortController() + await new PerplexitySearchProvider(options).search({ query: 'q' }, { signal: controller.signal }) + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(init.signal).toBe(controller.signal) + }) +}) + +describe('PerplexitySearchProvider error handling', () => { + it('maps an HTTP error to WEB_PROVIDER_ERROR with the provider message', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: { message: 'rate limited' } }, { status: 429 }))) + await expect(new PerplexitySearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR', message: 'rate limited' })) + }) + + it('handles a string-form error body', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: 'bad request' }, { status: 400 }))) + await expect(new PerplexitySearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ message: 'bad request' })) + }) + + it('keeps a status-line message when the error body is not JSON', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('upstream error', { status: 503 }))) + await expect(new PerplexitySearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ message: 'Perplexity API error (HTTP 503)' })) + }) + + it('keeps the status-line message when the JSON error body carries no detail', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({}, { status: 500 }))) + await expect(new PerplexitySearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ message: 'Perplexity API error (HTTP 500)' })) + }) + + it('maps an abort to WEB_ABORTED', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new DOMException('aborted', 'AbortError')))) + await expect(new PerplexitySearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) + }) + + it('maps an unparseable success body to WEB_PROVIDER_ERROR', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('not json', { status: 200 }))) + await expect(new PerplexitySearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) + }) + + it('maps a network failure to WEB_PROVIDER_ERROR', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new TypeError('connection refused')))) + await expect(new PerplexitySearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) + }) +}) + +describe('web-search-perplexity plugin registration', () => { + it('registers the provider into ctx.web (HMR-safe)', async () => { + const ctx = new Context() + await ctx.plugin(WebService, { searchProvider: PERPLEXITY_PROVIDER_ID }) + const fiber = await ctx.plugin(perplexityPlugin, { apiKey: 'pplx-key' }) + expect(ctx.web.searchStatus()).toEqual({ available: true, providerId: PERPLEXITY_PROVIDER_ID }) + await fiber.dispose() + expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'configured-missing' }) + }) + + it('has no default export (namespace plugin export shape)', () => { + expect('default' in perplexityPlugin).toBe(false) + }) + + it('falls back to env key and defaults for base URL and model when config omits them', async () => { + const prev = process.env.PERPLEXITY_API_KEY + process.env.PERPLEXITY_API_KEY = 'env-key' + try { + const fetchMock = vi.fn(async () => jsonResponse({ choices: [{ message: { content: 'a' } }], citations: [] })) + vi.stubGlobal('fetch', fetchMock) + const ctx = new Context() + await ctx.plugin(WebService, { searchProvider: PERPLEXITY_PROVIDER_ID }) + const fiber = await ctx.plugin(perplexityPlugin, {}) + expect(ctx.web.searchStatus()).toEqual({ available: true, providerId: PERPLEXITY_PROVIDER_ID }) + await ctx.web.search({ query: 'q' }) + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe('https://api.perplexity.ai/chat/completions') + expect(JSON.parse(init.body as string)).toMatchObject({ model: 'sonar' }) + await fiber.dispose() + } finally { + if (prev === undefined) delete process.env.PERPLEXITY_API_KEY + else process.env.PERPLEXITY_API_KEY = prev + } + }) + + it('is unavailable when neither config nor env supplies a key', async () => { + const prev = process.env.PERPLEXITY_API_KEY + delete process.env.PERPLEXITY_API_KEY + try { + const ctx = new Context() + await ctx.plugin(WebService, { searchProvider: PERPLEXITY_PROVIDER_ID }) + await ctx.plugin(perplexityPlugin, {}) + expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'configured-unavailable' }) + } finally { + if (prev !== undefined) process.env.PERPLEXITY_API_KEY = prev + } + }) +}) diff --git a/packages/web/web-search-perplexity/tsconfig.json b/packages/web/web-search-perplexity/tsconfig.json new file mode 100644 index 0000000000..cb9eb44552 --- /dev/null +++ b/packages/web/web-search-perplexity/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../web" + } + ] +} diff --git a/packages/web/web/README.md b/packages/web/web/README.md new file mode 100644 index 0000000000..9b9e2ca333 --- /dev/null +++ b/packages/web/web/README.md @@ -0,0 +1,45 @@ +# @deepseek-ai/dsh-web + +The **web access seam**: an abstract `WebService` (`ctx.web`) defining WHAT web access the harness has — search the web, fetch a URL — over multiple providers, without binding the model contract to one vendor's API shape. + +This package is the interface third of the web capability. Unlike bash/fs it spans two capabilities (search and fetch) on one seam, with potentially multiple providers each: + +| Package | Role | +|---|---| +| `@deepseek-ai/dsh-web` (this) | the interface: the service, provider registries, selection policy, request/result vocabulary, the `WebError` taxonomy | +| `@deepseek-ai/dsh-web-search-exa` | a search implementation: Exa | +| `@deepseek-ai/dsh-web-search-perplexity` | a search implementation: Perplexity | +| `@deepseek-ai/dsh-web-fetch-local` | a fetch implementation: anonymous public HTTP(S) | +| `@deepseek-ai/dsh-tool-web` | the model-facing `web_search` / `web_fetch` tool schemas over `ctx.web` | + +Search and fetch share no request schema and no business logic, but they are deliberately one seam: `ctx.web` is a single web-access middle layer with one provider-selection policy owner, one abort/error vocabulary, and one product-facing "how this harness reaches the web" config surface. The cost is the parallel `Search`/`Fetch` method pairs; that parallelism is intentional, not a missed extraction. + +## Service API (`ctx.web`) + +| Member | Semantics | +|---|---| +| `registerSearchProvider(provider)` / `registerFetchProvider(provider)` | Register a backend. Throws `WebError` `WEB_DUPLICATE_PROVIDER` on a duplicate id within that capability kind. Returns a disposer; emits `web/providers-change` on register and on dispose. Disposed with the calling fiber. | +| `searchStatus()` / `fetchStatus()` | Derived (never stored) `WebCapabilityStatus`: whether the capability has a selected usable provider, or the broad category it fails in. Diagnostics + execution-resolution input. | +| `search(request, exec?)` | Resolve the search provider and run one search. Enforces `request.maxResults` on the result (truncates `sources[]`, sets `truncated`). Throws `WebError` when the capability cannot run. | +| `fetch(request, exec?)` | Resolve the fetch provider and retrieve one URL. A non-2xx response is a result, not a throw. Throws `WebError` for failures to safely retrieve or represent the resource. | + +Providers register **capabilities**, not tools. `dsh-tool-web` is the only owner of model-facing names, descriptions, prompt guidance, JSON schemas, and presentation. + +## Selection + +Selection never depends on registration, config, or HMR order. A capability has an explicit provider id (config `searchProvider`/`fetchProvider`, or env `$DSH_WEB_SEARCH_PROVIDER`/`$DSH_WEB_FETCH_PROVIDER` feeding the same fields), or auto-selects when exactly one usable provider is registered: + +| Situation | `WebCapabilityStatus` | Execution | +|---|---|---| +| configured id registered and `status().available` | `available` for it | runs | +| configured id not registered | `configured-missing` | `WEB_PROVIDER_CONFIGURED_MISSING` | +| configured id registered but unavailable | `configured-unavailable` | `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` | +| no id, exactly one registered usable provider | `available` for it | runs | +| no id, no usable provider | `none` | `WEB_PROVIDER_UNAVAILABLE` | +| no id, multiple usable providers | `ambiguous` | `WEB_PROVIDER_AMBIGUOUS` | + +`WebCapabilityStatus` carries only `available` + a `reason` discriminant (plus the winning `providerId` on the available branch). The branchable per-reason detail lives in the thrown `WebError`, which is the surface callers route on — so the same fact never gets two homes that can disagree. A provider's own `status()` is a cheap local check (credential presence, parseable config) and **must not make network calls**; `dsh-tool-web` reads only the aggregated `searchStatus()`/`fetchStatus()`, never each provider's `status()` directly. + +## Vocabulary + +`WebSearchRequest` (`query`, `maxResults?`) → `WebSearchResult` (`providerId`, `query`, `content?`, `sources[]`, `truncated`); each `WebSearchSource` has a required `url` and optional `title`/`snippet`/`publishedAt` (Perplexity citations may be URL-only). `WebFetchRequest` (`url`, `timeoutMs?`) → `WebFetchResult` (`providerId`, final `url`, `statusCode`, `body`, `truncated`); `WebFetchBody` is a CLOSED discriminated union (`html` | `text`) owned here — consumers `switch` to exhaustiveness so a new kind breaks their compilation until handled. See `src/types.ts` for the full contracts and the `WebError` code taxonomy. diff --git a/packages/web/web/package.json b/packages/web/web/package.json new file mode 100644 index 0000000000..40ac10b715 --- /dev/null +++ b/packages/web/web/package.json @@ -0,0 +1,33 @@ +{ + "name": "@deepseek-ai/dsh-web", + "description": "Abstract web access capability seam (ctx.web) for the DeepSeek Harness — search/fetch provider registry, registration-order-independent selection, request/result vocabulary, and the WebError taxonomy", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-llm": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-llm": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/web/web/src/index.ts b/packages/web/web/src/index.ts new file mode 100644 index 0000000000..172c1a0ecb --- /dev/null +++ b/packages/web/web/src/index.ts @@ -0,0 +1,270 @@ +/** + * The web access seam (`ctx.web`): a provider registry plus a provider-selecting + * execution surface for two capabilities — search and fetch. Provider packages + * register concrete backends with `registerSearchProvider` / + * `registerFetchProvider`; the model-facing consumer + * (`@deepseek-ai/dsh-tool-web`) reads capability status and executes through + * `search()` / `fetch()`. + * + * The registry half stays close to `LlmService`: a `Map` per + * capability kind, register methods that return disposers, duplicate ids that + * throw, and execution-time resolution that throws when the selected provider is + * absent or unusable. On top of that sits one small selection-status layer so + * diagnostics and execution can explain why a capability can or cannot run, + * independent of registration order. + * + * @module @deepseek-ai/dsh-web + */ + +import { Context, Service } from 'cordis' +import z from 'schemastery' +import type { + WebCapabilityStatus, + WebExecContext, + WebFetchProvider, + WebFetchRequest, + WebFetchResult, + WebProviderStatus, + WebSearchProvider, + WebSearchRequest, + WebSearchResult, +} from './types.ts' +import { WebError } from './types.ts' + +export { + WebError, +} from './types.ts' +export type { + WebCapabilityStatus, + WebErrorCode, + WebExecContext, + WebFetchBody, + WebFetchProvider, + WebFetchRequest, + WebFetchResult, + WebProviderStatus, + WebSearchProvider, + WebSearchRequest, + WebSearchResult, + WebSearchSource, +} from './types.ts' + +declare module 'cordis' { + interface Context { + web: WebService + } + + interface Events { + /** + * Fired after the provider registry changes — a search or fetch provider was + * registered or disposed. Carries no payload and no capability graph: it + * means only "the provider registry changed; observers may recompute status + * from `ctx.web`". `searchStatus()` / `fetchStatus()` stay derived, not + * stored. + * @mode emit + */ + 'web/providers-change'(this: WebService): void + } +} + +/** Selection inputs shared by the status query and execution resolution. */ +interface Selection

{ + /** The configured provider id for this capability, if any. */ + readonly configuredId?: string + /** Providers registered for this capability kind. */ + readonly providers: ReadonlyMap +} + +/** + * Config for the web seam. `searchProvider` / `fetchProvider` pin which provider + * wins for each capability; both are optional (a single registered usable + * provider auto-selects). Operational overrides such as environment variables + * must feed these same fields rather than introduce a hidden priority chain. + */ +export interface WebServiceConfig { + /** Explicit search provider id. Omitted = auto-select when exactly one usable. */ + readonly searchProvider?: string + /** Explicit fetch provider id. Omitted = auto-select when exactly one usable. */ + readonly fetchProvider?: string +} + +/** + * The web access service. Registered as `ctx.web` (one instance per context). + * + * Selection semantics (identical for status and execution, never order- + * dependent): + * - A configured id that is registered and `status().available` → that provider. + * - A configured id not registered → `configured-missing` / + * `WEB_PROVIDER_CONFIGURED_MISSING`. + * - A configured id registered but unavailable → `configured-unavailable` / + * `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. + * - No id configured, exactly one registered usable provider → that provider. + * - No id configured, multiple usable providers → `ambiguous` / + * `WEB_PROVIDER_AMBIGUOUS`. + * - No id configured, no usable provider → `none` / `WEB_PROVIDER_UNAVAILABLE`. + */ +export class WebService extends Service { + /** + * Provider selection config. Operational env overrides feed the SAME fields: + * `$DSH_WEB_SEARCH_PROVIDER` / `$DSH_WEB_FETCH_PROVIDER` are equivalent to + * `searchProvider` / `fetchProvider` and are NOT a hidden priority chain. + */ + static Config: z = z.object({ + searchProvider: z.string(), + fetchProvider: z.string(), + }) + + private searchProviders = new Map() + private fetchProviders = new Map() + private readonly searchProviderId: string | undefined + private readonly fetchProviderId: string | undefined + + constructor(ctx: Context, config: WebServiceConfig = {}) { + super(ctx, 'web') + this.searchProviderId = config.searchProvider ?? process.env.DSH_WEB_SEARCH_PROVIDER + this.fetchProviderId = config.fetchProvider ?? process.env.DSH_WEB_FETCH_PROVIDER + } + + /** + * Register a search provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER` + * if its id is already registered for search. Returns a disposer; emits + * `web/providers-change` after a successful register and again on dispose. + * Disposed with the calling fiber. + */ + registerSearchProvider(provider: WebSearchProvider): () => void { + return this.registerProvider(this.searchProviders, provider) + } + + /** + * Register a fetch provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER` + * if its id is already registered for fetch. Returns a disposer; emits + * `web/providers-change` after a successful register and again on dispose. + * Disposed with the calling fiber. + */ + registerFetchProvider(provider: WebFetchProvider): () => void { + return this.registerProvider(this.fetchProviders, provider) + } + + private registerProvider

(store: Map, provider: P): () => void { + if (store.has(provider.id)) { + throw new WebError(`a web provider with id "${provider.id}" is already registered`, 'WEB_DUPLICATE_PROVIDER') + } + const dispose = this.ctx.effect(function* (this: WebService) { + store.set(provider.id, provider) + // Yield the rollback BEFORE emitting `web/providers-change`: the generator + // effect collects each yielded disposer before the next step runs, so a + // throwing change listener removes the just-added provider instead of + // leaking it into the registry. + yield () => { + store.delete(provider.id) + this.ctx.emit('web/providers-change') + } + this.ctx.emit('web/providers-change') + }.bind(this), 'web.registerProvider()') + // ctx.effect's disposer returns Promise; our disposer API is + // synchronous fire-and-forget — discard the (always-resolved) promise. + return () => void dispose() + } + + /** Search-capability selection status, derived live (never stored). */ + searchStatus(): WebCapabilityStatus { + return resolveStatus({ + providers: this.searchProviders, + ...this.searchProviderId !== undefined ? { configuredId: this.searchProviderId } : {}, + }) + } + + /** Fetch-capability selection status, derived live (never stored). */ + fetchStatus(): WebCapabilityStatus { + return resolveStatus({ + providers: this.fetchProviders, + ...this.fetchProviderId !== undefined ? { configuredId: this.fetchProviderId } : {}, + }) + } + + /** + * Run one search through the selected provider. Resolves the provider at call + * time with the selection rules above; throws {@link WebError} when the + * capability cannot run. The seam enforces `request.maxResults` on the result: + * if the provider over-returns, `sources[]` is truncated and `truncated` set. + */ + async search(request: WebSearchRequest, exec?: WebExecContext): Promise { + const provider = resolveProvider({ + providers: this.searchProviders, + ...this.searchProviderId !== undefined ? { configuredId: this.searchProviderId } : {}, + }) + const result = await provider.search(request, exec) + return capSources(result, request.maxResults) + } + + /** + * Retrieve one URL through the selected provider. Resolves the provider at + * call time with the selection rules above; throws {@link WebError} when the + * capability cannot run. A non-2xx response is a result, not a throw. + */ + async fetch(request: WebFetchRequest, exec?: WebExecContext): Promise { + const provider = resolveProvider({ + providers: this.fetchProviders, + ...this.fetchProviderId !== undefined ? { configuredId: this.fetchProviderId } : {}, + }) + return provider.fetch(request, exec) + } +} + +interface ResolvableProvider { + readonly id: string + status(): WebProviderStatus +} + +/** Compute the capability status from configured id + registered providers. */ +function resolveStatus

(selection: Selection

): WebCapabilityStatus { + const { configuredId, providers } = selection + if (configuredId !== undefined) { + const provider = providers.get(configuredId) + if (!provider) return { available: false, reason: 'configured-missing' } + if (!provider.status().available) return { available: false, reason: 'configured-unavailable' } + return { available: true, providerId: configuredId } + } + const usable = [...providers.values()].filter(provider => provider.status().available) + const [single] = usable + if (single === undefined) return { available: false, reason: 'none' } + if (usable.length > 1) return { available: false, reason: 'ambiguous' } + return { available: true, providerId: single.id } +} + +/** + * Resolve the selected provider or throw the matching {@link WebError}. Shares + * the selection rules with {@link resolveStatus} so status and execution can + * never disagree. + */ +function resolveProvider

(selection: Selection

): P { + const { configuredId, providers } = selection + if (configuredId !== undefined) { + const provider = providers.get(configuredId) + if (!provider) { + throw new WebError(`configured web provider "${configuredId}" is not registered`, 'WEB_PROVIDER_CONFIGURED_MISSING') + } + if (!provider.status().available) { + throw new WebError(`configured web provider "${configuredId}" is registered but unavailable`, 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE') + } + return provider + } + const usable = [...providers.values()].filter(provider => provider.status().available) + const [single] = usable + if (single === undefined) { + throw new WebError('no usable web provider is registered', 'WEB_PROVIDER_UNAVAILABLE') + } + if (usable.length > 1) { + const ids = usable.map(provider => provider.id).join(', ') + throw new WebError(`multiple usable web providers are registered (${ids}); configure one explicitly`, 'WEB_PROVIDER_AMBIGUOUS') + } + return single +} + +/** Enforce `maxResults` on a search result: truncate `sources[]` and flag it. */ +function capSources(result: WebSearchResult, maxResults: number | undefined): WebSearchResult { + if (maxResults === undefined || result.sources.length <= maxResults) return result + return { ...result, sources: result.sources.slice(0, maxResults), truncated: true } +} + +export default WebService diff --git a/packages/web/web/src/types.ts b/packages/web/web/src/types.ts new file mode 100644 index 0000000000..ec97101ae2 --- /dev/null +++ b/packages/web/web/src/types.ts @@ -0,0 +1,225 @@ +/** + * Vocabulary for the web capability seam (`ctx.web`): the search/fetch + * request/result shapes providers produce and consumers format, the provider + * and capability status discriminants selection reports, the execution-control + * context, and the typed error taxonomy. + * + * These types are shared by every provider backend + * (`@deepseek-ai/dsh-web-search-exa`, `@deepseek-ai/dsh-web-search-perplexity`, + * `@deepseek-ai/dsh-web-fetch-local`, and future backends) and by the + * model-facing consumer (`@deepseek-ai/dsh-tool-web`). Search and fetch share no + * request schema and no business logic, but they are deliberately one seam: + * `ctx.web` is a single web-access middle layer with one provider-selection + * policy, one abort/error vocabulary, and one product-facing configuration + * point. The cost is the parallel `Search`/`Fetch` shapes below; that + * parallelism is intentional. + * + * @module @deepseek-ai/dsh-web/types + */ + +import { HarnessError } from '@deepseek-ai/dsh-llm' + +/** + * Execution control threaded from the tool layer through the seam into a + * provider's network requests, stream readers, and expensive decoding. It is + * NOT business input: the first version carries only `signal` so `tool-web` can + * propagate turn cancellation, tool timeout, and agent disposal. It deliberately + * does NOT carry `ToolExecution`, which would make `dsh-web` depend on + * `dsh-tools`. + */ +export interface WebExecContext { + /** Abort signal a provider must honor for its network/decoding work. */ + readonly signal?: AbortSignal +} + +/** + * What one search-capable backend can return. The model-facing argument is just + * a query; `maxResults` is a `dsh-tool-web`-layer bound passed through unchanged + * and enforced on the way back by the seam (see {@link WebSearchResult}). + */ +export interface WebSearchRequest { + readonly query: string + /** + * Upper bound on returned sources; the seam truncates to it. Omitted = no + * bound. `dsh-tool-web` always sets it. A provider whose API supports a + * result-count control (Exa's `numResults`) should apply it at the request + * layer as a cost/latency optimization; the seam enforces the bound + * regardless. + */ + readonly maxResults?: number +} + +/** + * Normalized search outcome. `content` is optional provider-generated answer + * text or summary (Exa returns none; Perplexity returns a generated answer). + * `sources[]` is the portable citation surface. `truncated` is set by the seam + * when it cut `sources[]` down to `maxResults`. + */ +export interface WebSearchResult { + /** Id of the provider that produced this result. */ + readonly providerId: string + /** Echo of the query the provider answered. */ + readonly query: string + /** Optional provider-generated answer text, search context, or summary. */ + readonly content?: string + /** Citeable sources, already truncated to the request's `maxResults`. */ + readonly sources: readonly WebSearchSource[] + /** True when the seam dropped sources to honor `maxResults`. */ + readonly truncated: boolean +} + +/** + * One citeable source. A source always has a URL; `title`, `snippet`, and + * `publishedAt` are optional because not every provider returns them — forcing + * adapters to invent them would make the seam lie (Perplexity citations may be + * URL-only). `dsh-tool-web` renders `title ?? hostname(url)` for display. + */ +export interface WebSearchSource { + readonly url: string + readonly title?: string + readonly snippet?: string + /** Publication/crawl timestamp as a provider-supplied ISO-8601 string. */ + readonly publishedAt?: string +} + +/** + * What one fetch-capable backend is asked to retrieve. `timeoutMs` is an + * optional positive hint the provider caps. The request deliberately omits + * `format`, `prompt`, and extraction controls — those are presentation or + * higher-level LLM concerns, not safe-retrieval inputs. + */ +export interface WebFetchRequest { + readonly url: string + readonly timeoutMs?: number +} + +/** + * Normalized fetch outcome. A successful network fetch of a non-2xx response is + * a result, not an error: the status code is part of the fetched resource + * state. {@link WebError} is reserved for failures to safely retrieve or + * represent the resource. + */ +export interface WebFetchResult { + /** Id of the provider that produced this result. */ + readonly providerId: string + /** The final URL after allowed redirects (the request URL is in the request). */ + readonly url: string + /** HTTP status code of the fetched response. */ + readonly statusCode: number + /** Decoded body, classified by content kind. */ + readonly body: WebFetchBody + /** True when the provider capped the decoded body. */ + readonly truncated: boolean +} + +/** + * The decoded body of a fetched resource. A CLOSED discriminated union owned by + * `dsh-web`: the provider decodes the kind and `dsh-tool-web` renders it, so a + * new kind is a coordinated change across known packages, not a plugin + * extension. Consumers `switch` on `kind` ending in `default: assertNever(...)` + * so adding a kind breaks compilation at every consumer until handled. Each arm + * stays its own object literal even where fields coincide today, leaving room + * for arm-specific fields later (a `pdf` body's `pageCount`). + */ +export type WebFetchBody = + | { readonly kind: 'html'; readonly content: string } + | { readonly kind: 'text'; readonly content: string } + +/** + * Whether one concrete provider implementation is usable, by cheap local checks + * only (credential presence, parseable endpoint config). A provider `status()` + * must NOT make network calls. It is an input to selection, not a health system. + */ +export type WebProviderStatus = + | { readonly available: true } + | { readonly available: false; readonly reason: 'missing-credential' | 'misconfigured' } + +/** + * Whether a capability (search or fetch) has a selected usable provider, or the + * broad category in which selection fails. Intentionally small: it carries the + * winning `providerId` on the available branch (so diagnostics can report which + * provider won) but NOT the per-reason payload (the missing id, the ambiguous + * candidate set). That branchable detail lives in the {@link WebError} thrown at + * execution time — the surface callers route on — so the same fact does not get + * two homes that can disagree. + */ +export type WebCapabilityStatus = + | { readonly available: true; readonly providerId: string } + | { readonly available: false; readonly reason: 'none' | 'configured-missing' | 'configured-unavailable' | 'ambiguous' } + +/** + * A search-capable backend. Registered with `ctx.web.registerSearchProvider`. + * `id` is a stable string, unique within the search capability kind. + */ +export interface WebSearchProvider { + readonly id: string + /** Cheap local usability check; must not make network calls. */ + status(): WebProviderStatus + /** Run one search; honor `exec.signal` for cancellation. */ + search(request: WebSearchRequest, exec?: WebExecContext): Promise +} + +/** + * A fetch-capable backend. Registered with `ctx.web.registerFetchProvider`. + * `id` is a stable string, unique within the fetch capability kind. + */ +export interface WebFetchProvider { + readonly id: string + /** Cheap local usability check; must not make network calls. */ + status(): WebProviderStatus + /** Retrieve one URL; honor `exec.signal` for cancellation. */ + fetch(request: WebFetchRequest, exec?: WebExecContext): Promise +} + +/** + * Stable codes for {@link WebError}. Callers (hooks, tests, UI) route on these. + * + * - `WEB_PROVIDER_UNAVAILABLE`: no provider configured and none usable. + * - `WEB_PROVIDER_CONFIGURED_MISSING`: a configured id is not registered. + * - `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`: a configured id is registered but its + * `status()` reports unavailable. + * - `WEB_PROVIDER_AMBIGUOUS`: no id configured and multiple usable providers + * exist (selection refuses to pick by registration order). + * - `WEB_DUPLICATE_PROVIDER`: a registration-time programming error — an id is + * already registered for that capability kind. + * - `WEB_INVALID_URL`: the fetch URL is malformed or not http(s). + * - `WEB_BLOCKED_URL`: the fetch URL is rejected by policy (credentials in URL). + * - `WEB_REDIRECT_BLOCKED`: a cross-origin redirect was refused. + * - `WEB_FETCH_TOO_LARGE`: the response exceeded the byte/character cap. + * - `WEB_FETCH_TIMEOUT`: the fetch exceeded its timeout. + * - `WEB_ABORTED`: the operation was aborted via `WebExecContext.signal`. + * - `WEB_UNSUPPORTED_CONTENT_TYPE`: the response content type cannot be decoded. + * - `WEB_PROVIDER_ERROR`: catch-all for a provider's own failure surfaced through + * the seam, including network/transport failure (DNS, connection refused, TLS). + */ +export type WebErrorCode = + | 'WEB_PROVIDER_UNAVAILABLE' + | 'WEB_PROVIDER_CONFIGURED_MISSING' + | 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE' + | 'WEB_PROVIDER_AMBIGUOUS' + | 'WEB_DUPLICATE_PROVIDER' + | 'WEB_INVALID_URL' + | 'WEB_BLOCKED_URL' + | 'WEB_REDIRECT_BLOCKED' + | 'WEB_FETCH_TOO_LARGE' + | 'WEB_FETCH_TIMEOUT' + | 'WEB_ABORTED' + | 'WEB_UNSUPPORTED_CONTENT_TYPE' + | 'WEB_PROVIDER_ERROR' + +/** + * Typed web error. Extends {@link HarnessError} so it carries a stable + * {@link WebErrorCode} and chains `cause`. `dsh-web` owns this vocabulary so + * providers, the seam, and the tool layer raise the same codes instead of each + * inventing message strings. `ToolRegistry.execute()` converts a thrown + * `WebError` into an error tool result whose structured metadata exposes the + * code. + */ +export class WebError extends HarnessError { + override readonly code: WebErrorCode + + constructor(message: string, code: WebErrorCode, options?: ErrorOptions) { + super(message, code, options) + this.code = code + } +} diff --git a/packages/web/web/tests/web.spec.ts b/packages/web/web/tests/web.spec.ts new file mode 100644 index 0000000000..e97630ebab --- /dev/null +++ b/packages/web/web/tests/web.spec.ts @@ -0,0 +1,263 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import WebService, { + WebError, + type WebFetchProvider, + type WebFetchResult, + type WebProviderStatus, + type WebSearchProvider, + type WebSearchRequest, + type WebSearchResult, +} from '@deepseek-ai/dsh-web' + +/** A scripted search provider for contract tests. */ +function makeSearchProvider( + id: string, + status: WebProviderStatus, + search: (request: WebSearchRequest) => Promise, +): WebSearchProvider { + return { id, status: () => status, search: request => search(request) } +} + +function makeFetchProvider(id: string, status: WebProviderStatus, result: WebFetchResult): WebFetchProvider { + return { id, status: () => status, fetch: () => Promise.resolve(result) } +} + +const available: WebProviderStatus = { available: true } +const unavailable: WebProviderStatus = { available: false, reason: 'missing-credential' } + +function searchResult(providerId: string, overrides: Partial = {}): WebSearchResult { + return { providerId, query: 'q', sources: [], truncated: false, ...overrides } +} + +function fetchResult(providerId: string): WebFetchResult { + return { providerId, url: 'https://example.com', statusCode: 200, body: { kind: 'text', content: 'hi' }, truncated: false } +} + +/** Mount a WebService on a fresh root context with the given config. */ +async function mountWeb(config: ConstructorParameters[1] = {}): Promise<{ ctx: Context; web: WebService }> { + const ctx = new Context() + await ctx.plugin(WebService, config) + return { ctx, web: ctx.web } +} + +describe('WebService registration', () => { + it('registers and disposes a search provider, emitting providers-change each way', async () => { + const { ctx, web } = await mountWeb() + const changed = vi.fn() + ctx.on('web/providers-change', changed) + + const dispose = web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) + expect(changed).toHaveBeenCalledTimes(1) + expect(web.searchStatus()).toEqual({ available: true, providerId: 'exa' }) + + dispose() + expect(changed).toHaveBeenCalledTimes(2) + expect(web.searchStatus()).toEqual({ available: false, reason: 'none' }) + }) + + it('throws WEB_DUPLICATE_PROVIDER on a duplicate search id', async () => { + const { web } = await mountWeb() + web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) + expect(() => web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))) + .toThrow(expect.objectContaining({ code: 'WEB_DUPLICATE_PROVIDER' })) + }) + + it('keeps search and fetch id namespaces independent', async () => { + const { web } = await mountWeb() + web.registerSearchProvider(makeSearchProvider('shared', available, () => Promise.resolve(searchResult('shared')))) + expect(() => web.registerFetchProvider(makeFetchProvider('shared', available, fetchResult('shared')))).not.toThrow() + }) + + it('rolls back a registration when a providers-change listener throws', async () => { + const { ctx, web } = await mountWeb() + ctx.on('web/providers-change', () => { throw new Error('listener boom') }) + expect(() => web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))) + .toThrow('listener boom') + // The throwing listener must not leave the provider in the registry. + expect(web.searchStatus()).toEqual({ available: false, reason: 'none' }) + }) + + it('disposes provider registrations when the contributing fiber is disposed (HMR safety)', async () => { + const { ctx, web } = await mountWeb() + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + inner.web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) + }, { inject: ['web'] })) + expect(web.searchStatus()).toEqual({ available: true, providerId: 'exa' }) + await fiber.dispose() + expect(web.searchStatus()).toEqual({ available: false, reason: 'none' }) + }) +}) + +describe('WebService selection status', () => { + it('reports none when nothing is registered', async () => { + const { web } = await mountWeb() + expect(web.searchStatus()).toEqual({ available: false, reason: 'none' }) + expect(web.fetchStatus()).toEqual({ available: false, reason: 'none' }) + }) + + it('auto-selects the single usable provider when no id is configured', async () => { + const { web } = await mountWeb() + web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) + expect(web.searchStatus()).toEqual({ available: true, providerId: 'exa' }) + }) + + it('reports ambiguous when multiple usable providers exist and none is configured', async () => { + const { web } = await mountWeb() + web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) + web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity')))) + expect(web.searchStatus()).toEqual({ available: false, reason: 'ambiguous' }) + }) + + it('ignores unusable providers when auto-selecting', async () => { + const { web } = await mountWeb() + web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) + web.registerSearchProvider(makeSearchProvider('perplexity', unavailable, () => Promise.resolve(searchResult('perplexity')))) + expect(web.searchStatus()).toEqual({ available: true, providerId: 'exa' }) + }) + + it('reports none when providers exist but none are usable', async () => { + const { web } = await mountWeb() + web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa')))) + expect(web.searchStatus()).toEqual({ available: false, reason: 'none' }) + }) + + it('honors a configured id over a different registered provider', async () => { + const { web } = await mountWeb({ searchProvider: 'perplexity' }) + web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) + web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity')))) + expect(web.searchStatus()).toEqual({ available: true, providerId: 'perplexity' }) + }) + + it('reports configured-missing when the configured id is not registered', async () => { + const { web } = await mountWeb({ searchProvider: 'perplexity' }) + web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) + expect(web.searchStatus()).toEqual({ available: false, reason: 'configured-missing' }) + }) + + it('reports configured-unavailable when the configured id is registered but unusable', async () => { + const { web } = await mountWeb({ searchProvider: 'exa' }) + web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa')))) + expect(web.searchStatus()).toEqual({ available: false, reason: 'configured-unavailable' }) + }) + + it('does not let registration order change auto-selection', async () => { + const a = await mountWeb() + a.web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa')))) + a.web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity')))) + expect(a.web.searchStatus()).toEqual({ available: true, providerId: 'perplexity' }) + + const b = await mountWeb() + b.web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity')))) + b.web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa')))) + expect(b.web.searchStatus()).toEqual({ available: true, providerId: 'perplexity' }) + }) +}) + +describe('WebService execution resolution', () => { + it('throws WEB_PROVIDER_UNAVAILABLE when nothing is registered', async () => { + const { web } = await mountWeb() + await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_UNAVAILABLE' })) + }) + + it('throws WEB_PROVIDER_CONFIGURED_MISSING for an unregistered configured id', async () => { + const { web } = await mountWeb({ searchProvider: 'perplexity' }) + web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) + await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' })) + }) + + it('throws WEB_PROVIDER_CONFIGURED_UNAVAILABLE for an unusable configured id', async () => { + const { web } = await mountWeb({ searchProvider: 'exa' }) + web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa')))) + await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE' })) + }) + + it('throws WEB_PROVIDER_AMBIGUOUS rather than picking by order', async () => { + const { web } = await mountWeb() + web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) + web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity')))) + await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_AMBIGUOUS' })) + }) + + it('runs the selected provider and returns its result', async () => { + const { web } = await mountWeb() + web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve( + searchResult('exa', { content: 'answer', sources: [{ url: 'https://a' }] }), + ))) + const result = await web.search({ query: 'q' }) + expect(result.providerId).toBe('exa') + expect(result.content).toBe('answer') + expect(result.sources).toEqual([{ url: 'https://a' }]) + }) + + it('propagates the abort signal to the provider', async () => { + const { web } = await mountWeb() + const seen: (AbortSignal | undefined)[] = [] + web.registerSearchProvider({ + id: 'exa', + status: () => available, + search: (_request, exec) => { seen.push(exec?.signal); return Promise.resolve(searchResult('exa')) }, + }) + const controller = new AbortController() + await web.search({ query: 'q' }, { signal: controller.signal }) + expect(seen[0]).toBe(controller.signal) + }) +}) + +describe('WebService maxResults enforcement', () => { + it('truncates sources and sets truncated when a provider over-returns', async () => { + const { web } = await mountWeb() + web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa', { + sources: [{ url: 'https://1' }, { url: 'https://2' }, { url: 'https://3' }], + })))) + const result = await web.search({ query: 'q', maxResults: 2 }) + expect(result.sources).toHaveLength(2) + expect(result.truncated).toBe(true) + }) + + it('leaves truncated false when within the bound', async () => { + const { web } = await mountWeb() + web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa', { + sources: [{ url: 'https://1' }], + })))) + const result = await web.search({ query: 'q', maxResults: 8 }) + expect(result.sources).toHaveLength(1) + expect(result.truncated).toBe(false) + }) + + it('does not bound when maxResults is omitted', async () => { + const { web } = await mountWeb() + web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa', { + sources: [{ url: 'https://1' }, { url: 'https://2' }], + })))) + const result = await web.search({ query: 'q' }) + expect(result.sources).toHaveLength(2) + expect(result.truncated).toBe(false) + }) +}) + +describe('WebService fetch capability', () => { + it('resolves and runs the fetch provider independently of search', async () => { + const { web } = await mountWeb() + web.registerFetchProvider(makeFetchProvider('local-http', available, fetchResult('local-http'))) + const result = await web.fetch({ url: 'https://example.com' }) + expect(result.providerId).toBe('local-http') + expect(result.statusCode).toBe(200) + }) + + it('throws WEB_PROVIDER_UNAVAILABLE for fetch when no fetch provider is registered', async () => { + const { web } = await mountWeb() + web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) + await expect(web.fetch({ url: 'https://example.com' })).rejects.toThrow( + expect.objectContaining({ code: 'WEB_PROVIDER_UNAVAILABLE' }), + ) + }) +}) + +describe('WebError', () => { + it('is a HarnessError carrying its code', () => { + const error = new WebError('boom', 'WEB_INVALID_URL') + expect(error.code).toBe('WEB_INVALID_URL') + expect(error.name).toBe('WebError') + }) +}) diff --git a/packages/web/web/tsconfig.json b/packages/web/web/tsconfig.json new file mode 100644 index 0000000000..b187cddf35 --- /dev/null +++ b/packages/web/web/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2afc331514..a393b6c4b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -703,6 +703,92 @@ 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/web/tool-web: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@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 + '@deepseek-ai/dsh-web': + specifier: workspace:^ + version: link:../web + '@deepseek-ai/dsh-web-fetch-local': + specifier: workspace:^ + version: link:../web-fetch-local + '@deepseek-ai/dsh-web-search-exa': + specifier: workspace:^ + version: link:../web-search-exa + 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/web/web: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@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/web/web-fetch-local: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-web': + specifier: workspace:^ + version: link:../web + 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/web/web-search-exa: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-web': + specifier: workspace:^ + version: link:../web + 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/web/web-search-perplexity: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-web': + specifier: workspace:^ + version: link:../web + 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) + vendor/cordis: dependencies: '@cordisjs/plugin-include': diff --git a/tsconfig.base.json b/tsconfig.base.json index 7f46a9105a..bc4f13bdd5 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -34,6 +34,8 @@ "@cordisjs/plugin-timer": ["./vendor/timer/src"], "@cordisjs/plugin-hmr": ["./vendor/hmr/src"], "@cordisjs/plugin-logger-console": ["./vendor/logger-console/src"], + "@deepseek-ai/dsh-tool-web/search": ["./packages/web/tool-web/src/search.ts"], + "@deepseek-ai/dsh-tool-web/fetch": ["./packages/web/tool-web/src/fetch.ts"], // One wildcard maps every @deepseek-ai/dsh- to its source. Package // dir names are unique across groups, so first-on-disk-wins resolution is // unambiguous; adding a package under an existing group needs no edit @@ -45,6 +47,7 @@ "./packages/bash/*/src", "./packages/compact/*/src", "./packages/subagent/*/src", + "./packages/web/*/src", "./packages/session-persistence/*/src", "./packages/ui/*/src", "./packages/util/*/src", diff --git a/tsconfig.build.json b/tsconfig.build.json index 9d76a33385..fb7a058ea8 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -27,6 +27,11 @@ { "path": "./packages/llm/llm-pi-ai" }, { "path": "./packages/bash/bash-local" }, { "path": "./packages/bash/tool-bash" }, + { "path": "./packages/web/web" }, + { "path": "./packages/web/web-search-exa" }, + { "path": "./packages/web/web-search-perplexity" }, + { "path": "./packages/web/web-fetch-local" }, + { "path": "./packages/web/tool-web" }, { "path": "./packages/support/invariants" }, { "path": "./packages/ui/acp" }, { "path": "./packages/ui/acp-agent" }, From 567519184ba42ffd2204583327e08091ae3b5120 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 25 Jun 2026 15:28:15 +0800 Subject: [PATCH 10/75] fix: address codex review round 1 - Re-validate redirect targets through validateFetchUrl before following, so a same-origin Location carrying credentials (or a non-http(s)/over-long URL) cannot bypass the transport hygiene a direct request enforces. - Treat only DROPPED bytes as truncation: a body exactly at maxResponseBytes is no longer falsely flagged truncated (which emitted a spurious footer). - Honor the declared response charset: parse the Content-Type charset and decode with it (rejecting unsupported labels as WEB_UNSUPPORTED_CONTENT_TYPE) instead of always assuming UTF-8 and returning replacement characters. - Catalog the web seam vocabulary in docs/core-data-structures/web.md with type-equiv blocks + manifest entries, per the core-data-structures rule. --- docs/core-data-structures/core.md | 1 + docs/core-data-structures/web.md | 119 ++++++++++++++++++ packages/web/web-fetch-local/src/index.ts | 2 +- packages/web/web-fetch-local/src/policy.ts | 26 ++++ packages/web/web-fetch-local/src/provider.ts | 27 ++-- .../web-fetch-local/tests/fetch-local.spec.ts | 42 ++++++- scripts/type-equiv.manifest.json | 12 +- 7 files changed, 218 insertions(+), 11 deletions(-) create mode 100644 docs/core-data-structures/web.md diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 6d20900c93..09058a596a 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -22,6 +22,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashTask`s | | [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 | +| [web.md](web.md) | the web access seam: `WebSearchRequest`/`Result`, `WebFetchRequest`/`Result`, `WebFetchBody`, provider/capability status, `WebErrorCode` | > Type definitions on this page are pasted **verbatim** from source and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)). Inline JSDoc is omitted for readability; follow the source link for the full contracts. diff --git a/docs/core-data-structures/web.md b/docs/core-data-structures/web.md new file mode 100644 index 0000000000..f0ade276f7 --- /dev/null +++ b/docs/core-data-structures/web.md @@ -0,0 +1,119 @@ +# Web Access + +The web access seam — a [capability seam](../rfc/implemented/architecture/2026-06-24-web-capability-seam.md) that spans **two capabilities** (search and fetch) on one `ctx.web` service, split across packages: interface ([dsh-web](../../packages/web/web), `ctx.web` + the provider registries), implementations ([dsh-web-search-exa](../../packages/web/web-search-exa), [dsh-web-search-perplexity](../../packages/web/web-search-perplexity), [dsh-web-fetch-local](../../packages/web/web-fetch-local)), and consumer ([dsh-tool-web](../../packages/web/tool-web), the `web_search`/`web_fetch` tool schemas). Web is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A search-provider swap does not change how the model asks for a query, and a fetch-implementation swap does not change how the model asks for a URL. + +Source: [`packages/web/web/src/types.ts`](../../packages/web/web/src/types.ts) + +## Why one seam for two capabilities + +Search and fetch share no request schema and no business logic, but they are deliberately one `ctx.web` middle layer: one provider-selection policy owner, one abort/error vocabulary, one product-facing "how this harness reaches the web" config surface. The cost is the parallel `searchX`/`fetchX` method pairs on the service; that parallelism is intentional, not a missed extraction. Providers register **capabilities** (a `WebSearchProvider` or `WebFetchProvider`), not tools; the model-facing names, schemas, prompt guidance, and presentation all live in the single `dsh-tool-web` consumer. + +## Search request and result + +The model-facing tool argument is just a `query`; `maxResults` is a consumer-owned bound (`dsh-tool-web`'s `WEB_SEARCH_MAX_RESULTS`, default `8`) passed through the seam and enforced on the way back — if a provider over-returns, the seam truncates `sources[]` and sets `truncated`. + +```ts type-equiv +interface WebSearchRequest { + readonly query: string + /** + * Upper bound on returned sources; the seam truncates to it. Omitted = no + * bound. `dsh-tool-web` always sets it. + */ + readonly maxResults?: number +} +``` + +```ts type-equiv +interface WebSearchResult { + readonly providerId: string + readonly query: string + readonly content?: string + readonly sources: readonly WebSearchSource[] + readonly truncated: boolean +} +``` + +`content` is optional provider-generated answer text (Exa returns none; Perplexity returns a generated answer). `sources[]` is the portable citation surface. A source always has a `url`; `title`/`snippet`/`publishedAt` are optional because not every provider returns them — Perplexity citations may be URL-only, and forcing adapters to invent the rest would make the seam lie. `dsh-tool-web` renders `title ?? hostname(url)`. + +```ts type-equiv +interface WebSearchSource { + readonly url: string + readonly title?: string + readonly snippet?: string + readonly publishedAt?: string +} +``` + +## Fetch request and result + +```ts type-equiv +interface WebFetchRequest { + readonly url: string + readonly timeoutMs?: number +} +``` + +HTTP status is part of the fetched resource state, not automatically a failure: a successful network fetch of a `404`/`500` returns a `WebFetchResult` with the status code and a bounded decoded body. `url` is the final URL after allowed redirects. `WebError` is reserved for failures to safely retrieve or represent the resource. + +```ts type-equiv +interface WebFetchResult { + readonly providerId: string + readonly url: string + readonly statusCode: number + readonly body: WebFetchBody + readonly truncated: boolean +} +``` + +`WebFetchBody` is a **closed** discriminated union owned by `dsh-web` (not a merge-extensible map): the provider decodes the kind and `dsh-tool-web` renders it, so a new kind is a coordinated change across known packages, not a plugin extension. Consumers `switch` on `kind` ending in `default: assertNever(...)`, so adding a kind breaks compilation at every consumer until handled. Each arm stays its own object literal even where fields coincide today, leaving room for arm-specific fields later (a future `pdf` body's `pageCount`). + +```ts type-equiv +type WebFetchBody = + | { readonly kind: 'html'; readonly content: string } + | { readonly kind: 'text'; readonly content: string } +``` + +## Provider and capability status + +A provider's `status()` is a cheap LOCAL check (credential presence, parseable config) and **must not make network calls**. It is an input to selection, not a health system. + +```ts type-equiv +type WebProviderStatus = + | { readonly available: true } + | { readonly available: false; readonly reason: 'missing-credential' | 'misconfigured' } +``` + +The service aggregates provider status into a `WebCapabilityStatus`: whether the capability has a selected usable provider, or the broad category in which selection fails. It carries the winning `providerId` on the available branch but NOT the per-reason payload (the missing id, the ambiguous set) — that branchable detail lives in the thrown `WebError`, the surface callers route on, so the same fact never gets two homes that can disagree. + +```ts type-equiv +type WebCapabilityStatus = + | { readonly available: true; readonly providerId: string } + | { readonly available: false; readonly reason: 'none' | 'configured-missing' | 'configured-unavailable' | 'ambiguous' } +``` + +Selection never depends on registration, config, or HMR order: a capability has an explicit provider id (config `searchProvider`/`fetchProvider`, or the matching env var feeding the same field), or auto-selects when exactly one usable provider is registered; multiple usable providers with no configured id is `ambiguous`, not first-wins. + +## Errors + +`WebError extends HarnessError` ([core.md](core.md) error taxonomy) with a stable `WebErrorCode`. `WEB_DUPLICATE_PROVIDER` is a registration-time programming error (the analogue of `LlmService`'s `DUPLICATE_ADAPTER`); the `WEB_PROVIDER_*` selection codes and the fetch transport codes are execution outcomes. `WEB_PROVIDER_ERROR` is the catch-all for a provider's own failure surfaced through the seam, including network/transport failure (DNS, connection refused, TLS). + +```ts type-equiv +type WebErrorCode = + | 'WEB_PROVIDER_UNAVAILABLE' + | 'WEB_PROVIDER_CONFIGURED_MISSING' + | 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE' + | 'WEB_PROVIDER_AMBIGUOUS' + | 'WEB_DUPLICATE_PROVIDER' + | 'WEB_INVALID_URL' + | 'WEB_BLOCKED_URL' + | 'WEB_REDIRECT_BLOCKED' + | 'WEB_FETCH_TOO_LARGE' + | 'WEB_FETCH_TIMEOUT' + | 'WEB_ABORTED' + | 'WEB_UNSUPPORTED_CONTENT_TYPE' + | 'WEB_PROVIDER_ERROR' +``` + +## The service + +`WebService` (`ctx.web`, defined in [`packages/web/web/src/index.ts`](../../packages/web/web/src/index.ts)) is a provider registry plus a provider-selecting execution surface, close to `LlmService`'s shape: `registerSearchProvider`/`registerFetchProvider` (duplicate ids throw `WEB_DUPLICATE_PROVIDER`, return disposers, emit `web/providers-change`), `searchStatus`/`fetchStatus` (derived, never stored), and `search`/`fetch` (resolve the provider at call time, throw a structured `WebError` when the capability cannot run). Providers issue requests with the platform-native `fetch` (Node 24), mirroring `dsh-llm-deepseek`; the `dsh-web-fetch-local` provider owns safe retrieval (http/https-only, credential rejection, byte/char/timeout/redirect caps, same-origin-only redirects with per-hop re-validation, charset decoding) while `dsh-tool-web` owns presentation (HTML→markdown). SSRF / private-network blocking is deferred (see the RFC) — until it lands, `web_fetch` must not be enabled where it can reach sensitive internal targets. diff --git a/packages/web/web-fetch-local/src/index.ts b/packages/web/web-fetch-local/src/index.ts index 1d57410d68..eb3f8e4143 100644 --- a/packages/web/web-fetch-local/src/index.ts +++ b/packages/web/web-fetch-local/src/index.ts @@ -18,7 +18,7 @@ export { LocalFetchProvider, } from './provider.ts' export type { LocalFetchLimits } from './provider.ts' -export { classifyContentType, isSameOrigin, validateFetchUrl } from './policy.ts' +export { classifyContentType, decoderForCharset, isSameOrigin, parseCharset, validateFetchUrl } from './policy.ts' export type { FetchableKind } from './policy.ts' /** Default `User-Agent`: an explicit product agent, never a browser disguise. */ diff --git a/packages/web/web-fetch-local/src/policy.ts b/packages/web/web-fetch-local/src/policy.ts index 8261c5c1ed..7a76bd1af1 100644 --- a/packages/web/web-fetch-local/src/policy.ts +++ b/packages/web/web-fetch-local/src/policy.ts @@ -57,3 +57,29 @@ export function classifyContentType(contentType: string | null): FetchableKind | if (mime === 'application/json' || mime === 'application/xml' || mime.endsWith('+json') || mime.endsWith('+xml')) return 'text' return undefined } + +/** + * Extract the `charset` parameter from a response `Content-Type`, lower-cased, + * or `undefined` when absent. The provider feeds this label to `TextDecoder` + * so a non-UTF-8 response is decoded with its declared encoding rather than + * silently mangled into replacement characters. + */ +export function parseCharset(contentType: string | null): string | undefined { + const match = /;\s*charset\s*=\s*"?([^";]+)"?/i.exec(contentType ?? '') + return match?.[1]?.trim().toLowerCase() +} + +/** + * Build a `TextDecoder` for the declared charset, falling back to UTF-8 when + * none is declared. Throws {@link WebError} `WEB_UNSUPPORTED_CONTENT_TYPE` when + * the label is present but not a charset `TextDecoder` recognizes — better to + * fail loudly than return mojibake. + */ +export function decoderForCharset(charset: string | undefined): TextDecoder { + if (charset === undefined) return new TextDecoder('utf-8') + try { + return new TextDecoder(charset) + } catch (error: unknown) { + throw new WebError(`unsupported charset "${charset}"`, 'WEB_UNSUPPORTED_CONTENT_TYPE', { cause: error }) + } +} diff --git a/packages/web/web-fetch-local/src/provider.ts b/packages/web/web-fetch-local/src/provider.ts index 0622030af0..c8b99d08b0 100644 --- a/packages/web/web-fetch-local/src/provider.ts +++ b/packages/web/web-fetch-local/src/provider.ts @@ -21,7 +21,7 @@ import { WebError } from '@deepseek-ai/dsh-web' import type { WebFetchBody, WebFetchProvider, WebFetchRequest, WebFetchResult, WebProviderStatus } from '@deepseek-ai/dsh-web' -import { classifyContentType, isSameOrigin, validateFetchUrl } from './policy.ts' +import { classifyContentType, decoderForCharset, isSameOrigin, parseCharset, validateFetchUrl } from './policy.ts' /** Resolved provider limits (the plugin's schemastery Config supplies defaults). */ export interface LocalFetchLimits { @@ -92,14 +92,18 @@ export class LocalFetchProvider implements WebFetchProvider { throw new WebError(`redirect response (HTTP ${response.status}) without a Location header`, 'WEB_PROVIDER_ERROR') } const target = resolveRedirect(location, currentUrl) - if (!isSameOrigin(target, currentUrl)) { + // Re-validate the target against the same transport hygiene a direct + // request gets: a redirect must not be a back door to a credentialed, + // non-http(s), or over-long URL that validateFetchUrl would reject. + const validatedTarget = validateFetchUrl(target.toString(), this.limits.maxUrlLength) + if (!isSameOrigin(validatedTarget, currentUrl)) { throw new WebError( - `cross-origin redirect to ${target.origin} is not followed automatically; retry against that URL directly`, + `cross-origin redirect to ${validatedTarget.origin} is not followed automatically; retry against that URL directly`, 'WEB_REDIRECT_BLOCKED', ) } await response.body?.cancel() - currentUrl = target + currentUrl = validatedTarget continue } @@ -124,14 +128,18 @@ export class LocalFetchProvider implements WebFetchProvider { /** Read, byte-cap, classify, and decode the final response body. */ private async readBody(response: Response, finalUrl: URL): Promise { - const kind = classifyContentType(response.headers.get('content-type')) + const contentType = response.headers.get('content-type') + const kind = classifyContentType(contentType) if (kind === undefined) { await response.body?.cancel() - throw new WebError(`unsupported content type "${response.headers.get('content-type') ?? 'unknown'}"`, 'WEB_UNSUPPORTED_CONTENT_TYPE') + throw new WebError(`unsupported content type "${contentType ?? 'unknown'}"`, 'WEB_UNSUPPORTED_CONTENT_TYPE') } + // Resolve the decoder BEFORE reading the body so an unsupported charset + // fails without consuming the stream. + const decoder = decoderForCharset(parseCharset(contentType)) const { bytes, truncatedByBytes } = await this.readCapped(response) - const decoded = new TextDecoder('utf-8').decode(bytes) + const decoded = decoder.decode(bytes) const truncatedByChars = decoded.length > this.limits.maxBodyChars const content = truncatedByChars ? decoded.slice(0, this.limits.maxBodyChars) : decoded const body: WebFetchBody = kind === 'html' ? { kind: 'html', content } : { kind: 'text', content } @@ -173,7 +181,10 @@ export class LocalFetchProvider implements WebFetchProvider { const { done, value } = await reader.read() if (done) break const remaining = this.limits.maxResponseBytes - total - if (value.byteLength >= remaining) { + // Only DROPPED bytes count as truncation: a chunk that exactly fills the + // remaining capacity keeps all its bytes and we read on to observe EOF, + // so an exactly-at-cap body is not falsely flagged truncated. + if (value.byteLength > remaining) { chunks.push(value.subarray(0, remaining)) total += remaining truncatedByBytes = true diff --git a/packages/web/web-fetch-local/tests/fetch-local.spec.ts b/packages/web/web-fetch-local/tests/fetch-local.spec.ts index 3ca48150e1..54e8de20c0 100644 --- a/packages/web/web-fetch-local/tests/fetch-local.spec.ts +++ b/packages/web/web-fetch-local/tests/fetch-local.spec.ts @@ -3,7 +3,7 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } import { AddressInfo } from 'node:net' import { Context } from 'cordis' import WebService from '@deepseek-ai/dsh-web' -import { LocalFetchProvider, LOCAL_FETCH_PROVIDER_ID, classifyContentType, isSameOrigin, validateFetchUrl } from '@deepseek-ai/dsh-web-fetch-local' +import { LocalFetchProvider, LOCAL_FETCH_PROVIDER_ID, classifyContentType, decoderForCharset, isSameOrigin, parseCharset, validateFetchUrl } from '@deepseek-ai/dsh-web-fetch-local' import type { LocalFetchLimits } from '@deepseek-ai/dsh-web-fetch-local' import * as fetchPlugin from '@deepseek-ai/dsh-web-fetch-local' @@ -62,6 +62,19 @@ describe('policy helpers', () => { expect(isSameOrigin(new URL('https://a.com'), new URL('https://b.com'))).toBe(false) expect(isSameOrigin(new URL('http://a.com'), new URL('https://a.com'))).toBe(false) }) + + it('parses the charset parameter', () => { + expect(parseCharset('text/html; charset=UTF-8')).toBe('utf-8') + expect(parseCharset('text/plain; charset="iso-8859-1"')).toBe('iso-8859-1') + expect(parseCharset('text/plain')).toBeUndefined() + expect(parseCharset(null)).toBeUndefined() + }) + + it('builds a decoder for a charset and defaults to UTF-8', () => { + expect(decoderForCharset(undefined).encoding).toBe('utf-8') + expect(decoderForCharset('iso-8859-1').encoding).toBe('windows-1252') + expect(() => decoderForCharset('not-a-charset')).toThrow(expect.objectContaining({ code: 'WEB_UNSUPPORTED_CONTENT_TYPE' })) + }) }) describe('LocalFetchProvider success', () => { @@ -109,6 +122,13 @@ describe('LocalFetchProvider caps', () => { expect(result.truncated).toBe(true) }) + it('does not flag a body that exactly fills the byte cap as truncated', async () => { + handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('abcd') } + const result = await provider({ maxResponseBytes: 4 }).fetch({ url: base }) + expect(result.body.content).toBe('abcd') + expect(result.truncated).toBe(false) + }) + it('truncates a decoded body past the character cap', async () => { handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('abcdefghij') } const result = await provider({ maxBodyChars: 3 }).fetch({ url: base }) @@ -133,6 +153,19 @@ describe('LocalFetchProvider caps', () => { const result = await provider().fetch({ url: base }) expect(result.body.content).toBe('sized') }) + + it('decodes a non-UTF-8 declared charset', async () => { + // 0xE9 is "é" in ISO-8859-1; decoded as UTF-8 it would be a replacement char. + handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain; charset=iso-8859-1' }); res.end(Buffer.from([0x63, 0x61, 0x66, 0xE9])) } + const result = await provider().fetch({ url: base }) + expect(result.body.content).toBe('café') + }) + + it('rejects an unsupported declared charset', async () => { + handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain; charset=not-a-charset' }); res.end('x') } + await expect(provider().fetch({ url: base })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_UNSUPPORTED_CONTENT_TYPE' })) + }) }) describe('LocalFetchProvider redirects', () => { @@ -152,6 +185,13 @@ describe('LocalFetchProvider redirects', () => { .rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED' })) }) + it('re-validates a redirect target, rejecting same-origin credentials in the Location', async () => { + const { port } = server.address() as AddressInfo + handler = (_req, res) => { res.writeHead(302, { location: `http://user:pass@127.0.0.1:${port}/` }); res.end() } + await expect(provider().fetch({ url: base })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_BLOCKED_URL' })) + }) + it('rejects exceeding the redirect hop cap', async () => { handler = (req, res) => { const n = Number(new URL(req.url ?? '/', base).searchParams.get('n') ?? '0') diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 4008a7cc20..f250c5b383 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -48,6 +48,16 @@ { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentResult", "source": "packages/subagent/subagent/src/types.ts" }, { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentStopReasonMap", "source": "packages/subagent/subagent/src/types.ts" }, { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentRun", "source": "packages/subagent/subagent/src/types.ts" }, - { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentProvider", "source": "packages/subagent/subagent/src/types.ts" } + { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentProvider", "source": "packages/subagent/subagent/src/types.ts" }, + + { "doc": "docs/core-data-structures/web.md", "symbol": "WebSearchRequest", "source": "packages/web/web/src/types.ts" }, + { "doc": "docs/core-data-structures/web.md", "symbol": "WebSearchResult", "source": "packages/web/web/src/types.ts" }, + { "doc": "docs/core-data-structures/web.md", "symbol": "WebSearchSource", "source": "packages/web/web/src/types.ts" }, + { "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchRequest", "source": "packages/web/web/src/types.ts" }, + { "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchResult", "source": "packages/web/web/src/types.ts" }, + { "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchBody", "source": "packages/web/web/src/types.ts" }, + { "doc": "docs/core-data-structures/web.md", "symbol": "WebProviderStatus", "source": "packages/web/web/src/types.ts" }, + { "doc": "docs/core-data-structures/web.md", "symbol": "WebCapabilityStatus", "source": "packages/web/web/src/types.ts" }, + { "doc": "docs/core-data-structures/web.md", "symbol": "WebErrorCode", "source": "packages/web/web/src/types.ts" } ] } From 0930e483ecf98ef363fddb51e47bf0475d34b629 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 25 Jun 2026 16:03:55 +0800 Subject: [PATCH 11/75] fix: address codex review round 2 - Preserve abort errors while parsing search responses: when the caller's AbortSignal fires after headers but during response.json() (both the success and HTTP-error body parses), surface WEB_ABORTED instead of wrapping it as WEB_PROVIDER_ERROR, so agent cancel/dispose is not misreported as a provider failure. Applied to both the Exa and Perplexity providers. - Report a malformed baseURL as misconfigured in status() (URL.canParse), so selection diagnostics and execution agree (configured-unavailable up front rather than a late WEB_PROVIDER_ERROR). WebProviderStatus already had the reason. --- packages/web/web-search-exa/README.md | 2 +- packages/web/web-search-exa/src/provider.ts | 20 ++++++++++++++----- packages/web/web-search-exa/tests/exa.spec.ts | 19 ++++++++++++++++++ packages/web/web-search-perplexity/README.md | 2 +- .../web/web-search-perplexity/src/provider.ts | 15 +++++++++----- .../tests/perplexity.spec.ts | 19 ++++++++++++++++++ 6 files changed, 65 insertions(+), 12 deletions(-) diff --git a/packages/web/web-search-exa/README.md b/packages/web/web-search-exa/README.md index 7f39356e81..61da605df7 100644 --- a/packages/web/web-search-exa/README.md +++ b/packages/web/web-search-exa/README.md @@ -9,7 +9,7 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i | Key | Default | Meaning | |---|---|---| | `apiKey` | `$EXA_API_KEY` | Exa API key. Empty/absent → provider `status()` reports `missing-credential` (the seam reports `configured-unavailable`/`none`). | -| `baseURL` | `https://api.exa.ai` | Endpoint base; `/search` is appended. | +| `baseURL` | `https://api.exa.ai` | Endpoint base; `/search` is appended. An unparseable value makes `status()` report `misconfigured`. | ```yaml - id: web-search-exa diff --git a/packages/web/web-search-exa/src/provider.ts b/packages/web/web-search-exa/src/provider.ts index d7c464059a..70e14cbf25 100644 --- a/packages/web/web-search-exa/src/provider.ts +++ b/packages/web/web-search-exa/src/provider.ts @@ -72,6 +72,7 @@ export class ExaSearchProvider implements WebSearchProvider { status(): WebProviderStatus { if (this.options.apiKey.length === 0) return { available: false, reason: 'missing-credential' } + if (!isValidBaseUrl(this.options.baseURL)) return { available: false, reason: 'misconfigured' } return { available: true } } @@ -105,11 +106,14 @@ export class ExaSearchProvider implements WebSearchProvider { const parsed = await response.json() as ExaError const detail = parsed.error ?? parsed.message if (detail !== undefined && detail.length > 0) message = detail - } catch { - // The HTTP status is already captured in `message` above; a malformed or - // non-JSON error body (normal for gateway 5xx/429s) can only cost a - // richer provider message, never the real error. `response.json()` is - // the sole statement and nothing else of consequence reaches here. + } catch (error: unknown) { + // An abort fired mid-body must surface as WEB_ABORTED, not be swallowed + // into a generic HTTP-error message — cancellation is not a provider + // error (the seam's cancellation contract). + if (isAbortError(error)) throw new WebError('Exa search aborted', 'WEB_ABORTED', { cause: error }) + // Otherwise: the HTTP status is already captured in `message` above; a + // malformed/non-JSON error body (normal for gateway 5xx/429s) can only + // cost a richer provider message, never the real error. } throw new WebError(message, 'WEB_PROVIDER_ERROR') } @@ -118,12 +122,18 @@ export class ExaSearchProvider implements WebSearchProvider { try { payload = await response.json() as ExaSearchResponse } catch (error: unknown) { + if (isAbortError(error)) throw new WebError('Exa search aborted', 'WEB_ABORTED', { cause: error }) throw new WebError(`Exa returned an unparseable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) } return mapExaResponse(request.query, payload) } } +/** True when `baseURL` parses as an absolute URL (a cheap local config check). */ +function isValidBaseUrl(baseURL: string): boolean { + return URL.canParse(baseURL) +} + /** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */ function isAbortError(error: unknown): boolean { return error instanceof DOMException && error.name === 'AbortError' diff --git a/packages/web/web-search-exa/tests/exa.spec.ts b/packages/web/web-search-exa/tests/exa.spec.ts index 3fdd878180..403198401e 100644 --- a/packages/web/web-search-exa/tests/exa.spec.ts +++ b/packages/web/web-search-exa/tests/exa.spec.ts @@ -71,6 +71,11 @@ describe('ExaSearchProvider status', () => { it('is available with a key', () => { expect(new ExaSearchProvider(options).status()).toEqual({ available: true }) }) + + it('is misconfigured when the base URL is unparseable', () => { + expect(new ExaSearchProvider({ apiKey: 'exa-key', baseURL: 'not a url' }).status()) + .toEqual({ available: false, reason: 'misconfigured' }) + }) }) describe('ExaSearchProvider request mapping', () => { @@ -142,6 +147,20 @@ describe('ExaSearchProvider error handling', () => { await expect(new ExaSearchProvider(options).search({ query: 'q' })) .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) }) + + it('surfaces an abort during success-body parse as WEB_ABORTED, not provider error', async () => { + const body = { json: () => Promise.reject(new DOMException('aborted', 'AbortError')), ok: true, status: 200 } + vi.stubGlobal('fetch', vi.fn(async () => body as unknown as Response)) + await expect(new ExaSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) + }) + + it('surfaces an abort during error-body parse as WEB_ABORTED', async () => { + const body = { json: () => Promise.reject(new DOMException('aborted', 'AbortError')), ok: false, status: 500 } + vi.stubGlobal('fetch', vi.fn(async () => body as unknown as Response)) + await expect(new ExaSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) + }) }) describe('web-search-exa plugin registration', () => { diff --git a/packages/web/web-search-perplexity/README.md b/packages/web/web-search-perplexity/README.md index 3850e5e9c1..e7093a1133 100644 --- a/packages/web/web-search-perplexity/README.md +++ b/packages/web/web-search-perplexity/README.md @@ -9,7 +9,7 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i | Key | Default | Meaning | |---|---|---| | `apiKey` | `$PERPLEXITY_API_KEY` | Perplexity API key. Empty/absent → provider `status()` reports `missing-credential`. | -| `baseURL` | `https://api.perplexity.ai` | Endpoint base; `/chat/completions` is appended. | +| `baseURL` | `https://api.perplexity.ai` | Endpoint base; `/chat/completions` is appended. An unparseable value makes `status()` report `misconfigured`. | | `model` | `sonar` | Search model name. | ```yaml diff --git a/packages/web/web-search-perplexity/src/provider.ts b/packages/web/web-search-perplexity/src/provider.ts index 2b596414dd..69b4f794dd 100644 --- a/packages/web/web-search-perplexity/src/provider.ts +++ b/packages/web/web-search-perplexity/src/provider.ts @@ -81,6 +81,7 @@ export class PerplexitySearchProvider implements WebSearchProvider { status(): WebProviderStatus { if (this.options.apiKey.length === 0) return { available: false, reason: 'missing-credential' } + if (!URL.canParse(this.options.baseURL)) return { available: false, reason: 'misconfigured' } return { available: true } } @@ -113,11 +114,14 @@ export class PerplexitySearchProvider implements WebSearchProvider { const parsed = await response.json() as PerplexityError const detail = typeof parsed.error === 'string' ? parsed.error : parsed.error?.message ?? parsed.message if (detail !== undefined && detail.length > 0) message = detail - } catch { - // The HTTP status is already captured in `message` above; a malformed or - // non-JSON error body (normal for gateway 5xx/429s) can only cost a - // richer provider message, never the real error. `response.json()` is - // the sole statement and nothing else of consequence reaches here. + } catch (error: unknown) { + // An abort fired mid-body must surface as WEB_ABORTED, not be swallowed + // into a generic HTTP-error message — cancellation is not a provider + // error (the seam's cancellation contract). + if (isAbortError(error)) throw new WebError('Perplexity search aborted', 'WEB_ABORTED', { cause: error }) + // Otherwise: the HTTP status is already captured in `message` above; a + // malformed/non-JSON error body (normal for gateway 5xx/429s) can only + // cost a richer provider message, never the real error. } throw new WebError(message, 'WEB_PROVIDER_ERROR') } @@ -126,6 +130,7 @@ export class PerplexitySearchProvider implements WebSearchProvider { try { payload = await response.json() as PerplexityResponse } catch (error: unknown) { + if (isAbortError(error)) throw new WebError('Perplexity search aborted', 'WEB_ABORTED', { cause: error }) throw new WebError(`Perplexity returned an unparseable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) } return mapPerplexityResponse(request.query, payload) diff --git a/packages/web/web-search-perplexity/tests/perplexity.spec.ts b/packages/web/web-search-perplexity/tests/perplexity.spec.ts index 16557af888..c1f76a63fb 100644 --- a/packages/web/web-search-perplexity/tests/perplexity.spec.ts +++ b/packages/web/web-search-perplexity/tests/perplexity.spec.ts @@ -75,6 +75,11 @@ describe('PerplexitySearchProvider status', () => { it('is available with a key', () => { expect(new PerplexitySearchProvider(options).status()).toEqual({ available: true }) }) + + it('is misconfigured when the base URL is unparseable', () => { + expect(new PerplexitySearchProvider({ ...options, baseURL: 'not a url' }).status()) + .toEqual({ available: false, reason: 'misconfigured' }) + }) }) describe('PerplexitySearchProvider request mapping', () => { @@ -135,6 +140,20 @@ describe('PerplexitySearchProvider error handling', () => { .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) }) + it('surfaces an abort during success-body parse as WEB_ABORTED, not provider error', async () => { + const body = { json: () => Promise.reject(new DOMException('aborted', 'AbortError')), ok: true, status: 200 } + vi.stubGlobal('fetch', vi.fn(async () => body as unknown as Response)) + await expect(new PerplexitySearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) + }) + + it('surfaces an abort during error-body parse as WEB_ABORTED', async () => { + const body = { json: () => Promise.reject(new DOMException('aborted', 'AbortError')), ok: false, status: 500 } + vi.stubGlobal('fetch', vi.fn(async () => body as unknown as Response)) + await expect(new PerplexitySearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) + }) + it('maps a network failure to WEB_PROVIDER_ERROR', async () => { vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new TypeError('connection refused')))) await expect(new PerplexitySearchProvider(options).search({ query: 'q' })) From a1624530ee776c47413047527990e79a3bbb51bb Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 25 Jun 2026 16:32:39 +0800 Subject: [PATCH 12/75] fix: address codex review round 3 Resource-lifecycle and error-classification fixes in the local fetch provider: - Classify a timeout that fires DURING the body read as WEB_FETCH_TIMEOUT, not WEB_ABORTED: thread the controller signal into the body-read translate path and recover the timeout WebError from signal.reason, honoring the public WEB_FETCH_TIMEOUT contract for a stalled response body. - Cancel the response body before every blocked-redirect throw path (cross-origin, invalid target, missing Location), so a rejected redirect with a large or streaming body does not leak the socket after the tool returns WEB_REDIRECT_BLOCKED. - Cancel the body when charset validation fails, matching the unsupported-content-type and over-size paths (the round-1 charset check threw before readCapped owned the stream). --- packages/web/web-fetch-local/src/provider.ts | 73 +++++++++++++------ .../web-fetch-local/tests/fetch-local.spec.ts | 57 ++++++++++++++- 2 files changed, 108 insertions(+), 22 deletions(-) diff --git a/packages/web/web-fetch-local/src/provider.ts b/packages/web/web-fetch-local/src/provider.ts index c8b99d08b0..13ae7af708 100644 --- a/packages/web/web-fetch-local/src/provider.ts +++ b/packages/web/web-fetch-local/src/provider.ts @@ -71,7 +71,7 @@ export class LocalFetchProvider implements WebFetchProvider { const timer = setTimeout(() => { controller.abort(new WebError('web fetch timed out', 'WEB_FETCH_TIMEOUT')) }, timeoutMs) try { - return await this.followAndRead(request.url, controller, timeoutMs) + return await this.followAndRead(request.url, controller) } finally { clearTimeout(timer) if (exec?.signal !== undefined) exec.signal.removeEventListener('abort', onAbort) @@ -79,41 +79,50 @@ export class LocalFetchProvider implements WebFetchProvider { } /** Follow same-origin redirects up to the hop cap, then read the final response. */ - private async followAndRead(initialUrl: string, controller: AbortController, timeoutMs: number): Promise { + private async followAndRead(initialUrl: string, controller: AbortController): Promise { let currentUrl = validateFetchUrl(initialUrl, this.limits.maxUrlLength) for (let hop = 0; hop <= this.limits.maxRedirects; hop++) { - const response = await this.requestOnce(currentUrl, controller, timeoutMs) + const response = await this.requestOnce(currentUrl, controller) if (isRedirectStatus(response.status)) { const location = response.headers.get('location') if (location === null) { - // A redirect status with no Location is not a usable resource. + // A redirect status with no Location is not a usable resource. Cancel + // the (possibly streaming) body before throwing so no socket leaks. + await response.body?.cancel() throw new WebError(`redirect response (HTTP ${response.status}) without a Location header`, 'WEB_PROVIDER_ERROR') } const target = resolveRedirect(location, currentUrl) // Re-validate the target against the same transport hygiene a direct // request gets: a redirect must not be a back door to a credentialed, - // non-http(s), or over-long URL that validateFetchUrl would reject. - const validatedTarget = validateFetchUrl(target.toString(), this.limits.maxUrlLength) - if (!isSameOrigin(validatedTarget, currentUrl)) { - throw new WebError( - `cross-origin redirect to ${validatedTarget.origin} is not followed automatically; retry against that URL directly`, - 'WEB_REDIRECT_BLOCKED', - ) + // non-http(s), or over-long URL that validateFetchUrl would reject. A + // rejection here must still cancel the body first (see below). + let validatedTarget: URL + try { + validatedTarget = validateFetchUrl(target.toString(), this.limits.maxUrlLength) + if (!isSameOrigin(validatedTarget, currentUrl)) { + throw new WebError( + `cross-origin redirect to ${validatedTarget.origin} is not followed automatically; retry against that URL directly`, + 'WEB_REDIRECT_BLOCKED', + ) + } + } catch (error: unknown) { + await response.body?.cancel() + throw error } await response.body?.cancel() currentUrl = validatedTarget continue } - return await this.readBody(response, currentUrl) + return await this.readBody(response, currentUrl, controller.signal) } throw new WebError(`exceeded the maximum of ${this.limits.maxRedirects} redirects`, 'WEB_REDIRECT_BLOCKED') } - private async requestOnce(url: URL, controller: AbortController, _timeoutMs: number): Promise { + private async requestOnce(url: URL, controller: AbortController): Promise { try { return await fetch(url, { method: 'GET', @@ -122,12 +131,12 @@ export class LocalFetchProvider implements WebFetchProvider { signal: controller.signal, }) } catch (error: unknown) { - throw translateAbortOrNetwork(error) + throw translateAbortOrNetwork(error, controller.signal) } } /** Read, byte-cap, classify, and decode the final response body. */ - private async readBody(response: Response, finalUrl: URL): Promise { + private async readBody(response: Response, finalUrl: URL, signal: AbortSignal): Promise { const contentType = response.headers.get('content-type') const kind = classifyContentType(contentType) if (kind === undefined) { @@ -136,9 +145,16 @@ export class LocalFetchProvider implements WebFetchProvider { } // Resolve the decoder BEFORE reading the body so an unsupported charset - // fails without consuming the stream. - const decoder = decoderForCharset(parseCharset(contentType)) - const { bytes, truncatedByBytes } = await this.readCapped(response) + // fails without consuming the stream — but cancel the body on that failure + // so the socket does not leak (matching the unsupported-content-type path). + let decoder: TextDecoder + try { + decoder = decoderForCharset(parseCharset(contentType)) + } catch (error: unknown) { + await response.body?.cancel() + throw error + } + const { bytes, truncatedByBytes } = await this.readCapped(response, signal) const decoded = decoder.decode(bytes) const truncatedByChars = decoded.length > this.limits.maxBodyChars const content = truncatedByChars ? decoded.slice(0, this.limits.maxBodyChars) : decoded @@ -159,7 +175,7 @@ export class LocalFetchProvider implements WebFetchProvider { * past the cap is cut short (`truncatedByBytes`) rather than rejected, so a * server that under-reports still yields a bounded usable body. */ - private async readCapped(response: Response): Promise<{ bytes: Uint8Array; truncatedByBytes: boolean }> { + private async readCapped(response: Response, signal: AbortSignal): Promise<{ bytes: Uint8Array; truncatedByBytes: boolean }> { const declared = response.headers.get('content-length') if (declared !== null) { const length = Number(declared) @@ -195,7 +211,7 @@ export class LocalFetchProvider implements WebFetchProvider { } } catch (error: unknown) { /* v8 ignore next -- mid-stream read fault needs a network drop after headers; translate path covered by request-phase tests. */ - throw translateAbortOrNetwork(error) + throw translateAbortOrNetwork(error, signal) } finally { /* v8 ignore next 4 -- cancel() after a completed/broken read settles without rejecting; unobserved best-effort cleanup. */ await reader.cancel().catch(() => { @@ -235,9 +251,24 @@ function resolveRedirect(location: string, base: URL): URL { * already-typed `WebError` pass through; an `AbortError` becomes `WEB_ABORTED`; * anything else is a transport/network failure (`WEB_PROVIDER_ERROR`). */ -function translateAbortOrNetwork(error: unknown): WebError { +/** + * Translate a thrown fetch/stream error into a `WebError`. Our own + * `WEB_FETCH_TIMEOUT` (passed to `controller.abort(reason)`) and any other + * already-typed `WebError` pass through; an `AbortError` becomes `WEB_ABORTED`, + * UNLESS the abort was our timeout — the body-read reader surfaces a generic + * `AbortError` rather than the abort reason, so we recover the timeout's + * `WebError` from `signal.reason`; anything else is a transport/network failure + * (`WEB_PROVIDER_ERROR`). + */ +function translateAbortOrNetwork(error: unknown, signal?: AbortSignal): WebError { if (error instanceof WebError) return error if (error instanceof DOMException && error.name === 'AbortError') { + // A timeout abort carries its WebError as the signal reason; honor the + // WEB_FETCH_TIMEOUT contract instead of reporting a generic cancellation. + // (Node rejects WITH the reason — the WebError branch above — so this only + // fires on a runtime that surfaces a bare AbortError while reason is set.) + /* v8 ignore next */ + if (signal?.reason instanceof WebError) return signal.reason return new WebError('web fetch aborted', 'WEB_ABORTED', { cause: error }) } return new WebError(`web fetch failed: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) diff --git a/packages/web/web-fetch-local/tests/fetch-local.spec.ts b/packages/web/web-fetch-local/tests/fetch-local.spec.ts index 54e8de20c0..75a7cb1580 100644 --- a/packages/web/web-fetch-local/tests/fetch-local.spec.ts +++ b/packages/web/web-fetch-local/tests/fetch-local.spec.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' import { AddressInfo } from 'node:net' import { Context } from 'cordis' @@ -32,6 +32,7 @@ beforeEach(async () => { }) afterEach(async () => { + vi.unstubAllGlobals() await new Promise(resolve => server.close(() => { resolve() })) }) @@ -250,6 +251,20 @@ describe('LocalFetchProvider invalid URLs and abort', () => { .rejects.toThrow(expect.objectContaining({ code: 'WEB_FETCH_TIMEOUT' })) }) + it('classifies a timeout DURING the body read as WEB_FETCH_TIMEOUT, not WEB_ABORTED', async () => { + // Promise body that resolves headers (so fetch() returns) but a content-length + // that outlasts the bytes sent, so readCapped()'s reader awaits more and the + // timeout fires mid-read — the reader then surfaces a generic AbortError that + // must still be recovered as the timeout reason via signal.reason. + handler = (_req, res) => { + res.writeHead(200, { 'content-type': 'text/plain', 'content-length': '100' }) + res.write('partial') + // never send the remaining bytes nor end the response + } + await expect(provider({ timeoutMs: 80 }).fetch({ url: base })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_FETCH_TIMEOUT' })) + }) + it('maps a connection failure to WEB_PROVIDER_ERROR', async () => { // Port 1 on loopback is not listening: a real connection failure (not abort). await expect(provider().fetch({ url: 'http://127.0.0.1:1/' })) @@ -263,6 +278,46 @@ describe('LocalFetchProvider invalid URLs and abort', () => { }) }) +describe('LocalFetchProvider body cancellation on error paths', () => { + /** A fake Response whose body.cancel is observable. */ + type FakeInit = { status: number; headers: Record; location?: string } + function fakeResponse(init: FakeInit): { response: Response; cancelled: () => boolean } { + let cancelled = false + const headers = new Headers(init.headers) + if (init.location !== undefined) headers.set('location', init.location) + const response = { + status: init.status, + headers, + body: { cancel: () => { cancelled = true; return Promise.resolve() } }, + } as unknown as Response + return { response, cancelled: () => cancelled } + } + + it('cancels the body when a cross-origin redirect is blocked', async () => { + const { response, cancelled } = fakeResponse({ status: 302, headers: {}, location: 'https://elsewhere.test/' }) + vi.stubGlobal('fetch', vi.fn(async () => response)) + await expect(provider().fetch({ url: 'http://127.0.0.1:9/' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED' })) + expect(cancelled()).toBe(true) + }) + + it('cancels the body when an unsupported charset is rejected', async () => { + const { response, cancelled } = fakeResponse({ status: 200, headers: { 'content-type': 'text/plain; charset=not-a-charset' } }) + vi.stubGlobal('fetch', vi.fn(async () => response)) + await expect(provider().fetch({ url: 'http://127.0.0.1:9/' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_UNSUPPORTED_CONTENT_TYPE' })) + expect(cancelled()).toBe(true) + }) + + it('cancels the body when a redirect has no Location header', async () => { + const { response, cancelled } = fakeResponse({ status: 302, headers: {} }) + vi.stubGlobal('fetch', vi.fn(async () => response)) + await expect(provider().fetch({ url: 'http://127.0.0.1:9/' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) + expect(cancelled()).toBe(true) + }) +}) + describe('web-fetch-local plugin registration', () => { it('registers the provider into ctx.web (HMR-safe)', async () => { const ctx = new Context() From 1cd3a454da07ec028b0e98015868b88bec3d46d2 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 25 Jun 2026 18:00:07 +0800 Subject: [PATCH 13/75] fix: remove stale duplicate JSDoc on translateAbortOrNetwork The function carried two consecutive JSDoc blocks; the first was an outdated short version missing the timeout-recovery contract. Keep only the accurate detailed block. --- packages/web/web-fetch-local/src/provider.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/web/web-fetch-local/src/provider.ts b/packages/web/web-fetch-local/src/provider.ts index 13ae7af708..061eaf5e0c 100644 --- a/packages/web/web-fetch-local/src/provider.ts +++ b/packages/web/web-fetch-local/src/provider.ts @@ -245,12 +245,6 @@ function resolveRedirect(location: string, base: URL): URL { } } -/** - * Translate a thrown fetch/stream error into a `WebError`. Our own - * `WEB_FETCH_TIMEOUT` (passed to `controller.abort(reason)`) and any other - * already-typed `WebError` pass through; an `AbortError` becomes `WEB_ABORTED`; - * anything else is a transport/network failure (`WEB_PROVIDER_ERROR`). - */ /** * Translate a thrown fetch/stream error into a `WebError`. Our own * `WEB_FETCH_TIMEOUT` (passed to `controller.abort(reason)`) and any other From caef2529052d72b672b66665e0a864c185fbd4fc Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 26 Jun 2026 19:18:46 +0800 Subject: [PATCH 14/75] chore: adapt web seam to master's tsconfig + regenerate catalogs Register the five web packages in the root tsconfig.json project graph (master's typecheck moved to `tsc -b tsconfig.json` and dropped the separate tsconfig.typecheck.json), and regenerate the module graph and cordis catalog so they reflect the web packages on master. --- docs/cordis-catalog/events-and-services.md | 40 ++++++++++++++++++++-- docs/module-graph.md | 13 +++++++ tsconfig.json | 5 +++ 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 84ee2a893f..8134bd6546 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -11,7 +11,7 @@ The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary ## Events -Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out, no veto). The harness declares 24 events across 6 scopes. +Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out, no veto). The harness declares 25 events across 7 scopes. ### `agent/*` @@ -299,9 +299,21 @@ Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult Source: [`packages/core/tools/src/index.ts:43`](../../packages/core/tools/src/index.ts) +### `web/*` + +#### `web/providers-change` — emit + +Fired after the provider registry changes — a search or fetch provider was registered or disposed. Carries no payload and no capability graph: it means only "the provider registry changed; observers may recompute status from `ctx.web`". `searchStatus()` / `fetchStatus()` stay derived, not stored. + +```ts cordis-catalog +'web/providers-change'(this: WebService): void +``` + +Source: [`packages/web/web/src/index.ts:66`](../../packages/web/web/src/index.ts) + ## Services -The 10 `ctx.` services the harness provides. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against. +The 11 `ctx.` services the harness provides. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against. ### `ctx.agentLoop` — `AgentLoop` @@ -472,6 +484,30 @@ Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecution](../ Source: [`packages/core/tools/src/index.ts:277`](../../packages/core/tools/src/index.ts) +### `ctx.web` — `WebService` + +The web access service. Registered as `ctx.web` (one instance per context). + +Selection semantics (identical for status and execution, never order- dependent): + +- A configured id that is registered and `status().available` → that provider. +- A configured id not registered → `configured-missing` / `WEB_PROVIDER_CONFIGURED_MISSING`. +- A configured id registered but unavailable → `configured-unavailable` / `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. +- No id configured, exactly one registered usable provider → that provider. +- No id configured, multiple usable providers → `ambiguous` / `WEB_PROVIDER_AMBIGUOUS`. +- No id configured, no usable provider → `none` / `WEB_PROVIDER_UNAVAILABLE`. + +```ts cordis-catalog +registerSearchProvider(provider: WebSearchProvider): () => void +registerFetchProvider(provider: WebFetchProvider): () => void +searchStatus(): WebCapabilityStatus +fetchStatus(): WebCapabilityStatus +async search(request: WebSearchRequest, exec?: WebExecContext): Promise +async fetch(request: WebFetchRequest, exec?: WebExecContext): Promise +``` + +Source: [`packages/web/web/src/index.ts:106`](../../packages/web/web/src/index.ts) + ## Inherited tier (cordis core + loader/hmr/timer) The framework surface every plugin inherits, beyond the harness vocabulary above. This is pinned vendor source ([vendoring policy](../../vendor/README.md)); it is summarized here so the catalog is a complete picture of what `ctx` and the event bus offer, without elevating framework internals to the harness tier's prominence. diff --git a/docs/module-graph.md b/docs/module-graph.md index 346ef157fe..a17680cb6d 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -15,6 +15,7 @@ graph TD session --> brand session --> llm system-prompt --> llm + web --> llm agent --> brand agent --> llm agent --> session @@ -23,6 +24,9 @@ graph TD llm-replay --> llm llm-replay --> session session-persistence --> session + web-fetch-local --> web + web-search-exa --> web + web-search-perplexity --> web invariants --> agent invariants --> llm invariants --> session @@ -54,6 +58,10 @@ graph TD tool-bash --> bash tool-bash --> llm tool-bash --> tools + tool-web --> llm + tool-web --> system-prompt + tool-web --> tools + tool-web --> web agent-core --> agent agent-core --> agent-loop agent-core --> invariants @@ -102,10 +110,14 @@ graph TD | `llm-pi-ai` | `llm` | | `session` | `brand`, `llm` | | `system-prompt` | `llm` | +| `web` | `llm` | | `agent` | `brand`, `llm`, `session` | | `compact` | `llm`, `session` | | `llm-replay` | `llm`, `session` | | `session-persistence` | `session` | +| `web-fetch-local` | `web` | +| `web-search-exa` | `web` | +| `web-search-perplexity` | `web` | | `invariants` | `agent`, `llm`, `session` | | `session-persistence-jsonl` | `session`, `session-persistence` | | `session-persistence-sqlite` | `session`, `session-persistence` | @@ -115,6 +127,7 @@ graph TD | `agent-loop` | `agent`, `llm`, `session`, `session-persistence`, `system-prompt`, `tools` | | `subagent` | `agent`, `llm`, `tools` | | `tool-bash` | `agent`, `bash`, `llm`, `tools` | +| `tool-web` | `llm`, `system-prompt`, `tools`, `web` | | `agent-core` | `agent`, `agent-loop`, `invariants`, `llm`, `session`, `system-prompt`, `tool-bash`, `tools` | | `subagent-acp` | `agent`, `llm`, `subagent` | | `subagent-inprocess` | `agent`, `llm`, `session`, `subagent` | diff --git a/tsconfig.json b/tsconfig.json index 81f52357d5..120ad5a4fb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -38,6 +38,11 @@ { "path": "./packages/bash/bash-local" }, { "path": "./packages/bash/tool-bash" }, { "path": "./packages/compact/compact" }, + { "path": "./packages/web/web" }, + { "path": "./packages/web/web-search-exa" }, + { "path": "./packages/web/web-search-perplexity" }, + { "path": "./packages/web/web-fetch-local" }, + { "path": "./packages/web/tool-web" }, { "path": "./packages/support/invariants" }, { "path": "./packages/ui/acp" }, { "path": "./packages/ui/acp-agent" }, From f843ea7701f8361e255cd38b0ce06eaa61ecc657 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 26 Jun 2026 19:30:08 +0800 Subject: [PATCH 15/75] fix: align web packages with master's two-stage build layout The web packages were authored against the old single-stage layout where tsc emitted directly to lib/. Master compiles declarations to lib/types/ via tsc -b, then bundles JS into lib/ via tsdown. Point every web package's tsc outDir at lib/types, update package.json types/exports/files to the lib/types declaration + lib/ bundle shape (matching dsh-bash/dsh-tool-bash), and bundle tool-web's subpath entries from lib/types/*.js rather than src. --- packages/web/tool-web/package.json | 23 +++++++++++++++---- packages/web/tool-web/tsconfig.json | 2 +- packages/web/tool-web/tsdown.config.ts | 7 +++--- packages/web/web-fetch-local/package.json | 8 ++++--- packages/web/web-fetch-local/tsconfig.json | 2 +- packages/web/web-search-exa/package.json | 8 ++++--- packages/web/web-search-exa/tsconfig.json | 2 +- .../web/web-search-perplexity/package.json | 8 ++++--- .../web/web-search-perplexity/tsconfig.json | 2 +- packages/web/web/package.json | 8 ++++--- packages/web/web/tsconfig.json | 2 +- 11 files changed, 47 insertions(+), 25 deletions(-) diff --git a/packages/web/tool-web/package.json b/packages/web/tool-web/package.json index 8d46faa157..c31c685e45 100644 --- a/packages/web/tool-web/package.json +++ b/packages/web/tool-web/package.json @@ -5,16 +5,29 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { - ".": { "types": "./lib/index.d.ts", "default": "./lib/index.js" }, - "./search": { "types": "./lib/search.d.ts", "default": "./lib/search.js" }, - "./fetch": { "types": "./lib/fetch.d.ts", "default": "./lib/fetch.js" }, + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./search": { + "types": "./lib/types/search.d.ts", + "default": "./lib/search.js" + }, + "./fetch": { + "types": "./lib/types/fetch.d.ts", + "default": "./lib/fetch.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/search.js", + "lib/fetch.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/web/tool-web/tsconfig.json b/packages/web/tool-web/tsconfig.json index b4121a6c14..463a18dee9 100644 --- a/packages/web/tool-web/tsconfig.json +++ b/packages/web/tool-web/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/types" }, "include": ["src"], "references": [ diff --git a/packages/web/tool-web/tsdown.config.ts b/packages/web/tool-web/tsdown.config.ts index c4849939db..0f75095d18 100644 --- a/packages/web/tool-web/tsdown.config.ts +++ b/packages/web/tool-web/tsdown.config.ts @@ -3,11 +3,12 @@ import { defineConfig } from 'tsdown' /** * tool-web exposes one package root plus one entry per tool plugin, so each tool * can be loaded or replaced independently as a subpath plugin - * (`@deepseek-ai/dsh-tool-web/search`, `/fetch`). The root tsdown config only - * auto-discovers `src/index.ts`, so the subpath entries are declared here. + * (`@deepseek-ai/dsh-tool-web/search`, `/fetch`). The root tsdown builds only + * `lib/types/index.js`, so this override adds the subpath entries. Declarations + * come from `tsc -b` (dts: false), matching every package. */ export default defineConfig({ - entry: ['src/index.ts', 'src/search.ts', 'src/fetch.ts'], + entry: ['lib/types/index.js', 'lib/types/search.js', 'lib/types/fetch.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/packages/web/web-fetch-local/package.json b/packages/web/web-fetch-local/package.json index 7249697ec3..8d9a599a52 100644 --- a/packages/web/web-fetch-local/package.json +++ b/packages/web/web-fetch-local/package.json @@ -5,17 +5,19 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/web/web-fetch-local/tsconfig.json b/packages/web/web-fetch-local/tsconfig.json index cb9eb44552..aa7c949fec 100644 --- a/packages/web/web-fetch-local/tsconfig.json +++ b/packages/web/web-fetch-local/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/types" }, "include": [ "src" diff --git a/packages/web/web-search-exa/package.json b/packages/web/web-search-exa/package.json index fe79af8ba8..a111daa287 100644 --- a/packages/web/web-search-exa/package.json +++ b/packages/web/web-search-exa/package.json @@ -5,17 +5,19 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/web/web-search-exa/tsconfig.json b/packages/web/web-search-exa/tsconfig.json index cb9eb44552..aa7c949fec 100644 --- a/packages/web/web-search-exa/tsconfig.json +++ b/packages/web/web-search-exa/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/types" }, "include": [ "src" diff --git a/packages/web/web-search-perplexity/package.json b/packages/web/web-search-perplexity/package.json index 1d6229eae4..fde44ddd16 100644 --- a/packages/web/web-search-perplexity/package.json +++ b/packages/web/web-search-perplexity/package.json @@ -5,17 +5,19 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/web/web-search-perplexity/tsconfig.json b/packages/web/web-search-perplexity/tsconfig.json index cb9eb44552..aa7c949fec 100644 --- a/packages/web/web-search-perplexity/tsconfig.json +++ b/packages/web/web-search-perplexity/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/types" }, "include": [ "src" diff --git a/packages/web/web/package.json b/packages/web/web/package.json index 40ac10b715..8c68c58203 100644 --- a/packages/web/web/package.json +++ b/packages/web/web/package.json @@ -5,17 +5,19 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/web/web/tsconfig.json b/packages/web/web/tsconfig.json index b187cddf35..e9de391ba1 100644 --- a/packages/web/web/tsconfig.json +++ b/packages/web/web/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/types" }, "include": [ "src" From 90dceea0e40ec8442413c425c033059c52b41108 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Sun, 28 Jun 2026 13:49:02 +0800 Subject: [PATCH 16/75] refactor(fs): make dsh-file-context an event-gate plugin, not a method service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Invert the tool↔policy control flow per the file-context event-gate RFC. dsh-tool-fs becomes the executor — it reads/writes/edits through ctx.fs directly, owns read windowing, and dispatches fs/write-expectation / fs/edit-expectation (single-slot waterfalls) plus a contained fs/observed emit. dsh-file-context drops its ctx.fileContext service and becomes a pure event-gate plugin (observed-state + read-before-edit + version-guarded write/edit, decided on those events). The provider's version guard becomes optional so ctx.fs alone is a complete unconstrained text-storage seam: removing the policy plugin gracefully loses the policy instead of breaking the tool at a service-injection boundary. --- docs/architecture.md | 9 +- docs/cordis-catalog/events-and-services.md | 68 ++- docs/core-data-structures/filesystem.md | 43 +- docs/module-graph.md | 3 +- docs/rfc/README.md | 1 + .../2026-06-26-file-context-as-event-gate.md | 175 ++++++++ .../2026-06-26-fsspec-style-fs-seam.md | 10 +- packages/README.md | 12 +- packages/fs/README.md | 10 +- packages/fs/file-context/README.md | 43 +- packages/fs/file-context/package.json | 2 +- packages/fs/file-context/src/index.ts | 252 +++++------ packages/fs/file-context/src/types.ts | 45 +- packages/fs/file-context/tests/policy.spec.ts | 421 ++++++------------ packages/fs/fs-local/README.md | 10 +- packages/fs/fs-local/src/index.ts | 18 +- packages/fs/fs-local/tests/filesystem.spec.ts | 42 ++ packages/fs/fs/README.md | 25 +- packages/fs/fs/src/index.ts | 104 ++++- packages/fs/fs/src/types.ts | 19 +- packages/fs/fs/tests/service.spec.ts | 2 +- packages/fs/tool-fs/README.md | 30 +- packages/fs/tool-fs/package.json | 1 - packages/fs/tool-fs/src/edit.ts | 27 +- packages/fs/tool-fs/src/index.ts | 29 +- packages/fs/tool-fs/src/observe.ts | 34 ++ packages/fs/tool-fs/src/read.ts | 50 ++- packages/fs/tool-fs/src/types.ts | 32 ++ .../{file-context => tool-fs}/src/window.ts | 12 +- packages/fs/tool-fs/src/write.ts | 26 +- packages/fs/tool-fs/tests/integration.spec.ts | 362 ++++++++++----- packages/fs/tool-fs/tests/subpaths.spec.ts | 13 +- packages/fs/tool-fs/tests/tools.spec.ts | 85 +++- .../tests/window.spec.ts | 4 +- scripts/type-equiv.manifest.json | 3 +- 35 files changed, 1229 insertions(+), 793 deletions(-) create mode 100644 docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md create mode 100644 packages/fs/tool-fs/src/observe.ts create mode 100644 packages/fs/tool-fs/src/types.ts rename packages/fs/{file-context => tool-fs}/src/window.ts (92%) rename packages/fs/{file-context => tool-fs}/tests/window.spec.ts (98%) diff --git a/docs/architecture.md b/docs/architecture.md index 796026bb10..1afa766f63 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,8 +25,8 @@ For a catalog of the **data structures** this architecture moves around — the │ @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-file-context (filesystem policy) │ -│ @deepseek-ai/dsh-tool-fs (filesystem tool schemas) │ +│ @deepseek-ai/dsh-file-context (filesystem policy gate) │ +│ @deepseek-ai/dsh-tool-fs (filesystem tools+executor)│ │ @deepseek-ai/dsh-session-persistence-jsonl (persistence impl)│ ├─────────────────────────────────────────────────────────────┤ │ @deepseek-ai/dsh-agent (vocabulary + registry) │ @@ -57,8 +57,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, guarded writes/edits | -| `ctx.fileContext` | `FileContext` | dsh-file-context | filesystem policy: read windowing, observed-state, write/edit freshness over `ctx.fs` | +| `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 | All registrations (`registerAdapter`, `section`, `tools`, `register`, …) go through `ctx.effect()` and return disposers, so plugin hot-reload (vendored HMR) and fiber disposal clean up automatically. @@ -74,7 +73,7 @@ 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: `dsh-fs` owns the abstract `ctx.fs` provider seam (text IO + guarded mutation primitives), `dsh-fs-local` provides the local backend, `dsh-file-context` is a concrete `ctx.fileContext` policy service (read windowing + observed-state + write/edit freshness, injecting `fs`), and `dsh-tool-fs` exposes the model-facing `read`/`write`/`edit` schemas over `ctx.fileContext`. The policy layer is a concrete service, not a second swappable seam — it owns the model-facing observation policy a sandboxed/remote backend has no business carrying. +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-file-context` is a policy PLUGIN (no service) that decides the `fs/write-expectation`/`fs/edit-expectation` 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-file-context` gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool at a service-injection boundary. The default product config loads `dsh-file-context`, so the default behavior remains read-before-write/edit. See [the file-context 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. diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 78f9f44324..b1114ce4c8 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -11,7 +11,7 @@ The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary ## Events -Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out, no veto). The harness declares 22 events across 5 scopes. +Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out, no veto). The harness declares 25 events across 6 scopes. ### `agent/*` @@ -183,6 +183,44 @@ Types: [Agent](../core-data-structures/core.md) Source: [`packages/core/agent/src/types.ts:162`](../../packages/core/agent/src/types.ts) +### `fs/*` + +#### `fs/edit-expectation` — 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-file-context` 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-expectation'). + +```ts cordis-catalog +'fs/edit-expectation'(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. A listener MUST be a synchronous, side-effect-only recorder (`@deepseek-ai/dsh-file-context`'s is a `WeakMap.set`); the tool wraps the emit in a try/catch so a synchronous listener bug is logged and swallowed, never failing the already-completed mutation. cordis `emit` does not await listener promises, so this is not an async-error containment seam — async 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-expectation` — waterfall + +Single-slot decision: produce the write expectation 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-file-context` 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-expectation'(target: FsTarget, actor: object | undefined, next: () => FsWriteExpectation | undefined | Promise): Promise +``` + +Types: [FsTarget](../core-data-structures/filesystem.md) · [FsWriteExpectation](../core-data-structures/filesystem.md) + +Source: [`packages/fs/fs/src/index.ts:105`](../../packages/fs/fs/src/index.ts) + ### `llm/*` #### `llm/stream` — waterfall @@ -279,7 +317,7 @@ Source: [`packages/core/tools/src/index.ts:43`](../../packages/core/tools/src/in ## Services -The 10 `ctx.` services the harness provides. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against. +The 9 `ctx.` services the harness provides. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against. ### `ctx.agentLoop` — `AgentLoop` @@ -339,22 +377,6 @@ Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../c Source: [`packages/bash/bash/src/index.ts:59`](../../packages/bash/bash/src/index.ts) -### `ctx.fileContext` — `FileContext` - -The file-context policy service. Injects `fs`, registers as `ctx.fileContext`, and is the only read/write/edit path the model-facing tools use. - -```ts cordis-catalog -owner(exec?: FileContextExec): object | undefined -async resolve(path: string): Promise -async read(target: FsTarget, request: FileReadRequest, exec?: FileContextExec, signal?: AbortSignal): Promise -async write(target: FsTarget, content: string, exec?: FileContextExec, signal?: AbortSignal): Promise -async edit(target: FsTarget, edit: FsEditRequest, exec?: FileContextExec, signal?: AbortSignal): Promise -``` - -Types: [FileContextExec](../core-data-structures/filesystem.md) · [FileReadOutcome](../core-data-structures/filesystem.md) · [FileReadRequest](../core-data-structures/filesystem.md) · [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) - -Source: [`packages/fs/file-context/src/index.ts:65`](../../packages/fs/file-context/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). @@ -364,21 +386,21 @@ 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 honoring the FsWriteExpectation. -- 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. +- writeText is atomic temp-file + rename. `expected` is OPTIONAL: omit it for an unconditional create-or-overwrite (the bare-provider default), or supply a FsWriteExpectation 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): Promise abstract stat(target: FsTarget, signal?: AbortSignal): Promise abstract readText(target: FsTarget, signal?: AbortSignal): Promise abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> -abstract writeText(target: FsTarget, content: string, expected: FsWriteExpectation, signal?: AbortSignal): Promise -abstract editText(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, signal?: AbortSignal): Promise +abstract writeText(target: FsTarget, content: string, expected?: FsWriteExpectation, signal?: AbortSignal): Promise +abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise ``` 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) · [FsWriteExpectation](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) -Source: [`packages/fs/fs/src/index.ts:90`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts:158`](../../packages/fs/fs/src/index.ts) ### `ctx.llm` — `LlmService` diff --git a/docs/core-data-structures/filesystem.md b/docs/core-data-structures/filesystem.md index 99d073c6d4..e114c409d8 100644 --- a/docs/core-data-structures/filesystem.md +++ b/docs/core-data-structures/filesystem.md @@ -1,8 +1,10 @@ # Filesystem -The filesystem stack is split across four packages: a provider seam ([dsh-fs](../../packages/fs/fs), `ctx.fs`, text IO + guarded mutation), a local implementation ([dsh-fs-local](../../packages/fs/fs-local), local disk), a policy layer ([dsh-file-context](../../packages/fs/file-context), `ctx.fileContext`, read windowing + write/edit freshness), and a consumer ([dsh-tool-fs](../../packages/fs/tool-fs), the model-facing `read`/`write`/`edit` tools). 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 layer or the tool schemas. +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-file-context](../../packages/fs/file-context), 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. -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/file-context/src/types.ts`](../../packages/fs/file-context/src/types.ts). +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-file-context` 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. The default product config still loads it, so the default behavior remains 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/file-context/src/types.ts`](../../packages/fs/file-context/src/types.ts). Read-rendering source: [`packages/fs/tool-fs/src/types.ts`](../../packages/fs/tool-fs/src/types.ts). ## Target identity and metadata (provider seam) @@ -16,7 +18,7 @@ interface FsTarget { } ``` -The backend owns file-version tokens — the freshness token a write/edit guards against. The policy layer stores them for stale checks; consumers do not interpret them. Both ids are branded opaque strings. +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'> @@ -26,7 +28,7 @@ type FsTargetKey = Branded<'FsTargetKey'> type FsVersion = Branded<'FsVersion'> ``` -`stat` returns metadata (never content), or `undefined` when the target is absent. `type` lets the policy layer reject directories/special files before reading, and `size` lets it choose `readText` vs `streamText` without probing by failure. +`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 { @@ -38,7 +40,7 @@ interface FsInfo { ## Write and edit guards (provider seam) -`writeText` takes an explicit write expectation rather than inferring intent. `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`. +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 `FsWriteExpectation` — `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 FsWriteExpectation = @@ -53,7 +55,7 @@ interface FsWriteOutcome { } ``` -`editText` is a provider-level guarded mutation, not a `read` plus `write` composed in the policy layer. It verifies the expected version BEFORE literal matching (so a stale edit reports `FS_STALE_VERSION`, not a match failure against newer content), then applies the replacement and writes atomically — keeping matching, line-ending handling, stale checks, and atomic replacement inside one mutation critical section. +`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 { @@ -71,9 +73,15 @@ interface FsEditOutcome { } ``` -## Execution context and read outcome (policy layer) +## The fs policy events (provider-seam vocabulary) -The policy layer needs just enough execution context to derive the observed-state owner. `ToolExecution` satisfies this shape, so `dsh-tool-fs` passes its execution object through without making `dsh-file-context` import the tool, agent, or session packages. +`dsh-fs` owns three events the tool dispatches and the policy plugin listens for, so the emitter (`dsh-tool-fs`) and the listener (`dsh-file-context`) 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-expectation` and `fs/edit-expectation` 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 whose listener must be synchronous and side-effect-only; the tool contains a throw so a recording bug never fails the already-completed mutation. 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-file-context` import the tool, agent, or session packages. ```ts type-equiv interface FileContextExec { @@ -83,14 +91,9 @@ interface FileContextExec { } ``` -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 outcome (consumer / read rendering) -```ts type-equiv -interface FileReadRequest { - offset: number - limit: number -} -``` +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 { @@ -103,9 +106,9 @@ interface FileReadOutcome { } ``` -## Observed-file state (policy layer) +## Observed-file state (policy plugin) -Observed state is a `WeakMap>` inside `ctx.fileContext`. An entry exists **iff** the owner has read that target through `ctx.fileContext.read`, so its presence *is* the read record — there is no separate `hasRead` flag and no view distinction. The owner is normally `exec.agent.session`, but the policy layer treats it as opaque and never reads its fields. A successful read/write/edit refreshes the recorded version for that owner; disposal drops everything (HMR safety). +Observed state is a `WeakMap>` held inside the `dsh-file-context` 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) @@ -123,8 +126,8 @@ type FsErrorCode = | 'FS_ABORTED' ``` -`FS_NOT_OBSERVED` means no recorded read exists for this owner (or a `createIfAbsent` hit an existing file). `FS_STALE_VERSION` means the backend version no longer matches the observed one. Freshness authorization has no partial/full distinction, so there is no `FS_PARTIAL_OBSERVATION`. +`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 services +## The service and the plugin -`FileSystem` (`ctx.fs`, abstract) owns the provider primitives: `resolve`, `stat`, `readText`, `streamText`, `writeText`, and `editText`. `FileContext` (`ctx.fileContext`, concrete) injects `fs` and owns the model-facing policy: `read` windows text and records observed state, `write`/`edit` derive the freshness expectation and refresh state. The generated wiring catalog shows the exact service signatures on [events-and-services.md](../cordis-catalog/events-and-services.md#ctxfs--filesystem-abstract-seam). +`FileSystem` (`ctx.fs`, abstract) owns the provider primitives: `resolve`, `stat`, `readText`, `streamText`, `writeText`, and `editText`. `dsh-file-context` registers **no service** — it is a plugin that adds policy through the `fs/*` event gate: it decides the write/edit expectation 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). diff --git a/docs/module-graph.md b/docs/module-graph.md index ad3d7f7b89..dfa1c3a3ed 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -53,7 +53,6 @@ graph TD tool-bash --> bash tool-bash --> llm tool-bash --> tools - tool-fs --> file-context tool-fs --> fs tool-fs --> llm tool-fs --> system-prompt @@ -100,7 +99,7 @@ graph TD | `acp` | `agent`, `llm`, `session`, `session-persistence`, `tools` | | `agent-loop` | `agent`, `llm`, `session`, `session-persistence`, `system-prompt`, `tools` | | `tool-bash` | `agent`, `bash`, `llm`, `tools` | -| `tool-fs` | `file-context`, `fs`, `llm`, `system-prompt`, `tools` | +| `tool-fs` | `fs`, `llm`, `system-prompt`, `tools` | | `agent-core` | `agent`, `agent-loop`, `invariants`, `llm`, `session`, `system-prompt`, `tool-bash`, `tools` | | `acp-agent` | `acp`, `agent-core`, `session-persistence-jsonl` | | `stdio-agent` | `agent`, `agent-core`, `session`, `session-persistence-jsonl`, `ui-stdio` | diff --git a/docs/rfc/README.md b/docs/rfc/README.md index cf9c343206..64f9060acf 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -118,6 +118,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [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-file-context` an event-gate plugin, not a method interface](implemented/architecture/2026-06-26-file-context-as-event-gate.md) | 2026-06-26 | ### Process diff --git a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md new file mode 100644 index 0000000000..efb6667d4e --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md @@ -0,0 +1,175 @@ +# RFC: Make `dsh-file-context` 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-file-context` 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-file-context` 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-file-context plugin: listens to fs/write-expectation + + fs/edit-expectation (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-file-context` 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-file-context` does not break `dsh-tool-fs` at the service-injection boundary; it removes the policy gate and leaves the bare provider behavior. The product default still loads `dsh-file-context`, so the default user-facing behavior and prompt discipline remain read-before-write/edit. 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-file-context` stat + +`dsh-file-context` 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-file-context` 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-file-context` passes `vObserved` as the expectation; the provider raises `FS_STALE_VERSION` if the file has moved on. + +This is deliberate. If `dsh-file-context` 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-file-context` does **no** filesystem I/O; the "must be based on the latest read" guarantee is *realized* by CAS, and `dsh-file-context` 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 FsWriteExpectation union is UNCHANGED. +writeText(target: FsTarget, content: string, expected?: FsWriteExpectation, signal?: AbortSignal): Promise +// undefined → unconditionally create-or-overwrite (bare default) +// createIfAbsent → create only, reject an existing file (dsh-file-context, 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 +// 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 `FsWriteExpectation` 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-file-context` 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-file-context`. 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-file-context` no longer provides a method service. `dsh-fs` is the package both `dsh-tool-fs` and `dsh-file-context` 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`, `FsWriteExpectation`) 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-file-context` 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-expectation`, `fs/edit-expectation`) 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-file-context` 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-expectation` decider BEFORE `dsh-file-context` (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 the shipped `dsh-tool-fs` dispatches these waterfalls on every write/edit path and the shipped default config loads `dsh-file-context` 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-file-context`, 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, FsWriteExpectation } 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-expectation'(target: FsTarget, actor: object | undefined, next: () => FsWriteExpectation | undefined | Promise): Promise + /** + * 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-expectation'(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. Listeners MUST be synchronous, side-effect- + * only recorders (`dsh-file-context`'s is a WeakMap write); the tool wraps the + * emit in a try/catch so a synchronous listener bug is logged and swallowed, + * never failing the already-completed mutation. 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 the default product config loads `dsh-file-context`: 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 default file-context policy requires it. The bare-provider fallback does not change the default prompt stance. + +`dsh-tool-fs` gains the executor responsibilities relocated from the old `fileContext` method service, including **read windowing** (`window.ts`, `READ_MAX_BYTES`, `READ_MAX_LINE_LENGTH`, `FileReadRequest`/`FileReadOutcome`/`FileTextLine`, `STREAM_MIN_SIZE`), which is the tool's rendering detail now that the tool owns the read. Those read-windowing types and helpers move into `dsh-tool-fs`; the policy plugin must not remain a type dependency for the tool. + +`dsh-tool-fs` exposes each tool as a first-class **subpath plugin** (`/read`, `/write`, `/edit`) for focused deployments, plus a root plugin that composes all three. The `inject` change applies to **all four**: each of `read.ts`, `write.ts`, `edit.ts`, and `index.ts` drops `fileContext` from `inject` and adds `fs` (keeping `tools`/`systemPrompt`). Updating only the root plugin would leave a focused deployment that loads just `@deepseek-ai/dsh-tool-fs/edit` still coupled to the old method service, silently breaking the decoupling contract for exactly the deployments subpaths exist to serve. + +`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 a contained `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-expectation', target, exec, () => undefined)`, then `ctx.fs.writeText(target, content, expectation)`, then a contained `emit('fs/observed', target, outcome.version, exec)`. **Zero stat in the tool** with or without `dsh-file-context`. +- **edit** — `expectation = await ctx.waterfall('fs/edit-expectation', target, exec, () => undefined)`, then `ctx.fs.editText(target, edit, expectation)`, then a contained `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-file-context` 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-file-context` short-circuits the thunk before it runs in the default deployment. + +**`fs/observed` recording must never fail the tool, because it fires AFTER the mutation already succeeded** — a throw there becomes an `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. The tool therefore wraps the dispatch in a try/catch that logs and swallows synchronous listener bugs (the established fire-and-forget pattern in [agent.ts](../../../../packages/core/agent-loop/src/agent.ts)). The event contract is intentionally narrower than "arbitrary observers": an `fs/observed` listener MUST be synchronous and side-effect-only — `dsh-file-context`'s listener is a `WeakMap.set`, which cannot throw under normal operation and returns no promise. Cordis `emit` does not await listener promises, so the try/catch is NOT an async-error containment mechanism; async 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-file-context`) + +`dsh-file-context` 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>` 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-expectation` 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-expectation` 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-file-context` 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-file-context`) + +This is not the default product mode — the default product config loads `dsh-file-context`. It is the unconstrained provider floor that exists once the tool is no longer coupled to a policy method service. With `dsh-file-context` 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-file-context` would add: observed-state, read-before-edit, and version-guarded write/edit. Loading `dsh-file-context` 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 + +- All four `dsh-tool-fs` injection points — the root plugin AND the `/read`, `/write`, `/edit` subpath plugins — inject `fs` (+ `tools`/`systemPrompt`), not `fileContext`; each calls `ctx.fs` directly and dispatches the `fs/write-expectation`/`fs/edit-expectation` waterfalls (passing `exec` as the actor) and the contained `fs/observed` emit. Read windowing lives in `dsh-tool-fs`. +- `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-file-context` 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-file-context` that loads a **subpath plugin** (e.g. just `@deepseek-ai/dsh-tool-fs/edit`, plus `/read`/`/write` as the scenario needs) boots, and `read`/`write`(create AND overwrite)/`edit` work through `dsh-tool-fs` 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 subpath plugins — not just the root — carry no `fileContext` dependency. A bare-provider edit of a missing target reports `FS_STALE_VERSION`. With `dsh-file-context` 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-expectation` listener AFTER `dsh-file-context` 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. +- **Contained observed recording**: a test with a synchronously throwing `fs/observed` listener performs a write/edit and asserts the tool result is still success (the completed mutation is not turned into an `isError`). The event contract requires synchronous side-effect-only listeners; the try/catch is the synchronous backstop, not async rejection handling. +- `dsh-fs` `writeText`/`editText` make `expected` optional (omit ⇒ unconditional); the `FsWriteExpectation` union is unchanged, and `dsh-file-context`'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-file-context` performs no `stat`. +- `stat` budget: read = 1, write = 0, edit = 0 — in the tool, with or without `dsh-file-context` (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-expectation`/`fs/edit-expectation` slots hold exactly one decider; the first-registered (or `prepend`ed) listener wins and the rest are short-circuited. `dsh-file-context` 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-file-context` 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 default `dsh-file-context` plugin. A deployment that omits it is opting into an unconstrained filesystem on purpose; that is not the default product stance. diff --git a/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md index 25ff66816f..7f2e5d7091 100644 --- a/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md +++ b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md @@ -20,13 +20,15 @@ The old RFC already deferred a separate `@deepseek-ai/dsh-file-context` package. Split the stack into four layers: ```text -tool dsh-tool-fs model-facing schemas + text rendering -policy dsh-file-context ctx.fileContext (concrete service): observed-state, read windowing, write/edit freshness -provider seam dsh-fs ctx.fs: text IO + guarded mutation primitives +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-file-context 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 injects `fileContext`, not `fs`, and never reaches around the policy layer for model reads/writes/edits. +`dsh-tool-fs` keeps the same model-facing `read`/`write`/`edit` schemas. It injects `fs` (not a policy service) and reaches `ctx.fs` directly, dispatching the `fs/*` policy events so `dsh-file-context` can gate and record. + +The tool↔policy COUPLING below was reworked by [the file-context event-gate RFC](../architecture/2026-06-26-file-context-as-event-gate.md): `dsh-file-context` is now a gate PLUGIN that participates through the `fs/*` events (no `ctx.fileContext` service), and read windowing + the fs I/O moved up into `dsh-tool-fs`. The four-layer split, the provider contract, and the freshness *policy* this RFC decided are unchanged. Read the "`ctx.fileContext.read`/`write`/`edit`" method descriptions below as the policy DECISIONS the gate plugin now makes on the `fs/*` events, and the provider's version guard as optional (omit = unconditional bare provider). ## Provider Contract diff --git a/packages/README.md b/packages/README.md index 265fdd8676..85e802a5ab 100644 --- a/packages/README.md +++ b/packages/README.md @@ -31,10 +31,10 @@ dsh-agent ← dsh-llm, dsh-session, dsh-brand 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) +dsh-fs ← dsh-llm, dsh-brand (filesystem provider seam + fs/* events) dsh-fs-local ← dsh-fs (FileSystem impl) -dsh-file-context ← dsh-fs (read windowing + write/edit freshness policy) -dsh-tool-fs ← dsh-file-context, dsh-fs, dsh-tools (file tool schemas) +dsh-file-context ← 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-system-prompt, dsh-tools, dsh-agent @@ -63,10 +63,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 + guarded mutation primitives | `ctx.fs` | +| `fs/` | `fs` | Filesystem provider seam: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` events | `ctx.fs` | | `fs-local/` | `fs` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) | -| `file-context/` | `fs` | Policy layer: read windowing, observed-state, write/edit freshness | `ctx.fileContext` | -| `tool-fs/` | `fs` | Model-facing `read`/`write`/`edit` tool schemas | (registers on `ctx.tools`) | +| `file-context/` | `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`) | | `llm-deepseek/` | `llm` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) | | `llm-pi-ai/` | `llm` | DeepSeek adapter via `@earendil-works/pi-ai` (design twin) | (registers on `ctx.llm`) | | `session-persistence/` | `session-persistence` | Persistence seam + write coordinator | `ctx.sessionPersistence` | diff --git a/packages/fs/README.md b/packages/fs/README.md index a793a94b4b..79e1d5c0f6 100644 --- a/packages/fs/README.md +++ b/packages/fs/README.md @@ -1,12 +1,12 @@ # fs/ - filesystem capability family -The filesystem stack: a provider seam (text IO + guarded mutation), a local implementation, a policy layer (read windowing + write/edit freshness), and the model-facing file tools. All **product** packages. +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 + guarded mutation primitives | `ctx.fs` | +| `fs/` | Provider seam: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` policy events | `ctx.fs` | | `fs-local/` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) | -| `file-context/` | Policy layer: observed-state, read windowing, write/edit freshness | `ctx.fileContext` | -| `tool-fs/` | Model-facing `read`/`write`/`edit` tool schemas | (registers on `ctx.tools`) | +| `file-context/` | 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 layer, or the model-facing tool schemas. The policy layer (`file-context/`) is a concrete service, not a swappable seam — it owns the model-facing observation policy that does not belong on a provider backend. +The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas. The policy (`file-context/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. The default product config loads it. diff --git a/packages/fs/file-context/README.md b/packages/fs/file-context/README.md index 373fb2759b..b543722055 100644 --- a/packages/fs/file-context/README.md +++ b/packages/fs/file-context/README.md @@ -1,16 +1,18 @@ # @deepseek-ai/dsh-file-context -The **file-context policy layer**: a concrete `ctx.fileContext` service that owns model-facing read windowing and write/edit freshness on top of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)). This is the policy third of the filesystem stack — it is **not** a swappable seam, but the deferred policy layer that does not belong on the `FileSystem` provider base class. +The **file-context policy plugin**: it adds observed-state, read-before-edit, and version-guarded write/edit on top of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)) — through the `fs/*` event gate, **NOT** through a method service. This plugin registers **no** `ctx.fileContext` service and has no public `read`/`write`/`edit`/`resolve` methods. It is the policy third of the filesystem stack: not a swappable seam, but the policy that does not belong on the `FileSystem` provider base class. ```ts import type { Context } from 'cordis' -import FileContext from '@deepseek-ai/dsh-file-context' +import * as FileContext from '@deepseek-ai/dsh-file-context' declare const ctx: Context -// A ctx.fs provider must already be loaded (e.g. @deepseek-ai/dsh-fs-local); -// FileContext injects `fs` and registers ctx.fileContext. Load -// @deepseek-ai/dsh-tool-fs afterwards to expose read/write/edit to the model. +// 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/*-expectation slots. await ctx.plugin(FileContext) ``` @@ -18,26 +20,29 @@ await ctx.plugin(FileContext) | Layer | Package | Role | |---|---|---| -| tool | `@deepseek-ai/dsh-tool-fs` | model-facing schemas + text rendering | -| policy | `@deepseek-ai/dsh-file-context` (this) | `ctx.fileContext`: observed-state, read windowing, write/edit freshness | -| provider seam | `@deepseek-ai/dsh-fs` | `ctx.fs`: text IO + guarded mutation primitives | +| tool / executor | `@deepseek-ai/dsh-tool-fs` | model-facing schemas + read windowing + text rendering; reads/writes/edits via `ctx.fs`, dispatches the `fs/*` events | +| policy | `@deepseek-ai/dsh-file-context` (this) | observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate (no service) | +| 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` | -## Service API (`ctx.fileContext`) +## How the gate participates -| Member | Semantics | +Three `fs/*` events (declared by `@deepseek-ai/dsh-fs`, dispatched by `@deepseek-ai/dsh-tool-fs`): + +| Event | This plugin's listener | |---|---| -| `read(target, request, exec?, signal?)` | Stats the target, rejects absent/non-regular targets, chooses `readText`/`streamText` by size, builds the requested line window, records the version, and returns the `FileReadOutcome` the tool renders. | -| `write(target, content, exec?, signal?)` | No recorded read → `writeText({ kind: 'createIfAbsent' })` (only new files create blindly); a recorded read → `writeText({ kind: 'replaceIfVersion', version })`. Refreshes recorded state on success. | -| `edit(target, edit, exec?, signal?)` | Requires a recorded read by this owner (else `FS_NOT_OBSERVED`); passes the observed version to `ctx.fs.editText` as the stale guard and refreshes recorded state. | -| `owner(exec?)` | Derives the observed-state owner (`exec.agent.session`) — `undefined` when there is none. | +| `fs/write-expectation` | No prior observation → `{ kind: 'createIfAbsent' }`; a prior observation → `{ kind: 'replaceIfVersion', version: vObserved }`. Single-slot decision; does NOT call `next()`. | +| `fs/edit-expectation` | Requires a prior observation by this owner (else throws `FS_NOT_OBSERVED`); returns `{ version: vObserved }` as the CAS basis. Single-slot decision; does NOT call `next()`. | +| `fs/observed` | Records `{ version }` for this owner+target. Synchronous, side-effect-only `WeakMap.set`. | -## Observed state is the read record, freshness is the authorization +## Observed state is the prior-observation record; freshness is provider CAS -Observed state is a `WeakMap>`. An entry exists **iff** the owner has read that target through `read`, so its presence *is* the read record — there is no `hasRead` flag and no `full`/`partial` view. Authorization is based on version freshness only: a windowed read of lines 100-150 records the file's version, and a later edit of line 120 is authorized as long as the file is unchanged (the provider's stale guard enforces it). State is held weakly and dropped on disposal (HMR safety); persistence across sessions is deferred. +Observed state is a `WeakMap>`. 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. -## The no-bypass contract +## Single-slot, first-wins -A model-facing read MUST go through `ctx.fileContext.read`, never `ctx.fs.readText`/`streamText`, so every successful read records observed state before the tool renders. Direct `ctx.fs` calls remain an explicit escape hatch for non-tool consumers: a direct `ctx.fs.readText` records nothing, so a later `ctx.fileContext.edit` rejects with `FS_NOT_OBSERVED` until the file is read through `ctx.fileContext`. +The `fs/write-expectation`/`fs/edit-expectation` slots hold exactly one decider — this plugin fully decides and does not call `next()`. The slot is first-wins by registration order; this plugin owning it is the default-deployment convention, not an event-enforced invariant (a decider registered before / `prepend`ed would win instead). This is not a composable authorization chain — layered permission/audit/sandbox interception belongs on `tools/execute`. -The line-windowing mechanics live in `src/window.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the service wiring and policy. +## 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. diff --git a/packages/fs/file-context/package.json b/packages/fs/file-context/package.json index 77c905703b..577b650aac 100644 --- a/packages/fs/file-context/package.json +++ b/packages/fs/file-context/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-file-context", - "description": "File-context policy layer (ctx.fileContext) for the DeepSeek Harness — read windowing and write/edit freshness over the ctx.fs provider seam", + "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", diff --git a/packages/fs/file-context/src/index.ts b/packages/fs/file-context/src/index.ts index c8ed44dde1..5e0488ea0e 100644 --- a/packages/fs/file-context/src/index.ts +++ b/packages/fs/file-context/src/index.ts @@ -1,193 +1,159 @@ /** - * The file-context policy layer (`ctx.fileContext`): a concrete service that - * owns model-facing read windowing and write/edit freshness on top of the - * `ctx.fs` provider seam. It is NOT a swappable seam — it is the previously - * deferred policy layer that does not belong on the `FileSystem` provider base - * class (where a sandboxed/remote backend would otherwise inherit model-facing - * observation policy it has no business carrying). + * The file-context policy PLUGIN: observed-state, read-before-edit, and + * "write/edit must be based on the version you read" — added on top of the + * `ctx.fs` provider seam through the `fs/*` event gate, NOT through a method + * service. This plugin registers NO `ctx.fileContext` service and exposes no + * `read`/`write`/`edit`/`resolve` methods; it influences the world only by + * deciding the `fs/write-expectation`/`fs/edit-expectation` 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 read record + * ## Observed state IS the prior-observation record * - * Observed state lives here as `WeakMap>`. An - * entry exists iff the owner has read that target through {@link read}, so its - * presence *is* the read record — there is no separate `hasRead` flag. The owner - * is derived structurally from `{ agent?: { session? } }` and held weakly, so a - * collected session frees its state; disposal drops everything (HMR safety). + * State lives here as `WeakMap>`. 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, not full/partial views + * ## Freshness via provider CAS, not stat * - * Authorization is based on version freshness only. A windowed read records the - * file's version, and any later write/edit at that version is authorized — a - * model that read lines 100-150 of a large file can still edit line 120 as long - * as the file is unchanged. There is no `full`/`partial` distinction: the bytes - * the edit matches must merely come from the version the model read, which the - * provider's stale guard enforces. + * 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. * - * ## The no-bypass contract + * ## Single-slot, first-wins * - * A model-facing read MUST go through {@link read} (never `ctx.fs.readText`/ - * `streamText` directly), so every successful read records observed state before - * the tool renders. Direct `ctx.fs` calls are allowed for non-tool consumers but - * record nothing, so a later {@link edit} rejects with `FS_NOT_OBSERVED` until - * the file is read through `ctx.fileContext`. + * The `fs/write-expectation`/`fs/edit-expectation` listeners do NOT call + * `next()`: each fully decides its single slot. The slot is first-wins by + * registration order — this plugin owning it is the default-deployment + * convention, not an event-enforced invariant (a decider registered before / + * `prepend`ed would win instead). This is not a composable authorization chain; + * layered permission/audit/sandbox interception belongs on `tools/execute`. * * @module @deepseek-ai/dsh-file-context */ -import { Context, Service } from 'cordis' +import type { Context } from 'cordis' import { FsError } from '@deepseek-ai/dsh-fs' -import type { FsTarget, FsVersion, FsEditRequest, FsEditOutcome, FsWriteOutcome } from '@deepseek-ai/dsh-fs' -import { buildWindow } from './window.ts' -import type { FileContextExec, FileReadRequest, FileReadOutcome } from './types.ts' +import type { FsTarget, FsVersion, FsWriteExpectation } from '@deepseek-ai/dsh-fs' +import type { FileContextExec } from './types.ts' -export type { FileTextLine, ReadWindow, WindowResult } from './window.ts' -export { READ_MAX_BYTES, READ_MAX_LINE_LENGTH, buildWindow } from './window.ts' -export type { FileContextExec, FileReadRequest, FileReadOutcome } from './types.ts' - -/** Files at or above this size stream; smaller files read whole into memory. */ -export const STREAM_MIN_SIZE = 10 * 1024 * 1024 - -declare module 'cordis' { - interface Context { - fileContext: FileContext - } -} - -/** What an owner has observed about one target: just the version it last saw. */ -interface ObservedState { - version: FsVersion -} +export type { FileContextExec } from './types.ts' /** - * The file-context policy service. Injects `fs`, registers as `ctx.fileContext`, - * and is the only read/write/edit path the model-facing tools use. + * 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. */ -export class FileContext extends Service { - static inject = ['fs'] - +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 read record. + * entry's PRESENCE is the prior-observation record. */ - private observed = new WeakMap>() - - constructor(ctx: Context) { - super(ctx, 'fileContext') - ctx.effect(() => () => { - // Drop all recorded state on disposal so a reloaded service starts clean - // (HMR safety). The WeakMap itself would be GC'd, but replacing it makes - // the release observable and immediate for tests. - this.observed = new WeakMap() - }, 'fileContext observed-state teardown') - } + private observed = new WeakMap>() /** - * Derive the observed-state owner from an execution context — normally the + * 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. */ - owner(exec?: FileContextExec): object | undefined { - return exec?.agent?.session + private owner(actor: object | undefined): object | undefined { + return (actor as FileContextExec | undefined)?.agent?.session } - private getObserved(owner: object, targetKey: string): ObservedState | undefined { + private get(owner: object, targetKey: string): FsVersion | undefined { return this.observed.get(owner)?.get(targetKey) } - private record(owner: object, targetKey: string, version: FsVersion): void { + 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 }) + byTarget.set(targetKey, version) + } + + /** Drop all recorded state (HMR safety / disposal). */ + clear(): void { + this.observed = new WeakMap() } /** - * Resolve a path into a stable {@link FsTarget}, delegating to the provider. - * Exposed here so the model-facing tools never need to inject `ctx.fs` - * directly — they resolve and then read/write/edit entirely through - * `ctx.fileContext`. + * Decide the write expectation: 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). */ - async resolve(path: string): Promise { - return this.ctx.fs.resolve(path) + writeExpectation(target: FsTarget, actor: object | undefined): FsWriteExpectation { + const owner = this.owner(actor) + const prior = owner ? this.get(owner, target.targetKey) : undefined + return prior ? { kind: 'replaceIfVersion', version: prior } : { kind: 'createIfAbsent' } } /** - * Read a bounded line window from a target. Stats first (rejecting an absent - * target with `FS_NOT_FOUND` and a non-regular one with `FS_NOT_REGULAR_FILE`), - * chooses `readText` vs `streamText` by size — streaming when the size is - * large OR unknown so a size-less backend never buffers an arbitrarily large - * file — builds the window, then records the version observed AFTER the read - * so the recorded freshness token corresponds to the bytes actually returned - * (a writer racing between the routing stat and the read can't make a - * follow-up edit spuriously stale against a pre-read version). + * Decide the edit version guard: requires a prior observation by this owner + * (else `FS_NOT_OBSERVED`); returns the observed version as the CAS basis. */ - async read(target: FsTarget, request: FileReadRequest, exec?: FileContextExec, signal?: AbortSignal): Promise { - const info = await this.ctx.fs.stat(target, signal) - if (!info) throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND') - if (info.type !== 'file') throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') - - const chunks = info.size === undefined || info.size >= STREAM_MIN_SIZE - ? await this.ctx.fs.streamText(target, signal) - : [await this.ctx.fs.readText(target, signal)] - const window = await buildWindow(chunks, request, target.displayPath) - - // The version that matches the bytes just read: a stat taken after the read - // (falling back to the routing stat if the file vanished in the interim). - const after = await this.ctx.fs.stat(target, signal) - const version = after?.version ?? info.version - - const owner = this.owner(exec) - if (owner) this.record(owner, target.targetKey, version) - return { - offset: request.offset, - limit: request.limit, - lines: window.lines, - totalLines: window.totalLines, - version, - ...window.truncatedByBytes ? { truncatedByBytes: true } : {}, - } - } - - /** - * Create or fully replace a file. With no recorded read, writes - * `createIfAbsent` (only new files can be created blindly); with a recorded - * read, writes `replaceIfVersion` at the observed version (existing files are - * replaced only if unchanged since the read). Refreshes recorded state from - * the returned version on success. - */ - async write(target: FsTarget, content: string, exec?: FileContextExec, signal?: AbortSignal): Promise { - const owner = this.owner(exec) - const prior = owner ? this.getObserved(owner, target.targetKey) : undefined - const outcome = await this.ctx.fs.writeText( - target, - content, - prior ? { kind: 'replaceIfVersion', version: prior.version } : { kind: 'createIfAbsent' }, - signal, - ) - if (owner) this.record(owner, target.targetKey, outcome.version) - return outcome - } - - /** - * Apply a literal edit. Requires a recorded read by this owner (else - * `FS_NOT_OBSERVED`); passes the observed version to `ctx.fs.editText` as the - * stale guard and refreshes recorded state from the returned version. The - * provider owns the mutation critical section and the literal match. - */ - async edit(target: FsTarget, edit: FsEditRequest, exec?: FileContextExec, signal?: AbortSignal): Promise { - const owner = this.owner(exec) - const prior = owner ? this.getObserved(owner, target.targetKey) : undefined + editExpectation(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') } - const outcome = await this.ctx.fs.editText(target, edit, { version: prior.version }, signal) - this.record(owner, target.targetKey, outcome.version) - return outcome + 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) } } -export default FileContext +/** Cordis plugin name used by loader diagnostics. */ +export const name = 'file-context' + +/** + * 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() + }, 'file-context observed-state teardown') + + // fs/write-expectation: occupy the single decision slot — do NOT call next(). + // Deferred through Promise.resolve().then so the declared Promise return type + // holds (a throw rejects, never escapes synchronously through the waterfall). + ctx.on('fs/write-expectation', (target, actor) => Promise.resolve().then(() => gate.writeExpectation(target, actor))) + + // fs/edit-expectation: occupy the single decision slot — do NOT call next(). + // Deferred the same way so an FS_NOT_OBSERVED throw becomes a rejected promise + // the edit tool's `await ctx.waterfall(...)` surfaces as its isError result. + ctx.on('fs/edit-expectation', (target, actor) => Promise.resolve().then(() => gate.editExpectation(target, actor))) + + // fs/observed: synchronous, side-effect-only WeakMap write (cannot throw under + // normal operation); the tool contains any throw so a record bug never fails + // the already-completed mutation. + ctx.on('fs/observed', (target, version, actor) => { + gate.observe(target, version, actor) + }) +} diff --git a/packages/fs/file-context/src/types.ts b/packages/fs/file-context/src/types.ts index 842d9a08c7..b3157cc2e9 100644 --- a/packages/fs/file-context/src/types.ts +++ b/packages/fs/file-context/src/types.ts @@ -1,24 +1,21 @@ /** - * Vocabulary for the file-context policy layer (`ctx.fileContext`): the - * minimal execution-context shape used to derive an observed-state owner, the - * resolved read window, and the structured read outcome the model-facing `read` - * tool renders. + * Vocabulary for the file-context 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 model-facing - * read-windowing and observation policy on top of it. + * re-used from `@deepseek-ai/dsh-fs`; this package owns only the observed-state + * owner structure on top of it. * * @module @deepseek-ai/dsh-file-context/types */ -import type { FsVersion } from '@deepseek-ai/dsh-fs' -import type { FileTextLine } from './window.ts' - /** - * Minimal structural view of a tool execution the policy layer needs to derive + * 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 consumer passes its `exec` straight through without - * `dsh-file-context` importing `dsh-tools`, `dsh-agent`, or `dsh-session`. + * 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. @@ -30,27 +27,3 @@ export interface FileContextExec { session?: object } } - -/** Resolved read window. The consumer applies its defaults/caps before calling. */ -export interface FileReadRequest { - /** 1-based first line to return. */ - offset: number - /** Maximum number of lines to return. */ - limit: number -} - -/** Outcome of a bounded text read — what the model-facing `read` tool renders. */ -export interface FileReadOutcome { - /** 1-based first line requested. */ - offset: number - /** Maximum number of lines requested. */ - limit: number - /** Returned lines, already numbered. */ - lines: FileTextLine[] - /** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */ - totalLines: number - /** Whether selected output hit the byte cap before EOF or the requested limit. */ - truncatedByBytes?: true - /** Opaque version of the file at read time. */ - version: FsVersion -} diff --git a/packages/fs/file-context/tests/policy.spec.ts b/packages/fs/file-context/tests/policy.spec.ts index 317776d5b3..63808f1610 100644 --- a/packages/fs/file-context/tests/policy.spec.ts +++ b/packages/fs/file-context/tests/policy.spec.ts @@ -1,349 +1,192 @@ /** - * Tests for the file-context policy layer: registration/disposal/HMR, owner - * derivation, observed-state-as-read-record, read windowing over a fake - * provider, freshness-based write/edit authorization (including the key - * windowed-read-authorizes-edit behavior), the read→streamText size routing, - * and multi-owner isolation. The provider is a fake `ctx.fs` recording the - * expectations it was handed. + * Tests for the file-context 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 expectations and records versions on its own WeakMap. */ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' -import type { - FsEditOutcome, - FsEditRequest, - FsInfo, - FsTarget, - FsWriteExpectation, - FsWriteOutcome, -} from '@deepseek-ai/dsh-fs' -import FileContext, { STREAM_MIN_SIZE } from '@deepseek-ai/dsh-file-context' -import type { FileContextExec, FileReadRequest } from '@deepseek-ai/dsh-file-context' +import { FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' +import type { FsTarget, FsWriteExpectation } from '@deepseek-ai/dsh-fs' +import * as FileContext from '@deepseek-ai/dsh-file-context' +import type { FileContextExec } from '@deepseek-ai/dsh-file-context' -/** A fake provider: in-memory files, recording every expectation/version it is handed. */ -class FakeFs extends FileSystem { - files = new Map() - versions = new Map() - /** Size to report from stat (lets a test push read onto the streaming path). */ - reportSize?: number - /** When true, stat omits `size` entirely (a size-less backend). */ - omitSize = false - /** Whether streamText was used for the last read (vs readText). */ - lastReadStreamed = false - writeExpectations: FsWriteExpectation[] = [] - editExpectedVersions: string[] = [] +function target(path: string): FsTarget { + return { inputPath: path, targetKey: FsTargetKey(path), displayPath: path } +} +const ownerExec = (session: object): FileContextExec => ({ agent: { session } }) - private ver(key: string): FsVersion { - return FsVersion(`v${this.versions.get(key) ?? 0}`) - } - private bump(key: string): FsVersion { - const next = (this.versions.get(key) ?? 0) + 1 - this.versions.set(key, next) - return FsVersion(`v${next}`) - } - - override async resolve(path: string): Promise { - return { inputPath: path, targetKey: FsTargetKey(path), displayPath: path } - } - override async stat(target: FsTarget): Promise { - const content = this.files.get(target.targetKey) - if (content === undefined) return undefined - return { version: this.ver(target.targetKey), type: 'file', ...this.omitSize ? {} : { size: this.reportSize ?? content.length } } - } - override async readText(target: FsTarget): Promise { - this.lastReadStreamed = false - return this.files.get(target.targetKey) ?? '' - } - override async streamText(target: FsTarget): Promise> { - this.lastReadStreamed = true - const content = this.files.get(target.targetKey) ?? '' - return (async function* () { yield content })() - } - override async writeText(target: FsTarget, content: string, expected: FsWriteExpectation): Promise { - this.writeExpectations.push(expected) - const existed = this.files.has(target.targetKey) - this.files.set(target.targetKey, content) - return { operation: existed ? 'update' : 'create', version: this.bump(target.targetKey) } - } - override async editText(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }): Promise { - this.editExpectedVersions.push(expected.version) - const content = this.files.get(target.targetKey) ?? '' - this.files.set(target.targetKey, content.split(edit.oldString).join(edit.newString)) - return { replacements: 1, replaceAll: edit.replaceAll, version: this.bump(target.targetKey) } - } +/** Dispatch the write-expectation waterfall with the bare default thunk. */ +function writeExpectation(ctx: Context, t: FsTarget, actor: object | undefined): Promise { + return ctx.waterfall('fs/write-expectation', t, actor, () => undefined) +} +/** Dispatch the edit-expectation waterfall with the bare default thunk. */ +function editExpectation(ctx: Context, t: FsTarget, actor: object | undefined): Promise<{ version: FsVersion } | undefined> { + return ctx.waterfall('fs/edit-expectation', t, actor, () => undefined) } async function setup() { const ctx = new Context() - await ctx.plugin(FakeFs) - await ctx.plugin(FileContext) - const fs = ctx.fs as FakeFs - const fileContext = ctx.fileContext - return { ctx, fs, fileContext } + const fiber = await ctx.plugin(FileContext) + return { ctx, fiber } } -const READ_ALL: FileReadRequest = { offset: 1, limit: 2000 } -const ownerExec = (session: object): FileContextExec => ({ agent: { session } }) - describe('registration / disposal', () => { - it('registers as ctx.fileContext and injects fs', async () => { - const { fileContext } = await setup() - expect(fileContext).toBeDefined() + it('registers no service surface (it is a plugin, not ctx.fileContext)', async () => { + const { ctx } = await setup() + expect((ctx as Context & { fileContext?: unknown }).fileContext).toBeUndefined() }) - it('stays pending until ctx.fs exists', async () => { + it('mounts with no inject (reads no services)', async () => { + // It mounts immediately even with nothing else in the context. const ctx = new Context() - await ctx.plugin(FileContext) // no fs provider - expect(ctx.fileContext).toBeUndefined() - }) - - it('withdraws ctx.fileContext when its fiber is disposed (HMR safety)', async () => { - const ctx = new Context() - await ctx.plugin(FakeFs) - const fiber = await ctx.plugin(FileContext) - expect(ctx.fileContext).toBeDefined() - await fiber.dispose() - expect(ctx.fileContext).toBeUndefined() + await ctx.plugin(FileContext) + // The listener is live: an unobserved write decides createIfAbsent. + expect(await writeExpectation(ctx, target('a.txt'), undefined)).toEqual({ kind: 'createIfAbsent' }) }) }) -describe('owner derivation', () => { - it('derives the owner from exec.agent.session', async () => { - const { fileContext } = await setup() - const session = {} - expect(fileContext.owner(ownerExec(session))).toBe(session) +describe('write-expectation decision', () => { + it('an unobserved target decides createIfAbsent', async () => { + const { ctx } = await setup() + expect(await writeExpectation(ctx, target('a.txt'), ownerExec({}))).toEqual({ kind: 'createIfAbsent' }) }) - it('returns undefined with no exec, no agent, or no session', async () => { - const { fileContext } = await setup() - expect(fileContext.owner()).toBeUndefined() - expect(fileContext.owner({})).toBeUndefined() - expect(fileContext.owner({ agent: {} })).toBeUndefined() + it('a no-owner actor decides createIfAbsent', async () => { + const { ctx } = await setup() + expect(await writeExpectation(ctx, target('a.txt'), undefined)).toEqual({ kind: 'createIfAbsent' }) + expect(await writeExpectation(ctx, target('a.txt'), {})).toEqual({ kind: 'createIfAbsent' }) + }) + + it('an observed target decides replaceIfVersion at the observed version', async () => { + const { ctx } = await setup() + const exec = ownerExec({}) + ctx.emit('fs/observed', target('a.txt'), FsVersion('v7'), exec) + expect(await writeExpectation(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v7' }) }) }) -describe('read', () => { - it('returns a windowed outcome and rejects an absent target', async () => { - const { fs, fileContext } = await setup() - fs.files.set('a.txt', 'one\ntwo') - const outcome = await fileContext.read(await fs.resolve('a.txt'), READ_ALL) - expect(outcome.lines).toEqual([{ number: 1, text: 'one' }, { number: 2, text: 'two' }]) - expect(outcome.version).toBe('v0') - - await expect(fileContext.read(await fs.resolve('missing.txt'), READ_ALL)) - .rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) +describe('edit-expectation decision', () => { + it('rejects an unread edit with FS_NOT_OBSERVED', async () => { + const { ctx } = await setup() + await expect(editExpectation(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) }) - it('rejects a non-regular target', async () => { - const { fs, fileContext } = await setup() - fs.files.set('d', '') - const target = await fs.resolve('d') - // Force stat to report a directory. - fs.stat = async () => ({ version: FsVersion('v0'), type: 'directory' }) - await expect(fileContext.read(target, READ_ALL)).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) + it('rejects an edit with no owner (cannot prove prior observation)', async () => { + const { ctx } = await setup() + await expect(editExpectation(ctx, target('a.txt'), undefined)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) }) - it('reads small files whole and large files via streamText', async () => { - const { fs, fileContext } = await setup() - fs.files.set('a.txt', 'one\ntwo') - - await fileContext.read(await fs.resolve('a.txt'), READ_ALL) - expect(fs.lastReadStreamed).toBe(false) - - fs.reportSize = STREAM_MIN_SIZE - await fileContext.read(await fs.resolve('a.txt'), READ_ALL) - expect(fs.lastReadStreamed).toBe(true) - }) - - it('streams when the backend reports no size (never buffers a size-less file)', async () => { - const { fs, fileContext } = await setup() - fs.files.set('a.txt', 'one\ntwo') - fs.omitSize = true - await fileContext.read(await fs.resolve('a.txt'), READ_ALL) - expect(fs.lastReadStreamed).toBe(true) - }) - - it('records the version observed after the read, not the routing stat', async () => { - const { fs, fileContext } = await setup() + it('returns the observed version as the CAS basis after an observation', async () => { + const { ctx } = await setup() const exec = ownerExec({}) - fs.files.set('a.txt', 'hello') - fs.versions.set('a.txt', 1) - const target = await fs.resolve('a.txt') - // A writer bumps the version after the routing stat but before the post-read stat. - const realReadText = fs.readText.bind(fs) - fs.readText = async (t) => { - const text = await realReadText(t) - fs.versions.set('a.txt', 5) // file changed during the read - return text - } - const outcome = await fileContext.read(target, READ_ALL, exec) - expect(outcome.version).toBe('v5') - // The recorded (post-read) version authorizes an edit without going stale. - await fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec) - expect(fs.editExpectedVersions).toEqual(['v5']) - }) - - it('falls back to the routing-stat version if the file vanishes after the read', async () => { - const { fs, fileContext } = await setup() - fs.files.set('a.txt', 'hello') - const target = await fs.resolve('a.txt') - const realReadText = fs.readText.bind(fs) - fs.readText = async (t) => { - const text = await realReadText(t) - fs.files.delete('a.txt') // vanishes → post-read stat returns undefined - return text - } - const outcome = await fileContext.read(target, READ_ALL) - expect(outcome.version).toBe('v0') // the routing-stat version - }) - - it('surfaces truncatedByBytes when the window hits the byte cap', async () => { - const { fs, fileContext } = await setup() - fs.files.set('big.txt', Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n')) - const outcome = await fileContext.read(await fs.resolve('big.txt'), READ_ALL) - expect(outcome.truncatedByBytes).toBe(true) + ctx.emit('fs/observed', target('a.txt'), FsVersion('v3'), exec) + expect(await editExpectation(ctx, target('a.txt'), exec)).toEqual({ version: 'v3' }) }) }) -describe('observed-state is the read record', () => { - it('a read authorizes a later in-place write at the observed version', async () => { - const { fs, fileContext } = await setup() +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({}) - fs.files.set('a.txt', 'hello') - const target = await fs.resolve('a.txt') - - await fileContext.read(target, READ_ALL, exec) - await fileContext.write(target, 'goodbye', exec) - - expect(fs.writeExpectations).toEqual([{ kind: 'replaceIfVersion', version: 'v0' }]) + ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec) // a read + expect(await writeExpectation(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v0' }) }) - it('a windowed (partial) read still authorizes edit — freshness, not full/partial', async () => { - const { fs, fileContext } = await setup() + it('a write/edit observation refreshes the basis, so the next edit needs no re-read', async () => { + const { ctx } = await setup() const exec = ownerExec({}) - fs.files.set('a.txt', 'one\ntwo\nthree\nfour') - const target = await fs.resolve('a.txt') - - // Read only lines 2-3 — a partial window. - const outcome = await fileContext.read(target, { offset: 2, limit: 2 }, exec) - expect(outcome.lines.map(l => l.number)).toEqual([2, 3]) - - // Edit is authorized anyway: the file is unchanged since the read. - await fileContext.edit(target, { oldString: 'one', newString: 'X', replaceAll: false }, exec) - expect(fs.editExpectedVersions).toEqual(['v0']) + // A create records v1; the follow-up edit guards against v1 with no read. + ctx.emit('fs/observed', target('a.txt'), FsVersion('v1'), exec) + expect(await editExpectation(ctx, target('a.txt'), exec)).toEqual({ version: 'v1' }) + // The edit records v2; a second edit guards against v2. + ctx.emit('fs/observed', target('a.txt'), FsVersion('v2'), exec) + expect(await editExpectation(ctx, target('a.txt'), exec)).toEqual({ version: 'v2' }) }) - it('skips recording when there is no owner, so write is createIfAbsent', async () => { - const { fs, fileContext } = await setup() - fs.files.set('a.txt', 'hello') - const target = await fs.resolve('a.txt') - - await fileContext.read(target, READ_ALL) // no exec - // No recorded read → createIfAbsent → the provider rejects an existing target. - fs.writeText = async () => { throw new FsError('exists', 'FS_NOT_OBSERVED') } - await expect(fileContext.write(target, 'x')).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) - }) -}) - -describe('write policy', () => { - it('a create (no prior read) uses createIfAbsent', async () => { - const { fs, fileContext } = await setup() - const exec = ownerExec({}) - const target = await fs.resolve('new.txt') - const outcome = await fileContext.write(target, 'fresh', exec) - expect(outcome.operation).toBe('create') - expect(fs.writeExpectations).toEqual([{ kind: 'createIfAbsent' }]) - }) - - it('refreshes state after a write, so a follow-up edit needs no re-read', async () => { - const { fs, fileContext } = await setup() - const exec = ownerExec({}) - const target = await fs.resolve('a.txt') - await fileContext.write(target, 'one', exec) // create → state now at v1 - await fileContext.edit(target, { oldString: 'one', newString: 'two', replaceAll: false }, exec) - expect(fs.editExpectedVersions).toEqual(['v1']) - }) -}) - -describe('edit policy', () => { - it('rejects with FS_NOT_OBSERVED when the file was never read', async () => { - const { fs, fileContext } = await setup() - const exec = ownerExec({}) - fs.files.set('a.txt', 'hello') - const target = await fs.resolve('a.txt') - await expect(fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec)) - .rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) - }) - - it('rejects when there is no owner (cannot prove prior observation)', async () => { - const { fs, fileContext } = await setup() - fs.files.set('a.txt', 'hello') - const target = await fs.resolve('a.txt') - await expect(fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false })) - .rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) - }) - - it('passes the recorded version as the stale guard after a read', async () => { - const { fs, fileContext } = await setup() - const exec = ownerExec({}) - fs.files.set('a.txt', 'hello') - fs.versions.set('a.txt', 7) - const target = await fs.resolve('a.txt') - await fileContext.read(target, READ_ALL, exec) - await fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec) - expect(fs.editExpectedVersions).toEqual(['v7']) + it('a no-owner observation records nothing', async () => { + const { ctx } = await setup() + ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), undefined) + // Still unobserved for any owner. + await expect(editExpectation(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) }) }) describe('multi-owner isolation', () => { - it('owner A reading does not grant owner B edit authority', async () => { - const { fs, fileContext } = await setup() + it('owner A observing does not grant owner B edit authority', async () => { + const { ctx } = await setup() const a = ownerExec({}) const b = ownerExec({}) - fs.files.set('a.txt', 'hello') - const target = await fs.resolve('a.txt') - - await fileContext.read(target, READ_ALL, a) - await expect(fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, b)) - .rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) - await expect(fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, a)) - .resolves.toMatchObject({ replacements: 1 }) + ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), a) + await expect(editExpectation(ctx, target('a.txt'), b)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + expect(await editExpectation(ctx, target('a.txt'), a)).toEqual({ version: 'v0' }) }) it('each owner records its own observed version independently', async () => { - const { fs, fileContext } = await setup() + const { ctx } = await setup() const a = ownerExec({}) const b = ownerExec({}) - fs.files.set('a.txt', 'hello') - const target = await fs.resolve('a.txt') - - await fileContext.read(target, READ_ALL, a) // A sees v0 - await fileContext.write(target, 'mid', b) // B has no read → createIfAbsent - await fileContext.write(target, 'late', a) // A still holds its v0 observation - - expect(fs.writeExpectations).toEqual([ - { kind: 'createIfAbsent' }, - { kind: 'replaceIfVersion', version: 'v0' }, - ]) + ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), a) // A observed v0 + // B never observed → createIfAbsent; A still holds v0 → replaceIfVersion. + expect(await writeExpectation(ctx, target('a.txt'), b)).toEqual({ kind: 'createIfAbsent' }) + expect(await writeExpectation(ctx, target('a.txt'), a)).toEqual({ kind: 'replaceIfVersion', version: 'v0' }) }) }) -describe('disposal releases recorded state', () => { - it('a fresh service after disposal starts with no inherited state', async () => { - const ctx = new Context() - await ctx.plugin(FakeFs) - const fs = ctx.fs as FakeFs - const fiber = await ctx.plugin(FileContext) +describe('single-slot, first-wins', () => { + it('fully decides the slot without calling next() (the bare default is unreached)', async () => { + const { ctx } = await setup() + let defaultRan = false + const expectation = await ctx.waterfall('fs/write-expectation', target('a.txt'), ownerExec({}), () => { + defaultRan = true + return undefined + }) + expect(expectation).toEqual({ kind: 'createIfAbsent' }) + expect(defaultRan).toBe(false) + }) + + it('a SECOND decider registered AFTER file-context is not reached (first-wins short-circuit)', async () => { + const { ctx } = await setup() + let secondRan = false + // Registered after file-context, so it dispatches second; file-context does + // not call next(), so this never runs. (A decider registered BEFORE — or with + // prepend — would instead win: first-wins is by convention, not enforced.) + ctx.on('fs/edit-expectation', () => { + secondRan = true + return Promise.resolve(undefined) + }) const exec = ownerExec({}) - fs.files.set('a.txt', 'hello') - await ctx.fileContext.read(await fs.resolve('a.txt'), READ_ALL, exec) + ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec) + await editExpectation(ctx, target('a.txt'), exec) + 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(FileContext) + ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec) + expect(await editExpectation(ctx, target('a.txt'), exec)).toEqual({ version: 'v0' }) await fiber.dispose() await ctx.plugin(FileContext) - const target = await fs.resolve('a.txt') // Same owner object, but state was released on disposal. - await expect(ctx.fileContext.edit(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, exec)) - .rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + await expect(editExpectation(ctx, target('a.txt'), exec)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) + + it('no listeners remain after disposal (the gate no longer decides)', async () => { + const ctx = new Context() + const fiber = await ctx.plugin(FileContext) + await fiber.dispose() + // With no listener, the waterfall falls through to the bare default. + expect(await writeExpectation(ctx, target('a.txt'), ownerExec({}))).toBeUndefined() }) }) diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index a4bb087aea..60ad805938 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -6,17 +6,17 @@ The **local-filesystem implementation** of the `ctx.fs` provider seam ([`@deepse import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) -// ctx.fs is now the local backend; load @deepseek-ai/dsh-file-context for policy -// and @deepseek-ai/dsh-tool-fs to expose read/write/edit to the model. +// ctx.fs is now the local backend; load @deepseek-ai/dsh-file-context for the +// freshness policy gate and @deepseek-ai/dsh-tool-fs to expose read/write/edit. ``` ## Behavior - **`resolve(path)`** — relative paths resolve from `config.cwd` (default `process.cwd()`). 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 policy layer (`ctx.fileContext`) 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`. Honors the `FsWriteExpectation`: `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. Verifies the expected version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content), 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`). +- **`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 diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index 3c8ba61f78..9f769bc7cf 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -120,7 +120,7 @@ export class LocalFileSystem extends FileSystem { override async writeText( target: FsTarget, content: string, - expected: FsWriteExpectation, + expected?: FsWriteExpectation, signal?: AbortSignal, ): Promise { return this.withLock(target.targetKey, async () => { @@ -129,16 +129,19 @@ export class LocalFileSystem extends FileSystem { throw new FsError(`cannot write "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') } - if (expected.kind === 'replaceIfVersion') { + 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 (existing) { + } 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) @@ -152,16 +155,21 @@ export class LocalFileSystem extends FileSystem { override async editText( target: FsTarget, edit: FsEditRequest, - expected: { version: FsVersion }, + expected?: { version: FsVersion }, signal?: AbortSignal, ): Promise { 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') - if (existing.version !== expected.version) { + // 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') } diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts index 20c5cfa21f..4119188f1b 100644 --- a/packages/fs/fs-local/tests/filesystem.spec.ts +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -144,6 +144,26 @@ describe('writeText', () => { .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' }) @@ -173,6 +193,28 @@ describe('editText', () => { .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') diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md index 1599ac25cc..fd2307dec5 100644 --- a/packages/fs/fs/README.md +++ b/packages/fs/fs/README.md @@ -1,14 +1,14 @@ # @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 guarded literal edit — without saying HOW. +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), and [the split-the-filesystem-seam RFC](../../../docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md)): +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 | `@deepseek-ai/dsh-tool-fs` | model-facing `read`/`write`/`edit` schemas + text rendering | -| policy | `@deepseek-ai/dsh-file-context` | `ctx.fileContext`: observed-state, read windowing, write/edit freshness | -| provider seam | `@deepseek-ai/dsh-fs` (this) | `ctx.fs`: text IO + guarded mutation primitives | +| tool / executor | `@deepseek-ai/dsh-tool-fs` | model-facing `read`/`write`/`edit` schemas + read windowing + text rendering; reads/writes/edits via `ctx.fs`, dispatches the `fs/*` events | +| policy | `@deepseek-ai/dsh-file-context` | observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate (no service) | +| 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. @@ -23,15 +23,22 @@ A backend subclasses `FileSystem` and implements six primitives. | `stat(target, signal?)` | Return `FsInfo` metadata (`version`, `type`, optional `size`), or `undefined` when the target is absent. Never content. | | `readText(target, signal?)` | Read the whole regular text file as one decoded string. Owns regular-file checks, UTF-8 decoding, binary/NUL rejection (`FS_NOT_TEXT`). | | `streamText(target, signal?)` | Stream the same text as decoded chunks for large files (cross-chunk UTF-8 decoding stays here). | -| `writeText(target, content, expected, signal?)` | Atomic create/replace honoring the `FsWriteExpectation` (`createIfAbsent` or `replaceIfVersion`). | -| `editText(target, edit, expected, signal?)` | Version-guarded literal edit. Verifies `expected.version` BEFORE matching, then applies the replacement and writes atomically — one mutation critical section. | +| `writeText(target, content, expected?, signal?)` | Atomic create/replace. `expected` is OPTIONAL: omit ⇒ unconditional create-or-overwrite; supply an `FsWriteExpectation` (`createIfAbsent`/`replaceIfVersion`) to guard. | +| `editText(target, edit, expected?, signal?)` | Literal edit. `expected` is OPTIONAL: omit ⇒ unconditional edit of the current content; supply `{ version }` to guard (verified BEFORE matching). A missing target reports `FS_STALE_VERSION` either way. Applies and writes atomically — one mutation critical section. | + +The mutation runs inside the backend's per-target lock either way, so an unconditional write/edit is still atomic — "unconditional" drops the *version* precondition, not the atomicity. + +## The `fs/*` policy events + +This package declares three events (see the generated [catalog](../../../docs/cordis-catalog/events-and-services.md)) so the emitter (`@deepseek-ai/dsh-tool-fs`) and the policy listener (`@deepseek-ai/dsh-file-context`) share a vocabulary without the emitter depending on the policy plugin. `fs/write-expectation` and `fs/edit-expectation` are single-slot decision waterfalls (the listener fully decides, never calling `next()`); `fs/observed` is a fire-and-forget recording event. They carry only `dsh-fs` vocabulary plus an opaque `object` actor — no model-facing concepts and no agent/session owner structure. ## 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 version-guarded literal-edit critical section. It does **not** own line windows, numbered lines, rendered footers, or observed-state — those model-facing read-windowing and read-before-write/edit policies live one layer up in `ctx.fileContext` ([`@deepseek-ai/dsh-file-context`](../file-context)), so a sandboxed/remote backend inherits no model-facing observation policy. +`ctx.fs` is deliberately close to fsspec-style storage primitives — half a level above byte-level `cat`/`open`, because it decodes text and rejects binaries so the policy layer never touches raw bytes. It owns UTF-8 decoding, binary rejection, atomic writes, and the literal-edit critical section. It does **not** own line windows, numbered lines, rendered footers, or observed-state. Observed-state, read-before-edit, and version-guarded write/edit are policy a plugin (`@deepseek-ai/dsh-file-context`) ADDS by supplying the optional guard — not provider behavior — so a sandboxed/remote backend inherits no model-facing observation policy. `editText` stays on this seam (not composed in the policy layer from a read plus a write) because version guard + literal match + atomic rewrite must stay inside one critical section for correct error attribution and one-wins/one-stale concurrency, and a remote backend may implement it as a native compare-and-edit. ## Vocabulary -`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids RFC](../../../docs/rfc/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsWriteExpectation` is the explicit 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`). Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy RFC](../../../docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts. +`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids RFC](../../../docs/rfc/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsWriteExpectation` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy RFC](../../../docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts. + diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts index 70675b01c2..f23eae98fb 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -17,12 +17,12 @@ * * `ctx.fs` is deliberately close to fsspec-style storage primitives. It owns * UTF-8 decoding, binary/NUL rejection, atomic full-file writes, and the - * version-guarded literal-edit critical section — but NOT line windows, - * numbered lines, rendered footers, or observed-state. Those model-facing - * read-windowing and read-before-write/edit policies live one layer up in the - * concrete `ctx.fileContext` service (`@deepseek-ai/dsh-file-context`), so a - * sandboxed/remote backend inherits no model-facing observation policy it has - * no business carrying. + * literal-edit critical section — but NOT line windows, numbered lines, + * rendered footers, or observed-state. Read windowing lives in the model-facing + * tool (`@deepseek-ai/dsh-tool-fs`); observed-state and read-before-write/edit + * are policy a plugin (`@deepseek-ai/dsh-file-context`) adds through the `fs/*` + * 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 @@ -30,6 +30,30 @@ * 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-file-context`) adds on top by supplying the guard. + * + * ## The fs policy events live here, not in the policy plugin + * + * This package owns the `fs/write-expectation`, `fs/edit-expectation`, and + * `fs/observed` event vocabulary (see {@link Events}). The emitter is + * `@deepseek-ai/dsh-tool-fs` and the default listener is + * `@deepseek-ai/dsh-file-context`; the events live in the one package both + * 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 */ @@ -63,6 +87,47 @@ declare module 'cordis' { interface Context { fs: FileSystem } + + interface Events { + /** + * Single-slot decision: produce the write expectation for the next + * {@link FileSystem.writeText}. The tool dispatches this as an unbound + * waterfall (no `this`) and supplies a default thunk returning `undefined` + * (unconditional create-or-overwrite — the bare provider). The + * `@deepseek-ai/dsh-file-context` policy listener returns `createIfAbsent` + * (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-expectation'(target: FsTarget, actor: object | undefined, next: () => FsWriteExpectation | undefined | Promise): Promise + /** + * Single-slot decision: produce the optional version guard for the next + * {@link FileSystem.editText}. The tool dispatches this as an unbound + * waterfall and supplies a default thunk returning `undefined` (unconditional + * edit of the current content — the bare provider; no `stat`). The + * `@deepseek-ai/dsh-file-context` policy listener returns + * `{ version: vObserved }`, or throws `FS_NOT_OBSERVED` if the actor is unset + * or has not observed the target. Does NOT call `next()`: one decision, + * first-wins (see {@link Events.'fs/write-expectation'}). + * @mode waterfall + */ + 'fs/edit-expectation'(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. A listener MUST be a synchronous, + * side-effect-only recorder (`@deepseek-ai/dsh-file-context`'s is a + * `WeakMap.set`); the tool wraps the emit in a try/catch so a synchronous + * listener bug is logged and swallowed, never failing the already-completed + * mutation. cordis `emit` does not await listener promises, so this is not an + * async-error containment seam — async 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 + } } /** @@ -80,12 +145,15 @@ declare module 'cordis' { * - {@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 honoring the - * {@link FsWriteExpectation}. + * - {@link writeText} is atomic temp-file + rename. `expected` is OPTIONAL: + * omit it for an unconditional create-or-overwrite (the bare-provider default), + * or supply a {@link FsWriteExpectation} to guard the write. * - {@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. + * 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) { @@ -115,17 +183,21 @@ export abstract class FileSystem extends Service { abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> /** - * Create or fully replace a UTF-8 text file atomically, honoring `expected` - * as the create-vs-replace decision and stale guard. + * 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: FsWriteExpectation, signal?: AbortSignal): Promise + abstract writeText(target: FsTarget, content: string, expected?: FsWriteExpectation, signal?: AbortSignal): Promise /** - * Apply a literal edit to an existing UTF-8 text file. Verifies - * `expected.version` as the stale guard BEFORE literal matching, then applies - * the replacement and writes atomically — one mutation critical section. + * 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 + abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise } export default FileSystem diff --git a/packages/fs/fs/src/types.ts b/packages/fs/fs/src/types.ts index 62ba52b52f..258c7a1e8b 100644 --- a/packages/fs/fs/src/types.ts +++ b/packages/fs/fs/src/types.ts @@ -13,7 +13,8 @@ * consumer may show. * * Model-facing concepts (line windows, numbered lines, observed-state) do NOT - * live here; they belong to the policy layer (`ctx.fileContext`). + * live here; they belong to the consumer tool and the policy plugin + * (`@deepseek-ai/dsh-tool-fs` / `@deepseek-ai/dsh-file-context`). * * @module @deepseek-ai/dsh-fs/types */ @@ -78,11 +79,17 @@ export interface FsInfo { } /** - * The explicit intent of a {@link FileSystem.writeText} call. `createIfAbsent` - * creates a missing target and rejects an existing one with `FS_NOT_OBSERVED` - * (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 a - * version mismatch throws `FS_STALE_VERSION`. + * 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 FsWriteExpectation = | { kind: 'createIfAbsent' } diff --git a/packages/fs/fs/tests/service.spec.ts b/packages/fs/fs/tests/service.spec.ts index e06cfa9ee6..7091746dc3 100644 --- a/packages/fs/fs/tests/service.spec.ts +++ b/packages/fs/fs/tests/service.spec.ts @@ -38,7 +38,7 @@ class FakeFileSystem extends FileSystem { const content = await this.readText(target) return (async function* () { yield content })() } - override async writeText(target: FsTarget, content: string, _expected: FsWriteExpectation): Promise { + override async writeText(target: FsTarget, content: string, _expected?: FsWriteExpectation): Promise { const existed = this.files.has(target.targetKey) this.files.set(target.targetKey, content) return { operation: existed ? 'update' : 'create', version: FsVersion('v2') } diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index f751ecb051..e3e8a1cdff 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -1,15 +1,17 @@ # @deepseek-ai/dsh-tool-fs -The **model-facing filesystem tools** — `read`, `write`, `edit` — over the `ctx.fileContext` policy layer ([`@deepseek-ai/dsh-file-context`](../file-context)). This is the consumer layer of the filesystem stack; it owns tool names, JSON schemas, argument validation, prompt sections, and result formatting, and **never** touches filesystem I/O (no `node:fs`/`node:path`, no implementation import) or reaches around the policy layer to `ctx.fs`. +The **model-facing filesystem tools** — `read`, `write`, `edit` — and their **executor**. This is the consumer layer of the filesystem stack: it owns tool names, JSON schemas, argument validation, prompt sections, **read windowing**, and result formatting. It reads/writes/edits through the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)) **directly** — it injects `fs` (plus `tools`/`systemPrompt`), **not** a policy service. The freshness/observation policy is contributed by a separate plugin ([`@deepseek-ai/dsh-file-context`](../file-context)) through the `fs/*` event gate; the tool is not method-coupled to it. ```ts ignore-check -// Load a ctx.fs provider, the policy layer, then the tools. +// Default deployment: a ctx.fs provider, the policy plugin, then the tools. await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) // @deepseek-ai/dsh-fs-local -await ctx.plugin(FileContext) // @deepseek-ai/dsh-file-context +await ctx.plugin(FileContext) // @deepseek-ai/dsh-file-context (policy gate) await ctx.plugin(ToolFs) // this package — registers read/write/edit ``` -Each tool also ships as a subpath plugin for focused deployments: +`@deepseek-ai/dsh-file-context` is **optional**: omit it and the tools run against the bare provider (unconditional write/overwrite/edit, no observed-state). The default product config loads it, so the default behavior stays read-before-write/edit. + +Each tool also ships as a subpath plugin for focused deployments (each injects `fs`, not a policy service): ```ts ignore-check import * as readPlugin from '@deepseek-ai/dsh-tool-fs/read' @@ -22,17 +24,23 @@ import * as editPlugin from '@deepseek-ai/dsh-tool-fs/edit' | Tool | Arguments | Behavior | |---|---|---| | `read` | `file_path`, `offset?`, `limit?` | Line-numbered UTF-8 content with a pagination footer. `offset` is 1-based; `limit` defaults to and caps at 2000 lines. | -| `write` | `file_path`, `content` | Create or fully replace a file. Overwriting an existing file requires a prior `read` at the unchanged version; creating a new file does not. | -| `edit` | `file_path`, non-empty `old_string`, `new_string`, `replace_all?` | Literal replacement; unique match required unless `replace_all` is true. Requires a prior `read` (any window) and the file unchanged since. | +| `write` | `file_path`, `content` | Create or fully replace a file. With the policy plugin: overwriting an existing file requires a prior `read` at the unchanged version; creating a new file does not. Without it: unconditional. | +| `edit` | `file_path`, non-empty `old_string`, `new_string`, `replace_all?` | Literal replacement; unique match required unless `replace_all` is true. With the policy plugin: requires a prior `read` (any window) and the file unchanged since. Without it: unconditional. | Field names are snake_case to match Claude Code and existing harness tool schemas. -## How the read-before-write/edit policy is enforced +## The tool is the executor; policy is an event gate -The tools do **not** check whether a `read` ran or inspect any cache. Each tool resolves the path via `ctx.fileContext.resolve()`, then calls `ctx.fileContext.read/write/edit(target, …, exec)` — passing the current tool execution context straight through. `ctx.fileContext` derives the observed-state owner (normally the agent session) from that context and owns the freshness policy: a recorded read at the file's current version authorizes a write/edit, and any windowed read counts (authorization is freshness, not a full-view requirement). Backend errors (`FsError`) flow through `ToolRegistry.execute()` and become `isError` tool results with their `{ name, code }` attached. +The tools do **not** inject a policy service or inspect any cache. Each tool resolves the path via `ctx.fs.resolve()`, then: -## The no-bypass contract +- **read** — one `ctx.fs.stat` (type + size routing + version), then `readText`/`streamText`, then builds the line window, then emits a contained `fs/observed`. (1 stat.) +- **write** — `ctx.waterfall('fs/write-expectation', target, exec, () => undefined)` for the optional guard, then `ctx.fs.writeText(target, content, expectation)`, then `fs/observed`. (0 stat.) +- **edit** — `ctx.waterfall('fs/edit-expectation', target, exec, () => undefined)` for the optional guard, then `ctx.fs.editText(target, edit, expectation)`, then `fs/observed`. (0 stat.) -A model-facing read MUST go through `ctx.fileContext.read`, never `ctx.fs.readText`/`streamText`, so every successful read records observed-state before rendering — which is why the tools inject `fileContext`, not `fs`. Direct `ctx.fs` calls remain an explicit escape hatch for non-tool consumers: a direct `ctx.fs.readText` records nothing, so a later `edit` rejects with `FS_NOT_OBSERVED` until the file is read through `ctx.fileContext`. +The tool passes `exec` (the tool-execution context) as the opaque `actor` on every dispatch. The default thunks return `undefined` (the unconstrained bare provider). When `@deepseek-ai/dsh-file-context` is loaded it occupies the single decision slot — returning `createIfAbsent`/`replaceIfVersion`/`{ version }` or throwing `FS_NOT_OBSERVED` — and records on `fs/observed`. Backend errors (`FsError`) and a thrown `FS_NOT_OBSERVED` flow through `ToolRegistry.execute()` and become `isError` tool results with their `{ name, code }` attached. -Tool schemas reach the system prompt automatically via the tool registry; this package additionally registers short prose guidance through `ctx.systemPrompt.section(...)`. +## `fs/observed` never fails the tool + +`fs/observed` fires AFTER the read/write/edit already succeeded, so the tool wraps the emit in a try/catch (`src/observe.ts`) that logs and swallows a synchronous listener bug — otherwise a recording failure would turn a completed mutation into an `isError`. The event contract requires synchronous, side-effect-only listeners; this is the synchronous backstop, not async-error handling. + +The line-windowing mechanics live in `src/window.ts` (Cordis-free, independently unit-tested); `src/read.ts`/`write.ts`/`edit.ts` are the tool executors and `src/index.ts` composes them. diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index f158142fca..801712bf1c 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -32,7 +32,6 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-file-context": "^0.0.1", "@deepseek-ai/dsh-fs": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index d54fe3045b..5d70310502 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -1,8 +1,14 @@ /** * The model-facing `edit` tool: update an existing UTF-8 text file by replacing - * literal text, requiring a unique match by default. Execution goes through - * `ctx.fileContext`, which enforces prior observation (the freshness policy) - * and delegates the literal-match + stale-guard critical section to `ctx.fs`. + * literal text, requiring a unique match by default. The tool is the executor: + * it dispatches the `fs/edit-expectation` waterfall to obtain the optional + * version guard, calls `ctx.fs.editText` directly, and emits a contained + * `fs/observed`. The default thunk returns `undefined` (unconditional edit of + * the current content — the bare provider); a policy plugin + * (`@deepseek-ai/dsh-file-context`) occupies the single decision slot, returning + * `{ version: vObserved }` or throwing `FS_NOT_OBSERVED` for an unread file. The + * tool stats ZERO times either way; a missing target is reported by the provider + * as `FS_STALE_VERSION`. * * @module @deepseek-ai/dsh-tool-fs/edit */ @@ -11,7 +17,9 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { FsEditOutcome } from '@deepseek-ai/dsh-fs' +import type {} from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-system-prompt' +import { emitObserved } from './observe.ts' /** Validated `edit` arguments after defaulting. */ interface EditInput { @@ -60,13 +68,18 @@ export function apply(ctx: Context): void { }, async execute(args, exec): Promise { const input = parseEditArgs(args) - const target = await ctx.fileContext.resolve(input.filePath) - const outcome = await ctx.fileContext.edit( + const target = await ctx.fs.resolve(input.filePath) + // Single-slot decision: the policy plugin returns { version: vObserved } or + // throws FS_NOT_OBSERVED; the bare default is undefined (unconditional edit). + // No stat — the bare default never manufactures a version basis. + const expectation = await ctx.waterfall('fs/edit-expectation', target, exec, () => undefined) + const outcome = await ctx.fs.editText( target, { oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll }, - exec, + expectation, exec.signal, ) + emitObserved(ctx, target, outcome.version, exec) return [{ type: 'text', text: formatEditOutput(target.displayPath, outcome) }] }, })) @@ -76,7 +89,7 @@ export function apply(ctx: Context): void { export const name = 'fs-edit' /** Services required by the `edit` tool plugin. */ -export const inject = ['tools', 'fileContext', 'systemPrompt'] +export const inject = ['tools', 'fs', 'systemPrompt'] /** Named helper for direct registration in the root plugin and tests. */ export const applyEditTool = apply diff --git a/packages/fs/tool-fs/src/index.ts b/packages/fs/tool-fs/src/index.ts index 5509c7980b..b57810185e 100644 --- a/packages/fs/tool-fs/src/index.ts +++ b/packages/fs/tool-fs/src/index.ts @@ -1,15 +1,24 @@ /** * The model-facing filesystem tool suite (`read`, `write`, `edit`) over the - * `ctx.fileContext` policy layer. This root plugin registers all three tools by + * `ctx.fs` provider seam. This root plugin registers all three tools by * composing the per-tool registration helpers; each tool is also exposed as a * subpath plugin (`@deepseek-ai/dsh-tool-fs/read`, `/write`, `/edit`) for focused * deployments. * - * The package owns model-facing concerns only — tool names, JSON schemas, - * argument validation, prompt sections, result formatting. All filesystem - * execution goes through `ctx.fileContext` (never directly around it to - * `ctx.fs`), so every model read records observed-state before rendering; this - * package never imports `node:fs`, `node:path`, or an + * ## The tool is the executor; policy is an event gate + * + * The tool reads/writes/edits through `ctx.fs` DIRECTLY and owns model-facing + * concerns only — tool names, JSON schemas, argument validation, prompt + * sections, read windowing, result formatting. It does NOT inject a policy + * service. Instead, on each write/edit it dispatches a single-slot waterfall + * (`fs/write-expectation`/`fs/edit-expectation`) to obtain the OPTIONAL version + * guard, and after every read/write/edit it emits a contained `fs/observed`. A + * policy plugin (`@deepseek-ai/dsh-file-context`, loaded by the default product + * config) occupies the decision slot and listens for `fs/observed` to add + * observed-state + read-before-edit + version-guarded write/edit. With no policy + * plugin the waterfalls fall through to their `undefined` default (the + * unconstrained bare provider) and `fs/observed` is unheard — the tool still + * functions. This package never imports `node:fs`, `node:path`, or an * `@deepseek-ai/dsh-fs-local` implementation. * * @module @deepseek-ai/dsh-tool-fs @@ -20,15 +29,19 @@ import { applyReadTool } from './read.ts' import { applyWriteTool } from './write.ts' import { applyEditTool } from './edit.ts' -export { READ_LIMIT, applyReadTool, formatReadOutput, parseReadArgs } from './read.ts' +export { READ_LIMIT, STREAM_MIN_SIZE, applyReadTool, formatReadOutput, parseReadArgs } from './read.ts' export { applyWriteTool, formatWriteOutput, parseWriteArgs } from './write.ts' export { applyEditTool, formatEditOutput, parseEditArgs } from './edit.ts' +export { emitObserved } from './observe.ts' +export type { FileTextLine, ReadWindow, WindowResult } from './window.ts' +export { READ_MAX_BYTES, READ_MAX_LINE_LENGTH, buildWindow } from './window.ts' +export type { FileReadOutcome } from './types.ts' /** Cordis plugin name used by loader diagnostics. */ export const name = 'tool-fs' /** Services required by the filesystem tool suite. */ -export const inject = ['tools', 'fileContext', 'systemPrompt'] +export const inject = ['tools', 'fs', 'systemPrompt'] /** Register the full `read`/`write`/`edit` filesystem tool suite. */ export function apply(ctx: Context): void { diff --git a/packages/fs/tool-fs/src/observe.ts b/packages/fs/tool-fs/src/observe.ts new file mode 100644 index 0000000000..407dc66fbb --- /dev/null +++ b/packages/fs/tool-fs/src/observe.ts @@ -0,0 +1,34 @@ +/** + * The contained `fs/observed` emit shared by the `read`/`write`/`edit` tools. + * + * `fs/observed` fires AFTER a mutation/read already succeeded, so a throwing + * listener must never turn the completed operation into an `isError` result + * (the tool registry catches a tool throw into an error result). The event + * contract requires a synchronous, side-effect-only listener (the policy + * plugin's is a `WeakMap.set`); this try/catch is the synchronous backstop — + * it logs and swallows a listener bug, mirroring the fire-and-forget pattern in + * the agent loop. It is NOT async-error containment: cordis `emit` does not + * await listener promises, so async observation does not belong on this event. + * + * @module @deepseek-ai/dsh-tool-fs/observe + */ + +import type { Context } from 'cordis' +import type { FsTarget, FsVersion } from '@deepseek-ai/dsh-fs' +import type {} from '@deepseek-ai/dsh-fs' + +/** + * Emit `fs/observed` for a just-completed read/write/edit, containing any + * synchronous listener throw so the already-successful operation still reports + * success. + */ +export function emitObserved(ctx: Context, target: FsTarget, version: FsVersion, actor: object | undefined): void { + try { + ctx.emit('fs/observed', target, version, actor) + } catch (error: unknown) { + // Contained: the read/write/edit already succeeded. An `fs/observed` listener + // MUST be synchronous and side-effect-only; a synchronous bug is logged and + // swallowed so a recording failure never fails the completed operation. + ctx.logger.warn(`fs/observed listener threw for "${target.displayPath}": ${String(error)}`) + } +} diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index 8a6ae8d609..bc12068553 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -1,9 +1,12 @@ /** * The model-facing `read` tool: inspect a UTF-8 text file and return - * line-numbered content with pagination guidance. Execution goes through - * `ctx.fileContext` (which records observed state and owns read windowing) — - * this module owns only the model-facing schema, argument validation, and - * result formatting, never filesystem I/O. + * line-numbered content with pagination guidance. The tool is the executor — it + * stats and reads through `ctx.fs` directly, builds the line window + * ({@link module:@deepseek-ai/dsh-tool-fs/window}), and emits a contained + * `fs/observed` so a policy plugin (`@deepseek-ai/dsh-file-context`) can record + * the read. With no policy plugin the emit is simply unheard. This module owns + * the model-facing schema, argument validation, read windowing, and result + * formatting; the freshness/observation policy is not its concern. * * @module @deepseek-ai/dsh-tool-fs/read */ @@ -11,12 +14,19 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { FileReadOutcome } from '@deepseek-ai/dsh-file-context' +import { FsError } from '@deepseek-ai/dsh-fs' +import type {} from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-system-prompt' +import { buildWindow } from './window.ts' +import { emitObserved } from './observe.ts' +import type { FileReadOutcome } from './types.ts' /** Default and maximum number of lines returned by one `read` call. */ export const READ_LIMIT = 2000 +/** Files at or above this size stream; smaller files read whole into memory. */ +export const STREAM_MIN_SIZE = 10 * 1024 * 1024 + /** Validated `read` arguments after defaulting. */ interface ReadInput { filePath: string @@ -79,8 +89,32 @@ export function apply(ctx: Context): void { }, async execute(args, exec): Promise { const input = parseReadArgs(args) - const target = await ctx.fileContext.resolve(input.filePath) - const outcome = await ctx.fileContext.read(target, { offset: input.offset, limit: input.limit }, exec, exec.signal) + const target = await ctx.fs.resolve(input.filePath) + + // One stat: type check + size routing + the version recorded as observed. + // A writer racing between this stat and the read can at worst make a LATER + // guarded edit spuriously FS_STALE_VERSION (fail-closed: re-read; editText + // re-checks the version in its lock). + const info = await ctx.fs.stat(target, exec.signal) + if (!info) throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND') + if (info.type !== 'file') throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') + + // Stream when the file is large OR size is unknown, so a size-less backend + // never buffers an arbitrarily large file. + const chunks = info.size === undefined || info.size >= STREAM_MIN_SIZE + ? await ctx.fs.streamText(target, exec.signal) + : [await ctx.fs.readText(target, exec.signal)] + const window = await buildWindow(chunks, { offset: input.offset, limit: input.limit }, target.displayPath) + + const outcome: FileReadOutcome = { + offset: input.offset, + limit: input.limit, + lines: window.lines, + totalLines: window.totalLines, + version: info.version, + ...window.truncatedByBytes ? { truncatedByBytes: true } : {}, + } + emitObserved(ctx, target, info.version, exec) return [{ type: 'text', text: formatReadOutput(target.displayPath, outcome) }] }, })) @@ -90,7 +124,7 @@ export function apply(ctx: Context): void { export const name = 'fs-read' /** Services required by the `read` tool plugin. */ -export const inject = ['tools', 'fileContext', 'systemPrompt'] +export const inject = ['tools', 'fs', 'systemPrompt'] /** Named helper for direct registration in the root plugin and tests. */ export const applyReadTool = apply diff --git a/packages/fs/tool-fs/src/types.ts b/packages/fs/tool-fs/src/types.ts new file mode 100644 index 0000000000..48a0592abb --- /dev/null +++ b/packages/fs/tool-fs/src/types.ts @@ -0,0 +1,32 @@ +/** + * Vocabulary for the model-facing filesystem tools (`@deepseek-ai/dsh-tool-fs`): + * the structured read outcome the `read` tool renders. The read window + * (`offset`/`limit`) and per-line shape live in + * {@link module:@deepseek-ai/dsh-tool-fs/window}; this file owns the assembled + * outcome the tool formats. + * + * The provider vocabulary (`FsTarget`, `FsVersion`, write/edit shapes) is + * re-used from `@deepseek-ai/dsh-fs` — this package owns only the model-facing + * read-rendering shape on top of it. + * + * @module @deepseek-ai/dsh-tool-fs/types + */ + +import type { FsVersion } from '@deepseek-ai/dsh-fs' +import type { FileTextLine } from './window.ts' + +/** Outcome of a bounded text read — what the model-facing `read` tool renders. */ +export interface FileReadOutcome { + /** 1-based first line requested. */ + offset: number + /** Maximum number of lines requested. */ + limit: number + /** Returned lines, already numbered. */ + lines: FileTextLine[] + /** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */ + totalLines: number + /** Whether selected output hit the byte cap before EOF or the requested limit. */ + truncatedByBytes?: true + /** Opaque version of the file at read time. */ + version: FsVersion +} diff --git a/packages/fs/file-context/src/window.ts b/packages/fs/tool-fs/src/window.ts similarity index 92% rename from packages/fs/file-context/src/window.ts rename to packages/fs/tool-fs/src/window.ts index 97e51e2ee4..fb33907710 100644 --- a/packages/fs/file-context/src/window.ts +++ b/packages/fs/tool-fs/src/window.ts @@ -1,16 +1,16 @@ /** - * Cordis-free line-windowing for `@deepseek-ai/dsh-file-context`. Relocated - * from the local backend: turning a file's decoded text into a bounded, - * line-numbered window (offset/limit, byte cap, per-line truncation) is - * model-facing READ POLICY, not a storage primitive, so it lives in the policy - * layer rather than in every `ctx.fs` backend. + * Cordis-free line-windowing for `@deepseek-ai/dsh-tool-fs`. Turning a file's + * decoded text into a bounded, line-numbered window (offset/limit, byte cap, + * per-line truncation) is the model-facing READ-RENDERING detail the tool owns + * now that the tool reads through `ctx.fs` directly — it is not a storage + * primitive and not freshness policy. * * The provider (`ctx.fs.readText`/`streamText`) hands back already-decoded text * (UTF-8 validated, binary rejected); this module only scans that text for * newlines and builds the requested window. A capped line buffer means a * newline-free giant line can never balloon memory even when streamed. * - * @module @deepseek-ai/dsh-file-context/window + * @module @deepseek-ai/dsh-tool-fs/window */ import { FsError } from '@deepseek-ai/dsh-fs' diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index 8c242c5256..e2b44ee78a 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -1,8 +1,12 @@ /** - * The model-facing `write` tool: create or fully replace a UTF-8 text file. - * Execution goes through `ctx.fileContext`, which enforces the freshness policy - * (creating a new file needs no prior read; replacing an existing file requires - * a prior read in the same execution context at the unchanged version). + * The model-facing `write` tool: create or fully replace a UTF-8 text file. The + * tool is the executor: it dispatches the `fs/write-expectation` waterfall to + * obtain the optional version guard, calls `ctx.fs.writeText` directly, and + * emits a contained `fs/observed`. The default thunk returns `undefined` + * (unconditional create-or-overwrite — the bare provider); a policy plugin + * (`@deepseek-ai/dsh-file-context`) occupies the single decision slot and + * returns `createIfAbsent`/`replaceIfVersion` instead. The tool stats ZERO + * times either way. * * @module @deepseek-ai/dsh-tool-fs/write */ @@ -11,7 +15,9 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { FsWriteOutcome } from '@deepseek-ai/dsh-fs' +import type {} from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-system-prompt' +import { emitObserved } from './observe.ts' /** Validate value constraints the schema DSL can't express. */ export function parseWriteArgs(args: { file_path: string; content: string }): { filePath: string; content: string } { @@ -34,7 +40,7 @@ export function apply(ctx: Context): void { ctx.systemPrompt.section({ name: 'tool:write', order: 101, - text: 'Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the backend requires it) and prefer edit for targeted changes.', + text: 'Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default file-context policy requires it) and prefer edit for targeted changes.', }) ctx.tools.register(defineTool({ @@ -46,8 +52,12 @@ export function apply(ctx: Context): void { }, async execute(args, exec): Promise { const input = parseWriteArgs(args) - const target = await ctx.fileContext.resolve(input.filePath) - const outcome = await ctx.fileContext.write(target, input.content, exec, exec.signal) + const target = await ctx.fs.resolve(input.filePath) + // Single-slot decision: the policy plugin produces createIfAbsent/ + // replaceIfVersion; the bare default is undefined (unconditional). No stat. + const expectation = await ctx.waterfall('fs/write-expectation', target, exec, () => undefined) + const outcome = await ctx.fs.writeText(target, input.content, expectation, exec.signal) + emitObserved(ctx, target, outcome.version, exec) return [{ type: 'text', text: formatWriteOutput(target.displayPath, outcome) }] }, })) @@ -57,7 +67,7 @@ export function apply(ctx: Context): void { export const name = 'fs-write' /** Services required by the `write` tool plugin. */ -export const inject = ['tools', 'fileContext', 'systemPrompt'] +export const inject = ['tools', 'fs', 'systemPrompt'] /** Named helper for direct registration in the root plugin and tests. */ export const applyWriteTool = apply diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index d61bdb5cac..696741c334 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -1,12 +1,20 @@ /** - * Integration tests: the real local backend (`dsh-fs-local`) plus the real - * policy layer (`dsh-file-context`) plus the model tools (`dsh-tool-fs`), - * exercised through `ctx.tools.execute()` so nothing bypasses the tool registry. + * Integration tests: the real local backend (`dsh-fs-local`) plus the model + * tools (`dsh-tool-fs`) as the executor, exercised through `ctx.tools.execute()` + * so nothing bypasses the tool registry. Two deployments: + * + * - DEFAULT — with the real `dsh-file-context` policy gate plugin: read-before- + * write/edit, version-guarded mutation, FS_NOT_OBSERVED for unread edits. + * - BARE — WITHOUT the policy plugin, loading only SUBPATH plugins: every + * `fs/*` waterfall falls through to its undefined default, so write/edit are + * unconditional. This proves the subpaths (not just the root) carry no policy + * dependency. + * * These verify the WORLD — files are read back from disk and asserted * byte-for-byte — not the tool's self-report. */ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -15,8 +23,11 @@ import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' -import FileContext from '@deepseek-ai/dsh-file-context' +import * as FileContext from '@deepseek-ai/dsh-file-context' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' +import * as readPlugin from '@deepseek-ai/dsh-tool-fs/read' +import * as writePlugin from '@deepseek-ai/dsh-tool-fs/write' +import * as editPlugin from '@deepseek-ai/dsh-tool-fs/edit' let dir: string let ctx: Context @@ -24,20 +35,6 @@ let fiber: Awaited> // A stable session object stands in for an agent session (the file-state owner). const session = {} -beforeEach(async () => { - dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-')) - ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(LocalFileSystem, { cwd: dir }) - await ctx.plugin(FileContext) - fiber = await ctx.plugin(ToolFs) -}) -afterEach(async () => { - await fiber.dispose() - await rm(dir, { recursive: true, force: true }) -}) - let callCounter = 0 function call(name: string, args: unknown) { return ctx.tools.execute({ @@ -52,137 +49,260 @@ function text(result: { content: { type: string; text?: string }[] }): string { return result.content.filter(b => b.type === 'text').map(b => b.text).join('') } -describe('write → disk', () => { - it('creates a file with exactly the requested bytes', async () => { - const result = await call('write', { file_path: 'new.txt', content: 'line one\nline two\n' }) - expect(result.isError).toBe(false) - expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('line one\nline two\n') +afterEach(async () => { + await fiber.dispose() + await rm(dir, { recursive: true, force: true }) +}) + +// -------------------------------------------------------------------------- +// DEFAULT deployment: the policy gate plugin is loaded. +// -------------------------------------------------------------------------- +describe('default deployment (with dsh-file-context)', () => { + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-')) + ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalFileSystem, { cwd: dir }) + await ctx.plugin(FileContext) + fiber = await ctx.plugin(ToolFs) }) - it('rejects overwriting an existing file without reading it first', async () => { - await writeFile(join(dir, 'a.txt'), 'original') - const result = await call('write', { file_path: 'a.txt', content: 'clobber' }) - expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' }) - expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('original') + describe('write → disk', () => { + it('creates a file with exactly the requested bytes', async () => { + const result = await call('write', { file_path: 'new.txt', content: 'line one\nline two\n' }) + expect(result.isError).toBe(false) + expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('line one\nline two\n') + }) + + it('rejects overwriting an existing file without reading it first', async () => { + await writeFile(join(dir, 'a.txt'), 'original') + const result = await call('write', { file_path: 'a.txt', content: 'clobber' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' }) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('original') + }) + + it('allows overwriting after a read', async () => { + await writeFile(join(dir, 'a.txt'), 'original') + expect((await call('read', { file_path: 'a.txt' })).isError).toBe(false) + const result = await call('write', { file_path: 'a.txt', content: 'replaced' }) + expect(result.isError).toBe(false) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('replaced') + }) + + it('rejects a full overwrite when the file changed since the read (stale)', async () => { + await writeFile(join(dir, 'a.txt'), 'original') + await call('read', { file_path: 'a.txt' }) + await writeFile(join(dir, 'a.txt'), 'changed-externally') // out-of-band change + const result = await call('write', { file_path: 'a.txt', content: 'replaced' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' }) + }) }) - it('allows overwriting after a read', async () => { - await writeFile(join(dir, 'a.txt'), 'original') - expect((await call('read', { file_path: 'a.txt' })).isError).toBe(false) - const result = await call('write', { file_path: 'a.txt', content: 'replaced' }) - expect(result.isError).toBe(false) - expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('replaced') + describe('read', () => { + it('returns line-numbered content', async () => { + await writeFile(join(dir, 'a.txt'), 'alpha\nbeta') + const result = await call('read', { file_path: 'a.txt' }) + expect(text(result)).toContain('1: alpha') + expect(text(result)).toContain('2: beta') + expect(text(result)).toContain('(End of file - total 2 lines)') + }) + + it('reports a binary file as an error', async () => { + await writeFile(join(dir, 'bin'), Buffer.from([0x00, 0x01, 0x02])) + const result = await call('read', { file_path: 'bin' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ code: 'FS_NOT_TEXT' }) + }) + + it('paginates a multi-line file with offset/limit', async () => { + await writeFile(join(dir, 'a.txt'), 'one\ntwo\nthree\nfour') + const result = await call('read', { file_path: 'a.txt', offset: 2, limit: 2 }) + expect(text(result)).toContain('2: two') + expect(text(result)).toContain('3: three') + expect(text(result)).toContain('(Showing lines 2-3 of 4. Use offset=4 to continue.)') + }) }) - it('rejects a full overwrite when the file changed since the read (stale)', async () => { - await writeFile(join(dir, 'a.txt'), 'original') - await call('read', { file_path: 'a.txt' }) - await writeFile(join(dir, 'a.txt'), 'changed-externally') // out-of-band change - const result = await call('write', { file_path: 'a.txt', content: 'replaced' }) - expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' }) + describe('edit → disk', () => { + it('applies a unique literal replacement after a read', async () => { + await writeFile(join(dir, 'a.txt'), 'hello world') + await call('read', { file_path: 'a.txt' }) + const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) + expect(result.isError).toBe(false) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there') + }) + + it('rejects an edit before any read, leaving the file untouched', async () => { + await writeFile(join(dir, 'a.txt'), 'hello world') + const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' }) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello world') + }) + + it('lets a WINDOWED read authorize an edit when the file is unchanged (freshness, not full-view)', async () => { + // A file with more lines than the read window; read only the first line. + const lines = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`) + await writeFile(join(dir, 'a.txt'), lines.join('\n')) + const read = await call('read', { file_path: 'a.txt', offset: 1, limit: 1 }) + expect(read.isError).toBe(false) + expect(text(read)).toContain('(Showing lines 1-1 of 20') + + // Editing a line OUTSIDE the window is authorized because the file is unchanged. + const result = await call('edit', { file_path: 'a.txt', old_string: 'line 12', new_string: 'LINE 12' }) + expect(result.isError).toBe(false) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe(lines.map(l => l === 'line 12' ? 'LINE 12' : l).join('\n')) + }) + + it('rejects an edit when the file changed since the windowed read (stale before matching)', async () => { + await writeFile(join(dir, 'a.txt'), 'hello world') + await call('read', { file_path: 'a.txt', offset: 1, limit: 1 }) + await writeFile(join(dir, 'a.txt'), 'goodbye') // out-of-band change removes 'world' + const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' }) + }) + + it('rejects an ambiguous match without replace_all', async () => { + await writeFile(join(dir, 'a.txt'), 'a a a') + await call('read', { file_path: 'a.txt' }) + const result = await call('edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ code: 'FS_AMBIGUOUS_EDIT' }) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('a a a') + }) + + it('replaces all matches with replace_all', async () => { + await writeFile(join(dir, 'a.txt'), 'a a a') + await call('read', { file_path: 'a.txt' }) + const result = await call('edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b', replace_all: true }) + expect(result.isError).toBe(false) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('b b b') + }) + + it('supports a full write→edit cycle without an intervening read', async () => { + await call('write', { file_path: 'a.txt', content: 'one two' }) + const result = await call('edit', { file_path: 'a.txt', old_string: 'two', new_string: 'three' }) + expect(result.isError).toBe(false) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('one three') + }) + }) + + describe('the gate records only through the events (no method coupling)', () => { + it('a direct ctx.fs.readText records no observed-state, so a later edit rejects', async () => { + await writeFile(join(dir, 'a.txt'), 'hello world') + // Reach AROUND the tool — an explicit escape hatch for non-tool consumers. + await ctx.fs.readText(await ctx.fs.resolve('a.txt')) + // The model-facing edit still rejects: the read did not emit fs/observed. + const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' }) + }) + }) + + describe('stat budget', () => { + it('read stats once; write and edit never stat in the tool (the gate stats zero too)', async () => { + await writeFile(join(dir, 'a.txt'), 'hello world') + const statSpy = vi.spyOn(ctx.fs, 'stat') + + // read: exactly one stat (type + size routing + observed version). + await call('read', { file_path: 'a.txt' }) + expect(statSpy).toHaveBeenCalledTimes(1) + + // edit (guarded, after the read): the gate supplies vObserved; the tool + // does not stat to manufacture a basis. CAS happens in editText's lock. + statSpy.mockClear() + const edited = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) + expect(edited.isError).toBe(false) + expect(statSpy).not.toHaveBeenCalled() + + // write (guarded replace, after the edit refreshed observed state): zero stat. + statSpy.mockClear() + const written = await call('write', { file_path: 'a.txt', content: 'fresh' }) + expect(written.isError).toBe(false) + expect(statSpy).not.toHaveBeenCalled() + statSpy.mockRestore() + }) + }) + + describe('contained fs/observed recording', () => { + it('a synchronously throwing fs/observed listener does not fail the completed write', async () => { + ctx.on('fs/observed', () => { throw new Error('listener boom') }) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const result = await call('write', { file_path: 'a.txt', content: 'hi' }) + // The write succeeded on disk; the listener throw was logged and swallowed. + expect(result.isError).toBe(false) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hi') + expect(warn).toHaveBeenCalled() + warn.mockRestore() + }) }) }) -describe('read', () => { - it('returns line-numbered content', async () => { +// -------------------------------------------------------------------------- +// BARE deployment: SUBPATH plugins only, NO policy gate. +// -------------------------------------------------------------------------- +describe('bare provider (subpath plugins, no dsh-file-context)', () => { + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-bare-')) + ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalFileSystem, { cwd: dir }) + await ctx.plugin(readPlugin) + await ctx.plugin(writePlugin) + fiber = await ctx.plugin(editPlugin) + }) + + it('read works (it never needed policy)', async () => { await writeFile(join(dir, 'a.txt'), 'alpha\nbeta') const result = await call('read', { file_path: 'a.txt' }) + expect(result.isError).toBe(false) expect(text(result)).toContain('1: alpha') - expect(text(result)).toContain('2: beta') - expect(text(result)).toContain('(End of file - total 2 lines)') }) - it('reports a binary file as an error', async () => { - await writeFile(join(dir, 'bin'), Buffer.from([0x00, 0x01, 0x02])) - const result = await call('read', { file_path: 'bin' }) - expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_NOT_TEXT' }) + it('write unconditionally creates a new file', async () => { + const result = await call('write', { file_path: 'new.txt', content: 'fresh' }) + expect(result.isError).toBe(false) + expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('fresh') }) - it('paginates a multi-line file with offset/limit', async () => { - await writeFile(join(dir, 'a.txt'), 'one\ntwo\nthree\nfour') - const result = await call('read', { file_path: 'a.txt', offset: 2, limit: 2 }) - expect(text(result)).toContain('2: two') - expect(text(result)).toContain('3: three') - expect(text(result)).toContain('(Showing lines 2-3 of 4. Use offset=4 to continue.)') + it('write unconditionally OVERWRITES an existing unread file', async () => { + await writeFile(join(dir, 'a.txt'), 'original') + const result = await call('write', { file_path: 'a.txt', content: 'clobbered' }) + expect(result.isError).toBe(false) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('clobbered') }) -}) -describe('edit → disk', () => { - it('applies a unique literal replacement after a read', async () => { + it('edit unconditionally edits an UNREAD existing file', async () => { await writeFile(join(dir, 'a.txt'), 'hello world') - await call('read', { file_path: 'a.txt' }) const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) expect(result.isError).toBe(false) expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there') }) - it('rejects an edit before any read, leaving the file untouched', async () => { - await writeFile(join(dir, 'a.txt'), 'hello world') - const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) - expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' }) - expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello world') - }) - - it('lets a WINDOWED read authorize an edit when the file is unchanged (freshness, not full-view)', async () => { - // A file with more lines than the read window; read only the first line. - const lines = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`) - await writeFile(join(dir, 'a.txt'), lines.join('\n')) - const read = await call('read', { file_path: 'a.txt', offset: 1, limit: 1 }) - expect(read.isError).toBe(false) - expect(text(read)).toContain('(Showing lines 1-1 of 20') - - // Editing a line OUTSIDE the window is authorized because the file is unchanged. - const result = await call('edit', { file_path: 'a.txt', old_string: 'line 12', new_string: 'LINE 12' }) - expect(result.isError).toBe(false) - expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe(lines.map(l => l === 'line 12' ? 'LINE 12' : l).join('\n')) - }) - - it('rejects an edit when the file changed since the windowed read (stale before matching)', async () => { - await writeFile(join(dir, 'a.txt'), 'hello world') - await call('read', { file_path: 'a.txt', offset: 1, limit: 1 }) - await writeFile(join(dir, 'a.txt'), 'goodbye') // out-of-band change removes 'world' - const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) + it('edit of a MISSING target reports FS_STALE_VERSION even on the unguarded path', async () => { + const result = await call('edit', { file_path: 'missing.txt', old_string: 'a', new_string: 'b' }) expect(result.isError).toBe(true) expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' }) }) - it('rejects an ambiguous match without replace_all', async () => { - await writeFile(join(dir, 'a.txt'), 'a a a') - await call('read', { file_path: 'a.txt' }) - const result = await call('edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }) - expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_AMBIGUOUS_EDIT' }) - expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('a a a') - }) - - it('replaces all matches with replace_all', async () => { - await writeFile(join(dir, 'a.txt'), 'a a a') - await call('read', { file_path: 'a.txt' }) - const result = await call('edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b', replace_all: true }) - expect(result.isError).toBe(false) - expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('b b b') - }) - - it('supports a full write→edit cycle without an intervening read', async () => { - await call('write', { file_path: 'a.txt', content: 'one two' }) - const result = await call('edit', { file_path: 'a.txt', old_string: 'two', new_string: 'three' }) - expect(result.isError).toBe(false) - expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('one three') - }) -}) - -describe('no-bypass / escape-hatch contract', () => { - it('a direct ctx.fs.readText records no observed-state, so a later edit rejects', async () => { + it('edit still enforces literal-match codes (FS_EDIT_NOT_FOUND), unrelated to freshness', async () => { await writeFile(join(dir, 'a.txt'), 'hello world') - // Reach AROUND the policy layer — an explicit escape hatch for non-tool consumers. - await ctx.fs.readText(await ctx.fs.resolve('a.txt')) - // The model-facing edit still rejects: the read was not through ctx.fileContext. - const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) + const result = await call('edit', { file_path: 'a.txt', old_string: 'absent', new_string: 'x' }) expect(result.isError).toBe(true) - expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' }) + expect(result.error).toMatchObject({ code: 'FS_EDIT_NOT_FOUND' }) + }) + + it('neither write nor edit stats in the tool on the bare path', async () => { + await writeFile(join(dir, 'a.txt'), 'hello world') + const statSpy = vi.spyOn(ctx.fs, 'stat') + expect((await call('write', { file_path: 'a.txt', content: 'x y' })).isError).toBe(false) + expect((await call('edit', { file_path: 'a.txt', old_string: 'y', new_string: 'z' })).isError).toBe(false) + expect(statSpy).not.toHaveBeenCalled() + statSpy.mockRestore() }) }) diff --git a/packages/fs/tool-fs/tests/subpaths.spec.ts b/packages/fs/tool-fs/tests/subpaths.spec.ts index 7243955969..32a276ca25 100644 --- a/packages/fs/tool-fs/tests/subpaths.spec.ts +++ b/packages/fs/tool-fs/tests/subpaths.spec.ts @@ -1,7 +1,10 @@ /** * Tests for the per-tool subpath plugins (`@deepseek-ai/dsh-tool-fs/read`, - * `/write`, `/edit`): each registers exactly one tool, injects the same - * services (`tools`, `fileContext`, `systemPrompt`), and cleans up on disposal. + * `/write`, `/edit`): each registers exactly one tool, injects the same services + * (`tools`, `fs`, `systemPrompt`) — NOT a policy service — and cleans up on + * disposal. They boot over the bare `ctx.fs` provider with NO + * `@deepseek-ai/dsh-file-context`, proving each subpath carries no policy-plugin + * dependency. */ import { describe, expect, it } from 'vitest' @@ -15,7 +18,6 @@ import type { FsTarget, FsWriteOutcome, } from '@deepseek-ai/dsh-fs' -import FileContext from '@deepseek-ai/dsh-file-context' import * as readPlugin from '@deepseek-ai/dsh-tool-fs/read' import * as writePlugin from '@deepseek-ai/dsh-tool-fs/write' import * as editPlugin from '@deepseek-ai/dsh-tool-fs/edit' @@ -46,12 +48,11 @@ async function base() { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(StubFs) - await ctx.plugin(FileContext) return ctx } describe('subpath plugins', () => { - it('each registers exactly its one tool', async () => { + it('each registers exactly its one tool (over the bare provider, no policy plugin)', async () => { const cases: Array<[unknown, string]> = [ [readPlugin, 'read'], [writePlugin, 'write'], @@ -72,7 +73,7 @@ describe('subpath plugins', () => { expect(ctx.tools.schemas()).toHaveLength(0) }) - it('stays pending without a ctx.fileContext provider', async () => { + it('stays pending without a ctx.fs provider', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index ef1490b5ce..5ca6947354 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -1,13 +1,14 @@ /** - * Consumer-surface tests for the filesystem tools. They run the REAL - * `ctx.fileContext` policy service over a fake `ctx.fs` provider (the genuine - * collaborator, per the prefer-the-real-implementation rule), so they verify - * schemas, argument validation, result formatting, FsError→isError propagation, - * and that each tool records observed-state through `ctx.fileContext` (the - * no-bypass contract) — not just that it moved bytes. + * Consumer-surface tests for the filesystem tools as the EXECUTOR. They run the + * REAL `@deepseek-ai/dsh-file-context` gate plugin (the genuine policy + * collaborator, per the prefer-the-real-implementation rule) over a fake + * `ctx.fs` provider, so they verify schemas, argument validation, result + * formatting, FsError→isError propagation, and that each tool dispatches the + * `fs/*` waterfalls + records observed-state through the gate (read authorizes a + * later edit) — not just that it moved bytes. */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' @@ -21,15 +22,17 @@ import type { FsWriteExpectation, FsWriteOutcome, } from '@deepseek-ai/dsh-fs' -import FileContext from '@deepseek-ai/dsh-file-context' -import type { FileReadOutcome } from '@deepseek-ai/dsh-file-context' +import * as FileContext from '@deepseek-ai/dsh-file-context' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' -import { formatReadOutput } from '@deepseek-ai/dsh-tool-fs' +import { formatReadOutput, STREAM_MIN_SIZE } from '@deepseek-ai/dsh-tool-fs' +import type { FileReadOutcome } from '@deepseek-ai/dsh-tool-fs' /** An in-memory fake provider; a test can arm a rejection on any primitive. */ class FakeFs extends FileSystem { files = new Map() rejectWith?: FsError + writeExpectations: (FsWriteExpectation | undefined)[] = [] + editExpectations: ({ version: FsVersion } | undefined)[] = [] private throwIfArmed(): void { if (this.rejectWith) throw this.rejectWith @@ -51,14 +54,16 @@ class FakeFs extends FileSystem { const content = this.files.get(target.targetKey) ?? '' return (async function* () { yield content })() } - override async writeText(target: FsTarget, content: string, _expected: FsWriteExpectation): Promise { + override async writeText(target: FsTarget, content: string, expected?: FsWriteExpectation): Promise { this.throwIfArmed() + this.writeExpectations.push(expected) const existed = this.files.has(target.targetKey) this.files.set(target.targetKey, content) return { operation: existed ? 'update' : 'create', version: FsVersion('v2') } } - override async editText(target: FsTarget, edit: FsEditRequest): Promise { + override async editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }): Promise { this.throwIfArmed() + this.editExpectations.push(expected) const content = this.files.get(target.targetKey) ?? '' this.files.set(target.targetKey, content.split(edit.oldString).join(edit.newString)) return { replacements: 1, replaceAll: edit.replaceAll, version: FsVersion('v3') } @@ -104,11 +109,11 @@ describe('registration', () => { expect(prompt).toContain('Use the edit tool') }) - it('stays pending until ctx.fileContext exists (inject)', async () => { + it('stays pending until ctx.fs exists (inject)', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - await ctx.plugin(ToolFs) // no fileContext provider + await ctx.plugin(ToolFs) // no fs provider expect(ctx.tools.schemas()).toHaveLength(0) }) @@ -169,6 +174,7 @@ describe('read tool', () => { expect((await call(ctx, 'read', { file_path: 'a.txt' }, { session })).isError).toBe(false) const edited = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'hello', new_string: 'bye' }, { session }) expect(edited.isError).toBe(false) + expect(fs.editExpectations).toEqual([{ version: 'v1' }]) }) it('propagates FS_NOT_FOUND for an absent file', async () => { @@ -177,6 +183,48 @@ describe('read tool', () => { expect(result.isError).toBe(true) expect(result.error).toMatchObject({ code: 'FS_NOT_FOUND' }) }) + + it('rejects a non-regular target', async () => { + const { ctx, fs } = await setup() + fs.files.set('key:d', '') + fs.stat = async () => ({ version: FsVersion('v1'), type: 'directory' }) + const result = await call(ctx, 'read', { file_path: 'd' }) + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) + }) + + it('streams a large file (size at/above the cap) instead of reading whole', async () => { + const { ctx, fs } = await setup() + fs.files.set('key:big.txt', 'alpha\nbeta') + const readSpy = vi.spyOn(fs, 'readText') + const streamSpy = vi.spyOn(fs, 'streamText') + fs.stat = async () => ({ version: FsVersion('v1'), type: 'file', size: STREAM_MIN_SIZE }) + const result = await call(ctx, 'read', { file_path: 'big.txt' }) + expect(result.isError).toBe(false) + expect(text(result)).toContain('1: alpha') + expect(streamSpy).toHaveBeenCalled() + expect(readSpy).not.toHaveBeenCalled() + }) + + it('streams when the backend reports no size (never buffers a size-less file)', async () => { + const { ctx, fs } = await setup() + fs.files.set('key:a.txt', 'alpha') + const streamSpy = vi.spyOn(fs, 'streamText') + fs.stat = async () => ({ version: FsVersion('v1'), type: 'file' }) // no size + const result = await call(ctx, 'read', { file_path: 'a.txt' }) + expect(result.isError).toBe(false) + expect(streamSpy).toHaveBeenCalled() + }) + + it('surfaces a byte-capped read as a truncated footer', async () => { + const { ctx, fs } = await setup() + // Many long lines so the window hits the byte cap before EOF. + fs.files.set('key:big.txt', Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n')) + const result = await call(ctx, 'read', { file_path: 'big.txt' }) + expect(result.isError).toBe(false) + expect(text(result)).toContain('Output capped.') + }) + }) describe('formatReadOutput footer variants', () => { @@ -204,11 +252,12 @@ describe('formatReadOutput footer variants', () => { }) describe('write tool', () => { - it('formats a create result', async () => { - const { ctx } = await setup() - const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' }) + it('formats a create result and uses createIfAbsent (unobserved, with the gate)', async () => { + const { ctx, fs } = await setup() + const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' }, { session: {} }) expect(result.isError).toBe(false) expect(text(result)).toContain('Created file') + expect(fs.writeExpectations).toEqual([{ kind: 'createIfAbsent' }]) }) it('rejects a blank file_path', async () => { @@ -258,7 +307,7 @@ describe('edit tool', () => { expect(text(result)).toContain('file_path must be a non-empty string') }) - it('propagates FS_NOT_OBSERVED when the file was never read', async () => { + it('propagates FS_NOT_OBSERVED when the file was never read (the gate decides)', async () => { const { ctx, fs } = await setup() fs.files.set('key:a.txt', 'hello') const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }, { session: {} }) diff --git a/packages/fs/file-context/tests/window.spec.ts b/packages/fs/tool-fs/tests/window.spec.ts similarity index 98% rename from packages/fs/file-context/tests/window.spec.ts rename to packages/fs/tool-fs/tests/window.spec.ts index 6b1a8b5b93..b596a47465 100644 --- a/packages/fs/file-context/tests/window.spec.ts +++ b/packages/fs/tool-fs/tests/window.spec.ts @@ -6,8 +6,8 @@ */ import { describe, expect, it } from 'vitest' -import { buildWindow, READ_MAX_LINE_LENGTH } from '@deepseek-ai/dsh-file-context' -import type { ReadWindow } from '@deepseek-ai/dsh-file-context' +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 } diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 1c5ec2430e..f471723679 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -47,7 +47,6 @@ { "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": "FileContextExec", "source": "packages/fs/file-context/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FileReadRequest", "source": "packages/fs/file-context/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FileReadOutcome", "source": "packages/fs/file-context/src/types.ts" } + { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FileReadOutcome", "source": "packages/fs/tool-fs/src/types.ts" } ] } From f9f475cbea682b0120225bfa463411e2a6a01097 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Sun, 28 Jun 2026 14:02:51 +0800 Subject: [PATCH 17/75] fix: address codex review round 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correct the filesystem-wiring claims flagged by codex: no default/example config wires the fs tools yet (the demo agents do file ops through bash), so the docs and RFC no longer assert that "the default product config loads dsh-file-context". They now state the intended stance — a deployment that loads the fs tools is expected to also load dsh-file-context for read-before-write/edit. --- docs/architecture.md | 2 +- docs/core-data-structures/filesystem.md | 2 +- .../2026-06-26-file-context-as-event-gate.md | 10 +++++----- packages/fs/README.md | 2 +- packages/fs/tool-fs/README.md | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 1afa766f63..32737adbc7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -73,7 +73,7 @@ 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-file-context` is a policy PLUGIN (no service) that decides the `fs/write-expectation`/`fs/edit-expectation` 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-file-context` gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool at a service-injection boundary. The default product config loads `dsh-file-context`, so the default behavior remains read-before-write/edit. See [the file-context event-gate RFC](rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md). +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-file-context` is a policy PLUGIN (no service) that decides the `fs/write-expectation`/`fs/edit-expectation` 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-file-context` gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool at a service-injection boundary. The fs tools are not wired into any default/example config yet (the demo agents do file ops through bash); a deployment that loads `dsh-tool-fs` is expected to also load `dsh-file-context` so the default behavior is read-before-write/edit. See [the file-context 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. diff --git a/docs/core-data-structures/filesystem.md b/docs/core-data-structures/filesystem.md index e114c409d8..54ea3e05d0 100644 --- a/docs/core-data-structures/filesystem.md +++ b/docs/core-data-structures/filesystem.md @@ -2,7 +2,7 @@ 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-file-context](../../packages/fs/file-context), 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-file-context` 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. The default product config still loads it, so the default behavior remains read-before-write/edit. +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-file-context` 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-file-context` 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/file-context/src/types.ts`](../../packages/fs/file-context/src/types.ts). Read-rendering source: [`packages/fs/tool-fs/src/types.ts`](../../packages/fs/tool-fs/src/types.ts). diff --git a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md index efb6667d4e..999f795a08 100644 --- a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md +++ b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md @@ -29,7 +29,7 @@ provider seam dsh-fs ctx.fs: text IO + ATOMIC mutation primitives who 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-file-context` 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-file-context` does not break `dsh-tool-fs` at the service-injection boundary; it removes the policy gate and leaves the bare provider behavior. The product default still loads `dsh-file-context`, so the default user-facing behavior and prompt discipline remain read-before-write/edit. 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. +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-file-context` 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-file-context` 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-file-context`, so the user-facing behavior and prompt discipline are read-before-write/edit (no default/example config wires the fs tools yet — the demo agents do file ops through bash). 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`. @@ -70,7 +70,7 @@ These events carry existing `dsh-fs` vocabulary (`FsTarget`, `FsVersion`, `FsWri **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-file-context` 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-expectation`, `fs/edit-expectation`) 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-file-context` 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-expectation` decider BEFORE `dsh-file-context` (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 the shipped `dsh-tool-fs` dispatches these waterfalls on every write/edit path and the shipped default config loads `dsh-file-context` as the policy decider. +**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-file-context` 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-expectation` decider BEFORE `dsh-file-context` (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-file-context` 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-file-context`, 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. @@ -110,7 +110,7 @@ The `fs/*` decision events are **unbound waterfalls dispatched by the tool** (li ## 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 the default product config loads `dsh-file-context`: 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 default file-context policy requires it. The bare-provider fallback does not change the default prompt stance. +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-file-context`: 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 file-context policy 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 windowing** (`window.ts`, `READ_MAX_BYTES`, `READ_MAX_LINE_LENGTH`, `FileReadRequest`/`FileReadOutcome`/`FileTextLine`, `STREAM_MIN_SIZE`), which is the tool's rendering detail now that the tool owns the read. Those read-windowing types and helpers move into `dsh-tool-fs`; the policy plugin must not remain a type dependency for the tool. @@ -140,7 +140,7 @@ An observed-state entry is the **prior-observation record**: a successful `read` ## Bare-provider behavior (no `dsh-file-context`) -This is not the default product mode — the default product config loads `dsh-file-context`. It is the unconstrained provider floor that exists once the tool is no longer coupled to a policy method service. With `dsh-file-context` absent, every `fs/*` waterfall falls through to its `undefined` default and `fs/observed` has no listener: +This is not the intended deployment stance — a config loading the fs tools is expected to also load `dsh-file-context`. It is the unconstrained provider floor that exists once the tool is no longer coupled to a policy method service. With `dsh-file-context` 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. @@ -172,4 +172,4 @@ This amends — does not reverse — [the split-fs-seam RFC](../simplification/2 - **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-expectation`/`fs/edit-expectation` slots hold exactly one decider; the first-registered (or `prepend`ed) listener wins and the rest are short-circuited. `dsh-file-context` 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-file-context` 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 default `dsh-file-context` plugin. A deployment that omits it is opting into an unconstrained filesystem on purpose; that is not the default product stance. +- **The bare provider does no read-before-write/edit and no version check.** A deployment without `dsh-file-context` 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-file-context` 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. diff --git a/packages/fs/README.md b/packages/fs/README.md index 79e1d5c0f6..0fbce830e3 100644 --- a/packages/fs/README.md +++ b/packages/fs/README.md @@ -9,4 +9,4 @@ The filesystem stack: a provider seam (text IO + atomic mutation with an optiona | `file-context/` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit, via the `fs/*` event gate | (no service — `fs/*` listeners) | | `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`) | (registers on `ctx.tools`) | -The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas. The policy (`file-context/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. The default product config loads it. +The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas. The policy (`file-context/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it. diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index e3e8a1cdff..787a2cca9a 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -9,7 +9,7 @@ await ctx.plugin(FileContext) // @deepseek-ai/dsh-fi await ctx.plugin(ToolFs) // this package — registers read/write/edit ``` -`@deepseek-ai/dsh-file-context` is **optional**: omit it and the tools run against the bare provider (unconditional write/overwrite/edit, no observed-state). The default product config loads it, so the default behavior stays read-before-write/edit. +`@deepseek-ai/dsh-file-context` is **optional**: omit it and the tools run against the bare provider (unconditional write/overwrite/edit, no observed-state). A deployment that loads these tools is expected to also load it, so the behavior is read-before-write/edit. Each tool also ships as a subpath plugin for focused deployments (each injects `fs`, not a policy service): From 1059166cb19aa4264142e62b748b7e62d2407481 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Sun, 28 Jun 2026 14:15:04 +0800 Subject: [PATCH 18/75] fix: address codex review round 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - edit tool: add the read-before-edit requirement to the model-facing prompt (with the just-created/edited-this-session exception), matching write's guidance so the model doesn't only learn it via a failed FS_NOT_OBSERVED call. - fsspec-style-fs-seam RFC: correct the acceptance criteria that still claimed a ctx.fileContext service and a fileContext inject — the landed design is the fs/* event gate with the tool injecting fs. - filesystem-tool-schemas RFC: replace the stale "prior full file state" edit requirement with version-freshness wording (any windowed read authorizes a fresh edit; no partial-view flag). --- .../implemented/feature/2026-06-17-filesystem-tool-schemas.md | 2 +- .../simplification/2026-06-26-fsspec-style-fs-seam.md | 4 ++-- packages/fs/tool-fs/src/edit.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md index 45928e9871..fa572d4a0b 100644 --- a/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md +++ b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md @@ -62,7 +62,7 @@ Arguments: - `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 prior full file state derived from a previous read in the same execution context. `ctx.fs` derives the file-state owner and uses the recorded version as the stale guard. +`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-file-context` 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. diff --git a/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md index 7f2e5d7091..26bbb65056 100644 --- a/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md +++ b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md @@ -106,8 +106,8 @@ It keeps the interface/implementation/consumer discipline, consumer-never-import ## Acceptance Criteria - `dsh-fs` exposes exactly `resolve`/`stat`/`readText`/`streamText`/`writeText`/`editText`; `stat` returns `FsInfo | undefined`; `writeText` uses `FsWriteExpectation` (`createIfAbsent` or `replaceIfVersion`); removed types/primitives are gone, and the old `applyEdit` API is replaced by `editText`. -- `dsh-file-context` registers `ctx.fileContext`, owns observed-state plus `read`/`write`/`edit` policy, injects `fs`, and has HMR/disposal coverage. -- `dsh-tool-fs` injects `fileContext`; model-facing schemas stay byte-for-byte unchanged; the no-bypass contract and escape-hatch contract are documented and tested. +- `dsh-file-context` 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. diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index 5d70310502..d3725db1af 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -54,7 +54,7 @@ export function apply(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.', + text: 'Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default file-context policy requires it), unless you just created or edited it in this session.', }) ctx.tools.register(defineTool({ From 70a8b57738d3c3e573b083b07549719a657a8b52 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Sun, 28 Jun 2026 17:02:06 +0800 Subject: [PATCH 19/75] fix: drop tool-web subpath exports, align with tool-bash single-entry shape The web tool package exposed ./search and ./fetch as standalone subpath plugins, but nothing consumed them, the RFC never called for them, and the sibling dsh-tool-bash (also a multi-tool consumer) ships a single entry and selects tools via config. The extra entries also tripped the workspace constraints gate, whose expected `files` list covers single-entry and bin packages but not a non-bin multi-entry one. Collapse to a single `.` entry: drop the ./search|./fetch exports and their lib/*.js from package.json files, delete the per-package tsdown override (the root config's lib/types/index.js entry now suffices), and remove the plugin-shaped name/inject exports from search.ts/fetch.ts (renaming each apply to its applyWeb{Search,Fetch}Tool helper, still composed by the root plugin and re-exported from the index). Selective enablement stays via the existing { search?, fetch? } config. Docs updated to match. --- packages/web/tool-web/README.md | 2 +- packages/web/tool-web/package.json | 10 ---------- packages/web/tool-web/src/fetch.ts | 13 +------------ packages/web/tool-web/src/index.ts | 3 +-- packages/web/tool-web/src/search.ts | 13 +------------ packages/web/tool-web/tsdown.config.ts | 19 ------------------- 6 files changed, 4 insertions(+), 56 deletions(-) delete mode 100644 packages/web/tool-web/tsdown.config.ts diff --git a/packages/web/tool-web/README.md b/packages/web/tool-web/README.md index 762bfe0189..f57f38d0d5 100644 --- a/packages/web/tool-web/README.md +++ b/packages/web/tool-web/README.md @@ -2,7 +2,7 @@ The model-facing web tool suite — `web_search` and `web_fetch` — over the [web capability seam](../web/README.md) (`ctx.web`). It owns model-facing concerns only: tool names, JSON schemas, snake_case argument names, prompt sections, the result-count bound, result formatting, HTML→markdown presentation, and `presentCall`. All web access goes through `ctx.web`; this package never imports a concrete provider. -Each tool is also a subpath plugin (`@deepseek-ai/dsh-tool-web/search`, `/fetch`) for focused deployments. +Each tool is registered independently; a product that wants only one disables the other via config (`{ search: false }` / `{ fetch: false }`). ## Tools diff --git a/packages/web/tool-web/package.json b/packages/web/tool-web/package.json index c31c685e45..8c22afa9a8 100644 --- a/packages/web/tool-web/package.json +++ b/packages/web/tool-web/package.json @@ -11,21 +11,11 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./search": { - "types": "./lib/types/search.d.ts", - "default": "./lib/search.js" - }, - "./fetch": { - "types": "./lib/types/fetch.d.ts", - "default": "./lib/fetch.js" - }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", - "lib/search.js", - "lib/fetch.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" diff --git a/packages/web/tool-web/src/fetch.ts b/packages/web/tool-web/src/fetch.ts index a48ad41414..85977fbea4 100644 --- a/packages/web/tool-web/src/fetch.ts +++ b/packages/web/tool-web/src/fetch.ts @@ -3,8 +3,6 @@ * Execution goes through `ctx.web` — this module owns the model-facing schema, * argument validation, and PRESENTATION (HTML→markdown, truncation formatting), * while the fetch provider owns safe retrieval (transport, redirects, caps). - * - * @module @deepseek-ai/dsh-tool-web/fetch */ import type { Context } from 'cordis' @@ -51,7 +49,7 @@ export function presentFetchCall(args: { url: string; timeout_ms?: number }): To } /** Register the `web_fetch` tool and its system-prompt guidance. */ -export function apply(ctx: Context): void { +export function applyWebFetchTool(ctx: Context): void { ctx.systemPrompt.section({ name: 'tool:web_fetch', order: 111, @@ -76,12 +74,3 @@ export function apply(ctx: Context): void { presentCall: presentFetchCall, })) } - -/** Cordis plugin name used by loader diagnostics. */ -export const name = 'web-fetch' - -/** Services required by the `web_fetch` tool plugin. */ -export const inject = ['tools', 'web', 'systemPrompt'] - -/** Named helper for direct registration in the root plugin and tests. */ -export const applyWebFetchTool = apply diff --git a/packages/web/tool-web/src/index.ts b/packages/web/tool-web/src/index.ts index 031d7fe4cb..072fc6018e 100644 --- a/packages/web/tool-web/src/index.ts +++ b/packages/web/tool-web/src/index.ts @@ -1,8 +1,7 @@ /** * The model-facing web tool suite (`web_search`, `web_fetch`) over the `ctx.web` * seam. This root plugin registers the tools the product has ENABLED, composing - * the per-tool registration helpers; each tool is also exposed as a subpath - * plugin (`@deepseek-ai/dsh-tool-web/search`, `/fetch`) for focused deployments. + * the per-tool registration helpers (`applyWebSearchTool`, `applyWebFetchTool`). * * The package owns model-facing concerns only — tool names, JSON schemas, * argument validation, prompt sections, result-cap constants, result formatting, diff --git a/packages/web/tool-web/src/search.ts b/packages/web/tool-web/src/search.ts index 6ed9991903..2aa93ef10e 100644 --- a/packages/web/tool-web/src/search.ts +++ b/packages/web/tool-web/src/search.ts @@ -3,8 +3,6 @@ * Execution goes through `ctx.web` — this module owns only the model-facing * schema, argument validation, the result-count bound, and result formatting, * never provider selection or network access. - * - * @module @deepseek-ai/dsh-tool-web/search */ import type { Context } from 'cordis' @@ -70,7 +68,7 @@ export function presentSearchCall(args: { query: string }): ToolCallPresentation } /** Register the `web_search` tool and its system-prompt guidance. */ -export function apply(ctx: Context): void { +export function applyWebSearchTool(ctx: Context): void { ctx.systemPrompt.section({ name: 'tool:web_search', order: 110, @@ -94,12 +92,3 @@ export function apply(ctx: Context): void { presentCall: presentSearchCall, })) } - -/** Cordis plugin name used by loader diagnostics. */ -export const name = 'web-search' - -/** Services required by the `web_search` tool plugin. */ -export const inject = ['tools', 'web', 'systemPrompt'] - -/** Named helper for direct registration in the root plugin and tests. */ -export const applyWebSearchTool = apply diff --git a/packages/web/tool-web/tsdown.config.ts b/packages/web/tool-web/tsdown.config.ts deleted file mode 100644 index 0f75095d18..0000000000 --- a/packages/web/tool-web/tsdown.config.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { defineConfig } from 'tsdown' - -/** - * tool-web exposes one package root plus one entry per tool plugin, so each tool - * can be loaded or replaced independently as a subpath plugin - * (`@deepseek-ai/dsh-tool-web/search`, `/fetch`). The root tsdown builds only - * `lib/types/index.js`, so this override adds the subpath entries. Declarations - * come from `tsc -b` (dts: false), matching every package. - */ -export default defineConfig({ - entry: ['lib/types/index.js', 'lib/types/search.js', 'lib/types/fetch.js'], - outDir: 'lib', - format: ['esm'], - platform: 'node', - target: 'es2024', - fixedExtension: false, - dts: false, - clean: false, -}) From 4a1177093a0057cf46b9339e337601b6f1e25a08 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Sun, 28 Jun 2026 17:43:29 +0800 Subject: [PATCH 20/75] refactor(tool-fs): drop per-tool subpath plugins; adopt single-tsconfig build Adapt the four fs packages to master's single-tsconfig build convention (lib/types outDir + types path + files allowlist), brought in by the merge. While doing so, drop dsh-tool-fs's /read//write//edit subpath plugins. They were the only subpath-export package in the tree and forced bespoke tsdown, tsconfig path, package.json files, and workspace-constraint handling that no sibling tool package (e.g. dsh-tool-bash) carries, for a focused-deployment use case no consumer needed. dsh-tool-fs is now a single root plugin that registers read/write/edit, mirroring dsh-tool-bash; the per-tool registration helpers stay internal modules the root composes. The file-context event-gate RFC is amended to record the narrowed scope. --- .../2026-06-17-filesystem-capability-seam.md | 8 +- .../2026-06-26-file-context-as-event-gate.md | 8 +- .../2026-06-17-filesystem-tool-schemas.md | 2 +- packages/fs/file-context/package.json | 4 +- packages/fs/fs-local/package.json | 4 +- packages/fs/fs/package.json | 4 +- packages/fs/tool-fs/README.md | 8 -- packages/fs/tool-fs/package.json | 16 +--- packages/fs/tool-fs/src/edit.ts | 13 +-- packages/fs/tool-fs/src/index.ts | 5 +- packages/fs/tool-fs/src/read.ts | 13 +-- packages/fs/tool-fs/src/write.ts | 13 +-- packages/fs/tool-fs/tests/integration.spec.ts | 18 ++-- packages/fs/tool-fs/tests/subpaths.spec.ts | 83 ------------------- packages/fs/tool-fs/tsdown.config.ts | 21 ----- tsconfig.base.json | 3 - 16 files changed, 34 insertions(+), 189 deletions(-) delete mode 100644 packages/fs/tool-fs/tests/subpaths.spec.ts delete mode 100644 packages/fs/tool-fs/tsdown.config.ts diff --git a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md index 4faae84d83..0a4365bc82 100644 --- a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md +++ b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md @@ -49,7 +49,7 @@ The filesystem seam uses the same dependency direction as the bash trio: `@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 by composing the per-tool registration helpers (`read`, `write`, and `edit`). The same helpers are exposed as subpath plugins such as `@deepseek-ai/dsh-tool-fs/read`, `@deepseek-ai/dsh-tool-fs/write`, and `@deepseek-ai/dsh-tool-fs/edit` for focused deployments. Root and subpath plugins follow the same rule: they inject `fs` and never import an implementation package. +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 @@ -117,7 +117,7 @@ The tool package must keep model-facing contracts stable when backends change. A The first implementation requires a prior full `read` before updating an existing file with `write` or `edit`. `tool-fs` does not implement this by checking whether a tool named `read` ran or by reading the file-state cache. It passes the current execution context to `ctx.fs`, and `ctx.fs` derives the file-state owner and enforces file-state/stale-version policy. Creating a new file with `write` does not require prior state or an owner. -The root plugin registers the full suite by composing the per-tool registration helpers. The subpath plugins register one tool each for focused deployments and tests. Both forms inject `fs`, `tools`, and `systemPrompt`. +The root plugin registers the full suite by composing the per-tool registration helpers. It injects `fs`, `tools`, and `systemPrompt`. ## Migration plan @@ -128,7 +128,7 @@ This RFC starts from `origin/master`, where no filesystem tool package exists ye 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 first pass does not add a separate `@deepseek-ai/dsh-file-context` package. The file-state store lives behind `ctx.fs` so root and subpath `tool-fs` plugins share the same read-before-write/edit policy automatically. +This first pass does not add a separate `@deepseek-ai/dsh-file-context` package. The file-state store lives behind `ctx.fs` so the `tool-fs` plugin gets the read-before-write/edit policy automatically. 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. @@ -156,7 +156,7 @@ Beyond the happy/sad paths above, `dsh-fs-local` tests must cover the defensive- - **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 with a fake `ctx.fs` implementation. 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 pass the current execution context or structural projection through to `ctx.fs`, root-plugin suite registration, subpath plugin registration, and HMR cleanup. +`dsh-tool-fs` tests cover the consumer surface with a fake `ctx.fs` implementation. 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 pass the current execution context or structural projection through to `ctx.fs`, root-plugin suite registration, and HMR cleanup. Integration tests should load `dsh-fs-local` plus `dsh-tool-fs` and execute `read`, `write`, and `edit` through `ctx.tools.execute()` to prove the three packages work together without bypassing the tool registry. 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. diff --git a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md index 999f795a08..23460b211f 100644 --- a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md +++ b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md @@ -112,9 +112,9 @@ The `fs/*` decision events are **unbound waterfalls dispatched by the tool** (li 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-file-context`: 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 file-context policy 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 windowing** (`window.ts`, `READ_MAX_BYTES`, `READ_MAX_LINE_LENGTH`, `FileReadRequest`/`FileReadOutcome`/`FileTextLine`, `STREAM_MIN_SIZE`), which is the tool's rendering detail now that the tool owns the read. Those read-windowing types and helpers move into `dsh-tool-fs`; the policy plugin must not remain a type dependency for the tool. +`dsh-tool-fs` gains the executor responsibilities relocated from the old `fileContext` method service, including **read windowing** (`window.ts`, `READ_MAX_BYTES`, `READ_MAX_LINE_LENGTH`, `FileReadOutcome`/`FileTextLine`, `STREAM_MIN_SIZE`), which is the tool's rendering detail now that the tool owns the read. Those read-windowing types and helpers move into `dsh-tool-fs`; the policy plugin must not remain a type dependency for the tool. -`dsh-tool-fs` exposes each tool as a first-class **subpath plugin** (`/read`, `/write`, `/edit`) for focused deployments, plus a root plugin that composes all three. The `inject` change applies to **all four**: each of `read.ts`, `write.ts`, `edit.ts`, and `index.ts` drops `fileContext` from `inject` and adds `fs` (keeping `tools`/`systemPrompt`). Updating only the root plugin would leave a focused deployment that loads just `@deepseek-ai/dsh-tool-fs/edit` still coupled to the old method service, silently breaking the decoupling contract for exactly the deployments subpaths exist to serve. +`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: @@ -154,10 +154,10 @@ This amends — does not reverse — [the split-fs-seam RFC](../simplification/2 ## Acceptance Criteria -- All four `dsh-tool-fs` injection points — the root plugin AND the `/read`, `/write`, `/edit` subpath plugins — inject `fs` (+ `tools`/`systemPrompt`), not `fileContext`; each calls `ctx.fs` directly and dispatches the `fs/write-expectation`/`fs/edit-expectation` waterfalls (passing `exec` as the actor) and the contained `fs/observed` emit. Read windowing lives in `dsh-tool-fs`. +- The `dsh-tool-fs` root plugin injects `fs` (+ `tools`/`systemPrompt`), not `fileContext`; it calls `ctx.fs` directly and dispatches the `fs/write-expectation`/`fs/edit-expectation` waterfalls (passing `exec` as the actor) and the contained `fs/observed` emit. Read windowing 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-file-context` 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-file-context` that loads a **subpath plugin** (e.g. just `@deepseek-ai/dsh-tool-fs/edit`, plus `/read`/`/write` as the scenario needs) boots, and `read`/`write`(create AND overwrite)/`edit` work through `dsh-tool-fs` 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 subpath plugins — not just the root — carry no `fileContext` dependency. A bare-provider edit of a missing target reports `FS_STALE_VERSION`. With `dsh-file-context` present, the same unread `edit` is rejected `FS_NOT_OBSERVED` and the same unread overwrite uses `createIfAbsent` (rejected on an existing file). +- **Bare-provider test**: a config WITHOUT `dsh-file-context` 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-file-context` 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-expectation` listener AFTER `dsh-file-context` 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. - **Contained observed recording**: a test with a synchronously throwing `fs/observed` listener performs a write/edit and asserts the tool result is still success (the completed mutation is not turned into an `isError`). The event contract requires synchronous side-effect-only listeners; the try/catch is the synchronous backstop, not async rejection handling. - `dsh-fs` `writeText`/`editText` make `expected` optional (omit ⇒ unconditional); the `FsWriteExpectation` union is unchanged, and `dsh-file-context`'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`. diff --git a/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md index fa572d4a0b..6e510c69b1 100644 --- a/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md +++ b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md @@ -100,7 +100,7 @@ The following are deliberately out of scope for the first filesystem schema pass - `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 existing-file `write` and `edit` require a prior full read in the same execution context, while new-file `write` does not. -- The root plugin and subpath plugins register the same schemas. +- The `tool-fs` root plugin registers all three schemas. Integration tests should execute `read`, `write`, and `edit` through `ctx.tools.execute()` with a fake or local `ctx.fs` provider and verify that model arguments are translated into the expected `ctx.fs` calls. diff --git a/packages/fs/file-context/package.json b/packages/fs/file-context/package.json index 5d05d09f13..16ee567305 100644 --- a/packages/fs/file-context/package.json +++ b/packages/fs/file-context/package.json @@ -15,7 +15,9 @@ "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/fs/fs-local/package.json b/packages/fs/fs-local/package.json index f1073981ff..4945684713 100644 --- a/packages/fs/fs-local/package.json +++ b/packages/fs/fs-local/package.json @@ -15,7 +15,9 @@ "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/fs/fs/package.json b/packages/fs/fs/package.json index 65e71108a1..395816dbf9 100644 --- a/packages/fs/fs/package.json +++ b/packages/fs/fs/package.json @@ -15,7 +15,9 @@ "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index 787a2cca9a..86298031de 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -11,14 +11,6 @@ await ctx.plugin(ToolFs) // this package — re `@deepseek-ai/dsh-file-context` is **optional**: omit it and the tools run against the bare provider (unconditional write/overwrite/edit, no observed-state). A deployment that loads these tools is expected to also load it, so the behavior is read-before-write/edit. -Each tool also ships as a subpath plugin for focused deployments (each injects `fs`, not a policy service): - -```ts ignore-check -import * as readPlugin from '@deepseek-ai/dsh-tool-fs/read' -import * as writePlugin from '@deepseek-ai/dsh-tool-fs/write' -import * as editPlugin from '@deepseek-ai/dsh-tool-fs/edit' -``` - ## Tools (schemas per [the filesystem tool schemas RFC](../../../docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md)) | Tool | Arguments | Behavior | diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index a726bde6d4..5323bbadcf 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -11,23 +11,13 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./read": { - "types": "./lib/types/read.d.ts", - "default": "./lib/read.js" - }, - "./write": { - "types": "./lib/types/write.d.ts", - "default": "./lib/write.js" - }, - "./edit": { - "types": "./lib/types/edit.d.ts", - "default": "./lib/edit.js" - }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index d3725db1af..53029808ec 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -10,7 +10,7 @@ * tool stats ZERO times either way; a missing target is reported by the provider * as `FS_STALE_VERSION`. * - * @module @deepseek-ai/dsh-tool-fs/edit + * @module @deepseek-ai/dsh-tool-fs/src/edit */ import type { Context } from 'cordis' @@ -50,7 +50,7 @@ export function formatEditOutput(displayPath: string, outcome: FsEditOutcome): s } /** Register the `edit` tool and its system-prompt guidance. */ -export function apply(ctx: Context): void { +export function applyEditTool(ctx: Context): void { ctx.systemPrompt.section({ name: 'tool:edit', order: 102, @@ -84,12 +84,3 @@ export function apply(ctx: Context): void { }, })) } - -/** Cordis plugin name used by loader diagnostics. */ -export const name = 'fs-edit' - -/** Services required by the `edit` tool plugin. */ -export const inject = ['tools', 'fs', 'systemPrompt'] - -/** Named helper for direct registration in the root plugin and tests. */ -export const applyEditTool = apply diff --git a/packages/fs/tool-fs/src/index.ts b/packages/fs/tool-fs/src/index.ts index b57810185e..8c2123c3d4 100644 --- a/packages/fs/tool-fs/src/index.ts +++ b/packages/fs/tool-fs/src/index.ts @@ -1,9 +1,6 @@ /** * The model-facing filesystem tool suite (`read`, `write`, `edit`) over the - * `ctx.fs` provider seam. This root plugin registers all three tools by - * composing the per-tool registration helpers; each tool is also exposed as a - * subpath plugin (`@deepseek-ai/dsh-tool-fs/read`, `/write`, `/edit`) for focused - * deployments. + * `ctx.fs` provider seam. This single plugin registers all three tools. * * ## The tool is the executor; policy is an event gate * diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index bc12068553..54fd553011 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -8,7 +8,7 @@ * the model-facing schema, argument validation, read windowing, and result * formatting; the freshness/observation policy is not its concern. * - * @module @deepseek-ai/dsh-tool-fs/read + * @module @deepseek-ai/dsh-tool-fs/src/read */ import type { Context } from 'cordis' @@ -72,7 +72,7 @@ ${body} } /** Register the `read` tool and its system-prompt guidance. */ -export function apply(ctx: Context): void { +export function applyReadTool(ctx: Context): void { ctx.systemPrompt.section({ name: 'tool:read', order: 100, @@ -119,12 +119,3 @@ export function apply(ctx: Context): void { }, })) } - -/** Cordis plugin name used by loader diagnostics. */ -export const name = 'fs-read' - -/** Services required by the `read` tool plugin. */ -export const inject = ['tools', 'fs', 'systemPrompt'] - -/** Named helper for direct registration in the root plugin and tests. */ -export const applyReadTool = apply diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index e2b44ee78a..407744ec03 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -8,7 +8,7 @@ * returns `createIfAbsent`/`replaceIfVersion` instead. The tool stats ZERO * times either way. * - * @module @deepseek-ai/dsh-tool-fs/write + * @module @deepseek-ai/dsh-tool-fs/src/write */ import type { Context } from 'cordis' @@ -36,7 +36,7 @@ ${verb} file } /** Register the `write` tool and its system-prompt guidance. */ -export function apply(ctx: Context): void { +export function applyWriteTool(ctx: Context): void { ctx.systemPrompt.section({ name: 'tool:write', order: 101, @@ -62,12 +62,3 @@ export function apply(ctx: Context): void { }, })) } - -/** Cordis plugin name used by loader diagnostics. */ -export const name = 'fs-write' - -/** Services required by the `write` tool plugin. */ -export const inject = ['tools', 'fs', 'systemPrompt'] - -/** Named helper for direct registration in the root plugin and tests. */ -export const applyWriteTool = apply diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index 696741c334..cc99d52b80 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -5,10 +5,9 @@ * * - DEFAULT — with the real `dsh-file-context` policy gate plugin: read-before- * write/edit, version-guarded mutation, FS_NOT_OBSERVED for unread edits. - * - BARE — WITHOUT the policy plugin, loading only SUBPATH plugins: every - * `fs/*` waterfall falls through to its undefined default, so write/edit are - * unconditional. This proves the subpaths (not just the root) carry no policy - * dependency. + * - 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. @@ -25,9 +24,6 @@ import ToolRegistry from '@deepseek-ai/dsh-tools' import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' import * as FileContext from '@deepseek-ai/dsh-file-context' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' -import * as readPlugin from '@deepseek-ai/dsh-tool-fs/read' -import * as writePlugin from '@deepseek-ai/dsh-tool-fs/write' -import * as editPlugin from '@deepseek-ai/dsh-tool-fs/edit' let dir: string let ctx: Context @@ -243,18 +239,16 @@ describe('default deployment (with dsh-file-context)', () => { }) // -------------------------------------------------------------------------- -// BARE deployment: SUBPATH plugins only, NO policy gate. +// BARE deployment: the tool suite WITHOUT the policy gate. // -------------------------------------------------------------------------- -describe('bare provider (subpath plugins, no dsh-file-context)', () => { +describe('bare provider (no dsh-file-context)', () => { beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-bare-')) ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(LocalFileSystem, { cwd: dir }) - await ctx.plugin(readPlugin) - await ctx.plugin(writePlugin) - fiber = await ctx.plugin(editPlugin) + fiber = await ctx.plugin(ToolFs) }) it('read works (it never needed policy)', async () => { diff --git a/packages/fs/tool-fs/tests/subpaths.spec.ts b/packages/fs/tool-fs/tests/subpaths.spec.ts deleted file mode 100644 index 32a276ca25..0000000000 --- a/packages/fs/tool-fs/tests/subpaths.spec.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Tests for the per-tool subpath plugins (`@deepseek-ai/dsh-tool-fs/read`, - * `/write`, `/edit`): each registers exactly one tool, injects the same services - * (`tools`, `fs`, `systemPrompt`) — NOT a policy service — and cleans up on - * disposal. They boot over the bare `ctx.fs` provider with NO - * `@deepseek-ai/dsh-file-context`, proving each subpath carries no policy-plugin - * dependency. - */ - -import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' -import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' -import type { - FsEditOutcome, - FsInfo, - FsTarget, - FsWriteOutcome, -} from '@deepseek-ai/dsh-fs' -import * as readPlugin from '@deepseek-ai/dsh-tool-fs/read' -import * as writePlugin from '@deepseek-ai/dsh-tool-fs/write' -import * as editPlugin from '@deepseek-ai/dsh-tool-fs/edit' - -class StubFs extends FileSystem { - override async resolve(path: string): Promise { - return { inputPath: path, targetKey: FsTargetKey(path), displayPath: path } - } - override async stat(): Promise { - return { version: FsVersion('v'), type: 'file', size: 0 } - } - override async readText(): Promise { - return '' - } - override async streamText(): Promise> { - return (async function* () { yield '' })() - } - override async writeText(): Promise { - return { operation: 'create', version: FsVersion('v') } - } - override async editText(): Promise { - return { replacements: 1, replaceAll: false, version: FsVersion('v') } - } -} - -async function base() { - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(StubFs) - return ctx -} - -describe('subpath plugins', () => { - it('each registers exactly its one tool (over the bare provider, no policy plugin)', async () => { - const cases: Array<[unknown, string]> = [ - [readPlugin, 'read'], - [writePlugin, 'write'], - [editPlugin, 'edit'], - ] - for (const [plugin, toolName] of cases) { - const ctx = await base() - await ctx.plugin(plugin as Parameters[0]) - expect(ctx.tools.schemas().map(s => s.name)).toEqual([toolName]) - } - }) - - it('cleans up on disposal (HMR safety)', async () => { - const ctx = await base() - const fiber = await ctx.plugin(readPlugin as Parameters[0]) - expect(ctx.tools.schemas()).toHaveLength(1) - await fiber.dispose() - expect(ctx.tools.schemas()).toHaveLength(0) - }) - - it('stays pending without a ctx.fs provider', async () => { - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(writePlugin as Parameters[0]) - expect(ctx.tools.schemas()).toHaveLength(0) - }) -}) diff --git a/packages/fs/tool-fs/tsdown.config.ts b/packages/fs/tool-fs/tsdown.config.ts deleted file mode 100644 index ef07bcf108..0000000000 --- a/packages/fs/tool-fs/tsdown.config.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { defineConfig } from 'tsdown' - -/** - * tool-fs exposes one package root plus one entry per tool plugin, so each tool - * can be loaded or replaced independently as a subpath plugin - * (`@deepseek-ai/dsh-tool-fs/read`, `/write`, `/edit`). The root tsdown builds - * only `lib/types/index.js`, so this override adds the per-tool entries. tsdown - * reads the emitted JS under `lib/types` (from `tsc -b`); declarations come from - * `tsc -b` too (dts: false), matching every package. - */ -export default defineConfig({ - entry: ['lib/types/index.js', 'lib/types/read.js', 'lib/types/write.js', 'lib/types/edit.js'], - outDir: 'lib', - format: ['esm'], - platform: 'node', - target: 'es2024', - fixedExtension: false, - dts: false, - clean: false, -}) - diff --git a/tsconfig.base.json b/tsconfig.base.json index 309c36c33f..7ac09c1725 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -34,9 +34,6 @@ "@cordisjs/plugin-timer": ["./vendor/timer/src"], "@cordisjs/plugin-hmr": ["./vendor/hmr/src"], "@cordisjs/plugin-logger-console": ["./vendor/logger-console/src"], - "@deepseek-ai/dsh-tool-fs/read": ["./packages/fs/tool-fs/src/read.ts"], - "@deepseek-ai/dsh-tool-fs/write": ["./packages/fs/tool-fs/src/write.ts"], - "@deepseek-ai/dsh-tool-fs/edit": ["./packages/fs/tool-fs/src/edit.ts"], // One wildcard maps every @deepseek-ai/dsh- to its source. Package // dir names are unique across groups, so first-on-disk-wins resolution is // unambiguous; adding a package under an existing group needs no edit From f8e99b87404271fe13606f02dd792456bb3649c1 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 29 Jun 2026 10:34:08 +0800 Subject: [PATCH 21/75] refactor(tool-fs): consolidate read rendering; drop the fs/observed try-catch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two cohesion cleanups on the filesystem tool package: - Fold window.ts + types.ts + formatReadOutput into one cordis-free read-render.ts. Line windowing, the FileReadOutcome shape, and output formatting are one concern (the read tool's rendering); splitting them across three files added no value. read.ts is now just the tool (schema + I/O). - Drop observe.ts and emit fs/observed with a plain ctx.emit in read/write/edit. The event is contractually a synchronous, side-effect-only recorder (file-context's listener is a WeakMap.set), so the per-call try/catch guarded against a contract violation that cannot happen under the shipped listener — defensive code for an impossible case. The event contract (dsh-fs JSDoc, README, RFC) is updated to state the fire-and-forget semantics plainly. --- docs/cordis-catalog/events-and-services.md | 2 +- docs/core-data-structures/filesystem.md | 2 +- .../2026-06-26-file-context-as-event-gate.md | 22 +++---- packages/fs/fs/src/index.ts | 14 ++--- packages/fs/tool-fs/README.md | 6 +- packages/fs/tool-fs/src/edit.ts | 17 +++--- packages/fs/tool-fs/src/index.ts | 8 +-- packages/fs/tool-fs/src/observe.ts | 34 ----------- .../tool-fs/src/{window.ts => read-render.ts} | 57 ++++++++++++++++--- packages/fs/tool-fs/src/read.ts | 42 ++++---------- packages/fs/tool-fs/src/types.ts | 32 ----------- packages/fs/tool-fs/src/write.ts | 8 +-- packages/fs/tool-fs/tests/integration.spec.ts | 13 ----- .../{window.spec.ts => read-render.spec.ts} | 0 scripts/type-equiv.manifest.json | 2 +- 15 files changed, 100 insertions(+), 159 deletions(-) delete mode 100644 packages/fs/tool-fs/src/observe.ts rename packages/fs/tool-fs/src/{window.ts => read-render.ts} (65%) delete mode 100644 packages/fs/tool-fs/src/types.ts rename packages/fs/tool-fs/tests/{window.spec.ts => read-render.spec.ts} (100%) diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 37437c9262..e95687d78c 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -199,7 +199,7 @@ 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. A listener MUST be a synchronous, side-effect-only recorder (`@deepseek-ai/dsh-file-context`'s is a `WeakMap.set`); the tool wraps the emit in a try/catch so a synchronous listener bug is logged and swallowed, never failing the already-completed mutation. cordis `emit` does not await listener promises, so this is not an async-error containment seam — async audit/telemetry does not belong here. No listener ⇒ nothing recorded. `actor` is the opaque tool-execution context. +Record that an actor observed a target at a version, after a successful read/write/edit. Fire-and-forget (plain `emit`). A listener MUST be a synchronous, side-effect-only recorder (`@deepseek-ai/dsh-file-context`'s 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 diff --git a/docs/core-data-structures/filesystem.md b/docs/core-data-structures/filesystem.md index 54ea3e05d0..22cda4672d 100644 --- a/docs/core-data-structures/filesystem.md +++ b/docs/core-data-structures/filesystem.md @@ -4,7 +4,7 @@ The filesystem stack is split across four packages: a provider seam ([dsh-fs](.. 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-file-context` 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-file-context` 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/file-context/src/types.ts`](../../packages/fs/file-context/src/types.ts). Read-rendering source: [`packages/fs/tool-fs/src/types.ts`](../../packages/fs/tool-fs/src/types.ts). +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/file-context/src/types.ts`](../../packages/fs/file-context/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) diff --git a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md index 23460b211f..a51cde555d 100644 --- a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md +++ b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md @@ -96,10 +96,10 @@ interface Events { 'fs/edit-expectation'(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. Listeners MUST be synchronous, side-effect- - * only recorders (`dsh-file-context`'s is a WeakMap write); the tool wraps the - * emit in a try/catch so a synchronous listener bug is logged and swallowed, - * never failing the already-completed mutation. No listener ⇒ nothing recorded. + * read/write/edit. Fire-and-forget (plain emit). Listeners MUST be + * synchronous, side-effect-only recorders (`dsh-file-context`'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 @@ -112,19 +112,19 @@ The `fs/*` decision events are **unbound waterfalls dispatched by the tool** (li 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-file-context`: 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 file-context policy 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 windowing** (`window.ts`, `READ_MAX_BYTES`, `READ_MAX_LINE_LENGTH`, `FileReadOutcome`/`FileTextLine`, `STREAM_MIN_SIZE`), which is the tool's rendering detail now that the tool owns the read. Those read-windowing types and helpers move into `dsh-tool-fs`; the policy plugin must not remain a type dependency for the tool. +`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 a contained `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-expectation', target, exec, () => undefined)`, then `ctx.fs.writeText(target, content, expectation)`, then a contained `emit('fs/observed', target, outcome.version, exec)`. **Zero stat in the tool** with or without `dsh-file-context`. -- **edit** — `expectation = await ctx.waterfall('fs/edit-expectation', target, exec, () => undefined)`, then `ctx.fs.editText(target, edit, expectation)`, then a contained `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. +- **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-expectation', 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-file-context`. +- **edit** — `expectation = await ctx.waterfall('fs/edit-expectation', 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-file-context` 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-file-context` short-circuits the thunk before it runs in the default deployment. -**`fs/observed` recording must never fail the tool, because it fires AFTER the mutation already succeeded** — a throw there becomes an `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. The tool therefore wraps the dispatch in a try/catch that logs and swallows synchronous listener bugs (the established fire-and-forget pattern in [agent.ts](../../../../packages/core/agent-loop/src/agent.ts)). The event contract is intentionally narrower than "arbitrary observers": an `fs/observed` listener MUST be synchronous and side-effect-only — `dsh-file-context`'s listener is a `WeakMap.set`, which cannot throw under normal operation and returns no promise. Cordis `emit` does not await listener promises, so the try/catch is NOT an async-error containment mechanism; async 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. +**`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-file-context`'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-file-context`) @@ -154,12 +154,12 @@ This amends — does not reverse — [the split-fs-seam RFC](../simplification/2 ## Acceptance Criteria -- The `dsh-tool-fs` root plugin injects `fs` (+ `tools`/`systemPrompt`), not `fileContext`; it calls `ctx.fs` directly and dispatches the `fs/write-expectation`/`fs/edit-expectation` waterfalls (passing `exec` as the actor) and the contained `fs/observed` emit. Read windowing lives in `dsh-tool-fs`. (No subpath plugins — see the Tool contract above.) +- The `dsh-tool-fs` root plugin injects `fs` (+ `tools`/`systemPrompt`), not `fileContext`; it calls `ctx.fs` directly and dispatches the `fs/write-expectation`/`fs/edit-expectation` 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-file-context` 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-file-context` 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-file-context` 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-expectation` listener AFTER `dsh-file-context` 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. -- **Contained observed recording**: a test with a synchronously throwing `fs/observed` listener performs a write/edit and asserts the tool result is still success (the completed mutation is not turned into an `isError`). The event contract requires synchronous side-effect-only listeners; the try/catch is the synchronous backstop, not async rejection handling. +- **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 `FsWriteExpectation` union is unchanged, and `dsh-file-context`'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-file-context` performs no `stat`. - `stat` budget: read = 1, write = 0, edit = 0 — in the tool, with or without `dsh-file-context` (the bare default returns `undefined`, never stats). A test asserts neither write nor edit stats in the tool on either path. diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts index f23eae98fb..f25a521301 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -117,13 +117,13 @@ declare module 'cordis' { 'fs/edit-expectation'(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. A listener MUST be a synchronous, - * side-effect-only recorder (`@deepseek-ai/dsh-file-context`'s is a - * `WeakMap.set`); the tool wraps the emit in a try/catch so a synchronous - * listener bug is logged and swallowed, never failing the already-completed - * mutation. cordis `emit` does not await listener promises, so this is not an - * async-error containment seam — async audit/telemetry does not belong here. - * No listener ⇒ nothing recorded. `actor` is the opaque tool-execution context. + * read/write/edit. Fire-and-forget (plain `emit`). A listener MUST be a + * synchronous, side-effect-only recorder (`@deepseek-ai/dsh-file-context`'s + * is a `WeakMap.set`): the tool does not guard the emit, so a listener that + * throws 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 diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index 86298031de..bcc5c5cdf1 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -31,8 +31,8 @@ The tools do **not** inject a policy service or inspect any cache. Each tool res The tool passes `exec` (the tool-execution context) as the opaque `actor` on every dispatch. The default thunks return `undefined` (the unconstrained bare provider). When `@deepseek-ai/dsh-file-context` is loaded it occupies the single decision slot — returning `createIfAbsent`/`replaceIfVersion`/`{ version }` or throwing `FS_NOT_OBSERVED` — and records on `fs/observed`. Backend errors (`FsError`) and a thrown `FS_NOT_OBSERVED` flow through `ToolRegistry.execute()` and become `isError` tool results with their `{ name, code }` attached. -## `fs/observed` never fails the tool +## `fs/observed` is fire-and-forget -`fs/observed` fires AFTER the read/write/edit already succeeded, so the tool wraps the emit in a try/catch (`src/observe.ts`) that logs and swallows a synchronous listener bug — otherwise a recording failure would turn a completed mutation into an `isError`. The event contract requires synchronous, side-effect-only listeners; this is the synchronous backstop, not async-error handling. +`fs/observed` fires AFTER the read/write/edit already succeeded, via a plain `ctx.emit`. A listener is contractually a synchronous, side-effect-only recorder (`@deepseek-ai/dsh-file-context`'s is a `WeakMap.set`); the tool does not guard the emit, so a listener that throws would surface as the tool's `isError` result — async or fallible observation does not belong on this event. -The line-windowing mechanics live in `src/window.ts` (Cordis-free, independently unit-tested); `src/read.ts`/`write.ts`/`edit.ts` are the tool executors and `src/index.ts` composes them. +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. diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index 53029808ec..f1eab5e319 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -2,13 +2,12 @@ * The model-facing `edit` tool: update an existing UTF-8 text file by replacing * literal text, requiring a unique match by default. The tool is the executor: * it dispatches the `fs/edit-expectation` waterfall to obtain the optional - * version guard, calls `ctx.fs.editText` directly, and emits a contained - * `fs/observed`. The default thunk returns `undefined` (unconditional edit of - * the current content — the bare provider); a policy plugin - * (`@deepseek-ai/dsh-file-context`) occupies the single decision slot, returning - * `{ version: vObserved }` or throwing `FS_NOT_OBSERVED` for an unread file. The - * tool stats ZERO times either way; a missing target is reported by the provider - * as `FS_STALE_VERSION`. + * version guard, calls `ctx.fs.editText` directly, and emits `fs/observed`. The + * default thunk returns `undefined` (unconditional edit of the current content + * — the bare provider); a policy plugin (`@deepseek-ai/dsh-file-context`) + * 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 */ @@ -19,7 +18,6 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { FsEditOutcome } from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-system-prompt' -import { emitObserved } from './observe.ts' /** Validated `edit` arguments after defaulting. */ interface EditInput { @@ -79,7 +77,8 @@ export function applyEditTool(ctx: Context): void { expectation, exec.signal, ) - emitObserved(ctx, target, outcome.version, exec) + // 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) }] }, })) diff --git a/packages/fs/tool-fs/src/index.ts b/packages/fs/tool-fs/src/index.ts index 8c2123c3d4..285299e352 100644 --- a/packages/fs/tool-fs/src/index.ts +++ b/packages/fs/tool-fs/src/index.ts @@ -26,13 +26,11 @@ import { applyReadTool } from './read.ts' import { applyWriteTool } from './write.ts' import { applyEditTool } from './edit.ts' -export { READ_LIMIT, STREAM_MIN_SIZE, applyReadTool, formatReadOutput, parseReadArgs } from './read.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 { emitObserved } from './observe.ts' -export type { FileTextLine, ReadWindow, WindowResult } from './window.ts' -export { READ_MAX_BYTES, READ_MAX_LINE_LENGTH, buildWindow } from './window.ts' -export type { FileReadOutcome } from './types.ts' +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' diff --git a/packages/fs/tool-fs/src/observe.ts b/packages/fs/tool-fs/src/observe.ts deleted file mode 100644 index 407dc66fbb..0000000000 --- a/packages/fs/tool-fs/src/observe.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * The contained `fs/observed` emit shared by the `read`/`write`/`edit` tools. - * - * `fs/observed` fires AFTER a mutation/read already succeeded, so a throwing - * listener must never turn the completed operation into an `isError` result - * (the tool registry catches a tool throw into an error result). The event - * contract requires a synchronous, side-effect-only listener (the policy - * plugin's is a `WeakMap.set`); this try/catch is the synchronous backstop — - * it logs and swallows a listener bug, mirroring the fire-and-forget pattern in - * the agent loop. It is NOT async-error containment: cordis `emit` does not - * await listener promises, so async observation does not belong on this event. - * - * @module @deepseek-ai/dsh-tool-fs/observe - */ - -import type { Context } from 'cordis' -import type { FsTarget, FsVersion } from '@deepseek-ai/dsh-fs' -import type {} from '@deepseek-ai/dsh-fs' - -/** - * Emit `fs/observed` for a just-completed read/write/edit, containing any - * synchronous listener throw so the already-successful operation still reports - * success. - */ -export function emitObserved(ctx: Context, target: FsTarget, version: FsVersion, actor: object | undefined): void { - try { - ctx.emit('fs/observed', target, version, actor) - } catch (error: unknown) { - // Contained: the read/write/edit already succeeded. An `fs/observed` listener - // MUST be synchronous and side-effect-only; a synchronous bug is logged and - // swallowed so a recording failure never fails the completed operation. - ctx.logger.warn(`fs/observed listener threw for "${target.displayPath}": ${String(error)}`) - } -} diff --git a/packages/fs/tool-fs/src/window.ts b/packages/fs/tool-fs/src/read-render.ts similarity index 65% rename from packages/fs/tool-fs/src/window.ts rename to packages/fs/tool-fs/src/read-render.ts index fb33907710..97a1384792 100644 --- a/packages/fs/tool-fs/src/window.ts +++ b/packages/fs/tool-fs/src/read-render.ts @@ -1,19 +1,23 @@ /** - * Cordis-free line-windowing for `@deepseek-ai/dsh-tool-fs`. Turning a file's + * 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) is the model-facing READ-RENDERING detail the tool owns - * now that the tool reads through `ctx.fs` directly — it is not a storage - * primitive and not freshness policy. + * 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); this module only scans that text for - * newlines and builds the requested window. A capped line buffer means a + * (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 + * `/` envelope the model sees. * - * @module @deepseek-ai/dsh-tool-fs/window + * @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 @@ -40,7 +44,7 @@ export interface FileTextLine { text: string } -/** The windowed result this module builds from a file's decoded text. */ +/** The windowed result {@link buildWindow} produces from a file's decoded text. */ export interface WindowResult { /** Returned lines, already numbered. */ lines: FileTextLine[] @@ -50,6 +54,22 @@ export interface WindowResult { 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 @@ -137,3 +157,24 @@ export async function buildWindow( 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 `${displayPath} +file + +${body} +` +} diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index 54fd553011..31d31424cb 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -2,11 +2,12 @@ * 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/window}), and emits a contained - * `fs/observed` so a policy plugin (`@deepseek-ai/dsh-file-context`) can record - * the read. With no policy plugin the emit is simply unheard. This module owns - * the model-facing schema, argument validation, read windowing, and result - * formatting; the freshness/observation policy is not its concern. + * ({@link module:@deepseek-ai/dsh-tool-fs/read-render}), and emits `fs/observed` + * so a policy plugin (`@deepseek-ai/dsh-file-context`) can record the read. With + * 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 */ @@ -17,9 +18,8 @@ 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 } from './window.ts' -import { emitObserved } from './observe.ts' -import type { FileReadOutcome } from './types.ts' +import { buildWindow, formatReadOutput } from './read-render.ts' +import type { FileReadOutcome } from './read-render.ts' /** Default and maximum number of lines returned by one `read` call. */ export const READ_LIMIT = 2000 @@ -50,27 +50,6 @@ export function parseReadArgs(args: { file_path: string; offset?: number; limit? return { filePath: args.file_path, offset, limit } } -/** 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 `${displayPath} -file - -${body} -` -} - /** Register the `read` tool and its system-prompt guidance. */ export function applyReadTool(ctx: Context): void { ctx.systemPrompt.section({ @@ -114,7 +93,10 @@ export function applyReadTool(ctx: Context): void { version: info.version, ...window.truncatedByBytes ? { truncatedByBytes: true } : {}, } - emitObserved(ctx, target, info.version, exec) + // 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) }] }, })) diff --git a/packages/fs/tool-fs/src/types.ts b/packages/fs/tool-fs/src/types.ts deleted file mode 100644 index 48a0592abb..0000000000 --- a/packages/fs/tool-fs/src/types.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Vocabulary for the model-facing filesystem tools (`@deepseek-ai/dsh-tool-fs`): - * the structured read outcome the `read` tool renders. The read window - * (`offset`/`limit`) and per-line shape live in - * {@link module:@deepseek-ai/dsh-tool-fs/window}; this file owns the assembled - * outcome the tool formats. - * - * The provider vocabulary (`FsTarget`, `FsVersion`, write/edit shapes) is - * re-used from `@deepseek-ai/dsh-fs` — this package owns only the model-facing - * read-rendering shape on top of it. - * - * @module @deepseek-ai/dsh-tool-fs/types - */ - -import type { FsVersion } from '@deepseek-ai/dsh-fs' -import type { FileTextLine } from './window.ts' - -/** Outcome of a bounded text read — what the model-facing `read` tool renders. */ -export interface FileReadOutcome { - /** 1-based first line requested. */ - offset: number - /** Maximum number of lines requested. */ - limit: number - /** Returned lines, already numbered. */ - lines: FileTextLine[] - /** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */ - totalLines: number - /** Whether selected output hit the byte cap before EOF or the requested limit. */ - truncatedByBytes?: true - /** Opaque version of the file at read time. */ - version: FsVersion -} diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index 407744ec03..564d99ddd6 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -2,8 +2,8 @@ * The model-facing `write` tool: create or fully replace a UTF-8 text file. The * tool is the executor: it dispatches the `fs/write-expectation` waterfall to * obtain the optional version guard, calls `ctx.fs.writeText` directly, and - * emits a contained `fs/observed`. The default thunk returns `undefined` - * (unconditional create-or-overwrite — the bare provider); a policy plugin + * emits `fs/observed`. The default thunk returns `undefined` (unconditional + * create-or-overwrite — the bare provider); a policy plugin * (`@deepseek-ai/dsh-file-context`) occupies the single decision slot and * returns `createIfAbsent`/`replaceIfVersion` instead. The tool stats ZERO * times either way. @@ -17,7 +17,6 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { FsWriteOutcome } from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-system-prompt' -import { emitObserved } from './observe.ts' /** Validate value constraints the schema DSL can't express. */ export function parseWriteArgs(args: { file_path: string; content: string }): { filePath: string; content: string } { @@ -57,7 +56,8 @@ export function applyWriteTool(ctx: Context): void { // replaceIfVersion; the bare default is undefined (unconditional). No stat. const expectation = await ctx.waterfall('fs/write-expectation', target, exec, () => undefined) const outcome = await ctx.fs.writeText(target, input.content, expectation, exec.signal) - emitObserved(ctx, target, outcome.version, exec) + // 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) }] }, })) diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index cc99d52b80..087227f790 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -223,19 +223,6 @@ describe('default deployment (with dsh-file-context)', () => { statSpy.mockRestore() }) }) - - describe('contained fs/observed recording', () => { - it('a synchronously throwing fs/observed listener does not fail the completed write', async () => { - ctx.on('fs/observed', () => { throw new Error('listener boom') }) - const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) - const result = await call('write', { file_path: 'a.txt', content: 'hi' }) - // The write succeeded on disk; the listener throw was logged and swallowed. - expect(result.isError).toBe(false) - expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hi') - expect(warn).toHaveBeenCalled() - warn.mockRestore() - }) - }) }) // -------------------------------------------------------------------------- diff --git a/packages/fs/tool-fs/tests/window.spec.ts b/packages/fs/tool-fs/tests/read-render.spec.ts similarity index 100% rename from packages/fs/tool-fs/tests/window.spec.ts rename to packages/fs/tool-fs/tests/read-render.spec.ts diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 5b9b4b4673..68352a111b 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -51,7 +51,7 @@ { "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": "FileContextExec", "source": "packages/fs/file-context/src/types.ts" }, - { "doc": "docs/core-data-structures/filesystem.md", "symbol": "FileReadOutcome", "source": "packages/fs/tool-fs/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" }, From b92a3c531a19710052a2a145e560a49e107b0d0e Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 29 Jun 2026 15:21:12 +0800 Subject: [PATCH 22/75] feat(web): add DeepSeek-backed web search provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add @deepseek-ai/dsh-web-search-deepseek: a WebSearchProvider that calls DeepSeek's Anthropic-compatible Messages API with the native web_search_20250305 server tool and parses the structured web_search_tool_result blocks into the ctx.web seam's WebSearchResult. - Namespace plugin (inject: ['web']), no default export — registers into ctx.web like dsh-llm-deepseek registers into ctx.llm. - Strict mode: a response with no web_search_tool_result block throws WEB_PROVIDER_ERROR rather than scraping URLs from model prose. - Reuses $DEEPSEEK_API_KEY; baseURL defaults to the Anthropic-compatible base (api.deepseek.com/anthropic/v1) and does NOT reuse $DEEPSEEK_BASE_URL, which belongs to the chat-completions LLM adapter. - snippet joined from text-block citations; sources deduped by url. - Two-stage build layout (outDir lib/types) matching the other web packages; registered in tsconfig.json, tsconfig.build.json, knip.json, and docs/module-graph.md. --- docs/module-graph.md | 2 + knip.json | 4 + packages/web/web-search-deepseek/README.md | 36 ++ packages/web/web-search-deepseek/package.json | 35 ++ packages/web/web-search-deepseek/src/index.ts | 81 +++++ .../web/web-search-deepseek/src/provider.ts | 217 ++++++++++++ packages/web/web-search-deepseek/src/types.ts | 58 ++++ .../web-search-deepseek/tests/deepseek.e2e.ts | 36 ++ .../tests/deepseek.spec.ts | 326 ++++++++++++++++++ .../web/web-search-deepseek/tsconfig.json | 24 ++ pnpm-lock.yaml | 13 + tsconfig.build.json | 1 + tsconfig.json | 1 + 13 files changed, 834 insertions(+) create mode 100644 packages/web/web-search-deepseek/README.md create mode 100644 packages/web/web-search-deepseek/package.json create mode 100644 packages/web/web-search-deepseek/src/index.ts create mode 100644 packages/web/web-search-deepseek/src/provider.ts create mode 100644 packages/web/web-search-deepseek/src/types.ts create mode 100644 packages/web/web-search-deepseek/tests/deepseek.e2e.ts create mode 100644 packages/web/web-search-deepseek/tests/deepseek.spec.ts create mode 100644 packages/web/web-search-deepseek/tsconfig.json diff --git a/docs/module-graph.md b/docs/module-graph.md index a17680cb6d..cddfabee14 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -25,6 +25,7 @@ graph TD llm-replay --> session session-persistence --> session web-fetch-local --> web + web-search-deepseek --> web web-search-exa --> web web-search-perplexity --> web invariants --> agent @@ -116,6 +117,7 @@ graph TD | `llm-replay` | `llm`, `session` | | `session-persistence` | `session` | | `web-fetch-local` | `web` | +| `web-search-deepseek` | `web` | | `web-search-exa` | `web` | | `web-search-perplexity` | `web` | | `invariants` | `agent`, `llm`, `session` | diff --git a/knip.json b/knip.json index 3f0a56097c..f0b8e44705 100644 --- a/knip.json +++ b/knip.json @@ -37,6 +37,10 @@ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, + "packages/web/web-search-deepseek": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, "packages/ui/acp-agent": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] diff --git a/packages/web/web-search-deepseek/README.md b/packages/web/web-search-deepseek/README.md new file mode 100644 index 0000000000..5b56601bbb --- /dev/null +++ b/packages/web/web-search-deepseek/README.md @@ -0,0 +1,36 @@ +# @deepseek-ai/dsh-web-search-deepseek + +A [DeepSeek](https://deepseek.com)-backed `WebSearchProvider` for the harness [web capability seam](../web/README.md) (`ctx.web`). It calls DeepSeek's **Anthropic-compatible Messages API** (`POST {baseURL}/messages`) with the native `web_search_20250305` server tool enabled, and maps the structured `web_search_tool_result` blocks DeepSeek returns into the seam's normalized `WebSearchResult`. + +This is an **implementation** package: it registers a provider into `ctx.web`, it does not own the key and it does not register a model-facing tool. Like `@deepseek-ai/dsh-llm-deepseek`, it is a function/namespace plugin (`inject: ['web']`). The Anthropic wire shape is a provider-private detail — it does **not** make this provider depend on `ctx.llm`. + +## How it differs from a dedicated search endpoint + +Exa and Perplexity expose dedicated search endpoints; DeepSeek does not. Instead this provider issues a **full Messages model call** carrying the `web_search` server tool, so one search costs a complete model turn in latency and tokens — heavier than a pure retrieval endpoint. DeepSeek runs the search server-side and returns **structured** `web_search_tool_result` blocks; the provider parses those blocks and **never scrapes URLs out of model prose**. + +**Strict mode**: if the response carries no `web_search_tool_result` block (native search did not trigger), the provider throws `WebError` `WEB_PROVIDER_ERROR` rather than degrading to prose-scraping — honest and debuggable. + +It reuses `$DEEPSEEK_API_KEY` (no new secret) but **not** `$DEEPSEEK_BASE_URL`: the search endpoint is the Anthropic-compatible base (`https://api.deepseek.com/anthropic/v1`), distinct from the chat-completions base (`https://api.deepseek.com`) the LLM adapter uses. + +## Config + +| Key | Default | Meaning | +|---|---|---| +| `apiKey` | `$DEEPSEEK_API_KEY` | DeepSeek API key. Empty/absent → provider `status()` reports `missing-credential`. Sent as both `x-api-key` and `Authorization: Bearer` (official vs Anthropic-compatible proxy). | +| `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic-compatible endpoint base; `/messages` is appended. Use a separate env var such as `$DEEPSEEK_SEARCH_BASE_URL` when overriding it; do not reuse `$DEEPSEEK_BASE_URL`, which belongs to the chat-completions LLM adapter. An unparseable value makes `status()` report `misconfigured`. | +| `model` | `deepseek-v4-flash` | Anthropic-format model name. | +| `apiVersion` | `2023-06-01` | `anthropic-version` header value. | +| `maxTokens` | `4096` | Upper bound on generated tokens for the Messages request. | +| `maxUses` | `5` | Maximum `web_search` server-tool uses per request. | + +```yaml +- id: web-search-deepseek + name: '@deepseek-ai/dsh-web-search-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL +``` + +## Mapping + +DeepSeek returns no provider-generated answer surface this provider trusts as `content`, so `content` is omitted. `sources[]` is built from the `web_search_result` items inside `web_search_tool_result` blocks: `url` ← `url`, `title` ← `title`, `publishedAt` ← `page_age`. The per-source `snippet` lives separately in a `text` block's `citations[]` (a `cited_text` keyed by `url`), so the provider joins the two — a result with no citation excerpt simply has no `snippet`. Results are deduped by `url` (a `maxUses > 1` request can surface the same URL across searches). DeepSeek's `web_search` has no result-count knob (only `maxUses`), so `maxResults` is enforced by the seam (truncating `sources[]` and setting `truncated`). Provider failures surface as `WebError` `WEB_PROVIDER_ERROR`; an aborted request surfaces as `WEB_ABORTED`. diff --git a/packages/web/web-search-deepseek/package.json b/packages/web/web-search-deepseek/package.json new file mode 100644 index 0000000000..617e9f2768 --- /dev/null +++ b/packages/web/web-search-deepseek/package.json @@ -0,0 +1,35 @@ +{ + "name": "@deepseek-ai/dsh-web-search-deepseek", + "description": "DeepSeek-backed search provider (native web_search via the Anthropic-compatible API) for the DeepSeek Harness web capability seam (ctx.web)", + "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-web": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-web": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/web/web-search-deepseek/src/index.ts b/packages/web/web-search-deepseek/src/index.ts new file mode 100644 index 0000000000..9a04955dd9 --- /dev/null +++ b/packages/web/web-search-deepseek/src/index.ts @@ -0,0 +1,81 @@ +/** + * `@deepseek-ai/dsh-web-search-deepseek`: registers a DeepSeek-backed + * `WebSearchProvider` with `ctx.web`. A function/namespace plugin (NOT a + * default-export service): it registers INTO the seam's provider registry, like + * `@deepseek-ai/dsh-llm-deepseek` registers an adapter into `ctx.llm`. + * + * The provider talks to DeepSeek's Anthropic-compatible Messages API with the + * native `web_search_20250305` server tool. It reuses `$DEEPSEEK_API_KEY` (no + * new secret) but NOT `$DEEPSEEK_BASE_URL` — the search endpoint is the + * Anthropic-compatible base, distinct from the chat-completions base the LLM + * adapter uses. + * + * @module @deepseek-ai/dsh-web-search-deepseek + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import type {} from '@deepseek-ai/dsh-web' +import { + DeepSeekSearchProvider, + DEEPSEEK_DEFAULT_API_VERSION, + DEEPSEEK_DEFAULT_BASE_URL, + DEEPSEEK_DEFAULT_MAX_TOKENS, + DEEPSEEK_DEFAULT_MAX_USES, + DEEPSEEK_DEFAULT_MODEL, +} from './provider.ts' + +export { + DeepSeekSearchProvider, + DEEPSEEK_DEFAULT_API_VERSION, + DEEPSEEK_DEFAULT_BASE_URL, + DEEPSEEK_DEFAULT_MAX_TOKENS, + DEEPSEEK_DEFAULT_MAX_USES, + DEEPSEEK_DEFAULT_MODEL, + DEEPSEEK_PROVIDER_ID, + citationSnippets, + mapAnthropicResponse, +} from './provider.ts' +export type { DeepSeekSearchProviderOptions } from './provider.ts' + +/** Cordis plugin name used by loader diagnostics. */ +export const name = 'web-search-deepseek' + +/** The web seam this provider registers into. */ +export const inject = ['web'] + +export interface Config { + /** DeepSeek API key. Falls back to `$DEEPSEEK_API_KEY`. Empty → unavailable. */ + apiKey?: string + /** Anthropic-compatible endpoint base; `/messages` is appended. */ + baseURL?: string + /** Anthropic-format model name. Defaults to `deepseek-v4-flash`. */ + model?: string + /** `anthropic-version` header value. Defaults to `2023-06-01`. */ + apiVersion?: string + /** Upper bound on generated tokens for the Messages request. Defaults to 4096. */ + maxTokens?: number + /** Maximum `web_search` server-tool uses per request. Defaults to 5. */ + maxUses?: number +} + +export const Config: z = z.object({ + apiKey: z.string(), + baseURL: z.string(), + model: z.string(), + apiVersion: z.string(), + maxTokens: z.natural(), + maxUses: z.natural(), +}) + +/** Register the DeepSeek search provider with `ctx.web`. */ +export function apply(ctx: Context, config: Config): void { + ctx.web.registerSearchProvider(new DeepSeekSearchProvider({ + apiKey: config.apiKey ?? process.env.DEEPSEEK_API_KEY ?? '', + baseURL: config.baseURL ?? DEEPSEEK_DEFAULT_BASE_URL, + model: config.model ?? DEEPSEEK_DEFAULT_MODEL, + apiVersion: config.apiVersion ?? DEEPSEEK_DEFAULT_API_VERSION, + maxTokens: config.maxTokens ?? DEEPSEEK_DEFAULT_MAX_TOKENS, + maxUses: config.maxUses ?? DEEPSEEK_DEFAULT_MAX_USES, + })) +} diff --git a/packages/web/web-search-deepseek/src/provider.ts b/packages/web/web-search-deepseek/src/provider.ts new file mode 100644 index 0000000000..5d02ad01ab --- /dev/null +++ b/packages/web/web-search-deepseek/src/provider.ts @@ -0,0 +1,217 @@ +/** + * `DeepSeekSearchProvider`: a `WebSearchProvider` backed by DeepSeek's + * Anthropic-compatible Messages API with the native `web_search_20250305` server + * tool enabled. + * + * Unlike a dedicated search endpoint (Exa's `POST /search`, Perplexity's + * `/chat/completions`), this issues a FULL Messages model call carrying a server + * tool, so a search costs a complete model turn in latency and tokens. In return + * DeepSeek runs the search server-side and returns STRUCTURED + * `web_search_tool_result` blocks — this provider parses those blocks and never + * scrapes URLs out of model prose. Strict mode: if the response carries no + * `web_search_tool_result` block (native search did not trigger), it throws + * `WEB_PROVIDER_ERROR` rather than degrading to prose-scraping. + * + * Network requests use platform-native `fetch` (Node 24), mirroring + * `@deepseek-ai/dsh-llm-deepseek`'s adapter — not a cordis HTTP-client service. + * The Anthropic wire shape is a provider-private detail and does NOT make this + * provider depend on `ctx.llm`. + * + * @module @deepseek-ai/dsh-web-search-deepseek/provider + */ + +import { WebError } from '@deepseek-ai/dsh-web' +import type { + WebProviderStatus, + WebSearchProvider, + WebSearchRequest, + WebSearchResult, + WebSearchSource, +} from '@deepseek-ai/dsh-web' +import type { + AnthropicError, + AnthropicResponse, + ContentBlock, + TextBlock, + WebSearchToolResultBlock, +} from './types.ts' + +/** Stable id this provider registers under. */ +export const DEEPSEEK_PROVIDER_ID = 'deepseek' + +/** + * Default endpoint: DeepSeek's Anthropic-compatible surface, `/v1` included + * (`/messages` is appended). This is NOT the chat-completions base + * (`https://api.deepseek.com`) `@deepseek-ai/dsh-llm-deepseek` uses, so this + * provider does NOT reuse `$DEEPSEEK_BASE_URL` — only the API key is shared. + */ +export const DEEPSEEK_DEFAULT_BASE_URL = 'https://api.deepseek.com/anthropic/v1' + +/** Default Anthropic-format model name (aligned with the repo's DeepSeek model vocabulary). */ +export const DEEPSEEK_DEFAULT_MODEL = 'deepseek-v4-flash' + +/** Default `anthropic-version` header value. */ +export const DEEPSEEK_DEFAULT_API_VERSION = '2023-06-01' + +/** Default upper bound on generated tokens for the Messages request. */ +export const DEEPSEEK_DEFAULT_MAX_TOKENS = 4096 + +/** Default maximum `web_search` server-tool uses per request. */ +export const DEEPSEEK_DEFAULT_MAX_USES = 5 + +/** Attribution header sent on every request. Bump with the package version. */ +const USER_AGENT = 'deepseek-harness/0.0.1' + +export interface DeepSeekSearchProviderOptions { + /** DeepSeek API key. Empty/absent → `status()` reports `missing-credential`. */ + apiKey: string + /** Endpoint base; `/messages` is appended. */ + baseURL: string + /** Anthropic-format model name. */ + model: string + /** `anthropic-version` header value. */ + apiVersion: string + /** Upper bound on generated tokens for the Messages request. */ + maxTokens: number + /** Maximum `web_search` server-tool uses per request. */ + maxUses: number +} + +/** + * Build a `url → cited_text` map from every `text` block's `citations[]`. This + * is the snippet surface: Anthropic `web_search_result` items carry + * `url`/`title`/`page_age` but typically NO inline snippet — the excerpt lives + * in a separate `text` block's citation, keyed by `url` (first occurrence wins). + */ +export function citationSnippets(blocks: readonly ContentBlock[]): Map { + const map = new Map() + for (const block of blocks) { + if (block.type !== 'text') continue + for (const cite of (block as TextBlock).citations ?? []) { + if (cite.url != null && cite.url.length > 0 && cite.cited_text != null && cite.cited_text.length > 0 && !map.has(cite.url)) { + map.set(cite.url, cite.cited_text) + } + } + } + return map +} + +/** + * Map a DeepSeek Anthropic Messages response to a normalized search result. + * Walks `web_search_tool_result` blocks for citeable `web_search_result` items, + * joins each to its citation excerpt as `snippet`, and dedupes by `url` (a + * `max_uses > 1` request can surface the same URL across searches). The seam + * owns the final `maxResults` truncation, so `truncated` is always `false` here. + * + * Throws `WEB_PROVIDER_ERROR` (strict mode) when no `web_search_tool_result` + * block is present — native search did not trigger, and prose-scraping is not a + * fallback. + */ +export function mapAnthropicResponse(query: string, response: AnthropicResponse): WebSearchResult { + const blocks = response.content ?? [] + const resultBlocks = blocks.filter( + (block): block is WebSearchToolResultBlock => block.type === 'web_search_tool_result', + ) + if (resultBlocks.length === 0) { + throw new WebError( + 'DeepSeek returned no web_search_tool_result blocks; the request may not have triggered native web search', + 'WEB_PROVIDER_ERROR', + ) + } + + const snippets = citationSnippets(blocks) + const seen = new Set() + const sources: WebSearchSource[] = [] + for (const block of resultBlocks) { + for (const item of block.content ?? []) { + if (item.type !== 'web_search_result' || item.url.length === 0 || seen.has(item.url)) continue + seen.add(item.url) + const snippet = snippets.get(item.url) + sources.push({ + url: item.url, + ...item.title != null && item.title.length > 0 ? { title: item.title } : {}, + ...snippet != null && snippet.length > 0 ? { snippet } : {}, + ...item.page_age != null && item.page_age.length > 0 ? { publishedAt: item.page_age } : {}, + }) + } + } + return { providerId: DEEPSEEK_PROVIDER_ID, query, sources, truncated: false } +} + +/** The DeepSeek-backed search provider. */ +export class DeepSeekSearchProvider implements WebSearchProvider { + readonly id = DEEPSEEK_PROVIDER_ID + + constructor(private readonly options: DeepSeekSearchProviderOptions) {} + + status(): WebProviderStatus { + if (this.options.apiKey.length === 0) return { available: false, reason: 'missing-credential' } + if (!URL.canParse(this.options.baseURL)) return { available: false, reason: 'misconfigured' } + return { available: true } + } + + async search(request: WebSearchRequest, exec?: { readonly signal?: AbortSignal }): Promise { + let response: Response + try { + response = await fetch(`${this.options.baseURL}/messages`, { + method: 'POST', + headers: { + // Official DeepSeek expects `x-api-key`; an Anthropic-compatible proxy + // may expect `Authorization: Bearer` — send both so either resolves. + 'x-api-key': this.options.apiKey, + 'authorization': `Bearer ${this.options.apiKey}`, + 'anthropic-version': this.options.apiVersion, + 'content-type': 'application/json', + 'accept': 'application/json', + 'user-agent': USER_AGENT, + }, + body: JSON.stringify({ + model: this.options.model, + max_tokens: this.options.maxTokens, + messages: [{ + role: 'user', + content: [{ type: 'text', text: `Perform a web search for the query: ${request.query}` }], + }], + tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: this.options.maxUses }], + }), + ...exec?.signal ? { signal: exec.signal } : {}, + }) + } catch (error: unknown) { + if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error }) + throw new WebError(`DeepSeek search request failed: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) + } + + if (!response.ok) { + const status = response.status + let message = `DeepSeek API error (HTTP ${status})` + try { + const parsed = await response.json() as AnthropicError + const detail = typeof parsed.error === 'string' ? parsed.error : parsed.error?.message ?? parsed.message + if (detail !== undefined && detail.length > 0) message = detail + } catch (error: unknown) { + // An abort fired mid-body must surface as WEB_ABORTED, not be swallowed + // into a generic HTTP-error message — cancellation is not a provider + // error (the seam's cancellation contract). + if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error }) + // Otherwise: the HTTP status is already captured in `message` above; a + // malformed/non-JSON error body (normal for gateway 5xx/429s) can only + // cost a richer provider message, never the real error. + } + throw new WebError(message, 'WEB_PROVIDER_ERROR') + } + + let payload: AnthropicResponse + try { + payload = await response.json() as AnthropicResponse + } catch (error: unknown) { + if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error }) + throw new WebError(`DeepSeek returned an unparseable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) + } + return mapAnthropicResponse(request.query, payload) + } +} + +/** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */ +function isAbortError(error: unknown): boolean { + return error instanceof DOMException && error.name === 'AbortError' +} diff --git a/packages/web/web-search-deepseek/src/types.ts b/packages/web/web-search-deepseek/src/types.ts new file mode 100644 index 0000000000..bd88ed9663 --- /dev/null +++ b/packages/web/web-search-deepseek/src/types.ts @@ -0,0 +1,58 @@ +/** + * Wire types for DeepSeek's Anthropic-compatible Messages API + * (`POST {baseURL}/messages`) with the native `web_search_20250305` server tool + * enabled. Types only — no runtime code. + * + * DeepSeek returns structured content blocks: `web_search_tool_result` blocks + * carry the citeable `web_search_result` items (`url`/`title`/`page_age`), while + * the snippet/excerpt for a URL lives separately in a `text` block's + * `citations[]` (a `cited_text` keyed by `url`). The provider joins the two. + * + * The Anthropic wire shape is a provider-private detail; it does not make this + * provider depend on `ctx.llm`. + * + * @module @deepseek-ai/dsh-web-search-deepseek/types + */ + +/** A `web_search_result` item inside a `web_search_tool_result` block. */ +export interface WebSearchResultItem { + type: string + url: string + title?: string | null + /** Provider-supplied page age/recency string (mapped to `publishedAt`). */ + page_age?: string | null +} + +/** A `web_search_tool_result` content block: the citeable result surface. */ +export interface WebSearchToolResultBlock { + type: 'web_search_tool_result' + content?: WebSearchResultItem[] +} + +/** One citation location inside a `text` block (the snippet surface). */ +export interface CitationLocation { + type?: string + url?: string | null + cited_text?: string | null +} + +/** A `text` content block: the model's prose plus per-URL citations. */ +export interface TextBlock { + type: 'text' + text?: string | null + citations?: CitationLocation[] +} + +/** Any content block; only `web_search_tool_result` and `text` are consumed. */ +export type ContentBlock = WebSearchToolResultBlock | TextBlock | { type: string } + +/** DeepSeek's Anthropic Messages response envelope. */ +export interface AnthropicResponse { + content?: ContentBlock[] +} + +/** DeepSeek's error response envelope (best-effort; fields vary). */ +export interface AnthropicError { + error?: { message?: string } | string + message?: string +} diff --git a/packages/web/web-search-deepseek/tests/deepseek.e2e.ts b/packages/web/web-search-deepseek/tests/deepseek.e2e.ts new file mode 100644 index 0000000000..d06e384b31 --- /dev/null +++ b/packages/web/web-search-deepseek/tests/deepseek.e2e.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { + DeepSeekSearchProvider, + DEEPSEEK_DEFAULT_API_VERSION, + DEEPSEEK_DEFAULT_BASE_URL, + DEEPSEEK_DEFAULT_MAX_TOKENS, + DEEPSEEK_DEFAULT_MAX_USES, + DEEPSEEK_DEFAULT_MODEL, +} from '@deepseek-ai/dsh-web-search-deepseek' + +/** + * Real-API smoke for the DeepSeek search provider. Self-skips without + * `$DEEPSEEK_API_KEY`, per the with-key e2e policy in AGENTS.md § Secrets. This + * is the only test that proves DeepSeek's Anthropic-compatible endpoint actually + * triggers native `web_search` and returns the structured result blocks the + * provider parses — a mock cannot confirm the wire shape is real. + */ +const apiKey = process.env.DEEPSEEK_API_KEY +const maybe = apiKey !== undefined && apiKey.length > 0 ? describe : describe.skip + +maybe('DeepSeekSearchProvider real API', () => { + it('returns citeable sources for a live query via native web_search', async () => { + const provider = new DeepSeekSearchProvider({ + apiKey: apiKey!, + baseURL: process.env.DEEPSEEK_SEARCH_BASE_URL ?? DEEPSEEK_DEFAULT_BASE_URL, + model: process.env.DEEPSEEK_SEARCH_MODEL ?? DEEPSEEK_DEFAULT_MODEL, + apiVersion: DEEPSEEK_DEFAULT_API_VERSION, + maxTokens: DEEPSEEK_DEFAULT_MAX_TOKENS, + maxUses: DEEPSEEK_DEFAULT_MAX_USES, + }) + const result = await provider.search({ query: 'What is the DeepSeek coding agent?', maxResults: 5 }) + expect(result.providerId).toBe('deepseek') + expect(result.sources.length).toBeGreaterThan(0) + for (const source of result.sources) expect(source.url).toMatch(/^https?:\/\//) + }, 60_000) +}) diff --git a/packages/web/web-search-deepseek/tests/deepseek.spec.ts b/packages/web/web-search-deepseek/tests/deepseek.spec.ts new file mode 100644 index 0000000000..ff6b37cfa2 --- /dev/null +++ b/packages/web/web-search-deepseek/tests/deepseek.spec.ts @@ -0,0 +1,326 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import WebService from '@deepseek-ai/dsh-web' +import { + DeepSeekSearchProvider, + citationSnippets, + mapAnthropicResponse, + DEEPSEEK_PROVIDER_ID, +} from '@deepseek-ai/dsh-web-search-deepseek' +import * as deepseekPlugin from '@deepseek-ai/dsh-web-search-deepseek' +import type { AnthropicResponse } from '@deepseek-ai/dsh-web-search-deepseek/src/types.ts' + +const options = { + apiKey: 'ds-key', + baseURL: 'https://api.deepseek.test/anthropic/v1', + model: 'deepseek-chat', + apiVersion: '2023-06-01', + maxTokens: 4096, + maxUses: 5, +} + +function jsonResponse(body: unknown, init: ResponseInit = {}): Response { + return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' }, ...init }) +} + +/** A response with one result block plus a text block carrying the snippet. */ +function searchResponse(): AnthropicResponse { + return { + content: [ + { type: 'text', text: 'Here is what I found.', citations: [{ type: 'web_search_result_location', url: 'https://a.test', cited_text: 'excerpt for A' }] }, + { + type: 'web_search_tool_result', + content: [ + { type: 'web_search_result', url: 'https://a.test', title: 'A', page_age: '2026-02-02' }, + { type: 'web_search_result', url: 'https://b.test', title: 'B' }, + ], + }, + ], + } +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('citationSnippets', () => { + it('maps url → cited_text from text blocks, first occurrence wins', () => { + const map = citationSnippets([ + { type: 'text', citations: [{ url: 'https://a.test', cited_text: 'first' }, { url: 'https://a.test', cited_text: 'second' }] }, + { type: 'text', citations: [{ url: 'https://b.test', cited_text: 'b text' }] }, + ]) + expect(map.get('https://a.test')).toBe('first') + expect(map.get('https://b.test')).toBe('b text') + }) + + it('ignores citations missing url or cited_text', () => { + const map = citationSnippets([ + { type: 'text', citations: [{ url: 'https://a.test' }, { cited_text: 'orphan' }, { url: '', cited_text: 'empty url' }] }, + ]) + expect(map.size).toBe(0) + }) +}) + +describe('mapAnthropicResponse', () => { + it('joins result items to citation snippets and maps page_age to publishedAt', () => { + const result = mapAnthropicResponse('q', searchResponse()) + expect(result).toEqual({ + providerId: DEEPSEEK_PROVIDER_ID, + query: 'q', + sources: [ + { url: 'https://a.test', title: 'A', snippet: 'excerpt for A', publishedAt: '2026-02-02' }, + { url: 'https://b.test', title: 'B' }, + ], + truncated: false, + }) + }) + + it('dedupes repeated urls across result blocks (first wins)', () => { + const result = mapAnthropicResponse('q', { + content: [ + { type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test', title: 'first' }] }, + { type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test', title: 'second' }] }, + ], + }) + expect(result.sources).toEqual([{ url: 'https://a.test', title: 'first' }]) + }) + + it('skips non-result items and items with an empty url', () => { + const result = mapAnthropicResponse('q', { + content: [{ + type: 'web_search_tool_result', + content: [ + { type: 'web_search_result_error', url: 'https://err.test' }, + { type: 'web_search_result', url: '' }, + { type: 'web_search_result', url: 'https://ok.test' }, + ], + }], + }) + expect(result.sources).toEqual([{ url: 'https://ok.test' }]) + }) + + it('omits optional fields when absent or empty', () => { + const result = mapAnthropicResponse('q', { + content: [{ type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test', title: '', page_age: '' }] }], + }) + expect(result.sources).toEqual([{ url: 'https://a.test' }]) + }) + + it('tolerates a text block with no citations', () => { + const result = mapAnthropicResponse('q', { + content: [ + { type: 'text', text: 'no citations here' }, + { type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test', title: 'A' }] }, + ], + }) + expect(result.sources).toEqual([{ url: 'https://a.test', title: 'A' }]) + }) + + it('tolerates a result block with no content array', () => { + const result = mapAnthropicResponse('q', { + content: [ + { type: 'web_search_tool_result' }, + { type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test' }] }, + ], + }) + expect(result.sources).toEqual([{ url: 'https://a.test' }]) + }) + + it('throws WEB_PROVIDER_ERROR (strict mode) when no result block is present', () => { + expect(() => mapAnthropicResponse('q', { content: [{ type: 'text', text: 'just prose, no search' }] })) + .toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) + }) + + it('throws WEB_PROVIDER_ERROR when content is absent entirely', () => { + expect(() => mapAnthropicResponse('q', {})) + .toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) + }) +}) + +describe('DeepSeekSearchProvider status', () => { + it('is unavailable without a key', () => { + expect(new DeepSeekSearchProvider({ ...options, apiKey: '' }).status()) + .toEqual({ available: false, reason: 'missing-credential' }) + }) + + it('is available with a key', () => { + expect(new DeepSeekSearchProvider(options).status()).toEqual({ available: true }) + }) + + it('is misconfigured when the base URL is unparseable', () => { + expect(new DeepSeekSearchProvider({ ...options, baseURL: 'not a url' }).status()) + .toEqual({ available: false, reason: 'misconfigured' }) + }) +}) + +describe('DeepSeekSearchProvider request mapping', () => { + it('posts an Anthropic Messages request enabling the web_search server tool', async () => { + const fetchMock = vi.fn(async () => jsonResponse(searchResponse())) + vi.stubGlobal('fetch', fetchMock) + await new DeepSeekSearchProvider(options).search({ query: 'hello' }) + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe('https://api.deepseek.test/anthropic/v1/messages') + const headers = init.headers as Record + expect(headers['x-api-key']).toBe('ds-key') + expect(headers['authorization']).toBe('Bearer ds-key') + expect(headers['anthropic-version']).toBe('2023-06-01') + expect(JSON.parse(init.body as string)).toEqual({ + model: 'deepseek-chat', + max_tokens: 4096, + messages: [{ role: 'user', content: [{ type: 'text', text: 'Perform a web search for the query: hello' }] }], + tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: 5 }], + }) + }) + + it('forwards the abort signal', async () => { + const fetchMock = vi.fn(async () => jsonResponse(searchResponse())) + vi.stubGlobal('fetch', fetchMock) + const controller = new AbortController() + await new DeepSeekSearchProvider(options).search({ query: 'q' }, { signal: controller.signal }) + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(init.signal).toBe(controller.signal) + }) +}) + +describe('DeepSeekSearchProvider error handling', () => { + it('maps an HTTP error to WEB_PROVIDER_ERROR with the provider message', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: { message: 'rate limited' } }, { status: 429 }))) + await expect(new DeepSeekSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR', message: 'rate limited' })) + }) + + it('handles a string-form error body', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: 'bad request' }, { status: 400 }))) + await expect(new DeepSeekSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ message: 'bad request' })) + }) + + it('keeps a status-line message when the error body is not JSON', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('upstream error', { status: 503 }))) + await expect(new DeepSeekSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ message: 'DeepSeek API error (HTTP 503)' })) + }) + + it('keeps the status-line message when the JSON error body carries no detail', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({}, { status: 500 }))) + await expect(new DeepSeekSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ message: 'DeepSeek API error (HTTP 500)' })) + }) + + it('maps an abort to WEB_ABORTED', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new DOMException('aborted', 'AbortError')))) + await expect(new DeepSeekSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) + }) + + it('maps an unparseable success body to WEB_PROVIDER_ERROR', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('not json', { status: 200 }))) + await expect(new DeepSeekSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) + }) + + it('surfaces an abort during success-body parse as WEB_ABORTED', async () => { + const body = { json: () => Promise.reject(new DOMException('aborted', 'AbortError')), ok: true, status: 200 } + vi.stubGlobal('fetch', vi.fn(async () => body as unknown as Response)) + await expect(new DeepSeekSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) + }) + + it('surfaces an abort during error-body parse as WEB_ABORTED', async () => { + const body = { json: () => Promise.reject(new DOMException('aborted', 'AbortError')), ok: false, status: 500 } + vi.stubGlobal('fetch', vi.fn(async () => body as unknown as Response)) + await expect(new DeepSeekSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) + }) + + it('maps a network failure to WEB_PROVIDER_ERROR', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new TypeError('connection refused')))) + await expect(new DeepSeekSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) + }) + + it('strict mode flows through search(): a prose-only response throws WEB_PROVIDER_ERROR', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ content: [{ type: 'text', text: 'no search happened' }] }))) + await expect(new DeepSeekSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) + }) +}) + +describe('web-search-deepseek plugin registration', () => { + it('registers the provider into ctx.web (HMR-safe)', async () => { + const ctx = new Context() + await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID }) + const fiber = await ctx.plugin(deepseekPlugin, { apiKey: 'ds-key' }) + expect(ctx.web.searchStatus()).toEqual({ available: true, providerId: DEEPSEEK_PROVIDER_ID }) + await fiber.dispose() + expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'configured-missing' }) + }) + + it('has no default export (namespace plugin export shape)', () => { + expect('default' in deepseekPlugin).toBe(false) + }) + + it('survives the real Loader unwrapExports path keeping name/inject/Config', () => { + // A stray `export default apply` would make the cordis Loader's + // unwrapExports (`exports.default ?? exports`) collapse the module to the + // bare `apply` function, DROPPING `inject: ['web']` — the plugin would then + // read ctx.web without injecting it and throw "cannot get property … without + // inject" the moment it loads. A hand-built ctx.plugin(namespace) mount + // bypasses unwrapExports and cannot catch that, so drive the real path. + // Prove it bites: add `export default apply` to src/index.ts, watch this go + // red, revert. + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(deepseekPlugin) as Record + expect(unwrapped).toBe(deepseekPlugin) + expect(unwrapped.name).toBe('web-search-deepseek') + expect(unwrapped.inject).toEqual(['web']) + expect(typeof unwrapped.apply).toBe('function') + }) + + it('boots over ctx.web through the unwrapped module without an inject error', async () => { + const ctx = new Context() + await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID }) + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(deepseekPlugin) as Parameters[0] + // A collapsed export shape (dropped inject) would throw "without inject" here. + const fiber = await ctx.plugin(unwrapped, { apiKey: 'ds-key' }) + expect(ctx.web.searchStatus()).toEqual({ available: true, providerId: DEEPSEEK_PROVIDER_ID }) + await fiber.dispose() + }) + + it('falls back to the env key and defaults when config omits them', async () => { + const prev = process.env.DEEPSEEK_API_KEY + process.env.DEEPSEEK_API_KEY = 'env-key' + try { + const fetchMock = vi.fn(async () => jsonResponse(searchResponse())) + vi.stubGlobal('fetch', fetchMock) + const ctx = new Context() + await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID }) + const fiber = await ctx.plugin(deepseekPlugin, {}) + expect(ctx.web.searchStatus()).toEqual({ available: true, providerId: DEEPSEEK_PROVIDER_ID }) + await ctx.web.search({ query: 'q' }) + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe('https://api.deepseek.com/anthropic/v1/messages') + expect((init.headers as Record)['x-api-key']).toBe('env-key') + expect(JSON.parse(init.body as string)).toMatchObject({ model: 'deepseek-v4-flash' }) + await fiber.dispose() + } finally { + if (prev === undefined) delete process.env.DEEPSEEK_API_KEY + else process.env.DEEPSEEK_API_KEY = prev + } + }) + + it('is unavailable when neither config nor env supplies a key', async () => { + const prev = process.env.DEEPSEEK_API_KEY + delete process.env.DEEPSEEK_API_KEY + try { + const ctx = new Context() + await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID }) + await ctx.plugin(deepseekPlugin, {}) + expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'configured-unavailable' }) + } finally { + if (prev !== undefined) process.env.DEEPSEEK_API_KEY = prev + } + }) +}) diff --git a/packages/web/web-search-deepseek/tsconfig.json b/packages/web/web-search-deepseek/tsconfig.json new file mode 100644 index 0000000000..aa7c949fec --- /dev/null +++ b/packages/web/web-search-deepseek/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../web" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a393b6c4b9..a4e0811228 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -763,6 +763,19 @@ 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/web/web-search-deepseek: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-web': + specifier: workspace:^ + version: link:../web + 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/web/web-search-exa: dependencies: schemastery: diff --git a/tsconfig.build.json b/tsconfig.build.json index fb7a058ea8..840b9aee5f 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -30,6 +30,7 @@ { "path": "./packages/web/web" }, { "path": "./packages/web/web-search-exa" }, { "path": "./packages/web/web-search-perplexity" }, + { "path": "./packages/web/web-search-deepseek" }, { "path": "./packages/web/web-fetch-local" }, { "path": "./packages/web/tool-web" }, { "path": "./packages/support/invariants" }, diff --git a/tsconfig.json b/tsconfig.json index 120ad5a4fb..ba0af75248 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -41,6 +41,7 @@ { "path": "./packages/web/web" }, { "path": "./packages/web/web-search-exa" }, { "path": "./packages/web/web-search-perplexity" }, + { "path": "./packages/web/web-search-deepseek" }, { "path": "./packages/web/web-fetch-local" }, { "path": "./packages/web/tool-web" }, { "path": "./packages/support/invariants" }, From 8395722db555c2e44d3477d46df551b621d76b33 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 29 Jun 2026 17:23:11 +0800 Subject: [PATCH 23/75] fix: address codex review findings on web seam - search providers (exa/perplexity/deepseek): map the parsed response INSIDE the parse try, so a well-formed body of the wrong shape surfaces as WEB_PROVIDER_ERROR instead of escaping as a raw TypeError; a WebError the mapper throws on purpose is re-thrown untouched - web-fetch-local: validate numeric limits at plugin construction (positive finite caps; non-negative integer maxRedirects) rather than constructing a provider with nonsensical values - web-fetch-local: enforce the redirect budget BEFORE resolving each hop, so maxRedirects:N follows exactly N redirects and an over-limit hop reports "exceeded the maximum" rather than misdiagnosing a cross-origin block - drop the stale dsh-tool-web/search and /fetch path aliases (the package no longer declares those subpath exports) - strip trailing EOF blank lines flagged by git diff --check Each fix carries a regression test. --- packages/web/tool-web/src/index.ts | 1 - .../web/tool-web/tests/integration.spec.ts | 1 - packages/web/web-fetch-local/README.md | 4 +- packages/web/web-fetch-local/src/index.ts | 20 +++++ packages/web/web-fetch-local/src/provider.ts | 15 +++- .../web-fetch-local/tests/fetch-local.spec.ts | 90 +++++++++++++++++++ .../web/web-search-deepseek/src/provider.ts | 8 +- .../tests/deepseek.spec.ts | 6 ++ packages/web/web-search-exa/README.md | 2 +- packages/web/web-search-exa/src/provider.ts | 8 +- packages/web/web-search-exa/tests/exa.spec.ts | 7 ++ .../web/web-search-perplexity/src/provider.ts | 8 +- .../tests/perplexity.spec.ts | 6 ++ tsconfig.base.json | 2 - 14 files changed, 157 insertions(+), 21 deletions(-) diff --git a/packages/web/tool-web/src/index.ts b/packages/web/tool-web/src/index.ts index 072fc6018e..df8029466a 100644 --- a/packages/web/tool-web/src/index.ts +++ b/packages/web/tool-web/src/index.ts @@ -55,4 +55,3 @@ export function apply(ctx: Context, config: Config): void { if (config.search !== false) applyWebSearchTool(ctx) if (config.fetch !== false) applyWebFetchTool(ctx) } - diff --git a/packages/web/tool-web/tests/integration.spec.ts b/packages/web/tool-web/tests/integration.spec.ts index 18604190c6..50ae6c5624 100644 --- a/packages/web/tool-web/tests/integration.spec.ts +++ b/packages/web/tool-web/tests/integration.spec.ts @@ -96,4 +96,3 @@ describe('web_search integration over the real Exa provider', () => { expect(out.content.map(b => b.text).join('')).toContain('[Result](https://result.test)') }) }) - diff --git a/packages/web/web-fetch-local/README.md b/packages/web/web-fetch-local/README.md index fd9150e46f..58db557581 100644 --- a/packages/web/web-fetch-local/README.md +++ b/packages/web/web-fetch-local/README.md @@ -26,9 +26,11 @@ The provider owns **safe resource retrieval**: URL validation, HTTP transport, r | `maxBodyChars` | `100_000` | Maximum decoded body length in characters. | | `timeoutMs` | `30_000` | Default fetch timeout. | | `maxTimeoutMs` | `120_000` | Upper bound for a per-request timeout override. | -| `maxRedirects` | `5` | Maximum same-origin redirect hops. | +| `maxRedirects` | `5` | Maximum same-origin redirect hops (`0` follows none). | | `userAgent` | `deepseek-harness/…` | `User-Agent` header. | +The numeric limits are validated at plugin construction: every cap except `maxRedirects` must be a positive finite number, and `maxRedirects` must be a non-negative integer. An invalid value throws rather than silently constructing a provider with nonsensical limits. + ## Security note SSRF / private-network protection (blocking private, loopback, link-local, multicast, and otherwise non-public destinations, with DNS-resolve-then-validate and per-hop re-validation) is **deferred** — see the [web capability seam RFC](../../../docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md). Until it lands, this provider is an SSRF primitive and **must not be enabled** in a deployment that can reach sensitive internal network targets. diff --git a/packages/web/web-fetch-local/src/index.ts b/packages/web/web-fetch-local/src/index.ts index eb3f8e4143..7eb614f39a 100644 --- a/packages/web/web-fetch-local/src/index.ts +++ b/packages/web/web-fetch-local/src/index.ts @@ -60,10 +60,30 @@ export const Config: z = z.object({ /** The shape after schemastery applies its defaults to every field. */ type ResolvedConfig = Required +/** A resource limit (byte/char/length/timeout cap) must be a positive finite number. */ +function assertPositiveFinite(name: string, value: number): void { + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`web-fetch-local: ${name} must be a positive finite number`) + } +} + +/** The redirect hop cap must be a non-negative integer (0 follows no redirects). */ +function assertNonNegativeInteger(name: string, value: number): void { + if (!Number.isInteger(value) || value < 0) { + throw new Error(`web-fetch-local: ${name} must be a non-negative integer`) + } +} + /** Register the local HTTP(S) fetch provider with `ctx.web`. */ export function apply(ctx: Context, config: Config): void { // schemastery (Config) has already filled every defaulted field. const resolved = config as ResolvedConfig + assertPositiveFinite('maxUrlLength', resolved.maxUrlLength) + assertPositiveFinite('maxResponseBytes', resolved.maxResponseBytes) + assertPositiveFinite('maxBodyChars', resolved.maxBodyChars) + assertPositiveFinite('timeoutMs', resolved.timeoutMs) + assertPositiveFinite('maxTimeoutMs', resolved.maxTimeoutMs) + assertNonNegativeInteger('maxRedirects', resolved.maxRedirects) const limits: LocalFetchLimits = { maxUrlLength: resolved.maxUrlLength, maxResponseBytes: resolved.maxResponseBytes, diff --git a/packages/web/web-fetch-local/src/provider.ts b/packages/web/web-fetch-local/src/provider.ts index 061eaf5e0c..29b183b710 100644 --- a/packages/web/web-fetch-local/src/provider.ts +++ b/packages/web/web-fetch-local/src/provider.ts @@ -81,11 +81,21 @@ export class LocalFetchProvider implements WebFetchProvider { /** Follow same-origin redirects up to the hop cap, then read the final response. */ private async followAndRead(initialUrl: string, controller: AbortController): Promise { let currentUrl = validateFetchUrl(initialUrl, this.limits.maxUrlLength) + let redirectsFollowed = 0 - for (let hop = 0; hop <= this.limits.maxRedirects; hop++) { + for (;;) { const response = await this.requestOnce(currentUrl, controller) if (isRedirectStatus(response.status)) { + // The redirect budget is enforced BEFORE this hop's target is resolved + // or origin-checked, so `maxRedirects: N` follows at most N redirects + // exactly: the (N+1)th redirect is refused as "exceeded" regardless of + // where it points (a same-origin/cross-origin distinction on a hop we + // are not allowed to follow would be the wrong diagnosis). + if (redirectsFollowed >= this.limits.maxRedirects) { + await response.body?.cancel() + throw new WebError(`exceeded the maximum of ${this.limits.maxRedirects} redirects`, 'WEB_REDIRECT_BLOCKED') + } const location = response.headers.get('location') if (location === null) { // A redirect status with no Location is not a usable resource. Cancel @@ -113,13 +123,12 @@ export class LocalFetchProvider implements WebFetchProvider { } await response.body?.cancel() currentUrl = validatedTarget + redirectsFollowed++ continue } return await this.readBody(response, currentUrl, controller.signal) } - - throw new WebError(`exceeded the maximum of ${this.limits.maxRedirects} redirects`, 'WEB_REDIRECT_BLOCKED') } private async requestOnce(url: URL, controller: AbortController): Promise { diff --git a/packages/web/web-fetch-local/tests/fetch-local.spec.ts b/packages/web/web-fetch-local/tests/fetch-local.spec.ts index 75a7cb1580..27ed991c08 100644 --- a/packages/web/web-fetch-local/tests/fetch-local.spec.ts +++ b/packages/web/web-fetch-local/tests/fetch-local.spec.ts @@ -203,6 +203,60 @@ describe('LocalFetchProvider redirects', () => { .rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED' })) }) + it('follows exactly maxRedirects hops: a chain landing on the Nth redirect succeeds', async () => { + // maxRedirects: 2 → /?n=0 → /?n=1 → /?n=2(200). Exactly 2 redirects + 1 + // final = 3 requests; the cap is inclusive of the landing request. + let requests = 0 + handler = (req, res) => { + requests++ + const n = Number(new URL(req.url ?? '/', base).searchParams.get('n') ?? '0') + if (n >= 2) { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('landed') } + else { res.writeHead(302, { location: `/?n=${n + 1}` }); res.end() } + } + const result = await provider({ maxRedirects: 2 }).fetch({ url: `${base}/?n=0` }) + expect(result.body.content).toBe('landed') + expect(requests).toBe(3) + }) + + it('makes exactly maxRedirects+1 requests before blocking an over-long chain', async () => { + // maxRedirects: 2 on an infinite chain: requests at n=0,1,2 (the 3rd is the + // over-limit redirect, refused before its Location is followed) = 3 total. + let requests = 0 + handler = (req, res) => { + requests++ + const n = Number(new URL(req.url ?? '/', base).searchParams.get('n') ?? '0') + res.writeHead(302, { location: `/?n=${n + 1}` }) + res.end() + } + await expect(provider({ maxRedirects: 2 }).fetch({ url: `${base}/?n=0` })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED', message: 'exceeded the maximum of 2 redirects' })) + expect(requests).toBe(3) + }) + + it('reports an over-limit redirect as "exceeded", not cross-origin, even when the over-limit hop points cross-origin', async () => { + // The redirect budget is checked BEFORE the over-limit hop's target is + // origin-validated, so the diagnosis is "exceeded", not "cross-origin". + handler = (req, res) => { + const n = Number(new URL(req.url ?? '/', base).searchParams.get('n') ?? '0') + const location = n === 0 ? '/?n=1' : 'https://example.com/' + res.writeHead(302, { location }) + res.end() + } + await expect(provider({ maxRedirects: 1 }).fetch({ url: `${base}/?n=0` })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED', message: 'exceeded the maximum of 1 redirects' })) + }) + + it('maxRedirects: 0 follows no redirect but still fetches a direct 200', async () => { + handler = (req, res) => { + if (req.url === '/r') { res.writeHead(302, { location: '/done' }); res.end() } + else { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('direct') } + } + await expect(provider({ maxRedirects: 0 }).fetch({ url: `${base}/r` })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED' })) + const direct = await provider({ maxRedirects: 0 }).fetch({ url: `${base}/done` }) + expect(direct.body.content).toBe('direct') + }) + it('treats a redirect without a Location header as a provider error', async () => { handler = (_req, res) => { res.writeHead(302); res.end() } await expect(provider().fetch({ url: base })) @@ -331,4 +385,40 @@ describe('web-fetch-local plugin registration', () => { it('has no default export (namespace plugin export shape)', () => { expect('default' in fetchPlugin).toBe(false) }) + + it('rejects a non-positive resource limit at construction', async () => { + const ctx = new Context() + await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID }) + await expect(ctx.plugin(fetchPlugin, { maxResponseBytes: -1 })) + .rejects.toThrow(/maxResponseBytes must be a positive finite number/) + }) + + it('rejects a zero timeout at construction', async () => { + const ctx = new Context() + await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID }) + await expect(ctx.plugin(fetchPlugin, { timeoutMs: 0 })) + .rejects.toThrow(/timeoutMs must be a positive finite number/) + }) + + it('rejects a fractional redirect cap at construction', async () => { + const ctx = new Context() + await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID }) + await expect(ctx.plugin(fetchPlugin, { maxRedirects: 1.5 })) + .rejects.toThrow(/maxRedirects must be a non-negative integer/) + }) + + it('rejects a negative redirect cap at construction', async () => { + const ctx = new Context() + await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID }) + await expect(ctx.plugin(fetchPlugin, { maxRedirects: -1 })) + .rejects.toThrow(/maxRedirects must be a non-negative integer/) + }) + + it('accepts maxRedirects: 0 (follow no redirects) as valid config', async () => { + const ctx = new Context() + await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID }) + const fiber = await ctx.plugin(fetchPlugin, { maxRedirects: 0 }) + expect(ctx.web.fetchStatus()).toEqual({ available: true, providerId: LOCAL_FETCH_PROVIDER_ID }) + await fiber.dispose() + }) }) diff --git a/packages/web/web-search-deepseek/src/provider.ts b/packages/web/web-search-deepseek/src/provider.ts index 5d02ad01ab..b637d52d11 100644 --- a/packages/web/web-search-deepseek/src/provider.ts +++ b/packages/web/web-search-deepseek/src/provider.ts @@ -200,14 +200,14 @@ export class DeepSeekSearchProvider implements WebSearchProvider { throw new WebError(message, 'WEB_PROVIDER_ERROR') } - let payload: AnthropicResponse try { - payload = await response.json() as AnthropicResponse + const payload = await response.json() as AnthropicResponse + return mapAnthropicResponse(request.query, payload) } catch (error: unknown) { if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error }) - throw new WebError(`DeepSeek returned an unparseable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) + if (error instanceof WebError) throw error + throw new WebError(`DeepSeek returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) } - return mapAnthropicResponse(request.query, payload) } } diff --git a/packages/web/web-search-deepseek/tests/deepseek.spec.ts b/packages/web/web-search-deepseek/tests/deepseek.spec.ts index ff6b37cfa2..496b7f6a3c 100644 --- a/packages/web/web-search-deepseek/tests/deepseek.spec.ts +++ b/packages/web/web-search-deepseek/tests/deepseek.spec.ts @@ -220,6 +220,12 @@ describe('DeepSeekSearchProvider error handling', () => { .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) }) + it('maps a well-formed body of the wrong shape to WEB_PROVIDER_ERROR, not a raw TypeError', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ content: {} }, { status: 200 }))) + await expect(new DeepSeekSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) + }) + it('surfaces an abort during success-body parse as WEB_ABORTED', async () => { const body = { json: () => Promise.reject(new DOMException('aborted', 'AbortError')), ok: true, status: 200 } vi.stubGlobal('fetch', vi.fn(async () => body as unknown as Response)) diff --git a/packages/web/web-search-exa/README.md b/packages/web/web-search-exa/README.md index 61da605df7..6485d64c60 100644 --- a/packages/web/web-search-exa/README.md +++ b/packages/web/web-search-exa/README.md @@ -20,4 +20,4 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i ## Mapping -Exa returns a flat `results[]` and no generated answer, so `content` is omitted. Each result maps to a `WebSearchSource`: `url` ← `url`, `title` ← `title`, `snippet` ← the first non-empty `highlights[]` entry (a result with no highlight has no portable snippet and is dropped), `publishedAt` ← `publishedDate`. The provider passes `maxResults` through as Exa's `numResults` for a cost/latency optimization; the final bound is enforced by the seam. Provider failures (HTTP errors, network failure, unparseable bodies) surface as `WebError` `WEB_PROVIDER_ERROR`; an aborted request surfaces as `WEB_ABORTED`. +Exa returns a flat `results[]` and no generated answer, so `content` is omitted. Each result maps to a `WebSearchSource`: `url` ← `url`, `title` ← `title`, `snippet` ← the first non-empty `highlights[]` entry (a result with no highlight has no portable snippet and is dropped), `publishedAt` ← `publishedDate`. The provider passes `maxResults` through as Exa's `numResults` for a cost/latency optimization; the final bound is enforced by the seam. Provider failures (HTTP errors, network failure, unparseable or wrong-shape bodies) surface as `WebError` `WEB_PROVIDER_ERROR`; an aborted request surfaces as `WEB_ABORTED`. diff --git a/packages/web/web-search-exa/src/provider.ts b/packages/web/web-search-exa/src/provider.ts index 70e14cbf25..3774bd5d58 100644 --- a/packages/web/web-search-exa/src/provider.ts +++ b/packages/web/web-search-exa/src/provider.ts @@ -118,14 +118,14 @@ export class ExaSearchProvider implements WebSearchProvider { throw new WebError(message, 'WEB_PROVIDER_ERROR') } - let payload: ExaSearchResponse try { - payload = await response.json() as ExaSearchResponse + const payload = await response.json() as ExaSearchResponse + return mapExaResponse(request.query, payload) } catch (error: unknown) { if (isAbortError(error)) throw new WebError('Exa search aborted', 'WEB_ABORTED', { cause: error }) - throw new WebError(`Exa returned an unparseable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) + if (error instanceof WebError) throw error + throw new WebError(`Exa returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) } - return mapExaResponse(request.query, payload) } } diff --git a/packages/web/web-search-exa/tests/exa.spec.ts b/packages/web/web-search-exa/tests/exa.spec.ts index 403198401e..436e542d7c 100644 --- a/packages/web/web-search-exa/tests/exa.spec.ts +++ b/packages/web/web-search-exa/tests/exa.spec.ts @@ -60,6 +60,7 @@ describe('Exa result mapping', () => { it('tolerates a missing results array', () => { expect(mapExaResponse('q', {}).sources).toEqual([]) }) + }) describe('ExaSearchProvider status', () => { @@ -148,6 +149,12 @@ describe('ExaSearchProvider error handling', () => { .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) }) + it('maps a well-formed body of the wrong shape to WEB_PROVIDER_ERROR, not a raw TypeError', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ results: {} }, { status: 200 }))) + await expect(new ExaSearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) + }) + it('surfaces an abort during success-body parse as WEB_ABORTED, not provider error', async () => { const body = { json: () => Promise.reject(new DOMException('aborted', 'AbortError')), ok: true, status: 200 } vi.stubGlobal('fetch', vi.fn(async () => body as unknown as Response)) diff --git a/packages/web/web-search-perplexity/src/provider.ts b/packages/web/web-search-perplexity/src/provider.ts index 69b4f794dd..809086026a 100644 --- a/packages/web/web-search-perplexity/src/provider.ts +++ b/packages/web/web-search-perplexity/src/provider.ts @@ -126,14 +126,14 @@ export class PerplexitySearchProvider implements WebSearchProvider { throw new WebError(message, 'WEB_PROVIDER_ERROR') } - let payload: PerplexityResponse try { - payload = await response.json() as PerplexityResponse + const payload = await response.json() as PerplexityResponse + return mapPerplexityResponse(request.query, payload) } catch (error: unknown) { if (isAbortError(error)) throw new WebError('Perplexity search aborted', 'WEB_ABORTED', { cause: error }) - throw new WebError(`Perplexity returned an unparseable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) + if (error instanceof WebError) throw error + throw new WebError(`Perplexity returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) } - return mapPerplexityResponse(request.query, payload) } } diff --git a/packages/web/web-search-perplexity/tests/perplexity.spec.ts b/packages/web/web-search-perplexity/tests/perplexity.spec.ts index c1f76a63fb..d84c34a328 100644 --- a/packages/web/web-search-perplexity/tests/perplexity.spec.ts +++ b/packages/web/web-search-perplexity/tests/perplexity.spec.ts @@ -116,6 +116,12 @@ describe('PerplexitySearchProvider error handling', () => { .rejects.toThrow(expect.objectContaining({ message: 'bad request' })) }) + it('maps a well-formed body of the wrong shape to WEB_PROVIDER_ERROR, not a raw TypeError', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ search_results: null }, { status: 200 }))) + await expect(new PerplexitySearchProvider(options).search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) + }) + it('keeps a status-line message when the error body is not JSON', async () => { vi.stubGlobal('fetch', vi.fn(async () => new Response('upstream error', { status: 503 }))) await expect(new PerplexitySearchProvider(options).search({ query: 'q' })) diff --git a/tsconfig.base.json b/tsconfig.base.json index bc4f13bdd5..f0fdcfe197 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -34,8 +34,6 @@ "@cordisjs/plugin-timer": ["./vendor/timer/src"], "@cordisjs/plugin-hmr": ["./vendor/hmr/src"], "@cordisjs/plugin-logger-console": ["./vendor/logger-console/src"], - "@deepseek-ai/dsh-tool-web/search": ["./packages/web/tool-web/src/search.ts"], - "@deepseek-ai/dsh-tool-web/fetch": ["./packages/web/tool-web/src/fetch.ts"], // One wildcard maps every @deepseek-ai/dsh- to its source. Package // dir names are unique across groups, so first-on-disk-wins resolution is // unambiguous; adding a package under an existing group needs no edit From bb8f7799cef81909a1b595ae79714cef0e41c6a1 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 29 Jun 2026 17:39:52 +0800 Subject: [PATCH 24/75] fix: drop unreachable WebError rethrow in exa/perplexity search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `if (error instanceof WebError) throw error` guard is dead code in the exa and perplexity providers: their mappers (mapExaResponse / mapPerplexityResponse) never throw a WebError — a wrong-shape body throws a TypeError, which the catch correctly translates to WEB_PROVIDER_ERROR. The guard was added for symmetry with the deepseek provider, whose mapper DOES throw a WebError in strict mode (no web_search_tool_result block), so it keeps the rethrow. The unreachable lines tripped the per-file 100% coverage gate. --- packages/web/web-search-exa/src/provider.ts | 1 - packages/web/web-search-perplexity/src/provider.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/web/web-search-exa/src/provider.ts b/packages/web/web-search-exa/src/provider.ts index 3774bd5d58..cfb41cf77f 100644 --- a/packages/web/web-search-exa/src/provider.ts +++ b/packages/web/web-search-exa/src/provider.ts @@ -123,7 +123,6 @@ export class ExaSearchProvider implements WebSearchProvider { return mapExaResponse(request.query, payload) } catch (error: unknown) { if (isAbortError(error)) throw new WebError('Exa search aborted', 'WEB_ABORTED', { cause: error }) - if (error instanceof WebError) throw error throw new WebError(`Exa returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) } } diff --git a/packages/web/web-search-perplexity/src/provider.ts b/packages/web/web-search-perplexity/src/provider.ts index 809086026a..5b5feb897b 100644 --- a/packages/web/web-search-perplexity/src/provider.ts +++ b/packages/web/web-search-perplexity/src/provider.ts @@ -131,7 +131,6 @@ export class PerplexitySearchProvider implements WebSearchProvider { return mapPerplexityResponse(request.query, payload) } catch (error: unknown) { if (isAbortError(error)) throw new WebError('Perplexity search aborted', 'WEB_ABORTED', { cause: error }) - if (error instanceof WebError) throw error throw new WebError(`Perplexity returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) } } From cf71c0b215f93903d6798cf0fd56559ee7e480cd Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:08:53 +0800 Subject: [PATCH 25/75] fix: address web seam review findings --- docs/architecture.md | 4 ++- docs/core-data-structures/web.md | 4 +-- .../2026-06-24-web-capability-seam.md | 17 +++++++---- packages/README.md | 2 ++ packages/web/README.md | 1 + packages/web/web-search-deepseek/README.md | 4 +-- packages/web/web-search-deepseek/src/index.ts | 10 ++++--- .../web/web-search-deepseek/src/provider.ts | 6 ++++ .../tests/deepseek.spec.ts | 30 +++++++++++++++++++ 9 files changed, 64 insertions(+), 14 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index d50dedf5b4..f57d453067 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,6 +25,8 @@ For a catalog of the **data structures** this architecture moves around — the │ @deepseek-ai/dsh-bash-local (bash impl) │ │ @deepseek-ai/dsh-tool-bash (bash tool schemas) │ │ @deepseek-ai/dsh-web-search-exa (web search impl) │ +│ @deepseek-ai/dsh-web-search-perplexity (web search impl) │ +│ @deepseek-ai/dsh-web-search-deepseek (web search impl) │ │ @deepseek-ai/dsh-web-fetch-local (web fetch impl) │ │ @deepseek-ai/dsh-tool-web (web tool schemas) │ │ @deepseek-ai/dsh-subagent-* (subagent providers) │ @@ -78,7 +80,7 @@ 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 web capability uses the same three-package split but folds two capabilities onto one seam: `dsh-web` owns the abstract `ctx.web` service, which is a provider REGISTRY (`registerSearchProvider`/`registerFetchProvider`, registration-order-independent selection, the `WebError` taxonomy) rather than a single backend. Providers register capabilities, not tools — `dsh-web-search-exa`, `dsh-web-search-perplexity`, and `dsh-web-fetch-local` each register into `ctx.web` the way an `LlmAdapter` registers into `ctx.llm`, so they are namespace plugins (`inject: ['web']`), not key-owning services. `dsh-tool-web` is the single consumer that owns the model-facing `web_search`/`web_fetch` schemas, prompt sections, and presentation; it reads only the aggregated `ctx.web.searchStatus()`/`fetchStatus()` and executes through `ctx.web.search()`/`fetch()`, so provider selection has one owner. Search and fetch are deliberately one seam (one thing to inject and configure, one selection policy, one abort/error vocabulary) despite sharing no request schema — see the [web capability seam RFC](rfc/implemented/architecture/2026-06-24-web-capability-seam.md). +The web capability uses the same three-package split but folds two capabilities onto one seam: `dsh-web` owns the abstract `ctx.web` service, which is a provider REGISTRY (`registerSearchProvider`/`registerFetchProvider`, registration-order-independent selection, the `WebError` taxonomy) rather than a single backend. Providers register capabilities, not tools — `dsh-web-search-exa`, `dsh-web-search-perplexity`, `dsh-web-search-deepseek`, and `dsh-web-fetch-local` each register into `ctx.web` the way an `LlmAdapter` registers into `ctx.llm`, so they are namespace plugins (`inject: ['web']`), not key-owning services. `dsh-tool-web` is the single consumer that owns the model-facing `web_search`/`web_fetch` schemas, prompt sections, and presentation; it reads only the aggregated `ctx.web.searchStatus()`/`fetchStatus()` and executes through `ctx.web.search()`/`fetch()`, so provider selection has one owner. Search and fetch are deliberately one seam (one thing to inject and configure, one selection policy, one abort/error vocabulary) despite sharing no request schema — see the [web capability seam RFC](rfc/implemented/architecture/2026-06-24-web-capability-seam.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. diff --git a/docs/core-data-structures/web.md b/docs/core-data-structures/web.md index f0ade276f7..43ed4e7aeb 100644 --- a/docs/core-data-structures/web.md +++ b/docs/core-data-structures/web.md @@ -1,6 +1,6 @@ # Web Access -The web access seam — a [capability seam](../rfc/implemented/architecture/2026-06-24-web-capability-seam.md) that spans **two capabilities** (search and fetch) on one `ctx.web` service, split across packages: interface ([dsh-web](../../packages/web/web), `ctx.web` + the provider registries), implementations ([dsh-web-search-exa](../../packages/web/web-search-exa), [dsh-web-search-perplexity](../../packages/web/web-search-perplexity), [dsh-web-fetch-local](../../packages/web/web-fetch-local)), and consumer ([dsh-tool-web](../../packages/web/tool-web), the `web_search`/`web_fetch` tool schemas). Web is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A search-provider swap does not change how the model asks for a query, and a fetch-implementation swap does not change how the model asks for a URL. +The web access seam — a [capability seam](../rfc/implemented/architecture/2026-06-24-web-capability-seam.md) that spans **two capabilities** (search and fetch) on one `ctx.web` service, split across packages: interface ([dsh-web](../../packages/web/web), `ctx.web` + the provider registries), implementations ([dsh-web-search-exa](../../packages/web/web-search-exa), [dsh-web-search-perplexity](../../packages/web/web-search-perplexity), [dsh-web-search-deepseek](../../packages/web/web-search-deepseek), [dsh-web-fetch-local](../../packages/web/web-fetch-local)), and consumer ([dsh-tool-web](../../packages/web/tool-web), the `web_search`/`web_fetch` tool schemas). Web is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A search-provider swap does not change how the model asks for a query, and a fetch-implementation swap does not change how the model asks for a URL. Source: [`packages/web/web/src/types.ts`](../../packages/web/web/src/types.ts) @@ -33,7 +33,7 @@ interface WebSearchResult { } ``` -`content` is optional provider-generated answer text (Exa returns none; Perplexity returns a generated answer). `sources[]` is the portable citation surface. A source always has a `url`; `title`/`snippet`/`publishedAt` are optional because not every provider returns them — Perplexity citations may be URL-only, and forcing adapters to invent the rest would make the seam lie. `dsh-tool-web` renders `title ?? hostname(url)`. +`content` is optional provider-generated answer text (Exa and DeepSeek return none; Perplexity returns a generated answer). `sources[]` is the portable citation surface. A source always has a `url`; `title`/`snippet`/`publishedAt` are optional because not every provider returns them — Perplexity citations may be URL-only, and forcing adapters to invent the rest would make the seam lie. `dsh-tool-web` renders `title ?? hostname(url)`. ```ts type-equiv interface WebSearchSource { diff --git a/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md index 3b9fb166d0..55ada9d576 100644 --- a/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md +++ b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md @@ -17,7 +17,7 @@ There is also a provider-selection question. Existing `tool-bash` and `tool-fs` Introduce web access as a first-class capability seam following [the capability-seam RFC](../../implemented/architecture/2026-06-13-capability-seams.md): 1. `@deepseek-ai/dsh-web` (`packages/web/web`) owns `ctx.web`, provider registration, provider selection, shared request/result vocabulary, and web-specific errors. -2. Provider packages implement concrete backends and register capabilities with `ctx.web`, for example `@deepseek-ai/dsh-web-search-exa`, `@deepseek-ai/dsh-web-search-perplexity`, and `@deepseek-ai/dsh-web-fetch-local`. +2. Provider packages implement concrete backends and register capabilities with `ctx.web`, for example `@deepseek-ai/dsh-web-search-exa`, `@deepseek-ai/dsh-web-search-perplexity`, `@deepseek-ai/dsh-web-search-deepseek`, and `@deepseek-ai/dsh-web-fetch-local`. 3. `@deepseek-ai/dsh-tool-web` (`packages/web/tool-web`) owns the model-facing `web_search` and `web_fetch` tool schemas, prompt sections, argument validation, result formatting, and tool-owned presentation over `ctx.web`. Providers do not register tools. Providers register capabilities. `dsh-tool-web` is the only owner of model-facing names, descriptions, prompt guidance, JSON schemas, and presentation. @@ -46,6 +46,8 @@ The dependency direction mirrors bash and filesystem: consumer interface implementation <--depends on-- @deepseek-ai/dsh-web-search-perplexity implementation + <--depends on-- @deepseek-ai/dsh-web-search-deepseek + implementation <--depends on-- @deepseek-ai/dsh-web-fetch-local implementation ``` @@ -56,6 +58,7 @@ At runtime, provider packages register capabilities with `ctx.web`; `tool-web` r flowchart LR exa["@deepseek-ai/dsh-web-search-exa"] -->|registerSearchProvider| web["@deepseek-ai/dsh-web / ctx.web"] perplexity["@deepseek-ai/dsh-web-search-perplexity"] -->|registerSearchProvider| web + deepseek["@deepseek-ai/dsh-web-search-deepseek"] -->|registerSearchProvider| web fetchLocal["@deepseek-ai/dsh-web-fetch-local"] -->|registerFetchProvider| web toolWeb["@deepseek-ai/dsh-tool-web"] -->|searchStatus/fetchStatus| web toolWeb -->|ctx.tools.register| webSearch["tool: web_search"] @@ -152,6 +155,9 @@ The "single provider auto-selects" rule is for tests, demos, and simple deployme - id: web-search-perplexity name: '@deepseek-ai/dsh-web-search-perplexity' +- id: web-search-deepseek + name: '@deepseek-ai/dsh-web-search-deepseek' + - id: web-fetch-local name: '@deepseek-ai/dsh-web-fetch-local' @@ -326,10 +332,11 @@ Land the work in seam order: 1. Add `packages/web/web` with `ctx.web`, provider registration, provider status, capability status, selection, request/result/error types, and contract tests. 2. Add `packages/web/web-search-exa` with parser/unit tests and a self-skipping real-provider smoke test. 3. Add `packages/web/web-search-perplexity` with parser/unit tests and a self-skipping real-provider smoke test. -4. Add `packages/web/web-fetch-local` with local HTTP behavior tests. -5. Add `packages/web/tool-web` with config-driven tool registration, prompt sections, model formatting, presentation, and tool-registry tests. -6. Wire product app/example configs only after package behavior is stable, because tool schemas and prompt sections affect agent behavior and snapshots. -7. Update `docs/architecture.md`, `packages/README.md`, package READMEs, generated Cordis catalogs if new events/services are added, and maintenance scripts. +4. Add `packages/web/web-search-deepseek` with parser/unit tests and a self-skipping real-provider smoke test. +5. Add `packages/web/web-fetch-local` with local HTTP behavior tests. +6. Add `packages/web/tool-web` with config-driven tool registration, prompt sections, model formatting, presentation, and tool-registry tests. +7. Wire product app/example configs only after package behavior is stable, because tool schemas and prompt sections affect agent behavior and snapshots. +8. Update `docs/architecture.md`, `packages/README.md`, package READMEs, generated Cordis catalogs if new events/services are added, and maintenance scripts. ## Alternatives considered diff --git a/packages/README.md b/packages/README.md index f19cb4cf6d..5461f7b1a1 100644 --- a/packages/README.md +++ b/packages/README.md @@ -38,6 +38,7 @@ dsh-tool-bash ← dsh-bash, dsh-tools (bash tool schemas) dsh-web ← dsh-llm (abstract web seam; search/fetch registries, WebError) dsh-web-search-exa ← dsh-web (Exa WebSearchProvider) dsh-web-search-perplexity ← dsh-web (Perplexity WebSearchProvider) +dsh-web-search-deepseek ← dsh-web (DeepSeek native-web-search WebSearchProvider) dsh-web-fetch-local ← dsh-web (anonymous public HTTP(S) WebFetchProvider) dsh-tool-web ← dsh-web, dsh-tools, dsh-system-prompt (web tool schemas) dsh-llm-deepseek ← dsh-llm (DeepSeek adapter) @@ -80,6 +81,7 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop | `web/` | `web` | Abstract web seam (search/fetch provider registries + selection + vocabulary + `WebError`) | `ctx.web` | | `web-search-exa/` | `web` | Exa-backed `WebSearchProvider` | (registers on `ctx.web`) | | `web-search-perplexity/` | `web` | Perplexity-backed `WebSearchProvider` | (registers on `ctx.web`) | +| `web-search-deepseek/` | `web` | DeepSeek-backed `WebSearchProvider` using native `web_search` through the Anthropic-compatible API | (registers on `ctx.web`) | | `web-fetch-local/` | `web` | Anonymous public HTTP(S) `WebFetchProvider` | (registers on `ctx.web`) | | `tool-web/` | `web` | Model-facing `web_search`/`web_fetch` tool schemas | (registers on `ctx.tools`) | | `llm-deepseek/` | `llm` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) | diff --git a/packages/web/README.md b/packages/web/README.md index 0742d9c2cc..c2d34e615f 100644 --- a/packages/web/README.md +++ b/packages/web/README.md @@ -7,6 +7,7 @@ The web access capability seam: an abstract web interface, search/fetch provider | `web/` | Abstract web seam (search/fetch provider registries + selection + vocabulary + `WebError`) | `ctx.web` | | `web-search-exa/` | Exa-backed `WebSearchProvider` | (registers on `ctx.web`) | | `web-search-perplexity/` | Perplexity-backed `WebSearchProvider` | (registers on `ctx.web`) | +| `web-search-deepseek/` | DeepSeek-backed `WebSearchProvider` using native `web_search` through the Anthropic-compatible API | (registers on `ctx.web`) | | `web-fetch-local/` | Anonymous public HTTP(S) `WebFetchProvider` | (registers on `ctx.web`) | | `tool-web/` | Model-facing `web_search`/`web_fetch` tool schemas | (registers on `ctx.tools`) | diff --git a/packages/web/web-search-deepseek/README.md b/packages/web/web-search-deepseek/README.md index 5b56601bbb..41b000d26a 100644 --- a/packages/web/web-search-deepseek/README.md +++ b/packages/web/web-search-deepseek/README.md @@ -20,8 +20,8 @@ It reuses `$DEEPSEEK_API_KEY` (no new secret) but **not** `$DEEPSEEK_BASE_URL`: | `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic-compatible endpoint base; `/messages` is appended. Use a separate env var such as `$DEEPSEEK_SEARCH_BASE_URL` when overriding it; do not reuse `$DEEPSEEK_BASE_URL`, which belongs to the chat-completions LLM adapter. An unparseable value makes `status()` report `misconfigured`. | | `model` | `deepseek-v4-flash` | Anthropic-format model name. | | `apiVersion` | `2023-06-01` | `anthropic-version` header value. | -| `maxTokens` | `4096` | Upper bound on generated tokens for the Messages request. | -| `maxUses` | `5` | Maximum `web_search` server-tool uses per request. | +| `maxTokens` | `4096` | Positive-integer upper bound on generated tokens for the Messages request. | +| `maxUses` | `5` | Positive-integer maximum `web_search` server-tool uses per request. | ```yaml - id: web-search-deepseek diff --git a/packages/web/web-search-deepseek/src/index.ts b/packages/web/web-search-deepseek/src/index.ts index 9a04955dd9..c993fa8808 100644 --- a/packages/web/web-search-deepseek/src/index.ts +++ b/packages/web/web-search-deepseek/src/index.ts @@ -64,18 +64,20 @@ export const Config: z = z.object({ baseURL: z.string(), model: z.string(), apiVersion: z.string(), - maxTokens: z.natural(), - maxUses: z.natural(), + maxTokens: z.number().step(1).min(1), + maxUses: z.number().step(1).min(1), }) /** Register the DeepSeek search provider with `ctx.web`. */ export function apply(ctx: Context, config: Config): void { + const maxTokens = config.maxTokens ?? DEEPSEEK_DEFAULT_MAX_TOKENS + const maxUses = config.maxUses ?? DEEPSEEK_DEFAULT_MAX_USES ctx.web.registerSearchProvider(new DeepSeekSearchProvider({ apiKey: config.apiKey ?? process.env.DEEPSEEK_API_KEY ?? '', baseURL: config.baseURL ?? DEEPSEEK_DEFAULT_BASE_URL, model: config.model ?? DEEPSEEK_DEFAULT_MODEL, apiVersion: config.apiVersion ?? DEEPSEEK_DEFAULT_API_VERSION, - maxTokens: config.maxTokens ?? DEEPSEEK_DEFAULT_MAX_TOKENS, - maxUses: config.maxUses ?? DEEPSEEK_DEFAULT_MAX_USES, + maxTokens, + maxUses, })) } diff --git a/packages/web/web-search-deepseek/src/provider.ts b/packages/web/web-search-deepseek/src/provider.ts index b637d52d11..40566b4f75 100644 --- a/packages/web/web-search-deepseek/src/provider.ts +++ b/packages/web/web-search-deepseek/src/provider.ts @@ -147,6 +147,7 @@ export class DeepSeekSearchProvider implements WebSearchProvider { status(): WebProviderStatus { if (this.options.apiKey.length === 0) return { available: false, reason: 'missing-credential' } if (!URL.canParse(this.options.baseURL)) return { available: false, reason: 'misconfigured' } + if (!isPositiveInteger(this.options.maxTokens) || !isPositiveInteger(this.options.maxUses)) return { available: false, reason: 'misconfigured' } return { available: true } } @@ -215,3 +216,8 @@ export class DeepSeekSearchProvider implements WebSearchProvider { function isAbortError(error: unknown): boolean { return error instanceof DOMException && error.name === 'AbortError' } + +/** True for DeepSeek request limits that can be sent to the Messages API. */ +function isPositiveInteger(value: number): boolean { + return Number.isInteger(value) && value > 0 +} diff --git a/packages/web/web-search-deepseek/tests/deepseek.spec.ts b/packages/web/web-search-deepseek/tests/deepseek.spec.ts index 496b7f6a3c..ef688b7ad2 100644 --- a/packages/web/web-search-deepseek/tests/deepseek.spec.ts +++ b/packages/web/web-search-deepseek/tests/deepseek.spec.ts @@ -152,6 +152,15 @@ describe('DeepSeekSearchProvider status', () => { expect(new DeepSeekSearchProvider({ ...options, baseURL: 'not a url' }).status()) .toEqual({ available: false, reason: 'misconfigured' }) }) + + it('is misconfigured when request limits are not positive integers', () => { + expect(new DeepSeekSearchProvider({ ...options, maxTokens: 0 }).status()) + .toEqual({ available: false, reason: 'misconfigured' }) + expect(new DeepSeekSearchProvider({ ...options, maxUses: 0 }).status()) + .toEqual({ available: false, reason: 'misconfigured' }) + expect(new DeepSeekSearchProvider({ ...options, maxUses: 1.5 }).status()) + .toEqual({ available: false, reason: 'misconfigured' }) + }) }) describe('DeepSeekSearchProvider request mapping', () => { @@ -263,6 +272,27 @@ describe('web-search-deepseek plugin registration', () => { expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'configured-missing' }) }) + it('rejects maxTokens: 0 at plugin construction', async () => { + const ctx = new Context() + await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID }) + await expect(ctx.plugin(deepseekPlugin, { apiKey: 'ds-key', maxTokens: 0 })) + .rejects.toThrow(/maxTokens expected number >= 1/) + }) + + it('rejects maxUses: 0 at plugin construction', async () => { + const ctx = new Context() + await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID }) + await expect(ctx.plugin(deepseekPlugin, { apiKey: 'ds-key', maxUses: 0 })) + .rejects.toThrow(/maxUses expected number >= 1/) + }) + + it('rejects a fractional maxUses at plugin construction', async () => { + const ctx = new Context() + await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID }) + await expect(ctx.plugin(deepseekPlugin, { apiKey: 'ds-key', maxUses: 1.5 })) + .rejects.toThrow(/maxUses expected number multiple of 1/) + }) + it('has no default export (namespace plugin export shape)', () => { expect('default' in deepseekPlugin).toBe(false) }) From df0e7bd5f2add4b78318803559496db23bbc7c93 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 2 Jul 2026 02:20:24 +0800 Subject: [PATCH 26/75] feat(docs): generate a tool-schema catalog by booting the tool plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add docs/tool-catalog/tools.md, a generated reference of every model-facing tool a shipped `packages/*/tool-*` plugin contributes (name, description, JSON-Schema parameters) — the third generated catalog alongside the cordis events/services and core-data-structures catalogs. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real cordis Context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable: `todo_write` builds its enum with a runtime spread, descriptions are string-concatenated, `subagent`'s name is config-driven, and MCP tools register raw JSON Schema without `defineTool`. A completeness guard globs the on-disk `tool-*` packages and fails if any is absent from the boot manifest, restoring the "nothing silently omitted" property booting would otherwise lose. `verify-tool-catalog` runs inside `doc-sync`, so the artifact cannot drift. The boot-over-AST decision and the discovered-inventory / hand-written-recipe split are recorded in a process RFC. --- docs/rfc/README.md | 1 + .../process/2026-07-02-tool-schema-catalog.md | 45 ++++ docs/tool-catalog/tools.md | 165 +++++++++++++ package.json | 4 +- packages/core/tools/README.md | 2 +- .../core/tools/tests/gen-tool-catalog.spec.ts | 105 ++++++++ scripts/gen-tool-catalog.ts | 229 ++++++++++++++++++ 7 files changed, 549 insertions(+), 2 deletions(-) create mode 100644 docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md create mode 100644 docs/tool-catalog/tools.md create mode 100644 packages/core/tools/tests/gen-tool-catalog.spec.ts create mode 100644 scripts/gen-tool-catalog.ts diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 348a7fcdaf..981710c4f5 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -135,6 +135,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Core-data-structures catalog and the `ts type-equiv` drift gate](implemented/process/2026-06-20-core-data-structures-catalog.md) | 2026-06-20 | | [Generated cordis events + services catalog](implemented/process/2026-06-20-generated-cordis-catalog.md) | 2026-06-20 | | [Classify RFCs by kind via path-encoded subdirectories](implemented/process/2026-06-20-rfc-classification.md) | 2026-06-20 | +| [Generated tool-schema catalog (boot-and-harvest)](implemented/process/2026-07-02-tool-schema-catalog.md) | 2026-07-02 | ### Testing diff --git a/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md b/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md new file mode 100644 index 0000000000..9af018d2d6 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md @@ -0,0 +1,45 @@ +# RFC: Generated tool-schema catalog (boot-and-harvest) + +Status: implemented (accepted 2026-07-02) + +## Context + +A reader — a plugin author, a prompt engineer, someone auditing what the agent can do — has no single place that lists the model-facing tools the harness ships. The `name` / `description` / JSON-Schema `parameters` a tool contributes are what the model actually receives (via `ctx.systemPrompt.tools()` off `ctx.tools.schemas()`), but they are scattered across each `defineTool` call in each `packages/*/tool-*` package, buried in string concatenation and runtime spreads. The [cordis events & services catalog](../../../cordis-catalog/events-and-services.md) ([its RFC](2026-06-20-generated-cordis-catalog.md)) documents the *wiring* a plugin works against and the [core-data-structures catalog](../../../core-data-structures/core.md) documents the *vocabulary* those signatures move — but neither documents the *tools* the agent is offered. This RFC adds that third reference surface, `docs/tool-catalog/tools.md`, and a freshness gate so it cannot drift. + +## Decision + +Generate the catalog by **booting each tool plugin and reading its registered schemas**, not by parsing source. `scripts/gen-tool-catalog.ts` mounts each shipped tool package on a fresh cordis `Context` (with `SystemPrompt` + `ToolRegistry` and the injected seams the plugin's `apply` reads), calls `ctx.tools.schemas()` — exactly the `ToolSchema[]` the model is sent — disposes the context, and renders one `## ` section per package with a ` ```json ` `parameters` block per tool. It mirrors the `gen-cordis-catalog` / `gen-module-graph` CLI shape: default `--write` regenerates, `--check` fails if the committed copy is stale, output is deterministic (manifest-ordered, tools sorted by name). `verify-tool-catalog` (the `--check`) runs inside `doc-sync`, so the freshness gate fires in the same lefthook pre-push and CI paths as every other doc gate. + +### Why boot, not parse (the crux) + +The cordis catalog is a pure TypeScript-AST pass because every event/service name is a string literal that round-trips to a static declaration — the AST is the whole truth. **Tool schemas are not statically knowable**, so the same technique would produce a doc that lies: + +- `tool-todo` writes `enum: [...STATUSES]` — a spread of a runtime `const`. The AST sees the spread expression, not `["pending","in_progress","completed"]`. +- Every description is built by string **concatenation** (`'…' + '…'`). The AST sees concatenation nodes, not the final prose the model reads. +- `tool-subagent`'s tool name is `config.toolName ?? 'subagent'` — chosen at load, not a literal. +- An MCP plugin can register **raw JSON Schema** directly via `ctx.tools.register()` without `defineTool` at all, so enumerating `defineTool(` call sites structurally under-counts. + +The only faithful source of truth is the schema the registry actually holds after the plugin loads. Booting is the [unit-test discipline](../../../../AGENTS.md) "verify the world, not a synthetic stand-in" applied to a doc generator: read the shipped artifact, not a re-derivation of it. + +### Restoring "nothing silently omitted" + +Booting has a cost the AST pass did not: there is no source declaration set to enumerate, so a new tool package could simply be forgotten. A **completeness guard** restores the guarantee — `assertManifestComplete` globs every `tool-*` package under `packages/` and hard-errors if any is absent from the generator's boot manifest. A new tool package fails the generator, and therefore `doc-sync`, until it is registered. This is the same structural property the cordis generator gets for free from enumerating source, re-created for a boot-based generator. + +### A hand-maintained boot manifest is the irreducible policy + +The boot manifest (`TOOL_PACKAGES`) is a hand-written list — in tension with the proposed [Discover package inventories instead of maintaining static lists](../../proposed/process/2026-06-20-discover-package-inventory.md). The tension is deliberate and resolved as follows: the *inventory* is discovered (the glob guard means no one maintains "the list of tool packages" — the filesystem is the source of truth, and drift fails the gate), but the *boot recipe* per package — which seams to plug (`bash-local` for `ctx.bash`, `subagent` + `subagent-mock` for `ctx.subagents`) and with what config (`{ provider: 'mock' }`) — is genuine policy that no layout fact encodes. Per that RFC's own "what we give up" ("stay boring: read manifests, filter on explicit fields, print the resolved list, and fail loud"), a recipe closure is the boring, explicit form; inferring seam wiring from injects would be the "too clever" path it warns against. So: discovered inventory, hand-written recipe, gate on completeness. + +### Scope + +Shipped product tools under `packages/*/tool-*` only: `dsh-tool-bash` (`bash`, `bash_output`, `bash_kill`), `dsh-tool-todo` (`todo_write`), `dsh-tool-subagent` (`subagent`). The `examples/` demo tools (`echo`) are excluded, matching the cordis catalog's packages-only scope — a demo tool is not part of the product surface a reader is cataloguing. + +### A plain `json` fence + +Schema blocks use ` ```json `, not a bespoke `ts`-family fence. `doc-typecheck` only extracts `ts*` fences, so a JSON block is invisible to it — no `BlockKind` wiring is needed (unlike the cordis catalog's `ts cordis-catalog` fence, which had to be allowlisted so a bare signature fragment isn't compiled). + +## Consequences + +- The catalog cannot drift: a tool schema change the committed file doesn't reflect fails `verify-tool-catalog` in the pre-push hook and CI. A new `tool-*` package not added to the manifest fails the completeness guard outright. +- Tool description prose has a single home — the `defineTool` `description` at the source — and the generated entry is only as good as it, the same forcing function the cordis catalog applies to event JSDoc. +- The generator imports and executes workspace packages (the first repo script to do so; the others only read text). It runs under `tsx` via the root `tsconfig` `paths` map, the same unbuilt-source path the demos and tests use, so it needs no build step. +- A new capability seam behind a future tool means a new manifest recipe entry (which seams to mount). This is the deliberate hand-written cost called out above; it changes only when a tool package is added. diff --git a/docs/tool-catalog/tools.md b/docs/tool-catalog/tools.md new file mode 100644 index 0000000000..9e625d17ad --- /dev/null +++ b/docs/tool-catalog/tools.md @@ -0,0 +1,165 @@ + + +# Tool Schema Catalog + +Every model-facing tool a shipped plugin contributes to `ctx.tools`: the exact `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the [cordis events & services catalog](../cordis-catalog/events-and-services.md) (the wiring a plugin listens to and calls) and [core-data-structures/](../core-data-structures/core.md) (the types those signatures move) — this page is the *tools* the agent is offered. + +This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any package is missing from the generator's boot manifest, so a new tool cannot be silently undocumented. See [the tool-schema-catalog RFC](../rfc/implemented/process/2026-07-02-tool-schema-catalog.md). + +Scope: shipped product tools under `packages/*/tool-*`. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog's packages-only scope. + +## `@deepseek-ai/dsh-tool-bash` + +### `bash` + +Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. + +```json +{ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a task id immediately. No timeout applies." + } + }, + "required": [ + "command", + "description" + ] +} +``` + +Source: [`packages/bash/tool-bash/src/index.ts`](../../packages/bash/tool-bash/src/index.ts) + +### `bash_kill` + +Ask the executor to kill a running background bash task by task id. + +```json +{ + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the bash tool." + } + }, + "required": [ + "task_id" + ] +} +``` + +Source: [`packages/bash/tool-bash/src/index.ts`](../../packages/bash/tool-bash/src/index.ts) + +### `bash_output` + +Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output. + +```json +{ + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the bash tool." + } + }, + "required": [ + "task_id" + ] +} +``` + +Source: [`packages/bash/tool-bash/src/index.ts`](../../packages/bash/tool-bash/src/index.ts) + +## `@deepseek-ai/dsh-tool-subagent` + +### `subagent` + +Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. + +```json +{ + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + } + }, + "required": [ + "description", + "prompt" + ] +} +``` + +Source: [`packages/subagent/tool-subagent/src/index.ts`](../../packages/subagent/tool-subagent/src/index.ts) + +## `@deepseek-ai/dsh-tool-todo` + +### `todo_write` + +Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). + +```json +{ + "type": "object", + "properties": { + "todos": { + "type": "array", + "description": "The COMPLETE task list, replacing any previous list.", + "items": { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "What the task is — a short imperative line." + }, + "status": { + "type": "string", + "description": "pending (not started) | in_progress (now) | completed (done).", + "enum": [ + "pending", + "in_progress", + "completed" + ] + } + }, + "required": [ + "content", + "status" + ] + } + } + }, + "required": [ + "todos" + ] +} +``` + +Source: [`packages/todo/tool-todo/src/index.ts`](../../packages/todo/tool-todo/src/index.ts) diff --git a/package.json b/package.json index 2e83cdca61..7d82af4aad 100644 --- a/package.json +++ b/package.json @@ -34,10 +34,12 @@ "verify-node-next-types": "tsx scripts/verify-node-next-types.ts", "gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts", "verify-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts --check", + "gen-tool-catalog": "tsx scripts/gen-tool-catalog.ts", + "verify-tool-catalog": "tsx scripts/gen-tool-catalog.ts --check", "gen-module-graph": "tsx scripts/gen-module-graph.ts", "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", "constraints": "tsx scripts/check-workspace-constraints.ts", - "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-rfc-classification && pnpm run verify-type-equiv", + "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-tool-catalog && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-rfc-classification && pnpm run verify-type-equiv", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types", "demo:echo": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/echo-agent/cordis.yml", "demo:coding": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/coding-agent/cordis.yml", diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 6b1634cd70..102645ad82 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -8,7 +8,7 @@ Tool registry and execution waterfall. Tool plugins register their schemas and e - `ctx.tools.register(definition: ToolDefinition): () => void` Register a tool. Disposed with the calling fiber. - `ctx.tools.get(name: string): ToolDefinition | undefined` -- `ctx.tools.schemas(): ToolSchema[]` Schemas of all registered tools (without the `execute` functions). +- `ctx.tools.schemas(): ToolSchema[]` Schemas of all registered tools (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog/tools.md](../../../docs/tool-catalog/tools.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)). - `ctx.tools.execute(exec: ToolExecution): Promise` Execute one tool call through the `tools/execute` waterfall. ### Injected services diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts new file mode 100644 index 0000000000..57d6b6ccc4 --- /dev/null +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -0,0 +1,105 @@ +/** + * Guarantee tests for the tool-schema catalog generator + * (`scripts/gen-tool-catalog.ts`). + * + * The generated catalog is frozen by a regenerate-and-diff freshness gate, so + * the freshness half is exercised by `pnpm run verify-tool-catalog` in CI. What + * a freshness diff CANNOT prove is (a) that BOOTING the tool plugins yields the + * shipped schema — the whole reason this generator boots instead of parsing + * source (a runtime-spread enum resolves to its literal members) — and (b) that + * the completeness guard REJECTS a tool package missing from the boot manifest, + * the property that replaces the AST pass's "nothing silently omitted". These + * tests drive the exported `collectToolCatalog` / `assertManifestComplete` / + * `render` directly, mirroring the negative-path style of the cordis-catalog + * generator tests. + */ + +import { describe, expect, it } from 'vitest' +import { + assertManifestComplete, + collectToolCatalog, + render, + type ToolCatalog, +} from '../../../../scripts/gen-tool-catalog.ts' + +/** JSON Schema shape enough to reach the values AST extraction can't. */ +interface JsonSchema { + type: string + properties?: Record + items?: JsonSchema + enum?: string[] + required?: string[] +} + +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']) + // Every tool carries a JSON-Schema `parameters` object (what the model sees). + for (const entry of catalog) { + for (const schema of entry.schemas) { + expect((schema.parameters as unknown as JsonSchema).type).toBe('object') + } + } + }) + + it('resolves a runtime-spread enum to its literal members (the payoff over AST)', async () => { + const catalog = await collectToolCatalog() + const todo = catalog + .flatMap(entry => entry.schemas) + .find(s => s.name === 'todo_write') + // `todo-todo` writes `enum: [...STATUSES]` — a source AST would see the + // spread, not the values. Booting yields the shipped enum literals. + const status = (((todo?.parameters as unknown as JsonSchema).properties?.todos)?.items)?.properties?.status + expect(status?.enum).toEqual(['pending', 'in_progress', 'completed']) + }) + + it('attributes each package with a source pointer that names its index', async () => { + const catalog = await collectToolCatalog() + const bash = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-bash') + expect(bash?.source).toBe('packages/bash/tool-bash/src/index.ts') + }) +}) + +describe('gen-tool-catalog assertManifestComplete', () => { + it('passes when the manifest lists every on-disk tool package (the default)', () => { + expect(() => { assertManifestComplete() }).not.toThrow() + }) + + it('throws, naming the omitted package, when a tool package is missing from the manifest', () => { + // An empty manifest scanned against the real tree: every `tool-*` package + // is unlisted, so the guard must fire and name them. + expect(() => { assertManifestComplete([]) }).toThrow(/not in the boot manifest/) + expect(() => { assertManifestComplete([]) }).toThrow(/tool-bash/) + }) +}) + +describe('gen-tool-catalog render', () => { + it('emits a package heading, a tool heading, and a json schema fence', () => { + const catalog: ToolCatalog = [ + { + pkg: '@deepseek-ai/dsh-tool-demo', + source: 'packages/demo/tool-demo/src/index.ts', + schemas: [{ name: 'demo', description: 'A demo tool.', parameters: { type: 'object', properties: {} } }], + }, + ] + const md = render(catalog) + expect(md).toContain('## `@deepseek-ai/dsh-tool-demo`') + expect(md).toContain('### `demo`') + expect(md).toContain('A demo tool.') + expect(md).toContain('```json') + expect(md).toContain('Source: [`packages/demo/tool-demo/src/index.ts`]') + }) + + it('renders the strict flag when a schema sets it', () => { + const catalog: ToolCatalog = [ + { + pkg: '@deepseek-ai/dsh-tool-demo', + source: 'packages/demo/tool-demo/src/index.ts', + schemas: [{ name: 'demo', description: '', parameters: { type: 'object', properties: {} }, strict: true }], + }, + ] + expect(render(catalog)).toContain('Strict: `true`') + }) +}) diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts new file mode 100644 index 0000000000..8e62277713 --- /dev/null +++ b/scripts/gen-tool-catalog.ts @@ -0,0 +1,229 @@ +/** + * Generate (and verify) the tool-schema catalog in docs/tool-catalog/tools.md. + * + * The catalog is the MODEL-FACING TOOL reference: every tool a shipped plugin + * contributes to `ctx.tools`, with the exact `name` / `description` / JSON-Schema + * `parameters` the model receives via the system-prompt assembly. It complements + * the cordis events/services catalog (the wiring a plugin author works against) + * and the core-data-structures catalog (the vocabulary those signatures move): + * this page is the TOOLS the agent is offered. + * + * `tsx scripts/gen-tool-catalog.ts` → write the catalog + * `tsx scripts/gen-tool-catalog.ts --check` → exit 1 if the committed file + * is stale (CI / pre-push gate) + * + * Why this generator BOOTS PLUGINS instead of parsing source (unlike its AST + * sibling `gen-cordis-catalog.ts`): a tool's schema is not statically knowable. + * `tool-todo` writes `enum: [...STATUSES]` (a runtime spread), descriptions are + * built by string concatenation, `tool-subagent`'s tool name is `config.toolName`, + * and an MCP plugin can register RAW JSON Schema without `defineTool` at all. The + * faithful source of truth is therefore the SHIPPED schema: mount each tool + * plugin on a real cordis Context and read `ctx.tools.schemas()` — exactly the + * `ToolSchema[]` the model is sent. See + * docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md. + * + * Booting sacrifices the AST pass's structural "nothing can be silently omitted" + * property (there is no source declaration to enumerate), so a COMPLETENESS GUARD + * restores it: the generator globs every `tool-*` package under `packages/` and + * hard-errors if any such package is absent from the boot manifest below. A new + * tool package fails the generator — and thus the freshness gate — until it is + * registered here, mirroring how a new event appears in the cordis regenerate. + * + * Schema blocks use a plain ` ```json ` fence: doc-typecheck only extracts `ts*` + * fences, so no BlockKind wiring is needed there. + */ + +import { globSync, readFileSync, writeFileSync } from 'node:fs' +import { basename, resolve } from 'node:path' +import { Context } from 'cordis' +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 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 ToolTodo from '@deepseek-ai/dsh-tool-todo' +import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent' + +const root = resolve(import.meta.dirname, '..') +const OUT = 'docs/tool-catalog/tools.md' + +/** + * One tool-plugin package to boot. `mount` is a per-entry recipe (async): it + * plugs the injected seams the plugin's `apply` reads (an executor for + * `ctx.bash`, a provider for `ctx.subagents`) BEFORE the tool plugin itself. + * `SystemPrompt` + `ToolRegistry` are mounted for every entry by the caller + * (`ToolRegistry` injects `systemPrompt`), so `mount` only handles the extras. + * + * The recipe is irreducible policy — WHICH seams a given tool needs and with + * WHAT config is not derivable from the package layout — so it stays a hand- + * maintained closure. The `dir` field is what the completeness guard matches + * against the on-disk `tool-*` package glob, so a NEW tool package cannot be + * silently omitted (see the module doc). + */ +interface ToolPackage { + /** The npm package name, used as the catalog section heading. */ + pkg: string + /** The `packages//

` leaf name — matched by the completeness guard. */ + dir: string + /** Repo-relative source path linked from the catalog entry. */ + source: string + /** Plug the injected seams + the tool plugin onto a context that already + * carries `systemPrompt` + `tools`. */ + mount: (ctx: Context) => Promise +} + +/** + * The boot manifest: every shipped tool package (a `tool-*` leaf under + * `packages/`). Ordered by package name (the render order); the completeness + * guard proves it is exhaustive against the on-disk glob. + */ +const TOOL_PACKAGES: ToolPackage[] = [ + { + pkg: '@deepseek-ai/dsh-tool-bash', + dir: 'tool-bash', + source: 'packages/bash/tool-bash/src/index.ts', + async mount(ctx) { + await ctx.plugin(LocalBashExecutor) + await ctx.plugin(ToolBash) + }, + }, + { + pkg: '@deepseek-ai/dsh-tool-subagent', + dir: 'tool-subagent', + source: 'packages/subagent/tool-subagent/src/index.ts', + async mount(ctx) { + await ctx.plugin(SubagentService) + // Register a scripted provider under the name the tool delegates to. + await ctx.plugin(SubagentMock, { name: 'mock' }) + await ctx.plugin(ToolSubagent, { provider: 'mock' }) + }, + }, + { + pkg: '@deepseek-ai/dsh-tool-todo', + dir: 'tool-todo', + source: 'packages/todo/tool-todo/src/index.ts', + async mount(ctx) { + await ctx.plugin(ToolTodo) + }, + }, +] + +/** One package's contribution to the catalog: its schemas plus attribution. */ +interface CatalogPackage { + pkg: string + source: string + schemas: ToolSchema[] +} + +/** The whole catalog: one entry per booted tool package, in manifest order. */ +export type ToolCatalog = CatalogPackage[] + +/** + * Assert the boot manifest covers every shipped tool package on disk (a + * `tool-*` leaf under `packages/`). + * Booting has no source declaration to enumerate, so this glob restores the + * "a new tool cannot be silently undocumented" guarantee: an unlisted package + * fails the generator (and the freshness gate) until it is added to + * {@link TOOL_PACKAGES}. Exported for a direct negative test. + * + * `scanRoot` defaults to the repo root; a test may point it at a fixture tree. + */ +export function assertManifestComplete(packages: ToolPackage[] = TOOL_PACKAGES, scanRoot: string = root): void { + const onDisk = globSync('packages/*/tool-*', { cwd: scanRoot }).map(p => basename(p)).sort() + const listed = new Set(packages.map(p => p.dir)) + const missing = onDisk.filter(dir => !listed.has(dir)) + if (missing.length > 0) { + throw new Error( + `gen-tool-catalog: ${missing.length} tool package(s) not in the boot manifest: ${missing.join(', ')}. ` + + 'Add each to TOOL_PACKAGES in scripts/gen-tool-catalog.ts so its schema is catalogued.', + ) + } +} + +/** + * Boot each tool package on a fresh Context and harvest its model-facing + * schemas. A fresh Context per package keeps attribution clean (each entry's + * schemas come from exactly that package) and isolates a boot failure to its + * own entry. Disposed after harvest so no executor/provider outlives the run. + */ +export async function collectToolCatalog(packages: ToolPackage[] = TOOL_PACKAGES): Promise { + assertManifestComplete(packages) + const catalog: ToolCatalog = [] + for (const entry of packages) { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await entry.mount(ctx) + // Copy the schemas out before the context is torn down. + const schemas = ctx.tools.schemas().sort((a, b) => a.name.localeCompare(b.name)) + await ctx.fiber.dispose() + catalog.push({ pkg: entry.pkg, source: entry.source, schemas }) + } + return catalog +} + +/** Render one tool's entry: name, description, JSON-Schema parameters, source. */ +function renderTool(schema: ToolSchema, source: string): string[] { + const out = [`### \`${schema.name}\``, ''] + if (schema.description) out.push(schema.description, '') + if (schema.strict !== undefined) out.push(`Strict: \`${String(schema.strict)}\``, '') + out.push('```json', JSON.stringify(schema.parameters, null, 2), '```', '') + out.push(`Source: [\`${source}\`](../../${source})`, '') + return out +} + +/** Render the full catalog (pure, deterministic given the manifest-ordered input). */ +export function render(catalog: ToolCatalog): string { + const lines: string[] = [ + '', + '', + '# Tool Schema Catalog', + '', + 'Every model-facing tool a shipped plugin contributes to `ctx.tools`: the exact `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the [cordis events & services catalog](../cordis-catalog/events-and-services.md) (the wiring a plugin listens to and calls) and [core-data-structures/](../core-data-structures/core.md) (the types those signatures move) — this page is the *tools* the agent is offered.', + '', + 'This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any package is missing from the generator\'s boot manifest, so a new tool cannot be silently undocumented. See [the tool-schema-catalog RFC](../rfc/implemented/process/2026-07-02-tool-schema-catalog.md).', + '', + 'Scope: shipped product tools under `packages/*/tool-*`. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog\'s packages-only scope.', + '', + ] + for (const entry of catalog) { + lines.push(`## \`${entry.pkg}\``, '') + for (const schema of entry.schemas) lines.push(...renderTool(schema, entry.source)) + } + return lines.join('\n') +} + +/** CLI entry: default writes the catalog, `--check` fails if the committed copy + * is stale. Guarded behind an entry-point check so importing this module for + * tests neither regenerates the committed file nor calls process.exit. */ +async function main(): Promise { + const content = render(await collectToolCatalog()) + if (process.argv.includes('--check')) { + let committed: string | null = null + try { + committed = readFileSync(resolve(root, OUT), 'utf8') + } catch { + // Only ENOENT (not yet generated) is expected; a present-but-unreadable + // file is not a state this repo produces. Either way the remedy is the + // same — regenerate — so treat a read failure as "stale". + committed = null + } + if (committed === content) { + console.log(`gen-tool-catalog: ${OUT} is up to date.`) + process.exit(0) + } + console.error(`gen-tool-catalog: ${OUT} is stale. Run \`pnpm run gen-tool-catalog\` and commit ${OUT}.`) + process.exit(1) + } + + writeFileSync(resolve(root, OUT), content) + console.log(`gen-tool-catalog: wrote ${OUT}.`) +} + +// Run only when invoked as a script, not when imported by a test. +if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) { + await main() +} From 30c18637553d52bb66e811935967f139e0d54d6c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 2 Jul 2026 03:12:38 +0800 Subject: [PATCH 27/75] =?UTF-8?q?fix(fs):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20rename=20to=20dsh-fs-policy,=20fs/*-intent=20events,=20RFC?= =?UTF-8?q?=20currency,=20ENOTDIR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename per review naming decisions: - package dsh-file-context → dsh-fs-policy (dir, package name, plugin name, tsconfig refs, importers, type-equiv manifest, generated catalog + module-graph) - events fs/write-expectation → fs/write-intent, fs/edit-expectation → fs/edit-intent (fs/observed unchanged); type FsWriteExpectation → FsWriteIntent, "expectation" wording → "intent" throughout - exported FileContextExec → FsPolicyExec Make the implemented RFCs describe what shipped, not the superseded designs: the 2026-06-17 capability-seam + tool-schemas RFCs no longer place policy on ctx.fs or use full/partial-view authorization, and the fsspec RFC's ctx.fileContext service prose is rewritten to the fs/* event-gate reality (freshness-based auth). Sharpen docs/rfc/implemented/AGENTS.md: a rename is a fact to fix IN PLACE — the "new RFC" escape hatch is for macro decision reversals only, not renames. Code fixes from review: - fsio.ts resolveLocalTarget/probe translate ENOTDIR (a parent path segment is a file) into the structured FsError taxonomy instead of leaking a raw Node error; resolve reports FS_NOT_FOUND, probe reports absent. Regression tests proven to fail on the unfixed code. - tool-fs HMR test now asserts prompt sections (not just tool schemas) are withdrawn on disposal. - fs/observed is a plain (unguarded) ctx.emit: correct the fs-policy comment, filesystem.md, and tool-fs module doc that wrongly claimed the tool "contains" a throwing listener; a throw surfaces as the tool's isError result. - drop the false "loaded by the default product config" claim (no config wires the fs tools yet), the duplicate ctx.bash service-map row, the stale FileReadRequest catalog link-map entry, and the fs/fs README EOF blank line; correct the dsh-fs package.json description. --- docs/architecture.md | 5 +- docs/cordis-catalog/events-and-services.md | 22 ++--- docs/core-data-structures/filesystem.md | 22 ++--- docs/module-graph.md | 4 +- docs/rfc/README.md | 4 +- docs/rfc/implemented/AGENTS.md | 2 +- .../2026-06-17-filesystem-capability-seam.md | 48 +++++----- .../2026-06-26-file-context-as-event-gate.md | 92 +++++++++---------- .../2026-06-17-filesystem-tool-schemas.md | 20 ++-- .../2026-06-26-fsspec-style-fs-seam.md | 46 +++++----- packages/README.md | 4 +- packages/fs/README.md | 4 +- packages/fs/fs-local/README.md | 2 +- packages/fs/fs-local/src/fsio.ts | 22 ++++- packages/fs/fs-local/src/index.ts | 4 +- packages/fs/fs-local/tests/filesystem.spec.ts | 2 +- packages/fs/fs-local/tests/fsio.spec.ts | 19 +++- .../fs/{file-context => fs-policy}/README.md | 18 ++-- .../{file-context => fs-policy}/package.json | 2 +- .../{file-context => fs-policy}/src/index.ts | 43 ++++----- .../{file-context => fs-policy}/src/types.ts | 6 +- .../tests/policy.spec.ts | 92 +++++++++---------- .../{file-context => fs-policy}/tsconfig.json | 0 packages/fs/fs/README.md | 11 +-- packages/fs/fs/package.json | 2 +- packages/fs/fs/src/index.ts | 32 +++---- packages/fs/fs/src/types.ts | 8 +- packages/fs/fs/tests/service.spec.ts | 6 +- packages/fs/tool-fs/README.md | 14 +-- packages/fs/tool-fs/package.json | 2 +- packages/fs/tool-fs/src/edit.ts | 10 +- packages/fs/tool-fs/src/index.ts | 19 ++-- packages/fs/tool-fs/src/read.ts | 2 +- packages/fs/tool-fs/src/write.ts | 10 +- packages/fs/tool-fs/tests/integration.spec.ts | 10 +- packages/fs/tool-fs/tests/tools.spec.ts | 29 +++--- packages/fs/tool-fs/tsconfig.json | 2 +- pnpm-lock.yaml | 30 +++--- scripts/gen-cordis-catalog.ts | 5 +- scripts/type-equiv.manifest.json | 4 +- tsconfig.build.json | 2 +- tsconfig.json | 2 +- 42 files changed, 360 insertions(+), 323 deletions(-) rename packages/fs/{file-context => fs-policy}/README.md (60%) rename packages/fs/{file-context => fs-policy}/package.json (95%) rename packages/fs/{file-context => fs-policy}/src/index.ts (79%) rename packages/fs/{file-context => fs-policy}/src/types.ts (86%) rename packages/fs/{file-context => fs-policy}/tests/policy.spec.ts (56%) rename packages/fs/{file-context => fs-policy}/tsconfig.json (100%) diff --git a/docs/architecture.md b/docs/architecture.md index 3539a0dae3..eddb1ea235 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,7 +25,7 @@ For a catalog of the **data structures** this architecture moves around — the │ @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-file-context (filesystem policy gate) │ +│ @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)│ @@ -60,7 +60,6 @@ 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.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 | @@ -79,7 +78,7 @@ 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-file-context` is a policy PLUGIN (no service) that decides the `fs/write-expectation`/`fs/edit-expectation` 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-file-context` gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool at a service-injection boundary. The fs tools are not wired into any default/example config yet (the demo agents do file ops through bash); a deployment that loads `dsh-tool-fs` is expected to also load `dsh-file-context` so the default behavior is read-before-write/edit. See [the file-context event-gate RFC](rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md). +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 fs tools are not wired into any default/example config yet (the demo agents do file ops through bash); a deployment that loads `dsh-tool-fs` is expected to also load `dsh-fs-policy` so the default behavior is read-before-write/edit. 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. diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 7c1701af59..5a0f203e23 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -199,12 +199,12 @@ Source: [`packages/core/agent/src/types.ts:163`](../../packages/core/agent/src/t ### `fs/*` -#### `fs/edit-expectation` — waterfall +#### `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-file-context` 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-expectation'). +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-expectation'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> +'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> ``` Types: [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) @@ -213,7 +213,7 @@ 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-file-context`'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. +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 @@ -223,15 +223,15 @@ Types: [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core- Source: [`packages/fs/fs/src/index.ts:129`](../../packages/fs/fs/src/index.ts) -#### `fs/write-expectation` — waterfall +#### `fs/write-intent` — waterfall -Single-slot decision: produce the write expectation 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-file-context` 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. +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-expectation'(target: FsTarget, actor: object | undefined, next: () => FsWriteExpectation | undefined | Promise): Promise +'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise): Promise ``` -Types: [FsTarget](../core-data-structures/filesystem.md) · [FsWriteExpectation](../core-data-structures/filesystem.md) +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) @@ -440,7 +440,7 @@ 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 FsWriteExpectation to guard the write. +- 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 @@ -448,11 +448,11 @@ abstract resolve(path: string): Promise abstract stat(target: FsTarget, signal?: AbortSignal): Promise abstract readText(target: FsTarget, signal?: AbortSignal): Promise abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> -abstract writeText(target: FsTarget, content: string, expected?: FsWriteExpectation, signal?: AbortSignal): Promise +abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise ``` -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) · [FsWriteExpectation](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) +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) diff --git a/docs/core-data-structures/filesystem.md b/docs/core-data-structures/filesystem.md index 22cda4672d..2e66dd9d9e 100644 --- a/docs/core-data-structures/filesystem.md +++ b/docs/core-data-structures/filesystem.md @@ -1,10 +1,10 @@ # 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-file-context](../../packages/fs/file-context), 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 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-file-context` 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-file-context` so the default behavior is read-before-write/edit. +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/file-context/src/types.ts`](../../packages/fs/file-context/src/types.ts). Read-rendering source: [`packages/fs/tool-fs/src/read-render.ts`](../../packages/fs/tool-fs/src/read-render.ts). +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) @@ -40,10 +40,10 @@ interface FsInfo { ## 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 `FsWriteExpectation` — `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. +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 FsWriteExpectation = +type FsWriteIntent = | { kind: 'createIfAbsent' } | { kind: 'replaceIfVersion'; version: FsVersion } ``` @@ -75,16 +75,16 @@ interface FsEditOutcome { ## 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-file-context`) 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. +`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-expectation` and `fs/edit-expectation` 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 whose listener must be synchronous and side-effect-only; the tool contains a throw so a recording bug never fails the already-completed mutation. The generated catalog shows the exact signatures on [events-and-services.md](../cordis-catalog/events-and-services.md). +`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-file-context` import the tool, agent, or session packages. +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 FileContextExec { +interface FsPolicyExec { agent?: { session?: object } @@ -108,7 +108,7 @@ interface FileReadOutcome { ## Observed-file state (policy plugin) -Observed state is a `WeakMap>` held inside the `dsh-file-context` 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). +Observed state is a `WeakMap>` 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) @@ -130,4 +130,4 @@ type FsErrorCode = ## The service and the plugin -`FileSystem` (`ctx.fs`, abstract) owns the provider primitives: `resolve`, `stat`, `readText`, `streamText`, `writeText`, and `editText`. `dsh-file-context` registers **no service** — it is a plugin that adds policy through the `fs/*` event gate: it decides the write/edit expectation 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). +`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). diff --git a/docs/module-graph.md b/docs/module-graph.md index 3fe0d5aefe..69391388ac 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -22,8 +22,8 @@ graph TD agent --> session compact --> llm compact --> session - file-context --> fs fs-local --> fs + fs-policy --> fs llm-replay --> llm llm-replay --> session session-persistence --> session @@ -120,8 +120,8 @@ graph TD | `system-prompt` | `llm` | | `agent` | `brand`, `llm`, `session` | | `compact` | `llm`, `session` | -| `file-context` | `fs` | | `fs-local` | `fs` | +| `fs-policy` | `fs` | | `llm-replay` | `llm`, `session` | | `session-persistence` | `session` | | `compact-basic` | `agent`, `compact`, `llm`, `session` | diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 6d12cc9c3d..a79d15888c 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -98,7 +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 policy `ctx.fileContext`](implemented/simplification/2026-06-26-fsspec-style-fs-seam.md) | 2026-06-26 | +| [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 @@ -123,7 +123,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [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-file-context` an event-gate plugin, not a method interface](implemented/architecture/2026-06-26-file-context-as-event-gate.md) | 2026-06-26 | +| [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 | ### Process diff --git a/docs/rfc/implemented/AGENTS.md b/docs/rfc/implemented/AGENTS.md index e5ddc5eeea..831b8d5325 100644 --- a/docs/rfc/implemented/AGENTS.md +++ b/docs/rfc/implemented/AGENTS.md @@ -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. diff --git a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md index 0a4365bc82..731ee41ad7 100644 --- a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md +++ b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md @@ -20,19 +20,21 @@ We need the filesystem tools to land in the same capability-seam shape as bash b 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, filesystem vocabulary types, and file-state tracking contract. +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`. +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 is part of the filesystem seam, not a separate service. `ctx.fs` records which file states the current execution context has seen and validates write-like operations against that state. The first `tool-fs` consumer passes the current tool execution context, or a structural projection of it, through to `ctx.fs`; `ctx.fs` derives the file-state owner from that context, normally `exec.agent.session`. `tool-fs` does not know the cache shape, the owner key, or the `read` tool name/schema. +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 @@ -43,9 +45,9 @@ The filesystem seam uses the same dependency direction as the bash trio: 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 file-state contract. The interface defines a minimal structural execution context shape rather than importing `dsh-tools`, `dsh-agent`, or `dsh-session`; the implementation derives a file-state owner from that shape when one is available. The owner object is opaque to `dsh-fs`: `tool-fs` may pass the `ToolExecution` it already receives, or a projected object containing only the owner-bearing fields, without making `dsh-fs` depend on the tool or agent packages. +`@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, contains all direct `node:fs` / `node:path` access, and provides the in-memory file-state store for the local backend. +`@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`. @@ -62,15 +64,13 @@ The exact TypeScript signatures are implementation details for the PR, but the i - Create or replace a UTF-8 text file. - Edit an existing UTF-8 text file by literal replacement. -The interface must also cover file state: +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`: -- Derive a file-state owner from the current execution context, normally the active agent session. -- Record that the owner saw a target at a backend-defined version. -- Determine whether that owner has a full editable view of a target. -- Use the recorded version as the stale guard for write/edit operations that require prior observation. -- Refresh the recorded state after a successful write/edit so follow-up modifications can proceed without forcing another read. +- 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`). -The in-memory shape is conceptually a weakly-owned cache: file state is keyed first by the derived owner object, then by the backend `targetKey`. The owner is usually `exec.agent.session`, but `dsh-fs` treats it as opaque and does not import `dsh-session`. Each cached `FileState` records the `targetKey`, `displayPath`, backend `version`, current view (`full` or `partial`), update time, and source (`read`, `write`, `edit`, or a future seed path). Only a `full` view authorizes write/edit. A `partial` view records useful context (paged read, truncated read, injected context) but does not grant edit authority. +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>` 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. @@ -82,17 +82,17 @@ Resolved targets must expose at least three concepts: 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. -Text reads return structured UTF-8 line records or ranges with pagination metadata. `tool-fs` owns line-numbered model text rendering; the backend owns bounded line length, bounded output bytes, binary-file rejection, total-line accounting, and whether the returned content is a partial view of the file. +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. -When a read has a file-state owner, `ctx.fs` records the target, version, display path, view metadata, timestamp, and source. Partial views are useful context but do not authorize write/edit unless a future operation can prove the model saw the raw editable content. +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. For updates to existing files, `ctx.fs` should require a full prior file state for the current owner and reject absent or partial state. The backend then compares the current file version to the recorded version and rejects stale writes. If the recorded target no longer exists, the write is stale rather than a create. A create is expressed as a write to a target with no existing file and does not require prior state or a file-state owner. +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 part of `ctx.fs`, not composed in `tool-fs` from a read plus write. Literal matching, duplicate-match rejection, CRLF preservation, binary rejection, prior-file-state checking, stale-version checking, and atomic read-modify-write are filesystem/backend semantics. A remote backend may implement edit as a native compare-and-edit operation; the consumer should not force local-style composition. +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. -Direct tool executions without a derivable file-state owner can still exercise lower-level helpers in tests. Production `write`/`edit` tool calls should reject without an owner when they update an existing target, because those operations require prior state. Owner-less `write` may still create a new file when the backend confirms that the target does not already exist. +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` in the first implementation, 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. Initial codes should include `FS_NOT_FOUND`, `FS_NOT_TEXT`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_PARTIAL_OBSERVATION`, `FS_NOT_REGULAR_FILE`, `FS_AMBIGUOUS_EDIT`, and `FS_EDIT_NOT_FOUND`. +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 @@ -115,7 +115,7 @@ The package registers prompt guidance through `ctx.systemPrompt.section(...)` an 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 first implementation requires a prior full `read` before updating an existing file with `write` or `edit`. `tool-fs` does not implement this by checking whether a tool named `read` ran or by reading the file-state cache. It passes the current execution context to `ctx.fs`, and `ctx.fs` derives the file-state owner and enforces file-state/stale-version policy. Creating a new file with `write` does not require prior state or an owner. +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`. @@ -128,7 +128,7 @@ This RFC starts from `origin/master`, where no filesystem tool package exists ye 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 first pass does not add a separate `@deepseek-ai/dsh-file-context` package. The file-state store lives behind `ctx.fs` so the `tool-fs` plugin gets the read-before-write/edit policy automatically. +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. @@ -146,7 +146,7 @@ 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, pagination, output caps, binary-file rejection, abort handling, full-file create/update writes, owner-less creates, owner-less update rejection, parent-directory creation, non-regular target rejection, literal edit success/failure, unique-match enforcement, replace-all behavior, line-ending preservation, file-state recording after reads, session/owner isolation, read-before-update rejection, stale-version rejection, partial-view rejection, structured `FsError` codes, and file-state refresh after successful writes/edits. +`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: @@ -156,9 +156,9 @@ Beyond the happy/sad paths above, `dsh-fs-local` tests must cover the defensive- - **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 with a fake `ctx.fs` implementation. 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 pass the current execution context or structural projection through to `ctx.fs`, root-plugin suite registration, and HMR cleanup. +`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 execute `read`, `write`, and `edit` through `ctx.tools.execute()` to prove the three packages work together without bypassing the tool registry. 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. +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. @@ -172,7 +172,7 @@ Repo gates for the implementation include the focused vitest suites, `pnpm run t **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. -**File state inside `ctx.fs` can blur concerns.** Recording what an execution context has seen is workflow state, not raw filesystem I/O. This RFC still keeps it inside the filesystem seam because write/edit safety depends on backend-defined target identity and version tokens, and because putting it in `tool-fs` would couple write/edit to the read tool implementation. The boundary is narrow: `ctx.fs` derives the file-state owner, records file state, and checks stale versions, while `tool-fs` owns only model-facing schemas and formatting. +**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. diff --git a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md index a51cde555d..3d5e67ce20 100644 --- a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md +++ b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md @@ -1,4 +1,4 @@ -# RFC: Make `dsh-file-context` an event-gate plugin, not a method interface +# RFC: Make `dsh-fs-policy` an event-gate plugin, not a method interface Status: implemented @@ -9,48 +9,48 @@ Status: implemented 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-file-context` plugin's job. +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-file-context` becomes a gate + recorder plugin** that participates through events, never through a method the tool calls and never by registering a `ctx.fileContext` service. +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-file-context plugin: listens to fs/write-expectation + - fs/edit-expectation (single-slot waterfall) and fs/observed +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-file-context` 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-file-context` 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-file-context`, so the user-facing behavior and prompt discipline are read-before-write/edit (no default/example config wires the fs tools yet — the demo agents do file ops through bash). 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. +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 (no default/example config wires the fs tools yet — the demo agents do file ops through bash). 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-file-context` stat +## The policy is enforced by provider CAS, not by `dsh-fs-policy` stat -`dsh-file-context` 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: +`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-file-context` 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-file-context` passes `vObserved` as the expectation; the provider raises `FS_STALE_VERSION` if the file has moved on. +- "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-file-context` 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-file-context` does **no** filesystem I/O; the "must be based on the latest read" guarantee is *realized* by CAS, and `dsh-file-context` only chooses the basis (`vObserved`) and gates on prior observation. +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 FsWriteExpectation union is UNCHANGED. -writeText(target: FsTarget, content: string, expected?: FsWriteExpectation, signal?: AbortSignal): Promise +// writeText: expected is now optional. The FsWriteIntent union is UNCHANGED. +writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise // undefined → unconditionally create-or-overwrite (bare default) -// createIfAbsent → create only, reject an existing file (dsh-file-context, unobserved) [unchanged] +// 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 }). @@ -60,22 +60,22 @@ editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion // { version } → edit only at that version, else FS_STALE_VERSION (the current behavior) ``` -The `FsWriteExpectation` 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-file-context` 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". +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-file-context`. 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-file-context` no longer provides a method service. `dsh-fs` is the package both `dsh-tool-fs` and `dsh-file-context` 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. +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`, `FsWriteExpectation`) plus an opaque actor — not model-facing concepts (no line windows, numbered lines, or rendered footers leak down). +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-file-context` 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-expectation`, `fs/edit-expectation`) 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 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-file-context` 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-expectation` decider BEFORE `dsh-file-context` (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-file-context` as the policy decider. +**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-file-context`, 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. +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, FsWriteExpectation } from '@deepseek-ai/dsh-fs' +import type { FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs' interface Events { /** @@ -85,7 +85,7 @@ interface Events { * (unobserved) or { kind: 'replaceIfVersion', version: vObserved } (observed). * The listener does NOT call next(): one decision, not a composable chain. @mode waterfall */ - 'fs/write-expectation'(target: FsTarget, actor: object | undefined, next: () => FsWriteExpectation | undefined | Promise): Promise + 'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise): Promise /** * Single-slot decision: produce the optional version guard for the next * ctx.fs.editText. The default returns undefined (unconditional edit of the @@ -93,11 +93,11 @@ interface Events { * { 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-expectation'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> + 'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> /** * Record that an actor observed a target at a version, after a successful * read/write/edit. Fire-and-forget (plain emit). Listeners MUST be - * synchronous, side-effect-only recorders (`dsh-file-context`'s is a WeakMap + * 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 @@ -110,7 +110,7 @@ The `fs/*` decision events are **unbound waterfalls dispatched by the tool** (li ## 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-file-context`: 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 file-context policy requires it. The bare-provider fallback does not change the prompt stance. +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. @@ -119,34 +119,34 @@ The tool keeps its model-facing schemas (`read`/`write`/`edit`, byte-for-byte un `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-expectation', 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-file-context`. -- **edit** — `expectation = await ctx.waterfall('fs/edit-expectation', 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. +- **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-file-context` 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-file-context` short-circuits the thunk before it runs in the default deployment. +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-file-context`'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. +**`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-file-context`) +## Policy plugin contract (`dsh-fs-policy`) -`dsh-file-context` 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>` 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`. +`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>` 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-expectation` 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-expectation` 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/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-file-context` 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`. +`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-file-context`) +## 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-file-context`. It is the unconstrained provider floor that exists once the tool is no longer coupled to a policy method service. With `dsh-file-context` absent, every `fs/*` waterfall falls through to its `undefined` default and `fs/observed` has no listener: +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-file-context` would add: observed-state, read-before-edit, and version-guarded write/edit. Loading `dsh-file-context` layers those constraints on by having its listeners return guarded `expected` values instead of `undefined`; nothing in the bare provider changes. +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 @@ -154,15 +154,15 @@ This amends — does not reverse — [the split-fs-seam RFC](../simplification/2 ## Acceptance Criteria -- The `dsh-tool-fs` root plugin injects `fs` (+ `tools`/`systemPrompt`), not `fileContext`; it calls `ctx.fs` directly and dispatches the `fs/write-expectation`/`fs/edit-expectation` 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.) +- 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-file-context` 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-file-context` 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-file-context` 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-expectation` listener AFTER `dsh-file-context` 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. +- `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 `FsWriteExpectation` union is unchanged, and `dsh-file-context`'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-file-context` performs no `stat`. -- `stat` budget: read = 1, write = 0, edit = 0 — in the tool, with or without `dsh-file-context` (the bare default returns `undefined`, never stats). A test asserts neither write nor edit stats in the tool on either path. +- `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). @@ -170,6 +170,6 @@ This amends — does not reverse — [the split-fs-seam RFC](../simplification/2 - **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-expectation`/`fs/edit-expectation` slots hold exactly one decider; the first-registered (or `prepend`ed) listener wins and the rest are short-circuited. `dsh-file-context` 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`. +- **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-file-context` 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-file-context` 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. +- **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. diff --git a/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md index 6e510c69b1..217c60cf63 100644 --- a/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md +++ b/docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md @@ -4,7 +4,7 @@ Status: implemented ## Problem -[The filesystem capability-seam RFC](../architecture/2026-06-17-filesystem-capability-seam.md) defines the filesystem capability seam (`ctx.fs`), the three-package split (`dsh-fs`, `dsh-fs-local`, `dsh-tool-fs`), and the observed-file/stale-version policy for read-before-write/edit checks. 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 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. @@ -15,10 +15,10 @@ The schema should be small enough to implement in the first `dsh-tool-fs` pass, | 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. Updates to existing files require prior observation through `ctx.fs`; 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; requires prior full observation through `ctx.fs`. | 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 internal `ctx.fs` requests. +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 @@ -47,9 +47,9 @@ Arguments: - `file_path: string` — required. Path to write, resolved by `ctx.fs`. - `content: string` — required. Full UTF-8 text content to write. -For existing files, `write` requires prior full file state derived from a previous read in the same execution context. `ctx.fs` derives the file-state owner and uses the recorded version as the stale guard. Creating a new file does not require prior state or an owner. +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 `ctx.fs` file state and backend-produced versions, not by asking the model to copy version tokens through the schema. +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` @@ -62,7 +62,7 @@ Arguments: - `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-file-context` policy plugin derives the owner and supplies the recorded version as the stale guard; the provider's mutation lock enforces it. +`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. @@ -99,15 +99,15 @@ The following are deliberately out of scope for the first filesystem schema pass - `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 existing-file `write` and `edit` require a prior full read in the same execution context, while new-file `write` does not. +- 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()` with a fake or local `ctx.fs` provider and verify that model arguments are translated into the expected `ctx.fs` calls. +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 `ctx.fs` observed-file 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. +**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. diff --git a/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md index 26bbb65056..736b65df08 100644 --- a/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md +++ b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md @@ -1,4 +1,4 @@ -# RFC: Split the filesystem seam — provider text mutations plus policy `ctx.fileContext` +# RFC: Split the filesystem seam — provider text mutations plus the `dsh-fs-policy` plugin Status: implemented @@ -13,7 +13,7 @@ That makes every future backend reimplement model-facing read semantics and obse 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-file-context` 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. +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 @@ -21,14 +21,14 @@ 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-file-context observed-state + read-before-edit + write/edit freshness, contributed through the fs/* event gate (no service) +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 injects `fs` (not a policy service) and reaches `ctx.fs` directly, dispatching the `fs/*` policy events so `dsh-file-context` can gate and record. +`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. -The tool↔policy COUPLING below was reworked by [the file-context event-gate RFC](../architecture/2026-06-26-file-context-as-event-gate.md): `dsh-file-context` is now a gate PLUGIN that participates through the `fs/*` events (no `ctx.fileContext` service), and read windowing + the fs I/O moved up into `dsh-tool-fs`. The four-layer split, the provider contract, and the freshness *policy* this RFC decided are unchanged. Read the "`ctx.fileContext.read`/`write`/`edit`" method descriptions below as the policy DECISIONS the gate plugin now makes on the `fs/*` events, and the provider's version guard as optional (omit = unconditional bare provider). +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 @@ -39,7 +39,7 @@ abstract resolve(path: string): Promise abstract stat(target: FsTarget, signal?: AbortSignal): Promise abstract readText(target: FsTarget, signal?: AbortSignal): Promise abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> -abstract writeText(target: FsTarget, content: string, expected: FsWriteExpectation, signal?: AbortSignal): Promise +abstract writeText(target: FsTarget, content: string, expected: FsWriteIntent, signal?: AbortSignal): Promise abstract editText(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, signal?: AbortSignal): Promise interface FsInfo { @@ -48,18 +48,18 @@ interface FsInfo { size?: number } -type FsWriteExpectation = +type FsWriteIntent = | { kind: 'createIfAbsent' } | { kind: 'replaceIfVersion'; version: FsVersion } ``` -`stat` returns metadata, not content. `version` is the freshness token; `type` lets the policy reject directories/special files before reading; `size` lets `ctx.fileContext.read` choose `readText` vs `streamText` without probing by failure. `undefined` means absent. +`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. 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 also preserves backend-local locking and lets a future remote backend implement native compare-and-edit without forcing `ctx.fileContext` to pull the whole file through the policy layer. +`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. @@ -67,23 +67,25 @@ Deleted from `dsh-fs`: `readPage`, `FsExpectation`, `FsView`, `FsStateSource`, ` ## Policy Contract -`@deepseek-ai/dsh-file-context` registers concrete service `ctx.fileContext` and injects `fs`. It is a concrete service, not a seam: it owns the read-windowing and write/edit freshness policy that does not belong on the `FileSystem` provider base class (where a sandboxed/remote backend would otherwise inherit model-facing observation policy it has no business carrying). +`@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>`. An entry exists iff the owner has read that target through `ctx.fileContext.read`, so its presence *is* the read record — there is no separate `hasRead` flag. The owner is still derived structurally from `{ agent?: { session? } }`, but that shape no longer belongs to `dsh-fs`. +Observed state lives here as `WeakMap>`. 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`. -`read(target, request, exec?, signal?)` is the only read path used by the model-facing `read` tool. It stats the target, rejects absent/non-regular targets, chooses `readText` or `streamText` from `FsInfo`, builds the requested line window from text chunks, records `{ version: info.version }`, and returns the structured outcome that the tool renders. +The plugin decides three `fs/*` events: -`write(target, content, exec?, signal?)` uses freshness policy: no recorded read calls `writeText({ kind: 'createIfAbsent' })`, so only new files can be created blindly; a recorded read calls `writeText({ kind: 'replaceIfVersion', version: vObserved })`, so existing files are replaced only if they are unchanged since the read. A successful write refreshes recorded state from the returned outcome or a post-write `stat`. +- `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`. -`edit(target, edit, exec?, signal?)` requires a recorded read at `vObserved`, then calls `ctx.fs.editText(target, edit, { version: vObserved })` and refreshes recorded state from the returned version. `ctx.fileContext` does not implement literal replacement itself; it authorizes the operation and passes the observed version to the provider. The provider owns the mutation critical section, so concurrent edits based on the same observed version remain one-wins/one-stale rather than being merged or re-applied. If a backend needs a defensive whole-file edit cap, it should surface that as the same filesystem error taxonomy, but large model-facing reads should stream instead of failing just because the file is large. +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. +`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, `/` envelope), and dispatches the `fs/*` events. -The tool package only validates model args, calls `ctx.fileContext`, and renders results (`N: text`, footer, `/` envelope). The no-bypass rule is part of the contract: a model-facing `read` must call `ctx.fileContext.read`, never `ctx.fs.readText` or `ctx.fs.streamText`, so every successful read records observed-state before rendering. +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. -Direct `ctx.fs` calls are still allowed for non-tool consumers. They are explicit escape hatches: a direct `ctx.fs.readText` records no observed-state, so a later `ctx.fileContext.edit` rejects with `FS_NOT_OBSERVED` until the file is read through `ctx.fileContext`. +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 @@ -97,7 +99,7 @@ Cross-process writes are best-effort freshness plus atomic replacement: `mtime:s 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 `ctx.fileContext`. +- 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. @@ -105,8 +107,8 @@ It keeps the interface/implementation/consumer discipline, consumer-never-import ## Acceptance Criteria -- `dsh-fs` exposes exactly `resolve`/`stat`/`readText`/`streamText`/`writeText`/`editText`; `stat` returns `FsInfo | undefined`; `writeText` uses `FsWriteExpectation` (`createIfAbsent` or `replaceIfVersion`); removed types/primitives are gone, and the old `applyEdit` API is replaced by `editText`. -- `dsh-file-context` 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-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. @@ -116,7 +118,7 @@ It keeps the interface/implementation/consumer discipline, consumer-never-import ## 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 can surprise callers who later use `ctx.fileContext`. The failure is explicit (`FS_NOT_OBSERVED`) and documented. -- Large-file line windowing moves from the backend to `ctx.fileContext.read`; text decoding and binary rejection stay in `ctx.fs.streamText`, so this is relocation of windowing only, not a second text-IO implementation. +- 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. diff --git a/packages/README.md b/packages/README.md index 1ecdb8c9eb..04776937eb 100644 --- a/packages/README.md +++ b/packages/README.md @@ -38,7 +38,7 @@ dsh-bash-local ← dsh-bash (BashExecutor impl) dsh-tool-bash ← dsh-bash, dsh-tools (bash tool schemas) dsh-fs ← dsh-llm, dsh-brand (filesystem provider seam + fs/* events) dsh-fs-local ← dsh-fs (FileSystem impl) -dsh-file-context ← dsh-fs (observed-state + freshness policy gate, no service) +dsh-fs-policy ← dsh-fs (observed-state + freshness policy gate, no service) dsh-tool-fs ← dsh-fs, dsh-tools (file tools + executor) dsh-llm-deepseek ← dsh-llm (DeepSeek adapter) dsh-llm-pi-ai ← dsh-llm (pi-ai-backed adapter) @@ -78,7 +78,7 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop | `tool-bash/` | `bash` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) | | `fs/` | `fs` | Filesystem provider seam: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` events | `ctx.fs` | | `fs-local/` | `fs` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) | -| `file-context/` | `fs` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit via the `fs/*` event gate | (no service — `fs/*` listeners) | +| `fs-policy/` | `fs` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit via the `fs/*` event gate | (no service — `fs/*` listeners) | | `tool-fs/` | `fs` | Model-facing `read`/`write`/`edit` tools + executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`) | (registers on `ctx.tools`) | | `compact/` | `compact` | Abstract compaction seam + `compact/*` events + `CompactionResult` | `ctx.compact` | | `compact-basic/` | `compact` | A backend: char/4 estimation + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) | diff --git a/packages/fs/README.md b/packages/fs/README.md index 0fbce830e3..985a9f3ad6 100644 --- a/packages/fs/README.md +++ b/packages/fs/README.md @@ -6,7 +6,7 @@ The filesystem stack: a provider seam (text IO + atomic mutation with an optiona |---|---|---| | `fs/` | Provider seam: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` policy events | `ctx.fs` | | `fs-local/` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) | -| `file-context/` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit, via the `fs/*` event gate | (no service — `fs/*` listeners) | +| `fs-policy/` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit, via the `fs/*` event gate | (no service — `fs/*` listeners) | | `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`) | (registers on `ctx.tools`) | -The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas. The policy (`file-context/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it. +The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas. The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it. diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index 60ad805938..ca5a7bb09e 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -6,7 +6,7 @@ The **local-filesystem implementation** of the `ctx.fs` provider seam ([`@deepse import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) -// ctx.fs is now the local backend; load @deepseek-ai/dsh-file-context for the +// ctx.fs is now the local backend; load @deepseek-ai/dsh-fs-policy for the // freshness policy gate and @deepseek-ai/dsh-tool-fs to expose read/write/edit. ``` diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index 61cc3fee1a..b24b7e6b89 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -5,7 +5,7 @@ * * This is the PROVIDER layer: it hands back decoded whole-file text (validated * UTF-8, binary rejected) — never line windows or numbered lines, which are - * model-facing read policy owned by `@deepseek-ai/dsh-file-context`. Large files + * model-facing read policy owned by `@deepseek-ai/dsh-fs-policy`. Large files * stream their text in chunks so a huge file never has to be held whole in * memory; the binary/NUL sample and cross-chunk UTF-8 decoding stay here. * @@ -35,6 +35,16 @@ function isENOENT(error: unknown): boolean { return error instanceof Error && 'code' in error && error.code === 'ENOENT' } +/** + * A path component that is expected to be a directory is a regular file (e.g. + * resolving `afile/child.txt` when `afile` is a file). Like `ENOENT`, the target + * cannot exist — so the resolution/probe paths treat it as "absent" rather than + * letting a raw Node error escape without the structured `FsError` taxonomy. + */ +function isENOTDIR(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOTDIR' +} + function isAbortError(error: unknown): boolean { return error instanceof Error && error.name === 'AbortError' } @@ -119,6 +129,10 @@ export async function resolveLocalTarget(cwd: string, path: string): Promise { const type = info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other' return { version: versionOf(info), mode: info.mode & 0o777, type, size: info.size } } catch (error: unknown) { - /* v8 ignore next 2 -- a non-ENOENT stat failure needs a permission/IO fault; surface it. */ - if (!isENOENT(error)) throw error + // ENOENT (no such file) and ENOTDIR (a parent segment is a file) both mean + // the target is absent; any other stat failure is a real permission/IO fault. + /* v8 ignore next -- a non-ENOENT/ENOTDIR stat failure needs a permission/IO fault; surface it. */ + if (!isENOENT(error) && !isENOTDIR(error)) throw error return null } } diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index 9f769bc7cf..7a65148a99 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -21,7 +21,7 @@ import type { FsEditRequest, FsInfo, FsTarget, - FsWriteExpectation, + FsWriteIntent, FsWriteOutcome, } from '@deepseek-ai/dsh-fs' import { @@ -120,7 +120,7 @@ export class LocalFileSystem extends FileSystem { override async writeText( target: FsTarget, content: string, - expected?: FsWriteExpectation, + expected?: FsWriteIntent, signal?: AbortSignal, ): Promise { return this.withLock(target.targetKey, async () => { diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts index 4119188f1b..d149e741b0 100644 --- a/packages/fs/fs-local/tests/filesystem.spec.ts +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -3,7 +3,7 @@ * file/streamed text reads, atomic guarded writes (createIfAbsent / * replaceIfVersion), version-guarded literal edits, concurrency races, symlink * identity, and HMR/disposal. Read WINDOWING is policy and lives in - * `dsh-file-context`, so it is not exercised here. + * `dsh-fs-policy`, so it is not exercised here. */ import { afterEach, beforeEach, describe, expect, it } from 'vitest' diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index 33f26f797a..6f28d54402 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -2,7 +2,7 @@ * Cordis-free tests for the raw local-filesystem I/O: path resolution, probe, * whole-file/streamed text reads, binary/UTF-8 rejection, atomic-write temp * safety, literal edit matching, and line-ending handling. Line WINDOWING is - * policy and lives in `dsh-file-context`, so it is not tested here. + * policy and lives in `dsh-fs-policy`, so it is not tested here. */ import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -21,7 +21,7 @@ import { writeFileAtomic, } from '@deepseek-ai/dsh-fs-local' import type { LocalTarget } from '@deepseek-ai/dsh-fs-local' -import { FsTargetKey } from '@deepseek-ai/dsh-fs' +import { FsError, FsTargetKey } from '@deepseek-ai/dsh-fs' let dir: string beforeEach(async () => { @@ -88,6 +88,16 @@ describe('resolveLocalTarget', () => { it('rejects a blank path', async () => { await expect(resolveLocalTarget(dir, ' ')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) }) + + it('rejects a path whose ancestor is a file with a structured FsError (ENOTDIR)', async () => { + // "afile" is a regular file, so "afile/child.txt" hits ENOTDIR on realpath; + // the raw Node error must be translated into the FsError taxonomy so the tool + // result keeps its { name, code } metadata. + await writeFile(join(dir, 'afile'), 'i am a file') + const err = await resolveLocalTarget(dir, 'afile/child.txt').then(() => undefined, (e: unknown) => e) + expect(err).toBeInstanceOf(FsError) + expect(err).toMatchObject({ code: 'FS_NOT_FOUND' }) + }) }) describe('probe', () => { @@ -128,6 +138,11 @@ describe('probe', () => { await new Promise((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', () => { diff --git a/packages/fs/file-context/README.md b/packages/fs/fs-policy/README.md similarity index 60% rename from packages/fs/file-context/README.md rename to packages/fs/fs-policy/README.md index b543722055..ad912bfc95 100644 --- a/packages/fs/file-context/README.md +++ b/packages/fs/fs-policy/README.md @@ -1,10 +1,10 @@ -# @deepseek-ai/dsh-file-context +# @deepseek-ai/dsh-fs-policy -The **file-context policy plugin**: it adds observed-state, read-before-edit, and version-guarded write/edit on top of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)) — through the `fs/*` event gate, **NOT** through a method service. This plugin registers **no** `ctx.fileContext` service and has no public `read`/`write`/`edit`/`resolve` methods. It is the policy third of the filesystem stack: not a swappable seam, but the policy that does not belong on the `FileSystem` provider base class. +The **fs-policy plugin**: it adds observed-state, read-before-edit, and version-guarded write/edit on top of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)) — through the `fs/*` event gate, **NOT** through a method service. This plugin registers **no** `ctx.fsPolicy` service and has no public `read`/`write`/`edit`/`resolve` methods. It is the policy third of the filesystem stack: not a swappable seam, but the policy that does not belong on the `FileSystem` provider base class. ```ts import type { Context } from 'cordis' -import * as FileContext from '@deepseek-ai/dsh-file-context' +import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' declare const ctx: Context @@ -12,8 +12,8 @@ declare const ctx: Context // Load it alongside a ctx.fs provider (e.g. @deepseek-ai/dsh-fs-local) and the // @deepseek-ai/dsh-tool-fs tools; the tools dispatch the fs/* events this plugin // decides. Order does not matter for resolution (no inject), but the policy -// listener should be the first decider registered for the fs/*-expectation slots. -await ctx.plugin(FileContext) +// listener should be the first decider registered for the fs/*-intent slots. +await ctx.plugin(FsPolicy) ``` ## The four-layer split @@ -21,7 +21,7 @@ await ctx.plugin(FileContext) | Layer | Package | Role | |---|---|---| | tool / executor | `@deepseek-ai/dsh-tool-fs` | model-facing schemas + read windowing + text rendering; reads/writes/edits via `ctx.fs`, dispatches the `fs/*` events | -| policy | `@deepseek-ai/dsh-file-context` (this) | observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate (no service) | +| policy | `@deepseek-ai/dsh-fs-policy` (this) | observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate (no service) | | provider seam | `@deepseek-ai/dsh-fs` | `ctx.fs`: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` event vocabulary | | provider | `@deepseek-ai/dsh-fs-local` | local implementation of `ctx.fs` | @@ -31,8 +31,8 @@ Three `fs/*` events (declared by `@deepseek-ai/dsh-fs`, dispatched by `@deepseek | Event | This plugin's listener | |---|---| -| `fs/write-expectation` | No prior observation → `{ kind: 'createIfAbsent' }`; a prior observation → `{ kind: 'replaceIfVersion', version: vObserved }`. Single-slot decision; does NOT call `next()`. | -| `fs/edit-expectation` | Requires a prior observation by this owner (else throws `FS_NOT_OBSERVED`); returns `{ version: vObserved }` as the CAS basis. Single-slot decision; does NOT call `next()`. | +| `fs/write-intent` | No prior observation → `{ kind: 'createIfAbsent' }`; a prior observation → `{ kind: 'replaceIfVersion', version: vObserved }`. Single-slot decision; does NOT call `next()`. | +| `fs/edit-intent` | Requires a prior observation by this owner (else throws `FS_NOT_OBSERVED`); returns `{ version: vObserved }` as the CAS basis. Single-slot decision; does NOT call `next()`. | | `fs/observed` | Records `{ version }` for this owner+target. Synchronous, side-effect-only `WeakMap.set`. | ## Observed state is the prior-observation record; freshness is provider CAS @@ -41,7 +41,7 @@ Observed state is a `WeakMap>`. An entry exists ## Single-slot, first-wins -The `fs/write-expectation`/`fs/edit-expectation` slots hold exactly one decider — this plugin fully decides and does not call `next()`. The slot is first-wins by registration order; this plugin owning it is the default-deployment convention, not an event-enforced invariant (a decider registered before / `prepend`ed would win instead). This is not a composable authorization chain — layered permission/audit/sandbox interception belongs on `tools/execute`. +The `fs/write-intent`/`fs/edit-intent` slots hold exactly one decider — this plugin fully decides and does not call `next()`. The slot is first-wins by registration order; this plugin owning it is the default-deployment convention, not an event-enforced invariant (a decider registered before / `prepend`ed would win instead). This is not a composable authorization chain — layered permission/audit/sandbox interception belongs on `tools/execute`. ## No method coupling diff --git a/packages/fs/file-context/package.json b/packages/fs/fs-policy/package.json similarity index 95% rename from packages/fs/file-context/package.json rename to packages/fs/fs-policy/package.json index 16ee567305..c3f2a07982 100644 --- a/packages/fs/file-context/package.json +++ b/packages/fs/fs-policy/package.json @@ -1,5 +1,5 @@ { - "name": "@deepseek-ai/dsh-file-context", + "name": "@deepseek-ai/dsh-fs-policy", "description": "File-context policy plugin for the DeepSeek Harness — observed-state, read-before-edit, and version-guarded write/edit added over the ctx.fs provider seam through the fs/* event gate (no service surface)", "version": "0.0.1", "private": true, diff --git a/packages/fs/file-context/src/index.ts b/packages/fs/fs-policy/src/index.ts similarity index 79% rename from packages/fs/file-context/src/index.ts rename to packages/fs/fs-policy/src/index.ts index 5e0488ea0e..4d5c7964b7 100644 --- a/packages/fs/file-context/src/index.ts +++ b/packages/fs/fs-policy/src/index.ts @@ -1,10 +1,10 @@ /** - * The file-context policy PLUGIN: observed-state, read-before-edit, and + * The fs-policy PLUGIN: observed-state, read-before-edit, and * "write/edit must be based on the version you read" — added on top of the * `ctx.fs` provider seam through the `fs/*` event gate, NOT through a method - * service. This plugin registers NO `ctx.fileContext` service and exposes no + * service. This plugin registers NO `ctx.fsPolicy` service and exposes no * `read`/`write`/`edit`/`resolve` methods; it influences the world only by - * deciding the `fs/write-expectation`/`fs/edit-expectation` waterfalls and + * deciding the `fs/write-intent`/`fs/edit-intent` waterfalls and * recording on `fs/observed`. That is what keeps `@deepseek-ai/dsh-tool-fs` * (the executor) free of any method coupling to the policy layer — removing * this plugin gracefully loses the policy and leaves the unconstrained bare @@ -33,22 +33,22 @@ * * ## Single-slot, first-wins * - * The `fs/write-expectation`/`fs/edit-expectation` listeners do NOT call + * The `fs/write-intent`/`fs/edit-intent` listeners do NOT call * `next()`: each fully decides its single slot. The slot is first-wins by * registration order — this plugin owning it is the default-deployment * convention, not an event-enforced invariant (a decider registered before / * `prepend`ed would win instead). This is not a composable authorization chain; * layered permission/audit/sandbox interception belongs on `tools/execute`. * - * @module @deepseek-ai/dsh-file-context + * @module @deepseek-ai/dsh-fs-policy */ import type { Context } from 'cordis' import { FsError } from '@deepseek-ai/dsh-fs' -import type { FsTarget, FsVersion, FsWriteExpectation } from '@deepseek-ai/dsh-fs' -import type { FileContextExec } from './types.ts' +import type { FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs' +import type { FsPolicyExec } from './types.ts' -export type { FileContextExec } from './types.ts' +export type { FsPolicyExec } from './types.ts' /** * Per-context observed-file state and the three `fs/*` decisions over it. One @@ -69,7 +69,7 @@ class ObservedStateGate { * the write/edit prior-observation policy. */ private owner(actor: object | undefined): object | undefined { - return (actor as FileContextExec | undefined)?.agent?.session + return (actor as FsPolicyExec | undefined)?.agent?.session } private get(owner: object, targetKey: string): FsVersion | undefined { @@ -91,11 +91,11 @@ class ObservedStateGate { } /** - * Decide the write expectation: no prior observation ⇒ `createIfAbsent` (only + * Decide the write intent: no prior observation ⇒ `createIfAbsent` (only * new files can be created blindly); a prior observation ⇒ `replaceIfVersion` * at the observed version (existing files replaced only if unchanged). */ - writeExpectation(target: FsTarget, actor: object | undefined): FsWriteExpectation { + writeIntent(target: FsTarget, actor: object | undefined): FsWriteIntent { const owner = this.owner(actor) const prior = owner ? this.get(owner, target.targetKey) : undefined return prior ? { kind: 'replaceIfVersion', version: prior } : { kind: 'createIfAbsent' } @@ -105,7 +105,7 @@ class ObservedStateGate { * Decide the edit version guard: requires a prior observation by this owner * (else `FS_NOT_OBSERVED`); returns the observed version as the CAS basis. */ - editExpectation(target: FsTarget, actor: object | undefined): { version: FsVersion } { + editIntent(target: FsTarget, actor: object | undefined): { version: FsVersion } { const owner = this.owner(actor) const prior = owner ? this.get(owner, target.targetKey) : undefined if (!owner || !prior) { @@ -122,7 +122,7 @@ class ObservedStateGate { } /** Cordis plugin name used by loader diagnostics. */ -export const name = 'file-context' +export const name = 'fs-policy' /** * Register the three `fs/*` listeners. No `inject` — this plugin reads no @@ -138,21 +138,22 @@ export function apply(ctx: Context): void { // (HMR safety). The WeakMap itself would be GC'd, but replacing it makes the // release observable and immediate for tests. gate.clear() - }, 'file-context observed-state teardown') + }, 'fs-policy observed-state teardown') - // fs/write-expectation: occupy the single decision slot — do NOT call next(). + // fs/write-intent: occupy the single decision slot — do NOT call next(). // Deferred through Promise.resolve().then so the declared Promise return type // holds (a throw rejects, never escapes synchronously through the waterfall). - ctx.on('fs/write-expectation', (target, actor) => Promise.resolve().then(() => gate.writeExpectation(target, actor))) + ctx.on('fs/write-intent', (target, actor) => Promise.resolve().then(() => gate.writeIntent(target, actor))) - // fs/edit-expectation: occupy the single decision slot — do NOT call next(). + // fs/edit-intent: occupy the single decision slot — do NOT call next(). // Deferred the same way so an FS_NOT_OBSERVED throw becomes a rejected promise // the edit tool's `await ctx.waterfall(...)` surfaces as its isError result. - ctx.on('fs/edit-expectation', (target, actor) => Promise.resolve().then(() => gate.editExpectation(target, actor))) + ctx.on('fs/edit-intent', (target, actor) => Promise.resolve().then(() => gate.editIntent(target, actor))) - // fs/observed: synchronous, side-effect-only WeakMap write (cannot throw under - // normal operation); the tool contains any throw so a record bug never fails - // the already-completed mutation. + // fs/observed: synchronous, side-effect-only WeakMap write. The tool emits + // this with a plain (unguarded) ctx.emit, so this listener MUST NOT throw — + // a throw would surface as the tool's isError result for a mutation that + // already succeeded. A WeakMap.set honors that contract. ctx.on('fs/observed', (target, version, actor) => { gate.observe(target, version, actor) }) diff --git a/packages/fs/file-context/src/types.ts b/packages/fs/fs-policy/src/types.ts similarity index 86% rename from packages/fs/file-context/src/types.ts rename to packages/fs/fs-policy/src/types.ts index b3157cc2e9..9ee742a7f7 100644 --- a/packages/fs/file-context/src/types.ts +++ b/packages/fs/fs-policy/src/types.ts @@ -1,5 +1,5 @@ /** - * Vocabulary for the file-context policy plugin: the minimal execution-context + * Vocabulary for the fs-policy plugin: the minimal execution-context * shape used to derive an observed-state owner by narrowing the opaque `object` * actor the `fs/*` events carry. * @@ -7,7 +7,7 @@ * re-used from `@deepseek-ai/dsh-fs`; this package owns only the observed-state * owner structure on top of it. * - * @module @deepseek-ai/dsh-file-context/types + * @module @deepseek-ai/dsh-fs-policy/types */ /** @@ -20,7 +20,7 @@ * The owner is `agent.session` when present. It is treated as an opaque object * identity (a `WeakMap` key); this package never reads any of its fields. */ -export interface FileContextExec { +export interface FsPolicyExec { /** The agent on whose behalf the call runs, when there is one. */ agent?: { /** The session that owns observed-file state, used as an opaque key. */ diff --git a/packages/fs/file-context/tests/policy.spec.ts b/packages/fs/fs-policy/tests/policy.spec.ts similarity index 56% rename from packages/fs/file-context/tests/policy.spec.ts rename to packages/fs/fs-policy/tests/policy.spec.ts index 63808f1610..02bb934cfd 100644 --- a/packages/fs/file-context/tests/policy.spec.ts +++ b/packages/fs/fs-policy/tests/policy.spec.ts @@ -1,5 +1,5 @@ /** - * Tests for the file-context policy PLUGIN: it registers no service, only the + * Tests for the fs-policy PLUGIN: it registers no service, only the * three `fs/*` listeners. We dispatch those events directly (the unbound * waterfalls the tool would dispatch, and the `fs/observed` emit) and assert the * decisions: createIfAbsent vs replaceIfVersion, FS_NOT_OBSERVED for an unread @@ -7,87 +7,87 @@ * multi-owner isolation, single-slot first-wins, and disposal/HMR release. * * No `ctx.fs` provider is needed — the plugin does no filesystem I/O; it only - * decides expectations and records versions on its own WeakMap. + * decides intents and records versions on its own WeakMap. */ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' -import type { FsTarget, FsWriteExpectation } from '@deepseek-ai/dsh-fs' -import * as FileContext from '@deepseek-ai/dsh-file-context' -import type { FileContextExec } from '@deepseek-ai/dsh-file-context' +import type { FsTarget, FsWriteIntent } from '@deepseek-ai/dsh-fs' +import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' +import type { FsPolicyExec } from '@deepseek-ai/dsh-fs-policy' function target(path: string): FsTarget { return { inputPath: path, targetKey: FsTargetKey(path), displayPath: path } } -const ownerExec = (session: object): FileContextExec => ({ agent: { session } }) +const ownerExec = (session: object): FsPolicyExec => ({ agent: { session } }) -/** Dispatch the write-expectation waterfall with the bare default thunk. */ -function writeExpectation(ctx: Context, t: FsTarget, actor: object | undefined): Promise { - return ctx.waterfall('fs/write-expectation', t, actor, () => undefined) +/** Dispatch the write-intent waterfall with the bare default thunk. */ +function writeIntent(ctx: Context, t: FsTarget, actor: object | undefined): Promise { + return ctx.waterfall('fs/write-intent', t, actor, () => undefined) } -/** Dispatch the edit-expectation waterfall with the bare default thunk. */ -function editExpectation(ctx: Context, t: FsTarget, actor: object | undefined): Promise<{ version: FsVersion } | undefined> { - return ctx.waterfall('fs/edit-expectation', t, actor, () => undefined) +/** Dispatch the edit-intent waterfall with the bare default thunk. */ +function editIntent(ctx: Context, t: FsTarget, actor: object | undefined): Promise<{ version: FsVersion } | undefined> { + return ctx.waterfall('fs/edit-intent', t, actor, () => undefined) } async function setup() { const ctx = new Context() - const fiber = await ctx.plugin(FileContext) + const fiber = await ctx.plugin(FsPolicy) return { ctx, fiber } } describe('registration / disposal', () => { - it('registers no service surface (it is a plugin, not ctx.fileContext)', async () => { + it('registers no service surface (it is a plugin, not ctx.fsPolicy)', async () => { const { ctx } = await setup() - expect((ctx as Context & { fileContext?: unknown }).fileContext).toBeUndefined() + expect((ctx as Context & { fsPolicy?: unknown }).fsPolicy).toBeUndefined() }) it('mounts with no inject (reads no services)', async () => { // It mounts immediately even with nothing else in the context. const ctx = new Context() - await ctx.plugin(FileContext) + await ctx.plugin(FsPolicy) // The listener is live: an unobserved write decides createIfAbsent. - expect(await writeExpectation(ctx, target('a.txt'), undefined)).toEqual({ kind: 'createIfAbsent' }) + expect(await writeIntent(ctx, target('a.txt'), undefined)).toEqual({ kind: 'createIfAbsent' }) }) }) -describe('write-expectation decision', () => { +describe('write-intent decision', () => { it('an unobserved target decides createIfAbsent', async () => { const { ctx } = await setup() - expect(await writeExpectation(ctx, target('a.txt'), ownerExec({}))).toEqual({ kind: 'createIfAbsent' }) + expect(await writeIntent(ctx, target('a.txt'), ownerExec({}))).toEqual({ kind: 'createIfAbsent' }) }) it('a no-owner actor decides createIfAbsent', async () => { const { ctx } = await setup() - expect(await writeExpectation(ctx, target('a.txt'), undefined)).toEqual({ kind: 'createIfAbsent' }) - expect(await writeExpectation(ctx, target('a.txt'), {})).toEqual({ kind: 'createIfAbsent' }) + expect(await writeIntent(ctx, target('a.txt'), undefined)).toEqual({ kind: 'createIfAbsent' }) + expect(await writeIntent(ctx, target('a.txt'), {})).toEqual({ kind: 'createIfAbsent' }) }) it('an observed target decides replaceIfVersion at the observed version', async () => { const { ctx } = await setup() const exec = ownerExec({}) ctx.emit('fs/observed', target('a.txt'), FsVersion('v7'), exec) - expect(await writeExpectation(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v7' }) + expect(await writeIntent(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v7' }) }) }) -describe('edit-expectation decision', () => { +describe('edit-intent decision', () => { it('rejects an unread edit with FS_NOT_OBSERVED', async () => { const { ctx } = await setup() - await expect(editExpectation(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + await expect(editIntent(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) }) it('rejects an edit with no owner (cannot prove prior observation)', async () => { const { ctx } = await setup() - await expect(editExpectation(ctx, target('a.txt'), undefined)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + await expect(editIntent(ctx, target('a.txt'), undefined)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) }) it('returns the observed version as the CAS basis after an observation', async () => { const { ctx } = await setup() const exec = ownerExec({}) ctx.emit('fs/observed', target('a.txt'), FsVersion('v3'), exec) - expect(await editExpectation(ctx, target('a.txt'), exec)).toEqual({ version: 'v3' }) + expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v3' }) }) }) @@ -96,7 +96,7 @@ describe('observed-state is the prior-observation record', () => { const { ctx } = await setup() const exec = ownerExec({}) ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec) // a read - expect(await writeExpectation(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v0' }) + expect(await writeIntent(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v0' }) }) it('a write/edit observation refreshes the basis, so the next edit needs no re-read', async () => { @@ -104,17 +104,17 @@ describe('observed-state is the prior-observation record', () => { const exec = ownerExec({}) // A create records v1; the follow-up edit guards against v1 with no read. ctx.emit('fs/observed', target('a.txt'), FsVersion('v1'), exec) - expect(await editExpectation(ctx, target('a.txt'), exec)).toEqual({ version: 'v1' }) + expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v1' }) // The edit records v2; a second edit guards against v2. ctx.emit('fs/observed', target('a.txt'), FsVersion('v2'), exec) - expect(await editExpectation(ctx, target('a.txt'), exec)).toEqual({ version: 'v2' }) + expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v2' }) }) it('a no-owner observation records nothing', async () => { const { ctx } = await setup() ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), undefined) // Still unobserved for any owner. - await expect(editExpectation(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + await expect(editIntent(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) }) }) @@ -124,8 +124,8 @@ describe('multi-owner isolation', () => { const a = ownerExec({}) const b = ownerExec({}) ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), a) - await expect(editExpectation(ctx, target('a.txt'), b)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) - expect(await editExpectation(ctx, target('a.txt'), a)).toEqual({ version: 'v0' }) + await expect(editIntent(ctx, target('a.txt'), b)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + expect(await editIntent(ctx, target('a.txt'), a)).toEqual({ version: 'v0' }) }) it('each owner records its own observed version independently', async () => { @@ -134,8 +134,8 @@ describe('multi-owner isolation', () => { const b = ownerExec({}) ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), a) // A observed v0 // B never observed → createIfAbsent; A still holds v0 → replaceIfVersion. - expect(await writeExpectation(ctx, target('a.txt'), b)).toEqual({ kind: 'createIfAbsent' }) - expect(await writeExpectation(ctx, target('a.txt'), a)).toEqual({ kind: 'replaceIfVersion', version: 'v0' }) + expect(await writeIntent(ctx, target('a.txt'), b)).toEqual({ kind: 'createIfAbsent' }) + expect(await writeIntent(ctx, target('a.txt'), a)).toEqual({ kind: 'replaceIfVersion', version: 'v0' }) }) }) @@ -143,27 +143,27 @@ describe('single-slot, first-wins', () => { it('fully decides the slot without calling next() (the bare default is unreached)', async () => { const { ctx } = await setup() let defaultRan = false - const expectation = await ctx.waterfall('fs/write-expectation', target('a.txt'), ownerExec({}), () => { + const intent = await ctx.waterfall('fs/write-intent', target('a.txt'), ownerExec({}), () => { defaultRan = true return undefined }) - expect(expectation).toEqual({ kind: 'createIfAbsent' }) + expect(intent).toEqual({ kind: 'createIfAbsent' }) expect(defaultRan).toBe(false) }) - it('a SECOND decider registered AFTER file-context is not reached (first-wins short-circuit)', async () => { + it('a SECOND decider registered AFTER fs-policy is not reached (first-wins short-circuit)', async () => { const { ctx } = await setup() let secondRan = false - // Registered after file-context, so it dispatches second; file-context does + // Registered after fs-policy, so it dispatches second; fs-policy does // not call next(), so this never runs. (A decider registered BEFORE — or with // prepend — would instead win: first-wins is by convention, not enforced.) - ctx.on('fs/edit-expectation', () => { + ctx.on('fs/edit-intent', () => { secondRan = true return Promise.resolve(undefined) }) const exec = ownerExec({}) ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec) - await editExpectation(ctx, target('a.txt'), exec) + await editIntent(ctx, target('a.txt'), exec) expect(secondRan).toBe(false) }) }) @@ -172,21 +172,21 @@ describe('disposal releases recorded state (HMR safety)', () => { it('a fresh plugin after disposal starts with no inherited state', async () => { const ctx = new Context() const exec = ownerExec({}) - const fiber = await ctx.plugin(FileContext) + const fiber = await ctx.plugin(FsPolicy) ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec) - expect(await editExpectation(ctx, target('a.txt'), exec)).toEqual({ version: 'v0' }) + expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v0' }) await fiber.dispose() - await ctx.plugin(FileContext) + await ctx.plugin(FsPolicy) // Same owner object, but state was released on disposal. - await expect(editExpectation(ctx, target('a.txt'), exec)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + await expect(editIntent(ctx, target('a.txt'), exec)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) }) it('no listeners remain after disposal (the gate no longer decides)', async () => { const ctx = new Context() - const fiber = await ctx.plugin(FileContext) + const fiber = await ctx.plugin(FsPolicy) await fiber.dispose() // With no listener, the waterfall falls through to the bare default. - expect(await writeExpectation(ctx, target('a.txt'), ownerExec({}))).toBeUndefined() + expect(await writeIntent(ctx, target('a.txt'), ownerExec({}))).toBeUndefined() }) }) diff --git a/packages/fs/file-context/tsconfig.json b/packages/fs/fs-policy/tsconfig.json similarity index 100% rename from packages/fs/file-context/tsconfig.json rename to packages/fs/fs-policy/tsconfig.json diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md index fd2307dec5..917b660cfa 100644 --- a/packages/fs/fs/README.md +++ b/packages/fs/fs/README.md @@ -7,7 +7,7 @@ This package is the provider-seam layer of the four-layer filesystem stack, spli | Layer | Package | Role | |---|---|---| | tool / executor | `@deepseek-ai/dsh-tool-fs` | model-facing `read`/`write`/`edit` schemas + read windowing + text rendering; reads/writes/edits via `ctx.fs`, dispatches the `fs/*` events | -| policy | `@deepseek-ai/dsh-file-context` | observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate (no service) | +| policy | `@deepseek-ai/dsh-fs-policy` | observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate (no service) | | provider seam | `@deepseek-ai/dsh-fs` (this) | `ctx.fs`: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` event vocabulary | | provider | `@deepseek-ai/dsh-fs-local` | the host-filesystem implementation | @@ -23,22 +23,21 @@ A backend subclasses `FileSystem` and implements six primitives. | `stat(target, signal?)` | Return `FsInfo` metadata (`version`, `type`, optional `size`), or `undefined` when the target is absent. Never content. | | `readText(target, signal?)` | Read the whole regular text file as one decoded string. Owns regular-file checks, UTF-8 decoding, binary/NUL rejection (`FS_NOT_TEXT`). | | `streamText(target, signal?)` | Stream the same text as decoded chunks for large files (cross-chunk UTF-8 decoding stays here). | -| `writeText(target, content, expected?, signal?)` | Atomic create/replace. `expected` is OPTIONAL: omit ⇒ unconditional create-or-overwrite; supply an `FsWriteExpectation` (`createIfAbsent`/`replaceIfVersion`) to guard. | +| `writeText(target, content, expected?, signal?)` | Atomic create/replace. `expected` is OPTIONAL: omit ⇒ unconditional create-or-overwrite; supply an `FsWriteIntent` (`createIfAbsent`/`replaceIfVersion`) to guard. | | `editText(target, edit, expected?, signal?)` | Literal edit. `expected` is OPTIONAL: omit ⇒ unconditional edit of the current content; supply `{ version }` to guard (verified BEFORE matching). A missing target reports `FS_STALE_VERSION` either way. Applies and writes atomically — one mutation critical section. | The mutation runs inside the backend's per-target lock either way, so an unconditional write/edit is still atomic — "unconditional" drops the *version* precondition, not the atomicity. ## The `fs/*` policy events -This package declares three events (see the generated [catalog](../../../docs/cordis-catalog/events-and-services.md)) so the emitter (`@deepseek-ai/dsh-tool-fs`) and the policy listener (`@deepseek-ai/dsh-file-context`) share a vocabulary without the emitter depending on the policy plugin. `fs/write-expectation` and `fs/edit-expectation` are single-slot decision waterfalls (the listener fully decides, never calling `next()`); `fs/observed` is a fire-and-forget recording event. They carry only `dsh-fs` vocabulary plus an opaque `object` actor — no model-facing concepts and no agent/session owner structure. +This package declares three events (see the generated [catalog](../../../docs/cordis-catalog/events-and-services.md)) so the emitter (`@deepseek-ai/dsh-tool-fs`) and the policy listener (`@deepseek-ai/dsh-fs-policy`) share a vocabulary without the emitter depending on the policy plugin. `fs/write-intent` and `fs/edit-intent` are single-slot decision waterfalls (the listener fully decides, never calling `next()`); `fs/observed` is a fire-and-forget recording event. They carry only `dsh-fs` vocabulary plus an opaque `object` actor — no model-facing concepts and no agent/session owner structure. ## A provider seam, not the policy layer -`ctx.fs` is deliberately close to fsspec-style storage primitives — half a level above byte-level `cat`/`open`, because it decodes text and rejects binaries so the policy layer never touches raw bytes. It owns UTF-8 decoding, binary rejection, atomic writes, and the literal-edit critical section. It does **not** own line windows, numbered lines, rendered footers, or observed-state. Observed-state, read-before-edit, and version-guarded write/edit are policy a plugin (`@deepseek-ai/dsh-file-context`) ADDS by supplying the optional guard — not provider behavior — so a sandboxed/remote backend inherits no model-facing observation policy. +`ctx.fs` is deliberately close to fsspec-style storage primitives — half a level above byte-level `cat`/`open`, because it decodes text and rejects binaries so the policy layer never touches raw bytes. It owns UTF-8 decoding, binary rejection, atomic writes, and the literal-edit critical section. It does **not** own line windows, numbered lines, rendered footers, or observed-state. Observed-state, read-before-edit, and version-guarded write/edit are policy a plugin (`@deepseek-ai/dsh-fs-policy`) ADDS by supplying the optional guard — not provider behavior — so a sandboxed/remote backend inherits no model-facing observation policy. `editText` stays on this seam (not composed in the policy layer from a read plus a write) because version guard + literal match + atomic rewrite must stay inside one critical section for correct error attribution and one-wins/one-stale concurrency, and a remote backend may implement it as a native compare-and-edit. ## Vocabulary -`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids RFC](../../../docs/rfc/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsWriteExpectation` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy RFC](../../../docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts. - +`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids RFC](../../../docs/rfc/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy RFC](../../../docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts. diff --git a/packages/fs/fs/package.json b/packages/fs/fs/package.json index 395816dbf9..813cb04e16 100644 --- a/packages/fs/fs/package.json +++ b/packages/fs/fs/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-fs", - "description": "Abstract filesystem capability seam (ctx.fs) for the DeepSeek Harness — vocabulary types, the FileSystem service, and the read-before-write/edit file-state contract", + "description": "Abstract filesystem capability seam (ctx.fs) for the DeepSeek Harness — vocabulary types, the FileSystem service (text IO + optional version-guarded atomic mutations), and the fs/* policy event vocabulary", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts index f25a521301..e4aef709d0 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -2,7 +2,7 @@ * The filesystem provider seam (`ctx.fs`): an abstract service defining the * text-storage primitives a backend provides — resolve a path into a stable * target, stat its metadata, read/stream its text, write it atomically with an - * explicit expectation, and apply a guarded literal edit — without saying HOW. + * explicit intent, and apply a guarded literal edit — without saying HOW. * Implementations subclass {@link FileSystem} and register themselves as the * `fs` service; `@deepseek-ai/dsh-fs-local` (the host filesystem) is the first. * Future implementations swap in sandboxed, remote, virtual, or project-scoped @@ -20,7 +20,7 @@ * literal-edit critical section — but NOT line windows, numbered lines, * rendered footers, or observed-state. Read windowing lives in the model-facing * tool (`@deepseek-ai/dsh-tool-fs`); observed-state and read-before-write/edit - * are policy a plugin (`@deepseek-ai/dsh-file-context`) adds through the `fs/*` + * are policy a plugin (`@deepseek-ai/dsh-fs-policy`) adds through the `fs/*` * event gate. So a sandboxed/remote backend inherits no model-facing observation * policy it has no business carrying. * @@ -41,14 +41,14 @@ * unconditional write/edit is still atomic; "unconditional" drops the *version* * precondition, not the atomicity. Observed-state, read-before-edit, and * version-guarded write/edit are NOT provider behavior — they are policy a - * plugin (`@deepseek-ai/dsh-file-context`) adds on top by supplying the guard. + * plugin (`@deepseek-ai/dsh-fs-policy`) adds on top by supplying the guard. * * ## The fs policy events live here, not in the policy plugin * - * This package owns the `fs/write-expectation`, `fs/edit-expectation`, and + * This package owns the `fs/write-intent`, `fs/edit-intent`, and * `fs/observed` event vocabulary (see {@link Events}). The emitter is * `@deepseek-ai/dsh-tool-fs` and the default listener is - * `@deepseek-ai/dsh-file-context`; the events live in the one package both + * `@deepseek-ai/dsh-fs-policy`; the events live in the one package both * already depend on, so the emitter shares a vocabulary with the policy listener * without depending on the policy plugin. The events carry only `dsh-fs` * vocabulary plus an opaque `object` actor — no model-facing concepts (line @@ -64,7 +64,7 @@ import type { FsInfo, FsTarget, FsVersion, - FsWriteExpectation, + FsWriteIntent, FsWriteOutcome, } from './types.ts' @@ -79,7 +79,7 @@ export type { FsErrorCode, FsInfo, FsTarget, - FsWriteExpectation, + FsWriteIntent, FsWriteOutcome, } from './types.ts' @@ -90,11 +90,11 @@ declare module 'cordis' { interface Events { /** - * Single-slot decision: produce the write expectation for the next + * Single-slot decision: produce the write intent for the next * {@link FileSystem.writeText}. The tool dispatches this as an unbound * waterfall (no `this`) and supplies a default thunk returning `undefined` * (unconditional create-or-overwrite — the bare provider). The - * `@deepseek-ai/dsh-file-context` policy listener returns `createIfAbsent` + * `@deepseek-ai/dsh-fs-policy` policy listener returns `createIfAbsent` * (unobserved actor) or `{ kind: 'replaceIfVersion', version: vObserved }` * (observed) and does NOT call `next()` — one decision, not a composable * chain. The slot is first-wins: the first non-`next()` decider (registration @@ -102,23 +102,23 @@ declare module 'cordis' { * not layering. `actor` is the opaque tool-execution context, never read here. * @mode waterfall */ - 'fs/write-expectation'(target: FsTarget, actor: object | undefined, next: () => FsWriteExpectation | undefined | Promise): Promise + 'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise): Promise /** * Single-slot decision: produce the optional version guard for the next * {@link FileSystem.editText}. The tool dispatches this as an unbound * waterfall and supplies a default thunk returning `undefined` (unconditional * edit of the current content — the bare provider; no `stat`). The - * `@deepseek-ai/dsh-file-context` policy listener returns + * `@deepseek-ai/dsh-fs-policy` policy listener returns * `{ version: vObserved }`, or throws `FS_NOT_OBSERVED` if the actor is unset * or has not observed the target. Does NOT call `next()`: one decision, - * first-wins (see {@link Events.'fs/write-expectation'}). + * first-wins (see {@link Events.'fs/write-intent'}). * @mode waterfall */ - 'fs/edit-expectation'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> + 'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> /** * Record that an actor observed a target at a version, after a successful * read/write/edit. Fire-and-forget (plain `emit`). A listener MUST be a - * synchronous, side-effect-only recorder (`@deepseek-ai/dsh-file-context`'s + * synchronous, side-effect-only recorder (`@deepseek-ai/dsh-fs-policy`'s * is a `WeakMap.set`): the tool does not guard the emit, so a listener that * throws surfaces as the tool's `isError` result, and cordis `emit` does not * await listener promises — async or fallible audit/telemetry does not @@ -147,7 +147,7 @@ declare module 'cordis' { * binary/NUL rejection, and `FS_NOT_TEXT`. * - {@link writeText} is atomic temp-file + rename. `expected` is OPTIONAL: * omit it for an unconditional create-or-overwrite (the bare-provider default), - * or supply a {@link FsWriteExpectation} to guard the write. + * or supply a {@link FsWriteIntent} to guard the write. * - {@link editText} verifies `expected.version` BEFORE literal matching (so a * stale edit reports `FS_STALE_VERSION`, not `FS_EDIT_NOT_FOUND`/ * `FS_AMBIGUOUS_EDIT` against newer content), then applies literal replacement @@ -188,7 +188,7 @@ export abstract class FileSystem extends Service { * unconditional create-or-overwrite (the bare provider — no version guard, no * read-first requirement). Atomic either way. */ - abstract writeText(target: FsTarget, content: string, expected?: FsWriteExpectation, signal?: AbortSignal): Promise + abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise /** * Apply a literal edit to an existing UTF-8 text file. When `expected` is diff --git a/packages/fs/fs/src/types.ts b/packages/fs/fs/src/types.ts index 258c7a1e8b..15a58ee93b 100644 --- a/packages/fs/fs/src/types.ts +++ b/packages/fs/fs/src/types.ts @@ -1,12 +1,12 @@ /** * Vocabulary for the filesystem provider seam (`ctx.fs`): the opaque - * target/version identities, the metadata `stat` returns, the write-expectation + * target/version identities, the metadata `stat` returns, the write-intent * and outcome shapes, the literal-edit request/outcome, and the typed error * taxonomy. * * These types are shared by every backend (`@deepseek-ai/dsh-fs-local` and * future sandboxed/remote backends) and by the policy layer - * (`@deepseek-ai/dsh-file-context`). They are deliberately a *text-storage* + * (`@deepseek-ai/dsh-fs-policy`). They are deliberately a *text-storage* * vocabulary half a level above byte-level fsspec: `readText`/`streamText` hand * back decoded text, never raw bytes. Host-path assumptions stay out — `targetKey` * and `version` are opaque branded tokens, and `displayPath` is the only field a @@ -14,7 +14,7 @@ * * Model-facing concepts (line windows, numbered lines, observed-state) do NOT * live here; they belong to the consumer tool and the policy plugin - * (`@deepseek-ai/dsh-tool-fs` / `@deepseek-ai/dsh-file-context`). + * (`@deepseek-ai/dsh-tool-fs` / `@deepseek-ai/dsh-fs-policy`). * * @module @deepseek-ai/dsh-fs/types */ @@ -91,7 +91,7 @@ export interface FsInfo { * is expressed by omission, so the write and edit mutations share one symmetric * shape (`expected?`: omit = unconditional, present = guarded). */ -export type FsWriteExpectation = +export type FsWriteIntent = | { kind: 'createIfAbsent' } | { kind: 'replaceIfVersion'; version: FsVersion } diff --git a/packages/fs/fs/tests/service.spec.ts b/packages/fs/fs/tests/service.spec.ts index 7091746dc3..789ed7fdac 100644 --- a/packages/fs/fs/tests/service.spec.ts +++ b/packages/fs/fs/tests/service.spec.ts @@ -1,7 +1,7 @@ /** * Tests for the filesystem provider seam itself: registration, duplicate-service * behavior, disposal, and the branded id factories. The provider primitives and - * policy live in `dsh-fs-local` and `dsh-file-context`; this seam owns only the + * policy live in `dsh-fs-local` and `dsh-fs-policy`; this seam owns only the * abstract service contract, so a minimal fake backend exercises it. */ @@ -13,7 +13,7 @@ import type { FsEditRequest, FsInfo, FsTarget, - FsWriteExpectation, + FsWriteIntent, FsWriteOutcome, } from '@deepseek-ai/dsh-fs' @@ -38,7 +38,7 @@ class FakeFileSystem extends FileSystem { const content = await this.readText(target) return (async function* () { yield content })() } - override async writeText(target: FsTarget, content: string, _expected?: FsWriteExpectation): Promise { + override async writeText(target: FsTarget, content: string, _expected?: FsWriteIntent): Promise { const existed = this.files.has(target.targetKey) this.files.set(target.targetKey, content) return { operation: existed ? 'update' : 'create', version: FsVersion('v2') } diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index bcc5c5cdf1..bc38a5ee64 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -1,15 +1,15 @@ # @deepseek-ai/dsh-tool-fs -The **model-facing filesystem tools** — `read`, `write`, `edit` — and their **executor**. This is the consumer layer of the filesystem stack: it owns tool names, JSON schemas, argument validation, prompt sections, **read windowing**, and result formatting. It reads/writes/edits through the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)) **directly** — it injects `fs` (plus `tools`/`systemPrompt`), **not** a policy service. The freshness/observation policy is contributed by a separate plugin ([`@deepseek-ai/dsh-file-context`](../file-context)) through the `fs/*` event gate; the tool is not method-coupled to it. +The **model-facing filesystem tools** — `read`, `write`, `edit` — and their **executor**. This is the consumer layer of the filesystem stack: it owns tool names, JSON schemas, argument validation, prompt sections, **read windowing**, and result formatting. It reads/writes/edits through the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)) **directly** — it injects `fs` (plus `tools`/`systemPrompt`), **not** a policy service. The freshness/observation policy is contributed by a separate plugin ([`@deepseek-ai/dsh-fs-policy`](../fs-policy)) through the `fs/*` event gate; the tool is not method-coupled to it. ```ts ignore-check // Default deployment: a ctx.fs provider, the policy plugin, then the tools. await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) // @deepseek-ai/dsh-fs-local -await ctx.plugin(FileContext) // @deepseek-ai/dsh-file-context (policy gate) +await ctx.plugin(FsPolicy) // @deepseek-ai/dsh-fs-policy (policy gate) await ctx.plugin(ToolFs) // this package — registers read/write/edit ``` -`@deepseek-ai/dsh-file-context` is **optional**: omit it and the tools run against the bare provider (unconditional write/overwrite/edit, no observed-state). A deployment that loads these tools is expected to also load it, so the behavior is read-before-write/edit. +`@deepseek-ai/dsh-fs-policy` is **optional**: omit it and the tools run against the bare provider (unconditional write/overwrite/edit, no observed-state). A deployment that loads these tools is expected to also load it, so the behavior is read-before-write/edit. ## Tools (schemas per [the filesystem tool schemas RFC](../../../docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md)) @@ -26,13 +26,13 @@ Field names are snake_case to match Claude Code and existing harness tool schema The tools do **not** inject a policy service or inspect any cache. Each tool resolves the path via `ctx.fs.resolve()`, then: - **read** — one `ctx.fs.stat` (type + size routing + version), then `readText`/`streamText`, then builds the line window, then emits a contained `fs/observed`. (1 stat.) -- **write** — `ctx.waterfall('fs/write-expectation', target, exec, () => undefined)` for the optional guard, then `ctx.fs.writeText(target, content, expectation)`, then `fs/observed`. (0 stat.) -- **edit** — `ctx.waterfall('fs/edit-expectation', target, exec, () => undefined)` for the optional guard, then `ctx.fs.editText(target, edit, expectation)`, then `fs/observed`. (0 stat.) +- **write** — `ctx.waterfall('fs/write-intent', target, exec, () => undefined)` for the optional guard, then `ctx.fs.writeText(target, content, intent)`, then `fs/observed`. (0 stat.) +- **edit** — `ctx.waterfall('fs/edit-intent', target, exec, () => undefined)` for the optional guard, then `ctx.fs.editText(target, edit, intent)`, then `fs/observed`. (0 stat.) -The tool passes `exec` (the tool-execution context) as the opaque `actor` on every dispatch. The default thunks return `undefined` (the unconstrained bare provider). When `@deepseek-ai/dsh-file-context` is loaded it occupies the single decision slot — returning `createIfAbsent`/`replaceIfVersion`/`{ version }` or throwing `FS_NOT_OBSERVED` — and records on `fs/observed`. Backend errors (`FsError`) and a thrown `FS_NOT_OBSERVED` flow through `ToolRegistry.execute()` and become `isError` tool results with their `{ name, code }` attached. +The tool passes `exec` (the tool-execution context) as the opaque `actor` on every dispatch. The default thunks return `undefined` (the unconstrained bare provider). When `@deepseek-ai/dsh-fs-policy` is loaded it occupies the single decision slot — returning `createIfAbsent`/`replaceIfVersion`/`{ version }` or throwing `FS_NOT_OBSERVED` — and records on `fs/observed`. Backend errors (`FsError`) and a thrown `FS_NOT_OBSERVED` flow through `ToolRegistry.execute()` and become `isError` tool results with their `{ name, code }` attached. ## `fs/observed` is fire-and-forget -`fs/observed` fires AFTER the read/write/edit already succeeded, via a plain `ctx.emit`. A listener is contractually a synchronous, side-effect-only recorder (`@deepseek-ai/dsh-file-context`'s is a `WeakMap.set`); the tool does not guard the emit, so a listener that throws would surface as the tool's `isError` result — async or fallible observation does not belong on this event. +`fs/observed` fires AFTER the read/write/edit already succeeded, via a plain `ctx.emit`. A listener is contractually a synchronous, side-effect-only recorder (`@deepseek-ai/dsh-fs-policy`'s is a `WeakMap.set`); the tool does not guard the emit, so a listener that throws would surface as the tool's `isError` result — async or fallible observation does not belong on this event. The read rendering (line windowing + output formatting) lives in `src/read-render.ts` (Cordis-free, independently unit-tested); `src/read.ts`/`write.ts`/`edit.ts` are the tool executors and `src/index.ts` composes them. diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index 5323bbadcf..f92966f515 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -30,7 +30,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-file-context": "workspace:^", + "@deepseek-ai/dsh-fs-policy": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index f1eab5e319..efe2e5f53b 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -1,10 +1,10 @@ /** * The model-facing `edit` tool: update an existing UTF-8 text file by replacing * literal text, requiring a unique match by default. The tool is the executor: - * it dispatches the `fs/edit-expectation` waterfall to obtain the optional + * it dispatches the `fs/edit-intent` waterfall to obtain the optional * version guard, calls `ctx.fs.editText` directly, and emits `fs/observed`. The * default thunk returns `undefined` (unconditional edit of the current content - * — the bare provider); a policy plugin (`@deepseek-ai/dsh-file-context`) + * — the bare provider); a policy plugin (`@deepseek-ai/dsh-fs-policy`) * occupies the single decision slot, returning `{ version: vObserved }` or * throwing `FS_NOT_OBSERVED` for an unread file. The tool stats ZERO times * either way; a missing target is reported by the provider as `FS_STALE_VERSION`. @@ -52,7 +52,7 @@ export function applyEditTool(ctx: Context): void { ctx.systemPrompt.section({ name: 'tool:edit', order: 102, - text: 'Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default file-context policy requires it), unless you just created or edited it in this session.', + text: 'Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.', }) ctx.tools.register(defineTool({ @@ -70,11 +70,11 @@ export function applyEditTool(ctx: Context): void { // Single-slot decision: the policy plugin returns { version: vObserved } or // throws FS_NOT_OBSERVED; the bare default is undefined (unconditional edit). // No stat — the bare default never manufactures a version basis. - const expectation = await ctx.waterfall('fs/edit-expectation', target, exec, () => undefined) + const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined) const outcome = await ctx.fs.editText( target, { oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll }, - expectation, + intent, exec.signal, ) // Record the observed version (a no-op when no policy plugin listens). diff --git a/packages/fs/tool-fs/src/index.ts b/packages/fs/tool-fs/src/index.ts index 285299e352..e9f384a96c 100644 --- a/packages/fs/tool-fs/src/index.ts +++ b/packages/fs/tool-fs/src/index.ts @@ -8,15 +8,16 @@ * concerns only — tool names, JSON schemas, argument validation, prompt * sections, read windowing, result formatting. It does NOT inject a policy * service. Instead, on each write/edit it dispatches a single-slot waterfall - * (`fs/write-expectation`/`fs/edit-expectation`) to obtain the OPTIONAL version - * guard, and after every read/write/edit it emits a contained `fs/observed`. A - * policy plugin (`@deepseek-ai/dsh-file-context`, loaded by the default product - * config) occupies the decision slot and listens for `fs/observed` to add - * observed-state + read-before-edit + version-guarded write/edit. With no policy - * plugin the waterfalls fall through to their `undefined` default (the - * unconstrained bare provider) and `fs/observed` is unheard — the tool still - * functions. This package never imports `node:fs`, `node:path`, or an - * `@deepseek-ai/dsh-fs-local` implementation. + * (`fs/write-intent`/`fs/edit-intent`) to obtain the OPTIONAL version guard, and + * after every read/write/edit it emits `fs/observed` with a plain (unguarded) + * `ctx.emit`. A policy plugin (`@deepseek-ai/dsh-fs-policy`) occupies the + * decision slot and listens for `fs/observed` to add observed-state + + * read-before-edit + version-guarded write/edit; a deployment that loads these + * tools is expected to also load it. With no policy plugin the waterfalls fall + * through to their `undefined` default (the unconstrained bare provider) and + * `fs/observed` is unheard — the tool still functions. This package never + * imports `node:fs`, `node:path`, or an `@deepseek-ai/dsh-fs-local` + * implementation. * * @module @deepseek-ai/dsh-tool-fs */ diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index 31d31424cb..b7e0d43772 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -3,7 +3,7 @@ * line-numbered content with pagination guidance. The tool is the executor — it * stats and reads through `ctx.fs` directly, builds the line window * ({@link module:@deepseek-ai/dsh-tool-fs/read-render}), and emits `fs/observed` - * so a policy plugin (`@deepseek-ai/dsh-file-context`) can record the read. With + * so a policy plugin (`@deepseek-ai/dsh-fs-policy`) can record the read. With * no policy plugin the emit is simply unheard. This module owns the * model-facing schema, argument validation, and the read I/O; the rendering * (windowing + formatting) lives in `read-render.ts` and the diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index 564d99ddd6..ed9143f32a 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -1,10 +1,10 @@ /** * The model-facing `write` tool: create or fully replace a UTF-8 text file. The - * tool is the executor: it dispatches the `fs/write-expectation` waterfall to + * tool is the executor: it dispatches the `fs/write-intent` waterfall to * obtain the optional version guard, calls `ctx.fs.writeText` directly, and * emits `fs/observed`. The default thunk returns `undefined` (unconditional * create-or-overwrite — the bare provider); a policy plugin - * (`@deepseek-ai/dsh-file-context`) occupies the single decision slot and + * (`@deepseek-ai/dsh-fs-policy`) occupies the single decision slot and * returns `createIfAbsent`/`replaceIfVersion` instead. The tool stats ZERO * times either way. * @@ -39,7 +39,7 @@ export function applyWriteTool(ctx: Context): void { ctx.systemPrompt.section({ name: 'tool:write', order: 101, - text: 'Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default file-context policy requires it) and prefer edit for targeted changes.', + text: 'Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.', }) ctx.tools.register(defineTool({ @@ -54,8 +54,8 @@ export function applyWriteTool(ctx: Context): void { const target = await ctx.fs.resolve(input.filePath) // Single-slot decision: the policy plugin produces createIfAbsent/ // replaceIfVersion; the bare default is undefined (unconditional). No stat. - const expectation = await ctx.waterfall('fs/write-expectation', target, exec, () => undefined) - const outcome = await ctx.fs.writeText(target, input.content, expectation, exec.signal) + const intent = await ctx.waterfall('fs/write-intent', target, exec, () => undefined) + const outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal) // Record the observed version (a no-op when no policy plugin listens). ctx.emit('fs/observed', target, outcome.version, exec) return [{ type: 'text', text: formatWriteOutput(target.displayPath, outcome) }] diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index 087227f790..238909ff98 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -3,7 +3,7 @@ * tools (`dsh-tool-fs`) as the executor, exercised through `ctx.tools.execute()` * so nothing bypasses the tool registry. Two deployments: * - * - DEFAULT — with the real `dsh-file-context` policy gate plugin: read-before- + * - DEFAULT — with the real `dsh-fs-policy` policy gate plugin: read-before- * write/edit, version-guarded mutation, FS_NOT_OBSERVED for unread edits. * - BARE — WITHOUT the policy plugin: every `fs/*` waterfall falls through to * its undefined default, so write/edit are unconditional. This proves the @@ -22,7 +22,7 @@ import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' -import * as FileContext from '@deepseek-ai/dsh-file-context' +import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' let dir: string @@ -53,14 +53,14 @@ afterEach(async () => { // -------------------------------------------------------------------------- // DEFAULT deployment: the policy gate plugin is loaded. // -------------------------------------------------------------------------- -describe('default deployment (with dsh-file-context)', () => { +describe('default deployment (with dsh-fs-policy)', () => { beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-')) ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(LocalFileSystem, { cwd: dir }) - await ctx.plugin(FileContext) + await ctx.plugin(FsPolicy) fiber = await ctx.plugin(ToolFs) }) @@ -228,7 +228,7 @@ describe('default deployment (with dsh-file-context)', () => { // -------------------------------------------------------------------------- // BARE deployment: the tool suite WITHOUT the policy gate. // -------------------------------------------------------------------------- -describe('bare provider (no dsh-file-context)', () => { +describe('bare provider (no dsh-fs-policy)', () => { beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-bare-')) ctx = new Context() diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 5ca6947354..4a161a272b 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -1,6 +1,6 @@ /** * Consumer-surface tests for the filesystem tools as the EXECUTOR. They run the - * REAL `@deepseek-ai/dsh-file-context` gate plugin (the genuine policy + * REAL `@deepseek-ai/dsh-fs-policy` gate plugin (the genuine policy * collaborator, per the prefer-the-real-implementation rule) over a fake * `ctx.fs` provider, so they verify schemas, argument validation, result * formatting, FsError→isError propagation, and that each tool dispatches the @@ -19,10 +19,10 @@ import type { FsEditRequest, FsInfo, FsTarget, - FsWriteExpectation, + FsWriteIntent, FsWriteOutcome, } from '@deepseek-ai/dsh-fs' -import * as FileContext from '@deepseek-ai/dsh-file-context' +import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import { formatReadOutput, STREAM_MIN_SIZE } from '@deepseek-ai/dsh-tool-fs' import type { FileReadOutcome } from '@deepseek-ai/dsh-tool-fs' @@ -31,8 +31,8 @@ import type { FileReadOutcome } from '@deepseek-ai/dsh-tool-fs' class FakeFs extends FileSystem { files = new Map() rejectWith?: FsError - writeExpectations: (FsWriteExpectation | undefined)[] = [] - editExpectations: ({ version: FsVersion } | undefined)[] = [] + writeIntents: (FsWriteIntent | undefined)[] = [] + editIntents: ({ version: FsVersion } | undefined)[] = [] private throwIfArmed(): void { if (this.rejectWith) throw this.rejectWith @@ -54,16 +54,16 @@ class FakeFs extends FileSystem { const content = this.files.get(target.targetKey) ?? '' return (async function* () { yield content })() } - override async writeText(target: FsTarget, content: string, expected?: FsWriteExpectation): Promise { + override async writeText(target: FsTarget, content: string, expected?: FsWriteIntent): Promise { this.throwIfArmed() - this.writeExpectations.push(expected) + this.writeIntents.push(expected) const existed = this.files.has(target.targetKey) this.files.set(target.targetKey, content) return { operation: existed ? 'update' : 'create', version: FsVersion('v2') } } override async editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }): Promise { this.throwIfArmed() - this.editExpectations.push(expected) + this.editIntents.push(expected) const content = this.files.get(target.targetKey) ?? '' this.files.set(target.targetKey, content.split(edit.oldString).join(edit.newString)) return { replacements: 1, replaceAll: edit.replaceAll, version: FsVersion('v3') } @@ -75,7 +75,7 @@ async function setup() { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(FakeFs) - await ctx.plugin(FileContext) + await ctx.plugin(FsPolicy) await ctx.plugin(ToolFs) const fs = ctx.fs as FakeFs return { ctx, fs } @@ -122,11 +122,16 @@ describe('registration', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(FakeFs) - await ctx.plugin(FileContext) + await ctx.plugin(FsPolicy) const fiber = await ctx.plugin(ToolFs) + // Each tool contributes BOTH a schema and a prompt section; disposal must + // withdraw both, not just the schemas. expect(ctx.tools.schemas()).toHaveLength(3) + const sectionNames = (a: { sections: { name: string }[] }) => a.sections.map(s => s.name).sort() + expect(sectionNames(await ctx.systemPrompt.assemble())).toEqual(['tool:edit', 'tool:read', 'tool:write']) await fiber.dispose() expect(ctx.tools.schemas()).toHaveLength(0) + expect((await ctx.systemPrompt.assemble()).sections).toHaveLength(0) }) }) @@ -174,7 +179,7 @@ describe('read tool', () => { expect((await call(ctx, 'read', { file_path: 'a.txt' }, { session })).isError).toBe(false) const edited = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'hello', new_string: 'bye' }, { session }) expect(edited.isError).toBe(false) - expect(fs.editExpectations).toEqual([{ version: 'v1' }]) + expect(fs.editIntents).toEqual([{ version: 'v1' }]) }) it('propagates FS_NOT_FOUND for an absent file', async () => { @@ -257,7 +262,7 @@ describe('write tool', () => { const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' }, { session: {} }) expect(result.isError).toBe(false) expect(text(result)).toContain('Created file') - expect(fs.writeExpectations).toEqual([{ kind: 'createIfAbsent' }]) + expect(fs.writeIntents).toEqual([{ kind: 'createIfAbsent' }]) }) it('rejects a blank file_path', async () => { diff --git a/packages/fs/tool-fs/tsconfig.json b/packages/fs/tool-fs/tsconfig.json index 7c03431ee4..6af16400c0 100644 --- a/packages/fs/tool-fs/tsconfig.json +++ b/packages/fs/tool-fs/tsconfig.json @@ -12,6 +12,6 @@ { "path": "../../core/tools" }, { "path": "../../core/system-prompt" }, { "path": "../fs" }, - { "path": "../file-context" } + { "path": "../fs-policy" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39fdcbd628..58ea6d9bfe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -278,18 +278,6 @@ 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/file-context: - 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: devDependencies: '@deepseek-ai/dsh-brand': @@ -318,20 +306,32 @@ 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-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-file-context': - specifier: workspace:^ - version: link:../file-context '@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 diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 68933a2a13..8d84eff57a 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -80,10 +80,9 @@ const LINK_MAP: Record = { FsInfo: 'filesystem.md', FsTarget: 'filesystem.md', FsVersion: 'filesystem.md', - FsWriteExpectation: 'filesystem.md', + FsWriteIntent: 'filesystem.md', FsWriteOutcome: 'filesystem.md', - FileContextExec: 'filesystem.md', - FileReadRequest: 'filesystem.md', + FsPolicyExec: 'filesystem.md', FileReadOutcome: 'filesystem.md', } diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 6d584bc6c6..9a6fa4cab8 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -46,12 +46,12 @@ { "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": "FsWriteExpectation", "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": "FileContextExec", "source": "packages/fs/file-context/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" }, diff --git a/tsconfig.build.json b/tsconfig.build.json index a3334bb397..442d187a38 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -30,7 +30,7 @@ { "path": "./packages/bash/tool-bash" }, { "path": "./packages/fs/fs" }, { "path": "./packages/fs/fs-local" }, - { "path": "./packages/fs/file-context" }, + { "path": "./packages/fs/fs-policy" }, { "path": "./packages/fs/tool-fs" }, { "path": "./packages/support/invariants" }, { "path": "./packages/ui/acp" }, diff --git a/tsconfig.json b/tsconfig.json index a192e9319e..00a3a21460 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -39,7 +39,7 @@ { "path": "./packages/bash/tool-bash" }, { "path": "./packages/fs/fs" }, { "path": "./packages/fs/fs-local" }, - { "path": "./packages/fs/file-context" }, + { "path": "./packages/fs/fs-policy" }, { "path": "./packages/fs/tool-fs" }, { "path": "./packages/compact/compact" }, { "path": "./packages/compact/compact-basic" }, From eda2983b001010e3ea480e0500b6881f1f17b028 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 2 Jul 2026 03:16:17 +0800 Subject: [PATCH 28/75] =?UTF-8?q?fix(docs):=20address=20Codex=20review=20?= =?UTF-8?q?=E2=80=94=20document=20the=20subagent=5Ffork=20alias,=20harden?= =?UTF-8?q?=20dispose?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-1 Codex review findings on the tool-schema catalog: (A) The shipped coding-agent / acp-agent configs load dsh-tool-subagent twice — as `subagent` (spawn backend) and `subagent_fork` (fork backend) — so the model sees a `subagent_fork` tool the catalog never mentioned, while the intro claimed to list "the exact name the model receives". The registered name is the plugin's load-time `toolName` config, not a package fact, so rather than bake an example-app config into a packages-scoped generator, add a per-package deployment `note`: the subagent entry now records the `subagent_fork` alias and points at the leaf configs. Intro and RFC scope reworded to state the unit is the package (at its default config), with aliases noted — no longer overclaiming. A test asserts the note names `subagent_fork`, covering the config-driven-name path. (B) collectToolCatalog only disposed the context on the success path; a throw from mount/schemas() after earlier plugins mounted would leak the fiber. Move `ctx.fiber.dispose()` into a `finally` per the repo's dispose-to-quiescence rule. --- .../process/2026-07-02-tool-schema-catalog.md | 4 ++- docs/tool-catalog/tools.md | 6 ++-- .../core/tools/tests/gen-tool-catalog.spec.ts | 12 +++++++ scripts/gen-tool-catalog.ts | 36 ++++++++++++++----- 4 files changed, 46 insertions(+), 12 deletions(-) diff --git a/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md b/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md index 9af018d2d6..96355941b3 100644 --- a/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md +++ b/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md @@ -31,7 +31,9 @@ The boot manifest (`TOOL_PACKAGES`) is a hand-written list — in tension with t ### Scope -Shipped product tools under `packages/*/tool-*` only: `dsh-tool-bash` (`bash`, `bash_output`, `bash_kill`), `dsh-tool-todo` (`todo_write`), `dsh-tool-subagent` (`subagent`). The `examples/` demo tools (`echo`) are excluded, matching the cordis catalog's packages-only scope — a demo tool is not part of the product surface a reader is cataloguing. +Shipped product tool PACKAGES under `packages/*/tool-*`, each booted with its default config: `dsh-tool-bash` (`bash`, `bash_output`, `bash_kill`), `dsh-tool-todo` (`todo_write`), `dsh-tool-subagent` (`subagent`). The `examples/` demo tools (`echo`) are excluded, matching the cordis catalog's packages-only scope — a demo tool is not part of the product surface a reader is cataloguing. + +The unit is the PACKAGE, not the deployed tool instance. A package's registered tool name can be a load-time config — `tool-subagent`'s `toolName` — so the same package surfaces as `subagent` (spawn backend) AND `subagent_fork` (fork backend) in the shipped `coding-agent` / `acp-agent` configs, with an identical schema. The generator boots each package once at its default and records such shipped aliases in a per-package note, rather than enumerating every deployment permutation. Cataloguing at the package level keeps the source of truth the package (what a plugin author reads) and avoids leaking example-app `cordis.yml` config into a packages-scoped generator; the note keeps the doc honest about the names a reader will actually see the model receive. The design deliberately does not attempt to catalog "every configured tool instance across every leaf config" — that is a deployment inventory, a different (and unbounded) surface. ### A plain `json` fence diff --git a/docs/tool-catalog/tools.md b/docs/tool-catalog/tools.md index 9e625d17ad..97400f8223 100644 --- a/docs/tool-catalog/tools.md +++ b/docs/tool-catalog/tools.md @@ -3,11 +3,11 @@ # Tool Schema Catalog -Every model-facing tool a shipped plugin contributes to `ctx.tools`: the exact `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the [cordis events & services catalog](../cordis-catalog/events-and-services.md) (the wiring a plugin listens to and calls) and [core-data-structures/](../core-data-structures/core.md) (the types those signatures move) — this page is the *tools* the agent is offered. +Every model-facing tool a shipped plugin contributes to `ctx.tools`: the `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the [cordis events & services catalog](../cordis-catalog/events-and-services.md) (the wiring a plugin listens to and calls) and [core-data-structures/](../core-data-structures/core.md) (the types those signatures move) — this page is the *tools* the agent is offered. This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any package is missing from the generator's boot manifest, so a new tool cannot be silently undocumented. See [the tool-schema-catalog RFC](../rfc/implemented/process/2026-07-02-tool-schema-catalog.md). -Scope: shipped product tools under `packages/*/tool-*`. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog's packages-only scope. +Scope: shipped product tools under `packages/*/tool-*`, each booted with its DEFAULT config. The registered tool NAME can be a load-time config (e.g. `tool-subagent`'s `toolName`), so a deployment may surface a package under a different or additional name — a per-package note records those shipped aliases where they exist. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog's packages-only scope. ## `@deepseek-ai/dsh-tool-bash` @@ -119,6 +119,8 @@ Delegate a self-contained task to a subagent (a separate agent that works in its Source: [`packages/subagent/tool-subagent/src/index.ts`](../../packages/subagent/tool-subagent/src/index.ts) +The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/coding-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. + ## `@deepseek-ai/dsh-tool-todo` ### `todo_write` diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 57d6b6ccc4..eba74c833d 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -60,6 +60,18 @@ describe('gen-tool-catalog collectToolCatalog', () => { const bash = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-bash') expect(bash?.source).toBe('packages/bash/tool-bash/src/index.ts') }) + + it('records the shipped `subagent_fork` alias in a note (config-driven tool name)', async () => { + // `tool-subagent`'s registered name is the load-time `toolName` config, so + // the shipped agents surface this one package as both `subagent` and + // `subagent_fork`. Booting yields only the default name; the note is how a + // reader learns the fork alias the model also sees. Without it the catalog + // would silently under-report the shipped tool surface. + const catalog = await collectToolCatalog() + const subagent = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-subagent') + expect(subagent?.schemas.map(s => s.name)).toEqual(['subagent']) + expect(subagent?.note).toMatch(/subagent_fork/) + }) }) describe('gen-tool-catalog assertManifestComplete', () => { diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 8e62277713..be318f753c 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -72,6 +72,14 @@ interface ToolPackage { /** Plug the injected seams + the tool plugin onto a context that already * carries `systemPrompt` + `tools`. */ mount: (ctx: Context) => Promise + /** + * A deployment note rendered after the package's tools, for a fact that + * booting the package alone cannot show. The registered tool NAME can be a + * load-time config (`tool-subagent`'s `toolName`), so one package may surface + * under several names across deployments — the boot yields the package + * DEFAULT, and this note records the shipped alternatives the model sees. + */ + note?: string } /** @@ -99,6 +107,8 @@ const TOOL_PACKAGES: ToolPackage[] = [ await ctx.plugin(SubagentMock, { name: 'mock' }) await ctx.plugin(ToolSubagent, { provider: 'mock' }) }, + note: + 'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/coding-agent/cordis.yml` and `examples/acp-agent/cordis.yml`.', }, { pkg: '@deepseek-ai/dsh-tool-todo', @@ -115,6 +125,8 @@ interface CatalogPackage { pkg: string source: string schemas: ToolSchema[] + /** A deployment note (see {@link ToolPackage.note}), rendered after the tools. */ + note?: string } /** The whole catalog: one entry per booted tool package, in manifest order. */ @@ -153,13 +165,18 @@ export async function collectToolCatalog(packages: ToolPackage[] = TOOL_PACKAGES const catalog: ToolCatalog = [] for (const entry of packages) { const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await entry.mount(ctx) - // Copy the schemas out before the context is torn down. - const schemas = ctx.tools.schemas().sort((a, b) => a.name.localeCompare(b.name)) - await ctx.fiber.dispose() - catalog.push({ pkg: entry.pkg, source: entry.source, schemas }) + // Dispose in `finally` so a throw from `mount`/`schemas()` after earlier + // plugins mounted still tears the context down (no leaked executor/provider + // fiber) — the repo's "dispose must reach quiescence" rule. + try { + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await entry.mount(ctx) + const schemas = ctx.tools.schemas().sort((a, b) => a.name.localeCompare(b.name)) + catalog.push({ pkg: entry.pkg, source: entry.source, schemas, ...entry.note !== undefined ? { note: entry.note } : {} }) + } finally { + await ctx.fiber.dispose() + } } return catalog } @@ -182,16 +199,17 @@ export function render(catalog: ToolCatalog): string { '', '# Tool Schema Catalog', '', - 'Every model-facing tool a shipped plugin contributes to `ctx.tools`: the exact `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the [cordis events & services catalog](../cordis-catalog/events-and-services.md) (the wiring a plugin listens to and calls) and [core-data-structures/](../core-data-structures/core.md) (the types those signatures move) — this page is the *tools* the agent is offered.', + 'Every model-facing tool a shipped plugin contributes to `ctx.tools`: the `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the [cordis events & services catalog](../cordis-catalog/events-and-services.md) (the wiring a plugin listens to and calls) and [core-data-structures/](../core-data-structures/core.md) (the types those signatures move) — this page is the *tools* the agent is offered.', '', 'This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any package is missing from the generator\'s boot manifest, so a new tool cannot be silently undocumented. See [the tool-schema-catalog RFC](../rfc/implemented/process/2026-07-02-tool-schema-catalog.md).', '', - 'Scope: shipped product tools under `packages/*/tool-*`. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog\'s packages-only scope.', + 'Scope: shipped product tools under `packages/*/tool-*`, each booted with its DEFAULT config. The registered tool NAME can be a load-time config (e.g. `tool-subagent`\'s `toolName`), so a deployment may surface a package under a different or additional name — a per-package note records those shipped aliases where they exist. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog\'s packages-only scope.', '', ] for (const entry of catalog) { lines.push(`## \`${entry.pkg}\``, '') for (const schema of entry.schemas) lines.push(...renderTool(schema, entry.source)) + if (entry.note) lines.push(entry.note, '') } return lines.join('\n') } From 6c88b380ea8a4a65862000f782bd8ce00b77e184 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:30:04 +0800 Subject: [PATCH 29/75] docs(tool-catalog): register dsh-tool-fs in the boot manifest Master's new tool-schema catalog boots every tool-* package and hard-errors if one is absent from the manifest. Add the dsh-tool-fs entry (boot dsh-fs-local to satisfy the injected `fs`, harvest read/write/edit), note that dsh-fs-policy adds the read-before-write/edit gate without changing schemas, and regenerate docs/tool-catalog/tools.md. Update the collectToolCatalog test's expected tool set to include the fs tools. --- docs/tool-catalog/tools.md | 94 +++++++++++++++++++ .../core/tools/tests/gen-tool-catalog.spec.ts | 2 +- scripts/gen-tool-catalog.ts | 16 ++++ 3 files changed, 111 insertions(+), 1 deletion(-) diff --git a/docs/tool-catalog/tools.md b/docs/tool-catalog/tools.md index 97400f8223..9f00a66d75 100644 --- a/docs/tool-catalog/tools.md +++ b/docs/tool-catalog/tools.md @@ -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` diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index eba74c833d..591e57b721 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -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) { diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index be318f753c..0efc4354b7 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -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', From b06f1bb60d7aea7448d72f59a6953577c5f2001b Mon Sep 17 00:00:00 2001 From: "tn.shen" Date: Thu, 2 Jul 2026 12:55:01 +0800 Subject: [PATCH 30/75] fix(acp): enable filesystem tools in demo --- examples/acp-agent/README.md | 4 +-- examples/acp-agent/cordis.snapshot.yml | 28 +++++++++++++++------ examples/acp-agent/cordis.yml | 35 +++++++++++++++++++------- 3 files changed, 49 insertions(+), 18 deletions(-) diff --git a/examples/acp-agent/README.md b/examples/acp-agent/README.md index 58ba92cd0e..2ca4de690b 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -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; the agent's bash tools run there (see the per-session `cwd` note in `packages/ui/acp`). The filesystem tools in this demo use the local filesystem backend and resolve relative paths from the server launch directory, so launch the server from the harness repo with `pnpm --dir …` when using `read`/`write`/`edit` against this checkout. ## Snapshot tests (record-once / replay-deterministic) diff --git a/examples/acp-agent/cordis.snapshot.yml b/examples/acp-agent/cordis.snapshot.yml index f03cc49223..8a6f17d012 100644 --- a/examples/acp-agent/cordis.snapshot.yml +++ b/examples/acp-agent/cordis.snapshot.yml @@ -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' diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index 31d4d5429a..35c3176f34 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -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' From 743eb9ea09a3cbc879d34a33c8477b20e5af8ea5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:29:29 +0800 Subject: [PATCH 31/75] fix(fs): resolve paths against the caller's session cwd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ACP bridge gives each session its own workspace (SessionHeader.cwd), and dsh-tool-bash already resolves a bash workdir against it. But ctx.fs.resolve(path) took no caller context and dsh-fs-local resolved every relative path against a fixed config.cwd (process.cwd() at plugin load) — so in the ACP demo `write foo.txt` and `bash cat foo.txt` hit different directories the moment an editor opens any project other than the server's launch dir. Thread the session cwd into resolution, mirroring dsh-tool-bash: widen FileSystem.resolve to resolve(path, opts?: { cwd?: string }); dsh-fs-local bases a relative path on opts.cwd ?? config.cwd (absolute paths ignore it); the read/write/edit tools derive it via a shared sessionCwd(exec) helper (exec.agent?.session.header.cwd). The provider stays free of dsh-agent/dsh-session — the tool projects exec → cwd and hands over a plain string, per the explicit-at-seams convention. Backward compatible (the arg is optional). Tests: fs-local resolve(path,{cwd}) bases relative on the passed cwd / ignores it for absolute; tool integration writes/reads/edits in a session cwd != config.cwd and verifies the file on disk (proven to fail on the pre-fix no-cwd path). Fakes that stood in a bare {session:{}} now carry a header so sessionCwd doesn't throw. RFC in docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md. --- docs/cordis-catalog/events-and-services.md | 2 +- docs/rfc/README.md | 1 + .../2026-07-02-fs-per-session-cwd.md | 30 ++++++++++ packages/fs/fs-local/README.md | 2 +- packages/fs/fs-local/src/index.ts | 4 +- packages/fs/fs-local/tests/filesystem.spec.ts | 24 ++++++++ packages/fs/fs/README.md | 2 +- packages/fs/fs/src/index.ts | 10 +++- packages/fs/tool-fs/README.md | 4 +- packages/fs/tool-fs/src/edit.ts | 4 +- packages/fs/tool-fs/src/read.ts | 4 +- packages/fs/tool-fs/src/session-cwd.ts | 24 ++++++++ packages/fs/tool-fs/src/write.ts | 4 +- packages/fs/tool-fs/tests/integration.spec.ts | 55 ++++++++++++++++++- packages/fs/tool-fs/tests/tools.spec.ts | 8 +-- 15 files changed, 161 insertions(+), 17 deletions(-) create mode 100644 docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md create mode 100644 packages/fs/tool-fs/src/session-cwd.ts diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 5a0f203e23..c0fb5bd86b 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -444,7 +444,7 @@ Semantics every backend must honor: - 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): Promise +abstract resolve(path: string, opts?: { cwd?: string }): Promise abstract stat(target: FsTarget, signal?: AbortSignal): Promise abstract readText(target: FsTarget, signal?: AbortSignal): Promise abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 66794ed471..59516c24c5 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -124,6 +124,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [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 diff --git a/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md b/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md new file mode 100644 index 0000000000..5669ecd98f --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md @@ -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`. `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. diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index ca5a7bb09e..6fb75276e0 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -12,7 +12,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) ## Behavior -- **`resolve(path)`** — relative paths resolve from `config.cwd` (default `process.cwd()`). 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. +- **`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`). diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index 7a65148a99..97dda3c4dd 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -97,8 +97,8 @@ export class LocalFileSystem extends FileSystem { } } - override async resolve(path: string): Promise { - const local = await resolveLocalTarget(this.config.cwd, path) + override async resolve(path: string, opts?: { cwd?: string }): Promise { + const local = await resolveLocalTarget(opts?.cwd ?? this.config.cwd, path) return { inputPath: path, targetKey: local.targetKey, displayPath: local.displayPath } } diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts index d149e741b0..0b96350c2d 100644 --- a/packages/fs/fs-local/tests/filesystem.spec.ts +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -51,6 +51,30 @@ describe('registration', () => { }) }) +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') diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md index 917b660cfa..3cea538ff7 100644 --- a/packages/fs/fs/README.md +++ b/packages/fs/fs/README.md @@ -19,7 +19,7 @@ A backend subclasses `FileSystem` and implements six primitives. | Member | Semantics | |---|---| -| `resolve(path)` | Resolve a path into a stable `FsTarget` (`inputPath`, opaque `targetKey`, `displayPath`). Async — a remote backend may need I/O. The same file via different paths must yield the same `targetKey`. | +| `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). | diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts index e4aef709d0..e3a8a36b66 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -165,8 +165,16 @@ export abstract class FileSystem extends Service { * 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): Promise + abstract resolve(path: string, opts?: { cwd?: string }): Promise /** Return target metadata, or `undefined` when the target does not exist. */ abstract stat(target: FsTarget, signal?: AbortSignal): Promise diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index bc38a5ee64..dacef45590 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -23,9 +23,9 @@ Field names are snake_case to match Claude Code and existing harness tool schema ## 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()`, then: +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 a contained `fs/observed`. (1 stat.) +- **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.) diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index efe2e5f53b..c24692b012 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -18,6 +18,7 @@ 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 { @@ -66,7 +67,8 @@ export function applyEditTool(ctx: Context): void { }, async execute(args, exec): Promise { const input = parseEditArgs(args) - const target = await ctx.fs.resolve(input.filePath) + 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. diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index b7e0d43772..5befd8be92 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -20,6 +20,7 @@ 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 @@ -68,7 +69,8 @@ export function applyReadTool(ctx: Context): void { }, async execute(args, exec): Promise { const input = parseReadArgs(args) - const target = await ctx.fs.resolve(input.filePath) + 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 diff --git a/packages/fs/tool-fs/src/session-cwd.ts b/packages/fs/tool-fs/src/session-cwd.ts new file mode 100644 index 0000000000..b7774fb201 --- /dev/null +++ b/packages/fs/tool-fs/src/session-cwd.ts @@ -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 +} diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index ed9143f32a..8cb91ececd 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -17,6 +17,7 @@ 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 } { @@ -51,7 +52,8 @@ export function applyWriteTool(ctx: Context): void { }, async execute(args, exec): Promise { const input = parseWriteArgs(args) - const target = await ctx.fs.resolve(input.filePath) + 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) diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index 238909ff98..e8019234f0 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -28,8 +28,10 @@ import * as ToolFs from '@deepseek-ai/dsh-tool-fs' let dir: string let ctx: Context let fiber: Awaited> -// A stable session object stands in for an agent session (the file-state owner). -const session = {} +// 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) { @@ -287,3 +289,52 @@ describe('bare provider (no dsh-fs-policy)', () => { 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') + }) +}) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 4a161a272b..fb76f9215f 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -174,7 +174,7 @@ describe('read tool', () => { it('records observed state so a follow-up edit by the same session is authorized', async () => { const { ctx, fs } = await setup() - const session = {} + 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 }) @@ -259,7 +259,7 @@ describe('formatReadOutput footer variants', () => { 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: {} }) + 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' }]) @@ -284,7 +284,7 @@ describe('write tool', () => { describe('edit tool', () => { it('formats a single-replacement success after a read', async () => { const { ctx, fs } = await setup() - const session = {} + 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 }) @@ -315,7 +315,7 @@ describe('edit tool', () => { 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: {} }) + 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' }) }) From bd7fb31ae36f87d9922749687a1d549889a6cca2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:36:17 +0800 Subject: [PATCH 32/75] feat(tool-fs): editor-facing presentation for read/write/edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fs tools rendered as generic cards (title = tool name, raw file content) in an ACP editor. Give them tool-owned presentation like bash/subagent have: - read → title "Read ", kind read, offset/limit as rawInput - write → title "Write ", kind edit - edit → title "Edit ", kind edit, a clipped old→new rawInput summary Add a provider-neutral `locations: { path, line? }[]` to ToolCallPresentation — the files a call reads/modifies — so a capable editor can follow along / jump to the file (read carries its offset as the line). The ACP bridge forwards it onto the wire `tool_call` (ResolvedCallPresentation + call() + the tool_call build in streamSessionEventUpdate). This flips the `locations` cell in the ACP feature matrix to supported. The SDK already carries `tool_call.locations` (ToolCallLocation `{ path, line? }`), so no ACP types leak into dsh-tools. presentResult is intentionally omitted: it only receives `{ content, isError }`, not the write/edit outcome, so titling by create-vs-overwrite or replacement count would mean parsing the model-facing text — the static title stays. Tests: pure presentCall assertions for all three tools incl. locations and the edit rawInput clip; a bridge test drives the REAL fs tools through ToolPresenter and asserts locations reaches the wire tool_call (proven to fail without the forwarding line). New withFs harness option + dsh-fs devDeps on dsh-acp. --- docs/cordis-catalog/events-and-services.md | 2 +- docs/core-data-structures/tools.md | 2 +- packages/core/tools/README.md | 2 +- packages/core/tools/src/index.ts | 10 +++++ packages/fs/tool-fs/src/edit.ts | 13 +++++++ packages/fs/tool-fs/src/read.ts | 15 ++++++++ packages/fs/tool-fs/src/write.ts | 7 ++++ packages/fs/tool-fs/tests/tools.spec.ts | 40 ++++++++++++++++++++ packages/ui/acp/README.md | 4 +- packages/ui/acp/acp-feature-support.md | 4 +- packages/ui/acp/package.json | 3 ++ packages/ui/acp/src/index.ts | 4 ++ packages/ui/acp/tests/harness.ts | 17 +++++++++ packages/ui/acp/tests/stream-update.spec.ts | 41 ++++++++++++++++++++- pnpm-lock.yaml | 9 +++++ 15 files changed, 164 insertions(+), 9 deletions(-) diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index c0fb5bd86b..22b6dc791b 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -547,7 +547,7 @@ async execute(exec: ToolExecution): Promise 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) diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index f255fdbc32..1c1744c45c 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -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. diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 102645ad82..01a2e09d3b 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -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` 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. diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 5a17aa2b0c..c2f324e87b 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -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 diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index c24692b012..1fb76ffd3c 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -83,5 +83,18 @@ export function applyEditTool(ctx: Context): void { 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 }], + } + }, })) } diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index 5befd8be92..17fa7aa7ab 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -101,5 +101,20 @@ export function applyReadTool(ctx: Context): void { 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 } : {}, + } + }, })) } diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index 8cb91ececd..97098bd78d 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -62,5 +62,12 @@ export function applyWriteTool(ctx: Context): void { 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 }] } + }, })) } diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index fb76f9215f..364d944b02 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -320,3 +320,43 @@ describe('edit tool', () => { 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')}`) + }) +}) diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index ad50383542..8d0444de70 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -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 ` 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. diff --git a/packages/ui/acp/acp-feature-support.md b/packages/ui/acp/acp-feature-support.md index 429c862b27..c39b518aba 100644 --- a/packages/ui/acp/acp-feature-support.md +++ b/packages/ui/acp/acp-feature-support.md @@ -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. diff --git a/packages/ui/acp/package.json b/packages/ui/acp/package.json index 52080be092..b4c27f25e2 100644 --- a/packages/ui/acp/package.json +++ b/packages/ui/acp/package.json @@ -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" diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 754b1750be..255ca9c990 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -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 } : {}, } } diff --git a/packages/ui/acp/tests/harness.ts b/packages/ui/acp/tests/harness.ts index 9077d106d0..664ffbea5a 100644 --- a/packages/ui/acp/tests/harness.ts +++ b/packages/ui/acp/tests/harness.ts @@ -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 { 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 diff --git a/packages/ui/acp/tests/stream-update.spec.ts b/packages/ui/acp/tests/stream-update.spec.ts index 89d68fd1c0..9e1578c8c1 100644 --- a/packages/ui/acp/tests/stream-update.spec.ts +++ b/packages/ui/acp/tests/stream-update.spec.ts @@ -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 { +function registryOf(...tools: ToolDefinition[]): Pick { 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)', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 58ea6d9bfe..380cfb3b4e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -739,6 +739,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 @@ -757,6 +763,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 From a334395f0c004f9c0999ac0b0676244c3dbac5ce Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:46:59 +0800 Subject: [PATCH 33/75] feat(coding-agent): wire the filesystem tools into the demo Load dsh-fs-local + dsh-fs-policy + dsh-tool-fs after tool-todo (mirroring the acp-agent wiring), and steer the system prompt to prefer read/write/edit for file ops with bash for shell/tests/search. Update the welcome line and the FIXME(config-comments) bash note. Doc sweep now that both demos ship the fs tools and the seam resolves per-session cwd: architecture.md and the event-gate RFC no longer say the demos do file ops through bash / that no config wires the tools; the coding-agent + examples READMEs and the AGENTS.md layout blurb list the fs tools; the acp-agent README drops the launch-dir caveat (per-session cwd now works, so the server can launch anywhere). (stdio-agent is single-session, so fs-local's cwd = process.cwd() is the workspace. Keyless boot smoke is blocked locally by an unrelated inotify watcher-limit ENOSPC that also hits demo:echo; the config parses and the same fs stack boots green in the acp-agent snapshot tier.) --- AGENTS.md | 9 +++--- docs/architecture.md | 2 +- .../2026-06-26-file-context-as-event-gate.md | 2 +- examples/README.md | 2 +- examples/acp-agent/README.md | 2 +- examples/coding-agent/README.md | 5 +-- examples/coding-agent/cordis.yml | 31 ++++++++++++++----- 7 files changed, 35 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d53750222e..4bdeb727fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index eddb1ea235..1f8901b7f8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -78,7 +78,7 @@ 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 fs tools are not wired into any default/example config yet (the demo agents do file ops through bash); a deployment that loads `dsh-tool-fs` is expected to also load `dsh-fs-policy` so the default behavior is read-before-write/edit. See [the fs-policy event-gate RFC](rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md). +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. diff --git a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md index 3d5e67ce20..9f7b8a48c6 100644 --- a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md +++ b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md @@ -29,7 +29,7 @@ provider seam dsh-fs ctx.fs: text IO + ATOMIC mutation primitives who 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 (no default/example config wires the fs tools yet — the demo agents do file ops through bash). 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. +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`. diff --git a/examples/README.md b/examples/README.md index 887bc18beb..a95814bbbd 100644 --- a/examples/README.md +++ b/examples/README.md @@ -15,7 +15,7 @@ Run with: `pnpm run demo:echo`. When prompted, type "echo " 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. diff --git a/examples/acp-agent/README.md b/examples/acp-agent/README.md index 2ca4de690b..e9a38f2313 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -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`). The filesystem tools in this demo use the local filesystem backend and resolve relative paths from the server launch directory, so launch the server from the harness repo with `pnpm --dir …` when using `read`/`write`/`edit` against this checkout. +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) diff --git a/examples/coding-agent/README.md b/examples/coding-agent/README.md index 6a156dcc48..d8b764483d 100644 --- a/examples/coding-agent/README.md +++ b/examples/coding-agent/README.md @@ -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) diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index bc3c3f2ff7..625fa0c9b9 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -30,7 +30,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: @@ -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' From 94cbec816259db02b28d7940ff528dfcb4fc4216 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:09:09 +0800 Subject: [PATCH 34/75] test(acp): snapshot scenarios for the filesystem tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five recorded ACP snapshot scenarios exercising read/write/edit end-to-end through the real acp-agent subprocess, replayed keyless in CI: - fs-read — read a seeded file (read tool + presentation + observed-state) - fs-write — create a file (write, no prior version guard) - fs-edit — read then literal-replace (read-before-edit authorization) - fs-write-overwrite — read then rewrite (replaceIfVersion after a read) - fs-read-window — read lines 5-8 with offset/limit (windowing + the offset surfaced as the tool_call location line) The goldens confirm the tools render with their new presentation — Read/Write/ Edit titles, read/edit kinds, and `locations` (fs-read-window carries `{path, line:5}`) — and that the prompts steered the model to the fs tools, not bash (zero bash calls in any golden). Recorded against the real API, filtered to the new scenarios so no existing fixture churned. --- examples/acp-agent/tests/acp.snapshot.ts | 5 + .../tests/snapshots/fs-edit/input.json | 7 + .../tests/snapshots/fs-edit/session.jsonl | 147 ++++++++++++++++++ .../snapshots/fs-edit/stdout.golden.jsonl | 76 +++++++++ .../snapshots/fs-edit/workspace/config.txt | 2 + .../tests/snapshots/fs-read-window/input.json | 7 + .../snapshots/fs-read-window/session.jsonl | 102 ++++++++++++ .../fs-read-window/stdout.golden.jsonl | 59 +++++++ .../fs-read-window/workspace/big.txt | 10 ++ .../tests/snapshots/fs-read/input.json | 7 + .../tests/snapshots/fs-read/session.jsonl | 93 +++++++++++ .../snapshots/fs-read/stdout.golden.jsonl | 61 ++++++++ .../snapshots/fs-read/workspace/greeting.txt | 1 + .../snapshots/fs-write-overwrite/input.json | 7 + .../fs-write-overwrite/session.jsonl | 132 ++++++++++++++++ .../fs-write-overwrite/stdout.golden.jsonl | 71 +++++++++ .../fs-write-overwrite/workspace/data.txt | 1 + .../tests/snapshots/fs-write/input.json | 7 + .../tests/snapshots/fs-write/session.jsonl | 95 +++++++++++ .../snapshots/fs-write/stdout.golden.jsonl | 55 +++++++ 20 files changed, 945 insertions(+) create mode 100644 examples/acp-agent/tests/snapshots/fs-edit/input.json create mode 100644 examples/acp-agent/tests/snapshots/fs-edit/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl create mode 100644 examples/acp-agent/tests/snapshots/fs-edit/workspace/config.txt create mode 100644 examples/acp-agent/tests/snapshots/fs-read-window/input.json create mode 100644 examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl create mode 100644 examples/acp-agent/tests/snapshots/fs-read-window/workspace/big.txt create mode 100644 examples/acp-agent/tests/snapshots/fs-read/input.json create mode 100644 examples/acp-agent/tests/snapshots/fs-read/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl create mode 100644 examples/acp-agent/tests/snapshots/fs-read/workspace/greeting.txt create mode 100644 examples/acp-agent/tests/snapshots/fs-write-overwrite/input.json create mode 100644 examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl create mode 100644 examples/acp-agent/tests/snapshots/fs-write-overwrite/workspace/data.txt create mode 100644 examples/acp-agent/tests/snapshots/fs-write/input.json create mode 100644 examples/acp-agent/tests/snapshots/fs-write/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 94f18e64d3..7d19f44bb5 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -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 }, diff --git a/examples/acp-agent/tests/snapshots/fs-edit/input.json b/examples/acp-agent/tests/snapshots/fs-edit/input.json new file mode 100644 index 0000000000..1455aa373c --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-edit/input.json @@ -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." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl new file mode 100644 index 0000000000..0e6b253480 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl @@ -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":"/tmp/acp-snap-cwd-yl8qhJ/config.txt\nfile\n\n1: mode=DEBUG\n2: level=info\n\n(End of file - total 2 lines)\n"}],"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"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl new file mode 100644 index 0000000000..8f5d02632d --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl @@ -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":"{{cwd}}/config.txt\nfile\n\n1: mode=DEBUG\n2: level=info\n\n(End of file - total 2 lines)\n"}}]}}} +{"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"}} diff --git a/examples/acp-agent/tests/snapshots/fs-edit/workspace/config.txt b/examples/acp-agent/tests/snapshots/fs-edit/workspace/config.txt new file mode 100644 index 0000000000..267876a5af --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-edit/workspace/config.txt @@ -0,0 +1,2 @@ +mode=DEBUG +level=info diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/input.json b/examples/acp-agent/tests/snapshots/fs-read-window/input.json new file mode 100644 index 0000000000..a2f42ac808 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-read-window/input.json @@ -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." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl b/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl new file mode 100644 index 0000000000..f9433a8725 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl @@ -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":"/tmp/acp-snap-cwd-2yWjlu/big.txt\nfile\n\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"}],"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"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl new file mode 100644 index 0000000000..577f8adaa6 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl @@ -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":"{{cwd}}/big.txt\nfile\n\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"}}]}}} +{"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"}} diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/workspace/big.txt b/examples/acp-agent/tests/snapshots/fs-read-window/workspace/big.txt new file mode 100644 index 0000000000..ae121a6980 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-read-window/workspace/big.txt @@ -0,0 +1,10 @@ +line one +line two +line three +line four +line five +line six +line seven +line eight +line nine +line ten diff --git a/examples/acp-agent/tests/snapshots/fs-read/input.json b/examples/acp-agent/tests/snapshots/fs-read/input.json new file mode 100644 index 0000000000..c8097b7246 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-read/input.json @@ -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." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/fs-read/session.jsonl b/examples/acp-agent/tests/snapshots/fs-read/session.jsonl new file mode 100644 index 0000000000..c8e5d936d8 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-read/session.jsonl @@ -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":"/tmp/acp-snap-cwd-WE9Cx4/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}],"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"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl new file mode 100644 index 0000000000..1d19e735e4 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl @@ -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":"{{cwd}}/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}}]}}} +{"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"}} diff --git a/examples/acp-agent/tests/snapshots/fs-read/workspace/greeting.txt b/examples/acp-agent/tests/snapshots/fs-read/workspace/greeting.txt new file mode 100644 index 0000000000..ce01362503 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-read/workspace/greeting.txt @@ -0,0 +1 @@ +hello diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/input.json b/examples/acp-agent/tests/snapshots/fs-write-overwrite/input.json new file mode 100644 index 0000000000..585ec3ebed --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/input.json @@ -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." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl new file mode 100644 index 0000000000..ac97558243 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl @@ -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":"/tmp/acp-snap-cwd-X0UUW6/data.txt\nfile\n\n1: original contents\n\n(End of file - total 1 lines)\n"}],"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":"/tmp/acp-snap-cwd-X0UUW6/data.txt\nfile\n\nUpdated file\n"}],"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"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl new file mode 100644 index 0000000000..1d234c2dd4 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl @@ -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":"{{cwd}}/data.txt\nfile\n\n1: original contents\n\n(End of file - total 1 lines)\n"}}]}}} +{"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":"{{cwd}}/data.txt\nfile\n\nUpdated file\n"}}]}}} +{"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"}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/workspace/data.txt b/examples/acp-agent/tests/snapshots/fs-write-overwrite/workspace/data.txt new file mode 100644 index 0000000000..b2745f6f48 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/workspace/data.txt @@ -0,0 +1 @@ +original contents diff --git a/examples/acp-agent/tests/snapshots/fs-write/input.json b/examples/acp-agent/tests/snapshots/fs-write/input.json new file mode 100644 index 0000000000..1512e93735 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-write/input.json @@ -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." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl new file mode 100644 index 0000000000..533ec83084 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl @@ -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":"/tmp/acp-snap-cwd-v8qbp7/notes.txt\nfile\n\nCreated file\n"}],"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"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl new file mode 100644 index 0000000000..daa26245bb --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl @@ -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":"{{cwd}}/notes.txt\nfile\n\nCreated file\n"}}]}}} +{"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"}} From b3f8b4c9c69185363204e9e4871222718ce5e6e6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:38:06 +0800 Subject: [PATCH 35/75] test(fs): close abort/concurrency/observed coverage gaps + with-key e2e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behavioral gaps from the coverage audit (line coverage was already 100%; these close BEHAVIOR gaps): - fs-local: service-level writeText/editText pre-abort → FS_ABORTED (file unchanged); concurrent guarded-write race and mixed write-vs-edit race (one wins, one FS_STALE_VERSION, locks released); edit→edit version refresh at the provider; the replaceIfVersion post-write version matches a fresh stat. fsio: a mid-stream abort → FS_ABORTED (previously only pre-abort was covered). - fs-policy: the agent-without-session owner rung ({agent:{}} → no owner → createIfAbsent / FS_NOT_OBSERVED); fs/write-intent first-wins (symmetric to the existing edit-intent test). - tool-fs: abort-through-the-tool for read/write/edit (isError FS_ABORTED, file unchanged); a deterministic tool-tier concurrent-edit race via a shared read; the throwing-fs/observed contract (a throwing listener surfaces as isError but the mutation already hit disk); the replace_all edit message; parseReadArgs rejects fractional/NaN offset and zero/negative limit. - dsh-fs: FsError chains a cause through ErrorOptions. New with-key e2e (packages/fs/tool-fs/tests/fs-tools.e2e.ts, self-skips without DEEPSEEK_API_KEY): a real model drives the real read/write/edit tools to create → read → edit a file, verified on disk; a second test proves a relative path resolves against the per-session cwd (factory meta.cwd) not config.cwd. Booted via a plain tests/harness.ts. Added dsh-agent-loop + dsh-llm-deepseek devDeps. --- packages/fs/fs-local/tests/filesystem.spec.ts | 70 ++++++++++++++++ packages/fs/fs-local/tests/fsio.spec.ts | 16 ++++ packages/fs/fs-policy/tests/policy.spec.ts | 23 +++++ packages/fs/fs/tests/service.spec.ts | 7 ++ packages/fs/tool-fs/package.json | 2 + packages/fs/tool-fs/tests/fs-tools.e2e.ts | 84 +++++++++++++++++++ packages/fs/tool-fs/tests/harness.ts | 47 +++++++++++ packages/fs/tool-fs/tests/integration.spec.ts | 70 ++++++++++++++++ packages/fs/tool-fs/tests/tools.spec.ts | 23 +++++ pnpm-lock.yaml | 6 ++ 10 files changed, 348 insertions(+) create mode 100644 packages/fs/tool-fs/tests/fs-tools.e2e.ts create mode 100644 packages/fs/tool-fs/tests/harness.ts diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts index 0b96350c2d..03c751a538 100644 --- a/packages/fs/fs-local/tests/filesystem.spec.ts +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -196,6 +196,41 @@ describe('writeText', () => { .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', () => { @@ -297,6 +332,41 @@ describe('editText', () => { 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', () => { diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index 6f28d54402..0b16e9e2c8 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -222,6 +222,22 @@ describe('streamWholeText', () => { 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 => { + 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', () => { diff --git a/packages/fs/fs-policy/tests/policy.spec.ts b/packages/fs/fs-policy/tests/policy.spec.ts index 02bb934cfd..2ea61ffcd1 100644 --- a/packages/fs/fs-policy/tests/policy.spec.ts +++ b/packages/fs/fs-policy/tests/policy.spec.ts @@ -64,6 +64,13 @@ describe('write-intent decision', () => { 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({}) @@ -83,6 +90,11 @@ describe('edit-intent decision', () => { 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({}) @@ -166,6 +178,17 @@ describe('single-slot, first-wins', () => { 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)', () => { diff --git a/packages/fs/fs/tests/service.spec.ts b/packages/fs/fs/tests/service.spec.ts index 789ed7fdac..a0032afdee 100644 --- a/packages/fs/fs/tests/service.spec.ts +++ b/packages/fs/fs/tests/service.spec.ts @@ -108,4 +108,11 @@ describe('FsError', () => { 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') + }) }) diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index f92966f515..080b9a8f78 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -30,10 +30,12 @@ }, "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:^", diff --git a/packages/fs/tool-fs/tests/fs-tools.e2e.ts b/packages/fs/tool-fs/tests/fs-tools.e2e.ts new file mode 100644 index 0000000000..5e13e229fb --- /dev/null +++ b/packages/fs/tool-fs/tests/fs-tools.e2e.ts @@ -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) +}) diff --git a/packages/fs/tool-fs/tests/harness.ts b/packages/fs/tool-fs/tests/harness.ts new file mode 100644 index 0000000000..0a492c509e --- /dev/null +++ b/packages/fs/tool-fs/tests/harness.ts @@ -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 { + 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 { + return new Promise((resolve) => { + const dispose = ctx.on('agent/status', (subject, status) => { + if (subject === agent && status === 'idle') { + dispose() + resolve() + } + }) + }) +} diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index e8019234f0..c0973197eb 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -338,3 +338,73 @@ describe('per-session cwd', () => { 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') + }) +}) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 364d944b02..12f373753e 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -158,6 +158,20 @@ describe('read tool', () => { 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 }) @@ -291,6 +305,15 @@ describe('edit tool', () => { 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' }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 380cfb3b4e..d87855145f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -323,6 +323,9 @@ importers: '@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 @@ -335,6 +338,9 @@ importers: '@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 From 3aa6b3c77a92eaada8f76366816047b35697f3eb Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:47:20 +0800 Subject: [PATCH 36/75] chore(knip): register tool-fs e2e tests as knip entry points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new packages/fs/tool-fs/tests/*.e2e.ts (+ its harness.ts) need an explicit knip workspace entry — mirroring the other e2e-bearing packages — so knip follows them and does not flag the files or their dsh-agent-loop/dsh-llm-deepseek devDeps as unused. --- knip.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/knip.json b/knip.json index 67d99a861d..9e5829317d 100644 --- a/knip.json +++ b/knip.json @@ -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"] } } } From 490fe002a12af2c00766f8bde3c80a74d1e88c4e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:41:02 +0800 Subject: [PATCH 37/75] test(acp): snapshot the fs-policy rejection card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fs-policy gate throws FS_NOT_OBSERVED when the model edits a file it never read; that rejection surfaces as a failed tool_call_update, but no snapshot pinned it — a regression that dropped or mis-rendered the failed card would pass every gate. Record a scenario that edits a seeded file without a preceding read: the edit is vetoed, the file stays unchanged on disk, and the transcript shows the pending edit card followed by a status:'failed' update carrying the policy error. --- examples/acp-agent/tests/acp.snapshot.ts | 1 + .../snapshots/fs-policy-reject/input.json | 7 + .../snapshots/fs-policy-reject/session.jsonl | 185 ++++++++++++++++++ .../fs-policy-reject/stdout.golden.jsonl | 136 +++++++++++++ .../fs-policy-reject/workspace/settings.txt | 1 + 5 files changed, 330 insertions(+) create mode 100644 examples/acp-agent/tests/snapshots/fs-policy-reject/input.json create mode 100644 examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl create mode 100644 examples/acp-agent/tests/snapshots/fs-policy-reject/workspace/settings.txt diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 7d19f44bb5..3a27ad25d0 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -59,6 +59,7 @@ const SCENARIOS: Scenario[] = [ { name: 'fs-edit', hasModelTurn: true, recorded: true }, { name: 'fs-write-overwrite', hasModelTurn: true, recorded: true }, { name: 'fs-read-window', hasModelTurn: true, recorded: true }, + { name: 'fs-policy-reject', hasModelTurn: true, recorded: true }, { name: 'multi-turn', hasModelTurn: true, recorded: true }, { name: 'error-finish', hasModelTurn: true, recorded: false }, { name: 'cancel', hasModelTurn: true, recorded: false }, diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/input.json b/examples/acp-agent/tests/snapshots/fs-policy-reject/input.json new file mode 100644 index 0000000000..c44d44c675 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Do NOT use the read tool. Immediately use the edit tool to replace the literal text blue with green in settings.txt in the current directory. Do not read the file first. After the tool result, reply with exactly the single word DONE." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl new file mode 100644 index 0000000000..259de258ea --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl @@ -0,0 +1,185 @@ +{"type":"session","version":0,"id":"0a0f03b5-ffbe-478d-af03-49d0dbb96355","createdAt":1783004466431,"cwd":"/tmp/acp-snap-cwd-N3q5XK"} +{"type":"turn/start","seq":0,"time":1783004466441,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783004466442,"data":{"content":[{"type":"text","text":"Do NOT use the read tool. Immediately use the edit tool to replace the literal text blue with green in settings.txt in the current directory. Do not read the file first. After the tool result, reply with exactly the single word DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783004466442,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1783004467337,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1783004467337,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1783004467468,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1783004467500,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1783004467500,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1783004467500,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1783004467500,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" directly"}}} +{"type":"assistant/chunk","seq":10,"time":1783004467538,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":11,"time":1783004467539,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":12,"time":1783004467567,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" edit"}}} +{"type":"assistant/chunk","seq":13,"time":1783004467567,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":14,"time":1783004467567,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":15,"time":1783004467568,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" replace"}}} +{"type":"assistant/chunk","seq":16,"time":1783004467568,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":17,"time":1783004467591,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"blue"}}} +{"type":"assistant/chunk","seq":18,"time":1783004467591,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":19,"time":1783004467591,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":20,"time":1783004467591,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":21,"time":1783004467591,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"green"}}} +{"type":"assistant/chunk","seq":22,"time":1783004467620,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":23,"time":1783004467621,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} +{"type":"assistant/chunk","seq":24,"time":1783004467621,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" settings"}}} +{"type":"assistant/chunk","seq":25,"time":1783004467621,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":26,"time":1783004467621,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" without"}}} +{"type":"assistant/chunk","seq":27,"time":1783004467621,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reading"}}} +{"type":"assistant/chunk","seq":28,"time":1783004467649,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":29,"time":1783004467680,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} +{"type":"assistant/chunk","seq":30,"time":1783004467680,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":31,"time":1783004467708,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":32,"time":1783004467709,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":33,"time":1783004467709,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":34,"time":1783004467709,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":35,"time":1783004467738,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":36,"time":1783004467768,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":37,"time":1783004467797,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":38,"time":1783004467797,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} +{"type":"assistant/chunk","seq":39,"time":1783004467827,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":40,"time":1783004467828,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":41,"time":1783004467887,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":42,"time":1783004467887,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":43,"time":1783004467915,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":44,"time":1783004467915,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":45,"time":1783004467915,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"file"}}} +{"type":"assistant/chunk","seq":46,"time":1783004467993,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":47,"time":1783004467994,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":48,"time":1783004467994,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":49,"time":1783004467994,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":50,"time":1783004467994,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"settings"}}} +{"type":"assistant/chunk","seq":51,"time":1783004467994,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":52,"time":1783004467994,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":53,"time":1783004468010,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":54,"time":1783004468010,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":55,"time":1783004468010,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"old"}}} +{"type":"assistant/chunk","seq":56,"time":1783004468041,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"_string"}}} +{"type":"assistant/chunk","seq":57,"time":1783004468041,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":58,"time":1783004468041,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":59,"time":1783004468041,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":60,"time":1783004468070,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"blue"}}} +{"type":"assistant/chunk","seq":61,"time":1783004468071,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":62,"time":1783004468099,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":63,"time":1783004468099,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":64,"time":1783004468100,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"new"}}} +{"type":"assistant/chunk","seq":65,"time":1783004468100,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"_string"}}} +{"type":"assistant/chunk","seq":66,"time":1783004468129,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":67,"time":1783004468129,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":68,"time":1783004468129,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":69,"time":1783004468129,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"green"}}} +{"type":"assistant/chunk","seq":70,"time":1783004468158,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":71,"time":1783004468158,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":72,"time":1783004468223,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to directly use the edit tool to replace \"blue\" with \"green\" in settings.txt without reading it first, and then reply with exactly the word DONE."}}}} +{"type":"assistant/chunk","seq":73,"time":1783004468223,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}}}} +{"type":"assistant/chunk","seq":74,"time":1783004468224,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":126,"outputTokens":116,"cacheReadTokens":2176,"reasoningTokens":37}}}} +{"type":"assistant/chunk","seq":75,"time":1783004468224,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":76,"time":1783004468226,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to directly use the edit tool to replace \"blue\" with \"green\" in settings.txt without reading it first, and then reply with exactly the word DONE."},{"type":"tool-call","id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"usage":{"inputTokens":126,"outputTokens":116,"cacheReadTokens":2176,"reasoningTokens":37}},"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,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75],"surfaceOp":"append"} +{"type":"tool/call","seq":77,"time":1783004468226,"data":{"turn":1,"step":1,"callId":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}} +{"type":"tool/result","seq":78,"time":1783004468230,"data":{"turn":1,"step":1,"callId":"call_00_3fuirRMnjFj7LWlJL1eU3690","content":[{"type":"text","text":"Error: edit requires reading \"/tmp/acp-snap-cwd-N3q5XK/settings.txt\" first"}],"isError":true,"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[77],"surfaceOp":"append"} +{"type":"step/end","seq":79,"time":1783004468231,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":80,"time":1783004468231,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":81,"time":1783004469325,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":82,"time":1783004469325,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":83,"time":1783004469483,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" edit"}}} +{"type":"assistant/chunk","seq":84,"time":1783004469507,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":85,"time":1783004469508,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" requires"}}} +{"type":"assistant/chunk","seq":86,"time":1783004469541,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reading"}}} +{"type":"assistant/chunk","seq":87,"time":1783004469542,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":88,"time":1783004469542,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":89,"time":1783004469568,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} +{"type":"assistant/chunk","seq":90,"time":1783004469568,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" according"}}} +{"type":"assistant/chunk","seq":91,"time":1783004469596,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":92,"time":1783004469597,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":93,"time":1783004469597,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" fs"}}} +{"type":"assistant/chunk","seq":94,"time":1783004469626,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"-p"}}} +{"type":"assistant/chunk","seq":95,"time":1783004469626,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"olicy"}}} +{"type":"assistant/chunk","seq":96,"time":1783004469626,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":97,"time":1783004469656,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":98,"time":1783004469687,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":99,"time":1783004469687,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instructed"}}} +{"type":"assistant/chunk","seq":100,"time":1783004469719,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":101,"time":1783004469719,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}} +{"type":"assistant/chunk","seq":102,"time":1783004469719,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":103,"time":1783004469719,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":104,"time":1783004469719,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":105,"time":1783004469748,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":106,"time":1783004469748,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":107,"time":1783004469749,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" but"}}} +{"type":"assistant/chunk","seq":108,"time":1783004469749,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":109,"time":1783004469777,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":110,"time":1783004469777,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" itself"}}} +{"type":"assistant/chunk","seq":111,"time":1783004469807,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" en"}}} +{"type":"assistant/chunk","seq":112,"time":1783004469807,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"forces"}}} +{"type":"assistant/chunk","seq":113,"time":1783004469839,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":114,"time":1783004469839,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" rule"}}} +{"type":"assistant/chunk","seq":115,"time":1783004469869,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":116,"time":1783004469869,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":117,"time":1783004469869,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} +{"type":"assistant/chunk","seq":118,"time":1783004469898,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" follow"}}} +{"type":"assistant/chunk","seq":119,"time":1783004469898,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":120,"time":1783004469899,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":121,"time":1783004469928,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}} +{"type":"assistant/chunk","seq":122,"time":1783004469929,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instruction"}}} +{"type":"assistant/chunk","seq":123,"time":1783004469929,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" but"}}} +{"type":"assistant/chunk","seq":124,"time":1783004469957,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":125,"time":1783004469987,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":126,"time":1783004469987,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" won"}}} +{"type":"assistant/chunk","seq":127,"time":1783004470017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'t"}}} +{"type":"assistant/chunk","seq":128,"time":1783004470017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" let"}}} +{"type":"assistant/chunk","seq":129,"time":1783004470017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":130,"time":1783004470017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":131,"time":1783004470047,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":132,"time":1783004470047,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" without"}}} +{"type":"assistant/chunk","seq":133,"time":1783004470076,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reading"}}} +{"type":"assistant/chunk","seq":134,"time":1783004470076,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":135,"time":1783004470076,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":136,"time":1783004470076,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":137,"time":1783004470076,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":138,"time":1783004470105,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} +{"type":"assistant/chunk","seq":139,"time":1783004470135,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":140,"time":1783004470165,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} +{"type":"assistant/chunk","seq":141,"time":1783004470193,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} +{"type":"assistant/chunk","seq":142,"time":1783004470224,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":143,"time":1783004470253,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" -"}}} +{"type":"assistant/chunk","seq":144,"time":1783004470254,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":145,"time":1783004470282,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":146,"time":1783004470282,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":147,"time":1783004470315,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" an"}}} +{"type":"assistant/chunk","seq":148,"time":1783004470315,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" error"}}} +{"type":"assistant/chunk","seq":149,"time":1783004470315,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":150,"time":1783004470316,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" But"}}} +{"type":"assistant/chunk","seq":151,"time":1783004470344,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":152,"time":1783004470345,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":153,"time":1783004470345,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" said"}}} +{"type":"assistant/chunk","seq":154,"time":1783004470345,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":155,"time":1783004470345,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":156,"time":1783004470373,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":157,"time":1783004470374,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":158,"time":1783004470374,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} +{"type":"assistant/chunk","seq":159,"time":1783004470404,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":160,"time":1783004470404,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" after"}}} +{"type":"assistant/chunk","seq":161,"time":1783004470404,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":162,"time":1783004470404,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":163,"time":1783004470433,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} +{"type":"assistant/chunk","seq":164,"time":1783004470433,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":165,"time":1783004470433,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":166,"time":1783004470433,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":167,"time":1783004470433,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":168,"time":1783004470462,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":169,"time":1783004470462,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} +{"type":"assistant/chunk","seq":170,"time":1783004470462,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":171,"time":1783004470462,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} +{"type":"assistant/chunk","seq":172,"time":1783004470491,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instructed"}}} +{"type":"assistant/chunk","seq":173,"time":1783004470492,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":174,"time":1783004470492,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":175,"time":1783004470492,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":176,"time":1783004470521,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":177,"time":1783004470522,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The edit tool requires reading the file first according to the fs-policy. The user instructed me not to read the file, but the tool itself enforces this rule. I should follow the user's instruction but the tool won't let me do it without reading. Let me just report the result as is - the tool returned an error. But the user said to reply with exactly DONE after the tool result. Let me just reply DONE as instructed."}}}} +{"type":"assistant/chunk","seq":178,"time":1783004470522,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":179,"time":1783004470522,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":278,"outputTokens":95,"cacheReadTokens":2176,"reasoningTokens":92}}}} +{"type":"assistant/chunk","seq":180,"time":1783004470522,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":181,"time":1783004470523,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The edit tool requires reading the file first according to the fs-policy. The user instructed me not to read the file, but the tool itself enforces this rule. I should follow the user's instruction but the tool won't let me do it without reading. Let me just report the result as is - the tool returned an error. But the user said to reply with exactly DONE after the tool result. Let me just reply DONE as instructed."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":278,"outputTokens":95,"cacheReadTokens":2176,"reasoningTokens":92}},"sourceEventSeqs":[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,111,112,113,114,115,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,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180],"surfaceOp":"append"} +{"type":"step/end","seq":182,"time":1783004470523,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":183,"time":1783004470523,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl new file mode 100644 index 0000000000..69c1bbd1c0 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl @@ -0,0 +1,136 @@ +{"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":" directly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} +{"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":" 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":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"blue"}}}} +{"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":"green"}}}} +{"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":" in"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" settings"}}}} +{"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":" without"}}}} +{"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":" it"}}}} +{"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":","}}}} +{"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":" the"}}}} +{"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":"tool_call","toolCallId":"call_00_3fuirRMnjFj7LWlJL1eU3690","title":"Edit settings.txt","kind":"edit","status":"in_progress","rawInput":"\"blue\" → \"green\"","locations":[{"path":"settings.txt"}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_3fuirRMnjFj7LWlJL1eU3690","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: edit requires reading \"{{cwd}}/settings.txt\" first"}}]}}} +{"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":" requires"}}}} +{"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":" file"}}}} +{"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":" according"}}}} +{"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":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" fs"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"-p"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"olicy"}}}} +{"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":" instructed"}}}} +{"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":" not"}}}} +{"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":" file"}}}} +{"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":" but"}}}} +{"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":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" itself"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" en"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"forces"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" this"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" rule"}}}} +{"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":" should"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" follow"}}}} +{"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":"'s"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instruction"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" but"}}}} +{"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":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" won"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'t"}}}} +{"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":" do"}}}} +{"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":" without"}}}} +{"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":"."}}}} +{"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":" just"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}} +{"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":" result"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" as"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} +{"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":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" an"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" error"}}}} +{"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":" But"}}}} +{"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":" said"}}}} +{"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":" 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":" after"}}}} +{"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":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" result"}}}} +{"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":" 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":" just"}}}} +{"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":" 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":" as"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instructed"}}}} +{"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"}} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/workspace/settings.txt b/examples/acp-agent/tests/snapshots/fs-policy-reject/workspace/settings.txt new file mode 100644 index 0000000000..5686506464 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/workspace/settings.txt @@ -0,0 +1 @@ +color: blue From 1a57d6705848e9f4ae191a3d637081b37df343d7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 3 Jul 2026 02:04:03 +0800 Subject: [PATCH 38/75] refactor(tools): tagged render-intent union for tool-call presentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the "bag of optional fields" tool-presentation types (ToolCallPresentation / ToolResultPresentation / ToolTerminal) with a card-tagged discriminated union — the standing FIXME(tool-presentation). A tool declares one render intent per call/result and the ACP bridge switches on `card`: ToolCallView = generic | terminal | diff ToolResultView = generic | terminal The `diff` card is new: fs write/edit now emit an ACP {type:'diff'} content block (an editor's inline diff), which the old shapes could not express. The bridge also relativizes a file card's title against the session cwd (mirroring claude-agent-acp's toDisplayPath) while keeping locations/diff paths raw, and derives the no-capability fenced console fallback from a terminal result's output. read gains the window-in-title (`Read foo.txt (5 - 8)`) and an always-set location line, matching the reference adapter field-for-field. Migrates all three producer families (tool-fs, tool-bash, tool-todo) and the sole consumer (the ACP bridge) together — the source does not compile piecewise. Adds snapshot coverage for the terminal _meta path (a new capability-advertising scenario) and re-records the fs goldens to show the diff cards. Applied-hunk (result-time, context-line) diffs need a new result/event shape and are a follow-up. RFC: docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md --- AGENTS.md | 2 +- docs/cookbook/adding-a-tool.md | 22 +- docs/cordis-catalog/events-and-services.md | 2 +- docs/core-data-structures/core.md | 2 +- docs/core-data-structures/tools.md | 31 +- docs/rfc/README.md | 1 + .../2026-07-02-tool-render-intent-union.md | 70 ++++ ...2026-06-20-core-data-structures-catalog.md | 2 +- examples/acp-agent/tests/acp.snapshot.ts | 1 + .../snapshots/fs-edit/stdout.golden.jsonl | 4 +- .../fs-policy-reject/stdout.golden.jsonl | 2 +- .../fs-read-window/stdout.golden.jsonl | 2 +- .../snapshots/fs-read/stdout.golden.jsonl | 2 +- .../snapshots/fs-terminal-card/input.json | 7 + .../snapshots/fs-terminal-card/session.jsonl | 97 +++++ .../fs-terminal-card/stdout.golden.jsonl | 51 +++ .../fs-write-overwrite/stdout.golden.jsonl | 4 +- .../snapshots/fs-write/stdout.golden.jsonl | 2 +- packages/bash/tool-bash/README.md | 2 +- packages/bash/tool-bash/src/index.ts | 58 +-- packages/bash/tool-bash/tests/tools.spec.ts | 62 ++- packages/core/tools/README.md | 23 +- packages/core/tools/src/index.ts | 232 +++++++----- packages/core/tools/src/schema.ts | 14 +- packages/core/tools/tests/tools.spec.ts | 16 +- packages/fs/tool-fs/src/edit.ts | 16 +- packages/fs/tool-fs/src/read.ts | 25 +- packages/fs/tool-fs/src/write.ts | 18 +- packages/fs/tool-fs/tests/tools.spec.ts | 40 +- packages/todo/tool-todo/src/index.ts | 2 +- .../todo/tool-todo/tests/tool-todo.spec.ts | 2 +- packages/ui/acp/README.md | 18 +- packages/ui/acp/acp-feature-support.md | 4 +- packages/ui/acp/src/index.ts | 358 ++++++++++-------- packages/ui/acp/tests/stream-update.spec.ts | 271 +++++++++++-- 35 files changed, 1015 insertions(+), 450 deletions(-) create mode 100644 docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md create mode 100644 examples/acp-agent/tests/snapshots/fs-terminal-card/input.json create mode 100644 examples/acp-agent/tests/snapshots/fs-terminal-card/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl diff --git a/AGENTS.md b/AGENTS.md index 4bdeb727fe..ec7a41f865 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -250,7 +250,7 @@ Dev/test/demo run **unbuilt** via tsx + the source `paths` map in the root `tsco - **Tests**: vitest, colocated under `packages///tests/*.spec.ts`. Every registry needs an HMR-safety test (dispose the contributing fiber, assert cleanup). **Excessive tests are welcome** — when in doubt, write the test; err on the side of covering edge cases, error paths, event ordering, and concurrency races even if they seem unlikely. Review findings get regression tests (see `packages/core/agent-loop/tests/review-fixes.spec.ts`). The same generosity applies to **real-API (with-key) e2e tests — inference is cheap here (we are DeepSeek), so do not ration them**: cover the agent's real flows (a real prompt that writes a file, multi-turn, tool use, cancellation) and run them frequently while developing, especially cheap **smoke tests** that boot the real example and check the world. A green mock/no-key suite proves the plumbing, not the product — the with-key smoke test is what catches "green units, broken product". See § Secrets / .env for the with-key policy and why self-skip is a CI accommodation, not a verdict that real-API tests are expensive. - **Prefer the REAL implementation over a mock/stand-in in tests.** When the genuine collaborator is available in the repo, wire it up instead of hand-rolling a fake — a test that registers an inline `defineTool({ name: 'bash', … })` to stand in for `dsh-tool-bash` proves the *bridge* moves bytes but not that the *shipping tool* renders the way the test asserts; the two drift and the test passes while the product is wrong. Mock only the genuinely expensive/non-deterministic boundary (the LLM adapter, the network, the clock) and keep everything downstream real: a bridge tool-call test runs the scripted mock MODEL but the REAL tool + REAL executor (e.g. `makeBridgeHarness({ withBash: true })` plugs `dsh-bash-local` + `dsh-tool-bash` and runs an actual `echo`), so it verifies the actual `presentCall`/`presentResult` an editor sees. This is the unit-test echo of "verify the world, not a synthetic stand-in" (see § Defensive patterns) — a fake you wrote will agree with whatever you assumed; the real thing won't. - **A change that affects the editor-facing transcript or end-to-end agent UX needs a snapshot test (or an explicit note in the PR why none applies).** The snapshot tier (`examples/*/tests/**/*.snapshot.ts`, `pnpm run test:snapshot`) boots the real example subprocess, replays a recorded session JSONL deterministically (keyless), and diffs the normalized stdout transcript + re-persisted session log against committed goldens — the full-transcript regression net that mock-level unit tests structurally cannot be (it is what catches a bridge-translation or loop-structure regression that leaves every unit green). When you change the ACP bridge, the agent loop's observable output, tool presentation, or anything an editor renders, add or update a scenario under `examples/acp-agent/tests/snapshots/` and re-record with `pnpm run test:snapshot:record`. Reviewing the golden diff is part of the review. The rule is scoped to transcript/UX-affecting changes — a pure internal refactor with no observable-output change does not need one, but say so. See [docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md](docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md). -- **Designing a new subsystem includes designing its test infrastructure — END TO END, up front, as part of the same plan.** When you introduce a new capability seam, a new agent-lifecycle shape, or anything that produces an observable transcript (a new tool family, a subagent transport, a new UI surface), the plan must name how it will be covered at EVERY tier it touches — unit, real-API e2e, AND the full-transcript snapshot tier — and, critically, must check that the existing test infrastructure can actually express that coverage. Do not assume a snapshot/e2e harness built for one shape (e.g. a single top-level ACP session) transparently supports a new shape (e.g. a parent agent driving nested child agents): verify it, and if it cannot, the harness extension is in-scope work to plan and schedule, not a detail to discover mid-implementation. This rule exists because a real plan under-scoped exactly this: the subagent backends were planned with unit + e2e coverage but the snapshot tier turned out to assume one session per process (`dsh-llm-replay`'s single positional cursor, single-file harvest), so nested-agent snapshot coverage became unplanned net-new infrastructure (`TODO(subagent-snapshots)`). The cost of finding that during design is a paragraph; the cost of finding it mid-build is a re-plan. When the harness gap is large enough to be its own reviewable unit, schedule it as a dedicated stacked follow-up with its own RFC — but SAY SO in the originating plan, with the gap named, rather than letting it surface as a surprise. +- **A tool's editor/ACP representation is part of its design — decide it up front, not after.** When you add or change a model-facing tool, its ACP tool-call card is as much a deliverable as its `execute`: decide which render intent it declares via `presentCall`/`presentResult` (`generic` — a titled card with `kind`/`rawInput`/`content`/`locations`; `terminal` — a shell command; `diff` — a file create/modify rendered as an inline diff), and cover it with a snapshot test (the transcript tier is the only place card rendering is actually verified end-to-end — a unit test on the pure presenter proves the shape, not that an editor renders it). A tool that reads/writes files should almost always emit `locations` (for editor follow-along) and, for a mutation, a `diff` card; a tool that runs a command is a `terminal`. The presentation methods are pure functions of `args` (they run on live streaming AND session-log replay), so they must not do I/O or read session state — the bridge, not the tool, relativizes display paths and fills the session cwd. The reference implementations are `dsh-tool-fs` (generic/diff) and `dsh-tool-bash` (terminal); the vocabulary and the why are pinned in [docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md](docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md), and the step-by-step is in [docs/cookbook/adding-a-tool.md](docs/cookbook/adding-a-tool.md). When you introduce a new capability seam, a new agent-lifecycle shape, or anything that produces an observable transcript (a new tool family, a subagent transport, a new UI surface), the plan must name how it will be covered at EVERY tier it touches — unit, real-API e2e, AND the full-transcript snapshot tier — and, critically, must check that the existing test infrastructure can actually express that coverage. Do not assume a snapshot/e2e harness built for one shape (e.g. a single top-level ACP session) transparently supports a new shape (e.g. a parent agent driving nested child agents): verify it, and if it cannot, the harness extension is in-scope work to plan and schedule, not a detail to discover mid-implementation. This rule exists because a real plan under-scoped exactly this: the subagent backends were planned with unit + e2e coverage but the snapshot tier turned out to assume one session per process (`dsh-llm-replay`'s single positional cursor, single-file harvest), so nested-agent snapshot coverage became unplanned net-new infrastructure (`TODO(subagent-snapshots)`). The cost of finding that during design is a paragraph; the cost of finding it mid-build is a re-plan. When the harness gap is large enough to be its own reviewable unit, schedule it as a dedicated stacked follow-up with its own RFC — but SAY SO in the originating plan, with the gap named, rather than letting it surface as a surprise. ## Defensive patterns (hard-won) diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index 73706c84f1..c5e7931871 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -48,6 +48,26 @@ Follow tool-bash's background pattern: a `run_in_background` flag returns a task Prefer not to build policy into the tool. The seam is the `tools/execute` waterfall (veto or wrap — see the permission-gate example in [extension-cookbook.md](./extension-cookbook.md)), or a sandboxing implementation behind the tool's executor seam. +## How your tool renders in an editor (ACP presentation) + +Your tool's `execute` returns model-facing content; its **editor card** is a separate, optional concern you declare with two pure display methods on the `defineTool` options. Design this alongside `execute`, not after — an editor (Zed, over the ACP bridge) shows the card, and a tool with no presentation falls back to a bland generic card (title = tool name, raw args as input). + +Both methods return a **`card`-tagged render intent** — pick the card kind that matches what your tool does: + +- `presentCall(args)` → a `ToolCallView` (the PENDING card): + - `{ card: 'generic', title, kind?, rawInput?, content?, locations? }` — the default. Set `kind` for an icon (`read`/`search`/…); set `locations: [{ path, line? }]` for any file your tool touches so a capable editor follows along / jumps to it. + - `{ card: 'terminal', title, description?, cwd? }` — your call IS a shell command. `title` is the command, `description` renders above the terminal card. (tool-bash.) + - `{ card: 'diff', title, diffs, locations? }` — your call creates or modifies a file. `diffs: [{ path, oldText, newText }]` (`oldText: null` for a new file) renders as an inline diff card. (tool-fs `write`/`edit`.) +- `presentResult(args, { content, isError })` → a `ToolResultView` (the COMPLETED card): `{ card: 'generic', title?, content? }` or `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the run's captured output + exit — the bridge shows an exit pill and derives a fenced ` ```console ` fallback for editors without the terminal capability). + +Hard rules (they bite if broken): + +- **Purity.** These run on live streaming AND on session-log REPLAY, so they must be pure functions of `args` (+ the result) — NO I/O, NO reading session state, NO clock/random. A diff is derived from the args (`write` uses `oldText: null` because a call-time presenter has no prior file content); the BRIDGE, not the tool, fills the session cwd and relativizes a display-path title. If you find yourself wanting the file's old content or the working directory inside `presentCall`, stop — that belongs on the bridge or a future result-event shape, not the presenter. +- **UI-only formatting stays out of the model result.** A fenced ` ```console ` block, a diff, a relativized path — none of these may appear in what `execute` returns to the model; they live only in the presentation. (A `terminal` result view carries RAW `output`; the bridge adds the fences.) +- **`defineTool` soft-validates the display path.** A malformed/older logged arg shape makes the wrapper return `undefined` (a generic fallback) rather than throw — display must never crash a replay. + +The neutral vocabulary lives in `dsh-tools` (never import an ACP type into a tool); the ACP bridge maps each `card` to the wire. The design and the why are in [the render-intent-union RFC](../rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md); `dsh-tool-fs` (generic/diff) and `dsh-tool-bash` (terminal) are the reference implementations. + ## Tests every tool needs -Arg-validation rejections, result shaping for every outcome, the HMR disposal test, and — for tools with side effects — an integration spec that drives the tool through the agent loop with a scripted `MockAdapter` (`packages/core/agent-loop/tests/mock-adapter.ts`), asserting the `tool/call` / `tool/result` session events. +Arg-validation rejections, result shaping for every outcome, the HMR disposal test, and — for tools with side effects — an integration spec that drives the tool through the agent loop with a scripted `MockAdapter` (`packages/core/agent-loop/tests/mock-adapter.ts`), asserting the `tool/call` / `tool/result` session events. **If your tool has an editor card, also add:** a unit test on `presentCall`/`presentResult` asserting the exact view shape, AND — because a unit test proves the shape but not that an editor renders it — a **snapshot scenario** under `examples/acp-agent/tests/snapshots/` that drives the real tool through the ACP bridge and pins the rendered `tool_call` transcript (the card kind is only verified end-to-end there; see the [ACP snapshot-tests RFC](../rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md)). A tool whose card is a `terminal` needs a scenario whose `input.json` sets `terminalOutput: true` to exercise the capable-client `_meta` path. diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 22b6dc791b..bb7dcb58a4 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -547,7 +547,7 @@ async execute(exec: ToolExecution): Promise 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:287`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:319`](../../packages/core/tools/src/index.ts) ## Inherited tier (cordis core + loader/hmr/timer) diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 187a4b19a8..2395f180f7 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -11,7 +11,7 @@ Precisely, a data structure is **core** if either: 1. it flows through the agent-loop spine — the loop holds it, derives it, streams it, or logs it on every turn (a `Message`, a `StreamChunk`, a `SessionEvent`, the `Agent` handle itself), independent of which plugins are present; **or** 2. it is the single headline type a plugin author writes against a pipeline — `ToolDefinition` (what every tool *is*). -Everything else is documented on a **sub-page**, not here. The rule that draws the line: *the type you write, hold, or receive is core; the machinery that types it, renders it, or persists it is a sub-page detail.* So `ToolDefinition` is core, but the `SchemaSpec`/`InferArgs` DSL that types it, the `ToolCallPresentation` vocabulary that renders it, and the `SessionPersistence` seam that stores the event log are not — they live on the sub-pages below. +Everything else is documented on a **sub-page**, not here. The rule that draws the line: *the type you write, hold, or receive is core; the machinery that types it, renders it, or persists it is a sub-page detail.* So `ToolDefinition` is core, but the `SchemaSpec`/`InferArgs` DSL that types it, the `ToolCallView`/`ToolResultView` render-intent vocabulary that renders it, and the `SessionPersistence` seam that stores the event log are not — they live on the sub-pages below. | Sub-page | Owns | |---|---| diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 1c1744c45c..42aa4e70fd 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -12,21 +12,23 @@ A `ToolSchema` (the model-facing fields) plus the `execute` function and optiona interface ToolDefinition extends ToolSchema { execute(args: unknown, exec: ToolExecution): Promise /** - * Optional: how to present the PENDING state of one call in a UI, derived - * from the call's `args` (parsed arguments, `unknown` — the tool validates/ - * narrows its own input). Returning `undefined` (or omitting the method) tells - * a UI to fall back to a generic presentation (title = tool name, raw args as - * input). Pure and side-effect-free: a UI may call it during live streaming - * AND a session-log replay, so it must depend only on `args`. + * Optional: how to present the PENDING state of one call in a UI, derived from + * the call's `args` (parsed arguments, `unknown` — the tool validates/narrows + * its own input). Returns a {@link ToolCallView} (a `card`-tagged render intent), + * or `undefined` (or omit the method) to fall back to a generic presentation + * (title = tool name, raw args as input). Pure and side-effect-free: a UI may + * call it during live streaming AND a session-log replay, so it must depend + * only on `args`. */ - presentCall?(args: unknown): ToolCallPresentation | undefined + presentCall?(args: unknown): ToolCallView | undefined /** * Optional: how to present the COMPLETED state, given the same `args` and the - * `result` (`execute`'s content + whether it errored). Returning `undefined` - * (or omitting the method) tells a UI to keep the pending title and render the - * raw result content. Pure and side-effect-free for the same replay reason. + * `result` (`execute`'s content + whether it errored). Returns a + * {@link ToolResultView}, or `undefined` (or omit the method) to keep the + * pending title and render the raw result content. Pure and side-effect-free + * for the same replay reason. */ - presentResult?(args: unknown, result: ToolResult): ToolResultPresentation | undefined + presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined } ``` @@ -105,8 +107,11 @@ 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`, `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). +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`/`presentResult` return a **`card`-tagged render intent** — a discriminated union a UI bridge switches on: -> 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. +- `ToolCallView` (pending): `{ card: 'generic', title, kind?, rawInput?, content?, locations? }` (the default card; `locations` is `{ path, line? }[]` files the call reads/modifies, for editor follow-along), `{ card: 'terminal', title, description?, cwd? }` (a shell command → a terminal card), or `{ card: 'diff', title, diffs, locations? }` (a file create/modify → an inline diff card; `diffs` is `{ path, oldText, newText }[]`, `oldText: null` for a new file). +- `ToolResultView` (completed): `{ card: 'generic', title?, content? }` or `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit; a capable UI shows an exit-status pill, an incapable one gets a fenced ` ```console ` fallback the bridge derives from `output`). + +`ToolCallKind` (`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`) picks an icon on a generic card. `FileLocation` (`{ path, line? }`) and `FileDiff` (`{ path, oldText, newText }`) are the shared file-card vocabulary. The design is pinned in [the render-intent-union RFC](../rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md); the ACP bridge maps a `diff` card to a `{ type: 'diff' }` content block, a `terminal` card to the `_meta` terminal convention, and relativizes a file card's title against the session cwd. The full presentation field docs live in [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts). The bash tool's own schemas (`bash`/`bash_output`/`bash_kill`) and the executor they drive are on [bash.md](bash.md). diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 59516c24c5..fbefdd3727 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -125,6 +125,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [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 | +| [Tagged render-intent union for tool-call presentation](implemented/architecture/2026-07-02-tool-render-intent-union.md) | 2026-07-02 | ### Process diff --git a/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md b/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md new file mode 100644 index 0000000000..1ad5e84e33 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md @@ -0,0 +1,70 @@ +# RFC: Tagged render-intent union for tool-call presentation + +Status: implemented + +## Problem + +A tool declares how its calls render in a UI (an editor's tool-call card) through two callbacks, `presentCall`/`presentResult` on `ToolDefinition`, returning `ToolCallPresentation` / `ToolResultPresentation` with an optional `ToolTerminal` sub-shape. These grew incrementally into a **bag of optional fields**: `title`, `kind`, `rawInput`, `content`, `locations`, `terminal` on the call; `title`, `content`, `terminal` on the result; `cwd`/`output`/`exitCode`/`signal` on `ToolTerminal`. The split of responsibility is muddy: + +- The call-side and result-side `terminal` fields overlap, and the bridge reconciles a `content` block AND a `terminal` block AND `rawInput` per call, stitching them together with ad-hoc conditionals. +- Which combinations are *valid* is unwritten: a `terminal` call that also sets `content` means "description above the card"; a generic call that sets `terminal` is meaningless but representable. The type permits nonsense. +- There is no way to express the one file-tool affordance an editor most wants — a **diff card** (`{path, oldText, newText}`, which Zed renders as an inline diff / new-file preview). `ToolCallPresentation.content` is the *LLM* `ContentBlock[]` vocabulary (text/image), so a tool literally cannot ask for a diff. + +The existing `FIXME(tool-presentation)` in `packages/core/tools/src/index.ts` named the fix: "redesign the type so a tool declares its render INTENT once (e.g. a tagged union over card kinds) rather than a bag of optional fields the bridge stitches together." The rejected RFC [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md) deferred it explicitly: rich rendering "should return later as a tagged render-intent union after there are at least two real tools and two real consumers to validate the vocabulary." That bar is now met — two producer families (`dsh-tool-bash`, `dsh-tool-fs`) and two consumers (the ACP bridge live path + the snapshot-golden replay path). + +## Decision + +Replace the optional-field bag with a **`card`-tagged discriminated union**. A tool declares one render intent per call/result; the bridge switches on the tag. + +```ts ignore-check +type FileLocation = { path: string; line?: number } +type FileDiff = { path: string; oldText: string | null; newText: string } // oldText null ⇒ new file + +// presentCall → ToolCallView +type ToolCallView = GenericCallView | TerminalCallView | DiffCallView +interface GenericCallView { card: 'generic'; title: string; kind?: ToolCallKind; rawInput?: unknown; content?: ContentBlock[]; locations?: FileLocation[] } +interface TerminalCallView { card: 'terminal'; title: string; description?: string; cwd?: string } +interface DiffCallView { card: 'diff'; title: string; diffs: FileDiff[]; locations?: FileLocation[] } + +// presentResult → ToolResultView +type ToolResultView = GenericResultView | TerminalResultView +interface GenericResultView { card: 'generic'; title?: string; content?: ContentBlock[] } +interface TerminalResultView { card: 'terminal'; title?: string; output?: string; exitCode?: number; signal?: string } +``` + +`card` is **required** on every variant — a real discriminant, not an optional default. The bridge does `switch (view.card) { case 'generic': … case 'terminal': … case 'diff': … default: assertNever(view) }`. The union is **closed** (per the [switch-exhaustiveness convention](../../../../AGENTS.md)): a fourth render intent (a table, a chart) needs new bridge code to render it anyway, so a plugin-added variant that the bridge silently drops would be worse than a compile error. Adding a variant breaks compilation at the bridge switch — exactly the signal we want. + +### Why a tagged union beats the field-bag + +- **Invalid states become unrepresentable.** A generic card cannot carry terminal output; a terminal card cannot carry a diff. The old bag permitted all of these. +- **The bridge switches instead of stitching.** One arm per card kind, each producing exactly the wire shape that card needs, rather than reconciling five optional fields whose interactions are undocumented. +- **`diff` is a first-class intent.** `dsh-tool-fs` write/edit declare `card:'diff'`; the bridge emits an ACP `{type:'diff', path, oldText, newText}` `ToolCallContent` (already in the SDK's `ToolCallContent` union, previously unused by the bridge). This is the affordance the redesign unlocks. + +### Producer mapping + +- `dsh-tool-fs` read → `generic` (`kind:'read'`, a follow-along `location`); write → `diff` (`oldText:null`); edit → `diff` (`oldText:old_string || null`, `newText:new_string ?? ''`). This mirrors `claude-agent-acp`'s `toolInfoFromToolUse` Read/Write/Edit arms field-for-field. +- `dsh-tool-bash` foreground → `terminal` call + `terminal` result; `run_in_background` and `bash_output`/`bash_kill` → `generic`. +- `dsh-tool-todo` → `generic`. + +### Terminal fallback ownership + +`TerminalResultView` carries only `output`/`exitCode`/`signal`. A UI without the terminal capability needs a fenced ` ```console ` text fallback; that derivation moves to the **bridge** (it wraps `output` in a fenced block on the no-capability path), rather than the tool double-encoding it. This keeps the bash tool's result a single structured shape and preserves the existing capability-gated behavior byte-for-byte. + +### Purity preserved + +`presentCall`/`presentResult` remain pure functions of `args` (+ the result for `presentResult`) — they run on live streaming AND session-log replay, so they must be replay-deterministic. Every view is derived from args alone: write's diff is new-file style (`oldText:null`) because the tool has no old content at call time; edit's diff is `old_string`→`new_string`. + +## Relative-path display titles + +`claude-agent-acp` relativizes a file card's title path against the session cwd (`toDisplayPath`) — `Read src/foo.ts`, not `/abs/proj/src/foo.ts` — while keeping `locations[]`/`diff.path` **raw** (the editor opens the real path). Our `presentCall` is pure/args-only and cannot see the session cwd, so this relativization happens at the **bridge**, which already threads the session cwd into tool-call rendering (the same cwd it uses to resolve a terminal card's header). The bridge relativizes the title only, by an exact structured replace of the known `locations[0].path`/`diffs[0].path` substring — generic over the file-card kinds, never special-casing tool names. + +## Non-goals + +- **Applied-hunk diffs.** `claude-agent-acp` additionally rewrites Write/Edit diffs at *result* time with real structured-patch hunks (via a PostToolUse hook: `toolUpdateFromDiffToolResponse`). Our diffs are call-time and args-derived (the whole `old_string`→`new_string`, no surrounding context lines), because `presentResult` sees only `{content, isError}` and `FsEditOutcome` carries a replacement count/version, not hunk text. Real hunks would need a new result/event shape carrying the patch — a follow-up, not this change. This is the one remaining representation difference from `claude-agent-acp`, and it is architectural (needs a new event), not cosmetic. +- **Live incremental `terminal_output_delta` streaming** and **command classification** — the terminal-rendering RFC's own deferred follow-ups, untouched here. + +## Related + +- Supersedes the deferral in [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md) (rejected — "wait for two real tools and two real consumers, then a tagged render-intent union"). That bar is now met; this is that union. +- Folds `ToolTerminal` into the `terminal` views described by [ACP terminal and tool-call rendering](../feature/2026-06-18-acp-terminal-and-tool-rendering.md) (the `_meta` terminal-card convention and capability gate are unchanged; only the harness-side presentation type changes). +- The ACP SDK's `Diff` / `ToolCallContent` types back the new `diff` card. diff --git a/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md b/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md index d589f6136b..d578d8091c 100644 --- a/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md +++ b/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md @@ -23,7 +23,7 @@ The rule that settled the remaining cases: ***the type you write, hold, or recei - A data structure is **core** if it flows through the agent-loop spine — the loop holds, derives, streams, or logs it on every turn regardless of which plugins load (`Message`, `StreamChunk`, `SessionEvent`, the `Agent` handle) — **or** it is the single headline type a plugin author writes against a pipeline (`ToolDefinition`). - `ToolDefinition` is core (it is what every tool author writes) **even though the loop never holds one** — authoring-importance overrides the strict flows-through-spine rule for this one headline type. But its typing machinery — the `SchemaSpec`/`InferArgs` DSL — is a sub-page detail (you write a `ToolDefinition`; the type-level machinery that types it you do not). That is the spine-vs-seam line made sharp. - `ToolSchema` is core (it is a field of `GenerateOptions`, the model request that flows through every step) even though it is conceptually part of the tool pipeline — *flows through the spine* wins over *conceptual home* when they conflict. -- The tool-presentation vocabulary (`ToolCallPresentation`, …, carrying a `FIXME(tool-presentation)` redesign marker), the `SessionPersistence` durability seam, and bash vocabulary are sub-pages. +- The tool-presentation vocabulary (`ToolCallView`/`ToolResultView`, …), the `SessionPersistence` durability seam, and bash vocabulary are sub-pages. `core.md` is a **self-contained spine doc**: it states the exact type definition of each spine structure with minimal prose and links to sub-pages for the per-seam detail. The sub-pages are `llm-streaming.md`, `session.md`, `persistence.md` (split from session along the in-memory-model vs. durability-seam line), `tools.md`, and `bash.md`. diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 3a27ad25d0..cd14e8dc2e 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -52,6 +52,7 @@ const SCENARIOS: Scenario[] = [ { name: 'reject-extra-dirs', hasModelTurn: false, recorded: false }, { name: 'text-turn', hasModelTurn: true, recorded: true }, { name: 'tool-call-turn', hasModelTurn: true, recorded: true }, + { name: 'fs-terminal-card', 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 }, diff --git a/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl index 8f5d02632d..f7601d52e3 100644 --- a/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl @@ -14,7 +14,7 @@ {"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","toolCallId":"call_00_Qm1fHx9Xd5wOv1WZvgmj2865","title":"Read config.txt","kind":"read","status":"in_progress","locations":[{"path":"config.txt","line":1}]}}} {"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":"{{cwd}}/config.txt\nfile\n\n1: mode=DEBUG\n2: level=info\n\n(End of file - total 2 lines)\n"}}]}}} {"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"}}}} @@ -50,7 +50,7 @@ {"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","toolCallId":"call_00_OwPhDMqS06VEbY7rO4Rv1204","title":"Edit config.txt","kind":"edit","status":"in_progress","locations":[{"path":"config.txt"}],"content":[{"type":"diff","path":"config.txt","oldText":"DEBUG","newText":"RELEASE"}]}}} {"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"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl index 69c1bbd1c0..0de98c4a44 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl @@ -37,7 +37,7 @@ {"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_3fuirRMnjFj7LWlJL1eU3690","title":"Edit settings.txt","kind":"edit","status":"in_progress","rawInput":"\"blue\" → \"green\"","locations":[{"path":"settings.txt"}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_3fuirRMnjFj7LWlJL1eU3690","title":"Edit settings.txt","kind":"edit","status":"in_progress","locations":[{"path":"settings.txt"}],"content":[{"type":"diff","path":"settings.txt","oldText":"blue","newText":"green"}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_3fuirRMnjFj7LWlJL1eU3690","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: edit requires reading \"{{cwd}}/settings.txt\" first"}}]}}} {"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"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl index 577f8adaa6..8b40d5fba5 100644 --- a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl @@ -24,7 +24,7 @@ {"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","toolCallId":"call_00_0htYNlUzC9b8aH2gHN8h2706","title":"Read big.txt (5 - 8)","kind":"read","status":"in_progress","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":"{{cwd}}/big.txt\nfile\n\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"}}]}}} {"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"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl index 1d19e735e4..e84a07b931 100644 --- a/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl @@ -27,7 +27,7 @@ {"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","toolCallId":"call_00_6cBhaXfexPCkwewPFfJd4624","title":"Read greeting.txt","kind":"read","status":"in_progress","locations":[{"path":"greeting.txt","line":1}]}}} {"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":"{{cwd}}/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}}]}}} {"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"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-terminal-card/input.json b/examples/acp-agent/tests/snapshots/fs-terminal-card/input.json new file mode 100644 index 0000000000..de9237ea82 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-terminal-card/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize", "terminalOutput": true }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/fs-terminal-card/session.jsonl b/examples/acp-agent/tests/snapshots/fs-terminal-card/session.jsonl new file mode 100644 index 0000000000..7c0652d478 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-terminal-card/session.jsonl @@ -0,0 +1,97 @@ +{"type":"session","version":0,"id":"2a35d875-5d43-4d39-a995-a378d341643d","createdAt":1783012637644,"cwd":"/tmp/acp-snap-cwd-o9lBfw"} +{"type":"turn/start","seq":0,"time":1783012637647,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783012637647,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783012637648,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1783012638390,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1783012638390,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1783012638548,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1783012638578,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1783012638578,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1783012638578,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1783012638579,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":10,"time":1783012638579,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":11,"time":1783012638604,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":12,"time":1783012638634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":13,"time":1783012638635,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":14,"time":1783012638635,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":15,"time":1783012638663,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":16,"time":1783012638663,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":17,"time":1783012638663,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":18,"time":1783012638696,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":19,"time":1783012638697,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":20,"time":1783012638697,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":21,"time":1783012638778,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":22,"time":1783012638778,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":23,"time":1783012638778,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":24,"time":1783012638779,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":25,"time":1783012638807,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":26,"time":1783012638807,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":27,"time":1783012638807,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":28,"time":1783012638807,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":29,"time":1783012638837,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":30,"time":1783012638837,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":" TER"}}} +{"type":"assistant/chunk","seq":31,"time":1783012638838,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"MIN"}}} +{"type":"assistant/chunk","seq":32,"time":1783012638838,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"AL"}}} +{"type":"assistant/chunk","seq":33,"time":1783012638838,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"_OK"}}} +{"type":"assistant/chunk","seq":34,"time":1783012638865,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":35,"time":1783012638894,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":36,"time":1783012638894,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":37,"time":1783012638894,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":38,"time":1783012638894,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":39,"time":1783012638894,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":40,"time":1783012638921,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":41,"time":1783012638921,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"Run"}}} +{"type":"assistant/chunk","seq":42,"time":1783012638921,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":" echo"}}} +{"type":"assistant/chunk","seq":43,"time":1783012638951,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":" TER"}}} +{"type":"assistant/chunk","seq":44,"time":1783012638978,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"MIN"}}} +{"type":"assistant/chunk","seq":45,"time":1783012638978,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"AL"}}} +{"type":"assistant/chunk","seq":46,"time":1783012638978,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"_OK"}}} +{"type":"assistant/chunk","seq":47,"time":1783012638978,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":48,"time":1783012639008,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":49,"time":1783012639069,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a bash command and then reply with a single word."}}}} +{"type":"assistant/chunk","seq":50,"time":1783012639069,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Run echo TERMINAL_OK\"}"}}}} +{"type":"assistant/chunk","seq":51,"time":1783012639069,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":102,"outputTokens":85,"cacheReadTokens":2176,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":52,"time":1783012639069,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":53,"time":1783012639071,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a bash command and then reply with a single word."},{"type":"tool-call","id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Run echo TERMINAL_OK\"}"}],"usage":{"inputTokens":102,"outputTokens":85,"cacheReadTokens":2176,"reasoningTokens":17}},"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],"surfaceOp":"append"} +{"type":"tool/call","seq":54,"time":1783012639071,"data":{"turn":1,"step":1,"callId":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Run echo TERMINAL_OK\"}"}} +{"type":"tool/result","seq":55,"time":1783012639084,"data":{"turn":1,"step":1,"callId":"call_00_olli3mOeSioBRKRuiYlA1408","content":[{"type":"text","text":"TERMINAL_OK\n"}],"isError":false},"sourceEventSeqs":[54],"surfaceOp":"append"} +{"type":"step/end","seq":56,"time":1783012639084,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":57,"time":1783012639085,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":58,"time":1783012639687,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":59,"time":1783012639687,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":60,"time":1783012639763,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":61,"time":1783012639791,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ran"}}} +{"type":"assistant/chunk","seq":62,"time":1783012639821,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} +{"type":"assistant/chunk","seq":63,"time":1783012639821,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":64,"time":1783012639821,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":65,"time":1783012639848,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":66,"time":1783012639849,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"TER"}}} +{"type":"assistant/chunk","seq":67,"time":1783012639849,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"MIN"}}} +{"type":"assistant/chunk","seq":68,"time":1783012639849,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":69,"time":1783012639849,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":70,"time":1783012639849,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":71,"time":1783012639877,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":72,"time":1783012639877,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":73,"time":1783012639877,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":74,"time":1783012639905,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":75,"time":1783012639905,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":76,"time":1783012639905,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":77,"time":1783012639906,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":78,"time":1783012639906,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":79,"time":1783012639933,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":80,"time":1783012639934,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":81,"time":1783012639934,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} +{"type":"assistant/chunk","seq":82,"time":1783012639963,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":83,"time":1783012639963,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":84,"time":1783012639963,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":85,"time":1783012639963,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":86,"time":1783012639963,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":87,"time":1783012639963,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":88,"time":1783012639991,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":89,"time":1783012639992,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command ran successfully and output \"TERMINAL_OK\". The user asked me to reply with the single word DONE and stop."}}}} +{"type":"assistant/chunk","seq":90,"time":1783012639992,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":91,"time":1783012639992,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":204,"outputTokens":30,"cacheReadTokens":2176,"reasoningTokens":27}}}} +{"type":"assistant/chunk","seq":92,"time":1783012639992,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":93,"time":1783012639992,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command ran successfully and output \"TERMINAL_OK\". The user asked me to reply with the single word DONE and stop."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":204,"outputTokens":30,"cacheReadTokens":2176,"reasoningTokens":27}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":94,"time":1783012639992,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":95,"time":1783012639993,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl new file mode 100644 index 0000000000..6bbff6b55c --- /dev/null +++ b/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl @@ -0,0 +1,51 @@ +{"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":" run"}}}} +{"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":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" command"}}}} +{"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":" a"}}}} +{"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":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_olli3mOeSioBRKRuiYlA1408","title":"echo TERMINAL_OK","kind":"execute","status":"in_progress","rawInput":"echo TERMINAL_OK","content":[{"type":"content","content":{"type":"text","text":"Run echo TERMINAL_OK"}},{"type":"terminal","terminalId":"call_00_olli3mOeSioBRKRuiYlA1408"}],"_meta":{"terminal_info":{"terminal_id":"call_00_olli3mOeSioBRKRuiYlA1408","cwd":"{{cwd}}"}}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_olli3mOeSioBRKRuiYlA1408","status":"completed","_meta":{"terminal_output":{"terminal_id":"call_00_olli3mOeSioBRKRuiYlA1408","data":"TERMINAL_OK\n"},"terminal_exit":{"terminal_id":"call_00_olli3mOeSioBRKRuiYlA1408","exit_code":0}}}}} +{"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":" command"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ran"}}}} +{"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":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} +{"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":"TER"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"MIN"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"AL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_OK"}}}} +{"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":" 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":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} +{"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"}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl index 1d234c2dd4..b595a90083 100644 --- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl @@ -28,7 +28,7 @@ {"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","toolCallId":"call_00_GtqlR9riew6wgdQzLbbu6019","title":"Read data.txt","kind":"read","status":"in_progress","locations":[{"path":"data.txt","line":1}]}}} {"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":"{{cwd}}/data.txt\nfile\n\n1: original contents\n\n(End of file - total 1 lines)\n"}}]}}} {"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"}}}} @@ -50,7 +50,7 @@ {"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","toolCallId":"call_00_CkV4RzKjuERcr4NdJbtY3226","title":"Write data.txt","kind":"edit","status":"in_progress","locations":[{"path":"data.txt"}],"content":[{"type":"diff","path":"data.txt","oldText":null,"newText":"replaced"}]}}} {"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":"{{cwd}}/data.txt\nfile\n\nUpdated file\n"}}]}}} {"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":"."}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl index daa26245bb..eac3a6ea99 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl @@ -31,7 +31,7 @@ {"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","toolCallId":"call_00_OxAUP9dIc6I1B6Coo5vs8586","title":"Write notes.txt","kind":"edit","status":"in_progress","locations":[{"path":"notes.txt"}],"content":[{"type":"diff","path":"notes.txt","oldText":null,"newText":"hello world"}]}}} {"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":"{{cwd}}/notes.txt\nfile\n\nCreated file\n"}}]}}} {"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"}}}} diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index 656a22cdb1..29cec70970 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -34,7 +34,7 @@ The owning agent's session token (`session.header.id`) is stamped onto the task ## UI presentation -These tools own how their calls render in a UI (an editor's tool-call card) via the `dsh-tools` `presentCall`/`presentResult` seam — a UI never special-cases tool names. For `bash`: the **title** is the exact `command` ("ls -la src") and `kind` is `execute` (terminal/run treatment), matching the reference ACP adapters (claude-agent-acp, codex-acp), which both use the bare command as an execute tool's title. The command is ALSO the **rawInput** for non-terminal UIs that render it (an execute-kind card hides rawInput — Zed shows it only for non-terminal tools — so the command must BE the title to be seen). The model-written `description` rides as a **content** text block shown ABOVE the card. (claude-agent-acp DROPS the description in terminal mode and shows only the card; surfacing it as a content block is a deliberate divergence — we keep the human summary visible alongside the card.) The completed output is wrapped in a fenced ` ```console ` block as the no-terminal-capability fallback — a UI-only affordance, so the model-facing result text stays unfenced. A FOREGROUND `bash` run also flags itself as a **terminal** (the neutral `terminal` field: `presentCall` sets a `cwd` from the model `workdir` when given — absolute as-is, relative for the UI bridge to resolve against the session cwd — else leaves it for the bridge to fill from the session cwd; `presentResult` carries the raw output plus the parsed `exitCode`/`signal`) so a capable client (Zed) renders a terminal card with an exit-status pill instead of the text block — see `packages/ui/acp` ("Terminal card"). A `run_in_background` call is NOT a terminal (it returns a task id immediately and never streams a terminal — poll with `bash_output`), and an `isError` result (spawn failure / abort) carries no exit pill (there is no real process exit); both render as the ordinary execute card / fenced text. `bash_output`/`bash_kill` present a task-scoped title ("Read output from background task bash-3" / "Kill background task bash-3") with the task id as rawInput. These methods are pure/display-only (they also run on `session/load` replay), and a malformed/older logged arg shape falls back to a generic presentation rather than throwing. See `packages/core/tools` ("Tool-owned UI presentation") and `packages/ui/acp` ("Terminal card" / "Tool-call presentation"). +These tools own how their calls render in a UI (an editor's tool-call card) via the `dsh-tools` `presentCall`/`presentResult` seam, each returning a `card`-tagged render intent — a UI never special-cases tool names. A FOREGROUND `bash` run declares a **terminal card**: `presentCall` returns `{ card: 'terminal', title, description?, cwd? }` — the **title** is the exact `command` ("ls -la src"), the model-written `description` rides along (rendered ABOVE the card), and `cwd` comes from the model `workdir` when given (absolute as-is, relative for the UI bridge to resolve against the session cwd; else left for the bridge to fill from the session cwd) — and `presentResult` returns `{ card: 'terminal', title?, output?, exitCode?, signal? }` carrying the raw output plus the parsed `exitCode`/`signal`, so a capable client (Zed) renders a terminal card with an exit-status pill. The result carries the raw `output`; the bridge DERIVES the ` ```console ` fenced fallback for a no-terminal-capability UI (the tool no longer encodes the fences itself), so the model-facing result text stays unfenced. A `run_in_background` call is NOT a terminal (it returns a task id immediately and never streams a terminal — poll with `bash_output`) and instead returns a **generic card** (`{ card: 'generic', title, kind: 'execute', rawInput: command, content: [description] }`); an `isError` result (spawn failure / abort) likewise returns a `generic` result view with no exit pill (there is no real process exit). `bash_output`/`bash_kill` return a `generic` card with a task-scoped title ("Read output from background task bash-3" / "Kill background task bash-3") and the task id as rawInput. These methods are pure/display-only (they also run on `session/load` replay), and a malformed/older logged arg shape falls back to a generic presentation rather than throwing. See `packages/core/tools` ("Tool-owned UI presentation") and `packages/ui/acp` ("Terminal card" / "Tool-call presentation"). ## Background completion notices diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 9ad1f9a17c..29a6475c2c 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -41,7 +41,7 @@ import type { Context } from 'cordis' import { isAbsolute, resolve as resolvePath } from 'node:path' import { defineTool } from '@deepseek-ai/dsh-tools' -import type { ToolCallPresentation, ToolResult, ToolResultPresentation } from '@deepseek-ai/dsh-tools' +import type { GenericCallView, TerminalCallView, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' import { BashTaskId, OwnerToken } from '@deepseek-ai/dsh-bash' import type { BashRunResult, BashTask, CollectedOutput } from '@deepseek-ai/dsh-bash' @@ -158,16 +158,26 @@ export function renderResult(result: BashRunResult): string { */ type BashCallArgs = { command: string; description: string; workdir?: string; run_in_background?: boolean } -function presentBashCall(args: BashCallArgs): ToolCallPresentation { - const base = { - title: args.command, - kind: 'execute' as const, - rawInput: args.command, - content: [{ type: 'text' as const, text: args.description }], +function presentBashCall(args: BashCallArgs): GenericCallView | TerminalCallView { + // A background start is not an interactive terminal — a generic execute card + // with the command as rawInput and the description as a content block. + if (args.run_in_background === true) { + return { + card: 'generic', + title: args.command, + kind: 'execute', + rawInput: args.command, + content: [{ type: 'text', text: args.description }], + } + } + // A foreground run IS a terminal: the command titles the card, the description + // renders above it, and the cwd (when the model gave a workdir) heads it. + return { + card: 'terminal', + title: args.command, + description: args.description, + ...args.workdir !== undefined ? { cwd: args.workdir } : {}, } - // A background start is not an interactive terminal — no terminal card. - if (args.run_in_background === true) return base - return { ...base, terminal: args.workdir !== undefined ? { cwd: args.workdir } : {} } } /** @@ -186,21 +196,25 @@ function presentBashCall(args: BashCallArgs): ToolCallPresentation { * task-id ack, not a streamed run) and an `isError` result (a spawn failure or * abort — there is no real process exit to pill, and the body is an error * message, not `renderResult` output, so parsing it would be meaningless). Those - * fall back to the fenced `content` block with no terminal metadata. The bridge's - * orphan guard also drops a result terminal when the call wasn't terminal, so a - * background call (not marked terminal in `presentBashCall`) is doubly safe. - * A non-text result (unexpected for bash) falls through to `undefined`. + * return a `generic` result whose content is the fenced ```console block. A + * finished foreground run returns a `terminal` result carrying the RAW output + * and the parsed exit status; the BRIDGE derives the fenced fallback from + * `output` for a UI without terminal support, so the tool does not double-encode + * it. A non-text result (unexpected for bash) falls through to `undefined`. */ -function presentBashResult(args: unknown, result: ToolResult): ToolResultPresentation | undefined { +function presentBashResult(args: unknown, result: ToolResult): ToolResultView | undefined { const block = result.content.length === 1 ? result.content[0] : undefined if (block === undefined || block.type !== 'text') return undefined const raw = block.text - const fenced = raw.replace(/\n+$/, '') - const content = [{ type: 'text' as const, text: `\`\`\`console\n${fenced}\n\`\`\`` }] const isBackground = typeof args === 'object' && args !== null && (args as { run_in_background?: unknown }).run_in_background === true - // No exit pill / terminal output for a background ack or an errored run. - if (isBackground || result.isError) return { content } - return { content, terminal: { output: raw, ...parseExitStatus(raw) } } + // A background ack or an errored run is not a real terminal exit: render the + // fenced ```console fallback as generic content (no exit pill). + if (isBackground || result.isError) { + return { card: 'generic', content: [{ type: 'text', text: `\`\`\`console\n${raw.replace(/\n+$/, '')}\n\`\`\`` }] } + } + // A finished foreground run: RAW output + parsed exit for the terminal card. + // The bridge derives the no-capability fenced fallback from `output`. + return { card: 'terminal', output: raw, ...parseExitStatus(raw) } } /** @@ -237,8 +251,8 @@ function parseExitStatus(text: string): { exitCode: number } | { signal: string } /** Pending-state presentation for `bash_output`/`bash_kill` (background-task tools). */ -function presentTaskCall(verb: string, args: { task_id: string }): ToolCallPresentation { - return { title: `${verb} background task ${args.task_id}`, kind: 'execute', rawInput: args.task_id } +function presentTaskCall(verb: string, args: { task_id: string }): GenericCallView { + return { card: 'generic', title: `${verb} background task ${args.task_id}`, kind: 'execute', rawInput: args.task_id } } /** diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 9de079fd33..368ed4fdd7 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -716,45 +716,40 @@ describe('status lines', () => { }) describe('tool-owned UI presentation (presentCall / presentResult)', () => { - it('bash presentCall: title is the command, description as a content block, marks a terminal; workdir → cwd (absolute or relative, bridge resolves)', async () => { + it('bash presentCall: a foreground run is a terminal card (command title, description, workdir → cwd absolute or relative)', async () => { const ctx = await setup() - // No explicit workdir → the call still flags a terminal, but with no cwd (the - // UI bridge fills the session cwd it owns; the pure presenter can't see it). - // The command is the title (an execute card hides rawInput); the description - // rides as a content text block (shown above the terminal card). + // No explicit workdir → a terminal card with no cwd (the UI bridge fills the + // session cwd it owns; the pure presenter can't see it). expect(ctx.tools.get('bash')?.presentCall?.({ command: 'ls -la src', description: 'List files in src' })) - .toEqual({ title: 'ls -la src', kind: 'execute', rawInput: 'ls -la src', content: [{ type: 'text', text: 'List files in src' }], terminal: {} }) + .toEqual({ card: 'terminal', title: 'ls -la src', description: 'List files in src' }) // An ABSOLUTE workdir is surfaced verbatim as the terminal cwd header. expect(ctx.tools.get('bash')?.presentCall?.({ command: 'pwd', description: 'Print dir', workdir: '/tmp/x' })) - .toEqual({ title: 'pwd', kind: 'execute', rawInput: 'pwd', content: [{ type: 'text', text: 'Print dir' }], terminal: { cwd: '/tmp/x' } }) + .toEqual({ card: 'terminal', title: 'pwd', description: 'Print dir', cwd: '/tmp/x' }) // A RELATIVE workdir is passed through AS-IS (the bridge resolves it against // the session cwd, matching where execution runs) — not dropped. expect(ctx.tools.get('bash')?.presentCall?.({ command: 'pwd', description: 'Print dir', workdir: 'sub' })) - .toEqual({ title: 'pwd', kind: 'execute', rawInput: 'pwd', content: [{ type: 'text', text: 'Print dir' }], terminal: { cwd: 'sub' } }) + .toEqual({ card: 'terminal', title: 'pwd', description: 'Print dir', cwd: 'sub' }) }) - it('bash presentResult: console-block content AND terminal.output (RAW newlines) + parsed exit code', async () => { + it('bash presentResult: a terminal result carries RAW output (newlines intact) + parsed exit code', async () => { const ctx = await setup() const present = ctx.tools.get('bash')!.presentResult!( { command: 'echo hi', description: 'echo' }, { content: [{ type: 'text', text: 'hi\n[exit code: 0]\n\n' }], isError: false }, ) - // The fenced ```console content trims trailing blank lines for a tidy block; - // terminal.output keeps the RAW bytes (newlines intact) a terminal renderer - // needs; exitCode is parsed back from the [exit code: N] marker. - expect(present).toEqual({ - content: [{ type: 'text', text: '```console\nhi\n[exit code: 0]\n```' }], - terminal: { output: 'hi\n[exit code: 0]\n\n', exitCode: 0 }, - }) + // A terminal result keeps the RAW bytes (newlines intact) a terminal renderer + // needs; the bridge derives the fenced fallback. exitCode is parsed back from + // the [exit code: N] marker. + expect(present).toEqual({ card: 'terminal', output: 'hi\n[exit code: 0]\n\n', exitCode: 0 }) }) it('bash presentResult: a non-zero exit and a signal kill parse into exitCode / signal', async () => { const ctx = await setup() const args = { command: 'x', description: 'x' } const nonzero = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: 'oops\n[exit code: 3]' }], isError: false }) - expect(nonzero?.terminal).toEqual({ output: 'oops\n[exit code: 3]', exitCode: 3 }) + expect(nonzero).toEqual({ card: 'terminal', output: 'oops\n[exit code: 3]', exitCode: 3 }) const killed = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: 'gone\n[killed by signal: SIGKILL]' }], isError: false }) - expect(killed?.terminal).toEqual({ output: 'gone\n[killed by signal: SIGKILL]', signal: 'SIGKILL' }) + expect(killed).toEqual({ card: 'terminal', output: 'gone\n[killed by signal: SIGKILL]', signal: 'SIGKILL' }) }) it('bash presentResult exit parse is the inverse of renderResult markers (round-trip)', async () => { @@ -779,7 +774,8 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => { for (const c of cases) { const rendered = renderResult(c.result) const out = present.presentResult!({ command: 'x', description: 'x' }, { content: [{ type: 'text', text: rendered }], isError: false }) - const { output: _o, ...exit } = out?.terminal ?? {} + // Drop card + output; the remaining fields are the parsed exit. + const { card: _c, output: _o, ...exit } = out as { card: string; output?: string; exitCode?: number; signal?: string } expect(exit).toEqual(c.expect) } }) @@ -793,37 +789,35 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => { // the marker (renderResult always inserts one before a REAL marker), so this // no-trailing-newline body is NOT mistaken for a failure → exitCode 0. const out = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: '[exit code: 5]' }], isError: false }) - expect(out?.terminal).toEqual({ output: '[exit code: 5]', exitCode: 0 }) + expect(out).toEqual({ card: 'terminal', output: '[exit code: 5]', exitCode: 0 }) // Same for a fake signal marker with no leading newline. const sig = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: '[killed by signal: SIGKILL]' }], isError: false }) - expect(sig?.terminal).toEqual({ output: '[killed by signal: SIGKILL]', exitCode: 0 }) + expect(sig).toEqual({ card: 'terminal', output: '[killed by signal: SIGKILL]', exitCode: 0 }) }) - it('bash presentCall/presentResult: a run_in_background call is NOT a terminal and its ack carries no exit pill', async () => { + it('bash presentCall/presentResult: a run_in_background call is a generic card and its ack carries no exit pill', async () => { const ctx = await setup() - // The background start returns a task-id ack, not a streamed run — no terminal. + // The background start returns a task-id ack, not a streamed run — a generic + // execute card with the command as rawInput and the description as content. const call = ctx.tools.get('bash')!.presentCall!({ command: 'sleep 100', description: 'wait', run_in_background: true }) - expect(call).toEqual({ title: 'sleep 100', kind: 'execute', rawInput: 'sleep 100', content: [{ type: 'text', text: 'wait' }] }) - expect((call as { terminal?: unknown }).terminal).toBeUndefined() - // The ack result is fenced text only — no terminal output / exit pill. + expect(call).toEqual({ card: 'generic', title: 'sleep 100', kind: 'execute', rawInput: 'sleep 100', content: [{ type: 'text', text: 'wait' }] }) + // The ack result is a generic fenced-text card — no terminal output / exit pill. const result = ctx.tools.get('bash')!.presentResult!( { command: 'sleep 100', description: 'wait', run_in_background: true }, { content: [{ type: 'text', text: 'started background task bash-1' }], isError: false }, ) - expect(result?.terminal).toBeUndefined() - expect(result?.content).toEqual([{ type: 'text', text: '```console\nstarted background task bash-1\n```' }]) + expect(result).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\nstarted background task bash-1\n```' }] }) }) - it('bash presentResult: an isError result carries no exit pill (no real process exit to report)', async () => { + it('bash presentResult: an isError result is a generic card (no real process exit to report)', async () => { const ctx = await setup() // A spawn failure / abort has no process exit — the body is an error message, - // not renderResult output, so no terminal output/exit is emitted. + // not renderResult output, so a generic fenced card, no terminal output/exit. const out = ctx.tools.get('bash')!.presentResult!( { command: 'x', description: 'x' }, { content: [{ type: 'text', text: 'command aborted' }], isError: true }, ) - expect(out?.terminal).toBeUndefined() - expect(out?.content).toEqual([{ type: 'text', text: '```console\ncommand aborted\n```' }]) + expect(out).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\ncommand aborted\n```' }] }) }) it('bash presentResult: leaves a non-text (unexpected) result untouched → undefined (UI keeps raw content)', async () => { @@ -849,9 +843,9 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => { it('bash_output / bash_kill presentCall: a readable task-scoped title, task id as rawInput', async () => { const ctx = await setup() expect(ctx.tools.get('bash_output')!.presentCall!({ task_id: 'bash-3' })) - .toEqual({ title: 'Read output from background task bash-3', kind: 'execute', rawInput: 'bash-3' }) + .toEqual({ card: 'generic', title: 'Read output from background task bash-3', kind: 'execute', rawInput: 'bash-3' }) expect(ctx.tools.get('bash_kill')!.presentCall!({ task_id: 'bash-3' })) - .toEqual({ title: 'Kill background task bash-3', kind: 'execute', rawInput: 'bash-3' }) + .toEqual({ card: 'generic', title: 'Kill background task bash-3', kind: 'execute', rawInput: 'bash-3' }) }) it('presentCall validates softly: malformed args (missing required description) return undefined, never throw', async () => { diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 01a2e09d3b..a01881de5b 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -27,7 +27,7 @@ Tool registry and execution waterfall. Tool plugins register their schemas and e - `ToolDefinition` — `ToolSchema` + `execute(args, exec): Promise`, plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below). - `ToolExecution` — one pending tool call: `{ callId, name, arguments, agent?, signal? }`. - `ToolExecutionResult` — outcome: `{ callId, content, isError, error? }`. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text (the loop forwards it onto the `tool/result` session event for retry/sandbox plugins and replay). -- `ToolCallPresentation` / `ToolResultPresentation` — provider-neutral shapes a tool returns from `presentCall` / `presentResult` to own how a UI renders ITS calls (see "Tool-owned UI presentation"). +- `ToolCallView` / `ToolResultView` — provider-neutral `card`-tagged render intents a tool returns from `presentCall` / `presentResult` to own how a UI renders ITS calls (see "Tool-owned UI presentation"). ### Extension points @@ -70,12 +70,17 @@ See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, an ### Tool-owned UI presentation -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: +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 that return a **`card`-tagged render intent** (a discriminated union — a tool declares its card kind once and a UI bridge switches on `card`): -- `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`. +- `presentCall(args): ToolCallView | undefined` — the PENDING state, one of: + - `{ card: 'generic', title, kind?, rawInput?, content?, locations? }` — the default card: a human-readable `title`, 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 background task id, NOT the whole args object), optional `content` (extra UI content blocks), and optional `locations` (`{ path, line? }[]` — files this call reads/modifies, so a capable UI can follow along; the ACP bridge forwards them as `tool_call.locations`). + - `{ card: 'terminal', title, description?, cwd? }` — a shell command: a capable UI renders a terminal card (the `title` is the command, `description` renders above it, `cwd` heads it); an incapable UI falls back to a generic execute card. + - `{ card: 'diff', title, diffs, locations? }` — a file create/modify: a capable UI renders an inline diff card from `diffs` (`{ path, oldText, newText }[]`; `oldText: null` for a new file). Used by `write`/`edit`. +- `presentResult(args, result): ToolResultView | undefined` — the COMPLETED state, given the same `args` and the `{ content, isError }` result, one of: + - `{ card: 'generic', title?, content? }` — an optional replacement `title` and reformatted `content`. + - `{ card: 'terminal', title?, output?, exitCode?, signal? }` — a terminal run's captured `output` and exit status. A capable UI shows an exit-status pill; an incapable UI gets a fenced ` ```console ` fallback the BRIDGE derives from `output` (the tool does not encode the fences). -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` 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. +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` 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 views are provider-neutral — the ACP bridge (`dsh-acp`) maps each `card` to ACP `tool_call`/`tool_call_update` wire fields (a `diff` card to a `{ type: 'diff' }` content block, a `terminal` card to the `_meta` terminal convention), and relativizes a file card's title against the session cwd. See the render-intent-union RFC (`docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md`); `dsh-tool-bash` (terminal) and `dsh-tool-fs` (diff/generic) are the reference implementations. ```ts import { defineTool } from '@deepseek-ai/dsh-tools' @@ -90,13 +95,13 @@ const bash = defineTool({ async execute(args) { return [{ type: 'text', text: `ran: ${args.command}` }] }, - // The command is the readable title; the description rides as a content block. - presentCall: args => ({ title: args.command, kind: 'execute', rawInput: args.command, content: [{ type: 'text', text: args.description }] }), - // Wrap the output as a console block for the UI (not in the model-facing result). + // A terminal card: the command is the title, the description renders above it. + presentCall: args => ({ card: 'terminal', title: args.command, description: args.description }), + // A terminal result: the raw output + exit; the bridge derives the fenced fallback. presentResult: (_args, result) => { const block = result.content.length === 1 ? result.content[0] : undefined if (block === undefined || block.type !== 'text') return undefined - return { content: [{ type: 'text', text: '```console\n' + block.text + '\n```' }] } + return { card: 'terminal', output: block.text } }, }) ``` diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index c2f324e87b..594e5761cb 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -62,150 +62,182 @@ declare module 'cordis' { */ export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other' -// FIXME(tool-presentation): the ToolCallPresentation / ToolResultPresentation / -// ToolTerminal shapes need a rethink. They grew incrementally (title/kind/ -// rawInput, then a `content` block, then a `terminal` sub-shape carrying cwd/ -// output/exit) and the split of responsibility is now muddy: the call vs result -// terminal fields overlap, the bridge has to reconcile a `content` block AND a -// `terminal` block AND `rawInput` per call, and the "pending vs completed" -// boundary doesn't cleanly map to how editors actually render (terminal card, -// diff, generic card). Before more tools/UIs depend on this, redesign the type -// so a tool declares its render INTENT once (e.g. a tagged union over card -// kinds) rather than a bag of optional fields the bridge stitches together. -// Pin the design in an RFC and migrate dsh-tool-bash + the ACP bridge together. +/** + * A file location a tool reads or modifies, so a capable UI can "follow along" — + * highlight or jump to the file (and line) as the tool runs. Provider-neutral; + * a UI bridge maps it to its own affordance (the ACP bridge forwards it 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). + */ +export interface FileLocation { + path: string + line?: number +} /** - * How a tool wants ONE of its calls shown in a UI (an editor's tool-call card, - * a CLI log line) BEFORE the result is known — the *pending* state. Provider- - * neutral: a tool returns this from {@link ToolDefinition.presentCall} and a UI - * plugin (e.g. the ACP bridge) maps it to its own wire shape. The tool owns its - * own presentation — the UI must not special-case tool names. + * A single-file change a tool is about to make, for a UI that renders inline + * diffs (an editor's diff card). Provider-neutral; the ACP bridge forwards it as + * a `{ type: 'diff' }` tool-call content block. `oldText` is `null` for a + * new-file create (nothing to diff against); an overwrite also uses `null`, + * because a call-time presenter has no access to the file's prior content. */ -export interface ToolCallPresentation { +export interface FileDiff { + path: string + /** Prior content, or `null` for a new file / an overwrite (no prior content available at call time). */ + oldText: string | null + /** Content after the change. */ + newText: string +} + +/** + * How a tool wants ONE of its calls shown in a UI (an editor's tool-call card, a + * CLI log line) BEFORE the result is known — the *pending* state. A `card`-tagged + * discriminated union: a tool declares its render INTENT once and a UI bridge + * switches on `card` to map it to the bridge's own wire shape. Provider-neutral — + * the tool owns its presentation, so a UI never special-cases tool names. + * + * Returned by {@link ToolDefinition.presentCall}. See the render-intent-union + * RFC (docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md). + */ +export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView + +/** + * The default card: a titled tool-call row with an optional category icon, a + * salient raw input, extra content blocks, and follow-along file locations. Any + * tool whose call is not a terminal or a diff uses this. + */ +export interface GenericCallView { + card: 'generic' /** - * Human-readable, always-visible label describing what THIS call does (e.g. - * the model-written one-line summary of a bash command). Keep it short — a UI - * shows it as a card header / log line. Required: a presentation must have a - * title (a UI falls back to the tool name only when `presentCall` is absent). + * Human-readable, always-visible label describing what THIS call does. Keep it + * short — a UI shows it as a card header / log line. */ title: string /** Category for icon/treatment; defaults to `other` when omitted. */ kind?: ToolCallKind /** - * The salient input to surface in a detail/expanded view — e.g. the bash - * COMMAND itself (as a string), so the title can stay a readable summary - * while the exact command is still visible. Omit to show nothing; a string is - * rendered as-is, an object as pretty JSON. NOT the full raw args object - * unless that is genuinely what a reader wants. + * The salient input to surface in a detail/expanded view (e.g. a background + * task id). Omit to show nothing; a string renders as-is, an object as pretty + * JSON. NOT the full raw args object unless that is genuinely what a reader wants. */ rawInput?: unknown /** - * UI-facing content to show on the PENDING call alongside the title/card — - * harness {@link ContentBlock}s, in render order. A terminal tool uses this to - * surface its human-readable `description` as a text block ABOVE the terminal - * card (the card itself is requested via {@link terminal} and labelled by the - * command in `title`), since the card has no description slot. Omit to show no - * extra content. A UI maps these to its own content blocks and renders a - * {@link terminal} block (if any) as a terminal card. + * UI-facing content blocks to show on the pending call alongside the title. + * Omit to show none. A UI maps these to its own content blocks. */ 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 - * shell command (e.g. `bash`). Provider-neutral; a UI bridge maps it to its - * own terminal affordance and a UI that can't falls back to the normal card. - * Pair with {@link ToolResultPresentation.terminal} for the output/exit. - */ - terminal?: ToolTerminal + /** Files this call reads/modifies, for editor follow-along. Omit for a call that touches no file. */ + locations?: FileLocation[] } /** - * A request to render a tool call as a terminal. The pending presentation - * supplies the working directory; the result presentation (see - * {@link ToolResultPresentation.terminal}) supplies the captured output and exit - * status. Provider-neutral — no client-protocol types. A UI that supports - * terminals shows a cwd-headed terminal card with the command, its output, and - * an exit-status pill; a UI that does not ignores this and renders the ordinary - * card/content. + * A call that IS a shell command running in a working directory: a capable UI + * renders it as a terminal card (cwd-headed, with the command as the title and + * live/afterward output from the {@link TerminalResultView}); an incapable UI + * falls back to a generic card whose body is the fenced command output. Set by a + * tool whose call is a foreground command (e.g. `bash`). */ -export interface ToolTerminal { +export interface TerminalCallView { + card: 'terminal' + /** The command, shown as the terminal card's title / header line. */ + title: string /** - * Working directory the command ran in, shown as the terminal header. An + * A human-readable one-line summary of what the command does, rendered ABOVE + * the terminal card (the card itself has no description slot). Omit for none. + */ + description?: string + /** + * Working directory the command runs in, shown as the terminal header. An * ABSOLUTE path is used as-is; a RELATIVE path is resolved by the UI bridge - * against the session workspace (the pure tool presenter can't see the - * session cwd). Omit entirely to let the bridge use the session workspace. + * against the session workspace (the pure presenter can't see the session cwd). + * Omit entirely to let the bridge use the session workspace. */ cwd?: string - /** Captured command output (stdout+stderr as the tool chooses to combine them). Result-state only. */ - output?: string - /** - * Process exit code, when the run ended by exiting (not a signal). Result-state - * only; lets a capable UI show an exit-status pill on the terminal card. Omit - * when the command was killed by a signal or the exit code is unknown. - */ - exitCode?: number - /** - * Signal name that killed the process (e.g. `SIGTERM`), when it died by signal - * rather than exiting. Result-state only; mutually exclusive with `exitCode`. - */ - signal?: string } /** - * How a tool wants the COMPLETED call shown — the *result* state, after - * `execute` returns. Lets the tool reformat its result for a UI distinctly from - * the model-facing text it returned from `execute` (e.g. wrap command output in - * a fenced ```console block for monospace rendering, which the model-facing - * result must NOT carry). All fields optional: a UI keeps the pending-state - * title and renders the raw result content for anything left unset. + * A call that creates or modifies files, rendered as an inline diff card by a + * capable UI. Set by a tool whose call writes/edits a file (e.g. `write`, + * `edit`). The diffs are derived from the call ARGUMENTS (a create's `oldText` is + * `null`); result-time applied-hunk diffs are a separate follow-up. */ -export interface ToolResultPresentation { - /** Replacement title for the completed call (e.g. append an exit status). Omit to keep the pending-state title. */ +export interface DiffCallView { + card: 'diff' + /** Card header (e.g. `Write foo.txt`). */ + title: string + /** One entry per file the call changes. */ + diffs: FileDiff[] + /** Files this call modifies, for editor follow-along (usually the diffs' paths). */ + locations?: FileLocation[] +} + +/** + * How a tool wants the COMPLETED call shown — the *result* state, after `execute` + * returns. A `card`-tagged union mirroring {@link ToolCallView}: a UI switches on + * `card`. Lets the tool reformat its result for a UI distinctly from the + * model-facing text it returned from `execute`. Returned by + * {@link ToolDefinition.presentResult}; omitting the method keeps the pending + * title and renders the raw result content. + */ +export type ToolResultView = GenericResultView | TerminalResultView + +/** + * The default completed card: an optional replacement title and reformatted + * content. Omit a field to keep the pending title / render the raw result content. + */ +export interface GenericResultView { + card: 'generic' + /** Replacement title for the completed call. Omit to keep the pending-state title. */ title?: string /** * UI-facing result content (harness {@link ContentBlock}s), reformatted from * the model-facing result. Omit to let the UI render the raw result content. - * Stays in harness vocabulary; the UI maps these to its own content blocks. */ content?: ContentBlock[] +} + +/** + * The completed state of a {@link TerminalCallView}: the captured output and exit + * status. A capable UI renders `output` in the terminal card and shows an + * exit-status pill; an incapable UI gets a fenced ```console fallback the BRIDGE + * derives from `output` (the tool does not double-encode it). + */ +export interface TerminalResultView { + card: 'terminal' + /** Replacement title for the completed call. Omit to keep the pending-state title. */ + title?: string + /** Captured command output (stdout+stderr as the tool chooses to combine them). */ + output?: string /** - * Terminal output/exit for a call the pending presentation marked as a - * terminal (see {@link ToolCallPresentation.terminal}). A capable UI renders - * `output` in the terminal card and shows the exit status; an incapable UI - * uses `content` (the tool should supply a text fallback there too). + * Process exit code, when the run ended by exiting (not a signal). Lets a + * capable UI show an exit-status pill. Omit when killed by a signal or unknown. */ - terminal?: ToolTerminal + exitCode?: number + /** Signal name that killed the process (e.g. `SIGTERM`). Mutually exclusive with `exitCode`. */ + signal?: string } /** A registered tool: its schema plus the execution function. */ export interface ToolDefinition extends ToolSchema { execute(args: unknown, exec: ToolExecution): Promise /** - * Optional: how to present the PENDING state of one call in a UI, derived - * from the call's `args` (parsed arguments, `unknown` — the tool validates/ - * narrows its own input). Returning `undefined` (or omitting the method) tells - * a UI to fall back to a generic presentation (title = tool name, raw args as - * input). Pure and side-effect-free: a UI may call it during live streaming - * AND a session-log replay, so it must depend only on `args`. + * Optional: how to present the PENDING state of one call in a UI, derived from + * the call's `args` (parsed arguments, `unknown` — the tool validates/narrows + * its own input). Returns a {@link ToolCallView} (a `card`-tagged render intent), + * or `undefined` (or omit the method) to fall back to a generic presentation + * (title = tool name, raw args as input). Pure and side-effect-free: a UI may + * call it during live streaming AND a session-log replay, so it must depend + * only on `args`. */ - presentCall?(args: unknown): ToolCallPresentation | undefined + presentCall?(args: unknown): ToolCallView | undefined /** * Optional: how to present the COMPLETED state, given the same `args` and the - * `result` (`execute`'s content + whether it errored). Returning `undefined` - * (or omitting the method) tells a UI to keep the pending title and render the - * raw result content. Pure and side-effect-free for the same replay reason. + * `result` (`execute`'s content + whether it errored). Returns a + * {@link ToolResultView}, or `undefined` (or omit the method) to keep the + * pending title and render the raw result content. Pure and side-effect-free + * for the same replay reason. */ - presentResult?(args: unknown, result: ToolResult): ToolResultPresentation | undefined + presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined } /** The completed outcome handed to {@link ToolDefinition.presentResult}. */ diff --git a/packages/core/tools/src/schema.ts b/packages/core/tools/src/schema.ts index b717eabf9a..0b3fc749f4 100644 --- a/packages/core/tools/src/schema.ts +++ b/packages/core/tools/src/schema.ts @@ -21,7 +21,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm' -import type { ToolCallPresentation, ToolDefinition, ToolExecution, ToolResult, ToolResultPresentation } from './index.ts' +import type { ToolCallView, ToolDefinition, ToolExecution, ToolResult, ToolResultView } from './index.ts' // --------------------------------------------------------------------------- // SchemaSpec — the author-facing per-property type @@ -300,16 +300,16 @@ export interface DefineToolOptions { * argument shape — zero casts. Pure and side-effect-free: a UI may call it * during live streaming AND a session-log replay, so depend only on `args`. * The tool owns its presentation so a UI never special-cases tool names. See - * {@link ToolCallPresentation}. + * {@link ToolCallView}. */ - presentCall?(args: InferArgs): ToolCallPresentation | undefined + presentCall?(args: InferArgs): ToolCallView | undefined /** * Optional: how to present the COMPLETED state, given the typed `args` and the * `result`. Use it to reformat result content for a UI distinctly from the * model-facing text (e.g. a fenced ```console block). Pure and side-effect- - * free for the same replay reason. See {@link ToolResultPresentation}. + * free for the same replay reason. See {@link ToolResultView}. */ - presentResult?(args: InferArgs, result: ToolResult): ToolResultPresentation | undefined + presentResult?(args: InferArgs, result: ToolResult): ToolResultView | undefined /** Whether the tool requires structured output (default false). */ strict?: boolean } @@ -369,13 +369,13 @@ export function defineTool(options: DefineToolOptions): // fall back to `undefined` (a generic UI presentation) on any mismatch, rather // than the hard `ToolArgsError` the execute path raises. if (userPresentCall) { - tool.presentCall = (args: unknown): ToolCallPresentation | undefined => { + tool.presentCall = (args: unknown): ToolCallView | undefined => { if (validateArgs(options.parameters, args).length > 0) return undefined return userPresentCall(args as InferArgs) } } if (userPresentResult) { - tool.presentResult = (args: unknown, result: ToolResult): ToolResultPresentation | undefined => { + tool.presentResult = (args: unknown, result: ToolResult): ToolResultView | undefined => { if (validateArgs(options.parameters, args).length > 0) return undefined return userPresentResult(args as InferArgs, result) } diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index c88963ecc1..55aa5866ce 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -52,8 +52,8 @@ describe('ToolRegistry', () => { description: 'has presenters', parameters: { x: { type: 'string', required: true } }, async execute() { return [] }, - presentCall: args => ({ title: args.x }), - presentResult: (args, result) => ({ title: args.x, content: result.content }), + presentCall: args => ({ card: 'generic', title: args.x }), + presentResult: (args, result) => ({ card: 'generic', title: args.x, content: result.content }), })) const schema = ctx.tools.schemas()[0] as unknown as Record expect(Object.keys(schema).sort()).toEqual(['description', 'name', 'parameters']) @@ -906,15 +906,15 @@ describe('defineTool presentation (presentCall / presentResult)', () => { presentCall(args) { // args is typed { path: string; n?: number } — zero casts. expectTypeOf(args).toEqualTypeOf<{ path: string; n?: number }>() - return { title: `Open ${args.path}`, kind: 'read', rawInput: args.path } + return { card: 'generic', title: `Open ${args.path}`, kind: 'read', rawInput: args.path } }, presentResult(args, result) { - return { title: `Opened ${args.path}`, content: result.content } + return { card: 'generic', title: `Opened ${args.path}`, content: result.content } }, }) - expect(tool.presentCall!({ path: '/a', n: 2 })).toEqual({ title: 'Open /a', kind: 'read', rawInput: '/a' }) + expect(tool.presentCall!({ path: '/a', n: 2 })).toEqual({ card: 'generic', title: 'Open /a', kind: 'read', rawInput: '/a' }) expect(tool.presentResult!({ path: '/a' }, { content: [{ type: 'text', text: 'x' }], isError: false })) - .toEqual({ title: 'Opened /a', content: [{ type: 'text', text: 'x' }] }) + .toEqual({ card: 'generic', title: 'Opened /a', content: [{ type: 'text', text: 'x' }] }) }) it('a tool without presentCall/presentResult leaves them undefined (UI falls back generically)', () => { @@ -934,8 +934,8 @@ describe('defineTool presentation (presentCall / presentResult)', () => { description: 'demo', parameters: { path: { type: 'string', required: true } }, async execute() { return [] }, - presentCall: args => ({ title: args.path }), - presentResult: (args, result) => ({ title: args.path, content: result.content }), + presentCall: args => ({ card: 'generic', title: args.path }), + presentResult: (args, result) => ({ card: 'generic', title: args.path, content: result.content }), }) // Unlike execute (which throws ToolArgsError on a mismatch), the display // methods soft-validate and fall back to undefined so a UI never crashes diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index 1fb76ffd3c..7450bd895a 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -14,6 +14,7 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' +import type { DiffCallView } 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' @@ -83,16 +84,15 @@ export function applyEditTool(ctx: Context): void { 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) + // Pure display: a diff card of the literal replacement (old_string → + // new_string), derived from the call args. `oldText: old_string || null` + // matches claude-agent-acp's Edit arm; new_string is a required arg here, so + // it maps straight to newText. A follow-along location points at the file. + presentCall(args): DiffCallView { return { + card: 'diff', title: `Edit ${args.file_path}`, - kind: 'edit', - rawInput: `${JSON.stringify(clip(args.old_string))} → ${JSON.stringify(clip(args.new_string))}`, + diffs: [{ path: args.file_path, oldText: args.old_string || null, newText: args.new_string }], locations: [{ path: args.file_path }], } }, diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index 17fa7aa7ab..c984b53c9f 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -14,6 +14,7 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' +import type { GenericCallView } 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' @@ -101,19 +102,21 @@ export function applyReadTool(ctx: Context): void { 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(', ') + // Pure display: a generic card titled by the file with the read window + // appended (`Read foo.txt (5 - 8)`), `read` kind (icon), and a follow-along + // location whose line is the read's offset (defaulting to 1). The window is + // derived from the RAW args (offset/limit as the model passed them), NOT the + // tool's defaulted 1/READ_LIMIT, so an unbounded read shows a bare title. + presentCall(args): GenericCallView { + const { offset, limit } = args + const window = limit !== undefined && limit > 0 + ? ` (${offset ?? 1} - ${(offset ?? 1) + limit - 1})` + : offset !== undefined ? ` (from line ${offset})` : '' return { - title: `Read ${args.file_path}`, + card: 'generic', + title: `Read ${args.file_path}${window}`, kind: 'read', - locations: [{ path: args.file_path, ...args.offset !== undefined ? { line: args.offset } : {} }], - ...detail.length > 0 ? { rawInput: detail } : {}, + locations: [{ path: args.file_path, line: offset ?? 1 }], } }, })) diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index 97098bd78d..69844c55f6 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -13,6 +13,7 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' +import type { DiffCallView } 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' @@ -62,12 +63,17 @@ export function applyWriteTool(ctx: Context): void { 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 }] } + // Pure display: a diff card (an editor renders write as a new-file / full- + // replace diff). `oldText: null` — a call-time presenter has no access to the + // file's prior content, so even an overwrite renders new-file style, matching + // claude-agent-acp. A follow-along location points at the written file. + presentCall(args): DiffCallView { + return { + card: 'diff', + title: `Write ${args.file_path}`, + diffs: [{ path: args.file_path, oldText: null, newText: args.content }], + locations: [{ path: args.file_path }], + } }, })) } diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 12f373753e..7bdedf894a 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -352,34 +352,46 @@ describe('tool-owned presentation (pure presentCall)', () => { return ctx.tools.get(name)?.presentCall?.(args) } - it('read: titles by file, read kind, location with the offset line', async () => { + it('read: generic card titled by file with the read window, 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', + card: 'generic', title: 'Read src/a.ts (12 - 51)', kind: 'read', locations: [{ path: 'src/a.ts', line: 12 }], }) }) - it('read: omits rawInput and the location line when offset/limit are unset', async () => { + it('read: bare title and line-1 location 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' }], + card: 'generic', title: 'Read a.txt', kind: 'read', locations: [{ path: 'a.txt', line: 1 }], }) }) - 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('read: "from line N" window when only offset is set', async () => { + expect(await presentCall('read', { file_path: 'a.txt', offset: 5 })).toEqual({ + card: 'generic', title: 'Read a.txt (from line 5)', kind: 'read', locations: [{ path: 'a.txt', line: 5 }], }) }) - 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('write: diff card (new-file style, oldText null), location', async () => { + expect(await presentCall('write', { file_path: 'out.txt', content: 'hello' })).toEqual({ + card: 'diff', title: 'Write out.txt', + diffs: [{ path: 'out.txt', oldText: null, newText: 'hello' }], + locations: [{ path: 'out.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')}`) + it('read: a limit with no offset windows from line 1', async () => { + expect(await presentCall('read', { file_path: 'a.txt', limit: 10 })).toEqual({ + card: 'generic', title: 'Read a.txt (1 - 10)', kind: 'read', locations: [{ path: 'a.txt', line: 1 }], + }) + }) + + it('edit: an empty old_string maps to oldText null (a whole-file replace diff)', async () => { + // presentCall runs on replay of raw logged args, which parseEditArgs does not + // gate — an empty old_string must still produce a valid diff (oldText null). + expect(await presentCall('edit', { file_path: 'a.txt', old_string: '', new_string: 'seed' })).toEqual({ + card: 'diff', title: 'Edit a.txt', + diffs: [{ path: 'a.txt', oldText: null, newText: 'seed' }], + locations: [{ path: 'a.txt' }], + }) }) }) diff --git a/packages/todo/tool-todo/src/index.ts b/packages/todo/tool-todo/src/index.ts index 912e08f269..01d862e4d5 100644 --- a/packages/todo/tool-todo/src/index.ts +++ b/packages/todo/tool-todo/src/index.ts @@ -118,6 +118,6 @@ export function apply(ctx: Context): void { text: `Updated todo list: ${count('pending')} pending, ${count('in_progress')} in progress, ${count('completed')} completed.`, }]) }, - presentCall: args => ({ title: 'Update todo list', kind: 'other', rawInput: args.todos }), + presentCall: args => ({ card: 'generic', title: 'Update todo list', kind: 'other', rawInput: args.todos }), })) } diff --git a/packages/todo/tool-todo/tests/tool-todo.spec.ts b/packages/todo/tool-todo/tests/tool-todo.spec.ts index 8b1c504840..86e8814633 100644 --- a/packages/todo/tool-todo/tests/tool-todo.spec.ts +++ b/packages/todo/tool-todo/tests/tool-todo.spec.ts @@ -135,7 +135,7 @@ describe('dsh-tool-todo', () => { const ctx = await setup() const def = ctx.tools.get('todo_write')! const todos = [{ content: 'a', status: 'pending' }] - expect(def.presentCall?.({ todos })).toEqual({ title: 'Update todo list', kind: 'other', rawInput: todos }) + expect(def.presentCall?.({ todos })).toEqual({ card: 'generic', title: 'Update todo list', kind: 'other', rawInput: todos }) }) it('unregisters the tool when its contributing fiber is disposed (HMR-safety)', async () => { diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index 8d0444de70..32e4326b99 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -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/locations 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` (the render intent — a `card`-tagged `ToolCallView`/`ToolResultView` — owned by the TOOL via `presentCall`/`presentResult`, which the bridge switches on to build the wire shape — see Tool-call presentation) | ## Multi-session @@ -42,18 +42,24 @@ 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, 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 ` 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.) +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) and `presentResult(args, result)` (completed state) on its `dsh-tools` definition, each returning a **`card`-tagged render intent** — a discriminated union the bridge switches on. `presentCall` returns a `ToolCallView`, one of three cards: + +- `{ card: 'generic', title, kind?, rawInput?, content?, locations? }` — the default card: a human-readable `title`, a `kind` for the icon, the salient `rawInput` for a detail view, optional `content` blocks shown alongside, and optional `locations` (`FileLocation[]` = `{ path, line? }[]` files the call reads/modifies, forwarded as `tool_call.locations` so an editor can follow along). +- `{ card: 'terminal', title, description?, cwd? }` — a shell command → a terminal card (see Terminal card). +- `{ card: 'diff', title, diffs, locations? }` — a file create/modify → an inline diff card; `diffs` is `FileDiff[]` (`{ path, oldText, newText }`, `oldText: null` ⇒ new file). The bridge emits each diff as an ACP `{ type: 'diff', path, oldText, newText }` `tool_call.content` block, which Zed renders as an inline diff / new-file preview. + +`presentResult` returns a `ToolResultView`, one of two cards: `{ card: 'generic', title?, content? }` (an optional replacement `title` and reformatted `content`) or `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit — see Terminal card). The bridge looks the definition up by name in `ctx.tools` and `switch (view.card)`es to build the ACP `tool_call`/`tool_call_update` wire shape per card; 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` returns a `terminal` card for a foreground `bash` (title = the exact `command` "ls -la src", `description` = the model description); the `dsh-tool-fs` `write`/`edit` tools return a `diff` card and `read` returns a `generic` card (`kind: 'read'`, the read window in its title — `Read foo.txt (5 - 8)` — and a `locations` entry for the file). For a file card the bridge **relativizes the title** against the session cwd (mirroring `claude-agent-acp`'s `toDisplayPath` — `Read src/foo.ts`, not the absolute path) while keeping `locations[]`/`diffs[].path` **raw** so the editor opens the real path. 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. ## Terminal card (capability-gated) -A tool whose call IS a shell command (`bash`) can render as a real **terminal card** — a working-directory header with the command's output and an exit-status pill — rather than a plain text block. The tool asks for this with the neutral `terminal` field on its presentation (`dsh-tools`: a `{ cwd?, output?, exitCode?, signal? }` shape on `ToolCallPresentation`/`ToolResultPresentation`); the bridge maps it to the Zed `_meta` convention, gated on the client advertising `clientCapabilities._meta.terminal_output` in `initialize`: +A tool whose call IS a shell command (`bash`) can render as a real **terminal card** — a working-directory header with the command's output and an exit-status pill — rather than a plain text block. The tool asks for this with the `terminal` card variant of its render intent (`dsh-tools`: `{ card: 'terminal', title, description?, cwd? }` from `presentCall`, `{ card: 'terminal', title?, output?, exitCode?, signal? }` from `presentResult`); the bridge maps it to the Zed `_meta` convention, gated on the client advertising `clientCapabilities._meta.terminal_output` in `initialize`: -- `tool_call`: `content:[…, {type:'terminal', terminalId}]` + `_meta.terminal_info.{terminal_id, cwd}` — the terminal id is the harness `callId`; the cwd is the tool's explicit absolute `terminal.cwd`, else a relative `terminal.cwd` resolved against the session cwd, else the session's workspace cwd (the bridge fills the default, since the pure tool presenter can't see it). Any pending `content` the tool supplied (e.g. bash's `description`) renders BEFORE the terminal block, so the description sits above the card. -- `tool_call_update`: `_meta.terminal_output.{terminal_id, data}` (the captured output) plus `_meta.terminal_exit.{terminal_id, exit_code | signal}` when the tool reported a structured exit. In terminal mode the update's `content` is OMITTED — an ACP `tool_call_update.content` REPLACES the call's content, so sending the fenced text block would clobber the terminal content block from the call. +- `tool_call`: `content:[…, {type:'terminal', terminalId}]` + `_meta.terminal_info.{terminal_id, cwd}` — the terminal id is the harness `callId`; the cwd is the card's explicit absolute `cwd`, else a relative `cwd` resolved against the session cwd, else the session's workspace cwd (the bridge fills the default, since the pure tool presenter can't see it). The card's `description` renders as a content block BEFORE the terminal block, so the description sits above the card. +- `tool_call_update`: `_meta.terminal_output.{terminal_id, data}` (the terminal card's `output`) plus `_meta.terminal_exit.{terminal_id, exit_code | signal}` when the card reported a structured `exitCode`/`signal`. In terminal mode the update's `content` is OMITTED — an ACP `tool_call_update.content` REPLACES the call's content, so sending the fenced text block would clobber the terminal content block from the call. -When the client does NOT advertise the capability, none of the `_meta`/terminal content is emitted: the `tool_call` shows the `description` content block and the `tool_call_update` carries the ` ```console ` text block (above) as the rendering — so a non-Zed client is never worse off. The `_meta` object is ACP's spec-blessed extensibility point; the specific `terminal_info`/`terminal_output`/`terminal_exit` keys are a Zed convention, not the ACP `terminal/create` sub-protocol (which would make the editor execute the command, bypassing `dsh-bash`'s sandbox/env-scrub/ownership/cwd). Live incremental streaming and command classification are follow-ups. See [the terminal-rendering RFC](../../../docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md). +When the client does NOT advertise the capability, none of the `_meta`/terminal content is emitted: the `tool_call` shows the `description` content block and the `tool_call_update` carries a ` ```console ` text block the bridge DERIVES by fencing the terminal result's `output` (the tool no longer double-encodes the fences) — so a non-Zed client is never worse off. The `_meta` object is ACP's spec-blessed extensibility point; the specific `terminal_info`/`terminal_output`/`terminal_exit` keys are a Zed convention, not the ACP `terminal/create` sub-protocol (which would make the editor execute the command, bypassing `dsh-bash`'s sandbox/env-scrub/ownership/cwd). Live incremental streaming and command classification are follow-ups. See [the terminal-rendering RFC](../../../docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) and [the render-intent-union RFC](../../../docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md). ## Settle-exactly-once diff --git a/packages/ui/acp/acp-feature-support.md b/packages/ui/acp/acp-feature-support.md index c39b518aba..4d14c79f5e 100644 --- a/packages/ui/acp/acp-feature-support.md +++ b/packages/ui/acp/acp-feature-support.md @@ -99,7 +99,7 @@ Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult | `ToolCallKind` mapping | S | ✅ | ✅ | ✅ | `execute`/`read`/`edit`/`other` inferred from the tool; richer mapping possible. | | `ToolCallStatus` | S | ✅ | ✅ | ✅ | `in_progress` → `completed`/`failed`. | | `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). | +| `diff` content | S | ✅ | ✅ | ✅ | The `write`/`edit` tools declare a `diff` render intent (`presentCall` → `{ card: 'diff' }`); the bridge emits `{ type: 'diff', path, oldText, newText }` content blocks (call-time, args-derived — applied-hunk diffs are a follow-up). | | `terminal` content | S | ✅ | ✅ | ✅ | Via the Zed `_meta` terminal convention (see below), not the spec `terminal/*` sub-protocol. | | `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`. | @@ -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 tool rendering** — structured `diff` content for edit tools (the `locations` follow-along hint already ships on `read`/`write`/`edit`). +8. **Applied-hunk diff rendering** — the `write`/`edit` diff cards ship (call-time, args-derived: whole `old_string`→`new_string`). Result-time structured-patch hunks with surrounding context (what `claude-agent-acp` derives from a PostToolUse hook) need a new result/event shape carrying the patch — a follow-up. 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. diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 255ca9c990..6d388e9f89 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -36,7 +36,7 @@ import type { Context } from 'cordis' import { Readable, Writable } from 'node:stream' import { randomUUID } from 'node:crypto' -import { isAbsolute, resolve as resolvePath } from 'node:path' +import { isAbsolute, relative as relativePath, resolve as resolvePath } from 'node:path' import Schema from 'schemastery' import { AgentSideConnection, @@ -62,12 +62,12 @@ import { type StopReason, } from '@agentclientprotocol/sdk' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import { CallId } from '@deepseek-ai/dsh-llm' +import { assertNever, CallId } from '@deepseek-ai/dsh-llm' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, TodoItem, TurnEndReason } from '@deepseek-ai/dsh-session' -import type { ToolCallKind, ToolCallPresentation, ToolRegistry, ToolResultPresentation, ToolTerminal } from '@deepseek-ai/dsh-tools' +import type { ToolCallKind, ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } from '@deepseek-ai/dsh-tools' // Side-effect type import: declaration-merges `ctx.sessionPersistence` onto // Context (the bridge injects it and reads `list()` for load cwd validation). import type {} from '@deepseek-ai/dsh-session-persistence' @@ -813,67 +813,13 @@ export function streamSessionEventUpdate( return } case 'tool/call': { - const present = presenter.call(event.data.callId, event.data.name, event.data.arguments) - // A terminal-rendered call (a shell command) gets a terminal CARD when the - // client supports it: a `terminal` content block plus `_meta.terminal_info` - // (the cwd header). Otherwise it is an ordinary tool_call and the output - // arrives as text on the result. See the terminal-rendering RFC. - const asTerminal = present.terminal !== undefined && terminal.enabled - // The tool's pending content (e.g. bash's `description`) renders ABOVE the - // card; when the card is shown, append the terminal block AFTER it so the - // description sits over the command (Zed renders content blocks in order). - // Without the capability the description still renders as the card's body. - const callContent: ({ type: 'content'; content: AcpContentBlock } | { type: 'terminal'; terminalId: string })[] = [ - ...present.content !== undefined ? toolResultContent(present.content) : [], - ...asTerminal ? [{ type: 'terminal' as const, terminalId: event.data.callId }] : [], - ] - notify({ - sessionId, - update: { - sessionUpdate: 'tool_call', - toolCallId: event.data.callId, - title: present.title, - 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) } } } - : {}, - }, - }) + const view = presenter.call(event.data.callId, event.data.name, event.data.arguments) + notify({ sessionId, update: toolCallUpdate(event.data.callId, view, terminal) }) return } case 'tool/result': { - const present = presenter.result(event.data.callId, event.data.content, event.data.isError) - const term = present.terminal - // When the call rendered as a terminal AND the client is capable, the output - // and exit status ride on `_meta` (the terminal card consumes them) and the - // text `content` is OMITTED: a `tool_call_update.content` REPLACES the call's - // content collection in Zed, so sending the fenced ```console block here - // would clobber the terminal content block the call installed. The incapable - // path keeps sending `content` (the fenced fallback is the only rendering). - const asTerminal = term?.output !== undefined && terminal.enabled - const terminalResultMeta = asTerminal - ? { - _meta: { - terminal_output: { terminal_id: event.data.callId, data: term.output }, - ...terminalExitMeta(event.data.callId, term), - }, - } - : {} - notify({ - sessionId, - update: { - sessionUpdate: 'tool_call_update', - toolCallId: event.data.callId, - status: event.data.isError ? 'failed' : 'completed', - ...asTerminal ? {} : { content: toolResultContent(present.content) }, - ...present.title !== undefined ? { title: present.title } : {}, - ...terminalResultMeta, - }, - }) + const view = presenter.result(event.data.callId, event.data.content, event.data.isError) + notify({ sessionId, update: toolResultUpdate(event.data.callId, view, event.data.isError, terminal) }) return } case 'todo/write': { @@ -916,46 +862,20 @@ export interface TerminalRendering { /** Default: terminal rendering off (the ` ```console ` text fallback path). */ const noTerminalRendering: TerminalRendering = { enabled: false, cwd: undefined } -/** - * Resolved pending-state presentation the bridge feeds into a `tool_call` - * update: a title is always present (tool name when the tool gives none), `kind` - * and `rawInput` are optional. - */ -interface ResolvedCallPresentation { - title: string - kind: ToolCallKind - 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 -} - -/** Resolved completed-state presentation fed into a `tool_call_update`. */ -interface ResolvedResultPresentation { - /** UI content for the result (harness blocks; the tool may reformat, else the raw result). */ - content: ContentBlock[] - /** Optional replacement title for the completed call. */ - title?: string - /** Tool's terminal output/exit for a terminal-rendered call (the result side). */ - terminal?: ToolTerminal -} - /** * Resolves tool-owned presentation for a session's tool-call events. A tool - * declares `presentCall`/`presentResult` (see `dsh-tools`); this looks them up - * by name in the registry and applies the generic fallback when a tool defines - * neither. + * declares `presentCall`/`presentResult` (see `dsh-tools`) returning a + * `card`-tagged {@link ToolCallView}/{@link ToolResultView}; this looks them up + * by name in the registry and applies a generic fallback when a tool defines + * neither. The returned view is what {@link streamSessionEventUpdate} switches on. * * The `tool/result` session event carries only `{ callId, content, isError }` — * NOT the tool name or args — so to call a tool's `presentResult` (which needs - * both), the presenter remembers each `tool/call`'s `{ name, args }` keyed by - * callId and looks it up on the matching result. The map is bridge-LOCAL (not a - * change to the event schema or a core service): one presenter per live session - * (and a throwaway per `session/load` replay), and each entry is removed when - * its result arrives. In the normal loop a `tool/call` is always followed by a + * both), the presenter remembers each `tool/call`'s `{ name, args, card }` keyed + * by callId and looks it up on the matching result. The map is bridge-LOCAL (not + * a change to the event schema or a core service): one presenter per live session + * (and a throwaway per `session/load` replay), and each entry is removed when its + * result arrives. In the normal loop a `tool/call` is always followed by a * `tool/result` (the registry turns even a thrown tool into an isError result), * so the map holds only currently-in-flight calls. The one exception is a step * torn down mid-tool (an abort between `tool/call` and `tool/result`), which can @@ -965,7 +885,7 @@ interface ResolvedResultPresentation { * stale entry's only cost is one map slot until the session ends. */ export class ToolPresenter { - private readonly pending = new Map() + private readonly pending = new Map() /** * @param tools the registry to resolve tool definitions by name. @@ -980,10 +900,10 @@ export class ToolPresenter { private readonly onError: (message: string) => void = () => {}, ) {} - /** Pending-state presentation for a `tool/call`; remembers `(name, args)` for the matching result. */ - call(callId: CallId, name: string, argsJson: string): ResolvedCallPresentation { + /** Pending-state render intent for a `tool/call`; remembers `(name, args, card)` for the matching result. */ + call(callId: CallId, name: string, argsJson: string): ToolCallView { const args = parseToolArguments(argsJson) - let present: ToolCallPresentation | undefined + let present: ToolCallView | undefined try { present = this.tools.get(name)?.presentCall?.(args) } catch (error: unknown) { @@ -991,35 +911,20 @@ export class ToolPresenter { this.onError(`acp: tool "${name}" presentCall threw, using generic presentation: ${String(error)}`) present = undefined } - if (present === undefined) { - // No tool-owned presentation: fall back to the tool name as the title and - // the full parsed args as the raw input (the pre-seam behavior). A generic - // call is never a terminal, so a later result can't emit terminal output. - this.pending.set(callId, { name, args, isTerminal: false }) - return { title: name, kind: toolKindFor(name), rawInput: args } - } - // Remember whether THIS call rendered as a terminal, so `result()` only emits - // terminal output/exit for a call that actually registered a terminal — a - // `presentResult().terminal` without a matching `presentCall().terminal` - // would otherwise orphan `_meta.terminal_output` to a terminal Zed never made. - this.pending.set(callId, { name, args, isTerminal: present.terminal !== undefined }) - return { - title: present.title, - 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 } : {}, - } + // No tool-owned presentation: fall back to the tool name as the title and the + // full parsed args as the raw input (the generic card). + const view: ToolCallView = present ?? { card: 'generic', title: name, kind: toolKindFor(name), rawInput: args } + this.pending.set(callId, { name, args, card: view.card }) + return view } - /** Completed-state presentation for a `tool/result`; consumes the remembered `(name, args)`. */ - result(callId: CallId, content: ContentBlock[], isError: boolean): ResolvedResultPresentation { + /** Completed-state render intent for a `tool/result`; consumes the remembered `(name, args, card)`. */ + result(callId: CallId, content: ContentBlock[], isError: boolean): ToolResultView { const call = this.pending.get(callId) this.pending.delete(callId) // No remembered call (unknown/late callId) → nothing to present from; raw content. - if (call === undefined) return { content } - let present: ToolResultPresentation | undefined + if (call === undefined) return { card: 'generic', content } + let present: ToolResultView | undefined try { present = this.tools.get(call.name)?.presentResult?.(call.args, { content, isError }) } catch (error: unknown) { @@ -1027,15 +932,16 @@ export class ToolPresenter { this.onError(`acp: tool "${call.name}" presentResult threw, using raw result: ${String(error)}`) present = undefined } - if (present === undefined) return { content } - return { - content: present.content ?? content, - ...present.title !== undefined ? { title: present.title } : {}, - // Only propagate terminal output/exit when the PENDING call registered a - // terminal (finding: orphan terminal output otherwise). A result-only - // terminal with no matching call-side terminal is dropped. - ...present.terminal !== undefined && call.isTerminal ? { terminal: present.terminal } : {}, - } + if (present === undefined) return { card: 'generic', content } + // Orphan guard: only honor a `terminal` result when the PENDING call was a + // terminal. A result-only terminal with no matching call-side terminal would + // orphan `_meta.terminal_output` to a terminal Zed never made — drop it back + // to the raw content. + if (present.card === 'terminal' && call.card !== 'terminal') return { card: 'generic', content } + // A generic result that reformats no content keeps the RAW result content + // (the tool replaced only the title); fill it so the card is never blanked. + if (present.card === 'generic' && present.content === undefined) return { ...present, content } + return present } } @@ -1045,8 +951,8 @@ export class ToolPresenter { * results pass their raw content through unchanged. */ export const nullToolPresenter: Pick = { - call: (_callId, name, argsJson) => ({ title: name, kind: toolKindFor(name), rawInput: parseToolArguments(argsJson) }), - result: (_callId, content) => ({ content }), + call: (_callId, name, argsJson) => ({ card: 'generic', title: name, kind: toolKindFor(name), rawInput: parseToolArguments(argsJson) }), + result: (_callId, content) => ({ card: 'generic', content }), } /** Map a harness tool name to an ACP ToolKind (best-effort; default `other`). */ @@ -1079,20 +985,118 @@ function toolResultContent(blocks: ContentBlock[]): { type: 'content'; content: return out } +/** The `session/update` payload for a `tool_call` / `tool_call_update`. */ +type ToolCallSessionUpdate = SessionNotification['update'] + +/** An ACP tool-call content block (a text/image `content`, a `diff`, or a `terminal`). */ +type AcpToolCallContent = + | { type: 'content'; content: AcpContentBlock } + | { type: 'diff'; path: string; oldText: string | null; newText: string } + | { type: 'terminal'; terminalId: string } + /** - * Resolve the terminal card's header cwd. The tool's `terminal.cwd` (a model - * `workdir`) wins when ABSOLUTE; a RELATIVE one resolves against the session - * cwd (matching how `dsh-tool-bash` resolves a relative workdir for execution, - * so the header matches where the command actually ran); when the tool gives no - * cwd, the session workspace cwd is the default. Returns `undefined` only when - * neither the tool nor the session supplies one (Zed then shows "current - * directory"). + * Relativize a file card's TITLE path against the session workspace cwd, so a + * card reads `Read src/foo.ts` rather than `/abs/proj/src/foo.ts` — matching the + * reference ACP adapter's `toDisplayPath`. Only the TITLE is relativized; the + * card's `locations`/`diff` paths stay RAW (the editor opens the real path). The + * pure tool presenter can't see the session cwd, so this happens here where the + * bridge knows it. The rewrite is an exact substring replace of the known raw + * path (a card carries the same path in `locations[0]`/`diffs[0]`), never a + * heuristic. A path outside the workspace, or an absent/relative session cwd, is + * left unchanged. */ -function terminalCwd(term: ToolTerminal | undefined, sessionCwd: string | undefined): string | undefined { - const toolCwd = term?.cwd - if (toolCwd === undefined) return sessionCwd - if (isAbsolute(toolCwd)) return toolCwd - return sessionCwd !== undefined ? resolvePath(sessionCwd, toolCwd) : toolCwd +function displayTitle(title: string, rawPath: string | undefined, sessionCwd: string | undefined): string { + if (rawPath === undefined || sessionCwd === undefined || !isAbsolute(rawPath) || !isAbsolute(sessionCwd)) return title + const rel = relativePath(sessionCwd, rawPath) + // `relative` returns a `..`-prefixed path for a target outside the workspace; + // only relativize paths that stay inside it (and never to the empty string). + if (rel.length === 0 || rel.startsWith('..')) return title + return title.split(rawPath).join(rel) +} + +/** + * Resolve the terminal card's header cwd. A `TerminalCallView.cwd` (a model + * `workdir`) wins when ABSOLUTE; a RELATIVE one resolves against the session cwd + * (matching how `dsh-tool-bash` resolves a relative workdir for execution, so the + * header matches where the command actually ran); when the view gives no cwd, the + * session workspace cwd is the default. Returns `undefined` only when neither the + * view nor the session supplies one (Zed then shows "current directory"). + */ +function terminalCwd(viewCwd: string | undefined, sessionCwd: string | undefined): string | undefined { + if (viewCwd === undefined) return sessionCwd + if (isAbsolute(viewCwd)) return viewCwd + return sessionCwd !== undefined ? resolvePath(sessionCwd, viewCwd) : viewCwd +} + +/** + * Build the `tool_call` (pending) `session/update` from a tool's render intent. + * Switches on `view.card`: a `generic` card maps title/kind/rawInput/content/ + * locations; a `diff` card emits `{ type: 'diff' }` content blocks (the editor's + * inline diff) plus follow-along locations; a `terminal` card renders as a + * terminal when the client is capable (a `terminal` content block + the + * `_meta.terminal_info` cwd header) and otherwise falls back to a generic execute + * card whose body is the description. File-card titles are relativized against the + * session cwd (see {@link displayTitle}). + */ +function toolCallUpdate(callId: CallId, view: ToolCallView, terminal: TerminalRendering): ToolCallSessionUpdate { + switch (view.card) { + case 'generic': + return { + sessionUpdate: 'tool_call', + toolCallId: callId, + // Relativize the title against the session cwd when the card carries a + // file location (a read/file card); a location-less card (bash, todo) + // has no path to relativize, so the title is used as-is. + title: displayTitle(view.title, view.locations?.[0]?.path, terminal.cwd), + kind: view.kind ?? 'other', + status: 'in_progress', + ...view.rawInput !== undefined ? { rawInput: view.rawInput } : {}, + ...view.locations !== undefined ? { locations: view.locations } : {}, + ...view.content !== undefined && view.content.length > 0 ? { content: toolResultContent(view.content) } : {}, + } + case 'diff': { + const rawPath = view.locations?.[0]?.path ?? view.diffs[0]?.path + const content: AcpToolCallContent[] = view.diffs.map(d => ({ type: 'diff', path: d.path, oldText: d.oldText, newText: d.newText })) + return { + sessionUpdate: 'tool_call', + toolCallId: callId, + title: displayTitle(view.title, rawPath, terminal.cwd), + kind: 'edit', + status: 'in_progress', + ...view.locations !== undefined ? { locations: view.locations } : {}, + ...content.length > 0 ? { content } : {}, + } + } + case 'terminal': { + // A terminal-rendered call gets a terminal CARD when the client supports it: + // the description renders ABOVE the card, then the terminal block, plus + // `_meta.terminal_info` (the cwd header). Without the capability it is an + // ordinary execute card whose body is the description and whose rawInput is + // the command; the output arrives as text on the result. + const asTerminal = terminal.enabled + const description: AcpToolCallContent[] = view.description !== undefined + ? [{ type: 'content', content: { type: 'text', text: view.description } }] + : [] + const content: AcpToolCallContent[] = [ + ...description, + ...asTerminal ? [{ type: 'terminal' as const, terminalId: callId }] : [], + ] + return { + sessionUpdate: 'tool_call', + toolCallId: callId, + title: view.title, + kind: 'execute', + status: 'in_progress', + rawInput: view.title, + ...content.length > 0 ? { content } : {}, + ...asTerminal + ? { _meta: { terminal_info: { terminal_id: callId, cwd: terminalCwd(view.cwd, terminal.cwd) } } } + : {}, + } + } + default: + return assertNever(view, 'ToolCallView.card') + } } /** The `terminal_exit` `_meta` entry for a completed terminal call. */ @@ -1102,12 +1106,60 @@ interface TerminalExitMeta { /** * Build the optional `terminal_exit` portion of a `tool_call_update`'s `_meta` - * from the tool's terminal result: a `signal` death yields `{signal}`, an - * `exitCode` yields `{exit_code}`, and neither yields nothing (the card simply - * shows no exit pill). Spread into the `_meta` object alongside `terminal_output`. + * from a terminal result: a `signal` death yields `{signal}`, an `exitCode` + * yields `{exit_code}`, and neither yields nothing (the card simply shows no exit + * pill). Spread into the `_meta` object alongside `terminal_output`. */ -function terminalExitMeta(callId: string, term: ToolTerminal): TerminalExitMeta { - if (term.signal !== undefined) return { terminal_exit: { terminal_id: callId, signal: term.signal } } - if (term.exitCode !== undefined) return { terminal_exit: { terminal_id: callId, exit_code: term.exitCode } } +function terminalExitMeta(callId: string, view: TerminalResultView): TerminalExitMeta { + if (view.signal !== undefined) return { terminal_exit: { terminal_id: callId, signal: view.signal } } + if (view.exitCode !== undefined) return { terminal_exit: { terminal_id: callId, exit_code: view.exitCode } } return {} } + +/** + * Build the `tool_call_update` (completed) `session/update` from a result render + * intent. A `generic` result sends its reformatted content (or the raw result); + * a `terminal` result rides its output/exit on `_meta` when the client is capable + * (the terminal card consumes them and `content` is OMITTED — a + * `tool_call_update.content` REPLACES the call's content collection in Zed, so + * re-sending would clobber the terminal block the call installed) and otherwise + * derives the fenced ```console fallback from `output`. + */ +function toolResultUpdate(callId: CallId, view: ToolResultView, isError: boolean, terminal: TerminalRendering): ToolCallSessionUpdate { + const status = isError ? 'failed' as const : 'completed' as const + if (view.card === 'terminal') { + const output = view.output ?? '' + if (terminal.enabled) { + return { + sessionUpdate: 'tool_call_update', + toolCallId: callId, + status, + ...view.title !== undefined ? { title: view.title } : {}, + _meta: { + terminal_output: { terminal_id: callId, data: output }, + ...terminalExitMeta(callId, view), + }, + } + } + // No terminal capability: the bridge derives the fenced ```console fallback. + const fenced = `\`\`\`console\n${output.replace(/\n+$/, '')}\n\`\`\`` + return { + sessionUpdate: 'tool_call_update', + toolCallId: callId, + status, + content: [{ type: 'content', content: { type: 'text', text: fenced } }], + ...view.title !== undefined ? { title: view.title } : {}, + } + } + // The presenter fills a generic result's content from the raw result, so + // `content` is always defined here; the guard keeps this total for a + // directly-constructed view. + return { + sessionUpdate: 'tool_call_update', + toolCallId: callId, + status, + /* v8 ignore next -- content always defined via the presenter (see above) */ + ...view.content !== undefined ? { content: toolResultContent(view.content) } : {}, + ...view.title !== undefined ? { title: view.title } : {}, + } +} diff --git a/packages/ui/acp/tests/stream-update.spec.ts b/packages/ui/acp/tests/stream-update.spec.ts index 9e1578c8c1..85e21d19a2 100644 --- a/packages/ui/acp/tests/stream-update.spec.ts +++ b/packages/ui/acp/tests/stream-update.spec.ts @@ -163,7 +163,7 @@ describe('todosToPlan', () => { }) describe('ToolPresenter (tool-owned presentation via the tool registry)', () => { - /** A tool whose presentCall/presentResult mirror what tool-bash declares. */ + /** A tool whose presentCall/presentResult return generic-card views. */ const bashLike: ToolDefinition = { name: 'bash', description: 'run a command', @@ -171,9 +171,10 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () => execute: async () => [], presentCall: (args: unknown) => { const a = args as { command: string; description: string } - return { title: a.description, kind: 'execute', rawInput: a.command } + return { card: 'generic', title: a.description, kind: 'execute', rawInput: a.command } }, presentResult: (_args: unknown, result: { content: { type: string }[] }) => ({ + card: 'generic', content: [{ type: 'text', text: `wrapped:${result.content.length}` }], }), } @@ -247,8 +248,8 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () => description: 'm', parameters: {}, execute: async () => [], - presentCall: () => ({ title: 'Doing a thing' }), - presentResult: () => ({ title: 'Did the thing' }), + presentCall: () => ({ card: 'generic', title: 'Doing a thing' }), + presentResult: () => ({ card: 'generic', title: 'Did the thing' }), } const presenter = new ToolPresenter(registryOf(minimal)) const updates = updatesWith( @@ -336,11 +337,30 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () => 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 () => { + it('an unknown render-intent card throws via the exhaustiveness guard (closed union)', () => { + // The bridge switches on `view.card` and ends with assertNever: a rogue card + // (only reachable by a cast — the union is closed) must throw, so adding a + // real variant later fails to compile at the switch instead of silently + // dropping the card. + const rogue: ToolDefinition = { + name: 'rogue', + description: 'r', + parameters: {}, + execute: async () => [], + // A card value outside the union — forced with a cast (no valid input reaches this). + presentCall: () => ({ card: 'chart', title: 'nope' }) as unknown as ReturnType>, + } + const presenter = new ToolPresenter(registryOf(rogue)) + expect(() => updatesWith(presenter, evt('tool/call', { + turn: 1, step: 1, callId: CallId('c1'), name: 'rogue', arguments: '{}', + }))).toThrow('unreachable variant') + }) + + it('forwards fs-tool render intents onto the wire (REAL read → generic locations, edit → diff content)', 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".) + // read's follow-along `locations` and edit's `diff` content block. (AGENTS.md + // "prefer the real implementation over a mock".) const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) @@ -352,44 +372,50 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () => turn: 1, step: 1, callId: CallId('r1'), name: 'read', arguments: JSON.stringify({ file_path: 'src/a.ts', offset: 12 }), })) + // A generic card: the read window is in the title, the offset drives the + // follow-along location line. No rawInput (the window lives in the title). 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 }], + sessionUpdate: 'tool_call', toolCallId: 'r1', title: 'Read src/a.ts (from line 12)', kind: 'read', + locations: [{ path: 'src/a.ts', line: 12 }], }) + expect((readCall as { rawInput?: unknown }).rawInput).toBeUndefined() 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' }), })) + // A diff card: `edit` kind, a `{ type: 'diff' }` content block carrying the + // literal old→new replacement, plus the follow-along location. expect(editCall).toMatchObject({ sessionUpdate: 'tool_call', toolCallId: 'e1', title: 'Edit src/b.ts', kind: 'edit', locations: [{ path: 'src/b.ts' }], + content: [{ type: 'diff', path: 'src/b.ts', oldText: 'x', newText: 'y' }], }) await ctx.fiber.dispose() }) }) describe('terminal-card mapping (capability-gated)', () => { - // A tool that asks to render as a terminal — a stand-in for tool-bash's shape, - // letting us drive the bridge's terminal mapping without the real executor. - type CallTerm = { cwd?: string } | undefined - type ResultTerm = { output?: string; exitCode?: number; signal?: string } | undefined - const termTool = (callTerminal: CallTerm, resultTerminal: ResultTerm): ToolDefinition => ({ + // A tool that renders as a terminal — a stand-in for tool-bash's shape, letting + // us drive the bridge's terminal mapping without the real executor. `callCard` + // selects a terminal call view (optionally with a cwd) or a generic one (for the + // orphan-guard test); `resultTerminal` is the terminal result view's output/exit. + type CallCard = { card: 'terminal'; cwd?: string } | { card: 'generic' } + type ResultTerm = { title?: string; output?: string; exitCode?: number; signal?: string } + const termTool = (callCard: CallCard, resultTerminal: ResultTerm): ToolDefinition => ({ name: 'bash', description: 'run a command', parameters: {}, execute: async () => [], - presentCall: (args: unknown) => ({ - title: (args as { command: string }).command, - kind: 'execute', - rawInput: (args as { command: string }).command, - content: [{ type: 'text', text: (args as { description: string }).description }], - ...callTerminal !== undefined ? { terminal: callTerminal } : {}, - }), - presentResult: () => ({ - content: [{ type: 'text', text: 'fallback' }], - ...resultTerminal !== undefined ? { terminal: resultTerminal } : {}, - }), + presentCall: (args: unknown) => { + const command = (args as { command: string }).command + const description = (args as { description: string }).description + if (callCard.card === 'terminal') { + return { card: 'terminal', title: command, description, ...callCard.cwd !== undefined ? { cwd: callCard.cwd } : {} } + } + return { card: 'generic', title: command, kind: 'execute', rawInput: command, content: [{ type: 'text', text: description }] } + }, + presentResult: () => ({ card: 'terminal', ...resultTerminal }), }) const callEvent = evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: JSON.stringify({ command: 'echo hi', description: 'Greet' }) }) @@ -403,7 +429,7 @@ describe('terminal-card mapping (capability-gated)', () => { } it('capability ON: description content THEN terminal block; cwd from the session header when the tool gives none', () => { - const [call, update] = termUpdates(termTool({}, { output: 'hi\n', exitCode: 0 }), true, '/work/proj', callEvent, resultEvent) + const [call, update] = termUpdates(termTool({ card: 'terminal' }, { output: 'hi\n', exitCode: 0 }), true, '/work/proj', callEvent, resultEvent) expect(call).toMatchObject({ sessionUpdate: 'tool_call', content: [ @@ -422,33 +448,33 @@ describe('terminal-card mapping (capability-gated)', () => { }) it('capability ON: an ABSOLUTE tool cwd wins; a RELATIVE one resolves against the session cwd', () => { - const [absCall] = termUpdates(termTool({ cwd: '/explicit/abs' }, { output: 'x' }), true, '/work/proj', callEvent) + const [absCall] = termUpdates(termTool({ card: 'terminal', cwd: '/explicit/abs' }, { output: 'x' }), true, '/work/proj', callEvent) expect((absCall as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('/explicit/abs') - const [relCall] = termUpdates(termTool({ cwd: 'sub/dir' }, { output: 'x' }), true, '/work/proj', callEvent) + const [relCall] = termUpdates(termTool({ card: 'terminal', cwd: 'sub/dir' }, { output: 'x' }), true, '/work/proj', callEvent) // Relative workdir resolved against the session cwd — the card header matches // where execution actually ran (tool-bash resolves the same way). expect((relCall as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('/work/proj/sub/dir') // No session cwd to resolve against → the relative tool cwd is passed through as-is. - const [noSessionCwd] = termUpdates(termTool({ cwd: 'rel/only' }, { output: 'x' }), true, undefined, callEvent) + const [noSessionCwd] = termUpdates(termTool({ card: 'terminal', cwd: 'rel/only' }, { output: 'x' }), true, undefined, callEvent) expect((noSessionCwd as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('rel/only') }) it('capability ON: a signal kill maps to terminal_exit.signal', () => { - const [, update] = termUpdates(termTool({}, { output: 'gone', signal: 'SIGKILL' }), true, '/w', callEvent, resultEvent) + const [, update] = termUpdates(termTool({ card: 'terminal' }, { output: 'gone', signal: 'SIGKILL' }), true, '/w', callEvent, resultEvent) expect((update as unknown as { _meta: { terminal_exit: unknown } })._meta.terminal_exit).toEqual({ terminal_id: 'c1', signal: 'SIGKILL' }) }) it('capability ON: a terminal result with output but NO exit/signal emits terminal_output and NO exit pill', () => { // A terminal-rendering tool that reports no structured exit (neither exitCode // nor signal) — the card shows output but no exit pill. - const [, update] = termUpdates(termTool({}, { output: 'partial' }), true, '/w', callEvent, resultEvent) + const [, update] = termUpdates(termTool({ card: 'terminal' }, { output: 'partial' }), true, '/w', callEvent, resultEvent) const meta = (update as unknown as { _meta: { terminal_output?: unknown; terminal_exit?: unknown } })._meta expect(meta.terminal_output).toEqual({ terminal_id: 'c1', data: 'partial' }) expect(meta.terminal_exit).toBeUndefined() }) - it('capability OFF: no terminal block or _meta; the description content and fenced result still render', () => { - const [call, update] = termUpdates(termTool({}, { output: 'hi\n' }), false, '/work/proj', callEvent, resultEvent) + it('capability OFF: no terminal block or _meta; the description content and the bridge-derived fenced result render', () => { + const [call, update] = termUpdates(termTool({ card: 'terminal' }, { output: 'hi\n' }), false, '/work/proj', callEvent, resultEvent) expect(call).toEqual({ sessionUpdate: 'tool_call', toolCallId: 'c1', @@ -458,24 +484,187 @@ describe('terminal-card mapping (capability-gated)', () => { rawInput: 'echo hi', content: [{ type: 'content', content: { type: 'text', text: 'Greet' } }], }) + // The bridge derives the fenced ```console fallback from the terminal output. expect(update).toEqual({ sessionUpdate: 'tool_call_update', toolCallId: 'c1', status: 'completed', - content: [{ type: 'content', content: { type: 'text', text: 'fallback' } }], + content: [{ type: 'content', content: { type: 'text', text: '```console\nhi\n```' } }], }) }) - it('orphan guard: a result-side terminal with NO call-side terminal is dropped (no orphan terminal_output)', () => { - // presentCall declares NO terminal, but presentResult returns one — the - // bridge must not emit _meta.terminal_output for a terminal Zed never made. - const [call, update] = termUpdates(termTool(undefined, { output: 'hi\n', exitCode: 0 }), true, '/w', callEvent, resultEvent) - // The call had no terminal → ordinary tool_call (description content, no _meta). + it('orphan guard: a result-side terminal with a GENERIC call is dropped (no orphan terminal_output)', () => { + // presentCall is a generic card, but presentResult returns a terminal view — + // the bridge must not emit _meta.terminal_output for a terminal Zed never made. + const [call, update] = termUpdates(termTool({ card: 'generic' }, { output: 'hi\n', exitCode: 0 }), true, '/w', callEvent, resultEvent) + // The call was generic → ordinary tool_call (description content, no _meta). expect((call as { _meta?: unknown })._meta).toBeUndefined() expect((call as { content: unknown }).content).toEqual([{ type: 'content', content: { type: 'text', text: 'Greet' } }]) - // The result falls back to text content; NO terminal _meta. + // The result falls back to the RAW result content (the tool/result event's text); NO terminal _meta. expect((update as { _meta?: unknown })._meta).toBeUndefined() - expect((update as { content: unknown }).content).toEqual([{ type: 'content', content: { type: 'text', text: 'fallback' } }]) + expect((update as { content: unknown }).content).toEqual([{ type: 'content', content: { type: 'text', text: 'hi\n' } }]) + }) + + it('capability ON: a terminal result title replaces the completed-card title; missing output emits empty data', () => { + // A terminal result MAY carry a replacement title and MAY omit output (a run + // that produced nothing) — the _meta carries empty data, not a dropped key. + const [, update] = termUpdates(termTool({ card: 'terminal' }, { title: 'Ran echo', exitCode: 0 }), true, '/w', callEvent, resultEvent) + expect(update).toEqual({ + sessionUpdate: 'tool_call_update', + toolCallId: 'c1', + status: 'completed', + title: 'Ran echo', + _meta: { terminal_output: { terminal_id: 'c1', data: '' }, terminal_exit: { terminal_id: 'c1', exit_code: 0 } }, + }) + }) + + it('capability OFF: a terminal result title rides on the fenced fallback update', () => { + const [, update] = termUpdates(termTool({ card: 'terminal' }, { title: 'Ran echo', output: 'hi\n' }), false, '/w', callEvent, resultEvent) + expect(update).toEqual({ + sessionUpdate: 'tool_call_update', + toolCallId: 'c1', + status: 'completed', + content: [{ type: 'content', content: { type: 'text', text: '```console\nhi\n```' } }], + title: 'Ran echo', + }) + }) + + it('a terminal call with NO description and NO capability is a bare execute card (no content key)', () => { + // A terminal view whose presentCall omits `description`, with the capability + // OFF: no description block and no terminal block → the card carries no content. + const noDesc: ToolDefinition = { + name: 'bash', + description: 'run a command', + parameters: {}, + execute: async () => [], + presentCall: (args: unknown) => ({ card: 'terminal', title: (args as { command: string }).command }), + } + const [call] = termUpdates(noDesc, false, undefined, callEvent) + expect(call).toEqual({ + sessionUpdate: 'tool_call', + toolCallId: 'c1', + title: 'echo hi', + kind: 'execute', + status: 'in_progress', + rawInput: 'echo hi', + }) + }) +}) + +describe('diff-card mapping', () => { + // A stand-in diff tool, letting us drive the bridge's diff arm across shapes + // the shipping fs tools don't emit (no locations, empty diffs). + const diffTool = (view: unknown): ToolDefinition => ({ + name: 'writer', + description: 'writes a file', + parameters: {}, + execute: async () => [], + presentCall: () => view as ReturnType>, + }) + function callUpdate(tool: ToolDefinition, cwd: string | undefined): SessionNotification['update'] { + const presenter = new ToolPresenter(registryOf(tool)) + const out: SessionNotification['update'][] = [] + streamSessionEventUpdate( + SessionId('s1'), + evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'writer', arguments: '{}' }), + n => out.push(n.update), + presenter, + { enabled: false, cwd }, + ) + return out[0]! + } + + it('a diff with NO locations relativizes the title off the first diff path; omits the locations key', () => { + const update = callUpdate(diffTool({ card: 'diff', title: 'Write /work/proj/a.txt', diffs: [{ path: '/work/proj/a.txt', oldText: null, newText: 'x' }] }), '/work/proj') + expect(update).toEqual({ + sessionUpdate: 'tool_call', + toolCallId: 'c1', + title: 'Write a.txt', + kind: 'edit', + status: 'in_progress', + content: [{ type: 'diff', path: '/work/proj/a.txt', oldText: null, newText: 'x' }], + }) + }) + + it('a diff with an EMPTY diffs array omits the content key (no diff blocks to send)', () => { + const update = callUpdate(diffTool({ card: 'diff', title: 'Write nothing', diffs: [] }), undefined) + expect(update).toEqual({ + sessionUpdate: 'tool_call', + toolCallId: 'c1', + title: 'Write nothing', + kind: 'edit', + status: 'in_progress', + }) + }) +}) + +describe('relative-path display titles (bridge relativizes the title against the session cwd)', () => { + // The bridge relativizes a file card's TITLE against the session workspace cwd + // (mirroring the reference adapter's toDisplayPath), while leaving locations/ + // diff paths RAW. Drive it with the REAL fs tools so the title/locations come + // from the shipping presentCall, and pass an ABSOLUTE file path (which a real + // editor forwards). The presenter is pure/args-only; the cwd is known only here. + async function fsCtx(): Promise { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(FsLocal) + await ctx.plugin(ToolFs) + return ctx + } + function callUpdate(ctx: Context, sessionCwd: string | undefined, name: string, args: unknown): SessionNotification['update'] { + const presenter = new ToolPresenter(ctx.tools) + const out: SessionNotification['update'][] = [] + streamSessionEventUpdate( + SessionId('s1'), + evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name, arguments: JSON.stringify(args) }), + n => out.push(n.update), + presenter, + { enabled: false, cwd: sessionCwd }, + ) + return out[0]! + } + + it('read: an absolute path inside the workspace relativizes the TITLE; the location path stays absolute', async () => { + const ctx = await fsCtx() + const update = callUpdate(ctx, '/work/proj', 'read', { file_path: '/work/proj/src/a.ts', offset: 5 }) + expect(update).toMatchObject({ + title: 'Read src/a.ts (from line 5)', + locations: [{ path: '/work/proj/src/a.ts', line: 5 }], + }) + await ctx.fiber.dispose() + }) + + it('edit: the diff TITLE relativizes; the diff/location paths stay absolute (the editor opens the real path)', async () => { + const ctx = await fsCtx() + const update = callUpdate(ctx, '/work/proj', 'edit', { file_path: '/work/proj/src/b.ts', old_string: 'x', new_string: 'y' }) + expect(update).toMatchObject({ + title: 'Edit src/b.ts', + locations: [{ path: '/work/proj/src/b.ts' }], + content: [{ type: 'diff', path: '/work/proj/src/b.ts', oldText: 'x', newText: 'y' }], + }) + await ctx.fiber.dispose() + }) + + it('a path OUTSIDE the workspace is left as-is (no `..` title)', async () => { + const ctx = await fsCtx() + const update = callUpdate(ctx, '/work/proj', 'read', { file_path: '/etc/passwd' }) + expect((update as { title: string }).title).toBe('Read /etc/passwd') + await ctx.fiber.dispose() + }) + + it('no session cwd → the absolute title is left unchanged', async () => { + const ctx = await fsCtx() + const update = callUpdate(ctx, undefined, 'read', { file_path: '/work/proj/src/a.ts' }) + expect((update as { title: string }).title).toBe('Read /work/proj/src/a.ts') + await ctx.fiber.dispose() + }) + + it('a relative path is passed through unchanged (already display-friendly)', async () => { + const ctx = await fsCtx() + const update = callUpdate(ctx, '/work/proj', 'read', { file_path: 'src/a.ts' }) + expect((update as { title: string }).title).toBe('Read src/a.ts') + await ctx.fiber.dispose() }) }) From af79ceea1c03e4ded615fb23cd73894cbf2f7a64 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 3 Jul 2026 02:27:20 +0800 Subject: [PATCH 39/75] fix(acp): exhaustive result-card switch + tighten display-path guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the render-intent-union review: - toolResultUpdate branched on `if (card === 'terminal')` with a generic fallthrough; ToolResultView is a closed union, so make it an exhaustive `switch (view.card)` ending in assertNever (matching the call-side renderer and the § Conventions closed-union rule). Adding a result card later now fails to compile at the switch. Regression test: a rogue result card throws. - displayTitle's `rel.startsWith('..')` guard mis-rejected an in-workspace target whose relative form merely begins with the chars `..` (e.g. `..cache/x`, a real sibling name), leaving its title absolute. Test for a `..` SEGMENT (`..` alone or `..…`) so such paths relativize, matching claude-agent-acp's `cwd + sep` prefix check. Regression test added. --- packages/ui/acp/src/index.ts | 70 ++++++++++++--------- packages/ui/acp/tests/stream-update.spec.ts | 31 +++++++++ 2 files changed, 70 insertions(+), 31 deletions(-) diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 6d388e9f89..c01c004d91 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -36,7 +36,7 @@ import type { Context } from 'cordis' import { Readable, Writable } from 'node:stream' import { randomUUID } from 'node:crypto' -import { isAbsolute, relative as relativePath, resolve as resolvePath } from 'node:path' +import { isAbsolute, relative as relativePath, resolve as resolvePath, sep as pathSep } from 'node:path' import Schema from 'schemastery' import { AgentSideConnection, @@ -1008,9 +1008,12 @@ type AcpToolCallContent = function displayTitle(title: string, rawPath: string | undefined, sessionCwd: string | undefined): string { if (rawPath === undefined || sessionCwd === undefined || !isAbsolute(rawPath) || !isAbsolute(sessionCwd)) return title const rel = relativePath(sessionCwd, rawPath) - // `relative` returns a `..`-prefixed path for a target outside the workspace; - // only relativize paths that stay inside it (and never to the empty string). - if (rel.length === 0 || rel.startsWith('..')) return title + // Only relativize a target that stays INSIDE the workspace. `relative` prefixes + // a `..` SEGMENT for a target above the cwd — test for the segment (`..` alone + // or `..…`), NOT a bare `..` char prefix, so a sibling like `..cache/x` + // (a real in-workspace name) still relativizes. Never relativize to the empty + // string (rawPath === cwd — a non-file target). + if (rel.length === 0 || rel === '..' || rel.startsWith(`..${pathSep}`)) return title return title.split(rawPath).join(rel) } @@ -1127,39 +1130,44 @@ function terminalExitMeta(callId: string, view: TerminalResultView): TerminalExi */ function toolResultUpdate(callId: CallId, view: ToolResultView, isError: boolean, terminal: TerminalRendering): ToolCallSessionUpdate { const status = isError ? 'failed' as const : 'completed' as const - if (view.card === 'terminal') { - const output = view.output ?? '' - if (terminal.enabled) { + switch (view.card) { + case 'terminal': { + const output = view.output ?? '' + if (terminal.enabled) { + return { + sessionUpdate: 'tool_call_update', + toolCallId: callId, + status, + ...view.title !== undefined ? { title: view.title } : {}, + _meta: { + terminal_output: { terminal_id: callId, data: output }, + ...terminalExitMeta(callId, view), + }, + } + } + // No terminal capability: the bridge derives the fenced ```console fallback. + const fenced = `\`\`\`console\n${output.replace(/\n+$/, '')}\n\`\`\`` return { sessionUpdate: 'tool_call_update', toolCallId: callId, status, + content: [{ type: 'content', content: { type: 'text', text: fenced } }], ...view.title !== undefined ? { title: view.title } : {}, - _meta: { - terminal_output: { terminal_id: callId, data: output }, - ...terminalExitMeta(callId, view), - }, } } - // No terminal capability: the bridge derives the fenced ```console fallback. - const fenced = `\`\`\`console\n${output.replace(/\n+$/, '')}\n\`\`\`` - return { - sessionUpdate: 'tool_call_update', - toolCallId: callId, - status, - content: [{ type: 'content', content: { type: 'text', text: fenced } }], - ...view.title !== undefined ? { title: view.title } : {}, - } - } - // The presenter fills a generic result's content from the raw result, so - // `content` is always defined here; the guard keeps this total for a - // directly-constructed view. - return { - sessionUpdate: 'tool_call_update', - toolCallId: callId, - status, - /* v8 ignore next -- content always defined via the presenter (see above) */ - ...view.content !== undefined ? { content: toolResultContent(view.content) } : {}, - ...view.title !== undefined ? { title: view.title } : {}, + case 'generic': + return { + sessionUpdate: 'tool_call_update', + toolCallId: callId, + status, + // The presenter fills a generic result's content from the raw result, so + // `content` is always defined here; the guard keeps this total for a + // directly-constructed view. + /* v8 ignore next -- content always defined via the presenter (see above) */ + ...view.content !== undefined ? { content: toolResultContent(view.content) } : {}, + ...view.title !== undefined ? { title: view.title } : {}, + } + default: + return assertNever(view, 'ToolResultView.card') } } diff --git a/packages/ui/acp/tests/stream-update.spec.ts b/packages/ui/acp/tests/stream-update.spec.ts index 85e21d19a2..9c1898b3c1 100644 --- a/packages/ui/acp/tests/stream-update.spec.ts +++ b/packages/ui/acp/tests/stream-update.spec.ts @@ -356,6 +356,26 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () => }))).toThrow('unreachable variant') }) + it('an unknown render-intent RESULT card throws via the exhaustiveness guard (closed union)', () => { + // The result-side renderer is also an exhaustive switch + assertNever: a rogue + // result card (only reachable by a cast) must throw, so adding a real result + // variant later fails to compile at the switch. + const rogue: ToolDefinition = { + name: 'rogue', + description: 'r', + parameters: {}, + execute: async () => [], + presentCall: () => ({ card: 'generic', title: 'r' }), + presentResult: () => ({ card: 'chart' }) as unknown as ReturnType>, + } + const presenter = new ToolPresenter(registryOf(rogue)) + expect(() => updatesWith( + presenter, + evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'rogue', arguments: '{}' }), + evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'x' }], isError: false }), + )).toThrow('unreachable variant') + }) + it('forwards fs-tool render intents onto the wire (REAL read → generic locations, edit → diff content)', 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 — @@ -653,6 +673,17 @@ describe('relative-path display titles (bridge relativizes the title against the await ctx.fiber.dispose() }) + it('an in-workspace file whose relative form starts with `..` chars (a sibling name) still relativizes', async () => { + // `/work/proj/..cache/x` is INSIDE the workspace — its relative form + // `..cache/x` begins with the chars `..` but is NOT a parent segment. The + // guard tests for a `..` SEGMENT, so this relativizes (matching the reference + // adapter, which accepts any target under `cwd + sep`). + const ctx = await fsCtx() + const update = callUpdate(ctx, '/work/proj', 'read', { file_path: '/work/proj/..cache/x.ts' }) + expect((update as { title: string }).title).toBe('Read ..cache/x.ts') + await ctx.fiber.dispose() + }) + it('no session cwd → the absolute title is left unchanged', async () => { const ctx = await fsCtx() const update = callUpdate(ctx, undefined, 'read', { file_path: '/work/proj/src/a.ts' }) From 4d89bb3e7485d098697cb1c38bebaf464c1ccf3f Mon Sep 17 00:00:00 2001 From: Ziya <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:12:25 -0700 Subject: [PATCH 40/75] docs: bilingual docs contract, translation skill, and pairing gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Establish EN->ZH bilingual documentation for the README and docs tree: - docs/i18n/README.md — the pairing contract: sibling foo.md <-> foo.zh.md, English canonical, blob-hash source fingerprints, language switchers, scope/exclusions, and a manifest-driven rollout ratchet. - docs/i18n/translation-rules.md — how to translate: faithfulness, structure preservation, terminology discipline over docs/i18n/terminology.md, and typography rules grounded in MDN/K8s/Vue/clreq conventions. - .agents/skills/dsh-translate-docs — the committed agent workflow, following the dsh-code-review pattern of deferring to docs as sources of truth. - scripts/verify-translation-pairing.ts + manifest — a doc-sync gate: required pairs exist; every existing .zh.md is fresh (fingerprint = current source blob), switcher-linked, structure-matched, and non-orphaned; excluded (generated) docs stay unpaired. --list prints the translation work list. - RFC (implemented/process) recording the decision and the alternatives. - Dogfood: README.zh.md and the two i18n docs translated under their own rules. Gates: doc-sync green including the new gate; red/green proven for stale fingerprint, orphan, and excluded-file violations. --- .agents/skills/dsh-code-review/SKILL.md | 2 +- .agents/skills/dsh-translate-docs/SKILL.md | 56 +++++ AGENTS.md | 8 +- README.md | 2 + README.zh.md | 26 +++ docs/i18n/README.md | 47 ++++ docs/i18n/README.zh.md | 49 +++++ docs/i18n/translation-rules.md | 60 ++++++ docs/i18n/translation-rules.zh.md | 62 ++++++ docs/rfc/README.md | 1 + ...6-07-02-bilingual-docs-and-pairing-gate.md | 31 +++ package.json | 3 +- scripts/translation-pairing.manifest.json | 14 ++ scripts/verify-md-links.ts | 1 + scripts/verify-md-wrap.ts | 2 +- scripts/verify-translation-pairing.ts | 201 ++++++++++++++++++ 16 files changed, 560 insertions(+), 5 deletions(-) create mode 100644 .agents/skills/dsh-translate-docs/SKILL.md create mode 100644 README.zh.md create mode 100644 docs/i18n/README.md create mode 100644 docs/i18n/README.zh.md create mode 100644 docs/i18n/translation-rules.md create mode 100644 docs/i18n/translation-rules.zh.md create mode 100644 docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md create mode 100644 scripts/translation-pairing.manifest.json create mode 100644 scripts/verify-translation-pairing.ts diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index 54510133db..addc09f2ac 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -34,7 +34,7 @@ These come straight from the source docs above. They are not discretionary; abse 1. **Docs in sync.** If the PR changes a config key, default, error code, wire field, or event name, it must update the package README + module/JSDoc in the same diff. The `doc-sync` gate (check #4) does not catch prose drift in config keys, defaults, error codes, or wire fields — that is on the reviewer, but it is still required, not optional. 2. **Core-data-structures catalog in sync.** If the PR adds, removes, or reshapes a type the [core-data-structures catalog](../../../docs/core-data-structures/core.md) documents — a new `…Map` variant, a new content-block/session-event type, a field on `GenerateOptions`/`Agent`/`ToolDefinition`/a bash type, or a whole new core/seam type — it must update that catalog in the same diff (prose + any verbatim ` ```ts type-equiv ` block + the 1:1 `scripts/type-equiv.manifest.json`). The `verify-type-equiv` gate (part of `doc-sync`) catches a *drifted paste* of an already-documented type, but it cannot tell you a brand-new core type went undocumented — that judgment is yours. Confirm a genuinely spine-level type landed in core.md and a new capability's vocabulary on a sub-page, per the spine-vs-seam line in [core.md § What counts as "core"](../../../docs/core-data-structures/core.md#what-counts-as-core). A pure internal type with no cross-package reach needs no catalog entry — say so if it's a judgment call. 3. **HMR-safety test.** Any new registry/registration needs a test that disposes the contributing fiber and asserts cleanup (packages/AGENTS.md). Its absence blocks merge. -4. **Quality gates pass.** typecheck, lint, test, test:coverage (100% per-file on `packages/*/src`), knip, build, publint, constraints, `doc-sync` (doc-typecheck + verify-cordis-catalog + verify-md-wrap + verify-md-links + verify-type-equiv), module-graph freshness (the quality-gates RFC). Don't re-review what a gate already enforces — trust the gate and spend attention on what it can't check. Note that the `doc-sync` gate only covers compilable `ts` blocks, the generated cordis events/services catalog, markdown wrapping/links, and verbatim type-equiv blocks; prose drift (checks #1 and #2) is *additional* manual review on top of it, not covered by it. +4. **Quality gates pass.** typecheck, lint, test, test:coverage (100% per-file on `packages/*/src`), knip, build, publint, constraints, `doc-sync` (doc-typecheck + verify-cordis-catalog + verify-md-wrap + verify-md-links + verify-type-equiv + verify-translation-pairing), module-graph freshness (the quality-gates RFC). Don't re-review what a gate already enforces — trust the gate and spend attention on what it can't check. Note that the `doc-sync` gate only covers compilable `ts` blocks, the generated cordis events/services catalog, markdown wrapping/links, verbatim type-equiv blocks, and the bilingual pairing contract ([docs/i18n/README.md](../../../docs/i18n/README.md)); prose drift (checks #1 and #2) and translation *quality* (the [dsh-translate-docs](../dsh-translate-docs/SKILL.md) rules) are *additional* manual review on top of it, not covered by it. ## Reviewer-only checks (gates can't catch these — judgment required) diff --git a/.agents/skills/dsh-translate-docs/SKILL.md b/.agents/skills/dsh-translate-docs/SKILL.md new file mode 100644 index 0000000000..8fb231d2fc --- /dev/null +++ b/.agents/skills/dsh-translate-docs/SKILL.md @@ -0,0 +1,56 @@ +--- +name: dsh-translate-docs +description: Use when creating or updating Chinese (.zh.md) translations of this repo's documentation — orients the translator to the bilingual pairing contract, the terminology source of truth, the translation rules, and the freshness gate that verifies the result +--- + +# Translating DeepSeek-Harness docs + +**This skill is guidance, not a translation memory.** It is the workflow map for producing `.zh.md` files that pass the pairing gate and read as natural technical Chinese. You are the translator: the rules below say what must hold, not how to phrase any particular sentence — phrasing judgment is yours, terminology is not. + +## Sources of truth (read, don't re-summarize) + +These are authoritative; read them at the source so this skill never drifts out of sync. + +- **[docs/i18n/README.md](../../../docs/i18n/README.md)** — the pairing contract: sibling `foo.md ↔ foo.zh.md`, the `i18n-source` fingerprint format, the language-switcher lines, scope/exclusions, and the rollout manifest. +- **[docs/i18n/translation-rules.md](../../../docs/i18n/translation-rules.md)** — how to translate: faithfulness, structure preservation, terminology discipline, typography (MUST/SHOULD levels). +- **[docs/i18n/terminology.md](../../../docs/i18n/terminology.md)** — the terminology table. Load it BEFORE translating, not when a term feels uncertain; the terms you don't notice are the ones that drift. + +## Find the work + +- `pnpm run verify-translation-pairing --list` prints every in-scope document as missing / stale / ok — the work list for a translation batch. +- In a PR that edits English docs, the work list is the diff itself: every changed `.md` with an existing `.zh.md` sibling needs its translation updated in the same PR, and the gate goes red if you forget. + +## Triage by change type + +Do not process every file the same way: + +- **New translation** (no `.zh.md` yet): translate the whole file, section by section for long documents — keep each section's structure locked to the source as you go rather than fixing structure at the end. +- **Update** (`.zh.md` exists but stale): do NOT re-translate the file. The fingerprint names the exact source text the translation was based on — recover it and diff: + + ```sh + git cat-file -p > /tmp/old-source.md + git diff --no-index /tmp/old-source.md docs/foo.md + ``` + + Apply the smallest Chinese edits that cover that diff. A minimal update preserves the reviewed phrasing of everything that didn't change; a re-translation throws that review away. +- **Deleted or renamed source**: delete or rename the `.zh.md` alongside it — the gate reports it as an orphan otherwise. + +## Translate + +- Work through the document applying [translation-rules.md](../../../docs/i18n/translation-rules.md). Internally: first render faithfully, then re-read the Chinese alone for awkward or ambiguous phrasing, then polish — but write ONLY the final Chinese to the file, never drafts or notes. +- Every term in [terminology.md](../../../docs/i18n/terminology.md) renders exactly as specified, including first-occurrence annotations. A term the table misses: translate only with a citable precedent from a major Chinese OSS/vendor doc; otherwise keep the English and add it to the PR's 「待定术语」 list with your suggested rendering. Never invent a rendering inline — that decision belongs to a human and then to the table. +- Code blocks are byte-identical to the source, comments included. Relative links keep their English targets; only the switcher line links `.zh.md`. + +## Finish the pair + +1. Fingerprint: compute the source's current blob hash and write the comment as the FIRST line of the `.zh.md` — `git hash-object docs/foo.md` → ``. +2. Switcher: `[English](foo.md) | 中文` immediately after the translation's H1; confirm the English file carries `English | [中文](foo.zh.md)` after its own H1 — add it if this is the pair's first translation. +3. New batch landed? Add the English paths to `required` in [scripts/translation-pairing.manifest.json](../../../scripts/translation-pairing.manifest.json) so the gate ratchets forward. + +## Verify — the gate, not your eyes + +Run `pnpm run verify-translation-pairing`, then the rest of the Markdown gates (`pnpm run verify-md-wrap && pnpm run verify-md-links`, or full `pnpm run doc-sync` before the PR). Fix what they report; do not hand-check what they cover. What they can NOT check — translation quality, terminology judgment calls, tone — is exactly what the PR reviewer will read for, so keep the PR reviewable: state which files are new translations vs minimal updates, and list 「待定术语」 prominently. + +## How to respond to translation review + +Same discipline as any review in this repo (see [dsh-code-review](../dsh-code-review/SKILL.md) § How to respond): evaluate each comment on its merits, and for terminology comments, remember the table is the contract — a reviewer's rendering decision gets applied to [terminology.md](../../../docs/i18n/terminology.md) so it binds every future translation, not just patched into one file. diff --git a/AGENTS.md b/AGENTS.md index 4bdeb727fe..617a16089f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -177,9 +177,13 @@ pnpm run verify-package-paths # assert every packages/ cited in Markdown pnpm run verify-rfc-classification # assert every RFC lives in a valid # {lifecycle}/{class}/ folder and docs/rfc/README.md lists it # under the matching heading (closed class set + index completeness) +pnpm run verify-translation-pairing # assert the bilingual pairing contract + # (docs/i18n/README.md): required docs have a .zh.md sibling; + # every .zh.md is fingerprint-fresh, switcher-linked, and + # structure-matched. `--list` prints the translation work list pnpm run verify-node-next-types # assert built declarations typecheck for a # standard external NodeNext ESM TypeScript consumer -pnpm run doc-sync # doc-typecheck + verify-cordis-catalog + verify-md-wrap + verify-md-links + verify-doc-refs + verify-package-paths + verify-rfc-classification + verify-type-equiv (CI runs this) +pnpm run doc-sync # doc-typecheck + verify-cordis-catalog + verify-tool-catalog + verify-md-wrap + verify-md-links + verify-doc-refs + verify-package-paths + verify-rfc-classification + verify-type-equiv + verify-translation-pairing (CI runs this) pnpm run demo:echo # run examples/echo-agent (no API key; type "echo hi" to # see a tool call) — the mock skeleton pnpm run demo:coding # run examples/coding-agent — the real agent (needs @@ -276,7 +280,7 @@ This codebase aims to be **very type-safe and well documented** for maintainabil In the **core** packages (`packages/llm/llm`, `packages/core/tools`, `packages/core/agent`, `packages/core/agent-loop`, `packages/core/session`, `packages/core/system-prompt`), **type gymnastics are acceptable when they improve the DX of plugin authors** for common plugin types. The `defineTool` typed schema DSL in `dsh-tools` is the canonical example: the `SchemaSpec` to `InferArgs` type-level mapping gives tool authors zero-cast typed `execute` args, and the cost of the conditional types stays inside the core package. -Verbose documentation is fine **as long as docs and code stay strictly in sync**. Out-of-sync docs are worse than no docs. **When you change code, update its docs in the SAME change** — grep the package README and the module/JSDoc comments for the old behavior (config keys, defaults, error codes, wire field names, event names) and fix every hit. CI runs `pnpm run doc-sync` (`doc-typecheck` + `verify-cordis-catalog` + `verify-md-wrap` + `verify-md-links` + `verify-doc-refs` + `verify-package-paths` + `verify-rfc-classification` + `verify-type-equiv`), which typechecks every fenced `ts` block in `README.md`, `docs/**/*.md`, and `packages/*/*.md`, regenerates the cordis events/services catalog from source and fails if the committed copy is stale, asserts no hard-wrapped prose paragraphs, checks that every relative Markdown cross-link resolves, checks that every `docs/*.md` path cited in a source comment resolves, checks that every `packages/` reference naming a real package resolves, checks that every RFC is filed under a valid class folder and listed in its index, and checks that every ` ```ts type-equiv ` doc block still matches its source type — across those files plus `AGENTS.md` / `packages/AGENTS.md` — but that scope does NOT catch prose drift in `AGENTS.md` / `packages/AGENTS.md` / `packages/README.md` (config keys, defaults, error codes), so keeping those in sync remains on the author. Every module has a module-level doc comment explaining its role. Every exported class, interface, type, function, and non-obvious method has a JSDoc that explains semantics (not just the name) — contracts (what events fire when), disposal behavior, error behavior, and extension intent. Internal helpers get docs only where non-obvious. Prefer one-liners when one line suffices. +Verbose documentation is fine **as long as docs and code stay strictly in sync**. Out-of-sync docs are worse than no docs. **When you change code, update its docs in the SAME change** — grep the package README and the module/JSDoc comments for the old behavior (config keys, defaults, error codes, wire field names, event names) and fix every hit. CI runs `pnpm run doc-sync` (`doc-typecheck` + `verify-cordis-catalog` + `verify-tool-catalog` + `verify-md-wrap` + `verify-md-links` + `verify-doc-refs` + `verify-package-paths` + `verify-rfc-classification` + `verify-type-equiv` + `verify-translation-pairing`), which typechecks every fenced `ts` block in `README.md`, `docs/**/*.md`, and `packages/*/*.md`, regenerates the cordis events/services catalog from source and fails if the committed copy is stale, asserts no hard-wrapped prose paragraphs, checks that every relative Markdown cross-link resolves, checks that every `docs/*.md` path cited in a source comment resolves, checks that every `packages/` reference naming a real package resolves, checks that every RFC is filed under a valid class folder and listed in its index, checks that every ` ```ts type-equiv ` doc block still matches its source type, and checks the bilingual pairing contract (required docs have a fresh `.zh.md` sibling — see [docs/i18n/README.md](docs/i18n/README.md)) — across those files plus `AGENTS.md` / `packages/AGENTS.md` — but that scope does NOT catch prose drift in `AGENTS.md` / `packages/AGENTS.md` / `packages/README.md` (config keys, defaults, error codes), so keeping those in sync remains on the author. The same-change rule extends to translations: **editing an English doc that has a `.zh.md` sibling means updating the translation in the SAME change** (run the [dsh-translate-docs](.agents/skills/dsh-translate-docs/SKILL.md) skill); the pairing gate goes red otherwise. Every module has a module-level doc comment explaining its role. Every exported class, interface, type, function, and non-obvious method has a JSDoc that explains semantics (not just the name) — contracts (what events fire when), disposal behavior, error behavior, and extension intent. Internal helpers get docs only where non-obvious. Prefer one-liners when one line suffices. **Tag every new event with `@mode`.** The cordis events/services catalog ([docs/cordis-catalog/events-and-services.md](docs/cordis-catalog/events-and-services.md)) is GENERATED from source by `scripts/gen-cordis-catalog.ts` — never hand-edit it; run `pnpm run gen-cordis-catalog` and commit the result. When you add an event to an `interface Events` block, its JSDoc MUST carry a `@mode emit|waterfall|parallel|serial` tag (the generator hard-errors without it): use `waterfall` when the signature ends with a `next: () => …` parameter (the listener transforms or vetoes via `next()`), `parallel` when the loop awaits a fan-out and must run every listener (e.g. an awaited `Promise | void` checkpoint like `session/flush`), `serial` when the loop awaits listeners in registration order and should isolate side effects (e.g. an ordered surface-mutation checkpoint like `agent/pre-step`; Cordis stops early if a listener returns a bail value, so `void` serial listeners must not return a semantic veto), and `emit` for plain fire-and-forget notifications. The generator also cross-checks the tag against the signature where the shape is conclusive (a trailing `next` ⇒ waterfall) and hard-errors on a contradiction. Write the rest of the event's JSDoc to stand alone — it is the catalog entry's prose. diff --git a/README.md b/README.md index 1ce5aa8960..33c03fad14 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # DeepSeek Harness +English | [中文](README.zh.md) + Monorepo for the DeepSeek Harness group. ## Projects diff --git a/README.zh.md b/README.zh.md new file mode 100644 index 0000000000..4c911a42e0 --- /dev/null +++ b/README.zh.md @@ -0,0 +1,26 @@ + + +# DeepSeek Harness + +[English](README.md) | 中文 + +DeepSeek Harness 小组的 monorepo。 + +## 项目 + +- **DeepSeek Code** — DeepSeek 的编码 agent(智能体)产品。 + +## 开发 + +本 monorepo 基于 [Cordis](https://github.com/cordiverse/cordis) 框架构建(以源码形式收录在 `vendor/` 下),采用微内核风格:一切皆插件。 + +```sh +pnpm install +pnpm run test # vitest +pnpm run demo:echo # runnable echo-agent example (no API key needed) +pnpm run demo:coding # the real DeepSeek coding agent (needs DEEPSEEK_API_KEY) +``` + +面向人类读者:先读[开发指南](docs/development.md)了解本地环境、钩子、环境变量与质量门禁,动手改 package 之前再读[架构设计](docs/architecture.md)。局部上下文见 [packages/](packages/) 与 [vendor/](vendor/)。 + +面向 agent:遵循 [AGENTS.md](AGENTS.md)。 diff --git a/docs/i18n/README.md b/docs/i18n/README.md new file mode 100644 index 0000000000..e70a1fed0d --- /dev/null +++ b/docs/i18n/README.md @@ -0,0 +1,47 @@ +# Bilingual documentation + +English | [中文](README.zh.md) + +This repo's documentation is read by people and agents both inside and outside the company, so the README and the docs tree are maintained in English and Simplified Chinese. This page defines the pairing contract, the enforcement gate, and the rollout policy; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. The committed agent workflow lives in [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md). + +## The pairing contract + +- **English is canonical.** Every document is authored in English at its existing path, and the Chinese file is derived from it — translation flows EN → ZH only. A content change starts in the English file; the Chinese file never carries information its English source lacks. +- **Paired sibling files.** The translation of `foo.md` is `foo.zh.md` in the same directory. No locale directories, no separate translation repo, no interleaved bilingual files. +- **Source fingerprint.** The FIRST line of every `.zh.md` file is an HTML comment recording the repo-relative path and the git blob hash (first 12 hex digits of `git hash-object`) of the English source it was translated from: + + ```markdown + + ``` + + A blob hash, not a commit hash, so the fingerprint is computable for an English file edited in the same PR (`git hash-object docs/foo.md`), and so staleness is a pure content comparison. The fingerprint is also the update tool: `git cat-file -p ` recovers the exact source text a stale translation was based on, and `git diff ` isolates what changed so the translation can be updated minimally instead of re-translated. +- **Language switcher.** Both files link to each other immediately after their H1 heading: the English file carries `English | [中文](foo.zh.md)` and the Chinese file carries `[English](foo.md) | 中文`. +- **Structure mirrors the source.** Heading hierarchy, list shape, table columns, and code blocks match the English file one to one — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`). + +## The gate: verify-translation-pairing + +`pnpm run verify-translation-pairing` (part of `doc-sync`, so CI and the pre-push hook run it) enforces the contract mechanically: + +1. Every English file listed as `required` in [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) has a `.zh.md` sibling. +2. Every existing `.zh.md` file — required or not — passes all of: its English source exists (no orphans), its fingerprint matches the source's current blob hash (no stale translations), both sides carry the language switcher, and its fenced-code-block and heading counts equal the source's. +3. Files listed as `excluded` have no `.zh.md` sibling at all. + +`pnpm run verify-translation-pairing --list` prints the current translation state of every document in scope — missing, stale, or ok — and is the work list for translation batches. It never fails; it reports. + +The practical rule this gate creates: **when a PR edits an English document that has a `.zh.md` sibling, the same PR updates the translation** (run the [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill), exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a translation stale goes red in CI. + +## Scope, exclusions, and rollout + +**Scope**: the root `README.md` and everything under `docs/**`. Package READMEs (`packages/**`) join the scope in a later batch. + +**Excluded** (never paired, and the gate rejects a `.zh.md` for them): + +- `docs/cordis-catalog/`, `docs/tool-catalog/`, `docs/module-graph.md` — generated files; their generators emit English only, so a translation would go stale on every regeneration. +- `docs/AGENTS.md` — agent instructions, maintained in English only like the root `AGENTS.md`. +- `docs/i18n/terminology.md` — the terminology table is itself bilingual by construction. + +**Rollout**: the `required` list in the manifest is the enforcement frontier, not the goal. The goal is full bilingual coverage of the scope. Translation lands in reviewable batches (core entry docs, cookbook, RFCs, postmortems, …); each merged batch adds its files to `required`, so the gate ratchets forward and never regresses. Documents not yet in `required` are backlog — visible in `--list` — but any translation that already exists is held to the full contract regardless of the list. + +## Division of labor + +Translations here are produced by an agent running [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) and reviewed by a human — inference is cheap here, review attention is the scarce resource. The gate exists so that neither the agent nor the reviewer has to remember the contract: pairing, freshness, and structure are checked mechanically, and review attention goes to translation quality and terminology, where human judgment is the whole point. diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md new file mode 100644 index 0000000000..12b0f736d7 --- /dev/null +++ b/docs/i18n/README.zh.md @@ -0,0 +1,49 @@ + + +# 双语文档 + +[English](README.md) | 中文 + +本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此 README 与 docs 目录树以英文和简体中文双语维护。本页定义配对契约、强制门禁与推进策略;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。进仓的 agent 工作流见 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。 + +## 配对契约 + +- **英文是唯一真源。**每篇文档都以英文在其现有路径撰写,中文文件由它派生——翻译只沿 EN → ZH 单向流动。内容变更始于英文文件;中文文件永远不携带英文源没有的信息。 +- **配对的同目录文件。**`foo.md` 的译文是同目录下的 `foo.zh.md`。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。 +- **源指纹。**每个 `.zh.md` 文件的第一行是一条 HTML 注释,记录它翻译所依据的英文源的仓库相对路径和 git blob hash(`git hash-object` 的前 12 位十六进制): + + ```markdown + + ``` + + 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的英文文件也能算出指纹(`git hash-object docs/foo.md`),过期检测则是纯内容比较。指纹同时也是更新工具:`git cat-file -p ` 能还原过期译文当初依据的确切源文本,`git diff <当前 blob>` 能隔离出变化的部分,让译文做最小更新而不是整篇重译。 +- **语言切换行。**两个文件在各自 H1 标题之后立即互链:英文文件带 `English | [中文](foo.zh.md)`,中文文件带 `[English](foo.md) | 中文`。 +- **结构与源一一对应。**标题层级、列表形态、表格列与代码块和英文文件一一对应——完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。 + +## 门禁:verify-translation-pairing + +`pnpm run verify-translation-pairing`(`doc-sync` 的一环,因此 CI 和 pre-push 钩子都会运行)机械地强制这份契约: + +1. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 中 `required` 列出的每个英文文件都有 `.zh.md` 配对文件。 +2. 每个已存在的 `.zh.md` 文件——无论是否 required——都通过全部检查:其英文源存在(无孤儿)、指纹等于源的当前 blob hash(无过期译文)、双方都带语言切换行、其代码块与标题数量等于源文件。 +3. 列为 `excluded` 的文件完全没有 `.zh.md` 配对。 + +`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前翻译状态——missing、stale 或 ok——是翻译批次的工作清单。它从不失败;它只报告。 + +这个门禁带来的实际规则是:**当一个 PR 修改了已有 `.zh.md` 配对的英文文档时,同一个 PR 更新译文**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill),与本仓库既有的代码/README doc-sync 规则完全一致。留下过期译文的 PR 会在 CI 变红。 + +## 范围、排除与推进 + +**范围**:根 `README.md` 与 `docs/**` 下的全部内容。package README(`packages/**`)在后续批次加入范围。 + +**排除**(永不配对,门禁拒绝为它们建 `.zh.md`): + +- `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/module-graph.md` —— 生成文件;生成器只输出英文,译文在每次重新生成时必然过期。 +- `docs/AGENTS.md` —— agent 指令,与根 `AGENTS.md` 一样只以英文维护。 +- `docs/i18n/terminology.md` —— 术语表本身即是双语构造。 + +**推进**:manifest 中的 `required` 列表是强制边界,不是目标。目标是范围内的全量双语覆盖。翻译按可评审的批次落地(核心入口文档、cookbook、RFC、postmortem……);每个批次合入后把其文件加进 `required`,门禁只进不退。尚未进入 `required` 的文档是 backlog——在 `--list` 中可见——但任何已存在的译文无论在不在清单里都按完整契约检查。 + +## 分工 + +这里的译文由运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 的 agent 产出、由人评审——在这里推理(inference)很便宜,评审注意力才是稀缺资源。门禁的存在让 agent 和评审者都不必记住契约:配对、新鲜度和结构由机械检查兜底,评审注意力投向翻译质量与术语——这正是人的判断的用武之地。 diff --git a/docs/i18n/translation-rules.md b/docs/i18n/translation-rules.md new file mode 100644 index 0000000000..323504edea --- /dev/null +++ b/docs/i18n/translation-rules.md @@ -0,0 +1,60 @@ +# Translation rules (EN → ZH) + +English | [中文](translation-rules.zh.md) + +How to translate a document in this repo into Simplified Chinese. These rules bind humans and agents equally; the committed agent workflow that applies them is [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md), and the pairing/freshness mechanics live in [README.md](README.md). Rule levels follow RFC 2119 usage: **MUST** / **MUST NOT** are gate- or review-blocking; **SHOULD** needs a stated reason to deviate; **MAY** is discretionary. + +## Faithfulness + +- The translation MUST say what the source says — no added behavior, prerequisites, warnings, version claims, or examples, and no dropped ones. If the source is wrong, fix the English file first (English is canonical), then re-translate. +- The translation SHOULD read as natural technical Chinese, not word-by-word gloss. Translate meaning, restructure sentences where Chinese grammar wants it, and keep the author's register — terse stays terse. +- Do not translate the untranslatable: if a sentence resists natural rendering because it leans on an English idiom, translate the idea, not the idiom. + +## Structure preservation + +The paired files MUST match one to one in: + +- heading hierarchy (same levels, same order — heading TEXT is translated), +- list shape and numbering, +- tables (same columns, same row order; header cells translated per terminology), +- fenced code blocks — **byte-identical, including comments**; code is part of the verified surface (` ```ts ` blocks compile under `doc-typecheck`), and an edited comment is drift the fence-count gate cannot see, +- inline code spans (commands, flags, config keys, file paths, event names, API names, version numbers) — verbatim, never translated or reformatted, +- links and anchors: every relative link MUST point at the same target as the source — the canonical English file — so links never dangle when a translation batch lands before its neighbors. The ONLY zh-specific link is the language switcher. Link TEXT is translated; the target is not. + +The repo's Markdown conventions apply to `.zh.md` files unchanged: one physical line per paragraph (`verify-md-wrap`), resolving relative links (`verify-md-links`), exactly one trailing newline. + +## Terminology + +- [terminology.md](terminology.md) is the source of truth. Before translating, load it; while translating, every term it lists MUST be rendered exactly as it specifies, including its first-occurrence annotations (e.g. `agent(智能体)` on first mention, plain `agent` after) and its "不要译作" prohibitions. +- A technical term NOT in the table MAY be translated only when a major Chinese-language OSS or vendor doc has an established rendering for it (K8s/Vue/MDN Chinese docs, 微软简中风格指南, big-tech project docs). Cite the precedent in the PR. +- A term with NO established precedent MUST stay in English in the translation and MUST be listed in the PR description under 「待定术语」(pending terms) with a suggested rendering for the reviewer to decide. MUST NOT invent a Chinese rendering inline — an unprecedented translation creates exactly the ambiguity the terminology table exists to prevent. Decided terms then land in [terminology.md](terminology.md) in the same PR or a follow-up. + +## Typography + +The mixed-script rules below follow the cross-project consensus of the [MDN Simplified Chinese translation guide](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md), the [Kubernetes zh-cn localization guide](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/), the [Vue.js Chinese translation conventions](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5), and [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines), which in turn ground in [W3C clreq](https://www.w3.org/TR/clreq/) and GB/T 15834—2011: + +- MUST put one half-width space between Chinese text and Latin words, and between Chinese text and numerals: `每个 plugin 注册 3 个 tool`。No space between a full-width punctuation mark and anything. +- MUST use full-width (Chinese) punctuation in Chinese prose: `,。:;?!()「」`. Half-width punctuation stays inside code spans, inside complete English sentences quoted as-is, and in numbers (`3.5`, `1,024`). +- Enumeration commas: a Chinese list of parallel items uses 顿号(、), not commas. +- MUST NOT use full-width digits or full-width Latin letters — `123` never, `123` always. +- Proper nouns keep their canonical casing: GitHub, TypeScript, DeepSeek — never `github`/`Github` unless quoting code. +- Second person is 你, not 您 (matches the Vue and Kubernetes Chinese conventions and this repo's direct voice). +- Emphasis markers (`**bold**`, `*italic*`) stay on the same spans as the source; Chinese has no italics, so the rendered emphasis may look identical — do not substitute quotation marks or other decoration. + +## Quality bar + +- A translation is done when a bilingual engineer reading only the Chinese file gets everything a reader of the English file gets — same facts, same caveats, same tone — and nothing extra. +- Before handing off, self-check the result against this file and re-read the Chinese ALONE, without the English side by side; awkward phrasing is easier to hear without the source anchoring you. +- The mechanical contract (fingerprint, switcher, structure counts, wrap, links) is checked by `pnpm run verify-translation-pairing` and the rest of `doc-sync` — run them; do not hand-verify what a gate covers. + +## References + +Authorities cited by these rules, for humans and agents who want the underlying reasoning: + +- [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines) — the de-facto community standard for mixed CJK/Latin spacing and punctuation. +- [MDN zh-CN translation guide](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md) — an in-repo translation-rules file of the same shape as this one; spacing, punctuation, and glossary practice. +- [Kubernetes zh-cn localization guide](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/) — terminology-first-occurrence and punctuation practice from the largest zh localization team. +- [Vue.js docs-zh-cn 翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5) — per-term translate/keep decisions and tone. +- [zh-style-guide](https://zh-style-guide.readthedocs.io) — a community Chinese technical-writing style guide whose rule-level taxonomy (and RFC 2119 keyword levels) this file borrows; aggregates GB/T 15834/15835, clreq, and vendor guides. +- [W3C clreq](https://www.w3.org/TR/clreq/) and the [Microsoft Simplified Chinese style guide](https://learn.microsoft.com/en-us/globalization/reference/microsoft-style-guides) — the formal typographic and vendor-localization baselines. +- GB/T 19682-2005《翻译服务译文质量要求》 — the national standard whose three base requirements (忠实原文、术语统一、行文通顺) this file's Faithfulness and Terminology sections operationalize. diff --git a/docs/i18n/translation-rules.zh.md b/docs/i18n/translation-rules.zh.md new file mode 100644 index 0000000000..96576782e6 --- /dev/null +++ b/docs/i18n/translation-rules.zh.md @@ -0,0 +1,62 @@ + + +# 翻译规则(EN → ZH) + +[English](translation-rules.md) | 中文 + +本文规定如何把本仓库的文档翻译成简体中文。这些规则对人和 agent(智能体)同等生效;应用它们的进仓 agent 工作流是 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md),配对与新鲜度机制见 [README.md](README.md)。规则级别沿用 RFC 2119 的用法:**必须(MUST)**/**禁止(MUST NOT)**会卡门禁或评审;**应当(SHOULD)**偏离时要说明理由;**可以(MAY)**由译者自行裁量。 + +## 忠实性 + +- 译文必须说源文所说的话——不添加行为、前置条件、警告、版本声明或示例,也不丢弃任何一项。如果源文有错,先改英文文件(英文是唯一真源),再重新翻译。 +- 译文应当读起来是自然的中文技术文字,而不是逐词对照。翻译语义,在中文语法需要处重组句子,并保持原作者的语域——简练的保持简练。 +- 不要翻译不可译的东西:一句话如果依赖英文习语而无法自然转换,就翻译它的意思,而不是习语本身。 + +## 结构保持 + +配对的两个文件必须在以下方面一一对应: + +- 标题层级(相同级别、相同顺序——标题的**文字**要翻译), +- 列表形态与编号, +- 表格(相同的列、相同的行序;表头单元格按术语表翻译), +- 围栏代码块——**逐字节一致,包括注释**;代码属于被验证的表面(` ```ts ` 块要通过 `doc-typecheck` 编译),而被改动的注释是代码块计数门禁看不见的漂移, +- 行内代码(命令、flag、配置键、文件路径、事件名、API 名、版本号)——原样保留,从不翻译或重排, +- 链接与锚点:每个相对链接必须指向与源文相同的目标——即英文正典文件——这样翻译批次先后落地时链接永不悬空。唯一的 zh 特有链接是语言切换行。链接**文字**翻译;链接目标不翻。 + +本仓库的 Markdown 约定对 `.zh.md` 文件原样生效:一个段落一个物理行(`verify-md-wrap`)、相对链接必须可解析(`verify-md-links`)、文件末尾恰好一个换行。 + +## 术语 + +- [terminology.md](terminology.md) 是术语真源。翻译前先加载它;翻译中,表内的每个术语都必须严格按表规定的译法呈现,包括首次出现的括注(如首现写 `agent(智能体)`,之后写 `agent`)与「不要译作」的禁项。 +- 表中**没有**的技术术语,只有当某个主要中文 OSS 或厂商文档已有成型译法时(K8s/Vue/MDN 中文文档、微软简中风格指南、大厂项目文档)才可以翻译。在 PR 中注明先例出处。 +- **没有**成型先例的术语,译文中必须保留英文,并且必须在 PR 描述的「待定术语」下列出、附上建议译法交评审者定夺。禁止就地发明中文译法——无先例的翻译恰恰制造了术语表要防止的歧义。定下来的术语随后在同一个 PR 或后续 PR 进入 [terminology.md](terminology.md)。 + +## 排版 + +下面的中西文混排规则遵循 [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md)、[Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/)、[Vue.js 中文翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5)与[中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines)的跨项目共识,其根据是 [W3C clreq](https://www.w3.org/TR/clreq/) 与 GB/T 15834—2011: + +- 必须在中文与拉丁词之间、中文与数字之间各留一个半角空格:`每个 plugin 注册 3 个 tool`。全角标点与任何字符之间不加空格。 +- 中文行文必须使用全角(中文)标点:`,。:;?!()「」`。半角标点保留在代码内、按原样引用的完整英文句子内、以及数字内(`3.5`、`1,024`)。 +- 并列顿开:中文的并列项之间用顿号(、),不用逗号。 +- 禁止使用全角数字或全角拉丁字母——永远不写 `123`,永远写 `123`。 +- 专有名词保持规范大小写:GitHub、TypeScript、DeepSeek——除非引用代码,否则绝不写 `github`/`Github`。 +- 第二人称用「你」,不用「您」(与 Vue、Kubernetes 中文约定及本仓库的直接语气一致)。 +- 强调标记(`**加粗**`、`*斜体*`)落在与源文相同的文字段上;中文没有斜体,渲染效果可能看不出差别——不要用引号或其他装饰替代。 + +## 质量线 + +- 一篇译文的完成标准:一位只读中文文件的双语工程师,得到与英文读者完全相同的信息——相同的事实、相同的告诫、相同的语气——并且没有任何多余的内容。 +- 交付前,对照本文自查一遍,并**只读中文**再通读一遍、不看英文对照;没有源文锚着,别扭的表述更容易被听出来。 +- 机械契约(指纹、切换行、结构计数、折行、链接)由 `pnpm run verify-translation-pairing` 和 `doc-sync` 的其余门禁检查——跑门禁;门禁覆盖的不要手工核对。 + +## 参考资料 + +本文各规则引用的权威出处,供想了解底层依据的人和 agent 查阅: + +- [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines) —— 中西文混排空格与标点的社区事实标准。 +- [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md) —— 与本文同形态的进仓翻译规则文件;空格、标点与术语表实践。 +- [Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/) —— 最大的中文本地化团队的术语首现与标点实践。 +- [Vue.js docs-zh-cn 翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5) —— 逐术语的译/留决策与语气。 +- [zh-style-guide](https://zh-style-guide.readthedocs.io) —— 社区中文技术文档写作规范,本文借用了它的规则分类粒度(与 RFC 2119 关键词分级);它聚合了 GB/T 15834/15835、clreq 与各厂商指南。 +- [W3C clreq](https://www.w3.org/TR/clreq/) 与[微软简体中文风格指南](https://learn.microsoft.com/en-us/globalization/reference/microsoft-style-guides) —— 排版学与厂商本地化的正式基线。 +- GB/T 19682-2005《翻译服务译文质量要求》 —— 国家标准;本文「忠实性」与「术语」两节把它的三项基本要求(忠实原文、术语统一、行文通顺)落成可操作规则。 diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 59516c24c5..43619b395a 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -141,6 +141,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Generated cordis events + services catalog](implemented/process/2026-06-20-generated-cordis-catalog.md) | 2026-06-20 | | [Classify RFCs by kind via path-encoded subdirectories](implemented/process/2026-06-20-rfc-classification.md) | 2026-06-20 | | [Generated tool-schema catalog (boot-and-harvest)](implemented/process/2026-07-02-tool-schema-catalog.md) | 2026-07-02 | +| [Bilingual documentation via paired sibling files and a pairing gate](implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md) | 2026-07-02 | ### Testing diff --git a/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md b/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md new file mode 100644 index 0000000000..cd9dc413e7 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md @@ -0,0 +1,31 @@ +# Bilingual documentation via paired sibling files and a pairing gate + +## Context + +This repo's README and docs tree are read by people and agents inside and outside the company, in both English and Chinese. Maintaining a second language by hand, with no mechanism, is how translations rot: the English file moves on, the Chinese file silently lies, and no gate notices. The repo's standing answer to invariants of this kind is to encode them as a mechanical check (see [quality gates](2026-06-11-quality-gates.md) and [doc-sync enforcement](2026-06-11-doc-sync-enforcement.md)), so the bilingual policy ships with one. + +## Decision + +- **Paired sibling files, English canonical.** The translation of `foo.md` is `foo.zh.md` in the same directory; English is the only authoring language and translation flows EN → ZH. Policy: [docs/i18n/README.md](../../../i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../i18n/terminology.md). +- **A blob-hash fingerprint makes freshness checkable.** The first line of every `.zh.md` records the repo-relative path and the first 12 hex digits of the git blob hash of the English source it renders. Staleness is then a pure content comparison — no history lookup — and the hash is computable for a source edited in the same PR, which a commit-hash fingerprint (the MDN `l10n.sourceCommit` model) is not. +- **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: required pairs exist, every existing translation is fresh/switched/structure-matched/non-orphaned, and excluded (generated or bilingual-by-construction) files stay unpaired. The `required` list in [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) is a ratchet: each merged translation batch adds its files, so coverage only grows. +- **Translation is agent work with human review.** The committed workflow is [.agents/skills/dsh-translate-docs](../../../../.agents/skills/dsh-translate-docs/SKILL.md), following the same pattern as [dsh-code-review](../../../../.agents/skills/dsh-code-review/SKILL.md): the skill carries the workflow and defers to the docs as sources of truth. + +## Alternatives considered + +- **Locale directories (`docs/en/` + `docs/zh/`, the Kubernetes/ECharts model)** — rejected: this repo has no docs-site framework to map locales to routes, moving every English file would churn every existing cross-reference, and `verify-md-links`/`verify-doc-refs` would need path-mapping logic instead of working unchanged. +- **A separate translation repo (the PingCAP `docs`/`docs-cn` model)** — rejected: right for a docs product with independent release trains, overkill for a monorepo's own documentation; it also puts the translation outside the reach of this repo's gates. +- **Interleaved bilingual files (single file, both languages)** — rejected: doubles every diff, breaks the one-line-per-paragraph convention's diff ergonomics, and makes partial staleness invisible. +- **Commit-hash fingerprints (MDN `l10n.sourceCommit`)** — rejected in favor of blob hashes: a same-PR source edit has no commit hash yet, so the MDN model cannot express "translated against the version this PR introduces", and verifying it requires git history instead of file content. +- **Comparing git timestamps of the pair (no fingerprint)** — rejected: formatting-only English edits would false-positive, and a translation committed after an unrelated English edit would false-negative; content identity is the only signal that means what the gate claims. + +## Industry precedent + +Paired sibling files with locale suffixes are the dominant Chinese big-tech convention (ant-design `index.zh-CN.md`/`index.en-US.md`; arco-design `README.zh-CN.md` with a top-of-file switcher; Apache ShardingSphere's 387 `.cn.md`/`.en.md` pairs) — but none of those repos *enforce* pairing or freshness in CI; the convention holds by review alone. Freshness automation exists outside China: MDN's `l10n.sourceCommit` front-matter fingerprint, Vue's Ryu-Cho action (upstream-commit watcher that opens issues/PRs for stale translations), Kubernetes' localization drift scripts, and Microsoft's Azure co-op-translator (source-hash-driven LLM re-translation in CI). This design combines the two: the Chinese-ecosystem file layout with a fingerprint gate, plus a committed agent skill in place of a bot service. + +## Consequences + +- Editing an English doc that has a `.zh.md` sibling obligates the same PR to update the translation — the gate makes the doc-sync rule bilingual, and CI (not reviewer memory) carries the invariant. +- Generated docs (`cordis-catalog/`, `tool-catalog/`, `module-graph.md`) are never paired; their generators emit English only, and the gate rejects a stray translation of them. +- Rollout is incremental by design: documents outside `required` are visible backlog (`--list`), not red CI, so translation lands in reviewable batches without a big-bang PR. +- The fingerprint doubles as the update tool (`git cat-file -p ` recovers the exact translated-from text for a minimal diff-based update), so re-translation of whole files is never forced by the mechanism. diff --git a/package.json b/package.json index 7d82af4aad..a6a62b41a8 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "verify-package-paths": "tsx scripts/verify-package-paths.ts", "verify-rfc-classification": "tsx scripts/verify-rfc-classification.ts", "verify-type-equiv": "tsx scripts/verify-type-equiv.ts", + "verify-translation-pairing": "tsx scripts/verify-translation-pairing.ts", "verify-node-next-types": "tsx scripts/verify-node-next-types.ts", "gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts", "verify-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts --check", @@ -39,7 +40,7 @@ "gen-module-graph": "tsx scripts/gen-module-graph.ts", "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", "constraints": "tsx scripts/check-workspace-constraints.ts", - "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-tool-catalog && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-rfc-classification && pnpm run verify-type-equiv", + "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-tool-catalog && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-rfc-classification && pnpm run verify-type-equiv && pnpm run verify-translation-pairing", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types", "demo:echo": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/echo-agent/cordis.yml", "demo:coding": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/coding-agent/cordis.yml", diff --git a/scripts/translation-pairing.manifest.json b/scripts/translation-pairing.manifest.json new file mode 100644 index 0000000000..8b713c1ea0 --- /dev/null +++ b/scripts/translation-pairing.manifest.json @@ -0,0 +1,14 @@ +{ + "required": [ + "README.md", + "docs/i18n/README.md", + "docs/i18n/translation-rules.md" + ], + "excluded": [ + "docs/AGENTS.md", + "docs/module-graph.md", + "docs/cordis-catalog/", + "docs/tool-catalog/", + "docs/i18n/terminology.md" + ] +} diff --git a/scripts/verify-md-links.ts b/scripts/verify-md-links.ts index cbd913d5ca..2a96cfd0af 100644 --- a/scripts/verify-md-links.ts +++ b/scripts/verify-md-links.ts @@ -48,6 +48,7 @@ const root = resolve(import.meta.dirname, '..') */ const PATTERNS = [ 'README.md', + 'README.zh.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md', diff --git a/scripts/verify-md-wrap.ts b/scripts/verify-md-wrap.ts index f8acb26d78..c899b00f00 100644 --- a/scripts/verify-md-wrap.ts +++ b/scripts/verify-md-wrap.ts @@ -36,7 +36,7 @@ import type { Nodes } from 'mdast' const root = resolve(import.meta.dirname, '..') /** Files to check: doc-typecheck's scope plus the AGENTS.md pair. */ -const PATTERNS = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md', 'AGENTS.md', 'packages/AGENTS.md'] +const PATTERNS = ['README.md', 'README.zh.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md', 'AGENTS.md', 'packages/AGENTS.md'] /** A located hard-wrap: a prose paragraph spanning more than one source line. */ interface Violation { diff --git a/scripts/verify-translation-pairing.ts b/scripts/verify-translation-pairing.ts new file mode 100644 index 0000000000..b89b9b49fe --- /dev/null +++ b/scripts/verify-translation-pairing.ts @@ -0,0 +1,201 @@ +/** + * Doc-sync gate: enforce the bilingual pairing contract (docs/i18n/README.md). + * English is canonical; the translation of `foo.md` is a sibling `foo.zh.md` + * whose FIRST line fingerprints the English source it was translated from: + * + * + * + * The gate checks, mechanically, everything the contract promises: + * + * 1. Every English file in the manifest's `required` list has a `.zh.md` + * sibling (the enforcement frontier — grows batch by batch). + * 2. Every EXISTING `.zh.md`, required or not, is sound: its source exists + * (no orphans), its fingerprint equals the source's current blob hash + * (no stale translations), both sides carry the language-switcher link, + * and its fenced-code-block and heading counts match the source. + * 3. `excluded` files (generated docs, agent instructions, the bilingual + * terminology table) have no `.zh.md` at all. + * + * The fingerprint is a git BLOB hash, not a commit hash, so a translation + * updated in the same PR as its English source verifies without any history + * lookup: staleness is a pure content comparison, computed here directly + * (sha1 of `blob \0`) without spawning git. + * + * Run: `tsx scripts/verify-translation-pairing.ts` — or with `--list` to print + * the translation state (missing/stale/ok) of every in-scope document as a + * work list; `--list` always exits 0. + */ + +import { createHash } from 'node:crypto' +import { existsSync, readFileSync } from 'node:fs' +import { basename, join, resolve } from 'node:path' +import { glob } from 'node:fs/promises' +import { fromMarkdown } from 'mdast-util-from-markdown' +import { gfmFromMarkdown } from 'mdast-util-gfm' +import { gfm } from 'micromark-extension-gfm' +import type { Nodes } from 'mdast' + +const root = resolve(import.meta.dirname, '..') +const listMode = process.argv.includes('--list') + +/** Scope of the bilingual contract: the root README and the docs tree. */ +const SCOPE_PATTERNS = ['README.md', 'README.zh.md', 'docs/**/*.md'] + +/** The enforcement frontier and the never-paired set (docs/i18n/README.md § Scope). */ +interface Manifest { + required: string[] + excluded: string[] +} +const manifest = JSON.parse(readFileSync(join(root, 'scripts/translation-pairing.manifest.json'), 'utf8')) as Manifest + +/** First line of a translation: fingerprint of the English source it renders. */ +const FINGERPRINT = /^$/ + +/** An excluded entry ending in `/` excludes the whole directory. */ +function isExcluded(file: string): boolean { + return manifest.excluded.some(entry => (entry.endsWith('/') ? file.startsWith(entry) : file === entry)) +} + +/** Git blob hash (what `git hash-object` prints), truncated to 12 hex digits. */ +function blobHash(content: Buffer): string { + const hash = createHash('sha1') + hash.update(`blob ${content.byteLength}\0`) + hash.update(content) + return hash.digest('hex').slice(0, 12) +} + +/** Counts that must match between a source and its translation. */ +interface Shape { + codeBlocks: number + headings: number +} + +/** Whether `text` contains a relative markdown link to exactly `target`. */ +function linksTo(tree: Nodes, target: string): boolean { + let found = false + const visit = (node: Nodes): void => { + if (node.type === 'link' && node.url === target) found = true + if ('children' in node) for (const child of node.children) visit(child) + } + visit(tree) + return found +} + +function shapeOf(tree: Nodes): Shape { + let codeBlocks = 0 + let headings = 0 + const visit = (node: Nodes): void => { + if (node.type === 'code') codeBlocks++ + if (node.type === 'heading') headings++ + if ('children' in node) for (const child of node.children) visit(child) + } + visit(tree) + return { codeBlocks, headings } +} + +function parse(content: string): Nodes { + return fromMarkdown(content, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] }) +} + +// Enumerate the scope once, split into sources and translations. +const files = new Set() +for (const pattern of SCOPE_PATTERNS) { + for await (const match of glob(pattern, { cwd: root })) files.add(match) +} +const translations = [...files].filter(f => f.endsWith('.zh.md')).sort() +const sources = [...files].filter(f => !f.endsWith('.zh.md')).sort() + +const errors: string[] = [] +const state = new Map() + +// 1. Required pairs exist. +for (const req of manifest.required) { + if (!existsSync(join(root, req))) { + errors.push(`${req}: listed in translation-pairing.manifest.json \`required\` but the file does not exist`) + continue + } + const zh = req.replace(/\.md$/, '.zh.md') + if (!existsSync(join(root, zh))) { + errors.push(`${req}: required to have a translation, but ${zh} does not exist`) + state.set(req, 'missing') + } +} + +// 2. Every existing translation is sound. +for (const zh of translations) { + const source = zh.replace(/\.zh\.md$/, '.md') + const sourceAbs = join(root, source) + if (!existsSync(sourceAbs)) { + errors.push(`${zh}: orphan — its English source ${source} does not exist (delete or rename the translation alongside its source)`) + continue + } + if (isExcluded(source)) { + errors.push(`${zh}: ${source} is excluded from pairing (generated or bilingual-by-construction); this translation must not exist`) + continue + } + + const zhContent = readFileSync(join(root, zh), 'utf8') + const firstLine = zhContent.slice(0, zhContent.indexOf('\n')) + const match = FINGERPRINT.exec(firstLine) + if (!match?.groups) { + errors.push(`${zh}: first line is not an i18n-source fingerprint (expected \`\`, got \`${firstLine.slice(0, 60)}\`)`) + continue + } + if (match.groups['path'] !== source) { + errors.push(`${zh}: fingerprint names ${match.groups['path']} but the sibling source is ${source}`) + continue + } + + const sourceContent = readFileSync(sourceAbs) + const current = blobHash(sourceContent) + if (match.groups['hash'] !== current) { + errors.push(`${zh}: stale — fingerprint ${match.groups['hash']} but ${source} is now ${current} (update the translation, then re-fingerprint)`) + state.set(source, 'stale') + continue + } + + const zhTree = parse(zhContent) + const sourceTree = parse(sourceContent.toString('utf8')) + if (!linksTo(zhTree, basename(source))) { + errors.push(`${zh}: missing language switcher — no link to ${basename(source)}`) + } + if (!linksTo(sourceTree, basename(zh))) { + errors.push(`${source}: missing language switcher — no link back to ${basename(zh)}`) + } + const zhShape = shapeOf(zhTree) + const sourceShape = shapeOf(sourceTree) + if (zhShape.codeBlocks !== sourceShape.codeBlocks) { + errors.push(`${zh}: ${zhShape.codeBlocks} fenced code block(s) vs ${sourceShape.codeBlocks} in ${source} — code blocks must mirror the source`) + } + if (zhShape.headings !== sourceShape.headings) { + errors.push(`${zh}: ${zhShape.headings} heading(s) vs ${sourceShape.headings} in ${source} — heading structure must mirror the source`) + } + if (!state.has(source)) state.set(source, 'ok') +} + +// Complete the state map for --list: any in-scope, non-excluded source with no translation yet is backlog. +for (const source of sources) { + if (!isExcluded(source) && !state.has(source)) state.set(source, 'missing') +} + +if (listMode) { + const order = { stale: 0, missing: 1, ok: 2 } as const + const rows = [...state.entries()].sort((a, b) => order[a[1]] - order[b[1]] || a[0].localeCompare(b[0])) + for (const [file, status] of rows) { + const required = manifest.required.includes(file) + console.log(`${status.padEnd(7)} ${file}${status === 'missing' ? (required ? ' (required)' : ' (backlog)') : ''}`) + } + const counts = { ok: 0, stale: 0, missing: 0 } + for (const status of state.values()) counts[status]++ + console.log(`verify-translation-pairing: ${counts.ok} ok, ${counts.stale} stale, ${counts.missing} missing (of ${state.size} in scope)`) + process.exit(0) +} + +if (errors.length === 0) { + console.log(`verify-translation-pairing: ${translations.length} translation(s) checked against ${manifest.required.length} required pair(s), all sound.`) + process.exit(0) +} + +console.error('verify-translation-pairing: bilingual pairing contract violated (see docs/i18n/README.md):') +for (const message of errors) console.error(` ${message}`) +process.exit(1) From 803ed4bd9547ebc2fa677a473cb95884d7baa77d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 3 Jul 2026 14:36:20 +0800 Subject: [PATCH 41/75] feat(fs): add directory listing seam --- docs/cordis-catalog/events-and-services.md | 12 ++-- docs/core-data-structures/filesystem.md | 19 +++++- .../2026-06-17-filesystem-capability-seam.md | 9 ++- .../2026-06-26-fsspec-style-fs-seam.md | 13 +++- packages/fs/fs-local/README.md | 3 +- packages/fs/fs-local/src/fsio.ts | 64 ++++++++++++++++++- packages/fs/fs-local/src/index.ts | 18 +++++- packages/fs/fs-local/tests/filesystem.spec.ts | 46 ++++++++++++- packages/fs/fs-local/tests/fsio.spec.ts | 31 +++++++++ packages/fs/fs/README.md | 7 +- packages/fs/fs/src/index.ts | 15 ++++- packages/fs/fs/src/types.ts | 20 ++++++ packages/fs/fs/tests/service.spec.ts | 29 ++++++++- packages/fs/tool-fs/tests/tools.spec.ts | 4 ++ scripts/type-equiv.manifest.json | 1 + 15 files changed, 268 insertions(+), 23 deletions(-) diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 22b6dc791b..38b4ccc889 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -209,7 +209,7 @@ Single-slot decision: produce the optional version guard for the next FileSystem 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) +Source: [`packages/fs/fs/src/index.ts:119`](../../packages/fs/fs/src/index.ts) #### `fs/observed` — emit @@ -221,7 +221,7 @@ Record that an actor observed a target at a version, after a successful read/wri 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) +Source: [`packages/fs/fs/src/index.ts:131`](../../packages/fs/fs/src/index.ts) #### `fs/write-intent` — waterfall @@ -233,7 +233,7 @@ Single-slot decision: produce the write intent for the next FileSystem.writeText 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) +Source: [`packages/fs/fs/src/index.ts:107`](../../packages/fs/fs/src/index.ts) ### `llm/*` @@ -433,13 +433,14 @@ Source: [`packages/compact/compact/src/index.ts:63`](../../packages/compact/comp ### `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). +Abstract filesystem provider service. Subclass, implement the seven 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`. +- listDir returns direct children of a directory in stable name order with resolved child targets and cheap metadata only. It never reads file contents. Missing targets throw `FS_NOT_FOUND`, non-directories throw `FS_NOT_DIRECTORY`, permission failures throw `FS_PERMISSION_DENIED`, and other backend I/O failures throw `FS_IO_ERROR`. - 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`). @@ -448,13 +449,14 @@ abstract resolve(path: string, opts?: { cwd?: string }): Promise abstract stat(target: FsTarget, signal?: AbortSignal): Promise abstract readText(target: FsTarget, signal?: AbortSignal): Promise abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> +abstract listDir(target: FsTarget, signal?: AbortSignal): Promise abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise ``` 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) +Source: [`packages/fs/fs/src/index.ts:165`](../../packages/fs/fs/src/index.ts) ### `ctx.llm` — `LlmService` diff --git a/docs/core-data-structures/filesystem.md b/docs/core-data-structures/filesystem.md index 2e66dd9d9e..ee8b8c064a 100644 --- a/docs/core-data-structures/filesystem.md +++ b/docs/core-data-structures/filesystem.md @@ -38,6 +38,18 @@ interface FsInfo { } ``` +`listDir` returns direct child entries in stable name order. Each entry carries the child basename, type, resolved target, and cheap metadata when the backend can report it. It must not read file contents, so `size` is only for regular files and `version` is metadata-derived. + +```ts type-equiv +interface FsDirEntry { + name: string + type: 'file' | 'directory' | 'other' + target: FsTarget + version?: FsVersion + 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. @@ -117,8 +129,11 @@ Filesystem failures use stable `FsErrorCode` strings carried by `FsError` (`Harn ```ts type-equiv type FsErrorCode = | 'FS_NOT_FOUND' + | 'FS_NOT_DIRECTORY' | 'FS_NOT_TEXT' | 'FS_NOT_REGULAR_FILE' + | 'FS_PERMISSION_DENIED' + | 'FS_IO_ERROR' | 'FS_STALE_VERSION' | 'FS_NOT_OBSERVED' | 'FS_AMBIGUOUS_EDIT' @@ -126,8 +141,8 @@ type FsErrorCode = | '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`. +`FS_NOT_DIRECTORY`, `FS_PERMISSION_DENIED`, and `FS_IO_ERROR` are used by directory listing to distinguish an existing non-directory target, a denied listing, and an unexpected backend I/O failure. `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). +`FileSystem` (`ctx.fs`, abstract) owns the provider primitives: `resolve`, `stat`, `readText`, `streamText`, `listDir`, `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). diff --git a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md index 731ee41ad7..3c1f060984 100644 --- a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md +++ b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md @@ -30,7 +30,7 @@ The read-before-write/edit and observed-state policy is a fourth package, `@deep 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`. +The first model-facing consumer is deliberately text-file-only: `dsh-tool-fs` exposes model-facing `read`, `write`, and `edit` tools for UTF-8 text files. The provider seam also includes direct directory listing (`listDir`) so non-model-facing consumers such as skill discovery can enumerate roots through `ctx.fs` without importing `node:fs`; future consumers can add 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. @@ -57,9 +57,10 @@ The root `tool-fs` plugin registers the full filesystem tool suite (`read`, `wri `@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: +The exact TypeScript signatures are implementation details for the PR, but the interface must cover five semantic operations: - Resolve a model/plugin-supplied path into a backend-defined target. +- Stat and list target metadata without reading file contents. - 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. @@ -82,6 +83,8 @@ Resolved targets must expose at least three concepts: 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. +`listDir` lists direct directory children in stable name order and returns child names, types, resolved child targets, and cheap metadata (`version` and regular-file `size` when available) without opening file contents. Missing directories report `FS_NOT_FOUND`, non-directory targets report `FS_NOT_DIRECTORY`, permission failures report `FS_PERMISSION_DENIED`, and other backend listing failures report `FS_IO_ERROR`. + 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. @@ -92,7 +95,7 @@ Literal edit is a provider primitive (`editText`), not composed in `tool-fs` fro 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.) +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_DIRECTORY`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_PERMISSION_DENIED`, `FS_IO_ERROR`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `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 diff --git a/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md index 736b65df08..a3b5f03b0e 100644 --- a/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md +++ b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md @@ -39,6 +39,7 @@ abstract resolve(path: string): Promise abstract stat(target: FsTarget, signal?: AbortSignal): Promise abstract readText(target: FsTarget, signal?: AbortSignal): Promise abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> +abstract listDir(target: FsTarget, signal?: AbortSignal): Promise abstract writeText(target: FsTarget, content: string, expected: FsWriteIntent, signal?: AbortSignal): Promise abstract editText(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, signal?: AbortSignal): Promise @@ -48,12 +49,20 @@ interface FsInfo { size?: number } +interface FsDirEntry { + name: string + type: 'file' | 'directory' | 'other' + target: FsTarget + version?: FsVersion + 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. +`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. `listDir` returns direct children in stable name order with child names, types, resolved targets, and cheap metadata only; it does not read file contents. `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`. @@ -107,7 +116,7 @@ It keeps the interface/implementation/consumer discipline, consumer-never-import ## 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` exposes exactly `resolve`/`stat`/`readText`/`streamText`/`listDir`/`writeText`/`editText`; `stat` returns `FsInfo | undefined`; `listDir` returns stable direct-child metadata without reading contents; `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. diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index 6fb75276e0..3414f1ce65 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -1,6 +1,6 @@ # @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`. +The **local-filesystem implementation** of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)). Backs the seven `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' @@ -15,6 +15,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) - **`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. +- **`listDir`** — lists one directory level in stable `name.localeCompare()` order. Each entry carries the child basename, type, resolved child target (`displayPath` under the listed directory, `targetKey` as the realpath identity), and cheap stat metadata (`version`, plus `size` for regular files). It never opens or decodes file contents. Missing targets report `FS_NOT_FOUND`, file/special-file targets report `FS_NOT_DIRECTORY`, aborted calls report `FS_ABORTED`, permission failures report `FS_PERMISSION_DENIED`, and other `readdir` I/O failures report `FS_IO_ERROR`. - **`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`). diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index b24b7e6b89..1d22625816 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -20,8 +20,8 @@ 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 { chmod, mkdir, open, readFile, realpath, readdir, rename, rm, stat } from 'node:fs/promises' +import type { Dirent, 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' @@ -55,6 +55,12 @@ function errorMessage(error: unknown): string { } /* v8 ignore stop */ +/* v8 ignore start -- requires permission/kernel failures from readdir after a successful directory stat. */ +function isPermissionError(error: unknown): boolean { + return error instanceof Error && 'code' in error && (error.code === 'EACCES' || error.code === 'EPERM') +} +/* v8 ignore stop */ + function throwIfAborted(signal: AbortSignal | undefined, verb: string): void { if (signal?.aborted) throw new FsError(`${verb} aborted`, 'FS_ABORTED') } @@ -112,6 +118,15 @@ export interface PathInfo { size: number } +/** One local directory child with a resolved target and cheap metadata. */ +export interface LocalDirEntry { + name: string + type: 'file' | 'directory' | 'other' + target: LocalTarget + version?: FsVersion + 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 @@ -172,6 +187,51 @@ export async function probe(absolutePath: string): Promise { } } +// --- Directory listing --- + +/* v8 ignore start -- requires permission/kernel failures from readdir after a successful directory stat. */ +function listingIoError(displayPath: string, error: unknown): FsError { + if (isENOENT(error) || isENOTDIR(error)) return new FsError(`cannot list "${displayPath}": not found`, 'FS_NOT_FOUND', { cause: error }) + if (isPermissionError(error)) return new FsError(`cannot list "${displayPath}": permission denied`, 'FS_PERMISSION_DENIED', { cause: error }) + return new FsError(`cannot list "${displayPath}": ${errorMessage(error)}`, 'FS_IO_ERROR', { cause: error }) +} +/* v8 ignore stop */ + +/** + * List direct children of a directory in stable name order. Each child includes + * a resolved target plus stat metadata when still available; file contents are + * never read. + */ +export async function listDirectory(target: LocalTarget, signal?: AbortSignal): Promise { + throwIfAborted(signal, 'list') + const info = await probe(target.targetKey) + if (!info) throw new FsError(`cannot list "${target.displayPath}": not found`, 'FS_NOT_FOUND') + if (info.type !== 'directory') throw new FsError(`cannot list "${target.displayPath}": not a directory`, 'FS_NOT_DIRECTORY') + + let entries: Dirent[] + try { + entries = await readdir(target.targetKey, { withFileTypes: true, encoding: 'utf8' }) + } catch (error: unknown) { + /* v8 ignore next -- requires permission/kernel failure from readdir after a successful directory stat. */ + throw listingIoError(target.displayPath, error) + } + throwIfAborted(signal, 'list') + + return await Promise.all(entries + .sort((left, right) => left.name.localeCompare(right.name)) + .map(async (entry): Promise => { + const childTarget = await resolveLocalTarget(target.displayPath, entry.name) + const childInfo = await probe(childTarget.targetKey) + return { + name: entry.name, + type: childInfo?.type ?? 'other', + target: childTarget, + ...(childInfo ? { version: childInfo.version } : {}), + ...(childInfo?.type === 'file' ? { size: childInfo.size } : {}), + } + })) +} + // --- Reading --- function notTextError(verb: 'read' | 'edit', displayPath: string): FsError { diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index 97dda3c4dd..e52cb779e8 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -1,6 +1,6 @@ /** * Local-filesystem implementation of the `ctx.fs` provider seam. - * {@link LocalFileSystem} subclasses {@link FileSystem} and backs the six + * {@link LocalFileSystem} subclasses {@link FileSystem} and backs the seven * 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 @@ -17,6 +17,7 @@ import { Context } from 'cordis' import z from 'schemastery' import { FileSystem, FsError, FsVersion } from '@deepseek-ai/dsh-fs' import type { + FsDirEntry, FsEditOutcome, FsEditRequest, FsInfo, @@ -26,6 +27,7 @@ import type { } from '@deepseek-ai/dsh-fs' import { applyLiteralEdit, + listDirectory, probe, readForEdit, readWholeText, @@ -39,6 +41,7 @@ import type { FsIoInternals } from './fsio.ts' export { STREAM_MIN_SIZE, applyLiteralEdit, + listDirectory, probe, readForEdit, readWholeText, @@ -47,7 +50,7 @@ export { streamWholeText, writeFileAtomic, } from './fsio.ts' -export type { FsIoInternals, LineEndings, LocalTarget, PathInfo } from './fsio.ts' +export type { FsIoInternals, LineEndings, LocalDirEntry, LocalTarget, PathInfo } from './fsio.ts' /** Configuration for the local filesystem backend. */ export interface Config { @@ -117,6 +120,17 @@ export class LocalFileSystem extends FileSystem { return Promise.resolve(streamWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal)) } + override async listDir(target: FsTarget, signal?: AbortSignal): Promise { + const entries = await listDirectory({ displayPath: target.displayPath, targetKey: target.targetKey }, signal) + return entries.map(entry => ({ + name: entry.name, + type: entry.type, + target: { inputPath: entry.target.displayPath, targetKey: entry.target.targetKey, displayPath: entry.target.displayPath }, + ...(entry.version !== undefined ? { version: entry.version } : {}), + ...(entry.size !== undefined ? { size: entry.size } : {}), + })) + } + override async writeText( target: FsTarget, content: string, diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts index 03c751a538..cfbaa36e30 100644 --- a/packages/fs/fs-local/tests/filesystem.spec.ts +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -7,7 +7,7 @@ */ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { mkdtemp, readFile, rm, stat, symlink, writeFile, unlink } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, writeFile, unlink } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from 'cordis' @@ -118,6 +118,50 @@ describe('readText / streamText', () => { }) }) +describe('listDir', () => { + it('lists files and directories in stable name order with resolved child targets', async () => { + await mkdir(join(dir, 'skills', 'dir-skill'), { recursive: true }) + await writeFile(join(dir, 'skills', 'zeta.md'), 'zeta') + await writeFile(join(dir, 'skills', 'alpha.md'), 'alpha') + await symlink(join(dir, 'skills', 'missing-target'), join(dir, 'skills', 'broken-link')) + + const entries = await fs.listDir(await fs.resolve('skills')) + expect(entries.map(entry => [entry.name, entry.type])).toEqual([ + ['alpha.md', 'file'], + ['broken-link', 'other'], + ['dir-skill', 'directory'], + ['zeta.md', 'file'], + ]) + expect(entries.map(entry => entry.target.displayPath)).toEqual([ + join(dir, 'skills', 'alpha.md'), + join(dir, 'skills', 'broken-link'), + join(dir, 'skills', 'dir-skill'), + join(dir, 'skills', 'zeta.md'), + ]) + const materializedEntries = entries.filter(entry => entry.version !== undefined) + expect(materializedEntries.map(entry => entry.target.targetKey)) + .toEqual(await Promise.all(materializedEntries.map(entry => realpath(entry.target.displayPath)))) + expect(entries.find(entry => entry.name === 'alpha.md')?.size).toBe(5) + expect(typeof entries.find(entry => entry.name === 'alpha.md')?.version).toBe('string') + expect(entries.find(entry => entry.name === 'broken-link')?.version).toBeUndefined() + expect(entries.find(entry => entry.name === 'dir-skill')?.size).toBeUndefined() + }) + + it('reports a missing directory as FS_NOT_FOUND', async () => { + await expect(fs.listDir(await fs.resolve('missing'))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) + }) + + it('reports a file target as FS_NOT_DIRECTORY', async () => { + await writeFile(join(dir, 'a.txt'), 'text') + await expect(fs.listDir(await fs.resolve('a.txt'))).rejects.toMatchObject({ code: 'FS_NOT_DIRECTORY' }) + }) + + it('honors a pre-aborted signal', async () => { + await mkdir(join(dir, 'skills'), { recursive: true }) + await expect(fs.listDir(await fs.resolve('skills'), AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' }) + }) +}) + describe('writeText', () => { it('createIfAbsent creates a new file', async () => { const target = await fs.resolve('new.txt') diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index 0b16e9e2c8..27f08e39c7 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -12,6 +12,7 @@ import { join } from 'node:path' import { createServer } from 'node:net' import { applyLiteralEdit, + listDirectory, probe, readForEdit, readWholeText, @@ -145,6 +146,36 @@ describe('probe', () => { }) }) +describe('listDirectory', () => { + it('lists direct children in stable order without reading content', async () => { + const root = join(dir, 'skills') + await mkdir(join(root, 'dir-skill'), { recursive: true }) + await writeFile(join(root, 'zeta.md'), 'zeta') + await writeFile(join(root, 'alpha.md'), 'alpha') + await symlink(join(root, 'missing-target'), join(root, 'broken-link')) + + const entries = await listDirectory(localTarget(root)) + expect(entries.map(entry => [entry.name, entry.type])).toEqual([ + ['alpha.md', 'file'], + ['broken-link', 'other'], + ['dir-skill', 'directory'], + ['zeta.md', 'file'], + ]) + expect(entries.find(entry => entry.name === 'alpha.md')?.size).toBe(5) + expect(typeof entries.find(entry => entry.name === 'alpha.md')?.version).toBe('string') + expect(entries.find(entry => entry.name === 'broken-link')?.version).toBeUndefined() + expect(entries.find(entry => entry.name === 'dir-skill')?.size).toBeUndefined() + }) + + it('rejects missing, non-directory, and aborted listing requests', async () => { + await expect(listDirectory(localTarget(join(dir, 'missing')))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) + const file = join(dir, 'a.txt') + await writeFile(file, 'hi') + await expect(listDirectory(localTarget(file))).rejects.toMatchObject({ code: 'FS_NOT_DIRECTORY' }) + await expect(listDirectory(localTarget(dir), AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' }) + }) +}) + describe('readWholeText', () => { it('reads a small file', async () => { const file = join(dir, 'a.txt') diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md index 3cea538ff7..4296f22723 100644 --- a/packages/fs/fs/README.md +++ b/packages/fs/fs/README.md @@ -1,6 +1,6 @@ # @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. +The **filesystem provider seam**: an abstract `FileSystem` service (`ctx.fs`) defining the storage primitives a backend provides — resolve a path, stat metadata, read/stream text, list directories, 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)): @@ -15,7 +15,7 @@ A future sandboxed, virtual, or remote backend implements this interface and the ## Service API (`ctx.fs`) -A backend subclasses `FileSystem` and implements six primitives. +A backend subclasses `FileSystem` and implements seven primitives. | Member | Semantics | |---|---| @@ -23,6 +23,7 @@ A backend subclasses `FileSystem` and implements six primitives. | `stat(target, signal?)` | Return `FsInfo` metadata (`version`, `type`, optional `size`), or `undefined` when the target is absent. Never content. | | `readText(target, signal?)` | Read the whole regular text file as one decoded string. Owns regular-file checks, UTF-8 decoding, binary/NUL rejection (`FS_NOT_TEXT`). | | `streamText(target, signal?)` | Stream the same text as decoded chunks for large files (cross-chunk UTF-8 decoding stays here). | +| `listDir(target, signal?)` | List direct directory children in stable name order. Returns entry names, entry types, resolved child targets, and cheap metadata (`version`/file `size` when available); never reads file contents. Missing targets throw `FS_NOT_FOUND`, non-directories throw `FS_NOT_DIRECTORY`, permission failures throw `FS_PERMISSION_DENIED`, and other backend I/O failures throw `FS_IO_ERROR`. | | `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. | @@ -40,4 +41,4 @@ This package declares three events (see the generated [catalog](../../../docs/co ## 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. +`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_DIRECTORY`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_PERMISSION_DENIED`, `FS_IO_ERROR`, `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. diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts index e3a8a36b66..db0135ba80 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -59,6 +59,7 @@ import { Context, Service } from 'cordis' import type { + FsDirEntry, FsEditOutcome, FsEditRequest, FsInfo, @@ -76,6 +77,7 @@ export { export type { FsEditOutcome, FsEditRequest, + FsDirEntry, FsErrorCode, FsInfo, FsTarget, @@ -131,7 +133,7 @@ declare module 'cordis' { } /** - * Abstract filesystem provider service. Subclass, implement the six text-storage + * Abstract filesystem provider service. Subclass, implement the seven 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). @@ -145,6 +147,11 @@ declare module 'cordis' { * - {@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 listDir} returns direct children of a directory in stable name order + * with resolved child targets and cheap metadata only. It never reads file + * contents. Missing targets throw `FS_NOT_FOUND`, non-directories throw + * `FS_NOT_DIRECTORY`, permission failures throw `FS_PERMISSION_DENIED`, and + * other backend I/O failures throw `FS_IO_ERROR`. * - {@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. @@ -190,6 +197,12 @@ export abstract class FileSystem extends Service { */ abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> + /** + * List direct children of a directory in stable name order. Returns resolved + * child targets plus cheap metadata only; never reads file contents. + */ + abstract listDir(target: FsTarget, signal?: AbortSignal): Promise + /** * 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 diff --git a/packages/fs/fs/src/types.ts b/packages/fs/fs/src/types.ts index 15a58ee93b..ef81d40338 100644 --- a/packages/fs/fs/src/types.ts +++ b/packages/fs/fs/src/types.ts @@ -78,6 +78,23 @@ export interface FsInfo { size?: number } +/** + * One direct child returned by {@link FileSystem.listDir}. Listing returns + * metadata and resolved targets only; it must not read file contents. + */ +export interface FsDirEntry { + /** Basename of the child inside the listed directory. */ + name: string + /** Whether the child is a regular file, a directory, or something else. */ + type: 'file' | 'directory' | 'other' + /** Resolved child target for follow-up operations. */ + target: FsTarget + /** Opaque freshness token when the backend can report metadata cheaply. */ + version?: FsVersion + /** 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 @@ -130,8 +147,11 @@ export interface FsEditOutcome { */ export type FsErrorCode = | 'FS_NOT_FOUND' + | 'FS_NOT_DIRECTORY' | 'FS_NOT_TEXT' | 'FS_NOT_REGULAR_FILE' + | 'FS_PERMISSION_DENIED' + | 'FS_IO_ERROR' | 'FS_STALE_VERSION' | 'FS_NOT_OBSERVED' | 'FS_AMBIGUOUS_EDIT' diff --git a/packages/fs/fs/tests/service.spec.ts b/packages/fs/fs/tests/service.spec.ts index a0032afdee..9e5e3d29c7 100644 --- a/packages/fs/fs/tests/service.spec.ts +++ b/packages/fs/fs/tests/service.spec.ts @@ -9,6 +9,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import type { + FsDirEntry, FsEditOutcome, FsEditRequest, FsInfo, @@ -17,7 +18,7 @@ import type { FsWriteOutcome, } from '@deepseek-ai/dsh-fs' -/** A minimal in-memory fake implementing the six provider primitives. */ +/** A minimal in-memory fake implementing the seven provider primitives. */ class FakeFileSystem extends FileSystem { files = new Map() @@ -38,6 +39,18 @@ class FakeFileSystem extends FileSystem { const content = await this.readText(target) return (async function* () { yield content })() } + override async listDir(target: FsTarget): Promise { + if (target.targetKey !== 'skills') throw new FsError(`not a directory: ${target.displayPath}`, 'FS_NOT_DIRECTORY') + return [ + { + name: 'alpha.md', + type: 'file', + target: { inputPath: 'skills/alpha.md', targetKey: FsTargetKey('skills/alpha.md'), displayPath: 'skills/alpha.md' }, + size: 2, + version: FsVersion('v1'), + }, + ] + } override async writeText(target: FsTarget, content: string, _expected?: FsWriteIntent): Promise { const existed = this.files.has(target.targetKey) this.files.set(target.targetKey, content) @@ -86,6 +99,20 @@ describe('FileSystem provider seam', () => { expect(streamed).toBe(await fs.readText(target)) }) + it('listDir returns child entry targets without reading file content', async () => { + const ctx = new Context() + await ctx.plugin(FakeFileSystem) + const fs = ctx.fs as FakeFileSystem + const entries = await fs.listDir(await fs.resolve('skills')) + expect(entries).toEqual([{ + name: 'alpha.md', + type: 'file', + target: { inputPath: 'skills/alpha.md', targetKey: 'skills/alpha.md', displayPath: 'skills/alpha.md' }, + size: 2, + version: 'v1', + }]) + }) + it('stat returns undefined for an absent target', async () => { const ctx = new Context() await ctx.plugin(FakeFileSystem) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 12f373753e..66ce0dbf97 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -15,6 +15,7 @@ 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 { + FsDirEntry, FsEditOutcome, FsEditRequest, FsInfo, @@ -54,6 +55,9 @@ class FakeFs extends FileSystem { const content = this.files.get(target.targetKey) ?? '' return (async function* () { yield content })() } + override async listDir(_target: FsTarget): Promise { + return [] + } override async writeText(target: FsTarget, content: string, expected?: FsWriteIntent): Promise { this.throwIfArmed() this.writeIntents.push(expected) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 9a6fa4cab8..e43b09a36e 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -46,6 +46,7 @@ { "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": "FsDirEntry", "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" }, From 6fc2cee8371fe70ec7eb6fe6f4f60137e863d78d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 3 Jul 2026 15:12:40 +0800 Subject: [PATCH 42/75] fix(fs): translate listDir metadata failures --- docs/core-data-structures/filesystem.md | 2 +- docs/rfc/README.md | 1 + .../2026-06-17-filesystem-capability-seam.md | 10 ++-- ...07-03-filesystem-directory-listing-seam.md | 53 +++++++++++++++++++ .../2026-06-26-fsspec-style-fs-seam.md | 17 +++--- packages/fs/fs-local/README.md | 2 +- packages/fs/fs-local/src/fsio.ts | 32 +++++++---- packages/fs/fs-local/src/index.ts | 2 +- packages/fs/fs-local/tests/filesystem.spec.ts | 6 +++ packages/fs/fs-local/tests/fsio.spec.ts | 50 ++++++++++++++++- packages/fs/fs/README.md | 2 +- 11 files changed, 144 insertions(+), 33 deletions(-) create mode 100644 docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md diff --git a/docs/core-data-structures/filesystem.md b/docs/core-data-structures/filesystem.md index ee8b8c064a..038d33500e 100644 --- a/docs/core-data-structures/filesystem.md +++ b/docs/core-data-structures/filesystem.md @@ -38,7 +38,7 @@ interface FsInfo { } ``` -`listDir` returns direct child entries in stable name order. Each entry carries the child basename, type, resolved target, and cheap metadata when the backend can report it. It must not read file contents, so `size` is only for regular files and `version` is metadata-derived. +`listDir` returns direct child entries in stable name order. Each entry carries the child basename, type, resolved target, and cheap metadata when the backend can report it. It must not read file contents, so `size` is only for regular files and `version` is metadata-derived. Broken or disappeared children may be returned as `other` without metadata; permission or backend I/O failures while listing or resolving child metadata fail the whole listing with `FS_PERMISSION_DENIED` or `FS_IO_ERROR`. ```ts type-equiv interface FsDirEntry { diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 59516c24c5..ee23480805 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -125,6 +125,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [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 | +| [Add direct directory listing to the filesystem seam](implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md) | 2026-07-03 | ### Process diff --git a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md index 3c1f060984..50925b49cb 100644 --- a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md +++ b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md @@ -30,7 +30,7 @@ The read-before-write/edit and observed-state policy is a fourth package, `@deep 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 model-facing consumer is deliberately text-file-only: `dsh-tool-fs` exposes model-facing `read`, `write`, and `edit` tools for UTF-8 text files. The provider seam also includes direct directory listing (`listDir`) so non-model-facing consumers such as skill discovery can enumerate roots through `ctx.fs` without importing `node:fs`; future consumers can add 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`. +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`. Direct directory listing was later added by [Add direct directory listing to the filesystem seam](2026-07-03-filesystem-directory-listing-seam.md). 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. @@ -57,10 +57,10 @@ The root `tool-fs` plugin registers the full filesystem tool suite (`read`, `wri `@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 five semantic operations: +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. -- Stat and list target metadata without reading file contents. +- Stat target metadata without reading file contents. - 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. @@ -83,8 +83,6 @@ Resolved targets must expose at least three concepts: 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. -`listDir` lists direct directory children in stable name order and returns child names, types, resolved child targets, and cheap metadata (`version` and regular-file `size` when available) without opening file contents. Missing directories report `FS_NOT_FOUND`, non-directory targets report `FS_NOT_DIRECTORY`, permission failures report `FS_PERMISSION_DENIED`, and other backend listing failures report `FS_IO_ERROR`. - 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. @@ -95,7 +93,7 @@ Literal edit is a provider primitive (`editText`), not composed in `tool-fs` fro 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_DIRECTORY`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_PERMISSION_DENIED`, `FS_IO_ERROR`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `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.) +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. Directory-listing-specific codes were added later by [Add direct directory listing to the filesystem seam](2026-07-03-filesystem-directory-listing-seam.md).) ## Tool consumer behavior diff --git a/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md b/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md new file mode 100644 index 0000000000..a02cfeb5bb --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md @@ -0,0 +1,53 @@ +# Add direct directory listing to the filesystem seam + +## Status + +Implemented. + +## Context + +`@deepseek-ai/dsh-fs` is the provider seam for filesystem access, with local and future non-local backends behind the same `ctx.fs` contract. Before this change it could resolve paths, stat targets, read text, stream text, write text, and edit text. That was enough for model-facing file tools, but not for non-model-facing consumers that need to enumerate directories without importing `node:fs`. + +The immediate pressure came from skill loading: reading an individual `SKILL.md` can already go through `ctx.get('fs')`, but discovering which skill roots contain `/SKILL.md` or `.md` still needs directory enumeration. Adding directory listing only in `dsh-skill` would either keep a direct Node dependency there or invent a one-off local helper outside the filesystem provider stack. + +This branch deliberately lands the provider capability first and does not add a model-facing `ls`/`list` tool or change skill discovery. The follow-up consumer can validate UX and prompt shape separately, while this PR establishes the backend seam and local implementation. + +## Decision + +Add `FileSystem.listDir(target, signal?)` to `@deepseek-ai/dsh-fs`. + +`listDir` lists one directory level only. It returns direct children in stable name order and includes: + +- `name`: the child basename. +- `type`: `file`, `directory`, or `other`. +- `target`: the resolved child `FsTarget`. +- `version`: cheap metadata when available. +- `size`: regular-file size when available. + +It never reads file contents. Recursive traversal, globbing, pagination, search, file watching, and model-facing rendering are intentionally out of scope. + +The local backend implements this through `readdir({ withFileTypes: true })`, `resolveLocalTarget`, and metadata `stat`/`realpath` probes. The result order is deterministic (`name.localeCompare`) to keep prompt/listing output stable for future consumers and improve prefix-cache reuse. + +Broken or disappeared children may be represented as `type: 'other'` without `version`/`size`; they do not abort the whole listing. Permission or backend I/O failures while listing the directory or resolving/probing child metadata fail the whole listing with structured `FsError` codes: + +- `FS_NOT_FOUND` for missing targets. +- `FS_NOT_DIRECTORY` for existing non-directory targets. +- `FS_PERMISSION_DENIED` for permission failures. +- `FS_IO_ERROR` for other backend I/O failures. +- `FS_ABORTED` for aborted calls. + +## Rejected alternatives + +**Add a model-facing list tool now.** Rejected for this PR. The immediate request is the provider seam, and the user explicitly asked not to change skill loading or other upper layers in this branch. A model-facing tool needs prompt/schema/rendering decisions that should be reviewed separately. + +**Keep directory enumeration in each consumer.** Rejected. That would bind product packages such as `dsh-skill` to Node/local filesystem behavior and bypass policy/remote/sandboxed backends. + +**Make `listDir` recursive or glob-shaped.** Rejected for now. Skill-root discovery only needs direct children, and a simple direct listing is the smallest backend contract future consumers can safely compose. + +**Skip children that fail metadata resolution.** Rejected. The API promises resolved child targets, so permission/IO failures while resolving a child are contract failures. Broken or disappeared children are the exception because they can still be represented without claiming a live resolved file. + +## Consequences + +Every filesystem backend must now implement one additional provider primitive. That is deliberate foundation work while the harness is still unreleased, but it does mean future sandboxed/remote backends need to define equivalent direct-child listing behavior. + +The capability remains provider-facing. Until a consumer lands, ACP/model sessions will still need existing tools such as `bash` for directory listing. The absence of a model-facing `listdir` tool is expected, not a wiring failure. diff --git a/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md index a3b5f03b0e..48a1e5e47c 100644 --- a/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md +++ b/docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md @@ -39,7 +39,6 @@ abstract resolve(path: string): Promise abstract stat(target: FsTarget, signal?: AbortSignal): Promise abstract readText(target: FsTarget, signal?: AbortSignal): Promise abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> -abstract listDir(target: FsTarget, signal?: AbortSignal): Promise abstract writeText(target: FsTarget, content: string, expected: FsWriteIntent, signal?: AbortSignal): Promise abstract editText(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, signal?: AbortSignal): Promise @@ -49,20 +48,12 @@ interface FsInfo { size?: number } -interface FsDirEntry { - name: string - type: 'file' | 'directory' | 'other' - target: FsTarget - version?: FsVersion - 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. `listDir` returns direct children in stable name order with child names, types, resolved targets, and cheap metadata only; it does not read file contents. +`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`. @@ -116,7 +107,7 @@ It keeps the interface/implementation/consumer discipline, consumer-never-import ## Acceptance Criteria -- `dsh-fs` exposes exactly `resolve`/`stat`/`readText`/`streamText`/`listDir`/`writeText`/`editText`; `stat` returns `FsInfo | undefined`; `listDir` returns stable direct-child metadata without reading contents; `writeText` uses `FsWriteIntent` (`createIfAbsent` or `replaceIfVersion`); removed types/primitives are gone, and the old `applyEdit` API is replaced by `editText`. +- `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. @@ -124,6 +115,10 @@ It keeps the interface/implementation/consumer discipline, consumer-never-import - 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. +## Later extension + +The seam was later extended with direct directory listing by [Add direct directory listing to the filesystem seam](../architecture/2026-07-03-filesystem-directory-listing-seam.md). That follow-up is tracked separately so this RFC's acceptance criteria continue to describe the fsspec-style refit that originally shipped. + ## 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. diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index 3414f1ce65..d7bce1d3a7 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -15,7 +15,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) - **`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. -- **`listDir`** — lists one directory level in stable `name.localeCompare()` order. Each entry carries the child basename, type, resolved child target (`displayPath` under the listed directory, `targetKey` as the realpath identity), and cheap stat metadata (`version`, plus `size` for regular files). It never opens or decodes file contents. Missing targets report `FS_NOT_FOUND`, file/special-file targets report `FS_NOT_DIRECTORY`, aborted calls report `FS_ABORTED`, permission failures report `FS_PERMISSION_DENIED`, and other `readdir` I/O failures report `FS_IO_ERROR`. +- **`listDir`** — lists one directory level in stable `name.localeCompare()` order. Each entry carries the child basename, type, resolved child target (`displayPath` under the listed directory, `targetKey` as the realpath identity), and cheap stat metadata (`version`, plus `size` for regular files). It never opens or decodes file contents. Missing targets report `FS_NOT_FOUND`, file/special-file targets report `FS_NOT_DIRECTORY`, aborted calls report `FS_ABORTED`, permission failures report `FS_PERMISSION_DENIED`, and other listing or child metadata I/O failures report `FS_IO_ERROR`. Broken/disappeared children are returned as `other` without metadata, but permission/IO failures while resolving a child fail the whole listing with a structured `FsError`. - **`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`). diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index 1d22625816..2472730df7 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -55,11 +55,9 @@ function errorMessage(error: unknown): string { } /* v8 ignore stop */ -/* v8 ignore start -- requires permission/kernel failures from readdir after a successful directory stat. */ function isPermissionError(error: unknown): boolean { return error instanceof Error && 'code' in error && (error.code === 'EACCES' || error.code === 'EPERM') } -/* v8 ignore stop */ function throwIfAborted(signal: AbortSignal | undefined, verb: string): void { if (signal?.aborted) throw new FsError(`${verb} aborted`, 'FS_ABORTED') @@ -189,13 +187,14 @@ export async function probe(absolutePath: string): Promise { // --- Directory listing --- -/* v8 ignore start -- requires permission/kernel failures from readdir after a successful directory stat. */ function listingIoError(displayPath: string, error: unknown): FsError { + /* v8 ignore next -- defensive pass-through for races where a child resolver has already produced a structured FsError. */ + if (error instanceof FsError) return error + /* v8 ignore next -- requires the listed target/parent to disappear between successful preflight and listing/child resolution. */ if (isENOENT(error) || isENOTDIR(error)) return new FsError(`cannot list "${displayPath}": not found`, 'FS_NOT_FOUND', { cause: error }) if (isPermissionError(error)) return new FsError(`cannot list "${displayPath}": permission denied`, 'FS_PERMISSION_DENIED', { cause: error }) return new FsError(`cannot list "${displayPath}": ${errorMessage(error)}`, 'FS_IO_ERROR', { cause: error }) } -/* v8 ignore stop */ /** * List direct children of a directory in stable name order. Each child includes @@ -204,7 +203,12 @@ function listingIoError(displayPath: string, error: unknown): FsError { */ export async function listDirectory(target: LocalTarget, signal?: AbortSignal): Promise { throwIfAborted(signal, 'list') - const info = await probe(target.targetKey) + let info: PathInfo | null + try { + info = await probe(target.targetKey) + } catch (error: unknown) { + throw listingIoError(target.displayPath, error) + } if (!info) throw new FsError(`cannot list "${target.displayPath}": not found`, 'FS_NOT_FOUND') if (info.type !== 'directory') throw new FsError(`cannot list "${target.displayPath}": not a directory`, 'FS_NOT_DIRECTORY') @@ -217,19 +221,25 @@ export async function listDirectory(target: LocalTarget, signal?: AbortSignal): } throwIfAborted(signal, 'list') - return await Promise.all(entries - .sort((left, right) => left.name.localeCompare(right.name)) - .map(async (entry): Promise => { + const result: LocalDirEntry[] = [] + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + throwIfAborted(signal, 'list') + try { const childTarget = await resolveLocalTarget(target.displayPath, entry.name) const childInfo = await probe(childTarget.targetKey) - return { + result.push({ name: entry.name, type: childInfo?.type ?? 'other', target: childTarget, ...(childInfo ? { version: childInfo.version } : {}), ...(childInfo?.type === 'file' ? { size: childInfo.size } : {}), - } - })) + }) + } catch (error: unknown) { + throw listingIoError(join(target.displayPath, entry.name), error) + } + throwIfAborted(signal, 'list') + } + return result } // --- Reading --- diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index e52cb779e8..7b30753f77 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -125,7 +125,7 @@ export class LocalFileSystem extends FileSystem { return entries.map(entry => ({ name: entry.name, type: entry.type, - target: { inputPath: entry.target.displayPath, targetKey: entry.target.targetKey, displayPath: entry.target.displayPath }, + target: { inputPath: entry.name, targetKey: entry.target.targetKey, displayPath: entry.target.displayPath }, ...(entry.version !== undefined ? { version: entry.version } : {}), ...(entry.size !== undefined ? { size: entry.size } : {}), })) diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts index cfbaa36e30..516c1db7d3 100644 --- a/packages/fs/fs-local/tests/filesystem.spec.ts +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -138,6 +138,12 @@ describe('listDir', () => { join(dir, 'skills', 'dir-skill'), join(dir, 'skills', 'zeta.md'), ]) + expect(entries.map(entry => entry.target.inputPath)).toEqual([ + 'alpha.md', + 'broken-link', + 'dir-skill', + 'zeta.md', + ]) const materializedEntries = entries.filter(entry => entry.version !== undefined) expect(materializedEntries.map(entry => entry.target.targetKey)) .toEqual(await Promise.all(materializedEntries.map(entry => realpath(entry.target.displayPath)))) diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index 27f08e39c7..8d04a38d71 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -6,7 +6,7 @@ */ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { mkdtemp, readFile, rm, stat, symlink, writeFile, mkdir, readdir, realpath } from 'node:fs/promises' +import { chmod, 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' @@ -174,6 +174,54 @@ describe('listDirectory', () => { await expect(listDirectory(localTarget(file))).rejects.toMatchObject({ code: 'FS_NOT_DIRECTORY' }) await expect(listDirectory(localTarget(dir), AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' }) }) + + it('translates directory permission failures into FS_PERMISSION_DENIED', async () => { + const root = join(dir, 'restricted') + await mkdir(root) + await chmod(root, 0o000) + try { + const error = await listDirectory(localTarget(root)).then(() => undefined, (caught: unknown) => caught) + // Root-like environments may still be able to list mode-000 directories. + if (error === undefined) return + expect(error).toBeInstanceOf(FsError) + expect(error).toMatchObject({ code: 'FS_PERMISSION_DENIED' }) + } finally { + await chmod(root, 0o700) + } + }) + + it('translates preflight metadata IO failures into FS_IO_ERROR', async () => { + const loop = join(dir, 'loop') + await symlink(loop, loop) + await expect(listDirectory(localTarget(loop))).rejects.toMatchObject({ code: 'FS_IO_ERROR' }) + }) + + it('translates child resolution failures into structured listing errors', async () => { + const root = join(dir, 'listed') + await mkdir(root) + const loop = join(root, 'loop') + await symlink(loop, loop) + await expect(listDirectory(localTarget(root))).rejects.toMatchObject({ code: 'FS_IO_ERROR' }) + }) + + it('translates child permission failures into FS_PERMISSION_DENIED', async () => { + const root = join(dir, 'listed') + const protectedRoot = join(dir, 'protected') + const secret = join(protectedRoot, 'secret') + await mkdir(root) + await mkdir(secret, { recursive: true }) + await symlink(secret, join(root, 'secret-link')) + await chmod(protectedRoot, 0o000) + try { + const error = await listDirectory(localTarget(root)).then(() => undefined, (caught: unknown) => caught) + // Root-like environments may still resolve through mode-000 directories. + if (error === undefined) return + expect(error).toBeInstanceOf(FsError) + expect(error).toMatchObject({ code: 'FS_PERMISSION_DENIED' }) + } finally { + await chmod(protectedRoot, 0o700) + } + }) }) describe('readWholeText', () => { diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md index 4296f22723..4bd152cab9 100644 --- a/packages/fs/fs/README.md +++ b/packages/fs/fs/README.md @@ -23,7 +23,7 @@ A backend subclasses `FileSystem` and implements seven primitives. | `stat(target, signal?)` | Return `FsInfo` metadata (`version`, `type`, optional `size`), or `undefined` when the target is absent. Never content. | | `readText(target, signal?)` | Read the whole regular text file as one decoded string. Owns regular-file checks, UTF-8 decoding, binary/NUL rejection (`FS_NOT_TEXT`). | | `streamText(target, signal?)` | Stream the same text as decoded chunks for large files (cross-chunk UTF-8 decoding stays here). | -| `listDir(target, signal?)` | List direct directory children in stable name order. Returns entry names, entry types, resolved child targets, and cheap metadata (`version`/file `size` when available); never reads file contents. Missing targets throw `FS_NOT_FOUND`, non-directories throw `FS_NOT_DIRECTORY`, permission failures throw `FS_PERMISSION_DENIED`, and other backend I/O failures throw `FS_IO_ERROR`. | +| `listDir(target, signal?)` | List direct directory children in stable name order. Returns entry names, entry types, resolved child targets, and cheap metadata (`version`/file `size` when available); never reads file contents. Missing targets throw `FS_NOT_FOUND`, non-directories throw `FS_NOT_DIRECTORY`, permission failures throw `FS_PERMISSION_DENIED`, and other backend I/O failures throw `FS_IO_ERROR`. Broken/disappeared children may be returned as `other` without metadata; child permission/IO failures fail the whole listing with the same structured codes. | | `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. | From 744130725110d4efe535094652ac2c2b87bdc088 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 3 Jul 2026 15:52:05 +0800 Subject: [PATCH 43/75] fix(web): open WebError.code to string, aligning with other seams The closed WebErrorCode union leaked fetch-transport details (redirect, too-large, content-type) into the seam's shared vocabulary and made web the only seam with a closed error-code union. Drop it and let WebError carry an open code: string like LlmError/SubagentError; document the codes grouped by owner (seam-neutral vs dsh-web-fetch-local transport). Addresses tianyicui's leaky-abstraction review comment on WebErrorCode. --- docs/cordis-catalog/events-and-services.md | 4 +- docs/core-data-structures/core.md | 2 +- docs/core-data-structures/web.md | 19 +------- packages/web/web/src/index.ts | 1 - packages/web/web/src/types.ts | 55 ++++++++-------------- scripts/type-equiv.manifest.json | 3 +- 6 files changed, 25 insertions(+), 59 deletions(-) diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index c3843f944d..1bb243afc8 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -309,7 +309,7 @@ Fired after the provider registry changes — a search or fetch provider was reg 'web/providers-change'(this: WebService): void ``` -Source: [`packages/web/web/src/index.ts:66`](../../packages/web/web/src/index.ts) +Source: [`packages/web/web/src/index.ts:65`](../../packages/web/web/src/index.ts) ## Services @@ -506,7 +506,7 @@ async search(request: WebSearchRequest, exec?: WebExecContext): Promise ``` -Source: [`packages/web/web/src/index.ts:106`](../../packages/web/web/src/index.ts) +Source: [`packages/web/web/src/index.ts:105`](../../packages/web/web/src/index.ts) ## Inherited tier (cordis core + loader/hmr/timer) diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index fdfbcfbbf4..b6ce3d060f 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -22,7 +22,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashTask`s | | [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 | -| [web.md](web.md) | the web access seam: `WebSearchRequest`/`Result`, `WebFetchRequest`/`Result`, `WebFetchBody`, provider/capability status, `WebErrorCode` | +| [web.md](web.md) | the web access seam: `WebSearchRequest`/`Result`, `WebFetchRequest`/`Result`, `WebFetchBody`, provider/capability status, `WebError` | > Type definitions on this page are pasted **verbatim** from source and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)). Inline JSDoc is omitted for readability; follow the source link for the full contracts. diff --git a/docs/core-data-structures/web.md b/docs/core-data-structures/web.md index 43ed4e7aeb..1adde3bd75 100644 --- a/docs/core-data-structures/web.md +++ b/docs/core-data-structures/web.md @@ -95,24 +95,7 @@ Selection never depends on registration, config, or HMR order: a capability has ## Errors -`WebError extends HarnessError` ([core.md](core.md) error taxonomy) with a stable `WebErrorCode`. `WEB_DUPLICATE_PROVIDER` is a registration-time programming error (the analogue of `LlmService`'s `DUPLICATE_ADAPTER`); the `WEB_PROVIDER_*` selection codes and the fetch transport codes are execution outcomes. `WEB_PROVIDER_ERROR` is the catch-all for a provider's own failure surfaced through the seam, including network/transport failure (DNS, connection refused, TLS). - -```ts type-equiv -type WebErrorCode = - | 'WEB_PROVIDER_UNAVAILABLE' - | 'WEB_PROVIDER_CONFIGURED_MISSING' - | 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE' - | 'WEB_PROVIDER_AMBIGUOUS' - | 'WEB_DUPLICATE_PROVIDER' - | 'WEB_INVALID_URL' - | 'WEB_BLOCKED_URL' - | 'WEB_REDIRECT_BLOCKED' - | 'WEB_FETCH_TOO_LARGE' - | 'WEB_FETCH_TIMEOUT' - | 'WEB_ABORTED' - | 'WEB_UNSUPPORTED_CONTENT_TYPE' - | 'WEB_PROVIDER_ERROR' -``` +`WebError extends HarnessError` ([core.md](core.md) error taxonomy) with a `code: string` (open, like every other seam's error — `LlmError`, `SubagentError`), not a closed union: a provider may raise its own codes without editing `dsh-web`, and consumers must tolerate an unknown code. The codes split by owner. Seam-neutral codes are raised by `WebService` selection and the shared contract: `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_CONFIGURED_MISSING`, `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, `WEB_PROVIDER_AMBIGUOUS`, `WEB_DUPLICATE_PROVIDER` (a registration-time programming error, the analogue of `LlmService`'s `DUPLICATE_ADAPTER`), `WEB_ABORTED`, and `WEB_PROVIDER_ERROR` (the catch-all for a provider's own failure surfaced through the seam, including network/transport failure — DNS, connection refused, TLS). Fetch-transport codes are owned by the `dsh-web-fetch-local` implementation and a different fetch backend need not raise them: `WEB_INVALID_URL`, `WEB_BLOCKED_URL`, `WEB_REDIRECT_BLOCKED`, `WEB_FETCH_TOO_LARGE`, `WEB_FETCH_TIMEOUT`, `WEB_UNSUPPORTED_CONTENT_TYPE`. ## The service diff --git a/packages/web/web/src/index.ts b/packages/web/web/src/index.ts index 172c1a0ecb..50150f6961 100644 --- a/packages/web/web/src/index.ts +++ b/packages/web/web/src/index.ts @@ -36,7 +36,6 @@ export { } from './types.ts' export type { WebCapabilityStatus, - WebErrorCode, WebExecContext, WebFetchBody, WebFetchProvider, diff --git a/packages/web/web/src/types.ts b/packages/web/web/src/types.ts index ec97101ae2..6f85787d1b 100644 --- a/packages/web/web/src/types.ts +++ b/packages/web/web/src/types.ts @@ -172,8 +172,19 @@ export interface WebFetchProvider { } /** - * Stable codes for {@link WebError}. Callers (hooks, tests, UI) route on these. + * Typed web error. Extends {@link HarnessError} so it carries a stable, + * machine-routable `code` (a `string`, like every other seam's error) and + * chains `cause`. `ToolRegistry.execute()` converts a thrown `WebError` into an + * error tool result whose structured metadata exposes the code, so callers + * (hooks, tests, UI) route on it. * + * The `code` is an open `string`, NOT a closed union: a provider may raise its + * own codes without editing this package, and a consumer must tolerate an + * unknown code (a future provider will introduce ones this file never named). + * The codes split by who owns them — seam-neutral codes any provider may see, + * versus codes specific to a single implementation: + * + * Seam-neutral (raised by `WebService` selection and the shared contract): * - `WEB_PROVIDER_UNAVAILABLE`: no provider configured and none usable. * - `WEB_PROVIDER_CONFIGURED_MISSING`: a configured id is not registered. * - `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`: a configured id is registered but its @@ -182,44 +193,18 @@ export interface WebFetchProvider { * exist (selection refuses to pick by registration order). * - `WEB_DUPLICATE_PROVIDER`: a registration-time programming error — an id is * already registered for that capability kind. + * - `WEB_ABORTED`: the operation was aborted via `WebExecContext.signal`. + * - `WEB_PROVIDER_ERROR`: catch-all for a provider's own failure surfaced + * through the seam, including network/transport failure (DNS, connection + * refused, TLS). + * + * Fetch-transport codes (owned by the `dsh-web-fetch-local` implementation; a + * different fetch backend need not raise these and may raise its own): * - `WEB_INVALID_URL`: the fetch URL is malformed or not http(s). * - `WEB_BLOCKED_URL`: the fetch URL is rejected by policy (credentials in URL). * - `WEB_REDIRECT_BLOCKED`: a cross-origin redirect was refused. * - `WEB_FETCH_TOO_LARGE`: the response exceeded the byte/character cap. * - `WEB_FETCH_TIMEOUT`: the fetch exceeded its timeout. - * - `WEB_ABORTED`: the operation was aborted via `WebExecContext.signal`. * - `WEB_UNSUPPORTED_CONTENT_TYPE`: the response content type cannot be decoded. - * - `WEB_PROVIDER_ERROR`: catch-all for a provider's own failure surfaced through - * the seam, including network/transport failure (DNS, connection refused, TLS). */ -export type WebErrorCode = - | 'WEB_PROVIDER_UNAVAILABLE' - | 'WEB_PROVIDER_CONFIGURED_MISSING' - | 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE' - | 'WEB_PROVIDER_AMBIGUOUS' - | 'WEB_DUPLICATE_PROVIDER' - | 'WEB_INVALID_URL' - | 'WEB_BLOCKED_URL' - | 'WEB_REDIRECT_BLOCKED' - | 'WEB_FETCH_TOO_LARGE' - | 'WEB_FETCH_TIMEOUT' - | 'WEB_ABORTED' - | 'WEB_UNSUPPORTED_CONTENT_TYPE' - | 'WEB_PROVIDER_ERROR' - -/** - * Typed web error. Extends {@link HarnessError} so it carries a stable - * {@link WebErrorCode} and chains `cause`. `dsh-web` owns this vocabulary so - * providers, the seam, and the tool layer raise the same codes instead of each - * inventing message strings. `ToolRegistry.execute()` converts a thrown - * `WebError` into an error tool result whose structured metadata exposes the - * code. - */ -export class WebError extends HarnessError { - override readonly code: WebErrorCode - - constructor(message: string, code: WebErrorCode, options?: ErrorOptions) { - super(message, code, options) - this.code = code - } -} +export class WebError extends HarnessError {} diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 2a499580a7..ed8d342e28 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -58,7 +58,6 @@ { "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchResult", "source": "packages/web/web/src/types.ts" }, { "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchBody", "source": "packages/web/web/src/types.ts" }, { "doc": "docs/core-data-structures/web.md", "symbol": "WebProviderStatus", "source": "packages/web/web/src/types.ts" }, - { "doc": "docs/core-data-structures/web.md", "symbol": "WebCapabilityStatus", "source": "packages/web/web/src/types.ts" }, - { "doc": "docs/core-data-structures/web.md", "symbol": "WebErrorCode", "source": "packages/web/web/src/types.ts" } + { "doc": "docs/core-data-structures/web.md", "symbol": "WebCapabilityStatus", "source": "packages/web/web/src/types.ts" } ] } From 580496b72aa385ac15eed89db3e52f726a69fe94 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 3 Jul 2026 16:21:12 +0800 Subject: [PATCH 44/75] feat(web): expose exa/perplexity search tuning as config The Exa and Perplexity providers hard-coded request parameters that deployments should control while defaults are still unsettled. Exa gains searchType, numResults, and highlightsPerResult; Perplexity gains maxTokens (it previously sent none) and an optional searchRecency. Each follows the deepseek provider's shape: a defaulted Config field, a DEFAULT_* constant, and a positive-integer status() check for numeric limits. The call-level maxResults still flows through WebSearchRequest and wins over the configured default, keeping the seam layering intact. Addresses tianyicui's "make everything configurable" review comment. --- packages/web/web-search-exa/README.md | 5 +- packages/web/web-search-exa/src/index.ts | 28 +++++++-- packages/web/web-search-exa/src/provider.ts | 26 +++++++- packages/web/web-search-exa/src/types.ts | 4 +- packages/web/web-search-exa/tests/exa.e2e.ts | 9 ++- packages/web/web-search-exa/tests/exa.spec.ts | 59 ++++++++++++++++--- packages/web/web-search-perplexity/README.md | 2 + .../web/web-search-perplexity/src/index.ts | 22 +++++-- .../web/web-search-perplexity/src/provider.ts | 18 ++++++ .../tests/perplexity.e2e.ts | 3 +- .../tests/perplexity.spec.ts | 23 +++++++- 11 files changed, 172 insertions(+), 27 deletions(-) diff --git a/packages/web/web-search-exa/README.md b/packages/web/web-search-exa/README.md index 6485d64c60..0bc58d6559 100644 --- a/packages/web/web-search-exa/README.md +++ b/packages/web/web-search-exa/README.md @@ -10,6 +10,9 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i |---|---|---| | `apiKey` | `$EXA_API_KEY` | Exa API key. Empty/absent → provider `status()` reports `missing-credential` (the seam reports `configured-unavailable`/`none`). | | `baseURL` | `https://api.exa.ai` | Endpoint base; `/search` is appended. An unparseable value makes `status()` report `misconfigured`. | +| `searchType` | `auto` | Retrieval mode sent as Exa's `type`: `auto` (Exa decides), `keyword`, or `neural`. | +| `numResults` | (unset) | Default result count when a request carries no `maxResults`. Unset sends no default. Must be a positive integer. | +| `highlightsPerResult` | `1` | Highlight sentences requested per result (Exa's `highlightsPerUrl`). Must be a positive integer. | ```yaml - id: web-search-exa @@ -20,4 +23,4 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i ## Mapping -Exa returns a flat `results[]` and no generated answer, so `content` is omitted. Each result maps to a `WebSearchSource`: `url` ← `url`, `title` ← `title`, `snippet` ← the first non-empty `highlights[]` entry (a result with no highlight has no portable snippet and is dropped), `publishedAt` ← `publishedDate`. The provider passes `maxResults` through as Exa's `numResults` for a cost/latency optimization; the final bound is enforced by the seam. Provider failures (HTTP errors, network failure, unparseable or wrong-shape bodies) surface as `WebError` `WEB_PROVIDER_ERROR`; an aborted request surfaces as `WEB_ABORTED`. +Exa returns a flat `results[]` and no generated answer, so `content` is omitted. Each result maps to a `WebSearchSource`: `url` ← `url`, `title` ← `title`, `snippet` ← the first non-empty `highlights[]` entry (a result with no highlight has no portable snippet and is dropped), `publishedAt` ← `publishedDate`. A request's `maxResults` wins over the configured `numResults` default and is sent as Exa's `numResults` for a cost/latency optimization; the final bound is enforced by the seam. Provider failures (HTTP errors, network failure, unparseable or wrong-shape bodies) surface as `WebError` `WEB_PROVIDER_ERROR`; an aborted request surfaces as `WEB_ABORTED`. diff --git a/packages/web/web-search-exa/src/index.ts b/packages/web/web-search-exa/src/index.ts index f266474708..39a20b16a4 100644 --- a/packages/web/web-search-exa/src/index.ts +++ b/packages/web/web-search-exa/src/index.ts @@ -11,10 +11,17 @@ import type { Context } from 'cordis' import z from 'schemastery' import type {} from '@deepseek-ai/dsh-web' -import { ExaSearchProvider, EXA_DEFAULT_BASE_URL } from './provider.ts' +import { + ExaSearchProvider, + EXA_DEFAULT_BASE_URL, + EXA_DEFAULT_HIGHLIGHTS_PER_RESULT, + EXA_DEFAULT_SEARCH_TYPE, +} from './provider.ts' export { EXA_DEFAULT_BASE_URL, + EXA_DEFAULT_HIGHLIGHTS_PER_RESULT, + EXA_DEFAULT_SEARCH_TYPE, EXA_PROVIDER_ID, ExaSearchProvider, mapExaResponse, @@ -33,16 +40,29 @@ export interface Config { apiKey?: string /** Endpoint base; `/search` is appended. Defaults to the public API. */ baseURL?: string + /** Retrieval mode sent as Exa's `type`. Defaults to `auto`. */ + searchType?: 'auto' | 'keyword' | 'neural' + /** Default result count when a request carries no `maxResults`. Omitted = none. */ + numResults?: number + /** Highlight sentences requested per result. Defaults to 1. */ + highlightsPerResult?: number } export const Config: z = z.object({ apiKey: z.string(), baseURL: z.string(), + searchType: z.union(['auto', 'keyword', 'neural'] as const), + numResults: z.number().step(1).min(1), + highlightsPerResult: z.number().step(1).min(1), }) /** Register the Exa search provider with `ctx.web`. */ export function apply(ctx: Context, config: Config): void { - const apiKey = config.apiKey ?? process.env.EXA_API_KEY ?? '' - const baseURL = config.baseURL ?? EXA_DEFAULT_BASE_URL - ctx.web.registerSearchProvider(new ExaSearchProvider({ apiKey, baseURL })) + ctx.web.registerSearchProvider(new ExaSearchProvider({ + apiKey: config.apiKey ?? process.env.EXA_API_KEY ?? '', + baseURL: config.baseURL ?? EXA_DEFAULT_BASE_URL, + searchType: config.searchType ?? EXA_DEFAULT_SEARCH_TYPE, + highlightsPerResult: config.highlightsPerResult ?? EXA_DEFAULT_HIGHLIGHTS_PER_RESULT, + ...config.numResults !== undefined ? { numResults: config.numResults } : {}, + })) } diff --git a/packages/web/web-search-exa/src/provider.ts b/packages/web/web-search-exa/src/provider.ts index cfb41cf77f..f187f90344 100644 --- a/packages/web/web-search-exa/src/provider.ts +++ b/packages/web/web-search-exa/src/provider.ts @@ -28,6 +28,12 @@ export const EXA_PROVIDER_ID = 'exa' /** Default Exa search endpoint; `/search` is the operation. */ export const EXA_DEFAULT_BASE_URL = 'https://api.exa.ai' +/** Default retrieval mode: let Exa pick between keyword and neural search. */ +export const EXA_DEFAULT_SEARCH_TYPE = 'auto' + +/** Default number of highlight sentences requested per result. */ +export const EXA_DEFAULT_HIGHLIGHTS_PER_RESULT = 1 + /** Attribution header sent on every request. Bump with the package version. */ const USER_AGENT = 'deepseek-harness/0.0.1' @@ -36,6 +42,12 @@ export interface ExaSearchProviderOptions { apiKey: string /** Endpoint base; `/search` is appended. */ baseURL: string + /** Retrieval mode sent as Exa's `type`. */ + searchType: 'auto' | 'keyword' | 'neural' + /** Default result count when a request carries no `maxResults`. */ + numResults?: number + /** Highlight sentences requested per result (Exa's `highlightsPerUrl`). */ + highlightsPerResult: number } /** @@ -73,10 +85,14 @@ export class ExaSearchProvider implements WebSearchProvider { status(): WebProviderStatus { if (this.options.apiKey.length === 0) return { available: false, reason: 'missing-credential' } if (!isValidBaseUrl(this.options.baseURL)) return { available: false, reason: 'misconfigured' } + if (!isPositiveInteger(this.options.highlightsPerResult)) return { available: false, reason: 'misconfigured' } + if (this.options.numResults !== undefined && !isPositiveInteger(this.options.numResults)) return { available: false, reason: 'misconfigured' } return { available: true } } async search(request: WebSearchRequest, exec?: { readonly signal?: AbortSignal }): Promise { + // A per-request bound wins over the configured default; either may be absent. + const numResults = request.maxResults ?? this.options.numResults let response: Response try { response = await fetch(`${this.options.baseURL}/search`, { @@ -89,8 +105,9 @@ export class ExaSearchProvider implements WebSearchProvider { }, body: JSON.stringify({ query: request.query, - contents: { highlights: true }, - ...request.maxResults !== undefined ? { numResults: request.maxResults } : {}, + type: this.options.searchType, + contents: { highlights: { highlightsPerUrl: this.options.highlightsPerResult } }, + ...numResults !== undefined ? { numResults } : {}, }), ...exec?.signal ? { signal: exec.signal } : {}, }) @@ -133,6 +150,11 @@ function isValidBaseUrl(baseURL: string): boolean { return URL.canParse(baseURL) } +/** True for a request limit that can be sent to Exa (a positive whole number). */ +function isPositiveInteger(value: number): boolean { + return Number.isInteger(value) && value > 0 +} + /** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */ function isAbortError(error: unknown): boolean { return error instanceof DOMException && error.name === 'AbortError' diff --git a/packages/web/web-search-exa/src/types.ts b/packages/web/web-search-exa/src/types.ts index a0bda5f768..fae42d07cd 100644 --- a/packages/web/web-search-exa/src/types.ts +++ b/packages/web/web-search-exa/src/types.ts @@ -10,10 +10,12 @@ /** Request body sent to Exa's search endpoint. */ export interface ExaSearchRequest { query: string + /** Retrieval mode: keyword, neural (embeddings), or auto (Exa decides). */ + type: 'auto' | 'keyword' | 'neural' /** Exa's result-count control; the seam still enforces the bound on return. */ numResults?: number /** Ask Exa to return highlight sentences per result. */ - contents: { highlights: true } + contents: { highlights: { highlightsPerUrl: number } } } /** One entry of Exa's flat `results[]`. */ diff --git a/packages/web/web-search-exa/tests/exa.e2e.ts b/packages/web/web-search-exa/tests/exa.e2e.ts index 78f11940e5..32da0a485c 100644 --- a/packages/web/web-search-exa/tests/exa.e2e.ts +++ b/packages/web/web-search-exa/tests/exa.e2e.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { ExaSearchProvider, EXA_DEFAULT_BASE_URL } from '@deepseek-ai/dsh-web-search-exa' +import { ExaSearchProvider, EXA_DEFAULT_BASE_URL, EXA_DEFAULT_HIGHLIGHTS_PER_RESULT, EXA_DEFAULT_SEARCH_TYPE } from '@deepseek-ai/dsh-web-search-exa' /** * Real-API smoke for the Exa search provider. Self-skips without `$EXA_API_KEY` @@ -10,7 +10,12 @@ const maybe = apiKey !== undefined && apiKey.length > 0 ? describe : describe.sk maybe('ExaSearchProvider real API', () => { it('returns sources for a live query', async () => { - const provider = new ExaSearchProvider({ apiKey: apiKey!, baseURL: process.env.EXA_BASE_URL ?? EXA_DEFAULT_BASE_URL }) + const provider = new ExaSearchProvider({ + apiKey: apiKey!, + baseURL: process.env.EXA_BASE_URL ?? EXA_DEFAULT_BASE_URL, + searchType: EXA_DEFAULT_SEARCH_TYPE, + highlightsPerResult: EXA_DEFAULT_HIGHLIGHTS_PER_RESULT, + }) const result = await provider.search({ query: 'DeepSeek coding agent', maxResults: 5 }) expect(result.providerId).toBe('exa') expect(result.sources.length).toBeGreaterThan(0) diff --git a/packages/web/web-search-exa/tests/exa.spec.ts b/packages/web/web-search-exa/tests/exa.spec.ts index 436e542d7c..dcb6fbea6d 100644 --- a/packages/web/web-search-exa/tests/exa.spec.ts +++ b/packages/web/web-search-exa/tests/exa.spec.ts @@ -4,7 +4,7 @@ import WebService from '@deepseek-ai/dsh-web' import { ExaSearchProvider, mapExaResponse, mapExaResult, EXA_PROVIDER_ID } from '@deepseek-ai/dsh-web-search-exa' import * as exaPlugin from '@deepseek-ai/dsh-web-search-exa' -const options = { apiKey: 'exa-key', baseURL: 'https://api.exa.test' } +const options = { apiKey: 'exa-key', baseURL: 'https://api.exa.test', searchType: 'auto' as const, highlightsPerResult: 1 } function jsonResponse(body: unknown, init: ResponseInit = {}): Response { return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' }, ...init }) @@ -65,7 +65,7 @@ describe('Exa result mapping', () => { describe('ExaSearchProvider status', () => { it('is unavailable without a key', () => { - expect(new ExaSearchProvider({ apiKey: '', baseURL: options.baseURL }).status()) + expect(new ExaSearchProvider({ ...options, apiKey: '' }).status()) .toEqual({ available: false, reason: 'missing-credential' }) }) @@ -74,27 +74,60 @@ describe('ExaSearchProvider status', () => { }) it('is misconfigured when the base URL is unparseable', () => { - expect(new ExaSearchProvider({ apiKey: 'exa-key', baseURL: 'not a url' }).status()) + expect(new ExaSearchProvider({ ...options, baseURL: 'not a url' }).status()) + .toEqual({ available: false, reason: 'misconfigured' }) + }) + + it('is misconfigured when highlightsPerResult is not a positive integer', () => { + expect(new ExaSearchProvider({ ...options, highlightsPerResult: 0 }).status()) + .toEqual({ available: false, reason: 'misconfigured' }) + expect(new ExaSearchProvider({ ...options, highlightsPerResult: 1.5 }).status()) + .toEqual({ available: false, reason: 'misconfigured' }) + }) + + it('is misconfigured when numResults is set but not a positive integer', () => { + expect(new ExaSearchProvider({ ...options, numResults: -1 }).status()) .toEqual({ available: false, reason: 'misconfigured' }) }) }) describe('ExaSearchProvider request mapping', () => { - it('sends query, highlights, numResults and bearer auth', async () => { + it('sends query, type, highlights, numResults and bearer auth', async () => { const fetchMock = vi.fn(async () => jsonResponse({ results: [{ url: 'https://a.test', highlights: ['hi'] }] })) vi.stubGlobal('fetch', fetchMock) - const provider = new ExaSearchProvider(options) + const provider = new ExaSearchProvider({ ...options, searchType: 'neural', highlightsPerResult: 3 }) await provider.search({ query: 'hello', maxResults: 5 }) expect(fetchMock).toHaveBeenCalledOnce() const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] expect(url).toBe('https://api.exa.test/search') expect((init.headers as Record)['authorization']).toBe('Bearer exa-key') - expect(JSON.parse(init.body as string)).toEqual({ query: 'hello', contents: { highlights: true }, numResults: 5 }) + expect(JSON.parse(init.body as string)).toEqual({ + query: 'hello', + type: 'neural', + contents: { highlights: { highlightsPerUrl: 3 } }, + numResults: 5, + }) }) - it('omits numResults when maxResults is absent', async () => { + it('falls back to the configured numResults when a request omits maxResults', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ results: [] })) + vi.stubGlobal('fetch', fetchMock) + await new ExaSearchProvider({ ...options, numResults: 7 }).search({ query: 'q' }) + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(JSON.parse(init.body as string)).toMatchObject({ numResults: 7 }) + }) + + it('lets a request maxResults win over the configured numResults', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ results: [] })) + vi.stubGlobal('fetch', fetchMock) + await new ExaSearchProvider({ ...options, numResults: 7 }).search({ query: 'q', maxResults: 2 }) + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(JSON.parse(init.body as string)).toMatchObject({ numResults: 2 }) + }) + + it('omits numResults when neither maxResults nor a configured default is set', async () => { const fetchMock = vi.fn(async () => jsonResponse({ results: [] })) vi.stubGlobal('fetch', fetchMock) await new ExaSearchProvider(options).search({ query: 'q' }) @@ -184,6 +217,18 @@ describe('web-search-exa plugin registration', () => { expect('default' in exaPlugin).toBe(false) }) + it('threads searchType and highlightsPerResult config into the request', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ results: [] })) + vi.stubGlobal('fetch', fetchMock) + const ctx = new Context() + await ctx.plugin(WebService, { searchProvider: EXA_PROVIDER_ID }) + const fiber = await ctx.plugin(exaPlugin, { apiKey: 'exa-key', searchType: 'keyword', highlightsPerResult: 2 }) + await ctx.web.search({ query: 'q' }) + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(JSON.parse(init.body as string)).toMatchObject({ type: 'keyword', contents: { highlights: { highlightsPerUrl: 2 } } }) + await fiber.dispose() + }) + it('falls back to $EXA_API_KEY and the default base URL when config omits them', async () => { const prev = process.env.EXA_API_KEY process.env.EXA_API_KEY = 'env-key' diff --git a/packages/web/web-search-perplexity/README.md b/packages/web/web-search-perplexity/README.md index e7093a1133..f944413c96 100644 --- a/packages/web/web-search-perplexity/README.md +++ b/packages/web/web-search-perplexity/README.md @@ -11,6 +11,8 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i | `apiKey` | `$PERPLEXITY_API_KEY` | Perplexity API key. Empty/absent → provider `status()` reports `missing-credential`. | | `baseURL` | `https://api.perplexity.ai` | Endpoint base; `/chat/completions` is appended. An unparseable value makes `status()` report `misconfigured`. | | `model` | `sonar` | Search model name. | +| `maxTokens` | `1024` | Upper bound on generated answer tokens (`max_tokens`). Must be a positive integer. | +| `searchRecency` | (unset) | Recency window sent as `search_recency_filter`: `day`, `week`, `month`, or `year`. Unset sends no filter. | ```yaml - id: web-search-perplexity diff --git a/packages/web/web-search-perplexity/src/index.ts b/packages/web/web-search-perplexity/src/index.ts index 0fd46ffb71..3d375eaabb 100644 --- a/packages/web/web-search-perplexity/src/index.ts +++ b/packages/web/web-search-perplexity/src/index.ts @@ -10,17 +10,18 @@ import type { Context } from 'cordis' import z from 'schemastery' import type {} from '@deepseek-ai/dsh-web' -import { PerplexitySearchProvider, PERPLEXITY_DEFAULT_BASE_URL, PERPLEXITY_DEFAULT_MODEL } from './provider.ts' +import { PerplexitySearchProvider, PERPLEXITY_DEFAULT_BASE_URL, PERPLEXITY_DEFAULT_MAX_TOKENS, PERPLEXITY_DEFAULT_MODEL } from './provider.ts' export { PERPLEXITY_DEFAULT_BASE_URL, + PERPLEXITY_DEFAULT_MAX_TOKENS, PERPLEXITY_DEFAULT_MODEL, PERPLEXITY_PROVIDER_ID, PerplexitySearchProvider, mapPerplexityResponse, mapPerplexityResult, } from './provider.ts' -export type { PerplexitySearchProviderOptions } from './provider.ts' +export type { PerplexityRecency, PerplexitySearchProviderOptions } from './provider.ts' /** Cordis plugin name used by loader diagnostics. */ export const name = 'web-search-perplexity' @@ -35,18 +36,27 @@ export interface Config { baseURL?: string /** Search model name. Defaults to `sonar`. */ model?: string + /** Upper bound on generated answer tokens. Defaults to 1024. */ + maxTokens?: number + /** Recency window sent as `search_recency_filter`. Omitted = no filter. */ + searchRecency?: 'day' | 'week' | 'month' | 'year' } export const Config: z = z.object({ apiKey: z.string(), baseURL: z.string(), model: z.string(), + maxTokens: z.number().step(1).min(1), + searchRecency: z.union(['day', 'week', 'month', 'year'] as const), }) /** Register the Perplexity search provider with `ctx.web`. */ export function apply(ctx: Context, config: Config): void { - const apiKey = config.apiKey ?? process.env.PERPLEXITY_API_KEY ?? '' - const baseURL = config.baseURL ?? PERPLEXITY_DEFAULT_BASE_URL - const model = config.model ?? PERPLEXITY_DEFAULT_MODEL - ctx.web.registerSearchProvider(new PerplexitySearchProvider({ apiKey, baseURL, model })) + ctx.web.registerSearchProvider(new PerplexitySearchProvider({ + apiKey: config.apiKey ?? process.env.PERPLEXITY_API_KEY ?? '', + baseURL: config.baseURL ?? PERPLEXITY_DEFAULT_BASE_URL, + model: config.model ?? PERPLEXITY_DEFAULT_MODEL, + maxTokens: config.maxTokens ?? PERPLEXITY_DEFAULT_MAX_TOKENS, + ...config.searchRecency !== undefined ? { searchRecency: config.searchRecency } : {}, + })) } diff --git a/packages/web/web-search-perplexity/src/provider.ts b/packages/web/web-search-perplexity/src/provider.ts index 5b5feb897b..ed72ea82c3 100644 --- a/packages/web/web-search-perplexity/src/provider.ts +++ b/packages/web/web-search-perplexity/src/provider.ts @@ -32,6 +32,12 @@ export const PERPLEXITY_DEFAULT_BASE_URL = 'https://api.perplexity.ai' /** Default search model. */ export const PERPLEXITY_DEFAULT_MODEL = 'sonar' +/** Default upper bound on generated answer tokens. */ +export const PERPLEXITY_DEFAULT_MAX_TOKENS = 1024 + +/** Recency filter values Perplexity accepts for `search_recency_filter`. */ +export type PerplexityRecency = 'day' | 'week' | 'month' | 'year' + /** Attribution header sent on every request. Bump with the package version. */ const USER_AGENT = 'deepseek-harness/0.0.1' @@ -42,6 +48,10 @@ export interface PerplexitySearchProviderOptions { baseURL: string /** Search model name. */ model: string + /** Upper bound on generated answer tokens (`max_tokens`). */ + maxTokens: number + /** Optional recency window sent as `search_recency_filter`; omitted = no filter. */ + searchRecency?: PerplexityRecency } /** Map one structured Perplexity search result to a normalized source. */ @@ -82,6 +92,7 @@ export class PerplexitySearchProvider implements WebSearchProvider { status(): WebProviderStatus { if (this.options.apiKey.length === 0) return { available: false, reason: 'missing-credential' } if (!URL.canParse(this.options.baseURL)) return { available: false, reason: 'misconfigured' } + if (!isPositiveInteger(this.options.maxTokens)) return { available: false, reason: 'misconfigured' } return { available: true } } @@ -98,7 +109,9 @@ export class PerplexitySearchProvider implements WebSearchProvider { }, body: JSON.stringify({ model: this.options.model, + max_tokens: this.options.maxTokens, messages: [{ role: 'user', content: request.query }], + ...this.options.searchRecency !== undefined ? { search_recency_filter: this.options.searchRecency } : {}, }), ...exec?.signal ? { signal: exec.signal } : {}, }) @@ -140,3 +153,8 @@ export class PerplexitySearchProvider implements WebSearchProvider { function isAbortError(error: unknown): boolean { return error instanceof DOMException && error.name === 'AbortError' } + +/** True for a request limit that can be sent to Perplexity (a positive whole number). */ +function isPositiveInteger(value: number): boolean { + return Number.isInteger(value) && value > 0 +} diff --git a/packages/web/web-search-perplexity/tests/perplexity.e2e.ts b/packages/web/web-search-perplexity/tests/perplexity.e2e.ts index a546acab70..9414d46937 100644 --- a/packages/web/web-search-perplexity/tests/perplexity.e2e.ts +++ b/packages/web/web-search-perplexity/tests/perplexity.e2e.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { PerplexitySearchProvider, PERPLEXITY_DEFAULT_BASE_URL, PERPLEXITY_DEFAULT_MODEL } from '@deepseek-ai/dsh-web-search-perplexity' +import { PerplexitySearchProvider, PERPLEXITY_DEFAULT_BASE_URL, PERPLEXITY_DEFAULT_MAX_TOKENS, PERPLEXITY_DEFAULT_MODEL } from '@deepseek-ai/dsh-web-search-perplexity' /** * Real-API smoke for the Perplexity search provider. Self-skips without @@ -14,6 +14,7 @@ maybe('PerplexitySearchProvider real API', () => { apiKey: apiKey!, baseURL: process.env.PERPLEXITY_BASE_URL ?? PERPLEXITY_DEFAULT_BASE_URL, model: process.env.PERPLEXITY_MODEL ?? PERPLEXITY_DEFAULT_MODEL, + maxTokens: PERPLEXITY_DEFAULT_MAX_TOKENS, }) const result = await provider.search({ query: 'What is the DeepSeek coding agent?', maxResults: 5 }) expect(result.providerId).toBe('perplexity') diff --git a/packages/web/web-search-perplexity/tests/perplexity.spec.ts b/packages/web/web-search-perplexity/tests/perplexity.spec.ts index d84c34a328..6d55f384dd 100644 --- a/packages/web/web-search-perplexity/tests/perplexity.spec.ts +++ b/packages/web/web-search-perplexity/tests/perplexity.spec.ts @@ -8,7 +8,7 @@ import { } from '@deepseek-ai/dsh-web-search-perplexity' import * as perplexityPlugin from '@deepseek-ai/dsh-web-search-perplexity' -const options = { apiKey: 'pplx-key', baseURL: 'https://api.perplexity.test', model: 'sonar' } +const options = { apiKey: 'pplx-key', baseURL: 'https://api.perplexity.test', model: 'sonar', maxTokens: 1024 } function jsonResponse(body: unknown, init: ResponseInit = {}): Response { return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' }, ...init }) @@ -80,17 +80,34 @@ describe('PerplexitySearchProvider status', () => { expect(new PerplexitySearchProvider({ ...options, baseURL: 'not a url' }).status()) .toEqual({ available: false, reason: 'misconfigured' }) }) + + it('is misconfigured when maxTokens is not a positive integer', () => { + expect(new PerplexitySearchProvider({ ...options, maxTokens: 0 }).status()) + .toEqual({ available: false, reason: 'misconfigured' }) + expect(new PerplexitySearchProvider({ ...options, maxTokens: 1.5 }).status()) + .toEqual({ available: false, reason: 'misconfigured' }) + }) }) describe('PerplexitySearchProvider request mapping', () => { - it('sends a chat-completions request with the query as a user message', async () => { + it('sends a chat-completions request with the query, model and max_tokens', async () => { const fetchMock = vi.fn(async () => jsonResponse({ choices: [{ message: { content: 'a' } }], citations: [] })) vi.stubGlobal('fetch', fetchMock) await new PerplexitySearchProvider(options).search({ query: 'hello' }) const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] expect(url).toBe('https://api.perplexity.test/chat/completions') expect((init.headers as Record)['authorization']).toBe('Bearer pplx-key') - expect(JSON.parse(init.body as string)).toEqual({ model: 'sonar', messages: [{ role: 'user', content: 'hello' }] }) + expect(JSON.parse(init.body as string)).toEqual({ model: 'sonar', max_tokens: 1024, messages: [{ role: 'user', content: 'hello' }] }) + }) + + it('sends search_recency_filter when configured, and omits it otherwise', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ choices: [{ message: { content: 'a' } }], citations: [] })) + vi.stubGlobal('fetch', fetchMock) + await new PerplexitySearchProvider({ ...options, searchRecency: 'week' }).search({ query: 'q' }) + expect(JSON.parse((fetchMock.mock.calls[0] as unknown as [string, RequestInit])[1].body as string)).toMatchObject({ search_recency_filter: 'week' }) + + await new PerplexitySearchProvider(options).search({ query: 'q' }) + expect(JSON.parse((fetchMock.mock.calls[1] as unknown as [string, RequestInit])[1].body as string)).not.toHaveProperty('search_recency_filter') }) it('forwards the abort signal', async () => { From cd9d5598053b9782fd17ad77313cdaf767d222d7 Mon Sep 17 00:00:00 2001 From: Ziya <199893125+ZiyaZhang@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:27:34 -0700 Subject: [PATCH 45/75] docs: translate development.md to Chinese First backlog item translated with the dsh-translate-docs skill: full-file translation, terminology per docs/i18n/terminology.md, structure locked to the source (11 headings, 10 byte-identical code blocks), fingerprinted and added to the manifest's required list. --- docs/development.md | 2 + docs/development.zh.md | 157 ++++++++++++++++++++++ scripts/translation-pairing.manifest.json | 1 + 3 files changed, 160 insertions(+) create mode 100644 docs/development.zh.md diff --git a/docs/development.md b/docs/development.md index 431d7b4dac..ce431d95c5 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,5 +1,7 @@ # Development guide +English | [中文](development.zh.md) + This guide covers the local setup needed to work on DeepSeek Harness and understand the local hooks, daily checks, and CI gates. ## Prerequisites diff --git a/docs/development.zh.md b/docs/development.zh.md new file mode 100644 index 0000000000..5f285fbedf --- /dev/null +++ b/docs/development.zh.md @@ -0,0 +1,157 @@ + + +# 开发指南 + +[English](development.md) | 中文 + +本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建,以及本地钩子、日常检查与 CI 质量门禁的说明。 + +## 前置条件 + +- Node.js 24 或更新版本。仓库声明 `node >=24`;CI 在 Node 24 和 26 上跑矩阵。 +- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中钉住 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,先运行 `corepack enable`。 +- Git。 +- 可选:一个 DeepSeek API key,用于 coding-agent 演示和真实 API 的 e2e 测试。 + +## 首次搭建 + +在仓库根目录安装依赖: + +```sh +pnpm install +``` + +安装同时会运行根目录的 `postinstall` 脚本,它通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook;该包装脚本使用 lefthook 经过评审的 `--force` 模式,使已存在 `core.hooksPath` 的关联 worktree 不会让正常的 `pnpm run …` 命令失败。 + +如果因为依赖是从缓存恢复或 `postinstall` 被跳过而缺少钩子,手动安装: + +```sh +pnpm exec lefthook install --force +``` + +新克隆后先跑一次类型检查: + +```sh +pnpm run typecheck +``` + +这次首跑会构建 package/vendor 构建图,并跑根目录 no-emit `tsconfig.json` 图(覆盖 examples、tests 和 scripts)。根图使用同一份源码 `paths` 映射,但依赖 project references,因此 vendor 代码在它自己的 tsconfig 设置下被检查。 + +如果准备从新克隆或新 worktree 推送,还要构建一次: + +```sh +pnpm run build +``` + +`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件。 + +## 环境变量 + +真实的 DeepSeek 适配器和 coding-agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 读取凭证: + +```sh +DEEPSEEK_API_KEY=sk-... +DEEPSEEK_BASE_URL=https://... # optional +``` + +`DEEPSEEK_BASE_URL` 可选,默认为公开 API。绝不要提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。 + +## Git 钩子 + +lefthook 在 `lefthook.yml` 中配置,作为评审前的本地早期检查点: + +- `pre-commit` 运行对暂存文件的 ESLint 修复、`pnpm run typecheck` 和 vendor manifest 守卫。 +- `pre-push` 运行 `pnpm run test`、`pnpm run test:snapshot`、`pnpm run hygiene`、`pnpm run doc-sync` 和 `pnpm run verify-module-graph`。 + +vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。编辑 vendor 代码前先看 `vendor/README.md`。 + +这些钩子并不与 CI 完全一致。特别是:`pre-push` 跑不带覆盖率的单元测试,而 CI 跑 `pnpm run test:coverage`;CI 还会跑 echo-agent 和 built-bin 冒烟测试,并在 Node 24 和 26 上跑矩阵。 + +## CI 质量门禁 + +GitHub workflow 在每个 pull request 上运行这些门禁: + +- `pnpm install --frozen-lockfile` +- `pnpm run constraints` +- `pnpm run typecheck` +- `pnpm run lint` +- `pnpm run doc-sync` +- `pnpm run verify-module-graph` +- `pnpm run test:coverage` +- `pnpm run test:snapshot` +- `pnpm run build` +- `pnpm run hygiene` +- 一个 echo-agent 冒烟测试,检查演示的工具调用、工具结果和 JSONL 输出 +- built-bin 冒烟测试,用纯 `node` 运行发布产物 `lib/bin.js` 入口 + +`pnpm run hygiene` 是 `pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types` 的本地简写;CI 还会把 `pnpm run constraints` 作为更早的快速失败步骤单独跑一次,然后在 `pnpm run build` 之后跑完整的 hygiene 脚本。 + +## 日常命令 + +在仓库根目录使用: + +```sh +pnpm run test # unit tests +pnpm run test:coverage # unit tests with per-file coverage gates +pnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY +pnpm run typecheck # build package/vendor outputs, then typecheck examples, tests, and scripts +pnpm run lint # eslint . +pnpm run lint:fix # eslint . --fix +pnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs +pnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events-and-services.md from source +pnpm run verify-cordis-catalog # fail if the cordis events/services catalog is stale +pnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown +pnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type +pnpm run doc-sync # doc-typecheck, cordis-catalog freshness, markdown wrap/link, and type-equiv verification +pnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps +pnpm run verify-module-graph # fail if docs/module-graph.md is stale +pnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files +pnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable +pnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check +``` + +改动 package 的公开行为时,在同一个变更里更新相关 README 或 JSDoc。`pnpm run doc-sync` 能抓住被检查的 TypeScript 片段、cordis 事件/服务目录漂移和硬折行的 markdown 段落,但更广泛的行文/API 同步仍需评审把关。 + +## 演示 + +echo 演示不需要 API 凭证: + +```sh +pnpm run demo:echo +``` + +coding-agent 演示使用真实的 DeepSeek 适配器,需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`: + +```sh +pnpm run demo:coding +``` + +ACP 服务器演示把同一个编码 agent(智能体)通过 JSON-RPC stdio 暴露出来,同样需要 `DEEPSEEK_API_KEY`: + +```sh +pnpm run demo:acp +``` + +## TODO 标记 + +用三种注释标签之一标记代码中的已知问题,按紧急程度排序: + +- `FIXME` —— 应当阻塞新版本发布的问题。除非评审者明确同意可以照常合入,发布不应带着未解决的 `FIXME` 出门。 +- `TODO` —— 应当尽快修复的问题,等资源到位就处理。 +- `XXX` —— 也许某天会修的问题;优先级最低,不作承诺。 + +选择与紧急程度匹配的标签,让扫代码的人一眼分清「发布阻塞」和「有空再说」。 + +## 逐字记录类型(`ts type-equiv`) + +[核心数据结构](core-data-structures/core.md)文档粘贴真实的类型定义,让读者看到确切的形状。为防止粘贴内容在源码变化时漂移,把它围栏成 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号: + +```json +{ "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" } +``` + +`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明,并断言文档块与之一致(对空白和注释不敏感,因此文档块可以展示干净的定义、语义由行文承载)。它还强制 1:1 对应:每个 `ts type-equiv` 块恰好有一条 manifest 条目,反之亦然,因此不会有块被静默漏检,也不会有过期条目滞留。`doc-typecheck` 跳过 `ts type-equiv` 块(它们不能独立编译),并将其排除在 opt-out 比例之外。当你改动一个被记录的类型,门禁会失败直到你更新粘贴;当你增删一个块,在同一个变更里更新 manifest。 + +## 架构上下文 + +改动 `packages/` 下的任何东西之前先读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam(扩展点)与显式扩展点构建。 diff --git a/scripts/translation-pairing.manifest.json b/scripts/translation-pairing.manifest.json index 8b713c1ea0..4a735ea5af 100644 --- a/scripts/translation-pairing.manifest.json +++ b/scripts/translation-pairing.manifest.json @@ -1,6 +1,7 @@ { "required": [ "README.md", + "docs/development.md", "docs/i18n/README.md", "docs/i18n/translation-rules.md" ], From 0a595aea78742283d886accd6c803815bccbab48 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 3 Jul 2026 17:03:54 +0800 Subject: [PATCH 46/75] test(web): cover the config-present branch of exa/perplexity apply The numResults (exa) and searchRecency (perplexity) conditional spreads in apply() were only exercised on their absent side, leaving the 100% per-file branch gate red. Add plugin-registration tests that pass those config fields and assert they reach the request body. --- packages/web/web-search-exa/tests/exa.spec.ts | 6 +++--- .../web-search-perplexity/tests/perplexity.spec.ts | 12 ++++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/web/web-search-exa/tests/exa.spec.ts b/packages/web/web-search-exa/tests/exa.spec.ts index dcb6fbea6d..9cf31332f5 100644 --- a/packages/web/web-search-exa/tests/exa.spec.ts +++ b/packages/web/web-search-exa/tests/exa.spec.ts @@ -217,15 +217,15 @@ describe('web-search-exa plugin registration', () => { expect('default' in exaPlugin).toBe(false) }) - it('threads searchType and highlightsPerResult config into the request', async () => { + it('threads searchType, highlightsPerResult and numResults config into the request', async () => { const fetchMock = vi.fn(async () => jsonResponse({ results: [] })) vi.stubGlobal('fetch', fetchMock) const ctx = new Context() await ctx.plugin(WebService, { searchProvider: EXA_PROVIDER_ID }) - const fiber = await ctx.plugin(exaPlugin, { apiKey: 'exa-key', searchType: 'keyword', highlightsPerResult: 2 }) + const fiber = await ctx.plugin(exaPlugin, { apiKey: 'exa-key', searchType: 'keyword', highlightsPerResult: 2, numResults: 9 }) await ctx.web.search({ query: 'q' }) const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] - expect(JSON.parse(init.body as string)).toMatchObject({ type: 'keyword', contents: { highlights: { highlightsPerUrl: 2 } } }) + expect(JSON.parse(init.body as string)).toMatchObject({ type: 'keyword', contents: { highlights: { highlightsPerUrl: 2 } }, numResults: 9 }) await fiber.dispose() }) diff --git a/packages/web/web-search-perplexity/tests/perplexity.spec.ts b/packages/web/web-search-perplexity/tests/perplexity.spec.ts index 6d55f384dd..70a9a4c98b 100644 --- a/packages/web/web-search-perplexity/tests/perplexity.spec.ts +++ b/packages/web/web-search-perplexity/tests/perplexity.spec.ts @@ -198,6 +198,18 @@ describe('web-search-perplexity plugin registration', () => { expect('default' in perplexityPlugin).toBe(false) }) + it('threads maxTokens and searchRecency config into the request', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ choices: [{ message: { content: 'a' } }], citations: [] })) + vi.stubGlobal('fetch', fetchMock) + const ctx = new Context() + await ctx.plugin(WebService, { searchProvider: PERPLEXITY_PROVIDER_ID }) + const fiber = await ctx.plugin(perplexityPlugin, { apiKey: 'pplx-key', maxTokens: 256, searchRecency: 'month' }) + await ctx.web.search({ query: 'q' }) + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(JSON.parse(init.body as string)).toMatchObject({ max_tokens: 256, search_recency_filter: 'month' }) + await fiber.dispose() + }) + it('falls back to env key and defaults for base URL and model when config omits them', async () => { const prev = process.env.PERPLEXITY_API_KEY process.env.PERPLEXITY_API_KEY = 'env-key' From a899226397f83c20d925c5ac11ad9520207893ca Mon Sep 17 00:00:00 2001 From: Ziya <199893125+ZiyaZhang@users.noreply.github.com> Date: Fri, 3 Jul 2026 02:06:57 -0700 Subject: [PATCH 47/75] =?UTF-8?q?docs:=20harden=20pairing=20gate=20per=20r?= =?UTF-8?q?eview=20=E2=80=94=20structural=20signature,=20not=20counts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings addressed: - The gate compared only heading and code-block COUNTS, understating the contract it claims to enforce. It now compares ordered structural signatures: heading depths, fenced code blocks verbatim (info string + content), table column counts, list kinds, and every link target except the language switcher. Proven red on a heading demotion, a reworded code-block comment, and a retargeted link; green on all existing pairs. - Stated the gate's limit explicitly (header comment + docs/i18n/README.md both languages): green means fresh and structurally sound, NOT verified — translation quality is the reviewer's half of the contract. - first-line extraction no longer silently drops the last character of a newline-less file (split with limit instead of indexOf slice). - isExcluded documents the trailing-slash-is-the-boundary invariant. - Rollout guidance: grow the required frontier at the pace translation review is resourced. - dsh-code-review's doc-sync sublist is now the exhaustive chain. docs/i18n/README.zh.md updated via the minimal-diff workflow and re-fingerprinted. --- .agents/skills/dsh-code-review/SKILL.md | 2 +- docs/i18n/README.md | 8 +- docs/i18n/README.zh.md | 10 ++- scripts/verify-translation-pairing.ts | 113 +++++++++++++++++++----- 4 files changed, 103 insertions(+), 30 deletions(-) diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index addc09f2ac..1a0d2c8c6f 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -34,7 +34,7 @@ These come straight from the source docs above. They are not discretionary; abse 1. **Docs in sync.** If the PR changes a config key, default, error code, wire field, or event name, it must update the package README + module/JSDoc in the same diff. The `doc-sync` gate (check #4) does not catch prose drift in config keys, defaults, error codes, or wire fields — that is on the reviewer, but it is still required, not optional. 2. **Core-data-structures catalog in sync.** If the PR adds, removes, or reshapes a type the [core-data-structures catalog](../../../docs/core-data-structures/core.md) documents — a new `…Map` variant, a new content-block/session-event type, a field on `GenerateOptions`/`Agent`/`ToolDefinition`/a bash type, or a whole new core/seam type — it must update that catalog in the same diff (prose + any verbatim ` ```ts type-equiv ` block + the 1:1 `scripts/type-equiv.manifest.json`). The `verify-type-equiv` gate (part of `doc-sync`) catches a *drifted paste* of an already-documented type, but it cannot tell you a brand-new core type went undocumented — that judgment is yours. Confirm a genuinely spine-level type landed in core.md and a new capability's vocabulary on a sub-page, per the spine-vs-seam line in [core.md § What counts as "core"](../../../docs/core-data-structures/core.md#what-counts-as-core). A pure internal type with no cross-package reach needs no catalog entry — say so if it's a judgment call. 3. **HMR-safety test.** Any new registry/registration needs a test that disposes the contributing fiber and asserts cleanup (packages/AGENTS.md). Its absence blocks merge. -4. **Quality gates pass.** typecheck, lint, test, test:coverage (100% per-file on `packages/*/src`), knip, build, publint, constraints, `doc-sync` (doc-typecheck + verify-cordis-catalog + verify-md-wrap + verify-md-links + verify-type-equiv + verify-translation-pairing), module-graph freshness (the quality-gates RFC). Don't re-review what a gate already enforces — trust the gate and spend attention on what it can't check. Note that the `doc-sync` gate only covers compilable `ts` blocks, the generated cordis events/services catalog, markdown wrapping/links, verbatim type-equiv blocks, and the bilingual pairing contract ([docs/i18n/README.md](../../../docs/i18n/README.md)); prose drift (checks #1 and #2) and translation *quality* (the [dsh-translate-docs](../dsh-translate-docs/SKILL.md) rules) are *additional* manual review on top of it, not covered by it. +4. **Quality gates pass.** typecheck, lint, test, test:coverage (100% per-file on `packages/*/src`), knip, build, publint, constraints, `doc-sync` (doc-typecheck + verify-cordis-catalog + verify-tool-catalog + verify-md-wrap + verify-md-links + verify-doc-refs + verify-package-paths + verify-rfc-classification + verify-type-equiv + verify-translation-pairing), module-graph freshness (the quality-gates RFC). Don't re-review what a gate already enforces — trust the gate and spend attention on what it can't check. Note that the `doc-sync` gate only covers compilable `ts` blocks, the generated cordis events/services catalog, markdown wrapping/links, verbatim type-equiv blocks, and the bilingual pairing contract ([docs/i18n/README.md](../../../docs/i18n/README.md)); prose drift (checks #1 and #2) and translation *quality* (the [dsh-translate-docs](../dsh-translate-docs/SKILL.md) rules) are *additional* manual review on top of it, not covered by it. ## Reviewer-only checks (gates can't catch these — judgment required) diff --git a/docs/i18n/README.md b/docs/i18n/README.md index e70a1fed0d..fb0e17390e 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -16,20 +16,22 @@ This repo's documentation is read by people and agents both inside and outside t A blob hash, not a commit hash, so the fingerprint is computable for an English file edited in the same PR (`git hash-object docs/foo.md`), and so staleness is a pure content comparison. The fingerprint is also the update tool: `git cat-file -p ` recovers the exact source text a stale translation was based on, and `git diff ` isolates what changed so the translation can be updated minimally instead of re-translated. - **Language switcher.** Both files link to each other immediately after their H1 heading: the English file carries `English | [中文](foo.zh.md)` and the Chinese file carries `[English](foo.md) | 中文`. -- **Structure mirrors the source.** Heading hierarchy, list shape, table columns, and code blocks match the English file one to one — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`). +- **Structure mirrors the source.** Heading depths and order, list kinds, table columns, link targets, and verbatim code blocks match the English file one to one — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`). ## The gate: verify-translation-pairing `pnpm run verify-translation-pairing` (part of `doc-sync`, so CI and the pre-push hook run it) enforces the contract mechanically: 1. Every English file listed as `required` in [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) has a `.zh.md` sibling. -2. Every existing `.zh.md` file — required or not — passes all of: its English source exists (no orphans), its fingerprint matches the source's current blob hash (no stale translations), both sides carry the language switcher, and its fenced-code-block and heading counts equal the source's. +2. Every existing `.zh.md` file — required or not — passes all of: its English source exists (no orphans), its fingerprint matches the source's current blob hash (no stale translations), both sides carry the language switcher, and its structural signature matches the source in order — heading depths, verbatim code blocks (info string and content), table column counts, list kinds, and every link target apart from the switcher. 3. Files listed as `excluded` have no `.zh.md` sibling at all. `pnpm run verify-translation-pairing --list` prints the current translation state of every document in scope — missing, stale, or ok — and is the work list for translation batches. It never fails; it reports. The practical rule this gate creates: **when a PR edits an English document that has a `.zh.md` sibling, the same PR updates the translation** (run the [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill), exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a translation stale goes red in CI. +The gate's limit, stated plainly: **a green gate means fresh and structurally sound, not verified.** It checks the fingerprint and the shape; it cannot judge whether the Chinese is accurate, well-termed, or natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-fingerprinted `.zh.md` with a sloppy translation passes the gate; it must not pass review. + ## Scope, exclusions, and rollout **Scope**: the root `README.md` and everything under `docs/**`. Package READMEs (`packages/**`) join the scope in a later batch. @@ -40,7 +42,7 @@ The practical rule this gate creates: **when a PR edits an English document that - `docs/AGENTS.md` — agent instructions, maintained in English only like the root `AGENTS.md`. - `docs/i18n/terminology.md` — the terminology table is itself bilingual by construction. -**Rollout**: the `required` list in the manifest is the enforcement frontier, not the goal. The goal is full bilingual coverage of the scope. Translation lands in reviewable batches (core entry docs, cookbook, RFCs, postmortems, …); each merged batch adds its files to `required`, so the gate ratchets forward and never regresses. Documents not yet in `required` are backlog — visible in `--list` — but any translation that already exists is held to the full contract regardless of the list. +**Rollout**: the `required` list in the manifest is the enforcement frontier, not the goal. The goal is full bilingual coverage of the scope. Translation lands in reviewable batches (core entry docs, cookbook, RFCs, postmortems, …); each merged batch adds its files to `required`, so the gate ratchets forward and never regresses. Documents not yet in `required` are backlog — visible in `--list` — but any translation that already exists is held to the full contract regardless of the list. Pairing a document is a commitment: every later English edit to it must carry the translation along, so grow the frontier at the pace translation review is actually resourced, not ahead of it. ## Division of labor diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index 12b0f736d7..d1ca53f3d8 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -1,4 +1,4 @@ - + # 双语文档 @@ -18,20 +18,22 @@ 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的英文文件也能算出指纹(`git hash-object docs/foo.md`),过期检测则是纯内容比较。指纹同时也是更新工具:`git cat-file -p ` 能还原过期译文当初依据的确切源文本,`git diff <当前 blob>` 能隔离出变化的部分,让译文做最小更新而不是整篇重译。 - **语言切换行。**两个文件在各自 H1 标题之后立即互链:英文文件带 `English | [中文](foo.zh.md)`,中文文件带 `[English](foo.md) | 中文`。 -- **结构与源一一对应。**标题层级、列表形态、表格列与代码块和英文文件一一对应——完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。 +- **结构与源一一对应。**标题深度与顺序、列表类型、表格列、链接目标与逐字节一致的代码块和英文文件一一对应——完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。 ## 门禁:verify-translation-pairing `pnpm run verify-translation-pairing`(`doc-sync` 的一环,因此 CI 和 pre-push 钩子都会运行)机械地强制这份契约: 1. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 中 `required` 列出的每个英文文件都有 `.zh.md` 配对文件。 -2. 每个已存在的 `.zh.md` 文件——无论是否 required——都通过全部检查:其英文源存在(无孤儿)、指纹等于源的当前 blob hash(无过期译文)、双方都带语言切换行、其代码块与标题数量等于源文件。 +2. 每个已存在的 `.zh.md` 文件——无论是否 required——都通过全部检查:其英文源存在(无孤儿)、指纹等于源的当前 blob hash(无过期译文)、双方都带语言切换行、其结构签名与源按序一致——标题深度、逐字节一致的代码块(信息串与内容)、表格列数、列表类型、以及除切换行之外的每个链接目标。 3. 列为 `excluded` 的文件完全没有 `.zh.md` 配对。 `pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前翻译状态——missing、stale 或 ok——是翻译批次的工作清单。它从不失败;它只报告。 这个门禁带来的实际规则是:**当一个 PR 修改了已有 `.zh.md` 配对的英文文档时,同一个 PR 更新译文**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill),与本仓库既有的代码/README doc-sync 规则完全一致。留下过期译文的 PR 会在 CI 变红。 +把门禁的边界说白:**门禁绿意味着新鲜且结构健全,不意味着已核验。**它检查指纹和形状;它无法判断中文是否准确、术语是否得当、行文是否自然——那是契约中评审者的那一半,见 [translation-rules.md](translation-rules.md)。一个重打了指纹但翻得潦草的 `.zh.md` 能通过门禁;它不应通过评审。 + ## 范围、排除与推进 **范围**:根 `README.md` 与 `docs/**` 下的全部内容。package README(`packages/**`)在后续批次加入范围。 @@ -42,7 +44,7 @@ - `docs/AGENTS.md` —— agent 指令,与根 `AGENTS.md` 一样只以英文维护。 - `docs/i18n/terminology.md` —— 术语表本身即是双语构造。 -**推进**:manifest 中的 `required` 列表是强制边界,不是目标。目标是范围内的全量双语覆盖。翻译按可评审的批次落地(核心入口文档、cookbook、RFC、postmortem……);每个批次合入后把其文件加进 `required`,门禁只进不退。尚未进入 `required` 的文档是 backlog——在 `--list` 中可见——但任何已存在的译文无论在不在清单里都按完整契约检查。 +**推进**:manifest 中的 `required` 列表是强制边界,不是目标。目标是范围内的全量双语覆盖。翻译按可评审的批次落地(核心入口文档、cookbook、RFC、postmortem……);每个批次合入后把其文件加进 `required`,门禁只进不退。尚未进入 `required` 的文档是 backlog——在 `--list` 中可见——但任何已存在的译文无论在不在清单里都按完整契约检查。给一篇文档配对是一份承诺:此后对它的每次英文修改都必须带上译文,所以边界的扩张要跟上翻译评审的实际投入节奏,不要抢在前面。 ## 分工 diff --git a/scripts/verify-translation-pairing.ts b/scripts/verify-translation-pairing.ts index b89b9b49fe..47a4086c73 100644 --- a/scripts/verify-translation-pairing.ts +++ b/scripts/verify-translation-pairing.ts @@ -5,17 +5,25 @@ * * * - * The gate checks, mechanically, everything the contract promises: + * The gate checks, mechanically, the checkable half of the contract: * * 1. Every English file in the manifest's `required` list has a `.zh.md` * sibling (the enforcement frontier — grows batch by batch). * 2. Every EXISTING `.zh.md`, required or not, is sound: its source exists * (no orphans), its fingerprint equals the source's current blob hash * (no stale translations), both sides carry the language-switcher link, - * and its fenced-code-block and heading counts match the source. + * and its structural signature matches the source one to one — heading + * depths in order, fenced code blocks VERBATIM (info string + content), + * table column counts, list kinds, and every link target except the + * switcher itself. * 3. `excluded` files (generated docs, agent instructions, the bilingual * terminology table) have no `.zh.md` at all. * + * What it deliberately does NOT check is translation quality: a green gate + * means the pair is fresh and structurally sound, not that the Chinese is + * faithful — accuracy, terminology, and tone are the human reviewer's half + * of the contract (docs/i18n/translation-rules.md). + * * The fingerprint is a git BLOB hash, not a commit hash, so a translation * updated in the same PR as its English source verifies without any history * lookup: staleness is a pure content comparison, computed here directly @@ -51,7 +59,12 @@ const manifest = JSON.parse(readFileSync(join(root, 'scripts/translation-pairing /** First line of a translation: fingerprint of the English source it renders. */ const FINGERPRINT = /^$/ -/** An excluded entry ending in `/` excludes the whole directory. */ +/** + * An excluded entry ending in `/` excludes the whole directory. The trailing + * slash IS the path boundary — `docs/tool-catalog/` cannot prefix-match a + * sibling like `docs/tool-catalog-notes/x.md` — so directory entries in the + * manifest must keep their trailing slash. + */ function isExcluded(file: string): boolean { return manifest.excluded.some(entry => (entry.endsWith('/') ? file.startsWith(entry) : file === entry)) } @@ -64,13 +77,25 @@ function blobHash(content: Buffer): string { return hash.digest('hex').slice(0, 12) } -/** Counts that must match between a source and its translation. */ -interface Shape { - codeBlocks: number - headings: number +/** + * The structural signature a translation must reproduce from its source, as + * ordered sequences so a swap or a level change is caught, not just a count + * change. Prose is deliberately absent: the gate checks shape, never wording. + */ +interface Signature { + /** Heading depths in document order (h2 → 2). */ + headings: number[] + /** Fenced code blocks verbatim: info string + content, in order. */ + code: string[] + /** Column count of each table, in order. */ + tables: number[] + /** Each list's kind (ordered vs bullet), in order. */ + lists: string[] + /** Every link target in order, the language switcher's excluded. */ + links: string[] } -/** Whether `text` contains a relative markdown link to exactly `target`. */ +/** Whether the tree contains a link to exactly `target` (the switcher check). */ function linksTo(tree: Nodes, target: string): boolean { let found = false const visit = (node: Nodes): void => { @@ -81,16 +106,63 @@ function linksTo(tree: Nodes, target: string): boolean { return found } -function shapeOf(tree: Nodes): Shape { - let codeBlocks = 0 - let headings = 0 +/** Collect the structural signature, skipping links to `switcherTarget`. */ +function signatureOf(tree: Nodes, switcherTarget: string): Signature { + const sig: Signature = { headings: [], code: [], tables: [], lists: [], links: [] } const visit = (node: Nodes): void => { - if (node.type === 'code') codeBlocks++ - if (node.type === 'heading') headings++ + switch (node.type) { + case 'heading': + sig.headings.push(node.depth) + break + case 'code': + sig.code.push(`\`\`\`${node.lang ?? ''}${node.meta ? ` ${node.meta}` : ''}\n${node.value}`) + break + case 'table': + sig.tables.push(node.children[0]?.children.length ?? 0) + break + case 'list': + sig.lists.push(node.ordered ? 'ordered' : 'bullet') + break + case 'link': + if (node.url !== switcherTarget) sig.links.push(node.url) + break + default: + // Every other node kind is prose or container — not part of the signature. + break + } if ('children' in node) for (const child of node.children) visit(child) } visit(tree) - return { codeBlocks, headings } + return sig +} + +/** Render a signature element for an error message, truncated for readability. */ +function show(value: string | number | undefined): string { + if (value === undefined) return 'nothing' + const text = JSON.stringify(value) + return text.length > 72 ? `${text.slice(0, 72)}…` : text +} + +/** First divergence between two signatures, as messages; empty when identical. */ +function signatureDiff(source: Signature, zh: Signature): string[] { + const out: string[] = [] + const fields: [string, (string | number)[], (string | number)[]][] = [ + ['heading (depth)', source.headings, zh.headings], + ['code block', source.code, zh.code], + ['table (column count)', source.tables, zh.tables], + ['list (kind)', source.lists, zh.lists], + ['link target', source.links, zh.links], + ] + for (const [field, s, z] of fields) { + const length = Math.max(s.length, z.length) + for (let i = 0; i < length; i++) { + if (s[i] !== z[i]) { + out.push(`${field} #${i + 1} diverges from the source: source has ${show(s[i])}, translation has ${show(z[i])}`) + break + } + } + } + return out } function parse(content: string): Nodes { @@ -135,7 +207,7 @@ for (const zh of translations) { } const zhContent = readFileSync(join(root, zh), 'utf8') - const firstLine = zhContent.slice(0, zhContent.indexOf('\n')) + const firstLine = zhContent.split('\n', 1)[0] ?? '' const match = FINGERPRINT.exec(firstLine) if (!match?.groups) { errors.push(`${zh}: first line is not an i18n-source fingerprint (expected \`\`, got \`${firstLine.slice(0, 60)}\`)`) @@ -162,13 +234,10 @@ for (const zh of translations) { if (!linksTo(sourceTree, basename(zh))) { errors.push(`${source}: missing language switcher — no link back to ${basename(zh)}`) } - const zhShape = shapeOf(zhTree) - const sourceShape = shapeOf(sourceTree) - if (zhShape.codeBlocks !== sourceShape.codeBlocks) { - errors.push(`${zh}: ${zhShape.codeBlocks} fenced code block(s) vs ${sourceShape.codeBlocks} in ${source} — code blocks must mirror the source`) - } - if (zhShape.headings !== sourceShape.headings) { - errors.push(`${zh}: ${zhShape.headings} heading(s) vs ${sourceShape.headings} in ${source} — heading structure must mirror the source`) + const sourceSig = signatureOf(sourceTree, basename(zh)) + const zhSig = signatureOf(zhTree, basename(source)) + for (const divergence of signatureDiff(sourceSig, zhSig)) { + errors.push(`${zh}: ${divergence}`) } if (!state.has(source)) state.set(source, 'ok') } From d8fd3225af6d0ab64df1ea716befcef4c623adab Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:12:00 +0800 Subject: [PATCH 48/75] feat(tool-fs): result-time applied-hunk diffs for write/edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fs write/edit now emit a result-time contextual-diff tool_call_update (the applied hunk with ±3 context lines, one hunk per replace_all site), matching what claude-agent-acp sends and what makes an editor render the change in place. The call-time snippet diff stays; the result hunk supersedes it (ACP content-replace). Mechanism: - A persisted tool-private `meta` channel: execute may return `{ content, meta }`; `meta` (JsonValue) rides on the tool/result event and is handed back to presentResult, so the diff reproduces on replay (event-sourced). JsonValue is now exported from dsh-session. - The backend returns raw before/after text (storage facts) on FsWriteOutcome/FsEditOutcome; the tool computes the hunk via the npm `diff` package's structuredPatch. A create has no before → no result diff; a failed/aborted mutation carries no meta. - ToolResultView gains a DiffResultView; the bridge's result-side switch renders it as {type:'diff'} content blocks. RFC: docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md (justifies the npm `diff` runtime dep over vendoring and the meta channel); the render-intent-union RFC's Non-goal is updated to record this shipped. All fs snapshot goldens re-recorded; edit/overwrite gain the contextual result diff, create/read/policy-reject unchanged in structure. --- docs/cordis-catalog/events-and-services.md | 14 +- docs/core-data-structures/filesystem.md | 4 + docs/core-data-structures/session.md | 2 +- docs/core-data-structures/tools.md | 9 +- docs/module-graph.md | 6 +- docs/rfc/README.md | 1 + ...26-07-02-result-time-applied-hunk-diffs.md | 54 ++ .../2026-07-02-tool-render-intent-union.md | 2 +- .../tests/snapshots/fs-edit/session.jsonl | 278 +++++---- .../snapshots/fs-edit/stdout.golden.jsonl | 34 +- .../snapshots/fs-policy-reject/session.jsonl | 555 ++++++++++++------ .../fs-policy-reject/stdout.golden.jsonl | 323 +++++++--- .../snapshots/fs-read-window/session.jsonl | 228 +++---- .../fs-read-window/stdout.golden.jsonl | 38 +- .../tests/snapshots/fs-read/session.jsonl | 180 +++--- .../snapshots/fs-read/stdout.golden.jsonl | 32 +- .../snapshots/fs-terminal-card/session.jsonl | 191 +++--- .../fs-terminal-card/stdout.golden.jsonl | 30 +- .../fs-write-overwrite/session.jsonl | 277 ++++----- .../fs-write-overwrite/stdout.golden.jsonl | 69 ++- .../tests/snapshots/fs-write/session.jsonl | 189 +++--- .../snapshots/fs-write/stdout.golden.jsonl | 17 +- packages/core/agent-loop/src/loop.ts | 3 + packages/core/agent-loop/tests/loop.spec.ts | 26 + packages/core/session/src/index.ts | 1 + packages/core/session/src/json.ts | 10 + packages/core/session/src/types.ts | 12 +- packages/core/tools/README.md | 7 +- packages/core/tools/package.json | 2 + packages/core/tools/src/index.ts | 66 ++- packages/core/tools/src/schema.ts | 11 +- packages/core/tools/tests/tools.spec.ts | 32 + packages/fs/fs-local/src/fsio.ts | 19 + packages/fs/fs-local/src/index.ts | 13 + packages/fs/fs-local/tests/filesystem.spec.ts | 54 ++ packages/fs/fs/src/types.ts | 16 + packages/fs/fs/tests/service.spec.ts | 9 +- packages/fs/tool-fs/package.json | 4 + packages/fs/tool-fs/src/diff.ts | 92 +++ packages/fs/tool-fs/src/edit.ts | 27 +- packages/fs/tool-fs/src/index.ts | 2 + packages/fs/tool-fs/src/write.ts | 24 +- packages/fs/tool-fs/tests/diff.spec.ts | 113 ++++ packages/fs/tool-fs/tests/tools.spec.ts | 86 ++- packages/ui/acp/acp-feature-support.md | 3 +- packages/ui/acp/src/index.ts | 26 +- packages/ui/acp/tests/stream-update.spec.ts | 86 +++ pnpm-lock.yaml | 13 + 48 files changed, 2217 insertions(+), 1073 deletions(-) create mode 100644 docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md create mode 100644 packages/fs/tool-fs/src/diff.ts create mode 100644 packages/fs/tool-fs/tests/diff.spec.ts diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index bb7dcb58a4..90419feeb9 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -259,7 +259,7 @@ A session was created in the store. 'session/created'(session: Session): void ``` -Source: [`packages/core/session/src/index.ts:34`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:35`](../../packages/core/session/src/index.ts) #### `session/event` — emit @@ -271,7 +271,7 @@ An event was appended to a session log (sync, fire-and-forget). This is the per- Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:40`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:41`](../../packages/core/session/src/index.ts) #### `session/flush` — parallel @@ -281,7 +281,7 @@ Awaited durability checkpoint. The agent loop awaits `ctx.parallel('session/flus 'session/flush'(session: Session): Promise | void ``` -Source: [`packages/core/session/src/index.ts:49`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:50`](../../packages/core/session/src/index.ts) ### `subagent/*` @@ -337,7 +337,7 @@ A tool was registered or unregistered (the available tool set changed). 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:48`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:49`](../../packages/core/tools/src/index.ts) #### `tools/execute` — waterfall @@ -349,7 +349,7 @@ Waterfall around every tool execution — the single seam where sandbox, permiss Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:43`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:44`](../../packages/core/tools/src/index.ts) ## Services @@ -507,7 +507,7 @@ get(id: SessionId): Session | undefined list(): Session[] ``` -Source: [`packages/core/session/src/index.ts:322`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:323`](../../packages/core/session/src/index.ts) ### `ctx.subagents` — `SubagentService` @@ -547,7 +547,7 @@ async execute(exec: ToolExecution): Promise 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:319`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:370`](../../packages/core/tools/src/index.ts) ## Inherited tier (cordis core + loader/hmr/timer) diff --git a/docs/core-data-structures/filesystem.md b/docs/core-data-structures/filesystem.md index 2e66dd9d9e..a27c7fa4fb 100644 --- a/docs/core-data-structures/filesystem.md +++ b/docs/core-data-structures/filesystem.md @@ -52,6 +52,8 @@ type FsWriteIntent = interface FsWriteOutcome { operation: 'create' | 'update' version: FsVersion + before: string | null + after: string } ``` @@ -70,6 +72,8 @@ interface FsEditOutcome { replacements: number replaceAll: boolean version: FsVersion + before: string + after: string } ``` diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 1a0e209e41..789890680f 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -32,7 +32,7 @@ interface SessionEventMap { */ 'assistant/message': { turn: number; step: number; content: ContentBlock[]; usage?: TokenUsage } 'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string } - 'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string } } + 'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: JsonValue } /** Steering content injected between steps of a running turn. */ 'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource } /** diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 42aa4e70fd..ac765d25cd 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -10,7 +10,7 @@ A `ToolSchema` (the model-facing fields) plus the `execute` function and optiona ```ts type-equiv interface ToolDefinition extends ToolSchema { - execute(args: unknown, exec: ToolExecution): Promise + execute(args: unknown, exec: ToolExecution): Promise /** * Optional: how to present the PENDING state of one call in a UI, derived from * the call's `args` (parsed arguments, `unknown` — the tool validates/narrows @@ -100,6 +100,13 @@ interface ToolExecutionResult { * text in `content` is always present; this is extra structure for code. */ error?: ToolErrorInfo + /** + * The tool-private presentation payload from a successful `execute` (the object + * return form). Threaded onto the `tool/result` session event and back into + * {@link ToolResult} for `presentResult`. Opaque {@link JsonValue}; absent when + * the tool attached none or the call failed. + */ + meta?: JsonValue } ``` diff --git a/docs/module-graph.md b/docs/module-graph.md index 69391388ac..d09fd76eaf 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -40,6 +40,7 @@ graph TD session-persistence-sqlite --> session-persistence tools --> agent tools --> llm + tools --> session tools --> system-prompt ui-stdio --> agent ui-stdio --> llm @@ -64,6 +65,7 @@ graph TD tool-bash --> tools tool-fs --> fs tool-fs --> llm + tool-fs --> session tool-fs --> system-prompt tool-fs --> tools tool-todo --> agent @@ -128,13 +130,13 @@ graph TD | `invariants` | `agent`, `llm`, `session` | | `session-persistence-jsonl` | `session`, `session-persistence` | | `session-persistence-sqlite` | `session`, `session-persistence` | -| `tools` | `agent`, `llm`, `system-prompt` | +| `tools` | `agent`, `llm`, `session`, `system-prompt` | | `ui-stdio` | `agent`, `llm`, `session` | | `acp` | `agent`, `llm`, `session`, `session-persistence`, `tools` | | `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-fs` | `fs`, `llm`, `session`, `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` | diff --git a/docs/rfc/README.md b/docs/rfc/README.md index fbefdd3727..43a532754d 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -126,6 +126,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [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 | | [Tagged render-intent union for tool-call presentation](implemented/architecture/2026-07-02-tool-render-intent-union.md) | 2026-07-02 | +| [Result-time applied-hunk diffs for file mutations](implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md) | 2026-07-02 | ### Process diff --git a/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md b/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md new file mode 100644 index 0000000000..591dbb4779 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md @@ -0,0 +1,54 @@ +# RFC: Result-time applied-hunk diffs for file mutations + +Status: implemented + +## Problem + +The [tagged render-intent union](2026-07-02-tool-render-intent-union.md) gave `dsh-tool-fs` write/edit a `card:'diff'` at CALL time, derived purely from the tool's args: write ⇒ `{oldText:null, newText:content}` (the whole new file), edit ⇒ `{oldText:old_string, newText:new_string}` (the bare replaced snippet). An editor renders that as an inline diff, but it is a **context-free** diff — the bare `old_string`→`new_string` with no surrounding lines, and a `replace_all` that touched five scattered sites still renders as one snippet pair. + +Driving `claude-agent-acp`'s own ACP bridge shows what a full editor diff looks like: after the mutation applies, it emits a SECOND `tool_call_update` whose diff is the **applied hunk with ±3 context lines** (and one hunk per changed site for `replace_all`), reconstructed from the tool's `structuredPatch`. That result-time hunk is what makes Zed show the change *in place* in the file rather than as a floating snippet. Our tools stopped at the call-time snippet; the completed result carried only the plain "updated successfully" text, no diff. + +The obstacle is a seam boundary: `presentResult(args, result)` is a **pure function of `args` + the model-facing `result` (`{content, isError}`)** — it runs on live streaming AND on session-log replay, so it must be replay-deterministic and cannot do I/O. It never sees the file's before/after content, and `FsEditOutcome`/`FsWriteOutcome` carried only a replacement count + version, not the text. So there was no way to compute — or even carry — an applied hunk to the presenter. + +## Decision + +Add a **persisted, tool-private presentation channel** so a tool's `execute` can attach a result-time render payload that survives replay, and use it to carry the applied-hunk diff. + +### 1. A `meta` channel on the tool result (core) + +`ToolDefinition.execute` may now return either its model-facing `ContentBlock[]` (unchanged, the common case) OR `{ content: ContentBlock[]; meta?: JsonValue }`: + +```ts ignore-check +type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: JsonValue } +``` + +`meta` is an opaque, JSON-serializable payload the core never interprets. The registry threads it onto the `tool/result` **session event** (`{ …, meta?: JsonValue }`), so it is persisted with the log; on replay the same `meta` is read back and handed to `presentResult` via a widened `ToolResult` (`{ content, isError, meta? }`). Because the payload lives in the event log, the diff reproduces on session reload / snapshot replay **for free** — the event-sourcing guarantee, not a re-computation. `JsonValue` is exported from `dsh-session` (paired with the existing `isJsonValue` predicate that already gates every event's serializability at `append`). + +This is the general shape ("a tool attaches durable result presentation"), not an fs-specific one — any tool can use it. + +### 2. The tool computes the hunk; the backend returns before/after (fs) + +Per the [capability-seam split](2026-06-13-capability-seams.md), the storage backend returns only **storage facts** and the model-facing tool owns **presentation**: + +- `dsh-fs` widens `FsEditOutcome` with `{ before: string; after: string }` and `FsWriteOutcome` with `{ before: string | null; after: string }` (`before: null` ⇒ a create, or an existing-but-undiffable binary/non-UTF-8 file). The local backend already holds both texts at write time; it returns them as raw LF-normalized text, with **no diff/UI concept** entering the seam. +- `dsh-tool-fs` computes the contextual hunk from before/after and attaches it as `meta: { diffs: FileDiff[] }`. A result diff is emitted only when a before-version exists — edit always; write on overwrite; **a create emits none** (there is no before), matching `claude-agent-acp`'s empty `structuredPatch` on create. A failed/aborted/policy-rejected mutation applied nothing, so it carries no `meta` and renders no result diff. + +### 3. The bridge renders a `diff` result card + +`ToolResultView` gains a `DiffResultView { card:'diff'; title?; diffs: FileDiff[] }`; the bridge's result-side `switch (view.card)` gets a `diff` arm emitting the `{type:'diff'}` `ToolCallContent` blocks (mirroring the call-side arm). An ACP `tool_call_update.content` REPLACES the call's content in an editor, so the result-time contextual hunk **supersedes** the call-time snippet — the two-update sequence (call snippet, then result hunk) matches `claude-agent-acp` exactly. + +### The diff algorithm — a third-party runtime dependency over vendoring + +Computing hunks-with-context is a solved problem with sharp edge cases (grouping, context coalescing, the trailing-newline marker). Rather than hand-roll it, `dsh-tool-fs` takes a runtime dependency on the npm [`diff`](https://www.npmjs.com/package/diff) package (v9, ships its own types) and uses its `structuredPatch`. The repo's default is to vendor Cordis-framework source, but that policy is about the *framework*; a leaf tool package taking a small, well-known, self-typed utility dependency is the same shape as `dsh-acp` depending on `@agentclientprotocol/sdk`. Vendoring a diff algorithm would be re-implementing a battle-tested one for no benefit — the [pre-release "foundation over blast radius"](../../../../AGENTS.md) reasoning does not argue for re-deriving standard algorithms. The dependency is pinned and its output is normalized in one small module (`packages/fs/tool-fs/src/diff.ts`). + +## Non-goals + +- **Live incremental diff streaming.** The hunk is computed once, after the mutation completes; there is no per-keystroke diff. +- **Diffing a binary/non-UTF-8 overwrite.** `before` is `null` for such a file (it has no text diff basis); the write still succeeds and renders the call-time card only. +- **Rename/move diffs.** Only content diffs of a single resolved path. + +## Related + +- Completes the one remaining representation difference named as a non-goal in [Tagged render-intent union](2026-07-02-tool-render-intent-union.md) — that RFC's Non-goals section is updated to record that applied-hunk diffs shipped here. +- Builds on the [filesystem capability seam](2026-06-17-filesystem-capability-seam.md) (the before/after are storage facts the backend returns) and [event-sourced sessions](2026-06-11-event-sourced-sessions.md) (the `meta` payload persists on the `tool/result` event, so replay reproduces the card). +- The `meta` channel is deliberately generic: a future tool (a structured search, a data-table result) can attach its own durable result presentation without another core change. diff --git a/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md b/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md index 1ad5e84e33..66bea7a820 100644 --- a/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md +++ b/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md @@ -60,11 +60,11 @@ interface TerminalResultView { card: 'terminal'; title?: string; output?: string ## Non-goals -- **Applied-hunk diffs.** `claude-agent-acp` additionally rewrites Write/Edit diffs at *result* time with real structured-patch hunks (via a PostToolUse hook: `toolUpdateFromDiffToolResponse`). Our diffs are call-time and args-derived (the whole `old_string`→`new_string`, no surrounding context lines), because `presentResult` sees only `{content, isError}` and `FsEditOutcome` carries a replacement count/version, not hunk text. Real hunks would need a new result/event shape carrying the patch — a follow-up, not this change. This is the one remaining representation difference from `claude-agent-acp`, and it is architectural (needs a new event), not cosmetic. - **Live incremental `terminal_output_delta` streaming** and **command classification** — the terminal-rendering RFC's own deferred follow-ups, untouched here. ## Related - Supersedes the deferral in [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md) (rejected — "wait for two real tools and two real consumers, then a tagged render-intent union"). That bar is now met; this is that union. +- Extended by [Result-time applied-hunk diffs](2026-07-02-result-time-applied-hunk-diffs.md), which adds a persisted `meta` channel so write/edit emit a result-time contextual-hunk `DiffResultView` (context lines + one hunk per `replace_all` site) on top of this union's call-time diff card. - Folds `ToolTerminal` into the `terminal` views described by [ACP terminal and tool-call rendering](../feature/2026-06-18-acp-terminal-and-tool-rendering.md) (the `_meta` terminal-card convention and capability gate are unchanged; only the harness-side presentation type changes). - The ACP SDK's `Diff` / `ToolCallContent` types back the new `diff` card. diff --git a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl index 0e6b253480..d491e4cf0f 100644 --- a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl @@ -1,147 +1,131 @@ -{"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":"/tmp/acp-snap-cwd-yl8qhJ/config.txt\nfile\n\n1: mode=DEBUG\n2: level=info\n\n(End of file - total 2 lines)\n"}],"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"}}} +{"type":"session","version":0,"id":"554ed85e-1fa3-4791-b4a2-9256b53f8add","createdAt":1783069537397,"cwd":"/tmp/acp-snap-cwd-qAWDep"} +{"type":"turn/start","seq":0,"time":1783069537400,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783069537400,"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":1783069537401,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1783069537851,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1783069537851,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} +{"type":"assistant/chunk","seq":5,"time":1783069537974,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":6,"time":1783069538002,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" start"}}} +{"type":"assistant/chunk","seq":7,"time":1783069538003,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} +{"type":"assistant/chunk","seq":8,"time":1783069538003,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reading"}}} +{"type":"assistant/chunk","seq":9,"time":1783069538003,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":10,"time":1783069538035,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" config"}}} +{"type":"assistant/chunk","seq":11,"time":1783069538036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":12,"time":1783069538036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":13,"time":1783069538036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":14,"time":1783069538132,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":15,"time":1783069538132,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_MkPefqOY8sRQIkux83391414","name":"read","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":16,"time":1783069538166,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_MkPefqOY8sRQIkux83391414","name":"read","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":17,"time":1783069538166,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_MkPefqOY8sRQIkux83391414","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":18,"time":1783069538166,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_MkPefqOY8sRQIkux83391414","name":"read","argumentsDelta":"file"}}} +{"type":"assistant/chunk","seq":19,"time":1783069538166,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_MkPefqOY8sRQIkux83391414","name":"read","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":20,"time":1783069538166,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_MkPefqOY8sRQIkux83391414","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":21,"time":1783069538166,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_MkPefqOY8sRQIkux83391414","name":"read","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":22,"time":1783069538199,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_MkPefqOY8sRQIkux83391414","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":23,"time":1783069538199,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_MkPefqOY8sRQIkux83391414","name":"read","argumentsDelta":"config"}}} +{"type":"assistant/chunk","seq":24,"time":1783069538199,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_MkPefqOY8sRQIkux83391414","name":"read","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":25,"time":1783069538199,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_MkPefqOY8sRQIkux83391414","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":26,"time":1783069538232,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_MkPefqOY8sRQIkux83391414","name":"read","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":27,"time":1783069538298,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Let me start by reading the config.txt file."}}}} +{"type":"assistant/chunk","seq":28,"time":1783069538299,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_MkPefqOY8sRQIkux83391414","name":"read","arguments":"{\"file_path\": \"config.txt\"}"}}}} +{"type":"assistant/chunk","seq":29,"time":1783069538299,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":123,"outputTokens":55,"cacheReadTokens":2176,"reasoningTokens":10}}}} +{"type":"assistant/chunk","seq":30,"time":1783069538299,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":31,"time":1783069538301,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"Let me start by reading the config.txt file."},{"type":"tool-call","id":"call_00_MkPefqOY8sRQIkux83391414","name":"read","arguments":"{\"file_path\": \"config.txt\"}"}],"usage":{"inputTokens":123,"outputTokens":55,"cacheReadTokens":2176,"reasoningTokens":10}},"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],"surfaceOp":"append"} +{"type":"tool/call","seq":32,"time":1783069538301,"data":{"turn":1,"step":1,"callId":"call_00_MkPefqOY8sRQIkux83391414","name":"read","arguments":"{\"file_path\": \"config.txt\"}"}} +{"type":"tool/result","seq":33,"time":1783069538305,"data":{"turn":1,"step":1,"callId":"call_00_MkPefqOY8sRQIkux83391414","content":[{"type":"text","text":"/tmp/acp-snap-cwd-qAWDep/config.txt\nfile\n\n1: mode=DEBUG\n2: level=info\n\n(End of file - total 2 lines)\n"}],"isError":false},"sourceEventSeqs":[32],"surfaceOp":"append"} +{"type":"step/end","seq":34,"time":1783069538306,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":35,"time":1783069538306,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":36,"time":1783069539033,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":37,"time":1783069539034,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Now"}}} +{"type":"assistant/chunk","seq":38,"time":1783069539143,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":39,"time":1783069539167,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":40,"time":1783069539168,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":41,"time":1783069539168,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" replace"}}} +{"type":"assistant/chunk","seq":42,"time":1783069539168,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":43,"time":1783069539168,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" literal"}}} +{"type":"assistant/chunk","seq":44,"time":1783069539206,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" text"}}} +{"type":"assistant/chunk","seq":45,"time":1783069539206,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":46,"time":1783069539207,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"DEBUG"}}} +{"type":"assistant/chunk","seq":47,"time":1783069539207,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":48,"time":1783069539207,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":49,"time":1783069539207,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":50,"time":1783069539241,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"RE"}}} +{"type":"assistant/chunk","seq":51,"time":1783069539241,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"LEASE"}}} +{"type":"assistant/chunk","seq":52,"time":1783069539241,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":53,"time":1783069539241,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} +{"type":"assistant/chunk","seq":54,"time":1783069539242,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":55,"time":1783069539242,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" edit"}}} +{"type":"assistant/chunk","seq":56,"time":1783069539276,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":57,"time":1783069539276,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":58,"time":1783069539380,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":59,"time":1783069539380,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":60,"time":1783069539414,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":61,"time":1783069539414,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":62,"time":1783069539414,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"file"}}} +{"type":"assistant/chunk","seq":63,"time":1783069539414,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":64,"time":1783069539414,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":65,"time":1783069539415,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":66,"time":1783069539449,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":67,"time":1783069539450,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"config"}}} +{"type":"assistant/chunk","seq":68,"time":1783069539450,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":69,"time":1783069539483,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":70,"time":1783069539518,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":71,"time":1783069539519,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":72,"time":1783069539519,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"old"}}} +{"type":"assistant/chunk","seq":73,"time":1783069539519,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"_string"}}} +{"type":"assistant/chunk","seq":74,"time":1783069539519,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":75,"time":1783069539519,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":76,"time":1783069539553,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":77,"time":1783069539554,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"DEBUG"}}} +{"type":"assistant/chunk","seq":78,"time":1783069539555,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":79,"time":1783069539621,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":80,"time":1783069539622,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":81,"time":1783069539622,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"new"}}} +{"type":"assistant/chunk","seq":82,"time":1783069539622,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"_string"}}} +{"type":"assistant/chunk","seq":83,"time":1783069539622,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":84,"time":1783069539622,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":85,"time":1783069539662,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":86,"time":1783069539663,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"RE"}}} +{"type":"assistant/chunk","seq":87,"time":1783069539663,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"LEASE"}}} +{"type":"assistant/chunk","seq":88,"time":1783069539663,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":89,"time":1783069539692,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":90,"time":1783069539761,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Now I need to replace the literal text \"DEBUG\" with \"RELEASE\" using the edit tool."}}}} +{"type":"assistant/chunk","seq":91,"time":1783069539761,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}}}} +{"type":"assistant/chunk","seq":92,"time":1783069539761,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":241,"outputTokens":101,"cacheReadTokens":2176,"reasoningTokens":21}}}} +{"type":"assistant/chunk","seq":93,"time":1783069539761,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":94,"time":1783069539761,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Now I need to replace the literal text \"DEBUG\" with \"RELEASE\" using the edit tool."},{"type":"tool-call","id":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}],"usage":{"inputTokens":241,"outputTokens":101,"cacheReadTokens":2176,"reasoningTokens":21}},"sourceEventSeqs":[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,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],"surfaceOp":"append"} +{"type":"tool/call","seq":95,"time":1783069539762,"data":{"turn":1,"step":2,"callId":"call_00_BwdjVI05cT0dvHSaziZp0350","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}} +{"type":"tool/result","seq":96,"time":1783069539768,"data":{"turn":1,"step":2,"callId":"call_00_BwdjVI05cT0dvHSaziZp0350","content":[{"type":"text","text":"The file /tmp/acp-snap-cwd-qAWDep/config.txt has been updated successfully."}],"isError":false,"meta":{"diffs":[{"path":"config.txt","oldText":"mode=DEBUG\nlevel=info","newText":"mode=RELEASE\nlevel=info"}]}},"sourceEventSeqs":[95],"surfaceOp":"append"} +{"type":"step/end","seq":97,"time":1783069539768,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":98,"time":1783069539768,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":99,"time":1783069540733,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":100,"time":1783069540734,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":101,"time":1783069540860,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":102,"time":1783069540894,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" has"}}} +{"type":"assistant/chunk","seq":103,"time":1783069540894,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" been"}}} +{"type":"assistant/chunk","seq":104,"time":1783069540895,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" updated"}}} +{"type":"assistant/chunk","seq":105,"time":1783069540895,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":106,"time":1783069540930,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":107,"time":1783069540964,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":108,"time":1783069540998,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":109,"time":1783069540999,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":110,"time":1783069540999,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":111,"time":1783069540999,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":112,"time":1783069540999,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":113,"time":1783069541033,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":114,"time":1783069541033,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":115,"time":1783069541034,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":116,"time":1783069541034,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":117,"time":1783069541034,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} +{"type":"assistant/chunk","seq":118,"time":1783069541034,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":119,"time":1783069541067,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":120,"time":1783069541069,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":121,"time":1783069541069,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":122,"time":1783069541069,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":123,"time":1783069541069,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file has been updated. The user asked me to reply with exactly the single word DONE."}}}} +{"type":"assistant/chunk","seq":124,"time":1783069541069,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":125,"time":1783069541069,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":244,"outputTokens":23,"cacheReadTokens":2304,"reasoningTokens":20}}}} +{"type":"assistant/chunk","seq":126,"time":1783069541069,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":127,"time":1783069541069,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The file has been updated. The user asked me to reply with exactly the single word DONE."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":244,"outputTokens":23,"cacheReadTokens":2304,"reasoningTokens":20}},"sourceEventSeqs":[99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126],"surfaceOp":"append"} +{"type":"step/end","seq":128,"time":1783069541070,"data":{"turn":1,"step":3}} +{"type":"turn/end","seq":129,"time":1783069541070,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl index f7601d52e3..c2a8fe005c 100644 --- a/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl @@ -9,27 +9,10 @@ {"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","line":1}]}}} -{"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":"{{cwd}}/config.txt\nfile\n\n1: mode=DEBUG\n2: level=info\n\n(End of file - total 2 lines)\n"}}]}}} -{"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":"tool_call","toolCallId":"call_00_MkPefqOY8sRQIkux83391414","title":"Read config.txt","kind":"read","status":"in_progress","locations":[{"path":"config.txt","line":1}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_MkPefqOY8sRQIkux83391414","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/config.txt\nfile\n\n1: mode=DEBUG\n2: level=info\n\n(End of file - total 2 lines)\n"}}]}}} +{"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"}}}} @@ -50,12 +33,13 @@ {"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","locations":[{"path":"config.txt"}],"content":[{"type":"diff","path":"config.txt","oldText":"DEBUG","newText":"RELEASE"}]}}} -{"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":"tool_call","toolCallId":"call_00_BwdjVI05cT0dvHSaziZp0350","title":"Edit config.txt","kind":"edit","status":"in_progress","locations":[{"path":"config.txt"}],"content":[{"type":"diff","path":"config.txt","oldText":"DEBUG","newText":"RELEASE"}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_BwdjVI05cT0dvHSaziZp0350","status":"completed","content":[{"type":"diff","path":"config.txt","oldText":"mode=DEBUG\nlevel=info","newText":"mode=RELEASE\nlevel=info"}],"title":"Edit config.txt"}}} {"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":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" has"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" been"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" updated"}}}} {"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"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl index 259de258ea..ee3ca84d02 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl @@ -1,185 +1,370 @@ -{"type":"session","version":0,"id":"0a0f03b5-ffbe-478d-af03-49d0dbb96355","createdAt":1783004466431,"cwd":"/tmp/acp-snap-cwd-N3q5XK"} -{"type":"turn/start","seq":0,"time":1783004466441,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783004466442,"data":{"content":[{"type":"text","text":"Do NOT use the read tool. Immediately use the edit tool to replace the literal text blue with green in settings.txt in the current directory. Do not read the file first. After the tool result, reply with exactly the single word DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783004466442,"data":{"turn":1,"step":1}} -{"type":"assistant/chunk","seq":3,"time":1783004467337,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":4,"time":1783004467337,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":5,"time":1783004467468,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":6,"time":1783004467500,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":7,"time":1783004467500,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":8,"time":1783004467500,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":9,"time":1783004467500,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" directly"}}} -{"type":"assistant/chunk","seq":10,"time":1783004467538,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} -{"type":"assistant/chunk","seq":11,"time":1783004467539,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":12,"time":1783004467567,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" edit"}}} -{"type":"assistant/chunk","seq":13,"time":1783004467567,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":14,"time":1783004467567,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":15,"time":1783004467568,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" replace"}}} -{"type":"assistant/chunk","seq":16,"time":1783004467568,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":17,"time":1783004467591,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"blue"}}} -{"type":"assistant/chunk","seq":18,"time":1783004467591,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":19,"time":1783004467591,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":20,"time":1783004467591,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":21,"time":1783004467591,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"green"}}} -{"type":"assistant/chunk","seq":22,"time":1783004467620,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":23,"time":1783004467621,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} -{"type":"assistant/chunk","seq":24,"time":1783004467621,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" settings"}}} -{"type":"assistant/chunk","seq":25,"time":1783004467621,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} -{"type":"assistant/chunk","seq":26,"time":1783004467621,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" without"}}} -{"type":"assistant/chunk","seq":27,"time":1783004467621,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reading"}}} -{"type":"assistant/chunk","seq":28,"time":1783004467649,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} -{"type":"assistant/chunk","seq":29,"time":1783004467680,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} -{"type":"assistant/chunk","seq":30,"time":1783004467680,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":31,"time":1783004467708,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":32,"time":1783004467709,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} -{"type":"assistant/chunk","seq":33,"time":1783004467709,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":34,"time":1783004467709,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":35,"time":1783004467738,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":36,"time":1783004467768,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":37,"time":1783004467797,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":38,"time":1783004467797,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} -{"type":"assistant/chunk","seq":39,"time":1783004467827,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":40,"time":1783004467828,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":41,"time":1783004467887,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":42,"time":1783004467887,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":43,"time":1783004467915,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":44,"time":1783004467915,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":45,"time":1783004467915,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"file"}}} -{"type":"assistant/chunk","seq":46,"time":1783004467993,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"_path"}}} -{"type":"assistant/chunk","seq":47,"time":1783004467994,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":48,"time":1783004467994,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":49,"time":1783004467994,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":50,"time":1783004467994,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"settings"}}} -{"type":"assistant/chunk","seq":51,"time":1783004467994,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":52,"time":1783004467994,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":53,"time":1783004468010,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":54,"time":1783004468010,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":55,"time":1783004468010,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"old"}}} -{"type":"assistant/chunk","seq":56,"time":1783004468041,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"_string"}}} -{"type":"assistant/chunk","seq":57,"time":1783004468041,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":58,"time":1783004468041,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":59,"time":1783004468041,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":60,"time":1783004468070,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"blue"}}} -{"type":"assistant/chunk","seq":61,"time":1783004468071,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":62,"time":1783004468099,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":63,"time":1783004468099,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":64,"time":1783004468100,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"new"}}} -{"type":"assistant/chunk","seq":65,"time":1783004468100,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"_string"}}} -{"type":"assistant/chunk","seq":66,"time":1783004468129,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":67,"time":1783004468129,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":68,"time":1783004468129,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":69,"time":1783004468129,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"green"}}} -{"type":"assistant/chunk","seq":70,"time":1783004468158,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":71,"time":1783004468158,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":72,"time":1783004468223,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to directly use the edit tool to replace \"blue\" with \"green\" in settings.txt without reading it first, and then reply with exactly the word DONE."}}}} -{"type":"assistant/chunk","seq":73,"time":1783004468223,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}}}} -{"type":"assistant/chunk","seq":74,"time":1783004468224,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":126,"outputTokens":116,"cacheReadTokens":2176,"reasoningTokens":37}}}} -{"type":"assistant/chunk","seq":75,"time":1783004468224,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":76,"time":1783004468226,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to directly use the edit tool to replace \"blue\" with \"green\" in settings.txt without reading it first, and then reply with exactly the word DONE."},{"type":"tool-call","id":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"usage":{"inputTokens":126,"outputTokens":116,"cacheReadTokens":2176,"reasoningTokens":37}},"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,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75],"surfaceOp":"append"} -{"type":"tool/call","seq":77,"time":1783004468226,"data":{"turn":1,"step":1,"callId":"call_00_3fuirRMnjFj7LWlJL1eU3690","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}} -{"type":"tool/result","seq":78,"time":1783004468230,"data":{"turn":1,"step":1,"callId":"call_00_3fuirRMnjFj7LWlJL1eU3690","content":[{"type":"text","text":"Error: edit requires reading \"/tmp/acp-snap-cwd-N3q5XK/settings.txt\" first"}],"isError":true,"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[77],"surfaceOp":"append"} -{"type":"step/end","seq":79,"time":1783004468231,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":80,"time":1783004468231,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":81,"time":1783004469325,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":82,"time":1783004469325,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":83,"time":1783004469483,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" edit"}}} -{"type":"assistant/chunk","seq":84,"time":1783004469507,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":85,"time":1783004469508,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" requires"}}} -{"type":"assistant/chunk","seq":86,"time":1783004469541,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reading"}}} -{"type":"assistant/chunk","seq":87,"time":1783004469542,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":88,"time":1783004469542,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} -{"type":"assistant/chunk","seq":89,"time":1783004469568,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} -{"type":"assistant/chunk","seq":90,"time":1783004469568,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" according"}}} -{"type":"assistant/chunk","seq":91,"time":1783004469596,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":92,"time":1783004469597,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":93,"time":1783004469597,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" fs"}}} -{"type":"assistant/chunk","seq":94,"time":1783004469626,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"-p"}}} -{"type":"assistant/chunk","seq":95,"time":1783004469626,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"olicy"}}} -{"type":"assistant/chunk","seq":96,"time":1783004469626,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":97,"time":1783004469656,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} -{"type":"assistant/chunk","seq":98,"time":1783004469687,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":99,"time":1783004469687,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instructed"}}} -{"type":"assistant/chunk","seq":100,"time":1783004469719,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":101,"time":1783004469719,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}} -{"type":"assistant/chunk","seq":102,"time":1783004469719,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":103,"time":1783004469719,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} -{"type":"assistant/chunk","seq":104,"time":1783004469719,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":105,"time":1783004469748,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} -{"type":"assistant/chunk","seq":106,"time":1783004469748,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":107,"time":1783004469749,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" but"}}} -{"type":"assistant/chunk","seq":108,"time":1783004469749,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":109,"time":1783004469777,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":110,"time":1783004469777,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" itself"}}} -{"type":"assistant/chunk","seq":111,"time":1783004469807,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" en"}}} -{"type":"assistant/chunk","seq":112,"time":1783004469807,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"forces"}}} -{"type":"assistant/chunk","seq":113,"time":1783004469839,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} -{"type":"assistant/chunk","seq":114,"time":1783004469839,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" rule"}}} -{"type":"assistant/chunk","seq":115,"time":1783004469869,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":116,"time":1783004469869,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":117,"time":1783004469869,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} -{"type":"assistant/chunk","seq":118,"time":1783004469898,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" follow"}}} -{"type":"assistant/chunk","seq":119,"time":1783004469898,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":120,"time":1783004469899,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":121,"time":1783004469928,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}} -{"type":"assistant/chunk","seq":122,"time":1783004469929,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instruction"}}} -{"type":"assistant/chunk","seq":123,"time":1783004469929,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" but"}}} -{"type":"assistant/chunk","seq":124,"time":1783004469957,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":125,"time":1783004469987,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":126,"time":1783004469987,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" won"}}} -{"type":"assistant/chunk","seq":127,"time":1783004470017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'t"}}} -{"type":"assistant/chunk","seq":128,"time":1783004470017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" let"}}} -{"type":"assistant/chunk","seq":129,"time":1783004470017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":130,"time":1783004470017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} -{"type":"assistant/chunk","seq":131,"time":1783004470047,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} -{"type":"assistant/chunk","seq":132,"time":1783004470047,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" without"}}} -{"type":"assistant/chunk","seq":133,"time":1783004470076,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reading"}}} -{"type":"assistant/chunk","seq":134,"time":1783004470076,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":135,"time":1783004470076,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":136,"time":1783004470076,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":137,"time":1783004470076,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} -{"type":"assistant/chunk","seq":138,"time":1783004470105,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} -{"type":"assistant/chunk","seq":139,"time":1783004470135,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":140,"time":1783004470165,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} -{"type":"assistant/chunk","seq":141,"time":1783004470193,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} -{"type":"assistant/chunk","seq":142,"time":1783004470224,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":143,"time":1783004470253,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" -"}}} -{"type":"assistant/chunk","seq":144,"time":1783004470254,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":145,"time":1783004470282,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":146,"time":1783004470282,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} -{"type":"assistant/chunk","seq":147,"time":1783004470315,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" an"}}} -{"type":"assistant/chunk","seq":148,"time":1783004470315,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" error"}}} -{"type":"assistant/chunk","seq":149,"time":1783004470315,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":150,"time":1783004470316,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" But"}}} -{"type":"assistant/chunk","seq":151,"time":1783004470344,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":152,"time":1783004470345,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":153,"time":1783004470345,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" said"}}} -{"type":"assistant/chunk","seq":154,"time":1783004470345,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":155,"time":1783004470345,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":156,"time":1783004470373,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":157,"time":1783004470374,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":158,"time":1783004470374,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} -{"type":"assistant/chunk","seq":159,"time":1783004470404,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":160,"time":1783004470404,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" after"}}} -{"type":"assistant/chunk","seq":161,"time":1783004470404,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":162,"time":1783004470404,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":163,"time":1783004470433,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} -{"type":"assistant/chunk","seq":164,"time":1783004470433,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":165,"time":1783004470433,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":166,"time":1783004470433,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":167,"time":1783004470433,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} -{"type":"assistant/chunk","seq":168,"time":1783004470462,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":169,"time":1783004470462,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} -{"type":"assistant/chunk","seq":170,"time":1783004470462,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":171,"time":1783004470462,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} -{"type":"assistant/chunk","seq":172,"time":1783004470491,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instructed"}}} -{"type":"assistant/chunk","seq":173,"time":1783004470492,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":174,"time":1783004470492,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":175,"time":1783004470492,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":176,"time":1783004470521,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":177,"time":1783004470522,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The edit tool requires reading the file first according to the fs-policy. The user instructed me not to read the file, but the tool itself enforces this rule. I should follow the user's instruction but the tool won't let me do it without reading. Let me just report the result as is - the tool returned an error. But the user said to reply with exactly DONE after the tool result. Let me just reply DONE as instructed."}}}} -{"type":"assistant/chunk","seq":178,"time":1783004470522,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":179,"time":1783004470522,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":278,"outputTokens":95,"cacheReadTokens":2176,"reasoningTokens":92}}}} -{"type":"assistant/chunk","seq":180,"time":1783004470522,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":181,"time":1783004470523,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The edit tool requires reading the file first according to the fs-policy. The user instructed me not to read the file, but the tool itself enforces this rule. I should follow the user's instruction but the tool won't let me do it without reading. Let me just report the result as is - the tool returned an error. But the user said to reply with exactly DONE after the tool result. Let me just reply DONE as instructed."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":278,"outputTokens":95,"cacheReadTokens":2176,"reasoningTokens":92}},"sourceEventSeqs":[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,111,112,113,114,115,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,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180],"surfaceOp":"append"} -{"type":"step/end","seq":182,"time":1783004470523,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":183,"time":1783004470523,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"9e9f6ebd-684e-442e-bfee-d6aebb65ec67","createdAt":1783069553965,"cwd":"/tmp/acp-snap-cwd-owjbfU"} +{"type":"turn/start","seq":0,"time":1783069553968,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783069553968,"data":{"content":[{"type":"text","text":"Do NOT use the read tool. Immediately use the edit tool to replace the literal text blue with green in settings.txt in the current directory. Do not read the file first. After the tool result, reply with exactly the single word DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783069553969,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1783069554380,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1783069554380,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1783069554505,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1783069554539,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1783069554540,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1783069554540,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1783069554540,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":10,"time":1783069554540,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":11,"time":1783069554577,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" edit"}}} +{"type":"assistant/chunk","seq":12,"time":1783069554578,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":13,"time":1783069554578,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":14,"time":1783069554578,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" replace"}}} +{"type":"assistant/chunk","seq":15,"time":1783069554578,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":16,"time":1783069554614,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"blue"}}} +{"type":"assistant/chunk","seq":17,"time":1783069554614,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":18,"time":1783069554614,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":19,"time":1783069554614,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":20,"time":1783069554614,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"green"}}} +{"type":"assistant/chunk","seq":21,"time":1783069554649,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":22,"time":1783069554649,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} +{"type":"assistant/chunk","seq":23,"time":1783069554650,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" settings"}}} +{"type":"assistant/chunk","seq":24,"time":1783069554650,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":25,"time":1783069554650,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" without"}}} +{"type":"assistant/chunk","seq":26,"time":1783069554650,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reading"}}} +{"type":"assistant/chunk","seq":27,"time":1783069554685,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":28,"time":1783069554718,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":29,"time":1783069554718,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} +{"type":"assistant/chunk","seq":30,"time":1783069554718,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":31,"time":1783069554718,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":32,"time":1783069554719,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":33,"time":1783069554719,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":34,"time":1783069554753,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":35,"time":1783069554753,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":36,"time":1783069554826,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":37,"time":1783069554826,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":38,"time":1783069554856,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":39,"time":1783069554857,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":40,"time":1783069554857,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"file"}}} +{"type":"assistant/chunk","seq":41,"time":1783069554891,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":42,"time":1783069554891,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":43,"time":1783069554891,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":44,"time":1783069554891,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":45,"time":1783069554929,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"settings"}}} +{"type":"assistant/chunk","seq":46,"time":1783069554930,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":47,"time":1783069554930,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":48,"time":1783069554964,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":49,"time":1783069554965,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":50,"time":1783069554965,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"old"}}} +{"type":"assistant/chunk","seq":51,"time":1783069554995,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"_string"}}} +{"type":"assistant/chunk","seq":52,"time":1783069554995,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":53,"time":1783069554996,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":54,"time":1783069554996,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":55,"time":1783069555030,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"blue"}}} +{"type":"assistant/chunk","seq":56,"time":1783069555031,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":57,"time":1783069555071,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":58,"time":1783069555071,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":59,"time":1783069555071,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"new"}}} +{"type":"assistant/chunk","seq":60,"time":1783069555071,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"_string"}}} +{"type":"assistant/chunk","seq":61,"time":1783069555105,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":62,"time":1783069555105,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":63,"time":1783069555106,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":64,"time":1783069555106,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"green"}}} +{"type":"assistant/chunk","seq":65,"time":1783069555139,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":66,"time":1783069555139,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":67,"time":1783069555209,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the edit tool to replace \"blue\" with \"green\" in settings.txt without reading the file first. Let me do that."}}}} +{"type":"assistant/chunk","seq":68,"time":1783069555209,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}}}} +{"type":"assistant/chunk","seq":69,"time":1783069555209,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":126,"outputTokens":111,"cacheReadTokens":2176,"reasoningTokens":32}}}} +{"type":"assistant/chunk","seq":70,"time":1783069555209,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":71,"time":1783069555211,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the edit tool to replace \"blue\" with \"green\" in settings.txt without reading the file first. Let me do that."},{"type":"tool-call","id":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"usage":{"inputTokens":126,"outputTokens":111,"cacheReadTokens":2176,"reasoningTokens":32}},"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,61,62,63,64,65,66,67,68,69,70],"surfaceOp":"append"} +{"type":"tool/call","seq":72,"time":1783069555211,"data":{"turn":1,"step":1,"callId":"call_00_vCcG7c6T2vNO29dXgpkK5485","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}} +{"type":"tool/result","seq":73,"time":1783069555215,"data":{"turn":1,"step":1,"callId":"call_00_vCcG7c6T2vNO29dXgpkK5485","content":[{"type":"text","text":"Error: edit requires reading \"/tmp/acp-snap-cwd-owjbfU/settings.txt\" first"}],"isError":true,"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[72],"surfaceOp":"append"} +{"type":"step/end","seq":74,"time":1783069555215,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":75,"time":1783069555215,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":76,"time":1783069555951,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":77,"time":1783069555951,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":78,"time":1783069556045,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" edit"}}} +{"type":"assistant/chunk","seq":79,"time":1783069556079,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":80,"time":1783069556080,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" requires"}}} +{"type":"assistant/chunk","seq":81,"time":1783069556080,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reading"}}} +{"type":"assistant/chunk","seq":82,"time":1783069556113,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":83,"time":1783069556114,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":84,"time":1783069556114,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} +{"type":"assistant/chunk","seq":85,"time":1783069556114,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" due"}}} +{"type":"assistant/chunk","seq":86,"time":1783069556147,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":87,"time":1783069556148,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":88,"time":1783069556148,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" fs"}}} +{"type":"assistant/chunk","seq":89,"time":1783069556189,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"-p"}}} +{"type":"assistant/chunk","seq":90,"time":1783069556189,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"olicy"}}} +{"type":"assistant/chunk","seq":91,"time":1783069556190,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":92,"time":1783069556190,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" But"}}} +{"type":"assistant/chunk","seq":93,"time":1783069556215,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":94,"time":1783069556216,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":95,"time":1783069556216,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" explicitly"}}} +{"type":"assistant/chunk","seq":96,"time":1783069556216,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" told"}}} +{"type":"assistant/chunk","seq":97,"time":1783069556216,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":98,"time":1783069556249,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}} +{"type":"assistant/chunk","seq":99,"time":1783069556250,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":100,"time":1783069556250,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":101,"time":1783069556284,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":102,"time":1783069556284,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":103,"time":1783069556284,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":104,"time":1783069556284,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":105,"time":1783069556284,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" However"}}} +{"type":"assistant/chunk","seq":106,"time":1783069556284,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":107,"time":1783069556318,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":108,"time":1783069556319,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" edit"}}} +{"type":"assistant/chunk","seq":109,"time":1783069556319,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":110,"time":1783069556319,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" itself"}}} +{"type":"assistant/chunk","seq":111,"time":1783069556355,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" requires"}}} +{"type":"assistant/chunk","seq":112,"time":1783069556388,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":113,"time":1783069556427,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":114,"time":1783069556428,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":115,"time":1783069556462,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":116,"time":1783069556462,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":117,"time":1783069556462,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} +{"type":"assistant/chunk","seq":118,"time":1783069556462,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":119,"time":1783069556462,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":120,"time":1783069556463,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":121,"time":1783069556494,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" try"}}} +{"type":"assistant/chunk","seq":122,"time":1783069556494,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reading"}}} +{"type":"assistant/chunk","seq":123,"time":1783069556531,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":124,"time":1783069556531,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":125,"time":1783069556564,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} +{"type":"assistant/chunk","seq":126,"time":1783069556565,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":127,"time":1783069556599,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" editing"}}} +{"type":"assistant/chunk","seq":128,"time":1783069556632,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":129,"time":1783069556633,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":130,"time":1783069556667,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" but"}}} +{"type":"assistant/chunk","seq":131,"time":1783069556702,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":132,"time":1783069556703,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":133,"time":1783069556703,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" said"}}} +{"type":"assistant/chunk","seq":134,"time":1783069556703,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":135,"time":1783069556737,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Do"}}} +{"type":"assistant/chunk","seq":136,"time":1783069556738,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" NOT"}}} +{"type":"assistant/chunk","seq":137,"time":1783069556738,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":138,"time":1783069556738,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":139,"time":1783069556738,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":140,"time":1783069556738,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":141,"time":1783069556772,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".\""}}} +{"type":"assistant/chunk","seq":142,"time":1783069556807,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \n\n"}}} +{"type":"assistant/chunk","seq":143,"time":1783069556808,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Actually"}}} +{"type":"assistant/chunk","seq":144,"time":1783069556808,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":145,"time":1783069556809,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" looking"}}} +{"type":"assistant/chunk","seq":146,"time":1783069556841,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" more"}}} +{"type":"assistant/chunk","seq":147,"time":1783069556841,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" carefully"}}} +{"type":"assistant/chunk","seq":148,"time":1783069556842,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":149,"time":1783069556842,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":150,"time":1783069556842,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" error"}}} +{"type":"assistant/chunk","seq":151,"time":1783069556878,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" says"}}} +{"type":"assistant/chunk","seq":152,"time":1783069556879,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":153,"time":1783069556879,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"edit"}}} +{"type":"assistant/chunk","seq":154,"time":1783069556910,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" requires"}}} +{"type":"assistant/chunk","seq":155,"time":1783069556911,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reading"}}} +{"type":"assistant/chunk","seq":156,"time":1783069556911,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ..."}}} +{"type":"assistant/chunk","seq":157,"time":1783069556944,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} +{"type":"assistant/chunk","seq":158,"time":1783069556945,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":159,"time":1783069556979,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" -"}}} +{"type":"assistant/chunk","seq":160,"time":1783069556980,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":161,"time":1783069556980,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" might"}}} +{"type":"assistant/chunk","seq":162,"time":1783069557016,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" be"}}} +{"type":"assistant/chunk","seq":163,"time":1783069557016,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":164,"time":1783069557016,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} +{"type":"assistant/chunk","seq":165,"time":1783069557051,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" enforcement"}}} +{"type":"assistant/chunk","seq":166,"time":1783069557051,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":167,"time":1783069557085,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":168,"time":1783069557124,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" can"}}} +{"type":"assistant/chunk","seq":169,"time":1783069557125,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'t"}}} +{"type":"assistant/chunk","seq":170,"time":1783069557125,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" bypass"}}} +{"type":"assistant/chunk","seq":171,"time":1783069557160,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":172,"time":1783069557160,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" But"}}} +{"type":"assistant/chunk","seq":173,"time":1783069557201,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":174,"time":1783069557202,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":175,"time":1783069557202,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}} +{"type":"assistant/chunk","seq":176,"time":1783069557227,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instruction"}}} +{"type":"assistant/chunk","seq":177,"time":1783069557228,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":178,"time":1783069557228,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" quite"}}} +{"type":"assistant/chunk","seq":179,"time":1783069557228,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" strict"}}} +{"type":"assistant/chunk","seq":180,"time":1783069557228,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":181,"time":1783069557263,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":182,"time":1783069557263,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Do"}}} +{"type":"assistant/chunk","seq":183,"time":1783069557263,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" NOT"}}} +{"type":"assistant/chunk","seq":184,"time":1783069557264,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":185,"time":1783069557264,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":186,"time":1783069557264,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":187,"time":1783069557295,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":188,"time":1783069557295,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":189,"time":1783069557295,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Immediately"}}} +{"type":"assistant/chunk","seq":190,"time":1783069557296,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":191,"time":1783069557331,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":192,"time":1783069557332,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" edit"}}} +{"type":"assistant/chunk","seq":193,"time":1783069557332,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":194,"time":1783069557371,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"...\"\n\n"}}} +{"type":"assistant/chunk","seq":195,"time":1783069557371,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"I"}}} +{"type":"assistant/chunk","seq":196,"time":1783069557371,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" can"}}} +{"type":"assistant/chunk","seq":197,"time":1783069557413,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'t"}}} +{"type":"assistant/chunk","seq":198,"time":1783069557414,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" comply"}}} +{"type":"assistant/chunk","seq":199,"time":1783069557436,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":200,"time":1783069557437,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" both"}}} +{"type":"assistant/chunk","seq":201,"time":1783069557469,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" constraints"}}} +{"type":"assistant/chunk","seq":202,"time":1783069557470,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":203,"time":1783069557503,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":204,"time":1783069557504,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" edit"}}} +{"type":"assistant/chunk","seq":205,"time":1783069557504,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":206,"time":1783069557538,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" won"}}} +{"type":"assistant/chunk","seq":207,"time":1783069557573,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'t"}}} +{"type":"assistant/chunk","seq":208,"time":1783069557573,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" work"}}} +{"type":"assistant/chunk","seq":209,"time":1783069557606,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" without"}}} +{"type":"assistant/chunk","seq":210,"time":1783069557607,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":211,"time":1783069557642,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" prior"}}} +{"type":"assistant/chunk","seq":212,"time":1783069557642,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":213,"time":1783069557642,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":214,"time":1783069557643,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":215,"time":1783069557643,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":216,"time":1783069557643,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":217,"time":1783069557678,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" inform"}}} +{"type":"assistant/chunk","seq":218,"time":1783069557710,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":219,"time":1783069557711,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":220,"time":1783069557711,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" of"}}} +{"type":"assistant/chunk","seq":221,"time":1783069557744,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":222,"time":1783069557778,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".\n\n"}}} +{"type":"assistant/chunk","seq":223,"time":1783069557779,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Wait"}}} +{"type":"assistant/chunk","seq":224,"time":1783069557779,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":225,"time":1783069557779,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" let"}}} +{"type":"assistant/chunk","seq":226,"time":1783069557779,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":227,"time":1783069557779,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" re"}}} +{"type":"assistant/chunk","seq":228,"time":1783069557779,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"-read"}}} +{"type":"assistant/chunk","seq":229,"time":1783069557818,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":230,"time":1783069557818,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instructions"}}} +{"type":"assistant/chunk","seq":231,"time":1783069557818,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" more"}}} +{"type":"assistant/chunk","seq":232,"time":1783069557819,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" carefully"}}} +{"type":"assistant/chunk","seq":233,"time":1783069557819,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":234,"time":1783069557851,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":235,"time":1783069557852,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":236,"time":1783069557889,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" says"}}} +{"type":"assistant/chunk","seq":237,"time":1783069557922,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":238,"time":1783069557922,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Do"}}} +{"type":"assistant/chunk","seq":239,"time":1783069557922,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" NOT"}}} +{"type":"assistant/chunk","seq":240,"time":1783069557922,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":241,"time":1783069557922,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":242,"time":1783069557922,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":243,"time":1783069557954,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":244,"time":1783069557955,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".\""}}} +{"type":"assistant/chunk","seq":245,"time":1783069557991,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" -"}}} +{"type":"assistant/chunk","seq":246,"time":1783069558025,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" they"}}} +{"type":"assistant/chunk","seq":247,"time":1783069558059,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" specifically"}}} +{"type":"assistant/chunk","seq":248,"time":1783069558060,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" say"}}} +{"type":"assistant/chunk","seq":249,"time":1783069558101,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" don"}}} +{"type":"assistant/chunk","seq":250,"time":1783069558102,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'t"}}} +{"type":"assistant/chunk","seq":251,"time":1783069558102,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":252,"time":1783069558132,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":253,"time":1783069558133,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":254,"time":1783069558168,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":255,"time":1783069558168,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":256,"time":1783069558169,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" But"}}} +{"type":"assistant/chunk","seq":257,"time":1783069558169,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":258,"time":1783069558199,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" edit"}}} +{"type":"assistant/chunk","seq":259,"time":1783069558199,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":260,"time":1783069558200,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" documentation"}}} +{"type":"assistant/chunk","seq":261,"time":1783069558234,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" says"}}} +{"type":"assistant/chunk","seq":262,"time":1783069558235,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":263,"time":1783069558270,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Read"}}} +{"type":"assistant/chunk","seq":264,"time":1783069558303,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":265,"time":1783069558304,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":266,"time":1783069558304,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} +{"type":"assistant/chunk","seq":267,"time":1783069558304,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} +{"type":"assistant/chunk","seq":268,"time":1783069558338,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"the"}}} +{"type":"assistant/chunk","seq":269,"time":1783069558338,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" default"}}} +{"type":"assistant/chunk","seq":270,"time":1783069558338,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" fs"}}} +{"type":"assistant/chunk","seq":271,"time":1783069558338,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"-p"}}} +{"type":"assistant/chunk","seq":272,"time":1783069558338,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"olicy"}}} +{"type":"assistant/chunk","seq":273,"time":1783069558339,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" requires"}}} +{"type":"assistant/chunk","seq":274,"time":1783069558371,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":275,"time":1783069558372,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"),"}}} +{"type":"assistant/chunk","seq":276,"time":1783069558372,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" unless"}}} +{"type":"assistant/chunk","seq":277,"time":1783069558406,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" you"}}} +{"type":"assistant/chunk","seq":278,"time":1783069558407,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":279,"time":1783069558407,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" created"}}} +{"type":"assistant/chunk","seq":280,"time":1783069558407,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" or"}}} +{"type":"assistant/chunk","seq":281,"time":1783069558441,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" edited"}}} +{"type":"assistant/chunk","seq":282,"time":1783069558442,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":283,"time":1783069558442,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} +{"type":"assistant/chunk","seq":284,"time":1783069558442,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":285,"time":1783069558442,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" session"}}} +{"type":"assistant/chunk","seq":286,"time":1783069558478,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".\"\n\n"}}} +{"type":"assistant/chunk","seq":287,"time":1783069558479,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"So"}}} +{"type":"assistant/chunk","seq":288,"time":1783069558479,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":289,"time":1783069558515,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" can"}}} +{"type":"assistant/chunk","seq":290,"time":1783069558548,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'t"}}} +{"type":"assistant/chunk","seq":291,"time":1783069558548,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":292,"time":1783069558548,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" edit"}}} +{"type":"assistant/chunk","seq":293,"time":1783069558584,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" without"}}} +{"type":"assistant/chunk","seq":294,"time":1783069558585,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" having"}}} +{"type":"assistant/chunk","seq":295,"time":1783069558616,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":296,"time":1783069558616,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":297,"time":1783069558617,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":298,"time":1783069558617,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} +{"type":"assistant/chunk","seq":299,"time":1783069558650,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":300,"time":1783069558650,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":301,"time":1783069558684,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" system"}}} +{"type":"assistant/chunk","seq":302,"time":1783069558721,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" prevented"}}} +{"type":"assistant/chunk","seq":303,"time":1783069558757,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":304,"time":1783069558791,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":305,"time":1783069558791,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":306,"time":1783069558826,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'ll"}}} +{"type":"assistant/chunk","seq":307,"time":1783069558826,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} +{"type":"assistant/chunk","seq":308,"time":1783069558860,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} +{"type":"assistant/chunk","seq":309,"time":1783069558894,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" happened"}}} +{"type":"assistant/chunk","seq":310,"time":1783069558894,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":311,"time":1783069558895,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":312,"time":1783069558895,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"The"}}} +{"type":"assistant/chunk","seq":313,"time":1783069558928,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" edit"}}} +{"type":"assistant/chunk","seq":314,"time":1783069558928,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" tool"}}} +{"type":"assistant/chunk","seq":315,"time":1783069558929,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"'s"}}} +{"type":"assistant/chunk","seq":316,"time":1783069558960,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" files"}}} +{"type":"assistant/chunk","seq":317,"time":1783069558998,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ystem"}}} +{"type":"assistant/chunk","seq":318,"time":1783069558999,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" policy"}}} +{"type":"assistant/chunk","seq":319,"time":1783069558999,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" requires"}}} +{"type":"assistant/chunk","seq":320,"time":1783069559032,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" reading"}}} +{"type":"assistant/chunk","seq":321,"time":1783069559065,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" the"}}} +{"type":"assistant/chunk","seq":322,"time":1783069559066,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" file"}}} +{"type":"assistant/chunk","seq":323,"time":1783069559066,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" first"}}} +{"type":"assistant/chunk","seq":324,"time":1783069559066,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":","}}} +{"type":"assistant/chunk","seq":325,"time":1783069559066,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" so"}}} +{"type":"assistant/chunk","seq":326,"time":1783069559099,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" I"}}} +{"type":"assistant/chunk","seq":327,"time":1783069559100,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" cannot"}}} +{"type":"assistant/chunk","seq":328,"time":1783069559135,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" edit"}}} +{"type":"assistant/chunk","seq":329,"time":1783069559169,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" without"}}} +{"type":"assistant/chunk","seq":330,"time":1783069559207,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" a"}}} +{"type":"assistant/chunk","seq":331,"time":1783069559242,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" prior"}}} +{"type":"assistant/chunk","seq":332,"time":1783069559242,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" read"}}} +{"type":"assistant/chunk","seq":333,"time":1783069559243,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"."}}} +{"type":"assistant/chunk","seq":334,"time":1783069559243,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" I"}}} +{"type":"assistant/chunk","seq":335,"time":1783069559277,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" attempted"}}} +{"type":"assistant/chunk","seq":336,"time":1783069559311,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" the"}}} +{"type":"assistant/chunk","seq":337,"time":1783069559347,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" edit"}}} +{"type":"assistant/chunk","seq":338,"time":1783069559347,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" as"}}} +{"type":"assistant/chunk","seq":339,"time":1783069559380,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" instructed"}}} +{"type":"assistant/chunk","seq":340,"time":1783069559381,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":","}}} +{"type":"assistant/chunk","seq":341,"time":1783069559414,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" but"}}} +{"type":"assistant/chunk","seq":342,"time":1783069559415,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" the"}}} +{"type":"assistant/chunk","seq":343,"time":1783069559415,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" system"}}} +{"type":"assistant/chunk","seq":344,"time":1783069559449,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" rejected"}}} +{"type":"assistant/chunk","seq":345,"time":1783069559450,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" it"}}} +{"type":"assistant/chunk","seq":346,"time":1783069559483,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":".\n\n"}}} +{"type":"assistant/chunk","seq":347,"time":1783069559484,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"Would"}}} +{"type":"assistant/chunk","seq":348,"time":1783069559516,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" you"}}} +{"type":"assistant/chunk","seq":349,"time":1783069559517,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" like"}}} +{"type":"assistant/chunk","seq":350,"time":1783069559517,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" me"}}} +{"type":"assistant/chunk","seq":351,"time":1783069559517,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" to"}}} +{"type":"assistant/chunk","seq":352,"time":1783069559517,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" read"}}} +{"type":"assistant/chunk","seq":353,"time":1783069559552,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" the"}}} +{"type":"assistant/chunk","seq":354,"time":1783069559552,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" file"}}} +{"type":"assistant/chunk","seq":355,"time":1783069559553,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" first"}}} +{"type":"assistant/chunk","seq":356,"time":1783069559553,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" and"}}} +{"type":"assistant/chunk","seq":357,"time":1783069559586,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" then"}}} +{"type":"assistant/chunk","seq":358,"time":1783069559587,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" perform"}}} +{"type":"assistant/chunk","seq":359,"time":1783069559623,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" the"}}} +{"type":"assistant/chunk","seq":360,"time":1783069559623,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" edit"}}} +{"type":"assistant/chunk","seq":361,"time":1783069559623,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"?"}}} +{"type":"assistant/chunk","seq":362,"time":1783069559624,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The edit tool requires reading the file first due to the fs-policy. But the user explicitly told me not to use the read tool. However, the edit tool itself requires that I read the file first. Let me try reading the file first then editing it, but the user said \"Do NOT use the read tool.\" \n\nActually, looking more carefully, the error says \"edit requires reading ... first\" - this might be a policy enforcement that I can't bypass. But the user's instruction is quite strict: \"Do NOT use the read tool. Immediately use the edit tool...\"\n\nI can't comply with both constraints. The edit tool won't work without a prior read. Let me just inform the user of this.\n\nWait, let me re-read the instructions more carefully. The user says \"Do NOT use the read tool.\" - they specifically say don't use the read tool. But the edit tool documentation says \"Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\"\n\nSo I can't use edit without having read the file first. The system prevented me. I'll report what happened."}}}} +{"type":"assistant/chunk","seq":363,"time":1783069559624,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The edit tool's filesystem policy requires reading the file first, so I cannot edit without a prior read. I attempted the edit as instructed, but the system rejected it.\n\nWould you like me to read the file first and then perform the edit?"}}}} +{"type":"assistant/chunk","seq":364,"time":1783069559624,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":272,"outputTokens":285,"cacheReadTokens":2176,"reasoningTokens":234}}}} +{"type":"assistant/chunk","seq":365,"time":1783069559624,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":366,"time":1783069559624,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The edit tool requires reading the file first due to the fs-policy. But the user explicitly told me not to use the read tool. However, the edit tool itself requires that I read the file first. Let me try reading the file first then editing it, but the user said \"Do NOT use the read tool.\" \n\nActually, looking more carefully, the error says \"edit requires reading ... first\" - this might be a policy enforcement that I can't bypass. But the user's instruction is quite strict: \"Do NOT use the read tool. Immediately use the edit tool...\"\n\nI can't comply with both constraints. The edit tool won't work without a prior read. Let me just inform the user of this.\n\nWait, let me re-read the instructions more carefully. The user says \"Do NOT use the read tool.\" - they specifically say don't use the read tool. But the edit tool documentation says \"Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\"\n\nSo I can't use edit without having read the file first. The system prevented me. I'll report what happened."},{"type":"text","text":"The edit tool's filesystem policy requires reading the file first, so I cannot edit without a prior read. I attempted the edit as instructed, but the system rejected it.\n\nWould you like me to read the file first and then perform the edit?"}],"usage":{"inputTokens":272,"outputTokens":285,"cacheReadTokens":2176,"reasoningTokens":234}},"sourceEventSeqs":[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,111,112,113,114,115,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,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283,284,285,286,287,288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342,343,344,345,346,347,348,349,350,351,352,353,354,355,356,357,358,359,360,361,362,363,364,365],"surfaceOp":"append"} +{"type":"step/end","seq":367,"time":1783069559624,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":368,"time":1783069559624,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl index 0de98c4a44..5d37216325 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl @@ -5,7 +5,6 @@ {"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":" directly"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} {"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"}}}} @@ -24,21 +23,17 @@ {"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":" without"}}}} {"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":" it"}}}} -{"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":","}}}} -{"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":" the"}}}} -{"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":" file"}}}} +{"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":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_3fuirRMnjFj7LWlJL1eU3690","title":"Edit settings.txt","kind":"edit","status":"in_progress","locations":[{"path":"settings.txt"}],"content":[{"type":"diff","path":"settings.txt","oldText":"blue","newText":"green"}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_3fuirRMnjFj7LWlJL1eU3690","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: edit requires reading \"{{cwd}}/settings.txt\" first"}}]}}} +{"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":" do"}}}} +{"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":"tool_call","toolCallId":"call_00_vCcG7c6T2vNO29dXgpkK5485","title":"Edit settings.txt","kind":"edit","status":"in_progress","locations":[{"path":"settings.txt"}],"content":[{"type":"diff","path":"settings.txt","oldText":"blue","newText":"green"}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_vCcG7c6T2vNO29dXgpkK5485","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: edit requires reading \"{{cwd}}/settings.txt\" first"}}]}}} {"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"}}}} @@ -47,90 +42,280 @@ {"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":" first"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" according"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" due"}}}} {"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":" the"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" fs"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"-p"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"olicy"}}}} {"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":" But"}}}} +{"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":" instructed"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" explicitly"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" told"}}}} {"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":" not"}}}} {"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":" use"}}}} +{"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":" However"}}}} +{"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":" 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":" itself"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" requires"}}}} +{"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":" I"}}}} {"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":" file"}}}} -{"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":" but"}}}} -{"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":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" itself"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" en"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"forces"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" this"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" rule"}}}} -{"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":" should"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" follow"}}}} -{"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":"'s"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instruction"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" but"}}}} -{"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":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" won"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'t"}}}} -{"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":" do"}}}} -{"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":" without"}}}} -{"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":" first"}}}} {"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":" 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":" just"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" try"}}}} +{"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":" result"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" as"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} -{"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":" file"}}}} +{"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":" then"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" editing"}}}} +{"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":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" but"}}}} {"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":" said"}}}} +{"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":"Do"}}}} +{"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":" use"}}}} +{"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":" returned"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" an"}}}} +{"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":" \n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Actually"}}}} +{"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":" looking"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" more"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" carefully"}}}} +{"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":" error"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" says"}}}} +{"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":"edit"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" requires"}}}} +{"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":" ..."}}}} +{"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":"\""}}}} +{"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":" this"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" might"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" be"}}}} +{"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":" policy"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" enforcement"}}}} +{"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":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" can"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'t"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bypass"}}}} {"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":" But"}}}} {"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":" said"}}}} -{"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":" 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":" after"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'s"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instruction"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" quite"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" strict"}}}} +{"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":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Do"}}}} +{"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":" use"}}}} {"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":" result"}}}} +{"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":" Immediately"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} +{"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":"...\"\n\n"}}}} +{"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":" can"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'t"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" comply"}}}} +{"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":" both"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" constraints"}}}} +{"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":" 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":" won"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'t"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" work"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" without"}}}} +{"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":" prior"}}}} +{"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":"."}}}} {"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":" just"}}}} -{"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":" 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":" as"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instructed"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" inform"}}}} +{"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":" of"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" this"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Wait"}}}} +{"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":" 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":" re"}}}} +{"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":" instructions"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" more"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" carefully"}}}} {"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","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":" says"}}}} +{"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":"Do"}}}} +{"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":" use"}}}} +{"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":" -"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" they"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" specifically"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" say"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" don"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'t"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} +{"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":" But"}}}} +{"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":" documentation"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" says"}}}} +{"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":"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":" file"}}}} +{"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":" ("}}}} +{"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":" default"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" fs"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"-p"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"olicy"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" requires"}}}} +{"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":"),"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" unless"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" you"}}}} +{"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":" created"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" or"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" edited"}}}} +{"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":" in"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" this"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" session"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\"\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"So"}}}} +{"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":" can"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'t"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} +{"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":" without"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" having"}}}} +{"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":" file"}}}} +{"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":"."}}}} +{"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":" system"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" prevented"}}}} +{"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":"."}}}} +{"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":"'ll"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" what"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" happened"}}}} +{"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":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" edit"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"'s"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" files"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ystem"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" policy"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" requires"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" reading"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" first"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" so"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" cannot"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" edit"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" without"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" prior"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" read"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" attempted"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" edit"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" as"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" instructed"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" but"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" system"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" rejected"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" it"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":".\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Would"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" you"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" like"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" to"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" read"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" first"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" then"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" perform"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" edit"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"?"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl b/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl index f9433a8725..bad62dda0f 100644 --- a/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl @@ -1,102 +1,126 @@ -{"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":"/tmp/acp-snap-cwd-2yWjlu/big.txt\nfile\n\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"}],"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"}}} +{"type":"session","version":0,"id":"41e2e29c-6ca8-4adf-91b1-8927c9a066af","createdAt":1783069549203,"cwd":"/tmp/acp-snap-cwd-ogYesF"} +{"type":"turn/start","seq":0,"time":1783069549211,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783069549212,"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":1783069549213,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1783069549785,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1783069549786,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1783069549934,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1783069549973,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1783069549973,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1783069549974,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1783069549974,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":10,"time":1783069550006,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" lines"}}} +{"type":"assistant/chunk","seq":11,"time":1783069550007,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} +{"type":"assistant/chunk","seq":12,"time":1783069550007,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"5"}}} +{"type":"assistant/chunk","seq":13,"time":1783069550044,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-"}}} +{"type":"assistant/chunk","seq":14,"time":1783069550045,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"8"}}} +{"type":"assistant/chunk","seq":15,"time":1783069550045,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} +{"type":"assistant/chunk","seq":16,"time":1783069550073,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"5"}}} +{"type":"assistant/chunk","seq":17,"time":1783069550107,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":18,"time":1783069550141,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"6"}}} +{"type":"assistant/chunk","seq":19,"time":1783069550174,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":20,"time":1783069550175,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"7"}}} +{"type":"assistant/chunk","seq":21,"time":1783069550175,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":22,"time":1783069550175,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"8"}}} +{"type":"assistant/chunk","seq":23,"time":1783069550175,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":")"}}} +{"type":"assistant/chunk","seq":24,"time":1783069550175,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" of"}}} +{"type":"assistant/chunk","seq":25,"time":1783069550209,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" big"}}} +{"type":"assistant/chunk","seq":26,"time":1783069550209,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":27,"time":1783069550210,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} +{"type":"assistant/chunk","seq":28,"time":1783069550210,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":29,"time":1783069550248,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":30,"time":1783069550248,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":31,"time":1783069550248,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":32,"time":1783069550248,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" offset"}}} +{"type":"assistant/chunk","seq":33,"time":1783069550248,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"="}}} +{"type":"assistant/chunk","seq":34,"time":1783069550283,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"5"}}} +{"type":"assistant/chunk","seq":35,"time":1783069550283,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":36,"time":1783069550283,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" limit"}}} +{"type":"assistant/chunk","seq":37,"time":1783069550283,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"="}}} +{"type":"assistant/chunk","seq":38,"time":1783069550284,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"4"}}} +{"type":"assistant/chunk","seq":39,"time":1783069550284,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":40,"time":1783069550387,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":41,"time":1783069550387,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":42,"time":1783069550421,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":43,"time":1783069550421,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":44,"time":1783069550421,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":"file"}}} +{"type":"assistant/chunk","seq":45,"time":1783069550421,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":46,"time":1783069550421,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":47,"time":1783069550450,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":48,"time":1783069550450,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":49,"time":1783069550450,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":"big"}}} +{"type":"assistant/chunk","seq":50,"time":1783069550450,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":51,"time":1783069550490,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":52,"time":1783069550518,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":53,"time":1783069550519,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":54,"time":1783069550519,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":"offset"}}} +{"type":"assistant/chunk","seq":55,"time":1783069550519,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":56,"time":1783069550519,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":57,"time":1783069550553,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":"5"}}} +{"type":"assistant/chunk","seq":58,"time":1783069550624,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":59,"time":1783069550624,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":60,"time":1783069550624,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":"limit"}}} +{"type":"assistant/chunk","seq":61,"time":1783069550624,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":62,"time":1783069550624,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":63,"time":1783069550663,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":"4"}}} +{"type":"assistant/chunk","seq":64,"time":1783069550697,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":65,"time":1783069550739,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to read lines 5-8 (5,6,7,8) of big.txt using the read tool with offset=5 and limit=4."}}}} +{"type":"assistant/chunk","seq":66,"time":1783069550739,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","arguments":"{\"file_path\": \"big.txt\", \"offset\": 5, \"limit\": 4}"}}}} +{"type":"assistant/chunk","seq":67,"time":1783069550739,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":119,"outputTokens":113,"cacheReadTokens":2176,"reasoningTokens":36}}}} +{"type":"assistant/chunk","seq":68,"time":1783069550739,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":69,"time":1783069550741,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to read lines 5-8 (5,6,7,8) of big.txt using the read tool with offset=5 and limit=4."},{"type":"tool-call","id":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","arguments":"{\"file_path\": \"big.txt\", \"offset\": 5, \"limit\": 4}"}],"usage":{"inputTokens":119,"outputTokens":113,"cacheReadTokens":2176,"reasoningTokens":36}},"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,61,62,63,64,65,66,67,68],"surfaceOp":"append"} +{"type":"tool/call","seq":70,"time":1783069550742,"data":{"turn":1,"step":1,"callId":"call_00_pwDbbXpPbcf7LOV3jXa47203","name":"read","arguments":"{\"file_path\": \"big.txt\", \"offset\": 5, \"limit\": 4}"}} +{"type":"tool/result","seq":71,"time":1783069550746,"data":{"turn":1,"step":1,"callId":"call_00_pwDbbXpPbcf7LOV3jXa47203","content":[{"type":"text","text":"/tmp/acp-snap-cwd-ogYesF/big.txt\nfile\n\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"}],"isError":false},"sourceEventSeqs":[70],"surfaceOp":"append"} +{"type":"step/end","seq":72,"time":1783069550747,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":73,"time":1783069550747,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":74,"time":1783069551362,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":75,"time":1783069551362,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":76,"time":1783069551492,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":77,"time":1783069551528,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":78,"time":1783069551528,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":79,"time":1783069551528,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":80,"time":1783069551528,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":81,"time":1783069551528,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" lines"}}} +{"type":"assistant/chunk","seq":82,"time":1783069551559,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} +{"type":"assistant/chunk","seq":83,"time":1783069551559,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"5"}}} +{"type":"assistant/chunk","seq":84,"time":1783069551559,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"-"}}} +{"type":"assistant/chunk","seq":85,"time":1783069551560,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"8"}}} +{"type":"assistant/chunk","seq":86,"time":1783069551560,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" of"}}} +{"type":"assistant/chunk","seq":87,"time":1783069551592,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" big"}}} +{"type":"assistant/chunk","seq":88,"time":1783069551592,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":89,"time":1783069551592,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":90,"time":1783069551592,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":91,"time":1783069551592,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":92,"time":1783069551625,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":93,"time":1783069551625,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":94,"time":1783069551625,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":95,"time":1783069551659,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":96,"time":1783069551660,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":97,"time":1783069551660,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":98,"time":1783069551660,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":99,"time":1783069551690,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'ve"}}} +{"type":"assistant/chunk","seq":100,"time":1783069551691,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" done"}}} +{"type":"assistant/chunk","seq":101,"time":1783069551691,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":102,"time":1783069551726,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":103,"time":1783069551727,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":104,"time":1783069551758,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" now"}}} +{"type":"assistant/chunk","seq":105,"time":1783069551758,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":106,"time":1783069551758,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":107,"time":1783069551790,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":108,"time":1783069551790,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":109,"time":1783069551791,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":110,"time":1783069551791,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":111,"time":1783069551825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":112,"time":1783069551825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":113,"time":1783069551825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":114,"time":1783069551825,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":115,"time":1783069551859,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":116,"time":1783069551859,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":117,"time":1783069551859,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":118,"time":1783069551860,"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 the read, now I just need to reply with \"DONE\"."}}}} +{"type":"assistant/chunk","seq":119,"time":1783069551860,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":120,"time":1783069551860,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":313,"outputTokens":43,"cacheReadTokens":2176,"reasoningTokens":40}}}} +{"type":"assistant/chunk","seq":121,"time":1783069551860,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":122,"time":1783069551860,"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 the read, now I just need to reply with \"DONE\"."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":313,"outputTokens":43,"cacheReadTokens":2176,"reasoningTokens":40}},"sourceEventSeqs":[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,111,112,113,114,115,116,117,118,119,120,121],"surfaceOp":"append"} +{"type":"step/end","seq":123,"time":1783069551860,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":124,"time":1783069551861,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl index 8b40d5fba5..f24c053d50 100644 --- a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl @@ -1,13 +1,25 @@ {"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":"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":" 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":" ("}}}} +{"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":"6"}}}} +{"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":"7"}}}} +{"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":")"}}}} {"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"}}}} @@ -17,15 +29,15 @@ {"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":"="}}}} {"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":"="}}}} {"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 (5 - 8)","kind":"read","status":"in_progress","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":"{{cwd}}/big.txt\nfile\n\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"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_pwDbbXpPbcf7LOV3jXa47203","title":"Read big.txt (5 - 8)","kind":"read","status":"in_progress","locations":[{"path":"big.txt","line":5}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_pwDbbXpPbcf7LOV3jXa47203","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/big.txt\nfile\n\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"}}]}}} {"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"}}}} @@ -52,8 +64,20 @@ {"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_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":","}}}} +{"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"}} diff --git a/examples/acp-agent/tests/snapshots/fs-read/session.jsonl b/examples/acp-agent/tests/snapshots/fs-read/session.jsonl index c8e5d936d8..2a8d2d2640 100644 --- a/examples/acp-agent/tests/snapshots/fs-read/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read/session.jsonl @@ -1,93 +1,87 @@ -{"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":"/tmp/acp-snap-cwd-WE9Cx4/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}],"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"}}} +{"type":"session","version":0,"id":"93c4732a-4106-4056-ad0b-c9d314bbac92","createdAt":1783069528172,"cwd":"/tmp/acp-snap-cwd-mYfeWU"} +{"type":"turn/start","seq":0,"time":1783069528176,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783069528176,"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":1783069528177,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1783069528725,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1783069528725,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1783069528890,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1783069528891,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1783069528891,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1783069528891,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1783069528892,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":10,"time":1783069528892,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":11,"time":1783069528892,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":12,"time":1783069528924,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" greeting"}}} +{"type":"assistant/chunk","seq":13,"time":1783069528925,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":14,"time":1783069528925,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} +{"type":"assistant/chunk","seq":15,"time":1783069528925,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":16,"time":1783069528925,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":17,"time":1783069528959,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":18,"time":1783069528959,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} +{"type":"assistant/chunk","seq":19,"time":1783069528992,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"not"}}} +{"type":"assistant/chunk","seq":20,"time":1783069528992,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":21,"time":1783069528992,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"),"}}} +{"type":"assistant/chunk","seq":22,"time":1783069528992,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":23,"time":1783069528993,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":24,"time":1783069528993,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":25,"time":1783069529025,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":26,"time":1783069529026,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":27,"time":1783069529026,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":28,"time":1783069529061,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":29,"time":1783069529062,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":30,"time":1783069529062,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":31,"time":1783069529062,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":32,"time":1783069529062,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":33,"time":1783069529163,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":34,"time":1783069529163,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_rYYE3EPp4X5jGLh9EMmj6459","name":"read","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":35,"time":1783069529195,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_rYYE3EPp4X5jGLh9EMmj6459","name":"read","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":36,"time":1783069529195,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_rYYE3EPp4X5jGLh9EMmj6459","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":37,"time":1783069529196,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_rYYE3EPp4X5jGLh9EMmj6459","name":"read","argumentsDelta":"file"}}} +{"type":"assistant/chunk","seq":38,"time":1783069529196,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_rYYE3EPp4X5jGLh9EMmj6459","name":"read","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":39,"time":1783069529196,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_rYYE3EPp4X5jGLh9EMmj6459","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":40,"time":1783069529196,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_rYYE3EPp4X5jGLh9EMmj6459","name":"read","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":41,"time":1783069529229,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_rYYE3EPp4X5jGLh9EMmj6459","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":42,"time":1783069529229,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_rYYE3EPp4X5jGLh9EMmj6459","name":"read","argumentsDelta":"gre"}}} +{"type":"assistant/chunk","seq":43,"time":1783069529229,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_rYYE3EPp4X5jGLh9EMmj6459","name":"read","argumentsDelta":"eting"}}} +{"type":"assistant/chunk","seq":44,"time":1783069529229,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_rYYE3EPp4X5jGLh9EMmj6459","name":"read","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":45,"time":1783069529263,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_rYYE3EPp4X5jGLh9EMmj6459","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":46,"time":1783069529264,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_rYYE3EPp4X5jGLh9EMmj6459","name":"read","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":47,"time":1783069529331,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to read the file greeting.txt using the read tool (not bash), then reply with exactly the single word \"DONE\"."}}}} +{"type":"assistant/chunk","seq":48,"time":1783069529331,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_rYYE3EPp4X5jGLh9EMmj6459","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}}}} +{"type":"assistant/chunk","seq":49,"time":1783069529331,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":106,"outputTokens":75,"cacheReadTokens":2176,"reasoningTokens":29}}}} +{"type":"assistant/chunk","seq":50,"time":1783069529331,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":51,"time":1783069529333,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to read the file greeting.txt using the read tool (not bash), then reply with exactly the single word \"DONE\"."},{"type":"tool-call","id":"call_00_rYYE3EPp4X5jGLh9EMmj6459","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}],"usage":{"inputTokens":106,"outputTokens":75,"cacheReadTokens":2176,"reasoningTokens":29}},"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],"surfaceOp":"append"} +{"type":"tool/call","seq":52,"time":1783069529333,"data":{"turn":1,"step":1,"callId":"call_00_rYYE3EPp4X5jGLh9EMmj6459","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}} +{"type":"tool/result","seq":53,"time":1783069529338,"data":{"turn":1,"step":1,"callId":"call_00_rYYE3EPp4X5jGLh9EMmj6459","content":[{"type":"text","text":"/tmp/acp-snap-cwd-mYfeWU/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[52],"surfaceOp":"append"} +{"type":"step/end","seq":54,"time":1783069529338,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":55,"time":1783069529338,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":56,"time":1783069530344,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":57,"time":1783069530344,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":58,"time":1783069530439,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":59,"time":1783069530472,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" contains"}}} +{"type":"assistant/chunk","seq":60,"time":1783069530473,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":61,"time":1783069530473,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"hello"}}} +{"type":"assistant/chunk","seq":62,"time":1783069530473,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":63,"time":1783069530503,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":64,"time":1783069530503,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":65,"time":1783069530533,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" needed"}}} +{"type":"assistant/chunk","seq":66,"time":1783069530565,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":67,"time":1783069530566,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":68,"time":1783069530597,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":69,"time":1783069530598,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":70,"time":1783069530598,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":71,"time":1783069530598,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":72,"time":1783069530629,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":73,"time":1783069530666,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":74,"time":1783069530667,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":75,"time":1783069530667,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":76,"time":1783069530667,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":77,"time":1783069530667,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":78,"time":1783069530667,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":79,"time":1783069530695,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file contains \"hello\". I just needed to read it and reply with \"DONE\"."}}}} +{"type":"assistant/chunk","seq":80,"time":1783069530695,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":81,"time":1783069530695,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":240,"outputTokens":22,"cacheReadTokens":2176,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":82,"time":1783069530695,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":83,"time":1783069530695,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The file contains \"hello\". I just needed to read it and reply with \"DONE\"."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":240,"outputTokens":22,"cacheReadTokens":2176,"reasoningTokens":19}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":84,"time":1783069530695,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":85,"time":1783069530696,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl index e84a07b931..30076deeeb 100644 --- a/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl @@ -7,51 +7,45 @@ {"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":" file"}}}} {"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":" ("}}}} +{"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":"),"}}}} {"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":" 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":" \""}}}} {"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","line":1}]}}} -{"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":"{{cwd}}/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_rYYE3EPp4X5jGLh9EMmj6459","title":"Read greeting.txt","kind":"read","status":"in_progress","locations":[{"path":"greeting.txt","line":1}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_rYYE3EPp4X5jGLh9EMmj6459","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}}]}}} {"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":"\"."}}}} +{"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":" needed"}}}} {"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"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-terminal-card/session.jsonl b/examples/acp-agent/tests/snapshots/fs-terminal-card/session.jsonl index 7c0652d478..4ec738951c 100644 --- a/examples/acp-agent/tests/snapshots/fs-terminal-card/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-terminal-card/session.jsonl @@ -1,97 +1,94 @@ -{"type":"session","version":0,"id":"2a35d875-5d43-4d39-a995-a378d341643d","createdAt":1783012637644,"cwd":"/tmp/acp-snap-cwd-o9lBfw"} -{"type":"turn/start","seq":0,"time":1783012637647,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783012637647,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783012637648,"data":{"turn":1,"step":1}} -{"type":"assistant/chunk","seq":3,"time":1783012638390,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":4,"time":1783012638390,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":5,"time":1783012638548,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":6,"time":1783012638578,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":7,"time":1783012638578,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":8,"time":1783012638578,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":9,"time":1783012638579,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":10,"time":1783012638579,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":11,"time":1783012638604,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} -{"type":"assistant/chunk","seq":12,"time":1783012638634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} -{"type":"assistant/chunk","seq":13,"time":1783012638635,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":14,"time":1783012638635,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} -{"type":"assistant/chunk","seq":15,"time":1783012638663,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":16,"time":1783012638663,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":17,"time":1783012638663,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":18,"time":1783012638696,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} -{"type":"assistant/chunk","seq":19,"time":1783012638697,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":20,"time":1783012638697,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":21,"time":1783012638778,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":22,"time":1783012638778,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":23,"time":1783012638778,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":24,"time":1783012638779,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":25,"time":1783012638807,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":26,"time":1783012638807,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":27,"time":1783012638807,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":28,"time":1783012638807,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":29,"time":1783012638837,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":30,"time":1783012638837,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":" TER"}}} -{"type":"assistant/chunk","seq":31,"time":1783012638838,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"MIN"}}} -{"type":"assistant/chunk","seq":32,"time":1783012638838,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"AL"}}} -{"type":"assistant/chunk","seq":33,"time":1783012638838,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"_OK"}}} -{"type":"assistant/chunk","seq":34,"time":1783012638865,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":35,"time":1783012638894,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":36,"time":1783012638894,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":37,"time":1783012638894,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":38,"time":1783012638894,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":39,"time":1783012638894,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":40,"time":1783012638921,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":41,"time":1783012638921,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"Run"}}} -{"type":"assistant/chunk","seq":42,"time":1783012638921,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":" echo"}}} -{"type":"assistant/chunk","seq":43,"time":1783012638951,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":" TER"}}} -{"type":"assistant/chunk","seq":44,"time":1783012638978,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"MIN"}}} -{"type":"assistant/chunk","seq":45,"time":1783012638978,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"AL"}}} -{"type":"assistant/chunk","seq":46,"time":1783012638978,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"_OK"}}} -{"type":"assistant/chunk","seq":47,"time":1783012638978,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":48,"time":1783012639008,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":49,"time":1783012639069,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a bash command and then reply with a single word."}}}} -{"type":"assistant/chunk","seq":50,"time":1783012639069,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Run echo TERMINAL_OK\"}"}}}} -{"type":"assistant/chunk","seq":51,"time":1783012639069,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":102,"outputTokens":85,"cacheReadTokens":2176,"reasoningTokens":17}}}} -{"type":"assistant/chunk","seq":52,"time":1783012639069,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":53,"time":1783012639071,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a bash command and then reply with a single word."},{"type":"tool-call","id":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Run echo TERMINAL_OK\"}"}],"usage":{"inputTokens":102,"outputTokens":85,"cacheReadTokens":2176,"reasoningTokens":17}},"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],"surfaceOp":"append"} -{"type":"tool/call","seq":54,"time":1783012639071,"data":{"turn":1,"step":1,"callId":"call_00_olli3mOeSioBRKRuiYlA1408","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Run echo TERMINAL_OK\"}"}} -{"type":"tool/result","seq":55,"time":1783012639084,"data":{"turn":1,"step":1,"callId":"call_00_olli3mOeSioBRKRuiYlA1408","content":[{"type":"text","text":"TERMINAL_OK\n"}],"isError":false},"sourceEventSeqs":[54],"surfaceOp":"append"} -{"type":"step/end","seq":56,"time":1783012639084,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":57,"time":1783012639085,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":58,"time":1783012639687,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":59,"time":1783012639687,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":60,"time":1783012639763,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} -{"type":"assistant/chunk","seq":61,"time":1783012639791,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ran"}}} -{"type":"assistant/chunk","seq":62,"time":1783012639821,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} -{"type":"assistant/chunk","seq":63,"time":1783012639821,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":64,"time":1783012639821,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} -{"type":"assistant/chunk","seq":65,"time":1783012639848,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":66,"time":1783012639849,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"TER"}}} -{"type":"assistant/chunk","seq":67,"time":1783012639849,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"MIN"}}} -{"type":"assistant/chunk","seq":68,"time":1783012639849,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} -{"type":"assistant/chunk","seq":69,"time":1783012639849,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} -{"type":"assistant/chunk","seq":70,"time":1783012639849,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":71,"time":1783012639877,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} -{"type":"assistant/chunk","seq":72,"time":1783012639877,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":73,"time":1783012639877,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} -{"type":"assistant/chunk","seq":74,"time":1783012639905,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":75,"time":1783012639905,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":76,"time":1783012639905,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":77,"time":1783012639906,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":78,"time":1783012639906,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":79,"time":1783012639933,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} -{"type":"assistant/chunk","seq":80,"time":1783012639934,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":81,"time":1783012639934,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} -{"type":"assistant/chunk","seq":82,"time":1783012639963,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":83,"time":1783012639963,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":84,"time":1783012639963,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} -{"type":"assistant/chunk","seq":85,"time":1783012639963,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":86,"time":1783012639963,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":87,"time":1783012639963,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":88,"time":1783012639991,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":89,"time":1783012639992,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command ran successfully and output \"TERMINAL_OK\". The user asked me to reply with the single word DONE and stop."}}}} -{"type":"assistant/chunk","seq":90,"time":1783012639992,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":91,"time":1783012639992,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":204,"outputTokens":30,"cacheReadTokens":2176,"reasoningTokens":27}}}} -{"type":"assistant/chunk","seq":92,"time":1783012639992,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":93,"time":1783012639992,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command ran successfully and output \"TERMINAL_OK\". The user asked me to reply with the single word DONE and stop."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":204,"outputTokens":30,"cacheReadTokens":2176,"reasoningTokens":27}},"sourceEventSeqs":[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],"surfaceOp":"append"} -{"type":"step/end","seq":94,"time":1783012639992,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":95,"time":1783012639993,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"60f37569-c7d6-4275-9105-7b67fd4c8586","createdAt":1783069523946,"cwd":"/tmp/acp-snap-cwd-tMMuJX"} +{"type":"turn/start","seq":0,"time":1783069523951,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783069523951,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783069523952,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1783069524432,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1783069524432,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1783069524538,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1783069524574,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1783069524574,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1783069524574,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1783069524574,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":10,"time":1783069524575,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":11,"time":1783069524608,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":12,"time":1783069524642,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":13,"time":1783069524642,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":14,"time":1783069524642,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":15,"time":1783069524677,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":16,"time":1783069524677,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":17,"time":1783069524677,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":18,"time":1783069524677,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":19,"time":1783069524677,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":20,"time":1783069524678,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":21,"time":1783069524780,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":22,"time":1783069524780,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":23,"time":1783069524819,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":24,"time":1783069524819,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":25,"time":1783069524819,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":26,"time":1783069524819,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":27,"time":1783069524819,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":28,"time":1783069524853,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":29,"time":1783069524853,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":30,"time":1783069524853,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":" TER"}}} +{"type":"assistant/chunk","seq":31,"time":1783069524853,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"MIN"}}} +{"type":"assistant/chunk","seq":32,"time":1783069524887,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"AL"}}} +{"type":"assistant/chunk","seq":33,"time":1783069524888,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"_OK"}}} +{"type":"assistant/chunk","seq":34,"time":1783069524888,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":35,"time":1783069524922,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":36,"time":1783069524922,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":37,"time":1783069524922,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":38,"time":1783069524959,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":39,"time":1783069524959,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":40,"time":1783069524959,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":41,"time":1783069524960,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"E"}}} +{"type":"assistant/chunk","seq":42,"time":1783069524991,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"cho"}}} +{"type":"assistant/chunk","seq":43,"time":1783069524992,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":" TER"}}} +{"type":"assistant/chunk","seq":44,"time":1783069524992,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"MIN"}}} +{"type":"assistant/chunk","seq":45,"time":1783069524992,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"AL"}}} +{"type":"assistant/chunk","seq":46,"time":1783069524992,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"_OK"}}} +{"type":"assistant/chunk","seq":47,"time":1783069524992,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":" to"}}} +{"type":"assistant/chunk","seq":48,"time":1783069525027,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":" verify"}}} +{"type":"assistant/chunk","seq":49,"time":1783069525027,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":" terminal"}}} +{"type":"assistant/chunk","seq":50,"time":1783069525027,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":51,"time":1783069525064,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":52,"time":1783069525099,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a bash command and then reply with \"DONE\"."}}}} +{"type":"assistant/chunk","seq":53,"time":1783069525099,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal\"}"}}}} +{"type":"assistant/chunk","seq":54,"time":1783069525099,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2278,"outputTokens":88,"cacheReadTokens":0,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":55,"time":1783069525099,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":56,"time":1783069525101,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a bash command and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal\"}"}],"usage":{"inputTokens":2278,"outputTokens":88,"cacheReadTokens":0,"reasoningTokens":17}},"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],"surfaceOp":"append"} +{"type":"tool/call","seq":57,"time":1783069525101,"data":{"turn":1,"step":1,"callId":"call_00_sNkk1FV281UaJqU09vE20707","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal\"}"}} +{"type":"tool/result","seq":58,"time":1783069525115,"data":{"turn":1,"step":1,"callId":"call_00_sNkk1FV281UaJqU09vE20707","content":[{"type":"text","text":"TERMINAL_OK\n"}],"isError":false},"sourceEventSeqs":[57],"surfaceOp":"append"} +{"type":"step/end","seq":59,"time":1783069525116,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":60,"time":1783069525116,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":61,"time":1783069525842,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":62,"time":1783069525842,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":63,"time":1783069525957,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":64,"time":1783069525992,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ran"}}} +{"type":"assistant/chunk","seq":65,"time":1783069526025,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} +{"type":"assistant/chunk","seq":66,"time":1783069526025,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":67,"time":1783069526026,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":68,"time":1783069526063,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":69,"time":1783069526064,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"TER"}}} +{"type":"assistant/chunk","seq":70,"time":1783069526064,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"MIN"}}} +{"type":"assistant/chunk","seq":71,"time":1783069526064,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":72,"time":1783069526064,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":73,"time":1783069526064,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":74,"time":1783069526096,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":75,"time":1783069526097,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} +{"type":"assistant/chunk","seq":76,"time":1783069526133,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" now"}}} +{"type":"assistant/chunk","seq":77,"time":1783069526133,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":78,"time":1783069526134,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":79,"time":1783069526134,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":80,"time":1783069526134,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":81,"time":1783069526169,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":82,"time":1783069526169,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":83,"time":1783069526169,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":84,"time":1783069526169,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":85,"time":1783069526170,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":86,"time":1783069526170,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command ran successfully and output \"TERMINAL_OK\". I should now reply with \"DONE\"."}}}} +{"type":"assistant/chunk","seq":87,"time":1783069526170,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":88,"time":1783069526170,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":207,"outputTokens":24,"cacheReadTokens":2176,"reasoningTokens":21}}}} +{"type":"assistant/chunk","seq":89,"time":1783069526170,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":90,"time":1783069526170,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command ran successfully and output \"TERMINAL_OK\". I should now reply with \"DONE\"."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":207,"outputTokens":24,"cacheReadTokens":2176,"reasoningTokens":21}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":91,"time":1783069526171,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":92,"time":1783069526171,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl index 6bbff6b55c..1411855f29 100644 --- a/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl @@ -13,12 +13,12 @@ {"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":" a"}}}} -{"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":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_olli3mOeSioBRKRuiYlA1408","title":"echo TERMINAL_OK","kind":"execute","status":"in_progress","rawInput":"echo TERMINAL_OK","content":[{"type":"content","content":{"type":"text","text":"Run echo TERMINAL_OK"}},{"type":"terminal","terminalId":"call_00_olli3mOeSioBRKRuiYlA1408"}],"_meta":{"terminal_info":{"terminal_id":"call_00_olli3mOeSioBRKRuiYlA1408","cwd":"{{cwd}}"}}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_olli3mOeSioBRKRuiYlA1408","status":"completed","_meta":{"terminal_output":{"terminal_id":"call_00_olli3mOeSioBRKRuiYlA1408","data":"TERMINAL_OK\n"},"terminal_exit":{"terminal_id":"call_00_olli3mOeSioBRKRuiYlA1408","exit_code":0}}}}} +{"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_sNkk1FV281UaJqU09vE20707","title":"echo TERMINAL_OK","kind":"execute","status":"in_progress","rawInput":"echo TERMINAL_OK","content":[{"type":"content","content":{"type":"text","text":"Echo TERMINAL_OK to verify terminal"}},{"type":"terminal","terminalId":"call_00_sNkk1FV281UaJqU09vE20707"}],"_meta":{"terminal_info":{"terminal_id":"call_00_sNkk1FV281UaJqU09vE20707","cwd":"{{cwd}}"}}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_sNkk1FV281UaJqU09vE20707","status":"completed","_meta":{"terminal_output":{"terminal_id":"call_00_sNkk1FV281UaJqU09vE20707","data":"TERMINAL_OK\n"},"terminal_exit":{"terminal_id":"call_00_sNkk1FV281UaJqU09vE20707","exit_code":0}}}}} {"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":" command"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ran"}}}} @@ -31,21 +31,15 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"AL"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_OK"}}}} {"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":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" should"}}}} +{"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":" 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":" 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":" \""}}}} +{"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":" and"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} -{"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":"\"."}}}} {"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"}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl index ac97558243..591a8ec940 100644 --- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl @@ -1,132 +1,145 @@ -{"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":"/tmp/acp-snap-cwd-X0UUW6/data.txt\nfile\n\n1: original contents\n\n(End of file - total 1 lines)\n"}],"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":"/tmp/acp-snap-cwd-X0UUW6/data.txt\nfile\n\nUpdated file\n"}],"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"}}} +{"type":"session","version":0,"id":"9209a848-ea39-4f7f-b0ec-a59495c7da4b","createdAt":1783069543123,"cwd":"/tmp/acp-snap-cwd-MA4o8Q"} +{"type":"turn/start","seq":0,"time":1783069543128,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783069543128,"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":1783069543129,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1783069543667,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1783069543667,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1783069543763,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1783069543821,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1783069543822,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1783069543822,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1783069543822,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":\n"}}} +{"type":"assistant/chunk","seq":10,"time":1783069543822,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} +{"type":"assistant/chunk","seq":11,"time":1783069543823,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":12,"time":1783069543831,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Read"}}} +{"type":"assistant/chunk","seq":13,"time":1783069543831,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" data"}}} +{"type":"assistant/chunk","seq":14,"time":1783069543832,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":15,"time":1783069543832,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} +{"type":"assistant/chunk","seq":16,"time":1783069543879,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":17,"time":1783069543879,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" current"}}} +{"type":"assistant/chunk","seq":18,"time":1783069543879,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" directory"}}} +{"type":"assistant/chunk","seq":19,"time":1783069543879,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\n"}}} +{"type":"assistant/chunk","seq":20,"time":1783069543880,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}} +{"type":"assistant/chunk","seq":21,"time":1783069543880,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":22,"time":1783069543903,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Replace"}}} +{"type":"assistant/chunk","seq":23,"time":1783069543933,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" its"}}} +{"type":"assistant/chunk","seq":24,"time":1783069543934,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" entire"}}} +{"type":"assistant/chunk","seq":25,"time":1783069543934,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" contents"}}} +{"type":"assistant/chunk","seq":26,"time":1783069543968,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":27,"time":1783069543968,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":28,"time":1783069544008,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":29,"time":1783069544008,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"re"}}} +{"type":"assistant/chunk","seq":30,"time":1783069544008,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"placed"}}} +{"type":"assistant/chunk","seq":31,"time":1783069544008,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"\n"}}} +{"type":"assistant/chunk","seq":32,"time":1783069544008,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"3"}}} +{"type":"assistant/chunk","seq":33,"time":1783069544009,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":34,"time":1783069544041,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Reply"}}} +{"type":"assistant/chunk","seq":35,"time":1783069544042,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":36,"time":1783069544042,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":37,"time":1783069544042,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":38,"time":1783069544076,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":39,"time":1783069544077,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":40,"time":1783069544077,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"\n\n"}}} +{"type":"assistant/chunk","seq":41,"time":1783069544077,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} +{"type":"assistant/chunk","seq":42,"time":1783069544077,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":43,"time":1783069544077,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" start"}}} +{"type":"assistant/chunk","seq":44,"time":1783069544111,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} +{"type":"assistant/chunk","seq":45,"time":1783069544111,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reading"}}} +{"type":"assistant/chunk","seq":46,"time":1783069544112,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":47,"time":1783069544146,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":48,"time":1783069544146,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":49,"time":1783069544215,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":50,"time":1783069544215,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oyZxEdXevIb3TvYUZWJa3291","name":"read","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":51,"time":1783069544249,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oyZxEdXevIb3TvYUZWJa3291","name":"read","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":52,"time":1783069544250,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oyZxEdXevIb3TvYUZWJa3291","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":53,"time":1783069544250,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oyZxEdXevIb3TvYUZWJa3291","name":"read","argumentsDelta":"file"}}} +{"type":"assistant/chunk","seq":54,"time":1783069544284,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oyZxEdXevIb3TvYUZWJa3291","name":"read","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":55,"time":1783069544284,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oyZxEdXevIb3TvYUZWJa3291","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":56,"time":1783069544284,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oyZxEdXevIb3TvYUZWJa3291","name":"read","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":57,"time":1783069544284,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oyZxEdXevIb3TvYUZWJa3291","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":58,"time":1783069544319,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oyZxEdXevIb3TvYUZWJa3291","name":"read","argumentsDelta":"data"}}} +{"type":"assistant/chunk","seq":59,"time":1783069544319,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oyZxEdXevIb3TvYUZWJa3291","name":"read","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":60,"time":1783069544319,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oyZxEdXevIb3TvYUZWJa3291","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":61,"time":1783069544353,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oyZxEdXevIb3TvYUZWJa3291","name":"read","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":62,"time":1783069544390,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to:\n1. Read data.txt in the current directory\n2. Replace its entire contents with exactly \"replaced\"\n3. Reply with exactly \"DONE\"\n\nLet me start by reading the file."}}}} +{"type":"assistant/chunk","seq":63,"time":1783069544390,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_oyZxEdXevIb3TvYUZWJa3291","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}}}} +{"type":"assistant/chunk","seq":64,"time":1783069544390,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":123,"outputTokens":90,"cacheReadTokens":2176,"reasoningTokens":45}}}} +{"type":"assistant/chunk","seq":65,"time":1783069544390,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":66,"time":1783069544392,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to:\n1. Read data.txt in the current directory\n2. Replace its entire contents with exactly \"replaced\"\n3. Reply with exactly \"DONE\"\n\nLet me start by reading the file."},{"type":"tool-call","id":"call_00_oyZxEdXevIb3TvYUZWJa3291","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}],"usage":{"inputTokens":123,"outputTokens":90,"cacheReadTokens":2176,"reasoningTokens":45}},"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,61,62,63,64,65],"surfaceOp":"append"} +{"type":"tool/call","seq":67,"time":1783069544393,"data":{"turn":1,"step":1,"callId":"call_00_oyZxEdXevIb3TvYUZWJa3291","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}} +{"type":"tool/result","seq":68,"time":1783069544397,"data":{"turn":1,"step":1,"callId":"call_00_oyZxEdXevIb3TvYUZWJa3291","content":[{"type":"text","text":"/tmp/acp-snap-cwd-MA4o8Q/data.txt\nfile\n\n1: original contents\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[67],"surfaceOp":"append"} +{"type":"step/end","seq":69,"time":1783069544398,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":70,"time":1783069544398,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":71,"time":1783069545829,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":72,"time":1783069545829,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Now"}}} +{"type":"assistant/chunk","seq":73,"time":1783069545946,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":74,"time":1783069545980,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":75,"time":1783069545980,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":76,"time":1783069545980,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" replace"}}} +{"type":"assistant/chunk","seq":77,"time":1783069545981,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":78,"time":1783069545981,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" entire"}}} +{"type":"assistant/chunk","seq":79,"time":1783069546016,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" contents"}}} +{"type":"assistant/chunk","seq":80,"time":1783069546016,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":81,"time":1783069546016,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":82,"time":1783069546048,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":83,"time":1783069546049,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"re"}}} +{"type":"assistant/chunk","seq":84,"time":1783069546049,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"placed"}}} +{"type":"assistant/chunk","seq":85,"time":1783069546049,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":86,"time":1783069546082,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} +{"type":"assistant/chunk","seq":87,"time":1783069546082,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":88,"time":1783069546082,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} +{"type":"assistant/chunk","seq":89,"time":1783069546082,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":90,"time":1783069546082,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":91,"time":1783069546183,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":92,"time":1783069546183,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":93,"time":1783069546217,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":94,"time":1783069546218,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":95,"time":1783069546218,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":"file"}}} +{"type":"assistant/chunk","seq":96,"time":1783069546218,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":97,"time":1783069546218,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":98,"time":1783069546218,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":99,"time":1783069546251,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":100,"time":1783069546251,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":"data"}}} +{"type":"assistant/chunk","seq":101,"time":1783069546251,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":102,"time":1783069546284,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":103,"time":1783069546319,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":104,"time":1783069546320,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":105,"time":1783069546320,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":"content"}}} +{"type":"assistant/chunk","seq":106,"time":1783069546320,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":107,"time":1783069546320,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":108,"time":1783069546356,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":109,"time":1783069546356,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":"re"}}} +{"type":"assistant/chunk","seq":110,"time":1783069546357,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":"placed"}}} +{"type":"assistant/chunk","seq":111,"time":1783069546357,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":112,"time":1783069546389,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":113,"time":1783069546461,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Now I need to replace the entire contents with exactly \"replaced\" using the write tool."}}}} +{"type":"assistant/chunk","seq":114,"time":1783069546461,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}}}} +{"type":"assistant/chunk","seq":115,"time":1783069546461,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":272,"outputTokens":81,"cacheReadTokens":2176,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":116,"time":1783069546461,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":117,"time":1783069546461,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Now I need to replace the entire contents with exactly \"replaced\" using the write tool."},{"type":"tool-call","id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}],"usage":{"inputTokens":272,"outputTokens":81,"cacheReadTokens":2176,"reasoningTokens":19}},"sourceEventSeqs":[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,111,112,113,114,115,116],"surfaceOp":"append"} +{"type":"tool/call","seq":118,"time":1783069546461,"data":{"turn":1,"step":2,"callId":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}} +{"type":"tool/result","seq":119,"time":1783069546467,"data":{"turn":1,"step":2,"callId":"call_00_PPjJDvfhXspNG79WMy3b4358","content":[{"type":"text","text":"/tmp/acp-snap-cwd-MA4o8Q/data.txt\nfile\n\nUpdated file\n"}],"isError":false,"meta":{"diffs":[{"path":"data.txt","oldText":"original contents","newText":"replaced"}]}},"sourceEventSeqs":[118],"surfaceOp":"append"} +{"type":"step/end","seq":120,"time":1783069546467,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":121,"time":1783069546468,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":122,"time":1783069546848,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":123,"time":1783069546848,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"Done"}}} +{"type":"assistant/chunk","seq":124,"time":1783069546981,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":125,"time":1783069547014,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":126,"time":1783069547015,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":127,"time":1783069547015,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":128,"time":1783069547047,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":129,"time":1783069547047,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":130,"time":1783069547047,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":131,"time":1783069547047,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":132,"time":1783069547082,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":133,"time":1783069547082,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":134,"time":1783069547082,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":135,"time":1783069547082,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":136,"time":1783069547082,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":137,"time":1783069547082,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Done. Now I reply with exactly \"DONE\"."}}}} +{"type":"assistant/chunk","seq":138,"time":1783069547082,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":139,"time":1783069547082,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":144,"outputTokens":14,"cacheReadTokens":2432,"reasoningTokens":11}}}} +{"type":"assistant/chunk","seq":140,"time":1783069547082,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":141,"time":1783069547083,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"Done. Now I reply with exactly \"DONE\"."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":144,"outputTokens":14,"cacheReadTokens":2432,"reasoningTokens":11}},"sourceEventSeqs":[122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140],"surfaceOp":"append"} +{"type":"step/end","seq":142,"time":1783069547083,"data":{"turn":1,"step":3}} +{"type":"turn/end","seq":143,"time":1783069547083,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl index b595a90083..85b3597165 100644 --- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl @@ -5,31 +5,48 @@ {"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":":\n"}}}} +{"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":" 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":" in"}}}} +{"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":" current"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" directory"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"2"}}}} +{"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":" 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":" 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":"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":"\"\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"3"}}}} +{"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":" 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_GtqlR9riew6wgdQzLbbu6019","title":"Read data.txt","kind":"read","status":"in_progress","locations":[{"path":"data.txt","line":1}]}}} -{"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":"{{cwd}}/data.txt\nfile\n\n1: original contents\n\n(End of file - total 1 lines)\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"\n\n"}}}} +{"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":" file"}}}} +{"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_oyZxEdXevIb3TvYUZWJa3291","title":"Read data.txt","kind":"read","status":"in_progress","locations":[{"path":"data.txt","line":1}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_oyZxEdXevIb3TvYUZWJa3291","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/data.txt\nfile\n\n1: original contents\n\n(End of file - total 1 lines)\n"}}]}}} {"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"}}}} @@ -38,34 +55,30 @@ {"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"}],"content":[{"type":"diff","path":"data.txt","oldText":null,"newText":"replaced"}]}}} -{"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":"{{cwd}}/data.txt\nfile\n\nUpdated file\n"}}]}}} +{"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":"tool_call","toolCallId":"call_00_PPjJDvfhXspNG79WMy3b4358","title":"Write data.txt","kind":"edit","status":"in_progress","locations":[{"path":"data.txt"}],"content":[{"type":"diff","path":"data.txt","oldText":null,"newText":"replaced"}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_PPjJDvfhXspNG79WMy3b4358","status":"completed","content":[{"type":"diff","path":"data.txt","oldText":"original contents","newText":"replaced"}],"title":"Write data.txt"}}} {"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":" 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":" 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":" \""}}}} +{"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":"\"."}}}} {"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"}} diff --git a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl index 533ec83084..b100593c02 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl @@ -1,95 +1,94 @@ -{"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":"/tmp/acp-snap-cwd-v8qbp7/notes.txt\nfile\n\nCreated file\n"}],"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"}}} +{"type":"session","version":0,"id":"def3ba4c-1443-4c75-89ee-3436287cb97b","createdAt":1783069532897,"cwd":"/tmp/acp-snap-cwd-FOCYwl"} +{"type":"turn/start","seq":0,"time":1783069532903,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783069532903,"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":1783069532904,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1783069533351,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1783069533351,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1783069533461,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1783069533495,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1783069533495,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1783069533495,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1783069533496,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" create"}}} +{"type":"assistant/chunk","seq":10,"time":1783069533496,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":11,"time":1783069533496,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":12,"time":1783069533526,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" named"}}} +{"type":"assistant/chunk","seq":13,"time":1783069533559,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" notes"}}} +{"type":"assistant/chunk","seq":14,"time":1783069533560,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":15,"time":1783069533560,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":16,"time":1783069533592,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":17,"time":1783069533619,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" content"}}} +{"type":"assistant/chunk","seq":18,"time":1783069533620,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":19,"time":1783069533620,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"hello"}}} +{"type":"assistant/chunk","seq":20,"time":1783069533620,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" world"}}} +{"type":"assistant/chunk","seq":21,"time":1783069533620,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":22,"time":1783069533650,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} +{"type":"assistant/chunk","seq":23,"time":1783069533650,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":24,"time":1783069533650,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} +{"type":"assistant/chunk","seq":25,"time":1783069533650,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":26,"time":1783069533651,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":27,"time":1783069533651,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":28,"time":1783069533678,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":29,"time":1783069533678,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":30,"time":1783069533711,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":31,"time":1783069533711,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":32,"time":1783069533712,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":33,"time":1783069533712,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":34,"time":1783069533741,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":35,"time":1783069533741,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":36,"time":1783069533741,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":37,"time":1783069533837,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":38,"time":1783069533837,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":39,"time":1783069533837,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":40,"time":1783069533837,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":41,"time":1783069533884,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":"file"}}} +{"type":"assistant/chunk","seq":42,"time":1783069533885,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":43,"time":1783069533885,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":44,"time":1783069533885,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":45,"time":1783069533908,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":46,"time":1783069533909,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":"notes"}}} +{"type":"assistant/chunk","seq":47,"time":1783069533909,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":48,"time":1783069533909,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":49,"time":1783069533951,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":50,"time":1783069533951,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":51,"time":1783069533979,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":"content"}}} +{"type":"assistant/chunk","seq":52,"time":1783069533980,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":53,"time":1783069533980,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":54,"time":1783069533980,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":55,"time":1783069534004,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":"hello"}}} +{"type":"assistant/chunk","seq":56,"time":1783069534004,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":" world"}}} +{"type":"assistant/chunk","seq":57,"time":1783069534005,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":58,"time":1783069534039,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":59,"time":1783069534073,"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, then reply with exactly the word \"DONE\"."}}}} +{"type":"assistant/chunk","seq":60,"time":1783069534073,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}}}} +{"type":"assistant/chunk","seq":61,"time":1783069534073,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":115,"outputTokens":95,"cacheReadTokens":2176,"reasoningTokens":33}}}} +{"type":"assistant/chunk","seq":62,"time":1783069534073,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":63,"time":1783069534076,"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, then reply with exactly the word \"DONE\"."},{"type":"tool-call","id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}],"usage":{"inputTokens":115,"outputTokens":95,"cacheReadTokens":2176,"reasoningTokens":33}},"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,61,62],"surfaceOp":"append"} +{"type":"tool/call","seq":64,"time":1783069534076,"data":{"turn":1,"step":1,"callId":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}} +{"type":"tool/result","seq":65,"time":1783069534084,"data":{"turn":1,"step":1,"callId":"call_00_ICLusq2lV6YYBtn1szVM9454","content":[{"type":"text","text":"/tmp/acp-snap-cwd-FOCYwl/notes.txt\nfile\n\nCreated file\n"}],"isError":false},"sourceEventSeqs":[64],"surfaceOp":"append"} +{"type":"step/end","seq":66,"time":1783069534084,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":67,"time":1783069534084,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":68,"time":1783069535137,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":69,"time":1783069535137,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"File"}}} +{"type":"assistant/chunk","seq":70,"time":1783069535256,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" created"}}} +{"type":"assistant/chunk","seq":71,"time":1783069535289,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} +{"type":"assistant/chunk","seq":72,"time":1783069535289,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":73,"time":1783069535289,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":74,"time":1783069535326,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":75,"time":1783069535326,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} +{"type":"assistant/chunk","seq":76,"time":1783069535326,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":77,"time":1783069535326,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":78,"time":1783069535359,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":79,"time":1783069535360,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":80,"time":1783069535360,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":81,"time":1783069535360,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":82,"time":1783069535360,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":83,"time":1783069535399,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":84,"time":1783069535399,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":85,"time":1783069535399,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":86,"time":1783069535399,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"File created successfully. Now I should reply with exactly \"DONE\"."}}}} +{"type":"assistant/chunk","seq":87,"time":1783069535399,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":88,"time":1783069535400,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":256,"outputTokens":17,"cacheReadTokens":2176,"reasoningTokens":14}}}} +{"type":"assistant/chunk","seq":89,"time":1783069535400,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":90,"time":1783069535400,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"File created successfully. Now I should reply with exactly \"DONE\"."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":256,"outputTokens":17,"cacheReadTokens":2176,"reasoningTokens":14}},"sourceEventSeqs":[68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89],"surfaceOp":"append"} +{"type":"step/end","seq":91,"time":1783069535400,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":92,"time":1783069535400,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl index eac3a6ea99..5b4d170e18 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl @@ -23,29 +23,28 @@ {"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":" 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":" word"}}}} {"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"}],"content":[{"type":"diff","path":"notes.txt","oldText":null,"newText":"hello world"}]}}} -{"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":"{{cwd}}/notes.txt\nfile\n\nCreated file\n"}}]}}} -{"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":"tool_call","toolCallId":"call_00_ICLusq2lV6YYBtn1szVM9454","title":"Write notes.txt","kind":"edit","status":"in_progress","locations":[{"path":"notes.txt"}],"content":[{"type":"diff","path":"notes.txt","oldText":null,"newText":"hello world"}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_ICLusq2lV6YYBtn1szVM9454","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/notes.txt\nfile\n\nCreated file\n"}}]}}} +{"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":" 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":" should"}}}} {"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"}}}} diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 200115af9a..4ef1467222 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -724,6 +724,9 @@ async function runStep( content: result.content, isError: result.isError, ...result.error ? { error: result.error } : {}, + // The tool's private presentation payload (e.g. a result-time diff), + // persisted so a UI bridge reproduces the card on replay. + ...result.meta !== undefined ? { meta: result.meta } : {}, }, { surfaceOp: 'append', sourceEventSeqs: [callEvent.seq] }) // signal CAN flip during the await above (abort() inside a tool); // the analyzer can't see through the await boundary. diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index aa565f15b5..2120d1f3eb 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -110,6 +110,32 @@ describe('agent loop', () => { expect(types).toContain('tool/result') }) + it('threads a tool-attached meta (execute object return) onto the tool/result event', async () => { + const adapter = new MockAdapter([ + toolCallResponse('c1', 'writer', { path: 'a.txt' }, 'writing'), + textResponse('done'), + ]) + const ctx = await harness(adapter) + // A tool that returns the { content, meta } object form: the loop must + // persist `meta` on the tool/result event so a UI reproduces the card on replay. + ctx.tools.register(defineTool({ + name: 'writer', + description: 'writes a file', + parameters: { path: { type: 'string' } }, + async execute() { + return { content: [{ type: 'text', text: 'ok' }], meta: { diffs: [{ path: 'a.txt', oldText: null, newText: 'x' }] } } + }, + })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + + send(agent, 'use the tool') + await waitForIdle(ctx, agent) + + const toolResult = agent.session.events.find(e => e.type === 'tool/result') + expect(toolResult?.type === 'tool/result' && toolResult.data.meta) + .toEqual({ diffs: [{ path: 'a.txt', oldText: null, newText: 'x' }] }) + }) + it('passes assembled system prompt and tool schemas into the request', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index cef5e93e91..fee21664ff 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -16,6 +16,7 @@ import { SurfaceManager, isSurfaceEligibleType } from './surface.ts' export * from './types.ts' export { isJsonValue } from './json.ts' +export type { JsonValue } from './json.ts' export { interruptedTurnClosers } from './repair.ts' export type { SurfaceNode } from './surface.ts' export { isSurfaceEvent, isSurfaceEligibleType } from './surface.ts' diff --git a/packages/core/session/src/json.ts b/packages/core/session/src/json.ts index 6b830afdff..47197b7b90 100644 --- a/packages/core/session/src/json.ts +++ b/packages/core/session/src/json.ts @@ -13,6 +13,16 @@ * @module @deepseek-ai/dsh-session/json */ +/** + * A value that round-trips losslessly through JSON: `null`, a boolean, a finite + * number, a string, an array of such values, or a plain object whose values are + * such values. The static type companion to {@link isJsonValue} (which validates + * the same shape at runtime). Use it to type a payload that must survive + * session-log persistence and replay byte-identically — e.g. a tool's private + * presentation `meta`. + */ +export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } + /** * Whether `value` is losslessly JSON-serializable: only `null`, finite numbers, * booleans, strings, plain arrays, and plain objects of such values. Rejects diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 6ed8c4391e..6a9eaa0fc1 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -1,5 +1,6 @@ import type { Branded } from '@deepseek-ai/dsh-brand' import type { CallId, ContentBlock, MessageSource, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm' +import type { JsonValue } from './json.ts' /** Identifies one session in the store (and its persistence artifacts). */ export type SessionId = Branded<'SessionId'> @@ -210,7 +211,16 @@ export interface SessionEventMap { */ 'assistant/message': { turn: number; step: number; content: ContentBlock[]; usage?: TokenUsage } 'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string } - 'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string } } + /** + * A completed tool call's model-facing result, plus an optional tool-private + * `meta` presentation payload. `meta` is opaque to the core — the producing + * tool owns its shape and reads it back in `presentResult` — and is a + * {@link JsonValue} so it persists in the durable log and reproduces on replay + * (a UI bridge renders the identical card from a loaded session). Absent unless + * the tool attaches one (e.g. `dsh-tool-fs` carries its result-time contextual + * diff here). + */ + 'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: JsonValue } /** Steering content injected between steps of a running turn. */ 'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource } /** diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index a01881de5b..f6694269ea 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -24,7 +24,7 @@ Tool registry and execution waterfall. Tool plugins register their schemas and e ### Key types -- `ToolDefinition` — `ToolSchema` + `execute(args, exec): Promise`, plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below). +- `ToolDefinition` — `ToolSchema` + `execute(args, exec): Promise` (the bare array is the model-facing content; the object form additionally attaches an opaque, JSON-serializable `meta` presentation payload persisted on the `tool/result` event and handed back to `presentResult`), plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below). - `ToolExecution` — one pending tool call: `{ callId, name, arguments, agent?, signal? }`. - `ToolExecutionResult` — outcome: `{ callId, content, isError, error? }`. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text (the loop forwards it onto the `tool/result` session event for retry/sandbox plugins and replay). - `ToolCallView` / `ToolResultView` — provider-neutral `card`-tagged render intents a tool returns from `presentCall` / `presentResult` to own how a UI renders ITS calls (see "Tool-owned UI presentation"). @@ -76,11 +76,12 @@ A tool owns how ITS calls render in a UI (an editor's tool-call card, a CLI log - `{ card: 'generic', title, kind?, rawInput?, content?, locations? }` — the default card: a human-readable `title`, 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 background task id, NOT the whole args object), optional `content` (extra UI content blocks), and optional `locations` (`{ path, line? }[]` — files this call reads/modifies, so a capable UI can follow along; the ACP bridge forwards them as `tool_call.locations`). - `{ card: 'terminal', title, description?, cwd? }` — a shell command: a capable UI renders a terminal card (the `title` is the command, `description` renders above it, `cwd` heads it); an incapable UI falls back to a generic execute card. - `{ card: 'diff', title, diffs, locations? }` — a file create/modify: a capable UI renders an inline diff card from `diffs` (`{ path, oldText, newText }[]`; `oldText: null` for a new file). Used by `write`/`edit`. -- `presentResult(args, result): ToolResultView | undefined` — the COMPLETED state, given the same `args` and the `{ content, isError }` result, one of: +- `presentResult(args, result): ToolResultView | undefined` — the COMPLETED state, given the same `args` and the `{ content, isError, meta? }` result, one of: - `{ card: 'generic', title?, content? }` — an optional replacement `title` and reformatted `content`. - `{ card: 'terminal', title?, output?, exitCode?, signal? }` — a terminal run's captured `output` and exit status. A capable UI shows an exit-status pill; an incapable UI gets a fenced ` ```console ` fallback the BRIDGE derives from `output` (the tool does not encode the fences). + - `{ card: 'diff', title?, diffs }` — a completed file mutation as an inline diff. `diffs` is `FileDiff[]` — the APPLIED hunks with surrounding context (one entry per changed site), computed from the before/after file content, distinct from the call-time whole-snippet `diff`. Used by `write`/`edit`; a `tool_call_update.content` replaces the call's content, so this supersedes the pending snippet. -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` 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 views are provider-neutral — the ACP bridge (`dsh-acp`) maps each `card` to ACP `tool_call`/`tool_call_update` wire fields (a `diff` card to a `{ type: 'diff' }` content block, a `terminal` card to the `_meta` terminal convention), and relativizes a file card's title against the session cwd. See the render-intent-union RFC (`docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md`); `dsh-tool-bash` (terminal) and `dsh-tool-fs` (diff/generic) are the reference implementations. +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. `result.meta` is the tool's own optional presentation payload (`JsonValue`), attached by `execute` (see below) and persisted on the `tool/result` event, so a `presentResult` reading it stays replay-deterministic (the same `meta` is read back from the log). With `defineTool`, `args` is the typed `InferArgs` 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 views are provider-neutral — the ACP bridge (`dsh-acp`) maps each `card` to ACP `tool_call`/`tool_call_update` wire fields (a `diff` card to a `{ type: 'diff' }` content block, a `terminal` card to the `_meta` terminal convention), and relativizes a file card's title against the session cwd. See the render-intent-union RFC (`docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md`) and the applied-hunk-diffs RFC (`docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md`); `dsh-tool-bash` (terminal) and `dsh-tool-fs` (diff/generic) are the reference implementations. ```ts import { defineTool } from '@deepseek-ai/dsh-tools' diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json index a6d3bbe0ca..05394ea118 100644 --- a/packages/core/tools/package.json +++ b/packages/core/tools/package.json @@ -24,12 +24,14 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "cordis": "^4.0.0-rc.6" } diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 594e5761cb..0aa19a7b9a 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -11,6 +11,7 @@ import { Context, Service } from 'cordis' import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' +import type { JsonValue } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' export { @@ -87,6 +88,13 @@ export interface FileDiff { oldText: string | null /** Content after the change. */ newText: string + /** + * Index signature so a `FileDiff` is a valid {@link JsonValue} member — a tool + * persists result-time diffs as `tool/result` `meta`, which must round-trip + * through the session log. Every declared field is already JSON-compatible; + * this only makes the structural compatibility explicit. + */ + [key: string]: string | null } /** @@ -159,7 +167,8 @@ export interface TerminalCallView { * A call that creates or modifies files, rendered as an inline diff card by a * capable UI. Set by a tool whose call writes/edits a file (e.g. `write`, * `edit`). The diffs are derived from the call ARGUMENTS (a create's `oldText` is - * `null`); result-time applied-hunk diffs are a separate follow-up. + * `null`); the result-time applied-hunk diff (with context) is a separate + * {@link DiffResultView} the tool emits after `execute`. */ export interface DiffCallView { card: 'diff' @@ -179,7 +188,7 @@ export interface DiffCallView { * {@link ToolDefinition.presentResult}; omitting the method keeps the pending * title and renders the raw result content. */ -export type ToolResultView = GenericResultView | TerminalResultView +export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView /** * The default completed card: an optional replacement title and reformatted @@ -217,9 +226,37 @@ export interface TerminalResultView { signal?: string } +/** + * A completed file mutation rendered as an inline diff card, the *result-time* + * analogue of {@link DiffCallView}. Set by a tool whose `execute` applied a + * file change (e.g. `write`, `edit`): `diffs` are the APPLIED hunks computed + * from the before/after file content (one entry per hunk, each with surrounding + * context lines), so the editor shows the real change with context — distinct + * from the call-time whole-snippet {@link DiffCallView}. A `tool_call_update`'s + * content REPLACES the call's content in an editor, so this result diff + * supersedes the pending snippet. + */ +export interface DiffResultView { + card: 'diff' + /** Replacement title for the completed call. Omit to keep the pending-state title. */ + title?: string + /** One entry per applied hunk (a contextual diff), in file order. */ + diffs: FileDiff[] +} + +/** + * What a tool's `execute` returns. The bare {@link ContentBlock}`[]` form is the + * common case (model-facing content only); the object form additionally attaches + * a tool-private `meta` presentation payload ({@link JsonValue}) that the + * registry threads onto the `tool/result` session event and hands back to the + * tool's `presentResult`. `meta` is opaque to the core — the tool owns its shape + * and validates it on the way out — and persists so replay reproduces the card. + */ +export type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: JsonValue } + /** A registered tool: its schema plus the execution function. */ export interface ToolDefinition extends ToolSchema { - execute(args: unknown, exec: ToolExecution): Promise + execute(args: unknown, exec: ToolExecution): Promise /** * Optional: how to present the PENDING state of one call in a UI, derived from * the call's `args` (parsed arguments, `unknown` — the tool validates/narrows @@ -246,6 +283,13 @@ export interface ToolResult { content: ContentBlock[] /** Whether the call failed. */ isError: boolean + /** + * The tool-private presentation payload the tool attached from `execute` (via + * the object return form), threaded verbatim from the `tool/result` event. + * Opaque {@link JsonValue}; the tool narrows it back to its own shape. Absent + * when the tool attached none. + */ + meta?: JsonValue } /** One pending tool call, as it flows through the execution waterfall. */ @@ -289,6 +333,13 @@ export interface ToolExecutionResult { * text in `content` is always present; this is extra structure for code. */ error?: ToolErrorInfo + /** + * The tool-private presentation payload from a successful `execute` (the object + * return form). Threaded onto the `tool/result` session event and back into + * {@link ToolResult} for `presentResult`. Opaque {@link JsonValue}; absent when + * the tool attached none or the call failed. + */ + meta?: JsonValue } /** @@ -393,8 +444,13 @@ export class ToolRegistry extends Service { // Unknown tool routes through the same catch as a tool-thrown error, so // both failure classes get structured `{ name, code }` from one path. if (!tool) throw new ToolNotFoundError(exec.name) - const content = await tool.execute(exec.arguments, exec) - return { callId: exec.callId, content, isError: false } + // Normalize the two `execute` return shapes: a bare ContentBlock[] (no + // meta) or a { content, meta } object (a tool attaching a private + // presentation payload). An array IS the content; the object carries it. + const returned = await tool.execute(exec.arguments, exec) + const content = Array.isArray(returned) ? returned : returned.content + const meta = Array.isArray(returned) ? undefined : returned.meta + return { callId: exec.callId, content, isError: false, ...meta !== undefined ? { meta } : {} } } catch (error: unknown) { return toolErrorResult(exec.callId, error) } diff --git a/packages/core/tools/src/schema.ts b/packages/core/tools/src/schema.ts index 0b3fc749f4..592ff4df21 100644 --- a/packages/core/tools/src/schema.ts +++ b/packages/core/tools/src/schema.ts @@ -19,9 +19,8 @@ * @module dsh-tools/schema */ -import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm' -import type { ToolCallView, ToolDefinition, ToolExecution, ToolResult, ToolResultView } from './index.ts' +import type { ToolCallView, ToolDefinition, ToolExecuteReturn, ToolExecution, ToolResult, ToolResultView } from './index.ts' // --------------------------------------------------------------------------- // SchemaSpec — the author-facing per-property type @@ -291,9 +290,11 @@ export interface DefineToolOptions { parameters: S /** * Tool execution function. `args` is typed as {@link InferArgs} — zero - * casts needed. + * casts needed. Returns either a bare {@link ContentBlock}`[]` (model-facing + * content only) or a `{ content, meta }` object to also attach a tool-private + * presentation payload (see {@link ToolExecuteReturn}). */ - execute(args: InferArgs, exec: ToolExecution): Promise + execute(args: InferArgs, exec: ToolExecution): Promise /** * Optional: how to present the PENDING state of one call in a UI (an editor * tool-call card, a CLI log line). `args` is the typed, schema-validated @@ -354,7 +355,7 @@ export function defineTool(options: DefineToolOptions): description: options.description, parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record, ...options.strict !== undefined ? { strict: options.strict } : {}, - async execute(args: unknown, exec: ToolExecution): Promise { + async execute(args: unknown, exec: ToolExecution): Promise { // Validate the model-generated args before the typed body runs. On // mismatch we throw ToolArgsError; the registry turns it into an // isError result so the model can self-correct. After this guard, the diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index 55aa5866ce..78a843f937 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -81,6 +81,38 @@ describe('ToolRegistry', () => { expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'hi' }], isError: false }) }) + it('threads a tool-attached meta (object return form) onto the result', async () => { + const ctx = await setup() + ctx.tools.register({ + ...echoTool, + name: 'meta-tool', + async execute() { + return { content: [{ type: 'text', text: 'ok' }], meta: { diffs: [{ path: 'a', oldText: null, newText: 'x' }] } } + }, + }) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'meta-tool', arguments: {} }) + expect(result).toEqual({ + callId: CallId('c1'), + content: [{ type: 'text', text: 'ok' }], + isError: false, + meta: { diffs: [{ path: 'a', oldText: null, newText: 'x' }] }, + }) + }) + + it('omits meta when the object return form supplies none', async () => { + const ctx = await setup() + ctx.tools.register({ + ...echoTool, + name: 'no-meta-tool', + async execute() { + return { content: [{ type: 'text', text: 'ok' }] } + }, + }) + const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'no-meta-tool', arguments: {} }) + expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }) + expect('meta' in result).toBe(false) + }) + it('returns isError results for unknown tools and throwing tools', async () => { const ctx = await setup() ctx.tools.register({ diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index b24b7e6b89..9b77678f82 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -382,6 +382,25 @@ export async function readForEdit( return { content: normalizeLineEndings(raw), lineEndings: detectLineEndings(raw) } } +/** + * Best-effort read of a file's current text for a before/after diff basis, used + * by an overwrite. Returns the LF-normalized decoded content, or `null` when the + * file is binary or not valid UTF-8 — a write must succeed regardless of the + * prior bytes, so an undiffable prior file simply yields no contextual diff + * (the caller treats `null` the same as an absent file: call-time card only). + */ +export async function readTextForDiff(absolutePath: string, signal?: AbortSignal): Promise { + const buffer = await readFileAbortable(absolutePath, 'read', signal) + if (buffer.includes(0)) return null + try { + return normalizeLineEndings(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 + return null + } +} + /** * Apply a literal replacement to LF-normalized content. Throws * `FS_EDIT_NOT_FOUND` on empty `oldString` or zero matches and diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index 97dda3c4dd..3ce0fa2f92 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -28,6 +28,7 @@ import { applyLiteralEdit, probe, readForEdit, + readTextForDiff, readWholeText, resolveLocalTarget, restoreLineEndings, @@ -41,6 +42,7 @@ export { applyLiteralEdit, probe, readForEdit, + readTextForDiff, readWholeText, resolveLocalTarget, restoreLineEndings, @@ -143,11 +145,18 @@ export class LocalFileSystem extends FileSystem { // provider) — no version guard, no read-first requirement. Still atomic // (the per-target lock is unconditional), so the write is never torn. + // Capture the prior text (the before/after diff basis) BEFORE the write. + // `null` for a create (no existing file) OR an existing-but-undiffable + // file (binary/invalid-UTF-8) — a consumer renders no result-time diff for + // either, only the call-time whole-file card. + const before = existing ? await readTextForDiff(target.targetKey, signal) : null 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), + before, + after: content, } }) } @@ -183,6 +192,10 @@ export class LocalFileSystem extends FileSystem { replacements: edited.replacements, replaceAll: edit.replaceAll, version: this.versionAfterWrite(after, target), + // The LF-normalized before/after text (the applied-hunk diff basis); + // line-ending restoration is a storage detail the diff ignores. + before: original.content, + after: edited.content, } }) } diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts index 03c751a538..b6686b9627 100644 --- a/packages/fs/fs-local/tests/filesystem.spec.ts +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -188,6 +188,49 @@ describe('writeText', () => { await expect(fs.writeText(target, 'x')).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) }) + it('a create reports before:null and after = the written content (no prior file)', async () => { + const target = await fs.resolve('new.txt') + const outcome = await fs.writeText(target, 'fresh') + expect(outcome.before).toBeNull() + expect(outcome.after).toBe('fresh') + }) + + it('an overwrite reports before = the OLD content and after = the new content', async () => { + await writeFile(join(dir, 'a.txt'), 'old body') + const target = await fs.resolve('a.txt') + const outcome = await fs.writeText(target, 'new body') + expect(outcome.before).toBe('old body') + expect(outcome.after).toBe('new body') + }) + + it('an overwrite of a CRLF file returns LF-normalized before content', async () => { + await writeFile(join(dir, 'a.txt'), 'a\r\nb\r\n') + const target = await fs.resolve('a.txt') + const outcome = await fs.writeText(target, 'a\nB\n') + expect(outcome.before).toBe('a\nb\n') + }) + + it('an overwrite of a BINARY prior file reports before:null (undiffable), still succeeds', async () => { + await writeFile(join(dir, 'a.bin'), Buffer.from([0x00, 0x01, 0x02])) + const target = await fs.resolve('a.bin') + const outcome = await fs.writeText(target, 'now text') + expect(outcome.operation).toBe('update') + expect(outcome.before).toBeNull() + expect(outcome.after).toBe('now text') + }) + + it('an overwrite of an INVALID-UTF-8 (non-NUL) prior file reports before:null, still succeeds', async () => { + // 0xff is never valid UTF-8 but is not a NUL, so it exercises the decoder's + // fatal-throw path (not the NUL-scan short-circuit): an undiffable prior file + // still yields a successful write with no before-content basis. + await writeFile(join(dir, 'a.bin'), Buffer.from([0x68, 0xff, 0x69])) + const target = await fs.resolve('a.bin') + const outcome = await fs.writeText(target, 'now valid') + expect(outcome.operation).toBe('update') + expect(outcome.before).toBeNull() + expect(outcome.after).toBe('now valid') + }) + 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' }) @@ -242,6 +285,17 @@ describe('editText', () => { expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there') }) + it('reports before/after content (the applied-hunk basis), LF-normalized', async () => { + await writeFile(join(dir, 'a.txt'), 'a\r\nOLD\r\nb\r\n') + const target = await fs.resolve('a.txt') + const outcome = await fs.editText(target, { oldString: 'OLD', newString: 'NEW', replaceAll: false }) + expect(outcome.before).toBe('a\nOLD\nb\n') + expect(outcome.after).toBe('a\nNEW\nb\n') + // The written file keeps the original CRLF endings (before/after are the + // LF-normalized diff basis, not the on-disk bytes). + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('a\r\nNEW\r\nb\r\n') + }) + it('checks the stale version BEFORE literal matching', async () => { await writeFile(join(dir, 'a.txt'), 'hello world') const target = await fs.resolve('a.txt') diff --git a/packages/fs/fs/src/types.ts b/packages/fs/fs/src/types.ts index 15a58ee93b..2bc6f27765 100644 --- a/packages/fs/fs/src/types.ts +++ b/packages/fs/fs/src/types.ts @@ -101,6 +101,14 @@ export interface FsWriteOutcome { operation: 'create' | 'update' /** Opaque version of the file after the write. */ version: FsVersion + /** + * The file's content BEFORE the write, or `null` when the file did not exist + * (a create). Raw storage text (LF-normalized by the backend), never a diff — + * a consumer computes the result-time contextual diff from `before`/`after`. + */ + before: string | null + /** The file's content AFTER the write (the text that was written). */ + after: string } /** A literal-replacement edit request. */ @@ -121,6 +129,14 @@ export interface FsEditOutcome { replaceAll: boolean /** Opaque version of the file after the edit. */ version: FsVersion + /** + * The file's content BEFORE the edit. Raw storage text (LF-normalized by the + * backend), never a diff — a consumer computes the result-time contextual diff + * (the applied hunk with context) from `before`/`after`. + */ + before: string + /** The file's content AFTER the edit. */ + after: string } /** diff --git a/packages/fs/fs/tests/service.spec.ts b/packages/fs/fs/tests/service.spec.ts index a0032afdee..bba7420a55 100644 --- a/packages/fs/fs/tests/service.spec.ts +++ b/packages/fs/fs/tests/service.spec.ts @@ -39,14 +39,15 @@ class FakeFileSystem extends FileSystem { return (async function* () { yield content })() } override async writeText(target: FsTarget, content: string, _expected?: FsWriteIntent): Promise { - const existed = this.files.has(target.targetKey) + const before = this.files.get(target.targetKey) ?? null this.files.set(target.targetKey, content) - return { operation: existed ? 'update' : 'create', version: FsVersion('v2') } + return { operation: before !== null ? 'update' : 'create', version: FsVersion('v2'), before, after: content } } override async editText(target: FsTarget, edit: FsEditRequest): Promise { 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') } + const after = content.split(edit.oldString).join(edit.newString) + this.files.set(target.targetKey, after) + return { replacements: 1, replaceAll: edit.replaceAll, version: FsVersion('v3'), before: content, after } } } diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index 080b9a8f78..a7f71ce6fa 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -21,9 +21,13 @@ "src" ], "license": "BSD-3-Clause", + "dependencies": { + "diff": "^9.0.0" + }, "peerDependencies": { "@deepseek-ai/dsh-fs": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.6" diff --git a/packages/fs/tool-fs/src/diff.ts b/packages/fs/tool-fs/src/diff.ts new file mode 100644 index 0000000000..51d39bb3d0 --- /dev/null +++ b/packages/fs/tool-fs/src/diff.ts @@ -0,0 +1,92 @@ +/** + * Result-time contextual-diff computation for the `write`/`edit` tools. Turns a + * before/after pair of file texts into one {@link FileDiff} per applied hunk — + * each hunk's `oldText`/`newText` reconstructed from the unified-diff lines with + * ±{@link DIFF_CONTEXT} surrounding context lines, matching how claude-agent-acp + * renders an editor inline diff. + * + * This is display-only presentation vocabulary (a UI concern), so it lives in + * the model-facing tool, NOT the `dsh-fs` storage seam — the backend returns + * only the raw before/after text (storage facts) and the tool computes the diff. + * + * @module @deepseek-ai/dsh-tool-fs/src/diff + */ + +import { structuredPatch } from 'diff' +import type { FileDiff } from '@deepseek-ai/dsh-tools' +import type { JsonValue } from '@deepseek-ai/dsh-session' + +/** Context lines shown on each side of an applied hunk (matches claude-agent-acp). */ +export const DIFF_CONTEXT = 3 + +/** + * The `write`/`edit` tools' private `tool/result` `meta` payload: the applied + * contextual-diff hunks. A {@link JsonValue} (persisted with the session log, so + * `presentResult` reproduces the diff card on replay). The producing tool owns + * this shape; the bridge only sees the opaque `meta` and the tool narrows it back + * via {@link diffsFromMeta}. + */ +export type FsDiffMeta = { diffs: FileDiff[] } + +/** + * Compute one {@link FileDiff} per hunk between `before` and `after`, each + * carrying the applied change plus {@link DIFF_CONTEXT} context lines. Returns an + * empty array when the texts are identical (no hunks). For a scattered + * `replace_all` edit the patch yields multiple hunks, so multiple `FileDiff`s + * come back — matching the editor rendering one diff block per site. + * + * Each hunk's `oldText` is its `-` (removed) and context lines joined by `\n`; + * `newText` is its `+` (added) and context lines. A hunk with no old lines + * (a pure insertion) reports `oldText: null` (nothing to diff against), mirroring + * the call-time card's new-file convention. The unified-diff "\ No newline at end + * of file" markers are dropped — they annotate the patch, not file content. + */ +export function computeHunkDiffs(path: string, before: string, after: string): FileDiff[] { + const patch = structuredPatch('', '', before, after, undefined, undefined, { context: DIFF_CONTEXT }) + const diffs: FileDiff[] = [] + for (const hunk of patch.hunks) { + const oldLines: string[] = [] + const newLines: string[] = [] + for (const line of hunk.lines) { + // The unified-diff marker for a missing trailing newline annotates the + // patch, not the content — skip it so it never leaks into a diff block. + if (line.startsWith('\\')) continue + const text = line.slice(1) + if (line.startsWith('-')) { + oldLines.push(text) + } else if (line.startsWith('+')) { + newLines.push(text) + } else { + // A context (unchanged) line appears on both sides. + oldLines.push(text) + newLines.push(text) + } + } + diffs.push({ path, oldText: oldLines.length > 0 ? oldLines.join('\n') : null, newText: newLines.join('\n') }) + } + return diffs +} + +/** Whether `value` is a valid {@link FileDiff} (defensive narrowing from opaque `meta`). */ +function isFileDiff(value: JsonValue): value is FileDiff & JsonValue { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + const { path, oldText, newText } = value + return typeof path === 'string' + && (oldText === null || typeof oldText === 'string') + && typeof newText === 'string' +} + +/** + * Narrow an opaque `tool/result` `meta` back to this tool's {@link FileDiff} + * hunks, or `undefined` when it is absent/malformed. `presentResult` runs on + * arbitrary logged `meta` (possibly from an older shape or a hand-edited log), so + * it validates defensively rather than trusting the payload — a bad `meta` yields + * no diff card (the generic result rendering) instead of a thrown presenter. + */ +export function diffsFromMeta(meta: JsonValue | undefined): FileDiff[] | undefined { + if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined + const diffs = meta.diffs + if (!Array.isArray(diffs) || diffs.length === 0 || !diffs.every(isFileDiff)) return undefined + return diffs +} + diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index 7450bd895a..1a39cb63dd 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -14,11 +14,12 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' -import type { DiffCallView } from '@deepseek-ai/dsh-tools' +import type { DiffCallView, DiffResultView, ToolResult } 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 { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts' import { sessionCwd } from './session-cwd.ts' /** Validated `edit` arguments after defaulting. */ @@ -66,7 +67,7 @@ export function applyEditTool(ctx: Context): void { 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 { + async execute(args, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> { const input = parseEditArgs(args) const cwd = sessionCwd(exec) const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined) @@ -82,7 +83,17 @@ export function applyEditTool(ctx: Context): void { ) // 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) }] + // The result-time applied-hunk diff (before→after with context lines). An + // edit always changes content (parseEditArgs requires old_string to differ + // and editText matches at least once), so there is always at least one hunk. + // The bridge renders these as an inline diff that supersedes the call-time + // snippet; the display path is the model-facing `file_path` (the bridge + // relativizes it). + const diffs = computeHunkDiffs(input.filePath, outcome.before, outcome.after) + return { + content: [{ type: 'text', text: formatEditOutput(target.displayPath, outcome) }], + meta: { diffs }, + } }, // Pure display: a diff card of the literal replacement (old_string → // new_string), derived from the call args. `oldText: old_string || null` @@ -96,5 +107,15 @@ export function applyEditTool(ctx: Context): void { locations: [{ path: args.file_path }], } }, + // Result-time display: the applied contextual-diff hunks carried on `meta`. + // On success with diffs, a `diff` result card supersedes the call-time + // snippet; on error (nothing applied) or malformed meta, fall through to the + // generic "updated successfully" rendering. + presentResult(args, result: ToolResult): DiffResultView | undefined { + if (result.isError) return undefined + const diffs = diffsFromMeta(result.meta) + if (diffs === undefined) return undefined + return { card: 'diff', title: `Edit ${args.file_path}`, diffs } + }, })) } diff --git a/packages/fs/tool-fs/src/index.ts b/packages/fs/tool-fs/src/index.ts index e9f384a96c..0aaa0c1a7a 100644 --- a/packages/fs/tool-fs/src/index.ts +++ b/packages/fs/tool-fs/src/index.ts @@ -32,6 +32,8 @@ 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' +export { DIFF_CONTEXT, computeHunkDiffs, diffsFromMeta } from './diff.ts' +export type { FsDiffMeta } from './diff.ts' /** Cordis plugin name used by loader diagnostics. */ export const name = 'tool-fs' diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index 69844c55f6..bf58f21e6c 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -13,11 +13,12 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' -import type { DiffCallView } from '@deepseek-ai/dsh-tools' +import type { DiffCallView, DiffResultView, ToolResult } 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 { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts' import { sessionCwd } from './session-cwd.ts' /** Validate value constraints the schema DSL can't express. */ @@ -51,7 +52,7 @@ export function applyWriteTool(ctx: Context): void { 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 { + async execute(args, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> { const input = parseWriteArgs(args) const cwd = sessionCwd(exec) const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined) @@ -61,7 +62,14 @@ export function applyWriteTool(ctx: Context): void { 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) }] + // Result-time contextual diff ONLY for an overwrite (a before-version + // exists). A create has no "before" — `outcome.before` is null — so it + // carries no result diff, leaving just the call-time whole-file card. + const diffs = outcome.before !== null ? computeHunkDiffs(input.filePath, outcome.before, outcome.after) : [] + return { + content: [{ type: 'text', text: formatWriteOutput(target.displayPath, outcome) }], + ...diffs.length > 0 ? { meta: { diffs } } : {}, + } }, // Pure display: a diff card (an editor renders write as a new-file / full- // replace diff). `oldText: null` — a call-time presenter has no access to the @@ -75,5 +83,15 @@ export function applyWriteTool(ctx: Context): void { locations: [{ path: args.file_path }], } }, + // Result-time display: for an OVERWRITE, the applied contextual-diff hunks on + // `meta` supersede the call-time whole-file snippet. A create carries no meta + // (no "before"), so this returns undefined and the call-time new-file card + // stands; an error or malformed meta also falls through to generic rendering. + presentResult(args, result: ToolResult): DiffResultView | undefined { + if (result.isError) return undefined + const diffs = diffsFromMeta(result.meta) + if (diffs === undefined) return undefined + return { card: 'diff', title: `Write ${args.file_path}`, diffs } + }, })) } diff --git a/packages/fs/tool-fs/tests/diff.spec.ts b/packages/fs/tool-fs/tests/diff.spec.ts new file mode 100644 index 0000000000..12ab7209b6 --- /dev/null +++ b/packages/fs/tool-fs/tests/diff.spec.ts @@ -0,0 +1,113 @@ +/** + * Unit tests for the result-time contextual-diff computation (`src/diff.ts`): + * the pure before/after → {@link FileDiff}[] hunk builder and the defensive + * `meta` narrowing. These pin the exact hunk reconstruction (context lines, + * multi-hunk replaceAll, pure insertion/deletion, no-op) the ACP bridge renders. + */ + +import { describe, expect, it } from 'vitest' +import { computeHunkDiffs, diffsFromMeta, DIFF_CONTEXT } from '@deepseek-ai/dsh-tool-fs' +import type { JsonValue } from '@deepseek-ai/dsh-session' + +const lines = (n: number): string => Array.from({ length: n }, (_, i) => `line${i + 1}`).join('\n') + '\n' + +describe('computeHunkDiffs', () => { + it('a single-line change yields one hunk with ±context lines on both sides', () => { + const before = lines(8) + const after = before.replace('line4', 'CHANGED') + const diffs = computeHunkDiffs('f.txt', before, after) + expect(diffs).toEqual([{ + path: 'f.txt', + oldText: 'line1\nline2\nline3\nline4\nline5\nline6\nline7', + newText: 'line1\nline2\nline3\nCHANGED\nline5\nline6\nline7', + }]) + }) + + it('a scattered replace_all yields one FileDiff PER hunk (matching per-site editor blocks)', () => { + const before = lines(20) + const after = before.replace('line3', 'A').replace('line16', 'B') + const diffs = computeHunkDiffs('f.txt', before, after) + expect(diffs).toHaveLength(2) + expect(diffs[0]?.path).toBe('f.txt') + expect(diffs[0]?.oldText).toContain('line3') + expect(diffs[0]?.newText).toContain('A') + expect(diffs[1]?.oldText).toContain('line16') + expect(diffs[1]?.newText).toContain('B') + // The two hunks are distinct sites, not one merged block. + expect(diffs[0]?.newText).not.toContain('B') + expect(diffs[1]?.newText).not.toContain('A') + }) + + it('identical before/after (a no-op) yields no hunks', () => { + expect(computeHunkDiffs('f.txt', 'same\n', 'same\n')).toEqual([]) + }) + + it('a pure insertion into empty content reports oldText null (nothing to diff against)', () => { + const diffs = computeHunkDiffs('f.txt', '', 'brand new\n') + expect(diffs).toEqual([{ path: 'f.txt', oldText: null, newText: 'brand new' }]) + }) + + it('a pure deletion of the whole file reports newText empty', () => { + const diffs = computeHunkDiffs('f.txt', 'gone\n', '') + expect(diffs).toEqual([{ path: 'f.txt', oldText: 'gone', newText: '' }]) + }) + + it('drops the "\\ No newline at end of file" marker from a no-trailing-newline change', () => { + const diffs = computeHunkDiffs('f.txt', 'x', 'y') + // The marker line (starting with "\\") must never leak into a diff block. + expect(diffs).toEqual([{ path: 'f.txt', oldText: 'x', newText: 'y' }]) + expect(diffs[0]?.oldText).not.toContain('\\') + expect(diffs[0]?.newText).not.toContain('\\') + }) + + it('uses DIFF_CONTEXT (3) surrounding lines', () => { + expect(DIFF_CONTEXT).toBe(3) + const before = lines(20) + const after = before.replace('line10', 'CHANGED') + const [diff] = computeHunkDiffs('f.txt', before, after) + // 3 context above (7,8,9) + the change + 3 below (11,12,13) = 7 lines a side. + expect(diff?.oldText?.split('\n')).toHaveLength(7) + expect(diff?.newText.split('\n')).toHaveLength(7) + expect(diff?.oldText?.split('\n')[0]).toBe('line7') + }) +}) + +describe('diffsFromMeta (defensive narrowing)', () => { + // The narrowing accepts an opaque JsonValue; a malformed payload is not a + // statically-valid JsonValue, so route every case through one cast helper that + // mirrors how a hand-edited/older session log delivers arbitrary shapes. + const m = (value: unknown): JsonValue | undefined => value as JsonValue | undefined + const good = { diffs: [{ path: 'f.txt', oldText: 'a', newText: 'b' }] } + + it('narrows a well-formed { diffs } payload', () => { + expect(diffsFromMeta(m(good))).toEqual(good.diffs) + }) + + it('accepts a diff whose oldText is null (a create-style hunk)', () => { + const meta = { diffs: [{ path: 'f.txt', oldText: null, newText: 'x' }] } + expect(diffsFromMeta(m(meta))).toEqual(meta.diffs) + }) + + it('rejects undefined / non-object / array meta', () => { + expect(diffsFromMeta(undefined)).toBeUndefined() + expect(diffsFromMeta(null)).toBeUndefined() + expect(diffsFromMeta(m('nope'))).toBeUndefined() + expect(diffsFromMeta(m([]))).toBeUndefined() + }) + + it('rejects a missing / empty / non-array diffs field', () => { + expect(diffsFromMeta(m({}))).toBeUndefined() + expect(diffsFromMeta(m({ diffs: [] }))).toBeUndefined() + expect(diffsFromMeta(m({ diffs: 'x' }))).toBeUndefined() + }) + + it('rejects a diffs array containing a malformed entry', () => { + expect(diffsFromMeta(m({ diffs: [{ path: 'f.txt', oldText: 'a' }] }))).toBeUndefined() + expect(diffsFromMeta(m({ diffs: [{ path: 1, oldText: 'a', newText: 'b' }] }))).toBeUndefined() + expect(diffsFromMeta(m({ diffs: [{ path: 'f', oldText: 5, newText: 'b' }] }))).toBeUndefined() + expect(diffsFromMeta(m({ diffs: [{ path: 'f', oldText: 'a', newText: 7 }] }))).toBeUndefined() + expect(diffsFromMeta(m({ diffs: [null] }))).toBeUndefined() + expect(diffsFromMeta(m({ diffs: ['x'] }))).toBeUndefined() + expect(diffsFromMeta(m({ diffs: [[]] }))).toBeUndefined() + }) +}) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 7bdedf894a..f41f587d94 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -57,16 +57,17 @@ class FakeFs extends FileSystem { override async writeText(target: FsTarget, content: string, expected?: FsWriteIntent): Promise { this.throwIfArmed() this.writeIntents.push(expected) - const existed = this.files.has(target.targetKey) + const before = this.files.get(target.targetKey) ?? null this.files.set(target.targetKey, content) - return { operation: existed ? 'update' : 'create', version: FsVersion('v2') } + return { operation: before !== null ? 'update' : 'create', version: FsVersion('v2'), before, after: content } } override async editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }): Promise { 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') } + const after = content.split(edit.oldString).join(edit.newString) + this.files.set(target.targetKey, after) + return { replacements: 1, replaceAll: edit.replaceAll, version: FsVersion('v3'), before: content, after } } } @@ -395,3 +396,80 @@ describe('tool-owned presentation (pure presentCall)', () => { }) }) }) + +describe('result-time contextual diff (meta + presentResult)', () => { + // An edit records the applied contextual hunk on `tool/result` meta, and the + // tool's presentResult narrows it back into a `diff` result card the bridge + // renders. Drive execute end-to-end so the meta is the REAL computed hunk. + const withContext = 'a\nb\nc\nOLD\nd\ne\nf\n' + + it('edit: execute attaches the applied hunk as meta { diffs }', async () => { + const { ctx, fs } = await setup() + const session = { header: {} } + fs.files.set('key:a.txt', withContext) + await call(ctx, 'read', { file_path: 'a.txt' }, { session }) + const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'OLD', new_string: 'NEW' }, { session }) + expect(result.isError).toBe(false) + expect(result.meta).toEqual({ + diffs: [{ path: 'a.txt', oldText: 'a\nb\nc\nOLD\nd\ne\nf', newText: 'a\nb\nc\nNEW\nd\ne\nf' }], + }) + }) + + it('edit: presentResult turns the meta into a diff result card', async () => { + const { ctx, fs } = await setup() + const session = { header: {} } + fs.files.set('key:a.txt', withContext) + await call(ctx, 'read', { file_path: 'a.txt' }, { session }) + const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'OLD', new_string: 'NEW' }, { session }) + const view = ctx.tools.get('edit')?.presentResult?.({ file_path: 'a.txt', old_string: 'OLD', new_string: 'NEW' }, result) + expect(view).toEqual({ + card: 'diff', title: 'Edit a.txt', + diffs: [{ path: 'a.txt', oldText: 'a\nb\nc\nOLD\nd\ne\nf', newText: 'a\nb\nc\nNEW\nd\ne\nf' }], + }) + }) + + it('write OVERWRITE: execute attaches a contextual hunk; presentResult renders a diff card', async () => { + const { ctx, fs } = await setup() + const session = { header: {} } + fs.files.set('key:a.txt', withContext) + await call(ctx, 'read', { file_path: 'a.txt' }, { session }) + const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'a\nb\nc\nNEW\nd\ne\nf\n' }, { session }) + expect(result.isError).toBe(false) + expect(result.meta).toEqual({ diffs: [{ path: 'a.txt', oldText: 'a\nb\nc\nOLD\nd\ne\nf', newText: 'a\nb\nc\nNEW\nd\ne\nf' }] }) + const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'a.txt', content: 'x' }, result) + expect(view).toEqual({ card: 'diff', title: 'Write a.txt', diffs: [{ path: 'a.txt', oldText: 'a\nb\nc\nOLD\nd\ne\nf', newText: 'a\nb\nc\nNEW\nd\ne\nf' }] }) + }) + + it('write CREATE: no before-version → no meta, presentResult returns undefined (call-time card stands)', async () => { + const { ctx } = await setup() + const session = { header: {} } + const result = await call(ctx, 'write', { file_path: 'new.txt', content: 'fresh\n' }, { session }) + expect(result.isError).toBe(false) + expect(result.meta).toBeUndefined() + expect(ctx.tools.get('write')?.presentResult?.({ file_path: 'new.txt', content: 'fresh\n' }, result)).toBeUndefined() + }) + + it('write OVERWRITE with identical content: a before exists but yields no hunk → no meta', async () => { + const { ctx, fs } = await setup() + const session = { header: {} } + fs.files.set('key:a.txt', 'same\n') + await call(ctx, 'read', { file_path: 'a.txt' }, { session }) + const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'same\n' }, { session }) + expect(result.isError).toBe(false) + expect(result.meta).toBeUndefined() + }) + + it('presentResult returns undefined on an error result (nothing applied)', async () => { + const { ctx } = await setup() + const errorResult = { content: [{ type: 'text' as const, text: 'Error: boom' }], isError: true } + expect(ctx.tools.get('edit')?.presentResult?.({ file_path: 'a.txt', old_string: 'x', new_string: 'y' }, errorResult)).toBeUndefined() + expect(ctx.tools.get('write')?.presentResult?.({ file_path: 'a.txt', content: 'y' }, errorResult)).toBeUndefined() + }) + + it('presentResult returns undefined on malformed meta (defensive narrowing)', async () => { + const { ctx } = await setup() + const badMeta = { content: [{ type: 'text' as const, text: 'ok' }], isError: false, meta: { diffs: 'nope' } } + expect(ctx.tools.get('edit')?.presentResult?.({ file_path: 'a.txt', old_string: 'x', new_string: 'y' }, badMeta)).toBeUndefined() + expect(ctx.tools.get('write')?.presentResult?.({ file_path: 'a.txt', content: 'y' }, badMeta)).toBeUndefined() + }) +}) diff --git a/packages/ui/acp/acp-feature-support.md b/packages/ui/acp/acp-feature-support.md index 4d14c79f5e..6ef901f17b 100644 --- a/packages/ui/acp/acp-feature-support.md +++ b/packages/ui/acp/acp-feature-support.md @@ -99,7 +99,7 @@ Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult | `ToolCallKind` mapping | S | ✅ | ✅ | ✅ | `execute`/`read`/`edit`/`other` inferred from the tool; richer mapping possible. | | `ToolCallStatus` | S | ✅ | ✅ | ✅ | `in_progress` → `completed`/`failed`. | | `content` blocks | S | ✅ | ✅ | ✅ | Text content; the description renders above the card. | -| `diff` content | S | ✅ | ✅ | ✅ | The `write`/`edit` tools declare a `diff` render intent (`presentCall` → `{ card: 'diff' }`); the bridge emits `{ type: 'diff', path, oldText, newText }` content blocks (call-time, args-derived — applied-hunk diffs are a follow-up). | +| `diff` content | S | ✅ | ✅ | ✅ | The `write`/`edit` tools declare a `diff` render intent: `presentCall` → a call-time `{ card: 'diff' }` snippet, and `presentResult` → a result-time `{ card: 'diff' }` carrying the APPLIED hunk with surrounding context lines (one hunk per `replace_all` site), computed from the before/after file text and persisted on the `tool/result` event. The bridge emits `{ type: 'diff', path, oldText, newText }` content blocks; the result hunk supersedes the call snippet. | | `terminal` content | S | ✅ | ✅ | ✅ | Via the Zed `_meta` terminal convention (see below), not the spec `terminal/*` sub-protocol. | | `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`. | @@ -147,7 +147,6 @@ 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. **Applied-hunk diff rendering** — the `write`/`edit` diff cards ship (call-time, args-derived: whole `old_string`→`new_string`). Result-time structured-patch hunks with surrounding context (what `claude-agent-acp` derives from a PostToolUse hook) need a new result/event shape carrying the patch — a follow-up. 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. diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index c01c004d91..631a741447 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -66,7 +66,7 @@ import { assertNever, CallId } from '@deepseek-ai/dsh-llm' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' -import type { SessionEvent, TodoItem, TurnEndReason } from '@deepseek-ai/dsh-session' +import type { JsonValue, SessionEvent, TodoItem, TurnEndReason } from '@deepseek-ai/dsh-session' import type { ToolCallKind, ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } from '@deepseek-ai/dsh-tools' // Side-effect type import: declaration-merges `ctx.sessionPersistence` onto // Context (the bridge injects it and reads `list()` for load cwd validation). @@ -818,7 +818,7 @@ export function streamSessionEventUpdate( return } case 'tool/result': { - const view = presenter.result(event.data.callId, event.data.content, event.data.isError) + const view = presenter.result(event.data.callId, event.data.content, event.data.isError, event.data.meta) notify({ sessionId, update: toolResultUpdate(event.data.callId, view, event.data.isError, terminal) }) return } @@ -919,14 +919,14 @@ export class ToolPresenter { } /** Completed-state render intent for a `tool/result`; consumes the remembered `(name, args, card)`. */ - result(callId: CallId, content: ContentBlock[], isError: boolean): ToolResultView { + result(callId: CallId, content: ContentBlock[], isError: boolean, meta?: JsonValue): ToolResultView { const call = this.pending.get(callId) this.pending.delete(callId) // No remembered call (unknown/late callId) → nothing to present from; raw content. if (call === undefined) return { card: 'generic', content } let present: ToolResultView | undefined try { - present = this.tools.get(call.name)?.presentResult?.(call.args, { content, isError }) + present = this.tools.get(call.name)?.presentResult?.(call.args, { content, isError, ...meta !== undefined ? { meta } : {} }) } catch (error: unknown) { // A throwing presentResult must not break streaming/replay: log + fall back. this.onError(`acp: tool "${call.name}" presentResult threw, using raw result: ${String(error)}`) @@ -1126,7 +1126,9 @@ function terminalExitMeta(callId: string, view: TerminalResultView): TerminalExi * (the terminal card consumes them and `content` is OMITTED — a * `tool_call_update.content` REPLACES the call's content collection in Zed, so * re-sending would clobber the terminal block the call installed) and otherwise - * derives the fenced ```console fallback from `output`. + * derives the fenced ```console fallback from `output`. A `diff` result emits the + * applied-hunk `{ type: 'diff' }` content blocks, which replace the call-time + * whole-file snippet in the editor. */ function toolResultUpdate(callId: CallId, view: ToolResultView, isError: boolean, terminal: TerminalRendering): ToolCallSessionUpdate { const status = isError ? 'failed' as const : 'completed' as const @@ -1167,6 +1169,20 @@ function toolResultUpdate(callId: CallId, view: ToolResultView, isError: boolean ...view.content !== undefined ? { content: toolResultContent(view.content) } : {}, ...view.title !== undefined ? { title: view.title } : {}, } + case 'diff': { + // A result-time applied-hunk diff: emit one `{ type: 'diff' }` content block + // per hunk (mirroring the call-side diff arm). `tool_call_update.content` + // REPLACES the call's content in an editor, so these hunks supersede the + // call-time whole-file snippet the pending card installed. + const content: AcpToolCallContent[] = view.diffs.map(d => ({ type: 'diff', path: d.path, oldText: d.oldText, newText: d.newText })) + return { + sessionUpdate: 'tool_call_update', + toolCallId: callId, + status, + ...content.length > 0 ? { content } : {}, + ...view.title !== undefined ? { title: view.title } : {}, + } + } default: return assertNever(view, 'ToolResultView.card') } diff --git a/packages/ui/acp/tests/stream-update.spec.ts b/packages/ui/acp/tests/stream-update.spec.ts index 9c1898b3c1..0a9974e049 100644 --- a/packages/ui/acp/tests/stream-update.spec.ts +++ b/packages/ui/acp/tests/stream-update.spec.ts @@ -618,6 +618,92 @@ describe('diff-card mapping', () => { }) }) +describe('result-time diff card (REAL fs edit tool → tool_call_update diff blocks)', () => { + // Drive the SHIPPING fs edit tool through the bridge: the pending tool/call + // installs the call-time snippet, then the tool/result carries the tool's + // computed applied-hunk `meta`, which presentResult narrows into a `diff` + // result card the bridge forwards as `{ type: 'diff' }` content blocks. Uses + // the REAL tool (not a stand-in) per the anti-mock convention, mirroring the + // call-side diff test above. + async function fsCtx(): Promise { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(FsLocal) + await ctx.plugin(ToolFs) + return ctx + } + + function updatesWith(presenter: ToolPresenter, ...events: SessionEvent[]): SessionNotification['update'][] { + const out: SessionNotification['update'][] = [] + for (const event of events) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), presenter) + return out + } + + it('forwards the applied-hunk meta onto the wire as tool_call_update diff content', async () => { + const ctx = await fsCtx() + const presenter = new ToolPresenter(ctx.tools) + const args = JSON.stringify({ file_path: 'src/b.ts', old_string: 'OLD', new_string: 'NEW' }) + // The applied hunk the tool would compute and persist on the result meta. + const meta = { diffs: [{ path: 'src/b.ts', oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }] } + const [, resultUpdate] = updatesWith( + presenter, + evt('tool/call', { turn: 1, step: 1, callId: CallId('e1'), name: 'edit', arguments: args }), + evt('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [{ type: 'text', text: 'ok' }], isError: false, meta }), + ) + expect(resultUpdate).toEqual({ + sessionUpdate: 'tool_call_update', + toolCallId: 'e1', + status: 'completed', + title: 'Edit src/b.ts', + content: [{ type: 'diff', path: 'src/b.ts', oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }], + }) + await ctx.fiber.dispose() + }) + + it('an error result carries NO diff card (falls back to raw content)', async () => { + const ctx = await fsCtx() + const presenter = new ToolPresenter(ctx.tools) + const args = JSON.stringify({ file_path: 'src/b.ts', old_string: 'OLD', new_string: 'NEW' }) + const [, resultUpdate] = updatesWith( + presenter, + evt('tool/call', { turn: 1, step: 1, callId: CallId('e1'), name: 'edit', arguments: args }), + evt('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [{ type: 'text', text: 'Error: boom' }], isError: true }), + ) + expect(resultUpdate).toMatchObject({ sessionUpdate: 'tool_call_update', status: 'failed' }) + expect(resultUpdate).not.toHaveProperty('content', expect.arrayContaining([expect.objectContaining({ type: 'diff' })])) + await ctx.fiber.dispose() + }) + + it('a diff result with an EMPTY diffs array and no title omits both keys (nothing to send)', () => { + // A synthetic tool whose presentResult yields a `diff` card with no hunks and + // no title — the shipping fs tools never emit this (edit always has a hunk; an + // empty write returns undefined), so a stand-in is the only way to exercise + // the empty-content AND absent-title branches of the result-side diff arm. + const emptyDiffTool: ToolDefinition = { + name: 'writer', + description: 'writes a file', + parameters: {}, + execute: async () => [], + presentCall: () => ({ card: 'diff', title: 'Write x', diffs: [{ path: 'x', oldText: null, newText: 'y' }] }), + presentResult: () => ({ card: 'diff', diffs: [] }), + } + const presenter = new ToolPresenter(registryOf(emptyDiffTool)) + const [, resultUpdate] = updatesWith( + presenter, + evt('tool/call', { turn: 1, step: 1, callId: CallId('w1'), name: 'writer', arguments: '{}' }), + evt('tool/result', { turn: 1, step: 1, callId: CallId('w1'), content: [{ type: 'text', text: 'ok' }], isError: false }), + ) + expect(resultUpdate).toEqual({ + sessionUpdate: 'tool_call_update', + toolCallId: 'w1', + status: 'completed', + }) + expect(resultUpdate).not.toHaveProperty('content') + expect(resultUpdate).not.toHaveProperty('title') + }) +}) + describe('relative-path display titles (bridge relativizes the title against the session cwd)', () => { // The bridge relativizes a file card's TITLE against the session workspace cwd // (mirroring the reference adapter's toDisplayPath), while leaving locations/ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d87855145f..db4b513b10 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -271,6 +271,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../session '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../system-prompt @@ -319,6 +322,10 @@ importers: version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) packages/fs/tool-fs: + dependencies: + diff: + specifier: ^9.0.0 + version: 9.0.0 devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -2196,6 +2203,10 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + engines: {node: '>=0.3.1'} + dts-resolver@3.0.0: resolution: {integrity: sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==} engines: {node: ^22.18.0 || >=24.0.0} @@ -4393,6 +4404,8 @@ snapshots: dependencies: dequal: 2.0.3 + diff@9.0.0: {} + dts-resolver@3.0.0(oxc-resolver@11.20.0): optionalDependencies: oxc-resolver: 11.20.0 From 50a32cdcb104d98a43966a9b6ef3c17016306cbc Mon Sep 17 00:00:00 2001 From: Ziya <199893125+ZiyaZhang@users.noreply.github.com> Date: Fri, 3 Jul 2026 02:24:17 -0700 Subject: [PATCH 49/75] docs: extend terminology table with i18n mechanism terms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every new term the bilingual-docs work introduced, with precedented renderings where precedent exists: - orphan -> 孤立 (git's official zh l10n renders orphan as 孤立, e.g. 孤立分支 — not 孤儿; the translations were corrected to match) - info string -> 信息字符串 (CommonMark zh convention; corrected in the i18n README translation) - fenced code block -> 围栏代码块 (MDN zh), staged -> 暂存 (git zh), event-sourced -> 事件溯源 (DDD convention), smoke test -> 冒烟测试, fail-fast -> 快速失败, plus fingerprint/pairing/freshness/stale/contract - mechanism names coined by this repo, marked as such in the notes: language switcher -> 语言切换行, structural signature -> 结构签名, enforcement frontier -> 强制边界 - keep-English entries so future translators don't guess: backlog, blob hash, CI, doc-sync, e2e, monorepo, PR, worktree --- docs/i18n/README.zh.md | 2 +- docs/i18n/terminology.md | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index d1ca53f3d8..251a16e984 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -25,7 +25,7 @@ `pnpm run verify-translation-pairing`(`doc-sync` 的一环,因此 CI 和 pre-push 钩子都会运行)机械地强制这份契约: 1. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 中 `required` 列出的每个英文文件都有 `.zh.md` 配对文件。 -2. 每个已存在的 `.zh.md` 文件——无论是否 required——都通过全部检查:其英文源存在(无孤儿)、指纹等于源的当前 blob hash(无过期译文)、双方都带语言切换行、其结构签名与源按序一致——标题深度、逐字节一致的代码块(信息串与内容)、表格列数、列表类型、以及除切换行之外的每个链接目标。 +2. 每个已存在的 `.zh.md` 文件——无论是否 required——都通过全部检查:其英文源存在(无孤立文件)、指纹等于源的当前 blob hash(无过期译文)、双方都带语言切换行、其结构签名与源按序一致——标题深度、逐字节一致的代码块(信息字符串与内容)、表格列数、列表类型、以及除切换行之外的每个链接目标。 3. 列为 `excluded` 的文件完全没有 `.zh.md` 配对。 `pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前翻译状态——missing、stale 或 ok——是翻译批次的工作清单。它从不失败;它只报告。 diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index f9baa40adc..2303e43808 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -7,6 +7,7 @@ | ACP | ACP | 首次出现可写:ACP(Agent Client Protocol) | | AI | AI | 首次出现可写:人工智能(AI) | | API | API | | +| CI | CI | | | CLI | CLI | 首次出现可写:命令行界面(CLI) | | Cordis | Cordis | 保留英文 | | Function Calling | Function Calling | 首次出现可写:Function Calling(函数调用) | @@ -17,16 +18,22 @@ | loader | loader | | | LLM | LLM | 首次出现可写:大语言模型(LLM) | | MCP | MCP | | +| PR | PR | 首次出现可写:PR(pull request) | | RAG | RAG | 首次出现可写:检索增强生成(RAG) | | SDK | SDK | | | SSE | SSE | 首次出现可写:SSE(Server-Sent Events) | | agent | agent | 首次出现可写:agent(智能体) | | agent loop | agent loop | | +| backlog | backlog | 双语翻译语境指待翻清单 | +| blob hash | blob hash | git 对象哈希;`git hash-object` 的结果 | +| doc-sync | doc-sync | 仓库门禁名,保留英文 | +| e2e | e2e | | | fiber | fiber | 首次出现可写:fiber(插件运行时) | | fixture | fixture | 指测试前置数据或环境 | | fork | fork | 保留英文 | | harness | harness | 保留英文 | | manifest | manifest | 描述模块或工具元数据的文件 | +| monorepo | monorepo | | | schema DSL | schema DSL | | | schema | schema | 保留英文 | | seam | seam | 首次出现可写:seam(扩展点) | @@ -36,6 +43,7 @@ | subagent | subagent | 首次出现可写:subagent(子 agent) | | transcript | transcript | 首次出现可写:transcript(文本记录);指会话渲染给用户或编辑器的完整文本,区别于事件日志(event log) | | waterfall | waterfall | 首次出现可写:waterfall(瀑布式事件) | +| worktree | worktree | git 工作区概念,保留英文 | | wire format | 协议格式 | 首次出现可写:协议格式(wire format) | | adapter contract | 适配器契约 | 首次出现可写:适配器契约(adapter contract) | | adapter | 适配器 | | @@ -54,28 +62,39 @@ | config | 配置 | | | context | 上下文 | | | context compaction | 上下文压缩 | 首次出现可写:上下文压缩(context compaction) | +| contract | 契约 | 如:配对契约(pairing contract);另见 adapter contract | | coverage | 覆盖率 | | | crash recovery | 崩溃恢复 | | | dispose | dispose | 首次出现可写:dispose(释放资源);正文优先保留英文 | | durability | 持久性 | | +| enforcement frontier | 强制边界 | i18n 机制词:manifest `required` 清单所划的门禁生效范围 | | event log | 事件日志 | | | event | 事件 | | | event stream | 事件流 | | +| event-sourced | 事件溯源 | DDD 社区通行译法 | | executor | 执行器 | | | extension | 扩展 | | +| fail-fast | 快速失败 | | +| fenced code block | 围栏代码块 | MDN 中文同译 | | finish reason | 结束原因 | | +| fingerprint | 指纹 | i18n 机制词:`.zh.md` 首行记录英文源 blob hash 的 `i18n-source` 注释 | | foreground run | 前台运行 | | +| freshness | 新鲜度 | 指译文相对英文源的同步状态 | | hook | 钩子 | | | implementation | 实现 | | | inference | 推理(inference) | 每次提及时保留英文括注,避免与 reasoning 混淆 | +| info string | 信息字符串 | CommonMark 中文同译;代码围栏 ``` 之后的语言标注 | | injection | 注入 | | | interface | 接口 | | | integration | 集成 | | +| language switcher | 语言切换行 | i18n 机制词:双语配对文件顶部的互链行 | | memory | memory / 记忆 / 内存 | 按上下文区分:agent memory 译为“记忆”;resource/memory usage 译为“内存” | | message | 消息 | | | mod | 模组 | 区别于 module(模块);plugin 译作「插件」 | | model provider | 模型提供方 | | | module | 模块 | | +| orphan | 孤立 | git 官方中文同译(如「孤立分支」);指英文源已不存在的 `.zh.md`;不要译作:孤儿 | +| pairing | 配对 | | | permission | 权限 | | | persistence | 持久化 | | | pipeline | 流水线 | | @@ -93,11 +112,15 @@ | service | 服务 | | | session | 会话 | | | session event | 会话事件 | | +| smoke test | 冒烟测试 | | | snapshot | 快照 | | | spine | 主干 | | +| staged | 暂存 | git 官方中文同译 | +| stale | 过期 | 门禁输出保留英文 `stale`,行文译「过期」 | | step | 步骤 | | | stream | 流 | | | streaming | 流式输出 | | +| structural signature | 结构签名 | i18n 机制词:配对门禁比对的有序结构序列 | | system prompt | 系统提示词 | | | taxonomy | 分类体系 | | | token usage | token 用量 | | From f508ff2bf5d97f09df50f86dc55aa895c34de081 Mon Sep 17 00:00:00 2001 From: Ziya <199893125+ZiyaZhang@users.noreply.github.com> Date: Fri, 3 Jul 2026 02:32:54 -0700 Subject: [PATCH 50/75] docs: freshness/stale renderings per MDN HTTP-caching zh precedent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit freshness -> 新鲜度 confirmed against MDN's zh HTTP caching docs (freshness lifetime -> 新鲜度生命周期); precedent now cited in the table. The same source pairs stale with 陈旧, not 过期 (过期 maps to expired), so the stale entry and the i18n README translation now say 陈旧译文. --- docs/i18n/README.zh.md | 6 +++--- docs/i18n/terminology.md | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index 251a16e984..6daad930cf 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -16,7 +16,7 @@ ``` - 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的英文文件也能算出指纹(`git hash-object docs/foo.md`),过期检测则是纯内容比较。指纹同时也是更新工具:`git cat-file -p ` 能还原过期译文当初依据的确切源文本,`git diff <当前 blob>` 能隔离出变化的部分,让译文做最小更新而不是整篇重译。 + 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的英文文件也能算出指纹(`git hash-object docs/foo.md`),陈旧检测则是纯内容比较。指纹同时也是更新工具:`git cat-file -p ` 能还原陈旧译文当初依据的确切源文本,`git diff <当前 blob>` 能隔离出变化的部分,让译文做最小更新而不是整篇重译。 - **语言切换行。**两个文件在各自 H1 标题之后立即互链:英文文件带 `English | [中文](foo.zh.md)`,中文文件带 `[English](foo.md) | 中文`。 - **结构与源一一对应。**标题深度与顺序、列表类型、表格列、链接目标与逐字节一致的代码块和英文文件一一对应——完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。 @@ -25,12 +25,12 @@ `pnpm run verify-translation-pairing`(`doc-sync` 的一环,因此 CI 和 pre-push 钩子都会运行)机械地强制这份契约: 1. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 中 `required` 列出的每个英文文件都有 `.zh.md` 配对文件。 -2. 每个已存在的 `.zh.md` 文件——无论是否 required——都通过全部检查:其英文源存在(无孤立文件)、指纹等于源的当前 blob hash(无过期译文)、双方都带语言切换行、其结构签名与源按序一致——标题深度、逐字节一致的代码块(信息字符串与内容)、表格列数、列表类型、以及除切换行之外的每个链接目标。 +2. 每个已存在的 `.zh.md` 文件——无论是否 required——都通过全部检查:其英文源存在(无孤立文件)、指纹等于源的当前 blob hash(无陈旧译文)、双方都带语言切换行、其结构签名与源按序一致——标题深度、逐字节一致的代码块(信息字符串与内容)、表格列数、列表类型、以及除切换行之外的每个链接目标。 3. 列为 `excluded` 的文件完全没有 `.zh.md` 配对。 `pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前翻译状态——missing、stale 或 ok——是翻译批次的工作清单。它从不失败;它只报告。 -这个门禁带来的实际规则是:**当一个 PR 修改了已有 `.zh.md` 配对的英文文档时,同一个 PR 更新译文**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill),与本仓库既有的代码/README doc-sync 规则完全一致。留下过期译文的 PR 会在 CI 变红。 +这个门禁带来的实际规则是:**当一个 PR 修改了已有 `.zh.md` 配对的英文文档时,同一个 PR 更新译文**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill),与本仓库既有的代码/README doc-sync 规则完全一致。留下陈旧译文的 PR 会在 CI 变红。 把门禁的边界说白:**门禁绿意味着新鲜且结构健全,不意味着已核验。**它检查指纹和形状;它无法判断中文是否准确、术语是否得当、行文是否自然——那是契约中评审者的那一半,见 [translation-rules.md](translation-rules.md)。一个重打了指纹但翻得潦草的 `.zh.md` 能通过门禁;它不应通过评审。 diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index 2303e43808..f95a8e3a8f 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -79,7 +79,7 @@ | finish reason | 结束原因 | | | fingerprint | 指纹 | i18n 机制词:`.zh.md` 首行记录英文源 blob hash 的 `i18n-source` 注释 | | foreground run | 前台运行 | | -| freshness | 新鲜度 | 指译文相对英文源的同步状态 | +| freshness | 新鲜度 | MDN HTTP 缓存中文同译(freshness lifetime → 新鲜度生命周期);指译文相对英文源的同步状态 | | hook | 钩子 | | | implementation | 实现 | | | inference | 推理(inference) | 每次提及时保留英文括注,避免与 reasoning 混淆 | @@ -116,7 +116,7 @@ | snapshot | 快照 | | | spine | 主干 | | | staged | 暂存 | git 官方中文同译 | -| stale | 过期 | 门禁输出保留英文 `stale`,行文译「过期」 | +| stale | 陈旧 | MDN HTTP 缓存中文同译,与「新鲜(fresh)」成对;门禁输出保留英文 `stale`;expired 才译「过期」 | | step | 步骤 | | | stream | 流 | | | streaming | 流式输出 | | From 028277812668f03f0a1ea46b0190c2575e3442f4 Mon Sep 17 00:00:00 2001 From: Ziya <199893125+ZiyaZhang@users.noreply.github.com> Date: Fri, 3 Jul 2026 02:33:42 -0700 Subject: [PATCH 51/75] =?UTF-8?q?docs:=20one=20more=20stale-sense=20?= =?UTF-8?q?=E8=BF=87=E6=9C=9F=20->=20=E9=99=88=E6=97=A7=20in=20the=20i18n?= =?UTF-8?q?=20README=20translation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/i18n/README.zh.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index 6daad930cf..f86c2a2512 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -40,7 +40,7 @@ **排除**(永不配对,门禁拒绝为它们建 `.zh.md`): -- `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/module-graph.md` —— 生成文件;生成器只输出英文,译文在每次重新生成时必然过期。 +- `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/module-graph.md` —— 生成文件;生成器只输出英文,译文在每次重新生成时必然陈旧。 - `docs/AGENTS.md` —— agent 指令,与根 `AGENTS.md` 一样只以英文维护。 - `docs/i18n/terminology.md` —— 术语表本身即是双语构造。 From bb10931478f36107782a48da9287c1eb6616542f Mon Sep 17 00:00:00 2001 From: Ziya <199893125+ZiyaZhang@users.noreply.github.com> Date: Fri, 3 Jul 2026 02:53:08 -0700 Subject: [PATCH 52/75] docs: apply translation-review findings across the four zh files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A four-way parallel review of every EN<->ZH pair (one reviewer per pair, findings verified before applying) surfaced: - terminology: stale-sense 过期 -> 陈旧 in development.zh.md (the table reserves 过期 for expired); GitHub workflow -> GitHub 工作流 per the table; dropped the invented 质量 in 'CI gates' -> CI 门禁; taxonomy -> 分类体系 in translation-rules.zh.md; skill first-occurrence annotation added in the i18n README - verbatim spans: restored inside an inline code span the translation had localized - typography: full-width dashes normalized to no surrounding spaces across all four files (the rules' own MUST); one 顿号 between clauses -> comma; 顿号 before 以及 dropped - fidelity/wording: must-not rendered 不得 (not 不应); local setup -> 本地环境搭建; enforce -> 强制执行; verified surface -> 受验证的范围; batch-lands-before-neighbors nuance restored; 更新粘贴内容 --- README.zh.md | 2 +- docs/development.zh.md | 14 +++++++------- docs/i18n/README.zh.md | 16 ++++++++-------- docs/i18n/translation-rules.zh.md | 20 ++++++++++---------- 4 files changed, 26 insertions(+), 26 deletions(-) diff --git a/README.zh.md b/README.zh.md index 4c911a42e0..62dbb01683 100644 --- a/README.zh.md +++ b/README.zh.md @@ -21,6 +21,6 @@ pnpm run demo:echo # runnable echo-agent example (no API key needed) pnpm run demo:coding # the real DeepSeek coding agent (needs DEEPSEEK_API_KEY) ``` -面向人类读者:先读[开发指南](docs/development.md)了解本地环境、钩子、环境变量与质量门禁,动手改 package 之前再读[架构设计](docs/architecture.md)。局部上下文见 [packages/](packages/) 与 [vendor/](vendor/)。 +面向人类读者:先读[开发指南](docs/development.md)了解本地环境搭建、钩子、环境变量与质量门禁,动手改 package 之前再读[架构设计](docs/architecture.md)。局部上下文见 [packages/](packages/) 与 [vendor/](vendor/)。 面向 agent:遵循 [AGENTS.md](AGENTS.md)。 diff --git a/docs/development.zh.md b/docs/development.zh.md index 5f285fbedf..e08980c33d 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -4,7 +4,7 @@ [English](development.md) | 中文 -本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建,以及本地钩子、日常检查与 CI 质量门禁的说明。 +本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建,并帮助你理解本地钩子、日常检查与 CI 门禁。 ## 前置条件 @@ -67,9 +67,9 @@ vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `v 这些钩子并不与 CI 完全一致。特别是:`pre-push` 跑不带覆盖率的单元测试,而 CI 跑 `pnpm run test:coverage`;CI 还会跑 echo-agent 和 built-bin 冒烟测试,并在 Node 24 和 26 上跑矩阵。 -## CI 质量门禁 +## CI 门禁 -GitHub workflow 在每个 pull request 上运行这些门禁: +GitHub 工作流在每个 pull request 上运行这些门禁: - `pnpm install --frozen-lockfile` - `pnpm run constraints` @@ -136,9 +136,9 @@ pnpm run demo:acp 用三种注释标签之一标记代码中的已知问题,按紧急程度排序: -- `FIXME` —— 应当阻塞新版本发布的问题。除非评审者明确同意可以照常合入,发布不应带着未解决的 `FIXME` 出门。 -- `TODO` —— 应当尽快修复的问题,等资源到位就处理。 -- `XXX` —— 也许某天会修的问题;优先级最低,不作承诺。 +- `FIXME`——应当阻塞新版本发布的问题。除非评审者明确同意可以照常合入,发布不应带着未解决的 `FIXME` 出门。 +- `TODO`——应当尽快修复的问题,等资源到位就处理。 +- `XXX`——也许某天会修的问题;优先级最低,不作承诺。 选择与紧急程度匹配的标签,让扫代码的人一眼分清「发布阻塞」和「有空再说」。 @@ -150,7 +150,7 @@ pnpm run demo:acp { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" } ``` -`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明,并断言文档块与之一致(对空白和注释不敏感,因此文档块可以展示干净的定义、语义由行文承载)。它还强制 1:1 对应:每个 `ts type-equiv` 块恰好有一条 manifest 条目,反之亦然,因此不会有块被静默漏检,也不会有过期条目滞留。`doc-typecheck` 跳过 `ts type-equiv` 块(它们不能独立编译),并将其排除在 opt-out 比例之外。当你改动一个被记录的类型,门禁会失败直到你更新粘贴;当你增删一个块,在同一个变更里更新 manifest。 +`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明,并断言文档块与之一致(对空白和注释不敏感,因此文档块可以展示干净的定义,语义由行文承载)。它还强制 1:1 对应:每个 `ts type-equiv` 块恰好有一条 manifest 条目,反之亦然,因此不会有块被静默漏检,也不会有陈旧条目滞留。`doc-typecheck` 跳过 `ts type-equiv` 块(它们不能独立编译),并将其排除在 opt-out 比例之外。当你改动一个被记录的类型,门禁会失败直到你更新粘贴内容;当你增删一个块,在同一个变更里更新 manifest。 ## 架构上下文 diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index f86c2a2512..d7e0b29dc8 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -16,23 +16,23 @@ ``` - 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的英文文件也能算出指纹(`git hash-object docs/foo.md`),陈旧检测则是纯内容比较。指纹同时也是更新工具:`git cat-file -p ` 能还原陈旧译文当初依据的确切源文本,`git diff <当前 blob>` 能隔离出变化的部分,让译文做最小更新而不是整篇重译。 + 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的英文文件也能算出指纹(`git hash-object docs/foo.md`),陈旧检测则是纯内容比较。指纹同时也是更新工具:`git cat-file -p ` 能还原陈旧译文当初依据的确切源文本,`git diff ` 能隔离出变化的部分,让译文做最小更新而不是整篇重译。 - **语言切换行。**两个文件在各自 H1 标题之后立即互链:英文文件带 `English | [中文](foo.zh.md)`,中文文件带 `[English](foo.md) | 中文`。 - **结构与源一一对应。**标题深度与顺序、列表类型、表格列、链接目标与逐字节一致的代码块和英文文件一一对应——完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。 ## 门禁:verify-translation-pairing -`pnpm run verify-translation-pairing`(`doc-sync` 的一环,因此 CI 和 pre-push 钩子都会运行)机械地强制这份契约: +`pnpm run verify-translation-pairing`(`doc-sync` 的一环,因此 CI 和 pre-push 钩子都会运行)机械地强制执行这份契约: 1. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 中 `required` 列出的每个英文文件都有 `.zh.md` 配对文件。 -2. 每个已存在的 `.zh.md` 文件——无论是否 required——都通过全部检查:其英文源存在(无孤立文件)、指纹等于源的当前 blob hash(无陈旧译文)、双方都带语言切换行、其结构签名与源按序一致——标题深度、逐字节一致的代码块(信息字符串与内容)、表格列数、列表类型、以及除切换行之外的每个链接目标。 +2. 每个已存在的 `.zh.md` 文件——无论是否 required——都通过全部检查:其英文源存在(无孤立文件)、指纹等于源的当前 blob hash(无陈旧译文)、双方都带语言切换行、其结构签名与源按序一致——标题深度、逐字节一致的代码块(信息字符串与内容)、表格列数、列表类型,以及除切换行之外的每个链接目标。 3. 列为 `excluded` 的文件完全没有 `.zh.md` 配对。 `pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前翻译状态——missing、stale 或 ok——是翻译批次的工作清单。它从不失败;它只报告。 -这个门禁带来的实际规则是:**当一个 PR 修改了已有 `.zh.md` 配对的英文文档时,同一个 PR 更新译文**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill),与本仓库既有的代码/README doc-sync 规则完全一致。留下陈旧译文的 PR 会在 CI 变红。 +这个门禁带来的实际规则是:**当一个 PR 修改了已有 `.zh.md` 配对的英文文档时,同一个 PR 更新译文**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill(技能)),与本仓库既有的代码/README doc-sync 规则完全一致。留下陈旧译文的 PR 会在 CI 变红。 -把门禁的边界说白:**门禁绿意味着新鲜且结构健全,不意味着已核验。**它检查指纹和形状;它无法判断中文是否准确、术语是否得当、行文是否自然——那是契约中评审者的那一半,见 [translation-rules.md](translation-rules.md)。一个重打了指纹但翻得潦草的 `.zh.md` 能通过门禁;它不应通过评审。 +把门禁的边界说白:**门禁绿意味着新鲜且结构健全,不意味着已核验。**它检查指纹和形状;它无法判断中文是否准确、术语是否得当、行文是否自然——那是契约中评审者的那一半,见 [translation-rules.md](translation-rules.md)。一个重打了指纹但翻得潦草的 `.zh.md` 能通过门禁;它不得通过评审。 ## 范围、排除与推进 @@ -40,9 +40,9 @@ **排除**(永不配对,门禁拒绝为它们建 `.zh.md`): -- `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/module-graph.md` —— 生成文件;生成器只输出英文,译文在每次重新生成时必然陈旧。 -- `docs/AGENTS.md` —— agent 指令,与根 `AGENTS.md` 一样只以英文维护。 -- `docs/i18n/terminology.md` —— 术语表本身即是双语构造。 +- `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/module-graph.md`——生成文件;生成器只输出英文,译文在每次重新生成时必然陈旧。 +- `docs/AGENTS.md`——agent 指令,与根 `AGENTS.md` 一样只以英文维护。 +- `docs/i18n/terminology.md`——术语表本身即是双语构造。 **推进**:manifest 中的 `required` 列表是强制边界,不是目标。目标是范围内的全量双语覆盖。翻译按可评审的批次落地(核心入口文档、cookbook、RFC、postmortem……);每个批次合入后把其文件加进 `required`,门禁只进不退。尚未进入 `required` 的文档是 backlog——在 `--list` 中可见——但任何已存在的译文无论在不在清单里都按完整契约检查。给一篇文档配对是一份承诺:此后对它的每次英文修改都必须带上译文,所以边界的扩张要跟上翻译评审的实际投入节奏,不要抢在前面。 diff --git a/docs/i18n/translation-rules.zh.md b/docs/i18n/translation-rules.zh.md index 96576782e6..b1cf95a3ef 100644 --- a/docs/i18n/translation-rules.zh.md +++ b/docs/i18n/translation-rules.zh.md @@ -4,7 +4,7 @@ [English](translation-rules.md) | 中文 -本文规定如何把本仓库的文档翻译成简体中文。这些规则对人和 agent(智能体)同等生效;应用它们的进仓 agent 工作流是 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md),配对与新鲜度机制见 [README.md](README.md)。规则级别沿用 RFC 2119 的用法:**必须(MUST)**/**禁止(MUST NOT)**会卡门禁或评审;**应当(SHOULD)**偏离时要说明理由;**可以(MAY)**由译者自行裁量。 +本文规定如何把本仓库的文档翻译成简体中文。这些规则对人和 agent(智能体)同等生效;应用它们的进仓 agent 工作流是 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md),配对与新鲜度机制见 [README.md](README.md)。规则级别沿用 RFC 2119 的用法:**必须(MUST)**/**禁止(MUST NOT)**会卡门禁或评审;**应当(SHOULD)**偏离时要说明理由;**可以(MAY)**自行裁量。 ## 忠实性 @@ -19,9 +19,9 @@ - 标题层级(相同级别、相同顺序——标题的**文字**要翻译), - 列表形态与编号, - 表格(相同的列、相同的行序;表头单元格按术语表翻译), -- 围栏代码块——**逐字节一致,包括注释**;代码属于被验证的表面(` ```ts ` 块要通过 `doc-typecheck` 编译),而被改动的注释是代码块计数门禁看不见的漂移, +- 围栏代码块——**逐字节一致,包括注释**;代码属于受验证的范围(` ```ts ` 块要通过 `doc-typecheck` 编译),而被改动的注释是代码块计数门禁看不见的漂移, - 行内代码(命令、flag、配置键、文件路径、事件名、API 名、版本号)——原样保留,从不翻译或重排, -- 链接与锚点:每个相对链接必须指向与源文相同的目标——即英文正典文件——这样翻译批次先后落地时链接永不悬空。唯一的 zh 特有链接是语言切换行。链接**文字**翻译;链接目标不翻。 +- 链接与锚点:每个相对链接必须指向与源文相同的目标——即英文正典文件——这样某批译文先于相邻文件落地时,链接也永不悬空。唯一的 zh 特有链接是语言切换行。链接**文字**翻译;链接目标不翻。 本仓库的 Markdown 约定对 `.zh.md` 文件原样生效:一个段落一个物理行(`verify-md-wrap`)、相对链接必须可解析(`verify-md-links`)、文件末尾恰好一个换行。 @@ -53,10 +53,10 @@ 本文各规则引用的权威出处,供想了解底层依据的人和 agent 查阅: -- [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines) —— 中西文混排空格与标点的社区事实标准。 -- [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md) —— 与本文同形态的进仓翻译规则文件;空格、标点与术语表实践。 -- [Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/) —— 最大的中文本地化团队的术语首现与标点实践。 -- [Vue.js docs-zh-cn 翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5) —— 逐术语的译/留决策与语气。 -- [zh-style-guide](https://zh-style-guide.readthedocs.io) —— 社区中文技术文档写作规范,本文借用了它的规则分类粒度(与 RFC 2119 关键词分级);它聚合了 GB/T 15834/15835、clreq 与各厂商指南。 -- [W3C clreq](https://www.w3.org/TR/clreq/) 与[微软简体中文风格指南](https://learn.microsoft.com/en-us/globalization/reference/microsoft-style-guides) —— 排版学与厂商本地化的正式基线。 -- GB/T 19682-2005《翻译服务译文质量要求》 —— 国家标准;本文「忠实性」与「术语」两节把它的三项基本要求(忠实原文、术语统一、行文通顺)落成可操作规则。 +- [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines)——中西文混排空格与标点的社区事实标准。 +- [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md)——与本文同形态的进仓翻译规则文件;空格、标点与术语表实践。 +- [Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/)——最大的中文本地化团队的术语首现与标点实践。 +- [Vue.js docs-zh-cn 翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5)——逐术语的译/留决策与语气。 +- [zh-style-guide](https://zh-style-guide.readthedocs.io)——社区中文技术文档写作规范,本文借用了它的规则级别分类体系(与 RFC 2119 关键词分级);它聚合了 GB/T 15834/15835、clreq 与各厂商指南。 +- [W3C clreq](https://www.w3.org/TR/clreq/) 与[微软简体中文风格指南](https://learn.microsoft.com/en-us/globalization/reference/microsoft-style-guides)——排版学与厂商本地化的正式基线。 +- GB/T 19682-2005《翻译服务译文质量要求》——国家标准;本文「忠实性」与「术语」两节把它的三项基本要求(忠实原文、术语统一、行文通顺)落成可操作规则。 From dee2dee4022f87357e3059c45f24620dd288d463 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:00:16 +0800 Subject: [PATCH 53/75] fix(tool-fs): CRLF-safe write diff, opaque meta, doc sync Address the applied-hunk-diffs review: - CRLF write overwrite emitted bogus every-line-changed hunks: write's `before` was LF-normalized but `after` kept the raw model content, so a CRLF rewrite of an LF file diffed every line. Normalize write's `after` to LF so both sides share the diff basis (edit already did). Regression test proves it fails on the raw-after path. - The tool-private `meta` payload is now typed `unknown` (opaque) at every seam instead of `JsonValue`. This drops the `dsh-tools -> dsh-session` package edge that existed only to name the type, and removes the `FileDiff` index signature that had been widening the type solely for JsonValue-assignability. Serializability is still enforced at runtime by `Session.append`'s isJsonValue check, which was always the real guard. - Sync the docs the new result/meta surface left stale: ToolResultView's diff card + ToolExecutionResult.meta in tools.md/session.md type-equiv blocks, the acp/tools READMEs, and the adding-a-tool cookbook; regenerate the cordis catalog and module graph. --- docs/cookbook/adding-a-tool.md | 3 +- docs/cordis-catalog/events-and-services.md | 6 ++-- docs/core-data-structures/session.md | 2 +- docs/core-data-structures/tools.md | 4 +-- docs/module-graph.md | 3 +- ...26-07-02-result-time-applied-hunk-diffs.md | 8 ++--- packages/core/session/src/types.ts | 15 +++++---- packages/core/tools/README.md | 4 +-- packages/core/tools/package.json | 2 -- packages/core/tools/src/index.ts | 31 +++++++------------ packages/fs/fs-local/src/fsio.ts | 2 +- packages/fs/fs-local/src/index.ts | 6 +++- packages/fs/fs-local/tests/filesystem.spec.ts | 12 ++++--- packages/fs/fs/src/types.ts | 6 ++-- packages/fs/tool-fs/src/diff.ts | 18 +++++------ packages/ui/acp/README.md | 2 +- packages/ui/acp/src/index.ts | 4 +-- pnpm-lock.yaml | 3 -- 18 files changed, 63 insertions(+), 68 deletions(-) diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index c5e7931871..84247e3f3d 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -36,6 +36,7 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool (w - **Args are validated for you.** `defineTool` validates the model-generated `arguments` against the `SchemaSpec` before `execute` runs (type, required keys, enum membership, nested objects/arrays — [runtime arg validation](../rfc/implemented/architecture/2026-06-11-runtime-arg-validation.md)), so inside `execute` the args already match `InferArgs`. You still hand-check value constraints the DSL can't express (non-empty strings, positive numbers, cross-field rules); throw a descriptive Error for those. Raw JSON-Schema tools registered directly (MCP) are NOT validated by the harness — they validate their own input. - **Throwing means isError.** The registry catches anything `execute()` throws and returns `{isError: true}` to the model. Use that for infrastructure failures (bad input, spawn errors, aborts) — but REPORT domain failures in the result text instead (e.g. tool-bash returns `[exit code: 9]` with `isError: false`: the model decides what a failing command means). - **Honor `exec.signal`.** Cancel in-flight work when it fires. +- **Attach durable card data with `meta` (optional).** `execute` may return `{ content, meta }` instead of a bare `ContentBlock[]` — `meta` is a JSON-serializable payload the core treats as opaque, persisted on the `tool/result` event and handed back to your `presentResult` (so a card that needs more than `args`, like `write`/`edit`'s applied-hunk diff, survives a session replay). Keep UI-only data here, never in the model-facing `content`. - **Use `exec.agent` for async notifications.** `agent.inject(content, {source: {kind: 'plugin', plugin: ''}})` appends durable context the NEXT model request sees — it is not a wake-up (an idle agent stays idle). Guard against disposed agents (try/catch). ## Long-running work @@ -58,7 +59,7 @@ Both methods return a **`card`-tagged render intent** — pick the card kind tha - `{ card: 'generic', title, kind?, rawInput?, content?, locations? }` — the default. Set `kind` for an icon (`read`/`search`/…); set `locations: [{ path, line? }]` for any file your tool touches so a capable editor follows along / jumps to it. - `{ card: 'terminal', title, description?, cwd? }` — your call IS a shell command. `title` is the command, `description` renders above the terminal card. (tool-bash.) - `{ card: 'diff', title, diffs, locations? }` — your call creates or modifies a file. `diffs: [{ path, oldText, newText }]` (`oldText: null` for a new file) renders as an inline diff card. (tool-fs `write`/`edit`.) -- `presentResult(args, { content, isError })` → a `ToolResultView` (the COMPLETED card): `{ card: 'generic', title?, content? }` or `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the run's captured output + exit — the bridge shows an exit pill and derives a fenced ` ```console ` fallback for editors without the terminal capability). +- `presentResult(args, { content, isError, meta? })` → a `ToolResultView` (the COMPLETED card): `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the run's captured output + exit — the bridge shows an exit pill and derives a fenced ` ```console ` fallback for editors without the terminal capability), or `{ card: 'diff', title?, diffs }` (the APPLIED hunks of a completed file mutation, computed from the before/after content — `write`/`edit` attach the hunks via the `meta` channel and read them back here). `result.meta` is your tool's own optional presentation payload, attached from `execute` (see below) and persisted so a replay reproduces the card. Hard rules (they bite if broken): diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 90419feeb9..6854e56e2e 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -337,7 +337,7 @@ A tool was registered or unregistered (the available tool set changed). 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:49`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:48`](../../packages/core/tools/src/index.ts) #### `tools/execute` — waterfall @@ -349,7 +349,7 @@ Waterfall around every tool execution — the single seam where sandbox, permiss Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:44`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:43`](../../packages/core/tools/src/index.ts) ## Services @@ -547,7 +547,7 @@ async execute(exec: ToolExecution): Promise 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:370`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:363`](../../packages/core/tools/src/index.ts) ## Inherited tier (cordis core + loader/hmr/timer) diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 789890680f..41cad4660d 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -32,7 +32,7 @@ interface SessionEventMap { */ 'assistant/message': { turn: number; step: number; content: ContentBlock[]; usage?: TokenUsage } 'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string } - 'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: JsonValue } + 'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: unknown } /** Steering content injected between steps of a running turn. */ 'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource } /** diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index ac765d25cd..4f9be38e21 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -106,7 +106,7 @@ interface ToolExecutionResult { * {@link ToolResult} for `presentResult`. Opaque {@link JsonValue}; absent when * the tool attached none or the call failed. */ - meta?: JsonValue + meta?: unknown } ``` @@ -117,7 +117,7 @@ A waterfall listener receives `(exec, next)`: call `next()` to proceed (possibly 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`/`presentResult` return a **`card`-tagged render intent** — a discriminated union a UI bridge switches on: - `ToolCallView` (pending): `{ card: 'generic', title, kind?, rawInput?, content?, locations? }` (the default card; `locations` is `{ path, line? }[]` files the call reads/modifies, for editor follow-along), `{ card: 'terminal', title, description?, cwd? }` (a shell command → a terminal card), or `{ card: 'diff', title, diffs, locations? }` (a file create/modify → an inline diff card; `diffs` is `{ path, oldText, newText }[]`, `oldText: null` for a new file). -- `ToolResultView` (completed): `{ card: 'generic', title?, content? }` or `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit; a capable UI shows an exit-status pill, an incapable one gets a fenced ` ```console ` fallback the bridge derives from `output`). +- `ToolResultView` (completed): `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit; a capable UI shows an exit-status pill, an incapable one gets a fenced ` ```console ` fallback the bridge derives from `output`), or `{ card: 'diff', title?, diffs }` (a completed file mutation → the APPLIED hunks with context lines, one entry per changed site, computed from the before/after file content — distinct from the call-time whole-snippet `diff`, which it supersedes). `ToolCallKind` (`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`) picks an icon on a generic card. `FileLocation` (`{ path, line? }`) and `FileDiff` (`{ path, oldText, newText }`) are the shared file-card vocabulary. The design is pinned in [the render-intent-union RFC](../rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md); the ACP bridge maps a `diff` card to a `{ type: 'diff' }` content block, a `terminal` card to the `_meta` terminal convention, and relativizes a file card's title against the session cwd. diff --git a/docs/module-graph.md b/docs/module-graph.md index d09fd76eaf..8de73f8d80 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -40,7 +40,6 @@ graph TD session-persistence-sqlite --> session-persistence tools --> agent tools --> llm - tools --> session tools --> system-prompt ui-stdio --> agent ui-stdio --> llm @@ -130,7 +129,7 @@ graph TD | `invariants` | `agent`, `llm`, `session` | | `session-persistence-jsonl` | `session`, `session-persistence` | | `session-persistence-sqlite` | `session`, `session-persistence` | -| `tools` | `agent`, `llm`, `session`, `system-prompt` | +| `tools` | `agent`, `llm`, `system-prompt` | | `ui-stdio` | `agent`, `llm`, `session` | | `acp` | `agent`, `llm`, `session`, `session-persistence`, `tools` | | `agent-loop` | `agent`, `llm`, `session`, `session-persistence`, `system-prompt`, `tools` | diff --git a/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md b/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md index 591dbb4779..20328041f0 100644 --- a/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md +++ b/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md @@ -16,13 +16,13 @@ Add a **persisted, tool-private presentation channel** so a tool's `execute` can ### 1. A `meta` channel on the tool result (core) -`ToolDefinition.execute` may now return either its model-facing `ContentBlock[]` (unchanged, the common case) OR `{ content: ContentBlock[]; meta?: JsonValue }`: +`ToolDefinition.execute` may now return either its model-facing `ContentBlock[]` (unchanged, the common case) OR `{ content: ContentBlock[]; meta?: unknown }`: ```ts ignore-check -type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: JsonValue } +type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unknown } ``` -`meta` is an opaque, JSON-serializable payload the core never interprets. The registry threads it onto the `tool/result` **session event** (`{ …, meta?: JsonValue }`), so it is persisted with the log; on replay the same `meta` is read back and handed to `presentResult` via a widened `ToolResult` (`{ content, isError, meta? }`). Because the payload lives in the event log, the diff reproduces on session reload / snapshot replay **for free** — the event-sourcing guarantee, not a re-computation. `JsonValue` is exported from `dsh-session` (paired with the existing `isJsonValue` predicate that already gates every event's serializability at `append`). +`meta` is an opaque payload the core never interprets — typed `unknown` at every seam (the tool that produced it owns and narrows its shape). It MUST be JSON-serializable: the registry threads it onto the `tool/result` **session event**, and `Session.append` runtime-validates all event data with the existing `isJsonValue` predicate, so a non-serializable `meta` is rejected at the source. On replay the same `meta` is read back and handed to `presentResult` via a widened `ToolResult` (`{ content, isError, meta? }`). Because the payload lives in the event log, the diff reproduces on session reload / snapshot replay **for free** — the event-sourcing guarantee, not a re-computation. Typing `meta` as `unknown` (rather than a shared serializable-value type) keeps the tools core free of a dependency it would otherwise take just to name the type, and the runtime `isJsonValue` gate — not the static type — is what actually enforces serializability. This is the general shape ("a tool attaches durable result presentation"), not an fs-specific one — any tool can use it. @@ -39,7 +39,7 @@ Per the [capability-seam split](2026-06-13-capability-seams.md), the storage bac ### The diff algorithm — a third-party runtime dependency over vendoring -Computing hunks-with-context is a solved problem with sharp edge cases (grouping, context coalescing, the trailing-newline marker). Rather than hand-roll it, `dsh-tool-fs` takes a runtime dependency on the npm [`diff`](https://www.npmjs.com/package/diff) package (v9, ships its own types) and uses its `structuredPatch`. The repo's default is to vendor Cordis-framework source, but that policy is about the *framework*; a leaf tool package taking a small, well-known, self-typed utility dependency is the same shape as `dsh-acp` depending on `@agentclientprotocol/sdk`. Vendoring a diff algorithm would be re-implementing a battle-tested one for no benefit — the [pre-release "foundation over blast radius"](../../../../AGENTS.md) reasoning does not argue for re-deriving standard algorithms. The dependency is pinned and its output is normalized in one small module (`packages/fs/tool-fs/src/diff.ts`). +Computing hunks-with-context is a solved problem with sharp edge cases (grouping, context coalescing, the trailing-newline marker). Rather than hand-roll it, `dsh-tool-fs` takes a runtime dependency on the npm [`diff`](https://www.npmjs.com/package/diff) package (a `^9.0.0` range, exact-pinned by the lockfile; it ships its own types) and uses its `structuredPatch`. The repo's default is to vendor Cordis-framework source, but that policy is about the *framework*; a leaf tool package taking a small, well-known, self-typed utility dependency is the same shape as `dsh-acp` depending on `@agentclientprotocol/sdk`. Vendoring a diff algorithm would be re-implementing a battle-tested one for no benefit — the [pre-release "foundation over blast radius"](../../../../AGENTS.md) reasoning does not argue for re-deriving standard algorithms. The dependency's output is normalized in one small module (`packages/fs/tool-fs/src/diff.ts`). ## Non-goals diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 6a9eaa0fc1..62c3a33e49 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -1,6 +1,5 @@ import type { Branded } from '@deepseek-ai/dsh-brand' import type { CallId, ContentBlock, MessageSource, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm' -import type { JsonValue } from './json.ts' /** Identifies one session in the store (and its persistence artifacts). */ export type SessionId = Branded<'SessionId'> @@ -213,14 +212,14 @@ export interface SessionEventMap { 'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string } /** * A completed tool call's model-facing result, plus an optional tool-private - * `meta` presentation payload. `meta` is opaque to the core — the producing - * tool owns its shape and reads it back in `presentResult` — and is a - * {@link JsonValue} so it persists in the durable log and reproduces on replay - * (a UI bridge renders the identical card from a loaded session). Absent unless - * the tool attaches one (e.g. `dsh-tool-fs` carries its result-time contextual - * diff here). + * `meta` presentation payload. `meta` is opaque to the core (`unknown` — the + * producing tool owns its shape and reads it back in `presentResult`) but MUST + * be JSON-serializable: `Session.append` runtime-validates all event data with + * `isJsonValue`, so a non-serializable `meta` is rejected at the source, and the + * durable log reproduces the identical card on replay. Absent unless the tool + * attaches one (e.g. `dsh-tool-fs` carries its result-time contextual diff here). */ - 'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: JsonValue } + 'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: unknown } /** Steering content injected between steps of a running turn. */ 'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource } /** diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index f6694269ea..c8a3ddba4b 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -26,7 +26,7 @@ Tool registry and execution waterfall. Tool plugins register their schemas and e - `ToolDefinition` — `ToolSchema` + `execute(args, exec): Promise` (the bare array is the model-facing content; the object form additionally attaches an opaque, JSON-serializable `meta` presentation payload persisted on the `tool/result` event and handed back to `presentResult`), plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below). - `ToolExecution` — one pending tool call: `{ callId, name, arguments, agent?, signal? }`. -- `ToolExecutionResult` — outcome: `{ callId, content, isError, error? }`. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text (the loop forwards it onto the `tool/result` session event for retry/sandbox plugins and replay). +- `ToolExecutionResult` — outcome: `{ callId, content, isError, error?, meta? }`. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text. `meta` is the tool's opaque presentation payload from a successful `execute` (the object return form); the loop forwards both `error` and `meta` onto the `tool/result` session event (for retry/sandbox plugins, replay, and result-card rendering). - `ToolCallView` / `ToolResultView` — provider-neutral `card`-tagged render intents a tool returns from `presentCall` / `presentResult` to own how a UI renders ITS calls (see "Tool-owned UI presentation"). ### Extension points @@ -81,7 +81,7 @@ A tool owns how ITS calls render in a UI (an editor's tool-call card, a CLI log - `{ card: 'terminal', title?, output?, exitCode?, signal? }` — a terminal run's captured `output` and exit status. A capable UI shows an exit-status pill; an incapable UI gets a fenced ` ```console ` fallback the BRIDGE derives from `output` (the tool does not encode the fences). - `{ card: 'diff', title?, diffs }` — a completed file mutation as an inline diff. `diffs` is `FileDiff[]` — the APPLIED hunks with surrounding context (one entry per changed site), computed from the before/after file content, distinct from the call-time whole-snippet `diff`. Used by `write`/`edit`; a `tool_call_update.content` replaces the call's content, so this supersedes the pending snippet. -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. `result.meta` is the tool's own optional presentation payload (`JsonValue`), attached by `execute` (see below) and persisted on the `tool/result` event, so a `presentResult` reading it stays replay-deterministic (the same `meta` is read back from the log). With `defineTool`, `args` is the typed `InferArgs` 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 views are provider-neutral — the ACP bridge (`dsh-acp`) maps each `card` to ACP `tool_call`/`tool_call_update` wire fields (a `diff` card to a `{ type: 'diff' }` content block, a `terminal` card to the `_meta` terminal convention), and relativizes a file card's title against the session cwd. See the render-intent-union RFC (`docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md`) and the applied-hunk-diffs RFC (`docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md`); `dsh-tool-bash` (terminal) and `dsh-tool-fs` (diff/generic) are the reference implementations. +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. `result.meta` is the tool's own optional presentation payload (opaque `unknown`, JSON-serializable), attached by `execute` (see below) and persisted on the `tool/result` event, so a `presentResult` reading it stays replay-deterministic (the same `meta` is read back from the log). With `defineTool`, `args` is the typed `InferArgs` 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 views are provider-neutral — the ACP bridge (`dsh-acp`) maps each `card` to ACP `tool_call`/`tool_call_update` wire fields (a `diff` card to a `{ type: 'diff' }` content block, a `terminal` card to the `_meta` terminal convention), and relativizes a file card's title against the session cwd. See the render-intent-union RFC (`docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md`) and the applied-hunk-diffs RFC (`docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md`); `dsh-tool-bash` (terminal) and `dsh-tool-fs` (diff/generic) are the reference implementations. ```ts import { defineTool } from '@deepseek-ai/dsh-tools' diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json index 05394ea118..a6d3bbe0ca 100644 --- a/packages/core/tools/package.json +++ b/packages/core/tools/package.json @@ -24,14 +24,12 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "cordis": "^4.0.0-rc.6" } diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 0aa19a7b9a..acae166abe 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -11,7 +11,6 @@ import { Context, Service } from 'cordis' import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { JsonValue } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' export { @@ -88,13 +87,6 @@ export interface FileDiff { oldText: string | null /** Content after the change. */ newText: string - /** - * Index signature so a `FileDiff` is a valid {@link JsonValue} member — a tool - * persists result-time diffs as `tool/result` `meta`, which must round-trip - * through the session log. Every declared field is already JSON-compatible; - * this only makes the structural compatibility explicit. - */ - [key: string]: string | null } /** @@ -247,12 +239,13 @@ export interface DiffResultView { /** * What a tool's `execute` returns. The bare {@link ContentBlock}`[]` form is the * common case (model-facing content only); the object form additionally attaches - * a tool-private `meta` presentation payload ({@link JsonValue}) that the - * registry threads onto the `tool/result` session event and hands back to the - * tool's `presentResult`. `meta` is opaque to the core — the tool owns its shape - * and validates it on the way out — and persists so replay reproduces the card. + * a tool-private `meta` presentation payload that the registry threads onto the + * `tool/result` session event and hands back to the tool's `presentResult`. + * `meta` is opaque to the core (`unknown` — the tool owns and narrows its shape), + * and MUST be JSON-serializable: it persists on the durable log (the session + * enforces this at `append`), so replay reproduces the card. */ -export type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: JsonValue } +export type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unknown } /** A registered tool: its schema plus the execution function. */ export interface ToolDefinition extends ToolSchema { @@ -286,10 +279,10 @@ export interface ToolResult { /** * The tool-private presentation payload the tool attached from `execute` (via * the object return form), threaded verbatim from the `tool/result` event. - * Opaque {@link JsonValue}; the tool narrows it back to its own shape. Absent - * when the tool attached none. + * Opaque (`unknown`); the tool narrows it back to its own shape. Absent when + * the tool attached none. */ - meta?: JsonValue + meta?: unknown } /** One pending tool call, as it flows through the execution waterfall. */ @@ -336,10 +329,10 @@ export interface ToolExecutionResult { /** * The tool-private presentation payload from a successful `execute` (the object * return form). Threaded onto the `tool/result` session event and back into - * {@link ToolResult} for `presentResult`. Opaque {@link JsonValue}; absent when - * the tool attached none or the call failed. + * {@link ToolResult} for `presentResult`. Opaque (`unknown`); absent when the + * tool attached none or the call failed. */ - meta?: JsonValue + meta?: unknown } /** diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index 9b77678f82..e2aead2bfc 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -429,4 +429,4 @@ export function applyLiteralEdit( return { content: content.split(oldNorm).join(newNorm), replacements } } -export { restoreLineEndings } +export { normalizeLineEndings, restoreLineEndings } diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index 3ce0fa2f92..30c65058c9 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -26,6 +26,7 @@ import type { } from '@deepseek-ai/dsh-fs' import { applyLiteralEdit, + normalizeLineEndings, probe, readForEdit, readTextForDiff, @@ -156,7 +157,10 @@ export class LocalFileSystem extends FileSystem { operation: existing ? 'update' : 'create', version: this.versionAfterWrite(after, target), before, - after: content, + // LF-normalized to share the diff basis with `before` (also LF): a CRLF + // overwrite must not read as every line changed. Line-ending restoration + // is a storage detail the applied-hunk diff ignores. + after: normalizeLineEndings(content), } }) } diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts index b6686b9627..63baacd47b 100644 --- a/packages/fs/fs-local/tests/filesystem.spec.ts +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -203,11 +203,15 @@ describe('writeText', () => { expect(outcome.after).toBe('new body') }) - it('an overwrite of a CRLF file returns LF-normalized before content', async () => { - await writeFile(join(dir, 'a.txt'), 'a\r\nb\r\n') + it('an overwrite returns LF-normalized before AND after (a CRLF rewrite is not every-line-changed)', async () => { + // The applied-hunk diff bases on `before`/`after`; if `after` kept CRLF while + // `before` is LF-normalized, a CRLF rewrite would read as every line changed. + // Both sides are LF so only the genuinely-changed line diffs. + await writeFile(join(dir, 'a.txt'), 'a\r\nb\r\nc\r\n') const target = await fs.resolve('a.txt') - const outcome = await fs.writeText(target, 'a\nB\n') - expect(outcome.before).toBe('a\nb\n') + const outcome = await fs.writeText(target, 'a\r\nB\r\nc\r\n') + expect(outcome.before).toBe('a\nb\nc\n') + expect(outcome.after).toBe('a\nB\nc\n') }) it('an overwrite of a BINARY prior file reports before:null (undiffable), still succeeds', async () => { diff --git a/packages/fs/fs/src/types.ts b/packages/fs/fs/src/types.ts index 2bc6f27765..1b768724cf 100644 --- a/packages/fs/fs/src/types.ts +++ b/packages/fs/fs/src/types.ts @@ -103,11 +103,11 @@ export interface FsWriteOutcome { version: FsVersion /** * The file's content BEFORE the write, or `null` when the file did not exist - * (a create). Raw storage text (LF-normalized by the backend), never a diff — - * a consumer computes the result-time contextual diff from `before`/`after`. + * (a create). LF-normalized storage text (the diff basis), never a diff — a + * consumer computes the result-time contextual diff from `before`/`after`. */ before: string | null - /** The file's content AFTER the write (the text that was written). */ + /** The file's content AFTER the write, LF-normalized to share `before`'s diff basis. */ after: string } diff --git a/packages/fs/tool-fs/src/diff.ts b/packages/fs/tool-fs/src/diff.ts index 51d39bb3d0..273fe0b033 100644 --- a/packages/fs/tool-fs/src/diff.ts +++ b/packages/fs/tool-fs/src/diff.ts @@ -14,17 +14,17 @@ import { structuredPatch } from 'diff' import type { FileDiff } from '@deepseek-ai/dsh-tools' -import type { JsonValue } from '@deepseek-ai/dsh-session' /** Context lines shown on each side of an applied hunk (matches claude-agent-acp). */ export const DIFF_CONTEXT = 3 /** * The `write`/`edit` tools' private `tool/result` `meta` payload: the applied - * contextual-diff hunks. A {@link JsonValue} (persisted with the session log, so - * `presentResult` reproduces the diff card on replay). The producing tool owns - * this shape; the bridge only sees the opaque `meta` and the tool narrows it back - * via {@link diffsFromMeta}. + * contextual-diff hunks. Attached opaquely (as `unknown`) on the tool result and + * persisted with the session log — it must be JSON-serializable (the session + * validates this at `append`), so `presentResult` reproduces the diff card on + * replay. The producing tool owns this shape; the bridge only sees the opaque + * `meta` and the tool narrows it back via {@link diffsFromMeta}. */ export type FsDiffMeta = { diffs: FileDiff[] } @@ -68,9 +68,9 @@ export function computeHunkDiffs(path: string, before: string, after: string): F } /** Whether `value` is a valid {@link FileDiff} (defensive narrowing from opaque `meta`). */ -function isFileDiff(value: JsonValue): value is FileDiff & JsonValue { +function isFileDiff(value: unknown): value is FileDiff { if (typeof value !== 'object' || value === null || Array.isArray(value)) return false - const { path, oldText, newText } = value + const { path, oldText, newText } = value as Record return typeof path === 'string' && (oldText === null || typeof oldText === 'string') && typeof newText === 'string' @@ -83,9 +83,9 @@ function isFileDiff(value: JsonValue): value is FileDiff & JsonValue { * it validates defensively rather than trusting the payload — a bad `meta` yields * no diff card (the generic result rendering) instead of a thrown presenter. */ -export function diffsFromMeta(meta: JsonValue | undefined): FileDiff[] | undefined { +export function diffsFromMeta(meta: unknown): FileDiff[] | undefined { if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined - const diffs = meta.diffs + const diffs = (meta as Record).diffs if (!Array.isArray(diffs) || diffs.length === 0 || !diffs.every(isFileDiff)) return undefined return diffs } diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index 32e4326b99..ac38f36f58 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -48,7 +48,7 @@ How a tool call renders in the editor is owned by the TOOL, not the bridge — t - `{ card: 'terminal', title, description?, cwd? }` — a shell command → a terminal card (see Terminal card). - `{ card: 'diff', title, diffs, locations? }` — a file create/modify → an inline diff card; `diffs` is `FileDiff[]` (`{ path, oldText, newText }`, `oldText: null` ⇒ new file). The bridge emits each diff as an ACP `{ type: 'diff', path, oldText, newText }` `tool_call.content` block, which Zed renders as an inline diff / new-file preview. -`presentResult` returns a `ToolResultView`, one of two cards: `{ card: 'generic', title?, content? }` (an optional replacement `title` and reformatted `content`) or `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit — see Terminal card). The bridge looks the definition up by name in `ctx.tools` and `switch (view.card)`es to build the ACP `tool_call`/`tool_call_update` wire shape per card; 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` returns a `terminal` card for a foreground `bash` (title = the exact `command` "ls -la src", `description` = the model description); the `dsh-tool-fs` `write`/`edit` tools return a `diff` card and `read` returns a `generic` card (`kind: 'read'`, the read window in its title — `Read foo.txt (5 - 8)` — and a `locations` entry for the file). For a file card the bridge **relativizes the title** against the session cwd (mirroring `claude-agent-acp`'s `toDisplayPath` — `Read src/foo.ts`, not the absolute path) while keeping `locations[]`/`diffs[].path` **raw** so the editor opens the real path. +`presentResult` returns a `ToolResultView`, one of three cards: `{ card: 'generic', title?, content? }` (an optional replacement `title` and reformatted `content`), `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit — see Terminal card), or `{ card: 'diff', title?, diffs }` (a completed file mutation → the APPLIED hunks with context lines computed from the before/after content, which supersede the call-time snippet). The bridge looks the definition up by name in `ctx.tools` and `switch (view.card)`es to build the ACP `tool_call`/`tool_call_update` wire shape per card; 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` returns a `terminal` card for a foreground `bash` (title = the exact `command` "ls -la src", `description` = the model description); the `dsh-tool-fs` `write`/`edit` tools return a `diff` call card and a `diff` result card, and `read` returns a `generic` card (`kind: 'read'`, the read window in its title — `Read foo.txt (5 - 8)` — and a `locations` entry for the file). For a file card the bridge **relativizes the title** against the session cwd (mirroring `claude-agent-acp`'s `toDisplayPath` — `Read src/foo.ts`, not the absolute path) while keeping `locations[]`/`diffs[].path` **raw** so the editor opens the real path. 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. diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 631a741447..24cdc58f19 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -66,7 +66,7 @@ import { assertNever, CallId } from '@deepseek-ai/dsh-llm' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' -import type { JsonValue, SessionEvent, TodoItem, TurnEndReason } from '@deepseek-ai/dsh-session' +import type { SessionEvent, TodoItem, TurnEndReason } from '@deepseek-ai/dsh-session' import type { ToolCallKind, ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } from '@deepseek-ai/dsh-tools' // Side-effect type import: declaration-merges `ctx.sessionPersistence` onto // Context (the bridge injects it and reads `list()` for load cwd validation). @@ -919,7 +919,7 @@ export class ToolPresenter { } /** Completed-state render intent for a `tool/result`; consumes the remembered `(name, args, card)`. */ - result(callId: CallId, content: ContentBlock[], isError: boolean, meta?: JsonValue): ToolResultView { + result(callId: CallId, content: ContentBlock[], isError: boolean, meta?: unknown): ToolResultView { const call = this.pending.get(callId) this.pending.delete(callId) // No remembered call (unknown/late callId) → nothing to present from; raw content. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db4b513b10..9b974809cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -271,9 +271,6 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../session '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../system-prompt From a928a5a47a92d3082c7636da41e4bfa2bfdac300 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 3 Jul 2026 19:04:11 +0800 Subject: [PATCH 54/75] docs(acp): don't enumerate tool/result fields in the presenter note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The callId→args map note said the tool/result event "carries only { callId, content, isError }" — an exhaustive field list that drifts as the event grows (it also carries error, and now meta). State the load- bearing fact instead: the event omits the tool name/args, which is why the bridge remembers them per callId. --- packages/ui/acp/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index 32e4326b99..7cb425cf74 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -50,7 +50,7 @@ How a tool call renders in the editor is owned by the TOOL, not the bridge — t `presentResult` returns a `ToolResultView`, one of two cards: `{ card: 'generic', title?, content? }` (an optional replacement `title` and reformatted `content`) or `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit — see Terminal card). The bridge looks the definition up by name in `ctx.tools` and `switch (view.card)`es to build the ACP `tool_call`/`tool_call_update` wire shape per card; 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` returns a `terminal` card for a foreground `bash` (title = the exact `command` "ls -la src", `description` = the model description); the `dsh-tool-fs` `write`/`edit` tools return a `diff` card and `read` returns a `generic` card (`kind: 'read'`, the read window in its title — `Read foo.txt (5 - 8)` — and a `locations` entry for the file). For a file card the bridge **relativizes the title** against the session cwd (mirroring `claude-agent-acp`'s `toDisplayPath` — `Read src/foo.ts`, not the absolute path) while keeping `locations[]`/`diffs[].path` **raw** so the editor opens the real path. -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. +The `tool/result` session event does not carry 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. ## Terminal card (capability-gated) From f86e0bedecd3d82c3ba5a8f328f8064bfb2370b1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 3 Jul 2026 19:05:46 +0800 Subject: [PATCH 55/75] docs(tools): sync ToolExecutionResult.meta comment to `unknown` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pasted type-equiv block's JSDoc still said the meta payload is `{@link JsonValue}`; the source comment is `unknown` (the meta channel is opaque at the seam). verify-type-equiv compares type structure, not the comment, so the drift slipped through — align the doc comment. --- docs/core-data-structures/tools.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 4f9be38e21..04d8c33ce7 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -103,8 +103,8 @@ interface ToolExecutionResult { /** * The tool-private presentation payload from a successful `execute` (the object * return form). Threaded onto the `tool/result` session event and back into - * {@link ToolResult} for `presentResult`. Opaque {@link JsonValue}; absent when - * the tool attached none or the call failed. + * {@link ToolResult} for `presentResult`. Opaque (`unknown`); absent when the + * tool attached none or the call failed. */ meta?: unknown } From 4d36c0466bd41d9d8693d5c29c195a84d67562f7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 3 Jul 2026 19:20:14 +0800 Subject: [PATCH 56/75] docs(acp): drop tool/result field enumeration in ToolPresenter comment Same stale enumeration as the presenter-note fix, in the ToolPresenter JSDoc: it said the tool/result event "carries only { callId, content, isError }". The event also carries error and meta; the load-bearing fact is that it omits the tool name/args (why the presenter remembers them per callId). State that instead of an exhaustive list that drifts. --- packages/ui/acp/src/index.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index c01c004d91..231848de01 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -869,11 +869,11 @@ const noTerminalRendering: TerminalRendering = { enabled: false, cwd: undefined * by name in the registry and applies a generic fallback when a tool defines * neither. The returned view is what {@link streamSessionEventUpdate} switches on. * - * The `tool/result` session event carries only `{ callId, content, isError }` — - * NOT the tool name or args — so to call a tool's `presentResult` (which needs - * both), the presenter remembers each `tool/call`'s `{ name, args, card }` keyed - * by callId and looks it up on the matching result. The map is bridge-LOCAL (not - * a change to the event schema or a core service): one presenter per live session + * The `tool/result` session event does NOT carry the tool name or args — so to + * call a tool's `presentResult` (which needs both), the presenter remembers each + * `tool/call`'s `{ name, args, card }` keyed by callId and looks it up on the + * matching result. The map is bridge-LOCAL (not a change to the event schema or a + * core service): one presenter per live session * (and a throwaway per `session/load` replay), and each entry is removed when its * result arrives. In the normal loop a `tool/call` is always followed by a * `tool/result` (the registry turns even a thrown tool into an isError result), From 53b215c646d47adebefa51f0ac1bf06770dc82cf Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 3 Jul 2026 20:52:36 +0800 Subject: [PATCH 57/75] fix(tool-fs): write always renders a diff card on the completed update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Write CREATE rendered its completed tool_call_update as the model-facing result TEXT (`…Created file`), which — because an ACP tool_call_update.content REPLACES the call's content — clobbered the new-file diff the pending call installed. So Zed showed the diff, then replaced it with raw XML-ish text; only overwrite/edit looked right (their result re-sends a diff). write's presentResult now ALWAYS returns a diff card for a successful write: the applied contextual hunk from `meta` when there is one (overwrite), else an args-derived whole-file diff (`oldText: null`) for a create or an unchanged-content overwrite. This matches claude-agent-acp, where the create diff rides on the update and no result text replaces it. An error still falls through to generic rendering so its message shows. edit is unchanged (it always has a hunk; no whole-file fallback). Re-recorded fs-write / fs-write-overwrite goldens; the create's completed update is now a {type:'diff'} block, not the XML result text. --- ...26-07-02-result-time-applied-hunk-diffs.md | 2 +- .../fs-write-overwrite/session.jsonl | 269 ++++++++---------- .../fs-write-overwrite/stdout.golden.jsonl | 83 ++---- .../tests/snapshots/fs-write/session.jsonl | 190 +++++++------ .../snapshots/fs-write/stdout.golden.jsonl | 14 +- packages/fs/tool-fs/src/write.ts | 14 +- packages/fs/tool-fs/tests/tools.spec.ts | 28 +- 7 files changed, 292 insertions(+), 308 deletions(-) diff --git a/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md b/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md index 20328041f0..caa10f0de3 100644 --- a/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md +++ b/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md @@ -31,7 +31,7 @@ This is the general shape ("a tool attaches durable result presentation"), not a Per the [capability-seam split](2026-06-13-capability-seams.md), the storage backend returns only **storage facts** and the model-facing tool owns **presentation**: - `dsh-fs` widens `FsEditOutcome` with `{ before: string; after: string }` and `FsWriteOutcome` with `{ before: string | null; after: string }` (`before: null` ⇒ a create, or an existing-but-undiffable binary/non-UTF-8 file). The local backend already holds both texts at write time; it returns them as raw LF-normalized text, with **no diff/UI concept** entering the seam. -- `dsh-tool-fs` computes the contextual hunk from before/after and attaches it as `meta: { diffs: FileDiff[] }`. A result diff is emitted only when a before-version exists — edit always; write on overwrite; **a create emits none** (there is no before), matching `claude-agent-acp`'s empty `structuredPatch` on create. A failed/aborted/policy-rejected mutation applied nothing, so it carries no `meta` and renders no result diff. +- `dsh-tool-fs` computes the contextual hunk from before/after and attaches it as `meta: { diffs: FileDiff[] }`. A contextual hunk is computed only when a before-version exists — edit always; write on overwrite; a create has no before, matching `claude-agent-acp`'s empty `structuredPatch` on create. But the completed `tool_call_update` is ALWAYS a `diff` card for a successful mutation: an ACP `tool_call_update.content` REPLACES the call's content, so rendering the model-facing result text would clobber the pending diff. So `write`'s result falls back to an args-derived whole-file diff (`oldText: null`) when it has no contextual hunk (a create, or an overwrite whose content is unchanged), and `edit` — which always changes content — always has a hunk. A failed/aborted/policy-rejected mutation applied nothing, so it carries no `meta` and falls through to the generic error rendering (its message must show). ### 3. The bridge renders a `diff` result card diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl index 591a8ec940..8ad253a949 100644 --- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl @@ -1,145 +1,124 @@ -{"type":"session","version":0,"id":"9209a848-ea39-4f7f-b0ec-a59495c7da4b","createdAt":1783069543123,"cwd":"/tmp/acp-snap-cwd-MA4o8Q"} -{"type":"turn/start","seq":0,"time":1783069543128,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783069543128,"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":1783069543129,"data":{"turn":1,"step":1}} -{"type":"assistant/chunk","seq":3,"time":1783069543667,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":4,"time":1783069543667,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":5,"time":1783069543763,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":6,"time":1783069543821,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":7,"time":1783069543822,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":8,"time":1783069543822,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":9,"time":1783069543822,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":\n"}}} -{"type":"assistant/chunk","seq":10,"time":1783069543822,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} -{"type":"assistant/chunk","seq":11,"time":1783069543823,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":12,"time":1783069543831,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Read"}}} -{"type":"assistant/chunk","seq":13,"time":1783069543831,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" data"}}} -{"type":"assistant/chunk","seq":14,"time":1783069543832,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} -{"type":"assistant/chunk","seq":15,"time":1783069543832,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} -{"type":"assistant/chunk","seq":16,"time":1783069543879,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":17,"time":1783069543879,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" current"}}} -{"type":"assistant/chunk","seq":18,"time":1783069543879,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" directory"}}} -{"type":"assistant/chunk","seq":19,"time":1783069543879,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\n"}}} -{"type":"assistant/chunk","seq":20,"time":1783069543880,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}} -{"type":"assistant/chunk","seq":21,"time":1783069543880,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":22,"time":1783069543903,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Replace"}}} -{"type":"assistant/chunk","seq":23,"time":1783069543933,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" its"}}} -{"type":"assistant/chunk","seq":24,"time":1783069543934,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" entire"}}} -{"type":"assistant/chunk","seq":25,"time":1783069543934,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" contents"}}} -{"type":"assistant/chunk","seq":26,"time":1783069543968,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":27,"time":1783069543968,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":28,"time":1783069544008,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":29,"time":1783069544008,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"re"}}} -{"type":"assistant/chunk","seq":30,"time":1783069544008,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"placed"}}} -{"type":"assistant/chunk","seq":31,"time":1783069544008,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"\n"}}} -{"type":"assistant/chunk","seq":32,"time":1783069544008,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"3"}}} -{"type":"assistant/chunk","seq":33,"time":1783069544009,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":34,"time":1783069544041,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Reply"}}} -{"type":"assistant/chunk","seq":35,"time":1783069544042,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":36,"time":1783069544042,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":37,"time":1783069544042,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":38,"time":1783069544076,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} -{"type":"assistant/chunk","seq":39,"time":1783069544077,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":40,"time":1783069544077,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"\n\n"}}} -{"type":"assistant/chunk","seq":41,"time":1783069544077,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} -{"type":"assistant/chunk","seq":42,"time":1783069544077,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":43,"time":1783069544077,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" start"}}} -{"type":"assistant/chunk","seq":44,"time":1783069544111,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} -{"type":"assistant/chunk","seq":45,"time":1783069544111,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reading"}}} -{"type":"assistant/chunk","seq":46,"time":1783069544112,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":47,"time":1783069544146,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} -{"type":"assistant/chunk","seq":48,"time":1783069544146,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":49,"time":1783069544215,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":50,"time":1783069544215,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oyZxEdXevIb3TvYUZWJa3291","name":"read","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":51,"time":1783069544249,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oyZxEdXevIb3TvYUZWJa3291","name":"read","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":52,"time":1783069544250,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oyZxEdXevIb3TvYUZWJa3291","name":"read","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":53,"time":1783069544250,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oyZxEdXevIb3TvYUZWJa3291","name":"read","argumentsDelta":"file"}}} -{"type":"assistant/chunk","seq":54,"time":1783069544284,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oyZxEdXevIb3TvYUZWJa3291","name":"read","argumentsDelta":"_path"}}} -{"type":"assistant/chunk","seq":55,"time":1783069544284,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oyZxEdXevIb3TvYUZWJa3291","name":"read","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":56,"time":1783069544284,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oyZxEdXevIb3TvYUZWJa3291","name":"read","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":57,"time":1783069544284,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oyZxEdXevIb3TvYUZWJa3291","name":"read","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":58,"time":1783069544319,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oyZxEdXevIb3TvYUZWJa3291","name":"read","argumentsDelta":"data"}}} -{"type":"assistant/chunk","seq":59,"time":1783069544319,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oyZxEdXevIb3TvYUZWJa3291","name":"read","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":60,"time":1783069544319,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oyZxEdXevIb3TvYUZWJa3291","name":"read","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":61,"time":1783069544353,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oyZxEdXevIb3TvYUZWJa3291","name":"read","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":62,"time":1783069544390,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to:\n1. Read data.txt in the current directory\n2. Replace its entire contents with exactly \"replaced\"\n3. Reply with exactly \"DONE\"\n\nLet me start by reading the file."}}}} -{"type":"assistant/chunk","seq":63,"time":1783069544390,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_oyZxEdXevIb3TvYUZWJa3291","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}}}} -{"type":"assistant/chunk","seq":64,"time":1783069544390,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":123,"outputTokens":90,"cacheReadTokens":2176,"reasoningTokens":45}}}} -{"type":"assistant/chunk","seq":65,"time":1783069544390,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":66,"time":1783069544392,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to:\n1. Read data.txt in the current directory\n2. Replace its entire contents with exactly \"replaced\"\n3. Reply with exactly \"DONE\"\n\nLet me start by reading the file."},{"type":"tool-call","id":"call_00_oyZxEdXevIb3TvYUZWJa3291","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}],"usage":{"inputTokens":123,"outputTokens":90,"cacheReadTokens":2176,"reasoningTokens":45}},"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,61,62,63,64,65],"surfaceOp":"append"} -{"type":"tool/call","seq":67,"time":1783069544393,"data":{"turn":1,"step":1,"callId":"call_00_oyZxEdXevIb3TvYUZWJa3291","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}} -{"type":"tool/result","seq":68,"time":1783069544397,"data":{"turn":1,"step":1,"callId":"call_00_oyZxEdXevIb3TvYUZWJa3291","content":[{"type":"text","text":"/tmp/acp-snap-cwd-MA4o8Q/data.txt\nfile\n\n1: original contents\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[67],"surfaceOp":"append"} -{"type":"step/end","seq":69,"time":1783069544398,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":70,"time":1783069544398,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":71,"time":1783069545829,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":72,"time":1783069545829,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Now"}}} -{"type":"assistant/chunk","seq":73,"time":1783069545946,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":74,"time":1783069545980,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} -{"type":"assistant/chunk","seq":75,"time":1783069545980,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":76,"time":1783069545980,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" replace"}}} -{"type":"assistant/chunk","seq":77,"time":1783069545981,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":78,"time":1783069545981,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" entire"}}} -{"type":"assistant/chunk","seq":79,"time":1783069546016,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" contents"}}} -{"type":"assistant/chunk","seq":80,"time":1783069546016,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":81,"time":1783069546016,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":82,"time":1783069546048,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":83,"time":1783069546049,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"re"}}} -{"type":"assistant/chunk","seq":84,"time":1783069546049,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"placed"}}} -{"type":"assistant/chunk","seq":85,"time":1783069546049,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":86,"time":1783069546082,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} -{"type":"assistant/chunk","seq":87,"time":1783069546082,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":88,"time":1783069546082,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} -{"type":"assistant/chunk","seq":89,"time":1783069546082,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":90,"time":1783069546082,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":91,"time":1783069546183,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":92,"time":1783069546183,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":93,"time":1783069546217,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":94,"time":1783069546218,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":95,"time":1783069546218,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":"file"}}} -{"type":"assistant/chunk","seq":96,"time":1783069546218,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":"_path"}}} -{"type":"assistant/chunk","seq":97,"time":1783069546218,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":98,"time":1783069546218,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":99,"time":1783069546251,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":100,"time":1783069546251,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":"data"}}} -{"type":"assistant/chunk","seq":101,"time":1783069546251,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":102,"time":1783069546284,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":103,"time":1783069546319,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":104,"time":1783069546320,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":105,"time":1783069546320,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":"content"}}} -{"type":"assistant/chunk","seq":106,"time":1783069546320,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":107,"time":1783069546320,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":108,"time":1783069546356,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":109,"time":1783069546356,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":"re"}}} -{"type":"assistant/chunk","seq":110,"time":1783069546357,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":"placed"}}} -{"type":"assistant/chunk","seq":111,"time":1783069546357,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":112,"time":1783069546389,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":113,"time":1783069546461,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Now I need to replace the entire contents with exactly \"replaced\" using the write tool."}}}} -{"type":"assistant/chunk","seq":114,"time":1783069546461,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}}}} -{"type":"assistant/chunk","seq":115,"time":1783069546461,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":272,"outputTokens":81,"cacheReadTokens":2176,"reasoningTokens":19}}}} -{"type":"assistant/chunk","seq":116,"time":1783069546461,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":117,"time":1783069546461,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Now I need to replace the entire contents with exactly \"replaced\" using the write tool."},{"type":"tool-call","id":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}],"usage":{"inputTokens":272,"outputTokens":81,"cacheReadTokens":2176,"reasoningTokens":19}},"sourceEventSeqs":[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,111,112,113,114,115,116],"surfaceOp":"append"} -{"type":"tool/call","seq":118,"time":1783069546461,"data":{"turn":1,"step":2,"callId":"call_00_PPjJDvfhXspNG79WMy3b4358","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}} -{"type":"tool/result","seq":119,"time":1783069546467,"data":{"turn":1,"step":2,"callId":"call_00_PPjJDvfhXspNG79WMy3b4358","content":[{"type":"text","text":"/tmp/acp-snap-cwd-MA4o8Q/data.txt\nfile\n\nUpdated file\n"}],"isError":false,"meta":{"diffs":[{"path":"data.txt","oldText":"original contents","newText":"replaced"}]}},"sourceEventSeqs":[118],"surfaceOp":"append"} -{"type":"step/end","seq":120,"time":1783069546467,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":121,"time":1783069546468,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":122,"time":1783069546848,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":123,"time":1783069546848,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"Done"}}} -{"type":"assistant/chunk","seq":124,"time":1783069546981,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":125,"time":1783069547014,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} -{"type":"assistant/chunk","seq":126,"time":1783069547015,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":127,"time":1783069547015,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":128,"time":1783069547047,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":129,"time":1783069547047,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":130,"time":1783069547047,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":131,"time":1783069547047,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} -{"type":"assistant/chunk","seq":132,"time":1783069547082,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":133,"time":1783069547082,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":134,"time":1783069547082,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":135,"time":1783069547082,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":136,"time":1783069547082,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":137,"time":1783069547082,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Done. Now I reply with exactly \"DONE\"."}}}} -{"type":"assistant/chunk","seq":138,"time":1783069547082,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":139,"time":1783069547082,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":144,"outputTokens":14,"cacheReadTokens":2432,"reasoningTokens":11}}}} -{"type":"assistant/chunk","seq":140,"time":1783069547082,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":141,"time":1783069547083,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"Done. Now I reply with exactly \"DONE\"."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":144,"outputTokens":14,"cacheReadTokens":2432,"reasoningTokens":11}},"sourceEventSeqs":[122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140],"surfaceOp":"append"} -{"type":"step/end","seq":142,"time":1783069547083,"data":{"turn":1,"step":3}} -{"type":"turn/end","seq":143,"time":1783069547083,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"f283455e-a3d7-4b99-bf71-4e4494c6d71e","createdAt":1783082855218,"cwd":"/tmp/acp-snap-cwd-u64NRw"} +{"type":"turn/start","seq":0,"time":1783082855223,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783082855223,"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":1783082855224,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1783082855617,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1783082855617,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} +{"type":"assistant/chunk","seq":5,"time":1783082855716,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":6,"time":1783082855744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" start"}}} +{"type":"assistant/chunk","seq":7,"time":1783082855744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} +{"type":"assistant/chunk","seq":8,"time":1783082855745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reading"}}} +{"type":"assistant/chunk","seq":9,"time":1783082855773,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":10,"time":1783082855773,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" data"}}} +{"type":"assistant/chunk","seq":11,"time":1783082855774,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":12,"time":1783082855826,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":13,"time":1783082855827,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} +{"type":"assistant/chunk","seq":14,"time":1783082855831,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":15,"time":1783082855831,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" current"}}} +{"type":"assistant/chunk","seq":16,"time":1783082855831,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" directory"}}} +{"type":"assistant/chunk","seq":17,"time":1783082855831,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":18,"time":1783082855918,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":19,"time":1783082855918,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_hAzYZdfq8zzd4eA1NYKX7486","name":"read","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":20,"time":1783082855948,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_hAzYZdfq8zzd4eA1NYKX7486","name":"read","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":21,"time":1783082855949,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_hAzYZdfq8zzd4eA1NYKX7486","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":22,"time":1783082855949,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_hAzYZdfq8zzd4eA1NYKX7486","name":"read","argumentsDelta":"file"}}} +{"type":"assistant/chunk","seq":23,"time":1783082855949,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_hAzYZdfq8zzd4eA1NYKX7486","name":"read","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":24,"time":1783082855949,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_hAzYZdfq8zzd4eA1NYKX7486","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":25,"time":1783082855949,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_hAzYZdfq8zzd4eA1NYKX7486","name":"read","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":26,"time":1783082856009,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_hAzYZdfq8zzd4eA1NYKX7486","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":27,"time":1783082856009,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_hAzYZdfq8zzd4eA1NYKX7486","name":"read","argumentsDelta":"data"}}} +{"type":"assistant/chunk","seq":28,"time":1783082856009,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_hAzYZdfq8zzd4eA1NYKX7486","name":"read","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":29,"time":1783082856009,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_hAzYZdfq8zzd4eA1NYKX7486","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":30,"time":1783082856010,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_hAzYZdfq8zzd4eA1NYKX7486","name":"read","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":31,"time":1783082856065,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Let me start by reading the data.txt file in the current directory."}}}} +{"type":"assistant/chunk","seq":32,"time":1783082856065,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_hAzYZdfq8zzd4eA1NYKX7486","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}}}} +{"type":"assistant/chunk","seq":33,"time":1783082856066,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":123,"outputTokens":59,"cacheReadTokens":2176,"reasoningTokens":14}}}} +{"type":"assistant/chunk","seq":34,"time":1783082856066,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":35,"time":1783082856068,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"Let me start by reading the data.txt file in the current directory."},{"type":"tool-call","id":"call_00_hAzYZdfq8zzd4eA1NYKX7486","name":"read","arguments":"{\"file_path\": \"data.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":1783082856068,"data":{"turn":1,"step":1,"callId":"call_00_hAzYZdfq8zzd4eA1NYKX7486","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}} +{"type":"tool/result","seq":37,"time":1783082856073,"data":{"turn":1,"step":1,"callId":"call_00_hAzYZdfq8zzd4eA1NYKX7486","content":[{"type":"text","text":"/tmp/acp-snap-cwd-u64NRw/data.txt\nfile\n\n1: original contents\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[36],"surfaceOp":"append"} +{"type":"step/end","seq":38,"time":1783082856073,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":39,"time":1783082856073,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":40,"time":1783082856584,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":41,"time":1783082856584,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Now"}}} +{"type":"assistant/chunk","seq":42,"time":1783082856660,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":43,"time":1783082856692,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":44,"time":1783082856693,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":45,"time":1783082856693,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" replace"}}} +{"type":"assistant/chunk","seq":46,"time":1783082856693,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":47,"time":1783082856724,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" entire"}}} +{"type":"assistant/chunk","seq":48,"time":1783082856725,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" contents"}}} +{"type":"assistant/chunk","seq":49,"time":1783082856725,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":50,"time":1783082856725,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":51,"time":1783082856757,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":52,"time":1783082856757,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":53,"time":1783082856757,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" line"}}} +{"type":"assistant/chunk","seq":54,"time":1783082856758,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":55,"time":1783082856791,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" replaced"}}} +{"type":"assistant/chunk","seq":56,"time":1783082856792,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":57,"time":1783082856848,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":58,"time":1783082856848,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":59,"time":1783082856877,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":60,"time":1783082856878,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":61,"time":1783082856878,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":"file"}}} +{"type":"assistant/chunk","seq":62,"time":1783082856906,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":63,"time":1783082856907,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":64,"time":1783082856907,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":65,"time":1783082856907,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":66,"time":1783082856936,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":"data"}}} +{"type":"assistant/chunk","seq":67,"time":1783082856936,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":68,"time":1783082856936,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":69,"time":1783082856965,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":70,"time":1783082856965,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":71,"time":1783082856965,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":"content"}}} +{"type":"assistant/chunk","seq":72,"time":1783082856994,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":73,"time":1783082856995,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":74,"time":1783082856995,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":75,"time":1783082856995,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":"re"}}} +{"type":"assistant/chunk","seq":76,"time":1783082857023,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":"placed"}}} +{"type":"assistant/chunk","seq":77,"time":1783082857024,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":78,"time":1783082857052,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":79,"time":1783082857086,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Now I need to replace the entire contents with exactly the single line: replaced."}}}} +{"type":"assistant/chunk","seq":80,"time":1783082857086,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}}}} +{"type":"assistant/chunk","seq":81,"time":1783082857086,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":239,"outputTokens":78,"cacheReadTokens":2176,"reasoningTokens":16}}}} +{"type":"assistant/chunk","seq":82,"time":1783082857086,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":83,"time":1783082857087,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Now I need to replace the entire contents with exactly the single line: replaced."},{"type":"tool-call","id":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}],"usage":{"inputTokens":239,"outputTokens":78,"cacheReadTokens":2176,"reasoningTokens":16}},"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],"surfaceOp":"append"} +{"type":"tool/call","seq":84,"time":1783082857087,"data":{"turn":1,"step":2,"callId":"call_00_kwKFkGfN8j2XfvK34T5R7663","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}} +{"type":"tool/result","seq":85,"time":1783082857093,"data":{"turn":1,"step":2,"callId":"call_00_kwKFkGfN8j2XfvK34T5R7663","content":[{"type":"text","text":"/tmp/acp-snap-cwd-u64NRw/data.txt\nfile\n\nUpdated file\n"}],"isError":false,"meta":{"diffs":[{"path":"data.txt","oldText":"original contents","newText":"replaced"}]}},"sourceEventSeqs":[84],"surfaceOp":"append"} +{"type":"step/end","seq":86,"time":1783082857093,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":87,"time":1783082857094,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":88,"time":1783082857728,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":89,"time":1783082857729,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":90,"time":1783082857818,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":91,"time":1783082857850,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" has"}}} +{"type":"assistant/chunk","seq":92,"time":1783082857850,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" been"}}} +{"type":"assistant/chunk","seq":93,"time":1783082857850,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" replaced"}}} +{"type":"assistant/chunk","seq":94,"time":1783082857850,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":95,"time":1783082857878,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":96,"time":1783082857878,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"re"}}} +{"type":"assistant/chunk","seq":97,"time":1783082857907,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"placed"}}} +{"type":"assistant/chunk","seq":98,"time":1783082857908,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":99,"time":1783082857908,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":100,"time":1783082857908,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":101,"time":1783082857908,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":102,"time":1783082857963,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":103,"time":1783082857964,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":104,"time":1783082857964,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":105,"time":1783082857964,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":106,"time":1783082857964,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":107,"time":1783082857969,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":108,"time":1783082857969,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":109,"time":1783082857969,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":110,"time":1783082857969,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} +{"type":"assistant/chunk","seq":111,"time":1783082857970,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":112,"time":1783082857970,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":113,"time":1783082858000,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":114,"time":1783082858000,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":115,"time":1783082858000,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":116,"time":1783082858000,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file has been replaced with \"replaced\". Now I just need to reply with exactly the single word DONE."}}}} +{"type":"assistant/chunk","seq":117,"time":1783082858000,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":118,"time":1783082858000,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":234,"outputTokens":27,"cacheReadTokens":2304,"reasoningTokens":24}}}} +{"type":"assistant/chunk","seq":119,"time":1783082858000,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":120,"time":1783082858001,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The file has been replaced with \"replaced\". Now I just need to reply with exactly the single word DONE."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":234,"outputTokens":27,"cacheReadTokens":2304,"reasoningTokens":24}},"sourceEventSeqs":[88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119],"surfaceOp":"append"} +{"type":"step/end","seq":121,"time":1783082858001,"data":{"turn":1,"step":3}} +{"type":"turn/end","seq":122,"time":1783082858001,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl index 85b3597165..504483c7b9 100644 --- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl @@ -1,52 +1,21 @@ {"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":":\n"}}}} -{"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":" 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":" in"}}}} -{"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":" current"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" directory"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"2"}}}} -{"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":" 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":" 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":"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":"\"\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"3"}}}} -{"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":" 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":"\"\n\n"}}}} {"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":" 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":" file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" in"}}}} +{"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":" current"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" directory"}}}} {"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_oyZxEdXevIb3TvYUZWJa3291","title":"Read data.txt","kind":"read","status":"in_progress","locations":[{"path":"data.txt","line":1}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_oyZxEdXevIb3TvYUZWJa3291","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/data.txt\nfile\n\n1: original contents\n\n(End of file - total 1 lines)\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_hAzYZdfq8zzd4eA1NYKX7486","title":"Read data.txt","kind":"read","status":"in_progress","locations":[{"path":"data.txt","line":1}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_hAzYZdfq8zzd4eA1NYKX7486","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/data.txt\nfile\n\n1: original contents\n\n(End of file - total 1 lines)\n"}}]}}} {"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"}}}} @@ -57,28 +26,38 @@ {"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":" 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":" replaced"}}}} +{"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_kwKFkGfN8j2XfvK34T5R7663","title":"Write data.txt","kind":"edit","status":"in_progress","locations":[{"path":"data.txt"}],"content":[{"type":"diff","path":"data.txt","oldText":null,"newText":"replaced"}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_kwKFkGfN8j2XfvK34T5R7663","status":"completed","content":[{"type":"diff","path":"data.txt","oldText":"original contents","newText":"replaced"}],"title":"Write data.txt"}}} +{"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":" has"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" been"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" replaced"}}}} +{"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":" 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":"tool_call","toolCallId":"call_00_PPjJDvfhXspNG79WMy3b4358","title":"Write data.txt","kind":"edit","status":"in_progress","locations":[{"path":"data.txt"}],"content":[{"type":"diff","path":"data.txt","oldText":null,"newText":"replaced"}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_PPjJDvfhXspNG79WMy3b4358","status":"completed","content":[{"type":"diff","path":"data.txt","oldText":"original contents","newText":"replaced"}],"title":"Write data.txt"}}} -{"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":"\"."}}}} {"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":" 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":" 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_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"}} diff --git a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl index b100593c02..ff637f94a1 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl @@ -1,94 +1,96 @@ -{"type":"session","version":0,"id":"def3ba4c-1443-4c75-89ee-3436287cb97b","createdAt":1783069532897,"cwd":"/tmp/acp-snap-cwd-FOCYwl"} -{"type":"turn/start","seq":0,"time":1783069532903,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783069532903,"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":1783069532904,"data":{"turn":1,"step":1}} -{"type":"assistant/chunk","seq":3,"time":1783069533351,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":4,"time":1783069533351,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":5,"time":1783069533461,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":6,"time":1783069533495,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":7,"time":1783069533495,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":8,"time":1783069533495,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":9,"time":1783069533496,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" create"}}} -{"type":"assistant/chunk","seq":10,"time":1783069533496,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":11,"time":1783069533496,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} -{"type":"assistant/chunk","seq":12,"time":1783069533526,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" named"}}} -{"type":"assistant/chunk","seq":13,"time":1783069533559,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" notes"}}} -{"type":"assistant/chunk","seq":14,"time":1783069533560,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} -{"type":"assistant/chunk","seq":15,"time":1783069533560,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":16,"time":1783069533592,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":17,"time":1783069533619,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" content"}}} -{"type":"assistant/chunk","seq":18,"time":1783069533620,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":19,"time":1783069533620,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"hello"}}} -{"type":"assistant/chunk","seq":20,"time":1783069533620,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" world"}}} -{"type":"assistant/chunk","seq":21,"time":1783069533620,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":22,"time":1783069533650,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} -{"type":"assistant/chunk","seq":23,"time":1783069533650,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":24,"time":1783069533650,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} -{"type":"assistant/chunk","seq":25,"time":1783069533650,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":26,"time":1783069533651,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":27,"time":1783069533651,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} -{"type":"assistant/chunk","seq":28,"time":1783069533678,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":29,"time":1783069533678,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":30,"time":1783069533711,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":31,"time":1783069533711,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":32,"time":1783069533712,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":33,"time":1783069533712,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":34,"time":1783069533741,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} -{"type":"assistant/chunk","seq":35,"time":1783069533741,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":36,"time":1783069533741,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":37,"time":1783069533837,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":38,"time":1783069533837,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":39,"time":1783069533837,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":40,"time":1783069533837,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":41,"time":1783069533884,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":"file"}}} -{"type":"assistant/chunk","seq":42,"time":1783069533885,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":"_path"}}} -{"type":"assistant/chunk","seq":43,"time":1783069533885,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":44,"time":1783069533885,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":45,"time":1783069533908,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":46,"time":1783069533909,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":"notes"}}} -{"type":"assistant/chunk","seq":47,"time":1783069533909,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":".txt"}}} -{"type":"assistant/chunk","seq":48,"time":1783069533909,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":49,"time":1783069533951,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":50,"time":1783069533951,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":51,"time":1783069533979,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":"content"}}} -{"type":"assistant/chunk","seq":52,"time":1783069533980,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":53,"time":1783069533980,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":54,"time":1783069533980,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":55,"time":1783069534004,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":"hello"}}} -{"type":"assistant/chunk","seq":56,"time":1783069534004,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":" world"}}} -{"type":"assistant/chunk","seq":57,"time":1783069534005,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":58,"time":1783069534039,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":59,"time":1783069534073,"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, then reply with exactly the word \"DONE\"."}}}} -{"type":"assistant/chunk","seq":60,"time":1783069534073,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}}}} -{"type":"assistant/chunk","seq":61,"time":1783069534073,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":115,"outputTokens":95,"cacheReadTokens":2176,"reasoningTokens":33}}}} -{"type":"assistant/chunk","seq":62,"time":1783069534073,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":63,"time":1783069534076,"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, then reply with exactly the word \"DONE\"."},{"type":"tool-call","id":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}],"usage":{"inputTokens":115,"outputTokens":95,"cacheReadTokens":2176,"reasoningTokens":33}},"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,61,62],"surfaceOp":"append"} -{"type":"tool/call","seq":64,"time":1783069534076,"data":{"turn":1,"step":1,"callId":"call_00_ICLusq2lV6YYBtn1szVM9454","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}} -{"type":"tool/result","seq":65,"time":1783069534084,"data":{"turn":1,"step":1,"callId":"call_00_ICLusq2lV6YYBtn1szVM9454","content":[{"type":"text","text":"/tmp/acp-snap-cwd-FOCYwl/notes.txt\nfile\n\nCreated file\n"}],"isError":false},"sourceEventSeqs":[64],"surfaceOp":"append"} -{"type":"step/end","seq":66,"time":1783069534084,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":67,"time":1783069534084,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":68,"time":1783069535137,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":69,"time":1783069535137,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"File"}}} -{"type":"assistant/chunk","seq":70,"time":1783069535256,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" created"}}} -{"type":"assistant/chunk","seq":71,"time":1783069535289,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} -{"type":"assistant/chunk","seq":72,"time":1783069535289,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":73,"time":1783069535289,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} -{"type":"assistant/chunk","seq":74,"time":1783069535326,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":75,"time":1783069535326,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} -{"type":"assistant/chunk","seq":76,"time":1783069535326,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} -{"type":"assistant/chunk","seq":77,"time":1783069535326,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":78,"time":1783069535359,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":79,"time":1783069535360,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":80,"time":1783069535360,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} -{"type":"assistant/chunk","seq":81,"time":1783069535360,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} -{"type":"assistant/chunk","seq":82,"time":1783069535360,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":83,"time":1783069535399,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":84,"time":1783069535399,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":85,"time":1783069535399,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":86,"time":1783069535399,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"File created successfully. Now I should reply with exactly \"DONE\"."}}}} -{"type":"assistant/chunk","seq":87,"time":1783069535399,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":88,"time":1783069535400,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":256,"outputTokens":17,"cacheReadTokens":2176,"reasoningTokens":14}}}} -{"type":"assistant/chunk","seq":89,"time":1783069535400,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":90,"time":1783069535400,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"File created successfully. Now I should reply with exactly \"DONE\"."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":256,"outputTokens":17,"cacheReadTokens":2176,"reasoningTokens":14}},"sourceEventSeqs":[68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89],"surfaceOp":"append"} -{"type":"step/end","seq":91,"time":1783069535400,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":92,"time":1783069535400,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"4be5b8f3-93b3-4830-a8ad-089dafb693c1","createdAt":1783082851377,"cwd":"/tmp/acp-snap-cwd-bol9fl"} +{"type":"turn/start","seq":0,"time":1783082851381,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783082851382,"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":1783082851383,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1783082851775,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1783082851775,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1783082851949,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1783082851980,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1783082851981,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1783082851981,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1783082851981,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" create"}}} +{"type":"assistant/chunk","seq":10,"time":1783082851981,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":11,"time":1783082851982,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":12,"time":1783082852010,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" named"}}} +{"type":"assistant/chunk","seq":13,"time":1783082852011,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" notes"}}} +{"type":"assistant/chunk","seq":14,"time":1783082852011,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":15,"time":1783082852011,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":16,"time":1783082852043,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":17,"time":1783082852044,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" content"}}} +{"type":"assistant/chunk","seq":18,"time":1783082852076,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":19,"time":1783082852076,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"hello"}}} +{"type":"assistant/chunk","seq":20,"time":1783082852076,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" world"}}} +{"type":"assistant/chunk","seq":21,"time":1783082852076,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":22,"time":1783082852076,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} +{"type":"assistant/chunk","seq":23,"time":1783082852076,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":24,"time":1783082852107,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} +{"type":"assistant/chunk","seq":25,"time":1783082852107,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":26,"time":1783082852107,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":27,"time":1783082852108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":28,"time":1783082852108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":29,"time":1783082852140,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":30,"time":1783082852141,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":31,"time":1783082852141,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":32,"time":1783082852141,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":33,"time":1783082852170,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":34,"time":1783082852170,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":35,"time":1783082852229,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":36,"time":1783082852230,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":37,"time":1783082852257,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":38,"time":1783082852257,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":39,"time":1783082852257,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":"file"}}} +{"type":"assistant/chunk","seq":40,"time":1783082852289,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":41,"time":1783082852289,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":42,"time":1783082852289,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":43,"time":1783082852289,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":44,"time":1783082852317,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":"notes"}}} +{"type":"assistant/chunk","seq":45,"time":1783082852318,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":46,"time":1783082852318,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":47,"time":1783082852347,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":48,"time":1783082852347,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":49,"time":1783082852347,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":"content"}}} +{"type":"assistant/chunk","seq":50,"time":1783082852387,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":51,"time":1783082852388,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":52,"time":1783082852388,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":53,"time":1783082852388,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":"hello"}}} +{"type":"assistant/chunk","seq":54,"time":1783082852408,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":" world"}}} +{"type":"assistant/chunk","seq":55,"time":1783082852408,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":56,"time":1783082852437,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":57,"time":1783082852471,"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, then reply with exactly \"DONE\"."}}}} +{"type":"assistant/chunk","seq":58,"time":1783082852472,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}}}} +{"type":"assistant/chunk","seq":59,"time":1783082852472,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":115,"outputTokens":93,"cacheReadTokens":2176,"reasoningTokens":31}}}} +{"type":"assistant/chunk","seq":60,"time":1783082852472,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":61,"time":1783082852474,"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, then reply with exactly \"DONE\"."},{"type":"tool-call","id":"call_00_4aj3gzzSDsP64mCcrn8k4591","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":1783082852474,"data":{"turn":1,"step":1,"callId":"call_00_4aj3gzzSDsP64mCcrn8k4591","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}} +{"type":"tool/result","seq":63,"time":1783082852481,"data":{"turn":1,"step":1,"callId":"call_00_4aj3gzzSDsP64mCcrn8k4591","content":[{"type":"text","text":"/tmp/acp-snap-cwd-bol9fl/notes.txt\nfile\n\nCreated file\n"}],"isError":false},"sourceEventSeqs":[62],"surfaceOp":"append"} +{"type":"step/end","seq":64,"time":1783082852481,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":65,"time":1783082852482,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":66,"time":1783082852837,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":67,"time":1783082852838,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":68,"time":1783082852954,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" file"}}} +{"type":"assistant/chunk","seq":69,"time":1783082852983,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} +{"type":"assistant/chunk","seq":70,"time":1783082852983,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" created"}}} +{"type":"assistant/chunk","seq":71,"time":1783082852983,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} +{"type":"assistant/chunk","seq":72,"time":1783082852983,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":73,"time":1783082852983,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":74,"time":1783082853013,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":75,"time":1783082853013,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":76,"time":1783082853013,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":77,"time":1783082853013,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":78,"time":1783082853013,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":79,"time":1783082853042,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":80,"time":1783082853042,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":81,"time":1783082853042,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":82,"time":1783082853042,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":83,"time":1783082853042,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":84,"time":1783082853078,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":85,"time":1783082853078,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":86,"time":1783082853078,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":87,"time":1783082853078,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":88,"time":1783082853078,"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 exactly \"DONE\"."}}}} +{"type":"assistant/chunk","seq":89,"time":1783082853078,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":90,"time":1783082853078,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":126,"outputTokens":21,"cacheReadTokens":2304,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":91,"time":1783082853079,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":92,"time":1783082853079,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The file was created successfully. Now I just need to reply with exactly \"DONE\"."},{"type":"text","text":"DONE"}],"usage":{"inputTokens":126,"outputTokens":21,"cacheReadTokens":2304,"reasoningTokens":18}},"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,91],"surfaceOp":"append"} +{"type":"step/end","seq":93,"time":1783082853079,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":94,"time":1783082853079,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl index 5b4d170e18..ba9d2e691f 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl @@ -27,21 +27,23 @@ {"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":" word"}}}} {"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_ICLusq2lV6YYBtn1szVM9454","title":"Write notes.txt","kind":"edit","status":"in_progress","locations":[{"path":"notes.txt"}],"content":[{"type":"diff","path":"notes.txt","oldText":null,"newText":"hello world"}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_ICLusq2lV6YYBtn1szVM9454","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/notes.txt\nfile\n\nCreated file\n"}}]}}} -{"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":"tool_call","toolCallId":"call_00_4aj3gzzSDsP64mCcrn8k4591","title":"Write notes.txt","kind":"edit","status":"in_progress","locations":[{"path":"notes.txt"}],"content":[{"type":"diff","path":"notes.txt","oldText":null,"newText":"hello world"}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_4aj3gzzSDsP64mCcrn8k4591","status":"completed","content":[{"type":"diff","path":"notes.txt","oldText":null,"newText":"hello world"}],"title":"Write notes.txt"}}} +{"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":" should"}}}} +{"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":" exactly"}}}} diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index bf58f21e6c..7886dcd8d6 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -83,14 +83,18 @@ export function applyWriteTool(ctx: Context): void { locations: [{ path: args.file_path }], } }, - // Result-time display: for an OVERWRITE, the applied contextual-diff hunks on - // `meta` supersede the call-time whole-file snippet. A create carries no meta - // (no "before"), so this returns undefined and the call-time new-file card - // stands; an error or malformed meta also falls through to generic rendering. + // Result-time display: a `diff` card so the completed `tool_call_update` + // re-installs the diff rather than the model-facing result text (an ACP + // `tool_call_update.content` REPLACES the call's content, so a text result + // would clobber the pending diff card). An OVERWRITE uses the applied + // contextual hunks on `meta`; a CREATE has no `meta` (no prior content), so + // its whole-file new-file diff is derived from `args.content` (replay-safe, + // matching the call-time card). An error falls through to generic rendering + // so its message shows. presentResult(args, result: ToolResult): DiffResultView | undefined { if (result.isError) return undefined const diffs = diffsFromMeta(result.meta) - if (diffs === undefined) return undefined + ?? [{ path: args.file_path, oldText: null, newText: args.content }] return { card: 'diff', title: `Write ${args.file_path}`, diffs } }, })) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index f41f587d94..5fd2a533d7 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -440,16 +440,21 @@ describe('result-time contextual diff (meta + presentResult)', () => { expect(view).toEqual({ card: 'diff', title: 'Write a.txt', diffs: [{ path: 'a.txt', oldText: 'a\nb\nc\nOLD\nd\ne\nf', newText: 'a\nb\nc\nNEW\nd\ne\nf' }] }) }) - it('write CREATE: no before-version → no meta, presentResult returns undefined (call-time card stands)', async () => { + it('write CREATE: no before-version → no meta, but presentResult still renders a whole-file diff card', async () => { + // A create has no prior content (no `meta`), yet the completed card must be a + // `diff` — an ACP tool_call_update.content REPLACES the call's content, so a + // non-diff result would clobber the pending new-file diff. The whole-file diff + // is derived from the args (oldText:null), replay-safe. const { ctx } = await setup() const session = { header: {} } const result = await call(ctx, 'write', { file_path: 'new.txt', content: 'fresh\n' }, { session }) expect(result.isError).toBe(false) expect(result.meta).toBeUndefined() - expect(ctx.tools.get('write')?.presentResult?.({ file_path: 'new.txt', content: 'fresh\n' }, result)).toBeUndefined() + const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'new.txt', content: 'fresh\n' }, result) + expect(view).toEqual({ card: 'diff', title: 'Write new.txt', diffs: [{ path: 'new.txt', oldText: null, newText: 'fresh\n' }] }) }) - it('write OVERWRITE with identical content: a before exists but yields no hunk → no meta', async () => { + it('write OVERWRITE with identical content: a before exists but yields no hunk → no meta, presentResult falls back to a whole-file diff', async () => { const { ctx, fs } = await setup() const session = { header: {} } fs.files.set('key:a.txt', 'same\n') @@ -457,6 +462,8 @@ describe('result-time contextual diff (meta + presentResult)', () => { const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'same\n' }, { session }) expect(result.isError).toBe(false) expect(result.meta).toBeUndefined() + const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'a.txt', content: 'same\n' }, result) + expect(view).toEqual({ card: 'diff', title: 'Write a.txt', diffs: [{ path: 'a.txt', oldText: null, newText: 'same\n' }] }) }) it('presentResult returns undefined on an error result (nothing applied)', async () => { @@ -466,10 +473,21 @@ describe('result-time contextual diff (meta + presentResult)', () => { expect(ctx.tools.get('write')?.presentResult?.({ file_path: 'a.txt', content: 'y' }, errorResult)).toBeUndefined() }) - it('presentResult returns undefined on malformed meta (defensive narrowing)', async () => { + it('edit presentResult returns undefined on malformed meta (defensive narrowing)', async () => { + // edit has no whole-file fallback (only a literal replacement), so a malformed + // meta yields the generic "updated successfully" rendering. const { ctx } = await setup() const badMeta = { content: [{ type: 'text' as const, text: 'ok' }], isError: false, meta: { diffs: 'nope' } } expect(ctx.tools.get('edit')?.presentResult?.({ file_path: 'a.txt', old_string: 'x', new_string: 'y' }, badMeta)).toBeUndefined() - expect(ctx.tools.get('write')?.presentResult?.({ file_path: 'a.txt', content: 'y' }, badMeta)).toBeUndefined() + }) + + it('write presentResult falls back to a whole-file diff on malformed meta (never leaks the result text)', async () => { + // write always renders a diff card so the completed update can't clobber the + // pending diff with the model-facing text; a malformed meta falls back to the + // args-derived whole-file diff, same as a create. + const { ctx } = await setup() + const badMeta = { content: [{ type: 'text' as const, text: 'ok' }], isError: false, meta: { diffs: 'nope' } } + const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'a.txt', content: 'y' }, badMeta) + expect(view).toEqual({ card: 'diff', title: 'Write a.txt', diffs: [{ path: 'a.txt', oldText: null, newText: 'y' }] }) }) }) From da1d7f281d9d4833f41c574d1cf5413c4ce18ded Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:26:50 +0800 Subject: [PATCH 58/75] docs(tools): DiffResultView.diffs may be a whole-file diff, not only hunks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The write-diff-card fix made write's presentResult return an args-derived whole-file diff (oldText:null) for a create / unchanged overwrite, but the DiffResultView contract and its mirrored docs still said `diffs` is ALWAYS the applied contextual hunks computed from before/after. Correct the type JSDoc, the write execute-side comment, and the four mirrored surfaces (tools.md, tools README, acp-feature-support, adding-a-tool cookbook) to say: typically the applied hunks, or a whole-file diff when there is no before-image (a create) — and that a mutation returns the diff result even when it duplicates the call-time card, since a tool_call_update.content replace would otherwise clobber the diff with the model-facing text. Regenerate the cordis catalog (source line shift). --- docs/cookbook/adding-a-tool.md | 2 +- docs/cordis-catalog/events-and-services.md | 2 +- docs/core-data-structures/tools.md | 2 +- packages/core/tools/README.md | 2 +- packages/core/tools/src/index.ts | 18 ++++++++++-------- packages/fs/tool-fs/src/write.ts | 5 +++-- packages/ui/acp/acp-feature-support.md | 2 +- 7 files changed, 18 insertions(+), 15 deletions(-) diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index 84247e3f3d..836473e5de 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -59,7 +59,7 @@ Both methods return a **`card`-tagged render intent** — pick the card kind tha - `{ card: 'generic', title, kind?, rawInput?, content?, locations? }` — the default. Set `kind` for an icon (`read`/`search`/…); set `locations: [{ path, line? }]` for any file your tool touches so a capable editor follows along / jumps to it. - `{ card: 'terminal', title, description?, cwd? }` — your call IS a shell command. `title` is the command, `description` renders above the terminal card. (tool-bash.) - `{ card: 'diff', title, diffs, locations? }` — your call creates or modifies a file. `diffs: [{ path, oldText, newText }]` (`oldText: null` for a new file) renders as an inline diff card. (tool-fs `write`/`edit`.) -- `presentResult(args, { content, isError, meta? })` → a `ToolResultView` (the COMPLETED card): `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the run's captured output + exit — the bridge shows an exit pill and derives a fenced ` ```console ` fallback for editors without the terminal capability), or `{ card: 'diff', title?, diffs }` (the APPLIED hunks of a completed file mutation, computed from the before/after content — `write`/`edit` attach the hunks via the `meta` channel and read them back here). `result.meta` is your tool's own optional presentation payload, attached from `execute` (see below) and persisted so a replay reproduces the card. +- `presentResult(args, { content, isError, meta? })` → a `ToolResultView` (the COMPLETED card): `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the run's captured output + exit — the bridge shows an exit pill and derives a fenced ` ```console ` fallback for editors without the terminal capability), or `{ card: 'diff', title?, diffs }` (a completed file mutation — the applied hunks computed from the before/after content when there is a before-image, else a whole-file diff for a create; `write`/`edit` attach the hunks via the `meta` channel and read them back here). A mutation tool returns the `diff` result even when it duplicates the call-time card, because an ACP `tool_call_update.content` REPLACES the call's content — a non-diff result would clobber the pending diff. `result.meta` is your tool's own optional presentation payload, attached from `execute` (see below) and persisted so a replay reproduces the card. Hard rules (they bite if broken): diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 6854e56e2e..5553592ba0 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -547,7 +547,7 @@ async execute(exec: ToolExecution): Promise 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:363`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:365`](../../packages/core/tools/src/index.ts) ## Inherited tier (cordis core + loader/hmr/timer) diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 04d8c33ce7..355eefc2e7 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -117,7 +117,7 @@ A waterfall listener receives `(exec, next)`: call `next()` to proceed (possibly 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`/`presentResult` return a **`card`-tagged render intent** — a discriminated union a UI bridge switches on: - `ToolCallView` (pending): `{ card: 'generic', title, kind?, rawInput?, content?, locations? }` (the default card; `locations` is `{ path, line? }[]` files the call reads/modifies, for editor follow-along), `{ card: 'terminal', title, description?, cwd? }` (a shell command → a terminal card), or `{ card: 'diff', title, diffs, locations? }` (a file create/modify → an inline diff card; `diffs` is `{ path, oldText, newText }[]`, `oldText: null` for a new file). -- `ToolResultView` (completed): `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit; a capable UI shows an exit-status pill, an incapable one gets a fenced ` ```console ` fallback the bridge derives from `output`), or `{ card: 'diff', title?, diffs }` (a completed file mutation → the APPLIED hunks with context lines, one entry per changed site, computed from the before/after file content — distinct from the call-time whole-snippet `diff`, which it supersedes). +- `ToolResultView` (completed): `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit; a capable UI shows an exit-status pill, an incapable one gets a fenced ` ```console ` fallback the bridge derives from `output`), or `{ card: 'diff', title?, diffs }` (a completed file mutation → the change to show, typically the applied hunks with context lines computed from the before/after content, or a whole-file diff when there is no before-image — e.g. a file create. A `tool_call_update`'s content REPLACES the call's content, so a mutation tool returns this even when it duplicates the call-time snippet, to keep the result from clobbering the diff with result text). `ToolCallKind` (`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`) picks an icon on a generic card. `FileLocation` (`{ path, line? }`) and `FileDiff` (`{ path, oldText, newText }`) are the shared file-card vocabulary. The design is pinned in [the render-intent-union RFC](../rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md); the ACP bridge maps a `diff` card to a `{ type: 'diff' }` content block, a `terminal` card to the `_meta` terminal convention, and relativizes a file card's title against the session cwd. diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index c8a3ddba4b..aecbcd7d45 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -79,7 +79,7 @@ A tool owns how ITS calls render in a UI (an editor's tool-call card, a CLI log - `presentResult(args, result): ToolResultView | undefined` — the COMPLETED state, given the same `args` and the `{ content, isError, meta? }` result, one of: - `{ card: 'generic', title?, content? }` — an optional replacement `title` and reformatted `content`. - `{ card: 'terminal', title?, output?, exitCode?, signal? }` — a terminal run's captured `output` and exit status. A capable UI shows an exit-status pill; an incapable UI gets a fenced ` ```console ` fallback the BRIDGE derives from `output` (the tool does not encode the fences). - - `{ card: 'diff', title?, diffs }` — a completed file mutation as an inline diff. `diffs` is `FileDiff[]` — the APPLIED hunks with surrounding context (one entry per changed site), computed from the before/after file content, distinct from the call-time whole-snippet `diff`. Used by `write`/`edit`; a `tool_call_update.content` replaces the call's content, so this supersedes the pending snippet. + - `{ card: 'diff', title?, diffs }` — a completed file mutation as an inline diff. `diffs` is `FileDiff[]` — typically the applied hunks with surrounding context computed from the before/after content, or a whole-file diff (`oldText: null`) when there is no before-image (a file create). Used by `write`/`edit`; a `tool_call_update.content` replaces the call's content, so a mutation tool returns this even when it duplicates the call-time snippet (else the result text would clobber the pending diff). 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. `result.meta` is the tool's own optional presentation payload (opaque `unknown`, JSON-serializable), attached by `execute` (see below) and persisted on the `tool/result` event, so a `presentResult` reading it stays replay-deterministic (the same `meta` is read back from the log). With `defineTool`, `args` is the typed `InferArgs` 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 views are provider-neutral — the ACP bridge (`dsh-acp`) maps each `card` to ACP `tool_call`/`tool_call_update` wire fields (a `diff` card to a `{ type: 'diff' }` content block, a `terminal` card to the `_meta` terminal convention), and relativizes a file card's title against the session cwd. See the render-intent-union RFC (`docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md`) and the applied-hunk-diffs RFC (`docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md`); `dsh-tool-bash` (terminal) and `dsh-tool-fs` (diff/generic) are the reference implementations. diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index acae166abe..43cd860734 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -220,19 +220,21 @@ export interface TerminalResultView { /** * A completed file mutation rendered as an inline diff card, the *result-time* - * analogue of {@link DiffCallView}. Set by a tool whose `execute` applied a - * file change (e.g. `write`, `edit`): `diffs` are the APPLIED hunks computed - * from the before/after file content (one entry per hunk, each with surrounding - * context lines), so the editor shows the real change with context — distinct - * from the call-time whole-snippet {@link DiffCallView}. A `tool_call_update`'s - * content REPLACES the call's content in an editor, so this result diff - * supersedes the pending snippet. + * analogue of {@link DiffCallView}. Set by a tool whose `execute` applied a file + * change (e.g. `write`, `edit`): `diffs` are the change to show — typically the + * APPLIED hunks computed from the before/after content (one entry per hunk, each + * with surrounding context lines), so the editor shows the real change in place; + * a tool with no before-image (e.g. a file create) may instead give a whole-file + * diff (`oldText: null`). A `tool_call_update`'s content REPLACES the call's + * content in an editor, so a mutation tool returns this even when it duplicates + * the call-time snippet — otherwise the model-facing result text would replace + * (clobber) the pending diff card. */ export interface DiffResultView { card: 'diff' /** Replacement title for the completed call. Omit to keep the pending-state title. */ title?: string - /** One entry per applied hunk (a contextual diff), in file order. */ + /** The change to show, in file order — applied contextual hunks, or a whole-file diff when there is no before-image. */ diffs: FileDiff[] } diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index 7886dcd8d6..1054e2ff2f 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -62,9 +62,10 @@ export function applyWriteTool(ctx: Context): void { 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) - // Result-time contextual diff ONLY for an overwrite (a before-version + // Attach a contextual hunk as `meta` ONLY for an overwrite (a before-version // exists). A create has no "before" — `outcome.before` is null — so it - // carries no result diff, leaving just the call-time whole-file card. + // carries no `meta`; `presentResult` then renders a whole-file diff from the + // args, so the completed card is still a diff (never the result text). const diffs = outcome.before !== null ? computeHunkDiffs(input.filePath, outcome.before, outcome.after) : [] return { content: [{ type: 'text', text: formatWriteOutput(target.displayPath, outcome) }], diff --git a/packages/ui/acp/acp-feature-support.md b/packages/ui/acp/acp-feature-support.md index 6ef901f17b..4a051bf5aa 100644 --- a/packages/ui/acp/acp-feature-support.md +++ b/packages/ui/acp/acp-feature-support.md @@ -99,7 +99,7 @@ Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult | `ToolCallKind` mapping | S | ✅ | ✅ | ✅ | `execute`/`read`/`edit`/`other` inferred from the tool; richer mapping possible. | | `ToolCallStatus` | S | ✅ | ✅ | ✅ | `in_progress` → `completed`/`failed`. | | `content` blocks | S | ✅ | ✅ | ✅ | Text content; the description renders above the card. | -| `diff` content | S | ✅ | ✅ | ✅ | The `write`/`edit` tools declare a `diff` render intent: `presentCall` → a call-time `{ card: 'diff' }` snippet, and `presentResult` → a result-time `{ card: 'diff' }` carrying the APPLIED hunk with surrounding context lines (one hunk per `replace_all` site), computed from the before/after file text and persisted on the `tool/result` event. The bridge emits `{ type: 'diff', path, oldText, newText }` content blocks; the result hunk supersedes the call snippet. | +| `diff` content | S | ✅ | ✅ | ✅ | The `write`/`edit` tools declare a `diff` render intent: `presentCall` → a call-time `{ card: 'diff' }` snippet, and `presentResult` → a result-time `{ card: 'diff' }`. For an edit or an overwrite it carries the applied hunk(s) with surrounding context (one per `replace_all` site), computed from the before/after text and persisted on the `tool/result` event as `meta`; for a create (no before-image) it is an args-derived whole-file diff. The bridge emits `{ type: 'diff', path, oldText, newText }` content blocks; a successful mutation ALWAYS returns the result diff (an ACP `tool_call_update.content` replaces the call's content, so the result diff — not the model-facing text — is what survives). | | `terminal` content | S | ✅ | ✅ | ✅ | Via the Zed `_meta` terminal convention (see below), not the spec `terminal/*` sub-protocol. | | `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`. | From 86457689fc7a91d04881f47126b808a857d263e0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:30:11 +0800 Subject: [PATCH 59/75] docs(acp): note the whole-file-diff create case in the presentResult list The acp README's presentResult card list still described the `diff` result as always "the APPLIED hunks computed from before/after". Qualify it like the other surfaces: typically the applied hunks, or a whole-file diff for a create, and a successful mutation always returns it so the result text can't clobber the diff. --- packages/ui/acp/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index d929fa5ddb..242577bfb8 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -48,7 +48,7 @@ How a tool call renders in the editor is owned by the TOOL, not the bridge — t - `{ card: 'terminal', title, description?, cwd? }` — a shell command → a terminal card (see Terminal card). - `{ card: 'diff', title, diffs, locations? }` — a file create/modify → an inline diff card; `diffs` is `FileDiff[]` (`{ path, oldText, newText }`, `oldText: null` ⇒ new file). The bridge emits each diff as an ACP `{ type: 'diff', path, oldText, newText }` `tool_call.content` block, which Zed renders as an inline diff / new-file preview. -`presentResult` returns a `ToolResultView`, one of three cards: `{ card: 'generic', title?, content? }` (an optional replacement `title` and reformatted `content`), `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit — see Terminal card), or `{ card: 'diff', title?, diffs }` (a completed file mutation → the APPLIED hunks with context lines computed from the before/after content, which supersede the call-time snippet). The bridge looks the definition up by name in `ctx.tools` and `switch (view.card)`es to build the ACP `tool_call`/`tool_call_update` wire shape per card; 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` returns a `terminal` card for a foreground `bash` (title = the exact `command` "ls -la src", `description` = the model description); the `dsh-tool-fs` `write`/`edit` tools return a `diff` call card and a `diff` result card, and `read` returns a `generic` card (`kind: 'read'`, the read window in its title — `Read foo.txt (5 - 8)` — and a `locations` entry for the file). For a file card the bridge **relativizes the title** against the session cwd (mirroring `claude-agent-acp`'s `toDisplayPath` — `Read src/foo.ts`, not the absolute path) while keeping `locations[]`/`diffs[].path` **raw** so the editor opens the real path. +`presentResult` returns a `ToolResultView`, one of three cards: `{ card: 'generic', title?, content? }` (an optional replacement `title` and reformatted `content`), `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit — see Terminal card), or `{ card: 'diff', title?, diffs }` (a completed file mutation → typically the applied hunks with context lines computed from the before/after content, or a whole-file diff for a create; a successful mutation ALWAYS returns this so the model-facing result text can't clobber the diff — an ACP `tool_call_update.content` replaces the call's content). The bridge looks the definition up by name in `ctx.tools` and `switch (view.card)`es to build the ACP `tool_call`/`tool_call_update` wire shape per card; 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` returns a `terminal` card for a foreground `bash` (title = the exact `command` "ls -la src", `description` = the model description); the `dsh-tool-fs` `write`/`edit` tools return a `diff` call card and a `diff` result card, and `read` returns a `generic` card (`kind: 'read'`, the read window in its title — `Read foo.txt (5 - 8)` — and a `locations` entry for the file). For a file card the bridge **relativizes the title** against the session cwd (mirroring `claude-agent-acp`'s `toDisplayPath` — `Read src/foo.ts`, not the absolute path) while keeping `locations[]`/`diffs[].path` **raw** so the editor opens the real path. The `tool/result` session event does not carry 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. From d753660d66b0032a1c795b7b414d54b68e3927c9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:38:42 +0800 Subject: [PATCH 60/75] docs(fs-local): a null before-image still renders a whole-file diff card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The writeText comment still said a null `before` (a create or an undiffable binary file) means "a consumer renders no result-time diff, only the call-time whole-file card." That is stale since write's presentResult renders a whole-file diff for a null before-image. Correct it: a null `before` gives no contextual-hunk basis, so the consumer falls back to a whole-file diff — the tool still renders a result diff card, not the raw result text. --- packages/fs/fs-local/src/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index 30c65058c9..8ce96fe49d 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -148,8 +148,9 @@ export class LocalFileSystem extends FileSystem { // Capture the prior text (the before/after diff basis) BEFORE the write. // `null` for a create (no existing file) OR an existing-but-undiffable - // file (binary/invalid-UTF-8) — a consumer renders no result-time diff for - // either, only the call-time whole-file card. + // file (binary/invalid-UTF-8) — a null `before` gives no contextual-hunk + // basis, so a consumer falls back to a whole-file diff (the tool still + // renders a result-time diff card, not the raw result text). const before = existing ? await readTextForDiff(target.targetKey, signal) : null await writeFileAtomic(target.targetKey, content, existing?.mode, signal, this.internals) const after = await probe(target.targetKey) From e07886599c948c375eedb2edeb8b61983582fb2a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:50:52 +0800 Subject: [PATCH 61/75] docs: finish the whole-file-diff sweep across comments and RFCs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex's stale-prose pass found seven more spots still describing the result diff as ALWAYS an applied contextual hunk, or a create/binary overwrite as rendering "only the call-time card": the DiffCallView JSDoc and the acp bridge diff-arm comment, the FsWriteOutcome.before and readTextForDiff JSDoc, and three RFC lines. All now say: the result diff is the applied change — a contextual hunk when there is a before-image, else a whole-file diff (create / undiffable binary) — and a successful mutation always returns the result diff so the model-facing text can't clobber it. Regenerate the cordis catalog (source line shift). --- docs/cordis-catalog/events-and-services.md | 2 +- .../2026-07-02-result-time-applied-hunk-diffs.md | 4 ++-- .../2026-07-02-tool-render-intent-union.md | 2 +- packages/core/tools/src/index.ts | 5 +++-- packages/fs/fs-local/src/fsio.ts | 5 +++-- packages/fs/fs/src/types.ts | 6 ++++-- packages/ui/acp/src/index.ts | 10 ++++++---- 7 files changed, 20 insertions(+), 14 deletions(-) diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 5553592ba0..f4e30fe651 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -547,7 +547,7 @@ async execute(exec: ToolExecution): Promise 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:365`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:366`](../../packages/core/tools/src/index.ts) ## Inherited tier (cordis core + loader/hmr/timer) diff --git a/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md b/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md index caa10f0de3..8a4c49ff2c 100644 --- a/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md +++ b/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md @@ -35,7 +35,7 @@ Per the [capability-seam split](2026-06-13-capability-seams.md), the storage bac ### 3. The bridge renders a `diff` result card -`ToolResultView` gains a `DiffResultView { card:'diff'; title?; diffs: FileDiff[] }`; the bridge's result-side `switch (view.card)` gets a `diff` arm emitting the `{type:'diff'}` `ToolCallContent` blocks (mirroring the call-side arm). An ACP `tool_call_update.content` REPLACES the call's content in an editor, so the result-time contextual hunk **supersedes** the call-time snippet — the two-update sequence (call snippet, then result hunk) matches `claude-agent-acp` exactly. +`ToolResultView` gains a `DiffResultView { card:'diff'; title?; diffs: FileDiff[] }`; the bridge's result-side `switch (view.card)` gets a `diff` arm emitting the `{type:'diff'}` `ToolCallContent` blocks (mirroring the call-side arm). An ACP `tool_call_update.content` REPLACES the call's content in an editor, so the result diff **supersedes** the call-time snippet (and keeps the model-facing result text from clobbering it) — the two-update sequence (call snippet, then result diff) matches `claude-agent-acp` exactly. ### The diff algorithm — a third-party runtime dependency over vendoring @@ -44,7 +44,7 @@ Computing hunks-with-context is a solved problem with sharp edge cases (grouping ## Non-goals - **Live incremental diff streaming.** The hunk is computed once, after the mutation completes; there is no per-keystroke diff. -- **Diffing a binary/non-UTF-8 overwrite.** `before` is `null` for such a file (it has no text diff basis); the write still succeeds and renders the call-time card only. +- **Diffing a binary/non-UTF-8 overwrite.** `before` is `null` for such a file (it has no text diff basis); the write still succeeds and the result renders a whole-file diff (`oldText: null`) rather than a contextual hunk. - **Rename/move diffs.** Only content diffs of a single resolved path. ## Related diff --git a/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md b/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md index 66bea7a820..d574f50bba 100644 --- a/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md +++ b/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md @@ -65,6 +65,6 @@ interface TerminalResultView { card: 'terminal'; title?: string; output?: string ## Related - Supersedes the deferral in [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md) (rejected — "wait for two real tools and two real consumers, then a tagged render-intent union"). That bar is now met; this is that union. -- Extended by [Result-time applied-hunk diffs](2026-07-02-result-time-applied-hunk-diffs.md), which adds a persisted `meta` channel so write/edit emit a result-time contextual-hunk `DiffResultView` (context lines + one hunk per `replace_all` site) on top of this union's call-time diff card. +- Extended by [Result-time applied-hunk diffs](2026-07-02-result-time-applied-hunk-diffs.md), which adds a persisted `meta` channel so write/edit emit a result-time `DiffResultView` — the applied change (a contextual hunk with context lines / one per `replace_all` site, or a whole-file diff for a create) — on top of this union's call-time diff card. - Folds `ToolTerminal` into the `terminal` views described by [ACP terminal and tool-call rendering](../feature/2026-06-18-acp-terminal-and-tool-rendering.md) (the `_meta` terminal-card convention and capability gate are unchanged; only the harness-side presentation type changes). - The ACP SDK's `Diff` / `ToolCallContent` types back the new `diff` card. diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 43cd860734..76f67291ed 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -159,8 +159,9 @@ export interface TerminalCallView { * A call that creates or modifies files, rendered as an inline diff card by a * capable UI. Set by a tool whose call writes/edits a file (e.g. `write`, * `edit`). The diffs are derived from the call ARGUMENTS (a create's `oldText` is - * `null`); the result-time applied-hunk diff (with context) is a separate - * {@link DiffResultView} the tool emits after `execute`. + * `null`); the tool emits a separate {@link DiffResultView} after `execute` — the + * applied change (an edit/overwrite hunk with context, or a whole-file diff for a + * create). */ export interface DiffCallView { card: 'diff' diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index e2aead2bfc..ae4830336b 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -386,8 +386,9 @@ export async function readForEdit( * Best-effort read of a file's current text for a before/after diff basis, used * by an overwrite. Returns the LF-normalized decoded content, or `null` when the * file is binary or not valid UTF-8 — a write must succeed regardless of the - * prior bytes, so an undiffable prior file simply yields no contextual diff - * (the caller treats `null` the same as an absent file: call-time card only). + * prior bytes, so an undiffable prior file simply yields no contextual-hunk basis + * (the caller treats `null` the same as an absent file: the result renders a + * whole-file diff rather than an applied hunk). */ export async function readTextForDiff(absolutePath: string, signal?: AbortSignal): Promise { const buffer = await readFileAbortable(absolutePath, 'read', signal) diff --git a/packages/fs/fs/src/types.ts b/packages/fs/fs/src/types.ts index 1b768724cf..424d581771 100644 --- a/packages/fs/fs/src/types.ts +++ b/packages/fs/fs/src/types.ts @@ -103,8 +103,10 @@ export interface FsWriteOutcome { version: FsVersion /** * The file's content BEFORE the write, or `null` when the file did not exist - * (a create). LF-normalized storage text (the diff basis), never a diff — a - * consumer computes the result-time contextual diff from `before`/`after`. + * (a create) or was undiffable (binary/non-UTF-8). LF-normalized storage text + * (the diff basis), never a diff — a consumer computes the result-time + * contextual diff from `before`/`after` when `before` is present, else falls + * back to a whole-file diff. */ before: string | null /** The file's content AFTER the write, LF-normalized to share `before`'s diff basis. */ diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index cfa133ae8f..1c1f0f770a 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -1170,10 +1170,12 @@ function toolResultUpdate(callId: CallId, view: ToolResultView, isError: boolean ...view.title !== undefined ? { title: view.title } : {}, } case 'diff': { - // A result-time applied-hunk diff: emit one `{ type: 'diff' }` content block - // per hunk (mirroring the call-side diff arm). `tool_call_update.content` - // REPLACES the call's content in an editor, so these hunks supersede the - // call-time whole-file snippet the pending card installed. + // A result-time diff: emit one `{ type: 'diff' }` content block per entry + // (an applied hunk for an edit/overwrite, or a whole-file diff for a + // create), mirroring the call-side diff arm. `tool_call_update.content` + // REPLACES the call's content in an editor, so this result diff supersedes + // the diff the pending card installed (and keeps the model-facing result + // text from clobbering it). const content: AcpToolCallContent[] = view.diffs.map(d => ({ type: 'diff', path: d.path, oldText: d.oldText, newText: d.newText })) return { sessionUpdate: 'tool_call_update', From e09852f5a6d1db64bf40125d245365f61e569598 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:01:54 +0800 Subject: [PATCH 62/75] docs: correct three more result-diff comments to the whole-file case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three comments still implied the result diff is always an applied hunk or that write returns undefined on no-hunk: the toolResultUpdate JSDoc (a diff result "emits the applied-hunk blocks, which replace the call-time snippet"), the empty-diffs test comment ("an empty write returns undefined" — write now falls back to a whole-file diff), and diffsFromMeta's JSDoc ("a bad meta yields no diff card" — only true for edit; write falls back to a whole-file diff). Each now states the write whole-file fallback. Regenerate the catalog. --- packages/fs/tool-fs/src/diff.ts | 3 ++- packages/ui/acp/src/index.ts | 7 ++++--- packages/ui/acp/tests/stream-update.spec.ts | 7 ++++--- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/fs/tool-fs/src/diff.ts b/packages/fs/tool-fs/src/diff.ts index 273fe0b033..64f3c5c686 100644 --- a/packages/fs/tool-fs/src/diff.ts +++ b/packages/fs/tool-fs/src/diff.ts @@ -81,7 +81,8 @@ function isFileDiff(value: unknown): value is FileDiff { * hunks, or `undefined` when it is absent/malformed. `presentResult` runs on * arbitrary logged `meta` (possibly from an older shape or a hand-edited log), so * it validates defensively rather than trusting the payload — a bad `meta` yields - * no diff card (the generic result rendering) instead of a thrown presenter. + * `undefined`, and the caller decides the fallback (edit → the generic result + * rendering; write → an args-derived whole-file diff), never a thrown presenter. */ export function diffsFromMeta(meta: unknown): FileDiff[] | undefined { if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 1c1f0f770a..ab784d2efd 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -1126,9 +1126,10 @@ function terminalExitMeta(callId: string, view: TerminalResultView): TerminalExi * (the terminal card consumes them and `content` is OMITTED — a * `tool_call_update.content` REPLACES the call's content collection in Zed, so * re-sending would clobber the terminal block the call installed) and otherwise - * derives the fenced ```console fallback from `output`. A `diff` result emits the - * applied-hunk `{ type: 'diff' }` content blocks, which replace the call-time - * whole-file snippet in the editor. + * derives the fenced ```console fallback from `output`. A `diff` result emits its + * `{ type: 'diff' }` content blocks (an applied hunk, or a whole-file diff for a + * create), which replace the diff the call installed — so the model-facing result + * text can never clobber it. */ function toolResultUpdate(callId: CallId, view: ToolResultView, isError: boolean, terminal: TerminalRendering): ToolCallSessionUpdate { const status = isError ? 'failed' as const : 'completed' as const diff --git a/packages/ui/acp/tests/stream-update.spec.ts b/packages/ui/acp/tests/stream-update.spec.ts index 0a9974e049..67cd95ccdd 100644 --- a/packages/ui/acp/tests/stream-update.spec.ts +++ b/packages/ui/acp/tests/stream-update.spec.ts @@ -677,9 +677,10 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo it('a diff result with an EMPTY diffs array and no title omits both keys (nothing to send)', () => { // A synthetic tool whose presentResult yields a `diff` card with no hunks and - // no title — the shipping fs tools never emit this (edit always has a hunk; an - // empty write returns undefined), so a stand-in is the only way to exercise - // the empty-content AND absent-title branches of the result-side diff arm. + // no title — the shipping fs tools never emit this (edit always has a hunk; + // write always falls back to a whole-file diff), so a stand-in is the only way + // to exercise the empty-content AND absent-title branches of the result-side + // diff arm. const emptyDiffTool: ToolDefinition = { name: 'writer', description: 'writes a file', From 5dfe09959d507d4f3d10fe082da8e7e828cfd48c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:02:16 +0800 Subject: [PATCH 63/75] fix(fs): keep listDir child keys under stable parent --- packages/fs/fs-local/src/fsio.ts | 7 ++++++- packages/fs/fs-local/tests/fsio.spec.ts | 28 ++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index 2472730df7..5d1713864a 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -196,6 +196,11 @@ function listingIoError(displayPath: string, error: unknown): FsError { return new FsError(`cannot list "${displayPath}": ${errorMessage(error)}`, 'FS_IO_ERROR', { cause: error }) } +async function resolveListedChildTarget(parent: LocalTarget, name: string): Promise { + const identity = await resolveLocalTarget(parent.targetKey, name) + return { displayPath: join(parent.displayPath, name), targetKey: identity.targetKey } +} + /** * List direct children of a directory in stable name order. Each child includes * a resolved target plus stat metadata when still available; file contents are @@ -225,7 +230,7 @@ export async function listDirectory(target: LocalTarget, signal?: AbortSignal): for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { throwIfAborted(signal, 'list') try { - const childTarget = await resolveLocalTarget(target.displayPath, entry.name) + const childTarget = await resolveListedChildTarget(target, entry.name) const childInfo = await probe(childTarget.targetKey) result.push({ name: entry.name, diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index 8d04a38d71..3a30f73ed2 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -6,7 +6,7 @@ */ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { chmod, mkdtemp, readFile, rm, stat, symlink, writeFile, mkdir, readdir, realpath } from 'node:fs/promises' +import { chmod, mkdtemp, readFile, rm, stat, symlink, unlink, writeFile, mkdir, readdir, realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { createServer } from 'node:net' @@ -167,6 +167,32 @@ describe('listDirectory', () => { expect(entries.find(entry => entry.name === 'dir-skill')?.size).toBeUndefined() }) + it('derives child target keys from the listed parent identity', async () => { + const realOne = join(dir, 'real-one') + const realTwo = join(dir, 'real-two') + const link = join(dir, 'link') + await mkdir(realOne) + await mkdir(realTwo) + await writeFile(join(realOne, 'same.txt'), 'one') + await writeFile(join(realTwo, 'same.txt'), 'different two') + await symlink(realOne, link) + const target = await resolveLocalTarget(dir, 'link') + + await unlink(link) + await symlink(realTwo, link) + + const entries = await listDirectory(target) + expect(entries).toHaveLength(1) + expect(entries[0]).toMatchObject({ + name: 'same.txt', + target: { + displayPath: join(link, 'same.txt'), + targetKey: await realpath(join(realOne, 'same.txt')), + }, + size: 3, + }) + }) + it('rejects missing, non-directory, and aborted listing requests', async () => { await expect(listDirectory(localTarget(join(dir, 'missing')))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) const file = join(dir, 'a.txt') From ec05295a0cb1f3a990528a17495da858e0394db5 Mon Sep 17 00:00:00 2001 From: Ziya <199893125+ZiyaZhang@users.noreply.github.com> Date: Fri, 3 Jul 2026 07:41:24 -0700 Subject: [PATCH 64/75] docs: equal-authority pairing with sidecar consistency records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesign per review: neither language is canonical. A pair is three sibling files — foo.md, foo.zh.md, foo.i18n.yaml — and either language may be authored first (a Chinese-first RFC is as legitimate as an English-first one). The sidecar record holds the FULL git blob hash of both sides as of the last confirmed-consistent state, replacing the in-file one-directional fingerprint; editing either side without re-confirming the pair goes red. New --write mode re-records a pair after both sides are brought in line, making the confirmation a reviewable yaml diff. Pairs merge whole (completeness enforced). - gate rewritten around pair anchors (union of .zh.md and .i18n.yaml remnants) so half-deleted pairs are caught from either side; red/green proven for en-only edit, zh-only edit, missing record, and a record for an excluded file - verify-rfc-classification now skips .zh.md counterparts (same RFC, indexed via its English filename; the pairing gate owns consistency) - docs/i18n/README.md + translation-rules.md reframed bidirectionally (terminology table binds both directions; typography section governs the Chinese side); zh counterparts updated; skill workflow updated - RFC amended to the shipped design, records the English-canonical in-file-fingerprint model as considered-and-revised; RFC translated (docs/rfc/.../2026-07-02-bilingual-docs-and-pairing-gate.zh.md) and added to the required frontier - generated docs stay excluded with the follow-up recorded: teach the generators to emit Chinese, then de-list --- .agents/skills/dsh-translate-docs/SKILL.md | 38 ++-- AGENTS.md | 10 +- README.i18n.yaml | 6 + README.zh.md | 2 - docs/development.i18n.yaml | 6 + docs/development.zh.md | 2 - docs/i18n/README.i18n.yaml | 6 + docs/i18n/README.md | 35 +-- docs/i18n/README.zh.md | 37 ++-- docs/i18n/translation-rules.i18n.yaml | 6 + docs/i18n/translation-rules.md | 22 +- docs/i18n/translation-rules.zh.md | 26 +-- ...-bilingual-docs-and-pairing-gate.i18n.yaml | 6 + ...6-07-02-bilingual-docs-and-pairing-gate.md | 29 ++- ...7-02-bilingual-docs-and-pairing-gate.zh.md | 36 +++ scripts/translation-pairing.manifest.json | 3 +- scripts/verify-rfc-classification.ts | 3 + scripts/verify-translation-pairing.ts | 205 ++++++++++++------ 18 files changed, 307 insertions(+), 171 deletions(-) create mode 100644 README.i18n.yaml create mode 100644 docs/development.i18n.yaml create mode 100644 docs/i18n/README.i18n.yaml create mode 100644 docs/i18n/translation-rules.i18n.yaml create mode 100644 docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml create mode 100644 docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md diff --git a/.agents/skills/dsh-translate-docs/SKILL.md b/.agents/skills/dsh-translate-docs/SKILL.md index 8fb231d2fc..cde9f00b0e 100644 --- a/.agents/skills/dsh-translate-docs/SKILL.md +++ b/.agents/skills/dsh-translate-docs/SKILL.md @@ -1,55 +1,55 @@ --- name: dsh-translate-docs -description: Use when creating or updating Chinese (.zh.md) translations of this repo's documentation — orients the translator to the bilingual pairing contract, the terminology source of truth, the translation rules, and the freshness gate that verifies the result +description: Use when creating or updating the bilingual counterpart of a doc in this repo (English ↔ Chinese pairs) — orients the translator to the pairing contract, the terminology source of truth, the translation rules, and the consistency gate that verifies the result --- # Translating DeepSeek-Harness docs -**This skill is guidance, not a translation memory.** It is the workflow map for producing `.zh.md` files that pass the pairing gate and read as natural technical Chinese. You are the translator: the rules below say what must hold, not how to phrase any particular sentence — phrasing judgment is yours, terminology is not. +**This skill is guidance, not a translation memory.** It is the workflow map for keeping `foo.md ↔ foo.zh.md` pairs consistent and natural in both languages. Both languages carry equal authority — a change is authored in either one, and that side is the source for that update. You are the translator: the rules below say what must hold, not how to phrase any particular sentence — phrasing judgment is yours, terminology is not. ## Sources of truth (read, don't re-summarize) These are authoritative; read them at the source so this skill never drifts out of sync. -- **[docs/i18n/README.md](../../../docs/i18n/README.md)** — the pairing contract: sibling `foo.md ↔ foo.zh.md`, the `i18n-source` fingerprint format, the language-switcher lines, scope/exclusions, and the rollout manifest. +- **[docs/i18n/README.md](../../../docs/i18n/README.md)** — the pairing contract: the three-file pair (`foo.md`, `foo.zh.md`, `foo.i18n.yaml`), the consistency record's both-side blob hashes, the language-switcher lines, scope/exclusions, and the rollout manifest. - **[docs/i18n/translation-rules.md](../../../docs/i18n/translation-rules.md)** — how to translate: faithfulness, structure preservation, terminology discipline, typography (MUST/SHOULD levels). -- **[docs/i18n/terminology.md](../../../docs/i18n/terminology.md)** — the terminology table. Load it BEFORE translating, not when a term feels uncertain; the terms you don't notice are the ones that drift. +- **[docs/i18n/terminology.md](../../../docs/i18n/terminology.md)** — the terminology table, binding in both directions. Load it BEFORE translating, not when a term feels uncertain; the terms you don't notice are the ones that drift. ## Find the work -- `pnpm run verify-translation-pairing --list` prints every in-scope document as missing / stale / ok — the work list for a translation batch. -- In a PR that edits English docs, the work list is the diff itself: every changed `.md` with an existing `.zh.md` sibling needs its translation updated in the same PR, and the gate goes red if you forget. +- `pnpm run verify-translation-pairing --list` prints every in-scope document as missing / out-of-sync / ok — the work list for a translation batch. +- In a PR that edits paired docs, the work list is the diff itself: every changed side of a pair needs its counterpart updated and the pair re-recorded in the same PR, and the gate goes red if you forget. ## Triage by change type Do not process every file the same way: -- **New translation** (no `.zh.md` yet): translate the whole file, section by section for long documents — keep each section's structure locked to the source as you go rather than fixing structure at the end. -- **Update** (`.zh.md` exists but stale): do NOT re-translate the file. The fingerprint names the exact source text the translation was based on — recover it and diff: +- **New pair** (no counterpart yet): whichever language exists — English or Chinese — translate the whole file into the other, section by section for long documents, keeping each section's structure locked to the source as you go rather than fixing structure at the end. +- **Update** (pair exists, one side edited): do NOT re-translate. The consistency record names the exact last-confirmed text of both sides — recover the edited side's previous state and diff: ```sh - git cat-file -p > /tmp/old-source.md - git diff --no-index /tmp/old-source.md docs/foo.md + git cat-file -p > /tmp/last-confirmed.md + git diff --no-index /tmp/last-confirmed.md docs/foo.md ``` - Apply the smallest Chinese edits that cover that diff. A minimal update preserves the reviewed phrasing of everything that didn't change; a re-translation throws that review away. -- **Deleted or renamed source**: delete or rename the `.zh.md` alongside it — the gate reports it as an orphan otherwise. + Apply the smallest counterpart edits that cover that diff. A minimal update preserves the reviewed phrasing of everything that didn't change; a re-translation throws that review away. +- **Deleted or renamed doc**: delete or rename the counterpart and the `.i18n.yaml` alongside it — the gate reports an incomplete pair otherwise. ## Translate -- Work through the document applying [translation-rules.md](../../../docs/i18n/translation-rules.md). Internally: first render faithfully, then re-read the Chinese alone for awkward or ambiguous phrasing, then polish — but write ONLY the final Chinese to the file, never drafts or notes. -- Every term in [terminology.md](../../../docs/i18n/terminology.md) renders exactly as specified, including first-occurrence annotations. A term the table misses: translate only with a citable precedent from a major Chinese OSS/vendor doc; otherwise keep the English and add it to the PR's 「待定术语」 list with your suggested rendering. Never invent a rendering inline — that decision belongs to a human and then to the table. -- Code blocks are byte-identical to the source, comments included. Relative links keep their English targets; only the switcher line links `.zh.md`. +- Work through the document applying [translation-rules.md](../../../docs/i18n/translation-rules.md). Internally: first render faithfully, then re-read the counterpart alone for awkward or ambiguous phrasing, then polish — but write ONLY the final text to the file, never drafts or notes. +- Every term in [terminology.md](../../../docs/i18n/terminology.md) renders exactly as specified, in both directions, including first-occurrence annotations. A term the table misses: translate only with a citable precedent from a major Chinese OSS/vendor doc; otherwise keep the English and add it to the PR's 「待定术语」 list with your suggested rendering. Never invent a rendering inline — that decision belongs to a human and then to the table. +- Code blocks are byte-identical across the pair, comments included. Relative links keep their `.md` targets; only the switcher line links `.zh.md`. ## Finish the pair -1. Fingerprint: compute the source's current blob hash and write the comment as the FIRST line of the `.zh.md` — `git hash-object docs/foo.md` → ``. -2. Switcher: `[English](foo.md) | 中文` immediately after the translation's H1; confirm the English file carries `English | [中文](foo.zh.md)` after its own H1 — add it if this is the pair's first translation. -3. New batch landed? Add the English paths to `required` in [scripts/translation-pairing.manifest.json](../../../scripts/translation-pairing.manifest.json) so the gate ratchets forward. +1. Switcher: `[English](foo.md) | 中文` immediately after the Chinese file's H1, `English | [中文](foo.zh.md)` after the English file's H1 — add both if this is a new pair. +2. Record consistency: `pnpm run verify-translation-pairing --write` recomputes and records both sides' full blob hashes in `foo.i18n.yaml`. The yaml diff in your PR is the reviewable statement "I confirmed these two say the same thing" — only run it after you actually have. +3. New batch landed? Add the `.md` paths to `required` in [scripts/translation-pairing.manifest.json](../../../scripts/translation-pairing.manifest.json) so the gate ratchets forward. ## Verify — the gate, not your eyes -Run `pnpm run verify-translation-pairing`, then the rest of the Markdown gates (`pnpm run verify-md-wrap && pnpm run verify-md-links`, or full `pnpm run doc-sync` before the PR). Fix what they report; do not hand-check what they cover. What they can NOT check — translation quality, terminology judgment calls, tone — is exactly what the PR reviewer will read for, so keep the PR reviewable: state which files are new translations vs minimal updates, and list 「待定术语」 prominently. +Run `pnpm run verify-translation-pairing`, then the rest of the Markdown gates (`pnpm run verify-md-wrap && pnpm run verify-md-links`, or full `pnpm run doc-sync` before the PR). Fix what they report; do not hand-check what they cover. What they can NOT check — whether the two sides truly say the same thing, terminology judgment calls, tone — is exactly what the PR reviewer will read for, so keep the PR reviewable: state which pairs are new vs minimally updated, and list 「待定术语」 prominently. ## How to respond to translation review diff --git a/AGENTS.md b/AGENTS.md index 617a16089f..cb7f6ba2fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -178,9 +178,11 @@ pnpm run verify-rfc-classification # assert every RFC lives in a valid # {lifecycle}/{class}/ folder and docs/rfc/README.md lists it # under the matching heading (closed class set + index completeness) pnpm run verify-translation-pairing # assert the bilingual pairing contract - # (docs/i18n/README.md): required docs have a .zh.md sibling; - # every .zh.md is fingerprint-fresh, switcher-linked, and - # structure-matched. `--list` prints the translation work list + # (docs/i18n/README.md): required docs have a complete pair + # (foo.md + foo.zh.md + foo.i18n.yaml); every pair matches its + # recorded consistency hashes, is switcher-linked, and + # structure-matched. `--list` prints the work list; `--write` + # re-records a pair after you bring both sides in line pnpm run verify-node-next-types # assert built declarations typecheck for a # standard external NodeNext ESM TypeScript consumer pnpm run doc-sync # doc-typecheck + verify-cordis-catalog + verify-tool-catalog + verify-md-wrap + verify-md-links + verify-doc-refs + verify-package-paths + verify-rfc-classification + verify-type-equiv + verify-translation-pairing (CI runs this) @@ -280,7 +282,7 @@ This codebase aims to be **very type-safe and well documented** for maintainabil In the **core** packages (`packages/llm/llm`, `packages/core/tools`, `packages/core/agent`, `packages/core/agent-loop`, `packages/core/session`, `packages/core/system-prompt`), **type gymnastics are acceptable when they improve the DX of plugin authors** for common plugin types. The `defineTool` typed schema DSL in `dsh-tools` is the canonical example: the `SchemaSpec` to `InferArgs` type-level mapping gives tool authors zero-cast typed `execute` args, and the cost of the conditional types stays inside the core package. -Verbose documentation is fine **as long as docs and code stay strictly in sync**. Out-of-sync docs are worse than no docs. **When you change code, update its docs in the SAME change** — grep the package README and the module/JSDoc comments for the old behavior (config keys, defaults, error codes, wire field names, event names) and fix every hit. CI runs `pnpm run doc-sync` (`doc-typecheck` + `verify-cordis-catalog` + `verify-tool-catalog` + `verify-md-wrap` + `verify-md-links` + `verify-doc-refs` + `verify-package-paths` + `verify-rfc-classification` + `verify-type-equiv` + `verify-translation-pairing`), which typechecks every fenced `ts` block in `README.md`, `docs/**/*.md`, and `packages/*/*.md`, regenerates the cordis events/services catalog from source and fails if the committed copy is stale, asserts no hard-wrapped prose paragraphs, checks that every relative Markdown cross-link resolves, checks that every `docs/*.md` path cited in a source comment resolves, checks that every `packages/` reference naming a real package resolves, checks that every RFC is filed under a valid class folder and listed in its index, checks that every ` ```ts type-equiv ` doc block still matches its source type, and checks the bilingual pairing contract (required docs have a fresh `.zh.md` sibling — see [docs/i18n/README.md](docs/i18n/README.md)) — across those files plus `AGENTS.md` / `packages/AGENTS.md` — but that scope does NOT catch prose drift in `AGENTS.md` / `packages/AGENTS.md` / `packages/README.md` (config keys, defaults, error codes), so keeping those in sync remains on the author. The same-change rule extends to translations: **editing an English doc that has a `.zh.md` sibling means updating the translation in the SAME change** (run the [dsh-translate-docs](.agents/skills/dsh-translate-docs/SKILL.md) skill); the pairing gate goes red otherwise. Every module has a module-level doc comment explaining its role. Every exported class, interface, type, function, and non-obvious method has a JSDoc that explains semantics (not just the name) — contracts (what events fire when), disposal behavior, error behavior, and extension intent. Internal helpers get docs only where non-obvious. Prefer one-liners when one line suffices. +Verbose documentation is fine **as long as docs and code stay strictly in sync**. Out-of-sync docs are worse than no docs. **When you change code, update its docs in the SAME change** — grep the package README and the module/JSDoc comments for the old behavior (config keys, defaults, error codes, wire field names, event names) and fix every hit. CI runs `pnpm run doc-sync` (`doc-typecheck` + `verify-cordis-catalog` + `verify-tool-catalog` + `verify-md-wrap` + `verify-md-links` + `verify-doc-refs` + `verify-package-paths` + `verify-rfc-classification` + `verify-type-equiv` + `verify-translation-pairing`), which typechecks every fenced `ts` block in `README.md`, `docs/**/*.md`, and `packages/*/*.md`, regenerates the cordis events/services catalog from source and fails if the committed copy is stale, asserts no hard-wrapped prose paragraphs, checks that every relative Markdown cross-link resolves, checks that every `docs/*.md` path cited in a source comment resolves, checks that every `packages/` reference naming a real package resolves, checks that every RFC is filed under a valid class folder and listed in its index, checks that every ` ```ts type-equiv ` doc block still matches its source type, and checks the bilingual pairing contract (required docs have a complete, consistency-recorded EN/ZH pair — see [docs/i18n/README.md](docs/i18n/README.md)) — across those files plus `AGENTS.md` / `packages/AGENTS.md` — but that scope does NOT catch prose drift in `AGENTS.md` / `packages/AGENTS.md` / `packages/README.md` (config keys, defaults, error codes), so keeping those in sync remains on the author. The same-change rule extends to translations: **editing either side of a paired doc means updating the counterpart and re-recording the pair in the SAME change** (run the [dsh-translate-docs](.agents/skills/dsh-translate-docs/SKILL.md) skill, then `pnpm run verify-translation-pairing --write`); the pairing gate goes red otherwise. Every module has a module-level doc comment explaining its role. Every exported class, interface, type, function, and non-obvious method has a JSDoc that explains semantics (not just the name) — contracts (what events fire when), disposal behavior, error behavior, and extension intent. Internal helpers get docs only where non-obvious. Prefer one-liners when one line suffices. **Tag every new event with `@mode`.** The cordis events/services catalog ([docs/cordis-catalog/events-and-services.md](docs/cordis-catalog/events-and-services.md)) is GENERATED from source by `scripts/gen-cordis-catalog.ts` — never hand-edit it; run `pnpm run gen-cordis-catalog` and commit the result. When you add an event to an `interface Events` block, its JSDoc MUST carry a `@mode emit|waterfall|parallel|serial` tag (the generator hard-errors without it): use `waterfall` when the signature ends with a `next: () => …` parameter (the listener transforms or vetoes via `next()`), `parallel` when the loop awaits a fan-out and must run every listener (e.g. an awaited `Promise | void` checkpoint like `session/flush`), `serial` when the loop awaits listeners in registration order and should isolate side effects (e.g. an ordered surface-mutation checkpoint like `agent/pre-step`; Cordis stops early if a listener returns a bail value, so `void` serial listeners must not return a semantic veto), and `emit` for plain fire-and-forget notifications. The generator also cross-checks the tag against the signature where the shape is conclusive (a trailing `next` ⇒ waterfall) and hard-errors on a contradiction. Write the rest of the event's JSDoc to stand alone — it is the catalog entry's prose. diff --git a/README.i18n.yaml b/README.i18n.yaml new file mode 100644 index 0000000000..7d4d1b9814 --- /dev/null +++ b/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 33c03fad1450d91ba1adef3d89ce44d0ff73c25b +README.zh.md: 9d520023c528810ce75ee80efec2f2f087fb7b0e diff --git a/README.zh.md b/README.zh.md index 62dbb01683..9d520023c5 100644 --- a/README.zh.md +++ b/README.zh.md @@ -1,5 +1,3 @@ - - # DeepSeek Harness [English](README.md) | 中文 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml new file mode 100644 index 0000000000..2d18cb1c8a --- /dev/null +++ b/docs/development.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +development.md: ce431d95c5dbb976d0c3ffa827af46ba608b400e +development.zh.md: 36155ee2b93f2bc309ca1341cbed82c37e2759c9 diff --git a/docs/development.zh.md b/docs/development.zh.md index e08980c33d..36155ee2b9 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -1,5 +1,3 @@ - - # 开发指南 [English](development.md) | 中文 diff --git a/docs/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml new file mode 100644 index 0000000000..7a2407ed36 --- /dev/null +++ b/docs/i18n/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +README.md: 6e2bbd27c3288037bafeb6cc71b801d56b956ab4 +README.zh.md: 04c99ae336cf1e96cbc185f0ccbd063ef8977944 diff --git a/docs/i18n/README.md b/docs/i18n/README.md index fb0e17390e..6e2bbd27c3 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -6,44 +6,45 @@ This repo's documentation is read by people and agents both inside and outside t ## The pairing contract -- **English is canonical.** Every document is authored in English at its existing path, and the Chinese file is derived from it — translation flows EN → ZH only. A content change starts in the English file; the Chinese file never carries information its English source lacks. -- **Paired sibling files.** The translation of `foo.md` is `foo.zh.md` in the same directory. No locale directories, no separate translation repo, no interleaved bilingual files. -- **Source fingerprint.** The FIRST line of every `.zh.md` file is an HTML comment recording the repo-relative path and the git blob hash (first 12 hex digits of `git hash-object`) of the English source it was translated from: +- **Both languages carry equal authority.** A document may be authored and reviewed in either language first — a Chinese-first RFC is as legitimate as an English-first one — and the counterpart is translated from it. Neither file outranks the other; what binds them is that they must say the same thing. +- **A pair is three sibling files.** The English `foo.md`, the Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`, all in the same directory. No locale directories, no separate translation repo, no interleaved bilingual files. Pairs merge whole: a PR never lands one language without the other two files. +- **The consistency record.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last time the two were confirmed to say the same thing: - ```markdown - + ```yaml + foo.md: 3f786850e387550fdab836ed7e6dc881de23001b + foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b ``` - A blob hash, not a commit hash, so the fingerprint is computable for an English file edited in the same PR (`git hash-object docs/foo.md`), and so staleness is a pure content comparison. The fingerprint is also the update tool: `git cat-file -p ` recovers the exact source text a stale translation was based on, and `git diff ` isolates what changed so the translation can be updated minimally instead of re-translated. + Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. The recorded hash also recovers the exact last-confirmed text of either side (`git cat-file -p `), so an out-of-sync pair is updated by diffing the edited side against its last-confirmed state and patching the counterpart minimally — never by re-translating whole files. After bringing the pair back in line, `pnpm run verify-translation-pairing --write` re-records both hashes; that yaml diff is the reviewable act of confirming consistency. - **Language switcher.** Both files link to each other immediately after their H1 heading: the English file carries `English | [中文](foo.zh.md)` and the Chinese file carries `[English](foo.md) | 中文`. -- **Structure mirrors the source.** Heading depths and order, list kinds, table columns, link targets, and verbatim code blocks match the English file one to one — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`). +- **Structure mirrors the counterpart.** Heading depths and order, list kinds, table columns, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`). ## The gate: verify-translation-pairing `pnpm run verify-translation-pairing` (part of `doc-sync`, so CI and the pre-push hook run it) enforces the contract mechanically: -1. Every English file listed as `required` in [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) has a `.zh.md` sibling. -2. Every existing `.zh.md` file — required or not — passes all of: its English source exists (no orphans), its fingerprint matches the source's current blob hash (no stale translations), both sides carry the language switcher, and its structural signature matches the source in order — heading depths, verbatim code blocks (info string and content), table column counts, list kinds, and every link target apart from the switcher. -3. Files listed as `excluded` have no `.zh.md` sibling at all. +1. Every file listed as `required` in [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) has a complete pair. +2. Every pair that exists at all — required or not — is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), both sides carry the language switcher, and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table column counts, list kinds, and every link target apart from the switcher. +3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all. -`pnpm run verify-translation-pairing --list` prints the current translation state of every document in scope — missing, stale, or ok — and is the work list for translation batches. It never fails; it reports. +`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok — and is the work list for translation batches. It never fails; it reports. -The practical rule this gate creates: **when a PR edits an English document that has a `.zh.md` sibling, the same PR updates the translation** (run the [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill), exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a translation stale goes red in CI. +The practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart and re-records the pair** (run the [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill, then `--write`), exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI. -The gate's limit, stated plainly: **a green gate means fresh and structurally sound, not verified.** It checks the fingerprint and the shape; it cannot judge whether the Chinese is accurate, well-termed, or natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-fingerprinted `.zh.md` with a sloppy translation passes the gate; it must not pass review. +The gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and shape; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review. ## Scope, exclusions, and rollout **Scope**: the root `README.md` and everything under `docs/**`. Package READMEs (`packages/**`) join the scope in a later batch. -**Excluded** (never paired, and the gate rejects a `.zh.md` for them): +**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them): -- `docs/cordis-catalog/`, `docs/tool-catalog/`, `docs/module-graph.md` — generated files; their generators emit English only, so a translation would go stale on every regeneration. +- `docs/cordis-catalog/`, `docs/tool-catalog/`, `docs/module-graph.md` — generated files; their generators emit English only today, so a hand-written translation would go stale on every regeneration. The planned follow-up is to teach the generators to emit Chinese alongside English, at which point these leave the exclusion list. - `docs/AGENTS.md` — agent instructions, maintained in English only like the root `AGENTS.md`. - `docs/i18n/terminology.md` — the terminology table is itself bilingual by construction. -**Rollout**: the `required` list in the manifest is the enforcement frontier, not the goal. The goal is full bilingual coverage of the scope. Translation lands in reviewable batches (core entry docs, cookbook, RFCs, postmortems, …); each merged batch adds its files to `required`, so the gate ratchets forward and never regresses. Documents not yet in `required` are backlog — visible in `--list` — but any translation that already exists is held to the full contract regardless of the list. Pairing a document is a commitment: every later English edit to it must carry the translation along, so grow the frontier at the pace translation review is actually resourced, not ahead of it. +**Rollout**: the `required` list in the manifest is the enforcement frontier, not the goal. The goal is full bilingual coverage of the scope. Pairs land in reviewable batches (core entry docs, cookbook, RFCs, postmortems, …); each merged batch adds its files to `required`, so the gate ratchets forward and never regresses. Documents not yet in `required` are backlog — visible in `--list` — but any pair that already exists is held to the full contract regardless of the list. Pairing a document is a commitment: every later edit to either side must carry the counterpart along, so grow the frontier at the pace translation review is actually resourced, not ahead of it. ## Division of labor -Translations here are produced by an agent running [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) and reviewed by a human — inference is cheap here, review attention is the scarce resource. The gate exists so that neither the agent nor the reviewer has to remember the contract: pairing, freshness, and structure are checked mechanically, and review attention goes to translation quality and terminology, where human judgment is the whole point. +Counterparts here are produced by an agent running [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) and reviewed by a human — inference is cheap here, review attention is the scarce resource. The gate exists so that neither the agent nor the reviewer has to remember the contract: pair completeness, consistency, and structure are checked mechanically, and review attention goes to translation quality and terminology, where human judgment is the whole point. diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index d7e0b29dc8..04c99ae336 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -1,5 +1,3 @@ - - # 双语文档 [English](README.md) | 中文 @@ -8,44 +6,45 @@ ## 配对契约 -- **英文是唯一真源。**每篇文档都以英文在其现有路径撰写,中文文件由它派生——翻译只沿 EN → ZH 单向流动。内容变更始于英文文件;中文文件永远不携带英文源没有的信息。 -- **配对的同目录文件。**`foo.md` 的译文是同目录下的 `foo.zh.md`。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。 -- **源指纹。**每个 `.zh.md` 文件的第一行是一条 HTML 注释,记录它翻译所依据的英文源的仓库相对路径和 git blob hash(`git hash-object` 的前 12 位十六进制): +- **两种语言同权。**一篇文档可以先用任一语言撰写和评审——先写中文的 RFC 与先写英文的一样正当——另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。 +- **一对文档是三个同目录文件。**英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对整体合入:PR 永远不会只带一种语言而缺其余两个文件。 +- **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 git blob hash: - ```markdown - + ```yaml + foo.md: 3f786850e387550fdab836ed7e6dc881de23001b + foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b ``` - 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的英文文件也能算出指纹(`git hash-object docs/foo.md`),陈旧检测则是纯内容比较。指纹同时也是更新工具:`git cat-file -p ` 能还原陈旧译文当初依据的确切源文本,`git diff ` 能隔离出变化的部分,让译文做最小更新而不是整篇重译。 + 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。记录的 hash 还能还原任一侧上次确认时的确切文本(`git cat-file -p `),所以失去同步的配对是「把被改的一侧与其上次确认状态做 diff、再最小化地修补另一侧」——从不整篇重译。两侧对齐后,`pnpm run verify-translation-pairing --write` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审。 - **语言切换行。**两个文件在各自 H1 标题之后立即互链:英文文件带 `English | [中文](foo.zh.md)`,中文文件带 `[English](foo.md) | 中文`。 -- **结构与源一一对应。**标题深度与顺序、列表类型、表格列、链接目标与逐字节一致的代码块和英文文件一一对应——完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。 +- **结构与另一侧一一对应。**标题深度与顺序、列表类型、表格列、链接目标与逐字节一致的代码块在配对两侧一一对应——完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。 ## 门禁:verify-translation-pairing `pnpm run verify-translation-pairing`(`doc-sync` 的一环,因此 CI 和 pre-push 钩子都会运行)机械地强制执行这份契约: -1. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 中 `required` 列出的每个英文文件都有 `.zh.md` 配对文件。 -2. 每个已存在的 `.zh.md` 文件——无论是否 required——都通过全部检查:其英文源存在(无孤立文件)、指纹等于源的当前 blob hash(无陈旧译文)、双方都带语言切换行、其结构签名与源按序一致——标题深度、逐字节一致的代码块(信息字符串与内容)、表格列数、列表类型,以及除切换行之外的每个链接目标。 -3. 列为 `excluded` 的文件完全没有 `.zh.md` 配对。 +1. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 中 `required` 列出的每个文件都有完整配对。 +2. 任何已存在的配对——无论是否 required——都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、双方都带语言切换行、结构签名按序一致——标题深度、逐字节一致的代码块(信息字符串与内容)、表格列数、列表类型,以及除切换行之外的每个链接目标。 +3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。 -`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前翻译状态——missing、stale 或 ok——是翻译批次的工作清单。它从不失败;它只报告。 +`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态——missing、out-of-sync 或 ok——是翻译批次的工作清单。它从不失败;它只报告。 -这个门禁带来的实际规则是:**当一个 PR 修改了已有 `.zh.md` 配对的英文文档时,同一个 PR 更新译文**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill(技能)),与本仓库既有的代码/README doc-sync 规则完全一致。留下陈旧译文的 PR 会在 CI 变红。 +这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 更新另一侧并重新记录配对**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill(技能),再 `--write`),与本仓库既有的代码/README doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。 -把门禁的边界说白:**门禁绿意味着新鲜且结构健全,不意味着已核验。**它检查指纹和形状;它无法判断中文是否准确、术语是否得当、行文是否自然——那是契约中评审者的那一半,见 [translation-rules.md](translation-rules.md)。一个重打了指纹但翻得潦草的 `.zh.md` 能通过门禁;它不得通过评审。 +把门禁的边界说白:**门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。**它检查 hash 和形状;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然——那是契约中评审者的那一半,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。 ## 范围、排除与推进 **范围**:根 `README.md` 与 `docs/**` 下的全部内容。package README(`packages/**`)在后续批次加入范围。 -**排除**(永不配对,门禁拒绝为它们建 `.zh.md`): +**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`): -- `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/module-graph.md`——生成文件;生成器只输出英文,译文在每次重新生成时必然陈旧。 +- `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/module-graph.md`——生成文件;生成器目前只输出英文,手写译文在每次重新生成时必然陈旧。计划中的后续工作是让生成器同时输出中文,届时这些文件移出排除清单。 - `docs/AGENTS.md`——agent 指令,与根 `AGENTS.md` 一样只以英文维护。 - `docs/i18n/terminology.md`——术语表本身即是双语构造。 -**推进**:manifest 中的 `required` 列表是强制边界,不是目标。目标是范围内的全量双语覆盖。翻译按可评审的批次落地(核心入口文档、cookbook、RFC、postmortem……);每个批次合入后把其文件加进 `required`,门禁只进不退。尚未进入 `required` 的文档是 backlog——在 `--list` 中可见——但任何已存在的译文无论在不在清单里都按完整契约检查。给一篇文档配对是一份承诺:此后对它的每次英文修改都必须带上译文,所以边界的扩张要跟上翻译评审的实际投入节奏,不要抢在前面。 +**推进**:manifest 中的 `required` 列表是强制边界,不是目标。目标是范围内的全量双语覆盖。配对按可评审的批次落地(核心入口文档、cookbook、RFC、postmortem……);每个批次合入后把其文件加进 `required`,门禁只进不退。尚未进入 `required` 的文档是 backlog——在 `--list` 中可见——但任何已存在的配对无论在不在清单里都按完整契约检查。给一篇文档配对是一份承诺:此后对任一侧的每次修改都必须带上另一侧,所以边界的扩张要跟上翻译评审的实际投入节奏,不要抢在前面。 ## 分工 -这里的译文由运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 的 agent 产出、由人评审——在这里推理(inference)很便宜,评审注意力才是稀缺资源。门禁的存在让 agent 和评审者都不必记住契约:配对、新鲜度和结构由机械检查兜底,评审注意力投向翻译质量与术语——这正是人的判断的用武之地。 +这里的对侧译文由运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 的 agent 产出、由人评审——在这里推理(inference)很便宜,评审注意力才是稀缺资源。门禁的存在让 agent 和评审者都不必记住契约:配对完整性、一致性和结构由机械检查兜底,评审注意力投向翻译质量与术语——这正是人的判断的用武之地。 diff --git a/docs/i18n/translation-rules.i18n.yaml b/docs/i18n/translation-rules.i18n.yaml new file mode 100644 index 0000000000..579bd51511 --- /dev/null +++ b/docs/i18n/translation-rules.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +translation-rules.md: 4e190f58469f7d402dfa5600f17cf1621484f138 +translation-rules.zh.md: 89a1cddd23126f24354ce1f8d9af4e7bd403454d diff --git a/docs/i18n/translation-rules.md b/docs/i18n/translation-rules.md index 323504edea..4e190f5846 100644 --- a/docs/i18n/translation-rules.md +++ b/docs/i18n/translation-rules.md @@ -1,14 +1,14 @@ -# Translation rules (EN → ZH) +# Translation rules English | [中文](translation-rules.zh.md) -How to translate a document in this repo into Simplified Chinese. These rules bind humans and agents equally; the committed agent workflow that applies them is [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md), and the pairing/freshness mechanics live in [README.md](README.md). Rule levels follow RFC 2119 usage: **MUST** / **MUST NOT** are gate- or review-blocking; **SHOULD** needs a stated reason to deviate; **MAY** is discretionary. +How to translate between the two sides of a documentation pair in this repo. Both languages carry equal authority ([README.md](README.md)): a change is authored in either language, and that side is the source for that update — these rules govern producing or updating the counterpart. They bind humans and agents equally; the committed agent workflow that applies them is [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md). Rule levels follow RFC 2119 usage: **MUST** / **MUST NOT** are gate- or review-blocking; **SHOULD** needs a stated reason to deviate; **MAY** is discretionary. ## Faithfulness -- The translation MUST say what the source says — no added behavior, prerequisites, warnings, version claims, or examples, and no dropped ones. If the source is wrong, fix the English file first (English is canonical), then re-translate. -- The translation SHOULD read as natural technical Chinese, not word-by-word gloss. Translate meaning, restructure sentences where Chinese grammar wants it, and keep the author's register — terse stays terse. -- Do not translate the untranslatable: if a sentence resists natural rendering because it leans on an English idiom, translate the idea, not the idiom. +- The counterpart MUST say what the authored side says — no added behavior, prerequisites, warnings, version claims, or examples, and no dropped ones. If the pair disagrees on substance, neither language wins by default: fix the side that is wrong, then bring the other along in the same change. +- The counterpart SHOULD read as natural technical writing in its own language, not word-by-word gloss. Translate meaning, restructure sentences where the target grammar wants it, and keep the author's register — terse stays terse. +- Do not translate the untranslatable: if a sentence resists natural rendering because it leans on an idiom of the source language, translate the idea, not the idiom. ## Structure preservation @@ -19,19 +19,19 @@ The paired files MUST match one to one in: - tables (same columns, same row order; header cells translated per terminology), - fenced code blocks — **byte-identical, including comments**; code is part of the verified surface (` ```ts ` blocks compile under `doc-typecheck`), and an edited comment is drift the fence-count gate cannot see, - inline code spans (commands, flags, config keys, file paths, event names, API names, version numbers) — verbatim, never translated or reformatted, -- links and anchors: every relative link MUST point at the same target as the source — the canonical English file — so links never dangle when a translation batch lands before its neighbors. The ONLY zh-specific link is the language switcher. Link TEXT is translated; the target is not. +- links and anchors: every relative link MUST point at the same target in both files — by convention the `.md` path, not the `.zh.md` sibling — so links never dangle when one pair lands before its neighbors. The ONLY zh-specific link is the language switcher. Link TEXT is translated; the target is not. The repo's Markdown conventions apply to `.zh.md` files unchanged: one physical line per paragraph (`verify-md-wrap`), resolving relative links (`verify-md-links`), exactly one trailing newline. ## Terminology -- [terminology.md](terminology.md) is the source of truth. Before translating, load it; while translating, every term it lists MUST be rendered exactly as it specifies, including its first-occurrence annotations (e.g. `agent(智能体)` on first mention, plain `agent` after) and its "不要译作" prohibitions. +- [terminology.md](terminology.md) is the source of truth in both directions. Before translating, load it; while translating, every term it lists MUST be rendered exactly as it specifies, including its first-occurrence annotations (e.g. `agent(智能体)` on first mention, plain `agent` after) and its "不要译作" prohibitions. When the Chinese side is authored first, the English counterpart uses the table's English column the same way. - A technical term NOT in the table MAY be translated only when a major Chinese-language OSS or vendor doc has an established rendering for it (K8s/Vue/MDN Chinese docs, 微软简中风格指南, big-tech project docs). Cite the precedent in the PR. - A term with NO established precedent MUST stay in English in the translation and MUST be listed in the PR description under 「待定术语」(pending terms) with a suggested rendering for the reviewer to decide. MUST NOT invent a Chinese rendering inline — an unprecedented translation creates exactly the ambiguity the terminology table exists to prevent. Decided terms then land in [terminology.md](terminology.md) in the same PR or a follow-up. ## Typography -The mixed-script rules below follow the cross-project consensus of the [MDN Simplified Chinese translation guide](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md), the [Kubernetes zh-cn localization guide](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/), the [Vue.js Chinese translation conventions](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5), and [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines), which in turn ground in [W3C clreq](https://www.w3.org/TR/clreq/) and GB/T 15834—2011: +These rules govern the Chinese side; the English side follows the repo's normal Markdown conventions (root `AGENTS.md`). The mixed-script rules below follow the cross-project consensus of the [MDN Simplified Chinese translation guide](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md), the [Kubernetes zh-cn localization guide](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/), the [Vue.js Chinese translation conventions](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5), and [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines), which in turn ground in [W3C clreq](https://www.w3.org/TR/clreq/) and GB/T 15834—2011: - MUST put one half-width space between Chinese text and Latin words, and between Chinese text and numerals: `每个 plugin 注册 3 个 tool`。No space between a full-width punctuation mark and anything. - MUST use full-width (Chinese) punctuation in Chinese prose: `,。:;?!()「」`. Half-width punctuation stays inside code spans, inside complete English sentences quoted as-is, and in numbers (`3.5`, `1,024`). @@ -43,9 +43,9 @@ The mixed-script rules below follow the cross-project consensus of the [MDN Simp ## Quality bar -- A translation is done when a bilingual engineer reading only the Chinese file gets everything a reader of the English file gets — same facts, same caveats, same tone — and nothing extra. -- Before handing off, self-check the result against this file and re-read the Chinese ALONE, without the English side by side; awkward phrasing is easier to hear without the source anchoring you. -- The mechanical contract (fingerprint, switcher, structure counts, wrap, links) is checked by `pnpm run verify-translation-pairing` and the rest of `doc-sync` — run them; do not hand-verify what a gate covers. +- A pair is done when a bilingual engineer reading either file alone gets everything a reader of the other gets — same facts, same caveats, same tone — and nothing extra. +- Before handing off, self-check the result against this file and re-read the counterpart ALONE, without the source side by side; awkward phrasing is easier to hear without the source anchoring you. +- The mechanical contract (consistency record, switcher, structure, wrap, links) is checked by `pnpm run verify-translation-pairing` and the rest of `doc-sync` — run them; do not hand-verify what a gate covers. ## References diff --git a/docs/i18n/translation-rules.zh.md b/docs/i18n/translation-rules.zh.md index b1cf95a3ef..89a1cddd23 100644 --- a/docs/i18n/translation-rules.zh.md +++ b/docs/i18n/translation-rules.zh.md @@ -1,16 +1,14 @@ - - -# 翻译规则(EN → ZH) +# 翻译规则 [English](translation-rules.md) | 中文 -本文规定如何把本仓库的文档翻译成简体中文。这些规则对人和 agent(智能体)同等生效;应用它们的进仓 agent 工作流是 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md),配对与新鲜度机制见 [README.md](README.md)。规则级别沿用 RFC 2119 的用法:**必须(MUST)**/**禁止(MUST NOT)**会卡门禁或评审;**应当(SHOULD)**偏离时要说明理由;**可以(MAY)**自行裁量。 +本文规定如何在本仓库文档配对的两侧之间进行翻译。两种语言同权(见 [README.md](README.md)):一次变更用任一语言撰写,那一侧就是这次更新的源——本文的规则约束的是产出或更新另一侧。这些规则对人和 agent(智能体)同等生效;应用它们的进仓 agent 工作流是 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。规则级别沿用 RFC 2119 的用法:**必须(MUST)**/**禁止(MUST NOT)**会卡门禁或评审;**应当(SHOULD)**偏离时要说明理由;**可以(MAY)**自行裁量。 ## 忠实性 -- 译文必须说源文所说的话——不添加行为、前置条件、警告、版本声明或示例,也不丢弃任何一项。如果源文有错,先改英文文件(英文是唯一真源),再重新翻译。 -- 译文应当读起来是自然的中文技术文字,而不是逐词对照。翻译语义,在中文语法需要处重组句子,并保持原作者的语域——简练的保持简练。 -- 不要翻译不可译的东西:一句话如果依赖英文习语而无法自然转换,就翻译它的意思,而不是习语本身。 +- 另一侧必须说撰写侧所说的话——不添加行为、前置条件、警告、版本声明或示例,也不丢弃任何一项。如果两侧在实质内容上不一致,没有哪种语言默认获胜:改正错的那一侧,并在同一个变更里把另一侧带上。 +- 另一侧应当读起来是其语言自然的技术文字,而不是逐词对照。翻译语义,在目标语言语法需要处重组句子,并保持原作者的语域——简练的保持简练。 +- 不要翻译不可译的东西:一句话如果依赖源语言的习语而无法自然转换,就翻译它的意思,而不是习语本身。 ## 结构保持 @@ -21,19 +19,19 @@ - 表格(相同的列、相同的行序;表头单元格按术语表翻译), - 围栏代码块——**逐字节一致,包括注释**;代码属于受验证的范围(` ```ts ` 块要通过 `doc-typecheck` 编译),而被改动的注释是代码块计数门禁看不见的漂移, - 行内代码(命令、flag、配置键、文件路径、事件名、API 名、版本号)——原样保留,从不翻译或重排, -- 链接与锚点:每个相对链接必须指向与源文相同的目标——即英文正典文件——这样某批译文先于相邻文件落地时,链接也永不悬空。唯一的 zh 特有链接是语言切换行。链接**文字**翻译;链接目标不翻。 +- 链接与锚点:每个相对链接在两个文件中必须指向相同的目标——按约定是 `.md` 路径而非 `.zh.md` 兄弟文件——这样某对文档先于相邻文件落地时,链接也永不悬空。唯一的 zh 特有链接是语言切换行。链接**文字**翻译;链接目标不翻。 本仓库的 Markdown 约定对 `.zh.md` 文件原样生效:一个段落一个物理行(`verify-md-wrap`)、相对链接必须可解析(`verify-md-links`)、文件末尾恰好一个换行。 ## 术语 -- [terminology.md](terminology.md) 是术语真源。翻译前先加载它;翻译中,表内的每个术语都必须严格按表规定的译法呈现,包括首次出现的括注(如首现写 `agent(智能体)`,之后写 `agent`)与「不要译作」的禁项。 +- [terminology.md](terminology.md) 是双向的术语真源。翻译前先加载它;翻译中,表内的每个术语都必须严格按表规定的译法呈现,包括首次出现的括注(如首现写 `agent(智能体)`,之后写 `agent`)与「不要译作」的禁项。中文先行撰写时,英文另一侧同样按表中英文列使用术语。 - 表中**没有**的技术术语,只有当某个主要中文 OSS 或厂商文档已有成型译法时(K8s/Vue/MDN 中文文档、微软简中风格指南、大厂项目文档)才可以翻译。在 PR 中注明先例出处。 - **没有**成型先例的术语,译文中必须保留英文,并且必须在 PR 描述的「待定术语」下列出、附上建议译法交评审者定夺。禁止就地发明中文译法——无先例的翻译恰恰制造了术语表要防止的歧义。定下来的术语随后在同一个 PR 或后续 PR 进入 [terminology.md](terminology.md)。 ## 排版 -下面的中西文混排规则遵循 [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md)、[Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/)、[Vue.js 中文翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5)与[中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines)的跨项目共识,其根据是 [W3C clreq](https://www.w3.org/TR/clreq/) 与 GB/T 15834—2011: +本节规则约束中文一侧;英文一侧遵循仓库常规的 Markdown 约定(根 `AGENTS.md`)。下面的中西文混排规则遵循 [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md)、[Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/)、[Vue.js 中文翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5)与[中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines)的跨项目共识,其根据是 [W3C clreq](https://www.w3.org/TR/clreq/) 与 GB/T 15834—2011: - 必须在中文与拉丁词之间、中文与数字之间各留一个半角空格:`每个 plugin 注册 3 个 tool`。全角标点与任何字符之间不加空格。 - 中文行文必须使用全角(中文)标点:`,。:;?!()「」`。半角标点保留在代码内、按原样引用的完整英文句子内、以及数字内(`3.5`、`1,024`)。 @@ -41,13 +39,13 @@ - 禁止使用全角数字或全角拉丁字母——永远不写 `123`,永远写 `123`。 - 专有名词保持规范大小写:GitHub、TypeScript、DeepSeek——除非引用代码,否则绝不写 `github`/`Github`。 - 第二人称用「你」,不用「您」(与 Vue、Kubernetes 中文约定及本仓库的直接语气一致)。 -- 强调标记(`**加粗**`、`*斜体*`)落在与源文相同的文字段上;中文没有斜体,渲染效果可能看不出差别——不要用引号或其他装饰替代。 +- 强调标记(`**加粗**`、`*斜体*`)落在与另一侧相同的文字段上;中文没有斜体,渲染效果可能看不出差别——不要用引号或其他装饰替代。 ## 质量线 -- 一篇译文的完成标准:一位只读中文文件的双语工程师,得到与英文读者完全相同的信息——相同的事实、相同的告诫、相同的语气——并且没有任何多余的内容。 -- 交付前,对照本文自查一遍,并**只读中文**再通读一遍、不看英文对照;没有源文锚着,别扭的表述更容易被听出来。 -- 机械契约(指纹、切换行、结构计数、折行、链接)由 `pnpm run verify-translation-pairing` 和 `doc-sync` 的其余门禁检查——跑门禁;门禁覆盖的不要手工核对。 +- 一对文档的完成标准:一位双语工程师只读其中任一文件,得到与另一文件读者完全相同的信息——相同的事实、相同的告诫、相同的语气——并且没有任何多余的内容。 +- 交付前,对照本文自查一遍,并**只读另一侧**再通读一遍、不看源侧对照;没有源文锚着,别扭的表述更容易被听出来。 +- 机械契约(一致性记录、切换行、结构、折行、链接)由 `pnpm run verify-translation-pairing` 和 `doc-sync` 的其余门禁检查——跑门禁;门禁覆盖的不要手工核对。 ## 参考资料 diff --git a/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml b/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml new file mode 100644 index 0000000000..bc9a1cd466 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-02-bilingual-docs-and-pairing-gate.md: 517a6371eca5d747313c7efdb2756a50257701e4 +2026-07-02-bilingual-docs-and-pairing-gate.zh.md: f8f68bf5d4d7e6795318d9dd435a525f20a4f407 diff --git a/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md b/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md index cd9dc413e7..517a6371ec 100644 --- a/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md +++ b/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md @@ -1,31 +1,36 @@ # Bilingual documentation via paired sibling files and a pairing gate +English | [中文](2026-07-02-bilingual-docs-and-pairing-gate.zh.md) + ## Context -This repo's README and docs tree are read by people and agents inside and outside the company, in both English and Chinese. Maintaining a second language by hand, with no mechanism, is how translations rot: the English file moves on, the Chinese file silently lies, and no gate notices. The repo's standing answer to invariants of this kind is to encode them as a mechanical check (see [quality gates](2026-06-11-quality-gates.md) and [doc-sync enforcement](2026-06-11-doc-sync-enforcement.md)), so the bilingual policy ships with one. +This repo's README and docs tree are read by people and agents inside and outside the company, in both English and Chinese. Maintaining a second language by hand, with no mechanism, is how translations rot: one side moves on, the other silently lies, and no gate notices. The repo's standing answer to invariants of this kind is to encode them as a mechanical check (see [quality gates](2026-06-11-quality-gates.md) and [doc-sync enforcement](2026-06-11-doc-sync-enforcement.md)), so the bilingual policy ships with one. ## Decision -- **Paired sibling files, English canonical.** The translation of `foo.md` is `foo.zh.md` in the same directory; English is the only authoring language and translation flows EN → ZH. Policy: [docs/i18n/README.md](../../../i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../i18n/terminology.md). -- **A blob-hash fingerprint makes freshness checkable.** The first line of every `.zh.md` records the repo-relative path and the first 12 hex digits of the git blob hash of the English source it renders. Staleness is then a pure content comparison — no history lookup — and the hash is computable for a source edited in the same PR, which a commit-hash fingerprint (the MDN `l10n.sourceCommit` model) is not. -- **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: required pairs exist, every existing translation is fresh/switched/structure-matched/non-orphaned, and excluded (generated or bilingual-by-construction) files stay unpaired. The `required` list in [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) is a ratchet: each merged translation batch adds its files, so coverage only grows. +- **Paired sibling files with equal authority.** A documentation pair is three sibling files: English `foo.md`, Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`. Neither language is canonical — a document may be authored and reviewed Chinese-first and translated to English afterwards, or the reverse; what binds the pair is that both sides must say the same thing, and pairs merge whole (both languages plus the record, never one alone). Policy: [docs/i18n/README.md](../../../i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../i18n/terminology.md). +- **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR. +- **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: required pairs exist, every existing pair is complete (all three files) and consistent (both hashes match, switcher links both ways, structural signatures identical), and excluded (generated or bilingual-by-construction) files stay unpaired. The `required` list in [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) is a ratchet: each merged translation batch adds its files, so coverage only grows. - **Translation is agent work with human review.** The committed workflow is [.agents/skills/dsh-translate-docs](../../../../.agents/skills/dsh-translate-docs/SKILL.md), following the same pattern as [dsh-code-review](../../../../.agents/skills/dsh-code-review/SKILL.md): the skill carries the workflow and defers to the docs as sources of truth. ## Alternatives considered +- **English as the canonical source with a fingerprint inside the translation** — the design first proposed for this RFC: `.zh.md` files carried an HTML comment recording the English source's blob hash, and translation flowed EN → ZH only. Revised in review: the team wants Chinese-first authoring (write and review a Chinese RFC, then translate to English) with the two languages holding equal authority, which a one-directional canonical model cannot express. The sidecar record covering BOTH sides replaced the in-file one-directional fingerprint; the blob-hash mechanics survived unchanged. - **Locale directories (`docs/en/` + `docs/zh/`, the Kubernetes/ECharts model)** — rejected: this repo has no docs-site framework to map locales to routes, moving every English file would churn every existing cross-reference, and `verify-md-links`/`verify-doc-refs` would need path-mapping logic instead of working unchanged. - **A separate translation repo (the PingCAP `docs`/`docs-cn` model)** — rejected: right for a docs product with independent release trains, overkill for a monorepo's own documentation; it also puts the translation outside the reach of this repo's gates. -- **Interleaved bilingual files (single file, both languages)** — rejected: doubles every diff, breaks the one-line-per-paragraph convention's diff ergonomics, and makes partial staleness invisible. -- **Commit-hash fingerprints (MDN `l10n.sourceCommit`)** — rejected in favor of blob hashes: a same-PR source edit has no commit hash yet, so the MDN model cannot express "translated against the version this PR introduces", and verifying it requires git history instead of file content. -- **Comparing git timestamps of the pair (no fingerprint)** — rejected: formatting-only English edits would false-positive, and a translation committed after an unrelated English edit would false-negative; content identity is the only signal that means what the gate claims. +- **Interleaved bilingual files (single file, both languages)** — rejected: doubles every diff, breaks the one-line-per-paragraph convention's diff ergonomics, and makes partial inconsistency invisible. +- **Commit-hash records (the MDN `l10n.sourceCommit` model)** — rejected in favor of blob hashes: a same-PR edit has no commit hash yet, so the MDN model cannot express "consistent as of the state this PR introduces", and verifying it requires git history instead of file content. +- **Comparing git timestamps of the pair (no record)** — rejected: formatting-only edits would false-positive, and a counterpart committed after an unrelated edit would false-negative; content identity is the only signal that means what the gate claims. ## Industry precedent -Paired sibling files with locale suffixes are the dominant Chinese big-tech convention (ant-design `index.zh-CN.md`/`index.en-US.md`; arco-design `README.zh-CN.md` with a top-of-file switcher; Apache ShardingSphere's 387 `.cn.md`/`.en.md` pairs) — but none of those repos *enforce* pairing or freshness in CI; the convention holds by review alone. Freshness automation exists outside China: MDN's `l10n.sourceCommit` front-matter fingerprint, Vue's Ryu-Cho action (upstream-commit watcher that opens issues/PRs for stale translations), Kubernetes' localization drift scripts, and Microsoft's Azure co-op-translator (source-hash-driven LLM re-translation in CI). This design combines the two: the Chinese-ecosystem file layout with a fingerprint gate, plus a committed agent skill in place of a bot service. +Paired sibling files with locale suffixes are the dominant Chinese big-tech convention (ant-design `index.zh-CN.md`/`index.en-US.md`; arco-design `README.zh-CN.md` with a top-of-file switcher; Apache ShardingSphere's 387 `.cn.md`/`.en.md` pairs) — but none of those repos *enforce* pairing or consistency in CI; the convention holds by review alone. Consistency automation exists outside China: MDN's `l10n.sourceCommit` front-matter fingerprint, Vue's Ryu-Cho action (upstream-commit watcher that opens issues/PRs for stale translations), Kubernetes' localization drift scripts, and Microsoft's Azure co-op-translator (source-hash-driven LLM re-translation in CI). This design combines the two: the Chinese-ecosystem file layout with a hash-pair gate, plus a committed agent skill in place of a bot service. ## Consequences -- Editing an English doc that has a `.zh.md` sibling obligates the same PR to update the translation — the gate makes the doc-sync rule bilingual, and CI (not reviewer memory) carries the invariant. -- Generated docs (`cordis-catalog/`, `tool-catalog/`, `module-graph.md`) are never paired; their generators emit English only, and the gate rejects a stray translation of them. -- Rollout is incremental by design: documents outside `required` are visible backlog (`--list`), not red CI, so translation lands in reviewable batches without a big-bang PR. -- The fingerprint doubles as the update tool (`git cat-file -p ` recovers the exact translated-from text for a minimal diff-based update), so re-translation of whole files is never forced by the mechanism. +- Editing either side of a paired document obligates the same PR to update the counterpart and re-record the pair — the gate makes the doc-sync rule bilingual, and CI (not reviewer memory) carries the invariant. +- Every pair adds a third file to the tree. The record is machine-written (`--write`), so the cost is directory noise, not maintenance effort; in exchange, "who confirmed these consistent, and when" is answerable from git blame on the yaml. +- When the two sides disagree, no mechanical rule picks a winner — the PR review does. That is the price of equal authority, accepted deliberately: the alternative (a canonical language) forbids Chinese-first authoring. +- Generated docs (`cordis-catalog/`, `tool-catalog/`, `module-graph.md`) are excluded for now; the planned follow-up is to teach their generators to emit Chinese alongside English, at which point they leave the exclusion list. +- Rollout is incremental by design: documents outside `required` are visible backlog (`--list`), not red CI, so pairs land in reviewable batches without a big-bang PR. +- The recorded hashes double as the update tool (`git cat-file -p ` recovers either side's last-confirmed text for a minimal diff-based update), so re-translation of whole files is never forced by the mechanism. diff --git a/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md b/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md new file mode 100644 index 0000000000..f8f68bf5d4 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.zh.md @@ -0,0 +1,36 @@ +# 通过配对兄弟文件与配对门禁实现双语文档 + +[English](2026-07-02-bilingual-docs-and-pairing-gate.md) | 中文 + +## 背景 + +本仓库的 README 与 docs 目录树会被公司内外的人和 agent(智能体)以中英两种语言阅读。没有机制、纯靠手工维护第二语言,正是译文腐烂的方式:一侧继续演进,另一侧默默地说谎,而没有门禁会注意到。对这类不变式,本仓库一贯的答案是把它编码成机械检查(见[质量门禁](2026-06-11-quality-gates.md)与 [doc-sync 强制](2026-06-11-doc-sync-enforcement.md)),因此双语政策随附一道门禁一起交付。 + +## 决策 + +- **配对兄弟文件,两种语言同权。**一对文档是三个兄弟文件:英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典——一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束这对文件的是两侧必须说同样的话,且配对整体合入(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../i18n/terminology.md)。 +- **旁挂记录两侧 blob hash,使一致性可检查。**`foo.i18n.yaml` 保存两侧文件在上一次确认一致状态下各自的完整 git blob hash。此后改了任一侧而没重新确认配对,都能被机械检测出来——纯内容比较、无需查询历史——而且同一个 PR 里改动的文件也能算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write`)产生一份可评审的 yaml diff:确认一致在 PR 里是一个显式、可见的动作。 +- **`verify-translation-pairing` 加入 `doc-sync`。**门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行:required 的配对存在;任何已存在的配对完整(三个文件齐全)且一致(两个 hash 都匹配、切换行双向互链、结构签名一致);被排除的文件(生成物或本身即双语的)保持不配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 中的 `required` 清单是一个棘轮:每个合入的翻译批次把自己的文件加进去,覆盖面只增不减。 +- **翻译是 agent 的工作,由人评审。**进仓的工作流是 [.agents/skills/dsh-translate-docs](../../../../.agents/skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../../.agents/skills/dsh-code-review/SKILL.md) 同一模式:skill 承载工作流,并把真源让给文档。 + +## 曾考虑的替代方案 + +- **英文为正典源、指纹放在译文内**——本 RFC 最初提出的设计:`.zh.md` 文件携带一条 HTML 注释记录英文源的 blob hash,翻译只沿 EN → ZH 单向流动。评审中修订:团队需要中文先行的撰写方式(先写、先审中文 RFC,再译英文),两种语言同权,而单向正典模型无法表达这一点。覆盖**两侧**的旁挂记录取代了文件内的单向指纹;blob hash 的机制原样保留。 +- **语言目录(`docs/en/` + `docs/zh/`,Kubernetes/ECharts 模式)**——否决:本仓库没有把 locale 映射到路由的文档站框架,挪动每个英文文件会搅动所有既有交叉引用,且 `verify-md-links`/`verify-doc-refs` 将需要路径映射逻辑而不是原样工作。 +- **独立翻译仓库(PingCAP `docs`/`docs-cn` 模式)**——否决:适合有独立发布节奏的文档产品,对 monorepo 自己的文档而言过重;还会把译文置于本仓库门禁够不到的地方。 +- **中英混排单文件(一个文件、两种语言)**——否决:每个 diff 都翻倍,破坏一段一行约定的 diff 工效,且局部不一致不可见。 +- **Commit hash 式记录(MDN `l10n.sourceCommit` 模式)**——否决,改用 blob hash:同一个 PR 内的改动还没有 commit hash,MDN 模式无法表达「与本 PR 引入的状态一致」,且校验它需要 git 历史而非文件内容。 +- **比较配对两侧的 git 时间戳(无记录)**——否决:纯格式化的改动会误报,一次无关改动之后提交的另一侧会漏报;只有内容同一性这个信号与门禁的承诺名实相符。 + +## 业界先例 + +带语言后缀的配对兄弟文件是中国大厂的主流约定(ant-design 的 `index.zh-CN.md`/`index.en-US.md`;arco-design 的 `README.zh-CN.md` 加顶部切换行;Apache ShardingSphere 的 387 对 `.cn.md`/`.en.md`)——但这些仓库都没有在 CI 里**强制**配对或一致性;约定纯靠评审维系。一致性自动化存在于中国之外:MDN 的 `l10n.sourceCommit` front-matter 指纹、Vue 的 Ryu-Cho action(监视上游 commit、为陈旧译文自动开 issue/PR)、Kubernetes 的本地化漂移脚本、微软 Azure co-op-translator(CI 中由源 hash 驱动的 LLM 重译)。本设计把两者结合:中文生态的文件布局,加 hash 对门禁,再加一个进仓 agent skill(技能)替代 bot 服务。 + +## 后果 + +- 修改已配对文档的任一侧,同一个 PR 就有义务更新另一侧并重新记录配对——门禁把 doc-sync 规则双语化,不变式由 CI(而非评审者的记忆)承载。 +- 每个配对给目录树多添一个文件。记录由机器写入(`--write`),代价是目录噪音而非维护负担;换来的是「谁在何时确认过这对一致」可以从 yaml 的 git blame 直接回答。 +- 两侧说法冲突时,没有机械规则裁决谁赢——由 PR 评审裁决。这是同权的代价,是有意接受的:另一个选项(正典语言)禁止中文先行撰写。 +- 生成文档(`cordis-catalog/`、`tool-catalog/`、`module-graph.md`)暂被排除;计划中的后续工作是让它们的生成器在输出英文的同时输出中文,届时移出排除清单。 +- 推进天然是渐进的:`required` 之外的文档是可见的 backlog(`--list`),不是红的 CI,因此配对按可评审的批次落地,无需一个巨型 PR。 +- 记录的 hash 兼作更新工具(`git cat-file -p ` 能还原任一侧上次确认的文本,用于基于 diff 的最小更新),所以这套机制从不强迫整篇重译。 diff --git a/scripts/translation-pairing.manifest.json b/scripts/translation-pairing.manifest.json index 4a735ea5af..8c2708d48d 100644 --- a/scripts/translation-pairing.manifest.json +++ b/scripts/translation-pairing.manifest.json @@ -3,7 +3,8 @@ "README.md", "docs/development.md", "docs/i18n/README.md", - "docs/i18n/translation-rules.md" + "docs/i18n/translation-rules.md", + "docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md" ], "excluded": [ "docs/AGENTS.md", diff --git a/scripts/verify-rfc-classification.ts b/scripts/verify-rfc-classification.ts index 011ce9591a..4ff4264731 100644 --- a/scripts/verify-rfc-classification.ts +++ b/scripts/verify-rfc-classification.ts @@ -68,6 +68,9 @@ for (const lifecycle of LIFECYCLES) { const segs = match.split('/') // Allowlisted file directly at the lifecycle root (e.g. implemented/AGENTS.md). if (segs.length === 2 && ROOT_ALLOWLIST.has(segs[1] ?? '')) continue + // A Chinese counterpart (foo.zh.md, docs/i18n/README.md) is the SAME RFC, + // indexed via its English filename; the pairing gate owns its consistency. + if (match.endsWith('.zh.md')) continue const cls = segs[1] const base = segs[2] if (segs.length !== 3 || cls === undefined || base === undefined) { diff --git a/scripts/verify-translation-pairing.ts b/scripts/verify-translation-pairing.ts index 47a4086c73..eea30e4b22 100644 --- a/scripts/verify-translation-pairing.ts +++ b/scripts/verify-translation-pairing.ts @@ -1,41 +1,50 @@ /** * Doc-sync gate: enforce the bilingual pairing contract (docs/i18n/README.md). - * English is canonical; the translation of `foo.md` is a sibling `foo.zh.md` - * whose FIRST line fingerprints the English source it was translated from: + * English and Chinese carry EQUAL authority — either language may be authored + * first — so consistency is recorded per pair in a sidecar metadata file, + * `foo.i18n.yaml`, holding the full git blob hash of BOTH files as of the last + * time a human confirmed the two say the same thing: * - * + * foo.md: <40-hex blob hash> + * foo.zh.md: <40-hex blob hash> * * The gate checks, mechanically, the checkable half of the contract: * - * 1. Every English file in the manifest's `required` list has a `.zh.md` - * sibling (the enforcement frontier — grows batch by batch). - * 2. Every EXISTING `.zh.md`, required or not, is sound: its source exists - * (no orphans), its fingerprint equals the source's current blob hash - * (no stale translations), both sides carry the language-switcher link, - * and its structural signature matches the source one to one — heading + * 1. Every file in the manifest's `required` list has a COMPLETE pair + * (the enforcement frontier — grows batch by batch). + * 2. Every pair that exists at all is complete and consistent: all three + * files present (a `.zh.md` or a `.i18n.yaml` without its counterparts + * is an error — pairs merge whole, never half), each side's current + * blob hash equals the recorded one (an edit to EITHER side without a + * re-confirmed counterpart goes red), both sides carry the language + * switcher, and the structural signatures match one to one — heading * depths in order, fenced code blocks VERBATIM (info string + content), * table column counts, list kinds, and every link target except the * switcher itself. * 3. `excluded` files (generated docs, agent instructions, the bilingual - * terminology table) have no `.zh.md` at all. + * terminology table) have no `.zh.md` and no `.i18n.yaml` at all. * - * What it deliberately does NOT check is translation quality: a green gate - * means the pair is fresh and structurally sound, not that the Chinese is - * faithful — accuracy, terminology, and tone are the human reviewer's half - * of the contract (docs/i18n/translation-rules.md). + * What it deliberately does NOT check is translation quality or which side + * is "right": a green gate means the pair was confirmed consistent at these + * exact contents, not that the confirmation was sound — accuracy, + * terminology, and tone are the human reviewer's half of the contract + * (docs/i18n/translation-rules.md). * - * The fingerprint is a git BLOB hash, not a commit hash, so a translation - * updated in the same PR as its English source verifies without any history - * lookup: staleness is a pure content comparison, computed here directly - * (sha1 of `blob \0`) without spawning git. + * Blob hashes, not commit hashes, so a pair edited in the same PR verifies + * without any history lookup: consistency is a pure content comparison, + * computed here directly (sha1 of `blob \0`) without spawning + * git. The recorded hash also recovers the last-confirmed text of either + * side (`git cat-file -p `) for diff-based minimal updates. * - * Run: `tsx scripts/verify-translation-pairing.ts` — or with `--list` to print - * the translation state (missing/stale/ok) of every in-scope document as a - * work list; `--list` always exits 0. + * Run: `tsx scripts/verify-translation-pairing.ts` — or with `--list` to + * print the pairing state of every in-scope document as a work list (always + * exits 0), or with `--write` to (re)record both hashes for every complete + * pair after you have brought the two sides back in line (the resulting + * yaml diff is the reviewable act of confirming consistency). */ import { createHash } from 'node:crypto' -import { existsSync, readFileSync } from 'node:fs' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { basename, join, resolve } from 'node:path' import { glob } from 'node:fs/promises' import { fromMarkdown } from 'mdast-util-from-markdown' @@ -45,9 +54,10 @@ import type { Nodes } from 'mdast' const root = resolve(import.meta.dirname, '..') const listMode = process.argv.includes('--list') +const writeMode = process.argv.includes('--write') /** Scope of the bilingual contract: the root README and the docs tree. */ -const SCOPE_PATTERNS = ['README.md', 'README.zh.md', 'docs/**/*.md'] +const SCOPE_PATTERNS = ['README.md', 'README.zh.md', 'README.i18n.yaml', 'docs/**/*.md', 'docs/**/*.i18n.yaml'] /** The enforcement frontier and the never-paired set (docs/i18n/README.md § Scope). */ interface Manifest { @@ -56,9 +66,6 @@ interface Manifest { } const manifest = JSON.parse(readFileSync(join(root, 'scripts/translation-pairing.manifest.json'), 'utf8')) as Manifest -/** First line of a translation: fingerprint of the English source it renders. */ -const FINGERPRINT = /^$/ - /** * An excluded entry ending in `/` excludes the whole directory. The trailing * slash IS the path boundary — `docs/tool-catalog/` cannot prefix-match a @@ -69,18 +76,50 @@ function isExcluded(file: string): boolean { return manifest.excluded.some(entry => (entry.endsWith('/') ? file.startsWith(entry) : file === entry)) } -/** Git blob hash (what `git hash-object` prints), truncated to 12 hex digits. */ +/** Full git blob hash (what `git hash-object` prints). */ function blobHash(content: Buffer): string { const hash = createHash('sha1') hash.update(`blob ${content.byteLength}\0`) hash.update(content) - return hash.digest('hex').slice(0, 12) + return hash.digest('hex') +} + +/** The three paths of a pair, derived from the English-file path. */ +function pairPaths(source: string): { zh: string; meta: string } { + return { zh: source.replace(/\.md$/, '.zh.md'), meta: source.replace(/\.md$/, '.i18n.yaml') } +} + +const META_LINE = /^([^:#]+\.md): ([0-9a-f]{40})$/ + +/** Parse a `foo.i18n.yaml` consistency record: basename → recorded blob hash. */ +function parseMeta(content: string): Map | undefined { + const out = new Map() + for (const line of content.split('\n')) { + if (line === '' || line.startsWith('#')) continue + const match = META_LINE.exec(line) + if (!match?.[1] || !match[2]) return undefined + out.set(match[1], match[2]) + } + return out +} + +/** Render a `foo.i18n.yaml` consistency record. */ +function renderMeta(source: string, sourceHash: string, zh: string, zhHash: string): string { + return [ + '# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each', + '# side as of the last confirmed-consistent state. Both languages carry equal authority;', + '# after editing either side, bring the other along and re-record with:', + '# pnpm run verify-translation-pairing --write', + `${basename(source)}: ${sourceHash}`, + `${basename(zh)}: ${zhHash}`, + '', + ].join('\n') } /** - * The structural signature a translation must reproduce from its source, as - * ordered sequences so a swap or a level change is caught, not just a count - * change. Prose is deliberately absent: the gate checks shape, never wording. + * The structural signature the two sides must share, as ordered sequences so + * a swap or a level change is caught, not just a count change. Prose is + * deliberately absent: the gate checks shape, never wording. */ interface Signature { /** Heading depths in document order (h2 → 2). */ @@ -157,7 +196,7 @@ function signatureDiff(source: Signature, zh: Signature): string[] { const length = Math.max(s.length, z.length) for (let i = 0; i < length; i++) { if (s[i] !== z[i]) { - out.push(`${field} #${i + 1} diverges from the source: source has ${show(s[i])}, translation has ${show(z[i])}`) + out.push(`${field} #${i + 1} diverges between the pair: ${show(s[i])} vs ${show(z[i])}`) break } } @@ -169,16 +208,34 @@ function parse(content: string): Nodes { return fromMarkdown(content, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] }) } -// Enumerate the scope once, split into sources and translations. +// Enumerate the scope once. const files = new Set() for (const pattern of SCOPE_PATTERNS) { for await (const match of glob(pattern, { cwd: root })) files.add(match) } const translations = [...files].filter(f => f.endsWith('.zh.md')).sort() -const sources = [...files].filter(f => !f.endsWith('.zh.md')).sort() +const metas = [...files].filter(f => f.endsWith('.i18n.yaml')).sort() +const sources = [...files].filter(f => f.endsWith('.md') && !f.endsWith('.zh.md')).sort() + +// --write: (re)record both hashes for every complete pair, creating missing records. +if (writeMode) { + let written = 0 + for (const source of sources) { + if (isExcluded(source)) continue + const { zh, meta } = pairPaths(source) + if (!existsSync(join(root, zh))) continue + const record = renderMeta(source, blobHash(readFileSync(join(root, source))), zh, blobHash(readFileSync(join(root, zh)))) + if (existsSync(join(root, meta)) && readFileSync(join(root, meta), 'utf8') === record) continue + writeFileSync(join(root, meta), record) + console.log(`verify-translation-pairing: recorded ${meta}`) + written++ + } + console.log(`verify-translation-pairing: ${written} record(s) written; run the check to validate the pairs.`) + process.exit(0) +} const errors: string[] = [] -const state = new Map() +const state = new Map() // 1. Required pairs exist. for (const req of manifest.required) { @@ -186,82 +243,90 @@ for (const req of manifest.required) { errors.push(`${req}: listed in translation-pairing.manifest.json \`required\` but the file does not exist`) continue } - const zh = req.replace(/\.md$/, '.zh.md') + const { zh } = pairPaths(req) if (!existsSync(join(root, zh))) { errors.push(`${req}: required to have a translation, but ${zh} does not exist`) state.set(req, 'missing') } } -// 2. Every existing translation is sound. -for (const zh of translations) { - const source = zh.replace(/\.zh\.md$/, '.md') - const sourceAbs = join(root, source) - if (!existsSync(sourceAbs)) { - errors.push(`${zh}: orphan — its English source ${source} does not exist (delete or rename the translation alongside its source)`) - continue - } +// 2. Every pair that exists at all is complete and consistent. Anchor on the +// union of .zh.md files and .i18n.yaml records so a half-deleted pair is +// caught from either remnant. +const pairAnchors = new Set() +for (const zh of translations) pairAnchors.add(zh.replace(/\.zh\.md$/, '.md')) +for (const meta of metas) pairAnchors.add(meta.replace(/\.i18n\.yaml$/, '.md')) + +for (const source of [...pairAnchors].sort()) { + const { zh, meta } = pairPaths(source) + const have = { source: existsSync(join(root, source)), zh: existsSync(join(root, zh)), meta: existsSync(join(root, meta)) } + if (isExcluded(source)) { - errors.push(`${zh}: ${source} is excluded from pairing (generated or bilingual-by-construction); this translation must not exist`) + if (have.zh) errors.push(`${zh}: ${source} is excluded from pairing (generated or bilingual-by-construction); this translation must not exist`) + if (have.meta) errors.push(`${meta}: ${source} is excluded from pairing; this consistency record must not exist`) + continue + } + const missing = Object.entries(have).filter(([, ok]) => !ok).map(([k]) => (k === 'source' ? source : k === 'zh' ? zh : meta)) + if (missing.length > 0) { + errors.push(`${source}: incomplete pair — missing ${missing.join(', ')} (pairs merge whole: both languages plus the .i18n.yaml record)`) continue } - const zhContent = readFileSync(join(root, zh), 'utf8') - const firstLine = zhContent.split('\n', 1)[0] ?? '' - const match = FINGERPRINT.exec(firstLine) - if (!match?.groups) { - errors.push(`${zh}: first line is not an i18n-source fingerprint (expected \`\`, got \`${firstLine.slice(0, 60)}\`)`) - continue - } - if (match.groups['path'] !== source) { - errors.push(`${zh}: fingerprint names ${match.groups['path']} but the sibling source is ${source}`) + const sourceContent = readFileSync(join(root, source)) + const zhContent = readFileSync(join(root, zh)) + const record = parseMeta(readFileSync(join(root, meta), 'utf8')) + if (!record || record.size !== 2 || !record.has(basename(source)) || !record.has(basename(zh))) { + errors.push(`${meta}: malformed consistency record (expected exactly \`${basename(source)}: <40-hex>\` and \`${basename(zh)}: <40-hex>\`)`) continue } - const sourceContent = readFileSync(sourceAbs) - const current = blobHash(sourceContent) - if (match.groups['hash'] !== current) { - errors.push(`${zh}: stale — fingerprint ${match.groups['hash']} but ${source} is now ${current} (update the translation, then re-fingerprint)`) - state.set(source, 'stale') + let consistent = true + for (const [file, content] of [[source, sourceContent], [zh, zhContent]] as const) { + const current = blobHash(content) + if (record.get(basename(file)) !== current) { + errors.push(`${file}: out of sync — content no longer matches the pair's last confirmed-consistent state in ${meta} (bring the other side along, then re-record with --write)`) + consistent = false + } + } + if (!consistent) { + state.set(source, 'out-of-sync') continue } - const zhTree = parse(zhContent) const sourceTree = parse(sourceContent.toString('utf8')) + const zhTree = parse(zhContent.toString('utf8')) if (!linksTo(zhTree, basename(source))) { errors.push(`${zh}: missing language switcher — no link to ${basename(source)}`) } if (!linksTo(sourceTree, basename(zh))) { errors.push(`${source}: missing language switcher — no link back to ${basename(zh)}`) } - const sourceSig = signatureOf(sourceTree, basename(zh)) - const zhSig = signatureOf(zhTree, basename(source)) - for (const divergence of signatureDiff(sourceSig, zhSig)) { - errors.push(`${zh}: ${divergence}`) + for (const divergence of signatureDiff(signatureOf(sourceTree, basename(zh)), signatureOf(zhTree, basename(source)))) { + errors.push(`${source} ↔ ${zh}: ${divergence}`) } if (!state.has(source)) state.set(source, 'ok') } -// Complete the state map for --list: any in-scope, non-excluded source with no translation yet is backlog. +// Complete the state map for --list: any in-scope, non-excluded document with no pair yet is backlog. for (const source of sources) { if (!isExcluded(source) && !state.has(source)) state.set(source, 'missing') } if (listMode) { - const order = { stale: 0, missing: 1, ok: 2 } as const + const order = { 'out-of-sync': 0, missing: 1, ok: 2 } as const const rows = [...state.entries()].sort((a, b) => order[a[1]] - order[b[1]] || a[0].localeCompare(b[0])) for (const [file, status] of rows) { const required = manifest.required.includes(file) - console.log(`${status.padEnd(7)} ${file}${status === 'missing' ? (required ? ' (required)' : ' (backlog)') : ''}`) + console.log(`${status.padEnd(11)} ${file}${status === 'missing' ? (required ? ' (required)' : ' (backlog)') : ''}`) } - const counts = { ok: 0, stale: 0, missing: 0 } + const counts = { 'ok': 0, 'out-of-sync': 0, 'missing': 0 } for (const status of state.values()) counts[status]++ - console.log(`verify-translation-pairing: ${counts.ok} ok, ${counts.stale} stale, ${counts.missing} missing (of ${state.size} in scope)`) + console.log(`verify-translation-pairing: ${counts.ok} ok, ${counts['out-of-sync']} out-of-sync, ${counts.missing} missing (of ${state.size} in scope)`) process.exit(0) } if (errors.length === 0) { - console.log(`verify-translation-pairing: ${translations.length} translation(s) checked against ${manifest.required.length} required pair(s), all sound.`) + console.log(`verify-translation-pairing: ${pairAnchors.size} pair(s) checked against ${manifest.required.length} required, all consistent.`) process.exit(0) } From 8ca82d03ab3edf72096ce8aa8f7316cd2e1dcb6f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:52:18 +0800 Subject: [PATCH 65/75] refactor(tool-web): port web tools to the render-intent union master's web_search/web_fetch tools were authored against the old ToolCallPresentation bag; the render-intent union replaces it with a card-tagged discriminated union. Both are simple generic cards, so they declare card:'generic' explicitly. --- packages/web/tool-web/src/fetch.ts | 6 +++--- packages/web/tool-web/src/search.ts | 6 +++--- packages/web/tool-web/tests/tool-web.spec.ts | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/web/tool-web/src/fetch.ts b/packages/web/tool-web/src/fetch.ts index 85977fbea4..5f7334d952 100644 --- a/packages/web/tool-web/src/fetch.ts +++ b/packages/web/tool-web/src/fetch.ts @@ -7,7 +7,7 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' -import type { ToolCallPresentation } from '@deepseek-ai/dsh-tools' +import type { GenericCallView } from '@deepseek-ai/dsh-tools' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { WebFetchBody, WebFetchResult } from '@deepseek-ai/dsh-web' import { assertNever } from '@deepseek-ai/dsh-llm' @@ -44,8 +44,8 @@ export function formatFetchOutput(result: WebFetchResult): string { } /** Pending-call presentation: a fetch card titled by the URL. */ -export function presentFetchCall(args: { url: string; timeout_ms?: number }): ToolCallPresentation { - return { title: args.url, kind: 'fetch', rawInput: args.url } +export function presentFetchCall(args: { url: string; timeout_ms?: number }): GenericCallView { + return { card: 'generic', title: args.url, kind: 'fetch', rawInput: args.url } } /** Register the `web_fetch` tool and its system-prompt guidance. */ diff --git a/packages/web/tool-web/src/search.ts b/packages/web/tool-web/src/search.ts index 2aa93ef10e..6394d3f7e0 100644 --- a/packages/web/tool-web/src/search.ts +++ b/packages/web/tool-web/src/search.ts @@ -7,7 +7,7 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' -import type { ToolCallPresentation } from '@deepseek-ai/dsh-tools' +import type { GenericCallView } from '@deepseek-ai/dsh-tools' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { WebSearchResult } from '@deepseek-ai/dsh-web' import type {} from '@deepseek-ai/dsh-system-prompt' @@ -63,8 +63,8 @@ export function formatSearchOutput(result: WebSearchResult): string { } /** Pending-call presentation: a search card titled by the query. */ -export function presentSearchCall(args: { query: string }): ToolCallPresentation { - return { title: args.query, kind: 'search', rawInput: args.query } +export function presentSearchCall(args: { query: string }): GenericCallView { + return { card: 'generic', title: args.query, kind: 'search', rawInput: args.query } } /** Register the `web_search` tool and its system-prompt guidance. */ diff --git a/packages/web/tool-web/tests/tool-web.spec.ts b/packages/web/tool-web/tests/tool-web.spec.ts index 2422c32ce3..7af1ce7c36 100644 --- a/packages/web/tool-web/tests/tool-web.spec.ts +++ b/packages/web/tool-web/tests/tool-web.spec.ts @@ -80,7 +80,7 @@ describe('search formatting', () => { }) it('presents a search call as a search-kind card titled by the query', () => { - expect(presentSearchCall({ query: 'find me' })).toEqual({ title: 'find me', kind: 'search', rawInput: 'find me' }) + expect(presentSearchCall({ query: 'find me' })).toEqual({ card: 'generic', title: 'find me', kind: 'search', rawInput: 'find me' }) }) }) @@ -116,7 +116,7 @@ describe('fetch formatting', () => { }) it('presents a fetch call as a fetch-kind card titled by the url', () => { - expect(presentFetchCall({ url: 'https://a.test' })).toEqual({ title: 'https://a.test', kind: 'fetch', rawInput: 'https://a.test' }) + expect(presentFetchCall({ url: 'https://a.test' })).toEqual({ card: 'generic', title: 'https://a.test', kind: 'fetch', rawInput: 'https://a.test' }) }) }) From 497ea15bdfa9b36f5364287a74cf1218aa3f1933 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 3 Jul 2026 23:14:22 +0800 Subject: [PATCH 66/75] fix(acp): relativize the completed diff card title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The result-time diff card sent view.title raw, so a completed edit/write of an absolute in-workspace path flipped the card header back from the relativized `Edit src/b.ts` to the absolute path — the pending card relativizes, the result did not, and tool_call_update.title replaces the header. Apply displayTitle to the result diff arm using the diff path, mirroring the call-side card. Regression test proven red on the unfixed arm. Also record the overwrite diff-basis pre-read as a bounded follow-up (TODO(overwrite-diff-bound) + RFC non-goal): overwriting a large file reads the whole prior text into memory for a UI-only diff. --- ...26-07-02-result-time-applied-hunk-diffs.md | 1 + packages/fs/fs-local/src/index.ts | 3 +++ packages/ui/acp/src/index.ts | 7 ++++- packages/ui/acp/tests/stream-update.spec.ts | 26 +++++++++++++++++++ 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md b/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md index 8a4c49ff2c..9c95dff773 100644 --- a/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md +++ b/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md @@ -46,6 +46,7 @@ Computing hunks-with-context is a solved problem with sharp edge cases (grouping - **Live incremental diff streaming.** The hunk is computed once, after the mutation completes; there is no per-keystroke diff. - **Diffing a binary/non-UTF-8 overwrite.** `before` is `null` for such a file (it has no text diff basis); the write still succeeds and the result renders a whole-file diff (`oldText: null`) rather than a contextual hunk. - **Rename/move diffs.** Only content diffs of a single resolved path. +- **Bounding the overwrite diff basis.** An overwrite reads the whole prior file into memory to compute the contextual hunk (on top of the new content already held), so a very large text overwrite allocates both texts for a UI-only diff. A future refinement can bound the pre-read and fall back to a whole-file / no contextual diff above a size threshold; tracked as `TODO(overwrite-diff-bound)` at the read site. ## Related diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index 8ce96fe49d..567c6277c6 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -151,6 +151,9 @@ export class LocalFileSystem extends FileSystem { // file (binary/invalid-UTF-8) — a null `before` gives no contextual-hunk // basis, so a consumer falls back to a whole-file diff (the tool still // renders a result-time diff card, not the raw result text). + // TODO(overwrite-diff-bound): this reads the whole prior file into memory + // for a UI-only diff; bound the pre-read and fall back to no contextual + // basis above a size threshold (see the applied-hunk-diffs RFC non-goals). const before = existing ? await readTextForDiff(target.targetKey, signal) : null await writeFileAtomic(target.targetKey, content, existing?.mode, signal, this.internals) const after = await probe(target.targetKey) diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index ab784d2efd..bcfc0de6e4 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -1178,12 +1178,17 @@ function toolResultUpdate(callId: CallId, view: ToolResultView, isError: boolean // the diff the pending card installed (and keeps the model-facing result // text from clobbering it). const content: AcpToolCallContent[] = view.diffs.map(d => ({ type: 'diff', path: d.path, oldText: d.oldText, newText: d.newText })) + // Relativize the replacement title against the session cwd from the diff + // path, exactly as the call-side card does — `tool_call_update.title` + // replaces the card header, so a raw absolute path here would undo the + // pending card's relativized title. + const title = view.title !== undefined ? displayTitle(view.title, view.diffs[0]?.path, terminal.cwd) : undefined return { sessionUpdate: 'tool_call_update', toolCallId: callId, status, ...content.length > 0 ? { content } : {}, - ...view.title !== undefined ? { title: view.title } : {}, + ...title !== undefined ? { title } : {}, } } default: diff --git a/packages/ui/acp/tests/stream-update.spec.ts b/packages/ui/acp/tests/stream-update.spec.ts index 67cd95ccdd..b580a5fc3d 100644 --- a/packages/ui/acp/tests/stream-update.spec.ts +++ b/packages/ui/acp/tests/stream-update.spec.ts @@ -675,6 +675,32 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo await ctx.fiber.dispose() }) + it('the completed diff TITLE relativizes against the session cwd (the result title replaces the card header)', async () => { + // A `tool_call_update.title` replaces the card header, so the result-side + // diff must relativize its title exactly as the pending card did — otherwise + // a completed absolute-path edit flips `Edit src/b.ts` back to the raw + // absolute path. The diff/location paths stay absolute (the editor opens the + // real path). Drive the REAL fs edit tool with an absolute in-workspace path. + const ctx = await fsCtx() + const presenter = new ToolPresenter(ctx.tools) + const args = JSON.stringify({ file_path: '/work/proj/src/b.ts', old_string: 'OLD', new_string: 'NEW' }) + const meta = { diffs: [{ path: '/work/proj/src/b.ts', oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }] } + const out: SessionNotification['update'][] = [] + const rendering = { enabled: false, cwd: '/work/proj' } + for (const event of [ + evt('tool/call', { turn: 1, step: 1, callId: CallId('e1'), name: 'edit', arguments: args }), + evt('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [{ type: 'text', text: 'ok' }], isError: false, meta }), + ]) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), presenter, rendering) + expect(out[1]).toEqual({ + sessionUpdate: 'tool_call_update', + toolCallId: 'e1', + status: 'completed', + title: 'Edit src/b.ts', + content: [{ type: 'diff', path: '/work/proj/src/b.ts', oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }], + }) + await ctx.fiber.dispose() + }) + it('a diff result with an EMPTY diffs array and no title omits both keys (nothing to send)', () => { // A synthetic tool whose presentResult yields a `diff` card with no hunks and // no title — the shipping fs tools never emit this (edit always has a hunk; From 539051b2c9551cdc69b24ea5b3820a8a0c869e83 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 00:20:06 +0800 Subject: [PATCH 67/75] refactor(tools): move render-intent vocabulary to presentation.ts The tool render-intent vocabulary (ToolCallView/ToolResultView + members, FileLocation, FileDiff, ToolCallKind) is the UI-facing surface of dsh-tools; it lived inline in index.ts alongside the registry and execution core. Move it to its own presentation.ts module so index.ts is the registry + execute waterfall and the presentation vocabulary is a separate, one-directional dependency. presentation.ts owns ONLY render-intent types and references none of the execution types; index.ts imports the view types for ToolDefinition's presentCall/presentResult signatures (clean acyclic index -> presentation). The opaque `meta` presentation channel (ToolExecuteReturn, ToolResult, ToolExecutionResult) is execution plumbing and stays in index.ts. Public surface unchanged: index.ts re-exports the vocabulary, so consumers (tool-fs/tool-bash/tool-web/tool-todo, the ACP bridge) keep importing from @deepseek-ai/dsh-tools with zero churn. No producer/bridge/test edits; a pure internal relocation with no observable-output change (snapshot goldens untouched). --- docs/cordis-catalog/events-and-services.md | 6 +- docs/core-data-structures/tools.md | 2 +- packages/core/tools/src/index.ts | 203 ++------------------ packages/core/tools/src/presentation.ts | 206 +++++++++++++++++++++ packages/core/tools/src/schema.ts | 3 +- 5 files changed, 230 insertions(+), 190 deletions(-) create mode 100644 packages/core/tools/src/presentation.ts diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index b3d784e6fa..6cf03aefe4 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -337,7 +337,7 @@ A tool was registered or unregistered (the available tool set changed). 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:48`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:66`](../../packages/core/tools/src/index.ts) #### `tools/execute` — waterfall @@ -349,7 +349,7 @@ Waterfall around every tool execution — the single seam where sandbox, permiss Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:43`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:61`](../../packages/core/tools/src/index.ts) ### `web/*` @@ -559,7 +559,7 @@ async execute(exec: ToolExecution): Promise 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:366`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:199`](../../packages/core/tools/src/index.ts) ### `ctx.web` — `WebService` diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 355eefc2e7..f534ea1cdb 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -121,4 +121,4 @@ How a tool wants its call shown in a UI (an editor tool-call card, a CLI log lin `ToolCallKind` (`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`) picks an icon on a generic card. `FileLocation` (`{ path, line? }`) and `FileDiff` (`{ path, oldText, newText }`) are the shared file-card vocabulary. The design is pinned in [the render-intent-union RFC](../rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md); the ACP bridge maps a `diff` card to a `{ type: 'diff' }` content block, a `terminal` card to the `_meta` terminal convention, and relativizes a file card's title against the session cwd. -The full presentation field docs live in [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts). The bash tool's own schemas (`bash`/`bash_output`/`bash_kill`) and the executor they drive are on [bash.md](bash.md). +The full presentation field docs live in [`packages/core/tools/src/presentation.ts`](../../packages/core/tools/src/presentation.ts). The bash tool's own schemas (`bash`/`bash_output`/`bash_kill`) and the executor they drive are on [bash.md](bash.md). diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 76f67291ed..1a0e132e3a 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -12,6 +12,7 @@ import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-system-prompt' +import type { ToolCallView, ToolResultView } from './presentation.ts' export { defineTool, @@ -26,6 +27,23 @@ export { type JsonSchemaObject, } from './schema.ts' +// The render-intent vocabulary a tool declares via `presentCall`/`presentResult` +// lives in its own UI-facing module; re-export it so `@deepseek-ai/dsh-tools` +// stays the single public surface for consumers (producers + the ACP bridge). +export type { + ToolCallKind, + FileLocation, + FileDiff, + ToolCallView, + GenericCallView, + TerminalCallView, + DiffCallView, + ToolResultView, + GenericResultView, + TerminalResultView, + DiffResultView, +} from './presentation.ts' + declare module 'cordis' { interface Context { tools: ToolRegistry @@ -54,191 +72,6 @@ declare module 'cordis' { // parallel execution — Claude Code partitions read-only tools; phase 1 // executes sequentially). -/** - * Category of a tool call, used by a UI to pick an icon / treatment. A neutral - * vocabulary owned here (NOT an ACP type) so tools describe themselves without - * depending on any client protocol; a UI bridge maps it to its own enum. The - * member set mirrors the common ACP `ToolKind` values; `other` is the default. - */ -export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other' - -/** - * A file location a tool reads or modifies, so a capable UI can "follow along" — - * highlight or jump to the file (and line) as the tool runs. Provider-neutral; - * a UI bridge maps it to its own affordance (the ACP bridge forwards it 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). - */ -export interface FileLocation { - path: string - line?: number -} - -/** - * A single-file change a tool is about to make, for a UI that renders inline - * diffs (an editor's diff card). Provider-neutral; the ACP bridge forwards it as - * a `{ type: 'diff' }` tool-call content block. `oldText` is `null` for a - * new-file create (nothing to diff against); an overwrite also uses `null`, - * because a call-time presenter has no access to the file's prior content. - */ -export interface FileDiff { - path: string - /** Prior content, or `null` for a new file / an overwrite (no prior content available at call time). */ - oldText: string | null - /** Content after the change. */ - newText: string -} - -/** - * How a tool wants ONE of its calls shown in a UI (an editor's tool-call card, a - * CLI log line) BEFORE the result is known — the *pending* state. A `card`-tagged - * discriminated union: a tool declares its render INTENT once and a UI bridge - * switches on `card` to map it to the bridge's own wire shape. Provider-neutral — - * the tool owns its presentation, so a UI never special-cases tool names. - * - * Returned by {@link ToolDefinition.presentCall}. See the render-intent-union - * RFC (docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md). - */ -export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView - -/** - * The default card: a titled tool-call row with an optional category icon, a - * salient raw input, extra content blocks, and follow-along file locations. Any - * tool whose call is not a terminal or a diff uses this. - */ -export interface GenericCallView { - card: 'generic' - /** - * Human-readable, always-visible label describing what THIS call does. Keep it - * short — a UI shows it as a card header / log line. - */ - title: string - /** Category for icon/treatment; defaults to `other` when omitted. */ - kind?: ToolCallKind - /** - * The salient input to surface in a detail/expanded view (e.g. a background - * task id). Omit to show nothing; a string renders as-is, an object as pretty - * JSON. NOT the full raw args object unless that is genuinely what a reader wants. - */ - rawInput?: unknown - /** - * UI-facing content blocks to show on the pending call alongside the title. - * Omit to show none. A UI maps these to its own content blocks. - */ - content?: ContentBlock[] - /** Files this call reads/modifies, for editor follow-along. Omit for a call that touches no file. */ - locations?: FileLocation[] -} - -/** - * A call that IS a shell command running in a working directory: a capable UI - * renders it as a terminal card (cwd-headed, with the command as the title and - * live/afterward output from the {@link TerminalResultView}); an incapable UI - * falls back to a generic card whose body is the fenced command output. Set by a - * tool whose call is a foreground command (e.g. `bash`). - */ -export interface TerminalCallView { - card: 'terminal' - /** The command, shown as the terminal card's title / header line. */ - title: string - /** - * A human-readable one-line summary of what the command does, rendered ABOVE - * the terminal card (the card itself has no description slot). Omit for none. - */ - description?: string - /** - * Working directory the command runs in, shown as the terminal header. An - * ABSOLUTE path is used as-is; a RELATIVE path is resolved by the UI bridge - * against the session workspace (the pure presenter can't see the session cwd). - * Omit entirely to let the bridge use the session workspace. - */ - cwd?: string -} - -/** - * A call that creates or modifies files, rendered as an inline diff card by a - * capable UI. Set by a tool whose call writes/edits a file (e.g. `write`, - * `edit`). The diffs are derived from the call ARGUMENTS (a create's `oldText` is - * `null`); the tool emits a separate {@link DiffResultView} after `execute` — the - * applied change (an edit/overwrite hunk with context, or a whole-file diff for a - * create). - */ -export interface DiffCallView { - card: 'diff' - /** Card header (e.g. `Write foo.txt`). */ - title: string - /** One entry per file the call changes. */ - diffs: FileDiff[] - /** Files this call modifies, for editor follow-along (usually the diffs' paths). */ - locations?: FileLocation[] -} - -/** - * How a tool wants the COMPLETED call shown — the *result* state, after `execute` - * returns. A `card`-tagged union mirroring {@link ToolCallView}: a UI switches on - * `card`. Lets the tool reformat its result for a UI distinctly from the - * model-facing text it returned from `execute`. Returned by - * {@link ToolDefinition.presentResult}; omitting the method keeps the pending - * title and renders the raw result content. - */ -export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView - -/** - * The default completed card: an optional replacement title and reformatted - * content. Omit a field to keep the pending title / render the raw result content. - */ -export interface GenericResultView { - card: 'generic' - /** Replacement title for the completed call. Omit to keep the pending-state title. */ - title?: string - /** - * UI-facing result content (harness {@link ContentBlock}s), reformatted from - * the model-facing result. Omit to let the UI render the raw result content. - */ - content?: ContentBlock[] -} - -/** - * The completed state of a {@link TerminalCallView}: the captured output and exit - * status. A capable UI renders `output` in the terminal card and shows an - * exit-status pill; an incapable UI gets a fenced ```console fallback the BRIDGE - * derives from `output` (the tool does not double-encode it). - */ -export interface TerminalResultView { - card: 'terminal' - /** Replacement title for the completed call. Omit to keep the pending-state title. */ - title?: string - /** Captured command output (stdout+stderr as the tool chooses to combine them). */ - output?: string - /** - * Process exit code, when the run ended by exiting (not a signal). Lets a - * capable UI show an exit-status pill. Omit when killed by a signal or unknown. - */ - exitCode?: number - /** Signal name that killed the process (e.g. `SIGTERM`). Mutually exclusive with `exitCode`. */ - signal?: string -} - -/** - * A completed file mutation rendered as an inline diff card, the *result-time* - * analogue of {@link DiffCallView}. Set by a tool whose `execute` applied a file - * change (e.g. `write`, `edit`): `diffs` are the change to show — typically the - * APPLIED hunks computed from the before/after content (one entry per hunk, each - * with surrounding context lines), so the editor shows the real change in place; - * a tool with no before-image (e.g. a file create) may instead give a whole-file - * diff (`oldText: null`). A `tool_call_update`'s content REPLACES the call's - * content in an editor, so a mutation tool returns this even when it duplicates - * the call-time snippet — otherwise the model-facing result text would replace - * (clobber) the pending diff card. - */ -export interface DiffResultView { - card: 'diff' - /** Replacement title for the completed call. Omit to keep the pending-state title. */ - title?: string - /** The change to show, in file order — applied contextual hunks, or a whole-file diff when there is no before-image. */ - diffs: FileDiff[] -} - /** * What a tool's `execute` returns. The bare {@link ContentBlock}`[]` form is the * common case (model-facing content only); the object form additionally attaches diff --git a/packages/core/tools/src/presentation.ts b/packages/core/tools/src/presentation.ts new file mode 100644 index 0000000000..a64fe71eec --- /dev/null +++ b/packages/core/tools/src/presentation.ts @@ -0,0 +1,206 @@ +/** + * Tool render-intent vocabulary: the provider-neutral types a tool declares via + * {@link ToolDefinition.presentCall}/{@link ToolDefinition.presentResult} to say + * how ONE of its calls renders in a UI (an editor's tool-call card, a CLI log + * line). A UI bridge switches on the `card` tag to map each intent to its own + * wire shape, so a UI never special-cases tool names. + * + * This is the UI-facing surface of `dsh-tools`, kept separate from the registry + * and execution core in `index.ts`: this module owns ONLY presentation + * vocabulary and references none of the execution types, so the dependency runs + * one way (`index.ts` imports these views for the `ToolDefinition` method + * signatures). The opaque `meta` presentation channel is execution plumbing and + * lives with the registry in `index.ts`, not here. + * + * See the render-intent-union RFC + * (docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md). + * + * @module @deepseek-ai/dsh-tools/src/presentation + */ + +import type { ContentBlock } from '@deepseek-ai/dsh-llm' + +/** + * Category of a tool call, used by a UI to pick an icon / treatment. A neutral + * vocabulary owned here (NOT an ACP type) so tools describe themselves without + * depending on any client protocol; a UI bridge maps it to its own enum. The + * member set mirrors the common ACP `ToolKind` values; `other` is the default. + */ +export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other' + +/** + * A file location a tool reads or modifies, so a capable UI can "follow along" — + * highlight or jump to the file (and line) as the tool runs. Provider-neutral; + * a UI bridge maps it to its own affordance (the ACP bridge forwards it 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). + */ +export interface FileLocation { + path: string + line?: number +} + +/** + * A single-file change a tool is about to make, for a UI that renders inline + * diffs (an editor's diff card). Provider-neutral; the ACP bridge forwards it as + * a `{ type: 'diff' }` tool-call content block. `oldText` is `null` for a + * new-file create (nothing to diff against); an overwrite also uses `null`, + * because a call-time presenter has no access to the file's prior content. + */ +export interface FileDiff { + path: string + /** Prior content, or `null` for a new file / an overwrite (no prior content available at call time). */ + oldText: string | null + /** Content after the change. */ + newText: string +} + +/** + * How a tool wants ONE of its calls shown in a UI (an editor's tool-call card, a + * CLI log line) BEFORE the result is known — the *pending* state. A `card`-tagged + * discriminated union: a tool declares its render INTENT once and a UI bridge + * switches on `card` to map it to the bridge's own wire shape. Provider-neutral — + * the tool owns its presentation, so a UI never special-cases tool names. + * + * Returned by {@link ToolDefinition.presentCall}. See the render-intent-union + * RFC (docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md). + */ +export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView + +/** + * The default card: a titled tool-call row with an optional category icon, a + * salient raw input, extra content blocks, and follow-along file locations. Any + * tool whose call is not a terminal or a diff uses this. + */ +export interface GenericCallView { + card: 'generic' + /** + * Human-readable, always-visible label describing what THIS call does. Keep it + * short — a UI shows it as a card header / log line. + */ + title: string + /** Category for icon/treatment; defaults to `other` when omitted. */ + kind?: ToolCallKind + /** + * The salient input to surface in a detail/expanded view (e.g. a background + * task id). Omit to show nothing; a string renders as-is, an object as pretty + * JSON. NOT the full raw args object unless that is genuinely what a reader wants. + */ + rawInput?: unknown + /** + * UI-facing content blocks to show on the pending call alongside the title. + * Omit to show none. A UI maps these to its own content blocks. + */ + content?: ContentBlock[] + /** Files this call reads/modifies, for editor follow-along. Omit for a call that touches no file. */ + locations?: FileLocation[] +} + +/** + * A call that IS a shell command running in a working directory: a capable UI + * renders it as a terminal card (cwd-headed, with the command as the title and + * live/afterward output from the {@link TerminalResultView}); an incapable UI + * falls back to a generic card whose body is the fenced command output. Set by a + * tool whose call is a foreground command (e.g. `bash`). + */ +export interface TerminalCallView { + card: 'terminal' + /** The command, shown as the terminal card's title / header line. */ + title: string + /** + * A human-readable one-line summary of what the command does, rendered ABOVE + * the terminal card (the card itself has no description slot). Omit for none. + */ + description?: string + /** + * Working directory the command runs in, shown as the terminal header. An + * ABSOLUTE path is used as-is; a RELATIVE path is resolved by the UI bridge + * against the session workspace (the pure presenter can't see the session cwd). + * Omit entirely to let the bridge use the session workspace. + */ + cwd?: string +} + +/** + * A call that creates or modifies files, rendered as an inline diff card by a + * capable UI. Set by a tool whose call writes/edits a file (e.g. `write`, + * `edit`). The diffs are derived from the call ARGUMENTS (a create's `oldText` is + * `null`); the tool emits a separate {@link DiffResultView} after `execute` — the + * applied change (an edit/overwrite hunk with context, or a whole-file diff for a + * create). + */ +export interface DiffCallView { + card: 'diff' + /** Card header (e.g. `Write foo.txt`). */ + title: string + /** One entry per file the call changes. */ + diffs: FileDiff[] + /** Files this call modifies, for editor follow-along (usually the diffs' paths). */ + locations?: FileLocation[] +} + +/** + * How a tool wants the COMPLETED call shown — the *result* state, after `execute` + * returns. A `card`-tagged union mirroring {@link ToolCallView}: a UI switches on + * `card`. Lets the tool reformat its result for a UI distinctly from the + * model-facing text it returned from `execute`. Returned by + * {@link ToolDefinition.presentResult}; omitting the method keeps the pending + * title and renders the raw result content. + */ +export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView + +/** + * The default completed card: an optional replacement title and reformatted + * content. Omit a field to keep the pending title / render the raw result content. + */ +export interface GenericResultView { + card: 'generic' + /** Replacement title for the completed call. Omit to keep the pending-state title. */ + title?: string + /** + * UI-facing result content (harness {@link ContentBlock}s), reformatted from + * the model-facing result. Omit to let the UI render the raw result content. + */ + content?: ContentBlock[] +} + +/** + * The completed state of a {@link TerminalCallView}: the captured output and exit + * status. A capable UI renders `output` in the terminal card and shows an + * exit-status pill; an incapable UI gets a fenced ```console fallback the BRIDGE + * derives from `output` (the tool does not double-encode it). + */ +export interface TerminalResultView { + card: 'terminal' + /** Replacement title for the completed call. Omit to keep the pending-state title. */ + title?: string + /** Captured command output (stdout+stderr as the tool chooses to combine them). */ + output?: string + /** + * Process exit code, when the run ended by exiting (not a signal). Lets a + * capable UI show an exit-status pill. Omit when killed by a signal or unknown. + */ + exitCode?: number + /** Signal name that killed the process (e.g. `SIGTERM`). Mutually exclusive with `exitCode`. */ + signal?: string +} + +/** + * A completed file mutation rendered as an inline diff card, the *result-time* + * analogue of {@link DiffCallView}. Set by a tool whose `execute` applied a file + * change (e.g. `write`, `edit`): `diffs` are the change to show — typically the + * APPLIED hunks computed from the before/after content (one entry per hunk, each + * with surrounding context lines), so the editor shows the real change in place; + * a tool with no before-image (e.g. a file create) may instead give a whole-file + * diff (`oldText: null`). A `tool_call_update`'s content REPLACES the call's + * content in an editor, so a mutation tool returns this even when it duplicates + * the call-time snippet — otherwise the model-facing result text would replace + * (clobber) the pending diff card. + */ +export interface DiffResultView { + card: 'diff' + /** Replacement title for the completed call. Omit to keep the pending-state title. */ + title?: string + /** The change to show, in file order — applied contextual hunks, or a whole-file diff when there is no before-image. */ + diffs: FileDiff[] +} diff --git a/packages/core/tools/src/schema.ts b/packages/core/tools/src/schema.ts index 592ff4df21..78b3c91538 100644 --- a/packages/core/tools/src/schema.ts +++ b/packages/core/tools/src/schema.ts @@ -20,7 +20,8 @@ */ import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm' -import type { ToolCallView, ToolDefinition, ToolExecuteReturn, ToolExecution, ToolResult, ToolResultView } from './index.ts' +import type { ToolDefinition, ToolExecuteReturn, ToolExecution, ToolResult } from './index.ts' +import type { ToolCallView, ToolResultView } from './presentation.ts' // --------------------------------------------------------------------------- // SchemaSpec — the author-facing per-property type From fb78a844af7a4c810fab8bb3d4152f02e153018d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 00:39:09 +0800 Subject: [PATCH 68/75] docs(tools): fix cross-module JSDoc links and the catalog source list Codex review of the vocabulary relocation found two doc-accuracy issues: - presentation.ts's JSDoc used {@link ToolDefinition...}, which the TypeScript language service cannot resolve because presentation.ts deliberately does not import index.ts (that would create the cycle the split avoids). Demote those three to plain `ToolDefinition` code text; same-file and imported @links (TerminalResultView, ContentBlock) stay. - docs/core-data-structures/tools.md's source header listed only index.ts and schema.ts; add presentation.ts, which now owns the presentation vocabulary the page documents. --- docs/core-data-structures/tools.md | 2 +- packages/core/tools/src/presentation.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index f534ea1cdb..3b1f8d76c2 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -2,7 +2,7 @@ The tool pipeline of [dsh-tools](../../packages/core/tools). [core.md](core.md) introduces `ToolDefinition` as the one pipeline-authoring type promoted to the spine and `ToolSchema` as the model-facing wire shape. This page owns the full `ToolDefinition`, the typed schema DSL that builds it, the waterfall execution shapes, and the UI-presentation vocabulary. -Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) · [`packages/core/tools/src/schema.ts`](../../packages/core/tools/src/schema.ts) +Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) · [`packages/core/tools/src/schema.ts`](../../packages/core/tools/src/schema.ts) · [`packages/core/tools/src/presentation.ts`](../../packages/core/tools/src/presentation.ts) ## `ToolDefinition` — a registered tool diff --git a/packages/core/tools/src/presentation.ts b/packages/core/tools/src/presentation.ts index a64fe71eec..b99fa08ebd 100644 --- a/packages/core/tools/src/presentation.ts +++ b/packages/core/tools/src/presentation.ts @@ -1,6 +1,6 @@ /** * Tool render-intent vocabulary: the provider-neutral types a tool declares via - * {@link ToolDefinition.presentCall}/{@link ToolDefinition.presentResult} to say + * `ToolDefinition.presentCall`/`ToolDefinition.presentResult` to say * how ONE of its calls renders in a UI (an editor's tool-call card, a CLI log * line). A UI bridge switches on the `card` tag to map each intent to its own * wire shape, so a UI never special-cases tool names. @@ -62,7 +62,7 @@ export interface FileDiff { * switches on `card` to map it to the bridge's own wire shape. Provider-neutral — * the tool owns its presentation, so a UI never special-cases tool names. * - * Returned by {@link ToolDefinition.presentCall}. See the render-intent-union + * Returned by `ToolDefinition.presentCall`. See the render-intent-union * RFC (docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md). */ export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView @@ -144,7 +144,7 @@ export interface DiffCallView { * returns. A `card`-tagged union mirroring {@link ToolCallView}: a UI switches on * `card`. Lets the tool reformat its result for a UI distinctly from the * model-facing text it returned from `execute`. Returned by - * {@link ToolDefinition.presentResult}; omitting the method keeps the pending + * `ToolDefinition.presentResult`; omitting the method keeps the pending * title and renders the raw result content. */ export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView From ed022780a2ef240ac80ef173531f6562b2a07d3d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 00:39:54 +0800 Subject: [PATCH 69/75] docs: rebrand README for DeepSeek Harness SDK Replace outdated references to 'DeepSeek Code' with the correct product name 'DeepSeek Harness SDK'. Update demo commands to reflect the SDK nature (demo:agent, demo:acp) rather than the old coding-agent product. --- README.i18n.yaml | 4 ++-- README.md | 8 ++------ README.zh.md | 8 ++------ 3 files changed, 6 insertions(+), 14 deletions(-) diff --git a/README.i18n.yaml b/README.i18n.yaml index 7d4d1b9814..7659e0bc9e 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 33c03fad1450d91ba1adef3d89ce44d0ff73c25b -README.zh.md: 9d520023c528810ce75ee80efec2f2f087fb7b0e +README.md: 175ab76ebb1cd4f635bced46c6edd448b9fb4d45 +README.zh.md: af7a6baf10b9588ff67ab967b5829ecee551533b diff --git a/README.md b/README.md index 33c03fad14..175ab76ebb 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,7 @@ English | [中文](README.zh.md) -Monorepo for the DeepSeek Harness group. - -## Projects - -- **DeepSeek Code** — DeepSeek's coding agent product. +The **DeepSeek Harness SDK** is a plugin-based SDK for building agent harnesses. ## Development @@ -16,7 +12,7 @@ This monorepo is built on the [Cordis](https://github.com/cordiverse/cordis) fra pnpm install pnpm run test # vitest pnpm run demo:echo # runnable echo-agent example (no API key needed) -pnpm run demo:coding # the real DeepSeek coding agent (needs DEEPSEEK_API_KEY) +pnpm run demo:coding # full-featured agent harness demo (needs DEEPSEEK_API_KEY) ``` For humans, start with the [development guide](docs/development.md) for local setup, hooks, environment variables, and quality gates, then read the [architecture design](docs/architecture.md) before package work. Local context lives in [packages/](packages/) and [vendor/](vendor/). diff --git a/README.zh.md b/README.zh.md index 9d520023c5..af7a6baf10 100644 --- a/README.zh.md +++ b/README.zh.md @@ -2,11 +2,7 @@ [English](README.md) | 中文 -DeepSeek Harness 小组的 monorepo。 - -## 项目 - -- **DeepSeek Code** — DeepSeek 的编码 agent(智能体)产品。 +**DeepSeek Harness SDK** 是一个基于插件的 SDK,用于构建 agent harness。 ## 开发 @@ -16,7 +12,7 @@ DeepSeek Harness 小组的 monorepo。 pnpm install pnpm run test # vitest pnpm run demo:echo # runnable echo-agent example (no API key needed) -pnpm run demo:coding # the real DeepSeek coding agent (needs DEEPSEEK_API_KEY) +pnpm run demo:coding # full-featured agent harness demo (needs DEEPSEEK_API_KEY) ``` 面向人类读者:先读[开发指南](docs/development.md)了解本地环境搭建、钩子、环境变量与质量门禁,动手改 package 之前再读[架构设计](docs/architecture.md)。局部上下文见 [packages/](packages/) 与 [vendor/](vendor/)。 From 39cdb3ea28d72535a0e14d84c5b3eb4f56fd3b7e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 00:51:25 +0800 Subject: [PATCH 70/75] docs: add translation review guidance --- .agents/skills/dsh-code-review/SKILL.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index 1a0d2c8c6f..9dc4de5ca6 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -25,6 +25,7 @@ These define the conventions and gates this repo is checked against, and they ar - **AGENTS.md § Defensive patterns (hard-won)** — each bullet is a bug class that bit us. Reviewing anything touching process lifecycle, async/await, disposal, or adapter error paths? Re-read this first — then look for the *adjacent* mistake it doesn't name. - **AGENTS.md § Type Safety and Documentation** — the doc-sync rule (code change ⇒ update README + JSDoc in the SAME commit) and the no-hard-wrap markdown convention. - **[packages/AGENTS.md](../../../packages/AGENTS.md)** — per-package conventions (file layout, the HMR-safety test requirement). +- **[docs/i18n/translation-rules.md](../../../docs/i18n/translation-rules.md) and [docs/i18n/terminology.md](../../../docs/i18n/terminology.md)** — the authoritative standard for bilingual-doc review: faithfulness, structure, typography, and the binding terminology table. For PRs touching translated docs or pending terms, read these before judging the translation; [dsh-translate-docs](../dsh-translate-docs/SKILL.md) is the translator workflow. - **[RFC index](../../../docs/rfc/README.md)** — the *why* behind the architecture. Especially [quality gates](../../../docs/rfc/implemented/process/2026-06-11-quality-gates.md) (what a PR must pass) and [capability seams](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md) (the three-package split). If a change seems to fight an RFC, that's a discussion, not a silent override — and not an automatic veto either: an RFC can be wrong for this case, so reason about it. ## Hard blockers (documented requirements — missing one blocks merge) @@ -45,6 +46,7 @@ Where your independent reasoning earns its keep. Start here, then keep going acr - **Seam discipline.** New swappable capability? Check it's split per the capability-seams RFC (interface / impl / consumer), and that the consumer injects the interface key, never an implementation type. - **Test quality — sufficiency, not just coverage.** 100% per-file coverage and a green suite are necessary, not sufficient: they prove the lines *ran*, not that the feature *works the way it ships*. Judge whether the tests are sufficient on two axes. (1) **Would they fail if the behavior regressed?** A test that passes but asserts the wrong thing — or restates the implementation instead of the contract (events fired, disposal reached, the world changed) — is worse than none. (2) **Do they exercise the REAL thing, the way it's actually used?** Prefer the genuine collaborator over a fake, drive the change through its real entry path (the cordis Loader, the ACP bridge, a booted subprocess — not a hand-built `ctx.plugin({...})` that bypasses `unwrapExports`), and verify the WORLD (re-read the file/log/registry externally), not the agent's self-report. A test that fakes the inputs just enough to cover every line will agree with whatever the author assumed; the real thing won't. When a test sets up a *clean/happy* path to reach a line, ask whether the line's PURPOSE is exercised — e.g. a durability/teardown path "tested" by a fully-completed turn never proves the mid-flight teardown it exists for; a torn-tail recovery branch covered by a well-formed log never proves recovery. Flag tests that hit the line but not the scenario. See AGENTS.md § Defensive patterns "Line coverage is not behavior coverage" and "Prefer the REAL implementation over a mock/stand-in in tests". - **Snapshot coverage for transcript/UX changes.** If the PR changes the editor-facing transcript or end-to-end agent UX — the ACP bridge's event→update translation, the agent loop's observable output, tool presentation, or anything an editor renders — it must add or update a snapshot scenario (`examples/*/tests/**/*.snapshot.ts`, goldens under `examples/acp-agent/tests/snapshots/`) or note explicitly why none applies (AGENTS.md § Conventions). Review the golden diff itself: a changed `stdout.golden.txt` / `session.golden.txt` is a behavior change in disguise — confirm it's intended, not an accidental regression someone re-recorded away. A pure internal refactor with no observable-output change is exempt, but the PR should say so. See [docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md](../../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md). +- **Bilingual docs: review translation quality, not just pairing.** If the PR adds or edits a doc pair, read the changed English and Chinese sides and compare the meaning, not only the mechanical diff. Verify terms against [terminology.md](../../../docs/i18n/terminology.md), including first-occurrence annotations and "do not translate as" prohibitions; if a new term has no established precedent, the PR should keep it in English, list it under `待定术语`, and update the terminology table once the rendering is decided. A green `verify-translation-pairing` only proves hashes, switchers, and structure were recorded — it does not prove the translation is faithful, natural, or correctly termed. Treat [translation-rules.md](../../../docs/i18n/translation-rules.md) MUST/MUST NOT violations as blocking. - **Intent and contracts.** Does the change do what the PR says, and honor the documented contract on *both* sides of every seam it touches (see AGENTS.md "Honor cross-seam contracts on BOTH sides")? ## How to respond From bae8bf49903b536f15d5dc479df53a371453d0c6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 00:54:31 +0800 Subject: [PATCH 71/75] scripts: lint only package directories --- scripts/publint-all.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/publint-all.ts b/scripts/publint-all.ts index df13d87caa..0e06372c3f 100644 --- a/scripts/publint-all.ts +++ b/scripts/publint-all.ts @@ -1,5 +1,5 @@ import { execFileSync } from 'node:child_process' -import { readdirSync } from 'node:fs' +import { existsSync, readdirSync } from 'node:fs' import { resolve } from 'node:path' // publint every harness package. Packages live at packages// @@ -14,6 +14,7 @@ const packages = readdirSync(packagesRoot, { withFileTypes: true }) .flatMap(group => readdirSync(resolve(packagesRoot, group.name), { withFileTypes: true }) .filter(pkg => pkg.isDirectory()) + .filter(pkg => existsSync(resolve(packagesRoot, group.name, pkg.name, 'package.json'))) .map(pkg => `packages/${group.name}/${pkg.name}`), ) From ca91d7c2daaae6490b931e47287845fc08a8d7ab Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 00:57:35 +0800 Subject: [PATCH 72/75] scripts: ignore local artifacts in constraints --- scripts/check-workspace-constraints.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index a669a8572d..f579169ab0 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -27,6 +27,8 @@ const vendoredPackages = new Set([ '@cordisjs/plugin-logger-console', ]) +const localArtifactDirs = new Set(['node_modules']) + /** The subset of package.json fields this constraint check cares about. */ interface PackageManifest { name?: string @@ -64,10 +66,13 @@ function packageDirs(base: string, depth: number): string[] { if (depth === 1) { return readdirSync(join(root, base), { withFileTypes: true }) .filter(entry => entry.isDirectory()) + .filter(entry => !localArtifactDirs.has(entry.name)) + .filter(entry => existsSync(join(root, base, entry.name, 'package.json'))) .map(entry => join(base, entry.name)) } return readdirSync(join(root, base), { withFileTypes: true }) .filter(entry => entry.isDirectory()) + .filter(entry => !localArtifactDirs.has(entry.name)) .flatMap(group => packageDirs(join(base, group.name), depth - 1)) } @@ -177,6 +182,7 @@ function checkHierarchyShape(): string[] { } for (const pkg of readdirSync(join(packagesRoot, group.name), { withFileTypes: true })) { if (!pkg.isDirectory()) continue + if (localArtifactDirs.has(pkg.name)) continue const pkgRel = join(groupRel, pkg.name) if (!existsSync(join(packagesRoot, group.name, pkg.name, 'package.json'))) { errors.push(`${pkgRel}: expected a package here (no package.json found) — the hierarchy is exactly packages//, no deeper nesting`) From 4cf2ade4badc86be3266989d05decb40236a59a5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 00:57:49 +0800 Subject: [PATCH 73/75] docs: clarify top-level demos --- README.i18n.yaml | 4 ++-- README.md | 4 ++-- README.zh.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.i18n.yaml b/README.i18n.yaml index 7659e0bc9e..41574386b3 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 175ab76ebb1cd4f635bced46c6edd448b9fb4d45 -README.zh.md: af7a6baf10b9588ff67ab967b5829ecee551533b +README.md: 880ca9a3420aec23b82bb2d3e5e96f7895b8b3b6 +README.zh.md: bf733a6699b958a658bd4c2f7becc8ce769dd70b diff --git a/README.md b/README.md index 175ab76ebb..880ca9a342 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ This monorepo is built on the [Cordis](https://github.com/cordiverse/cordis) fra ```sh pnpm install pnpm run test # vitest -pnpm run demo:echo # runnable echo-agent example (no API key needed) -pnpm run demo:coding # full-featured agent harness demo (needs DEEPSEEK_API_KEY) +pnpm run demo:coding # coding-agent demo (needs DEEPSEEK_API_KEY) +pnpm run demo:acp # ACP server demo (needs DEEPSEEK_API_KEY) ``` For humans, start with the [development guide](docs/development.md) for local setup, hooks, environment variables, and quality gates, then read the [architecture design](docs/architecture.md) before package work. Local context lives in [packages/](packages/) and [vendor/](vendor/). diff --git a/README.zh.md b/README.zh.md index af7a6baf10..bf733a6699 100644 --- a/README.zh.md +++ b/README.zh.md @@ -11,8 +11,8 @@ ```sh pnpm install pnpm run test # vitest -pnpm run demo:echo # runnable echo-agent example (no API key needed) -pnpm run demo:coding # full-featured agent harness demo (needs DEEPSEEK_API_KEY) +pnpm run demo:coding # coding-agent demo (needs DEEPSEEK_API_KEY) +pnpm run demo:acp # ACP server demo (needs DEEPSEEK_API_KEY) ``` 面向人类读者:先读[开发指南](docs/development.md)了解本地环境搭建、钩子、环境变量与质量门禁,动手改 package 之前再读[架构设计](docs/architecture.md)。局部上下文见 [packages/](packages/) 与 [vendor/](vendor/)。 From 51640362b59167c5276e1d5d30b8a6d3af136e0b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 01:07:26 +0800 Subject: [PATCH 74/75] docs: rename coding demo to repl --- AGENTS.md | 16 ++++++++-------- README.i18n.yaml | 4 ++-- README.md | 4 ++-- README.zh.md | 4 ++-- docs/cookbook/extension-cookbook.md | 2 +- docs/development.i18n.yaml | 4 ++-- docs/development.md | 10 +++++----- docs/development.zh.md | 10 +++++----- .../2026-06-20-extract-example-app-packages.md | 2 +- examples/README.md | 6 +++--- examples/acp-agent/README.md | 2 +- examples/acp-agent/package.json | 2 +- examples/coding-agent/README.md | 8 ++++---- examples/coding-agent/cordis.yml | 8 ++++---- examples/coding-agent/package.json | 2 +- examples/coding-agent/tests/keyless-smoke.e2e.ts | 6 +++--- package.json | 2 +- packages/support/ui-stdio/README.md | 2 +- packages/ui/stdio-agent/README.md | 6 +++--- packages/ui/stdio-agent/src/bin.ts | 4 ++-- 20 files changed, 52 insertions(+), 52 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4e0a62b13b..f755979476 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,7 +94,7 @@ packages/ Harness packages, grouped by role at packages///. acp/ Agent Client Protocol bridge: drive the agent from an ACP editor (Zed) over JSON-RPC stdio stdio-agent/ stdio chat APP: agent-core spine + console logger + readline - UI + a pre-created main agent + a bin (the demo:echo/coding + UI + a pre-created main agent + a bin (the demo:echo/repl front door) acp-agent/ ACP server APP: agent-core spine + JSONL persistence + the acp bridge, NO stdout logger + a bin (the demo:acp front door) @@ -115,10 +115,10 @@ 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 + fs tools + key). coding-agent = the REPL agent demo: 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, + app (pnpm run demo:repl, needs DEEPSEEK_API_KEY). acp-agent = the + ACP server agent demo 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. @@ -188,10 +188,10 @@ pnpm run verify-node-next-types # assert built declarations typecheck for a pnpm run doc-sync # doc-typecheck + verify-cordis-catalog + verify-tool-catalog + verify-md-wrap + verify-md-links + verify-doc-refs + verify-package-paths + verify-rfc-classification + verify-type-equiv + verify-translation-pairing (CI runs this) pnpm run demo:echo # run examples/echo-agent (no API key; type "echo hi" to # see a tool call) — the mock skeleton -pnpm run demo:coding # run examples/coding-agent — the real agent (needs - # DEEPSEEK_API_KEY; give it a coding task) -pnpm run demo:acp # run examples/acp-agent — the coding agent as an ACP - # server over JSON-RPC stdio (needs DEEPSEEK_API_KEY; +pnpm run demo:repl # run examples/coding-agent — the REPL agent demo + # (needs DEEPSEEK_API_KEY; give it a coding task) +pnpm run demo:acp # run examples/acp-agent — the ACP server agent demo + # over JSON-RPC stdio (needs DEEPSEEK_API_KEY; # drive it from Zed or another ACP client) ``` diff --git a/README.i18n.yaml b/README.i18n.yaml index 41574386b3..c9a639e720 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 880ca9a3420aec23b82bb2d3e5e96f7895b8b3b6 -README.zh.md: bf733a6699b958a658bd4c2f7becc8ce769dd70b +README.md: 7ddf68bab06ecf891856e6d1393ccdefd9eeba38 +README.zh.md: 4bcee075512c37ea60a8be5ca5ae8acf945f161a diff --git a/README.md b/README.md index 880ca9a342..7ddf68bab0 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ This monorepo is built on the [Cordis](https://github.com/cordiverse/cordis) fra ```sh pnpm install pnpm run test # vitest -pnpm run demo:coding # coding-agent demo (needs DEEPSEEK_API_KEY) -pnpm run demo:acp # ACP server demo (needs DEEPSEEK_API_KEY) +pnpm run demo:repl # REPL agent demo (needs DEEPSEEK_API_KEY) +pnpm run demo:acp # ACP server agent demo (needs DEEPSEEK_API_KEY) ``` For humans, start with the [development guide](docs/development.md) for local setup, hooks, environment variables, and quality gates, then read the [architecture design](docs/architecture.md) before package work. Local context lives in [packages/](packages/) and [vendor/](vendor/). diff --git a/README.zh.md b/README.zh.md index bf733a6699..4bcee07551 100644 --- a/README.zh.md +++ b/README.zh.md @@ -11,8 +11,8 @@ ```sh pnpm install pnpm run test # vitest -pnpm run demo:coding # coding-agent demo (needs DEEPSEEK_API_KEY) -pnpm run demo:acp # ACP server demo (needs DEEPSEEK_API_KEY) +pnpm run demo:repl # REPL agent demo (needs DEEPSEEK_API_KEY) +pnpm run demo:acp # ACP server agent demo (needs DEEPSEEK_API_KEY) ``` 面向人类读者:先读[开发指南](docs/development.md)了解本地环境搭建、钩子、环境变量与质量门禁,动手改 package 之前再读[架构设计](docs/architecture.md)。局部上下文见 [packages/](packages/) 与 [vendor/](vendor/)。 diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index e6c0378361..a02fccfac1 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -83,4 +83,4 @@ export function apply(ctx: Context) { ## Runnable wirings -Three complete examples load their plugin trees from `cordis.yml`: [`examples/echo-agent`](../../examples/echo-agent) (mock model + echo tool — the all-mock skeleton check, `pnpm run demo:echo`), [`examples/coding-agent`](../../examples/coding-agent) (DeepSeek V4 + the bash tool suite — the real thing, `pnpm run demo:coding`), and [`examples/acp-agent`](../../examples/acp-agent) (the same coding agent exposed as an ACP server over JSON-RPC stdio — the client-driver shape, `pnpm run demo:acp`). Each leaf is now just its swappable backends plus an app-package entry: the stdio demos load [`@deepseek-ai/dsh-stdio-agent`](../../packages/ui/stdio-agent), the ACP demo loads [`@deepseek-ai/dsh-acp-agent`](../../packages/ui/acp-agent), and both app packages share the spine via the [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) bundle. +Three complete examples load their plugin trees from `cordis.yml`: [`examples/echo-agent`](../../examples/echo-agent) (mock model + echo tool — the all-mock skeleton check, `pnpm run demo:echo`), [`examples/coding-agent`](../../examples/coding-agent) (DeepSeek V4 + the bash tool suite behind a terminal REPL UI, `pnpm run demo:repl`), and [`examples/acp-agent`](../../examples/acp-agent) (an agent exposed as an ACP server over JSON-RPC stdio — the client-driver shape, `pnpm run demo:acp`). Each leaf is now just its swappable backends plus an app-package entry: the stdio demos load [`@deepseek-ai/dsh-stdio-agent`](../../packages/ui/stdio-agent), the ACP demo loads [`@deepseek-ai/dsh-acp-agent`](../../packages/ui/acp-agent), and both app packages share the spine via the [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) bundle. diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 2d18cb1c8a..4b9823584b 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -development.md: ce431d95c5dbb976d0c3ffa827af46ba608b400e -development.zh.md: 36155ee2b93f2bc309ca1341cbed82c37e2759c9 +development.md: 3e11ae594759e6251e46f3bf9e0b021d9e1555c5 +development.zh.md: e8cea20a713767411304c2a3c97099004b9392c3 diff --git a/docs/development.md b/docs/development.md index ce431d95c5..3e11ae5947 100644 --- a/docs/development.md +++ b/docs/development.md @@ -9,7 +9,7 @@ This guide covers the local setup needed to work on DeepSeek Harness and underst - Node.js 24 or newer. The repo declares `node >=24`; CI runs the matrix on Node 24 and 26. - Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack. - Git. -- Optional: a DeepSeek API key for the coding-agent demo and real-API e2e tests. +- Optional: a DeepSeek API key for the REPL/ACP agent demos and real-API e2e tests. ## First-time setup @@ -45,7 +45,7 @@ pnpm run build ## Environment variables -The real DeepSeek adapter and coding-agent demo read credentials from the environment or from a gitignored `.env` at the repo root: +The real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root: ```sh DEEPSEEK_API_KEY=sk-... @@ -118,13 +118,13 @@ The echo demo does not need API credentials: pnpm run demo:echo ``` -The coding-agent demo uses the real DeepSeek adapter and needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`: +The REPL agent demo uses the real DeepSeek adapter and needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`: ```sh -pnpm run demo:coding +pnpm run demo:repl ``` -The ACP server demo exposes the same coding agent over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`: +The ACP server agent demo exposes the agent over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`: ```sh pnpm run demo:acp diff --git a/docs/development.zh.md b/docs/development.zh.md index 36155ee2b9..e8cea20a71 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -9,7 +9,7 @@ - Node.js 24 或更新版本。仓库声明 `node >=24`;CI 在 Node 24 和 26 上跑矩阵。 - 启用了 Corepack 的 pnpm。仓库在 `package.json` 中钉住 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,先运行 `corepack enable`。 - Git。 -- 可选:一个 DeepSeek API key,用于 coding-agent 演示和真实 API 的 e2e 测试。 +- 可选:一个 DeepSeek API key,用于 REPL/ACP agent(智能体)演示和真实 API 的 e2e 测试。 ## 首次搭建 @@ -45,7 +45,7 @@ pnpm run build ## 环境变量 -真实的 DeepSeek 适配器和 coding-agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 读取凭证: +真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 读取凭证: ```sh DEEPSEEK_API_KEY=sk-... @@ -118,13 +118,13 @@ echo 演示不需要 API 凭证: pnpm run demo:echo ``` -coding-agent 演示使用真实的 DeepSeek 适配器,需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`: +REPL agent 演示使用真实的 DeepSeek 适配器,需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`: ```sh -pnpm run demo:coding +pnpm run demo:repl ``` -ACP 服务器演示把同一个编码 agent(智能体)通过 JSON-RPC stdio 暴露出来,同样需要 `DEEPSEEK_API_KEY`: +ACP 服务器 agent 演示通过 JSON-RPC stdio 暴露 agent,同样需要 `DEEPSEEK_API_KEY`: ```sh pnpm run demo:acp diff --git a/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md b/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md index b6c30819dd..02517d7322 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md +++ b/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md @@ -37,7 +37,7 @@ The old `base*.yml`/`acp-tail.yml` includes already deduped the *config*, but a ## Verification - Each example directory is `cordis.yml` (+ the acp `cordis.snapshot.yml`) + `README.md` + tests only — no `start.ts`, no infra preamble; `base.yml`/`base-core.yml`/`acp-tail.yml` are gone. -- `demo:echo` / `demo:coding` / `demo:acp` run via the app-package `bin`s. +- `demo:echo` / `demo:repl` / `demo:acp` run via the app-package `bin`s. - The new packages carry the per-file 100% coverage gate and a README like every `@deepseek-ai/dsh-*`. Each app package has a keyless **real-load-path** smoke that boots it through its `bin` + the cordis Loader (not a hand-built `ctx.plugin({...})` mount), guarding the `unwrapExports` export-shape bug class ([postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md)). - The ACP snapshot **replay** transcript is unchanged: the boot restructuring preserved the plugin set + load order, so `pnpm run test:snapshot` stays green against the committed goldens with no re-record. diff --git a/examples/README.md b/examples/README.md index a95814bbbd..1e3134ba2d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -15,12 +15,12 @@ Run with: `pnpm run demo:echo`. When prompted, type "echo " to trigge ## coding-agent -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. +A REPL agent demo: 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. The UI is a terminal readline REPL. -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. +Run with: `pnpm run demo:repl` (needs `DEEPSEEK_API_KEY` in the environment or a gitignored repo-root `.env`). See [coding-agent/README.md](coding-agent/README.md) for details. ## acp-agent -The same coding agent exposed as an **Agent Client Protocol (ACP)** server over JSON-RPC stdio, via the [`@deepseek-ai/dsh-acp-agent`](../packages/ui/acp-agent) app — drive it from Zed or any other ACP client. Also the home of the keyless snapshot tests. +An agent demo exposed as an **Agent Client Protocol (ACP)** server over JSON-RPC stdio, via the [`@deepseek-ai/dsh-acp-agent`](../packages/ui/acp-agent) app — drive it from Zed or any other ACP client. Also the home of the keyless snapshot tests. Run with: `pnpm run demo:acp` (needs `DEEPSEEK_API_KEY`). See [acp-agent/README.md](acp-agent/README.md) for the Zed setup and the snapshot-test design. diff --git a/examples/acp-agent/README.md b/examples/acp-agent/README.md index e9a38f2313..278ea74e53 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -1,6 +1,6 @@ # acp-agent example -The DeepSeek Harness coding agent exposed as an **Agent Client Protocol (ACP)** server over JSON-RPC stdio — drive it from Zed or any other ACP client. +The DeepSeek Harness agent demo exposed as an **Agent Client Protocol (ACP)** server over JSON-RPC stdio — drive it from Zed or any other ACP client. ```sh pnpm run demo:acp # needs DEEPSEEK_API_KEY (repo-root .env or env) diff --git a/examples/acp-agent/package.json b/examples/acp-agent/package.json index 499d21af99..8ee54f5650 100644 --- a/examples/acp-agent/package.json +++ b/examples/acp-agent/package.json @@ -1,6 +1,6 @@ { "name": "acp-agent-example", - "description": "Runnable demo: the coding agent as an ACP server over JSON-RPC stdio (Zed & other ACP editors)", + "description": "Runnable demo: an agent as an ACP server over JSON-RPC stdio (Zed & other ACP editors)", "private": true, "version": "0.0.1", "type": "module" diff --git a/examples/coding-agent/README.md b/examples/coding-agent/README.md index d8b764483d..4b15d2dc5a 100644 --- a/examples/coding-agent/README.md +++ b/examples/coding-agent/README.md @@ -1,6 +1,6 @@ # coding-agent -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. +The REPL agent demo 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`. The UI is a terminal readline REPL. ## Run it @@ -8,7 +8,7 @@ The real stdio coding-agent wiring: DeepSeek V4 + the `read`/`write`/`edit` file # repo root .env (gitignored) or exported env: # DEEPSEEK_API_KEY=sk-… # DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm run demo:coding +pnpm run demo:repl ``` 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. @@ -26,7 +26,7 @@ Type a coding task. The agent works through the `read`/`write`/`edit` filesystem Each run starts a fresh session by default (its event log lands under `./.sessions/`). To **continue** a previous conversation, set `RESUME_SESSION_ID` to that session's id — the `main` agent then rehydrates the persisted log instead of starting fresh, so the model sees the earlier turns as history: ```sh -RESUME_SESSION_ID= pnpm run demo:coding +RESUME_SESSION_ID= pnpm run demo:repl ``` The id is wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`); unset, the agent starts a new session. A missing/unreadable id is non-fatal — it logs a warning and starts no `main` agent. @@ -37,7 +37,7 @@ This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads | Entry | Demonstrates | |---|---| -| `hmr` (`@cordisjs/plugin-hmr`) | the dev/demo edit-reload loop — a **leaf** entry (not baked into the app) because it is Loader-only and needs `node --expose-internals`, which `demo:coding` passes | +| `hmr` (`@cordisjs/plugin-hmr`) | the dev/demo edit-reload loop — a **leaf** entry (not baked into the app) because it is Loader-only and needs `node --expose-internals`, which `demo:repl` passes | | `llm-deepseek` | real `LlmAdapter` via config (`!!js process.env.…` secrets); swap one line to `@deepseek-ai/dsh-llm-pi-ai` for the library-backed twin | | `bash` (`dsh-bash-local`) | the executor implementation — the swappable half of the bash seam. The model-facing `bash`/`bash_output`/`bash_kill` tool schemas (`tool-bash`) come from `agent-core`, so only the executor is a leaf choice | | `stdio-agent` (`@deepseek-ai/dsh-stdio-agent`) | the app bundle: the agent-core spine + console logger + JSONL persistence + readline UI + a pre-created `main` agent. Its config carries the model, system prompt, `persistenceRoot` (`./.sessions`), and `resumeSessionId` — so persistence and the agent are configured here, not wired as separate leaf plugins | diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index 625fa0c9b9..0c95299fca 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -1,4 +1,4 @@ -# The coding-agent plugin tree: the real coding agent. The two swappable +# The coding-agent plugin tree: the REPL agent demo. The two swappable # backends — the DeepSeek adapter and the local bash executor — plus `hmr` for # the dev/demo reload loop, then the stdio chat app (@deepseek-ai/dsh-stdio- # agent), which bundles the whole agent-core spine (timer, llm, sessions, @@ -6,7 +6,7 @@ # logger, JSONL persistence, the readline UI, and a pre-created `main` agent. # # `hmr` is a leaf entry (not baked into dsh-stdio-agent): it is a Loader-only -# dev plugin that needs `--expose-internals` — the `demo:coding` script passes +# dev plugin that needs `--expose-internals` — the `demo:repl` script passes # it. Requires DEEPSEEK_API_KEY (and optionally DEEPSEEK_BASE_URL) in the # environment — the dsh-stdio-agent bin loads the gitignored repo-root .env # first. cordis.yml reads them via the `!!js` tag. @@ -37,7 +37,7 @@ timeoutMs: 60000 # The stdio chat app: the whole spine + front-door cluster, configured for a -# real coding agent driving a pre-created `main` agent. +# REPL agent demo driving a pre-created `main` agent. - id: stdio-agent name: '@deepseek-ai/dsh-stdio-agent' config: @@ -46,7 +46,7 @@ # 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 read, write, edit, bash, subagent, and todo_write).' + welcome: 'agent REPL 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. diff --git a/examples/coding-agent/package.json b/examples/coding-agent/package.json index d92eeb6fdb..b3594ff597 100644 --- a/examples/coding-agent/package.json +++ b/examples/coding-agent/package.json @@ -3,5 +3,5 @@ "private": true, "version": "0.0.1", "type": "module", - "description": "Runnable demo: a real coding agent — DeepSeek V4 + the bash tool suite" + "description": "Runnable demo: an agent REPL UI with DeepSeek V4 and coding tools" } diff --git a/examples/coding-agent/tests/keyless-smoke.e2e.ts b/examples/coding-agent/tests/keyless-smoke.e2e.ts index 4e5f3e78dc..8448d09dda 100644 --- a/examples/coding-agent/tests/keyless-smoke.e2e.ts +++ b/examples/coding-agent/tests/keyless-smoke.e2e.ts @@ -24,7 +24,7 @@ import { afterEach, describe, expect, it } from 'vitest' * product. */ -// The dsh-stdio-agent bin (the demo:coding entry) and this example's cordis.yml. +// The dsh-stdio-agent bin (the demo:repl entry) and this example's cordis.yml. // The bin resolves its config-path arg from CWD; the test spawns from a temp // cwd, so we pass the example config's ABSOLUTE path. const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url)) @@ -51,7 +51,7 @@ async function bootAndEof(): Promise<{ stdout: string; code: number }> { return new Promise((resolve, reject) => { const proc = spawn( process.execPath, - // --expose-internals: cordis.yml loads the HMR plugin (mirrors demo:coding). + // --expose-internals: cordis.yml loads the HMR plugin (mirrors demo:repl). ['--expose-internals', '--import', tsxLoader, binScript, configPath], { cwd, @@ -94,6 +94,6 @@ describe('coding-agent keyless smoke (real cordis.yml via the Loader)', () => { it('boots the full plugin tree, prints its banner, and exits cleanly on EOF', async () => { const { stdout, code } = await bootAndEof() expect(code).toBe(0) - expect(stdout).toContain('coding-agent ready.') + expect(stdout).toContain('agent REPL ready.') }, 15_000) }) diff --git a/package.json b/package.json index a6a62b41a8..893b0059f6 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-tool-catalog && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-rfc-classification && pnpm run verify-type-equiv && pnpm run verify-translation-pairing", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types", "demo:echo": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/echo-agent/cordis.yml", - "demo:coding": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/coding-agent/cordis.yml", + "demo:repl": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/coding-agent/cordis.yml", "demo:acp": "node --import tsx packages/ui/acp-agent/src/bin.ts examples/acp-agent/cordis.yml", "postinstall": "node scripts/install-lefthook.mjs" }, diff --git a/packages/support/ui-stdio/README.md b/packages/support/ui-stdio/README.md index b65fd4d8e1..d0697a824d 100644 --- a/packages/support/ui-stdio/README.md +++ b/packages/support/ui-stdio/README.md @@ -15,7 +15,7 @@ This package consolidates what were two near-identical copies under `examples/ec - id: ui-stdio name: '@deepseek-ai/dsh-ui-stdio' config: - welcome: 'coding-agent ready. Give it a coding task.' + welcome: 'agent REPL ready. Give it a coding task.' ``` ## Rendering diff --git a/packages/ui/stdio-agent/README.md b/packages/ui/stdio-agent/README.md index a78df0fa72..550b52c1ea 100644 --- a/packages/ui/stdio-agent/README.md +++ b/packages/ui/stdio-agent/README.md @@ -15,7 +15,7 @@ A terminal chat always wants the same cluster, so the package owns it rather tha | `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` | | `@deepseek-ai/dsh-ui-stdio` | the readline UI, bound to the `main` agent | -`@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, NOT baked in here: it is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader`, and the in-process test tier cannot even import it (so a package whose `apply` statically pulled it in could never carry the per-file coverage gate). Unlike the console logger, a stray `hmr` is not a stdout-purity footgun, so leaving it at the leaf costs no safety. The `demo:echo` / `demo:coding` leaves load it and pass `--expose-internals`. +`@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, NOT baked in here: it is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader`, and the in-process test tier cannot even import it (so a package whose `apply` statically pulled it in could never carry the per-file coverage gate). Unlike the console logger, a stray `hmr` is not a stdout-purity footgun, so leaving it at the leaf costs no safety. The `demo:echo` / `demo:repl` leaves load it and pass `--expose-internals`. The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapter (`llm-deepseek` for the real model, or the mock `mock-llm` for a demo) and a bash executor (`bash-local`) — `hmr`, plus this app's [`Config`](#config). The whole plugin tree a run loads is therefore: this app's cluster, the spine inside `agent-core`, `hmr`, and the two leaf backends. @@ -31,12 +31,12 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte ## The bin -`dsh-stdio-agent [path-to-cordis.yml]` (default `./cordis.yml`) loads a gitignored `.env` from the cwd (`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`), then drives the cordis Loader against the config and awaits the whole plugin tree before returning. Run it under `node --expose-internals`: the cordis Loader resolves the config's bare plugin specifiers (`@deepseek-ai/dsh-*`, npm packages) through its internal module loader, which is only active under that flag. The `demo:echo` / `demo:coding` scripts invoke it that way. +`dsh-stdio-agent [path-to-cordis.yml]` (default `./cordis.yml`) loads a gitignored `.env` from the cwd (`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`), then drives the cordis Loader against the config and awaits the whole plugin tree before returning. Run it under `node --expose-internals`: the cordis Loader resolves the config's bare plugin specifiers (`@deepseek-ai/dsh-*`, npm packages) through its internal module loader, which is only active under that flag. The `demo:echo` / `demo:repl` scripts invoke it that way. ## Example leaf `cordis.yml` ```yaml -# A real coding agent: hmr + the DeepSeek adapter + local bash, then this app. +# A REPL agent demo: hmr + the DeepSeek adapter + local bash, then this app. - id: hmr name: '@cordisjs/plugin-hmr' config: diff --git a/packages/ui/stdio-agent/src/bin.ts b/packages/ui/stdio-agent/src/bin.ts index 92cd9f5e90..a07bb2e600 100644 --- a/packages/ui/stdio-agent/src/bin.ts +++ b/packages/ui/stdio-agent/src/bin.ts @@ -6,7 +6,7 @@ * duplicated in their `start.ts`: load the gitignored repo-root `.env`, then * drive the cordis Loader against the config path (default `./cordis.yml`). * - * Usage: `dsh-stdio-agent [path-to-cordis.yml]`. The `demo:echo` / `demo:coding` + * Usage: `dsh-stdio-agent [path-to-cordis.yml]`. The `demo:echo` / `demo:repl` * scripts invoke it with the example's config. * * @module @deepseek-ai/dsh-stdio-agent/bin @@ -105,7 +105,7 @@ function assertEntriesLoaded(ctx: Context): void { * * Bare plugin specifiers in the config (`@deepseek-ai/dsh-*`, npm packages) are * resolved by the cordis Loader's internal module loader, which is only active - * under `node --expose-internals` (the flag the `demo:echo`/`demo:coding` scripts + * under `node --expose-internals` (the flag the `demo:echo`/`demo:repl` scripts * pass). Without it the Loader falls back to resolving relative to its own module * and cannot find the config's plugins, so a consumer running the built bin must * pass `--expose-internals` (or install the plugins where node hoists them). From db885271ed62d93370a7a4cb756bba0ff8a9c883 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 01:18:32 +0800 Subject: [PATCH 75/75] docs: polish Chinese README summary --- README.i18n.yaml | 2 +- README.zh.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.i18n.yaml b/README.i18n.yaml index c9a639e720..0a981d4323 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write README.md: 7ddf68bab06ecf891856e6d1393ccdefd9eeba38 -README.zh.md: 4bcee075512c37ea60a8be5ca5ae8acf945f161a +README.zh.md: 59a0419164f2dfee6f66903cc93d7b35da1d9063 diff --git a/README.zh.md b/README.zh.md index 4bcee07551..59a0419164 100644 --- a/README.zh.md +++ b/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -**DeepSeek Harness SDK** 是一个基于插件的 SDK,用于构建 agent harness。 +**DeepSeek Harness SDK** 是用于构建 agent harness 的 SDK,采取基于插件的设计。 ## 开发