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.
This commit is contained in:
@@ -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 }
|
||||
/**
|
||||
|
||||
@@ -26,7 +26,7 @@ Tool registry and execution waterfall. Tool plugins register their schemas and e
|
||||
|
||||
- `ToolDefinition` — `ToolSchema` + `execute(args, exec): Promise<ContentBlock[] | { content: ContentBlock[]; meta? }>` (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<S>` shape; the helper soft-validates before calling (a malformed/older logged arg shape yields `undefined` rather than throwing, since display must never crash a replay). The 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<S>` shape; the helper soft-validates before calling (a malformed/older logged arg shape yields `undefined` rather than throwing, since display must never crash a replay). The 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'
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -429,4 +429,4 @@ export function applyLiteralEdit(
|
||||
return { content: content.split(oldNorm).join(newNorm), replacements }
|
||||
}
|
||||
|
||||
export { restoreLineEndings }
|
||||
export { normalizeLineEndings, restoreLineEndings }
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown>
|
||||
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<string, unknown>).diffs
|
||||
if (!Array.isArray(diffs) || diffs.length === 0 || !diffs.every(isFileDiff)) return undefined
|
||||
return diffs
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user