mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
feat(web): make produced-file overflow discoverable
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .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: 8abdeba18ff7abe056b3f618b016f3c73e6b4ecb
|
||||
2026-07-31-web-workspace-file-links.zh.md: 40e1094034c671fba6254fd71c9b29e70620cb6c
|
||||
|
||||
@@ -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 file lane stays on one line and measures the exact chip and localized remainder widths with `ResizeObserver`; it renders the largest fitting first-seen prefix, bounded at 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. `host.describe.canOpenPath` reports the configured or best-effort detected native-desktop capability, and the connection publishes that fact per successful generation. When the lane hides files, **Show in folder** opens the session workspace by passing `.` through the same owner `openFile`; it appears only when the page is loopback and the current description permits native opening, so direct remote Web and headless/container Linux Hosts omit the action by default. Deployments where platform detection cannot identify the operator-visible desktop — including SSH tunnels, headless macOS/Windows Hosts, and WSL without working Windows interop — must set gateway `nativeOpen: false`.
|
||||
|
||||
**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 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 ten successful writes and pins two leading chips, the exact `+ 8 files` remainder, one-line geometry, and the capability-present folder action 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 still sees the file chips but sees no folder action; clicking a chip cannot open anything because `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. A local SSH port forward can make a remote Host look loopback-local, so such deployments must set `nativeOpen: false`; the same override is required whenever the permissive macOS/Windows/WSL probe or a stale Linux display/opener signal would be a false positive. A future operator-visible capability can replace that deployment declaration. 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.
|
||||
|
||||
@@ -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 始终只有一行,并用 `ResizeObserver` 测量真实 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 交接。`host.describe.canOpenPath` 会报告显式配置或尽力探测得到的原生桌面能力,connection 在每次成功 generation 中发布这一事实。文件 lane 隐藏了条目时,**在文件夹中显示**会把 `.` 经由同一个 owner `openFile` 打开为会话 workspace;它只在页面使用 loopback 且当前 description 允许原生打开时出现,因此直接远程 Web 与 headless/容器 Linux Host 默认会省略该操作。若平台探测无法识别操作者实际可见的桌面——包括 SSH 隧道、无界面的 macOS/Windows Host,以及 Windows interop 不可用的 WSL——部署必须为网关设置 `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 通道(`apps/web/tests/produced-files.e2e.ts`)冷播种十次成功 write,在组装后的应用中钉住两个开头 chip、精确的 `+ 8 files` 剩余计数、单行几何与能力存在时的文件夹操作;它刻意不点击,因为点击会把路径交给 Host 打开器,从而在跑测试的机器上启动一个真实应用。产出的文件以 `file://` 文档打开,它无法 `fetch` 自己的同级文件(一个加载 `./data.json` 的多文件产物会坏,而 `<script src>`、`<img>` 与 CSS `@import` 不受影响)——这是 HTTP 提供曾有、而此处没有的那一项能力。经网络访问的客户端仍能看到文件 chip,但看不到文件夹操作;点击 chip 无法打开任何东西,因为 `host.openPath` 在 Host 上运行,且被 `/api` 信任 fence 钉在回环。那是范围决定的显现,不是缺陷,也正因如此该行把完整路径保留在 `title` 中,供只能复制它的读者使用。本地 SSH 端口转发会让远端 Host 看似处于本机 loopback,因此这类部署必须设置 `nativeOpen: false`;只要宽松的 macOS/Windows/WSL 探测或过期的 Linux display/opener 信号会造成误报,也同样需要这一覆盖。未来可由一项 operator-visible 能力取代这份部署声明。markdown 会由平台交给 `.md` 的默认处理程序打开,通常是编辑器而非渲染器;在产品内渲染它是另一个被推迟的交互面。
|
||||
|
||||
@@ -1,28 +1,108 @@
|
||||
// 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'
|
||||
// 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.
|
||||
// Clicking is not driven: it would launch a real native application.
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
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 { 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'
|
||||
import { connectFreshWorkspace, 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,19 +111,25 @@ 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 Promise.all(PRODUCED.map(path => writeFile(join(scaffold.workspaceCwd, path), path)))
|
||||
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 })
|
||||
// Register the seeded cwd through the same first-run workspace flow a user
|
||||
// sees; once adopted, the cold session becomes addressable in the tree.
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd, '.')
|
||||
const workspace = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd)
|
||||
if (workspace === undefined) throw new Error('produced-files workspace was not registered')
|
||||
await workspace.attachSession(SessionId(SEED_ID))
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -51,24 +137,36 @@ 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()
|
||||
// The attached cold seed is first; the workspace flow's fresh blank
|
||||
// session follows it. Session title projection may use the cwd fallback.
|
||||
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)
|
||||
expect(await page.getByRole('button', { name: 'Show in folder', exact: true }).count()).toBe(1)
|
||||
expect(await page.getByText('Produced', { exact: true }).count()).toBe(1)
|
||||
|
||||
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([])
|
||||
|
||||
6
apps/web/tests/produced-files.overlay.yml
Normal file
6
apps/web/tests/produced-files.overlay.yml
Normal 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
|
||||
@@ -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: 2e7fdcc2028cf17d88f986efef3fb580839be18b
|
||||
module-graph.zh.md: 71bf4b361d8b639d54b91e6264f21f19df6fce13
|
||||
module-graph.md: 3317cf9eeca527f531f6203a44af02eb898a896a
|
||||
module-graph.zh.md: ed2b665c168bb4b93c76fca9b52e77f27da3306f
|
||||
|
||||
@@ -1133,6 +1133,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
|
||||
@@ -1450,7 +1451,7 @@ flowchart TD
|
||||
| [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
|
||||
| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `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-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` | [`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-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), [`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) |
|
||||
| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`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), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) |
|
||||
|
||||
@@ -1135,6 +1135,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
|
||||
@@ -1452,7 +1453,7 @@ flowchart TD
|
||||
| [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
|
||||
| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `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-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` | [`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-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), [`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) |
|
||||
| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`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), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议约定类型、`AbstractApiClient` 抽象,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `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 发送 unary/respond,并为 `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 浏览器信任栅栏
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -87,10 +87,68 @@ 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()
|
||||
const descriptions: Array<boolean | undefined> = []
|
||||
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.
|
||||
const loop = handle.start({})
|
||||
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])
|
||||
stopDescription()
|
||||
})
|
||||
|
||||
it('isolates description subscribers so later listeners and the consumer sink still run', async () => {
|
||||
;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
|
||||
const handle = await mount()
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const seen: boolean[] = []
|
||||
const stopThrowing = handle.hostDescription.subscribe(() => { throw new Error('subscriber bug') })
|
||||
const stopRecording = handle.hostDescription.subscribe(() => {
|
||||
const value = handle.hostDescription.getSnapshot()?.canOpenPath
|
||||
if (value !== undefined) seen.push(value)
|
||||
})
|
||||
let connected = 0
|
||||
const loop = handle.start({ onConnected: () => { connected++ } })
|
||||
try {
|
||||
await vi.waitFor(() => { expect(connected).toBe(1) })
|
||||
expect(seen).toEqual([true])
|
||||
expect(errorSpy).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
stopThrowing()
|
||||
stopRecording()
|
||||
loop.stop()
|
||||
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('WebApiClient keeps unary calls and respond on globalThis.fetch', async () => {
|
||||
|
||||
@@ -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)
|
||||
@@ -210,6 +215,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 +251,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 {
|
||||
|
||||
@@ -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 }>> =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }>> =
|
||||
|
||||
@@ -24,6 +24,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')),
|
||||
},
|
||||
@@ -83,8 +87,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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`;无界面的 macOS/Windows Host、Windows interop 不可用的 WSL,或 display/opener 探测误报的 Linux 桌面也必须这样配置。识别操作者实际可见的桌面仍属于部署策略。
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -4,46 +4,146 @@
|
||||
// through the same openFile the tool rows use — the Host's own opener, on the
|
||||
// Host machine.
|
||||
|
||||
import { useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { 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
|
||||
|
||||
/**
|
||||
* 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]
|
||||
for (const width of chipWidths) prefix.push((prefix.at(-1) ?? 0) + width)
|
||||
for (let shown = chipWidths.length; shown >= 0; shown -= 1) {
|
||||
const more = moreWidthsByShown[shown]
|
||||
const items = shown + (more === undefined ? 0 : 1)
|
||||
const needed = (prefix[shown] ?? 0) + (more ?? 0) + Math.max(0, items - 1) * gap
|
||||
if (needed <= available) return shown
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/** Matched paths plus the opener and locale seats needed to present them. */
|
||||
export type ProducedFilesProps = Pick<TurnTailOwnerProps, 'openFile'> & {
|
||||
matched: readonly string[]
|
||||
/** True only when this loopback deployment exposes a user-visible native opener. */
|
||||
canOpenPath: boolean
|
||||
} & PropsLocale<typeof NS>
|
||||
|
||||
/** Slot-owned props before the connection capability is injected. */
|
||||
export type ProducedFilesSeatProps = Omit<ProducedFilesProps, 'canOpenPath'>
|
||||
|
||||
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, canOpenPath, t }: ProducedFilesProps) {
|
||||
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 moreProbes = useRef<Array<HTMLSpanElement | null>>([])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const row = rowRef.current
|
||||
if (row === null) return
|
||||
const measure = (): void => {
|
||||
const styles = getComputedStyle(row)
|
||||
const gap = Number.parseFloat(styles.columnGap || styles.gap) || 0
|
||||
const chips = chipProbes.current.slice(0, limit)
|
||||
.map(probe => probe?.getBoundingClientRect().width ?? 0)
|
||||
const more = Array.from({ length: limit + 1 }, (_, candidate) =>
|
||||
paths.length === candidate
|
||||
? undefined
|
||||
: moreProbes.current[candidate]?.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, ...moreProbes.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>
|
||||
))}
|
||||
{Array.from({ length: limit + 1 }, (_, candidate) => {
|
||||
const remaining = paths.length - candidate
|
||||
if (remaining === 0) return null
|
||||
return (
|
||||
<span
|
||||
key={candidate}
|
||||
ref={(node) => { moreProbes.current[candidate] = node }}
|
||||
className={`${css.more} ${css.probe}`}
|
||||
>
|
||||
{moreLabel(t, remaining)}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,10 +7,14 @@
|
||||
* 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 { createElement, useSyncExternalStore } from 'react'
|
||||
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'
|
||||
import { ProducedFiles } from './ProducedFiles.tsx'
|
||||
import {
|
||||
ProducedFiles, type ProducedFilesSeatProps,
|
||||
} from './ProducedFiles.tsx'
|
||||
import { en, NS, zh, type DeliverablesKey } from './locales.ts'
|
||||
import {
|
||||
deliverablesDefinition, producedFileMentions, selectProducedFiles,
|
||||
@@ -27,13 +31,24 @@ 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
|
||||
const ProducedFilesSeat = (props: ProducedFilesSeatProps): ReturnType<typeof createElement> => {
|
||||
const description = useSyncExternalStore(
|
||||
listener => connection.hostDescription.subscribe(listener),
|
||||
() => connection.hostDescription.getSnapshot(),
|
||||
)
|
||||
return createElement(ProducedFiles, {
|
||||
...props,
|
||||
canOpenPath: connection.isLoopback && description?.canOpenPath === true,
|
||||
})
|
||||
}
|
||||
ctx.conversationEvents.register(deliverablesDefinition)
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries')
|
||||
ctx.slots.inject(
|
||||
@@ -42,7 +57,7 @@ export function apply(ctx: ClientContext): void {
|
||||
name: 'conversation.chat.turnTail',
|
||||
select: selectProducedFiles,
|
||||
locale: NS,
|
||||
}, ProducedFiles),
|
||||
}, ProducedFilesSeat),
|
||||
)
|
||||
// The prose side of the same vocabulary: the chat view reaches this face
|
||||
// via ctx.get, so its absence — this plugin composed out — is the off state.
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* (HMR safety) against the real SlotsService.
|
||||
*/
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import type { ComponentType } from '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 +20,7 @@ import type {
|
||||
import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { ProducedFiles } from '../src/client/ProducedFiles.tsx'
|
||||
import { fitProducedFiles, ProducedFiles, type ProducedFilesSeatProps } from '../src/client/ProducedFiles.tsx'
|
||||
import {
|
||||
basename, deliverablesDefinition, producedFileMentions, producedForClosing, selectProducedFiles,
|
||||
type DeliverablesTurnData,
|
||||
@@ -27,9 +28,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>()
|
||||
@@ -269,21 +281,104 @@ describe('produced-file Turn data', () => {
|
||||
describe('ProducedFiles row', () => {
|
||||
const t = makeTranslate(zh)
|
||||
|
||||
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)
|
||||
// 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()
|
||||
vi.stubGlobal('ResizeObserver', class {
|
||||
constructor(callback: ResizeObserverCallback) { resize = callback }
|
||||
observe(): void {}
|
||||
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} canOpenPath 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()
|
||||
|
||||
view.unmount()
|
||||
expect(disconnect).toHaveBeenCalledOnce()
|
||||
bounds.mockRestore()
|
||||
})
|
||||
|
||||
it('keeps the folder action absent without overflow or a local native opener', () => {
|
||||
const openFile = vi.fn<(path: string) => void>()
|
||||
const noOverflow = render(
|
||||
<ProducedFiles matched={['a.md']} openFile={openFile} canOpenPath t={t} />,
|
||||
)
|
||||
expect(noOverflow.queryByRole('button', { name: '在文件夹中显示' })).toBeNull()
|
||||
noOverflow.unmount()
|
||||
const headless = render(
|
||||
<ProducedFiles
|
||||
matched={['a.md', 'b.md', 'c.md', 'd.md', 'e.md', 'f.md', 'g.md']}
|
||||
openFile={openFile}
|
||||
canOpenPath={false}
|
||||
t={t}
|
||||
/>,
|
||||
)
|
||||
expect(headless.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={() => {}}
|
||||
canOpenPath={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,13 +435,56 @@ 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 = (canOpenPath: boolean) => ({
|
||||
version: 'test', cwd: '/workspace', attachedSessions: 1, canOpenPath,
|
||||
})
|
||||
let description: ReturnType<typeof hostDescription> | undefined = hostDescription(true)
|
||||
const descriptionListeners = new Set<() => void>()
|
||||
const connection = {
|
||||
api: { settings: {} },
|
||||
isLoopback: false,
|
||||
hostDescription: {
|
||||
getSnapshot: () => description,
|
||||
subscribe: (listener: () => void) => {
|
||||
descriptionListeners.add(listener)
|
||||
return () => { descriptionListeners.delete(listener) }
|
||||
},
|
||||
},
|
||||
}
|
||||
ctx.provide('connection', connection as never)
|
||||
await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await()
|
||||
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(1)
|
||||
|
||||
// The native action needs both independent facts. A Host capability does
|
||||
// not authorize a remote page; loopback without a capable Host does not
|
||||
// render a dead action; reconnect/disconnect retracts the capability.
|
||||
const Entry = ctx.slots.entries('conversation.chat.turnTail')[0]!.component as ComponentType<ProducedFilesSeatProps>
|
||||
const entryProps: ProducedFilesSeatProps = {
|
||||
matched: ['a.md', 'b.md', 'c.md', 'd.md', 'e.md', 'f.md', 'g.md'],
|
||||
openFile: () => {},
|
||||
t: makeTranslate(zh),
|
||||
}
|
||||
const surface = render(<Entry {...entryProps} />)
|
||||
expect(surface.queryByRole('button', { name: '在文件夹中显示' })).toBeNull()
|
||||
connection.isLoopback = true
|
||||
description = hostDescription(false)
|
||||
surface.rerender(<Entry {...entryProps} />)
|
||||
expect(surface.queryByRole('button', { name: '在文件夹中显示' })).toBeNull()
|
||||
act(() => {
|
||||
description = hostDescription(true)
|
||||
for (const listener of descriptionListeners) listener()
|
||||
})
|
||||
expect(surface.getByRole('button', { name: '在文件夹中显示' })).toBeTruthy()
|
||||
act(() => {
|
||||
description = undefined
|
||||
for (const listener of descriptionListeners) listener()
|
||||
})
|
||||
expect(surface.queryByRole('button', { name: '在文件夹中显示' })).toBeNull()
|
||||
surface.unmount()
|
||||
|
||||
// The prose face is live while the plugin is: a produced turn yields a
|
||||
// resolver whose matches open through the owner-supplied opener.
|
||||
const opened: string[] = []
|
||||
|
||||
@@ -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: 2a27ab9f2cfcd4f12c32b815583b13f439b3eaac
|
||||
README.zh.md: ba49ccff82bab1e677bad1495e48b8503564ae1d
|
||||
README.md: 0cfebdd1efcd410c9413bcb4c9533135423bb019
|
||||
README.zh.md: f14007e9038cf633406b914018f6cb9b344506b9
|
||||
|
||||
@@ -46,7 +46,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.
|
||||
|
||||
|
||||
@@ -46,7 +46,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 与会话都不销毁——只替换组装,且替换失败会恢复原来的组装。
|
||||
|
||||
|
||||
@@ -2669,6 +2669,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
provider: selection.provider,
|
||||
model: selection.model,
|
||||
attachedSessions: ctx.agents.list().length,
|
||||
canOpenPath: canOpenPaths(),
|
||||
}))
|
||||
},
|
||||
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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
|
||||
}>>
|
||||
|
||||
/**
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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 } } }
|
||||
|
||||
@@ -279,10 +279,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', () => {
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -2068,6 +2068,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
|
||||
|
||||
Reference in New Issue
Block a user