Merge branch 'worktree-hooks-d-subagent' into worktree-hooks-e-protocol
# Conflicts: # docs/module-graph.md # pnpm-lock.yaml
This commit is contained in:
@@ -11,8 +11,10 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
|
||||
| [`core/`](core/README.md) | Product API spine: session, system-prompt, tools, agent, and the concrete loop | Product — stable surface |
|
||||
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
|
||||
| [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface |
|
||||
| [`fs/`](fs/README.md) | Filesystem capability family: the abstract seam, a local impl, and the model-facing file tools | Product — stable surface |
|
||||
| [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface |
|
||||
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface |
|
||||
| [`web/`](web/README.md) | Web capability family: the abstract seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface |
|
||||
| [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool (whole-list task tracking on the session log) | Product — stable surface |
|
||||
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
|
||||
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
|
||||
@@ -36,6 +38,16 @@ dsh-compact-basic ← dsh-compact, dsh-session, dsh-llm, dsh-agent (char/4 + to
|
||||
dsh-tools ← dsh-llm, dsh-system-prompt, dsh-agent
|
||||
dsh-bash-local ← dsh-bash (BashExecutor impl)
|
||||
dsh-tool-bash ← dsh-bash, dsh-tools (bash tool schemas)
|
||||
dsh-fs ← dsh-llm, dsh-brand (filesystem provider seam + fs/* events)
|
||||
dsh-fs-local ← dsh-fs (FileSystem impl)
|
||||
dsh-fs-policy ← dsh-fs (observed-state + freshness policy gate, no service)
|
||||
dsh-tool-fs ← dsh-fs, dsh-tools (file tools + executor)
|
||||
dsh-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)
|
||||
dsh-llm-pi-ai ← dsh-llm (pi-ai-backed adapter)
|
||||
dsh-agent-loop ← dsh-llm, dsh-session, dsh-session-persistence, dsh-system-prompt, dsh-tools, dsh-agent
|
||||
@@ -72,8 +84,18 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop
|
||||
| `bash/` | `bash` | Abstract bash executor seam (interface + vocabulary) | `ctx.bash` |
|
||||
| `bash-local/` | `bash` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) |
|
||||
| `tool-bash/` | `bash` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) |
|
||||
| `fs/` | `fs` | Filesystem provider seam: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` events | `ctx.fs` |
|
||||
| `fs-local/` | `fs` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) |
|
||||
| `fs-policy/` | `fs` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit via the `fs/*` event gate | (no service — `fs/*` listeners) |
|
||||
| `tool-fs/` | `fs` | Model-facing `read`/`write`/`edit` tools + executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`) | (registers on `ctx.tools`) |
|
||||
| `compact/` | `compact` | Abstract compaction seam + `compact/*` events + `CompactionResult` | `ctx.compact` |
|
||||
| `compact-basic/` | `compact` | A backend: char/4 estimation + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) |
|
||||
| `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`) |
|
||||
| `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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -781,6 +781,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] })
|
||||
// Buffer (don't append yet) any post-execute additionalContext for this call.
|
||||
if (result.additionalContext) pendingContext.push(result.additionalContext)
|
||||
|
||||
@@ -115,6 +115,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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -231,7 +231,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 (`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?: unknown }
|
||||
/** Steering content injected between steps of a running turn. */
|
||||
'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource }
|
||||
/**
|
||||
|
||||
@@ -8,7 +8,7 @@ Tool registry and execution pipeline. Tool plugins register their schemas and ex
|
||||
|
||||
- `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<ToolExecutionResult>` Execute one tool call through the `tools/pre-execute` → dispatch → `tools/post-execute` pipeline.
|
||||
|
||||
### Injected services
|
||||
@@ -25,12 +25,12 @@ Tool registry and execution pipeline. Tool plugins register their schemas and ex
|
||||
|
||||
### 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?, additionalContext? }`. 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). `additionalContext` (a `HookContext`) ferries any `tools/post-execute` context up to the loop, which buffers it and appends it as a `context/message` after all `tool/result`s in the step.
|
||||
- `ToolExecutionResult` — outcome: `{ callId, content, isError, error?, additionalContext?, meta? }`. 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). `additionalContext` (a `HookContext`) ferries any `tools/post-execute` context up to the loop, which buffers it and appends it as a `context/message` after all `tool/result`s in the step. `meta` is the tool's opaque presentation payload from a successful `execute` (the object return form); the loop forwards it onto the `tool/result` session event for result-card rendering.
|
||||
- `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite (changing `arguments`) is deliberately NOT offered (it would desync the pre-execution audit/history/UI from what ran — its own proposed RFC); `ask` degrades to `deny` until the permission system lands.
|
||||
- `PostToolDecision` — `{kind:'accept', content?, additionalContext?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContext?}` (turn it into an `isError` whose content is the corrective feedback). Output replacement is clean because `tool/result` is logged AFTER `execute()` returns.
|
||||
- `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
|
||||
|
||||
@@ -73,12 +73,18 @@ 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), 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, 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[]` — 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. With `defineTool`, `args` is the typed `InferArgs<S>` shape; the helper soft-validates before calling (a malformed/older logged arg shape yields `undefined` rather than throwing, since display must never crash a replay). The shapes are provider-neutral — the ACP bridge (`dsh-acp`) maps them to ACP `tool_call`/`tool_call_update` wire fields, and `dsh-tool-bash` is the reference implementation.
|
||||
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'
|
||||
@@ -93,13 +99,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 }
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { ToolCallView, ToolResultView } from './presentation.ts'
|
||||
|
||||
export {
|
||||
defineTool,
|
||||
@@ -27,6 +28,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
|
||||
@@ -73,147 +91,37 @@ declare module 'cordis' {
|
||||
// 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.
|
||||
* 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 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 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.
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export interface ToolCallPresentation {
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
content?: ContentBlock[]
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export interface ToolTerminal {
|
||||
/**
|
||||
* Working directory the command ran 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.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
export interface ToolResultPresentation {
|
||||
/** Replacement title for the completed call (e.g. append an exit status). 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[]
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
terminal?: ToolTerminal
|
||||
}
|
||||
export type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unknown }
|
||||
|
||||
/** 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 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}. */
|
||||
@@ -222,6 +130,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 (`unknown`); the tool narrows it back to its own shape. Absent when
|
||||
* the tool attached none.
|
||||
*/
|
||||
meta?: unknown
|
||||
}
|
||||
|
||||
/** One pending tool call, as it flows through the execution pipeline (`tools/pre-execute` → dispatch → `tools/post-execute`). */
|
||||
@@ -265,6 +180,7 @@ export interface ToolExecutionResult {
|
||||
* text in `content` is always present; this is extra structure for code.
|
||||
*/
|
||||
error?: ToolErrorInfo
|
||||
/**
|
||||
/**
|
||||
* Extra model-facing context a `tools/post-execute` listener attached for the
|
||||
* NEXT request (Claude Code's PostToolUse `additionalContext`). It is NOT part
|
||||
@@ -276,6 +192,13 @@ export interface ToolExecutionResult {
|
||||
* from `execute()` up to the loop's per-step buffer.
|
||||
*/
|
||||
additionalContext?: HookContext
|
||||
/**
|
||||
* 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 (`unknown`); absent when the
|
||||
* tool attached none or the call failed.
|
||||
*/
|
||||
meta?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -442,8 +365,13 @@ export class ToolRegistry extends Service {
|
||||
try {
|
||||
const tool = this.store.get(exec.name)
|
||||
if (!tool) throw new ToolNotFoundError(exec.name)
|
||||
const content = await tool.execute(exec.arguments, exec)
|
||||
result = { 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
|
||||
result = { callId: exec.callId, content, isError: false, ...meta !== undefined ? { meta } : {} }
|
||||
} catch (error: unknown) {
|
||||
result = toolErrorResult(exec.callId, error)
|
||||
}
|
||||
@@ -480,6 +408,7 @@ export class ToolRegistry extends Service {
|
||||
content: [...result.content],
|
||||
isError: result.isError,
|
||||
...result.error ? { error: result.error } : {},
|
||||
...result.meta !== undefined ? { meta: result.meta } : {},
|
||||
}
|
||||
const decision = await this.ctx.waterfall(
|
||||
this, 'tools/post-execute', exec, result,
|
||||
|
||||
206
packages/core/tools/src/presentation.ts
Normal file
206
packages/core/tools/src/presentation.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Tool render-intent vocabulary: the provider-neutral types a tool declares via
|
||||
* `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.
|
||||
*
|
||||
* 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 `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
|
||||
* `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[]
|
||||
}
|
||||
@@ -19,9 +19,9 @@
|
||||
* @module dsh-tools/schema
|
||||
*/
|
||||
|
||||
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 { ToolDefinition, ToolExecuteReturn, ToolExecution, ToolResult } from './index.ts'
|
||||
import type { ToolCallView, ToolResultView } from './presentation.ts'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SchemaSpec — the author-facing per-property type
|
||||
@@ -291,25 +291,27 @@ 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
|
||||
* 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<S>): ToolCallPresentation | undefined
|
||||
presentCall?(args: InferArgs<S>): 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<S>, result: ToolResult): ToolResultPresentation | undefined
|
||||
presentResult?(args: InferArgs<S>, result: ToolResult): ToolResultView | undefined
|
||||
/** Whether the tool requires structured output (default false). */
|
||||
strict?: boolean
|
||||
}
|
||||
@@ -354,7 +356,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
|
||||
@@ -369,13 +371,13 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
|
||||
// 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<S>)
|
||||
}
|
||||
}
|
||||
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<S>, result)
|
||||
}
|
||||
|
||||
117
packages/core/tools/tests/gen-tool-catalog.spec.ts
Normal file
117
packages/core/tools/tests/gen-tool-catalog.spec.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 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<string, JsonSchema>
|
||||
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', 'edit', 'read', 'subagent', 'todo_write', 'web_fetch', 'web_search', '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')
|
||||
})
|
||||
|
||||
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', () => {
|
||||
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`')
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>
|
||||
expect(Object.keys(schema).sort()).toEqual(['description', 'name', 'parameters'])
|
||||
@@ -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({
|
||||
@@ -1019,15 +1051,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)', () => {
|
||||
@@ -1047,8 +1079,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
|
||||
|
||||
12
packages/fs/README.md
Normal file
12
packages/fs/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# fs/ - filesystem capability family
|
||||
|
||||
The filesystem stack: a provider seam (text IO + atomic mutation with an optional version guard), a local implementation, a policy gate plugin (observed-state + read-before-edit + version-guarded write/edit), and the model-facing file tools + executor. All **product** packages.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `fs/` | Provider seam: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` policy events | `ctx.fs` |
|
||||
| `fs-local/` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) |
|
||||
| `fs-policy/` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit, via the `fs/*` event gate | (no service — `fs/*` listeners) |
|
||||
| `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`) | (registers on `ctx.tools`) |
|
||||
|
||||
The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas. The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it.
|
||||
26
packages/fs/fs-local/README.md
Normal file
26
packages/fs/fs-local/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# @deepseek-ai/dsh-fs-local
|
||||
|
||||
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'
|
||||
|
||||
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
// ctx.fs is now the local backend; load @deepseek-ai/dsh-fs-policy for the
|
||||
// freshness policy gate and @deepseek-ai/dsh-tool-fs to expose read/write/edit.
|
||||
```
|
||||
|
||||
## Behavior
|
||||
|
||||
- **`resolve(path, opts?)`** — a relative `path` resolves against `opts.cwd` when the caller supplies one (the model-facing tools pass the calling agent's session cwd — see [the per-session cwd RFC](../../../docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md)), else `config.cwd` (default `process.cwd()`); an absolute `path` ignores both. The `targetKey` is the file's `realpath`, so two input paths reaching the same file through symlinks share one identity, and writes/edits land on the link target (preserving the link). A not-yet-existing path uses the realpathed parent directory plus basename when the parent exists; only an unresolvable parent falls back to the absolute path. `displayPath` is the absolute (un-resolved) path.
|
||||
- **`stat`** — returns `FsInfo` (`version` = `mtimeMs:size`, `type` of `file`/`directory`/`other`, byte `size`) or `undefined` when the target is absent.
|
||||
- **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` streams it in chunks (cross-chunk decoding) so a huge file never has to be held whole in memory. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) decides which to call by size and owns the line windowing.
|
||||
- **`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`).
|
||||
|
||||
## `cwd` is not a sandbox
|
||||
|
||||
`config.cwd` is a resolution default, not a containment boundary — absolute paths and `..` escape it. Enforce containment with a stricter `ctx.fs` backend or a permission plugin on the `tools/execute` waterfall. See [the filesystem capability-seam RFC's Risks section](../../../docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md#risks).
|
||||
|
||||
The raw I/O lives in `src/fsio.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the thin service wiring.
|
||||
36
packages/fs/fs-local/package.json
Normal file
36
packages/fs/fs-local/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-fs-local",
|
||||
"description": "Local-filesystem implementation of the DeepSeek Harness filesystem seam (ctx.fs)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-fs": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
508
packages/fs/fs-local/src/fsio.ts
Normal file
508
packages/fs/fs-local/src/fsio.ts
Normal file
@@ -0,0 +1,508 @@
|
||||
/**
|
||||
* Cordis-free local-filesystem I/O for `@deepseek-ai/dsh-fs-local`. Kept
|
||||
* separate from the service class (mirroring `dsh-bash-local`'s `run.ts`) so
|
||||
* the raw stat/read/write/edit mechanics can be unit-tested without a Context.
|
||||
*
|
||||
* This is the PROVIDER layer: it hands back decoded whole-file text (validated
|
||||
* UTF-8, binary rejected) — never line windows or numbered lines, which are
|
||||
* model-facing read policy owned by `@deepseek-ai/dsh-fs-policy`. Large files
|
||||
* stream their text in chunks so a huge file never has to be held whole in
|
||||
* memory; the binary/NUL sample and cross-chunk UTF-8 decoding stay here.
|
||||
*
|
||||
* Writes are atomic: content goes to a temp file opened exclusively (`wx`,
|
||||
* `0o600`, so a pre-existing path can never be clobbered and write-in-progress
|
||||
* bytes stay owner-only) inside a randomly-named private staging directory
|
||||
* (`0o700`) next to the target, then `rename`d over the target. Edits are
|
||||
* read-modify-write over the same atomic primitive.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-fs-local/fsio
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { chmod, mkdir, open, readFile, realpath, 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'
|
||||
|
||||
/** Files at or above this size stream their text; smaller files read whole. */
|
||||
export const STREAM_MIN_SIZE = 10 * 1024 * 1024
|
||||
|
||||
const BINARY_SAMPLE_BYTES = 8192
|
||||
|
||||
function isENOENT(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === 'ENOENT'
|
||||
}
|
||||
|
||||
/**
|
||||
* A path component that is expected to be a directory is a regular file (e.g.
|
||||
* resolving `afile/child.txt` when `afile` is a file). Like `ENOENT`, the target
|
||||
* cannot exist — so the resolution/probe paths treat it as "absent" rather than
|
||||
* letting a raw Node error escape without the structured `FsError` taxonomy.
|
||||
*/
|
||||
function isENOTDIR(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === 'ENOTDIR'
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === 'AbortError'
|
||||
}
|
||||
|
||||
/* v8 ignore start -- composes secondary cleanup-failure messages, which require a filesystem/kernel fault after the primary failure. */
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
function isPermissionError(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && (error.code === 'EACCES' || error.code === 'EPERM')
|
||||
}
|
||||
|
||||
function throwIfAborted(signal: AbortSignal | undefined, verb: string): void {
|
||||
if (signal?.aborted) throw new FsError(`${verb} aborted`, 'FS_ABORTED')
|
||||
}
|
||||
|
||||
/**
|
||||
* `readFile` with the supplied signal, translating a mid-read `AbortError` into
|
||||
* the seam's structured `FsError('FS_ABORTED')` (Node rejects an aborted
|
||||
* `readFile` with a bare `AbortError`, which would otherwise escape the seam's
|
||||
* error taxonomy — the streaming/write paths translate it the same way).
|
||||
*/
|
||||
async function readFileAbortable(absolutePath: string, verb: 'read' | 'edit', signal?: AbortSignal): Promise<Buffer> {
|
||||
try {
|
||||
return await readFile(absolutePath, signal ? { signal } : {})
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next 2 -- a non-abort readFile rejection needs a permission/IO fault racing an open file. */
|
||||
if (!isAbortError(error)) throw error
|
||||
throw new FsError(`${verb} aborted`, 'FS_ABORTED')
|
||||
}
|
||||
}
|
||||
|
||||
/** Opaque version token from a stat: mtime (ns precision) + size. */
|
||||
function versionOf(info: Stats): FsVersion {
|
||||
return FsVersion(`${info.mtimeMs}:${info.size}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seam: lets specs force the streaming read path (via a small
|
||||
* `streamMinSize`) and pin the temp-file name (to prove exclusive-open
|
||||
* behavior) without a 10 MB fixture or a name race.
|
||||
*/
|
||||
export interface FsIoInternals {
|
||||
/** Override {@link STREAM_MIN_SIZE} for read routing. */
|
||||
streamMinSize?: number
|
||||
/** Override the generated private staging-dir name (relative to the target dir). */
|
||||
tempDirName?: (writePath: string) => string
|
||||
/** Override the generated temp-file name (relative to the private staging dir). */
|
||||
tempName?: (writePath: string) => string
|
||||
/** Test hook after the temp file is written/synced but before final chmod+rename. */
|
||||
inspectTemp?: (paths: { stagingDir: string; tempPath: string }) => void | Promise<void>
|
||||
}
|
||||
|
||||
/** A resolved local path: the absolute path shown to callers and its realpath identity. */
|
||||
export interface LocalTarget {
|
||||
/** Absolute path (symlinks not resolved) — used for display. */
|
||||
displayPath: string
|
||||
/** Realpath identity — used as the stable target key and the I/O path. */
|
||||
targetKey: FsTargetKey
|
||||
}
|
||||
|
||||
/** Result of probing a path: null when it does not exist. */
|
||||
export interface PathInfo {
|
||||
version: FsVersion
|
||||
mode: number
|
||||
type: 'file' | 'directory' | 'other'
|
||||
size: number
|
||||
}
|
||||
|
||||
/** 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
|
||||
* `targetKey` realpaths the nearest EXISTING ancestor directory and re-appends
|
||||
* the still-missing suffix, so a not-yet-created file gets the same stable key
|
||||
* it will have after creation — even when an ancestor (e.g. `cwd`) is a symlink
|
||||
* and intermediate directories are created by the write. Two input paths
|
||||
* reaching the same file via symlinks share one key. Falls back to the absolute
|
||||
* path only when no ancestor (not even the filesystem root) can be resolved.
|
||||
*/
|
||||
export async function resolveLocalTarget(cwd: string, path: string): Promise<LocalTarget> {
|
||||
if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND')
|
||||
const displayPath = resolve(cwd, path)
|
||||
try {
|
||||
// Prefer the file's own realpath (resolves a symlinked file to its target).
|
||||
return { displayPath, targetKey: FsTargetKey(await realpath(displayPath)) }
|
||||
} catch (error: unknown) {
|
||||
// A path component is a file, not a directory (e.g. "afile/child.txt" where
|
||||
// "afile" is a regular file): the target can neither exist nor be created,
|
||||
// so surface the structured taxonomy instead of a raw Node ENOTDIR.
|
||||
if (isENOTDIR(error)) throw new FsError(`cannot resolve "${displayPath}": a parent path segment is not a directory`, 'FS_NOT_FOUND')
|
||||
/* v8 ignore next -- non-ENOENT realpath failure needs a permission/IO fault; ENOENT falls through to ancestor resolution. */
|
||||
if (!isENOENT(error)) throw error
|
||||
}
|
||||
// File absent: realpath the nearest existing ancestor and re-append the
|
||||
// missing suffix (the file basename plus any not-yet-created intermediate
|
||||
// dirs), so the key is stable across creation of those dirs.
|
||||
const missing = [basename(displayPath)]
|
||||
let ancestor = dirname(displayPath)
|
||||
while (true) {
|
||||
try {
|
||||
const realAncestor = await realpath(ancestor)
|
||||
return { displayPath, targetKey: FsTargetKey(join(realAncestor, ...missing)) }
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next -- a non-ENOENT realpath failure needs a permission/IO fault. */
|
||||
if (!isENOENT(error)) throw error
|
||||
const parent = dirname(ancestor)
|
||||
/* v8 ignore next -- the filesystem root always realpaths, so the walk terminates before parent === ancestor. */
|
||||
if (parent === ancestor) return { displayPath, targetKey: FsTargetKey(displayPath) }
|
||||
missing.unshift(basename(ancestor))
|
||||
ancestor = parent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Probe a path for its version, mode, type, and size. Null if absent. */
|
||||
export async function probe(absolutePath: string): Promise<PathInfo | null> {
|
||||
try {
|
||||
const info = await stat(absolutePath)
|
||||
const type = info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other'
|
||||
return { version: versionOf(info), mode: info.mode & 0o777, type, size: info.size }
|
||||
} catch (error: unknown) {
|
||||
// ENOENT (no such file) and ENOTDIR (a parent segment is a file) both mean
|
||||
// the target is absent; any other stat failure is a real permission/IO fault.
|
||||
/* v8 ignore next -- a non-ENOENT/ENOTDIR stat failure needs a permission/IO fault; surface it. */
|
||||
if (!isENOENT(error) && !isENOTDIR(error)) throw error
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// --- Directory listing ---
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
async function resolveListedChildTarget(parent: LocalTarget, name: string): Promise<LocalTarget> {
|
||||
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
|
||||
* never read.
|
||||
*/
|
||||
export async function listDirectory(target: LocalTarget, signal?: AbortSignal): Promise<LocalDirEntry[]> {
|
||||
throwIfAborted(signal, 'list')
|
||||
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')
|
||||
|
||||
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')
|
||||
|
||||
const result: LocalDirEntry[] = []
|
||||
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
throwIfAborted(signal, 'list')
|
||||
try {
|
||||
const childTarget = await resolveListedChildTarget(target, entry.name)
|
||||
const childInfo = await probe(childTarget.targetKey)
|
||||
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 ---
|
||||
|
||||
function notTextError(verb: 'read' | 'edit', displayPath: string): FsError {
|
||||
return new FsError(`cannot ${verb} "${displayPath}": invalid UTF-8 text`, 'FS_NOT_TEXT')
|
||||
}
|
||||
|
||||
function decodeUtf8(buffer: Uint8Array, verb: 'read' | 'edit', displayPath: string): string {
|
||||
try {
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(buffer)
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes; any other throw is an unreachable runtime fault. */
|
||||
if (!(error instanceof TypeError)) throw error
|
||||
throw notTextError(verb, displayPath)
|
||||
}
|
||||
}
|
||||
|
||||
function decodeUtf8Stream(
|
||||
decoder: TextDecoder,
|
||||
chunk: Uint8Array | undefined,
|
||||
verb: 'read' | 'edit',
|
||||
displayPath: string,
|
||||
): string {
|
||||
try {
|
||||
return chunk ? decoder.decode(chunk, { stream: true }) : decoder.decode()
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes; any other throw is an unreachable runtime fault. */
|
||||
if (!(error instanceof TypeError)) throw error
|
||||
throw notTextError(verb, displayPath)
|
||||
}
|
||||
}
|
||||
|
||||
async function statRegularFile(target: LocalTarget, verb: 'read', signal?: AbortSignal): Promise<Stats> {
|
||||
throwIfAborted(signal, verb)
|
||||
let info: Stats
|
||||
try {
|
||||
info = await stat(target.targetKey)
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next 2 -- a non-ENOENT stat failure needs a permission/IO fault; only the not-found path is reachable in tests. */
|
||||
if (!isENOENT(error)) throw error
|
||||
throw new FsError(`cannot ${verb} "${target.displayPath}": not found`, 'FS_NOT_FOUND')
|
||||
}
|
||||
if (!info.isFile()) throw new FsError(`cannot ${verb} "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
|
||||
return info
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a whole regular UTF-8 text file into a single decoded string. Rejects
|
||||
* non-regular files, invalid UTF-8, and NUL-byte binary samples.
|
||||
*/
|
||||
export async function readWholeText(target: LocalTarget, signal?: AbortSignal): Promise<string> {
|
||||
await statRegularFile(target, 'read', signal)
|
||||
const raw = await readFileAbortable(target.targetKey, 'read', signal)
|
||||
throwIfAborted(signal, 'read')
|
||||
if (raw.subarray(0, BINARY_SAMPLE_BYTES).includes(0)) {
|
||||
throw new FsError(`cannot read "${target.displayPath}": binary file`, 'FS_NOT_TEXT')
|
||||
}
|
||||
return decodeUtf8(raw, 'read', target.displayPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a whole regular UTF-8 text file as decoded text chunks. Same text
|
||||
* semantics as {@link readWholeText} (regular-file check, binary/NUL rejection,
|
||||
* cross-chunk UTF-8 decoding), but never holds the whole file in memory.
|
||||
*/
|
||||
export async function* streamWholeText(target: LocalTarget, signal?: AbortSignal): AsyncIterable<string> {
|
||||
await statRegularFile(target, 'read', signal)
|
||||
const stream = createReadStream(target.targetKey, signal ? { signal } : {})
|
||||
const decoder = new TextDecoder('utf-8', { fatal: true })
|
||||
let sampledBytes = 0
|
||||
|
||||
function scanBinarySample(chunk: Buffer): void {
|
||||
if (sampledBytes >= BINARY_SAMPLE_BYTES) return
|
||||
const sample = chunk.subarray(0, Math.min(chunk.length, BINARY_SAMPLE_BYTES - sampledBytes))
|
||||
if (sample.includes(0)) {
|
||||
throw new FsError(`cannot read "${target.displayPath}": binary file`, 'FS_NOT_TEXT')
|
||||
}
|
||||
sampledBytes += sample.length
|
||||
}
|
||||
|
||||
try {
|
||||
for await (const chunk of stream as AsyncIterable<Buffer>) {
|
||||
scanBinarySample(chunk)
|
||||
yield decodeUtf8Stream(decoder, chunk, 'read', target.displayPath)
|
||||
}
|
||||
yield decodeUtf8Stream(decoder, undefined, 'read', target.displayPath)
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next 4 -- mid-stream errors need an abort/IO fault racing the loop; pre-abort is caught by throwIfAborted. */
|
||||
if (isAbortError(error)) throw new FsError('read aborted', 'FS_ABORTED')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// --- Writing ---
|
||||
|
||||
async function removeStagingDirOrThrow(stagingDir: string, originalError: unknown): Promise<never> {
|
||||
try {
|
||||
await rm(stagingDir, { recursive: true, force: true })
|
||||
} catch (cleanupError: unknown) {
|
||||
/* v8 ignore next 1 -- cleanup failure here needs a second filesystem fault after the primary write failure. */
|
||||
throw new FsError(`write failed (${errorMessage(originalError)}) and temp cleanup failed (${errorMessage(cleanupError)})`, 'FS_NOT_FOUND', { cause: originalError })
|
||||
}
|
||||
throw originalError
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically write `content` to `absolutePath`: create parent dirs, write to a
|
||||
* randomly-named temp file opened exclusively (`wx`, `0o600`) inside a private
|
||||
* (`0o700`) staging directory, fsync, optionally chmod to the final mode while
|
||||
* still private, then rename over the target. `mode` (when given) preserves an
|
||||
* existing file's permissions across the replace.
|
||||
*/
|
||||
export async function writeFileAtomic(
|
||||
absolutePath: string,
|
||||
content: string,
|
||||
mode: number | undefined,
|
||||
signal: AbortSignal | undefined,
|
||||
internals: FsIoInternals = {},
|
||||
): Promise<void> {
|
||||
throwIfAborted(signal, 'write')
|
||||
const directory = dirname(absolutePath)
|
||||
await mkdir(directory, { recursive: true })
|
||||
|
||||
throwIfAborted(signal, 'write')
|
||||
const stagingDirName = internals.tempDirName?.(absolutePath) ?? `.${basename(absolutePath)}.${process.pid}.${randomUUID()}.tmpdir`
|
||||
const stagingDir = join(directory, stagingDirName)
|
||||
const tempName = internals.tempName?.(absolutePath) ?? `${basename(absolutePath)}.tmp`
|
||||
const tempPath = join(stagingDir, tempName)
|
||||
let handle: Awaited<ReturnType<typeof open>> | undefined
|
||||
let stagingCreated = false
|
||||
try {
|
||||
await mkdir(stagingDir, { mode: 0o700 })
|
||||
stagingCreated = true
|
||||
await chmod(stagingDir, 0o700)
|
||||
|
||||
handle = await open(tempPath, 'wx', 0o600)
|
||||
await handle.chmod(0o600)
|
||||
await handle.writeFile(content, { encoding: 'utf8', ...signal ? { signal } : {} })
|
||||
await handle.sync()
|
||||
await internals.inspectTemp?.({ stagingDir, tempPath })
|
||||
if (mode !== undefined) await handle.chmod(mode)
|
||||
await handle.close()
|
||||
handle = undefined
|
||||
|
||||
throwIfAborted(signal, 'write')
|
||||
await rename(tempPath, absolutePath)
|
||||
await rm(stagingDir, { recursive: true, force: true })
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next -- abort-mid-write needs a writeFile/signal race; the non-abort (rename/open) side is tested. */
|
||||
let failure: unknown = isAbortError(error) ? new FsError('write aborted', 'FS_ABORTED') : error
|
||||
/* v8 ignore next 8 -- reached only if writeFile/sync throws with the handle open (IO fault); close-failure is a double fault. */
|
||||
if (handle) {
|
||||
try {
|
||||
await handle.close()
|
||||
} catch (closeError: unknown) {
|
||||
failure = new FsError(`write failed (${errorMessage(failure)}) and temp close failed (${errorMessage(closeError)})`, 'FS_NOT_FOUND', { cause: failure })
|
||||
}
|
||||
}
|
||||
if (!stagingCreated) throw failure
|
||||
return removeStagingDirOrThrow(stagingDir, failure)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Editing ---
|
||||
|
||||
/** Line ending style detected before LF normalization. */
|
||||
export type LineEndings = 'LF' | 'CRLF'
|
||||
|
||||
function normalizeLineEndings(content: string): string {
|
||||
return content.replaceAll('\r\n', '\n')
|
||||
}
|
||||
|
||||
function detectLineEndings(raw: string): LineEndings {
|
||||
const sample = raw.slice(0, 4096)
|
||||
const crlfCount = sample.split('\r\n').length - 1
|
||||
const lfCount = sample.split('\n').length - 1 - crlfCount
|
||||
return crlfCount > lfCount ? 'CRLF' : 'LF'
|
||||
}
|
||||
|
||||
function restoreLineEndings(content: string, lineEndings: LineEndings): string {
|
||||
return lineEndings === 'LF' ? content : normalizeLineEndings(content).split('\n').join('\r\n')
|
||||
}
|
||||
|
||||
function countOccurrences(content: string, needle: string): number {
|
||||
let count = 0
|
||||
let index = 0
|
||||
while (true) {
|
||||
const found = content.indexOf(needle, index)
|
||||
if (found === -1) return count
|
||||
count += 1
|
||||
index = found + needle.length
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and decode a file for editing: rejects binaries, returns LF-normalized
|
||||
* content plus the original line-ending style for write-back.
|
||||
*/
|
||||
export async function readForEdit(
|
||||
absolutePath: string,
|
||||
displayPath: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ content: string; lineEndings: LineEndings }> {
|
||||
throwIfAborted(signal, 'edit')
|
||||
const buffer = await readFileAbortable(absolutePath, 'edit', signal)
|
||||
throwIfAborted(signal, 'edit')
|
||||
if (buffer.includes(0)) throw new FsError(`cannot edit "${displayPath}": binary file`, 'FS_NOT_TEXT')
|
||||
const raw = decodeUtf8(buffer, 'edit', displayPath)
|
||||
return { content: normalizeLineEndings(raw), lineEndings: detectLineEndings(raw) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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-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<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
|
||||
* `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 { normalizeLineEndings, restoreLineEndings }
|
||||
233
packages/fs/fs-local/src/index.ts
Normal file
233
packages/fs/fs-local/src/index.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Local-filesystem implementation of the `ctx.fs` provider seam.
|
||||
* {@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
|
||||
* paths reaching the same file through symlinks share one key, and writes land
|
||||
* on the link target — preserving the link).
|
||||
*
|
||||
* Future sandboxed/remote/virtual backends are sibling packages implementing
|
||||
* the same interface; loading this one populates `ctx.fs`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-fs-local
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { FileSystem, FsError, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type {
|
||||
FsDirEntry,
|
||||
FsEditOutcome,
|
||||
FsEditRequest,
|
||||
FsInfo,
|
||||
FsTarget,
|
||||
FsWriteIntent,
|
||||
FsWriteOutcome,
|
||||
} from '@deepseek-ai/dsh-fs'
|
||||
import {
|
||||
applyLiteralEdit,
|
||||
listDirectory,
|
||||
normalizeLineEndings,
|
||||
probe,
|
||||
readForEdit,
|
||||
readTextForDiff,
|
||||
readWholeText,
|
||||
resolveLocalTarget,
|
||||
restoreLineEndings,
|
||||
streamWholeText,
|
||||
writeFileAtomic,
|
||||
} from './fsio.ts'
|
||||
import type { FsIoInternals } from './fsio.ts'
|
||||
|
||||
export {
|
||||
STREAM_MIN_SIZE,
|
||||
applyLiteralEdit,
|
||||
listDirectory,
|
||||
probe,
|
||||
readForEdit,
|
||||
readTextForDiff,
|
||||
readWholeText,
|
||||
resolveLocalTarget,
|
||||
restoreLineEndings,
|
||||
streamWholeText,
|
||||
writeFileAtomic,
|
||||
} from './fsio.ts'
|
||||
export type { FsIoInternals, LineEndings, LocalDirEntry, LocalTarget, PathInfo } from './fsio.ts'
|
||||
|
||||
/** Configuration for the local filesystem backend. */
|
||||
export interface Config {
|
||||
/** Base directory for relative paths. Defaults to `process.cwd()`. */
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
type ResolvedConfig = Required<Config>
|
||||
|
||||
/**
|
||||
* The host-filesystem backend. Reads resolve relative paths from {@link Config.cwd}
|
||||
* (a resolution default, NOT a containment boundary — see the filesystem
|
||||
* capability-seam RFC); enforce
|
||||
* containment with a stricter backend or a `tools/execute` permission plugin.
|
||||
*/
|
||||
export class LocalFileSystem extends FileSystem {
|
||||
static Config: z<Config> = z.object({
|
||||
cwd: z.string().default(process.cwd()),
|
||||
})
|
||||
|
||||
readonly config: ResolvedConfig
|
||||
/** Test seam forwarded to fsio (force streaming path, pin temp names). */
|
||||
internals: FsIoInternals = {}
|
||||
/** Per-targetKey tail promise: serializes mutating ops so the read→guard→write
|
||||
* window can't interleave, making concurrent writes/edits deterministically
|
||||
* ordered (one wins, the rest see the new version and reject as stale). */
|
||||
private locks = new Map<string, Promise<unknown>>()
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx)
|
||||
this.config = config as ResolvedConfig
|
||||
}
|
||||
|
||||
/** Run `op` with exclusive access to `targetKey` (FIFO per key). */
|
||||
private async withLock<T>(targetKey: string, op: () => Promise<T>): Promise<T> {
|
||||
const prior = this.locks.get(targetKey) ?? Promise.resolve()
|
||||
const run = prior.then(op, op)
|
||||
// Keep the chain alive but swallow this op's result/throw for the *next* waiter.
|
||||
const tail = run.then(() => undefined, () => undefined)
|
||||
this.locks.set(targetKey, tail)
|
||||
try {
|
||||
return await run
|
||||
} finally {
|
||||
if (this.locks.get(targetKey) === tail) {
|
||||
this.locks.delete(targetKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override async resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget> {
|
||||
const local = await resolveLocalTarget(opts?.cwd ?? this.config.cwd, path)
|
||||
return { inputPath: path, targetKey: local.targetKey, displayPath: local.displayPath }
|
||||
}
|
||||
|
||||
override async stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> {
|
||||
if (signal?.aborted) throw new FsError('stat aborted', 'FS_ABORTED')
|
||||
const info = await probe(target.targetKey)
|
||||
if (!info) return undefined
|
||||
return { version: info.version, type: info.type, size: info.size }
|
||||
}
|
||||
|
||||
override async readText(target: FsTarget, signal?: AbortSignal): Promise<string> {
|
||||
return readWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal)
|
||||
}
|
||||
|
||||
override streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> {
|
||||
return Promise.resolve(streamWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal))
|
||||
}
|
||||
|
||||
override async listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]> {
|
||||
const entries = await listDirectory({ displayPath: target.displayPath, targetKey: target.targetKey }, signal)
|
||||
return entries.map(entry => ({
|
||||
name: entry.name,
|
||||
type: entry.type,
|
||||
target: { inputPath: entry.name, 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,
|
||||
expected?: FsWriteIntent,
|
||||
signal?: AbortSignal,
|
||||
): Promise<FsWriteOutcome> {
|
||||
return this.withLock(target.targetKey, async () => {
|
||||
const existing = await probe(target.targetKey)
|
||||
if (existing && existing.type !== 'file') {
|
||||
throw new FsError(`cannot write "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
|
||||
}
|
||||
|
||||
if (expected?.kind === 'replaceIfVersion') {
|
||||
// Stale guard: the file must still exist at the version the owner observed.
|
||||
if (!existing) throw new FsError(`cannot write "${target.displayPath}": file no longer exists`, 'FS_STALE_VERSION')
|
||||
if (existing.version !== expected.version) {
|
||||
throw new FsError(`cannot write "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
|
||||
}
|
||||
} else if (expected?.kind === 'createIfAbsent' && existing) {
|
||||
// createIfAbsent onto an existing file: a blind overwrite — require a read first.
|
||||
throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, 'FS_NOT_OBSERVED')
|
||||
}
|
||||
// expected === undefined: unconditional create-or-overwrite (the bare
|
||||
// provider) — no version guard, no read-first requirement. Still atomic
|
||||
// (the per-target lock is unconditional), so the write is never torn.
|
||||
|
||||
// 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 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)
|
||||
return {
|
||||
operation: existing ? 'update' : 'create',
|
||||
version: this.versionAfterWrite(after, target),
|
||||
before,
|
||||
// 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),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override async editText(
|
||||
target: FsTarget,
|
||||
edit: FsEditRequest,
|
||||
expected?: { version: FsVersion },
|
||||
signal?: AbortSignal,
|
||||
): Promise<FsEditOutcome> {
|
||||
return this.withLock(target.targetKey, async () => {
|
||||
const existing = await probe(target.targetKey)
|
||||
// Stale guard BEFORE literal matching: an edit based on an old read reports
|
||||
// FS_STALE_VERSION, not FS_EDIT_NOT_FOUND/FS_AMBIGUOUS_EDIT against newer content.
|
||||
// A missing target reports FS_STALE_VERSION on BOTH paths (guarded and
|
||||
// unconditional) — one "cannot edit this target now" code.
|
||||
if (!existing) throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
|
||||
if (existing.type !== 'file') throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
|
||||
// expected === undefined: unconditional edit of the current content — no
|
||||
// version guard. Still inside the per-target lock, so the read→match→write
|
||||
// window is serialized and atomic.
|
||||
if (expected && existing.version !== expected.version) {
|
||||
throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
|
||||
}
|
||||
|
||||
const original = await readForEdit(target.targetKey, target.displayPath, signal)
|
||||
const edited = applyLiteralEdit(original.content, edit.oldString, edit.newString, edit.replaceAll, target.displayPath)
|
||||
const content = restoreLineEndings(edited.content, original.lineEndings)
|
||||
await writeFileAtomic(target.targetKey, content, existing.mode, signal, this.internals)
|
||||
|
||||
const after = await probe(target.targetKey)
|
||||
return {
|
||||
replacements: edited.replacements,
|
||||
replaceAll: edit.replaceAll,
|
||||
version: this.versionAfterWrite(after, target),
|
||||
// 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,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/* v8 ignore next 5 -- the post-write probe finding the file absent requires a
|
||||
* concurrent unlink between rename and stat; fall back to a sentinel version. */
|
||||
private versionAfterWrite(after: { version: FsVersion } | null, target: FsTarget): FsVersion {
|
||||
if (after) return after.version
|
||||
return FsVersion(`missing:${target.targetKey}`)
|
||||
}
|
||||
}
|
||||
|
||||
export default LocalFileSystem
|
||||
513
packages/fs/fs-local/tests/filesystem.spec.ts
Normal file
513
packages/fs/fs-local/tests/filesystem.spec.ts
Normal file
@@ -0,0 +1,513 @@
|
||||
/**
|
||||
* Tests for the local backend through the `ctx.fs` provider seam: stat, whole-
|
||||
* file/streamed text reads, atomic guarded writes (createIfAbsent /
|
||||
* replaceIfVersion), version-guarded literal edits, concurrency races, symlink
|
||||
* identity, and HMR/disposal. Read WINDOWING is policy and lives in
|
||||
* `dsh-fs-policy`, so it is not exercised here.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { 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'
|
||||
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
|
||||
import { FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsTarget } from '@deepseek-ai/dsh-fs'
|
||||
|
||||
let dir: string
|
||||
let ctx: Context
|
||||
let fs: LocalFileSystem
|
||||
let fiber: Awaited<ReturnType<Context['plugin']>>
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'dsh-fs-'))
|
||||
ctx = new Context()
|
||||
fiber = await ctx.plugin(LocalFileSystem, { cwd: dir })
|
||||
fs = ctx.fs as LocalFileSystem
|
||||
})
|
||||
afterEach(async () => {
|
||||
await fiber.dispose()
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function lockCount(localFs: LocalFileSystem): number {
|
||||
return (localFs as unknown as { locks: Map<string, Promise<unknown>> }).locks.size
|
||||
}
|
||||
|
||||
/** The version the backend currently reports for a resolved target. */
|
||||
async function versionOf(target: FsTarget): Promise<FsVersion> {
|
||||
const info = await fs.stat(target)
|
||||
if (!info) throw new Error('expected target to exist')
|
||||
return info.version
|
||||
}
|
||||
|
||||
describe('registration', () => {
|
||||
it('registers LocalFileSystem as ctx.fs with a default cwd', async () => {
|
||||
const bare = new Context()
|
||||
const bareFiber = await bare.plugin(LocalFileSystem)
|
||||
expect((bare.fs as LocalFileSystem).config.cwd).toBe(process.cwd())
|
||||
await bareFiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolve', () => {
|
||||
it('resolves a relative path against opts.cwd, not config.cwd', async () => {
|
||||
// config.cwd is `dir`; a call supplying a DIFFERENT cwd bases the relative
|
||||
// path there (the per-session-workspace seam — mirrors tool-bash workdir).
|
||||
const other = await mkdtemp(join(tmpdir(), 'dsh-fs-other-'))
|
||||
try {
|
||||
await writeFile(join(other, 'x.txt'), 'in other')
|
||||
const viaOther = await fs.resolve('x.txt', { cwd: other })
|
||||
expect(await fs.readText(viaOther)).toBe('in other')
|
||||
// Same relative path with no opts falls back to config.cwd (= dir), where
|
||||
// x.txt does not exist.
|
||||
await expect(fs.readText(await fs.resolve('x.txt'))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
} finally {
|
||||
await rm(other, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores opts.cwd for an ABSOLUTE path', async () => {
|
||||
await writeFile(join(dir, 'abs.txt'), 'absolute')
|
||||
const target = await fs.resolve(join(dir, 'abs.txt'), { cwd: '/nonexistent-base' })
|
||||
expect(await fs.readText(target)).toBe('absolute')
|
||||
})
|
||||
})
|
||||
|
||||
describe('stat', () => {
|
||||
it('returns file metadata, directory type, and undefined for absent', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello')
|
||||
const fileInfo = await fs.stat(await fs.resolve('a.txt'))
|
||||
expect(fileInfo?.type).toBe('file')
|
||||
expect(fileInfo?.size).toBe(5)
|
||||
expect(typeof fileInfo?.version).toBe('string')
|
||||
|
||||
expect((await fs.stat(await fs.resolve('.')))?.type).toBe('directory')
|
||||
expect(await fs.stat(await fs.resolve('missing.txt'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('honors a pre-aborted signal', async () => {
|
||||
await expect(fs.stat(await fs.resolve('a.txt'), AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('readText / streamText', () => {
|
||||
it('reads whole-file text', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'one\ntwo\nthree')
|
||||
expect(await fs.readText(await fs.resolve('a.txt'))).toBe('one\ntwo\nthree')
|
||||
})
|
||||
|
||||
it('streams the same text', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'one\ntwo\nthree')
|
||||
const target = await fs.resolve('a.txt')
|
||||
let streamed = ''
|
||||
for await (const chunk of await fs.streamText(target)) streamed += chunk
|
||||
expect(streamed).toBe('one\ntwo\nthree')
|
||||
})
|
||||
|
||||
it('rejects a missing file, a directory, binary, and invalid UTF-8', async () => {
|
||||
await expect(fs.readText(await fs.resolve('nope'))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
await expect(fs.readText(await fs.resolve('.'))).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
|
||||
|
||||
await writeFile(join(dir, 'bin'), Buffer.from([0x68, 0x00, 0x69]))
|
||||
await expect(fs.readText(await fs.resolve('bin'))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
|
||||
await writeFile(join(dir, 'bad'), Buffer.from([0x68, 0xff, 0x69]))
|
||||
await expect(fs.readText(await fs.resolve('bad'))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('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'),
|
||||
])
|
||||
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))))
|
||||
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')
|
||||
const outcome = await fs.writeText(target, 'fresh', { kind: 'createIfAbsent' })
|
||||
expect(outcome.operation).toBe('create')
|
||||
expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('fresh')
|
||||
})
|
||||
|
||||
it('createIfAbsent rejects an existing file as FS_NOT_OBSERVED', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'old')
|
||||
const target = await fs.resolve('a.txt')
|
||||
await expect(fs.writeText(target, 'new', { kind: 'createIfAbsent' }))
|
||||
.rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('old')
|
||||
})
|
||||
|
||||
it('replaceIfVersion replaces when the version matches', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'old')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const outcome = await fs.writeText(target, 'new', { kind: 'replaceIfVersion', version: await versionOf(target) })
|
||||
expect(outcome.operation).toBe('update')
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('new')
|
||||
})
|
||||
|
||||
it('replaceIfVersion rejects a stale version', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'v1')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const stale = await versionOf(target)
|
||||
await writeFile(join(dir, 'a.txt'), 'changed-externally')
|
||||
await expect(fs.writeText(target, 'v2', { kind: 'replaceIfVersion', version: stale }))
|
||||
.rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
})
|
||||
|
||||
it('replaceIfVersion rejects a deleted target as stale, without recreating it', async () => {
|
||||
const path = join(dir, 'a.txt')
|
||||
await writeFile(path, 'v1')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const version = await versionOf(target)
|
||||
await unlink(path)
|
||||
await expect(fs.writeText(target, 'v2', { kind: 'replaceIfVersion', version }))
|
||||
.rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('rejects writing onto a directory', async () => {
|
||||
const target = await fs.resolve('.')
|
||||
await expect(fs.writeText(target, 'x', { kind: 'createIfAbsent' }))
|
||||
.rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
|
||||
})
|
||||
|
||||
it('unconditionally creates a new file with no expectation (bare provider)', async () => {
|
||||
const target = await fs.resolve('new.txt')
|
||||
const outcome = await fs.writeText(target, 'fresh')
|
||||
expect(outcome.operation).toBe('create')
|
||||
expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('fresh')
|
||||
})
|
||||
|
||||
it('unconditionally OVERWRITES an existing file with no expectation (bare provider)', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'old')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const outcome = await fs.writeText(target, 'clobbered')
|
||||
expect(outcome.operation).toBe('update')
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('clobbered')
|
||||
})
|
||||
|
||||
it('rejects writing onto a directory even with no expectation', async () => {
|
||||
const target = await fs.resolve('.')
|
||||
await expect(fs.writeText(target, 'x')).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
|
||||
})
|
||||
|
||||
it('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 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\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 () => {
|
||||
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' })
|
||||
expect(lockCount(fs)).toBe(0)
|
||||
await expect(fs.writeText(target, 'again', { kind: 'createIfAbsent' }))
|
||||
.rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
expect(lockCount(fs)).toBe(0)
|
||||
})
|
||||
|
||||
it('replaceIfVersion returns the post-write version (matches a fresh stat)', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'v1')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const before = await versionOf(target)
|
||||
// Change the byte length so the mtimeMs:size token provably differs (a
|
||||
// same-size same-tick rewrite can collide — the documented version-token
|
||||
// limitation; not what this test is about).
|
||||
const outcome = await fs.writeText(target, 'a much longer replacement body', { kind: 'replaceIfVersion', version: before })
|
||||
expect(outcome.version).not.toBe(before)
|
||||
expect(outcome.version).toBe(await versionOf(target))
|
||||
})
|
||||
|
||||
it('honors a pre-aborted signal without creating the file', async () => {
|
||||
const target = await fs.resolve('aborted.txt')
|
||||
await expect(fs.writeText(target, 'x', undefined, AbortSignal.abort()))
|
||||
.rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
await expect(stat(join(dir, 'aborted.txt'))).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect(lockCount(fs)).toBe(0)
|
||||
})
|
||||
|
||||
it('two concurrent guarded writes: one updates, the other is rejected as stale', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'base')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const version = await versionOf(target)
|
||||
const results = await Promise.allSettled([
|
||||
fs.writeText(target, 'one', { kind: 'replaceIfVersion', version }),
|
||||
fs.writeText(target, 'two', { kind: 'replaceIfVersion', version }),
|
||||
])
|
||||
expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(1)
|
||||
const rejected = results.filter(r => r.status === 'rejected')
|
||||
expect(rejected).toHaveLength(1)
|
||||
expect((rejected[0] as PromiseRejectedResult).reason).toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
expect(lockCount(fs)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('editText', () => {
|
||||
it('applies a literal edit at the matching version', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const outcome = await fs.editText(target, { oldString: 'world', newString: 'there', replaceAll: false }, { version: await versionOf(target) })
|
||||
expect(outcome.replacements).toBe(1)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there')
|
||||
})
|
||||
|
||||
it('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')
|
||||
const stale = await versionOf(target)
|
||||
// Change the file so 'world' is gone — a stale edit must report STALE, not NOT_FOUND.
|
||||
await writeFile(join(dir, 'a.txt'), 'goodbye')
|
||||
await expect(fs.editText(target, { oldString: 'world', newString: 'there', replaceAll: false }, { version: stale }))
|
||||
.rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
})
|
||||
|
||||
it('unconditionally edits the current content with no expectation (bare provider)', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
const target = await fs.resolve('a.txt')
|
||||
// No version guard: any current content is edited, regardless of version.
|
||||
const outcome = await fs.editText(target, { oldString: 'world', newString: 'there', replaceAll: false })
|
||||
expect(outcome.replacements).toBe(1)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there')
|
||||
})
|
||||
|
||||
it('reports a missing target as FS_STALE_VERSION even with no expectation (bare provider)', async () => {
|
||||
const target = await fs.resolve('missing.txt')
|
||||
await expect(fs.editText(target, { oldString: 'a', newString: 'b', replaceAll: false }))
|
||||
.rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
})
|
||||
|
||||
it('still reports literal-match codes with no expectation (FS_EDIT_NOT_FOUND, unrelated to freshness)', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
const target = await fs.resolve('a.txt')
|
||||
await expect(fs.editText(target, { oldString: 'absent', newString: 'x', replaceAll: false }))
|
||||
.rejects.toMatchObject({ code: 'FS_EDIT_NOT_FOUND' })
|
||||
})
|
||||
|
||||
it('rejects a deleted target as stale (before matching)', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const version = await versionOf(target)
|
||||
await unlink(join(dir, 'a.txt'))
|
||||
await expect(fs.editText(target, { oldString: 'hello', newString: 'bye', replaceAll: false }, { version }))
|
||||
.rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
})
|
||||
|
||||
it('rejects a non-regular target', async () => {
|
||||
const target = await fs.resolve('.')
|
||||
await expect(fs.editText(target, { oldString: 'a', newString: 'b', replaceAll: false }, { version: FsVersion('v') }))
|
||||
.rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
|
||||
})
|
||||
|
||||
it('rejects zero matches and ambiguous matches at the right version', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'a a a')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const version = await versionOf(target)
|
||||
await expect(fs.editText(target, { oldString: 'z', newString: 'X', replaceAll: false }, { version }))
|
||||
.rejects.toMatchObject({ code: 'FS_EDIT_NOT_FOUND' })
|
||||
await expect(fs.editText(target, { oldString: 'a', newString: 'X', replaceAll: false }, { version }))
|
||||
.rejects.toMatchObject({ code: 'FS_AMBIGUOUS_EDIT' })
|
||||
})
|
||||
|
||||
it('replaces all matches with replaceAll', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'a a a')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const outcome = await fs.editText(target, { oldString: 'a', newString: 'b', replaceAll: true }, { version: await versionOf(target) })
|
||||
expect(outcome.replacements).toBe(3)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('b b b')
|
||||
})
|
||||
|
||||
it('rejects invalid UTF-8 without rewriting the file', async () => {
|
||||
const path = join(dir, 'bad.txt')
|
||||
const bytes = Buffer.from([0x68, 0xff, 0x69])
|
||||
await writeFile(path, bytes)
|
||||
const target = await fs.resolve('bad.txt')
|
||||
const version = await versionOf(target)
|
||||
await expect(fs.editText(target, { oldString: 'h', newString: 'H', replaceAll: false }, { version }))
|
||||
.rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
expect(await readFile(path)).toEqual(bytes)
|
||||
})
|
||||
|
||||
it('two concurrent edits: one wins, the other is rejected as stale', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'base')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const version = await versionOf(target)
|
||||
const results = await Promise.allSettled([
|
||||
fs.editText(target, { oldString: 'base', newString: 'one', replaceAll: false }, { version }),
|
||||
fs.editText(target, { oldString: 'base', newString: 'two', replaceAll: false }, { version }),
|
||||
])
|
||||
expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(1)
|
||||
const rejected = results.filter(r => r.status === 'rejected')
|
||||
expect(rejected).toHaveLength(1)
|
||||
expect((rejected[0] as PromiseRejectedResult).reason).toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
expect(lockCount(fs)).toBe(0)
|
||||
})
|
||||
|
||||
it('honors a pre-aborted signal without rewriting the file', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'keep')
|
||||
const target = await fs.resolve('a.txt')
|
||||
await expect(fs.editText(target, { oldString: 'keep', newString: 'x', replaceAll: false }, undefined, AbortSignal.abort()))
|
||||
.rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('keep')
|
||||
expect(lockCount(fs)).toBe(0)
|
||||
})
|
||||
|
||||
it('a successful edit refreshes the version so an immediate follow-up edit proceeds', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'one two')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const first = await fs.editText(target, { oldString: 'one', newString: 'ONE', replaceAll: false }, { version: await versionOf(target) })
|
||||
// The version the first edit returned is a valid guard for a second edit —
|
||||
// no intervening re-stat needed.
|
||||
const second = await fs.editText(target, { oldString: 'two', newString: 'TWO', replaceAll: false }, { version: first.version })
|
||||
expect(second.replacements).toBe(1)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('ONE TWO')
|
||||
})
|
||||
|
||||
it('concurrent write vs edit at the same version: one wins, the other is stale', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'base')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const version = await versionOf(target)
|
||||
const results = await Promise.allSettled([
|
||||
fs.writeText(target, 'written', { kind: 'replaceIfVersion', version }),
|
||||
fs.editText(target, { oldString: 'base', newString: 'edited', replaceAll: false }, { version }),
|
||||
])
|
||||
expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(1)
|
||||
const rejected = results.filter(r => r.status === 'rejected')
|
||||
expect(rejected).toHaveLength(1)
|
||||
expect((rejected[0] as PromiseRejectedResult).reason).toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
expect(lockCount(fs)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('symlink targetKey identity', () => {
|
||||
it('two paths to the same file via a symlink share one version and write the real target', async () => {
|
||||
await writeFile(join(dir, 'real.txt'), 'hello')
|
||||
await symlink(join(dir, 'real.txt'), join(dir, 'link.txt'))
|
||||
const viaReal = await fs.resolve('real.txt')
|
||||
const viaLink = await fs.resolve('link.txt')
|
||||
expect(viaLink.targetKey).toBe(viaReal.targetKey)
|
||||
|
||||
const version = await versionOf(viaReal)
|
||||
await fs.editText(viaLink, { oldString: 'hello', newString: 'bye', replaceAll: false }, { version })
|
||||
expect(await readFile(join(dir, 'real.txt'), 'utf8')).toBe('bye') // link preserved
|
||||
})
|
||||
|
||||
it('a stale change is detected across both paths', async () => {
|
||||
await writeFile(join(dir, 'real.txt'), 'hello')
|
||||
await symlink(join(dir, 'real.txt'), join(dir, 'link.txt'))
|
||||
const viaReal = await fs.resolve('real.txt')
|
||||
const stale = await versionOf(viaReal)
|
||||
await writeFile(join(dir, 'real.txt'), 'changed')
|
||||
const viaLink = await fs.resolve('link.txt')
|
||||
await expect(fs.editText(viaLink, { oldString: 'hello', newString: 'bye', replaceAll: false }, { version: stale }))
|
||||
.rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('HMR / disposal', () => {
|
||||
it('disposing the fiber withdraws ctx.fs', async () => {
|
||||
const local = new Context()
|
||||
const localFiber = await local.plugin(LocalFileSystem, { cwd: dir })
|
||||
expect(local.fs).toBeDefined()
|
||||
await localFiber.dispose()
|
||||
expect(local.fs).toBeUndefined()
|
||||
})
|
||||
})
|
||||
468
packages/fs/fs-local/tests/fsio.spec.ts
Normal file
468
packages/fs/fs-local/tests/fsio.spec.ts
Normal file
@@ -0,0 +1,468 @@
|
||||
/**
|
||||
* Cordis-free tests for the raw local-filesystem I/O: path resolution, probe,
|
||||
* whole-file/streamed text reads, binary/UTF-8 rejection, atomic-write temp
|
||||
* safety, literal edit matching, and line-ending handling. Line WINDOWING is
|
||||
* policy and lives in `dsh-fs-policy`, so it is not tested here.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { 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'
|
||||
import {
|
||||
applyLiteralEdit,
|
||||
listDirectory,
|
||||
probe,
|
||||
readForEdit,
|
||||
readWholeText,
|
||||
resolveLocalTarget,
|
||||
restoreLineEndings,
|
||||
streamWholeText,
|
||||
writeFileAtomic,
|
||||
} from '@deepseek-ai/dsh-fs-local'
|
||||
import type { LocalTarget } from '@deepseek-ai/dsh-fs-local'
|
||||
import { FsError, FsTargetKey } from '@deepseek-ai/dsh-fs'
|
||||
|
||||
let dir: string
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'dsh-fsio-'))
|
||||
})
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const localTarget = (path: string): LocalTarget => ({ displayPath: path, targetKey: FsTargetKey(path) })
|
||||
|
||||
async function collect(chunks: AsyncIterable<string>): Promise<string> {
|
||||
let out = ''
|
||||
for await (const chunk of chunks) out += chunk
|
||||
return out
|
||||
}
|
||||
|
||||
describe('resolveLocalTarget', () => {
|
||||
it('resolves a relative path from cwd and realpaths it', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'hi')
|
||||
const target = await resolveLocalTarget(dir, 'a.txt')
|
||||
expect(target.displayPath).toBe(file)
|
||||
expect(target.targetKey).toBe(await realpath(file))
|
||||
})
|
||||
|
||||
it('uses the realpathed parent + basename when the file does not exist (stable across create)', async () => {
|
||||
const target = await resolveLocalTarget(dir, 'missing.txt')
|
||||
expect(target.targetKey).toBe(join(await realpath(dir), 'missing.txt'))
|
||||
})
|
||||
|
||||
it('two paths to the same file via a symlink share one targetKey', async () => {
|
||||
const real = join(dir, 'real.txt')
|
||||
await writeFile(real, 'hi')
|
||||
const link = join(dir, 'link.txt')
|
||||
await symlink(real, link)
|
||||
const viaReal = await resolveLocalTarget(dir, 'real.txt')
|
||||
const viaLink = await resolveLocalTarget(dir, 'link.txt')
|
||||
expect(viaLink.targetKey).toBe(viaReal.targetKey)
|
||||
expect(viaLink.displayPath).toBe(link)
|
||||
})
|
||||
|
||||
it('realpaths the nearest existing ancestor when intermediate dirs are missing', async () => {
|
||||
const target = await resolveLocalTarget(dir, 'no-such-dir/child.txt')
|
||||
expect(target.targetKey).toBe(join(await realpath(dir), 'no-such-dir', 'child.txt'))
|
||||
})
|
||||
|
||||
it('keeps the key stable across create when an ancestor is a symlink', async () => {
|
||||
// A symlinked workspace root with a not-yet-created subdirectory: the
|
||||
// pre-create key (via the symlink, missing parent) must equal the
|
||||
// post-create key (file exists, realpathed) so observed-state survives.
|
||||
const realRoot = join(dir, 'real-root')
|
||||
await mkdir(realRoot)
|
||||
const linkRoot = join(dir, 'link-root')
|
||||
await symlink(realRoot, linkRoot)
|
||||
|
||||
const before = await resolveLocalTarget(linkRoot, 'sub/file.txt')
|
||||
await mkdir(join(realRoot, 'sub'), { recursive: true })
|
||||
await writeFile(join(realRoot, 'sub', 'file.txt'), 'hi') // create through the real path
|
||||
const after = await resolveLocalTarget(linkRoot, 'sub/file.txt')
|
||||
expect(before.targetKey).toBe(after.targetKey)
|
||||
})
|
||||
|
||||
it('rejects a blank path', async () => {
|
||||
await expect(resolveLocalTarget(dir, ' ')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
})
|
||||
|
||||
it('rejects a path whose ancestor is a file with a structured FsError (ENOTDIR)', async () => {
|
||||
// "afile" is a regular file, so "afile/child.txt" hits ENOTDIR on realpath;
|
||||
// the raw Node error must be translated into the FsError taxonomy so the tool
|
||||
// result keeps its { name, code } metadata.
|
||||
await writeFile(join(dir, 'afile'), 'i am a file')
|
||||
const err = await resolveLocalTarget(dir, 'afile/child.txt').then(() => undefined, (e: unknown) => e)
|
||||
expect(err).toBeInstanceOf(FsError)
|
||||
expect(err).toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('probe', () => {
|
||||
it('returns null for a missing path and metadata for a file', async () => {
|
||||
expect(await probe(join(dir, 'nope'))).toBeNull()
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'hi')
|
||||
const info = await probe(file)
|
||||
expect(info?.type).toBe('file')
|
||||
expect(info?.size).toBe(2)
|
||||
expect(typeof info?.version).toBe('string')
|
||||
})
|
||||
|
||||
it('reports a directory and a non-regular type', async () => {
|
||||
const sub = join(dir, 'sub')
|
||||
await mkdir(sub)
|
||||
expect((await probe(sub))?.type).toBe('directory')
|
||||
})
|
||||
|
||||
it('reports a socket/special file as type "other"', async () => {
|
||||
const sockPath = join(dir, 'sock')
|
||||
const server = createServer()
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(sockPath, () => { resolve() })
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
// A restricted sandbox may forbid unix-domain sockets; that is an
|
||||
// environment limit, not a filesystem regression — skip rather than fail.
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code === 'EPERM' || code === 'EACCES' || code === 'ENOTSUP') return
|
||||
throw error
|
||||
}
|
||||
try {
|
||||
expect((await probe(sockPath))?.type).toBe('other')
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => { server.close(() => { resolve() }) })
|
||||
}
|
||||
})
|
||||
|
||||
it('returns null when an ancestor path segment is a file (ENOTDIR), not a raw throw', async () => {
|
||||
await writeFile(join(dir, 'afile'), 'i am a file')
|
||||
expect(await probe(join(dir, 'afile', 'child.txt'))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('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('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')
|
||||
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' })
|
||||
})
|
||||
|
||||
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', () => {
|
||||
it('reads a small file', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo\nthree')
|
||||
expect(await readWholeText(localTarget(file))).toBe('one\ntwo\nthree')
|
||||
})
|
||||
|
||||
it('rejects a missing file and a directory', async () => {
|
||||
await expect(readWholeText(localTarget(join(dir, 'nope')))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
await expect(readWholeText(localTarget(dir))).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
|
||||
})
|
||||
|
||||
it('rejects binary and invalid UTF-8', async () => {
|
||||
await writeFile(join(dir, 'bin'), Buffer.from([0x68, 0x00, 0x69]))
|
||||
await expect(readWholeText(localTarget(join(dir, 'bin')))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
await writeFile(join(dir, 'bad'), Buffer.from([0x68, 0xff, 0x69]))
|
||||
await expect(readWholeText(localTarget(join(dir, 'bad')))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
})
|
||||
|
||||
it('honors a pre-aborted signal', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one')
|
||||
await expect(readWholeText(localTarget(file), AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
|
||||
it('passes a live (non-aborted) signal through', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo')
|
||||
expect(await readWholeText(localTarget(file), new AbortController().signal)).toBe('one\ntwo')
|
||||
})
|
||||
|
||||
it('translates a mid-read AbortError into FS_ABORTED', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo')
|
||||
const ac = new AbortController()
|
||||
// Abort after the synchronous entry check but before readFile runs (the
|
||||
// stat await yields control back here), so readFile rejects AbortError.
|
||||
const pending = readWholeText(localTarget(file), ac.signal)
|
||||
ac.abort()
|
||||
await expect(pending).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('streamWholeText', () => {
|
||||
it('streams the whole file as decoded text', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo\nthree')
|
||||
expect(await collect(streamWholeText(localTarget(file)))).toBe('one\ntwo\nthree')
|
||||
})
|
||||
|
||||
it('streams a large multi-chunk file correctly', async () => {
|
||||
const file = join(dir, 'big.txt')
|
||||
const content = Array.from({ length: 50 }, (_, i) => `line ${i}: ${'x'.repeat(3000)}`).join('\n')
|
||||
await writeFile(file, content)
|
||||
expect(await collect(streamWholeText(localTarget(file)))).toBe(content)
|
||||
})
|
||||
|
||||
it('rejects a missing file, directory, binary, and invalid UTF-8', async () => {
|
||||
await expect(collect(streamWholeText(localTarget(join(dir, 'nope'))))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
await expect(collect(streamWholeText(localTarget(dir)))).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
|
||||
await writeFile(join(dir, 'bin'), Buffer.from([0x68, 0x00, 0x69]))
|
||||
await expect(collect(streamWholeText(localTarget(join(dir, 'bin'))))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
await writeFile(join(dir, 'bad'), Buffer.from([0x68, 0xff, 0x69]))
|
||||
await expect(collect(streamWholeText(localTarget(join(dir, 'bad'))))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
})
|
||||
|
||||
it('honors a pre-aborted signal', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one')
|
||||
await expect(collect(streamWholeText(localTarget(file), AbortSignal.abort()))).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
|
||||
it('passes a live (non-aborted) signal through the stream', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo')
|
||||
expect(await collect(streamWholeText(localTarget(file), new AbortController().signal))).toBe('one\ntwo')
|
||||
})
|
||||
|
||||
it('translates a mid-stream abort into FS_ABORTED', async () => {
|
||||
// A multi-chunk file so the stream yields more than once; abort after the
|
||||
// first chunk and assert the structured code, not a raw AbortError.
|
||||
const file = join(dir, 'big.txt')
|
||||
await writeFile(file, 'x'.repeat(256 * 1024))
|
||||
const ac = new AbortController()
|
||||
const run = async (): Promise<void> => {
|
||||
let seen = 0
|
||||
for await (const _chunk of streamWholeText(localTarget(file), ac.signal)) {
|
||||
seen += 1
|
||||
if (seen === 1) ac.abort()
|
||||
}
|
||||
}
|
||||
await expect(run()).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('writeFileAtomic — temp-file safety', () => {
|
||||
it('writes through a private staging dir and owner-only temp file', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
let inspected = false
|
||||
await writeFileAtomic(file, 'hello', 0o640, undefined, {
|
||||
inspectTemp: async ({ stagingDir, tempPath }) => {
|
||||
inspected = true
|
||||
expect((await stat(stagingDir)).mode & 0o777).toBe(0o700)
|
||||
expect((await stat(tempPath)).mode & 0o777).toBe(0o600)
|
||||
},
|
||||
})
|
||||
expect(inspected).toBe(true)
|
||||
expect(await readFile(file, 'utf8')).toBe('hello')
|
||||
expect((await stat(file)).mode & 0o777).toBe(0o640)
|
||||
expect((await readdir(dir)).filter(n => n.includes('.tmp'))).toEqual([])
|
||||
})
|
||||
|
||||
it('creates new files owner-only by default', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFileAtomic(file, 'hello', undefined, undefined)
|
||||
expect((await stat(file)).mode & 0o777).toBe(0o600)
|
||||
})
|
||||
|
||||
it('opens staging paths exclusively — a pre-existing path is never clobbered', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
const tempDirName = '.fixed-temp.tmpdir'
|
||||
await mkdir(join(dir, tempDirName))
|
||||
await writeFile(join(dir, tempDirName, 'PRECIOUS'), 'keep')
|
||||
await expect(
|
||||
writeFileAtomic(file, 'hello', undefined, undefined, { tempDirName: () => tempDirName }),
|
||||
).rejects.toMatchObject({ code: 'EEXIST' })
|
||||
expect(await readFile(join(dir, tempDirName, 'PRECIOUS'), 'utf8')).toBe('keep')
|
||||
await expect(stat(file)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('creates parent directories as needed', async () => {
|
||||
const file = join(dir, 'nested', 'deep', 'a.txt')
|
||||
await writeFileAtomic(file, 'hi', undefined, undefined)
|
||||
expect(await readFile(file, 'utf8')).toBe('hi')
|
||||
})
|
||||
|
||||
it('passes a live (non-aborted) signal through the write', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFileAtomic(file, 'hi', undefined, new AbortController().signal)
|
||||
expect(await readFile(file, 'utf8')).toBe('hi')
|
||||
})
|
||||
|
||||
it('aborts before writing when the signal is already aborted', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await expect(writeFileAtomic(file, 'hi', undefined, AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
await expect(stat(file)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('cleans up the temp file when the final rename fails', async () => {
|
||||
const sub = join(dir, 'occupied')
|
||||
await mkdir(sub)
|
||||
await expect(writeFileAtomic(sub, 'hi', undefined, undefined)).rejects.toBeInstanceOf(Error)
|
||||
expect((await readdir(dir)).filter(n => n.includes('.tmp'))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyLiteralEdit', () => {
|
||||
it('replaces a unique match', () => {
|
||||
expect(applyLiteralEdit('a b c', 'b', 'X', false, 'f')).toEqual({ content: 'a X c', replacements: 1 })
|
||||
})
|
||||
|
||||
it('rejects zero matches', () => {
|
||||
expect(() => applyLiteralEdit('a b c', 'z', 'X', false, 'f')).toThrow(expect.objectContaining({ code: 'FS_EDIT_NOT_FOUND' }))
|
||||
})
|
||||
|
||||
it('rejects an empty oldString without scanning forever', () => {
|
||||
expect(() => applyLiteralEdit('a b c', '', 'X', false, 'f')).toThrow(expect.objectContaining({ code: 'FS_EDIT_NOT_FOUND' }))
|
||||
})
|
||||
|
||||
it('rejects multiple matches without replaceAll', () => {
|
||||
expect(() => applyLiteralEdit('a a a', 'a', 'X', false, 'f')).toThrow(expect.objectContaining({ code: 'FS_AMBIGUOUS_EDIT' }))
|
||||
})
|
||||
|
||||
it('replaces all matches with replaceAll', () => {
|
||||
expect(applyLiteralEdit('a a a', 'a', 'X', true, 'f')).toEqual({ content: 'X X X', replacements: 3 })
|
||||
})
|
||||
|
||||
it('matches across normalized line endings', () => {
|
||||
expect(applyLiteralEdit('one\ntwo', 'one\ntwo', 'x', false, 'f').replacements).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('readForEdit + restoreLineEndings', () => {
|
||||
it('round-trips CRLF: matches on LF, writes back CRLF', async () => {
|
||||
const file = join(dir, 'crlf.txt')
|
||||
await writeFile(file, 'one\r\ntwo\r\n')
|
||||
const original = await readForEdit(file, file)
|
||||
expect(original.lineEndings).toBe('CRLF')
|
||||
const edited = applyLiteralEdit(original.content, 'two', 'TWO', false, file)
|
||||
expect(restoreLineEndings(edited.content, original.lineEndings)).toBe('one\r\nTWO\r\n')
|
||||
})
|
||||
|
||||
it('rejects a binary file and invalid UTF-8', async () => {
|
||||
await writeFile(join(dir, 'bin'), Buffer.from([0x00, 0x01]))
|
||||
await expect(readForEdit(join(dir, 'bin'), join(dir, 'bin'))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
await writeFile(join(dir, 'bad'), Buffer.from([0x68, 0xff, 0x69]))
|
||||
await expect(readForEdit(join(dir, 'bad'), join(dir, 'bad'))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
})
|
||||
|
||||
it('passes a live (non-aborted) signal through the read', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo')
|
||||
const original = await readForEdit(file, file, new AbortController().signal)
|
||||
expect(original.content).toBe('one\ntwo')
|
||||
})
|
||||
|
||||
it('translates a mid-read AbortError into FS_ABORTED', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
await writeFile(file, 'one\ntwo')
|
||||
const ac = new AbortController()
|
||||
// Abort after the synchronous entry check, while readFile is pending.
|
||||
const pending = readForEdit(file, file, ac.signal)
|
||||
ac.abort()
|
||||
await expect(pending).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
})
|
||||
15
packages/fs/fs-local/tsconfig.json
Normal file
15
packages/fs/fs-local/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../fs" }
|
||||
]
|
||||
}
|
||||
48
packages/fs/fs-policy/README.md
Normal file
48
packages/fs/fs-policy/README.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# @deepseek-ai/dsh-fs-policy
|
||||
|
||||
The **fs-policy plugin**: it adds observed-state, read-before-edit, and version-guarded write/edit on top of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)) — through the `fs/*` event gate, **NOT** through a method service. This plugin registers **no** `ctx.fsPolicy` service and has no public `read`/`write`/`edit`/`resolve` methods. It is the policy third of the filesystem stack: not a swappable seam, but the policy that does not belong on the `FileSystem` provider base class.
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
|
||||
|
||||
declare const ctx: Context
|
||||
|
||||
// No service to inject — this plugin only registers the three fs/* listeners.
|
||||
// Load it alongside a ctx.fs provider (e.g. @deepseek-ai/dsh-fs-local) and the
|
||||
// @deepseek-ai/dsh-tool-fs tools; the tools dispatch the fs/* events this plugin
|
||||
// decides. Order does not matter for resolution (no inject), but the policy
|
||||
// listener should be the first decider registered for the fs/*-intent slots.
|
||||
await ctx.plugin(FsPolicy)
|
||||
```
|
||||
|
||||
## The four-layer split
|
||||
|
||||
| Layer | Package | Role |
|
||||
|---|---|---|
|
||||
| tool / executor | `@deepseek-ai/dsh-tool-fs` | model-facing schemas + read windowing + text rendering; reads/writes/edits via `ctx.fs`, dispatches the `fs/*` events |
|
||||
| policy | `@deepseek-ai/dsh-fs-policy` (this) | observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate (no service) |
|
||||
| provider seam | `@deepseek-ai/dsh-fs` | `ctx.fs`: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` event vocabulary |
|
||||
| provider | `@deepseek-ai/dsh-fs-local` | local implementation of `ctx.fs` |
|
||||
|
||||
## How the gate participates
|
||||
|
||||
Three `fs/*` events (declared by `@deepseek-ai/dsh-fs`, dispatched by `@deepseek-ai/dsh-tool-fs`):
|
||||
|
||||
| Event | This plugin's listener |
|
||||
|---|---|
|
||||
| `fs/write-intent` | No prior observation → `{ kind: 'createIfAbsent' }`; a prior observation → `{ kind: 'replaceIfVersion', version: vObserved }`. Single-slot decision; does NOT call `next()`. |
|
||||
| `fs/edit-intent` | Requires a prior observation by this owner (else throws `FS_NOT_OBSERVED`); returns `{ version: vObserved }` as the CAS basis. Single-slot decision; does NOT call `next()`. |
|
||||
| `fs/observed` | Records `{ version }` for this owner+target. Synchronous, side-effect-only `WeakMap.set`. |
|
||||
|
||||
## Observed state is the prior-observation record; freshness is provider CAS
|
||||
|
||||
Observed state is a `WeakMap<owner, Map<targetKey, FsVersion>>`. An entry exists **iff** the owner has read, written, OR edited that target (every success emits `fs/observed`), so its presence is the prior-observation record — there is no `hasRead` flag and no `full`/`partial` view. This plugin does **no** filesystem I/O: "have you observed this file?" is a `WeakMap` lookup, and "is the version you read still current?" is decided inside `ctx.fs.editText`/`writeText` in the same atomic lock that performs the mutation — this plugin only supplies `vObserved` as the basis. A windowed read of lines 100-150 records the file's version, and a later edit of line 120 is authorized as long as the file is unchanged. State is held weakly and dropped on disposal (HMR safety); persistence across sessions is deferred.
|
||||
|
||||
## Single-slot, first-wins
|
||||
|
||||
The `fs/write-intent`/`fs/edit-intent` slots hold exactly one decider — this plugin fully decides and does not call `next()`. The slot is first-wins by registration order; this plugin owning it is the default-deployment convention, not an event-enforced invariant (a decider registered before / `prepend`ed would win instead). This is not a composable authorization chain — layered permission/audit/sandbox interception belongs on `tools/execute`.
|
||||
|
||||
## No method coupling
|
||||
|
||||
Because the plugin influences the world only through events, removing it does not break `@deepseek-ai/dsh-tool-fs` at a service-injection boundary: the tool falls through to the bare `ctx.fs` provider (unconditional write/edit, no observed-state). Loading it back layers the policy on. That graceful add/remove is the whole point of the event gate over a mandatory method service.
|
||||
33
packages/fs/fs-policy/package.json
Normal file
33
packages/fs/fs-policy/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-fs-policy",
|
||||
"description": "File-context policy plugin for the DeepSeek Harness — observed-state, read-before-edit, and version-guarded write/edit added over the ctx.fs provider seam through the fs/* event gate (no service surface)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-fs": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
160
packages/fs/fs-policy/src/index.ts
Normal file
160
packages/fs/fs-policy/src/index.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* The fs-policy PLUGIN: observed-state, read-before-edit, and
|
||||
* "write/edit must be based on the version you read" — added on top of the
|
||||
* `ctx.fs` provider seam through the `fs/*` event gate, NOT through a method
|
||||
* service. This plugin registers NO `ctx.fsPolicy` service and exposes no
|
||||
* `read`/`write`/`edit`/`resolve` methods; it influences the world only by
|
||||
* deciding the `fs/write-intent`/`fs/edit-intent` waterfalls and
|
||||
* recording on `fs/observed`. That is what keeps `@deepseek-ai/dsh-tool-fs`
|
||||
* (the executor) free of any method coupling to the policy layer — removing
|
||||
* this plugin gracefully loses the policy and leaves the unconstrained bare
|
||||
* provider, rather than breaking the tool at a service-injection boundary.
|
||||
*
|
||||
* ## Observed state IS the prior-observation record
|
||||
*
|
||||
* State lives here as `WeakMap<owner, Map<targetKey, { version }>>`. An entry
|
||||
* exists iff the owner has read, written, OR edited that target (every success
|
||||
* emits `fs/observed`), so its presence means "this owner has observed this
|
||||
* target at this version". This is what lets a create-then-edit or
|
||||
* edit-then-edit sequence work without an intervening re-read: the mutation
|
||||
* refreshes the recorded version to its own result. The owner is derived
|
||||
* structurally from `{ agent?: { session? } }` and held weakly, so a collected
|
||||
* session frees its state; disposal drops everything (HMR safety).
|
||||
*
|
||||
* ## Freshness via provider CAS, not stat
|
||||
*
|
||||
* This plugin does NO filesystem I/O. "Have you observed this file?" is a
|
||||
* `WeakMap` lookup (no record ⇒ `FS_NOT_OBSERVED`). "Is the version you read
|
||||
* still current?" is decided INSIDE `ctx.fs.editText`/`writeText`, in the same
|
||||
* atomic lock that performs the mutation — this plugin only supplies the
|
||||
* observed version as the CAS basis. Stat-ing and comparing here would open a
|
||||
* TOCTOU gap the provider lock has to back up anyway, so it is deliberately
|
||||
* avoided.
|
||||
*
|
||||
* ## Single-slot, first-wins
|
||||
*
|
||||
* The `fs/write-intent`/`fs/edit-intent` listeners do NOT call
|
||||
* `next()`: each fully decides its single slot. The slot is first-wins by
|
||||
* registration order — this plugin owning it is the default-deployment
|
||||
* convention, not an event-enforced invariant (a decider registered before /
|
||||
* `prepend`ed would win instead). This is not a composable authorization chain;
|
||||
* layered permission/audit/sandbox interception belongs on `tools/execute`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-fs-policy
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { FsError } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsPolicyExec } from './types.ts'
|
||||
|
||||
export type { FsPolicyExec } from './types.ts'
|
||||
|
||||
/**
|
||||
* Per-context observed-file state and the three `fs/*` decisions over it. One
|
||||
* instance is created per `apply()` so disposal can drop all state for HMR.
|
||||
*/
|
||||
class ObservedStateGate {
|
||||
/**
|
||||
* Observed-file state, keyed first by the owner object (weakly held, so a
|
||||
* collected session frees its state), then by {@link FsTarget.targetKey}. An
|
||||
* entry's PRESENCE is the prior-observation record.
|
||||
*/
|
||||
private observed = new WeakMap<object, Map<string, FsVersion>>()
|
||||
|
||||
/**
|
||||
* Derive the observed-state owner from the opaque event actor — normally the
|
||||
* active agent session. `undefined` when no owner can be derived (e.g. a
|
||||
* direct tool call with no agent); such calls read freely but cannot satisfy
|
||||
* the write/edit prior-observation policy.
|
||||
*/
|
||||
private owner(actor: object | undefined): object | undefined {
|
||||
return (actor as FsPolicyExec | undefined)?.agent?.session
|
||||
}
|
||||
|
||||
private get(owner: object, targetKey: string): FsVersion | undefined {
|
||||
return this.observed.get(owner)?.get(targetKey)
|
||||
}
|
||||
|
||||
private set(owner: object, targetKey: string, version: FsVersion): void {
|
||||
let byTarget = this.observed.get(owner)
|
||||
if (!byTarget) {
|
||||
byTarget = new Map()
|
||||
this.observed.set(owner, byTarget)
|
||||
}
|
||||
byTarget.set(targetKey, version)
|
||||
}
|
||||
|
||||
/** Drop all recorded state (HMR safety / disposal). */
|
||||
clear(): void {
|
||||
this.observed = new WeakMap()
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide the write intent: no prior observation ⇒ `createIfAbsent` (only
|
||||
* new files can be created blindly); a prior observation ⇒ `replaceIfVersion`
|
||||
* at the observed version (existing files replaced only if unchanged).
|
||||
*/
|
||||
writeIntent(target: FsTarget, actor: object | undefined): FsWriteIntent {
|
||||
const owner = this.owner(actor)
|
||||
const prior = owner ? this.get(owner, target.targetKey) : undefined
|
||||
return prior ? { kind: 'replaceIfVersion', version: prior } : { kind: 'createIfAbsent' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide the edit version guard: requires a prior observation by this owner
|
||||
* (else `FS_NOT_OBSERVED`); returns the observed version as the CAS basis.
|
||||
*/
|
||||
editIntent(target: FsTarget, actor: object | undefined): { version: FsVersion } {
|
||||
const owner = this.owner(actor)
|
||||
const prior = owner ? this.get(owner, target.targetKey) : undefined
|
||||
if (!owner || !prior) {
|
||||
throw new FsError(`edit requires reading "${target.displayPath}" first`, 'FS_NOT_OBSERVED')
|
||||
}
|
||||
return { version: prior }
|
||||
}
|
||||
|
||||
/** Record a successful read/write/edit: this owner observed this target at this version. */
|
||||
observe(target: FsTarget, version: FsVersion, actor: object | undefined): void {
|
||||
const owner = this.owner(actor)
|
||||
if (owner) this.set(owner, target.targetKey, version)
|
||||
}
|
||||
}
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'fs-policy'
|
||||
|
||||
/**
|
||||
* Register the three `fs/*` listeners. No `inject` — this plugin reads no
|
||||
* services; it operates only on its own `WeakMap`. The waterfalls are unbound
|
||||
* (the tool dispatches them with no `this`), so the listeners take the raw
|
||||
* `(target, actor, next)` arguments.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const gate = new ObservedStateGate()
|
||||
|
||||
ctx.effect(() => () => {
|
||||
// Drop all recorded state on disposal so a reloaded plugin starts clean
|
||||
// (HMR safety). The WeakMap itself would be GC'd, but replacing it makes the
|
||||
// release observable and immediate for tests.
|
||||
gate.clear()
|
||||
}, 'fs-policy observed-state teardown')
|
||||
|
||||
// fs/write-intent: occupy the single decision slot — do NOT call next().
|
||||
// Deferred through Promise.resolve().then so the declared Promise return type
|
||||
// holds (a throw rejects, never escapes synchronously through the waterfall).
|
||||
ctx.on('fs/write-intent', (target, actor) => Promise.resolve().then(() => gate.writeIntent(target, actor)))
|
||||
|
||||
// fs/edit-intent: occupy the single decision slot — do NOT call next().
|
||||
// Deferred the same way so an FS_NOT_OBSERVED throw becomes a rejected promise
|
||||
// the edit tool's `await ctx.waterfall(...)` surfaces as its isError result.
|
||||
ctx.on('fs/edit-intent', (target, actor) => Promise.resolve().then(() => gate.editIntent(target, actor)))
|
||||
|
||||
// fs/observed: synchronous, side-effect-only WeakMap write. The tool emits
|
||||
// this with a plain (unguarded) ctx.emit, so this listener MUST NOT throw —
|
||||
// a throw would surface as the tool's isError result for a mutation that
|
||||
// already succeeded. A WeakMap.set honors that contract.
|
||||
ctx.on('fs/observed', (target, version, actor) => {
|
||||
gate.observe(target, version, actor)
|
||||
})
|
||||
}
|
||||
29
packages/fs/fs-policy/src/types.ts
Normal file
29
packages/fs/fs-policy/src/types.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Vocabulary for the fs-policy plugin: the minimal execution-context
|
||||
* shape used to derive an observed-state owner by narrowing the opaque `object`
|
||||
* actor the `fs/*` events carry.
|
||||
*
|
||||
* The provider vocabulary (`FsTarget`, `FsVersion`, write/edit shapes) is
|
||||
* re-used from `@deepseek-ai/dsh-fs`; this package owns only the observed-state
|
||||
* owner structure on top of it.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-fs-policy/types
|
||||
*/
|
||||
|
||||
/**
|
||||
* Minimal structural view of a tool execution the policy plugin needs to derive
|
||||
* an observed-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution` satisfies
|
||||
* this shape, so the tool passes its `exec` straight through as the opaque
|
||||
* `object` actor on the `fs/*` events; this plugin narrows that actor to this
|
||||
* shape without importing `dsh-tools`, `dsh-agent`, or `dsh-session`.
|
||||
*
|
||||
* The owner is `agent.session` when present. It is treated as an opaque object
|
||||
* identity (a `WeakMap` key); this package never reads any of its fields.
|
||||
*/
|
||||
export interface FsPolicyExec {
|
||||
/** The agent on whose behalf the call runs, when there is one. */
|
||||
agent?: {
|
||||
/** The session that owns observed-file state, used as an opaque key. */
|
||||
session?: object
|
||||
}
|
||||
}
|
||||
215
packages/fs/fs-policy/tests/policy.spec.ts
Normal file
215
packages/fs/fs-policy/tests/policy.spec.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Tests for the fs-policy PLUGIN: it registers no service, only the
|
||||
* three `fs/*` listeners. We dispatch those events directly (the unbound
|
||||
* waterfalls the tool would dispatch, and the `fs/observed` emit) and assert the
|
||||
* decisions: createIfAbsent vs replaceIfVersion, FS_NOT_OBSERVED for an unread
|
||||
* edit, observed-state-as-prior-observation (read/write/edit all record),
|
||||
* multi-owner isolation, single-slot first-wins, and disposal/HMR release.
|
||||
*
|
||||
* No `ctx.fs` provider is needed — the plugin does no filesystem I/O; it only
|
||||
* decides intents and records versions on its own WeakMap.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsTarget, FsWriteIntent } from '@deepseek-ai/dsh-fs'
|
||||
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
|
||||
import type { FsPolicyExec } from '@deepseek-ai/dsh-fs-policy'
|
||||
|
||||
function target(path: string): FsTarget {
|
||||
return { inputPath: path, targetKey: FsTargetKey(path), displayPath: path }
|
||||
}
|
||||
const ownerExec = (session: object): FsPolicyExec => ({ agent: { session } })
|
||||
|
||||
/** Dispatch the write-intent waterfall with the bare default thunk. */
|
||||
function writeIntent(ctx: Context, t: FsTarget, actor: object | undefined): Promise<FsWriteIntent | undefined> {
|
||||
return ctx.waterfall('fs/write-intent', t, actor, () => undefined)
|
||||
}
|
||||
/** Dispatch the edit-intent waterfall with the bare default thunk. */
|
||||
function editIntent(ctx: Context, t: FsTarget, actor: object | undefined): Promise<{ version: FsVersion } | undefined> {
|
||||
return ctx.waterfall('fs/edit-intent', t, actor, () => undefined)
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(FsPolicy)
|
||||
return { ctx, fiber }
|
||||
}
|
||||
|
||||
describe('registration / disposal', () => {
|
||||
it('registers no service surface (it is a plugin, not ctx.fsPolicy)', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect((ctx as Context & { fsPolicy?: unknown }).fsPolicy).toBeUndefined()
|
||||
})
|
||||
|
||||
it('mounts with no inject (reads no services)', async () => {
|
||||
// It mounts immediately even with nothing else in the context.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(FsPolicy)
|
||||
// The listener is live: an unobserved write decides createIfAbsent.
|
||||
expect(await writeIntent(ctx, target('a.txt'), undefined)).toEqual({ kind: 'createIfAbsent' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('write-intent decision', () => {
|
||||
it('an unobserved target decides createIfAbsent', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect(await writeIntent(ctx, target('a.txt'), ownerExec({}))).toEqual({ kind: 'createIfAbsent' })
|
||||
})
|
||||
|
||||
it('a no-owner actor decides createIfAbsent', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect(await writeIntent(ctx, target('a.txt'), undefined)).toEqual({ kind: 'createIfAbsent' })
|
||||
expect(await writeIntent(ctx, target('a.txt'), {})).toEqual({ kind: 'createIfAbsent' })
|
||||
})
|
||||
|
||||
it('an actor with an agent but no session has no owner (createIfAbsent)', async () => {
|
||||
// The middle optional-chain rung: agent present, session undefined ⇒ owner
|
||||
// undefined ⇒ unobservable, so a write can only be a blind create.
|
||||
const { ctx } = await setup()
|
||||
expect(await writeIntent(ctx, target('a.txt'), { agent: {} })).toEqual({ kind: 'createIfAbsent' })
|
||||
})
|
||||
|
||||
it('an observed target decides replaceIfVersion at the observed version', async () => {
|
||||
const { ctx } = await setup()
|
||||
const exec = ownerExec({})
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v7'), exec)
|
||||
expect(await writeIntent(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v7' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('edit-intent decision', () => {
|
||||
it('rejects an unread edit with FS_NOT_OBSERVED', async () => {
|
||||
const { ctx } = await setup()
|
||||
await expect(editIntent(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
|
||||
it('rejects an edit with no owner (cannot prove prior observation)', async () => {
|
||||
const { ctx } = await setup()
|
||||
await expect(editIntent(ctx, target('a.txt'), undefined)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
|
||||
it('rejects an edit whose actor has an agent but no session (no owner)', async () => {
|
||||
const { ctx } = await setup()
|
||||
await expect(editIntent(ctx, target('a.txt'), { agent: {} })).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
|
||||
it('returns the observed version as the CAS basis after an observation', async () => {
|
||||
const { ctx } = await setup()
|
||||
const exec = ownerExec({})
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v3'), exec)
|
||||
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v3' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('observed-state is the prior-observation record', () => {
|
||||
it('a read observation authorizes an in-place write at that version', async () => {
|
||||
const { ctx } = await setup()
|
||||
const exec = ownerExec({})
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec) // a read
|
||||
expect(await writeIntent(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v0' })
|
||||
})
|
||||
|
||||
it('a write/edit observation refreshes the basis, so the next edit needs no re-read', async () => {
|
||||
const { ctx } = await setup()
|
||||
const exec = ownerExec({})
|
||||
// A create records v1; the follow-up edit guards against v1 with no read.
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v1'), exec)
|
||||
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v1' })
|
||||
// The edit records v2; a second edit guards against v2.
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v2'), exec)
|
||||
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v2' })
|
||||
})
|
||||
|
||||
it('a no-owner observation records nothing', async () => {
|
||||
const { ctx } = await setup()
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), undefined)
|
||||
// Still unobserved for any owner.
|
||||
await expect(editIntent(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('multi-owner isolation', () => {
|
||||
it('owner A observing does not grant owner B edit authority', async () => {
|
||||
const { ctx } = await setup()
|
||||
const a = ownerExec({})
|
||||
const b = ownerExec({})
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), a)
|
||||
await expect(editIntent(ctx, target('a.txt'), b)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
expect(await editIntent(ctx, target('a.txt'), a)).toEqual({ version: 'v0' })
|
||||
})
|
||||
|
||||
it('each owner records its own observed version independently', async () => {
|
||||
const { ctx } = await setup()
|
||||
const a = ownerExec({})
|
||||
const b = ownerExec({})
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), a) // A observed v0
|
||||
// B never observed → createIfAbsent; A still holds v0 → replaceIfVersion.
|
||||
expect(await writeIntent(ctx, target('a.txt'), b)).toEqual({ kind: 'createIfAbsent' })
|
||||
expect(await writeIntent(ctx, target('a.txt'), a)).toEqual({ kind: 'replaceIfVersion', version: 'v0' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('single-slot, first-wins', () => {
|
||||
it('fully decides the slot without calling next() (the bare default is unreached)', async () => {
|
||||
const { ctx } = await setup()
|
||||
let defaultRan = false
|
||||
const intent = await ctx.waterfall('fs/write-intent', target('a.txt'), ownerExec({}), () => {
|
||||
defaultRan = true
|
||||
return undefined
|
||||
})
|
||||
expect(intent).toEqual({ kind: 'createIfAbsent' })
|
||||
expect(defaultRan).toBe(false)
|
||||
})
|
||||
|
||||
it('a SECOND decider registered AFTER fs-policy is not reached (first-wins short-circuit)', async () => {
|
||||
const { ctx } = await setup()
|
||||
let secondRan = false
|
||||
// Registered after fs-policy, so it dispatches second; fs-policy does
|
||||
// not call next(), so this never runs. (A decider registered BEFORE — or with
|
||||
// prepend — would instead win: first-wins is by convention, not enforced.)
|
||||
ctx.on('fs/edit-intent', () => {
|
||||
secondRan = true
|
||||
return Promise.resolve(undefined)
|
||||
})
|
||||
const exec = ownerExec({})
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec)
|
||||
await editIntent(ctx, target('a.txt'), exec)
|
||||
expect(secondRan).toBe(false)
|
||||
})
|
||||
|
||||
it('a SECOND write-intent decider registered AFTER fs-policy is not reached', async () => {
|
||||
const { ctx } = await setup()
|
||||
let secondRan = false
|
||||
ctx.on('fs/write-intent', () => {
|
||||
secondRan = true
|
||||
return Promise.resolve(undefined)
|
||||
})
|
||||
await writeIntent(ctx, target('a.txt'), ownerExec({}))
|
||||
expect(secondRan).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('disposal releases recorded state (HMR safety)', () => {
|
||||
it('a fresh plugin after disposal starts with no inherited state', async () => {
|
||||
const ctx = new Context()
|
||||
const exec = ownerExec({})
|
||||
const fiber = await ctx.plugin(FsPolicy)
|
||||
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec)
|
||||
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v0' })
|
||||
await fiber.dispose()
|
||||
|
||||
await ctx.plugin(FsPolicy)
|
||||
// Same owner object, but state was released on disposal.
|
||||
await expect(editIntent(ctx, target('a.txt'), exec)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
|
||||
it('no listeners remain after disposal (the gate no longer decides)', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(FsPolicy)
|
||||
await fiber.dispose()
|
||||
// With no listener, the waterfall falls through to the bare default.
|
||||
expect(await writeIntent(ctx, target('a.txt'), ownerExec({}))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
14
packages/fs/fs-policy/tsconfig.json
Normal file
14
packages/fs/fs-policy/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../fs" }
|
||||
]
|
||||
}
|
||||
44
packages/fs/fs/README.md
Normal file
44
packages/fs/fs/README.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# @deepseek-ai/dsh-fs
|
||||
|
||||
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)):
|
||||
|
||||
| Layer | Package | Role |
|
||||
|---|---|---|
|
||||
| tool / executor | `@deepseek-ai/dsh-tool-fs` | model-facing `read`/`write`/`edit` schemas + read windowing + text rendering; reads/writes/edits via `ctx.fs`, dispatches the `fs/*` events |
|
||||
| policy | `@deepseek-ai/dsh-fs-policy` | observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate (no service) |
|
||||
| provider seam | `@deepseek-ai/dsh-fs` (this) | `ctx.fs`: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` event vocabulary |
|
||||
| provider | `@deepseek-ai/dsh-fs-local` | the host-filesystem implementation |
|
||||
|
||||
A future sandboxed, virtual, or remote backend implements this interface and the policy/tool layers don't change.
|
||||
|
||||
## Service API (`ctx.fs`)
|
||||
|
||||
A backend subclasses `FileSystem` and implements seven primitives.
|
||||
|
||||
| Member | Semantics |
|
||||
|---|---|
|
||||
| `resolve(path, opts?)` | Resolve a path into a stable `FsTarget` (`inputPath`, opaque `targetKey`, `displayPath`). `opts.cwd` is the base a relative `path` resolves against (a caller supplies its session workspace; absolute paths ignore it; omitted ⇒ the backend default). Async — a remote backend may need I/O. The same file via different paths must yield the same `targetKey`. |
|
||||
| `stat(target, signal?)` | Return `FsInfo` metadata (`version`, `type`, optional `size`), or `undefined` when the target is absent. Never content. |
|
||||
| `readText(target, signal?)` | Read the whole regular text file as one decoded string. Owns regular-file checks, UTF-8 decoding, binary/NUL rejection (`FS_NOT_TEXT`). |
|
||||
| `streamText(target, signal?)` | Stream the same text as decoded chunks for large files (cross-chunk UTF-8 decoding stays here). |
|
||||
| `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. |
|
||||
|
||||
The mutation runs inside the backend's per-target lock either way, so an unconditional write/edit is still atomic — "unconditional" drops the *version* precondition, not the atomicity.
|
||||
|
||||
## The `fs/*` policy events
|
||||
|
||||
This package declares three events (see the generated [catalog](../../../docs/cordis-catalog/events-and-services.md)) so the emitter (`@deepseek-ai/dsh-tool-fs`) and the policy listener (`@deepseek-ai/dsh-fs-policy`) share a vocabulary without the emitter depending on the policy plugin. `fs/write-intent` and `fs/edit-intent` are single-slot decision waterfalls (the listener fully decides, never calling `next()`); `fs/observed` is a fire-and-forget recording event. They carry only `dsh-fs` vocabulary plus an opaque `object` actor — no model-facing concepts and no agent/session owner structure.
|
||||
|
||||
## A provider seam, not the policy layer
|
||||
|
||||
`ctx.fs` is deliberately close to fsspec-style storage primitives — half a level above byte-level `cat`/`open`, because it decodes text and rejects binaries so the policy layer never touches raw bytes. It owns UTF-8 decoding, binary rejection, atomic writes, and the literal-edit critical section. It does **not** own line windows, numbered lines, rendered footers, or observed-state. Observed-state, read-before-edit, and version-guarded write/edit are policy a plugin (`@deepseek-ai/dsh-fs-policy`) ADDS by supplying the optional guard — not provider behavior — so a sandboxed/remote backend inherits no model-facing observation policy.
|
||||
|
||||
`editText` stays on this seam (not composed in the policy layer from a read plus a write) because version guard + literal match + atomic rewrite must stay inside one critical section for correct error attribution and one-wins/one-stale concurrency, and a remote backend may implement it as a native compare-and-edit.
|
||||
|
||||
## Vocabulary
|
||||
|
||||
`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids RFC](../../../docs/rfc/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy RFC](../../../docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_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.
|
||||
34
packages/fs/fs/package.json
Normal file
34
packages/fs/fs/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-fs",
|
||||
"description": "Abstract filesystem capability seam (ctx.fs) for the DeepSeek Harness — vocabulary types, the FileSystem service (text IO + optional version-guarded atomic mutations), and the fs/* policy event vocabulary",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
224
packages/fs/fs/src/index.ts
Normal file
224
packages/fs/fs/src/index.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* The filesystem provider seam (`ctx.fs`): an abstract service defining the
|
||||
* text-storage primitives a backend provides — resolve a path into a stable
|
||||
* target, stat its metadata, read/stream its text, write it atomically with an
|
||||
* explicit intent, and apply a guarded literal edit — without saying HOW.
|
||||
* Implementations subclass {@link FileSystem} and register themselves as the
|
||||
* `fs` service; `@deepseek-ai/dsh-fs-local` (the host filesystem) is the first.
|
||||
* Future implementations swap in sandboxed, remote, virtual, or project-scoped
|
||||
* backends without touching the model-facing tool schemas
|
||||
* (`@deepseek-ai/dsh-tool-fs`).
|
||||
*
|
||||
* The split mirrors the bash seam (`BashExecutor`/`LocalBashExecutor`). See the
|
||||
* capability-seam RFC for why a swappable capability is three (here four)
|
||||
* packages.
|
||||
*
|
||||
* ## This is a provider seam, not the policy layer
|
||||
*
|
||||
* `ctx.fs` is deliberately close to fsspec-style storage primitives. It owns
|
||||
* UTF-8 decoding, binary/NUL rejection, atomic full-file writes, and the
|
||||
* literal-edit critical section — but NOT line windows, numbered lines,
|
||||
* rendered footers, or observed-state. Read windowing lives in the model-facing
|
||||
* tool (`@deepseek-ai/dsh-tool-fs`); observed-state and read-before-write/edit
|
||||
* are policy a plugin (`@deepseek-ai/dsh-fs-policy`) adds through the `fs/*`
|
||||
* event gate. So a sandboxed/remote backend inherits no model-facing observation
|
||||
* policy it has no business carrying.
|
||||
*
|
||||
* `editText` stays on this seam (not composed in the policy layer from a read
|
||||
* plus a write) because version guard + literal match + atomic rewrite must
|
||||
* stay inside one mutation critical section for correct error attribution and
|
||||
* one-wins/one-stale concurrency, and a remote backend may implement it as a
|
||||
* native compare-and-edit.
|
||||
*
|
||||
* ## The version guard is OPTIONAL — additive policy, not subtractive
|
||||
*
|
||||
* `ctx.fs` on its own is a complete, unconstrained text-storage seam: `read`
|
||||
* reads, `write` unconditionally creates-or-overwrites, `edit` unconditionally
|
||||
* replaces literal text in the current content. Both mutations take their
|
||||
* version guard as an OPTIONAL argument — omit it for the unconstrained
|
||||
* bare-provider behavior, supply it to guard against a concurrent change. The
|
||||
* mutation runs inside the backend's per-target lock either way, so an
|
||||
* unconditional write/edit is still atomic; "unconditional" drops the *version*
|
||||
* precondition, not the atomicity. Observed-state, read-before-edit, and
|
||||
* version-guarded write/edit are NOT provider behavior — they are policy a
|
||||
* plugin (`@deepseek-ai/dsh-fs-policy`) adds on top by supplying the guard.
|
||||
*
|
||||
* ## The fs policy events live here, not in the policy plugin
|
||||
*
|
||||
* This package owns the `fs/write-intent`, `fs/edit-intent`, and
|
||||
* `fs/observed` event vocabulary (see {@link Events}). The emitter is
|
||||
* `@deepseek-ai/dsh-tool-fs` and the default listener is
|
||||
* `@deepseek-ai/dsh-fs-policy`; the events live in the one package both
|
||||
* already depend on, so the emitter shares a vocabulary with the policy listener
|
||||
* without depending on the policy plugin. The events carry only `dsh-fs`
|
||||
* vocabulary plus an opaque `object` actor — no model-facing concepts (line
|
||||
* windows, numbered lines) and no agent/session owner structure leak down.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-fs
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type {
|
||||
FsDirEntry,
|
||||
FsEditOutcome,
|
||||
FsEditRequest,
|
||||
FsInfo,
|
||||
FsTarget,
|
||||
FsVersion,
|
||||
FsWriteIntent,
|
||||
FsWriteOutcome,
|
||||
} from './types.ts'
|
||||
|
||||
export {
|
||||
FsError,
|
||||
FsTargetKey,
|
||||
FsVersion,
|
||||
} from './types.ts'
|
||||
export type {
|
||||
FsEditOutcome,
|
||||
FsEditRequest,
|
||||
FsDirEntry,
|
||||
FsErrorCode,
|
||||
FsInfo,
|
||||
FsTarget,
|
||||
FsWriteIntent,
|
||||
FsWriteOutcome,
|
||||
} from './types.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
fs: FileSystem
|
||||
}
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* Single-slot decision: produce the write intent for the next
|
||||
* {@link FileSystem.writeText}. The tool dispatches this as an unbound
|
||||
* waterfall (no `this`) and supplies a default thunk returning `undefined`
|
||||
* (unconditional create-or-overwrite — the bare provider). The
|
||||
* `@deepseek-ai/dsh-fs-policy` policy listener returns `createIfAbsent`
|
||||
* (unobserved actor) or `{ kind: 'replaceIfVersion', version: vObserved }`
|
||||
* (observed) and does NOT call `next()` — one decision, not a composable
|
||||
* chain. The slot is first-wins: the first non-`next()` decider (registration
|
||||
* order, or `prepend`) occupies it; a second decider is a misconfiguration,
|
||||
* not layering. `actor` is the opaque tool-execution context, never read here.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise<FsWriteIntent | undefined>): Promise<FsWriteIntent | undefined>
|
||||
/**
|
||||
* Single-slot decision: produce the optional version guard for the next
|
||||
* {@link FileSystem.editText}. The tool dispatches this as an unbound
|
||||
* waterfall and supplies a default thunk returning `undefined` (unconditional
|
||||
* edit of the current content — the bare provider; no `stat`). The
|
||||
* `@deepseek-ai/dsh-fs-policy` policy listener returns
|
||||
* `{ version: vObserved }`, or throws `FS_NOT_OBSERVED` if the actor is unset
|
||||
* or has not observed the target. Does NOT call `next()`: one decision,
|
||||
* first-wins (see {@link Events.'fs/write-intent'}).
|
||||
* @mode waterfall
|
||||
*/
|
||||
'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined>
|
||||
/**
|
||||
* Record that an actor observed a target at a version, after a successful
|
||||
* read/write/edit. Fire-and-forget (plain `emit`). A listener MUST be a
|
||||
* synchronous, side-effect-only recorder (`@deepseek-ai/dsh-fs-policy`'s
|
||||
* is a `WeakMap.set`): the tool does not guard the emit, so a listener that
|
||||
* throws surfaces as the tool's `isError` result, and cordis `emit` does not
|
||||
* await listener promises — async or fallible audit/telemetry does not
|
||||
* belong here. No listener ⇒ nothing recorded. `actor` is the opaque
|
||||
* tool-execution context.
|
||||
* @mode emit
|
||||
*/
|
||||
'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract filesystem provider service. Subclass, implement the 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:
|
||||
* - {@link resolve} returns a stable {@link FsTarget}; the same underlying file
|
||||
* reached by different input paths must yield the same `targetKey` so stale
|
||||
* guards and target lookup agree across paths (e.g. through symlinks).
|
||||
* - {@link stat} returns {@link FsInfo} metadata (never content) or `undefined`
|
||||
* when the target is absent.
|
||||
* - {@link readText}/{@link streamText} read the whole regular text file (the
|
||||
* stream for large files); both own regular-file checks, UTF-8 decoding,
|
||||
* binary/NUL rejection, and `FS_NOT_TEXT`.
|
||||
* - {@link 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.
|
||||
* - {@link editText} verifies `expected.version` BEFORE literal matching (so a
|
||||
* stale edit reports `FS_STALE_VERSION`, not `FS_EDIT_NOT_FOUND`/
|
||||
* `FS_AMBIGUOUS_EDIT` against newer content), then applies literal replacement
|
||||
* and writes atomically — all inside one mutation critical section. `expected`
|
||||
* is OPTIONAL: omit it for an unconditional edit of the current content (a
|
||||
* missing target still reports `FS_STALE_VERSION`).
|
||||
*/
|
||||
export abstract class FileSystem extends Service {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'fs')
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a model/plugin-supplied path into a stable {@link FsTarget}. May
|
||||
* perform I/O (a remote/sandboxed backend may need a round-trip to map a path
|
||||
* to a stable identity), hence async even though the local backend only
|
||||
* normalizes + realpaths.
|
||||
*
|
||||
* `opts.cwd` is the base directory a RELATIVE `path` resolves against; an
|
||||
* absolute `path` ignores it. Omitted ⇒ the backend's own default base (the
|
||||
* local backend uses its configured `cwd`). The CALLER supplies this — the
|
||||
* seam does not read a session or agent — so a tool can resolve against the
|
||||
* caller's per-session workspace (`exec.agent.session.header.cwd`) without the
|
||||
* provider depending on `dsh-agent`/`dsh-session`. Mirrors how `dsh-tool-bash`
|
||||
* defaults a bash `workdir` to the session cwd.
|
||||
*/
|
||||
abstract resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget>
|
||||
|
||||
/** Return target metadata, or `undefined` when the target does not exist. */
|
||||
abstract stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined>
|
||||
|
||||
/** Read the whole regular text file as a single decoded string. */
|
||||
abstract readText(target: FsTarget, signal?: AbortSignal): Promise<string>
|
||||
|
||||
/**
|
||||
* Stream the whole regular text file as decoded text chunks (same text
|
||||
* semantics as {@link readText}, for large files). The backend owns
|
||||
* cross-chunk UTF-8 decoding and binary rejection so the policy layer never
|
||||
* touches raw bytes.
|
||||
*/
|
||||
abstract streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>>
|
||||
|
||||
/**
|
||||
* 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<FsDirEntry[]>
|
||||
|
||||
/**
|
||||
* Create or fully replace a UTF-8 text file atomically. `expected` is the
|
||||
* create-vs-replace decision and stale guard when supplied; OMITTING it is an
|
||||
* unconditional create-or-overwrite (the bare provider — no version guard, no
|
||||
* read-first requirement). Atomic either way.
|
||||
*/
|
||||
abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise<FsWriteOutcome>
|
||||
|
||||
/**
|
||||
* Apply a literal edit to an existing UTF-8 text file. When `expected` is
|
||||
* supplied, verifies `expected.version` as the stale guard BEFORE literal
|
||||
* matching; OMITTING it edits the current content unconditionally (no version
|
||||
* guard). Either way applies the replacement and writes atomically — one
|
||||
* mutation critical section — and a missing target reports `FS_STALE_VERSION`.
|
||||
*/
|
||||
abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome>
|
||||
}
|
||||
|
||||
export default FileSystem
|
||||
192
packages/fs/fs/src/types.ts
Normal file
192
packages/fs/fs/src/types.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Vocabulary for the filesystem provider seam (`ctx.fs`): the opaque
|
||||
* target/version identities, the metadata `stat` returns, the write-intent
|
||||
* and outcome shapes, the literal-edit request/outcome, and the typed error
|
||||
* taxonomy.
|
||||
*
|
||||
* These types are shared by every backend (`@deepseek-ai/dsh-fs-local` and
|
||||
* future sandboxed/remote backends) and by the policy layer
|
||||
* (`@deepseek-ai/dsh-fs-policy`). They are deliberately a *text-storage*
|
||||
* vocabulary half a level above byte-level fsspec: `readText`/`streamText` hand
|
||||
* back decoded text, never raw bytes. Host-path assumptions stay out — `targetKey`
|
||||
* and `version` are opaque branded tokens, and `displayPath` is the only field a
|
||||
* consumer may show.
|
||||
*
|
||||
* Model-facing concepts (line windows, numbered lines, observed-state) do NOT
|
||||
* live here; they belong to the consumer tool and the policy plugin
|
||||
* (`@deepseek-ai/dsh-tool-fs` / `@deepseek-ai/dsh-fs-policy`).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-fs/types
|
||||
*/
|
||||
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
/**
|
||||
* Opaque key for stale guards and target lookup. The local backend uses a
|
||||
* realpath-like string; a remote backend might use a workspace URI or file id.
|
||||
* Consumers MUST NOT parse it or assume it is a local absolute path.
|
||||
*/
|
||||
export type FsTargetKey = Branded<'FsTargetKey'>
|
||||
|
||||
/** Brand a string as an {@link FsTargetKey}. */
|
||||
export function FsTargetKey(key: string): FsTargetKey {
|
||||
return key as FsTargetKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Opaque file-version token — the freshness token a write/edit guards against.
|
||||
* The local backend derives it from mtime+size; a remote backend might use a
|
||||
* revision id. The policy layer records it for stale checks; consumers may
|
||||
* display related metadata but MUST NOT interpret this token.
|
||||
*/
|
||||
export type FsVersion = Branded<'FsVersion'>
|
||||
|
||||
/** Brand a string as an {@link FsVersion}. */
|
||||
export function FsVersion(v: string): FsVersion {
|
||||
return v as FsVersion
|
||||
}
|
||||
|
||||
/**
|
||||
* A path resolved by a backend into a stable identity. `resolve()` produces
|
||||
* this; every other operation takes it.
|
||||
*/
|
||||
export interface FsTarget {
|
||||
/** The original model/plugin-supplied path, for diagnostics only. */
|
||||
inputPath: string
|
||||
/** Opaque key for stale guards and target lookup. */
|
||||
targetKey: FsTargetKey
|
||||
/**
|
||||
* Path for model/UI-facing output. May be a local absolute path,
|
||||
* workspace-relative path, or remote URI depending on the backend.
|
||||
*/
|
||||
displayPath: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata about a target — what {@link FileSystem.stat} returns. Lets the
|
||||
* policy layer reject directories/special files before reading and choose
|
||||
* `readText` vs `streamText` from `size` without probing by failure. `version`
|
||||
* is the freshness token. `undefined` from `stat` means the target is absent.
|
||||
*/
|
||||
export interface FsInfo {
|
||||
/** Opaque freshness token of the target right now. */
|
||||
version: FsVersion
|
||||
/** Whether the target is a regular file, a directory, or something else. */
|
||||
type: 'file' | 'directory' | 'other'
|
||||
/** Byte size of a regular file, when the backend can report it. */
|
||||
size?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* `FS_NOT_OBSERVED` (the path the policy plugin uses when the owner has no prior
|
||||
* read). `replaceIfVersion` replaces only when the target exists at the observed
|
||||
* version; a missing target or a version mismatch throws `FS_STALE_VERSION`.
|
||||
*
|
||||
* `writeText` takes this OPTIONALLY: omitting `expected` is the third,
|
||||
* unconstrained state — an unconditional create-or-overwrite (the bare
|
||||
* provider). The union itself carries only the two GUARDED intents; "no guard"
|
||||
* is expressed by omission, so the write and edit mutations share one symmetric
|
||||
* shape (`expected?`: omit = unconditional, present = guarded).
|
||||
*/
|
||||
export type FsWriteIntent =
|
||||
| { kind: 'createIfAbsent' }
|
||||
| { kind: 'replaceIfVersion'; version: FsVersion }
|
||||
|
||||
/** Outcome of a full-file write. */
|
||||
export interface FsWriteOutcome {
|
||||
/** Whether the write created a new file or replaced an existing one. */
|
||||
operation: 'create' | 'update'
|
||||
/** Opaque version of the file after the write. */
|
||||
version: FsVersion
|
||||
/**
|
||||
* The file's content BEFORE the write, or `null` when the file did not exist
|
||||
* (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. */
|
||||
after: string
|
||||
}
|
||||
|
||||
/** 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
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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_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'
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
146
packages/fs/fs/tests/service.spec.ts
Normal file
146
packages/fs/fs/tests/service.spec.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Tests for the filesystem provider seam itself: registration, duplicate-service
|
||||
* behavior, disposal, and the branded id factories. The provider primitives and
|
||||
* policy live in `dsh-fs-local` and `dsh-fs-policy`; this seam owns only the
|
||||
* abstract service contract, so a minimal fake backend exercises it.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type {
|
||||
FsDirEntry,
|
||||
FsEditOutcome,
|
||||
FsEditRequest,
|
||||
FsInfo,
|
||||
FsTarget,
|
||||
FsWriteIntent,
|
||||
FsWriteOutcome,
|
||||
} from '@deepseek-ai/dsh-fs'
|
||||
|
||||
/** A minimal in-memory fake implementing the seven provider primitives. */
|
||||
class FakeFileSystem extends FileSystem {
|
||||
files = new Map<string, string>()
|
||||
|
||||
override async resolve(path: string): Promise<FsTarget> {
|
||||
return { inputPath: path, targetKey: FsTargetKey(path), displayPath: path }
|
||||
}
|
||||
override async stat(target: FsTarget): Promise<FsInfo | undefined> {
|
||||
const content = this.files.get(target.targetKey)
|
||||
if (content === undefined) return undefined
|
||||
return { version: FsVersion('v1'), type: 'file', size: content.length }
|
||||
}
|
||||
override async readText(target: FsTarget): Promise<string> {
|
||||
const content = this.files.get(target.targetKey)
|
||||
if (content === undefined) throw new FsError(`not found: ${target.displayPath}`, 'FS_NOT_FOUND')
|
||||
return content
|
||||
}
|
||||
override async streamText(target: FsTarget): Promise<AsyncIterable<string>> {
|
||||
const content = await this.readText(target)
|
||||
return (async function* () { yield content })()
|
||||
}
|
||||
override async listDir(target: FsTarget): Promise<FsDirEntry[]> {
|
||||
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<FsWriteOutcome> {
|
||||
const before = this.files.get(target.targetKey) ?? null
|
||||
this.files.set(target.targetKey, content)
|
||||
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) ?? ''
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
describe('FileSystem provider seam', () => {
|
||||
it('registers as ctx.fs and serves the primitives', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(FakeFileSystem)
|
||||
const fs = ctx.fs as FakeFileSystem
|
||||
fs.files.set('a.txt', 'hi')
|
||||
const target = await fs.resolve('a.txt')
|
||||
expect((await fs.stat(target))?.type).toBe('file')
|
||||
expect(await fs.readText(target)).toBe('hi')
|
||||
})
|
||||
|
||||
it('throws when a second implementation is loaded (duplicate service)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(FakeFileSystem)
|
||||
await expect(ctx.plugin(FakeFileSystem)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('removes the service when the providing fiber is disposed', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(FakeFileSystem)
|
||||
expect(ctx.fs).toBeDefined()
|
||||
await fiber.dispose()
|
||||
expect(ctx.fs).toBeUndefined()
|
||||
})
|
||||
|
||||
it('streamText yields the same text readText returns', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(FakeFileSystem)
|
||||
const fs = ctx.fs as FakeFileSystem
|
||||
fs.files.set('a.txt', 'one\ntwo')
|
||||
const target = await fs.resolve('a.txt')
|
||||
let streamed = ''
|
||||
for await (const chunk of await fs.streamText(target)) streamed += chunk
|
||||
expect(streamed).toBe(await fs.readText(target))
|
||||
})
|
||||
|
||||
it('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)
|
||||
const fs = ctx.fs as FakeFileSystem
|
||||
expect(await fs.stat(await fs.resolve('missing.txt'))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('branded id factories', () => {
|
||||
it('FsTargetKey and FsVersion brand a string at compile time (identity at runtime)', () => {
|
||||
expect(FsTargetKey('k')).toBe('k')
|
||||
expect(FsVersion('v')).toBe('v')
|
||||
})
|
||||
})
|
||||
|
||||
describe('FsError', () => {
|
||||
it('carries a stable code and HarnessError name', () => {
|
||||
const error = new FsError('nope', 'FS_NOT_FOUND')
|
||||
expect(error.code).toBe('FS_NOT_FOUND')
|
||||
expect(error.name).toBe('FsError')
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
})
|
||||
|
||||
it('chains an underlying cause through ErrorOptions', () => {
|
||||
const root = new Error('EACCES')
|
||||
const error = new FsError('cannot read', 'FS_ABORTED', { cause: root })
|
||||
expect(error.cause).toBe(root)
|
||||
expect(error.code).toBe('FS_ABORTED')
|
||||
})
|
||||
})
|
||||
14
packages/fs/fs/tsconfig.json
Normal file
14
packages/fs/fs/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../util/brand" },
|
||||
{ "path": "../../llm/llm" }
|
||||
]
|
||||
}
|
||||
38
packages/fs/tool-fs/README.md
Normal file
38
packages/fs/tool-fs/README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# @deepseek-ai/dsh-tool-fs
|
||||
|
||||
The **model-facing filesystem tools** — `read`, `write`, `edit` — and their **executor**. This is the consumer layer of the filesystem stack: it owns tool names, JSON schemas, argument validation, prompt sections, **read windowing**, and result formatting. It reads/writes/edits through the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)) **directly** — it injects `fs` (plus `tools`/`systemPrompt`), **not** a policy service. The freshness/observation policy is contributed by a separate plugin ([`@deepseek-ai/dsh-fs-policy`](../fs-policy)) through the `fs/*` event gate; the tool is not method-coupled to it.
|
||||
|
||||
```ts ignore-check
|
||||
// Default deployment: a ctx.fs provider, the policy plugin, then the tools.
|
||||
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) // @deepseek-ai/dsh-fs-local
|
||||
await ctx.plugin(FsPolicy) // @deepseek-ai/dsh-fs-policy (policy gate)
|
||||
await ctx.plugin(ToolFs) // this package — registers read/write/edit
|
||||
```
|
||||
|
||||
`@deepseek-ai/dsh-fs-policy` is **optional**: omit it and the tools run against the bare provider (unconditional write/overwrite/edit, no observed-state). A deployment that loads these tools is expected to also load it, so the behavior is read-before-write/edit.
|
||||
|
||||
## Tools (schemas per [the filesystem tool schemas RFC](../../../docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md))
|
||||
|
||||
| Tool | Arguments | Behavior |
|
||||
|---|---|---|
|
||||
| `read` | `file_path`, `offset?`, `limit?` | Line-numbered UTF-8 content with a pagination footer. `offset` is 1-based; `limit` defaults to and caps at 2000 lines. |
|
||||
| `write` | `file_path`, `content` | Create or fully replace a file. With the policy plugin: overwriting an existing file requires a prior `read` at the unchanged version; creating a new file does not. Without it: unconditional. |
|
||||
| `edit` | `file_path`, non-empty `old_string`, `new_string`, `replace_all?` | Literal replacement; unique match required unless `replace_all` is true. With the policy plugin: requires a prior `read` (any window) and the file unchanged since. Without it: unconditional. |
|
||||
|
||||
Field names are snake_case to match Claude Code and existing harness tool schemas.
|
||||
|
||||
## The tool is the executor; policy is an event gate
|
||||
|
||||
The tools do **not** inject a policy service or inspect any cache. Each tool resolves the path via `ctx.fs.resolve(path, { cwd })` — passing the calling agent's session cwd (`exec.agent.session.header.cwd`) so a relative path resolves against the session's workspace, matching `dsh-tool-bash` (see [the per-session cwd RFC](../../../docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md)) — then:
|
||||
|
||||
- **read** — one `ctx.fs.stat` (type + size routing + version), then `readText`/`streamText`, then builds the line window, then emits `fs/observed` with a plain `ctx.emit`. (1 stat.)
|
||||
- **write** — `ctx.waterfall('fs/write-intent', target, exec, () => undefined)` for the optional guard, then `ctx.fs.writeText(target, content, intent)`, then `fs/observed`. (0 stat.)
|
||||
- **edit** — `ctx.waterfall('fs/edit-intent', target, exec, () => undefined)` for the optional guard, then `ctx.fs.editText(target, edit, intent)`, then `fs/observed`. (0 stat.)
|
||||
|
||||
The tool passes `exec` (the tool-execution context) as the opaque `actor` on every dispatch. The default thunks return `undefined` (the unconstrained bare provider). When `@deepseek-ai/dsh-fs-policy` is loaded it occupies the single decision slot — returning `createIfAbsent`/`replaceIfVersion`/`{ version }` or throwing `FS_NOT_OBSERVED` — and records on `fs/observed`. Backend errors (`FsError`) and a thrown `FS_NOT_OBSERVED` flow through `ToolRegistry.execute()` and become `isError` tool results with their `{ name, code }` attached.
|
||||
|
||||
## `fs/observed` is fire-and-forget
|
||||
|
||||
`fs/observed` fires AFTER the read/write/edit already succeeded, via a plain `ctx.emit`. A listener is contractually a synchronous, side-effect-only recorder (`@deepseek-ai/dsh-fs-policy`'s is a `WeakMap.set`); the tool does not guard the emit, so a listener that throws would surface as the tool's `isError` result — async or fallible observation does not belong on this event.
|
||||
|
||||
The read rendering (line windowing + output formatting) lives in `src/read-render.ts` (Cordis-free, independently unit-tested); `src/read.ts`/`write.ts`/`edit.ts` are the tool executors and `src/index.ts` composes them.
|
||||
48
packages/fs/tool-fs/package.json
Normal file
48
packages/fs/tool-fs/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-fs",
|
||||
"description": "Model-facing filesystem tools (read, write, edit) over the DeepSeek Harness filesystem seam (ctx.fs)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
92
packages/fs/tool-fs/src/diff.ts
Normal file
92
packages/fs/tool-fs/src/diff.ts
Normal 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'
|
||||
|
||||
/** 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. 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[] }
|
||||
|
||||
/**
|
||||
* 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: unknown): value is FileDiff {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
||||
const { path, oldText, newText } = value as Record<string, unknown>
|
||||
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
|
||||
* `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
|
||||
const diffs = (meta as Record<string, unknown>).diffs
|
||||
if (!Array.isArray(diffs) || diffs.length === 0 || !diffs.every(isFileDiff)) return undefined
|
||||
return diffs
|
||||
}
|
||||
121
packages/fs/tool-fs/src/edit.ts
Normal file
121
packages/fs/tool-fs/src/edit.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* The model-facing `edit` tool: update an existing UTF-8 text file by replacing
|
||||
* literal text, requiring a unique match by default. The tool is the executor:
|
||||
* it dispatches the `fs/edit-intent` waterfall to obtain the optional
|
||||
* version guard, calls `ctx.fs.editText` directly, and emits `fs/observed`. The
|
||||
* default thunk returns `undefined` (unconditional edit of the current content
|
||||
* — the bare provider); a policy plugin (`@deepseek-ai/dsh-fs-policy`)
|
||||
* occupies the single decision slot, returning `{ version: vObserved }` or
|
||||
* throwing `FS_NOT_OBSERVED` for an unread file. The tool stats ZERO times
|
||||
* either way; a missing target is reported by the provider as `FS_STALE_VERSION`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs/src/edit
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { 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. */
|
||||
interface EditInput {
|
||||
filePath: string
|
||||
oldString: string
|
||||
newString: string
|
||||
replaceAll: boolean
|
||||
}
|
||||
|
||||
/** Validate value constraints the schema DSL can't express. */
|
||||
export function parseEditArgs(args: { file_path: string; old_string: string; new_string: string; replace_all?: boolean }): EditInput {
|
||||
if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string')
|
||||
if (args.old_string.length === 0) throw new Error('old_string must be a non-empty string')
|
||||
if (args.old_string === args.new_string) throw new Error('old_string and new_string must differ')
|
||||
return {
|
||||
filePath: args.file_path,
|
||||
oldString: args.old_string,
|
||||
newString: args.new_string,
|
||||
replaceAll: args.replace_all ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
/** Format an edit outcome as a Claude-style model-facing success message. */
|
||||
export function formatEditOutput(displayPath: string, outcome: FsEditOutcome): string {
|
||||
return outcome.replaceAll
|
||||
? `The file ${displayPath} has been updated. All occurrences were successfully replaced.`
|
||||
: `The file ${displayPath} has been updated successfully.`
|
||||
}
|
||||
|
||||
/** Register the `edit` tool and its system-prompt guidance. */
|
||||
export function applyEditTool(ctx: Context): void {
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:edit',
|
||||
order: 102,
|
||||
text: 'Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.',
|
||||
})
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'edit',
|
||||
description: 'Edit an existing UTF-8 text file by replacing literal text.',
|
||||
parameters: {
|
||||
file_path: { type: 'string', required: true, description: 'Path to edit, resolved by the filesystem backend.' },
|
||||
old_string: { type: 'string', required: true, description: 'Literal text to replace. Must match exactly.' },
|
||||
new_string: { type: 'string', required: true, description: 'Literal replacement text. Use an empty string to delete the match.' },
|
||||
replace_all: { type: 'boolean', description: 'Replace all matches. Defaults to false; when false, old_string must appear exactly once.' },
|
||||
},
|
||||
async execute(args, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> {
|
||||
const input = parseEditArgs(args)
|
||||
const cwd = sessionCwd(exec)
|
||||
const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined)
|
||||
// Single-slot decision: the policy plugin returns { version: vObserved } or
|
||||
// throws FS_NOT_OBSERVED; the bare default is undefined (unconditional edit).
|
||||
// No stat — the bare default never manufactures a version basis.
|
||||
const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)
|
||||
const outcome = await ctx.fs.editText(
|
||||
target,
|
||||
{ oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll },
|
||||
intent,
|
||||
exec.signal,
|
||||
)
|
||||
// Record the observed version (a no-op when no policy plugin listens).
|
||||
ctx.emit('fs/observed', target, outcome.version, exec)
|
||||
// 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`
|
||||
// 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}`,
|
||||
diffs: [{ path: args.file_path, oldText: args.old_string || null, newText: args.new_string }],
|
||||
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 }
|
||||
},
|
||||
}))
|
||||
}
|
||||
49
packages/fs/tool-fs/src/index.ts
Normal file
49
packages/fs/tool-fs/src/index.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* The model-facing filesystem tool suite (`read`, `write`, `edit`) over the
|
||||
* `ctx.fs` provider seam. This single plugin registers all three tools.
|
||||
*
|
||||
* ## The tool is the executor; policy is an event gate
|
||||
*
|
||||
* The tool reads/writes/edits through `ctx.fs` DIRECTLY and owns model-facing
|
||||
* concerns only — tool names, JSON schemas, argument validation, prompt
|
||||
* sections, read windowing, result formatting. It does NOT inject a policy
|
||||
* service. Instead, on each write/edit it dispatches a single-slot waterfall
|
||||
* (`fs/write-intent`/`fs/edit-intent`) to obtain the OPTIONAL version guard, and
|
||||
* after every read/write/edit it emits `fs/observed` with a plain (unguarded)
|
||||
* `ctx.emit`. A policy plugin (`@deepseek-ai/dsh-fs-policy`) occupies the
|
||||
* decision slot and listens for `fs/observed` to add observed-state +
|
||||
* read-before-edit + version-guarded write/edit; a deployment that loads these
|
||||
* tools is expected to also load it. With no policy plugin the waterfalls fall
|
||||
* through to their `undefined` default (the unconstrained bare provider) and
|
||||
* `fs/observed` is unheard — the tool still functions. This package never
|
||||
* imports `node:fs`, `node:path`, or an `@deepseek-ai/dsh-fs-local`
|
||||
* implementation.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { applyReadTool } from './read.ts'
|
||||
import { applyWriteTool } from './write.ts'
|
||||
import { applyEditTool } from './edit.ts'
|
||||
|
||||
export { READ_LIMIT, STREAM_MIN_SIZE, applyReadTool, parseReadArgs } from './read.ts'
|
||||
export { applyWriteTool, formatWriteOutput, parseWriteArgs } from './write.ts'
|
||||
export { applyEditTool, formatEditOutput, parseEditArgs } from './edit.ts'
|
||||
export { READ_MAX_BYTES, READ_MAX_LINE_LENGTH, buildWindow, formatReadOutput } from './read-render.ts'
|
||||
export type { FileReadOutcome, FileTextLine, ReadWindow, WindowResult } from './read-render.ts'
|
||||
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'
|
||||
|
||||
/** Services required by the filesystem tool suite. */
|
||||
export const inject = ['tools', 'fs', 'systemPrompt']
|
||||
|
||||
/** Register the full `read`/`write`/`edit` filesystem tool suite. */
|
||||
export function apply(ctx: Context): void {
|
||||
applyReadTool(ctx)
|
||||
applyWriteTool(ctx)
|
||||
applyEditTool(ctx)
|
||||
}
|
||||
180
packages/fs/tool-fs/src/read-render.ts
Normal file
180
packages/fs/tool-fs/src/read-render.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Cordis-free read rendering for `@deepseek-ai/dsh-tool-fs`: turn a file's
|
||||
* decoded text into a bounded, line-numbered window (offset/limit, byte cap,
|
||||
* per-line truncation) and format it as the model-facing text block. This is
|
||||
* the `read` tool's RENDERING detail — not a storage primitive, not freshness
|
||||
* policy — so it lives apart from the tool's I/O and event wiring as a pure,
|
||||
* independently-testable module (no cordis, no filesystem).
|
||||
*
|
||||
* The provider (`ctx.fs.readText`/`streamText`) hands back already-decoded text
|
||||
* (UTF-8 validated, binary rejected); {@link buildWindow} only scans that text
|
||||
* for newlines and builds the requested window. A capped line buffer means a
|
||||
* newline-free giant line can never balloon memory even when streamed.
|
||||
* {@link formatReadOutput} turns the resulting {@link FileReadOutcome} into the
|
||||
* `<path>/<content>` envelope the model sees.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs/read-render
|
||||
*/
|
||||
|
||||
import { FsError } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
|
||||
/** Maximum characters returned for a single line. */
|
||||
export const READ_MAX_LINE_LENGTH = 2000
|
||||
|
||||
/** Maximum bytes returned for selected file lines. */
|
||||
export const READ_MAX_BYTES = 50 * 1024
|
||||
|
||||
const READ_MAX_LINE_SUFFIX = `... (line truncated to ${READ_MAX_LINE_LENGTH} chars)`
|
||||
const LINE_BUFFER_CAP = READ_MAX_LINE_LENGTH + 1
|
||||
|
||||
/** Resolved read window. The consumer applies its defaults/caps before calling. */
|
||||
export interface ReadWindow {
|
||||
/** 1-based first line to return. */
|
||||
offset: number
|
||||
/** Maximum number of lines to return. */
|
||||
limit: number
|
||||
}
|
||||
|
||||
/** One line returned from a text file. */
|
||||
export interface FileTextLine {
|
||||
/** 1-based line number in the file. */
|
||||
number: number
|
||||
/** Line text without its trailing newline. */
|
||||
text: string
|
||||
}
|
||||
|
||||
/** The windowed result {@link buildWindow} produces from a file's decoded text. */
|
||||
export interface WindowResult {
|
||||
/** Returned lines, already numbered. */
|
||||
lines: FileTextLine[]
|
||||
/** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */
|
||||
totalLines: number
|
||||
/** Whether selected output hit the byte cap before EOF or the requested limit. */
|
||||
truncatedByBytes: boolean
|
||||
}
|
||||
|
||||
/** Outcome of a bounded text read — what {@link formatReadOutput} renders. */
|
||||
export interface FileReadOutcome {
|
||||
/** 1-based first line requested. */
|
||||
offset: number
|
||||
/** Maximum number of lines requested. */
|
||||
limit: number
|
||||
/** Returned lines, already numbered. */
|
||||
lines: FileTextLine[]
|
||||
/** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */
|
||||
totalLines: number
|
||||
/** Whether selected output hit the byte cap before EOF or the requested limit. */
|
||||
truncatedByBytes?: true
|
||||
/** Opaque version of the file at read time. */
|
||||
version: FsVersion
|
||||
}
|
||||
|
||||
interface WindowAccumulator {
|
||||
lines: FileTextLine[]
|
||||
totalLines: number
|
||||
outputBytes: number
|
||||
truncatedByBytes: boolean
|
||||
done: boolean
|
||||
}
|
||||
|
||||
function newAccumulator(): WindowAccumulator {
|
||||
return { lines: [], totalLines: 0, outputBytes: 0, truncatedByBytes: false, done: false }
|
||||
}
|
||||
|
||||
function truncateLine(line: string): string {
|
||||
return line.length > READ_MAX_LINE_LENGTH ? `${line.substring(0, READ_MAX_LINE_LENGTH)}${READ_MAX_LINE_SUFFIX}` : line
|
||||
}
|
||||
|
||||
function lineByteSize(line: string, currentLineCount: number): number {
|
||||
return Buffer.byteLength(line, 'utf8') + (currentLineCount > 0 ? 1 : 0)
|
||||
}
|
||||
|
||||
function consumeLine(acc: WindowAccumulator, rawLine: string, request: ReadWindow): void {
|
||||
acc.totalLines += 1
|
||||
if (acc.totalLines < request.offset || acc.lines.length >= request.limit) return
|
||||
|
||||
const text = truncateLine(rawLine)
|
||||
const bytes = lineByteSize(text, acc.lines.length)
|
||||
if (acc.outputBytes + bytes > READ_MAX_BYTES) {
|
||||
acc.truncatedByBytes = true
|
||||
acc.done = true
|
||||
return
|
||||
}
|
||||
acc.outputBytes += bytes
|
||||
acc.lines.push({ number: acc.totalLines, text })
|
||||
}
|
||||
|
||||
function stripCarriageReturn(line: string): string {
|
||||
return line.endsWith('\r') ? line.slice(0, -1) : line
|
||||
}
|
||||
|
||||
function finish(acc: WindowAccumulator, request: ReadWindow, displayPath: string): WindowResult {
|
||||
if (!acc.truncatedByBytes && request.offset > acc.totalLines && !(acc.totalLines === 0 && request.offset === 1)) {
|
||||
throw new FsError(`offset ${request.offset} is out of range for "${displayPath}" (${acc.totalLines} lines)`, 'FS_NOT_FOUND')
|
||||
}
|
||||
return { lines: acc.lines, totalLines: acc.totalLines, truncatedByBytes: acc.truncatedByBytes }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a bounded, line-numbered window from a file's decoded text chunks.
|
||||
* Accepts an `AsyncIterable<string>` (a chunked `streamText`) or an
|
||||
* `Iterable<string>` (a whole-file `readText` wrapped as `[text]`), so one code
|
||||
* path serves both. Scans for newlines with a capped line buffer (a newline-free
|
||||
* giant line is truncated, never buffered past {@link READ_MAX_LINE_LENGTH}),
|
||||
* enforces the byte cap, and throws `FS_NOT_FOUND` for an offset past EOF.
|
||||
*/
|
||||
export async function buildWindow(
|
||||
chunks: AsyncIterable<string> | Iterable<string>,
|
||||
request: ReadWindow,
|
||||
displayPath: string,
|
||||
): Promise<WindowResult> {
|
||||
const acc = newAccumulator()
|
||||
let lineBuffer = ''
|
||||
|
||||
function appendToLineBuffer(segment: string): void {
|
||||
if (lineBuffer.length >= LINE_BUFFER_CAP) return
|
||||
lineBuffer += segment
|
||||
if (lineBuffer.length > LINE_BUFFER_CAP) lineBuffer = lineBuffer.slice(0, LINE_BUFFER_CAP)
|
||||
}
|
||||
|
||||
function flushLine(): void {
|
||||
consumeLine(acc, stripCarriageReturn(lineBuffer), request)
|
||||
lineBuffer = ''
|
||||
}
|
||||
|
||||
for await (const chunk of chunks) {
|
||||
let startPos = 0
|
||||
let newlinePos: number
|
||||
while ((newlinePos = chunk.indexOf('\n', startPos)) !== -1) {
|
||||
appendToLineBuffer(chunk.slice(startPos, newlinePos))
|
||||
flushLine()
|
||||
startPos = newlinePos + 1
|
||||
if (acc.done) return finish(acc, request, displayPath)
|
||||
}
|
||||
appendToLineBuffer(chunk.slice(startPos))
|
||||
}
|
||||
if (lineBuffer.length > 0) flushLine()
|
||||
return finish(acc, request, displayPath)
|
||||
}
|
||||
|
||||
/** Format a read outcome as one OpenCode-style line-numbered text block body. */
|
||||
export function formatReadOutput(displayPath: string, outcome: FileReadOutcome): string {
|
||||
const endLine = outcome.lines.at(-1)?.number ?? Math.max(0, outcome.offset - 1)
|
||||
let footer: string
|
||||
if (outcome.truncatedByBytes) {
|
||||
footer = `(Output capped. Showing lines ${outcome.offset}-${endLine}. Use offset=${endLine + 1} to continue.)`
|
||||
} else if (endLine < outcome.totalLines) {
|
||||
footer = `(Showing lines ${outcome.offset}-${endLine} of ${outcome.totalLines}. Use offset=${endLine + 1} to continue.)`
|
||||
} else {
|
||||
footer = `(End of file - total ${outcome.totalLines} lines)`
|
||||
}
|
||||
const body = outcome.lines.length > 0
|
||||
? `${outcome.lines.map(line => `${line.number}: ${line.text}`).join('\n')}\n\n${footer}`
|
||||
: footer
|
||||
return `<path>${displayPath}</path>
|
||||
<type>file</type>
|
||||
<content>
|
||||
${body}
|
||||
</content>`
|
||||
}
|
||||
123
packages/fs/tool-fs/src/read.ts
Normal file
123
packages/fs/tool-fs/src/read.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* The model-facing `read` tool: inspect a UTF-8 text file and return
|
||||
* line-numbered content with pagination guidance. The tool is the executor — it
|
||||
* stats and reads through `ctx.fs` directly, builds the line window
|
||||
* ({@link module:@deepseek-ai/dsh-tool-fs/read-render}), and emits `fs/observed`
|
||||
* so a policy plugin (`@deepseek-ai/dsh-fs-policy`) can record the read. With
|
||||
* no policy plugin the emit is simply unheard. This module owns the
|
||||
* model-facing schema, argument validation, and the read I/O; the rendering
|
||||
* (windowing + formatting) lives in `read-render.ts` and the
|
||||
* freshness/observation policy is not its concern.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs/src/read
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { 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'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { buildWindow, formatReadOutput } from './read-render.ts'
|
||||
import type { FileReadOutcome } from './read-render.ts'
|
||||
import { sessionCwd } from './session-cwd.ts'
|
||||
|
||||
/** Default and maximum number of lines returned by one `read` call. */
|
||||
export const READ_LIMIT = 2000
|
||||
|
||||
/** Files at or above this size stream; smaller files read whole into memory. */
|
||||
export const STREAM_MIN_SIZE = 10 * 1024 * 1024
|
||||
|
||||
/** Validated `read` arguments after defaulting. */
|
||||
interface ReadInput {
|
||||
filePath: string
|
||||
offset: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: number, name: string): number {
|
||||
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1) {
|
||||
throw new Error(`${name} must be a positive integer`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/** Validate value constraints the schema DSL can't express. */
|
||||
export function parseReadArgs(args: { file_path: string; offset?: number; limit?: number }): ReadInput {
|
||||
if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string')
|
||||
const offset = args.offset === undefined ? 1 : parsePositiveInteger(args.offset, 'offset')
|
||||
const limit = args.limit === undefined ? READ_LIMIT : parsePositiveInteger(args.limit, 'limit')
|
||||
if (limit > READ_LIMIT) throw new Error(`limit must be less than or equal to ${READ_LIMIT}`)
|
||||
return { filePath: args.file_path, offset, limit }
|
||||
}
|
||||
|
||||
/** Register the `read` tool and its system-prompt guidance. */
|
||||
export function applyReadTool(ctx: Context): void {
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:read',
|
||||
order: 100,
|
||||
text: 'Use the read tool to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.',
|
||||
})
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'read',
|
||||
description: 'Read a UTF-8 text file and return line-numbered content.',
|
||||
parameters: {
|
||||
file_path: { type: 'string', required: true, description: 'Path to read, resolved by the filesystem backend.' },
|
||||
offset: { type: 'number', description: '1-based first line to return. Defaults to 1.' },
|
||||
limit: { type: 'number', description: `Maximum number of lines to return. Defaults to ${READ_LIMIT}.` },
|
||||
},
|
||||
async execute(args, exec): Promise<ContentBlock[]> {
|
||||
const input = parseReadArgs(args)
|
||||
const cwd = sessionCwd(exec)
|
||||
const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined)
|
||||
|
||||
// One stat: type check + size routing + the version recorded as observed.
|
||||
// A writer racing between this stat and the read can at worst make a LATER
|
||||
// guarded edit spuriously FS_STALE_VERSION (fail-closed: re-read; editText
|
||||
// re-checks the version in its lock).
|
||||
const info = await ctx.fs.stat(target, exec.signal)
|
||||
if (!info) throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND')
|
||||
if (info.type !== 'file') throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
|
||||
|
||||
// Stream when the file is large OR size is unknown, so a size-less backend
|
||||
// never buffers an arbitrarily large file.
|
||||
const chunks = info.size === undefined || info.size >= STREAM_MIN_SIZE
|
||||
? await ctx.fs.streamText(target, exec.signal)
|
||||
: [await ctx.fs.readText(target, exec.signal)]
|
||||
const window = await buildWindow(chunks, { offset: input.offset, limit: input.limit }, target.displayPath)
|
||||
|
||||
const outcome: FileReadOutcome = {
|
||||
offset: input.offset,
|
||||
limit: input.limit,
|
||||
lines: window.lines,
|
||||
totalLines: window.totalLines,
|
||||
version: info.version,
|
||||
...window.truncatedByBytes ? { truncatedByBytes: true } : {},
|
||||
}
|
||||
// Record the observed version (a no-op when no policy plugin listens). The
|
||||
// read already succeeded; an fs/observed listener is contractually a
|
||||
// synchronous, side-effect-only recorder.
|
||||
ctx.emit('fs/observed', target, info.version, exec)
|
||||
return [{ type: 'text', text: formatReadOutput(target.displayPath, outcome) }]
|
||||
},
|
||||
// Pure display: a 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 {
|
||||
card: 'generic',
|
||||
title: `Read ${args.file_path}${window}`,
|
||||
kind: 'read',
|
||||
locations: [{ path: args.file_path, line: offset ?? 1 }],
|
||||
}
|
||||
},
|
||||
}))
|
||||
}
|
||||
24
packages/fs/tool-fs/src/session-cwd.ts
Normal file
24
packages/fs/tool-fs/src/session-cwd.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Derive the working directory a filesystem tool resolves relative paths
|
||||
* against: the calling agent's per-session workspace
|
||||
* (`exec.agent.session.header.cwd`), so each ACP session's `read`/`write`/`edit`
|
||||
* act on ITS workspace, not the server's launch dir — mirroring how
|
||||
* `dsh-tool-bash` defaults a bash `workdir` to the session cwd.
|
||||
*
|
||||
* The `agent` is optional-chained — a non-agent caller yields `undefined`, and
|
||||
* the tool then calls `ctx.fs.resolve(path)` with no base so the backend applies
|
||||
* its own configured default (preserving the non-ACP / no-session behavior).
|
||||
* `session`/`header` are non-optional on a real `Agent`, so only `agent` needs
|
||||
* the guard (mirroring `dsh-tool-bash`'s `resolveWorkdir`). Returning `undefined`
|
||||
* rather than reading `process.cwd()` here keeps the default in ONE place (the
|
||||
* provider), per the "explicit > implicit at seams" convention.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs/session-cwd
|
||||
*/
|
||||
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/** The session workspace cwd for this call, or `undefined` when none applies. */
|
||||
export function sessionCwd(exec: ToolExecution): string | undefined {
|
||||
return exec.agent?.session.header.cwd
|
||||
}
|
||||
102
packages/fs/tool-fs/src/write.ts
Normal file
102
packages/fs/tool-fs/src/write.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* The model-facing `write` tool: create or fully replace a UTF-8 text file. The
|
||||
* tool is the executor: it dispatches the `fs/write-intent` waterfall to
|
||||
* obtain the optional version guard, calls `ctx.fs.writeText` directly, and
|
||||
* emits `fs/observed`. The default thunk returns `undefined` (unconditional
|
||||
* create-or-overwrite — the bare provider); a policy plugin
|
||||
* (`@deepseek-ai/dsh-fs-policy`) occupies the single decision slot and
|
||||
* returns `createIfAbsent`/`replaceIfVersion` instead. The tool stats ZERO
|
||||
* times either way.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs/src/write
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { 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. */
|
||||
export function parseWriteArgs(args: { file_path: string; content: string }): { filePath: string; content: string } {
|
||||
if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string')
|
||||
return { filePath: args.file_path, content: args.content }
|
||||
}
|
||||
|
||||
/** Format a write outcome as one model-facing text block body. */
|
||||
export function formatWriteOutput(displayPath: string, outcome: FsWriteOutcome): string {
|
||||
const verb = outcome.operation === 'create' ? 'Created' : 'Updated'
|
||||
return `<path>${displayPath}</path>
|
||||
<type>file</type>
|
||||
<content>
|
||||
${verb} file
|
||||
</content>`
|
||||
}
|
||||
|
||||
/** Register the `write` tool and its system-prompt guidance. */
|
||||
export function applyWriteTool(ctx: Context): void {
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:write',
|
||||
order: 101,
|
||||
text: 'Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.',
|
||||
})
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'write',
|
||||
description: 'Create or fully replace a UTF-8 text file.',
|
||||
parameters: {
|
||||
file_path: { type: 'string', required: true, description: 'Path to write, resolved by the filesystem backend.' },
|
||||
content: { type: 'string', required: true, description: 'Full UTF-8 text content to write.' },
|
||||
},
|
||||
async execute(args, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> {
|
||||
const input = parseWriteArgs(args)
|
||||
const cwd = sessionCwd(exec)
|
||||
const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined)
|
||||
// Single-slot decision: the policy plugin produces createIfAbsent/
|
||||
// replaceIfVersion; the bare default is undefined (unconditional). No stat.
|
||||
const intent = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)
|
||||
const outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal)
|
||||
// Record the observed version (a no-op when no policy plugin listens).
|
||||
ctx.emit('fs/observed', target, outcome.version, exec)
|
||||
// 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 `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) }],
|
||||
...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
|
||||
// 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 }],
|
||||
}
|
||||
},
|
||||
// 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)
|
||||
?? [{ path: args.file_path, oldText: null, newText: args.content }]
|
||||
return { card: 'diff', title: `Write ${args.file_path}`, diffs }
|
||||
},
|
||||
}))
|
||||
}
|
||||
113
packages/fs/tool-fs/tests/diff.spec.ts
Normal file
113
packages/fs/tool-fs/tests/diff.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
84
packages/fs/tool-fs/tests/fs-tools.e2e.ts
Normal file
84
packages/fs/tool-fs/tests/fs-tools.e2e.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { fsHarness, waitForIdle } from './harness.ts'
|
||||
|
||||
/**
|
||||
* With-key smoke for the filesystem tools: a REAL model drives the REAL
|
||||
* read/write/edit tools (over the real local backend + policy gate), and we
|
||||
* verify the WORLD — the file on disk — not the agent's self-report. This is the
|
||||
* "green units, broken product" guard: mocks prove the plumbing, only a real
|
||||
* model proves the tools actually work end-to-end. Key-gated (self-skips without
|
||||
* DEEPSEEK_API_KEY).
|
||||
*/
|
||||
|
||||
let ctx: Context | undefined
|
||||
let workdir: string | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx?.fiber.dispose()
|
||||
ctx = undefined
|
||||
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
|
||||
workdir = undefined
|
||||
})
|
||||
|
||||
const SYSTEM = 'You are a coding assistant. Use the write tool to create files, the read tool to inspect '
|
||||
+ 'them, and the edit tool for literal replacements. Read a file before editing it. Keep replies terse.'
|
||||
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () => {
|
||||
it('creates, reads, then edits a file — verified on disk', async () => {
|
||||
workdir = await mkdtemp(join(tmpdir(), 'dsh-fs-e2e-'))
|
||||
ctx = await fsHarness(workdir)
|
||||
// agentLoop.create prepares a session with no cwd, so the provider default
|
||||
// (config.cwd = workdir) is the workspace.
|
||||
const agent = ctx.agentLoop.create(AgentId('fs-e2e'), { model: 'deepseek-v4-flash', systemPrompt: SYSTEM })
|
||||
|
||||
agent.send([{ type: 'text', text:
|
||||
'Create a file named note.txt containing exactly the line: status: draft. '
|
||||
+ 'Then read it back, then edit it to replace the literal word draft with final. '
|
||||
+ 'Tell me when done.' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// Verify the WORLD: the edit landed on disk.
|
||||
const content = await readFile(join(workdir, 'note.txt'), 'utf8')
|
||||
expect(content).toContain('status: final')
|
||||
expect(content).not.toContain('draft')
|
||||
|
||||
// The log records real read/write/edit tool calls (not bash).
|
||||
const calls = [...agent.session.events].filter(e => e.type === 'tool/call').map(e => e.data.name)
|
||||
expect(calls).toContain('write')
|
||||
expect(calls).toContain('read')
|
||||
expect(calls).toContain('edit')
|
||||
}, 180_000)
|
||||
|
||||
it('resolves a relative path against the per-session cwd (factory meta.cwd)', async () => {
|
||||
// config.cwd is the harness workdir, but the agent's SESSION cwd is a
|
||||
// different dir; the write must land in the SESSION dir, proving the tool
|
||||
// passes the per-session cwd (not the backend default).
|
||||
const configDir = await mkdtemp(join(tmpdir(), 'dsh-fs-e2e-cfg-'))
|
||||
workdir = configDir
|
||||
const sessionDir = await mkdtemp(join(tmpdir(), 'dsh-fs-e2e-session-'))
|
||||
try {
|
||||
ctx = await fsHarness(configDir)
|
||||
const handle = ctx.agents.create({
|
||||
agentId: AgentId('fs-e2e-cwd'),
|
||||
sessionId: SessionId(`fs-e2e-cwd-${Date.now()}`),
|
||||
meta: { cwd: sessionDir },
|
||||
agentOptions: { model: 'deepseek-v4-flash', systemPrompt: SYSTEM },
|
||||
})
|
||||
handle.agent.send([{ type: 'text', text:
|
||||
'Use the write tool to create a file named where.txt containing exactly the line: here. Tell me when done.' }])
|
||||
await waitForIdle(ctx, handle.agent)
|
||||
|
||||
// The file is in the SESSION dir, not the config dir.
|
||||
expect(await readFile(join(sessionDir, 'where.txt'), 'utf8')).toContain('here')
|
||||
await expect(readFile(join(configDir, 'where.txt'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
} finally {
|
||||
await rm(sessionDir, { recursive: true, force: true })
|
||||
}
|
||||
}, 180_000)
|
||||
})
|
||||
47
packages/fs/tool-fs/tests/harness.ts
Normal file
47
packages/fs/tool-fs/tests/harness.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Context } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
|
||||
/**
|
||||
* Shared harness for the fs-tools with-key e2e: a minimal real agent stack (the
|
||||
* DeepSeek adapter + the real fs provider + the read-before-write/edit policy +
|
||||
* the model-facing read/write/edit tools). Lives outside the *.e2e.ts pattern so
|
||||
* importing it never re-registers another file's tests.
|
||||
*
|
||||
* `fsCwd` is the local backend's default base; a per-session cwd (set via a
|
||||
* session header) overrides it, but this harness creates agents without a
|
||||
* session cwd, so the provider default IS the workspace.
|
||||
*/
|
||||
export async function fsHarness(fsCwd: string): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] })
|
||||
await ctx.plugin(LocalFileSystem, { cwd: fsCwd })
|
||||
await ctx.plugin(FsPolicy)
|
||||
await ctx.plugin(ToolFs)
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
dispose()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
410
packages/fs/tool-fs/tests/integration.spec.ts
Normal file
410
packages/fs/tool-fs/tests/integration.spec.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
/**
|
||||
* Integration tests: the real local backend (`dsh-fs-local`) plus the model
|
||||
* tools (`dsh-tool-fs`) as the executor, exercised through `ctx.tools.execute()`
|
||||
* so nothing bypasses the tool registry. Two deployments:
|
||||
*
|
||||
* - DEFAULT — with the real `dsh-fs-policy` policy gate plugin: read-before-
|
||||
* write/edit, version-guarded mutation, FS_NOT_OBSERVED for unread edits.
|
||||
* - BARE — WITHOUT the policy plugin: every `fs/*` waterfall falls through to
|
||||
* its undefined default, so write/edit are unconditional. This proves the
|
||||
* tool carries no dependency on the policy plugin.
|
||||
*
|
||||
* These verify the WORLD — files are read back from disk and asserted
|
||||
* byte-for-byte — not the tool's self-report.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
|
||||
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
|
||||
let dir: string
|
||||
let ctx: Context
|
||||
let fiber: Awaited<ReturnType<Context['plugin']>>
|
||||
// A stable session object stands in for an agent session (the file-state
|
||||
// owner). It carries a `header` (no `cwd`) so `sessionCwd(exec)` resolves to
|
||||
// `undefined` and the backend falls back to its configured cwd (= `dir`).
|
||||
const session = { header: {} }
|
||||
|
||||
let callCounter = 0
|
||||
function call(name: string, args: unknown) {
|
||||
return ctx.tools.execute({
|
||||
callId: CallId(`call-${++callCounter}`),
|
||||
name,
|
||||
arguments: args,
|
||||
agent: { session } as never,
|
||||
})
|
||||
}
|
||||
|
||||
function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await fiber.dispose()
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// DEFAULT deployment: the policy gate plugin is loaded.
|
||||
// --------------------------------------------------------------------------
|
||||
describe('default deployment (with dsh-fs-policy)', () => {
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-'))
|
||||
ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: dir })
|
||||
await ctx.plugin(FsPolicy)
|
||||
fiber = await ctx.plugin(ToolFs)
|
||||
})
|
||||
|
||||
describe('write → disk', () => {
|
||||
it('creates a file with exactly the requested bytes', async () => {
|
||||
const result = await call('write', { file_path: 'new.txt', content: 'line one\nline two\n' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('line one\nline two\n')
|
||||
})
|
||||
|
||||
it('rejects overwriting an existing file without reading it first', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'original')
|
||||
const result = await call('write', { file_path: 'a.txt', content: 'clobber' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('original')
|
||||
})
|
||||
|
||||
it('allows overwriting after a read', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'original')
|
||||
expect((await call('read', { file_path: 'a.txt' })).isError).toBe(false)
|
||||
const result = await call('write', { file_path: 'a.txt', content: 'replaced' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('replaced')
|
||||
})
|
||||
|
||||
it('rejects a full overwrite when the file changed since the read (stale)', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'original')
|
||||
await call('read', { file_path: 'a.txt' })
|
||||
await writeFile(join(dir, 'a.txt'), 'changed-externally') // out-of-band change
|
||||
const result = await call('write', { file_path: 'a.txt', content: 'replaced' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('read', () => {
|
||||
it('returns line-numbered content', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'alpha\nbeta')
|
||||
const result = await call('read', { file_path: 'a.txt' })
|
||||
expect(text(result)).toContain('1: alpha')
|
||||
expect(text(result)).toContain('2: beta')
|
||||
expect(text(result)).toContain('(End of file - total 2 lines)')
|
||||
})
|
||||
|
||||
it('reports a binary file as an error', async () => {
|
||||
await writeFile(join(dir, 'bin'), Buffer.from([0x00, 0x01, 0x02]))
|
||||
const result = await call('read', { file_path: 'bin' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
})
|
||||
|
||||
it('paginates a multi-line file with offset/limit', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'one\ntwo\nthree\nfour')
|
||||
const result = await call('read', { file_path: 'a.txt', offset: 2, limit: 2 })
|
||||
expect(text(result)).toContain('2: two')
|
||||
expect(text(result)).toContain('3: three')
|
||||
expect(text(result)).toContain('(Showing lines 2-3 of 4. Use offset=4 to continue.)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edit → disk', () => {
|
||||
it('applies a unique literal replacement after a read', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
await call('read', { file_path: 'a.txt' })
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there')
|
||||
})
|
||||
|
||||
it('rejects an edit before any read, leaving the file untouched', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello world')
|
||||
})
|
||||
|
||||
it('lets a WINDOWED read authorize an edit when the file is unchanged (freshness, not full-view)', async () => {
|
||||
// A file with more lines than the read window; read only the first line.
|
||||
const lines = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`)
|
||||
await writeFile(join(dir, 'a.txt'), lines.join('\n'))
|
||||
const read = await call('read', { file_path: 'a.txt', offset: 1, limit: 1 })
|
||||
expect(read.isError).toBe(false)
|
||||
expect(text(read)).toContain('(Showing lines 1-1 of 20')
|
||||
|
||||
// Editing a line OUTSIDE the window is authorized because the file is unchanged.
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'line 12', new_string: 'LINE 12' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe(lines.map(l => l === 'line 12' ? 'LINE 12' : l).join('\n'))
|
||||
})
|
||||
|
||||
it('rejects an edit when the file changed since the windowed read (stale before matching)', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
await call('read', { file_path: 'a.txt', offset: 1, limit: 1 })
|
||||
await writeFile(join(dir, 'a.txt'), 'goodbye') // out-of-band change removes 'world'
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
})
|
||||
|
||||
it('rejects an ambiguous match without replace_all', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'a a a')
|
||||
await call('read', { file_path: 'a.txt' })
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_AMBIGUOUS_EDIT' })
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('a a a')
|
||||
})
|
||||
|
||||
it('replaces all matches with replace_all', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'a a a')
|
||||
await call('read', { file_path: 'a.txt' })
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b', replace_all: true })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('b b b')
|
||||
})
|
||||
|
||||
it('supports a full write→edit cycle without an intervening read', async () => {
|
||||
await call('write', { file_path: 'a.txt', content: 'one two' })
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'two', new_string: 'three' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('one three')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the gate records only through the events (no method coupling)', () => {
|
||||
it('a direct ctx.fs.readText records no observed-state, so a later edit rejects', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
// Reach AROUND the tool — an explicit escape hatch for non-tool consumers.
|
||||
await ctx.fs.readText(await ctx.fs.resolve('a.txt'))
|
||||
// The model-facing edit still rejects: the read did not emit fs/observed.
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('stat budget', () => {
|
||||
it('read stats once; write and edit never stat in the tool (the gate stats zero too)', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
const statSpy = vi.spyOn(ctx.fs, 'stat')
|
||||
|
||||
// read: exactly one stat (type + size routing + observed version).
|
||||
await call('read', { file_path: 'a.txt' })
|
||||
expect(statSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
// edit (guarded, after the read): the gate supplies vObserved; the tool
|
||||
// does not stat to manufacture a basis. CAS happens in editText's lock.
|
||||
statSpy.mockClear()
|
||||
const edited = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
|
||||
expect(edited.isError).toBe(false)
|
||||
expect(statSpy).not.toHaveBeenCalled()
|
||||
|
||||
// write (guarded replace, after the edit refreshed observed state): zero stat.
|
||||
statSpy.mockClear()
|
||||
const written = await call('write', { file_path: 'a.txt', content: 'fresh' })
|
||||
expect(written.isError).toBe(false)
|
||||
expect(statSpy).not.toHaveBeenCalled()
|
||||
statSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// BARE deployment: the tool suite WITHOUT the policy gate.
|
||||
// --------------------------------------------------------------------------
|
||||
describe('bare provider (no dsh-fs-policy)', () => {
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-bare-'))
|
||||
ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: dir })
|
||||
fiber = await ctx.plugin(ToolFs)
|
||||
})
|
||||
|
||||
it('read works (it never needed policy)', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'alpha\nbeta')
|
||||
const result = await call('read', { file_path: 'a.txt' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toContain('1: alpha')
|
||||
})
|
||||
|
||||
it('write unconditionally creates a new file', async () => {
|
||||
const result = await call('write', { file_path: 'new.txt', content: 'fresh' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('fresh')
|
||||
})
|
||||
|
||||
it('write unconditionally OVERWRITES an existing unread file', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'original')
|
||||
const result = await call('write', { file_path: 'a.txt', content: 'clobbered' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('clobbered')
|
||||
})
|
||||
|
||||
it('edit unconditionally edits an UNREAD existing file', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there')
|
||||
})
|
||||
|
||||
it('edit of a MISSING target reports FS_STALE_VERSION even on the unguarded path', async () => {
|
||||
const result = await call('edit', { file_path: 'missing.txt', old_string: 'a', new_string: 'b' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
})
|
||||
|
||||
it('edit still enforces literal-match codes (FS_EDIT_NOT_FOUND), unrelated to freshness', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
const result = await call('edit', { file_path: 'a.txt', old_string: 'absent', new_string: 'x' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_EDIT_NOT_FOUND' })
|
||||
})
|
||||
|
||||
it('neither write nor edit stats in the tool on the bare path', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
const statSpy = vi.spyOn(ctx.fs, 'stat')
|
||||
expect((await call('write', { file_path: 'a.txt', content: 'x y' })).isError).toBe(false)
|
||||
expect((await call('edit', { file_path: 'a.txt', old_string: 'y', new_string: 'z' })).isError).toBe(false)
|
||||
expect(statSpy).not.toHaveBeenCalled()
|
||||
statSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Per-session cwd: a relative file_path resolves against the CALLING session's
|
||||
// workspace (`exec.agent.session.header.cwd`), NOT the backend's config.cwd —
|
||||
// so an ACP editor's per-session dir wins, matching dsh-tool-bash. The regression
|
||||
// this guards: before the seam fix the tool passed no cwd, so a relative write
|
||||
// landed in config.cwd instead of the session dir.
|
||||
// --------------------------------------------------------------------------
|
||||
describe('per-session cwd', () => {
|
||||
let sessionDir: string
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-cfg-'))
|
||||
sessionDir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-session-'))
|
||||
ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: dir }) // config.cwd = dir, NOT sessionDir
|
||||
await ctx.plugin(FsPolicy)
|
||||
fiber = await ctx.plugin(ToolFs)
|
||||
})
|
||||
afterEach(async () => { await rm(sessionDir, { recursive: true, force: true }) })
|
||||
|
||||
const callIn = (sessionObj: object, name: string, args: unknown) =>
|
||||
ctx.tools.execute({
|
||||
callId: CallId(`call-${++callCounter}`),
|
||||
name,
|
||||
arguments: args,
|
||||
agent: { session: sessionObj } as never,
|
||||
})
|
||||
|
||||
it('writes a relative path into the SESSION cwd, not config.cwd', async () => {
|
||||
const result = await callIn({ header: { cwd: sessionDir } }, 'write', { file_path: 'note.txt', content: 'hi' })
|
||||
expect(result.isError).toBe(false)
|
||||
// Verify the WORLD: the file is in the session dir, and NOT in config.cwd.
|
||||
expect(await readFile(join(sessionDir, 'note.txt'), 'utf8')).toBe('hi')
|
||||
await expect(readFile(join(dir, 'note.txt'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('read + edit both resolve against the session cwd (end-to-end)', async () => {
|
||||
// ONE session object across both calls — observed-state keys by owner
|
||||
// identity, so read must record under the same owner the edit reads.
|
||||
const session = { header: { cwd: sessionDir } }
|
||||
await writeFile(join(sessionDir, 'code.txt'), 'alpha')
|
||||
expect((await callIn(session, 'read', { file_path: 'code.txt' })).isError).toBe(false)
|
||||
const edited = await callIn(session, 'edit', { file_path: 'code.txt', old_string: 'alpha', new_string: 'beta' })
|
||||
expect(edited.isError).toBe(false)
|
||||
expect(await readFile(join(sessionDir, 'code.txt'), 'utf8')).toBe('beta')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Abort-through-the-tool, tool-tier concurrency, and the fs/observed contract —
|
||||
// all through ctx.tools.execute() against the REAL backend + policy.
|
||||
// --------------------------------------------------------------------------
|
||||
describe('signal, concurrency, and the fs/observed contract', () => {
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'dsh-tool-fs-'))
|
||||
ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: dir })
|
||||
await ctx.plugin(FsPolicy)
|
||||
fiber = await ctx.plugin(ToolFs)
|
||||
})
|
||||
|
||||
const session = { header: {} }
|
||||
const callSig = (signal: AbortSignal, name: string, args: unknown) =>
|
||||
ctx.tools.execute({ callId: CallId(`c-${++callCounter}`), name, arguments: args, agent: { session } as never, signal })
|
||||
const callOwned = (name: string, args: unknown) =>
|
||||
ctx.tools.execute({ callId: CallId(`c-${++callCounter}`), name, arguments: args, agent: { session } as never })
|
||||
|
||||
it('a pre-aborted signal makes read/write/edit return isError FS_ABORTED', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello')
|
||||
const read = await callSig(AbortSignal.abort(), 'read', { file_path: 'a.txt' })
|
||||
expect(read.isError).toBe(true)
|
||||
expect(read.error).toMatchObject({ code: 'FS_ABORTED' })
|
||||
|
||||
const write = await callSig(AbortSignal.abort(), 'write', { file_path: 'new.txt', content: 'x' })
|
||||
expect(write.isError).toBe(true)
|
||||
expect(write.error).toMatchObject({ code: 'FS_ABORTED' })
|
||||
await expect(readFile(join(dir, 'new.txt'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
|
||||
// Read first (un-aborted, SAME session owner) so the edit clears the
|
||||
// observation gate; then the aborted edit fails on the signal, not on
|
||||
// FS_NOT_OBSERVED.
|
||||
expect((await callOwned('read', { file_path: 'a.txt' })).isError).toBe(false)
|
||||
const edit = await callSig(AbortSignal.abort(), 'edit', { file_path: 'a.txt', old_string: 'hello', new_string: 'bye' })
|
||||
expect(edit.isError).toBe(true)
|
||||
expect(edit.error).toMatchObject({ code: 'FS_ABORTED' })
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello') // unchanged
|
||||
})
|
||||
|
||||
it('two concurrent edits of the same file, same session: one wins, one FS_STALE_VERSION', async () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'base value here')
|
||||
// One read establishes the observed version both edits guard against; then
|
||||
// race two edits so both carry the SAME observed version (the barrier).
|
||||
expect((await callOwned('read', { file_path: 'a.txt' })).isError).toBe(false)
|
||||
const [one, two] = await Promise.all([
|
||||
callOwned('edit', { file_path: 'a.txt', old_string: 'base', new_string: 'ONE', replaceAll: false }),
|
||||
callOwned('edit', { file_path: 'a.txt', old_string: 'value', new_string: 'TWO', replaceAll: false }),
|
||||
])
|
||||
const errors = [one, two].filter(r => r.isError)
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]?.error).toMatchObject({ code: 'FS_STALE_VERSION' })
|
||||
// The world is consistent: exactly one edit landed.
|
||||
const onDisk = await readFile(join(dir, 'a.txt'), 'utf8')
|
||||
expect(onDisk === 'ONE value here' || onDisk === 'base TWO here').toBe(true)
|
||||
})
|
||||
|
||||
it('a throwing fs/observed listener surfaces as isError, but the mutation already hit disk', async () => {
|
||||
// fs/observed is a plain ctx.emit AFTER the write succeeded; a throwing
|
||||
// listener cannot roll the write back — it only turns the tool result into
|
||||
// isError. The file must still carry the written bytes.
|
||||
ctx.on('fs/observed', () => { throw new Error('recording bug') })
|
||||
const result = await callOwned('write', { file_path: 'w.txt', content: 'durable' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(await readFile(join(dir, 'w.txt'), 'utf8')).toBe('durable')
|
||||
})
|
||||
})
|
||||
102
packages/fs/tool-fs/tests/read-render.spec.ts
Normal file
102
packages/fs/tool-fs/tests/read-render.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Cordis-free tests for the line-windowing module: offset/limit windows, byte
|
||||
* caps, per-line truncation, CRLF stripping, offset-past-EOF rejection, and the
|
||||
* capped line buffer for newline-free giant lines — all over an async-iterable
|
||||
* of decoded text chunks (so one code path serves whole-file and streamed reads).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildWindow, READ_MAX_LINE_LENGTH } from '@deepseek-ai/dsh-tool-fs'
|
||||
import type { ReadWindow } from '@deepseek-ai/dsh-tool-fs'
|
||||
|
||||
const READ_ALL: ReadWindow = { offset: 1, limit: 2000 }
|
||||
|
||||
/** Yield `text` as one chunk (whole-file read shape). */
|
||||
async function* whole(text: string): AsyncIterable<string> {
|
||||
yield text
|
||||
}
|
||||
|
||||
/** Yield `text` split into fixed-size chunks (streamed read shape). */
|
||||
async function* chunked(text: string, size: number): AsyncIterable<string> {
|
||||
for (let i = 0; i < text.length; i += size) yield text.slice(i, i + size)
|
||||
}
|
||||
|
||||
describe('buildWindow', () => {
|
||||
it('numbers lines and reports total for a whole-file read', async () => {
|
||||
const result = await buildWindow(whole('one\ntwo\nthree'), READ_ALL, 'f')
|
||||
expect(result.lines).toEqual([
|
||||
{ number: 1, text: 'one' },
|
||||
{ number: 2, text: 'two' },
|
||||
{ number: 3, text: 'three' },
|
||||
])
|
||||
expect(result.totalLines).toBe(3)
|
||||
expect(result.truncatedByBytes).toBe(false)
|
||||
})
|
||||
|
||||
it('applies offset/limit', async () => {
|
||||
const result = await buildWindow(whole('one\ntwo\nthree\nfour'), { offset: 2, limit: 2 }, 'f')
|
||||
expect(result.lines.map(l => l.number)).toEqual([2, 3])
|
||||
expect(result.totalLines).toBe(4)
|
||||
})
|
||||
|
||||
it('strips CRLF', async () => {
|
||||
const result = await buildWindow(whole('one\r\ntwo\r\n'), READ_ALL, 'f')
|
||||
expect(result.lines.map(l => l.text)).toEqual(['one', 'two'])
|
||||
})
|
||||
|
||||
it('truncates an over-long line', async () => {
|
||||
const result = await buildWindow(whole('x'.repeat(3000)), READ_ALL, 'f')
|
||||
expect(result.lines[0]?.text).toContain(`... (line truncated to ${READ_MAX_LINE_LENGTH} chars)`)
|
||||
})
|
||||
|
||||
it('caps output bytes and reports truncatedByBytes', async () => {
|
||||
const big = Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n')
|
||||
const result = await buildWindow(whole(big), READ_ALL, 'f')
|
||||
expect(result.truncatedByBytes).toBe(true)
|
||||
})
|
||||
|
||||
it('reads an empty file at offset 1 as zero lines', async () => {
|
||||
const result = await buildWindow(whole(''), READ_ALL, 'f')
|
||||
expect(result.lines).toEqual([])
|
||||
expect(result.totalLines).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects an offset past EOF', async () => {
|
||||
await expect(buildWindow(whole('one\ntwo'), { offset: 9, limit: 1 }, 'f')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
})
|
||||
|
||||
it('flushes a final line with no trailing newline', async () => {
|
||||
const result = await buildWindow(whole('one\ntwo'), READ_ALL, 'f')
|
||||
expect(result.lines.map(l => l.text)).toEqual(['one', 'two'])
|
||||
})
|
||||
|
||||
it('handles a trailing newline (no dangling empty line)', async () => {
|
||||
const result = await buildWindow(whole('one\ntwo\n'), READ_ALL, 'f')
|
||||
expect(result.lines.map(l => l.text)).toEqual(['one', 'two'])
|
||||
expect(result.totalLines).toBe(2)
|
||||
})
|
||||
|
||||
describe('chunked input (streamed read shape)', () => {
|
||||
it('windows identically when text arrives in small chunks', async () => {
|
||||
const result = await buildWindow(chunked('one\ntwo\nthree', 2), { offset: 2, limit: 1 }, 'f')
|
||||
expect(result.lines).toEqual([{ number: 2, text: 'two' }])
|
||||
expect(result.totalLines).toBe(3)
|
||||
})
|
||||
|
||||
it('caps a newline-free giant line split across chunks without unbounded buffering', async () => {
|
||||
const result = await buildWindow(chunked('z'.repeat(5000), 256), READ_ALL, 'f')
|
||||
expect(result.lines[0]?.text).toContain(`... (line truncated to ${READ_MAX_LINE_LENGTH} chars)`)
|
||||
})
|
||||
|
||||
it('caps output bytes mid-stream', async () => {
|
||||
const big = Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n')
|
||||
const result = await buildWindow(chunked(big, 512), READ_ALL, 'f')
|
||||
expect(result.truncatedByBytes).toBe(true)
|
||||
})
|
||||
|
||||
it('flushes a final newline-terminated line across a chunk boundary', async () => {
|
||||
const result = await buildWindow(chunked('one\ntwo\n', 3), READ_ALL, 'f')
|
||||
expect(result.lines.map(l => l.text)).toEqual(['one', 'two'])
|
||||
})
|
||||
})
|
||||
})
|
||||
497
packages/fs/tool-fs/tests/tools.spec.ts
Normal file
497
packages/fs/tool-fs/tests/tools.spec.ts
Normal file
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* Consumer-surface tests for the filesystem tools as the EXECUTOR. They run the
|
||||
* REAL `@deepseek-ai/dsh-fs-policy` gate plugin (the genuine policy
|
||||
* collaborator, per the prefer-the-real-implementation rule) over a fake
|
||||
* `ctx.fs` provider, so they verify schemas, argument validation, result
|
||||
* formatting, FsError→isError propagation, and that each tool dispatches the
|
||||
* `fs/*` waterfalls + records observed-state through the gate (read authorizes a
|
||||
* later edit) — not just that it moved bytes.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type {
|
||||
FsDirEntry,
|
||||
FsEditOutcome,
|
||||
FsEditRequest,
|
||||
FsInfo,
|
||||
FsTarget,
|
||||
FsWriteIntent,
|
||||
FsWriteOutcome,
|
||||
} from '@deepseek-ai/dsh-fs'
|
||||
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import { formatReadOutput, STREAM_MIN_SIZE } from '@deepseek-ai/dsh-tool-fs'
|
||||
import type { FileReadOutcome } from '@deepseek-ai/dsh-tool-fs'
|
||||
|
||||
/** An in-memory fake provider; a test can arm a rejection on any primitive. */
|
||||
class FakeFs extends FileSystem {
|
||||
files = new Map<string, string>()
|
||||
rejectWith?: FsError
|
||||
writeIntents: (FsWriteIntent | undefined)[] = []
|
||||
editIntents: ({ version: FsVersion } | undefined)[] = []
|
||||
|
||||
private throwIfArmed(): void {
|
||||
if (this.rejectWith) throw this.rejectWith
|
||||
}
|
||||
|
||||
override async resolve(path: string): Promise<FsTarget> {
|
||||
return { inputPath: path, targetKey: FsTargetKey(`key:${path}`), displayPath: `/abs/${path}` }
|
||||
}
|
||||
override async stat(target: FsTarget): Promise<FsInfo | undefined> {
|
||||
this.throwIfArmed()
|
||||
const content = this.files.get(target.targetKey)
|
||||
if (content === undefined) return undefined
|
||||
return { version: FsVersion('v1'), type: 'file', size: content.length }
|
||||
}
|
||||
override async readText(target: FsTarget): Promise<string> {
|
||||
return this.files.get(target.targetKey) ?? ''
|
||||
}
|
||||
override async streamText(target: FsTarget): Promise<AsyncIterable<string>> {
|
||||
const content = this.files.get(target.targetKey) ?? ''
|
||||
return (async function* () { yield content })()
|
||||
}
|
||||
override async listDir(_target: FsTarget): Promise<FsDirEntry[]> {
|
||||
return []
|
||||
}
|
||||
override async writeText(target: FsTarget, content: string, expected?: FsWriteIntent): Promise<FsWriteOutcome> {
|
||||
this.throwIfArmed()
|
||||
this.writeIntents.push(expected)
|
||||
const before = this.files.get(target.targetKey) ?? null
|
||||
this.files.set(target.targetKey, content)
|
||||
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) ?? ''
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(FakeFs)
|
||||
await ctx.plugin(FsPolicy)
|
||||
await ctx.plugin(ToolFs)
|
||||
const fs = ctx.fs as FakeFs
|
||||
return { ctx, fs }
|
||||
}
|
||||
|
||||
let callCounter = 0
|
||||
function call(ctx: Context, name: string, args: unknown, agent?: object) {
|
||||
return ctx.tools.execute({
|
||||
callId: CallId(`call-${++callCounter}`),
|
||||
name,
|
||||
arguments: args,
|
||||
...agent ? { agent: agent as never } : {},
|
||||
})
|
||||
}
|
||||
|
||||
function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
|
||||
}
|
||||
|
||||
describe('registration', () => {
|
||||
it('registers read, write, and edit', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect(ctx.tools.schemas().map(s => s.name).sort()).toEqual(['edit', 'read', 'write'])
|
||||
})
|
||||
|
||||
it('registers prompt sections for each tool', async () => {
|
||||
const { ctx } = await setup()
|
||||
const prompt = renderPrompt(await ctx.systemPrompt.assemble())
|
||||
expect(prompt).toContain('Use the read tool')
|
||||
expect(prompt).toContain('Use the write tool')
|
||||
expect(prompt).toContain('Use the edit tool')
|
||||
})
|
||||
|
||||
it('stays pending until ctx.fs exists (inject)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(ToolFs) // no fs provider
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('unregisters everything on fiber disposal (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(FakeFs)
|
||||
await ctx.plugin(FsPolicy)
|
||||
const fiber = await ctx.plugin(ToolFs)
|
||||
// Each tool contributes BOTH a schema and a prompt section; disposal must
|
||||
// withdraw both, not just the schemas.
|
||||
expect(ctx.tools.schemas()).toHaveLength(3)
|
||||
const sectionNames = (a: { sections: { name: string }[] }) => a.sections.map(s => s.name).sort()
|
||||
expect(sectionNames(await ctx.systemPrompt.assemble())).toEqual(['tool:edit', 'tool:read', 'tool:write'])
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
expect((await ctx.systemPrompt.assemble()).sections).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('read tool', () => {
|
||||
it('formats line-numbered content with a footer', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
fs.files.set('key:a.txt', 'hello\nworld')
|
||||
const result = await call(ctx, 'read', { file_path: 'a.txt' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toBe(`<path>/abs/a.txt</path>
|
||||
<type>file</type>
|
||||
<content>
|
||||
1: hello
|
||||
2: world
|
||||
|
||||
(End of file - total 2 lines)
|
||||
</content>`)
|
||||
})
|
||||
|
||||
it('rejects a non-positive offset via arg validation', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'read', { file_path: 'a.txt', offset: 0 })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('offset must be a positive integer')
|
||||
})
|
||||
|
||||
it('rejects a fractional or NaN offset, and a zero/negative limit', async () => {
|
||||
const { ctx } = await setup()
|
||||
for (const args of [
|
||||
{ file_path: 'a.txt', offset: 1.5 },
|
||||
{ file_path: 'a.txt', offset: Number.NaN },
|
||||
{ file_path: 'a.txt', limit: 0 },
|
||||
{ file_path: 'a.txt', limit: -3 },
|
||||
]) {
|
||||
const result = await call(ctx, 'read', args)
|
||||
expect(result.isError, JSON.stringify(args)).toBe(true)
|
||||
expect(text(result)).toMatch(/must be a positive integer/)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a limit above the cap', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'read', { file_path: 'a.txt', limit: 99999 })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('less than or equal to 2000')
|
||||
})
|
||||
|
||||
it('rejects a blank file_path', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'read', { file_path: ' ' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('file_path must be a non-empty string')
|
||||
})
|
||||
|
||||
it('records observed state so a follow-up edit by the same session is authorized', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
const session = { header: {} }
|
||||
fs.files.set('key:a.txt', 'hello')
|
||||
expect((await call(ctx, 'read', { file_path: 'a.txt' }, { session })).isError).toBe(false)
|
||||
const edited = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'hello', new_string: 'bye' }, { session })
|
||||
expect(edited.isError).toBe(false)
|
||||
expect(fs.editIntents).toEqual([{ version: 'v1' }])
|
||||
})
|
||||
|
||||
it('propagates FS_NOT_FOUND for an absent file', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'read', { file_path: 'missing.txt' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
})
|
||||
|
||||
it('rejects a non-regular target', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
fs.files.set('key:d', '')
|
||||
fs.stat = async () => ({ version: FsVersion('v1'), type: 'directory' })
|
||||
const result = await call(ctx, 'read', { file_path: 'd' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
|
||||
})
|
||||
|
||||
it('streams a large file (size at/above the cap) instead of reading whole', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
fs.files.set('key:big.txt', 'alpha\nbeta')
|
||||
const readSpy = vi.spyOn(fs, 'readText')
|
||||
const streamSpy = vi.spyOn(fs, 'streamText')
|
||||
fs.stat = async () => ({ version: FsVersion('v1'), type: 'file', size: STREAM_MIN_SIZE })
|
||||
const result = await call(ctx, 'read', { file_path: 'big.txt' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toContain('1: alpha')
|
||||
expect(streamSpy).toHaveBeenCalled()
|
||||
expect(readSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('streams when the backend reports no size (never buffers a size-less file)', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
fs.files.set('key:a.txt', 'alpha')
|
||||
const streamSpy = vi.spyOn(fs, 'streamText')
|
||||
fs.stat = async () => ({ version: FsVersion('v1'), type: 'file' }) // no size
|
||||
const result = await call(ctx, 'read', { file_path: 'a.txt' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(streamSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces a byte-capped read as a truncated footer', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
// Many long lines so the window hits the byte cap before EOF.
|
||||
fs.files.set('key:big.txt', Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n'))
|
||||
const result = await call(ctx, 'read', { file_path: 'big.txt' })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toContain('Output capped.')
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('formatReadOutput footer variants', () => {
|
||||
const base: FileReadOutcome = { offset: 1, limit: 2000, lines: [{ number: 1, text: 'x' }], totalLines: 1, version: FsVersion('v') }
|
||||
|
||||
it('reports a byte-capped read', () => {
|
||||
const out = formatReadOutput('/f', { ...base, totalLines: 99, truncatedByBytes: true })
|
||||
expect(out).toContain('(Output capped. Showing lines 1-1. Use offset=2 to continue.)')
|
||||
})
|
||||
|
||||
it('reports a more-remaining page', () => {
|
||||
const out = formatReadOutput('/f', { ...base, totalLines: 99 })
|
||||
expect(out).toContain('(Showing lines 1-1 of 99. Use offset=2 to continue.)')
|
||||
})
|
||||
|
||||
it('reports end-of-file', () => {
|
||||
expect(formatReadOutput('/f', base)).toContain('(End of file - total 1 lines)')
|
||||
})
|
||||
|
||||
it('renders an empty file as just the footer', () => {
|
||||
const out = formatReadOutput('/f', { ...base, lines: [], totalLines: 0 })
|
||||
expect(out).toContain('(End of file - total 0 lines)')
|
||||
expect(out).not.toContain(': ')
|
||||
})
|
||||
})
|
||||
|
||||
describe('write tool', () => {
|
||||
it('formats a create result and uses createIfAbsent (unobserved, with the gate)', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' }, { session: { header: {} } })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toContain('Created file')
|
||||
expect(fs.writeIntents).toEqual([{ kind: 'createIfAbsent' }])
|
||||
})
|
||||
|
||||
it('rejects a blank file_path', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'write', { file_path: ' ', content: 'hi' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('file_path must be a non-empty string')
|
||||
})
|
||||
|
||||
it('propagates a backend FsError as an isError result carrying its code', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
fs.rejectWith = new FsError('blocked', 'FS_STALE_VERSION')
|
||||
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ name: 'FsError', code: 'FS_STALE_VERSION' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('edit tool', () => {
|
||||
it('formats a single-replacement success after a read', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
const session = { header: {} }
|
||||
fs.files.set('key:a.txt', 'a')
|
||||
await call(ctx, 'read', { file_path: 'a.txt' }, { session })
|
||||
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }, { session })
|
||||
expect(text(result)).toBe('The file /abs/a.txt has been updated successfully.')
|
||||
})
|
||||
|
||||
it('formats the replace_all success message distinctly', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
const session = { header: {} }
|
||||
fs.files.set('key:a.txt', 'a a a')
|
||||
await call(ctx, 'read', { file_path: 'a.txt' }, { session })
|
||||
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b', replace_all: true }, { session })
|
||||
expect(text(result)).toBe('The file /abs/a.txt has been updated. All occurrences were successfully replaced.')
|
||||
})
|
||||
|
||||
it('rejects identical old/new strings', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'x', new_string: 'x' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('must differ')
|
||||
})
|
||||
|
||||
it('rejects an empty old_string', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: '', new_string: 'x' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('old_string must be a non-empty string')
|
||||
})
|
||||
|
||||
it('rejects a blank file_path', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'edit', { file_path: ' ', old_string: 'a', new_string: 'b' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('file_path must be a non-empty string')
|
||||
})
|
||||
|
||||
it('propagates FS_NOT_OBSERVED when the file was never read (the gate decides)', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
fs.files.set('key:a.txt', 'hello')
|
||||
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }, { session: { header: {} } })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ code: 'FS_NOT_OBSERVED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool-owned presentation (pure presentCall)', () => {
|
||||
// presentCall is a pure display function of args (no I/O); it drives the ACP
|
||||
// card's title/kind and the `locations` an editor follows along to.
|
||||
const presentCall = async (name: string, args: unknown) => {
|
||||
const { ctx } = await setup()
|
||||
return ctx.tools.get(name)?.presentCall?.(args)
|
||||
}
|
||||
|
||||
it('read: 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({
|
||||
card: 'generic', title: 'Read src/a.ts (12 - 51)', kind: 'read',
|
||||
locations: [{ path: 'src/a.ts', line: 12 }],
|
||||
})
|
||||
})
|
||||
|
||||
it('read: bare title and line-1 location when offset/limit are unset', async () => {
|
||||
expect(await presentCall('read', { file_path: 'a.txt' })).toEqual({
|
||||
card: 'generic', title: 'Read a.txt', kind: 'read', locations: [{ path: 'a.txt', line: 1 }],
|
||||
})
|
||||
})
|
||||
|
||||
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('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('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' }],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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, 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()
|
||||
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, 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')
|
||||
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()
|
||||
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 () => {
|
||||
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('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()
|
||||
})
|
||||
|
||||
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' }] })
|
||||
})
|
||||
})
|
||||
17
packages/fs/tool-fs/tsconfig.json
Normal file
17
packages/fs/tool-fs/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../../core/tools" },
|
||||
{ "path": "../../core/system-prompt" },
|
||||
{ "path": "../fs" },
|
||||
{ "path": "../fs-policy" }
|
||||
]
|
||||
}
|
||||
@@ -17,7 +17,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
|
||||
|
||||
@@ -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 }),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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` (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, 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) 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:
|
||||
|
||||
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.
|
||||
- `{ 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 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.
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -99,9 +99,9 @@ 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` → 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 | ❌ | ✅ | ✅ | 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,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. **Diff + location tool rendering** — `diff` content and `locations` for edit tools.
|
||||
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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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, sep as pathSep } 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,66 +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 } : {},
|
||||
...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, event.data.meta)
|
||||
notify({ sessionId, update: toolResultUpdate(event.data.callId, view, event.data.isError, terminal) })
|
||||
return
|
||||
}
|
||||
case 'todo/write': {
|
||||
@@ -915,44 +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[]
|
||||
/** 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
|
||||
* 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),
|
||||
* 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
|
||||
@@ -962,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<CallId, { name: string; args: unknown; isTerminal: boolean }>()
|
||||
private readonly pending = new Map<CallId, { name: string; args: unknown; card: ToolCallView['card'] }>()
|
||||
|
||||
/**
|
||||
* @param tools the registry to resolve tool definitions by name.
|
||||
@@ -977,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) {
|
||||
@@ -988,50 +911,37 @@ 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.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, 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.
|
||||
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 })
|
||||
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)}`)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1041,8 +951,8 @@ export class ToolPresenter {
|
||||
* results pass their raw content through unchanged.
|
||||
*/
|
||||
export const nullToolPresenter: Pick<ToolPresenter, 'call' | 'result'> = {
|
||||
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`). */
|
||||
@@ -1075,20 +985,121 @@ 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)
|
||||
// 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 `..<sep>…`), 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
@@ -1098,12 +1109,89 @@ 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`. 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
|
||||
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 } : {},
|
||||
}
|
||||
}
|
||||
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 } : {},
|
||||
}
|
||||
case 'diff': {
|
||||
// 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 }))
|
||||
// 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 } : {},
|
||||
...title !== undefined ? { title } : {},
|
||||
}
|
||||
}
|
||||
default:
|
||||
return assertNever(view, 'ToolResultView.card')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,10 @@ import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
|
||||
import {
|
||||
ClientSideConnection,
|
||||
@@ -165,6 +168,15 @@ export async function makeBridgeHarness(options: {
|
||||
* tool + the bridge's own todo/write→plan mapping, not a stand-in.
|
||||
*/
|
||||
withTodo?: boolean
|
||||
/**
|
||||
* Plug the REAL filesystem stack (`dsh-fs-local` + `dsh-fs-policy` +
|
||||
* `dsh-tool-fs`) so a test can drive `read`/`write`/`edit` through the bridge
|
||||
* and assert their tool-owned presentation (title/kind/`locations`) on the
|
||||
* wire — the shipping tools, not a stand-in. `fsCwd` sets the local backend's
|
||||
* base directory (default: `storageDir`).
|
||||
*/
|
||||
withFs?: boolean
|
||||
fsCwd?: string
|
||||
} = { storageDir: '' }): Promise<BridgeHarness> {
|
||||
const adapter = new MockAdapter(options.script ?? [])
|
||||
|
||||
@@ -183,6 +195,11 @@ export async function makeBridgeHarness(options: {
|
||||
if (options.withTodo) {
|
||||
await ctx.plugin(ToolTodo)
|
||||
}
|
||||
if (options.withFs) {
|
||||
await ctx.plugin(LocalFileSystem, { cwd: options.fsCwd ?? options.storageDir })
|
||||
await ctx.plugin(FsPolicy)
|
||||
await ctx.plugin(ToolFs)
|
||||
}
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
// Two identity byte pipes cross-wired into the two ndJsonStreams: bytes the
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionNotification } from '@agentclientprotocol/sdk'
|
||||
import type { ToolDefinition, ToolRegistry } from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolDefinition, ToolRegistry as ToolRegistryType } from '@deepseek-ai/dsh-tools'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import FsLocal from '@deepseek-ai/dsh-fs-local'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import { streamSessionEventUpdate, agentOptions, todosToPlan, ToolPresenter } from '../src/index.ts'
|
||||
|
||||
/** Collect the updates a single event produces (no presenter → generic fallback). */
|
||||
@@ -20,7 +25,7 @@ function liveUpdatesFor(event: SessionEvent): SessionNotification['update'][] {
|
||||
}
|
||||
|
||||
/** A tiny tool registry stub exposing just `get` for {@link ToolPresenter}. */
|
||||
function registryOf(...tools: ToolDefinition[]): Pick<ToolRegistry, 'get'> {
|
||||
function registryOf(...tools: ToolDefinition[]): Pick<ToolRegistryType, 'get'> {
|
||||
const map = new Map(tools.map(t => [t.name, t]))
|
||||
return { get: name => map.get(name) }
|
||||
}
|
||||
@@ -158,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',
|
||||
@@ -166,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}` }],
|
||||
}),
|
||||
}
|
||||
@@ -242,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(
|
||||
@@ -330,29 +336,106 @@ 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('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<NonNullable<ToolDefinition['presentCall']>>,
|
||||
}
|
||||
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('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<NonNullable<ToolDefinition['presentResult']>>,
|
||||
}
|
||||
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 —
|
||||
// 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)
|
||||
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 }),
|
||||
}))
|
||||
// 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 (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' }) })
|
||||
@@ -366,7 +449,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: [
|
||||
@@ -385,33 +468,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',
|
||||
@@ -421,24 +504,311 @@ 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<NonNullable<ToolDefinition['presentCall']>>,
|
||||
})
|
||||
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('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('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;
|
||||
// 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',
|
||||
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 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<Context> {
|
||||
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('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' })
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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).
|
||||
|
||||
16
packages/web/README.md
Normal file
16
packages/web/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# 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-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`) |
|
||||
|
||||
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.
|
||||
30
packages/web/tool-web/README.md
Normal file
30
packages/web/tool-web/README.md
Normal file
@@ -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 registered independently; a product that wants only one disables the other via config (`{ search: false }` / `{ fetch: false }`).
|
||||
|
||||
## 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.
|
||||
45
packages/web/tool-web/package.json
Normal file
45
packages/web/tool-web/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"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/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-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"
|
||||
}
|
||||
}
|
||||
76
packages/web/tool-web/src/fetch.ts
Normal file
76
packages/web/tool-web/src/fetch.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
|
||||
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 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 }): GenericCallView {
|
||||
return { card: 'generic', title: args.url, kind: 'fetch', rawInput: args.url }
|
||||
}
|
||||
|
||||
/** Register the `web_fetch` tool and its system-prompt guidance. */
|
||||
export function applyWebFetchTool(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<ContentBlock[]> {
|
||||
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,
|
||||
}))
|
||||
}
|
||||
85
packages/web/tool-web/src/html.ts
Normal file
85
packages/web/tool-web/src/html.ts
Normal file
@@ -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<string, string> = {
|
||||
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(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<noscript\b[^>]*>[\s\S]*?<\/noscript>/gi, '')
|
||||
.replace(/<!--[\s\S]*?-->/g, '')
|
||||
|
||||
// Convert links to markdown before stripping tags.
|
||||
text = text.replace(/<a\b[^>]*\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(/<h([1-6])\b[^>]*>([\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(/<li\b[^>]*>([\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(/<br\s*\/?>/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
|
||||
}
|
||||
57
packages/web/tool-web/src/index.ts
Normal file
57
packages/web/tool-web/src/index.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 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 (`applyWebSearchTool`, `applyWebFetchTool`).
|
||||
*
|
||||
* 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<Config> = 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)
|
||||
}
|
||||
94
packages/web/tool-web/src/search.ts
Normal file
94
packages/web/tool-web/src/search.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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 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 }): GenericCallView {
|
||||
return { card: 'generic', title: args.query, kind: 'search', rawInput: args.query }
|
||||
}
|
||||
|
||||
/** Register the `web_search` tool and its system-prompt guidance. */
|
||||
export function applyWebSearchTool(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<ContentBlock[]> {
|
||||
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,
|
||||
}))
|
||||
}
|
||||
98
packages/web/tool-web/tests/integration.spec.ts
Normal file
98
packages/web/tool-web/tests/integration.spec.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 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<ReturnType<Context['plugin']>>
|
||||
|
||||
beforeEach(async () => {
|
||||
handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/html' }); res.end('<h1>Hello</h1><p>World</p>') }
|
||||
server = createServer((req, res) => { handler(req, res) })
|
||||
await new Promise<void>(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<void>(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<ToolResult> {
|
||||
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)')
|
||||
})
|
||||
})
|
||||
49
packages/web/tool-web/tests/load-path.spec.ts
Normal file
49
packages/web/tool-web/tests/load-path.spec.ts
Normal file
@@ -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<string, unknown>
|
||||
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<Context['plugin']>[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()
|
||||
})
|
||||
})
|
||||
281
packages/web/tool-web/tests/tool-web.spec.ts
Normal file
281
packages/web/tool-web/tests/tool-web.spec.ts
Normal file
@@ -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<typeof WebService>[1]
|
||||
search?: WebSearchProvider
|
||||
fetchProvider?: import('@deepseek-ai/dsh-web').WebFetchProvider
|
||||
} = {}): Promise<{ ctx: Context; fiber: Awaited<ReturnType<Context['plugin']>>; 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({ card: 'generic', 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: '<h1>Title</h1><p>Body text</p>' },
|
||||
})
|
||||
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: '<p>y</p>' })).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({ card: 'generic', 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('<style>.x{}</style><script>bad()</script><p>Tom & Jerry</p><a href="https://a.test">link</a>')
|
||||
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('<p>a'b</p>')).toBe("a'b")
|
||||
expect(htmlToMarkdown('<div>x</div>\n\n\n<div>y</div>')).toBe('x\n\ny')
|
||||
})
|
||||
|
||||
it('decodes hex entities and named entities, and leaves unknown/out-of-range ones intact', () => {
|
||||
expect(htmlToMarkdown('<p>AB</p>')).toBe('AB')
|
||||
expect(htmlToMarkdown('<p>© —</p>')).toBe('© —')
|
||||
expect(htmlToMarkdown('<p>¬areal;</p>')).toBe('¬areal;')
|
||||
// An out-of-range code point keeps the original entity text (fromCodePoint fallback).
|
||||
expect(htmlToMarkdown('<p>�</p>')).toBe('�')
|
||||
expect(htmlToMarkdown('<p>�</p>')).toBe('�')
|
||||
})
|
||||
|
||||
it('renders a link with an empty label as its bare href', () => {
|
||||
expect(htmlToMarkdown('<a href="https://a.test"></a>')).toBe('https://a.test')
|
||||
})
|
||||
|
||||
it('converts headings and list items to markdown', () => {
|
||||
expect(htmlToMarkdown('<h2>Heading</h2><p>after</p>')).toContain('## Heading')
|
||||
const list = htmlToMarkdown('<ul><li>one</li><li>two</li></ul>')
|
||||
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()
|
||||
})
|
||||
})
|
||||
17
packages/web/tool-web/tsconfig.json
Normal file
17
packages/web/tool-web/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../../core/tools" },
|
||||
{ "path": "../../core/system-prompt" },
|
||||
{ "path": "../web" }
|
||||
]
|
||||
}
|
||||
36
packages/web/web-fetch-local/README.md
Normal file
36
packages/web/web-fetch-local/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# @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 (`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.
|
||||
35
packages/web/web-fetch-local/package.json
Normal file
35
packages/web/web-fetch-local/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"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/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"
|
||||
}
|
||||
}
|
||||
97
packages/web/web-fetch-local/src/index.ts
Normal file
97
packages/web/web-fetch-local/src/index.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* `@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, decoderForCharset, isSameOrigin, parseCharset, 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<Config> = 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<Config>
|
||||
|
||||
/** 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,
|
||||
maxBodyChars: resolved.maxBodyChars,
|
||||
timeoutMs: resolved.timeoutMs,
|
||||
maxTimeoutMs: resolved.maxTimeoutMs,
|
||||
maxRedirects: resolved.maxRedirects,
|
||||
userAgent: resolved.userAgent,
|
||||
}
|
||||
ctx.web.registerFetchProvider(new LocalFetchProvider(limits))
|
||||
}
|
||||
85
packages/web/web-fetch-local/src/policy.ts
Normal file
85
packages/web/web-fetch-local/src/policy.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 })
|
||||
}
|
||||
}
|
||||
278
packages/web/web-fetch-local/src/provider.ts
Normal file
278
packages/web/web-fetch-local/src/provider.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* `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, decoderForCharset, isSameOrigin, parseCharset, 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<WebFetchResult> {
|
||||
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)
|
||||
} 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): Promise<WebFetchResult> {
|
||||
let currentUrl = validateFetchUrl(initialUrl, this.limits.maxUrlLength)
|
||||
let redirectsFollowed = 0
|
||||
|
||||
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
|
||||
// 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. 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
|
||||
redirectsFollowed++
|
||||
continue
|
||||
}
|
||||
|
||||
return await this.readBody(response, currentUrl, controller.signal)
|
||||
}
|
||||
}
|
||||
|
||||
private async requestOnce(url: URL, controller: AbortController): Promise<Response> {
|
||||
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, controller.signal)
|
||||
}
|
||||
}
|
||||
|
||||
/** Read, byte-cap, classify, and decode the final response body. */
|
||||
private async readBody(response: Response, finalUrl: URL, signal: AbortSignal): Promise<WebFetchResult> {
|
||||
const contentType = response.headers.get('content-type')
|
||||
const kind = classifyContentType(contentType)
|
||||
if (kind === undefined) {
|
||||
await response.body?.cancel()
|
||||
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 — 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
|
||||
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, signal: AbortSignal): 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
|
||||
// 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
|
||||
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, signal)
|
||||
} 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`,
|
||||
* 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 })
|
||||
}
|
||||
424
packages/web/web-fetch-local/tests/fetch-local.spec.ts
Normal file
424
packages/web/web-fetch-local/tests/fetch-local.spec.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
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 WebService from '@deepseek-ai/dsh-web'
|
||||
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'
|
||||
|
||||
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<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const { port } = server.address() as AddressInfo
|
||||
base = `http://127.0.0.1:${port}`
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals()
|
||||
await new Promise<void>(resolve => server.close(() => { resolve() }))
|
||||
})
|
||||
|
||||
function provider(overrides: Partial<LocalFetchLimits> = {}): 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)
|
||||
})
|
||||
|
||||
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', () => {
|
||||
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('<h1>hi</h1>') }
|
||||
const result = await provider().fetch({ url: base })
|
||||
expect(result.body).toEqual({ kind: 'html', content: '<h1>hi</h1>' })
|
||||
})
|
||||
|
||||
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('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 })
|
||||
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')
|
||||
})
|
||||
|
||||
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', () => {
|
||||
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('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')
|
||||
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('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 }))
|
||||
.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('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/' }))
|
||||
.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('LocalFetchProvider body cancellation on error paths', () => {
|
||||
/** A fake Response whose body.cancel is observable. */
|
||||
type FakeInit = { status: number; headers: Record<string, string>; 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()
|
||||
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)
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
24
packages/web/web-fetch-local/tsconfig.json
Normal file
24
packages/web/web-fetch-local/tsconfig.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
36
packages/web/web-search-deepseek/README.md
Normal file
36
packages/web/web-search-deepseek/README.md
Normal file
@@ -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` | 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
|
||||
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`.
|
||||
35
packages/web/web-search-deepseek/package.json
Normal file
35
packages/web/web-search-deepseek/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
83
packages/web/web-search-deepseek/src/index.ts
Normal file
83
packages/web/web-search-deepseek/src/index.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* `@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<Config> = z.object({
|
||||
apiKey: z.string(),
|
||||
baseURL: z.string(),
|
||||
model: z.string(),
|
||||
apiVersion: z.string(),
|
||||
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,
|
||||
maxUses,
|
||||
}))
|
||||
}
|
||||
223
packages/web/web-search-deepseek/src/provider.ts
Normal file
223
packages/web/web-search-deepseek/src/provider.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* `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<string, string> {
|
||||
const map = new Map<string, string>()
|
||||
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<string>()
|
||||
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' }
|
||||
if (!isPositiveInteger(this.options.maxTokens) || !isPositiveInteger(this.options.maxUses)) return { available: false, reason: 'misconfigured' }
|
||||
return { available: true }
|
||||
}
|
||||
|
||||
async search(request: WebSearchRequest, exec?: { readonly signal?: AbortSignal }): Promise<WebSearchResult> {
|
||||
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')
|
||||
}
|
||||
|
||||
try {
|
||||
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 })
|
||||
if (error instanceof WebError) throw error
|
||||
throw new WebError(`DeepSeek returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */
|
||||
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
|
||||
}
|
||||
58
packages/web/web-search-deepseek/src/types.ts
Normal file
58
packages/web/web-search-deepseek/src/types.ts
Normal file
@@ -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
|
||||
}
|
||||
36
packages/web/web-search-deepseek/tests/deepseek.e2e.ts
Normal file
36
packages/web/web-search-deepseek/tests/deepseek.e2e.ts
Normal file
@@ -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)
|
||||
})
|
||||
362
packages/web/web-search-deepseek/tests/deepseek.spec.ts
Normal file
362
packages/web/web-search-deepseek/tests/deepseek.spec.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
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' })
|
||||
})
|
||||
|
||||
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', () => {
|
||||
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<string, string>
|
||||
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('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))
|
||||
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('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)
|
||||
})
|
||||
|
||||
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<string, unknown>
|
||||
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<Context['plugin']>[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<string, string>)['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
|
||||
}
|
||||
})
|
||||
})
|
||||
24
packages/web/web-search-deepseek/tsconfig.json
Normal file
24
packages/web/web-search-deepseek/tsconfig.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
26
packages/web/web-search-exa/README.md
Normal file
26
packages/web/web-search-exa/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# @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. 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
|
||||
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`. 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`.
|
||||
35
packages/web/web-search-exa/package.json
Normal file
35
packages/web/web-search-exa/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"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/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"
|
||||
}
|
||||
}
|
||||
68
packages/web/web-search-exa/src/index.ts
Normal file
68
packages/web/web-search-exa/src/index.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* `@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,
|
||||
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,
|
||||
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
|
||||
/** 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<Config> = 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 {
|
||||
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 } : {},
|
||||
}))
|
||||
}
|
||||
161
packages/web/web-search-exa/src/provider.ts
Normal file
161
packages/web/web-search-exa/src/provider.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* `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'
|
||||
|
||||
/** 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'
|
||||
|
||||
export interface ExaSearchProviderOptions {
|
||||
/** Exa API key. Empty/absent → `status()` reports `missing-credential`. */
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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' }
|
||||
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<WebSearchResult> {
|
||||
// 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`, {
|
||||
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,
|
||||
type: this.options.searchType,
|
||||
contents: { highlights: { highlightsPerUrl: this.options.highlightsPerResult } },
|
||||
...numResults !== undefined ? { numResults } : {},
|
||||
}),
|
||||
...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 (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')
|
||||
}
|
||||
|
||||
try {
|
||||
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 unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 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'
|
||||
}
|
||||
38
packages/web/web-search-exa/src/types.ts
Normal file
38
packages/web/web-search-exa/src/types.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 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
|
||||
/** 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: { highlightsPerUrl: number } }
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
24
packages/web/web-search-exa/tests/exa.e2e.ts
Normal file
24
packages/web/web-search-exa/tests/exa.e2e.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
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`
|
||||
* (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,
|
||||
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)
|
||||
for (const source of result.sources) expect(source.url).toMatch(/^https?:\/\//)
|
||||
}, 30_000)
|
||||
})
|
||||
264
packages/web/web-search-exa/tests/exa.spec.ts
Normal file
264
packages/web/web-search-exa/tests/exa.spec.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
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', 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 })
|
||||
}
|
||||
|
||||
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({ ...options, apiKey: '' }).status())
|
||||
.toEqual({ available: false, reason: 'missing-credential' })
|
||||
})
|
||||
|
||||
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({ ...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, 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, 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<string, string>)['authorization']).toBe('Bearer exa-key')
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
query: 'hello',
|
||||
type: 'neural',
|
||||
contents: { highlights: { highlightsPerUrl: 3 } },
|
||||
numResults: 5,
|
||||
})
|
||||
})
|
||||
|
||||
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' })
|
||||
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' }))
|
||||
})
|
||||
|
||||
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))
|
||||
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', () => {
|
||||
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('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, 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 } }, numResults: 9 })
|
||||
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'
|
||||
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
|
||||
}
|
||||
})
|
||||
})
|
||||
24
packages/web/web-search-exa/tsconfig.json
Normal file
24
packages/web/web-search-exa/tsconfig.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
26
packages/web/web-search-perplexity/README.md
Normal file
26
packages/web/web-search-perplexity/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# @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. 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
|
||||
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`).
|
||||
35
packages/web/web-search-perplexity/package.json
Normal file
35
packages/web/web-search-perplexity/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"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/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"
|
||||
}
|
||||
}
|
||||
62
packages/web/web-search-perplexity/src/index.ts
Normal file
62
packages/web/web-search-perplexity/src/index.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* `@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_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 { PerplexityRecency, 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
|
||||
/** 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<Config> = 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 {
|
||||
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 } : {},
|
||||
}))
|
||||
}
|
||||
160
packages/web/web-search-perplexity/src/provider.ts
Normal file
160
packages/web/web-search-perplexity/src/provider.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* `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'
|
||||
|
||||
/** 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'
|
||||
|
||||
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
|
||||
/** 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. */
|
||||
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' }
|
||||
if (!URL.canParse(this.options.baseURL)) return { available: false, reason: 'misconfigured' }
|
||||
if (!isPositiveInteger(this.options.maxTokens)) return { available: false, reason: 'misconfigured' }
|
||||
return { available: true }
|
||||
}
|
||||
|
||||
async search(request: WebSearchRequest, exec?: { readonly signal?: AbortSignal }): Promise<WebSearchResult> {
|
||||
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,
|
||||
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 } : {},
|
||||
})
|
||||
} 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 (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')
|
||||
}
|
||||
|
||||
try {
|
||||
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 unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */
|
||||
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
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user