feat(tool-fs): result-time applied-hunk diffs for write/edit

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.
This commit is contained in:
Tianyi Cui
2026-07-03 17:12:00 +08:00
parent af79ceea1c
commit d8fd3225af
48 changed files with 2217 additions and 1073 deletions

View File

@@ -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.

View File

@@ -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)

View File

@@ -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'

View File

@@ -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

View File

@@ -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 }
/**

View File

@@ -24,7 +24,7 @@ Tool registry and execution waterfall. Tool plugins register their schemas and e
### Key types
- `ToolDefinition``ToolSchema` + `execute(args, exec): Promise<ContentBlock[]>`, plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below).
- `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).
- `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<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`); `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<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'

View File

@@ -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"
}

View File

@@ -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<ContentBlock[]>
execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn>
/**
* 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)
}

View File

@@ -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<S extends SchemaSpec> {
parameters: S
/**
* Tool execution function. `args` is typed as {@link InferArgs<S>} — 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<S>, exec: ToolExecution): Promise<ContentBlock[]>
execute(args: InferArgs<S>, exec: ToolExecution): Promise<ToolExecuteReturn>
/**
* 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<S extends SchemaSpec>(options: DefineToolOptions<S>):
description: options.description,
parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record<string, unknown>,
...options.strict !== undefined ? { strict: options.strict } : {},
async execute(args: unknown, exec: ToolExecution): Promise<ContentBlock[]> {
async execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn> {
// 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

View File

@@ -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({

View File

@@ -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<string | null> {
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

View File

@@ -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,
}
})
}

View File

@@ -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')

View File

@@ -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
}
/**

View File

@@ -39,14 +39,15 @@ class FakeFileSystem extends FileSystem {
return (async function* () { yield content })()
}
override async writeText(target: FsTarget, content: string, _expected?: FsWriteIntent): Promise<FsWriteOutcome> {
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<FsEditOutcome> {
const content = this.files.get(target.targetKey) ?? ''
this.files.set(target.targetKey, content.split(edit.oldString).join(edit.newString))
return { replacements: 1, replaceAll: edit.replaceAll, version: FsVersion('v3') }
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 }
}
}

View File

@@ -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"

View File

@@ -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
}

View File

@@ -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<ContentBlock[]> {
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 }
},
}))
}

View File

@@ -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'

View File

@@ -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<ContentBlock[]> {
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 }
},
}))
}

View File

@@ -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()
})
})

View File

@@ -57,16 +57,17 @@ class FakeFs extends FileSystem {
override async writeText(target: FsTarget, content: string, expected?: FsWriteIntent): Promise<FsWriteOutcome> {
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<FsEditOutcome> {
this.throwIfArmed()
this.editIntents.push(expected)
const content = this.files.get(target.targetKey) ?? ''
this.files.set(target.targetKey, content.split(edit.oldString).join(edit.newString))
return { replacements: 1, replaceAll: edit.replaceAll, version: FsVersion('v3') }
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()
})
})

View File

@@ -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.

View File

@@ -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')
}

View File

@@ -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<Context> {
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/