fix(api): preserve native path opening behavior

This commit is contained in:
imccyu
2026-08-27 21:57:58 +08:00
parent 72cf4fae83
commit 18ae39a665
31 changed files with 85 additions and 150 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.md
2026-08-10-unary-apiproxy-remote-migration.md: 027376cbf772043cce21f487c94288cad6bece1c 2026-08-10-unary-apiproxy-remote-migration.md: 11254099556113d921da502f0150522886a718f3
2026-08-10-unary-apiproxy-remote-migration.zh.md: f7507959a992dd17ca60883ada7769c7196fdc3a 2026-08-10-unary-apiproxy-remote-migration.zh.md: 50b303876863e992566f6ed6fb0bd0a89326344f
@@ -8,7 +8,7 @@ English | [中文](2026-08-10-unary-apiproxy-remote-migration.zh.md)
The Host API Proxy duplicated simple unary operations across business Services, API Proxy interfaces, Zod schemas, route tables, client stubs, and Client callers. [Typert Remote calls](2026-08-02-typert-remote-method-calls.md) already let a business package own this class of call, but moving an endpoint without its lifecycle and projection policy could change observable behavior. The Host API Proxy duplicated simple unary operations across business Services, API Proxy interfaces, Zod schemas, route tables, client stubs, and Client callers. [Typert Remote calls](2026-08-02-typert-remote-method-calls.md) already let a business package own this class of call, but moving an endpoint without its lifecycle and projection policy could change observable behavior.
Agent-bound calls require particular care. Shared lookup policy reuses live Agents, resumes ordinary cold Sessions with their recorded presets, deduplicates concurrent resumes, and rejects subagent-owned identities. Skill listing instead must inspect a Session without activating its Agent. Native desktop operations must keep the browser from choosing an arbitrary Host path. Agent-bound calls require particular care. Shared lookup policy reuses live Agents, resumes ordinary cold Sessions with their recorded presets, deduplicates concurrent resumes, and rejects subagent-owned identities. Skill listing instead must inspect a Session without activating its Agent. Settings and preset operations keep their Host-owned document paths out of browser requests; Session file links preserve their caller-resolved path behavior.
## Decision ## Decision
@@ -30,11 +30,11 @@ Simple unary operations live on their natural business Remote owner. The busines
| `workspace.list`, `workspace.insertSessionBefore`, `workspace.archiveSession` | Equivalent `workspace/*` methods | The Workspace registry owns detached snapshots and serialized mutations. | | `workspace.list`, `workspace.insertSessionBefore`, `workspace.archiveSession` | Equivalent `workspace/*` methods | The Workspace registry owns detached snapshots and serialized mutations. |
| `skill.list` | `skills/list` | `SessionSkillCatalog` observes the Session and its recorded preset, uses a live Agent only when one already exists, and never activates an Agent for listing. | | `skill.list` | `skills/list` | `SessionSkillCatalog` observes the Session and its recorded preset, uses a live Agent only when one already exists, and never activates an Agent for listing. |
| `fileReferences/list` | `fileReferences/list` | `SessionFileReferences` supplies the Session Controller's established Agent lookup to the provider; cold lookup behavior remains unchanged. | | `fileReferences/list` | `fileReferences/list` | `SessionFileReferences` supplies the Session Controller's established Agent lookup to the provider; cold lookup behavior remains unchanged. |
| `host.openPath` | `session/openWorkspacePath` | `SessionController` resolves the path against the addressed Session's workspace before native opening. | | `host.openPath` | `session/openWorkspacePath` | The Session-aware Client resolves relative paths against the known workspace before `SessionController` hands them to the native opener. |
The shared Agent and Session resolver remains the authority for endpoints that accept those objects. It provides the same live reuse, cold restoration, concurrent deduplication, preset setup, persistence failures, and subagent ownership fence that legacy API Proxy calls used. `TypertLookupFailure` preserves resolver-owned RPC errors instead of collapsing them into `internal`. The shared Agent and Session resolver remains the authority for endpoints that accept those objects. It provides the same live reuse, cold restoration, concurrent deduplication, preset setup, persistence failures, and subagent ownership fence that legacy API Proxy calls used. `TypertLookupFailure` preserves resolver-owned RPC errors instead of collapsing them into `internal`.
The native path implementation lives in `@deepseek-ai/dsh-native-command`. Session and Settings controllers select the target; the utility only performs platform detection, WSL translation, browser preference, text-editor intent, and shell-free command execution. The native path implementation lives in `@deepseek-ai/dsh-native-command`. Settings controllers select Host-owned targets, while Session-aware Clients resolve workspace paths before calling `SessionController`; the utility only performs platform detection, WSL translation, browser preference, text-editor intent, and shell-free command execution.
## Browser authentication ## Browser authentication
@@ -50,7 +50,7 @@ Focused Host and Client tests cover Remote calls, lookup and no-activation polic
**Move every unary operation.** Rejected because `host.describe` combines deployment facts and Connection readiness, while Session export is a streamed download rather than a unary business method. **Move every unary operation.** Rejected because `host.describe` combines deployment facts and Connection readiness, while Session export is a streamed download rather than a unary business method.
**Put native opening in one controller.** Rejected because Session, Settings, and the retained Host description consume the same platform operation. A Host utility avoids controller-to-controller imports without making the browser authoritative for filesystem targets. **Put native opening in one controller.** Rejected because Session, Settings, and the retained Host description consume the same platform operation. A Host utility avoids controller-to-controller imports and duplicated platform logic.
## Consequences ## Consequences
@@ -8,7 +8,7 @@ Status: implemented
Host API Proxy 曾在业务 Service、API Proxy interface、Zod schema、路由表、Client stub 与 Client 调用方之间重复定义简单一元操作。[Typert Remote 调用](2026-08-02-typert-remote-method-calls.zh.md)已经允许业务包持有这类调用,但如果迁移 endpoint 时没有一并保留生命周期与投影策略,就会改变可观察行为。 Host API Proxy 曾在业务 Service、API Proxy interface、Zod schema、路由表、Client stub 与 Client 调用方之间重复定义简单一元操作。[Typert Remote 调用](2026-08-02-typert-remote-method-calls.zh.md)已经允许业务包持有这类调用,但如果迁移 endpoint 时没有一并保留生命周期与投影策略,就会改变可观察行为。
与 Agent 绑定的调用需要格外谨慎。共享 lookup 策略会复用 live Agent、用记录的 preset 恢复普通冷 Session、对并发恢复去重,并拒绝由 subagent 持有的 identity。skill 列表则必须检查 Session 而不激活 Agent。原生桌面操作必须避免让浏览器选择任意 Host 路径 与 Agent 绑定的调用需要格外谨慎。共享 lookup 策略会复用 live Agent、用记录的 preset 恢复普通冷 Session、对并发恢复去重,并拒绝由 subagent 持有的 identity。skill 列表则必须检查 Session 而不激活 Agent。Settings 与 preset 操作不会把 Host 持有的文档路径放进浏览器请求;Session 文件链接保留由调用方解析路径的行为
## 决策 ## 决策
@@ -30,11 +30,11 @@ Host API Proxy 曾在业务 Service、API Proxy interface、Zod schema、路由
| `workspace.list``workspace.insertSessionBefore``workspace.archiveSession` | 对应的 `workspace/*` 方法 | Workspace registry 持有脱离可变对象的 snapshot 与串行 mutation。 | | `workspace.list``workspace.insertSessionBefore``workspace.archiveSession` | 对应的 `workspace/*` 方法 | Workspace registry 持有脱离可变对象的 snapshot 与串行 mutation。 |
| `skill.list` | `skills/list` | `SessionSkillCatalog` 观察 Session 及其记录的 preset,仅在 live Agent 已存在时使用它,列表查询绝不激活 Agent。 | | `skill.list` | `skills/list` | `SessionSkillCatalog` 观察 Session 及其记录的 preset,仅在 live Agent 已存在时使用它,列表查询绝不激活 Agent。 |
| `fileReferences/list` | `fileReferences/list` | `SessionFileReferences` 向 provider 提供 Session Controller 的既有 Agent lookup;冷 lookup 行为保持不变。 | | `fileReferences/list` | `fileReferences/list` | `SessionFileReferences` 向 provider 提供 Session Controller 的既有 Agent lookup;冷 lookup 行为保持不变。 |
| `host.openPath` | `session/openWorkspacePath` | `SessionController` 先基于目标 Session 的 workspace 解析路径,再执行原生打开。 | | `host.openPath` | `session/openWorkspacePath` | Session-aware Client 先基于已知 workspace 解析相对路径,再`SessionController` 交给原生打开。 |
共享 Agent 与 Session resolver 仍是接收这些对象的 endpoint 的权威。它提供与旧 API Proxy 调用相同的 live 复用、冷恢复、并发去重、preset setup、持久化失败与 subagent ownership fence。`TypertLookupFailure` 保留 resolver 持有的 RPC error,而不把它们归并为 `internal` 共享 Agent 与 Session resolver 仍是接收这些对象的 endpoint 的权威。它提供与旧 API Proxy 调用相同的 live 复用、冷恢复、并发去重、preset setup、持久化失败与 subagent ownership fence。`TypertLookupFailure` 保留 resolver 持有的 RPC error,而不把它们归并为 `internal`
原生路径实现在 `@deepseek-ai/dsh-native-command` 中。Session 与 Settings controller 选择目标;该工具仅负责平台探测、WSL 转换、浏览器偏好、文本编辑器意图与无 shell 命令执行。 原生路径实现在 `@deepseek-ai/dsh-native-command` 中。Settings controller 选择 Host 持有的目标,Session-aware Client 则在调用 `SessionController` 前解析 workspace 路径;该工具仅负责平台探测、WSL 转换、浏览器偏好、文本编辑器意图与无 shell 命令执行。
## 浏览器认证 ## 浏览器认证
@@ -50,7 +50,7 @@ Connection 在选择 Typert interceptor 或 API Proxy fallback 前认证完整
**迁移每一个一元操作。** 否决,因为 `host.describe` 组合部署事实与 Connection readiness,而 Session export 是流式下载,不是一元业务方法。 **迁移每一个一元操作。** 否决,因为 `host.describe` 组合部署事实与 Connection readiness,而 Session export 是流式下载,不是一元业务方法。
**把原生打开操作放入某个 controller。** 否决,因为 Session、Settings 与保留的 Host 描述都会消费同一平台操作。Host 工具可以避免 controller 间导入,同时不让浏览器成为文件系统目标的权威 **把原生打开操作放入某个 controller。** 否决,因为 Session、Settings 与保留的 Host 描述都会消费同一平台操作。Host 工具可以避免 controller 间导入与重复的平台逻辑
## 后果 ## 后果
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md
2026-07-28-tool-call-file-open-in-os.md: c8ede5c5c2fbdc9797edd9bf80673442c39873c2 2026-07-28-tool-call-file-open-in-os.md: a8d5bd116b3f4cf1434d44b643dad3d883763a82
2026-07-28-tool-call-file-open-in-os.zh.md: 1e51dd4dced25bd14272358199baef82f396635a 2026-07-28-tool-call-file-open-in-os.zh.md: b486ec0972356419c2df5abbc148dc57a4586920
@@ -10,7 +10,7 @@ Chat tool rows treated the whole summary line as a click target that opened the
## Decision ## Decision
File-tool path summaries (`read` / `write` / `edit` args carrying `path` or `file_path`) render as links underlined at rest with a pointer cursor. Clicking the path calls `session/openWorkspacePath` through the chat view's `openFile` injection; the Host resolves relative paths against the addressed Session's cwd. File-link rows disable args expand (leading icon is inert); whole-row click, row hover fill, and the click-to-open-details gesture are removed from tool rows (including bash and todo registrations). The details panel and its inject surface remain for programmatic selection; rows no longer drive them. File-tool path summaries (`read` / `write` / `edit` args carrying `path` or `file_path`) render as links underlined at rest with a pointer cursor. Clicking the path calls `session/openWorkspacePath` through the chat view's `openFile` injection; the chat view resolves relative paths against the addressed Session's cwd when it is known. File-link rows disable args expand (leading icon is inert); whole-row click, row hover fill, and the click-to-open-details gesture are removed from tool rows (including bash and todo registrations). The details panel and its inject surface remain for programmatic selection; rows no longer drive them.
`session/openWorkspacePath` uses the authenticated Remote carrier, while the product UI offers the gesture only on a loopback page whose `host.describe.canOpenPath` is true. Platform adapters open without a shell: `open` on macOS, PowerShell `Invoke-Item` on Windows, and `xdg-open` on desktop Linux; browser-renderable documents prefer the named default browser on macOS and desktop Linux. WSL is a separate host shape despite Node reporting `linux`: the adapter recognizes its environment or Microsoft kernel release, translates the Linux path with `wslpath -w`, and passes the resulting Windows/UNC path to the same PowerShell handoff. The opener's platform facts and command runner are injectable for tests. URL-only read args (`web_fetch`) are not file links. `session/openWorkspacePath` uses the authenticated Remote carrier, while the product UI offers the gesture only on a loopback page whose `host.describe.canOpenPath` is true. Platform adapters open without a shell: `open` on macOS, PowerShell `Invoke-Item` on Windows, and `xdg-open` on desktop Linux; browser-renderable documents prefer the named default browser on macOS and desktop Linux. WSL is a separate host shape despite Node reporting `linux`: the adapter recognizes its environment or Microsoft kernel release, translates the Linux path with `wslpath -w`, and passes the resulting Windows/UNC path to the same PowerShell handoff. The opener's platform facts and command runner are injectable for tests. URL-only read args (`web_fetch`) are not file links.
@@ -10,7 +10,7 @@ Status: implemented
## 决策 ## 决策
文件工具的路径摘要(`read``write``edit` 参数中的 `path``file_path`)渲染为静止状态下即带下划线的链接,并使用 pointer 光标。点击路径会经聊天视图的 `openFile` injection 调用 `session/openWorkspacePath`Host 以目标 Session 的 cwd 为基准解析相对路径。带文件链接的行关闭参数展开(左侧图标不可点);工具行(含 bash 与 todo 注册)去掉整行点击、整行悬停底色,以及点击打开 details 的手势。details 面板及其 inject 面仍保留供程序化选择;工具行不再驱动它们。 文件工具的路径摘要(`read``write``edit` 参数中的 `path``file_path`)渲染为静止状态下即带下划线的链接,并使用 pointer 光标。点击路径会经聊天视图的 `openFile` injection 调用 `session/openWorkspacePath`聊天视图会在目标 Session 的 cwd 已知时据此解析相对路径。带文件链接的行关闭参数展开(左侧图标不可点);工具行(含 bash 与 todo 注册)去掉整行点击、整行悬停底色,以及点击打开 details 的手势。details 面板及其 inject 面仍保留供程序化选择;工具行不再驱动它们。
`session/openWorkspacePath` 使用经过认证的 Remote carrier,而产品 UI 只在 loopback 页面且 `host.describe.canOpenPath` 为 true 时提供该手势。平台适配器不经 shell 打开:macOS 为 `open`Windows 为 PowerShell `Invoke-Item`,桌面 Linux 为 `xdg-open`;浏览器可渲染的文档会在 macOS 与桌面 Linux 上优先使用指定的默认浏览器。尽管 Node 将 WSL 报告为 `linux`,WSL 仍是一种独立的宿主形态:适配器根据其环境或 Microsoft 内核 release 识别它,用 `wslpath -w` 转换 Linux 路径,并将所得 Windows/UNC 路径交给同一 PowerShell 交接。打开器的平台信息和命令运行器可在测试中注入。仅含 URL 的 read 参数(`web_fetch`)不是文件链接。 `session/openWorkspacePath` 使用经过认证的 Remote carrier,而产品 UI 只在 loopback 页面且 `host.describe.canOpenPath` 为 true 时提供该手势。平台适配器不经 shell 打开:macOS 为 `open`Windows 为 PowerShell `Invoke-Item`,桌面 Linux 为 `xdg-open`;浏览器可渲染的文档会在 macOS 与桌面 Linux 上优先使用指定的默认浏览器。尽管 Node 将 WSL 报告为 `linux`,WSL 仍是一种独立的宿主形态:适配器根据其环境或 Microsoft 内核 release 识别它,用 `wslpath -w` 转换 Linux 路径,并将所得 Windows/UNC 路径交给同一 PowerShell 交接。打开器的平台信息和命令运行器可在测试中注入。仅含 URL 的 read 参数(`web_fetch`)不是文件链接。
+1 -1
View File
@@ -158,7 +158,7 @@ describe('web e2e: a finished turn ends with the files it produced', () => {
]) ])
expect(response.status()).toBe(200) expect(response.status()).toBe(200)
expect(openPath).toHaveBeenCalledTimes(1) expect(openPath).toHaveBeenCalledTimes(1)
expect(openPath.mock.calls[0]![0]).toMatchObject({ path: '.' }) expect(openPath.mock.calls[0]![0]).toMatchObject({ path: `${scaffold.workspaceCwd}/.` })
} finally { } finally {
openPath.mockRestore() openPath.mockRestore()
} }
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md # pnpm run verify-translation-pairing --write docs/config-catalog.md
config-catalog.md: 0a08455a27fa94f1bb69628b61dd8eb23d318ded config-catalog.md: 05bd17a600869782409038188457db6d362f07a4
config-catalog.zh.md: ad6a9c0481750ec41c0701d3a9e3989fd0e9ebf4 config-catalog.zh.md: 1095b31530a28af2c4a23520fd5e68e067b46c11
+1 -1
View File
@@ -304,7 +304,7 @@ export interface Config {
} }
``` ```
Source: [`packages/api/session-controller/src/index.ts:69`](../packages/api/session-controller/src/index.ts) Source: [`packages/api/session-controller/src/index.ts:67`](../packages/api/session-controller/src/index.ts)
<a id="deepseek-aidsh-api-settings-controller"></a> <a id="deepseek-aidsh-api-settings-controller"></a>
+1 -1
View File
@@ -306,7 +306,7 @@ export interface Config {
} }
``` ```
来源:[`packages/api/session-controller/src/index.ts:69`](../packages/api/session-controller/src/index.ts) 来源:[`packages/api/session-controller/src/index.ts:67`](../packages/api/session-controller/src/index.ts)
<a id="deepseek-aidsh-api-settings-controller"></a> <a id="deepseek-aidsh-api-settings-controller"></a>
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/event-producer-consumer.md # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
event-producer-consumer.md: 2c443095b796eb274fa099b4b7d91b4454311a37 event-producer-consumer.md: 8c30d5a945e2abb21c1e8dad5d6aa4e472c18b23
event-producer-consumer.zh.md: b35e1c56d7f51d3aed7f3f81aec228708cb952e3 event-producer-consumer.zh.md: 29304d9ac855a36ffc8a5e6766bedc841cf2738c
+5 -5
View File
@@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:538`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:537`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:518`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:517`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:545`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:544`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:524`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:523`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:531`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
+5 -5
View File
@@ -23,11 +23,11 @@
| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:538`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:537`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:518`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:517`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:545`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:544`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:524`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:523`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:531`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/session.md # pnpm run verify-translation-pairing --write docs/subsystems/session.md
session.md: fe3f71de6aeb6b5d9924fa88c94688f7eac8ff0a session.md: 919a8eff583886610b56b34294ae1c13a06493b0
session.zh.md: 844fd7a057c853fcbda6add875b997069ab024de session.zh.md: 8ffc7c8256311328c9e56e63625f3fbfcb5241a7
+5 -5
View File
@@ -581,7 +581,7 @@ The backends that consume this contract are on [persistence.md](persistence.md).
`ModelCatalog` is the Host-generation model directory returned by `session/modelCatalog`: it carries the deployment default, routable provider ids, successful provider groups, and isolated provider failures. It is not derived from one Session and remains separate from Session projections. `ModelCatalog` is the Host-generation model directory returned by `session/modelCatalog`: it carries the deployment default, routable provider ids, successful provider groups, and isolated provider failures. It is not derived from one Session and remains separate from Session projections.
`SessionOpenWorkspacePathRequest` carries a `sessionId` and an absolute or Session-workspace-relative `path`. `SessionOpenWorkspacePathValue` confirms that the Host accepted the native handoff. The controller inspects the Session without activating its Agent, resolves a relative path against the recorded cwd, and reports missing Sessions, cancellation, and opener failures through the Session Remote error vocabulary. `SessionOpenWorkspacePathRequest` carries an absolute or workspace-resolved `path`. `SessionOpenWorkspacePathValue` confirms that the Host accepted the native handoff. A Session-aware Client resolves relative paths against its current Session cwd when known; the controller hands the path to the opener unchanged and reports invalid requests, cancellation, and opener failures through the Session Remote error vocabulary.
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers --> <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
@@ -650,11 +650,11 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH
@Remote('modelCatalog') modelCatalog(): Promise<ModelCatalog> @Remote('modelCatalog') modelCatalog(): Promise<ModelCatalog>
/** /**
* Open a path resolved against one Session's workspace on the Host desktop. * Open one path prepared by a Session-aware caller on the Host desktop.
* @param request - Session identity and absolute or workspace-relative path. * @param request - path after best-effort Session workspace resolution.
* @param signal - caller lifetime; abort terminates inspection or the native command. * @param signal - caller lifetime; abort terminates the native command.
* @returns confirmation after the native opener accepts the path. * @returns confirmation after the native opener accepts the path.
* @throws TypertRemoteFailure when the request is invalid, the Session is missing, or the opener fails. * @throws TypertRemoteFailure when the request is invalid, cancelled, or the opener fails.
*/ */
@Remote('openWorkspacePath') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise<SessionOpenWorkspacePathValue> @Remote('openWorkspacePath') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise<SessionOpenWorkspacePathValue>
+5 -5
View File
@@ -585,7 +585,7 @@ interface TurnEndReasonMap {
`ModelCatalog` 是 `session/modelCatalog` 返回的 Host generation 模型目录:它携带部署默认值、可路由 provider id、成功的 provider 分组与相互隔离的 provider 失败。它不由某个 Session 派生,因此与 Session projection 分开保存。 `ModelCatalog` 是 `session/modelCatalog` 返回的 Host generation 模型目录:它携带部署默认值、可路由 provider id、成功的 provider 分组与相互隔离的 provider 失败。它不由某个 Session 派生,因此与 Session projection 分开保存。
`SessionOpenWorkspacePathRequest` 携带 `sessionId` 与绝对路径或相对于 Session workspace 的 `path`。`SessionOpenWorkspacePathValue` 确认 Host 已接受原生交接。controller 在不激活 Agent 的前提下检查 Session,基于记录的 cwd 解析相对路径,并通过 Session Remote 错误词汇表报告 Session 缺失、取消与打开器失败。 `SessionOpenWorkspacePathRequest` 携带绝对路径或已按 workspace 解析的 `path`。`SessionOpenWorkspacePathValue` 确认 Host 已接受原生交接。Session-aware Client 会在已知当前 Session cwd 时据此解析相对路径controller 将路径原样交给打开器,并通过 Session Remote 错误词汇表报告无效请求、取消与打开器失败。
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers --> <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
@@ -654,11 +654,11 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH
@Remote('modelCatalog') modelCatalog(): Promise<ModelCatalog> @Remote('modelCatalog') modelCatalog(): Promise<ModelCatalog>
/** /**
* Open a path resolved against one Session's workspace on the Host desktop. * Open one path prepared by a Session-aware caller on the Host desktop.
* @param request - Session identity and absolute or workspace-relative path. * @param request - path after best-effort Session workspace resolution.
* @param signal - caller lifetime; abort terminates inspection or the native command. * @param signal - caller lifetime; abort terminates the native command.
* @returns confirmation after the native opener accepts the path. * @returns confirmation after the native opener accepts the path.
* @throws TypertRemoteFailure when the request is invalid, the Session is missing, or the opener fails. * @throws TypertRemoteFailure when the request is invalid, cancelled, or the opener fails.
*/ */
@Remote('openWorkspacePath') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise<SessionOpenWorkspacePathValue> @Remote('openWorkspacePath') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise<SessionOpenWorkspacePathValue>
-5
View File
@@ -460,11 +460,6 @@
"tests/**/*.ts" "tests/**/*.ts"
] ]
}, },
"packages/context/file-reference": {
"ignoreDependencies": [
"zod"
]
},
"packages/context/session-reference": { "packages/context/session-reference": {
"ignoreDependencies": [ "ignoreDependencies": [
"zod" "zod"
+5 -29
View File
@@ -7,9 +7,7 @@ import { openNativePath } from '@deepseek-ai/dsh-native-command'
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionObservation } from '@deepseek-ai/dsh-session-query' import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path'
import { import {
ApiSessionNotFound,
ApiSessionAgentController, ApiSessionAgentController,
inspectApiSession, inspectApiSession,
type ApiSessionAgentResult, type ApiSessionAgentResult,
@@ -245,11 +243,11 @@ export class SessionController extends TypertRemoteService {
} }
/** /**
* Open a path resolved against one Session's workspace on the Host desktop. * Open one path prepared by a Session-aware caller on the Host desktop.
* @param request - Session identity and absolute or workspace-relative path. * @param request - path after best-effort Session workspace resolution.
* @param signal - caller lifetime; abort terminates inspection or the native command. * @param signal - caller lifetime; abort terminates the native command.
* @returns confirmation after the native opener accepts the path. * @returns confirmation after the native opener accepts the path.
* @throws TypertRemoteFailure when the request is invalid, the Session is missing, or the opener fails. * @throws TypertRemoteFailure when the request is invalid, cancelled, or the opener fails.
*/ */
@Remote('openWorkspacePath') @Remote('openWorkspacePath')
async openWorkspacePath( async openWorkspacePath(
@@ -264,30 +262,8 @@ export class SessionController extends TypertRemoteService {
}) })
} }
signal.throwIfAborted() signal.throwIfAborted()
let cwd: string | undefined
try { try {
cwd = (await this.inspect(request.sessionId, signal)).meta.cwd await this.openPath(request.path, signal)
} catch (error: unknown) {
if (signal.aborted) {
throw new TypertRemoteFailure({
code: 'cancelled', message: 'path open was aborted', details: {},
})
}
if (error instanceof ApiSessionNotFound) {
throw new TypertRemoteFailure({
code: 'session-not-found',
message: error.message,
details: { sessionId: request.sessionId },
})
}
throw new TypertRemoteFailure({
code: 'internal',
message: `session "${request.sessionId}" could not be inspected: ${String(error)}`,
details: {},
})
}
try {
await this.openPath(resolveWorkspacePath(cwd, request.path), signal)
return { opened: true } return { opened: true }
} catch (error: unknown) { } catch (error: unknown) {
if (signal.aborted) { if (signal.aborted) {
+2 -3
View File
@@ -362,10 +362,9 @@ export interface SessionCancelValue {
readonly accepted: true readonly accepted: true
} }
/** Session-addressed request to open one workspace path on the Host desktop. */ /** Request to open one path prepared by a Session-aware caller on the Host desktop. */
export interface SessionOpenWorkspacePathRequest { export interface SessionOpenWorkspacePathRequest {
readonly sessionId: SessionId /** Path after best-effort Session workspace resolution, in Host filesystem syntax. */
/** Absolute or Session-workspace-relative path. */
readonly path: string readonly path: string
} }
@@ -1,11 +1,10 @@
import { Context } from '@deepseek-ai/cordis' import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentRegistry from '@deepseek-ai/dsh-agent'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SessionStore from '@deepseek-ai/dsh-session'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { import {
createSessionTestController, createSessionTestController,
createSessionTestRemote, createSessionTestRemote,
testSessionPersistence,
} from './test-remote.ts' } from './test-remote.ts'
async function context(): Promise<Context> { async function context(): Promise<Context> {
@@ -16,10 +15,8 @@ async function context(): Promise<Context> {
} }
describe('session/openWorkspacePath', () => { describe('session/openWorkspacePath', () => {
it('resolves a relative path against the attached Session cwd', async () => { it('hands a Client-resolved workspace path to the Host opener unchanged', async () => {
const ctx = await context() const ctx = await context()
const sessionId = SessionId('open-relative')
ctx.sessions.create(sessionId, { meta: { cwd: '/workspace/project' } })
const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve()) const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve())
const remote = createSessionTestRemote(ctx, { const remote = createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
@@ -28,18 +25,14 @@ describe('session/openWorkspacePath', () => {
}) })
const signal = new AbortController().signal const signal = new AbortController().signal
await expect(remote.openWorkspacePath({ sessionId, path: 'src/a.ts' }, signal)) await expect(remote.openWorkspacePath({ path: '/workspace/project/src/a.ts' }, signal))
.resolves.toEqual({ ok: true, value: { opened: true } }) .resolves.toEqual({ ok: true, value: { opened: true } })
expect(openPath).toHaveBeenCalledWith('/workspace/project/src/a.ts', signal) expect(openPath).toHaveBeenCalledWith('/workspace/project/src/a.ts', signal)
expect(ctx.agents.list()).toEqual([]) expect(ctx.agents.list()).toEqual([])
}) })
it('preserves absolute paths and cwd-less Session paths', async () => { it('preserves relative and absolute Host-resolvable paths', async () => {
const ctx = await context() const ctx = await context()
const withCwd = SessionId('open-absolute')
const withoutCwd = SessionId('open-without-cwd')
ctx.sessions.create(withCwd, { meta: { cwd: '/workspace/project' } })
ctx.sessions.create(withoutCwd)
const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve()) const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve())
const remote = createSessionTestRemote(ctx, { const remote = createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
@@ -47,18 +40,13 @@ describe('session/openWorkspacePath', () => {
openPath, openPath,
}) })
await remote.openWorkspacePath({ sessionId: withCwd, path: '/tmp/result.html' }) await remote.openWorkspacePath({ path: '/tmp/result.html' })
await remote.openWorkspacePath({ sessionId: withoutCwd, path: 'result.html' }) await remote.openWorkspacePath({ path: 'result.html' })
expect(openPath.mock.calls.map(call => call[0])).toEqual(['/tmp/result.html', 'result.html']) expect(openPath.mock.calls.map(call => call[0])).toEqual(['/tmp/result.html', 'result.html'])
}) })
it('rejects empty paths and missing Sessions before opening anything', async () => { it('rejects empty paths before opening anything', async () => {
const ctx = await context() const ctx = await context()
const sessionId = SessionId('open-validation')
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
list: () => Promise.resolve([]),
inspect: () => Promise.resolve(undefined),
}) as never)
const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve()) const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve())
const remote = createSessionTestRemote(ctx, { const remote = createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
@@ -66,17 +54,13 @@ describe('session/openWorkspacePath', () => {
openPath, openPath,
}) })
await expect(remote.openWorkspacePath({ sessionId, path: '' })) await expect(remote.openWorkspacePath({ path: '' }))
.resolves.toMatchObject({ ok: false, error: { code: 'bad-request' } }) .resolves.toMatchObject({ ok: false, error: { code: 'bad-request' } })
await expect(remote.openWorkspacePath({ sessionId, path: 'result.html' }))
.resolves.toMatchObject({ ok: false, error: { code: 'session-not-found' } })
expect(openPath).not.toHaveBeenCalled() expect(openPath).not.toHaveBeenCalled()
}) })
it('preserves native opener failure and cancellation results', async () => { it('preserves native opener failure and cancellation results', async () => {
const ctx = await context() const ctx = await context()
const sessionId = SessionId('open-failure')
ctx.sessions.create(sessionId, { meta: { cwd: '/workspace/project' } })
const openPath = vi.fn((_path: string, _signal: AbortSignal) => const openPath = vi.fn((_path: string, _signal: AbortSignal) =>
Promise.reject(new Error('desktop unavailable'))) Promise.reject(new Error('desktop unavailable')))
const remote = createSessionTestRemote(ctx, { const remote = createSessionTestRemote(ctx, {
@@ -85,7 +69,7 @@ describe('session/openWorkspacePath', () => {
openPath, openPath,
}) })
await expect(remote.openWorkspacePath({ sessionId, path: 'result.html' })) await expect(remote.openWorkspacePath({ path: 'result.html' }))
.resolves.toMatchObject({ .resolves.toMatchObject({
ok: false, ok: false,
error: { code: 'internal', message: 'path open failed: desktop unavailable' }, error: { code: 'internal', message: 'path open failed: desktop unavailable' },
@@ -93,38 +77,12 @@ describe('session/openWorkspacePath', () => {
const aborted = new AbortController() const aborted = new AbortController()
aborted.abort(new Error('cancelled')) aborted.abort(new Error('cancelled'))
await expect(remote.openWorkspacePath({ sessionId, path: 'result.html' }, aborted.signal)) await expect(remote.openWorkspacePath({ path: 'result.html' }, aborted.signal))
.resolves.toMatchObject({ ok: false, error: { code: 'cancelled' } }) .resolves.toMatchObject({ ok: false, error: { code: 'cancelled' } })
}) })
it('classifies inspection cancellation and non-session failures', async () => {
const ctx = await context()
const controller = createSessionTestController(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
cwd: '/default',
})
const inspect = vi.spyOn(controller, 'inspect')
const aborted = new AbortController()
inspect.mockImplementationOnce(async () => {
aborted.abort(new Error('cancelled'))
throw new Error('inspection stopped')
})
await expect(controller.openWorkspacePath({
sessionId: SessionId('inspection-cancelled'), path: 'result.html',
}, aborted.signal)).rejects.toMatchObject({ failure: { code: 'cancelled' } })
inspect.mockRejectedValueOnce('storage offline')
const failed = controller.openWorkspacePath({
sessionId: SessionId('inspection-failed'), path: 'result.html',
}, new AbortController().signal)
await expect(failed).rejects.toMatchObject({ failure: { code: 'internal' } })
await expect(failed).rejects.toThrow('storage offline')
})
it('classifies opener cancellation and non-Error failures', async () => { it('classifies opener cancellation and non-Error failures', async () => {
const ctx = await context() const ctx = await context()
const sessionId = SessionId('open-error-kinds')
ctx.sessions.create(sessionId, { meta: { cwd: '/workspace/project' } })
const aborted = new AbortController() const aborted = new AbortController()
const openPath = vi.fn() const openPath = vi.fn()
.mockImplementationOnce(async () => { .mockImplementationOnce(async () => {
@@ -138,10 +96,10 @@ describe('session/openWorkspacePath', () => {
openPath, openPath,
}) })
await expect(controller.openWorkspacePath({ sessionId, path: 'first.html' }, aborted.signal)) await expect(controller.openWorkspacePath({ path: 'first.html' }, aborted.signal))
.rejects.toMatchObject({ failure: { code: 'cancelled' } }) .rejects.toMatchObject({ failure: { code: 'cancelled' } })
await expect(controller.openWorkspacePath({ await expect(controller.openWorkspacePath({
sessionId, path: 'second.html', path: 'second.html',
}, new AbortController().signal)).rejects.toMatchObject({ }, new AbortController().signal)).rejects.toMatchObject({
failure: { code: 'internal', message: 'path open failed: desktop unavailable' }, failure: { code: 'internal', message: 'path open failed: desktop unavailable' },
}) })
@@ -44,7 +44,6 @@
{ "path": "../../subagent/subagent" }, { "path": "../../subagent/subagent" },
{ "path": "../../typert/protocol" }, { "path": "../../typert/protocol" },
{ "path": "../../typert/registry" }, { "path": "../../typert/registry" },
{ "path": "../../util/workspace-path" },
{ "path": "../../workspace/workspace" } { "path": "../../workspace/workspace" }
] ]
} }
@@ -3501,9 +3501,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
}) })
} }
case 'session/openWorkspacePath': { case 'session/openWorkspacePath': {
const pathRequest = request as { readonly sessionId: SessionId; readonly path: string } return sessionOk({ opened: true as const })
const missing = requireRemoteSession(pathRequest)
return missing ?? sessionOk({ opened: true as const })
} }
case 'session/modelCatalog': return Promise.resolve({ case 'session/modelCatalog': return Promise.resolve({
ok: true, ok: true,
+3 -1
View File
@@ -74,7 +74,8 @@
"@deepseek-ai/dsh-session-stats": "workspace:^", "@deepseek-ai/dsh-session-stats": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^" "@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-util-workspace-path": "workspace:^"
}, },
"devDependencies": { "devDependencies": {
"@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/cordis": "workspace:^",
@@ -104,6 +105,7 @@
"@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-util-workspace-path": "workspace:^",
"@types/react": "~18.3.1", "@types/react": "~18.3.1",
"react": "^18.2.0" "react": "^18.2.0"
}, },
+5 -1
View File
@@ -5,6 +5,7 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client'
import type { BoundActions, ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { BoundActions, ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path'
// Type-only service and declaration merges used by the apply world. // Type-only service and declaration merges used by the apply world.
import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -117,7 +118,10 @@ export function apply(ctx: Context): void {
}, },
fileMentions: (owner: TurnTailOwnerProps) => ctx.get('chatFileMentions')?.forClosing(owner), fileMentions: (owner: TurnTailOwnerProps) => ctx.get('chatFileMentions')?.forClosing(owner),
openFile: async (path) => { openFile: async (path) => {
const result = await ctx.remote.session.openWorkspacePath({ sessionId, path }) const cwd = ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd
const result = await ctx.remote.session.openWorkspacePath({
path: resolveWorkspacePath(cwd, path),
})
if (!result.ok) throw new Error(`path open failed: ${result.error.message}`) if (!result.ok) throw new Error(`path open failed: ${result.error.message}`)
}, },
loadOlder: () => { void session.loadOlder() }, loadOlder: () => { void session.loadOlder() },
@@ -123,7 +123,7 @@ describe('Chat inject API', () => {
const b = await bench() const b = await bench()
const { injected } = b.chatViewApi(ROOT) const { injected } = b.chatViewApi(ROOT)
await injected.openFile('src/a.ts') await injected.openFile('src/a.ts')
expect(b.openWorkspacePath).toHaveBeenCalledWith({ sessionId: ROOT, path: 'src/a.ts' }) expect(b.openWorkspacePath).toHaveBeenCalledWith({ path: '/proj/src/a.ts' })
b.openWorkspacePath.mockResolvedValueOnce({ b.openWorkspacePath.mockResolvedValueOnce({
ok: false, ok: false,
+3
View File
@@ -50,6 +50,9 @@
{ {
"path": "../../runtime-diagnostics/invariants" "path": "../../runtime-diagnostics/invariants"
}, },
{
"path": "../../util/workspace-path"
},
{ {
"path": "../../session/session-stats" "path": "../../session/session-stats"
}, },
@@ -223,7 +223,7 @@ describe('run_code sub-calls through the real chat machinery', () => {
view.getByText('notes/demo.txt').click() view.getByText('notes/demo.txt').click()
expect(b.layout.openDetails).not.toHaveBeenCalled() expect(b.layout.openDetails).not.toHaveBeenCalled()
await vi.waitFor(() => { await vi.waitFor(() => {
expect(b.openWorkspacePath).toHaveBeenCalledWith({ sessionId: SID, path: 'notes/demo.txt' }) expect(b.openWorkspacePath).toHaveBeenCalledWith({ path: 'notes/demo.txt' })
}) })
view.getByText('List notes').click() view.getByText('List notes').click()
expect(b.layout.openDetails).not.toHaveBeenCalled() expect(b.layout.openDetails).not.toHaveBeenCalled()
@@ -137,7 +137,7 @@ describe('keyed toolview hole through the real machinery', () => {
view.getByText('src/a.ts').click() view.getByText('src/a.ts').click()
expect(b.layout.openDetails).not.toHaveBeenCalled() expect(b.layout.openDetails).not.toHaveBeenCalled()
await vi.waitFor(() => { await vi.waitFor(() => {
expect(b.openWorkspacePath).toHaveBeenCalledWith({ sessionId: SID, path: 'src/a.ts' }) expect(b.openWorkspacePath).toHaveBeenCalledWith({ path: 'src/a.ts' })
}) })
await b.runtime.dispose() await b.runtime.dispose()
}) })
@@ -1384,10 +1384,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
}, },
{ {
signature: '@Remote(\'openWorkspacePath\') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise<SessionOpenWorkspacePathValue>', signature: '@Remote(\'openWorkspacePath\') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise<SessionOpenWorkspacePathValue>',
description: 'Open a path resolved against one Session\'s workspace on the Host desktop.', description: 'Open one path prepared by a Session-aware caller on the Host desktop.',
parameters: [{ name: 'request', description: 'Session identity and absolute or workspace-relative path.' }, { name: 'signal', description: 'caller lifetime; abort terminates inspection or the native command.' }], parameters: [{ name: 'request', description: 'path after best-effort Session workspace resolution.' }, { name: 'signal', description: 'caller lifetime; abort terminates the native command.' }],
returns: 'confirmation after the native opener accepts the path.', returns: 'confirmation after the native opener accepts the path.',
throws: ['TypertRemoteFailure when the request is invalid, the Session is missing, or the opener fails.'], throws: ['TypertRemoteFailure when the request is invalid, cancelled, or the opener fails.'],
}, },
{ {
signature: '@Remote(\'rename\') rename(request: SessionRenameRequest): Promise<SessionRenameValue>', signature: '@Remote(\'rename\') rename(request: SessionRenameRequest): Promise<SessionRenameValue>',
@@ -4960,7 +4960,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
}, },
{ {
name: 'SessionOpenWorkspacePathRequest', name: 'SessionOpenWorkspacePathRequest',
declaration: 'export interface SessionOpenWorkspacePathRequest {\n readonly sessionId: SessionId;\n readonly path: string;\n}', declaration: 'export interface SessionOpenWorkspacePathRequest {\n readonly path: string;\n}',
}, },
{ {
name: 'SessionOpenWorkspacePathValue', name: 'SessionOpenWorkspacePathValue',
+1 -3
View File
@@ -34,9 +34,7 @@ export interface ApiProxyDefaults {
/** Validated DEFLATE level for session-log ZIP entries; defaults to 6. */ /** Validated DEFLATE level for session-log ZIP entries; defaults to 6. */
sessionExportCompressionLevel?: SessionLogCompressionLevel sessionExportCompressionLevel?: SessionLogCompressionLevel
/** /**
* Whether handing a path to the native opener can work at all the * Whether `host.describe` reports that the Client may offer native path actions.
* `hasDocument` capability the preset roster reports, and the switch
* between opening a preset directory and answering its path as text.
* Absent, platform detection decides ({@link canOpenNativePath}). * Absent, platform detection decides ({@link canOpenNativePath}).
*/ */
canOpenPath?: () => boolean canOpenPath?: () => boolean
+3
View File
@@ -2065,6 +2065,9 @@ importers:
'@deepseek-ai/dsh-tools': '@deepseek-ai/dsh-tools':
specifier: workspace:^ specifier: workspace:^
version: link:../../core/tools version: link:../../core/tools
'@deepseek-ai/dsh-util-workspace-path':
specifier: workspace:^
version: link:../../util/workspace-path
'@types/react': '@types/react':
specifier: ~18.3.1 specifier: ~18.3.1
version: 18.3.31 version: 18.3.31