Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification
# Conflicts: # .agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml # .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml # .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md # .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md # .agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml # .agents/notes/implemented/feature/2026-07-06-sandbox.md # .agents/notes/implemented/feature/2026-07-06-sandbox.zh.md # .agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml # .agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml # .agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml # .agents/notes/implemented/simplification/2026-07-30-private-agent-send.i18n.yaml # docs/architecture.i18n.yaml # docs/architecture.md # docs/architecture.zh.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/compaction.i18n.yaml # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/core.md # docs/core-data-structures/core.zh.md # docs/core-data-structures/llm-streaming.i18n.yaml # docs/core-data-structures/llm-streaming.md # docs/core-data-structures/llm-streaming.zh.md # docs/core-data-structures/session.i18n.yaml # docs/event-producer-consumer.md # docs/module-graph.md # docs/persistence-catalog.md # examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl # examples/acp-agent/tests/snapshots/bash-spill/session.jsonl # examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl # examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl # examples/acp-agent/tests/snapshots/cancel/session.jsonl # examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl # examples/acp-agent/tests/snapshots/error-finish/session.jsonl # examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl # examples/acp-agent/tests/snapshots/fs-edit/session.jsonl # examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl # examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl # examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl # examples/acp-agent/tests/snapshots/fs-read/session.jsonl # examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl # examples/acp-agent/tests/snapshots/fs-write/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl # examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl # examples/acp-agent/tests/snapshots/multi-turn/session.jsonl # examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl # examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl # examples/acp-agent/tests/snapshots/pty-tools/session.jsonl # examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl # examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl # examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl # examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl # examples/acp-agent/tests/snapshots/skill-load/session.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl # examples/acp-agent/tests/snapshots/text-turn/session.jsonl # examples/acp-agent/tests/snapshots/todo-write/session.jsonl # examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl # examples/acp-agent/tests/snapshots/web-fetch/session.jsonl # examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl # examples/acp-agent/tests/snapshots/workflow-run/session.jsonl # examples/acp-agent/tests/snapshots/workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl # examples/headless-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/pty-tools/session.jsonl # examples/headless-agent/tests/snapshots/pty-tools/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/ralph-loop/stream-json.expected.jsonl # examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl # examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/bash-tool/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl # examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl # examples/jsonrpc-agent/tests/snapshots/text-turn/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl # packages/client/runtime/README.i18n.yaml # packages/client/runtime/src/client/sessions/request-inspection.ts # packages/compact/compact-basic/README.i18n.yaml # packages/compact/compact-basic/README.md # packages/compact/compact-basic/README.zh.md # packages/compact/compact-basic/src/index.ts # packages/context/time-context/tests/time-context.spec.ts # packages/context/tmux-context/README.i18n.yaml # packages/context/tmux-context/tests/tmux-context.spec.ts # packages/context/workspace-context/tests/workspace-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-loop/README.i18n.yaml # packages/core/agent-loop/README.md # packages/core/agent-loop/README.zh.md # packages/core/agent-loop/src/agent.ts # packages/core/agent/README.i18n.yaml # packages/core/agent/README.md # packages/core/agent/README.zh.md # packages/core/agent/src/types.ts # packages/core/session/README.i18n.yaml # packages/core/session/README.md # packages/core/session/README.zh.md # packages/fs/tool-str-replace-editor/tests/tools.spec.ts # packages/goal/command-goal/tests/command-goal.spec.ts # packages/goal/goal/tests/goal.spec.ts # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api/index.ts # packages/host/apiproxy/tests/api-proxy-workspace.spec.ts # packages/llm/llm/README.i18n.yaml # packages/llm/llm/README.md # packages/llm/llm/README.zh.md # packages/llm/llm/src/index.ts # packages/pty/pty-local/tests/index.spec.ts # packages/pty/pty-local/tests/local.spec.ts # packages/pty/pty/tests/service.spec.ts # packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts # packages/pty/tool-bash-persistent/tests/tools.spec.ts # packages/pty/tool-pty/tests/loader-composition.spec.ts # packages/pty/tool-pty/tests/tools.spec.ts # packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts # packages/skill/tool-skill/tests/tool-skill.spec.ts # packages/tasks/tasks-local/tests/tasks.spec.ts # packages/ui/tui/README.i18n.yaml # packages/ui/tui/tests/tui.spec.ts # packages/ui/user-approval/src/index.ts # packages/ui/user-approval/tests/approval.spec.ts
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/fs/fs-sandbox/README.md
|
||||
README.md: 790444a4184b9bcccd3a0798cf0c09cb6f1b166e
|
||||
README.zh.md: d54bdcbe65673b6892ebd1d539dd066f66d68cb6
|
||||
README.md: d6070f4971e7531e929e659b6b5ed476a672dc5d
|
||||
README.zh.md: c051b240163f229f7fbc583939c9f4da5095e9aa
|
||||
|
||||
@@ -22,11 +22,19 @@ A denial is a structured `FsError` (`FS_SANDBOX_DENIED`, carrying the effective
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through `dsh-tool-fs`, which renders this backend's `FS_SANDBOX_DENIED` refusals as the `[sandbox: file access denied under <mode> mode]` marker plus the same-turn escalation hint.
|
||||
### Filesystem policy and refusals
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The policy owner contributes capability-neutral `sandbox:policy` context. Indirectly, `dsh-tool-fs` renders this backend's `FS_SANDBOX_DENIED` refusals as the `[sandbox: file access denied under <mode> mode]` marker plus the same-turn escalation hint.
|
||||
|
||||
#### Token effect
|
||||
|
||||
The current-policy clause adds a small runtime-context message while this backend is mounted; a denial adds the bounded marker and escalation hint to conversation history.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
A standing-policy change appends an owner-rendered superseding runtime-context snapshot after retained history; operation results remain append-only.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
|
||||
@@ -22,11 +22,19 @@
|
||||
|
||||
## 模型体验
|
||||
|
||||
通过 `dsh-tool-fs` 间接产生影响;该消费方把本后端的 `FS_SANDBOX_DENIED` 拒绝渲染为 `[sandbox: file access denied under <mode> mode]` 标记和同轮次升级提示。
|
||||
### 文件系统策略与拒绝
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
策略归属方会贡献与具体能力无关的 `sandbox:policy` 上下文。作为间接影响,`dsh-tool-fs` 会把本后端的 `FS_SANDBOX_DENIED` 拒绝渲染为 `[sandbox: file access denied under <mode> mode]` 标记和同轮次升级提示。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
该后端挂载期间,当前策略条款会增加一条简短的运行时上下文消息;拒绝则会把有界标记和升级提示追加到对话历史。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
不会直接使缓存失效;上述消费方负责请求前缀的任何变化。
|
||||
常驻策略发生变化时,会在保留的历史之后追加一份由归属方渲染、取代先前状态的运行时上下文快照;操作结果保持仅追加。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ import { isPathUnder } from './containment.ts'
|
||||
* Plugin config: the local backend's knobs, verbatim (only `cwd`, the resolve
|
||||
* base for relative paths). The sandbox default (mode + `workspace-write`
|
||||
* fallback root) is NOT here — `ctx.sandboxPolicy` resolves each calling
|
||||
* session for both enforcing families.
|
||||
* session for every enforcing capability.
|
||||
*/
|
||||
export type Config = LocalConfig
|
||||
|
||||
|
||||
@@ -11,11 +11,12 @@
|
||||
import type { Context } from 'cordis'
|
||||
import { sep } from 'node:path'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView, SearchResultView, ToolResult } from '@deepseek-ai/dsh-tools'
|
||||
import type { SpillRef } from '@deepseek-ai/dsh-spill'
|
||||
import type {} from '@deepseek-ai/dsh-bash'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
|
||||
import { globSearchMeta, searchViewFromMeta } from './presentation.ts'
|
||||
import { singleQuote } from './shell-quote.ts'
|
||||
import { acceptedSurfaceValue } from './surface.ts'
|
||||
|
||||
@@ -43,6 +44,8 @@ export interface GlobToolCaps {
|
||||
sampleOverCapGlobResults: boolean
|
||||
/** Max paths retained inline; later paths go to the formatted spill file. */
|
||||
maxResults: number
|
||||
/** Max bytes of serialized `presentationMeta`; trailing paths drop past it. */
|
||||
maxMetaBytes: number
|
||||
/** Cap on the complete raw `rg` stdout the tool will parse. */
|
||||
rawOutputMaxBytes: number
|
||||
/** Cooperative tool-call budget (ms) attached as `ToolDefinition.timeoutMs`. */
|
||||
@@ -231,6 +234,24 @@ function renderGlobPaths(paths: string[], caps: GlobToolCaps, root: string, spil
|
||||
return formatGlobOutput(sampleAcrossTopLevel(paths, caps.maxResults, root), paths.length, spillRef)
|
||||
}
|
||||
|
||||
/**
|
||||
* The inline page of paths a completed `glob` card shows, computed the SAME way
|
||||
* {@link renderGlobPaths} computes its model-facing page so the card and the text
|
||||
* agree on which paths survived the cap. A result within the cap is shown whole;
|
||||
* an over-cap result is either the modification-time head or the top-level sample,
|
||||
* matching the deployment's `sampleOverCapGlobResults`.
|
||||
*
|
||||
* @param paths - the complete discovered path list, in modification-time order.
|
||||
* @param caps - the resolved glob caps (the inline cap and the sampling switch).
|
||||
* @param root - the search root in the same display-path space as `paths`.
|
||||
* @returns the inline page and whether the complete result was capped.
|
||||
*/
|
||||
function globCardPage(paths: string[], caps: GlobToolCaps, root: string): { items: string[]; truncated: boolean } {
|
||||
if (paths.length <= caps.maxResults) return { items: paths, truncated: false }
|
||||
if (!caps.sampleOverCapGlobResults) return { items: paths.slice(0, caps.maxResults), truncated: true }
|
||||
return { items: sampleAcrossTopLevel(paths, caps.maxResults, root).items, truncated: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending-call presentation: a search card titled by the pattern (and root).
|
||||
*
|
||||
@@ -242,6 +263,24 @@ export function presentGlobCall(args: { pattern: string; path?: string }): Gener
|
||||
return { card: 'generic', title: `Glob ${args.pattern}${where}`, kind: 'search', rawInput: args.pattern }
|
||||
}
|
||||
|
||||
/**
|
||||
* Completed-call presentation: the search card projected from the result's
|
||||
* `presentationMeta` (the discovered path list, with the truncation signal). A UI
|
||||
* without a search card falls back to the raw `tool/result` content, so the view
|
||||
* carries no result text of its own. Malformed or absent metadata (an obsolete or
|
||||
* hand-edited replayed log) falls back to the generic card.
|
||||
*
|
||||
* @param _args - the raw tool arguments; unused, the view derives from the result.
|
||||
* @param result - the final model-facing tool result carrying the projected metadata.
|
||||
* @returns the search card view, or `undefined` for the generic fallback.
|
||||
*/
|
||||
export function presentGlobResult(_args: { pattern: string; path?: string }, result: ToolResult): SearchResultView | undefined {
|
||||
if (result.isError) return undefined
|
||||
const view = searchViewFromMeta(result.meta)
|
||||
if (view === undefined || view.shape !== 'paths') return undefined
|
||||
return view
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the `glob` tool and its system-prompt guidance.
|
||||
*
|
||||
@@ -289,6 +328,10 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
|
||||
},
|
||||
},
|
||||
render: (_args, value) => [{ type: 'text', text: renderGlobPaths(value.paths, caps, value.root) }],
|
||||
presentationMeta: (_args, value) => {
|
||||
const page = globCardPage(value.paths, caps, value.root)
|
||||
return globSearchMeta({ items: page.items, truncated: page.truncated, seen: value.paths.length }, caps.maxMetaBytes)
|
||||
},
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const input = parseGlobArgs(args)
|
||||
@@ -305,6 +348,7 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
|
||||
return { root, paths: all }
|
||||
},
|
||||
presentCall: presentGlobCall,
|
||||
presentResult: presentGlobResult,
|
||||
})
|
||||
ctx.tools.register(tool)
|
||||
|
||||
|
||||
@@ -12,13 +12,14 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
|
||||
import { ItemRetainer, TextRetainer } from '@deepseek-ai/dsh-retention'
|
||||
import type { GenericCallView, SearchResultView, ToolResult } from '@deepseek-ai/dsh-tools'
|
||||
import type { RetainedItems } from '@deepseek-ai/dsh-retention'
|
||||
import type { SpillRef } from '@deepseek-ai/dsh-spill'
|
||||
import type {} from '@deepseek-ai/dsh-bash'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { SearchError, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
|
||||
import type { GrepMatch } from './search-core.ts'
|
||||
import { SearchError, previewLine, retainGrepMatches, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
|
||||
import { grepSearchMeta, searchViewFromMeta } from './presentation.ts'
|
||||
import { singleQuote } from './shell-quote.ts'
|
||||
import { acceptedSurfaceValue } from './surface.ts'
|
||||
|
||||
@@ -41,6 +42,8 @@ export interface GrepToolCaps {
|
||||
maxMatches: number
|
||||
/** Max bytes retained per matched-line preview. */
|
||||
maxLineBytes: number
|
||||
/** Max bytes of serialized `presentationMeta`; trailing file groups drop past it. */
|
||||
maxMetaBytes: number
|
||||
/** Cap on the complete raw `rg` stdout the tool will parse. */
|
||||
rawOutputMaxBytes: number
|
||||
/** Cooperative tool-call budget (ms) attached as `ToolDefinition.timeoutMs`. */
|
||||
@@ -54,13 +57,6 @@ export interface GrepInput {
|
||||
include?: string
|
||||
}
|
||||
|
||||
/** One parsed match: the file, the 1-based line number, and the (possibly previewed) line text. */
|
||||
export interface GrepMatch {
|
||||
path: string
|
||||
lineNumber: number
|
||||
line: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject an `include` that is not ONE positive glob filter: blank strings,
|
||||
* negated patterns (`!…`), and comma-separated lists. A comma inside a brace
|
||||
@@ -177,22 +173,6 @@ export function parseGrepMatches(stdout: string): GrepMatch[] {
|
||||
return matches
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound one matched-line preview to `maxBytes` (UTF-8 boundary preserved) and
|
||||
* mark the cut. The cap is a per-line budget fact; the complete line stays in
|
||||
* the searched file for `read`.
|
||||
*
|
||||
* @param line - the matched line text (trailing newline already stripped).
|
||||
* @param maxBytes - the preview budget in bytes.
|
||||
* @returns the preview, suffixed with ` (line truncated)` when bytes were cut.
|
||||
*/
|
||||
export function previewLine(line: string, maxBytes: number): string {
|
||||
const retainer = new TextRetainer({ kind: 'head', maxBytes })
|
||||
retainer.push(line)
|
||||
const kept = retainer.finish()
|
||||
return kept.truncated ? `${kept.text} (line truncated)` : kept.text
|
||||
}
|
||||
|
||||
/** `match` / `matches` for a count. */
|
||||
function matchNoun(count: number): string {
|
||||
return count === 1 ? 'match' : 'matches'
|
||||
@@ -241,18 +221,10 @@ export function formatGrepOutput(retained: RetainedItems<GrepMatch>, spillRef: S
|
||||
return `${header}\n\n${body}\n\n(${recovery})`
|
||||
}
|
||||
|
||||
/** Apply the Native per-line preview budget without changing the canonical matches. */
|
||||
function previewGrepMatches(matches: GrepMatch[], maxLineBytes: number): GrepMatch[] {
|
||||
return matches.map(match => ({ ...match, line: previewLine(match.line, maxLineBytes) }))
|
||||
}
|
||||
|
||||
/** Retain and format one canonical match list for the Native surface. */
|
||||
function renderGrepMatches(matches: GrepMatch[], maxMatches: number, maxLineBytes: number, spillRef?: SpillRef): string {
|
||||
if (matches.length === 0) return 'No matches found'
|
||||
const previewed = previewGrepMatches(matches, maxLineBytes)
|
||||
const retainer = new ItemRetainer<GrepMatch>({ kind: 'head', maxItems: maxMatches })
|
||||
for (const match of previewed) retainer.push(match)
|
||||
return formatGrepOutput(retainer.finish(), spillRef)
|
||||
/** Format one already-retained match list for the Native surface. */
|
||||
function formatRetainedGrep(retained: RetainedItems<GrepMatch>, spillRef?: SpillRef): string {
|
||||
if (retained.seen === 0) return 'No matches found'
|
||||
return formatGrepOutput(retained, spillRef)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -268,6 +240,27 @@ export function presentGrepCall(args: { pattern: string; path?: string; include?
|
||||
return { card: 'generic', title: `Grep ${args.pattern}${where}${filter}`, kind: 'search', rawInput: args.pattern }
|
||||
}
|
||||
|
||||
/**
|
||||
* Completed-call presentation: the search card projected from the result's
|
||||
* `presentationMeta` (matches grouped by file, with the truncation signal). A UI
|
||||
* without a search card falls back to the raw `tool/result` content, so the view
|
||||
* carries no result text of its own. Malformed or absent metadata (an obsolete or
|
||||
* hand-edited replayed log) falls back to the generic card.
|
||||
*
|
||||
* @param _args - the raw tool arguments; unused, the view derives from the result.
|
||||
* @param result - the final model-facing tool result carrying the projected metadata.
|
||||
* @returns the search card view, or `undefined` for the generic fallback.
|
||||
*/
|
||||
export function presentGrepResult(
|
||||
_args: { pattern: string; path?: string; include?: string },
|
||||
result: ToolResult,
|
||||
): SearchResultView | undefined {
|
||||
if (result.isError) return undefined
|
||||
const view = searchViewFromMeta(result.meta)
|
||||
if (view === undefined || view.shape !== 'matches') return undefined
|
||||
return view
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the `grep` tool and its system-prompt guidance.
|
||||
*
|
||||
@@ -315,8 +308,10 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void {
|
||||
},
|
||||
render: (_args, value) => [{
|
||||
type: 'text',
|
||||
text: renderGrepMatches(value.matches, caps.maxMatches, caps.maxLineBytes),
|
||||
text: formatRetainedGrep(retainGrepMatches(value.matches, caps.maxMatches, caps.maxLineBytes)),
|
||||
}],
|
||||
presentationMeta: (_args, value) =>
|
||||
grepSearchMeta(retainGrepMatches(value.matches, caps.maxMatches, caps.maxLineBytes), caps.maxMetaBytes),
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const input = parseGrepArgs(args)
|
||||
@@ -335,6 +330,7 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void {
|
||||
return { matches: all }
|
||||
},
|
||||
presentCall: presentGrepCall,
|
||||
presentResult: presentGrepResult,
|
||||
})
|
||||
ctx.tools.register(tool)
|
||||
|
||||
@@ -344,17 +340,20 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void {
|
||||
if (value === undefined) return decision
|
||||
const matches = value.matches
|
||||
if (matches.length <= caps.maxMatches) return decision
|
||||
// The spill artifact holds the COMPLETE result: preview each line, but keep
|
||||
// every match (no inline cap), so the recovery file is the full search.
|
||||
const previewedAll = matches.map(match => ({ ...match, line: previewLine(match.line, caps.maxLineBytes) }))
|
||||
const spillRef = await trySaveFormattedResult(
|
||||
ctx,
|
||||
exec,
|
||||
'grep-results.txt',
|
||||
`Found ${matches.length} ${matchNoun(matches.length)}\n\n${formatGrepMatches(previewGrepMatches(matches, caps.maxLineBytes))}`,
|
||||
`Found ${matches.length} ${matchNoun(matches.length)}\n\n${formatGrepMatches(previewedAll)}`,
|
||||
)
|
||||
return {
|
||||
kind: 'accept',
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: renderGrepMatches(matches, caps.maxMatches, caps.maxLineBytes, spillRef),
|
||||
text: formatRetainedGrep(retainGrepMatches(matches, caps.maxMatches, caps.maxLineBytes), spillRef),
|
||||
}],
|
||||
...decision.additionalContexts !== undefined ? { additionalContexts: decision.additionalContexts } : {},
|
||||
}
|
||||
|
||||
@@ -31,9 +31,9 @@ import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { GLOB_MAX_RESULTS, applyGlobTool } from './glob.ts'
|
||||
import { GREP_MAX_LINE_BYTES, GREP_MAX_MATCHES, applyGrepTool } from './grep.ts'
|
||||
import { RAW_OUTPUT_MAX_BYTES, SEARCH_TIMEOUT_MS } from './search-core.ts'
|
||||
import { RAW_OUTPUT_MAX_BYTES, SEARCH_META_MAX_BYTES, SEARCH_TIMEOUT_MS } from './search-core.ts'
|
||||
|
||||
export { GLOB_MAX_RESULTS, GLOB_VCS_EXCLUDES, applyGlobTool, buildGlobCommand, formatGlobOutput, parseGlobArgs, presentGlobCall, sampleAcrossTopLevel } from './glob.ts'
|
||||
export { GLOB_MAX_RESULTS, GLOB_VCS_EXCLUDES, applyGlobTool, buildGlobCommand, formatGlobOutput, parseGlobArgs, presentGlobCall, presentGlobResult, sampleAcrossTopLevel } from './glob.ts'
|
||||
export type { GlobInput, GlobSample, GlobToolCaps } from './glob.ts'
|
||||
export {
|
||||
GREP_MAX_LINE_BYTES,
|
||||
@@ -45,11 +45,20 @@ export {
|
||||
parseGrepArgs,
|
||||
parseGrepMatches,
|
||||
presentGrepCall,
|
||||
previewLine,
|
||||
presentGrepResult,
|
||||
} from './grep.ts'
|
||||
export type { GrepInput, GrepMatch, GrepToolCaps } from './grep.ts'
|
||||
export { RAW_OUTPUT_MAX_BYTES, SEARCH_TIMEOUT_MS, SearchError, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
|
||||
export type { RipgrepRun, SearchErrorCode } from './search-core.ts'
|
||||
export type { GrepInput, GrepToolCaps } from './grep.ts'
|
||||
export {
|
||||
RAW_OUTPUT_MAX_BYTES,
|
||||
SEARCH_META_MAX_BYTES,
|
||||
SEARCH_TIMEOUT_MS,
|
||||
SearchError,
|
||||
previewLine,
|
||||
runRipgrep,
|
||||
toWorkdirRelative,
|
||||
trySaveFormattedResult,
|
||||
} from './search-core.ts'
|
||||
export type { GrepMatch, RipgrepRun, SearchErrorCode } from './search-core.ts'
|
||||
export { singleQuote } from './shell-quote.ts'
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
@@ -68,6 +77,8 @@ export interface Config {
|
||||
grepMaxMatches?: number
|
||||
/** Max bytes retained for one matched-line preview (the cut preserves UTF-8 boundaries). */
|
||||
grepMaxLineBytes?: number
|
||||
/** Max bytes of one search's serialized `presentationMeta`; trailing groups/paths drop past it so the persisted card stays bounded. */
|
||||
searchMetaMaxBytes?: number
|
||||
/** Max complete raw `rg` stdout bytes a search will parse; larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. */
|
||||
rawOutputMaxBytes?: number
|
||||
/** Cooperative tool-call timeout budget (ms) on both tools, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`. */
|
||||
@@ -79,6 +90,7 @@ export const Config: z<Config> = z.object({
|
||||
globMaxResults: z.number().default(GLOB_MAX_RESULTS),
|
||||
grepMaxMatches: z.number().default(GREP_MAX_MATCHES),
|
||||
grepMaxLineBytes: z.number().default(GREP_MAX_LINE_BYTES),
|
||||
searchMetaMaxBytes: z.number().default(SEARCH_META_MAX_BYTES),
|
||||
rawOutputMaxBytes: z.number().default(RAW_OUTPUT_MAX_BYTES),
|
||||
timeoutMs: z.number().default(SEARCH_TIMEOUT_MS),
|
||||
})
|
||||
@@ -133,6 +145,7 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
assertPositiveInteger('globMaxResults', resolved.globMaxResults)
|
||||
assertPositiveInteger('grepMaxMatches', resolved.grepMaxMatches)
|
||||
assertPositiveInteger('grepMaxLineBytes', resolved.grepMaxLineBytes)
|
||||
assertPositiveInteger('searchMetaMaxBytes', resolved.searchMetaMaxBytes)
|
||||
assertPositiveInteger('rawOutputMaxBytes', resolved.rawOutputMaxBytes)
|
||||
assertPositiveInteger('timeoutMs', resolved.timeoutMs)
|
||||
if (!await ripgrepAvailable(ctx)) {
|
||||
@@ -142,12 +155,14 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
applyGlobTool(ctx, {
|
||||
sampleOverCapGlobResults: resolved.sampleOverCapGlobResults,
|
||||
maxResults: resolved.globMaxResults,
|
||||
maxMetaBytes: resolved.searchMetaMaxBytes,
|
||||
rawOutputMaxBytes: resolved.rawOutputMaxBytes,
|
||||
timeoutMs: resolved.timeoutMs,
|
||||
})
|
||||
applyGrepTool(ctx, {
|
||||
maxMatches: resolved.grepMaxMatches,
|
||||
maxLineBytes: resolved.grepMaxLineBytes,
|
||||
maxMetaBytes: resolved.searchMetaMaxBytes,
|
||||
rawOutputMaxBytes: resolved.rawOutputMaxBytes,
|
||||
timeoutMs: resolved.timeoutMs,
|
||||
})
|
||||
|
||||
205
packages/fs/tool-fs-search/src/presentation.ts
Normal file
205
packages/fs/tool-fs-search/src/presentation.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Result-time search-card presentation for `grep` and `glob`. Both tools land on
|
||||
* one `card: 'search'` render intent ({@link SearchResultView}) with two
|
||||
* `shape`-discriminated variants: `grep` projects its matches grouped by file
|
||||
* ({@link SearchMatchesResultView}), `glob` projects a flat path list
|
||||
* ({@link SearchPathsResultView}). This module owns the value→`presentationMeta`
|
||||
* projection each tool declares and the defensive `meta`→view narrowing each
|
||||
* tool's `presentResult` reads back on replay.
|
||||
*
|
||||
* The canonical value never crosses the wire — only the model-facing render text
|
||||
* and this JSON `meta` do — so the structured shape a UI renders MUST ride in
|
||||
* `meta`. Each projection consumes the SAME retained matches/paths the
|
||||
* model-facing render consumes ({@link module:@deepseek-ai/dsh-tool-fs-search/search-core}
|
||||
* `retainGrepMatches`/`retainGlobPaths`), so text and card agree about which
|
||||
* results survived the inline cap, and reports `total` (every result found) and
|
||||
* `truncated`, so a UI never presents a capped result as complete.
|
||||
*
|
||||
* A second, independent cap bounds the JSON `meta` itself: the retained matches
|
||||
* of a broad search (hundreds of long lines) can still serialize to hundreds of
|
||||
* kilobytes, and `meta` is persisted with the session log and re-sent on every
|
||||
* request. {@link capMetaBytes} drops trailing groups/paths until the serialized
|
||||
* `meta` fits `maxMetaBytes` and marks the result `truncated`; a deployment's
|
||||
* final output budget (`dsh-spill-policy`) only shrinks `content`, never `meta`,
|
||||
* so this projection owns keeping `meta` bounded.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs-search/presentation
|
||||
*/
|
||||
|
||||
import type {
|
||||
SearchFileMatches,
|
||||
SearchLineMatch,
|
||||
SearchResultView,
|
||||
} from '@deepseek-ai/dsh-tools'
|
||||
import type { RetainedItems } from '@deepseek-ai/dsh-retention'
|
||||
import type { GrepMatch } from './search-core.ts'
|
||||
|
||||
/**
|
||||
* The retention fields a meta projection reads: the retained page, whether the
|
||||
* complete result was capped, and the pre-cap total. Both a full
|
||||
* {@link RetainedItems} (from `retainGrepMatches`) and `glob`'s sampled page
|
||||
* satisfy this structural subset, so a projection consumes either without a fake
|
||||
* `kept`/`omitted`.
|
||||
*/
|
||||
type RetainedPage<T> = Pick<RetainedItems<T>, 'items' | 'truncated' | 'seen'>
|
||||
|
||||
/**
|
||||
* The `grep`/`glob` tools' private `tool/result` `meta` payload: the capped,
|
||||
* structured search result. Attached opaquely (as `JsonValue`) on the tool result
|
||||
* and persisted with the session log, so `presentResult` reproduces the search
|
||||
* card on replay. The `matches` shape carries the by-file groups; the `paths`
|
||||
* shape carries the flat list. Both carry the pre-cap `total` and the `truncated`
|
||||
* flag. The producing tool owns and narrows this opaque shape.
|
||||
*
|
||||
* The member shapes use object-literal `type` aliases rather than the
|
||||
* {@link SearchFileMatches}/{@link SearchLineMatch} interfaces because only a type
|
||||
* alias is assignable to the `JsonValue` index signature `presentationMeta`
|
||||
* returns; the two are structurally identical, so the projected value still reads
|
||||
* back as a {@link SearchResultView}.
|
||||
*/
|
||||
export type SearchMeta =
|
||||
| { shape: 'matches'; files: MetaFileMatches[]; truncated: boolean; total: number }
|
||||
| { shape: 'paths'; paths: string[]; truncated: boolean; total: number }
|
||||
|
||||
/** One matched line in {@link SearchMeta} (the JSON-assignable form of {@link SearchLineMatch}). */
|
||||
type MetaLineMatch = { lineNumber: number; line: string }
|
||||
|
||||
/** One file's grouped matches in {@link SearchMeta} (the JSON-assignable form of {@link SearchFileMatches}). */
|
||||
type MetaFileMatches = { path: string; matches: MetaLineMatch[] }
|
||||
|
||||
/**
|
||||
* Group flat matches by file (first-seen order) into the structured by-file shape
|
||||
* a UI renders as expandable per-file groups. The grouping matches the
|
||||
* model-facing text grouping
|
||||
* ({@link module:@deepseek-ai/dsh-tool-fs-search/grep} `formatGrepMatches`), so
|
||||
* card and text agree about file order and membership.
|
||||
*
|
||||
* @param matches - the retained matches to group, in output order.
|
||||
* @returns one entry per file, in first-seen order.
|
||||
*/
|
||||
export function groupMatchesByFile(matches: GrepMatch[]): MetaFileMatches[] {
|
||||
const byFile = new Map<string, MetaLineMatch[]>()
|
||||
for (const match of matches) {
|
||||
const entry: MetaLineMatch = { lineNumber: match.lineNumber, line: match.line }
|
||||
const group = byFile.get(match.path)
|
||||
if (group !== undefined) group.push(entry)
|
||||
else byFile.set(match.path, [entry])
|
||||
}
|
||||
return Array.from(byFile, ([path, fileMatches]) => ({ path, matches: fileMatches }))
|
||||
}
|
||||
|
||||
/** The serialized UTF-8 byte size of one meta payload (the size persisted and re-sent). */
|
||||
function metaBytes(meta: SearchMeta): number {
|
||||
return Buffer.byteLength(JSON.stringify(meta), 'utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop trailing top-level items (file groups or paths) until the serialized meta
|
||||
* fits `maxMetaBytes`, marking the result `truncated` when anything was dropped.
|
||||
* `total` is preserved (it counts what the search found, not what meta retains).
|
||||
* A single item too large to fit on its own is kept: the invariant is a bounded
|
||||
* payload wherever droppable, never an empty card that hides a real result.
|
||||
*
|
||||
* @param meta - the projected meta, already capped to the inline item count.
|
||||
* @param maxMetaBytes - the serialized-meta byte budget.
|
||||
* @returns the same meta when it fits, else a byte-bounded copy marked `truncated`.
|
||||
*/
|
||||
function capMetaBytes(meta: SearchMeta, maxMetaBytes: number): SearchMeta {
|
||||
if (metaBytes(meta) <= maxMetaBytes) return meta
|
||||
if (meta.shape === 'matches') {
|
||||
const files = [...meta.files]
|
||||
while (files.length > 1 && metaBytes({ ...meta, files, truncated: true }) > maxMetaBytes) files.pop()
|
||||
return { ...meta, files, truncated: true }
|
||||
}
|
||||
const paths = [...meta.paths]
|
||||
while (paths.length > 1 && metaBytes({ ...meta, paths, truncated: true }) > maxMetaBytes) paths.pop()
|
||||
return { ...meta, paths, truncated: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Project the retained `grep` matches into {@link SearchMeta} for the search
|
||||
* card. Consumes the same {@link RetainedItems} the model-facing render consumes
|
||||
* (preview budget and inline match cap already applied), groups the retained
|
||||
* matches by file, reports `total` (every parsed match) and `truncated`, then
|
||||
* bounds the serialized meta to `maxMetaBytes`.
|
||||
*
|
||||
* @param retained - the retention outcome over every parsed match (previewed, capped).
|
||||
* @param maxMetaBytes - the serialized-meta byte budget.
|
||||
* @returns the `matches`-shaped search metadata.
|
||||
*/
|
||||
export function grepSearchMeta(retained: RetainedPage<GrepMatch>, maxMetaBytes: number): SearchMeta {
|
||||
const meta: SearchMeta = {
|
||||
shape: 'matches',
|
||||
files: groupMatchesByFile(retained.items),
|
||||
truncated: retained.truncated,
|
||||
total: retained.seen,
|
||||
}
|
||||
return capMetaBytes(meta, maxMetaBytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Project the retained `glob` paths into {@link SearchMeta} for the search card.
|
||||
* Consumes the same {@link RetainedItems} the model-facing render consumes (inline
|
||||
* path cap already applied), reports `total` (every discovered path) and
|
||||
* `truncated`, then bounds the serialized meta to `maxMetaBytes`.
|
||||
*
|
||||
* @param retained - the retention outcome over every discovered path (capped).
|
||||
* @param maxMetaBytes - the serialized-meta byte budget.
|
||||
* @returns the `paths`-shaped search metadata.
|
||||
*/
|
||||
export function globSearchMeta(retained: RetainedPage<string>, maxMetaBytes: number): SearchMeta {
|
||||
const meta: SearchMeta = {
|
||||
shape: 'paths',
|
||||
paths: retained.items,
|
||||
truncated: retained.truncated,
|
||||
total: retained.seen,
|
||||
}
|
||||
return capMetaBytes(meta, maxMetaBytes)
|
||||
}
|
||||
|
||||
/** Whether `value` is a valid {@link SearchLineMatch} (defensive narrowing from opaque `meta`). */
|
||||
function isSearchLineMatch(value: unknown): value is SearchLineMatch {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
||||
const { lineNumber, line } = value as Record<string, unknown>
|
||||
return typeof lineNumber === 'number' && typeof line === 'string'
|
||||
}
|
||||
|
||||
/** Whether `value` is a valid {@link SearchFileMatches} (defensive narrowing from opaque `meta`). */
|
||||
function isSearchFileMatches(value: unknown): value is SearchFileMatches {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
||||
const { path, matches } = value as Record<string, unknown>
|
||||
return typeof path === 'string' && Array.isArray(matches) && matches.every(isSearchLineMatch)
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow opaque live or replayed result metadata to a {@link SearchResultView}.
|
||||
* Malformed metadata returns `undefined` so `presentResult` can fall back to the
|
||||
* generic card instead of throwing during replay of an older or hand-edited log.
|
||||
* The view carries no result text: a UI without a search card falls back to the
|
||||
* raw `tool/result` content.
|
||||
*
|
||||
* A zero-result meta (`files: []` / `paths: []`) narrows to a valid empty card —
|
||||
* unlike the mirrored `diffsFromMeta`, which rejects empty diffs, because a
|
||||
* zero-match grep is a legitimate result a UI shows as "no matches", not an
|
||||
* absent projection.
|
||||
*
|
||||
* @param meta - result metadata (the {@link SearchMeta} the tool projected).
|
||||
* @returns the search view, or `undefined` for absent or malformed metadata.
|
||||
*/
|
||||
export function searchViewFromMeta(meta: unknown): SearchResultView | undefined {
|
||||
if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined
|
||||
const record = meta as Record<string, unknown>
|
||||
const { truncated, total } = record
|
||||
if (typeof truncated !== 'boolean' || typeof total !== 'number') return undefined
|
||||
if (record.shape === 'matches') {
|
||||
const { files } = record
|
||||
if (!Array.isArray(files) || !files.every(isSearchFileMatches)) return undefined
|
||||
return { card: 'search', shape: 'matches', files: files, truncated, total }
|
||||
}
|
||||
if (record.shape === 'paths') {
|
||||
const { paths } = record
|
||||
if (!Array.isArray(paths) || !paths.every((path): path is string => typeof path === 'string')) return undefined
|
||||
return { card: 'search', shape: 'paths', paths, truncated, total }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -19,6 +19,8 @@
|
||||
import { isAbsolute, relative, sep } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import { ItemRetainer, TextRetainer } from '@deepseek-ai/dsh-retention'
|
||||
import type { RetainedItems } from '@deepseek-ai/dsh-retention'
|
||||
import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
@@ -36,6 +38,18 @@ export const RAW_OUTPUT_MAX_BYTES = 20_000_000
|
||||
*/
|
||||
export const SEARCH_TIMEOUT_MS = 30_000
|
||||
|
||||
/**
|
||||
* Default cap in bytes on one search's serialized `presentationMeta` (the
|
||||
* `searchMetaMaxBytes` config). The inline match/path caps already bound the item
|
||||
* COUNT, but retained matches of a broad search (many long lines) can still
|
||||
* serialize to hundreds of kilobytes, and `meta` is persisted with the session
|
||||
* log and re-sent on every request. A deployment's final output budget
|
||||
* (`dsh-spill-policy`) only shrinks a result's `content`, never its `meta`, so the
|
||||
* projection owns this cap. 64 KiB holds the full default-capped result of a
|
||||
* typical search while bounding the pathological one.
|
||||
*/
|
||||
export const SEARCH_META_MAX_BYTES = 65_536
|
||||
|
||||
/**
|
||||
* Stable, machine-routable codes for search failures. Package-owned (not
|
||||
* `FsErrorCode`) because these tools are bash-backed discovery, not `ctx.fs`
|
||||
@@ -212,6 +226,63 @@ export function toWorkdirRelative(path: string, workdir: string): string {
|
||||
return rel
|
||||
}
|
||||
|
||||
/** One parsed match: the file, the 1-based line number, and the (possibly previewed) line text. */
|
||||
export interface GrepMatch {
|
||||
path: string
|
||||
lineNumber: number
|
||||
line: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound one matched-line preview to `maxBytes` (UTF-8 boundary preserved) and
|
||||
* mark the cut. The cap is a per-line budget fact; the complete line stays in
|
||||
* the searched file for `read`.
|
||||
*
|
||||
* @param line - the matched line text (trailing newline already stripped).
|
||||
* @param maxBytes - the preview budget in bytes.
|
||||
* @returns the preview, suffixed with ` (line truncated)` when bytes were cut.
|
||||
*/
|
||||
export function previewLine(line: string, maxBytes: number): string {
|
||||
const retainer = new TextRetainer({ kind: 'head', maxBytes })
|
||||
retainer.push(line)
|
||||
const kept = retainer.finish()
|
||||
return kept.truncated ? `${kept.text} (line truncated)` : kept.text
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the shared inline cap to a canonical `grep` match list: preview each
|
||||
* retained line to `maxLineBytes` and keep the first `maxMatches`. The single
|
||||
* retention pass both the model-facing render ({@link module:@deepseek-ai/dsh-tool-fs-search/grep}
|
||||
* `formatGrepOutput`) and the search-card projection
|
||||
* ({@link module:@deepseek-ai/dsh-tool-fs-search/presentation} `grepSearchMeta`)
|
||||
* consume, so text and card never disagree about which matches survived.
|
||||
*
|
||||
* @param matches - every match the search parsed (the canonical value's matches).
|
||||
* @param maxMatches - the inline match cap (the `grepMaxMatches` config).
|
||||
* @param maxLineBytes - the per-matched-line preview budget in bytes.
|
||||
* @returns the retention outcome over the previewed matches.
|
||||
*/
|
||||
export function retainGrepMatches(matches: GrepMatch[], maxMatches: number, maxLineBytes: number): RetainedItems<GrepMatch> {
|
||||
const retainer = new ItemRetainer<GrepMatch>({ kind: 'head', maxItems: maxMatches })
|
||||
for (const match of matches) retainer.push({ ...match, line: previewLine(match.line, maxLineBytes) })
|
||||
return retainer.finish()
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the shared inline cap to a canonical `glob` path list: keep the first
|
||||
* `maxResults`. The single retention pass both the model-facing render and the
|
||||
* search-card projection consume.
|
||||
*
|
||||
* @param paths - every path the search discovered (the canonical value's paths).
|
||||
* @param maxResults - the inline path cap (the `globMaxResults` config).
|
||||
* @returns the retention outcome over the paths.
|
||||
*/
|
||||
export function retainGlobPaths(paths: string[], maxResults: number): RetainedItems<string> {
|
||||
const retainer = new ItemRetainer<string>({ kind: 'head', maxItems: maxResults })
|
||||
for (const path of paths) retainer.push(path)
|
||||
return retainer.finish()
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort save of one COMPLETE formatted search result through
|
||||
* `ctx.spillStore.saveText()` — the model-facing recovery path for a capped
|
||||
|
||||
176
packages/fs/tool-fs-search/tests/presentation.spec.ts
Normal file
176
packages/fs/tool-fs-search/tests/presentation.spec.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Unit tests for the search-card presentation layer (`src/presentation.ts`): the
|
||||
* canonical value → `presentationMeta` projections (`grepSearchMeta`,
|
||||
* `globSearchMeta`, `groupMatchesByFile`) and the defensive `meta` → view
|
||||
* narrowing (`searchViewFromMeta`). These pin the by-file grouping, the
|
||||
* `truncated`/`total` honesty over already-retained input, the serialized-meta
|
||||
* byte cap, and the malformed-metadata fallback a replayed or hand-edited log can
|
||||
* deliver.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
globSearchMeta,
|
||||
grepSearchMeta,
|
||||
groupMatchesByFile,
|
||||
searchViewFromMeta,
|
||||
} from '../src/presentation.ts'
|
||||
import type { GrepMatch } from '../src/search-core.ts'
|
||||
import { retainGlobPaths, retainGrepMatches } from '../src/search-core.ts'
|
||||
|
||||
const match = (path: string, lineNumber: number, line: string): GrepMatch => ({ path, lineNumber, line })
|
||||
|
||||
/** A byte cap large enough that no test payload here is meta-capped. */
|
||||
const WIDE = 1_000_000
|
||||
|
||||
describe('groupMatchesByFile', () => {
|
||||
it('groups matches by first-seen file order, keeping line/lineNumber only', () => {
|
||||
expect(groupMatchesByFile([
|
||||
match('b.ts', 2, 'x'),
|
||||
match('a.ts', 1, 'y'),
|
||||
match('b.ts', 5, 'z'),
|
||||
])).toEqual([
|
||||
{ path: 'b.ts', matches: [{ lineNumber: 2, line: 'x' }, { lineNumber: 5, line: 'z' }] },
|
||||
{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'y' }] },
|
||||
])
|
||||
})
|
||||
|
||||
it('returns an empty list for no matches', () => {
|
||||
expect(groupMatchesByFile([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('grepSearchMeta', () => {
|
||||
it('projects grouped matches with total and a false truncation flag within the cap', () => {
|
||||
const meta = grepSearchMeta(retainGrepMatches([match('a.ts', 1, 'one'), match('a.ts', 2, 'two')], 10, 2000), WIDE)
|
||||
expect(meta).toEqual({
|
||||
shape: 'matches',
|
||||
files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'one' }, { lineNumber: 2, line: 'two' }] }],
|
||||
truncated: false,
|
||||
total: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('reports the pre-cap total and truncation from the shared retention pass', () => {
|
||||
const meta = grepSearchMeta(retainGrepMatches([match('a.ts', 1, 'one'), match('a.ts', 2, 'two'), match('b.ts', 3, 'three')], 2, 2000), WIDE)
|
||||
expect(meta).toEqual({
|
||||
shape: 'matches',
|
||||
files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'one' }, { lineNumber: 2, line: 'two' }] }],
|
||||
truncated: true,
|
||||
total: 3,
|
||||
})
|
||||
})
|
||||
|
||||
it('carries the per-line preview budget (UTF-8 boundary) the retention pass applied', () => {
|
||||
const meta = grepSearchMeta(retainGrepMatches([match('a.txt', 1, 'aéaéaéaé')], 10, 7), WIDE)
|
||||
expect(meta).toMatchObject({ shape: 'matches', files: [{ path: 'a.txt', matches: [{ lineNumber: 1, line: 'aéaéa (line truncated)' }] }] })
|
||||
})
|
||||
|
||||
it('drops trailing file groups until the serialized meta fits the byte cap, marking it truncated', () => {
|
||||
const retained = retainGrepMatches(
|
||||
[match('a.ts', 1, 'x'.repeat(60)), match('b.ts', 2, 'y'.repeat(60)), match('c.ts', 3, 'z'.repeat(60))],
|
||||
10,
|
||||
2000,
|
||||
)
|
||||
// One 60-byte group serializes to ~110 bytes; a 260-byte cap holds two, not three.
|
||||
const meta = grepSearchMeta(retained, 260)
|
||||
expect(meta.shape).toBe('matches')
|
||||
if (meta.shape !== 'matches') throw new Error('unreachable')
|
||||
expect(meta.truncated).toBe(true)
|
||||
expect(meta.total).toBe(3)
|
||||
expect(meta.files.length).toBeLessThan(3)
|
||||
expect(Buffer.byteLength(JSON.stringify(meta), 'utf8')).toBeLessThanOrEqual(260)
|
||||
})
|
||||
|
||||
it('keeps a single oversized group rather than emit an empty card', () => {
|
||||
const meta = grepSearchMeta(retainGrepMatches([match('a.ts', 1, 'x'.repeat(500))], 10, 2000), 50)
|
||||
expect(meta.shape).toBe('matches')
|
||||
if (meta.shape !== 'matches') throw new Error('unreachable')
|
||||
expect(meta.files).toHaveLength(1)
|
||||
expect(meta.truncated).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('globSearchMeta', () => {
|
||||
it('projects the path list with total and a false truncation flag within the cap', () => {
|
||||
expect(globSearchMeta(retainGlobPaths(['a.ts', 'b.ts'], 10), WIDE)).toEqual({ shape: 'paths', paths: ['a.ts', 'b.ts'], truncated: false, total: 2 })
|
||||
})
|
||||
|
||||
it('reports the pre-cap total and truncation from the shared retention pass', () => {
|
||||
expect(globSearchMeta(retainGlobPaths(['a.ts', 'b.ts', 'c.ts'], 2), WIDE)).toEqual({ shape: 'paths', paths: ['a.ts', 'b.ts'], truncated: true, total: 3 })
|
||||
})
|
||||
|
||||
it('drops trailing paths until the serialized meta fits the byte cap, marking it truncated', () => {
|
||||
const retained = retainGlobPaths([`${'a'.repeat(100)}.ts`, `${'b'.repeat(100)}.ts`, `${'c'.repeat(100)}.ts`], 10)
|
||||
const meta = globSearchMeta(retained, 180)
|
||||
expect(meta.shape).toBe('paths')
|
||||
if (meta.shape !== 'paths') throw new Error('unreachable')
|
||||
expect(meta.truncated).toBe(true)
|
||||
expect(meta.total).toBe(3)
|
||||
expect(meta.paths.length).toBeLessThan(3)
|
||||
expect(Buffer.byteLength(JSON.stringify(meta), 'utf8')).toBeLessThanOrEqual(180)
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchViewFromMeta (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
|
||||
|
||||
it('narrows a well-formed matches payload into a matches view', () => {
|
||||
const meta = { shape: 'matches', files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'x' }] }], truncated: true, total: 5 }
|
||||
expect(searchViewFromMeta(m(meta))).toEqual({ card: 'search', ...meta })
|
||||
})
|
||||
|
||||
it('narrows a well-formed paths payload into a paths view', () => {
|
||||
const meta = { shape: 'paths', paths: ['a.ts', 'b.ts'], truncated: false, total: 2 }
|
||||
expect(searchViewFromMeta(m(meta))).toEqual({ card: 'search', ...meta })
|
||||
})
|
||||
|
||||
it('narrows a zero-result payload into a valid empty card (not a rejected projection)', () => {
|
||||
expect(searchViewFromMeta(m({ shape: 'matches', files: [], truncated: false, total: 0 })))
|
||||
.toEqual({ card: 'search', shape: 'matches', files: [], truncated: false, total: 0 })
|
||||
expect(searchViewFromMeta(m({ shape: 'paths', paths: [], truncated: false, total: 0 })))
|
||||
.toEqual({ card: 'search', shape: 'paths', paths: [], truncated: false, total: 0 })
|
||||
})
|
||||
|
||||
it('rejects undefined / non-object / array meta', () => {
|
||||
expect(searchViewFromMeta(undefined)).toBeUndefined()
|
||||
expect(searchViewFromMeta(null)).toBeUndefined()
|
||||
expect(searchViewFromMeta(m('nope'))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m([]))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a payload with a missing / mistyped truncated or total field', () => {
|
||||
expect(searchViewFromMeta(m({ shape: 'paths', paths: [], total: 0 }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ shape: 'paths', paths: [], truncated: 'no', total: 0 }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ shape: 'paths', paths: [], truncated: false }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ shape: 'paths', paths: [], truncated: false, total: '0' }))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects an unknown or missing shape discriminant', () => {
|
||||
expect(searchViewFromMeta(m({ shape: 'other', truncated: false, total: 0 }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ truncated: false, total: 0 }))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a matches payload with a malformed files array', () => {
|
||||
const base = { shape: 'matches', truncated: false, total: 1 }
|
||||
expect(searchViewFromMeta(m({ ...base, files: 'x' }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ ...base, files: [null] }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ ...base, files: ['x'] }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ ...base, files: [[]] }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ ...base, files: [{ path: 1, matches: [] }] }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ ...base, files: [{ path: 'a', matches: 'x' }] }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ ...base, files: [{ path: 'a', matches: [null] }] }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ ...base, files: [{ path: 'a', matches: [{ lineNumber: '1', line: 'x' }] }] }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ ...base, files: [{ path: 'a', matches: [{ lineNumber: 1, line: 2 }] }] }))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a paths payload with a non-array or non-string-element paths field', () => {
|
||||
const base = { shape: 'paths', truncated: false, total: 1 }
|
||||
expect(searchViewFromMeta(m({ ...base, paths: 'x' }))).toBeUndefined()
|
||||
expect(searchViewFromMeta(m({ ...base, paths: [1] }))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -27,7 +27,9 @@ import {
|
||||
formatGrepMatches,
|
||||
parseGrepMatches,
|
||||
presentGlobCall,
|
||||
presentGlobResult,
|
||||
presentGrepCall,
|
||||
presentGrepResult,
|
||||
previewLine,
|
||||
sampleAcrossTopLevel,
|
||||
toWorkdirRelative,
|
||||
@@ -987,6 +989,73 @@ describe('presentation', () => {
|
||||
expect(presentGrepCall({ pattern: 'todo' })).toMatchObject({ card: 'generic', title: 'Grep todo', kind: 'search' })
|
||||
expect(presentGrepCall({ pattern: 'todo', path: 'src', include: '*.ts' }).title).toBe('Grep todo in src (*.ts)')
|
||||
})
|
||||
|
||||
it('grep projects a search card from a real execute, grouped by file with total and truncation', async () => {
|
||||
const { ctx, bash } = await setup({ config: { grepMaxMatches: 2 } })
|
||||
bash.handler = () => runResult([
|
||||
matchLine('a.ts', 1, 'one'),
|
||||
matchLine('a.ts', 2, 'two'),
|
||||
matchLine('b.ts', 3, 'three'),
|
||||
'',
|
||||
].join('\n'))
|
||||
const result = await call(ctx, 'grep', { pattern: 'e' }, { agent: agent('/w') })
|
||||
if (result.isError) throw new Error('expected grep success')
|
||||
// The presentationMeta projection rides the result meta (a surface call).
|
||||
expect(result.meta).toEqual({
|
||||
shape: 'matches',
|
||||
files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'one' }, { lineNumber: 2, line: 'two' }] }],
|
||||
truncated: true,
|
||||
total: 3,
|
||||
})
|
||||
const view = presentGrepResult({ pattern: 'e' }, result)
|
||||
expect(view).toEqual({
|
||||
card: 'search',
|
||||
shape: 'matches',
|
||||
files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'one' }, { lineNumber: 2, line: 'two' }] }],
|
||||
truncated: true,
|
||||
total: 3,
|
||||
})
|
||||
})
|
||||
|
||||
it('glob projects a search card from a real execute, a flat path list with total and truncation', async () => {
|
||||
const { ctx, bash } = await setup({ config: { globMaxResults: 2 } })
|
||||
bash.handler = () => runResult('a.ts\nb.ts\nc.ts\n')
|
||||
const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') })
|
||||
if (result.isError) throw new Error('expected glob success')
|
||||
expect(result.meta).toEqual({ shape: 'paths', paths: ['a.ts', 'b.ts'], truncated: true, total: 3 })
|
||||
const view = presentGlobResult({ pattern: '*.ts' }, result)
|
||||
expect(view).toEqual({ card: 'search', shape: 'paths', paths: ['a.ts', 'b.ts'], truncated: true, total: 3 })
|
||||
})
|
||||
|
||||
it('nested Code dispatch computes no meta, so presentResult falls back to the generic card', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult(`${matchLine('a.ts', 1, 'one')}\n`)
|
||||
const result = await call(ctx, 'grep', { pattern: 'o' }, {
|
||||
agent: agent('/w'),
|
||||
parent: Symbol('run_code') as ToolExecutionToken,
|
||||
})
|
||||
if (result.isError) throw new Error('expected grep success')
|
||||
expect(result.meta).toBeUndefined()
|
||||
expect(presentGrepResult({ pattern: 'o' }, result)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('presentResult returns undefined for a failed result and for the other tool’s meta shape', () => {
|
||||
const errorResult = { content: [{ type: 'text' as const, text: 'boom' }], isError: true }
|
||||
expect(presentGrepResult({ pattern: 'x' }, errorResult)).toBeUndefined()
|
||||
expect(presentGlobResult({ pattern: '*' }, errorResult)).toBeUndefined()
|
||||
// A grep result carrying a paths-shaped meta (and vice versa) is not this
|
||||
// tool's shape: each presenter narrows to its own shape and otherwise falls back.
|
||||
const pathsResult = { content: [], isError: false, meta: { shape: 'paths', paths: ['a.ts'], truncated: false, total: 1 } }
|
||||
const matchesResult = { content: [], isError: false, meta: { shape: 'matches', files: [], truncated: false, total: 0 } }
|
||||
expect(presentGrepResult({ pattern: 'x' }, pathsResult)).toBeUndefined()
|
||||
expect(presentGlobResult({ pattern: '*' }, matchesResult)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('presentResult falls back to the generic card on malformed replayed meta', () => {
|
||||
const malformed = { content: [], isError: false, meta: { shape: 'matches', files: 'nope', truncated: false, total: 0 } }
|
||||
expect(presentGrepResult({ pattern: 'x' }, malformed)).toBeUndefined()
|
||||
expect(presentGlobResult({ pattern: '*' }, { content: [], isError: false, meta: 42 })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('helpers', () => {
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/fs/tool-fs/README.md
|
||||
README.md: 4ff9b043525e8e7a0b59e3d91410951d88bb9a69
|
||||
README.zh.md: ce93e10072d74ce268273aa472bfbb3f34f46259
|
||||
README.md: a695d0ba8fb1d600689d2b68763e8423d1591da5
|
||||
README.zh.md: 5c600ab70b46da640637aec64efc1c0f0d0d54c0
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
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.
|
||||
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**. 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. Under a confining provider, the shared sandbox-policy service is required for per-session execution and the tool exposes escalation for filesystem mutations.
|
||||
|
||||
```ts ignore-check
|
||||
// Default deployment: a ctx.fs provider, the policy plugin, then the tools.
|
||||
@@ -34,7 +34,7 @@ All keys are optional; the defaults are the shipped read caps.
|
||||
|
||||
Field names are snake_case to match Claude Code and existing harness tool schemas.
|
||||
|
||||
Canonical successes are `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. Native renderers preserve the line-numbered read and mutation acknowledgements below. Write/edit derive replayable diff-card metadata from these values; the values themselves are execution-local and are not added to `tool/result`.
|
||||
Canonical successes are `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. Native renderers preserve the line-numbered read and mutation acknowledgements below. `write`/`edit` derive replayable diff-card metadata, and `read` derives a replayable read-card window `{ path, offset, lines, totalLines, lang? }`, from these canonical values; the canonical values themselves are execution-local and are not added to `tool/result`, only the derived presentation metadata is persisted.
|
||||
|
||||
## The tool is the executor; policy is an event gate
|
||||
|
||||
@@ -46,6 +46,8 @@ The tools do **not** inject a policy service or inspect any cache. Each tool res
|
||||
|
||||
The tool passes `exec` (the tool-execution context) as the opaque `actor` on every dispatch. The default thunks return `undefined` (the unconstrained bare provider). When `@deepseek-ai/dsh-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.
|
||||
|
||||
When `ctx.fs.sandboxMode` reports confinement, write/edit advertise `sandbox_permissions` and `justification` and resolve approved retries through `ctx.approval`. The policy owner contributes capability-neutral standing policy; the tool results retain operation-specific denial and retry guidance.
|
||||
|
||||
## `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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
**面向模型的文件系统工具**(`read`、`write`、`edit`)及其**执行器**。这是文件系统栈的消费方层:拥有工具名称、JSON Schema、参数校验、提示词段、**读取窗口逻辑**和结果格式化。它**直接**通过 `ctx.fs` 提供方 seam([`@deepseek-ai/dsh-fs`](../fs))读取/写入/编辑:注入 `fs`(以及 `tools`/`systemPrompt`),**不**注入策略服务。新鲜度/观察策略由独立插件([`@deepseek-ai/dsh-fs-policy`](../fs-policy))通过 `fs/*` 事件门禁贡献;工具不与其方法耦合。
|
||||
**面向模型的文件系统工具**(`read`、`write`、`edit`)及其**执行器**。这是文件系统栈的消费方层:拥有工具名称、JSON Schema、参数校验、提示词段、**读取窗口逻辑**和结果格式化。它**直接**通过 `ctx.fs` 提供方 seam([`@deepseek-ai/dsh-fs`](../fs))读取/写入/编辑。新鲜度/观察策略由独立插件([`@deepseek-ai/dsh-fs-policy`](../fs-policy))通过 `fs/*` 事件门禁贡献;工具不与其方法耦合。使用施加沙箱限制的提供方时,逐会话执行需要共享沙箱策略服务,工具还会为文件系统变更提供升权路径。
|
||||
|
||||
```ts ignore-check
|
||||
// Default deployment: a ctx.fs provider, the policy plugin, then the tools.
|
||||
@@ -34,7 +34,7 @@ await ctx.plugin(ToolFs) // this package — re
|
||||
|
||||
字段名使用 snake_case,与 Claude Code 和现有 harness 工具 schema 一致。
|
||||
|
||||
规范成功值分别为:`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。原生渲染器会保留下方带行号的读取结果和变更确认。写入/编辑从这些值派生可回放的 diff 卡片元数据;这些值本身仅限于本次执行,不会添加到 `tool/result`。
|
||||
规范成功值分别为:`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。原生渲染器会保留下方带行号的读取结果和变更确认。`write`/`edit` 从这些规范值派生可回放的 diff 卡片元数据,`read` 派生可回放的读取卡片窗口 `{ path, offset, lines, totalLines, lang? }`;规范值本身仅限于本次执行,不会添加到 `tool/result`,只有派生出的呈现元数据会被持久化。
|
||||
|
||||
## 工具就是执行器;策略是事件门禁
|
||||
|
||||
@@ -46,6 +46,8 @@ await ctx.plugin(ToolFs) // this package — re
|
||||
|
||||
工具在每次分派中把 `exec`(工具执行上下文)作为不透明 `actor` 传入。默认 thunk 返回 `undefined`(不受约束的裸提供方)。加载 `@deepseek-ai/dsh-fs-policy` 后,它会占用单个决策槽:返回 `createIfAbsent`/`replaceIfVersion`/`{ version }` 或抛出 `FS_NOT_OBSERVED`,并在 `fs/observed` 时记录。后端错误(`FsError`)和抛出的 `FS_NOT_OBSERVED` 会流经 `ToolRegistry.execute()`,变成 `isError` 工具结果,并附带 `{ name, code }`。
|
||||
|
||||
当 `ctx.fs.sandboxMode` 表明提供方施加沙箱限制时,write/edit 会公开 `sandbox_permissions` 与 `justification`,并通过 `ctx.approval` 解析经批准的重试。策略归属方会贡献与具体能力无关的常驻策略;工具结果仍保留操作特定的拒绝与重试引导。
|
||||
|
||||
## `fs/observed` 发后即忘
|
||||
|
||||
`fs/observed` 在读取/写入/编辑已经成功之后,通过普通 `ctx.emit` 发出。监听器的契约是同步且只有副作用的记录器(`@deepseek-ai/dsh-fs-policy` 使用 `WeakMap.set`);工具不保护这次发出,因此监听器抛出会作为工具的 `isError` 结果出现。异步或可能失败的观察不属于该事件。
|
||||
|
||||
@@ -168,3 +168,105 @@ export function formatReadOutput(displayPath: string, outcome: FileReadOutcome):
|
||||
${body}
|
||||
</content>`
|
||||
}
|
||||
|
||||
/**
|
||||
* Lowercased file-extension to syntax-highlighting language hint. Keys are the
|
||||
* extension without its dot; a UI treats an absent key as plain text. The map is
|
||||
* intentionally small — common source, config, and markup extensions a
|
||||
* line-numbered code view benefits from highlighting — not an exhaustive registry.
|
||||
*/
|
||||
const LANG_BY_EXTENSION: Readonly<Record<string, string>> = {
|
||||
ts: 'ts', tsx: 'tsx', mts: 'ts', cts: 'ts',
|
||||
js: 'js', jsx: 'jsx', mjs: 'js', cjs: 'js',
|
||||
json: 'json', jsonc: 'json',
|
||||
py: 'py', rb: 'rb', go: 'go', rs: 'rs', java: 'java',
|
||||
c: 'c', h: 'c', cc: 'cpp', cpp: 'cpp', hpp: 'cpp', cxx: 'cpp',
|
||||
cs: 'cs', kt: 'kotlin', swift: 'swift', php: 'php',
|
||||
sh: 'sh', bash: 'sh', zsh: 'sh',
|
||||
yaml: 'yaml', yml: 'yaml', toml: 'toml', ini: 'ini',
|
||||
md: 'md', markdown: 'md', mdx: 'mdx',
|
||||
html: 'html', htm: 'html', css: 'css', scss: 'scss', less: 'less',
|
||||
sql: 'sql', xml: 'xml', lua: 'lua',
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a syntax-highlighting language hint from a read path's file extension.
|
||||
* Pure and case-insensitive on the extension; a dotfile with no extension
|
||||
* (`.gitignore`) and an unknown extension both yield `undefined`.
|
||||
* @param path - the model-facing path the read reported.
|
||||
* @returns the language hint for {@link LANG_BY_EXTENSION}, or `undefined` when the extension maps to none.
|
||||
*/
|
||||
export function langFromPath(path: string): string | undefined {
|
||||
const base = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
|
||||
const dot = base.lastIndexOf('.')
|
||||
// A leading dot is a dotfile (no extension), not an empty extension.
|
||||
if (dot <= 0) return undefined
|
||||
const ext = base.slice(dot + 1).toLowerCase()
|
||||
// Own-property check only: a filename whose extension is an Object.prototype
|
||||
// key (`foo.constructor`, `foo.__proto__`) must map to no language, not to the
|
||||
// inherited member — otherwise a function would reach `lang` and fail the
|
||||
// tool-output JSON validation.
|
||||
return Object.hasOwn(LANG_BY_EXTENSION, ext) ? LANG_BY_EXTENSION[ext] : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The `read` tool's private `tool/result` `meta` payload: the structured
|
||||
* line-numbered window a capable UI renders as a code view. 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 read card on replay when the raw structured output is no longer
|
||||
* on the wire. The producing tool owns and narrows this opaque shape.
|
||||
*/
|
||||
export interface FsReadMeta {
|
||||
/** The read file's model-facing path. */
|
||||
path: string
|
||||
/** The 1-based first line the window requested, kept even when `lines` is empty. */
|
||||
offset: number
|
||||
/** The returned window's lines, each keeping its file line number. */
|
||||
lines: FileTextLine[]
|
||||
/** Exact total line count in the file. */
|
||||
totalLines: number
|
||||
/** Syntax-highlighting language hint from the extension, or omitted for plain text. */
|
||||
lang?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `value` is a valid {@link FileTextLine} (defensive narrowing from
|
||||
* opaque `meta`). `number` must be a 1-based integer line number, since a card
|
||||
* rendered from a zero, fractional, or non-finite line number would violate the
|
||||
* 1-based numbering contract the read window promises.
|
||||
*/
|
||||
function isFileTextLine(value: unknown): value is FileTextLine {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
||||
const { number, text } = value as Record<string, unknown>
|
||||
return typeof number === 'number' && Number.isInteger(number) && number >= 1 && typeof text === 'string'
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow opaque live or replayed result metadata to a structured read window.
|
||||
* Malformed metadata returns `undefined` so presentation can fall back to the
|
||||
* generic text card instead of throwing during replay. Beyond shape, the
|
||||
* semantic contract of a read window is enforced against replayed JSON that is
|
||||
* well-typed but out of range: `offset` must be a 1-based integer, `totalLines`
|
||||
* must be a non-negative integer, each line number must be a 1-based integer no
|
||||
* less than `offset`, the line numbers must strictly increase, and no line number
|
||||
* may exceed `totalLines`. Any violation declines to the generic fallback rather
|
||||
* than emitting a card that misnumbers or overcounts.
|
||||
* @param meta - result metadata.
|
||||
* @returns the validated read window, or `undefined` for absent, malformed, or semantically invalid data.
|
||||
*/
|
||||
export function readMetaFromMeta(meta: unknown): FsReadMeta | undefined {
|
||||
if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined
|
||||
const { path, offset, lines, totalLines, lang } = meta as Record<string, unknown>
|
||||
if (typeof path !== 'string' || typeof totalLines !== 'number' || typeof offset !== 'number') return undefined
|
||||
if (!Number.isInteger(offset) || offset < 1) return undefined
|
||||
if (!Number.isInteger(totalLines) || totalLines < 0) return undefined
|
||||
if (!Array.isArray(lines) || !lines.every(isFileTextLine)) return undefined
|
||||
if (lang !== undefined && typeof lang !== 'string') return undefined
|
||||
let previous = offset - 1
|
||||
for (const { number } of lines) {
|
||||
if (number <= previous || number > totalLines) return undefined
|
||||
previous = number
|
||||
}
|
||||
return { path, offset, lines, totalLines, ...lang === undefined ? {} : { lang } }
|
||||
}
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView, GenericResultView, ToolResult } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView, ReadResultView, ToolResult } from '@deepseek-ai/dsh-tools'
|
||||
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 { buildWindow, formatReadOutput, langFromPath, readMetaFromMeta } from './read-render.ts'
|
||||
import { sessionResolveOptions } from './session-cwd.ts'
|
||||
|
||||
/** Default and maximum number of lines returned by one `read` call (the `readLimit` config). */
|
||||
@@ -118,6 +118,19 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
|
||||
}),
|
||||
}]
|
||||
},
|
||||
// Project the structured window into persisted `meta` so a UI's read card
|
||||
// survives replay: the raw canonical output object is not on the wire, only
|
||||
// the model-facing text, from which the line/lang data cannot be recovered.
|
||||
presentationMeta: (_args, value) => {
|
||||
const lang = langFromPath(value.path)
|
||||
return {
|
||||
path: value.path,
|
||||
offset: value.offset,
|
||||
lines: value.lines.map(({ number, text }) => ({ number, text })),
|
||||
totalLines: value.totalLines,
|
||||
...lang === undefined ? {} : { lang },
|
||||
}
|
||||
},
|
||||
},
|
||||
// Observation races fail closed because guarded mutations re-check the version in-lock.
|
||||
isConcurrencySafe: () => true,
|
||||
@@ -154,15 +167,32 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
|
||||
ctx.emit('fs/observed', target, info.version, exec)
|
||||
return outcome
|
||||
},
|
||||
presentResult(_args, result: ToolResult): GenericResultView | undefined {
|
||||
// Result-time display: a `read` card carrying the structured line window a
|
||||
// capable UI renders as a line-numbered, syntax-highlighted view. The
|
||||
// structured data is narrowed from the persisted `meta` (replay-safe); the
|
||||
// envelope-stripped model-facing text rides along as `content` so a UI without
|
||||
// the read capability still shows the file text. A malformed or absent meta,
|
||||
// or a result whose text is not the read envelope, declines to `undefined`
|
||||
// (the generic fallback), never throwing on replay of obsolete logged output.
|
||||
presentResult(_args, result: ToolResult): ReadResultView | undefined {
|
||||
if (result.isError) return undefined
|
||||
const meta = readMetaFromMeta(result.meta)
|
||||
if (meta === undefined) return undefined
|
||||
const only = result.content.length === 1 ? result.content[0] : undefined
|
||||
const text = only?.type === 'text' ? only.text : undefined
|
||||
if (text === undefined) return undefined
|
||||
// Group 1 always captures (possibly empty) when the envelope matches.
|
||||
const body = /^<path>[^\n]*<\/path>\n<type>file<\/type>\n<content>\n([\s\S]*)\n<\/content>$/u.exec(text)?.[1]
|
||||
if (body === undefined) return undefined
|
||||
return { card: 'generic', content: [{ type: 'text', text: body }] }
|
||||
return {
|
||||
card: 'read',
|
||||
path: meta.path,
|
||||
offset: meta.offset,
|
||||
lines: meta.lines,
|
||||
totalLines: meta.totalLines,
|
||||
...meta.lang === undefined ? {} : { lang: meta.lang },
|
||||
content: [{ type: 'text', text: body }],
|
||||
}
|
||||
},
|
||||
// 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
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildWindow, READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from '../src/read-render.ts'
|
||||
import { buildWindow, langFromPath, readMetaFromMeta, READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from '../src/read-render.ts'
|
||||
import type { ReadWindow } from '../src/read-render.ts'
|
||||
|
||||
const DEFAULT_CAPS = { maxLineLength: READ_MAX_LINE_LENGTH, maxBytes: READ_MAX_BYTES }
|
||||
@@ -116,3 +116,103 @@ describe('buildWindow', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('langFromPath', () => {
|
||||
it('maps a known extension to its language hint, case-insensitively', () => {
|
||||
expect(langFromPath('src/a.ts')).toBe('ts')
|
||||
expect(langFromPath('src/a.TSX')).toBe('tsx')
|
||||
expect(langFromPath('/abs/module.mjs')).toBe('js')
|
||||
expect(langFromPath('conf.yml')).toBe('yaml')
|
||||
expect(langFromPath('README.md')).toBe('md')
|
||||
})
|
||||
|
||||
it('reads the extension after the last path segment and last dot', () => {
|
||||
expect(langFromPath('a.py.bak')).toBeUndefined()
|
||||
expect(langFromPath('archive.tar.gz')).toBeUndefined()
|
||||
expect(langFromPath('/dir.py/plain')).toBeUndefined()
|
||||
expect(langFromPath('C:\\src\\main.rs')).toBe('rs')
|
||||
})
|
||||
|
||||
it('returns undefined for a dotfile, an extensionless name, and an unknown extension', () => {
|
||||
expect(langFromPath('.gitignore')).toBeUndefined()
|
||||
expect(langFromPath('/etc/hosts')).toBeUndefined()
|
||||
expect(langFromPath('data.unknownext')).toBeUndefined()
|
||||
expect(langFromPath('trailingdot.')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for a filename whose extension is an Object.prototype key', () => {
|
||||
// Own-property lookup only: these must not resolve to the inherited member
|
||||
// (a function/object), which would fail the tool-output JSON validation.
|
||||
expect(langFromPath('foo.constructor')).toBeUndefined()
|
||||
expect(langFromPath('foo.__proto__')).toBeUndefined()
|
||||
expect(langFromPath('foo.toString')).toBeUndefined()
|
||||
expect(langFromPath('foo.hasOwnProperty')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('readMetaFromMeta', () => {
|
||||
const good = { path: '/abs/a.ts', offset: 1, lines: [{ number: 1, text: 'x' }], totalLines: 1, lang: 'ts' }
|
||||
|
||||
it('narrows a well-formed read meta, with and without a lang hint', () => {
|
||||
expect(readMetaFromMeta(good)).toEqual(good)
|
||||
const noLang = { path: '/abs/a', offset: 1, lines: [], totalLines: 0 }
|
||||
expect(readMetaFromMeta(noLang)).toEqual(noLang)
|
||||
})
|
||||
|
||||
it('narrows an empty window at a positive offset (byte cap below the first selected line)', () => {
|
||||
const empty = { path: '/abs/a', offset: 5, lines: [], totalLines: 9 }
|
||||
expect(readMetaFromMeta(empty)).toEqual(empty)
|
||||
})
|
||||
|
||||
it('returns undefined for absent, non-object, or array meta', () => {
|
||||
expect(readMetaFromMeta(undefined)).toBeUndefined()
|
||||
expect(readMetaFromMeta(null)).toBeUndefined()
|
||||
expect(readMetaFromMeta('nope')).toBeUndefined()
|
||||
expect(readMetaFromMeta([good])).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when a field is missing or the wrong type (defensive narrowing)', () => {
|
||||
expect(readMetaFromMeta({ ...good, path: 5 })).toBeUndefined()
|
||||
expect(readMetaFromMeta({ ...good, offset: '1' })).toBeUndefined()
|
||||
expect(readMetaFromMeta({ ...good, totalLines: '1' })).toBeUndefined()
|
||||
expect(readMetaFromMeta({ ...good, lines: 'nope' })).toBeUndefined()
|
||||
expect(readMetaFromMeta({ ...good, lines: [{ number: '1', text: 'x' }] })).toBeUndefined()
|
||||
expect(readMetaFromMeta({ ...good, lines: [{ number: 1 }] })).toBeUndefined()
|
||||
expect(readMetaFromMeta({ ...good, lines: [null] })).toBeUndefined()
|
||||
expect(readMetaFromMeta({ ...good, lang: 5 })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects an offset that is not a 1-based integer', () => {
|
||||
expect(readMetaFromMeta({ ...good, offset: 0 })).toBeUndefined()
|
||||
expect(readMetaFromMeta({ ...good, offset: 1.5 })).toBeUndefined()
|
||||
expect(readMetaFromMeta({ ...good, offset: NaN })).toBeUndefined()
|
||||
expect(readMetaFromMeta({ ...good, offset: Infinity })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a first line number below offset', () => {
|
||||
expect(readMetaFromMeta({ ...good, offset: 2, lines: [{ number: 1, text: 'x' }], totalLines: 2 })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a line number that is not a 1-based integer', () => {
|
||||
expect(readMetaFromMeta({ ...good, lines: [{ number: 0, text: 'x' }], totalLines: 1 })).toBeUndefined()
|
||||
expect(readMetaFromMeta({ ...good, lines: [{ number: 1.5, text: 'x' }], totalLines: 2 })).toBeUndefined()
|
||||
expect(readMetaFromMeta({ ...good, lines: [{ number: NaN, text: 'x' }], totalLines: 1 })).toBeUndefined()
|
||||
expect(readMetaFromMeta({ ...good, lines: [{ number: Infinity, text: 'x' }], totalLines: 1 })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a totalLines that is not a non-negative integer', () => {
|
||||
expect(readMetaFromMeta({ ...good, totalLines: -1 })).toBeUndefined()
|
||||
expect(readMetaFromMeta({ ...good, totalLines: 1.5 })).toBeUndefined()
|
||||
expect(readMetaFromMeta({ ...good, totalLines: NaN })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects lines that do not strictly increase or exceed totalLines', () => {
|
||||
const twoLines = { path: '/abs/a', offset: 1, lang: 'ts' }
|
||||
// Duplicate line numbers.
|
||||
expect(readMetaFromMeta({ ...twoLines, lines: [{ number: 1, text: 'a' }, { number: 1, text: 'b' }], totalLines: 2 })).toBeUndefined()
|
||||
// Out-of-order line numbers.
|
||||
expect(readMetaFromMeta({ ...twoLines, lines: [{ number: 2, text: 'b' }, { number: 1, text: 'a' }], totalLines: 2 })).toBeUndefined()
|
||||
// A line number past totalLines.
|
||||
expect(readMetaFromMeta({ ...twoLines, lines: [{ number: 3, text: 'c' }], totalLines: 2 })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -320,6 +320,39 @@ describe('read tool', () => {
|
||||
expect(text(result)).toContain('Output capped.')
|
||||
})
|
||||
|
||||
it('attaches the structured window as presentation meta, and presentResult narrows it into a read card', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
fs.files.set('key:a.ts', 'const x = 1\nconst y = 2')
|
||||
const result = await call(ctx, 'read', { file_path: 'a.ts' })
|
||||
expect(result.isError).toBe(false)
|
||||
if (result.isError) throw new Error('expected read success')
|
||||
// The extension drives the lang hint; the window rides on persisted meta.
|
||||
expect(result.meta).toEqual({
|
||||
path: '/abs/a.ts',
|
||||
offset: 1,
|
||||
lines: [{ number: 1, text: 'const x = 1' }, { number: 2, text: 'const y = 2' }],
|
||||
totalLines: 2,
|
||||
lang: 'ts',
|
||||
})
|
||||
const view = ctx.tools.get('read')?.presentResult?.({ file_path: 'a.ts' }, result)
|
||||
expect(view).toEqual({
|
||||
card: 'read',
|
||||
path: '/abs/a.ts',
|
||||
offset: 1,
|
||||
lines: [{ number: 1, text: 'const x = 1' }, { number: 2, text: 'const y = 2' }],
|
||||
totalLines: 2,
|
||||
lang: 'ts',
|
||||
content: [{ type: 'text', text: '1: const x = 1\n2: const y = 2\n\n(End of file - total 2 lines)' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('omits the lang hint in meta for an extension that maps to no language', async () => {
|
||||
const { ctx, fs } = await setup()
|
||||
fs.files.set('key:notes', 'plain')
|
||||
const result = await call(ctx, 'read', { file_path: 'notes' })
|
||||
if (result.isError) throw new Error('expected read success')
|
||||
expect(result.meta).toEqual({ path: '/abs/notes', offset: 1, lines: [{ number: 1, text: 'plain' }], totalLines: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatReadOutput footer variants', () => {
|
||||
@@ -450,33 +483,72 @@ describe('tool-owned presentation (pure presentCall)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('read: completed presentation removes the model-facing XML envelope', async () => {
|
||||
expect(await presentResult('read', { file_path: 'a.txt' }, {
|
||||
content: [{ type: 'text', text: '<path>/tmp/a.txt</path>\n<type>file</type>\n<content>\n1: hello\n\n(End of file - total 1 lines)\n</content>' }],
|
||||
it('read: completed presentation is a read card carrying the structured window with the envelope stripped', async () => {
|
||||
// The structured line data rides on persisted meta (the raw output object is
|
||||
// not on the wire); presentResult narrows it and appends the stripped text as
|
||||
// the no-capability `content` fallback.
|
||||
const meta = { path: '/tmp/a.ts', offset: 1, lines: [{ number: 1, text: 'hello' }], totalLines: 1, lang: 'ts' }
|
||||
expect(await presentResult('read', { file_path: 'a.ts' }, {
|
||||
content: [{ type: 'text', text: '<path>/tmp/a.ts</path>\n<type>file</type>\n<content>\n1: hello\n\n(End of file - total 1 lines)\n</content>' }],
|
||||
isError: false,
|
||||
meta,
|
||||
})).toEqual({
|
||||
card: 'generic',
|
||||
card: 'read',
|
||||
path: '/tmp/a.ts',
|
||||
offset: 1,
|
||||
lines: [{ number: 1, text: 'hello' }],
|
||||
totalLines: 1,
|
||||
lang: 'ts',
|
||||
content: [{ type: 'text', text: '1: hello\n\n(End of file - total 1 lines)' }],
|
||||
})
|
||||
expect(await presentResult('read', { file_path: 'a.txt' }, {
|
||||
// A window whose extension maps to no language omits `lang` from the card.
|
||||
expect(await presentResult('read', { file_path: 'notes' }, {
|
||||
content: [{ type: 'text', text: '<path>/tmp/notes</path>\n<type>file</type>\n<content>\nbody\n</content>' }],
|
||||
isError: false,
|
||||
meta: { path: '/tmp/notes', offset: 1, lines: [{ number: 1, text: 'body' }], totalLines: 1 },
|
||||
})).toEqual({
|
||||
card: 'read',
|
||||
path: '/tmp/notes',
|
||||
offset: 1,
|
||||
lines: [{ number: 1, text: 'body' }],
|
||||
totalLines: 1,
|
||||
content: [{ type: 'text', text: 'body' }],
|
||||
})
|
||||
// Malformed envelope text with valid meta still declines (the fallback text is unavailable).
|
||||
expect(await presentResult('read', { file_path: 'a.ts' }, {
|
||||
content: [{ type: 'text', text: 'malformed replay' }],
|
||||
isError: false,
|
||||
meta,
|
||||
})).toBeUndefined()
|
||||
// Valid envelope but absent/malformed meta declines to the generic fallback.
|
||||
expect(await presentResult('read', { file_path: 'a.ts' }, {
|
||||
content: [{ type: 'text', text: '<path>/tmp/a.ts</path>\n<type>file</type>\n<content>\n1: hello\n</content>' }],
|
||||
isError: false,
|
||||
})).toBeUndefined()
|
||||
expect(await presentResult('read', { file_path: 'a.ts' }, {
|
||||
content: [{ type: 'text', text: '<path>/tmp/a.ts</path>\n<type>file</type>\n<content>\n1: hello\n</content>' }],
|
||||
isError: false,
|
||||
meta: { path: '/tmp/a.ts', lines: 'nope', totalLines: 1 },
|
||||
})).toBeUndefined()
|
||||
})
|
||||
|
||||
it('read: completed presentation declines errors and non-single-text content', async () => {
|
||||
const envelope = '<path>/tmp/a.txt</path>\n<type>file</type>\n<content>\nbody\n</content>'
|
||||
const meta = { path: '/tmp/a.txt', offset: 1, lines: [{ number: 1, text: 'body' }], totalLines: 1 }
|
||||
expect(await presentResult('read', { file_path: 'a.txt' }, {
|
||||
content: [{ type: 'text', text: envelope }],
|
||||
isError: true,
|
||||
meta,
|
||||
})).toBeUndefined()
|
||||
expect(await presentResult('read', { file_path: 'a.txt' }, {
|
||||
content: [{ type: 'text', text: envelope }, { type: 'text', text: 'second' }],
|
||||
isError: false,
|
||||
meta,
|
||||
})).toBeUndefined()
|
||||
expect(await presentResult('read', { file_path: 'a.txt' }, {
|
||||
content: [{ type: 'reasoning', text: envelope }],
|
||||
isError: false,
|
||||
meta,
|
||||
})).toBeUndefined()
|
||||
})
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ function agent(ctx: Context, cwd: string): Agent {
|
||||
followup: () => {},
|
||||
steer: () => {},
|
||||
inject: () => {},
|
||||
updateInbox: () => 'not-found',
|
||||
reserveTurnAdmission: () => undefined,
|
||||
cancel() {},
|
||||
whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user