Merge remote-tracking branch 'origin/master' into feat/web-message-feedback-ui

This commit is contained in:
Chinesezjc
2026-08-11 22:21:51 +08:00
64 changed files with 1015 additions and 240 deletions

View File

@@ -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 .agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md
2026-08-10-minimal-preset-owns-rl-composition.md: 002cad0827e969b322997821dc978db85e2955f3
2026-08-10-minimal-preset-owns-rl-composition.zh.md: e957b57395c68b336695bdae07ea15a54ca1ea4e
2026-08-10-minimal-preset-owns-rl-composition.md: d83083de74f1d3203822d82e4e04560819412b48
2026-08-10-minimal-preset-owns-rl-composition.zh.md: 36aeea0d8af6e371eddd6f09e36bb48a0348959e

View File

@@ -6,23 +6,23 @@ English | [中文](2026-08-10-minimal-preset-owns-rl-composition.zh.md)
## Problem
The Web surface offered two owners for the Claude SWE-compatible RL agent: a process-wide `core-web.cordis.yml` patch and the per-session `minimal` preset. Once [agent presets](../architecture/2026-08-03-per-session-agent-presets.md) became the agent-composition boundary, the preset's scoped `deployment:persona` shadowed the overlay's corrected global persona with stale coding-agent text. The overlay test mounted no preset, while the preset test booted without the overlay, so neither exercised the composition users selected.
The shipped Web configuration offered two owners for the Claude SWE-compatible RL agent: a process-wide `core-web.cordis.yml` patch and the per-session `minimal` preset. Once [agent presets](../architecture/2026-08-03-per-session-agent-presets.md) became the agent-composition boundary, the preset's scoped `deployment:persona` shadowed the overlay's corrected global persona with stale coding-agent text. The overlay test mounted no preset, while the preset test booted without the overlay, so neither exercised the composition users selected.
The split also hid other drift. The preset mounted one-shot Bash rather than the [persistent Bash](../feature/2026-07-29-persistent-bash-str-replace-editor.md) used by the RL harness and omitted the RL compaction policy. Keeping both owners makes every future prompt, tool, and policy change a cross-product.
## Decision
The shipped Web `minimal` preset is the sole Web owner of the RL agent composition. It declares an entry-local PTY registry and local backend, persistent `bash` with the RL environment description and 300-second timeout, `str_replace_editor`, and an entry-local compaction backend. Tool presentation remains a deployment choice. The compaction policy keeps the RL threshold, absolute retention, generation cap, and retry count; model capacity comes from routed adapter metadata because `contextWindow` is no longer a compact-basic config field. The editor accepts no `requireAbsolutePath` setting because absolute paths are its unconditional contract.
The shipped Web `minimal` preset is the sole Web owner of the RL agent composition. It declares an entry-local PTY registry and local backend, persistent `bash` with the RL environment description and 300-second timeout, and `str_replace_editor`. Tool presentation remains a deployment choice. The later [bare two-tool runtime decision](../feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md) supersedes this note's original compaction and filesystem-provider choices: the current preset mounts an entry-local `fs-local` provider and no compaction backend. The editor accepts no `requireAbsolutePath` setting because absolute paths are its unconditional contract.
The preset persona is exactly `You are a helpful software engineer assistant.` and sets `complete: true`. A complete `PromptSection` participates in ordinary assembly so tools, contexts, variables, and cooperative listeners still resolve; after the `system-prompt/assemble` waterfall, the prompt registry restores a detached copy of that section as the sole system-prompt section. Multiple effective complete sections reject assembly. This final registry constraint prevents harness identity, Web orientation, tool guidance, or an assembly listener from appending prompt text.
The process-wide `core-web.cordis.yml` patch is absent. Browser UI, workspace attachment, persistence, filesystem, subprocess, sandbox, permission, model routing, and other cross-session services remain host-owned. Selecting `minimal` changes one agent's model-facing composition without changing other sessions in the Web process.
The process-wide `core-web.cordis.yml` patch is absent. Browser UI, workspace attachment, persistence, subprocess, sandbox, permission, model routing, and other cross-session services remain host-owned. Selecting `minimal` changes one agent's model-facing composition and shadows the host filesystem provider only for that agent, without changing other sessions in the Web process.
## Verification
System-prompt and persona package tests prove final complete-section enforcement, including waterfall mutation and duplicate rejection. The shipped-preset composition test asserts the exact prompt, Bash description, absolute editor schema, and two-tool catalog under the default native presentation. The keyless Web replay sends a real request through a `minimal` agent while global identity, Web surface text, and a test section are registered, then executes two persistent Bash calls to prove environment and cwd state survive and executes the editor through an absolute path.
System-prompt and persona package tests prove final complete-section enforcement, including waterfall mutation and duplicate rejection. The shipped-preset composition test asserts the exact prompt, Bash description, absolute editor schema, and two-tool catalog under the default native presentation. The keyless Web replay sends a real request through a `minimal` agent while global identity, Web-orientation text, and a test section are registered, asserts the entry-local filesystem is bare and compaction is absent, then executes two persistent Bash calls to prove environment and cwd state survive and executes the editor through an absolute path.
The standalone [`minimal.cordis.yml`](../../../../examples/jsonrpc-agent/minimal.cordis.yml) mirrors the same prompt, tools, timeouts, and compaction policy for the bundled JSON-RPC runtime. Its keyless SDK replay asserts the assembled system prompt and two-tool catalog, executes persistent Bash across calls, and exercises the editor; the Python SDK tutorial provides the runnable entry point.
The standalone [`minimal.cordis.yml`](../../../../examples/jsonrpc-agent/minimal.cordis.yml) is the complete two-tool composition for the bundled JSON-RPC runtime. The [bare two-tool runtime decision](../feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md) owns its launch-specific environment configuration, bare filesystem, and absence of compaction. Its keyless SDK replay asserts the assembled system prompt and two-tool catalog, executes persistent Bash across calls, and exercises the editor; the Python SDK tutorial provides the runnable entry point.
## Alternatives considered
@@ -36,4 +36,4 @@ The standalone [`minimal.cordis.yml`](../../../../examples/jsonrpc-agent/minimal
## Consequences
The RL prompt is fixed rather than environment-overridable. The Web preset and standalone JSON-RPC example state the same contract for their respective launch surfaces. The model sees only persistent `bash` and `str_replace_editor`; shell state is per agent and disappears with that agent. The preset pays for its own PTY and compaction service instances, while other presets pay nothing for them. The local persistent-shell backend requires the supported POSIX terminal substrate, so this preset is not a Windows agent surface.
The Web RL prompt is fixed rather than environment-overridable; the standalone JSON-RPC prompt is deployment-selected. The Web preset and standalone JSON-RPC example state the same two-tool contract for their respective launch paths. The model sees only persistent `bash` and `str_replace_editor`; shell state is per agent and disappears with that agent. The Web preset pays for its own PTY and bare filesystem service instances, while other presets pay nothing for them. The local persistent-shell backend requires the supported POSIX terminal substrate, so this preset does not support Windows agents.

View File

@@ -6,23 +6,23 @@ Status: implemented
## 问题
Web surface 同时由两个位置定义与 Claude SWE 兼容的 RL agent智能体进程级 `core-web.cordis.yml` patch以及逐会话的 `minimal` preset。[agent preset](../architecture/2026-08-03-per-session-agent-presets.md) 成为 agent 组合边界后preset 中带作用域的 `deployment:persona` 会用陈旧的 coding-agent 文本遮蔽 overlay 修正过的全局 persona。overlay 测试没有挂载 preset而 preset 测试启动时没有 overlay因此两者都没有覆盖用户实际选择的组合。
随附 Web 配置同时由两个位置定义与 Claude SWE 兼容的 RL agent智能体进程级 `core-web.cordis.yml` patch以及逐会话的 `minimal` preset。[agent preset](../architecture/2026-08-03-per-session-agent-presets.md) 成为 agent 组合边界后preset 中带作用域的 `deployment:persona` 会用陈旧的 coding-agent 文本遮蔽 overlay 修正过的全局 persona。overlay 测试没有挂载 preset而 preset 测试启动时没有 overlay因此两者都没有覆盖用户实际选择的组合。
这种拆分还掩盖了其他偏差。preset 挂载了一次性 Bash而不是 RL harness 使用的[持久 Bash](../feature/2026-07-29-persistent-bash-str-replace-editor.md),并且遗漏了 RL 压缩compaction策略。保留两个所有者会使今后每次修改提示词、工具或策略时都必须验证二者的交叉组合。
## 决策
随附的 Web `minimal` preset 是 RL agent 组合在 Web 中的唯一所有者。它声明 entry 本地的 PTY 注册表与本地后端、带 RL 环境描述且超时为 300 秒的持久 `bash``str_replace_editor`,以及 entry 本地的压缩后端。工具呈现仍由部署选择。压缩策略保留 RL 的阈值、绝对保留量、生成上限和重试次数;模型容量来自经路由选定的适配器元数据,因为 `contextWindow` 已不再是 compact-basic 的配置字段。编辑器不接受 `requireAbsolutePath` 设置,因为要求绝对路径是它的无条件约定。
随附的 Web `minimal` preset 是 RL agent 组合在 Web 中的唯一所有者。它声明 entry 本地的 PTY 注册表与本地后端、带 RL 环境描述且超时为 300 秒的持久 `bash`,以及 `str_replace_editor`。工具呈现仍由部署选择。后续的[裸双工具运行时决策](../feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md)取代了本记录最初的压缩与文件系统提供方选择:当前 preset 挂载 entry 本地的 `fs-local` 提供方,不挂载压缩后端。编辑器不接受 `requireAbsolutePath` 设置,因为要求绝对路径是它的无条件约定。
preset persona 恰好是 `You are a helpful software engineer assistant.`,并设置 `complete: true`。complete `PromptSection` 参与常规组装,因此工具、上下文、变量和协作式监听器仍会解析;`system-prompt/assemble` waterfall瀑布式事件结束后提示词注册表会将该段落的独立副本恢复为唯一的系统提示词段落。存在多个有效 complete 段时,组装会被拒绝。这项最终注册表约束可防止 harness 身份、Web 定位、工具引导或组装监听器追加提示词文本。
进程级 `core-web.cordis.yml` patch 不再存在。浏览器 UI、workspace 附加、持久化、文件系统、子进程、沙箱、权限、模型路由及其他跨会话服务仍由宿主持有。选择 `minimal` 会改变一个 agent 面向模型的组合,不会改变 Web 进程中的其他会话。
进程级 `core-web.cordis.yml` patch 不再存在。浏览器 UI、workspace 附加、持久化、子进程、沙箱、权限、模型路由及其他跨会话服务仍由宿主持有。选择 `minimal` 会改变一个 agent 面向模型的组合,并且仅为该 agent 遮蔽宿主文件系统提供方,不会改变 Web 进程中的其他会话。
## 验证
系统提示词与 persona 包测试证明了 complete 段的最终约束,包括 waterfall 修改与重复项拒绝。交付 preset 组合测试在默认原生呈现下断言精确的提示词、Bash 描述、要求绝对路径的编辑器 schema 和双工具目录。无密钥 Web 回放通过 `minimal` agent 发送一个真实请求同时注册全局身份、Web surface 文本和一个测试段落;随后执行两次持久 Bash 调用,证明环境与 cwd 状态能够保留,并通过绝对路径执行编辑器。
系统提示词与 persona 包测试证明了 complete 段的最终约束,包括 waterfall 修改与重复项拒绝。交付 preset 组合测试在默认原生呈现下断言精确的提示词、Bash 描述、要求绝对路径的编辑器 schema 和双工具目录。无密钥 Web 回放通过 `minimal` agent 发送一个真实请求同时注册全局身份、Web 定位文本和一个测试段落;它断言 entry 本地文件系统是裸后端且压缩不存在,随后执行两次持久 Bash 调用,证明环境与 cwd 状态能够保留,并通过绝对路径执行编辑器。
独立的 [`minimal.cordis.yml`](../../../../examples/jsonrpc-agent/minimal.cordis.yml) 内置 JSON-RPC 运行时复现相同的提示词、工具、超时和压缩策略。其无密钥 SDK 回放会断言组装后的系统提示词与双工具目录,跨调用执行持久 Bash并使用编辑器Python SDK 教程提供可运行的入口。
独立的 [`minimal.cordis.yml`](../../../../examples/jsonrpc-agent/minimal.cordis.yml) 内置 JSON-RPC 运行时的完整双工具组合。[裸双工具运行时决策](../feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md)说明其启动方式专属的环境配置、裸文件系统和无压缩选择。其无密钥 SDK 回放会断言组装后的系统提示词与双工具目录,跨调用执行持久 Bash并使用编辑器Python SDK 教程提供可运行的入口。
## 考虑过的替代方案
@@ -36,4 +36,4 @@ preset persona 恰好是 `You are a helpful software engineer assistant.`,并
## 后果
RL 提示词固定不变不能通过环境覆盖。Web preset 与独立 JSON-RPC 示例分别在各自的启动界面声明相同的约定。模型只看到持久 `bash``str_replace_editor`shell 状态按 agent 隔离,并随该 agent 一并消失。preset 为自身的 PTY 与压缩服务实例承担开销,其他 preset 无需承担。持久 shell 的本地后端需要受支持的 POSIX 终端基础环境,因此该 preset 不适用于 Windows agent surface
Web RL 提示词固定不变,不能通过环境覆盖;独立 JSON-RPC 提示词由部署选择。Web preset 与独立 JSON-RPC 示例分别在各自的启动路径声明相同的双工具约定。模型只看到持久 `bash``str_replace_editor`shell 状态按 agent 隔离,并随该 agent 一并消失。Web preset 为自身的 PTY 与裸文件系统服务实例承担开销,其他 preset 无需承担。持久 shell 的本地后端需要受支持的 POSIX 终端基础环境,因此该 preset 不支持 Windows agent。

View File

@@ -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 .agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md
2026-07-31-web-workspace-file-links.md: 977a24adf2f87db8a34027f36feccee44d231f9d
2026-07-31-web-workspace-file-links.zh.md: c350fad4c74c3ed6121aa5cb1243b43140196c68
2026-07-31-web-workspace-file-links.md: 611f012e0201fa9002ea473ef9a107841bf83bcc
2026-07-31-web-workspace-file-links.zh.md: 5f450493e960604db8c11d679c8f279f7f5b1d1a

View File

@@ -14,11 +14,11 @@ Two distinct defects sat behind that. The transcript never said what a turn had
## Decision
**A finished turn ends with the files it produced.** The row is its own plugin, `@deepseek-ai/dsh-client-ui-deliverables`, registered into the `conversation.chat.turnTail` hole the chat view renders between a closing message's body and its IconActions — ui-conversation owns the hole and the owner currency (nodes, closing seq, `openFile`), the plugin owns every policy. `producedForClosing` reads the paths off the mutation tools' own follow-along `locations` — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a turn's output is listed whether or not the closing message named it, and a new mutation tool joins by declaring what it does rather than by being added to a list. Reads, deletes, and failed calls contribute nothing; a path appears once per turn in first-seen order; accumulation resets on the turn boundary, so a turn that mutates and then ends without content text cannot spill into the next turn's row. One cordis.yml line composes the surface in or out; the unregistered hole renders nothing.
**A finished turn ends with the files it produced.** The row is its own plugin, `@deepseek-ai/dsh-client-ui-deliverables`, registered into the `conversation.chat.turnTail` hole the chat view renders between a closing message's body and its IconActions — ui-conversation owns the hole and the owner currency (nodes, closing seq, `openFile`), the plugin owns every policy. `producedForClosing` reads the paths off the mutation tools' own follow-along `locations` — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a turn's output is listed whether or not the closing message named it, and a new mutation tool joins by declaring what it does rather than by being added to a list. Reads, deletes, and failed calls contribute nothing; a path appears once per turn in first-seen order; accumulation resets on the turn boundary, so a turn that mutates and then ends without content text cannot spill into the next turn's row. The single-line lane measures its chips and localized remainder, then shows the largest prefix that fits (up to six) plus `+ N files`. One cordis.yml line composes the surface in or out; the unregistered hole renders nothing.
**The path link reads as a link.** Underlined at rest, not only on hover. This is the smaller half of the diff and the larger half of the fix.
**Opening stays the Host's job, and prefers the default browser.** `host.openPath` hands the path to the operating system, which yields a `file://` document in a real browser: full page capabilities, and no reachability into `/api`, because a `file://` document is not same-origin with it. Measured on the reported artifact: `localStorage` works, the theme toggle flips, the tabs switch, and `fetch` to the API fails. For documents a browser renders — `.html`, `.htm`, `.xhtml`, `.svg` — the opener resolves the default *browser* rather than the type's default application when the platform can name one, because a developer who binds `.html` to an editor would otherwise click a produced page and get source code. macOS reads the LaunchServices `https` handler and desktop Linux reads `$BROWSER`; either falls back to the default application when no browser can be named. Windows uses its registered association, and WSL first translates the path before using that same Windows handoff.
**Opening stays the Host's job, and prefers the default browser.** `host.openPath` hands the path to the operating system, which yields a `file://` document in a real browser: full page capabilities, and no reachability into `/api`, because a `file://` document is not same-origin with it. Measured on the reported artifact: `localStorage` works, the theme toggle flips, the tabs switch, and `fetch` to the API fails. For documents a browser renders — `.html`, `.htm`, `.xhtml`, `.svg` — the opener resolves the default *browser* rather than the type's default application when the platform can name one, because a developer who binds `.html` to an editor would otherwise click a produced page and get source code. macOS reads the LaunchServices `https` handler and desktop Linux reads `$BROWSER`; either falls back to the default application when no browser can be named. Windows uses its registered association, and WSL first translates the path before using that same Windows handoff. When files are hidden, **Show in folder** passes `.` through the same owner `openFile`; it appears only for a loopback page whose current `host.describe.canOpenPath` permits native opening. Other deployments omit it, with `nativeOpen: false` available when desktop detection would be a false positive.
**Serving workspace files over HTTP is out of scope, and so are non-local clients.** Serving files from the harness itself — same-origin with `/api`, behind `CSP: sandbox`, or from a second listener whose own port gives served documents their own origin — was rejected with the product scope: previews for a browser that is not on the Host machine are not supported, so the Host opener answers the supported case completely and the HTTP machinery would answer only the unsupported one.
@@ -28,8 +28,9 @@ Two distinct defects sat behind that. The transcript never said what a turn had
- **Same-origin HTTP serving without isolation** — measurably unsafe, and recorded so nobody retries it: a document served beside `/api` drove `settings.describe` to a `200` with full data and `session.list` to 35 KB of every session's transcript, from a page that need not be agent-authored at all (a read row makes every file in a cloned repository openable).
- **`Content-Security-Policy: sandbox` over that same-origin serving** — closes the hole by taking the document's origin away, which measurably breaks the pages this feature exists to show: the reported artifact throws `SecurityError` on load, and because an uncaught exception aborts the rest of its `<script>`, every listener declared after that line — theme toggle, mobile menu, model tabs — never binds. Two of the four artifacts in the reporting user's workspace were dead pages under it, and they still rendered perfectly, so the breakage was invisible.
- **Linkifying paths in the assistant's closing message** — the shape a user asks for ("put the link at the end"), but it makes rendering depend on the model spelling a path recognizably. The tool calls already carry `locations` as structured fact, so the produced-files row consumes that instead.
- **Horizontal chip scrolling** — keeps every file in the DOM but makes the hidden tail undiscoverable, adds a nested horizontal gesture to the transcript, and provides no exact account of what is out of view. One measured line with a stable remainder preserves the answer's vertical rhythm and keeps the omission explicit.
- **An embedded WebView in the desktop shell** — the strongest isolation available, since the preview then runs in a container the product owns rather than in the user's browser. It belongs to the desktop shell's own design, not to this surface, and is recorded here as the direction a future preview capability should take.
## Consequences
Every existing file affordance changed at once: write, edit, read, and the generic single-file card all reach `openFile`, so the link fix and the browser preference apply to all of them without a per-row change. The keyless web lane (`apps/web/tests/produced-files.e2e.ts`) cold-seeds a recorded write turn and pins the row in the assembled application; it deliberately does not click, because the click hands a path to the Host's opener and would launch a real application on the machine running the suite. A produced file opens as a `file://` document, which cannot `fetch` its own siblings (a multi-file artifact that loads `./data.json` breaks, while `<script src>`, `<img>`, and CSS `@import` are unaffected) — the one capability HTTP serving had that this does not. A client reached over the network sees nothing when it clicks: `host.openPath` runs on the Host and is loopback-pinned by the `/api` trust fence. That is the scope decision showing through, not a defect, and it is why the row keeps the full path in its `title` for a reader who can only copy it. Markdown opens in whatever the platform hands `.md`, usually an editor rather than a renderer; rendering it inside the product is a separate, deferred surface.
Every existing file affordance changed at once: write, edit, read, and the generic single-file card all reach `openFile`, so the link fix and browser preference apply without per-row changes. The assembled Web test covers overflow geometry and a one-click Host handoff without launching a native application. A produced `file://` document cannot `fetch` its own siblings (while `<script src>`, `<img>`, and CSS `@import` work), the one capability HTTP serving had that this does not. Remote clients keep the chips but omit the folder action; the full path remains in each chip's `title`. Markdown still opens in the platform's `.md` application; in-product rendering is separate work.

View File

@@ -14,11 +14,11 @@ Status: implemented
## 决定
**完成的一轮以它产出的文件收尾。** 该行是独立插件 `@deepseek-ai/dsh-client-ui-deliverables`,注册进 chat 视图在收尾消息正文与其 IconActions 之间渲染的 `conversation.chat.turnTail` 空位——ui-conversation 拥有空位与 owner 通货(节点、收尾 seq、`openFile`),插件拥有全部策略。`producedForClosing` 从改写工具自身的跟随文件 `locations` 中读出路径——diff 卡片,或 `kind``edit` 的 generic 卡片(即 `str_replace_editor` 的 insert 所呈现的形状——因此无论收尾消息是否点名这一轮的产出都会被列出新的改写工具靠声明自己做了什么加入而不是靠被加进某张名单。read、删除与失败的调用不贡献任何条目同一路径在一轮内按首见顺序只出现一次累积在 turn 边界重置因此一轮若先改写文件、随后没有正文内容就结束不会溢进下一轮的行里。cordis.yml 中的一行即可把该交互面组合进来或去掉;未注册的空位什么也不渲染。
**完成的一轮以它产出的文件收尾。** 该行是独立插件 `@deepseek-ai/dsh-client-ui-deliverables`,注册进 chat 视图在收尾消息正文与其 IconActions 之间渲染的 `conversation.chat.turnTail` 空位——ui-conversation 拥有空位与 owner 通货(节点、收尾 seq、`openFile`),插件拥有全部策略。`producedForClosing` 从改写工具自身的跟随文件 `locations` 中读出路径——diff 卡片,或 `kind``edit` 的 generic 卡片(即 `str_replace_editor` 的 insert 所呈现的形状——因此无论收尾消息是否点名这一轮的产出都会被列出新的改写工具靠声明自己做了什么加入而不是靠被加进某张名单。read、删除与失败的调用不贡献任何条目同一路径在一轮内按首见顺序只出现一次累积在 turn 边界重置,因此一轮若先改写文件、随后没有正文内容就结束,不会溢进下一轮的行里。单行 lane 会测量 chip 和本地化剩余计数,再显示能放下的最大前缀(至多六个)及 `+ N 个文件`cordis.yml 中的一行即可把该交互面组合进来或去掉;未注册的空位什么也不渲染。
**路径链接读得出是链接。** 静止状态下就带下划线,而不只在悬停时。这是本次改动中更小的那一半,却是修复中更大的那一半。
**打开仍然是 Host 的职责,并且优先选用默认浏览器。** `host.openPath` 把路径交给操作系统,得到的是真实浏览器里的一份 `file://` 文档:页面能力完整,且够不到 `/api`——因为 `file://` 文档与它并不同源。在所报告的那份产物上实测:`localStorage` 可用、主题切换生效、tabs 可切换,而对 API 的 `fetch` 失败。对浏览器能渲染的文档——`.html``.htm``.xhtml``.svg`——平台能够确定默认浏览器时,打开器解析的是默认**浏览器**而非该类型的默认应用,因为把 `.html` 绑给编辑器的开发者否则点开一个产出的页面得到的会是源码。macOS 读取 LaunchServices 的 `https` 处理程序,桌面 Linux 读取 `$BROWSER`无法确定浏览器时两者都会回退到默认应用。Windows 使用其注册的文件关联WSL 则先转换路径,再使用同一 Windows 交接。
**打开仍然是 Host 的职责,并且优先选用默认浏览器。** `host.openPath` 把路径交给操作系统,得到的是真实浏览器里的一份 `file://` 文档:页面能力完整,且够不到 `/api`——因为 `file://` 文档与它并不同源。在所报告的那份产物上实测:`localStorage` 可用、主题切换生效、tabs 可切换,而对 API 的 `fetch` 失败。对浏览器能渲染的文档——`.html``.htm``.xhtml``.svg`——平台能够确定默认浏览器时,打开器解析的是默认**浏览器**而非该类型的默认应用,因为把 `.html` 绑给编辑器的开发者否则点开一个产出的页面得到的会是源码。macOS 读取 LaunchServices 的 `https` 处理程序,桌面 Linux 读取 `$BROWSER`无法确定浏览器时两者都会回退到默认应用。Windows 使用其注册的文件关联WSL 则先转换路径,再使用同一 Windows 交接。存在隐藏文件时,**在文件夹中显示**会把 `.` 经由同一 owner `openFile` 传递;它只在 loopback 页面的当前 `host.describe.canOpenPath` 允许原生打开时出现。其他部署会省略它;桌面探测误报时可配置 `nativeOpen: false`
**以 HTTP 提供工作区文件不在范围内,非本机客户端亦然。** 由 harness 自己提供文件——与 `/api` 同源、置于 `CSP: sandbox` 之后、或交给一个以自身端口给所服务文档独立源的第二监听器——随产品范围一并否决:不为“浏览器不在 Host 机器上”的场景提供预览,因此 Host 打开器完整回答受支持的场景,而那套 HTTP 机制只会回答不受支持的那个。
@@ -28,8 +28,9 @@ Status: implemented
- **同源 HTTP 提供且不加隔离**——经实测不安全,记录在此以免有人重试:与 `/api` 并排提供的文档把 `settings.describe` 打到 `200` 并拿到完整数据,把 `session.list` 打到 35 KB 的全部会话转录,而这个页面根本不必由 agent 撰写(一条 read 行就让 clone 下来的仓库里任何文件变得可打开)。
- **在那套同源提供之上加 `Content-Security-Policy: sandbox`**——它以剥夺文档的源来堵住这个洞,而这经实测会破坏本功能存在的意义所在的那类页面:所报告的产物在加载时抛 `SecurityError`,又因为未捕获异常会中止其 `<script>` 的其余部分,该行之后声明的所有监听器——主题切换、移动端菜单、模型 tabs——统统不会绑定。报告者工作区里四份产物有两份在它之下是死页面而且它们渲染得完美无缺所以这种破坏是看不见的。
- **把路径在助手的收尾消息里链接化**——这是用户开口要的形状(“在结尾附上链接”),但它让渲染取决于模型是否把路径拼写得可识别。工具调用已经把 `locations` 作为结构化事实携带,产出文件行消费的正是它。
- **让文件 chip 横向滚动**——这样会把每个文件都留在 DOM 中,却使隐藏的尾部难以发现,在 transcript 内增加一层横向手势,也无法精确说明视口外还有什么。经过测量的一行和稳定的剩余计数既保留回答的纵向节奏,也明确呈现省略量。
- **桌面端外壳中的内嵌 WebView**——可得到的最强隔离,因为那时预览跑在产品自己拥有的容器里,而不是用户的浏览器里。它属于桌面端外壳自身的设计,而非本交互面,记录在此作为未来预览能力应走的方向。
## 影响
现有的每一处文件交互都同时改变了write、edit、read 与通用单文件卡片都汇到 `openFile`,因此链接可见性修复与浏览器优先策略无需逐行改动即适用于全部。无密钥 web 通道(`apps/web/tests/produced-files.e2e.ts`)冷播种一段录制的 write 轮次,在组装后的应用中钉住该行;它刻意不点击,因为点击会把路径交给 Host 打开器,从而在跑测试的机器上启动一个真实应用。产出的文件以 `file://` 文档打开,它无法 `fetch` 自己的同级文件(一个加载 `./data.json` 的多文件产物会坏,而 `<script src>``<img>` CSS `@import` 不受影响)——这是 HTTP 提供曾有、而此处没有的那一项能力。经网络访问的客户端点击后看不到任何东西:`host.openPath` 在 Host 上运行,且被 `/api` 信任 fence 钉在回环。那是范围决定的显现,不是缺陷,也正因如此该行把完整路径保留在 `title`供只能复制它的读者使用。markdown 由平台交给 `.md` 的默认处理程序打开,通常是编辑器而非渲染器;在产品内渲染它是另一个被推迟的交互面
现有的每一处文件交互都同时改变了write、edit、read 与通用单文件卡片都汇到 `openFile`,因此链接修复与浏览器优先策略无需逐行改动。组装层 Web 测试覆盖溢出几何和单次点击的 Host 交接,且不会启动原生应用。产出的 `file://` 文档无法 `fetch` 同级文件( `<script src>``<img>` CSS `@import` 可用),这是 HTTP 提供曾有、而此处没有的能力。远程客户端保留 chip但省略文件夹操作每个 chip 的 `title` 仍保留完整路径。Markdown 由平台 `.md` 应用打开;产品内渲染属于另一项工作

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md
2026-08-11-minimal-profiles-bare-two-tool-runtime.md: cf48c71214420443411bf91253b5e3a1c72a2c6c
2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md: 5cfe819e1cd18f9c9a3c8d63ab9bf8e34b888a24

View File

@@ -0,0 +1,39 @@
# Agent Note: Minimal profiles use the bare two-tool runtime
Status: implemented
English | [中文](2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md)
## Problem
The Web `minimal` preset and standalone JSON-RPC minimal composition exposed persistent `bash` and `str_replace_editor`, but their supporting services did not match the intended training runtime. Both mounted context compaction, while the Web preset inherited the host's sandboxed filesystem and the JSON-RPC composition mounted `fs-sandbox` plus filesystem policy. A long session could therefore replace history, and the editor advertised and enforced a filesystem policy that the bare local reference runtime does not have.
The two launch paths also have different configuration owners. Web mounts a per-agent preset over a running host, while the Python SDK initializes a complete stdio JSON-RPC child process. Treating them as one interchangeable Cordis leaf would hide those lifecycle differences, and the SDK example had no environment path for selecting its model or system prompt.
## Decision
Both shipped minimal profiles expose exactly persistent `bash` and `str_replace_editor`, mount no context-compaction provider, and run the editor against `@deepseek-ai/dsh-fs-local`. The Web preset isolates `ctx.fs` inside the agent entry and mounts `fs-local` beside the editor, so other Web agents retain the host filesystem provider. Its persona remains the fixed complete prompt owned by the earlier [minimal-preset composition decision](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md).
The standalone [`minimal.cordis.yml`](../../../../examples/jsonrpc-agent/minimal.cordis.yml) remains a complete JSON-RPC process composition. It mounts `dsh-jsonrpc`, the local PTY and subprocess services required by persistent Bash, `fs-local`, the two tool consumers, and uncompressed JSONL persistence. It does not mount `token-meter`, `compact-basic`, `fs-sandbox`, or `fs-policy`. Persistent Bash still consumes the deployment's danger-full-access sandbox policy; the editor is not confined by that policy.
`DSH_SYSTEM_PROMPT` selects the standalone persona. `DSH_MODEL` names the DeepSeek provider catalog entry, and `DSH_CONTEXT_WINDOW` supplies that entry's capacity. Because the SDK client owns the JSON-RPC `initialize` request, [`minimal.py`](../../../../examples/jsonrpc-agent/minimal.py) also uses `DSH_MODEL` as its default `model` argument; an explicit `--model` remains authoritative. Endpoint and credential variables stay owned by the DeepSeek adapter's existing environment-resolution path.
## Verification
The Web replay boots the complete Web host, creates the agent through the preset service, and asserts that the scoped filesystem is bare, no scoped compaction service exists, and the assembled request contains exactly the fixed prompt and two tools. It then executes persistent Bash and the editor against the real scoped services.
The SDK replay boots the real JSON-RPC agent process through the SDK client, injects an environment-selected prompt, asserts the assembled prompt and exact two-tool catalog, and executes both tools. Python SDK bundled-runtime coverage initializes the standalone configuration through each available packaged carrier with environment-selected model, model capacity, and prompt values. Cordis validation checks that both configurations resolve their declared plugins and configuration fields.
## Alternatives considered
**Keep `compact-basic` mounted with a high threshold.** Rejected because even an inert-for-short-tests provider permits history replacement in longer sessions and leaves the minimal composition dependent on model-capacity metadata and the token meter.
**Keep `fs-sandbox` in danger-full-access mode.** Rejected because the sandboxed provider still makes confinement and escalation part of the editor capability. The target runtime requires the bare local provider, whose lack of `sandboxMode` is composition truth.
**Use one Cordis leaf for Web and Python SDK startup.** Rejected because a Web preset contributes agent-scoped services to an existing multi-session host, while the Python SDK must launch a complete process containing the JSON-RPC server and its process-wide dependencies.
**Read `DSH_MODEL` only inside Cordis.** Rejected because Cordis configures the provider catalog but does not own the SDK client's JSON-RPC `initialize` request. The launcher must pass the same model to the client request for the environment value to select the routed model.
## Consequences
Minimal sessions never summarize or replace earlier history; callers must keep turns within the selected model's context capacity. The editor can address any absolute path visible to the runtime process, independently of the persistent shell's sandbox policy. The two launch paths share their model-facing tool and no-compaction guarantees while retaining different prompt and model configuration appropriate to their owners. The Python SDK path continues to communicate only through the bundled stdio JSON-RPC runtime.

View File

@@ -0,0 +1,39 @@
# Agent Note: minimal profile 使用裸双工具运行时
Status: implemented
[English](2026-08-11-minimal-profiles-bare-two-tool-runtime.md) | 中文
## 问题
Web `minimal` preset 与独立 JSON-RPC minimal 组合对外提供持久 `bash``str_replace_editor`,但支撑服务与目标训练运行时不一致。两者都挂载上下文压缩,而 Web preset 继承宿主的沙箱文件系统JSON-RPC 组合则挂载 `fs-sandbox` 和文件系统策略。因此,长会话可能替换历史记录,编辑器也会宣告并实施裸本地参考运行时并不具备的文件系统策略。
两条启动路径的配置所有者也不同。Web 在已运行的宿主上挂载逐 agent presetPython SDK 则初始化一个完整的 stdio JSON-RPC 子进程。将二者视为可互换的同一个 Cordis leaf 会掩盖生命周期差异,而且 SDK 示例没有通过环境选择模型或系统提示词的入口。
## 决策
两种随附 minimal profile 都只对外提供持久 `bash``str_replace_editor`,不挂载上下文压缩提供方,并让编辑器使用 `@deepseek-ai/dsh-fs-local`。Web preset 在 agent entry 内隔离 `ctx.fs`,将 `fs-local` 与编辑器一起挂载,因此其他 Web agent 仍使用宿主文件系统提供方。其 persona 继续采用较早的 [minimal preset 组合决策](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md)所拥有的固定 complete 提示词。
独立的 [`minimal.cordis.yml`](../../../../examples/jsonrpc-agent/minimal.cordis.yml) 仍是完整的 JSON-RPC 进程组合。它挂载 `dsh-jsonrpc`、持久 Bash 所需的本地 PTY 和子进程服务、`fs-local`、两个工具消费方,以及未压缩的 JSONL 持久化。它不挂载 `token-meter``compact-basic``fs-sandbox``fs-policy`。持久 Bash 仍消费部署的 danger-full-access 沙箱策略;编辑器不受该策略限制。
`DSH_SYSTEM_PROMPT` 选择独立组合的 persona。`DSH_MODEL` 命名 DeepSeek 提供方目录项,`DSH_CONTEXT_WINDOW` 提供该目录项的容量。由于 SDK 客户端拥有 JSON-RPC `initialize` 请求,[`minimal.py`](../../../../examples/jsonrpc-agent/minimal.py)也使用 `DSH_MODEL` 作为 `model` 参数的默认值;显式 `--model` 仍具有最高优先级。端点与凭据变量继续由 DeepSeek 适配器现有的环境解析路径持有。
## 验证
Web 回放会启动完整 Web 宿主,通过 preset 服务创建 agent并断言作用域文件系统为裸后端、不存在作用域压缩服务而且组装请求只包含固定提示词与两个工具。随后它通过真实作用域服务执行持久 Bash 和编辑器。
SDK 回放通过 SDK 客户端启动真实 JSON-RPC agent 进程注入由环境选择的提示词断言组装提示词与精确双工具目录并执行两个工具。Python SDK 内置运行时覆盖会通过每种可用的打包载体使用环境选择的模型、模型容量和提示词值初始化独立配置。Cordis 校验会检查两份配置能否解析声明的插件和配置字段。
## 考虑过的替代方案
**以较高阈值保留 `compact-basic`。** 不予采用,因为即便提供方在短测试中未触发,较长会话仍允许替换历史记录,而且 minimal 组合仍会依赖模型容量元数据与 token meter。
**在 danger-full-access 模式下保留 `fs-sandbox`。** 不予采用,因为沙箱提供方仍会使限权与提权成为编辑器能力的一部分。目标运行时要求裸本地提供方,而其不具备 `sandboxMode` 正是组合事实。
**为 Web 与 Python SDK 启动使用同一个 Cordis leaf。** 不予采用,因为 Web preset 向现有多会话宿主贡献 agent 作用域服务,而 Python SDK 必须启动包含 JSON-RPC 服务器及其进程级依赖的完整进程。
**只在 Cordis 内读取 `DSH_MODEL`。** 不予采用,因为 Cordis 配置提供方目录,但不拥有 SDK 客户端的 JSON-RPC `initialize` 请求。launcher 必须向客户端请求传递同一个模型,环境值才能选择路由模型。
## 后果
Minimal 会话不会摘要或替换较早历史;调用方必须让会话轮次保持在所选模型的上下文容量内。编辑器可以访问运行时进程可见的任何绝对路径,且不受持久 shell 沙箱策略影响。两条启动路径共享面向模型的工具与无压缩保证同时保留适合各自所有者的不同提示词和模型配置。Python SDK 路径继续仅通过内置 stdio JSON-RPC 运行时通信。

View File

@@ -1,8 +1,9 @@
# The `minimal` agent preset: a fixed-prompt, two-tool coding surface.
# The `minimal` agent preset: a fixed-prompt, two-tool coding-agent composition.
#
# The persona is the complete system prompt, so global identity, Web surface,
# The persona is the complete system prompt, so global identity, Web orientation,
# tool guidance, and later assembly listeners cannot add prompt text. The model
# composes only the persistent `bash` and `str_replace_editor` tools.
# composes only the persistent `bash` and `str_replace_editor` tools. Context
# compaction is deliberately absent.
- id: persona
name: '@deepseek-ai/dsh-persona'
@@ -41,33 +42,20 @@
* Please avoid commands that may produce a very large amount of output.
* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.
# The editor requires absolute paths unconditionally.
- id: str-replace-editor
name: '@deepseek-ai/dsh-tool-str-replace-editor'
config:
maxOutputChars: 16000
# Model capacity comes from routed model metadata; this block states the
# compaction policy explicitly.
#
# `tokenMeter` is deliberately NOT in this realm: the meter stays on the HOST
# plane, and the row here resolves that one instance. It takes no configuration,
# keys every fold by Session, and owns the context-meter projection units the
# browser reads for every session — behind a realm those units would come and go
# with whichever presets happen to be mounted. What a preset chooses is whether
# its agent compacts at all, which is `compact-basic` below.
- id: compaction
# The bare local filesystem shadows the host's sandboxed provider only for this
# preset. The editor shares that realm and requires absolute paths.
- id: filesystem
name: cordis:group
group: true
isolate:
compact: true
fs: true
config:
- id: compact-basic
name: '@deepseek-ai/dsh-compact-basic'
- id: fs-local
name: '@deepseek-ai/dsh-fs-local'
config:
thresholdRatio: 0.8
retainTokens: 20480
summarizationProvider: ''
summarizationModel: ''
maxTokens: 8192
compactionRetries: 1
cwd: !!js process.env.DSH_CWD ?? process.cwd()
- id: str-replace-editor
name: '@deepseek-ai/dsh-tool-str-replace-editor'
config:
maxOutputChars: 16000

View File

@@ -35,6 +35,7 @@
"@deepseek-ai/dsh-goal-session": "workspace:^",
"@deepseek-ai/dsh-cmdline": "workspace:^",
"@deepseek-ai/dsh-environment": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-headless": "workspace:^",
"@deepseek-ai/dsh-mcp-client": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",

View File

@@ -408,16 +408,15 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
}
}, 30_000)
it('uses the Harness-home environment and managed credential through the published entry', async () => {
it('uses the launching endpoint and managed credential through the published entry', async () => {
const apiKey = 'built-home-layer-key'
const server = await startMockLlmServer({
sequence: ['success'],
apiKey,
successText: 'home environment reached the mock',
successText: 'launching endpoint reached the mock',
})
const home = mkdtempSync(join(tmpdir(), 'dsh-home-environment-'))
const project = mkdtempSync(join(tmpdir(), 'dsh-home-project-'))
writeFileSync(join(home, '.env'), `DEEPSEEK_BASE_URL=${server.baseURL}\n`)
writeFileSync(join(home, '.credentials.yaml'), `DEEPSEEK_API_KEY: ${apiKey}\n`, { mode: 0o600 })
createEnvironmentProbeProfile(home, project)
try {
@@ -427,7 +426,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
DSH_HOME: home,
DSH_TELEMETRY_DISABLED: '1',
DEEPSEEK_API_KEY: undefined,
DEEPSEEK_BASE_URL: undefined,
DEEPSEEK_BASE_URL: server.baseURL,
},
project,
)
@@ -435,7 +434,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
result.code,
`${result.stderr}\nstdout:\n${result.stdout}\nmock requests: ${String(server.requests.length)}`,
).toBe(0)
expect(result.stdout).toBe('home environment reached the mock')
expect(result.stdout).toBe('launching endpoint reached the mock')
expect(result.stdout).not.toContain(apiKey)
expect(result.stderr).not.toContain(apiKey)
expect(server.requests).toHaveLength(1)

View File

@@ -14,7 +14,7 @@ import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import { resolveSessionPreset, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets'
import { applyChildComposition, childSessionMeta } from '@deepseek-ai/dsh-subagent'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
import type {} from '@deepseek-ai/dsh-compact-basic'
import type {} from '@deepseek-ai/dsh-skill'
import type {} from '@deepseek-ai/dsh-tools'
// Type-only: resolves `ctx.get('sessionProjections')` and `ctx.get('tokenMeter')`.
@@ -216,16 +216,8 @@ describe('the shipped Web composition', () => {
expect(assembly.tools.find(tool => tool.name === 'bash')?.description).toBe(MINIMAL_BASH_DESCRIPTION)
expect(JSON.stringify(assembly.tools.find(tool => tool.name === 'str_replace_editor')?.parameters))
.toContain('Absolute path')
const compact = ctx.agentPresets.serviceFor(handle.agent, 'compact')
expect(compact).toBeDefined()
expect((compact as BasicCompactService).config).toMatchObject({
thresholdRatio: 0.8,
retainTokens: 20480,
summarizationProvider: '',
summarizationModel: '',
maxTokens: 8192,
compactionRetries: 1,
})
expect(ctx.agentPresets.serviceFor(handle.agent, 'compact')).toBeUndefined()
expect(handle.agent.ctx.get('compact')).toBeUndefined()
} finally {
await handle.dispose()
}

View File

@@ -55,6 +55,10 @@ describe('minimal agent preset', () => {
const requestHeader = agentHandle.agent.session.requestHeader()
if (requestHeader === undefined) throw new Error('the minimal agent issued no model request')
const presetFileSystem = scaffold.ctx.agentPresets.serviceFor(agentHandle.agent, 'fs')
expect(presetFileSystem).toBeDefined()
expect(presetFileSystem?.sandboxMode).toBeUndefined()
expect(scaffold.ctx.agentPresets.serviceFor(agentHandle.agent, 'compact')).toBeUndefined()
const stateDir = join(scaffold.workspaceCwd, 'persistent-state')
await mkdir(stateDir)

View File

@@ -1,28 +1,107 @@
// Web e2e scenario: the produced-files row a finished turn ends with. Cold-seeds
// a recorded write turn (zero model calls). Package tests cover the derivation
// in isolation, but only the assembled application shows that a turn's writes
// reach the transcript as an openable row (docs/testing.md snapshot rule). The
// click itself is not driven here: it hands the path to the Host's opener,
// which would launch a real application on the machine running the suite.
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import { join } from 'node:path'
// Web e2e scenario: the single-line produced-files summary a finished turn
// ends with. Cold-seeds ten writes (zero model calls), then verifies the real
// assembled lane keeps a precise +N and a capability-gated folder handoff.
// The folder request is intercepted so one real browser click can exercise
// the full client carrier without launching a native application in CI.
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest'
import { CallId, createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-session-title'
import {
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
// Borrowed read-only: this scenario needs any settled turn whose tools WROTE a
// file, not a new recording (the message-actions borrowing pattern).
const SEED = fileURLToPath(new URL('./snapshots/permission-policy-context/session.jsonl', import.meta.url))
const MODE = webSnapshotMode()
const OVERLAY = fileURLToPath(new URL('./produced-files.overlay.yml', import.meta.url))
const SEED_ID = 'produced-files-web-e2e'
const DONE = 'PRODUCED_FILES_DONE'
/** The file the borrowed recording's write tool produces. */
const PRODUCED = 'policy-neutral.txt'
/** Short leading names plus a long third name make the narrow lane deterministically show two. */
const PRODUCED = [
'关于我.md',
'index.html',
'long-generated-experience-specification-for-produced-files-overflow.md',
'styles.css',
'app.ts',
'schema.json',
'README.md',
'preview.svg',
'notes.txt',
'manifest.yaml',
] as const
/** Build one settled turn whose successful write calls carry ten locations. */
function producedFixture(): string {
const session = Session.create(SessionId('produced-files-source'))
const eventTimeOrigin = new Date().setHours(12, 0, 0, 0)
session.append('turn/start', { turn: 1 })
const user = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'Create the site files.' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('session/title', {
title: 'Produced files overflow', messageSeqs: [user.seq], source: { kind: 'fallback' },
})
session.append('step/start', { turn: 1, step: 1 })
const calls = PRODUCED.map((path, index) => ({
path,
callId: CallId(`produced-files-${String(index)}`),
args: JSON.stringify({ file_path: path, content: `content of ${path}\n` }),
}))
session.append('assistant/message', {
turn: 1,
step: 1,
message: createAssistantMessage({
content: calls.map(call => ({
type: 'tool-call' as const,
id: call.callId,
name: 'write',
arguments: call.args,
})),
source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}),
}, { surfaceOp: 'append' })
for (const call of calls) {
const source = session.append('tool/call', {
turn: 1, step: 1, callId: call.callId, name: 'write', arguments: call.args,
})
session.append('tool/result', {
turn: 1,
step: 1,
message: createToolResultMessage({
callId: call.callId,
content: [{ type: 'text', text: `Created ${call.path}` }],
isError: false,
}),
}, { surfaceOp: 'append', sourceEventSeqs: [source.seq] })
}
session.append('step/start', { turn: 1, step: 2 })
session.append('assistant/message', {
turn: 1,
step: 2,
message: createAssistantMessage({
content: [{ type: 'text', text: `Created the site.\n\n${DONE}` }],
source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}),
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 2 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
return [
JSON.stringify({
type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}',
createdAt: 0, cwd: '{{cwd}}',
}),
...session.events.map(event => JSON.stringify({
...event, time: eventTimeOrigin + event.seq * 1_000,
})),
'',
].join('\n')
}
describe('web e2e: a finished turn ends with the files it produced', () => {
let scaffold: WebScaffold
@@ -31,16 +110,13 @@ describe('web e2e: a finished turn ends with the files it produced', () => {
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
// The seeded Session's cwd is the scaffold workspace; the recording's own
// nested directory is created too, so its paths stay resolvable.
await mkdir(join(scaffold.workspaceCwd, 'workspace'), { recursive: true })
await writeFile(join(scaffold.workspaceCwd, PRODUCED), 'neutral\n')
const raw = await readFile(SEED, 'utf8')
expect(raw, 'borrowed recording must carry the write this scenario reads').toContain(PRODUCED)
await seedSession(scaffold, raw, SEED_ID)
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
await seedSession(scaffold, producedFixture(), SEED_ID)
browser = await chromium.launch()
page = await newEnglishPage(browser)
// Keep the responsive sidebar available while selecting the cold seed;
// the assertion itself narrows the conversation after navigation.
await page.setViewportSize({ width: 1280, height: 900 })
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
@@ -51,24 +127,52 @@ describe('web e2e: a finished turn ends with the files it produced', () => {
await scaffold?.close()
})
it.skipIf(MODE === 'record')('lists the written file under the closing message, as an opener', async () => {
it.skipIf(MODE === 'record')('keeps a narrow ten-file summary on one line with +8 and a folder action', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-produced-files'))
const groupRow = page.locator('[role="treeitem"]').first()
await groupRow.waitFor({ timeout: 15_000 })
await groupRow.click()
if (await groupRow.getAttribute('aria-expanded') !== 'true') await groupRow.click()
const sessionRow = page.locator('[role="treeitem"]').nth(1)
await sessionRow.waitFor({ timeout: 10_000 })
await sessionRow.click()
// The row the turn ends with — derived from the write call's locations,
// not from whatever the closing message happened to say.
const chip = page.getByRole('button', { name: `Open ${PRODUCED}`, exact: true }).first()
await chip.waitFor({ timeout: 15_000 })
expect(await chip.innerText()).toBe(PRODUCED)
// The full path stays reachable for a reader who wants to copy it.
expect(await chip.getAttribute('title')).toContain(PRODUCED)
// A turn's produced files are labelled, not left as bare chips.
expect(await page.getByText('Produced', { exact: true }).count()).toBeGreaterThan(0)
await expect.poll(() => page.getByText(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
await page.setViewportSize({ width: 780, height: 900 })
const row = page.locator('[data-produced-files-row]')
await row.waitFor({ timeout: 15_000 })
const chips = row.getByRole('button')
await expect.poll(() => chips.count()).toBe(2)
expect(await chips.nth(0).innerText()).toBe('关于我.md')
expect(await chips.nth(1).innerText()).toBe('index.html')
expect(await row.getByText('+ 8 files', { exact: true }).count()).toBe(1)
const showFolder = page.getByRole('button', { name: 'Show in folder', exact: true })
expect(await showFolder.count()).toBe(1)
expect(await page.getByText('Produced', { exact: true }).count()).toBe(1)
const openPath = vi.spyOn(scaffold.ctx.apiProxy.host, 'openPath')
.mockImplementation(async (request, _signal) => ({
rpcId: request.rpcId,
result: { ok: true, value: { opened: true as const } },
}))
try {
const [response] = await Promise.all([
page.waitForResponse(response => new URL(response.url()).pathname === '/api/host.openPath'),
showFolder.click({ clickCount: 1 }),
])
expect(response.status()).toBe(200)
expect(openPath).toHaveBeenCalledTimes(1)
expect(openPath.mock.calls[0]![0].payload).toEqual({ path: `${scaffold.workspaceCwd}/.` })
} finally {
openPath.mockRestore()
}
const tops = await row.locator(':scope > *').evaluateAll(elements =>
elements.map(element => element.getBoundingClientRect().top))
expect(new Set(tops.map(top => Math.round(top))).size).toBe(1)
const geometry = await row.evaluate(element => ({
clientWidth: element.clientWidth, scrollWidth: element.scrollWidth,
}))
expect(geometry.scrollWidth).toBeLessThanOrEqual(geometry.clientWidth)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])

View File

@@ -0,0 +1,6 @@
# The summary test asserts the native-folder action without launching it. Pin
# the capability so headless Linux CI and desktop developer hosts expose the
# same UI branch; platform opener behavior belongs to the Host unit tests.
- id: api-gateway
config:
nativeOpen: true

View File

@@ -13,7 +13,6 @@
- img
- img
- text: Context injection @deepseek-ai/dsh-system-prompt
- text: Running
- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that.":
- img
- img

View File

@@ -354,10 +354,10 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => {
{ timeout: 10_000 },
).toBe(2)
expect(await page.locator('[data-queue-dock]').count()).toBe(0)
// The reasoning row streams independently of the steering handoff; wait
// for it so the mid snapshot pins the assistant step, not the pre-render
// gap a fast machine can catch between steering acceptance and the block.
await page.locator('[data-variant="think"]').first().waitFor({ timeout: 10_000 })
// The reasoning row streams independently of the steering handoff. Wait
// for the block to settle so the mid snapshot does not race its transient
// visually-hidden Running label while the question keeps the turn open.
await page.locator('[data-variant="think"][data-state="ok"]').first().waitFor({ timeout: 10_000 })
const mid = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(STEER_ALL_MID, mid, MODE)

View File

@@ -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 docs/module-graph.md
module-graph.md: e4d607487452c4f66a4c602d0c7b1148e4789332
module-graph.zh.md: 1f69a3812e71e3a3205f53f6a479394ac1ca83cb
module-graph.md: 59868013f6b6b64cab98ee6f5f2bca99ca579516
module-graph.zh.md: b908a4cb2e50fe3aeceb19b675ce30e351527a89

View File

@@ -1237,6 +1237,7 @@ flowchart TD
pkg_client_ui_command --> pkg_client_ui_slash
pkg_client_ui_command --> pkg_client_ui_slots
pkg_client_ui_command --> pkg_invariants
pkg_client_ui_deliverables --> pkg_client_connection
pkg_client_ui_deliverables --> pkg_client_locale
pkg_client_ui_deliverables --> pkg_client_runtime
pkg_client_ui_deliverables --> pkg_client_ui_conversation
@@ -1534,7 +1535,7 @@ flowchart TD
| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
| [`client-ui-command`](../packages/client/ui-command) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-feedback`](../packages/client/ui-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`message-feedback`](../packages/feedback/message-feedback) |
| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) |
| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) |

View File

@@ -1239,6 +1239,7 @@ flowchart TD
pkg_client_ui_command --> pkg_client_ui_slash
pkg_client_ui_command --> pkg_client_ui_slots
pkg_client_ui_command --> pkg_invariants
pkg_client_ui_deliverables --> pkg_client_connection
pkg_client_ui_deliverables --> pkg_client_locale
pkg_client_ui_deliverables --> pkg_client_runtime
pkg_client_ui_deliverables --> pkg_client_ui_conversation
@@ -1536,7 +1537,7 @@ flowchart TD
| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
| [`client-ui-command`](../packages/client/ui-command) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-feedback`](../packages/client/ui-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`message-feedback`](../packages/feedback/message-feedback) |
| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) |
| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) |

View File

@@ -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 docs/user/guide/python-sdk.md
python-sdk.md: 0713de6f96c110624d5f5a8f2ccf56dd4ce01096
python-sdk.zh.md: f8fa877b407a43c643fda405e1059fb487182761
python-sdk.md: c6aee27e08b266ae3e54f7817cc5b9689ad8fba4
python-sdk.zh.md: 0b0e37a6fff8ee11d4694163ecb7d22f93bcd550

View File

@@ -2,7 +2,7 @@
English | [中文](python-sdk.zh.md)
This tutorial installs the Python SDK, runs a checked-in Cordis composition without the Web UI, and uses the same API in your own program. It uses the compact [`minimal.cordis.yml`](../../../examples/jsonrpc-agent/minimal.cordis.yml) configuration as a complete example with a fixed system prompt, tool catalog, persistent-shell behavior, and compaction policy.
This tutorial installs the Python SDK, runs a checked-in Cordis composition without the Web UI, and uses the same API in your own program. It uses the compact [`minimal.cordis.yml`](../../../examples/jsonrpc-agent/minimal.cordis.yml) configuration as a complete example with a configurable system prompt, a two-tool catalog, persistent-shell behavior, and context compaction disabled.
## Prerequisites
@@ -63,6 +63,8 @@ Set the credential in the environment. Set `DEEPSEEK_BASE_URL` as well when the
```sh
export DEEPSEEK_API_KEY=sk-your-key-here
# export DEEPSEEK_BASE_URL=http://127.0.0.1:8000/v1
# export DSH_MODEL=deepseek-v4-flash
# export DSH_SYSTEM_PROMPT='You are a helpful software engineer assistant.'
```
Run one task from the repository checkout:
@@ -110,21 +112,23 @@ print(result.final_response)
## Understand the example configuration
| Surface | Fixed value |
| Property | Value |
|---|---|
| System prompt | `You are a helpful software engineer assistant.` |
| System prompt | `DSH_SYSTEM_PROMPT`, falling back to `You are a helpful software engineer assistant.` |
| Model in `minimal.py` | `--model`, then `DSH_MODEL`, then `deepseek-v4-flash` |
| Model-facing tools | Persistent `bash` and `str_replace_editor` only |
| Bash timeout | 300 seconds |
| Editor output limit | 16,000 characters |
| Compaction | Trigger ratio `0.8`, retain `20,480` tokens, summary cap `8,192` tokens, one retry |
| Context compaction | Disabled |
| Filesystem | Bare local backend; absolute editor paths may address any path visible to the runtime process |
| Session persistence | Uncompressed JSONL under `DSH_SESSION_ROOT` |
The configuration omits harness identity, workspace prompt text, skills, one-shot Bash, task tools, and every other model-facing plugin. Filesystem policy facts are logged as runtime user context rather than appended to the system prompt. The editor requires absolute paths as an unconditional current contract, so the obsolete `requireAbsolutePath` option is absent.
The configuration omits harness identity, workspace prompt text, skills, one-shot Bash, task tools, compaction, and every other model-facing plugin. Sandbox-policy facts are logged as runtime user context rather than appended to the system prompt. The editor requires absolute paths as an unconditional current contract, so the obsolete `requireAbsolutePath` option is absent.
## Choose workspace and session IDs
`cwd` selects the workspace available to the agent, while `session_root` stores session logs and state. Use a fresh session id for an independent task; reuse an id only when the next call should continue the same conversation and persistent shell state.
The composition uses `danger-full-access`. Run it only inside a disposable checkout or container: Bash and the editor can modify any path allowed to the runtime process. The persistent PTY backend requires a POSIX terminal substrate and is not a Windows agent surface.
The composition uses `danger-full-access`. Run it only inside a disposable checkout or container: Bash and the editor can modify any path allowed to the runtime process. The persistent PTY backend requires a POSIX terminal substrate, so this composition does not support Windows agents.
For the complete SDK lifecycle and result contract, see the [Python SDK reference](../../../python/sdk/README.md). For Cordis composition syntax, see [Configuration](./config.md).

View File

@@ -2,7 +2,7 @@
[English](python-sdk.md) | 中文
本教程介绍如何安装 Python SDK、在不使用 Web UI 的情况下运行仓库内置 Cordis 组合,以及如何在自己的程序中调用同一套 API。教程使用精简且完整的 [`minimal.cordis.yml`](../../../examples/jsonrpc-agent/minimal.cordis.yml) 作为示例,其中固定了系统提示词、工具目录持久 shell 行为和压缩(compaction策略
本教程介绍如何安装 Python SDK、在不使用 Web UI 的情况下运行仓库内置 Cordis 组合,以及如何在自己的程序中调用同一套 API。教程使用精简且完整的 [`minimal.cordis.yml`](../../../examples/jsonrpc-agent/minimal.cordis.yml) 作为示例,其中包含可配置的系统提示词、工具目录持久 shell 行为并关闭上下文压缩context compaction
## 前置要求
@@ -63,6 +63,8 @@ python -m pip install --find-links dist-python "deepseek-harness-sdk==$version"
```sh
export DEEPSEEK_API_KEY=sk-your-key-here
# export DEEPSEEK_BASE_URL=http://127.0.0.1:8000/v1
# export DSH_MODEL=deepseek-v4-flash
# export DSH_SYSTEM_PROMPT='You are a helpful software engineer assistant.'
```
从仓库 checkout 运行一个任务:
@@ -110,21 +112,23 @@ print(result.final_response)
## 了解示例配置
| 方面 | 固定值 |
| 属性 | 值 |
|---|---|
| 系统提示词 | `You are a helpful software engineer assistant.` |
| 系统提示词 | `DSH_SYSTEM_PROMPT`;未设置时使用 `You are a helpful software engineer assistant.` |
| `minimal.py` 使用的模型 | `--model`,其次为 `DSH_MODEL`,最后为 `deepseek-v4-flash` |
| 面向模型的工具 | 仅持久 `bash``str_replace_editor` |
| Bash 超时 | 300 秒 |
| 编辑器输出上限 | 16,000 个字符 |
| 压缩 | 触发比例 `0.8`、保留 `20,480` 个 token、摘要上限 `8,192` 个 token、重试 1 次 |
| 上下文压缩 | 已关闭 |
| 文件系统 | 裸本地后端;编辑器使用绝对路径,可以访问运行时进程可见的任何路径 |
| 会话持久化 | `DSH_SESSION_ROOT` 下未压缩的 JSONL |
该配置省略了 harness 身份、workspace 提示词文本、skill技能、一次性 Bash、任务工具和其他所有面向模型的插件。文件系统策略事实记录为运行时用户上下文,而不会追加到系统提示词中。编辑器无条件要求绝对路径,因此配置中没有已经废弃的 `requireAbsolutePath` 选项。
该配置省略了 harness 身份、workspace 提示词文本、skill技能、一次性 Bash、任务工具、上下文压缩和其他所有面向模型的插件。沙箱策略事实记录为运行时用户上下文,而不会追加到系统提示词中。编辑器无条件要求绝对路径,因此配置中没有已经废弃的 `requireAbsolutePath` 选项。
## 选择 workspace 与 session id
`cwd` 用于选择 agent 可访问的 workspace`session_root` 用于保存会话日志和状态。独立任务应使用新的 session id只有下一次调用需要延续同一段对话和持久 shell 状态时,才复用原有 id。
该组合使用 `danger-full-access`。只能在可丢弃的 checkout 或容器内运行Bash 与编辑器可以修改运行时进程有权访问的任何路径。持久 PTY 后端需要 POSIX 终端环境,因此该模式不适用于 Windows agent。
该组合使用 `danger-full-access`。只能在可丢弃的 checkout 或容器内运行Bash 与编辑器可以修改运行时进程有权访问的任何路径。持久 PTY 后端需要 POSIX 终端环境,因此该组合不支持 Windows agent。
完整的 SDK 生命周期与结果约定见 [Python SDK 参考](../../../python/sdk/README.md)。Cordis 组合语法见[配置](./config.md)。

View File

@@ -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 examples/jsonrpc-agent/README.md
README.md: 5a52b255f9f26a67da17c22e3d7c62fa4ae8258d
README.zh.md: 8d2f9807ff259000b5a6823357f8c41b43bfa434
README.md: 9eb37fd29442dc40c7a17cd266c225e6750a6886
README.zh.md: f358d3c8b22017a10dff62ce2dedced2bfd6de6c

View File

@@ -20,7 +20,9 @@ The surrounding runtime also loads JSONL session persistence and automatic conte
| `DEEPSEEK_API_KEY` | Credential passed to the OpenAI-compatible host endpoint |
| `DEEPSEEK_BASE_URL` | Host endpoint used by `dsh-llm-deepseek` |
| `DSH_CWD` | Agent workspace for bash and filesystem tools |
| `DSH_CONTEXT_WINDOW` | Context capacity recorded for the `DSH_MODEL` catalog entry in the minimal variant |
| `DSH_MAX_TOKENS_AS_SUCCESS` | `true` (default) accepts token-limited results; `false` reports them as errors |
| `DSH_MODEL` | Default model used by `minimal.py`; `--model` takes precedence |
| `DSH_SESSION_ROOT` | JSONL session directory |
| `DSH_SYSTEM_PROMPT` | Deployment-provided coding persona |
@@ -28,9 +30,9 @@ Pass the config path through the Python SDK's `cordis` option or `DSH_CORDIS_CON
## Minimal variant
[`minimal.cordis.yml`](minimal.cordis.yml) is the complete standalone counterpart of the Web `minimal` preset. It fixes the system prompt and compaction policy, and its model-facing tools are exactly:
[`minimal.cordis.yml`](minimal.cordis.yml) is the complete standalone counterpart of the Web `minimal` preset. `DSH_SYSTEM_PROMPT` selects its system prompt, with `You are a helpful software engineer assistant.` as the fallback, and no context-compaction plugin is mounted. Its model-facing tools are exactly:
- owner-scoped persistent `bash`
- `str_replace_editor` with `view`, `create`, `str_replace`, and `insert`
It composes the local PTY, filesystem intent policy, session sandbox policy, and JSONL persistence needed by the bundled runtime. [`minimal.py`](minimal.py) runs it through the Python SDK; the [Python SDK tutorial](../../docs/user/guide/python-sdk.md) uses this configuration to cover setup, session management, and the security boundary.
It composes the local PTY, bare `fs-local` backend, danger-full-access policy for persistent Bash, and uncompressed JSONL persistence needed by the bundled runtime. [`minimal.py`](minimal.py) runs it through the Python SDK and uses `DSH_MODEL` as its default model; the [Python SDK tutorial](../../docs/user/guide/python-sdk.md) covers setup, session management, and the security boundary.

View File

@@ -20,7 +20,9 @@
| `DEEPSEEK_API_KEY` | 传给 OpenAI 兼容宿主端点的凭据 |
| `DEEPSEEK_BASE_URL` | `dsh-llm-deepseek` 使用的宿主端点 |
| `DSH_CWD` | bash 和文件系统工具使用的 agent workspace |
| `DSH_CONTEXT_WINDOW` | 极简变体中为 `DSH_MODEL` 目录项记录的上下文容量 |
| `DSH_MAX_TOKENS_AS_SUCCESS` | `true`(默认)接受受 token 上限限制的结果;`false` 将其报告为错误 |
| `DSH_MODEL` | `minimal.py` 使用的默认模型;`--model` 优先 |
| `DSH_SESSION_ROOT` | JSONL 会话目录 |
| `DSH_SYSTEM_PROMPT` | 由部署提供的编码人格 |
@@ -28,9 +30,9 @@
## 极简变体
[`minimal.cordis.yml`](minimal.cordis.yml) 是 Web `minimal` preset 的完整独立版本。它固定系统提示词与压缩策略,面向模型的能力严格只有:
[`minimal.cordis.yml`](minimal.cordis.yml) 是 Web `minimal` preset 的完整独立版本。`DSH_SYSTEM_PROMPT` 选择它的系统提示词,未设置时使用 `You are a helpful software engineer assistant.`,且不挂载上下文压缩插件。面向模型的工具严格只有:
- 所有者作用域内持久化的 `bash`
- 提供 `view``create``str_replace``insert``str_replace_editor`
它组合了内置运行时所需的本地 PTY、文件系统意图策略、会话沙箱策略与 JSONL 持久化。[`minimal.py`](minimal.py) 通过 Python SDK 运行该配置;[Python SDK 教程](../../docs/user/guide/python-sdk.md)以此配置介绍设置方式、会话管理与安全边界。
它组合了内置运行时所需的本地 PTY、`fs-local` 后端、供持久 Bash 使用的 danger-full-access 策略,以及未压缩的 JSONL 持久化。[`minimal.py`](minimal.py) 通过 Python SDK 运行该配置,并把 `DSH_MODEL` 作为默认模型[Python SDK 教程](../../docs/user/guide/python-sdk.md)以此配置介绍设置方式、会话管理与安全边界。

View File

@@ -1,6 +1,6 @@
# Complete unattended minimal-agent composition for the Python SDK. The model
# sees one fixed system prompt and only the owner-scoped persistent Bash and
# string-replace editor tools.
# sees one deployment-selected system prompt and only the owner-scoped
# persistent Bash and string-replace editor tools. Context compaction is absent.
- id: jsonrpc
name: '@deepseek-ai/dsh-jsonrpc'
@@ -9,6 +9,12 @@
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKeyEnv: DEEPSEEK_API_KEY
streamIdleTimeoutMs: 172800000
models:
- id: !!js process.env.DSH_MODEL ?? 'deepseek-v4-flash'
contextWindow: !!js Number(process.env.DSH_CONTEXT_WINDOW ?? 1000000)
- id: sandbox
name: '@deepseek-ai/dsh-sandbox-local'
@@ -30,22 +36,18 @@
config:
timeoutMs: 300000
# The sandbox-aware filesystem backend applies the same per-session policy as
# Bash. danger-full-access permits unrestricted workspace behavior while
# keeping one policy boundary for both tools.
- id: fs-sandbox
name: '@deepseek-ai/dsh-fs-sandbox'
# The editor uses the bare local filesystem; persistent Bash still consumes the
# shared danger-full-access sandbox policy above.
- id: fs-local
name: '@deepseek-ai/dsh-fs-local'
config:
cwd: !!js process.env.DSH_CWD ?? process.cwd()
- id: fs-policy
name: '@deepseek-ai/dsh-fs-policy'
- id: agent-spine
name: '@deepseek-ai/dsh-agent-spine-demo'
config:
includeHarnessIdentity: false
persona: You are a helpful software engineer assistant.
persona: !!js process.env.DSH_SYSTEM_PROMPT ?? 'You are a helpful software engineer assistant.'
workspaceContext: false
skills:
enabled: false
@@ -76,16 +78,3 @@
config:
root: !!js process.env.DSH_SESSION_ROOT ?? './.sessions'
compression: none
- id: token-meter
name: '@deepseek-ai/dsh-token-meter'
- id: compact-basic
name: '@deepseek-ai/dsh-compact-basic'
config:
thresholdRatio: 0.8
retainTokens: 20480
summarizationProvider: ''
summarizationModel: ''
maxTokens: 8192
compactionRetries: 1

View File

@@ -4,6 +4,7 @@
from __future__ import annotations
import argparse
import os
from pathlib import Path
from deepseek_harness import DeepSeekHarness
@@ -20,7 +21,7 @@ def main() -> None:
parser.add_argument("--session-root", type=Path, default=Path(".dsh-sessions"))
parser.add_argument("--session-id")
parser.add_argument("--provider", default="deepseek-official")
parser.add_argument("--model", default="deepseek-v4-flash")
parser.add_argument("--model", default=os.environ.get("DSH_MODEL", "deepseek-v4-flash"))
parser.add_argument("--max-tokens", type=int)
args = parser.parse_args()

View File

@@ -2,8 +2,8 @@
* Keyless snapshot coverage for the TypeScript SDK path: each scenario spawns
* the REAL `dsh-jsonrpc-agent` runtime (per `DSH_EXAMPLE_MODE`) through the
* REAL `@deepseek-ai/dsh-sdk-client`, drives one turn over stdio JSON-RPC,
* and pins three surfaces — the SDK `RunResult`, the complete notification
* stream, and the persisted session logs. Replay serves recorded model
* and pins the SDK `RunResult`, the complete notification stream, and the
* persisted session logs. Replay serves recorded model
* responses via `llm-replay` (`cordis.snapshot.yml`); `DSH_SNAPSHOT=record`
* re-records against the live API; `DSH_SNAPSHOT=refresh` replays committed
* fixtures and rewrites expected outputs.
@@ -38,7 +38,7 @@ const minimalReplayConfig = join(testsDir, '..', 'minimal.snapshot.cordis.yml')
const runtimeBin = fileURLToPath(new URL('../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url))
const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
const MINIMAL_SYSTEM_PROMPT = 'You are a helpful software engineer assistant.'
const MINIMAL_SYSTEM_PROMPT = 'You are the environment-selected minimal software engineer.'
const MINIMAL_BASH_DESCRIPTION = `Run commands in a bash shell
* When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped.
* You don't have access to the internet via this tool.
@@ -67,6 +67,8 @@ interface SdkScenario {
children: number
/** Optional scenario-specific live and replay compositions. */
configs?: { live: string; replay: string }
/** Environment overrides passed to the runtime subprocess. */
environment?: Readonly<Record<string, string>>
/** Cwd-relative files whose final contents are part of the scenario contract. */
expectedFiles?: Readonly<Record<string, string>>
/** Assembled model-facing tool names and required argument keys. */
@@ -104,6 +106,7 @@ const SCENARIOS: SdkScenario[] = [
sessionId: 'persistent-tools-snapshot',
children: 0,
configs: { live: minimalLiveConfig, replay: minimalReplayConfig },
environment: { DSH_SYSTEM_PROMPT: MINIMAL_SYSTEM_PROMPT },
expectedFiles: { 'note.txt': 'target:\n\tnew\n' },
expectedTools: { bash: ['command'], str_replace_editor: ['command', 'path'] },
expectedSystem: MINIMAL_SYSTEM_PROMPT,
@@ -291,6 +294,7 @@ async function runScenario(scenario: SdkScenario): Promise<{
DSH_SNAPSHOT_FILE: parentFixture,
...childFixtures.length > 0 ? { DSH_SNAPSHOT_CHILD_FILES: childFixtures.join(delimiter) } : {},
},
...scenario.environment,
}
const harness = new DeepSeekHarness({

View File

@@ -106,6 +106,7 @@ const BOOTSTRAP_NAMES = new Set([
'GIT_CONFIG_GLOBAL', 'GIT_CONFIG_SYSTEM', 'GIT_CONFIG_COUNT',
'EDITOR', 'VISUAL', 'PAGER',
// Network reach and trust.
'DEEPSEEK_BASE_URL', 'DEEPSEEK_SEARCH_BASE_URL',
'SSL_CERT_FILE', 'SSL_CERT_DIR',
'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE',

View File

@@ -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/client/connection/README.md
README.md: 85ff46052ba2f032ee6a95b16c396d45e766d3ba
README.zh.md: 89cbb19a984d88e09b7af0890f57ecd15d46d3a5
README.md: a82bb55ab65df2732ad16248d2cc9aa15b60e94d
README.zh.md: d4c19db8e74e3562ec55b5ef60675aef79b82008

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` abstraction, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. The Host half owns the single `/api` route and its Fetch bridge; a registered TypeRT interceptor claims its Remote endpoints before the API Proxy fallback. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from — and the agent-preset authoring plane, `agentPreset.read`/`copy`/`openDocument`/`remove`, since a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop (authoring is copy-only, so none of them accepts composition text or a path); `agentPreset.list` and `agentPreset.select` stay out — the roster carries only ids and trust, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md).
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + observable generation-scoped `hostDescription` + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` abstraction, and the loop's sink/config types. Each successful readiness handshake publishes the exact `host.describe` value before `onConnected`; generation loss and explicit stop clear it, so native-capability consumers never retain a disconnected answer. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. The Host half owns the single `/api` route and its Fetch bridge; a registered TypeRT interceptor claims its Remote endpoints before the API Proxy fallback. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from — and the agent-preset authoring plane, `agentPreset.read`/`copy`/`openDocument`/`remove`, since a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop (authoring is copy-only, so none of them accepts composition text or a path); `agentPreset.list` and `agentPreset.select` stay out — the roster carries only ids and trust, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md).
## /api browser-trust fence

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议约定类型、`AbstractApiClient` 抽象,以及循环的 sink配置类型。浏览器载体以 HTTP POST 发送 unaryrespond并为 `events.mux``events.host` 各开一条只下行的 WebSocket进程内载体满足同一双流抽象。Host half 持有唯一 `/api` route 及其 Fetch bridge已注册的 TypeRT interceptor 会先认领自己的 Remote endpoint未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory``host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处——以及 agent preset 的创作面 `agentPreset.read`/`copy`/`openDocument`/`remove`,因为组装指明了一个会话所运行的插件,读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面(创作只有复制一种写入,因此这些方法都不接收组装文本或路径);`agentPreset.list``agentPreset.select` 不在其中——名单只携带 id 与信任级别,而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash以空信任表过信任 fence从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md)。
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 可观察且按 generation 生效的 `hostDescription` + 单消费方流循环启动器);导出表层携带协议约定类型、`AbstractApiClient` 抽象,以及循环的 sink配置类型。每次就绪握手成功后,都会在 `onConnected` 之前发布完整的 `host.describe`generation 失效或显式 stop 会清空它,因此原生能力消费者不会保留已经断线的判断。浏览器载体以 HTTP POST 发送 unaryrespond并为 `events.mux``events.host` 各开一条只下行的 WebSocket进程内载体满足同一双流抽象。Host half 持有唯一 `/api` route 及其 Fetch bridge已注册的 TypeRT interceptor 会先认领自己的 Remote endpoint未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory``host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处——以及 agent preset 的创作面 `agentPreset.read`/`copy`/`openDocument`/`remove`,因为组装指明了一个会话所运行的插件,读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面(创作只有复制一种写入,因此这些方法都不接收组装文本或路径);`agentPreset.list``agentPreset.select` 不在其中——名单只携带 id 与信任级别,而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash以空信任表过信任 fence从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md)。
## /api 浏览器信任栅栏

View File

@@ -1,4 +1,4 @@
import type { IApiClient, HostFrame, MuxFrame, RpcRequest } from './api.ts'
import type { HostDescription, IApiClient, HostFrame, MuxFrame, RpcRequest } from './api.ts'
/** Reconnect/backoff tunables (deployment-varying — no hardcoded tunables; these become the
* future `ctx.connection` plugin's Config). All fields optional; defaults below. */
@@ -45,7 +45,7 @@ export interface ConnectionSinks {
onMuxEnvelope?: (envelope: RpcRequest<MuxFrame>) => void
onHostEnvelope?: (envelope: RpcRequest<HostFrame>) => void
/** After each connection generation is established (both streams open + describe succeeded), first connect included. */
onConnected?: () => void
onConnected?: (description: HostDescription) => void
/** Coarse state transitions (deduplicated: fires only on change). The initial pre-connect
* span reports nothing — the UI treats "no state yet" as connecting, not as an outage. */
onStateChange?: (state: ConnectionState) => void
@@ -99,6 +99,11 @@ export class ConnectionController {
return this.running
}
/** Re-read both mutable liveness guards after a potentially reentrant sink. */
private isGenerationActive(controller: AbortController): boolean {
return this.isRunning() && !controller.signal.aborted
}
private async loop(): Promise<void> {
while (this.running) {
const gen = ++this.generation
@@ -143,7 +148,11 @@ export class ConnectionController {
if (ac.signal.aborted) throw new Error('generation aborted during readiness handshake')
this.attempt = 0
this.emitState('connected')
this.callSink(this.sinks.onConnected)
// A state sink may synchronously stop this controller. Do not publish
// a description for a generation that no longer exists afterward.
if (this.isGenerationActive(ac)) {
this.callSink(() => { this.sinks.onConnected?.(descriptionResult.value) })
}
} catch {
// Transport failure: treat as generation failure, fall through to the shared backoff.
if (!ac.signal.aborted) ac.abort()
@@ -183,8 +192,7 @@ export class ConnectionController {
}
/** Sink exception isolation: a business-layer throw is logged only, never affecting pump or reconnect semantics. */
private callSink(fn: (() => void) | undefined): void {
if (fn === undefined) return
private callSink(fn: () => void): void {
try {
fn()
} catch (error) {

View File

@@ -2305,7 +2305,9 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
interrupt: request => Promise.resolve(ok(request, { accepted: true as const })),
},
host: {
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }),
describe: request => ok(request, {
version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions, canOpenPath: true,
}),
// Deterministic native pick: the keyless lanes drive the full
// pick-then-adopt path without an OS chooser (design-mock content,
// same tree the browse primitives serve).

View File

@@ -4,7 +4,7 @@
* controller with its sinks.
*/
import type { Context } from '@deepseek-ai/cordis'
import type { IApiClient } from './api.ts'
import type { HostDescription, IApiClient } from './api.ts'
import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts'
import { FixtureApiClient } from './fixture.ts'
import { WebApiClient } from './web-api-client.ts'
@@ -41,6 +41,13 @@ export {
export type { ConnectionConfig, ConnectionSinks, ConnectionState }
export type { ClientConnectionRpc } from '../rpc.ts'
/** Observable Host description published by each completed connection handshake. */
export interface HostDescriptionSource {
/** Latest connected-generation description; absent before connect and while reconnecting. */
getSnapshot(): HostDescription | undefined
/** Subscribe to description replacement and connection loss. */
subscribe(listener: () => void): () => void
}
/** Required services (none — this is the wire root). */
export const inject: string[] = []
@@ -55,6 +62,8 @@ export interface ConnectionHandle {
readonly api: IApiClient
/** Whether the current page authority is loopback; non-browser contexts default to true. */
readonly isLoopback: boolean
/** Generation-scoped Host facts, including native path-open capability. */
readonly hostDescription: HostDescriptionSource
/** Generic logical RPC channels over the same Connection transport. */
readonly rpc: ClientConnectionRpc
/**
@@ -79,16 +88,56 @@ export function apply(ctx: Context): void {
const api: IApiClient = fixtureClient ?? new WebApiClient()
const rpc = fixtureClient?.rpc ?? createWebConnectionRpc()
let started = false
let description: HostDescription | undefined
const descriptionListeners = new Set<() => void>()
const publishDescription = (next: HostDescription | undefined): void => {
if (Object.is(description, next)) return
description = next
for (const listener of [...descriptionListeners]) {
try {
listener()
} catch (error) {
console.error('[web-runtime] host-description listener threw:', error)
}
}
}
const handle: ConnectionHandle = {
api,
isLoopback: pageLocation === undefined || isLoopbackHostname(pageLocation.hostname),
hostDescription: {
getSnapshot: () => description,
subscribe: (listener) => {
descriptionListeners.add(listener)
return () => { descriptionListeners.delete(listener) }
},
},
rpc,
start(sinks, config) {
if (started) throw new Error('connection: the stream loop is already owned by another consumer')
started = true
const controller = new ConnectionController(api, sinks, config ?? {})
const controller = new ConnectionController(api, {
...sinks,
onConnected: (next) => {
publishDescription(next)
// A description subscriber may synchronously stop the loop. In that
// case publishDescription(undefined) has already retracted this
// generation, so do not leak its stale connected notification to
// the consumer sink afterward.
if (!Object.is(description, next)) return
sinks.onConnected?.(next)
},
onStateChange: (state) => {
if (state === 'reconnecting') publishDescription(undefined)
sinks.onStateChange?.(state)
},
}, config ?? {})
controller.start()
return { stop: () => { controller.stop() } }
return {
stop: () => {
controller.stop()
publishDescription(undefined)
},
}
},
}
ctx.provide('connection', handle)

View File

@@ -87,10 +87,87 @@ describe('connection client apply', () => {
it('start() hands out one loop, rejects a second consumer, and stop() aborts the streams', async () => {
;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
const handle = await mount()
// config omitted: the `config ?? {}` default arm is part of the API.
const loop = handle.start({})
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const descriptions: Array<boolean | undefined> = []
const stopThrowing = handle.hostDescription.subscribe(() => { throw new Error('subscriber bug') })
const stopDescription = handle.hostDescription.subscribe(() => {
descriptions.push(handle.hostDescription.getSnapshot()?.canOpenPath)
})
expect(handle.hostDescription.getSnapshot()).toBeUndefined()
// config omitted: the `config ?? {}` default arm is part of the surface.
let connected = 0
const loop = handle.start({ onConnected: () => { connected++ } })
expect(() => handle.start({})).toThrow(/already owned by another consumer/)
await vi.waitFor(() => {
expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true)
})
loop.stop() // teardown must not throw; the fixture streams abort quietly
expect(handle.hostDescription.getSnapshot()).toBeUndefined()
expect(descriptions).toEqual([true, undefined])
expect(connected).toBe(1)
expect(errorSpy).toHaveBeenCalledTimes(2)
stopThrowing()
stopDescription()
errorSpy.mockRestore()
})
it('does not announce a generation synchronously stopped by a description subscriber', async () => {
;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
const handle = await mount()
const owner: { loop?: ReturnType<ConnectionHandle['start']> } = {}
let sawDescription = false
const stopDescription = handle.hostDescription.subscribe(() => {
if (handle.hostDescription.getSnapshot() === undefined) return
sawDescription = true
owner.loop?.stop()
})
const connected = vi.fn()
const loop = handle.start({ onConnected: connected })
owner.loop = loop
try {
await vi.waitFor(() => { expect(sawDescription).toBe(true) })
expect(handle.hostDescription.getSnapshot()).toBeUndefined()
expect(connected).not.toHaveBeenCalled()
} finally {
stopDescription()
loop.stop()
}
})
it('retracts the host description while reconnecting and republishes the next generation', async () => {
;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
const handle = await mount()
const descriptions: Array<boolean | undefined> = []
const reconnectSnapshots: Array<boolean | undefined> = []
const stopDescription = handle.hostDescription.subscribe(() => {
descriptions.push(handle.hostDescription.getSnapshot()?.canOpenPath)
})
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const loop = handle.start({
onStateChange: (state) => {
if (state === 'reconnecting') {
reconnectSnapshots.push(handle.hostDescription.getSnapshot()?.canOpenPath)
}
},
}, { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, streamOpenTimeoutMs: 500 })
try {
await vi.waitFor(() => {
expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true)
})
const timing = (globalThis as Record<string, unknown>).__fxTiming as
| { breakStreams(): void }
| undefined
if (timing === undefined) throw new Error('fixture timing hooks missing')
timing.breakStreams()
await vi.waitFor(() => { expect(reconnectSnapshots).toEqual([undefined]) })
await vi.waitFor(() => { expect(descriptions).toEqual([true, undefined, true]) })
expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true)
} finally {
stopDescription()
loop.stop()
warnSpy.mockRestore()
}
})
it('WebApiClient keeps unary calls and respond on globalThis.fetch', async () => {

View File

@@ -23,10 +23,14 @@ describe('connection lifecycle', () => {
it('announces connected after describe + both streams open, then pumps frames to sinks', async () => {
const api = new FakeApiClient()
const muxSeen: string[] = []
const descriptions: boolean[] = []
let connected = 0
const controller = new ConnectionController(api, {
onMuxEnvelope: envelope => muxSeen.push(envelope.payload.type),
onConnected: () => { connected++ },
onConnected: (description) => {
connected++
descriptions.push(description.canOpenPath)
},
}, FAST)
controller.start()
try {
@@ -34,6 +38,7 @@ describe('connection lifecycle', () => {
api.pushMux(subscribedFrame())
await vi.waitFor(() => { expect(muxSeen).toEqual(['session/subscribed']) })
expect(api.callsOf('host.describe')).toHaveLength(1)
expect(descriptions).toEqual([true])
} finally {
controller.stop()
}
@@ -75,7 +80,7 @@ describe('connection lifecycle', () => {
try {
await vi.waitFor(() => { expect(describeCalls).toBe(2) }) // retried after backoff
expect(connected).toBe(0) // never announced during the failed generation
gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 }))
gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }))
await vi.waitFor(() => { expect(connected).toBe(1) })
} finally {
controller.stop()
@@ -97,7 +102,7 @@ describe('connection lifecycle', () => {
},
})
}
return Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 }))
return Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }))
}
let connected = 0
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
@@ -175,6 +180,38 @@ describe('connection lifecycle', () => {
}
})
it('rejects a generation whose streams end during readiness and retries', async () => {
const api = new FakeApiClient()
const firstDescribe = deferred<Awaited<ReturnType<FakeApiClient['onDescribe']>>>()
let describeCalls = 0
api.onDescribe = () => {
describeCalls++
return describeCalls === 1
? firstDescribe.promise
: Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }))
}
const states: ConnectionState[] = []
let connected = 0
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const controller = new ConnectionController(api, {
onConnected: () => { connected++ },
onStateChange: state => states.push(state),
}, FAST)
controller.start()
try {
await vi.waitFor(() => { expect(api.openMuxCount).toBe(1) })
api.endStreams()
firstDescribe.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }))
await vi.waitFor(() => { expect(describeCalls).toBe(2) })
await vi.waitFor(() => { expect(connected).toBe(1) })
expect(states).toEqual(['reconnecting', 'connected'])
} finally {
controller.stop()
warnSpy.mockRestore()
}
})
it('proceeds as connected via the timeout guard when a carrier never fires onOpen', async () => {
const api = new FakeApiClient()
api.suppressStreamOpen = true // misbehaving carrier: streams open but onOpen never fires
@@ -210,6 +247,24 @@ describe('connection lifecycle', () => {
}
})
it('does not announce a generation stopped synchronously by its connected state sink', async () => {
const api = new FakeApiClient()
const states: ConnectionState[] = []
let connected = 0
const controller = new ConnectionController(api, {
onConnected: () => { connected++ },
onStateChange: (state) => {
states.push(state)
if (state === 'connected') controller.stop()
},
}, FAST)
controller.start()
await vi.waitFor(() => { expect(states).toEqual(['connected']) })
await vi.waitFor(() => { expect(api.openMuxCount).toBe(0) })
expect(connected).toBe(0)
})
it('deduplicates consecutive reconnecting emissions across two straight failures', async () => {
const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onDescribe']>>>()
@@ -228,7 +283,7 @@ describe('connection lifecycle', () => {
controller.start()
try {
await vi.waitFor(() => { expect(describeCalls).toBe(3) })
gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 }))
gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }))
await vi.waitFor(() => { expect(connected).toBe(1) })
expect(states).toEqual(['reconnecting', 'connected']) // two failures, one reconnecting emission
} finally {

View File

@@ -71,8 +71,15 @@ export class FakeApiClient implements IApiClient {
() => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' }))
onUpdateQueue: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{
version: string
cwd: string
attachedSessions: number
canOpenPath: boolean
}>> =
() => Promise.resolve(ok({
version: '0-fake', cwd: '/f', attachedSessions: 0, canOpenPath: true,
}))
onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
() => Promise.resolve(ok({ path: null }))
onOpenPath: (payload: unknown) => Promise<RpcResponse<{ opened: true }>> =

View File

@@ -31,6 +31,10 @@ async function mount(): Promise<Bench> {
const handle: ConnectionHandle = {
api,
isLoopback: true,
hostDescription: {
getSnapshot: () => undefined,
subscribe: () => () => {},
},
rpc: {
call: () => Promise.reject(new Error('unexpected generic RPC call')),
},
@@ -86,7 +90,7 @@ describe('runtime client apply', () => {
expect(workspaces.list.getSnapshot().items[0]?.workspaceId).toBe('w-new')
// Mux sink and onConnected route without throwing (manager semantics own the behavior).
bench.sinks?.onMuxEnvelope?.({ rpcId: 'r2' as never, payload: { type: 'stream/error', message: 'x' } as never })
bench.sinks?.onConnected?.()
bench.sinks?.onConnected?.({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true })
})
it('selects the recent Workspace once when the first baselines have no current session', async () => {
@@ -99,7 +103,7 @@ describe('runtime client apply', () => {
}))
bench.api.onList = () => Promise.resolve(ok({ items: [] }))
bench.sinks?.onConnected?.()
bench.sinks?.onConnected?.({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true })
await flushMicrotasks()
const sessions = bench.ctx.get('sessions') as SessionsService

View File

@@ -90,8 +90,15 @@ export class FakeApiClient implements IApiClient {
onUpdateQueue: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{
version: string
cwd: string
attachedSessions: number
canOpenPath: boolean
}>> =
() => Promise.resolve(ok({
version: '0-fake', cwd: '/f', attachedSessions: 0, canOpenPath: true,
}))
onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
() => Promise.resolve(ok({ path: null }))
onOpenPath: (payload: unknown) => Promise<RpcResponse<{ opened: true }>> =

View File

@@ -61,6 +61,10 @@ async function mount(): Promise<Bench> {
const handle: ConnectionHandle = {
api,
isLoopback: true,
hostDescription: {
getSnapshot: () => undefined,
subscribe: () => () => {},
},
rpc: {
call: () => Promise.reject(new Error('unexpected generic RPC call')),
},
@@ -122,8 +126,9 @@ describe('wire event bridge', () => {
const bench = await mount()
let resets = 0
bench.ctx.on('connection/reset', () => { resets++ })
bench.sinks?.onConnected?.()
bench.sinks?.onConnected?.() // second generation after a reconnect
const description = { version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }
bench.sinks?.onConnected?.(description)
bench.sinks?.onConnected?.(description) // second generation after a reconnect
expect(resets).toBe(2)
})
})

View File

@@ -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/client/ui-deliverables/README.md
README.md: 7d03e5faedda3ba8c9cc4cab6ca134d98dc7ec13
README.zh.md: dfbbc7a39aa94aab438119a4f23ffb02da2daa3d
README.md: 9cfcde9510c5d6814d5b42fd58a0520ea37b8679
README.zh.md: 94dba9bb38725ddb3ef39d55a5f8a185a5008d18

View File

@@ -6,7 +6,7 @@ Produced-files feature owner: registers the deliverables row a finished turn end
`deliverablesDefinition` folds each Turn's successful mutation calls into engine-published `DeliverablesTurnData`; `producedForClosing` reads that data with the closing Assistant seq. The vocabulary is the mutation tools' own follow-along `locations`, never the closing prose: a produced file is listed whether or not the model remembered to name it. A mutation is recognized by render intent, not tool name — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a new mutation tool joins by declaring what it does. Reads, deletes, and failed calls contribute nothing; a path appears once per Turn in first-seen order. The Conversation Location index owns Turn membership, so a Turn that mutates and then ends without content text cannot spill into the next Turn's row.
`ProducedFiles` renders the row between the closing message's body and its IconActions footer: a quiet label, up to six chips (basename text, full path as the `title`), and an explicit remainder count past the cap. Each chip opens through the owner-supplied `openFile` — the same Host opener the tool rows use, with the chat view resolving relative paths against the session cwd. Design rationale: the [workspace file links Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md).
`ProducedFiles` renders the row between the closing message's body and its IconActions footer: a quiet label and one measured file lane. It shows the largest leading prefix that fits (up to six chips; basename text, full path as the `title`) while reserving the exact localized `+ N files` width, so the remainder stays visible without wrapping or horizontal scrolling. Each chip opens through the owner-supplied `openFile` — the same Host opener the tool rows use, with the chat view resolving relative paths against the session cwd. When files are hidden, a second-line **Show in folder** action opens the session workspace through that same owner path only while the page is loopback and the current Host handshake reports `canOpenPath`; direct remote Web and headless/container Linux Hosts omit the action by default. Design rationale: the [workspace file links Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md).
The closing prose carries the same vocabulary. This plugin provides the `chatFileMentions` service the chat view consults per closing message: `producedFileMentions` resolves an inline-code token by exact path, or by being exactly the basename of exactly one produced path — a basename two paths share stays inert rather than guessing, so a mention link can never open the wrong file or 404. A resolved mention keeps its code chip and takes the markdown sheet's link language — link-blue at rest, underlined on hover, exactly like URL-promoted inline code — with the full path as its `title`; mentions never render inside anchors or streaming text. Decision record: the [inline file mentions Agent Note](../../../.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md).
@@ -21,3 +21,4 @@ None; this package neither assembles nor sends provider requests.
## Known Limitations and Deferred Work
- **Mention matching is exact path or unique basename only.** A suffix mention (`out/index.html` written as `index.html` resolves; `deep/out/index.html` written as `out/index.html` does not) stays inert; widening the matcher is deferred until a real closing-message shape needs it.
- **Native folder handoff targets the Host desktop.** A browser reached through a non-loopback authority omits the action, as does a deployment reporting no native opener. SSH forwarding that makes a remote Host look loopback-local must set the gateway's `nativeOpen: false`; so must a headless macOS/Windows Host, a WSL deployment without working Windows interop, or any Linux desktop whose display/opener probe is a false positive. Identifying the operator-visible desktop remains deployment policy.

View File

@@ -6,7 +6,7 @@
`deliverablesDefinition` 把每个 Turn 中成功的修改调用折叠进引擎发布的 `DeliverablesTurnData``producedForClosing` 结合收尾 Assistant 的 seq 读取这份数据。依据的是修改工具自身附带的 `locations`而不是收尾正文无论模型是否记得点名产出文件都会被列出。修改操作按渲染意图而非工具名识别diff 卡片,或 `kind``edit` 的通用卡片(即 `str_replace_editor` 的 insert 操作所呈现的形态);因此新的修改工具只需声明自身行为即可加入。读取、删除和失败的调用不贡献任何条目;同一路径在一个 Turn 内按首见顺序只出现一次。Conversation Location 索引拥有 Turn 成员关系,因此一个 Turn 即使先修改文件、随后没有正文内容就结束,也不会溢进下一个 Turn 的行里。
`ProducedFiles` 在收尾消息正文与其 IconActions 之间渲染该行:一个低调的标签至多六个标签项文本为文件名,完整路径作为 `title`超出上限则显示一个明确的剩余计数。每个标签项经由属主提供的 `openFile` 打开——与工具行相同的 Host 打开器chat 视图会把相对路径按会话 cwd 解析。设计原理:[workspace 文件链接 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md)。
`ProducedFiles` 在收尾消息正文与其 IconActions 之间渲染该行:一个低调的标签和一条经过测量的单行文件 lane。它展示能够放下的最大前缀至多六个标签项文本为文件名,完整路径作为 `title`并为本地化后的精确 `+ N 个文件` 宽度预留空间,因此剩余计数始终可见,既不换行也不横向滚动。每个标签项经由属主提供的 `openFile` 打开——与工具行相同的 Host 打开器chat 视图会把相对路径按会话 cwd 解析。存在隐藏文件时,第二行的**在文件夹中显示**也经由同一属主路径打开会话 workspace它只在页面使用 loopback 且当前 Host 握手报告 `canOpenPath` 时出现,直接远程 Web 与 headless容器 Linux Host 默认均省略该操作。设计原理:[workspace 文件链接 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md)。
收尾正文承载同一份词表。本插件提供供 chat 视图按收尾消息查询的 `chatFileMentions` 服务:`producedFileMentions` 按精确路径解析行内代码 token或当 token 恰好是且仅是一条产出路径的 basename 时解析——两条路径共享同一 basename 时,文本保持不可点击而不作猜测,因此提及链接永远不会打开错误的文件或 404。解析成功的提及保留代码标签并采用 Markdown 样式表的链接样式:静止时为链接蓝色,悬停时显示下划线,与 URL 提升的行内代码完全一致——完整路径作为其 `title`;提及绝不会渲染在链接内部或流式文本中。决策记录:[行内文件提及 Agent Note](../../../.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md)。
@@ -21,3 +21,4 @@
## 已知限制与暂缓事项
- **提及匹配只认精确路径或唯一 basename。**后缀式提及(`out/index.html` 写作 `index.html` 可解析;`deep/out/index.html` 写作 `out/index.html` 则不行)保持不可点击;等真实的收尾消息形态产生需求后再放宽匹配规则。
- **原生文件夹交接以 Host 桌面为目标。**经非 loopback 权威访问的浏览器会省略该操作,报告没有原生打开器的部署也一样。若 SSH 转发让远端 Host 看似处于本机 loopback部署必须为网关设置 `nativeOpen: false`;无界面的 macOSWindows Host、Windows interop 不可用的 WSL或 displayopener 探测误报的 Linux 桌面也必须这样配置。识别操作者实际可见的桌面仍属于部署策略。

View File

@@ -32,6 +32,7 @@
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-conversation"
@@ -48,6 +49,7 @@
"react": "^18.2.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
@@ -56,6 +58,7 @@
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",

View File

@@ -1,24 +1,39 @@
/* Turn-tail produced-files row: a quiet label followed by wrapping file chips.
Sits between the assistant body and its IconActions footer, so it reads as
part of the answer rather than as another tool row. */
/* Turn-tail produced-files summary: one measured chip lane plus an optional
native-folder action below it. */
.root {
display: flex;
flex-wrap: wrap;
position: relative;
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
align-items: center;
gap: 8px;
column-gap: 8px;
row-gap: 6px;
margin-top: 16px;
font-size: 13px;
line-height: 22px;
}
.label {
grid-column: 1;
grid-row: 1;
color: var(--dsw-alias-label-tertiary);
}
.row {
grid-column: 2;
grid-row: 1;
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 8px;
min-width: 0;
overflow: hidden;
}
/* One produced file. A link by behavior (it opens the file), a chip by shape:
full paths are long and several may wrap onto one row. */
full paths are long, while the measured lane stays on one row. */
.file {
flex: 0 0 auto;
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
@@ -38,7 +53,53 @@
text-decoration: underline;
}
.file:focus-visible,
.showFolder:focus-visible {
outline: none;
box-shadow: inset 0 0 0 2px var(--dsw-alias-border-l3);
}
/* Overflow count: the row never silently drops files it did not show. */
.more {
flex: 0 0 auto;
white-space: nowrap;
color: var(--dsw-alias-label-tertiary);
}
.showFolder {
grid-column: 2;
grid-row: 2;
justify-self: start;
margin: 0;
padding: 0 2px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
font: inherit;
line-height: 20px;
cursor: pointer;
}
.showFolder:hover {
color: var(--dsw-alias-label-secondary);
text-decoration: underline;
}
/* Exact browser-native probes for every candidate shown count. They share the
visible styles but never affect layout, accessibility, or scroll width. */
.measure {
position: absolute;
width: 0;
height: 0;
overflow: hidden;
visibility: hidden;
pointer-events: none;
contain: strict;
}
.probe {
position: absolute;
inset: 0 auto auto 0;
width: max-content;
}

View File

@@ -4,46 +4,153 @@
// through the same openFile the tool rows use — the Host's own opener, on the
// Host machine.
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import { useLayoutEffect, useRef, useState } from 'react'
import type { HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client'
import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { basename } from './turn-deliverables.ts'
import type { NS } from './locales.ts'
import css from './ProducedFiles.module.css'
/** Files past this stay counted but unlisted: a refactor turn must not bury the answer. */
const SHOWN = 6
/** At most six chips compete for the one-line summary; every other path stays counted. */
const SHOWN_LIMIT = 6
/** Matched paths plus the opener and locale seats needed to present them. */
/**
* Select the largest prefix whose measured chips and exact remainder fit.
* @param available - usable width of the one-line file lane.
* @param gap - computed flex gap between adjacent visible items.
* @param chipWidths - measured widths for the candidate file chips.
* @param moreWidthsByShown - exact localized remainder width for each shown count.
* @returns Number of leading chips to render.
*/
export function fitProducedFiles(
available: number,
gap: number,
chipWidths: readonly number[],
moreWidthsByShown: readonly (number | undefined)[],
): number {
if (available <= 0) return chipWidths.length
const prefix = [0]
let prefixWidth = 0
for (const width of chipWidths) {
prefixWidth += width
prefix.push(prefixWidth)
}
let largestFit = 0
for (const [shown, width] of prefix.entries()) {
const more = moreWidthsByShown[shown]
const items = shown + (more === undefined ? 0 : 1)
const needed = width + (more ?? 0) + Math.max(0, items - 1) * gap
if (needed <= available) largestFit = shown
}
return largestFit
}
/** Registration-side Host capability facts. */
export interface ProducedFilesInjected {
/** Whether the browser itself is connected over loopback. */
isLoopback: boolean
hooks: {
/** Current generation's Host description, bound by the slot renderer. */
hostDescription: HostDescriptionSource
}
}
/** Matched paths plus the opener, locale, and injected Host capability. */
export type ProducedFilesProps = Pick<TurnTailOwnerProps, 'openFile'> & {
matched: readonly string[]
} & PropsLocale<typeof NS>
} & PropsLocale<typeof NS> & InjectFace<ProducedFilesInjected>
function moreLabel(t: ProducedFilesProps['t'], count: number): string {
return count === 1 ? t('produced.moreOne') : t('produced.more', { count: String(count) })
}
/**
* Render one turn's produced files as openable chips.
* @param props - selector-matched paths, the chat view's file opener, and the locale seat.
* @returns The produced-files row.
*/
export function ProducedFiles({ matched: paths, openFile, t }: ProducedFilesProps) {
const shown = paths.slice(0, SHOWN)
export function ProducedFiles({
matched: paths, openFile, isLoopback, useHostDescription, t,
}: ProducedFilesProps) {
const hostCanOpenPath = useHostDescription(description => description?.canOpenPath === true)
const canOpenPath = isLoopback && hostCanOpenPath
const limit = Math.min(paths.length, SHOWN_LIMIT)
const [shownCount, setShownCount] = useState(limit)
const rowRef = useRef<HTMLDivElement>(null)
const chipProbes = useRef<Array<HTMLButtonElement | null>>([])
const moreProbe = useRef<HTMLSpanElement>(null)
useLayoutEffect(() => {
const row = rowRef.current
const remainderProbe = moreProbe.current
/* v8 ignore next -- React attaches both refs before the layout effect runs. */
if (row === null || remainderProbe === null) return
const measure = (): void => {
const styles = getComputedStyle(row)
const gap = Number.parseFloat(styles.columnGap || styles.gap) || 0
// React attaches every still-mounted callback ref before layout effects run.
const activeChipProbes = chipProbes.current.slice(0, limit) as HTMLButtonElement[]
const chips = activeChipProbes.map(probe => probe.getBoundingClientRect().width)
const more = Array.from({ length: limit + 1 }, (_, candidate) => {
if (paths.length === candidate) return undefined
remainderProbe.textContent = moreLabel(t, paths.length - candidate)
return remainderProbe.getBoundingClientRect().width
})
setShownCount(fitProducedFiles(row.clientWidth, gap, chips, more))
}
measure()
if (typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(measure)
observer.observe(row)
for (const probe of [...chipProbes.current, moreProbe.current]) {
if (probe !== null) observer.observe(probe)
}
return () => { observer.disconnect() }
}, [limit, paths, t])
const visibleCount = Math.min(shownCount, limit)
const shown = paths.slice(0, visibleCount)
const hidden = paths.length - shown.length
return (
<div className={css.root}>
<span className={css.label}>{t('produced.label')}</span>
{shown.map(path => (
<button
key={path}
type="button"
className={css.file}
// The full path is the disambiguator when two turns produce files
// that share a basename; the chip itself stays short.
title={path}
aria-label={t('produced.open', { name: path })}
onClick={() => { openFile(path) }}
>
{basename(path)}
<div ref={rowRef} className={css.row} data-produced-files-row>
{shown.map(path => (
<button
key={path}
type="button"
className={css.file}
// The full path is the disambiguator when two turns produce files
// that share a basename; the chip itself stays short.
title={path}
aria-label={t('produced.open', { name: path })}
onClick={() => { openFile(path) }}
>
{basename(path)}
</button>
))}
{hidden > 0 && <span className={css.more}>{moreLabel(t, hidden)}</span>}
</div>
{hidden > 0 && canOpenPath && (
<button type="button" className={css.showFolder} onClick={() => { openFile('.') }}>
{t('produced.showInFolder')}
</button>
))}
{hidden > 0 && <span className={css.more}>{t('produced.more', { count: String(hidden) })}</span>}
)}
<div className={css.measure} aria-hidden="true">
{paths.slice(0, limit).map((path, index) => (
<button
key={path}
ref={(node) => { chipProbes.current[index] = node }}
type="button"
tabIndex={-1}
className={`${css.file} ${css.probe}`}
>
{basename(path)}
</button>
))}
<span ref={moreProbe} className={`${css.more} ${css.probe}`} />
</div>
</div>
)
}

View File

@@ -7,6 +7,7 @@
* composing this plugin out of cordis.yml removes both surfaces entirely;
* the owning view renders an empty chain and inert prose at zero cost.
*/
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {} from '@deepseek-ai/dsh-client-locale/client'
@@ -27,13 +28,14 @@ export { ProducedFiles, type ProducedFilesProps } from './ProducedFiles.tsx'
export { producedForClosing } from './turn-deliverables.ts'
/** Required services for the tail-slot registration and its dictionaries. */
export const inject = ['slots', 'locale', 'conversationEvents']
export const inject = ['slots', 'locale', 'conversationEvents', 'connection']
/**
* Client plugin body: register the dictionaries and the turn-tail entry.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
const connection = ctx.get('connection') as ConnectionHandle
ctx.conversationEvents.register(deliverablesDefinition)
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries')
ctx.slots.inject(
@@ -42,6 +44,10 @@ export function apply(ctx: ClientContext): void {
name: 'conversation.chat.turnTail',
select: selectProducedFiles,
locale: NS,
inject: () => ({
isLoopback: connection.isLoopback,
hooks: { hostDescription: connection.hostDescription },
}),
}, ProducedFiles),
)
// The prose side of the same vocabulary: the chat view reaches this face

View File

@@ -6,15 +6,19 @@ export const NS = 'deliverables'
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'produced.label': '产物',
'produced.more': '还有 {count} 个',
'produced.moreOne': '+ 1 个文件',
'produced.more': '+ {count} 个文件',
'produced.open': '打开 {name}',
'produced.showInFolder': '在文件夹中显示',
}
/** English dictionary (same key set). */
export const en: Record<DeliverablesKey, string> = {
'produced.label': 'Produced',
'produced.more': '{count} more',
'produced.moreOne': '+ 1 file',
'produced.more': '+ {count} files',
'produced.open': 'Open {name}',
'produced.showInFolder': 'Show in folder',
}
/** Union of this namespace's dictionary keys. */

View File

@@ -6,7 +6,7 @@
* (HMR safety) against the real SlotsService.
*/
import { Context } from '@deepseek-ai/cordis'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { act, cleanup, fireEvent, render, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
ConversationEventRegistry, ConversationNodeAssembler, SlotsService,
@@ -19,7 +19,9 @@ import type {
import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import { ProducedFiles } from '../src/client/ProducedFiles.tsx'
import {
fitProducedFiles, ProducedFiles, type ProducedFilesProps,
} from '../src/client/ProducedFiles.tsx'
import {
basename, deliverablesDefinition, producedFileMentions, producedForClosing, selectProducedFiles,
type DeliverablesTurnData,
@@ -27,9 +29,20 @@ import {
import { apply, inject } from '../src/client/index.ts'
import { apply as applyNode } from '../src/index.ts'
import { apply as applyInvariant } from '../src/invariant.ts'
import { zh } from '../src/client/locales.ts'
import { en, zh } from '../src/client/locales.ts'
afterEach(cleanup)
const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
afterEach(() => {
cleanup()
vi.restoreAllMocks()
vi.unstubAllGlobals()
if (originalClientWidth === undefined) {
delete (HTMLElement.prototype as { clientWidth?: number }).clientWidth
} else {
Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidth)
}
})
class TestTurnDataStore implements ConversationLocationDataStore<ConversationTurnDataMap> {
private readonly values = new Map<string, unknown>()
@@ -268,22 +281,132 @@ describe('produced-file Turn data', () => {
describe('ProducedFiles row', () => {
const t = makeTranslate(zh)
const capability = (
canOpenPath: boolean | undefined,
isLoopback = true,
): Pick<ProducedFilesProps, 'isLoopback' | 'useHostDescription'> => {
const description = canOpenPath === undefined
? undefined
: { version: 'test', cwd: '/workspace', attachedSessions: 1, canOpenPath }
return {
isLoopback,
useHostDescription: selector => selector(description),
}
}
it('renders capped chips with the full path reachable and opens one on click', () => {
// Seven files: six chips plus an explicit remainder — the row bounds what
// it shows and says so rather than dropping the rest silently.
it('selects the largest prefix using the exact remainder width', () => {
expect(fitProducedFiles(230, 8, [70, 60, 60], [55, 55, 55, 55])).toBe(2)
expect(fitProducedFiles(145, 8, [70, 60, 60], [55, 55, 55, 55])).toBe(1)
expect(fitProducedFiles(300, 8, [70, 60, 60], [55, 55, 55, 55])).toBe(3)
// A zero-width lane is a pre-layout test/hidden state, not evidence that
// every chip overflowed; keep the bounded initial prefix until measured.
expect(fitProducedFiles(0, 8, [70, 60], [60, 50, undefined])).toBe(2)
expect(fitProducedFiles(128, 8, [60, 60], [70, 50, undefined])).toBe(2)
// Candidate-specific suffix widths matter at the 10 -> 9 digit boundary.
expect(fitProducedFiles(126, 8, [60], [70, 50])).toBe(1)
expect(fitProducedFiles(20, 8, [60], [70, 50])).toBe(0)
})
it('keeps one measured line, updates on resize, and opens a file or the workspace folder', () => {
const paths = ['deep/a.html', 'b.css', 'c.ts', 'd.ts', 'e.ts', 'f.ts', 'g.ts']
const openFile = vi.fn<(path: string) => void>()
const view = render(<ProducedFiles matched={paths} openFile={openFile} t={t} />)
let available = 226
let resize: ResizeObserverCallback | undefined
const disconnect = vi.fn()
const observeNode = vi.fn<(target: Element) => void>()
vi.stubGlobal('ResizeObserver', class {
constructor(callback: ResizeObserverCallback) { resize = callback }
observe(target: Element): void {
expect(target).toBeInstanceOf(Element)
observeNode(target)
}
disconnect(): void { disconnect() }
})
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
configurable: true,
get(this: HTMLElement) { return this.hasAttribute('data-produced-files-row') ? available : 0 },
})
const rect = (width: number): DOMRect => ({
x: 0, y: 0, width, height: 22, top: 0, right: width, bottom: 22, left: 0,
toJSON: () => ({}),
})
const bounds = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(function getProbeRect(this: HTMLElement) {
if (this.closest('[aria-hidden="true"]') === null) return rect(0)
if (this.tagName !== 'BUTTON') return rect(60)
return rect(this.textContent === 'a.html' || this.textContent === 'b.css' ? 50 : 100)
})
const view = render(
<ProducedFiles matched={paths} openFile={openFile} {...capability(true)} t={t} />,
)
expect(view.getByText('产物')).toBeTruthy()
// Chips carry the basename; the full path stays reachable as the title.
const row = view.container.querySelector('[data-produced-files-row]')
if (!(row instanceof HTMLElement)) throw new Error('produced row missing')
// The third probe is 100px: two chips plus the remainder fit, three do not.
expect(within(row).getAllByRole('button')).toHaveLength(2)
expect(within(row).getByText('+ 5 个文件')).toBeTruthy()
const chip = view.getByRole('button', { name: '打开 deep/a.html' })
expect(chip.textContent).toBe('a.html')
expect(chip.getAttribute('title')).toBe('deep/a.html')
expect(view.queryByRole('button', { name: '打开 g.ts' })).toBeNull()
expect(view.getByText('还有 1 个')).toBeTruthy()
fireEvent.click(chip)
expect(openFile).toHaveBeenCalledWith('deep/a.html')
const showFolder = view.getByRole('button', { name: '在文件夹中显示' })
fireEvent.click(showFolder)
expect(openFile).toHaveBeenLastCalledWith('.')
available = 150
act(() => { resize?.([], {} as ResizeObserver) })
expect(within(row).getAllByRole('button')).toHaveLength(1)
expect(within(row).getByText('+ 6 个文件')).toBeTruthy()
// A missing/unsupported computed gap falls back to zero rather than NaN.
vi.stubGlobal('getComputedStyle', () => ({ columnGap: '', gap: '' } as CSSStyleDeclaration))
available = 165
act(() => { resize?.([], {} as ResizeObserver) })
expect(within(row).getAllByRole('button')).toHaveLength(2)
// Ref callbacks leave nulls in the probe arrays when the candidate set
// shrinks; the replacement observer must skip those stale slots.
observeNode.mockClear()
view.rerender(
<ProducedFiles matched={paths.slice(0, 1)} openFile={openFile} {...capability(true)} t={t} />,
)
expect(within(row).getAllByRole('button')).toHaveLength(1)
expect(observeNode).toHaveBeenCalledTimes(3)
view.unmount()
expect(disconnect).toHaveBeenCalledTimes(2)
bounds.mockRestore()
})
it('keeps the folder action absent without overflow or a local native opener', () => {
const openFile = vi.fn<(path: string) => void>()
const view = render(
<ProducedFiles matched={['a.md']} openFile={openFile} {...capability(true)} t={t} />,
)
const overflowing = ['a.md', 'b.md', 'c.md', 'd.md', 'e.md', 'f.md', 'g.md']
expect(view.queryByRole('button', { name: '在文件夹中显示' })).toBeNull()
for (const unavailable of [capability(false), capability(true, false), capability(undefined)]) {
view.rerender(<ProducedFiles matched={overflowing} openFile={openFile} {...unavailable} t={t} />)
expect(view.queryByRole('button', { name: '在文件夹中显示' })).toBeNull()
}
})
it('uses singular English copy when exactly one file is hidden', () => {
const view = render(
<ProducedFiles
matched={['a.md', 'b.md', 'c.md', 'd.md', 'e.md', 'f.md', 'g.md']}
openFile={() => {}}
{...capability(false)}
t={makeTranslate(en)}
/>,
)
const row = view.container.querySelector('[data-produced-files-row]')
if (!(row instanceof HTMLElement)) throw new Error('produced row missing')
expect(within(row).getByText('+ 1 file')).toBeTruthy()
})
})
@@ -340,7 +463,12 @@ describe('plugin registration', () => {
name: 'root',
children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } },
} as never, () => null)
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
const hostDescription = { getSnapshot: () => undefined, subscribe: () => () => {} }
ctx.provide('connection', {
api: { settings: {} },
isLoopback: false,
hostDescription,
} as never)
// ui-theme's Appearance row binds a durable scope through these two.
ctx.provide('remote', { $on: () => () => {} } as never)
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
@@ -348,7 +476,9 @@ describe('plugin registration', () => {
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(1)
const [entry] = ctx.slots.entries('conversation.chat.turnTail')
expect(entry).toBeDefined()
expect(entry?.inject?.()).toEqual({ isLoopback: false, hooks: { hostDescription } })
// The prose face is live while the plugin is: a produced turn yields a
// resolver whose matches open through the owner-supplied opener.

View File

@@ -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/host/apiproxy/README.md
README.md: de9bea5ca543d21140332783ee549829d0090f9b
README.zh.md: 9e5539831e4f90f7283275c9b9925614ada7d623
README.md: 541ebdb7a6802286b9b698575136534486387a8c
README.zh.md: 595ef03c24873272fa78ad67289b264458083614

View File

@@ -50,7 +50,7 @@ A stale continuation discards every partial result, deduplication entry, and cur
Directory picking delegates to the composed `ctx.directoryPicker` backend ([the directory-picker seam](../directory-picker/README.md)); a method called outside the composed capability's kind fails with `directory-picker-unavailable` (the client needs no advertisement — the composed picker package's own client half renders the matching interaction). Under `native`, `host.pickDirectory` opens one native chooser and returns its selected path (`null` on cancel); this user-paced method does not use the default 30-second unary timeout, while caller/connection aborts still propagate to the native process. Under `browse`, `host.listDirectory` returns one name-sorted directory level with breadcrumb ancestry, a `home` anchor, and host-owned `hidden` flags (absent path = home directory), and `host.createDirectory` creates one validated child segment; the backend's typed failures map 1:1 onto the `directory-unreadable`/`directory-exists`/`directory-create-failed` codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other `/api` request.
`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, and `xdg-open` on desktop Linux). For `.html`, `.htm`, `.xhtml`, and `.svg`, macOS and desktop Linux prefer a named default browser and fall back to that application handoff when none can be named. WSL translates every Linux path through `wslpath -w` and hands the resulting Windows/UNC path to Windows `Invoke-Item`, including browser-renderable documents, instead of assuming a Linux desktop association. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`.
`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, and `xdg-open` on desktop Linux). For `.html`, `.htm`, `.xhtml`, and `.svg`, macOS and desktop Linux prefer a named default browser and fall back to that application handoff when none can be named. WSL translates every Linux path through `wslpath -w` and hands the resulting Windows/UNC path to Windows `Invoke-Item`, including browser-renderable documents, instead of assuming a Linux desktop association. `host.describe.canOpenPath` advertises whether that handoff can reach a user-visible desktop: explicit gateway `nativeOpen` wins, an injected opener is usable by definition, and platform detection otherwise accepts macOS, Windows, WSL, or Linux with a display while rejecting headless/container Linux. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`; clients combine both facts before presenting a native action.
The `agentPreset.list` domain exposes the deployment's preset roster so a browser can offer a choice when starting a session; each row carries its `trust` (a `user` preset is exactly as privileged as the plugins it names), whether it is the current default, and — when the preset cannot compose a session — a `broken` reason, because a damaged directory still occupies its id and a surface must be able to show and delete it rather than offer it and fail the session start. A deployment composing no presets answers with an empty roster rather than an error, because sharing the host composition is a valid deployment. `agentPreset.select` recomposes one session's agent from a different preset, and is allowed only while the session is blank: once a turn has run, that history was produced under the preset's tools and swapping them would strand logged tool calls, so the attempt answers `agent-preset-locked`. The agent and the session survive — only the composition is swapped, and a failed swap restores the previous one.

View File

@@ -50,7 +50,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
目录选择委托给组合的 `ctx.directoryPicker` 后端([目录选择 seam](../directory-picker/README.md));调用组合能力 kind 之外的方法会以 `directory-picker-unavailable` 失败(客户端不需要广播——组合的选择器包自己的 client half 渲染匹配的交互)。在 `native` 下,`host.pickDirectory` 打开一个原生选择器并返回选中路径(取消为 `null`);该方法需等待用户完成操作,不使用默认的 30 秒一元调用超时,而调用方与连接的中止仍会传播至原生进程。在 `browse` 下,`host.listDirectory` 返回一个按名称排序的目录层级,携带面包屑祖先链、`home` 锚点与宿主判定的 `hidden` 标志(不带路径即家目录),`host.createDirectory` 创建一个经校验的子段;后端的类型化失败 1:1 映射为 `directory-unreadable``directory-exists``directory-create-failed` 错误码。浏览器载体的前缀级信任栅栏dsh-client-connection像覆盖其他所有 `/api` 请求一样覆盖上述全部方法。
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径macOS 为 `open`Windows 为 `Invoke-Item`,桌面 Linux 为 `xdg-open`)。对于 `.html``.htm``.xhtml``.svg`macOS 和桌面 Linux 会优先使用能够确定的默认浏览器无法确定时回退到上述应用交接。WSL 会通过 `wslpath -w` 转换每个 Linux 路径,并将所得 Windows/UNC 路径交给 Windows `Invoke-Item`,浏览器可渲染的文档也不例外,而非假定存在 Linux 桌面文件关联。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径macOS 为 `open`Windows 为 `Invoke-Item`,桌面 Linux 为 `xdg-open`)。对于 `.html``.htm``.xhtml``.svg`macOS 和桌面 Linux 会优先使用能够确定的默认浏览器无法确定时回退到上述应用交接。WSL 会通过 `wslpath -w` 转换每个 Linux 路径,并将所得 Windows/UNC 路径交给 Windows `Invoke-Item`,浏览器可渲染的文档也不例外,而非假定存在 Linux 桌面文件关联。`host.describe.canOpenPath` 会宣告这次交接能否抵达用户可见的桌面:网关显式配置的 `nativeOpen` 优先,注入的 opener 按定义可用,否则平台检测接受 macOS、Windows、WSL 或带 display 的 Linux并拒绝 headless容器 Linux。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制;客户端会组合这两个事实后再呈现原生操作
`agentPreset.list` 领域向浏览器暴露部署的 preset 名单,使其在开启会话时能够提供选择;每一行携带它的 `trust``user` preset 的权限恰好等于它所引用的插件)、它是否为当前默认值,以及——当该 preset 无法组装会话时——一条 `broken` 原因:损坏的目录仍占着它的 id界面必须能展示并删除它而不是把它端出来然后在会话启动时失败。未组装任何 preset 的部署返回空名单而非错误,因为共用宿主组装本身就是一种有效部署。`agentPreset.select` 用另一个 preset 重组某个会话的 agent且仅在会话空白时允许一旦跑过任何轮次那段历史就是在该 preset 的工具下产生的,替换会留下无法执行的已记录 tool call此时返回 `agent-preset-locked`。agent 与会话都不销毁——只替换组装,且替换失败会恢复原来的组装。

View File

@@ -2813,6 +2813,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
provider: selection.provider,
model: selection.model,
attachedSessions: ctx.agents.list().length,
canOpenPath: canOpenPaths(),
}))
},

View File

@@ -17,6 +17,7 @@ export const hostDescribeValueSchema = z.object({
provider: z.string().optional(),
model: z.string().optional(),
attachedSessions: z.number().int().nonnegative(),
canOpenPath: z.boolean(),
}) satisfies z.ZodType<Wire<ResponseValue<'host.describe'>>>
/** host.pickDirectory request payload (empty object literal). */

View File

@@ -41,6 +41,7 @@ export interface HostApi {
* applied when a new agent doesn't specify them explicitly, absent when the host configures
* no explicit default (the adapter falls back internally);
* attachedSessions = count of currently attached sessions (those with a live agent);
* canOpenPath = whether this deployment can hand a path to a user-visible native desktop.
*/
describe(request: RpcRequest<{}>): Promise<RpcResponse<{
version: string
@@ -48,6 +49,7 @@ export interface HostApi {
provider?: string
model?: string
attachedSessions: number
canOpenPath: boolean
}>>
/**

View File

@@ -61,7 +61,10 @@ function stubAgent(session: Session): Agent {
async function harness(
root = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))),
picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null },
extras: { openPath?: (path: string, signal: AbortSignal) => Promise<void> } = {},
extras: {
openPath?: (path: string, signal: AbortSignal) => Promise<void>
canOpenPath?: () => boolean
} = {},
) {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -103,6 +106,7 @@ async function harness(
defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }),
cwd: root,
...extras.openPath === undefined ? {} : { openPath: extras.openPath },
...extras.canOpenPath === undefined ? {} : { canOpenPath: extras.canOpenPath },
})
return { api, ctx, storageDomain, root }
}
@@ -225,6 +229,13 @@ describe('host.listDirectory / host.createDirectory', () => {
})
describe('host.openPath', () => {
it('describes whether this deployment can reach a user-visible native desktop', async () => {
const visible = await harness(undefined, undefined, { canOpenPath: () => true })
const headless = await harness(undefined, undefined, { canOpenPath: () => false })
expect(expectOk(await visible.api.host.describe(request({}))).canOpenPath).toBe(true)
expect(expectOk(await headless.api.host.describe(request({}))).canOpenPath).toBe(false)
})
it('opens through the injected native boundary', async () => {
const opened: string[] = []
const { api } = await harness(undefined, undefined, {

View File

@@ -72,7 +72,9 @@ function scriptedApi(overrides: {
...overrides.subagents,
},
host: {
describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }),
describe: r => ok(r, {
version: '0-test', cwd: '/t', attachedSessions: 0, canOpenPath: true,
}),
pickDirectory: r => ok(r, { path: null }),
listDirectory: r => ok(r, { path: '/t', home: '/t', crumbs: [], entries: [], truncated: false }),
createDirectory: r => ok(r, { path: '/t/new' }),

View File

@@ -140,7 +140,13 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
},
host: {
async describe(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0 } } }
return {
rpcId: request.rpcId,
result: {
ok: true,
value: { version: 'v', cwd: '/w', attachedSessions: 0, canOpenPath: true },
},
}
},
async pickDirectory(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { path: null } } }

View File

@@ -308,10 +308,15 @@ describe('host domain schemas', () => {
it('validates describe request/value', () => {
expect(hostDescribeRequestSchema.parse({})).toEqual({})
const value = hostDescribeValueSchema.parse({
version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2,
version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2, canOpenPath: true,
})
expect(value).toMatchObject({ provider: 'p', model: 'm', attachedSessions: 2 })
expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0 }).provider).toBeUndefined()
expect(value).toMatchObject({ provider: 'p', model: 'm', attachedSessions: 2, canOpenPath: true })
expect(hostDescribeValueSchema.parse({
version: '1', cwd: '/x', attachedSessions: 0, canOpenPath: false,
}).provider).toBeUndefined()
expect(() => hostDescribeValueSchema.parse({
version: '1', cwd: '/x', attachedSessions: 0,
})).toThrow()
})
it('validates the browse listing/creation payloads', () => {

6
pnpm-lock.yaml generated
View File

@@ -168,6 +168,9 @@ importers:
'@deepseek-ai/dsh-environment':
specifier: workspace:^
version: link:../../packages/util/environment
'@deepseek-ai/dsh-fs-local':
specifier: workspace:^
version: link:../../packages/fs/fs-local
'@deepseek-ai/dsh-goal':
specifier: workspace:^
version: link:../../packages/goal/goal
@@ -2168,6 +2171,9 @@ importers:
'@deepseek-ai/cordis':
specifier: workspace:^
version: link:../../../vendor/cordis
'@deepseek-ai/dsh-client-connection':
specifier: workspace:^
version: link:../connection
'@deepseek-ai/dsh-client-locale':
specifier: workspace:^
version: link:../locale

View File

@@ -15,6 +15,8 @@ from deepseek_harness.errors import TransportClosedError
from deepseek_harness_runtime import resolve_bundled_launch_args
_MODES = ("exe", "node")
_REPO_ROOT = Path(__file__).parents[3]
_MINIMAL_CONFIG = _REPO_ROOT / "examples" / "jsonrpc-agent" / "minimal.cordis.yml"
# The config must include the JSON-RPC serving plugin.
_CORDIS_YML = """\
@@ -80,6 +82,30 @@ def test_bundled_runtime_boots_a_cordis_config(tmp_path: Path, mode: str) -> Non
assert init.serverInfo.name == "deepseek-harness-sdk-runtime"
@pytest.mark.parametrize("mode", _MODES)
def test_python_sdk_boots_minimal_jsonrpc_config(tmp_path: Path, mode: str) -> None:
launch_args = _launch_args(mode)
model = "minimal-environment-model"
harness = DeepSeekHarness(
model=model,
cwd=str(tmp_path),
session_root=str(tmp_path / "sessions"),
cordis=str(_MINIMAL_CONFIG),
env={
"DSH_MODEL": model,
"DSH_CONTEXT_WINDOW": "1000000",
"DSH_SYSTEM_PROMPT": "You are the Python SDK minimal boot test agent.",
},
api_key="sk-dummy-for-boot",
base_url="http://127.0.0.1:9",
launch_args_override=launch_args,
request_timeout_seconds=120,
)
with harness:
pass
@pytest.mark.parametrize("mode", _MODES)
def test_bundled_runtime_surfaces_unbundled_plugin_failure(tmp_path: Path, mode: str) -> None:
launch_args = _launch_args(mode)