refactor(api): remove ApiProxy package

This commit is contained in:
imccyu
2026-08-27 22:26:29 +08:00
parent e14d354e83
commit 4f00a8b82a
42 changed files with 54 additions and 3529 deletions
-1
View File
@@ -110,7 +110,6 @@
"@deepseek-ai/dsh-fs-observation-policy": "workspace:^", "@deepseek-ai/dsh-fs-observation-policy": "workspace:^",
"@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-fs-sandbox": "workspace:^",
"@deepseek-ai/dsh-host-frontend-static": "workspace:^", "@deepseek-ai/dsh-host-frontend-static": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^",
-3
View File
@@ -32,9 +32,6 @@
{ {
"path": "../../packages/bundle/web-app" "path": "../../packages/bundle/web-app"
}, },
{
"path": "../../packages/host/apiproxy"
},
{ {
"path": "../../packages/host/webserver" "path": "../../packages/host/webserver"
}, },
-5
View File
@@ -95,11 +95,6 @@
- id: workspace-controller - id: workspace-controller
name: '@deepseek-ai/dsh-api-workspace-controller' name: '@deepseek-ai/dsh-api-workspace-controller'
# The API gateway: the transport-agnostic dispatch face every client shape
# shares. The base layer's agent-default-model service owns the default model.
- id: api-gateway
name: '@deepseek-ai/dsh-host-apiproxy'
- id: cordis-host-runner - id: cordis-host-runner
name: '@deepseek-ai/dsh-cordis-host-runner' name: '@deepseek-ai/dsh-cordis-host-runner'
-1
View File
@@ -94,7 +94,6 @@
"@deepseek-ai/dsh-cordis-host-runner": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^",
"@deepseek-ai/dsh-web-frontend": "workspace:^", "@deepseek-ai/dsh-web-frontend": "workspace:^",
"@deepseek-ai/dsh-host-frontend-static": "workspace:^", "@deepseek-ai/dsh-host-frontend-static": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-auto": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-auto": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",
@@ -429,18 +429,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
}, },
], ],
}, },
{
key: 'apiProxy',
summary: 'Root interface of the unified API.',
description: 'Root interface of the unified API. New client-request domain = one new file pair + one field here + one map row.',
methods: [
{
signature: 'downloads: DownloadsApi',
description: 'Host-only download surfaces (GET, no wire envelope); absent from IApiClient.',
parameters: [],
},
],
},
{ {
key: 'approval', key: 'approval',
summary: 'Approval service that applies session policy before answerers and logs every ask/outcome pair to the requesting session.', summary: 'Approval service that applies session policy before answerers and logs every ask/outcome pair to the requesting session.',
@@ -1382,6 +1370,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
parameters: [], parameters: [],
returns: 'provider-grouped models, the deployment default, and isolated provider failures.', returns: 'provider-grouped models, the deployment default, and isolated provider failures.',
}, },
{
signature: '@Remote canOpenWorkspacePath(): boolean',
description: 'Report whether this deployment can hand a Session workspace path to a native desktop.',
parameters: [],
returns: 'true when the matching open operation is available.',
},
{ {
signature: '@Remote(\'openWorkspacePath\') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise<SessionOpenWorkspacePathValue>', signature: '@Remote(\'openWorkspacePath\') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise<SessionOpenWorkspacePathValue>',
description: 'Open one path prepared by a Session-aware caller on the Host desktop.', description: 'Open one path prepared by a Session-aware caller on the Host desktop.',
@@ -1966,6 +1960,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
returns: 'provider writability, local-document presence, and one view per namespace.', returns: 'provider writability, local-document presence, and one view per namespace.',
throws: ['TypertRemoteFailure when no settings provider is mounted.'], throws: ['TypertRemoteFailure when no settings provider is mounted.'],
}, },
{
signature: '@Remote canOpenAgentPresetDirectory(): boolean',
description: 'Report whether this deployment can open an authored Agent preset directory natively.',
parameters: [],
returns: 'true when the matching open operation is available.',
},
{ {
signature: '@Remote update( ns: string, patch: Record<string, JsonValue>, expectedRevision: number | undefined, ): Promise<SettingsNamespaceView>', signature: '@Remote update( ns: string, patch: Record<string, JsonValue>, expectedRevision: number | undefined, ): Promise<SettingsNamespaceView>',
description: 'Merge a patch into one namespace\'s stored user section.', description: 'Merge a patch into one namespace\'s stored user section.',
@@ -2610,9 +2610,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
parameters: [], parameters: [],
}, },
{ {
signature: 'registerRemoteEvents(source: TypertRemoteEventSource): () => Promise<void>', signature: 'registerRemoteEvents( source: TypertRemoteEventSource, host: RemoteEventHostInfo, ): () => Promise<void>',
description: 'Register the sole application-selected forwarded-event source.', description: 'Register the sole application-selected forwarded-event source.',
parameters: [{ name: 'source', description: 'stream factory installed by the Remote assembly.' }], parameters: [{ name: 'source', description: 'stream factory installed by the Remote assembly.' }, { name: 'host', description: 'stable Host facts included in each Client generation\'s opening frame.' }],
returns: 'disposer removing this source and cancelling its active streams.', returns: 'disposer removing this source and cancelling its active streams.',
}, },
{ {
@@ -3934,10 +3934,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'DomainTableSpec', name: 'DomainTableSpec',
declaration: 'export interface DomainTableSpec<K extends string = string, V = unknown> {\n readonly valueSchema: ZodType<V>;\n readonly __key?: K;\n}', declaration: 'export interface DomainTableSpec<K extends string = string, V = unknown> {\n readonly valueSchema: ZodType<V>;\n readonly __key?: K;\n}',
}, },
{
name: 'DownloadsApi',
declaration: 'export interface DownloadsApi {\n sessionLog(request: {\n sessionId: SessionId;\n includeDescendants?: boolean;\n }, signal: AbortSignal): Promise<Response>;\n}',
},
{ {
name: 'DshEnvironment', name: 'DshEnvironment',
declaration: 'export type DshEnvironment = Readonly<Record<DshEnvironmentKey, string>>;', declaration: 'export type DshEnvironment = Readonly<Record<DshEnvironmentKey, string>>;',
@@ -4606,6 +4602,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'RedactedSecret', name: 'RedactedSecret',
declaration: 'export interface RedactedSecret {\n path: string[];\n set: boolean;\n}', declaration: 'export interface RedactedSecret {\n path: string[];\n set: boolean;\n}',
}, },
{
name: 'RemoteEventHostInfo',
declaration: 'export interface RemoteEventHostInfo {\n readonly home: string;\n}',
},
{ {
name: 'ReplayEnvelope', name: 'ReplayEnvelope',
declaration: 'export interface ReplayEnvelope {\n response: unknown;\n blocks?: readonly unknown[];\n}', declaration: 'export interface ReplayEnvelope {\n response: unknown;\n blocks?: readonly unknown[];\n}',
@@ -4662,26 +4662,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ResumeAgentOptions', name: 'ResumeAgentOptions',
declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}', declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}',
}, },
{
name: 'RpcError',
declaration: 'export type RpcError = {\n [C in RpcErrorCode]: {\n code: C;\n message: string;\n details: RpcErrorDetailsMap[C];\n };\n}[RpcErrorCode];',
},
{
name: 'RpcErrorCode',
declaration: 'export type RpcErrorCode = keyof RpcErrorDetailsMap;',
},
{
name: 'RpcErrorDetailsMap',
declaration: 'export interface RpcErrorDetailsMap {\n \'bad-request\': {\n issues: ZodIssue[];\n };\n \'cancelled\': {};\n \'session-not-found\': {\n sessionId: SessionId;\n };\n \'invalid-time-zone\': {\n value: string;\n };\n \'agent-preset-read-only\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-preset-locked\': {\n sessionId: SessionId;\n agentPreset: string;\n };\n \'agent-preset-not-found\': {\n agentPreset: string;\n available: readonly string[];\n };\n \'agent-preset-invalid\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-busy\': {\n reason: string;\n };\n \'internal\': {};\n}',
},
{
name: 'RpcId',
declaration: 'export type RpcId = Branded<\'rpc-id\'>;',
},
{
name: 'RpcResult',
declaration: 'export type RpcResult<T> = {\n ok: true;\n value: T;\n} | {\n ok: false;\n error: RpcError;\n};',
},
{ {
name: 'RunnerFailureRule', name: 'RunnerFailureRule',
declaration: 'export interface RunnerFailureRule {\n allowedExitCodes?: readonly number[];\n fatalSignatures: readonly string[];\n informationalLines?: readonly string[];\n}', declaration: 'export interface RunnerFailureRule {\n allowedExitCodes?: readonly number[];\n fatalSignatures: readonly string[];\n informationalLines?: readonly string[];\n}',
@@ -4758,10 +4738,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SendTeamMessageResult', name: 'SendTeamMessageResult',
declaration: 'export interface SendTeamMessageResult {\n readonly messageId: TeamMessageId;\n readonly status: \'accepted\' | \'queued\';\n}', declaration: 'export interface SendTeamMessageResult {\n readonly messageId: TeamMessageId;\n readonly status: \'accepted\' | \'queued\';\n}',
}, },
{
name: 'ServerResponse',
declaration: 'export interface ServerResponse {\n type: \'server-response\';\n rpcId: RpcId;\n result: RpcResult<unknown>;\n}',
},
{ {
name: 'Session', name: 'Session',
declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}', declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}',
-6
View File
@@ -1,6 +0,0 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
README.md: 131ea9c510733740664ca8b46510650110bca234
README.zh.md: 4578e89acc73f1a77648dce087da1bca6b364f16
-135
View File
@@ -1,135 +0,0 @@
---
description: "Legacy HTTP transport for Host bootstrap metadata and streamed Session-log ZIP downloads while generated Typert Remotes own business operations."
kind: "package-reference"
---
# @deepseek-ai/dsh-host-apiproxy
English | [中文](README.zh.md)
## Summary
`dsh-host-apiproxy` carries the two Host operations that do not yet belong to a generated business Remote: the `host.describe` bootstrap snapshot and streamed Session-log ZIP downloads. Its browser-safe envelope and fetch adapters serve HTTP and in-process clients, while API Gateway carries all ordinary business operations. The shipped Web composition assembles both transports in [`dsh-web-app`](../../bundle/web-app/README.md).
## Table of Contents
- [Use this package](#use-this-package)
- [Understand the implementation](#understand-the-implementation)
- [Further Exploration](#further-exploration)
- [Model Experience](#model-experience)
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
- [Dev Note](#dev-note)
-----
<a id="use-this-package"></a>
## Use this package
Compose this package when a GUI host needs bootstrap metadata and Session-log export: load `ApiProxyService`, wrap `ctx.apiProxy` in a carrier, and use generated Remotes for all other business calls.
### Choosing a carrier
`toFetchHandler(api)` turns the gateway into a pure WHATWG fetch function for an HTTP server (the shipped Web composition exposes it behind `/api/…` routes), while `InProcessApiClient` runs the same serialization and validation path in-process — the isomorphic point for callers and tests that need the full wire path without a network.
```text
const client = new InProcessApiClient(toFetchHandler(ctx.apiProxy))
const response = await client.host.describe({})
```
The HTTP carrier refuses non-JSON POST bodies with 415 before dispatch, so cross-site simple requests can never run a side-effectful method blind. The browser carrier applies the same Host/Origin checks and signed-cookie authentication to every Host API method ([`dsh-client-connection`](../../client/connection/README.md)); individual Client features may still withhold native or persistent operations on non-loopback pages.
### What the gateway exposes
The unary map contains only `host.describe`; the direct download route is `GET` or `HEAD /api/session.export`. Session, workspace, settings, credentials, LLM, skill, file-reference, command, and interaction operations are generated Remotes owned by their business packages and assembled by [`dsh-api-remotes`](../../api/remotes/README.md).
### Exporting sessions
`GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP of the session's stored artifact text verbatim, every subagent descendant under `subagents/<id>/`, and each referenced image under `media/<attachmentId>.<ext>`. `HEAD` runs the same root preparation without a body, so browsers detect pre-stream failures before handing the GET to the download manager. The response is chunked as it is produced, and `sessionExportCompressionLevel` (09, default 6) trades CPU and latency against archive size. Missing persistence, session-query, or attachment services answer 500, a backend without per-session raw artifacts 501, and a missing root session 404.
### Configuration
| Field | Default | Meaning |
|---|---|---|
| `nativeOpen` | platform-detected | Whether the deployment can hand paths to a native desktop opener |
| `sessionExportCompressionLevel` | `6` | DEFLATE level for every session-log ZIP entry, 09 |
The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-host-apiproxy) is the exhaustive source for every accepted field and its JSDoc.
-----
<a id="understand-the-implementation"></a>
## Understand the implementation
<details>
<summary>Implementation internals — click to expand</summary>
### Design concept
The package is built on one separation: the API contract is channel-independent, and physical transports are carriers around it. Wire messages form a two-member discriminated union — `ClientRequest` (the POST `/api/<method>` body) and `ServerResponse` (that POST's response body) — decoupled from the physical channel. Responses always echo the matching request's `rpcId` and never mint a new one. Business errors ride the `RpcResult` error branch with a closed `RpcErrorDetailsMap`; HTTP status expresses only the carrier. The layering and protocol decisions are recorded in the [GUI layering and RPC protocol RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md).
### Source map
| File | Role |
|---|---|
| [`src/api/`](src/api/) | Contract layer: domain interfaces, payload types, zod schemas, `RpcMethodMap` — zero Node dependencies |
| [`src/fetch/handler.ts`](src/fetch/handler.ts) | Host carrier: `toFetchHandler`, envelope parsing, unary dispatch, session export |
| [`src/fetch/client.ts`](src/fetch/client.ts) | Client carrier: `AbstractApiClient` plus platform subclasses, `InProcessApiClient` |
| [`src/api-proxy.ts`](src/api-proxy.ts) | Gateway implementation: `createApiProxy` over the composed host context |
| [`src/session-export.ts`](src/session-export.ts) | Session-log ZIP export: raw artifact reads, media collection, fflate streaming |
### The gateway service
`ApiProxyService` provides `ctx.apiProxy`, reports process metadata through `host.describe`, and delegates Session archive production to the persistence, query, attachment, and live Session services. The Host cwd is the default project directory. Product `dsh --profile headless` is a direct core entry point and does not mount this package.
### Request flow
A `host.describe` request enters the fetch carrier, which parses the envelope and payload, dispatches the method, and returns a response echoing the request's `rpcId`. Session export bypasses that envelope because its streamed ZIP body and HTTP status are the result.
### What the gateway owns
The package owns its legacy envelope, Host bootstrap snapshot, and archive download. API Gateway owns generated Remote dispatch and streams; business packages own their methods and result types.
</details>
-----
<a id="further-exploration"></a>
## Further Exploration
Read these when the package-level contract is not enough. They move from the layering decision to the browser-side consumption architecture and the adjacent subsystems.
- [GUI layering and RPC protocol RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) — the layering model and the channel-independent message protocol.
- [Web client architecture RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) — how the browser consumes the API.
- [Browser HTTP carrier](../../client/connection/README.md) — Host/Origin checks, signed-cookie authentication, and the routes the shipped Web composition registers.
- [Web-server subsystem](../../../docs/subsystems/web-server.md) — the HTTP server the carrier rides on.
- [Generated configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-host-apiproxy) — every accepted config field and its source declaration.
-----
<a id="model-experience"></a>
## Model Experience
None, as the wire contract and fetch carriers move already-composed messages and register nothing model-facing.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
<a id="known-limitations-and-deferred-work"></a>
These limits define where the gateway is a poor fit; they are current package constraints, not a task backlog.
- **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists.
<a id="dev-note"></a>
### Dev Note
<details>
<summary>Working context for maintainers — click to expand</summary>
This Dev Note is working context for maintainers: open directions. It is explicitly non-authoritative — shipped behavior and limits live in the sections above. A protocol version field waits for an independently released client; a multi-user carrier must replace provider search diagnostics with public-safe text; per-connection picker adaptivity (native for a local browser, browse for a remote one) remains an undecided direction for the host surface.
</details>
-135
View File
@@ -1,135 +0,0 @@
---
description: "Host 启动元数据与 Session 日志 ZIP 流下载的旧版 HTTP 载体;普通业务操作由生成的 Typert Remote 持有。"
kind: "package-reference"
---
# @deepseek-ai/dsh-host-apiproxy
[English](README.md) | 中文
## 概述
`dsh-host-apiproxy` 承载尚不属于生成业务 Remote 的两项 Host 操作:`host.describe` 启动快照与流式 Session 日志 ZIP 下载。它的浏览器安全 envelope 与 fetch adapter 服务 HTTP 和进程内客户端,其余普通业务操作由 API Gateway 承载。随发行版交付的 Web 组合在 [`dsh-web-app`](../../bundle/web-app/README.zh.md) 中组装两种传输。
## 目录
- [使用本包](#use-this-package)
- [理解实现](#understand-the-implementation)
- [进一步探索](#further-exploration)
- [模型体验](#model-experience)
- [已知限制与延期工作](#known-limitations-and-deferred-work)
- [开发备注](#dev-note)
-----
<a id="use-this-package"></a>
## 使用本包
当 GUI Host 需要启动元数据与 Session 日志导出时组合本包:加载 `ApiProxyService`,把 `ctx.apiProxy` 包进一个载体,其他业务调用使用生成的 Remote。
### 选择载体
`toFetchHandler(api)` 把网关变成纯 WHATWG fetch 函数,供 HTTP 服务器使用(随发行版交付的 Web 组合把它暴露在 `/api/…` 路由之后);`InProcessApiClient` 则在进程内运行同一条序列化与校验路径——这是需要完整协议路径但不需要网络的调用方与测试的同构接点。
```text
const client = new InProcessApiClient(toFetchHandler(ctx.apiProxy))
const response = await client.host.describe({})
```
HTTP 载体在分发前以 415 拒绝非 JSON 的 POST 请求体,因此跨站「简单请求」永远无法盲目执行有副作用的方法。浏览器载体对每个 Host API 方法实施相同的 Host/Origin 检查与签名 cookie 认证([`dsh-client-connection`](../../client/connection/README.zh.md));各 Client 功能仍可以在非 loopback 页面上拒绝原生操作或持久化操作。
### 网关暴露什么
一元映射只包含 `host.describe`;直接下载路由是 `GET``HEAD /api/session.export`。Session、workspace、settings、credentials、LLM、skill、file-reference、command 与 interaction 操作都是由各业务包持有、并由 [`dsh-api-remotes`](../../api/remotes/README.zh.md) 组装的生成 Remote。
### 导出会话
`GET /api/session.export?sessionId=…&includeDescendants=true` 流式输出一个 ZIP,其中每个会话的已存工件文本原样包含,每个子代理后代位于 `subagents/<id>/` 下,每张被引用的图片位于 `media/<attachmentId>.<ext>` 下。`HEAD` 在无请求体的情况下运行同样的根准备,因此浏览器能在把 GET 交给下载管理器之前检测到流前失败。响应边生成边分块输出,`sessionExportCompressionLevel`(0–9,默认 6)在 CPU 与延迟之间权衡归档大小。缺少 persistence、session-query 或 attachment 服务时回答 500,后端没有按会话原始工件时回答 501,根会话缺失时回答 404。
### 配置
| 字段 | 默认值 | 含义 |
|---|---|---|
| `nativeOpen` | 平台探测 | 部署能否把路径交给原生桌面打开器 |
| `sessionExportCompressionLevel` | `6` | 每个会话日志 ZIP 条目的 DEFLATE 级别,09 |
生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-host-apiproxy)是每个受支持字段及其 JSDoc 的穷尽式真源。
-----
<a id="understand-the-implementation"></a>
## 理解实现
<details>
<summary>实现细节——点击展开</summary>
### 设计理念
本包建立在一个分离之上:API 约定与通道无关,物理传输只是围绕它的载体。协议消息构成一个二元可辨识联合——`ClientRequest`POST `/api/<method>` 的请求体)与 `ServerResponse`(该 POST 的响应体)——与物理通道解耦。响应始终回显对应请求的 `rpcId`,绝不签发新值。业务错误由 `RpcResult` 的错误分支承载,其 `RpcErrorDetailsMap` 封闭错误码集合;HTTP 状态只表达载体层结果。分层与协议决策记录在 [GUI 分层与 RPC 协议 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md) 中。
### 源码地图
| 文件 | 职责 |
|---|---|
| [`src/api/`](src/api/) | 约定层:领域接口、payload 类型、zod schema、`RpcMethodMap`——零 Node 依赖 |
| [`src/fetch/handler.ts`](src/fetch/handler.ts) | 宿主载体:`toFetchHandler`、信封解析、一元分发、会话导出 |
| [`src/fetch/client.ts`](src/fetch/client.ts) | 客户端载体:`AbstractApiClient` 及平台子类、`InProcessApiClient` |
| [`src/api-proxy.ts`](src/api-proxy.ts) | 网关实现:基于所组合宿主上下文的 `createApiProxy` |
| [`src/session-export.ts`](src/session-export.ts) | 会话日志 ZIP 导出:原始工件读取、媒体收集、fflate 流式输出 |
### 网关服务
`ApiProxyService` 提供 `ctx.apiProxy`,通过 `host.describe` 报告进程元数据,并把 Session 归档生成委派给 persistence、query、attachment 与 live Session 服务。Host cwd 是默认项目目录。产品的 `dsh --profile headless` 是直连 core 的入口,不挂载本包。
### 请求流
`host.describe` 请求进入 fetch 载体,载体解析 envelope 与 payload、分发方法,并返回回显请求 `rpcId` 的响应。Session 导出不使用该 envelope,因为其流式 ZIP body 与 HTTP 状态就是结果。
### 网关拥有什么
本包持有旧版 envelope、Host 启动快照与归档下载。API Gateway 持有生成的 Remote 分发与流;业务包持有各自的方法和结果类型。
</details>
-----
<a id="further-exploration"></a>
## 进一步探索
当包级约定不够用时阅读以下内容。它们从分层决策进入浏览器侧消费架构与相邻子系统。
- [GUI 分层与 RPC 协议 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md)——分层模型与通道无关的消息协议。
- [Web 客户端架构 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md)——浏览器如何消费该 API。
- [浏览器 HTTP 载体](../../client/connection/README.zh.md)——Host/Origin 检查、签名 cookie 认证,以及随发行版交付的 Web 组合注册的路由。
- [Web 服务器子系统](../../../docs/subsystems/web-server.zh.md)——载体所搭乘的 HTTP 服务器。
- [生成配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-host-apiproxy)——每个受支持配置字段及其源声明。
-----
<a id="model-experience"></a>
## 模型体验
无。该协议约定与 fetch 载体只搬运已组装好的消息,不注册任何面向模型的内容。
#### KV Cache 影响
无;该包既不组装也不发送提供方请求。
## 已知限制与延期工作
<a id="known-limitations-and-deferred-work"></a>
这些限制说明网关在何处不合适;它们是当前包约束,不是任务积压。
- **没有协议版本字段**——客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。
<a id="dev-note"></a>
### 开发备注
<details>
<summary>维护者的工作上下文——点击展开</summary>
本开发备注是维护者的工作上下文:开放方向。它明确不具权威性——已交付行为与限制见上文各节。协议版本字段等待独立发布的客户端;多用户载体必须把提供方搜索诊断替换为可安全公开的文本;按连接的自适应目录选择(本地浏览器用 native、远程浏览器用 browse)仍是宿主表面的一个未定方向。
</details>
-74
View File
@@ -1,74 +0,0 @@
{
"name": "@deepseek-ai/dsh-host-apiproxy",
"description": "API gateway: the ApiProxy contract (api/), the fetch carrier pair (fetch/), and the host-side gateway plugin providing ctx.apiProxy",
"version": "0.1.1-rc.2",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/host/apiproxy"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json",
"./api": {
"types": "./lib/types/api/index.d.ts",
"default": "./lib/types/api/index.js"
},
"./api/*": {
"types": "./lib/types/api/*.d.ts",
"default": "./lib/types/api/*.js"
},
"./client": {
"types": "./lib/types/fetch/client.d.ts",
"default": "./lib/types/fetch/client.js"
}
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts"
],
"license": "MIT",
"dependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-default-model": "workspace:^",
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-native-command": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-util-crypto": "workspace:^",
"@deepseek-ai/schemastery": "workspace:^",
"fflate": "^0.8.2",
"zod": "^4.4.3"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^"
}
}
-137
View File
@@ -1,137 +0,0 @@
/**
* Host-side ApiProxy implementation. Signature discipline: unary takes the
* narrow RpcRequest<P> and echoes request.rpcId on the RpcResponse<T>.
*/
import { homedir } from 'node:os'
import type { Context } from '@deepseek-ai/cordis'
import type { ModelSelection } from '@deepseek-ai/dsh-agent'
import { canOpenNativePath } from '@deepseek-ai/dsh-native-command'
import type { ApiProxy } from './api/index.ts'
import {
DEFAULT_SESSION_LOG_COMPRESSION_LEVEL,
flushLiveSessionLog,
sessionLogExportDeps,
sessionLogZipFilename,
streamSessionLogZip,
type SessionLogExportReady,
type SessionLogCompressionLevel,
} from './session-export.ts'
import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
import type { RpcRequest, RpcResponse } from './api/rpc.ts'
/** Wrap an ok result echoing the request's rpcId. */
function ok<T>(request: RpcRequest<unknown>, value: T): RpcResponse<T> {
return { rpcId: request.rpcId, result: { ok: true, value } }
}
/** Deployment metadata and Host integrations consumed by the API implementation. */
export interface ApiProxyDefaults {
/** Current deployment model selection reported by `host.describe`. */
defaultModelSelection: () => ModelSelection
/** Project hint reported by `host.describe`; must match Session Controller's default cwd. */
cwd: string
/** Validated DEFLATE level for session-log ZIP entries; defaults to 6. */
sessionExportCompressionLevel?: SessionLogCompressionLevel
/**
* Whether `host.describe` reports that the Client may offer native path actions.
* Absent, platform detection decides ({@link canOpenNativePath}).
*/
canOpenPath?: () => boolean
}
/**
* Implement ApiProxy over a composed host context.
* @param ctx - a context with the Host spine mounted.
* @param defaults - host routing and project-directory defaults.
* @returns the ApiProxy implementation.
*/
export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
const sessionExportCompressionLevel = defaults.sessionExportCompressionLevel
?? DEFAULT_SESSION_LOG_COMPRESSION_LEVEL
/** Whether this deployment can hand a path to a native opener at all. */
function canOpenPaths(): boolean {
if (defaults.canOpenPath !== undefined) return defaults.canOpenPath()
return canOpenNativePath()
}
return {
host: {
describe(request) {
// TODO(apiproxy-version): read the version from apps/cli/package.json.
const selection = defaults.defaultModelSelection()
return Promise.resolve(ok(request, {
version: '0.0.1',
// This must match the default cwd supplied to Session Controller so
// the UI's project hint names where a cwd-less create request lands.
cwd: defaults.cwd,
// Read live for the same reason: this is what the NEXT session will
// start from, so a saved default has to be what it reports.
provider: selection.provider,
model: selection.model,
attachedSessions: ctx.agents.list().length,
home: homedir(),
canOpenPath: canOpenPaths(),
}))
},
},
downloads: {
async sessionLog(request, signal) {
// Clean error path first: missing services answer 500 and a missing
// root artifact 404 before any zip byte is produced. The root content
// read here is reused as the first zip entry, so nothing is read twice.
const deps = sessionLogExportDeps(ctx)
if (deps.sessionQuery === undefined || deps.sessionPersistence === undefined || deps.attachments === undefined) {
return new Response(
'session log export is unavailable: missing session-query, session-persistence, or attachments service',
{ status: 500 },
)
}
if (!deps.sessionPersistence.supportsRawArtifacts) {
return new Response(
'session log export is unavailable: the persistence backend does not expose per-session raw artifacts',
{ status: 501 },
)
}
const ready: SessionLogExportReady = {
sessionQuery: deps.sessionQuery,
sessionPersistence: deps.sessionPersistence,
attachments: deps.attachments,
sessions: deps.sessions,
}
let root: SessionRawArtifact | undefined
try {
await flushLiveSessionLog(deps, request.sessionId, signal)
root = await deps.sessionPersistence.readRaw(request.sessionId, signal)
signal.throwIfAborted()
} catch {
signal.throwIfAborted()
// Root preparation failure: answer 500 without echoing the error,
// which may carry absolute host paths into the browser error bar.
return new Response('session log export failed to prepare the stored artifact', { status: 500 })
}
if (root === undefined) {
return new Response('session not found', { status: 404 })
}
return new Response(
streamSessionLogZip(
ready,
root,
request.sessionId,
request.includeDescendants === true,
sessionExportCompressionLevel,
signal,
),
{
headers: {
'content-type': 'application/zip',
'content-disposition': `attachment; filename="${sessionLogZipFilename(request.sessionId)}"`,
},
},
)
},
},
}
}
@@ -1,25 +0,0 @@
/**
* downloads domain zod schemas. The download surface has no wire
* envelope: the request arrives as query parameters (all strings), so its
* request schema parses the raw query-parameter object into the method's
* exact request shape.
*/
import { z } from 'zod'
import type { DownloadsApi } from './downloads.ts'
import { sessionIdSchema } from './ids.schema.ts'
/**
* session.export query params → the sessionLog request. `includeDescendants`
* accepts exactly `true`/`false`/absent; any other value is rejected (400) so
* a misspelled flag cannot silently under-export.
*/
export const sessionLogQuerySchema = z
.object({
sessionId: sessionIdSchema,
includeDescendants: z.union([z.literal('true'), z.literal('false')]).optional(),
})
.transform(query => ({
sessionId: query.sessionId,
...(query.includeDescendants === 'true' ? { includeDescendants: true } : {}),
})) satisfies z.ZodType<Parameters<DownloadsApi['sessionLog']>[0]>
@@ -1,24 +0,0 @@
/**
* downloads domain contract: Host-only GET download surfaces with no wire
* envelope. Carrier routes answer these directly, and the browser
* `IApiClient` never exposes them.
*/
import type { SessionId } from '@deepseek-ai/dsh-session/types'
/** Host-only download surfaces (no wire envelope; absent from IApiClient). */
export interface DownloadsApi {
/**
* Stream one session-log ZIP — the root artifact verbatim plus each subagent
* descendant's — as an attachment response. The carrier's GET route answers
* this directly; the browser never calls it.
* @param request - the root session id and whether to include descendants.
* @param signal - cancellation for the underlying reads.
* @returns the ZIP attachment response; missing services answer 500 and a
* missing root session 404 before any byte is produced.
*/
sessionLog(
request: { sessionId: SessionId; includeDescendants?: boolean },
signal: AbortSignal,
): Promise<Response>
}
@@ -1,21 +0,0 @@
/**
* host domain zod schemas (names derived from map keys).
*/
import { z } from 'zod'
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
/** host.describe request payload (empty object literal). */
export const hostDescribeRequestSchema = z.object({}) satisfies z.ZodType<Wire<RequestPayload<'host.describe'>>>
/** host.describe response value. */
export const hostDescribeValueSchema = z.object({
version: z.string(),
cwd: z.string(),
provider: z.string().optional(),
model: z.string().optional(),
attachedSessions: z.number().int().nonnegative(),
home: z.string(),
canOpenPath: z.boolean(),
}) satisfies z.ZodType<Wire<ResponseValue<'host.describe'>>>
-30
View File
@@ -1,30 +0,0 @@
/**
* host domain contract. No protocol version: client and host ship
* together; introduce protocolVersion only when an independently released client appears.
*/
import type { RpcRequest, RpcResponse } from './rpc.ts'
/** Host-level unary methods. */
export interface HostApi {
/**
* One-shot host snapshot. Empty payload uses the literal `{}` (extend in place when fields arrive).
* version = the host app's (apps/cli) package.json version; cwd = the host process working
* directory (root for session persistence and tool execution); provider/model = the defaults
* 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);
* home = the host account home directory (Web display abbreviation on POSIX);
* canOpenPath = whether this deployment can hand a path to a user-visible native desktop.
*/
describe(request: RpcRequest<{}>): Promise<RpcResponse<{
version: string
cwd: string
provider?: string
model?: string
attachedSessions: number
home: string
canOpenPath: boolean
}>>
}
@@ -1,7 +0,0 @@
/** Branded identity schemas shared by the remaining API Proxy domains. */
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { z } from 'zod'
/** Non-empty Session identity after transport validation. */
export const sessionIdSchema = z.string().min(1) as unknown as z.ZodType<SessionId>
-44
View File
@@ -1,44 +0,0 @@
/**
* apiproxy contract-layer barrel. api/ has zero Node dependencies and is
* importable from the browser; the TypeScript interfaces are authoritative,
* while HTTP supplies the carrier.
*/
import type { HostApi } from './host.ts'
import type { DownloadsApi } from './downloads.ts'
/** Root interface of the unified API. New client-request domain = one new file pair + one field here + one map row. */
export interface ApiProxy {
host: HostApi
/** Host-only download surfaces (GET, no wire envelope); absent from IApiClient. */
downloads: DownloadsApi
}
// ---- Domain interfaces and payload entities ----
export type {
ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelSelection,
} from '@deepseek-ai/dsh-api-session-controller/types'
export type { HostApi } from './host.ts'
export type { DownloadsApi } from './downloads.ts'
// ---- Message layer: narrow forms (domain-signature view) ----
export type { RpcRequest, RpcResponse } from './rpc.ts'
// ---- Message layer: unary wire forms ----
export type {
ClientRequest,
RpcMessage,
ServerResponse,
} from './rpc.ts'
// ---- Errors and ids ----
export { RpcId, transportError } from './rpc.ts'
export type { RpcError, RpcErrorCode, RpcErrorDetailsMap, RpcResult } from './rpc.ts'
export {
clientRequestSchema,
serverResponseSchema,
} from './rpc.schema.ts'
// ---- Method registry and derived generics ----
export type { RequestPayload, ResponseValue, RpcMethodMap } from './rpc-map.ts'
-23
View File
@@ -1,23 +0,0 @@
/**
* RPC method registry and signature-derived generics. Map keys are the wire
* path segments of API Proxy unary calls.
*/
import type { HostApi } from './host.ts'
import type { RpcResponse } from './rpc.ts'
/**
* Method name → method signature. Signatures are the single source of truth; payload/value
* types are always derived from here. A method may declare a trailing AbortSignal after the
* request; the carrier passes its request signal, never a wire field.
*/
export interface RpcMethodMap {
'host.describe': HostApi['describe']
}
/** Business request payload of method K (reaches through the RpcRequest narrow form to payload). */
export type RequestPayload<K extends keyof RpcMethodMap> = Parameters<RpcMethodMap[K]>[0]['payload']
/** Business return value of method K (reaches through the RpcResponse narrow form to infer the ok value of result). */
export type ResponseValue<K extends keyof RpcMethodMap> =
Awaited<ReturnType<RpcMethodMap[K]>> extends RpcResponse<infer T> ? T : never
@@ -1,83 +0,0 @@
/**
* Message-layer zod schemas for API Proxy unary calls and Host pushes. The
* payload slot is unknown in the full-form schemas — business payloads get a
* second parse dispatched by method (two-level parse discipline). Brand cast
* point: rpcIdSchema, and only there.
*/
import { z } from 'zod'
import type { z as zCore } from 'zod'
type ZodIssue = zCore.core.$ZodIssue
import type { ClientRequest, RpcError, RpcId, ServerResponse } from './rpc.ts'
/**
* Wire widening of a contract type: widens every property (deeply) to `original | undefined`.
* The repo enables exactOptionalPropertyTypes while zod `.optional()` outputs `T | undefined`,
* so `satisfies z.ZodType<ContractType>` is unusable across the board; anchoring is always
* written `satisfies z.ZodType<Wire<ContractType>>` — the widening only adds undefined, so
* missing fields / wrong types still fail to compile. On the JSON wire, "absent" and
* "value undefined" serialize identically, so the widening loses no validation semantics.
*/
export type Wire<T> = T extends readonly (infer E)[] ? Wire<E>[]
: T extends object ? { [K in keyof T]: Wire<T[K]> | undefined }
: T
/**
* RpcId: one brand cast after schema validation (the only cast point in this
* file). No min-length: the id is an opaque echo token, and rejecting values
* here would only turn a correlatable error report into a client-side parse
* failure (the handler substitutes a sentinel when a request's id is unreadable).
*/
export const rpcIdSchema = z.string() as unknown as z.ZodType<RpcId>
/** Error body: discriminated by code, per-branch details aligned to RpcErrorDetailsMap; details is required. */
export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code', [
z.object({ code: z.literal('bad-request'), message: z.string(), details: z.object({ issues: z.array(z.custom<ZodIssue>()) }) }),
z.object({ code: z.literal('cancelled'), message: z.string(), details: z.object({}) }),
z.object({ code: z.literal('session-not-found'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
z.object({ code: z.literal('invalid-time-zone'), message: z.string(), details: z.object({ value: z.string() }) }),
z.object({ code: z.literal('agent-preset-read-only'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }),
z.object({ code: z.literal('agent-preset-locked'), message: z.string(), details: z.object({ sessionId: z.string(), agentPreset: z.string() }) }),
z.object({ code: z.literal('agent-preset-not-found'), message: z.string(), details: z.object({ agentPreset: z.string(), available: z.array(z.string()) }) }),
z.object({ code: z.literal('agent-preset-invalid'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }),
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
]) as unknown as z.ZodType<RpcError>
/**
* Business success/failure result schema (generic, reusable).
* @param value - Schema for the business value.
* @returns Schema for RpcResult<T>.
*/
export function rpcResultSchema<T>(value: z.ZodType<T>): z.ZodUnion<readonly [z.ZodType, z.ZodType]> {
return z.union([
z.object({ ok: z.literal(true), value }),
z.object({ ok: z.literal(false), error: rpcErrorSchema }),
])
}
// ---- Wire envelope schemas (payload/result.value stay wide for the second business parse) ----
// The wide value slot is optional: a void business result serializes with no
// `value` field at all. Each endpoint's own second parse still requires its
// declared value, so absence never passes for a method that returns data.
/** ClientRequest full form (payload stays wide — the business layer runs the second parse). */
export const clientRequestSchema = z.object({
type: z.literal('client-request'),
rpcId: rpcIdSchema,
method: z.string(),
payload: z.unknown(),
}) as unknown as z.ZodType<ClientRequest>
/** ServerResponse full form (result.value stays wide). */
export const serverResponseSchema = z.object({
type: z.literal('server-response'),
rpcId: rpcIdSchema,
result: rpcResultSchema(z.unknown().optional()),
}) as unknown as z.ZodType<ServerResponse>
/** Wire full-form union (discriminated by type). */
export const rpcMessageSchema = z.discriminatedUnion('type', [
clientRequestSchema as unknown as z.ZodObject<z.ZodRawShape>,
serverResponseSchema as unknown as z.ZodObject<z.ZodRawShape>,
])
-104
View File
@@ -1,104 +0,0 @@
/**
* API Proxy request and response message model. Logical messages remain
* independent of their physical carrier.
* api/ contract layer: zero Node dependencies, importable from the browser.
*/
import type { z as zCore } from 'zod'
type ZodIssue = zCore.core.$ZodIssue
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
/**
* Message correlation id: the initiator mints it on a request; a response
* echoes the matching request's rpcId and never mints a new one.
*/
export type RpcId = Branded<'rpc-id'>
/**
* Brands a string as RpcId (same precedent as core `SessionId()`). The Client
* mints each request id and the Host echoes it in the response.
* @param id - Raw id string (implementations mint UUIDs; tests may pass fixtures).
* @returns The same string, branded (compile-time cast, zero runtime cost).
*/
export function RpcId(id: string): RpcId {
return id as RpcId
}
/** Error code → details type map (a second table isomorphic to RpcMethodMap). New code = one row here + one branch in the error schema. */
export interface RpcErrorDetailsMap {
'bad-request': { issues: ZodIssue[] }
'cancelled': {}
'session-not-found': { sessionId: SessionId }
'invalid-time-zone': { value: string }
'agent-preset-read-only': { agentPreset: string; reason: string }
'agent-preset-locked': { sessionId: SessionId; agentPreset: string }
'agent-preset-not-found': { agentPreset: string; available: readonly string[] }
'agent-preset-invalid': { agentPreset: string; reason: string }
'agent-busy': { reason: string }
'internal': {}
}
/** Closed error-code union (the keys of RpcErrorDetailsMap). */
export type RpcErrorCode = keyof RpcErrorDetailsMap
/**
* Distributive union expanded from the map: code is the discriminant, so
* `switch (error.code)` narrows details. details is required (internal uses an explicit {}).
*/
export type RpcError = {
[C in RpcErrorCode]: { code: C; message: string; details: RpcErrorDetailsMap[C] }
}[RpcErrorCode]
/** Business success/failure result: the result slot of a unary response; methods never throw business errors. */
export type RpcResult<T> = { ok: true; value: T } | { ok: false; error: RpcError }
/**
* Fold a transport exception into the RpcResult error branch (unified error
* API; 'internal' as the catch-all code). Lives with RpcResult so every
* carrier consumer folds the same way.
* @param error - the thrown value from the carrier.
* @returns the error branch of an RpcResult.
*/
export function transportError<T>(error: unknown): RpcResult<T> {
return {
ok: false,
error: { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} },
}
}
/**
* Signature-layer narrow form, request side (domain-interface view, shared by
* both directions): rpcId is explicit in the signature, never mixed into the
* business payload; the type tag and method are filled in by the carrier layer.
*/
export interface RpcRequest<P> {
rpcId: RpcId
payload: P
}
/** Signature-layer narrow form, response side: rpcId always echoes the matching request. */
export interface RpcResponse<T> {
rpcId: RpcId
result: RpcResult<T>
}
// ---- Wire full forms ----
/** Call initiated by the client (wire carrier: POST /api/<method> body). */
export interface ClientRequest {
type: 'client-request'
rpcId: RpcId
method: string
payload: unknown
}
/** Response to a ClientRequest (wire carrier: the HTTP response body of that POST); rpcId echoed. */
export interface ServerResponse {
type: 'server-response'
rpcId: RpcId
result: RpcResult<unknown>
}
/** Authoritative wire full-form union; narrow via `switch (message.type)`. */
export type RpcMessage = ClientRequest | ServerResponse
-204
View File
@@ -1,204 +0,0 @@
/**
* Client side of the fetch carrier. AbstractApiClient holds request correlation,
* envelope wrap/unwrap, zod parsing, and the payload-direct
* IApiClient domain methods (business code never mints). Platform differences ride two aspects:
* abstract doFetch (transport) + overridable onEnvelope (tap). ApiProxy (the impl face) is untouched.
*/
import type { z } from 'zod'
import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
import type { RequestPayload, ResponseValue, RpcMethodMap } from '../api/rpc-map.ts'
import type { ClientRequest, RpcMessage, RpcResponse } from '../api/rpc.ts'
import { RpcId } from '../api/rpc.ts'
import type { Wire } from '../api/rpc.schema.ts'
import { serverResponseSchema } from '../api/rpc.schema.ts'
import { hostDescribeValueSchema } from '../api/host.schema.ts'
/**
* Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary
* methods take the business payload directly — the carrier mints the rpcId and wraps the
* envelope. Business code needing the call's rpcId reads it from the RpcResponse echo.
* Unary methods accept an optional external AbortSignal as the last parameter.
* Bounded calls merge it with the instance timeout via AbortSignal.any; user-paced calls
* carry only that external signal. In both cases the signal rides beside the request, never
* on the wire, like the stream signatures.
* Relationship: ApiProxy is the narrow-form signature contract the impl side implements;
* IApiClient is the payload-direct view clients consume; AbstractApiClient bridges the two.
* Derived per method key from RpcMethodMap so a map row addition updates this mechanically.
*/
export interface IApiClient {
host: {
describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.describe'>>>
}
}
/**
* S→C second-level parse table: value schema by method (the response-path
* mirror of the handler's request table; key coverage compiler-enforced against RpcMethodMap).
*/
const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseValue<K>>> } = {
'host.describe': hostDescribeValueSchema,
}
/** Default timeout for bounded unary calls (rpc-compare 2026-07-19: a hung host must not leave callers pending forever). */
const DEFAULT_TIMEOUT_MS = 30_000
/** URL base for in-process handler injection (fake authority, opencode precedent). */
const INTERNAL_BASE = 'http://dsh.internal'
/**
* Abstract fetch-carrier client. Subclasses supply the transport (doFetch) and may refine the
* per-message tap (onEnvelope) — platform aspects stay in subclasses, protocol invariants stay
* here. Envelope observation is a first-class aspect of this data middle layer: the instance
* owns a microtask-batched buffer (frame storms must not cost one consumer update per frame),
* and observers subscribe via subscribeEnvelopes. The isomorphic point survives: an in-process
* subclass whose doFetch is toFetchHandler(api).fetch never touches the network.
*/
export abstract class AbstractApiClient implements IApiClient {
/** Instance-owned observation buffer (module-level state would leak across instances/tests). */
private envelopeBatch: RpcMessage[] = []
private flushScheduled = false
private readonly envelopeListeners = new Set<(batch: readonly RpcMessage[]) => void>()
/** @param timeoutMs - timeout for unary calls. */
constructor(protected readonly timeoutMs: number = DEFAULT_TIMEOUT_MS) {}
/** Transport aspect: browser fetch, injected handler.fetch, IPC bridge, ... */
protected abstract doFetch(input: URL, init?: RequestInit): Promise<Response>
/**
* Subscribe to batched envelope observation (diagnostics/logging consumers).
* Batches follow microtask boundaries; a listener throw is isolated (observation
* must never break the carrier).
* @param listener - receives each flushed batch in arrival order.
* @returns unsubscribe function.
*/
subscribeEnvelopes(listener: (batch: readonly RpcMessage[]) => void): () => void {
this.envelopeListeners.add(listener)
return () => {
this.envelopeListeners.delete(listener)
}
}
/** Per-message tap: feeds the instance buffer. Subclasses may override to observe unbatched (call super to keep batching). */
protected onEnvelope(message: RpcMessage): void {
if (this.envelopeListeners.size === 0) return
this.envelopeBatch.push(message)
if (this.flushScheduled) return
this.flushScheduled = true
queueMicrotask(() => {
this.flushScheduled = false
// Never empty here: a flush is only ever scheduled by the push above,
// and this callback is the sole drain point.
const batch = this.envelopeBatch
this.envelopeBatch = []
for (const notify of this.envelopeListeners) {
try {
notify(batch)
} catch (error) {
console.error('[apiproxy] envelope listener threw:', error)
}
}
})
}
/** Browser = same-origin (a fake authority would fail DNS on real requests); no-location env (Node) = fake authority. */
protected resolveBase(): string {
const loc = (globalThis as { location?: { origin?: string } }).location
return loc?.origin !== undefined && loc.origin !== 'null' ? loc.origin : INTERNAL_BASE
}
protected mintRpcId(): RpcId {
// Not crypto.randomUUID: browsers withhold it outside secure contexts,
// and this base also mints on pages served over plain HTTP.
return RpcId(randomUUID())
}
/**
* Shared POST leg of unary calls: JSON body,
* default timeout merged with the caller's external signal, non-2xx → transport throw.
*/
private async postJson(
path: string,
body: ClientRequest,
signal: AbortSignal | undefined,
): Promise<Response> {
const requestSignal = signal === undefined
? AbortSignal.timeout(this.timeoutMs)
: AbortSignal.any([AbortSignal.timeout(this.timeoutMs), signal])
const response = await this.doFetch(new URL(path, this.resolveBase()), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
signal: requestSignal,
})
if (!response.ok) throw new Error(`transport failure for ${path}: HTTP ${response.status}`)
return response
}
/**
* Unary protocol path: mint → tap → POST full form → envelope parse → verify
* echo → value parse → tap → narrow. Virtual so a fake carrier (fixture) can
* override transport at this layer.
*/
protected async callUnary<K extends keyof RpcMethodMap>(
method: K,
payload: RequestPayload<K>,
signal?: AbortSignal,
): Promise<RpcResponse<ResponseValue<K>>> {
const message: ClientRequest = { type: 'client-request', rpcId: this.mintRpcId(), method, payload }
this.onEnvelope(message)
const response = await this.postJson(`/api/${method}`, message, signal)
const full = serverResponseSchema.parse(await response.json())
this.onEnvelope(full)
if (full.rpcId !== message.rpcId) throw new Error(`rpcId mismatch for ${method}: sent ${message.rpcId}, got ${full.rpcId}`)
if (!full.result.ok) return { rpcId: full.rpcId, result: full.result }
// Second-level S→C parse: the ok value must match the method's Value schema (mirror of the
// handler's request-payload parse). The cast collapses the Wire<> widening, same as the handler side.
const value = UNARY_VALUE_SCHEMAS[method].parse(full.result.value) as ResponseValue<K>
return { rpcId: full.rpcId, result: { ok: true, value } }
}
// ---- IApiClient API (arrow properties so destructured/passed references stay bound) ----
readonly host: IApiClient['host'] = {
describe: (payload, signal) => this.callUnary('host.describe', payload, signal),
}
}
/**
* In-process client over an injected fetch-shaped handler (the isomorphic point:
* `new InProcessApiClient(toFetchHandler(api))` never touches the network). Lives here because
* in-process injection is this package's own capability (handler and client are both local).
*/
export class InProcessApiClient extends AbstractApiClient {
constructor(private readonly handler: { fetch: typeof fetch }, timeoutMs?: number) {
super(timeoutMs)
}
/**
* Faithful to real fetch: reject on signal abort even when the in-process
* handler ignores the signal (a hung impl must not defeat timeout/cancel).
*/
protected doFetch(input: URL, init?: RequestInit): Promise<Response> {
const signal = init?.signal ?? undefined
if (signal === undefined) return this.handler.fetch(input, init)
if (signal.aborted) return Promise.reject(abortError(signal))
return new Promise((resolve, reject) => {
const onAbort = (): void => { reject(abortError(signal)) }
signal.addEventListener('abort', onAbort, { once: true })
this.handler.fetch(input, init)
.then(resolve, reject)
.finally(() => { signal.removeEventListener('abort', onAbort) })
})
}
}
/** Mirror fetch's abort rejection: the signal's reason when present, else a DOMException-style AbortError. */
function abortError(signal: AbortSignal): Error {
const reason: unknown = signal.reason
if (reason instanceof Error) return reason
if (typeof reason === 'string') return new Error(reason)
return new Error('This operation was aborted')
}
-158
View File
@@ -1,158 +0,0 @@
/**
* Server side of the fetch carrier: maps an ApiProxy onto a pure
* WHATWG Request->Response function. Two-level parse: full form (type/rpcId/method +
* path==method) -> payload dispatched per method. HTTP status expresses only the carrier
* (404 unknown path / 415 non-JSON media type / 400 non-JSON body / 500 handler crash);
* business errors are always 200 + ServerResponse.
*/
import type { z } from 'zod'
import type { ApiProxy } from '../api/index.ts'
import { sessionLogQuerySchema } from '../api/downloads.schema.ts'
import type { RequestPayload, ResponseValue, RpcMethodMap } from '../api/rpc-map.ts'
import type { ClientRequest, RpcError, RpcRequest, RpcResponse, ServerResponse } from '../api/rpc.ts'
import { RpcId } from '../api/rpc.ts'
import type { Wire } from '../api/rpc.schema.ts'
import { clientRequestSchema } from '../api/rpc.schema.ts'
import { hostDescribeRequestSchema } from '../api/host.schema.ts'
/**
* Unary dispatch table, keyed by (and compiler-locked to) RpcMethodMap: a map row without a
* route row fails to compile, and each row's schema/invoke pair is checked against that row's
* payload type — a schema pasted onto the wrong row is a type error, not a runtime surprise.
* Schemas anchor to the Wire<> widening (the repo-wide exactOptionalPropertyTypes accommodation
* documented on Wire); the dispatch point carries the one Wire→exact cast.
* Every invoke receives the carrier Request's signal; routes whose contract
* declares a signal parameter forward it, and the rest ignore it.
*/
type UnaryRoutes = {
[K in keyof RpcMethodMap]: {
schema: z.ZodType<Wire<RequestPayload<K>>>
invoke(api: ApiProxy, request: RpcRequest<RequestPayload<K>>, signal: AbortSignal): Promise<RpcResponse<ResponseValue<K>>>
}
}
const UNARY_ROUTES: UnaryRoutes = {
'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },
}
/** Route lookup that narrows an arbitrary path segment to a map key (single cast point for the string→key refinement). */
function methodFor(path: string): keyof RpcMethodMap | undefined {
return Object.hasOwn(UNARY_ROUTES, path) ? path as keyof RpcMethodMap : undefined
}
/**
* Sentinel rpcId for error responses to envelopes whose own rpcId is unreadable: the response
* must still be a valid ServerResponse (a self-violating shape would turn the server's explicit
* bad-request report into a client-side parse failure). Fixed value, documented here as wire contract.
*/
const INVALID_REQUEST_RPC_ID = RpcId('invalid-request')
/** Wrap a business error as a ServerResponse full form (rpcId backfilled; an unreadable rpcId uses the invalid-request sentinel). */
function errorResponse(rpcId: RpcId, error: RpcError): Response {
const body: ServerResponse = { type: 'server-response', rpcId, result: { ok: false, error } }
return Response.json(body)
}
/** Complete the impl's narrow form into a ServerResponse full form. */
function fullResponse(narrow: RpcResponse<unknown>): Response {
const body: ServerResponse = { type: 'server-response', rpcId: narrow.rpcId, result: narrow.result }
return Response.json(body)
}
/**
* Parse the payload and invoke one unary route. Generic over the map key so
* the row's schema/invoke pairing typechecks; the only cast collapses the
* Wire<> widening back to the exact payload (undefined-valued properties and
* absent ones are indistinguishable after JSON transport).
*/
// K appears once in the signature but ties the UNARY_ROUTES[K] row lookup to its own
// schema/invoke pairing; a union parameter degrades the row to an uninvokable intersection.
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters
async function handleUnary<K extends keyof RpcMethodMap>(
api: ApiProxy, method: K, message: ClientRequest, signal: AbortSignal,
): Promise<Response> {
const route = UNARY_ROUTES[method]
const payload = route.schema.safeParse(message.payload)
if (!payload.success) {
return errorResponse(message.rpcId, { code: 'bad-request', message: `invalid payload for ${method}`, details: { issues: payload.error.issues } })
}
try {
return fullResponse(await route.invoke(api, { rpcId: message.rpcId, payload: payload.data }, signal))
} catch (error: unknown) {
// The impl never throws business errors; reaching here means the implementation itself crashed — 500, carrier layer.
return new Response(`handler failure: ${String(error)}`, { status: 500 })
}
}
/**
* Wraps an ApiProxy into a pure fetch function (isomorphic point: feed the returned fetch straight to InProcessApiClient).
* @param api - the host-side ApiProxy implementation.
* @returns an object holding `fetch(Request)`; paths outside /api/ return 404.
*/
export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } {
return {
// Signature matches global fetch: the isomorphic point hands this function to InProcessApiClient as its transport aspect,
// Clients call in (url, init) form — normalize to Request before handling.
async fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const req = input instanceof Request ? input : new Request(input, init)
const url = new URL(req.url)
const path = url.pathname
// No-envelope Host-only download channel:
// physical routes that answer directly, without a wire envelope.
if (path === '/api/session.export' && (req.method === 'GET' || req.method === 'HEAD')) {
// Query params are a different boundary from the POST envelope, but
// the request still casts its brands only through the domain schema.
const parsed = sessionLogQuerySchema.safeParse(Object.fromEntries(url.searchParams))
if (!parsed.success) {
return new Response('missing or invalid sessionId query parameter', { status: 400 })
}
const response = await api.downloads.sessionLog(parsed.data, req.signal)
if (req.method === 'GET') return response
await response.body?.cancel()
return new Response(null, { status: response.status, headers: response.headers })
}
if (req.method !== 'POST' || !path.startsWith('/api/')) {
return new Response('not found', { status: 404 })
}
// Cross-site write fence: browsers send "simple" POSTs (text/plain,
// form encodings) without a CORS preflight, so a malicious page could
// otherwise execute side-effectful RPCs blind — the response stays
// unreadable cross-origin, but the requested mutation would still run. Only the
// JSON media type is accepted; anything else is forced into a preflight
// this server never answers. 415 = carrier layer, like the 400 below.
const mediaType = req.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()
if (mediaType !== 'application/json') {
return new Response('content type must be application/json', { status: 415 })
}
let body: unknown
try {
body = await req.json()
} catch {
// 400 = carrier layer (body is not even JSON); valid JSON with a bad shape goes 200 + bad-request.
return new Response('body is not JSON', { status: 400 })
}
const method = methodFor(path.slice('/api/'.length))
if (method === undefined) return new Response('not found', { status: 404 })
const envelope = clientRequestSchema.safeParse(body)
if (!envelope.success) {
// Best effort at correlation: salvage a string rpcId from the raw body;
// otherwise the fixed sentinel keeps the response a valid ServerResponse.
const rawId = (body as { rpcId?: unknown } | null)?.rpcId
const rpcId = typeof rawId === 'string' ? RpcId(rawId) : INVALID_REQUEST_RPC_ID
return errorResponse(rpcId, { code: 'bad-request', message: 'invalid client-request message', details: { issues: envelope.error.issues } })
}
const message: ClientRequest = envelope.data
if (message.method !== method) {
return errorResponse(message.rpcId, { code: 'bad-request', message: `method "${message.method}" does not match path "${method}"`, details: { issues: [] } })
}
return handleUnary(api, method, message, req.signal)
},
}
}
-91
View File
@@ -1,91 +0,0 @@
/**
* @deepseek-ai/dsh-host-apiproxy — the API gateway every client shape shares:
* the ApiProxy contract (api/: types + zod schemas, browser-safe), the fetch
* carrier pair (fetch/: toFetchHandler on the host side, AbstractApiClient +
* platform subclasses on the client side), and the host-side implementation
* (api-proxy.ts: createApiProxy + the ApiProxyService gateway plugin providing
* `ctx.apiProxy`). Transport-agnostic by design: this package registers no
* routes — physical carriers wrap `ctx.apiProxy` themselves.
*
* The gateway consumes `ctx.agentDefaultModel` only for the deployment metadata
* returned by `host.describe`; Session Controller owns Session model selection.
*/
import { Context, Service } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type {} from '@deepseek-ai/dsh-agent-default-model'
import type { ApiProxy } from './api/index.ts'
import { createApiProxy } from './api-proxy.ts'
import {
DEFAULT_SESSION_LOG_COMPRESSION_LEVEL,
type SessionLogCompressionLevel,
} from './session-export.ts'
export type * from './api/index.ts'
export { RpcId } from './api/rpc.ts'
export { toFetchHandler } from './fetch/handler.ts'
export { AbstractApiClient, InProcessApiClient } from './fetch/client.ts'
export type { IApiClient } from './fetch/client.ts'
export { createApiProxy } from './api-proxy.ts'
export type { ApiProxyDefaults } from './api-proxy.ts'
declare module '@deepseek-ai/cordis' {
interface Context {
/** The host-side ApiProxy implementation (the transport-agnostic gateway face). */
apiProxy: ApiProxy
}
}
/** Gateway plugin configuration. */
export interface Config {
/**
* Whether this deployment can hand paths to a native desktop opener —
* the `hasDocument` capability the agent-preset roster reports. Absent,
* the platform is asked (macOS/Windows/WSL yes; Linux only with a display
* server); set it explicitly where detection misleads, e.g. `false` in a
* container whose DISPLAY points nowhere a user can see.
*/
nativeOpen?: boolean
/**
* DEFLATE level for every session-log ZIP entry: `0` stores without
* compression, `1` favors CPU/latency, and `9` favors archive size.
* @default 6
*/
sessionExportCompressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
}
/**
* The API gateway service: implements the ApiProxy contract over the composed
* host context and provides it as `ctx.apiProxy`. Its cwd metadata must match
* the default project directory supplied to Session Controller.
*/
export class ApiProxyService extends Service implements ApiProxy {
static inject = [
'agentDefaultModel', 'agents', 'attachments', 'sessions', 'sessionQuery',
]
static Config: z<Config> = z.object({
nativeOpen: z.boolean(),
sessionExportCompressionLevel: z.number().step(1).min(0).max(9)
.default(DEFAULT_SESSION_LOG_COMPRESSION_LEVEL) as z<SessionLogCompressionLevel>,
})
readonly host: ApiProxy['host']
readonly downloads: ApiProxy['downloads']
constructor(ctx: Context, config: Config) {
super(ctx, 'apiProxy')
const api = createApiProxy(ctx, {
defaultModelSelection: () => ctx.agentDefaultModel.currentSelection(),
cwd: process.cwd(),
...config.nativeOpen === undefined ? {} : { canOpenPath: () => config.nativeOpen as boolean },
...(config.sessionExportCompressionLevel === undefined
? {}
: { sessionExportCompressionLevel: config.sessionExportCompressionLevel }),
})
this.host = api.host
this.downloads = api.downloads
}
}
export default ApiProxyService
-32
View File
@@ -1,32 +0,0 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-host-apiproxy`.
* @module @deepseek-ai/dsh-host-apiproxy/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-host-apiproxy'
/** Cordis companion plugin name. */
export const name = 'host-apiproxy-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this package is the wire contract layer plus the
* host-side unary gateway over services owned elsewhere. rpcId round-trip and
* schema acceptance are enforced at the carrier boundary and exercised by the
* protocol-isomorphism suite.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
@@ -1,457 +0,0 @@
/**
* Host-side session-log download: streams one ZIP archive whose files are the
* sessions' stored artifact text verbatim plus every referenced media object.
* The root artifact sits under its original base name (`session.jsonl`); each
* subagent descendant under `subagents/<id>/<filename>`; each image referenced
* by any included log under `media/<attachmentId>.<ext>` (content-addressed,
* so one archive never duplicates a shared image). No manifest is written —
* every file is byte-identical to the backend's durable artifact or attachment
* store and self-describing through its own header line or media type. Before
* each live session's artifact read, the SessionStore flush barrier makes the
* current in-memory log durable; cold sessions need no barrier. Request abort
* and response-consumer cancellation share one producer signal and terminate
* the active compressor.
* Compression runs on the host with fflate's streaming Zip API, so the archive
* bytes are produced incrementally and the host never holds the whole archive
* in one buffer; production waits for consumer pull whenever the response queue
* reaches its byte high-water mark, so a slow consumer bounds accumulation to
* the fixed 64 KiB response queue plus one synchronous fflate push.
* @module
*/
import { Zip, ZipDeflate } from 'fflate'
import type { Context } from '@deepseek-ai/cordis'
import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { SessionLineageNode, SessionQueryEngine } from '@deepseek-ai/dsh-session-query'
import type { SessionId, SessionStore } from '@deepseek-ai/dsh-session'
import type { SessionPersistence, SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
/** Valid fflate DEFLATE levels accepted by session-log export. */
export type SessionLogCompressionLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
/** Balanced default used when a direct createApiProxy caller omits deployment config. */
export const DEFAULT_SESSION_LOG_COMPRESSION_LEVEL: SessionLogCompressionLevel = 6
/** The services a session-log export needs (the live-session store is optional). */
export interface SessionLogExportDeps {
readonly sessionQuery: SessionQueryEngine | undefined
readonly sessionPersistence: SessionPersistence | undefined
readonly attachments: AttachmentStore | undefined
readonly sessions: SessionStore | undefined
}
/** The export services narrowed to the mounted ones streaming actually reads. */
export interface SessionLogExportReady {
readonly sessionQuery: SessionQueryEngine
readonly sessionPersistence: SessionPersistence
readonly attachments: AttachmentStore
readonly sessions: SessionStore | undefined
}
/**
* Resolve the persistence, session-query, and attachment services a log export needs.
* @param ctx - the composed host context.
* @returns the export services (absent when the deployment does not mount them).
*/
export function sessionLogExportDeps(ctx: Context): SessionLogExportDeps {
return {
sessionQuery: ctx.get('sessionQuery'),
sessionPersistence: ctx.get('sessionPersistence'),
attachments: ctx.get('attachments'),
sessions: ctx.get('sessions'),
}
}
/**
* Flush one currently live session through the store's authoritative durability
* barrier immediately before its raw artifact is read. A cold or absent id has
* no in-memory work to flush.
* @param deps - export services, including the optional live-session store.
* @param id - the session whose artifact is about to be read.
* @param signal - optional cancellation observed around the flush barrier.
*/
export async function flushLiveSessionLog(
deps: Pick<SessionLogExportDeps, 'sessions'>,
id: SessionId,
signal?: AbortSignal,
): Promise<void> {
signal?.throwIfAborted()
const sessions = deps.sessions
if (sessions === undefined) return
const session = sessions.get(id)
if (session === undefined) return
await sessions.flush(session)
signal?.throwIfAborted()
}
/** One exported file: a stored artifact text or one referenced media object. */
export type SessionLogZipEntry =
| { readonly path: string; readonly content: string }
| { readonly path: string; readonly data: Uint8Array }
/** Zip extension for each accepted raster media type. */
const MEDIA_TYPE_EXTENSIONS: Record<ImageAttachmentRef['mediaType'], string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/webp': 'webp',
'image/gif': 'gif',
}
/**
* The zip path for one media object: content-addressed by the opaque
* attachment id so shared images land once and the id in the log maps back to
* the archive entry without a manifest.
* @param ref - the durable reference from a session log.
* @returns the archive path.
*/
function mediaEntryPath(ref: ImageAttachmentRef): string {
return `media/${String(ref.attachmentId)}.${MEDIA_TYPE_EXTENSIONS[ref.mediaType]}`
}
/**
* Collect every image reference inside one content array, descending into
* nested tool results the way the live attachment route does.
* @param content - an event content array (or nested tool-result content).
* @param refs - the dedupe map being filled (keyed by attachment id).
*/
function collectImageRefs(content: unknown, refs: Map<string, ImageAttachmentRef>): void {
if (!Array.isArray(content)) return
const pending: unknown[] = []
for (const item of content) pending.push(item)
while (pending.length > 0) {
const value = pending.pop()
if (typeof value !== 'object' || value === null || Array.isArray(value)) continue
const block = value as { type?: unknown; attachment?: unknown; content?: unknown }
if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) {
const ref = block.attachment as ImageAttachmentRef
refs.set(String(ref.attachmentId), ref)
}
if (Array.isArray(block.content)) {
for (const item of block.content) pending.push(item)
}
}
}
/**
* Collect every image reference one session event carries, across the same
* carriers the live attachment route scans (direct content, message content,
* inserted messages, and completed assistant chunk blocks).
* @param event - one parsed JSONL event object.
* @param refs - the dedupe map being filled (keyed by attachment id).
*/
function collectEventImageRefs(event: unknown, refs: Map<string, ImageAttachmentRef>): void {
const data = (event as { data?: unknown }).data
if (typeof data !== 'object' || data === null) return
const carrier = data as {
content?: unknown
message?: { content?: unknown }
inserted?: Array<{ content?: unknown }>
chunk?: { type?: unknown; block?: unknown }
}
collectImageRefs(carrier.content, refs)
if (carrier.message !== undefined) collectImageRefs(carrier.message.content, refs)
if (carrier.inserted !== undefined) {
for (const message of carrier.inserted) collectImageRefs(message.content, refs)
}
if (carrier.chunk?.type === 'block-end') collectImageRefs([carrier.chunk.block], refs)
}
/**
* Collect the distinct media references one stored artifact text names.
* Lines that fail to parse cannot reference media and are skipped (the
* artifact text itself is exported verbatim regardless).
* @param content - the stored artifact text.
* @returns the dedupe map keyed by attachment id.
*/
function imageRefsInArtifact(content: string): Map<string, ImageAttachmentRef> {
const refs = new Map<string, ImageAttachmentRef>()
for (const line of content.split('\n')) {
if (line === '') continue
let event: unknown
try {
event = JSON.parse(line)
} catch {
continue
}
collectEventImageRefs(event, refs)
}
return refs
}
/**
* One safe zip path segment from an untrusted session id. Session ids are
* host-controlled, but the brand allows any non-empty string, so `../`, dot
* segments, and separator characters are neutralized before they can shape
* archive entries. Distinct ids may collapse onto one segment (id collision
* is impossible for the host-minted UUIDs, so no uniqueness suffix is kept).
* @param id - the raw session id.
* @returns a filesystem-safe single path segment.
*/
function safeSessionIdSegment(id: string): string {
return id.replace(/[^A-Za-z0-9_-]/g, '_')
}
/**
* The export archive filename for one root session.
* @param sessionId - the root session id (sanitized to one safe path segment).
* @returns the attachment filename for the session's export archive.
*/
export function sessionLogZipFilename(sessionId: string): string {
return `dsh-session-${safeSessionIdSegment(sessionId)}.zip`
}
/**
* Yield the export entries in zip order: the preloaded root artifact first,
* then every subagent descendant in lineage order (each flushed when live,
* read from the persistence backend right before it is yielded, and dropped
* after the consumer moves on), then every distinct media object referenced by any of
* the included logs (read and verified from the attachment store, one archive
* entry per attachment id). The host holds at most one descendant's artifact
* text and one media object at a time beyond the root.
* @param deps - the mounted export services (the caller answered 500 before this runs).
* @param root - the already-read root artifact (read by the caller so the
* missing-session path can answer cleanly before streaming starts).
* @param sessionId - the root session id.
* @param includeDescendants - whether to include every subagent descendant.
* @param signal - optional cancellation forwarded to lineage, persistence, and attachment reads.
* @returns the export entries in zip order.
*/
export async function* sessionLogZipEntries(
deps: SessionLogExportReady,
root: SessionRawArtifact,
sessionId: SessionId,
includeDescendants: boolean,
signal?: AbortSignal,
): AsyncGenerator<SessionLogZipEntry> {
const media = new Map<string, ImageAttachmentRef>()
const rememberMedia = (content: string): void => {
for (const [id, ref] of imageRefsInArtifact(content)) media.set(id, ref)
}
rememberMedia(root.content)
yield { path: root.filename, content: root.content }
if (includeDescendants) {
const seen = new Set<SessionId>([sessionId])
const collect = async function* (
nodes: readonly SessionLineageNode[],
): AsyncGenerator<SessionLogZipEntry> {
for (const node of nodes) {
signal?.throwIfAborted()
const id = node.session.header.id
if (seen.has(id)) continue
seen.add(id)
await flushLiveSessionLog(deps, id, signal)
const raw = await deps.sessionPersistence.readRaw(id, signal)
signal?.throwIfAborted()
if (raw === undefined) {
throw new Error(`subagent "${id}" has no stored log artifact`)
}
rememberMedia(raw.content)
yield {
path: `subagents/${safeSessionIdSegment(id)}/${raw.filename}`,
content: raw.content,
}
yield* collect(node.descendants)
}
}
const lineage = await deps.sessionQuery.traceSession(sessionId, signal)
signal?.throwIfAborted()
yield* collect(lineage.descendants)
}
for (const ref of media.values()) {
signal?.throwIfAborted()
const stored = await deps.attachments.readImage(ref, signal)
signal?.throwIfAborted()
yield { path: mediaEntryPath(ref), data: stored.data }
}
}
/** How many code units of artifact text one zip push carries (bounded encode memory). */
const PUSH_CHUNK_CODE_UNITS = 1 << 16
/** How many bytes of media one zip push carries (bounded memory; images are already size-capped). */
const PUSH_CHUNK_BYTES = 1 << 16
/** Byte capacity retained by the response stream before ZIP production waits for pull. */
const RESPONSE_HIGH_WATER_MARK_BYTES = 1 << 16
/** One producer waiter released only when ReadableStream pull restores capacity. */
class ResponseCapacityGate {
private releasePending: (() => void) | undefined
/**
* Wait until the response queue has positive byte capacity or cancellation wins.
* @param controller - response controller whose desired size owns capacity.
* @param signal - combined request/consumer cancellation.
*/
async wait(
controller: ReadableStreamDefaultController<Uint8Array>,
signal: AbortSignal,
): Promise<void> {
signal.throwIfAborted()
if (controller.desiredSize === null || controller.desiredSize > 0) return
await new Promise<void>((resolve) => {
const release = (): void => {
this.releasePending = undefined
signal.removeEventListener('abort', release)
resolve()
}
this.releasePending = release
signal.addEventListener('abort', release, { once: true })
})
signal.throwIfAborted()
}
/** Release the current producer waiter after a consumer pull. */
pulled(): void {
this.releasePending?.()
}
}
/**
* Push one media object's bytes into a deflate stream in bounded chunks,
* waiting for consumer capacity between chunks like the artifact path does.
* @param deflate - the zip entry's deflate stream.
* @param data - the stored image bytes.
* @param controller - response queue controller.
* @param capacity - pull-driven response-capacity gate.
* @param signal - cancellation; throws when aborted.
*/
async function pushBinaryChunks(
deflate: ZipDeflate,
data: Uint8Array,
controller: ReadableStreamDefaultController<Uint8Array>,
capacity: ResponseCapacityGate,
signal: AbortSignal,
): Promise<void> {
let offset = 0
do {
signal.throwIfAborted()
const end = Math.min(offset + PUSH_CHUNK_BYTES, data.byteLength)
const finalChunk = end >= data.byteLength
deflate.push(data.subarray(offset, end), finalChunk)
offset = end
await capacity.wait(controller, signal)
} while (offset < data.byteLength)
}
/**
* Push one artifact's text into a deflate stream in bounded chunks, never
* splitting a surrogate pair across a chunk boundary (a lone high surrogate
* re-encodes as U+FFFD and would silently corrupt the exported artifact).
* @param deflate - the zip entry's deflate stream.
* @param content - the artifact text verbatim.
* @param controller - response queue controller.
* @param capacity - pull-driven response-capacity gate.
* @param signal - cancellation; throws when aborted.
*/
async function pushArtifactChunks(
deflate: ZipDeflate,
content: string,
controller: ReadableStreamDefaultController<Uint8Array>,
capacity: ResponseCapacityGate,
signal: AbortSignal,
): Promise<void> {
const encoder = new TextEncoder()
let offset = 0
let finalChunk: boolean
do {
signal.throwIfAborted()
let end = Math.min(offset + PUSH_CHUNK_CODE_UNITS, content.length)
if (end < content.length && end - offset > 1) {
// Back off one code unit when the boundary lands inside a surrogate
// pair: the pair then starts the next chunk whole.
const last = content.charCodeAt(end - 1)
if (last >= 0xd800 && last <= 0xdbff) end -= 1
}
finalChunk = end >= content.length
deflate.push(encoder.encode(content.slice(offset, end)), finalChunk)
offset = end
await capacity.wait(controller, signal)
} while (!finalChunk)
}
/**
* Stream one session-log ZIP as a WHATWG ReadableStream. The root artifact is
* read and validated by the caller before this is called (missing root or
* missing services answer cleanly before any byte is produced); each entry is
* then encoded and deflated in bounded chunks as it is produced, so the
* archive bytes arrive incrementally. A descendant that fails to read errors
* the stream (fail-loud, never silent under-export).
* @param deps - the mounted export services (the caller answered 500 before this runs).
* @param root - the already-read root artifact (first zip entry).
* @param sessionId - the root session id.
* @param includeDescendants - whether to include every subagent descendant.
* @param compressionLevel - validated fflate DEFLATE level for every ZIP entry.
* @param signal - request cancellation combined with response-consumer cancellation.
* @returns the zip byte stream.
*/
export function streamSessionLogZip(
deps: SessionLogExportReady,
root: SessionRawArtifact,
sessionId: SessionId,
includeDescendants: boolean,
compressionLevel: SessionLogCompressionLevel,
signal: AbortSignal,
): ReadableStream<Uint8Array> {
const consumerAbort = new AbortController()
const producerSignal = AbortSignal.any([signal, consumerAbort.signal])
let zip: Zip | undefined
let zipTerminated = false
const capacity = new ResponseCapacityGate()
const terminateZip = (): void => {
if (zip === undefined || zipTerminated) return
zipTerminated = true
zip.terminate()
}
return new ReadableStream<Uint8Array>({
start(controller) {
// fflate invokes the callback synchronously per compressed chunk, so a
// single push can enqueue ahead of a slow consumer; the capacity gate
// waits for pull between pushes once the byte queue is full, bounding
// accumulation to the queue high-water mark plus one synchronous push.
const archive = new Zip((error, data, final) => {
/* v8 ignore next 3 -- fflate reports only internal zip failures, unreachable for valid inputs */
if (error) {
controller.error(error)
return
}
/* v8 ignore next -- fflate may emit empty chunks; not controllable from tests */
if (data.byteLength > 0) controller.enqueue(data)
if (final) controller.close()
})
zip = archive
void (async () => {
try {
for await (const entry of sessionLogZipEntries(deps, root, sessionId, includeDescendants, producerSignal)) {
const deflate = new ZipDeflate(entry.path, { level: compressionLevel })
archive.add(deflate)
if ('content' in entry) {
await pushArtifactChunks(deflate, entry.content, controller, capacity, producerSignal)
} else {
await pushBinaryChunks(deflate, entry.data, controller, capacity, producerSignal)
}
}
archive.end()
} catch (error) {
// A mid-stream failure (missing descendant, cancellation, read
// error) must fail the download rather than ship a truncated archive.
/* v8 ignore next -- typed backends reject with Error, and DOMException is one in Node */
terminateZip()
controller.error(error instanceof Error ? error : new Error(String(error)))
}
})()
},
pull() {
capacity.pulled()
},
cancel(reason) {
consumerAbort.abort(
reason instanceof Error ? reason : new Error('session log export stream cancelled'),
)
terminateZip()
},
}, {
highWaterMark: RESPONSE_HIGH_WATER_MARK_BYTES,
size: chunk => chunk.byteLength,
})
}
@@ -1,236 +0,0 @@
/**
* Settings events consumed by Client model and permission surfaces.
*/
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime from '@deepseek-ai/dsh-tools'
import { SettingsProvider, settingsNamespace } from '@deepseek-ai/dsh-settings'
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
import { CredentialProvider } from '@deepseek-ai/dsh-credentials'
import type {
CredentialInfo,
CredentialKey,
CredentialRecord,
CredentialRecordEntry,
CredentialRecordInfo,
CredentialRef,
ResolvedCredential,
} from '@deepseek-ai/dsh-credentials'
import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model'
/** In-memory settings provider: the Service Definition base class owns all tested behavior. */
class MemorySettings extends SettingsProvider {
doc: Record<string, unknown>
constructor(ctx: ConstructorParameters<typeof SettingsProvider>[0], options?: {
doc?: Record<string, unknown>
readOnly?: boolean
documentPath?: string
preparedPath?: string
}) {
super(ctx)
this.doc = structuredClone(options?.doc ?? {})
this.readOnly = options?.readOnly ?? false
this.path = options?.documentPath
this.preparedPath = options?.preparedPath
}
private readonly readOnly: boolean
private readonly path: string | undefined
private readonly preparedPath: string | undefined
get writable(): boolean {
return !this.readOnly
}
override get documentPath(): string | undefined {
return this.path
}
override prepareDocument(): Promise<string | undefined> {
return Promise.resolve(this.preparedPath ?? this.documentPath)
}
protected load(): Promise<Record<string, unknown>> {
return Promise.resolve(structuredClone(this.doc))
}
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
this.doc[ns] = structuredClone(section)
return Promise.resolve()
}
}
/** In-memory credential provider with an env-shadow double for the rejection path. */
class MemoryCredentials extends CredentialProvider {
private readonly values = new Map<string, string>()
constructor(ctx: ConstructorParameters<typeof CredentialProvider>[0], options?: { shadowed?: string[] }) {
super(ctx)
this.shadowed = new Set(options?.shadowed ?? [])
}
private readonly shadowed: Set<string>
resolve(ref: CredentialRef): Promise<ResolvedCredential | undefined> {
if (this.shadowed.has(ref)) return Promise.resolve({ value: 'from-env', source: 'env' })
const value = this.values.get(ref)
return Promise.resolve(value === undefined ? undefined : { value, source: 'file' })
}
describe(ref: CredentialRef): Promise<CredentialInfo> {
if (this.shadowed.has(ref)) return Promise.resolve({ configured: true, source: 'env', writable: false })
const configured = this.values.has(ref)
return Promise.resolve({ configured, ...configured ? { source: 'file' } : {}, writable: true })
}
set(ref: CredentialRef, value: string): Promise<void> {
if (this.shadowed.has(ref)) {
return Promise.reject(new Error(`credentials: ${ref} is shadowed by the read-only environment`))
}
this.values.set(ref, value)
this.ctx.emit('credentials/reference-updated', ref)
return Promise.resolve()
}
unset(ref: CredentialRef): Promise<void> {
if (this.shadowed.has(ref)) {
return Promise.reject(new Error(`credentials: ${ref} is shadowed by the read-only environment`))
}
this.values.delete(ref)
this.ctx.emit('credentials/reference-updated', ref)
return Promise.resolve()
}
// The record half has no wire face on this proxy, so the double answers the
// empty store rather than modelling storage the tests never exercise.
readRecord(): Promise<CredentialRecord | undefined> {
return Promise.resolve(undefined)
}
describeRecord(): Promise<CredentialRecordInfo> {
return Promise.resolve({ configured: false, writable: true })
}
listRecords(): Promise<readonly CredentialRecordEntry[]> {
return Promise.resolve([])
}
modifyRecord(
_key: CredentialKey,
mutate: (current: CredentialRecord | undefined) => Promise<CredentialRecord | undefined>,
): Promise<CredentialRecord | undefined> {
return mutate(undefined)
}
deleteRecord(): Promise<void> {
return Promise.resolve()
}
}
const NS = settingsNamespace('llm-deepseek')
const AdapterConfig = z.object({
apiKey: z.string().role('secret'),
apiKeyEnv: z.string().default('DEEPSEEK_API_KEY'),
baseURL: z.string(),
})
async function harness(options?: {
settings?: false | {
doc?: Record<string, unknown>
readOnly?: boolean
documentPath?: string
preparedPath?: string
}
credentials?: false | { shadowed?: string[] }
}): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(ToolRuntime)
await ctx.plugin(AgentRegistry)
if (options?.settings !== false) await ctx.plugin(MemorySettings, options?.settings)
if (options?.credentials !== false) await ctx.plugin(MemoryCredentials, options?.credentials)
return ctx
}
/** Observe settings commits while one API operation runs. */
async function captureSettingsUpdates(
ctx: Context,
run: () => Promise<void>,
): Promise<Array<readonly [SettingsNamespace, number]>> {
const updates: Array<readonly [SettingsNamespace, number]> = []
const dispose = ctx.on('settings/document-updated', (namespace, revision) => {
updates.push([namespace, revision])
})
try {
await run()
return updates
} finally {
dispose()
}
}
/** Expected settings event tuple with its owner-assigned revision. */
function expectedSettingsUpdate(ns: string): readonly unknown[] {
return [ns, expect.any(Number)]
}
describe('settings events', () => {
it('forwards a provider settings change for model-catalog consumers', async () => {
// Editing `models` changes no route, so llm/adapters-updated never fires
// and an open model picker would keep serving the stale catalog. Storing
// an override equal to the resolved value emits nothing on
// settings/updated, so another tab would never learn the field became
// overridden.
const ctx = await harness()
ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } })
const updates = await captureSettingsUpdates(ctx, async () => {
await ctx.settings.update(settingsNamespace('llm-deepseek'), { baseURL: 'https://base' })
})
expect(updates).toEqual([expectedSettingsUpdate('llm-deepseek')])
// The resolved value never moved: base already said https://base.
expect(ctx.settings.describe().find(view => String(view.ns) === 'llm-deepseek')?.value)
.toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' })
})
it('broadcasts a permission change without invalidating the model catalog', async () => {
const ctx = await harness()
const permission = ctx.settings.register(settingsNamespace('permission'), z.object({
defaultPreset: z.union(['read-only', 'workspace-write']).required(),
}), {
base: { defaultPreset: 'read-only' },
})
const updates = await captureSettingsUpdates(ctx, async () => {
await permission.update({ defaultPreset: 'workspace-write' })
})
expect(updates).toEqual([expectedSettingsUpdate('permission')])
})
it('forwards an Agent-default settings change for model-catalog consumers', async () => {
const ctx = await harness()
const defaultModel = ctx.settings.register(AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE, z.object({
provider: z.string().required(),
model: z.string().required(),
}), { base: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } })
// The shared section names the selection every blank session resolves to,
// so an externally edited default — another tab, a
// hand-edited settings.yaml — has to reach an open selector as well.
const updates = await captureSettingsUpdates(ctx, async () => {
await defaultModel.replace({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
})
expect(updates).toEqual([expectedSettingsUpdate('agent-default-model')])
})
})
@@ -1,51 +0,0 @@
import { homedir } from 'node:os'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { createApiProxy } from '../src/api-proxy.ts'
let nextRpc = 1
const contexts: Context[] = []
afterEach(async () => {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
})
function request<P>(payload: P): RpcRequest<P> {
return { rpcId: RpcId(`host-${String(nextRpc++)}`), payload }
}
function expectOk<T>(response: { readonly result: { readonly ok: true; readonly value: T } | { readonly ok: false } }): T {
expect(response.result.ok).toBe(true)
if (!response.result.ok) throw new Error('unreachable')
return response.result.value
}
async function harness(
extras: {
canOpenPath?: () => boolean
} = {},
) {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(AgentRegistry)
const api = createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }),
cwd: '/tmp/dsh-apiproxy-host',
...extras.canOpenPath === undefined ? {} : { canOpenPath: extras.canOpenPath },
})
return { api }
}
describe('host.describe', () => {
it('describes whether the deployment can reach a native desktop', async () => {
const visible = await harness({ canOpenPath: () => true })
const headless = await harness({ canOpenPath: () => false })
expect(expectOk(await visible.api.host.describe(request({}))).canOpenPath).toBe(true)
expect(expectOk(await headless.api.host.describe(request({}))).canOpenPath).toBe(false)
expect(expectOk(await visible.api.host.describe(request({}))).home).toBe(homedir())
})
})
@@ -1,228 +0,0 @@
/**
* Wire-protocol coverage over the isomorphic point: InProcessApiClient →
* toFetchHandler(scripted impl) runs the real envelope wrap/unwrap, zod
* two-level parse, and rpcId discipline with no network or browser. Each case
* scripts its own minimal ApiProxy.
*/
import { describe, expect, it, vi } from 'vitest'
import type { ApiProxy, RpcMessage, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy'
import { InProcessApiClient, RpcId, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
function ok<T>(request: RpcRequest<unknown>, value: T): Promise<RpcResponse<T>> {
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value } })
}
/** Scripted impl: every method resolves an empty-ish OK unless a case overrides it. */
function scriptedApi(overrides: {
host?: Partial<ApiProxy['host']>
} = {}): ApiProxy {
return {
host: {
describe: r => ok(r, {
version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true,
}),
...overrides.host,
},
downloads: { sessionLog: async () => new Response('stub', { status: 404 }) },
}
}
function client(api: ApiProxy, timeoutMs?: number): InProcessApiClient {
return new InProcessApiClient(toFetchHandler(api), timeoutMs)
}
describe('unary round trip', () => {
it('carries payload out and value back through the full wire form', async () => {
let seen: RpcRequest<{}> | undefined
const api = scriptedApi({
host: {
describe: (request) => {
seen = request
return ok(request, { version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true })
},
},
})
const response = await client(api).host.describe({})
expect(seen?.payload).toEqual({})
expect(seen?.rpcId).toBeTruthy()
expect(response.rpcId).toBe(seen?.rpcId)
expect(response.result).toMatchObject({ ok: true, value: { version: '0-test' } })
})
it('passes business errors through as 200 + err result, not a throw', async () => {
const api = scriptedApi({
host: {
describe: request => Promise.resolve({
rpcId: request.rpcId,
result: { ok: false, error: { code: 'internal', message: 'nope', details: {} } },
}),
},
})
const response = await client(api).host.describe({})
expect(response.result).toEqual({ ok: false, error: { code: 'internal', message: 'nope', details: {} } })
})
it('throws on rpcId echo mismatch', async () => {
const api = scriptedApi({
host: {
describe: () => Promise.resolve({
rpcId: RpcId('forged'),
result: { ok: true, value: { version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true } },
}),
},
})
await expect(client(api).host.describe({})).rejects.toThrow(/rpcId mismatch/)
})
it('rejects a malformed envelope as bad-request, salvaging the rpcId or falling back to the sentinel', async () => {
const handler = toFetchHandler(scriptedApi())
// No salvageable rpcId → the fixed invalid-request sentinel keeps the response a valid ServerResponse.
const noId = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nonsense: true }) })
expect(noId.status).toBe(200)
const noIdParsed = await noId.json() as { rpcId: string; result: { ok: boolean } }
expect(noIdParsed.result.ok).toBe(false)
expect(noIdParsed.rpcId).toBe('invalid-request')
// A string rpcId in the otherwise-bad body is salvaged for correlation.
const withId = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ rpcId: 'salvage-me', nonsense: true }) })
const withIdParsed = await withId.json() as { rpcId: string; result: { ok: boolean } }
expect(withIdParsed.result.ok).toBe(false)
expect(withIdParsed.rpcId).toBe('salvage-me')
})
it('maps carrier failures to HTTP statuses and the client throws transport failure', async () => {
const handler = toFetchHandler(scriptedApi())
// Unknown method → 404.
const notFound = await handler.fetch('http://dsh.internal/api/no.such', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
expect(notFound.status).toBe(404)
// Non-JSON body → 400.
const badBody = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{oops' })
expect(badBody.status).toBe(400)
// Impl crash → 500, and through the client that is a throw, not an err result.
const crashing = scriptedApi({ host: { describe: () => { throw new Error('impl exploded') } } })
await expect(client(crashing).host.describe({})).rejects.toThrow(/transport failure .*500/)
})
it('rejects non-JSON media types before executing anything (cross-site simple-request fence)', async () => {
const describe = vi.fn((request: RpcRequest<{}>) => ok(request, {
version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true,
}))
const handler = toFetchHandler(scriptedApi({ host: { describe } }))
const body = JSON.stringify({ type: 'client-request', rpcId: 'r1', method: 'host.describe', payload: {} })
// A "simple" browser POST (text/plain — sent with no CORS preflight) is
// refused at the carrier before the impl runs.
const plain = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'text/plain' }, body })
expect(plain.status).toBe(415)
// A string body with no explicit header defaults to text/plain — same fence.
const unlabelled = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', body })
expect(unlabelled.status).toBe(415)
expect(describe).not.toHaveBeenCalled()
// Media-type parameters pass: the fence checks the type, not the exact string.
const charset = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json; charset=utf-8' }, body })
expect(charset.status).toBe(200)
expect(describe).toHaveBeenCalledTimes(1)
})
it('rejects when the transport never resolves within timeoutMs', async () => {
// AbortSignal.timeout is immune to fake timers; a short real timeout keeps this fast.
const never = new InProcessApiClient({
fetch: (_i: RequestInfo | URL, init?: RequestInit) => new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => { reject(new Error('aborted by timeout')) })
}),
}, 25)
await expect(never.host.describe({})).rejects.toThrow()
})
it('aborts a unary call through the caller-supplied external signal', async () => {
// Real-fetch semantics: on abort the rejection is the signal's reason, and the abort
// works even when the transport ignores the signal entirely (hung impl).
const gate = new AbortController()
const hung = new InProcessApiClient({ fetch: () => new Promise<Response>(() => {}) }, 60_000)
const call = hung.host.describe({}, gate.signal)
gate.abort(new Error('externally aborted'))
await expect(call).rejects.toThrow(/externally aborted/)
})
it('rejects an already-aborted signal before touching the transport, mapping a string reason to an Error', async () => {
let touched = false
const c = new InProcessApiClient({
fetch: () => {
touched = true
return Promise.resolve(new Response('{}'))
},
}, 60_000)
const gate = new AbortController()
gate.abort('gone before start')
await expect(c.host.describe({}, gate.signal)).rejects.toThrow('gone before start')
expect(touched).toBe(false)
})
it('maps a non-Error, non-string abort reason to the default AbortError message', async () => {
const gate = new AbortController()
const hung = new InProcessApiClient({ fetch: () => new Promise<Response>(() => {}) }, 60_000)
const call = hung.host.describe({}, gate.signal)
gate.abort(42)
await expect(call).rejects.toThrow('This operation was aborted')
})
it('passes a signal-less doFetch straight through to the handler', async () => {
class Probe extends InProcessApiClient {
direct(url: URL): Promise<Response> {
return this.doFetch(url)
}
}
const probe = new Probe({ fetch: () => Promise.resolve(new Response('raw')) })
const response = await probe.direct(new URL('http://dsh.internal/probe'))
expect(await response.text()).toBe('raw')
})
it('throws on an S→C ok value that fails the method value schema (second-level parse)', async () => {
// Impl echoes rpcId but returns a wrong-shaped value: envelope parse passes, value parse must reject.
const api = scriptedApi({
host: { describe: request => Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value: { version: 1 } } }) as never },
})
await expect(client(api).host.describe({})).rejects.toThrow()
})
})
describe('envelope tap', () => {
it('delivers one microtask batch of full forms per unary call', async () => {
const api = scriptedApi()
const tapped = client(api)
const batches: (readonly RpcMessage[])[] = []
tapped.subscribeEnvelopes(batch => batches.push(batch))
await tapped.host.describe({})
await vi.waitFor(() => { expect(batches.length).toBeGreaterThan(0) })
const all = batches.flat()
expect(all.map(m => m.type)).toEqual(['client-request', 'server-response'])
expect(all[0]?.rpcId).toBe(all[1]?.rpcId)
})
it('isolates a throwing listener and keeps serving the call', async () => {
const api = scriptedApi()
const tapped = client(api)
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
const good: string[] = []
tapped.subscribeEnvelopes(() => { throw new Error('listener bug') })
tapped.subscribeEnvelopes(batch => good.push(...batch.map(m => m.type)))
const response = await tapped.host.describe({})
expect(response.result.ok).toBe(true)
await vi.waitFor(() => { expect(good).toContain('server-response') })
} finally {
errorSpy.mockRestore()
}
})
it('buffers nothing with zero subscribers and unsubscribes cleanly', async () => {
const api = scriptedApi()
const tapped = client(api)
await tapped.host.describe({}) // no subscribers: must not accumulate
const batches: (readonly RpcMessage[])[] = []
const unsubscribe = tapped.subscribeEnvelopes(batch => batches.push(batch))
unsubscribe()
await tapped.host.describe({})
await new Promise(resolve => setTimeout(resolve, 0))
expect(batches).toEqual([])
})
})
@@ -1,207 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type { ApiProxy } from '../src/api/index.ts'
import type { RpcMessage } from '../src/api/rpc.ts'
import { toFetchHandler } from '../src/fetch/handler.ts'
import { AbstractApiClient, InProcessApiClient } from '../src/fetch/client.ts'
/** Minimal in-memory ApiProxy that echoes rpcIds. */
function fakeApi(overrides: Partial<{ crashOn: string }> = {}): ApiProxy {
return {
host: {
async describe(request) {
if (overrides.crashOn === 'host.describe') throw new Error('impl crashed')
return {
rpcId: request.rpcId,
result: {
ok: true,
value: { version: 'v', cwd: '/w', attachedSessions: 0, home: '/h', canOpenPath: true },
},
}
},
},
downloads: {
async sessionLog() {
return new Response('stub', { status: 404 })
},
},
}
}
function client(api: ApiProxy = fakeApi(), timeoutMs?: number): InProcessApiClient {
return new InProcessApiClient(toFetchHandler(api), timeoutMs)
}
describe('unary round trip (handler ⇄ client, no network)', () => {
it('carries a success result and echoes the minted rpcId', async () => {
const response = await client().host.describe({})
expect(response.result).toMatchObject({ ok: true, value: { version: 'v', cwd: '/w' } })
expect(response.rpcId).toMatch(/[0-9a-f-]{36}/)
})
it('carries a business error as 200 + error result', async () => {
const api = fakeApi()
api.host.describe = request => Promise.resolve({
rpcId: request.rpcId,
result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } },
})
const response = await client(api).host.describe({})
expect(response.result.ok).toBe(false)
if (!response.result.ok) expect(response.result.error.code).toBe('internal')
})
})
describe('handler carrier-layer statuses', () => {
const handler = toFetchHandler(fakeApi())
it('404s unknown paths and non-POST non-stream methods', async () => {
expect((await handler.fetch(new Request('http://x/other', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }))).status).toBe(404)
expect((await handler.fetch(new Request('http://x/api/host.describe', { method: 'GET' }))).status).toBe(404)
expect((await handler.fetch(new Request('http://x/api/no.such', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ type: 'client-request', rpcId: 'r', method: 'no.such', payload: {} }) }))).status).toBe(404)
})
it('400s a non-JSON body', async () => {
const response = await handler.fetch(new Request('http://x/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: 'not json' }))
expect(response.status).toBe(400)
})
it('rejects a malformed envelope with bad-request and the invalid-request sentinel rpcId', async () => {
const response = await handler.fetch(new Request('http://x/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nope: true }) }))
expect(response.status).toBe(200)
const body = await response.json() as { rpcId: string; result: { ok: boolean; error?: { code: string } } }
expect(body.rpcId).toBe('invalid-request')
expect(body.result.error?.code).toBe('bad-request')
})
it('rejects an invalid payload with the zod issues attached', async () => {
const body = JSON.stringify({ type: 'client-request', rpcId: 'r-10', method: 'host.describe', payload: null })
const response = await handler.fetch(new Request('http://x/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body }))
const parsed = await response.json() as { result: { error?: { code: string; details: { issues: unknown[] } } } }
expect(parsed.result.error?.code).toBe('bad-request')
expect(parsed.result.error?.details.issues.length).toBeGreaterThan(0)
})
it('rejects a request whose envelope method does not match its path', async () => {
const body = JSON.stringify({ type: 'client-request', rpcId: 'r-mismatch', method: 'other.method', payload: {} })
const response = await handler.fetch(new Request('http://x/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body }))
const parsed = await response.json() as { result: { error?: { code: string; message: string } } }
expect(parsed.result.error).toMatchObject({
code: 'bad-request',
message: 'method "other.method" does not match path "host.describe"',
})
})
it('500s when the impl itself throws', async () => {
const crashing = toFetchHandler(fakeApi({ crashOn: 'host.describe' }))
const body = JSON.stringify({ type: 'client-request', rpcId: 'r-11', method: 'host.describe', payload: {} })
const response = await crashing.fetch(new Request('http://x/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body }))
expect(response.status).toBe(500)
expect(await response.text()).toContain('impl crashed')
})
it('accepts (url, init) form fetch invocation', async () => {
const body = JSON.stringify({ type: 'client-request', rpcId: 'r-12', method: 'host.describe', payload: {} })
const response = await handler.fetch('http://x/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body })
expect(response.status).toBe(200)
})
})
describe('client transport failures', () => {
it('throws on a non-OK unary transport', async () => {
const broken = new InProcessApiClient({ fetch: async () => new Response('down', { status: 503 }) })
await expect(broken.host.describe({})).rejects.toThrow('transport failure for /api/host.describe: HTTP 503')
})
it('throws on an rpcId echo mismatch', async () => {
const lying = new InProcessApiClient({
fetch: async () => Response.json({
type: 'server-response',
rpcId: 'someone-else',
result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0, home: '/h', canOpenPath: true } },
}),
})
await expect(lying.host.describe({})).rejects.toThrow('rpcId mismatch')
})
})
describe('envelope observation', () => {
it('batches envelopes per microtask and isolates a throwing listener', async () => {
const c = client()
const batches: (readonly RpcMessage[])[] = []
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const unsubscribeThrowing = c.subscribeEnvelopes(() => { throw new Error('observer bug') })
const unsubscribe = c.subscribeEnvelopes((batch) => { batches.push(batch) })
await c.host.describe({})
await new Promise((resolve) => { setTimeout(resolve, 0) })
// request and response tap in separate microtask windows (the await between
// them yields), so both arrive but batch count is timing-defined
expect(batches.flatMap(batch => batch.map(message => message.type))).toEqual(['client-request', 'server-response'])
expect(errorSpy).toHaveBeenCalled()
unsubscribe()
unsubscribeThrowing()
errorSpy.mockRestore()
})
it('skips buffering entirely with no listeners and after unsubscribe', async () => {
const c = client()
const seen: RpcMessage[] = []
const unsubscribe = c.subscribeEnvelopes((batch) => { seen.push(...batch) })
unsubscribe()
await c.host.describe({})
await new Promise((resolve) => { setTimeout(resolve, 0) })
expect(seen).toHaveLength(0)
})
it('coalesces multiple calls in one microtask window into one flush', async () => {
const c = client()
const batches: (readonly RpcMessage[])[] = []
c.subscribeEnvelopes((batch) => { batches.push(batch) })
await Promise.all([c.host.describe({}), c.host.describe({})])
await new Promise((resolve) => { setTimeout(resolve, 0) })
const total = batches.reduce((n, batch) => n + batch.length, 0)
expect(total).toBe(4)
})
})
describe('resolveBase', () => {
it('prefers a real location.origin and falls back to the internal authority', async () => {
class Probe extends AbstractApiClient {
urls: string[] = []
protected async doFetch(input: URL): Promise<Response> {
this.urls.push(input.href)
return Response.json({
type: 'server-response',
rpcId: this.lastMinted,
result: {
ok: true,
value: { version: 'v', cwd: '/w', attachedSessions: 0, home: '/h', canOpenPath: true },
},
})
}
lastMinted = ''
protected override mintRpcId(): ReturnType<AbstractApiClient['mintRpcId']> {
const id = super.mintRpcId()
this.lastMinted = id
return id
}
}
const probe = new Probe()
await probe.host.describe({})
expect(probe.urls[0]).toMatch(/^http:\/\/dsh\.internal\//)
const globalWithLocation = globalThis as { location?: { origin?: string } }
globalWithLocation.location = { origin: 'http://host.example' }
try {
const probe2 = new Probe()
await probe2.host.describe({})
expect(probe2.urls[0]).toMatch(/^http:\/\/host\.example\//)
globalWithLocation.location = { origin: 'null' } // sandboxed iframe shape
const probe3 = new Probe()
await probe3.host.describe({})
expect(probe3.urls[0]).toMatch(/^http:\/\/dsh\.internal\//)
} finally {
delete globalWithLocation.location
}
})
})
@@ -1,96 +0,0 @@
import { describe, expect, it } from 'vitest'
import { RpcId, transportError } from '../src/api/rpc.ts'
import {
clientRequestSchema, rpcErrorSchema, rpcIdSchema, rpcMessageSchema,
rpcResultSchema, serverResponseSchema,
} from '../src/api/rpc.schema.ts'
import { z } from 'zod'
import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts'
describe('RpcId', () => {
it('brands a raw string at zero runtime cost', () => {
expect(RpcId('abc')).toBe('abc')
expect(rpcIdSchema.parse('abc')).toBe('abc')
// No min-length: the id is an opaque echo token (see rpcIdSchema's contract).
expect(rpcIdSchema.parse('')).toBe('')
expect(() => rpcIdSchema.parse(42)).toThrow()
})
})
describe('transportError', () => {
it('folds Error and non-Error throws into the internal error branch', () => {
expect(transportError(new Error('wire down'))).toEqual({ ok: false, error: { code: 'internal', message: 'wire down', details: {} } })
expect(transportError('raw')).toMatchObject({ ok: false, error: { code: 'internal', message: 'raw' } })
})
})
describe('rpcErrorSchema', () => {
it('accepts every code branch with its required details', () => {
expect(rpcErrorSchema.parse({ code: 'bad-request', message: 'm', details: { issues: [] } }).code).toBe('bad-request')
expect(rpcErrorSchema.parse({ code: 'cancelled', message: 'm', details: {} }).code).toBe('cancelled')
expect(rpcErrorSchema.parse({ code: 'session-not-found', message: 'm', details: { sessionId: 's' } }).code).toBe('session-not-found')
expect(rpcErrorSchema.parse({ code: 'invalid-time-zone', message: 'm', details: { value: 'CST' } }).code).toBe('invalid-time-zone')
expect(rpcErrorSchema.parse({ code: 'agent-preset-read-only', message: 'm', details: { agentPreset: 'p', reason: 'system' } }).code).toBe('agent-preset-read-only')
expect(rpcErrorSchema.parse({ code: 'agent-preset-locked', message: 'm', details: { sessionId: 's', agentPreset: 'p' } }).code).toBe('agent-preset-locked')
expect(rpcErrorSchema.parse({ code: 'agent-preset-not-found', message: 'm', details: { agentPreset: 'p', available: [] } }).code).toBe('agent-preset-not-found')
expect(rpcErrorSchema.parse({ code: 'agent-preset-invalid', message: 'm', details: { agentPreset: 'p', reason: 'bad' } }).code).toBe('agent-preset-invalid')
expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy')
expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal')
})
it('rejects a known code with missing details', () => {
expect(() => rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: {} })).toThrow()
expect(() => rpcErrorSchema.parse({ code: 'internal', message: 'm' })).toThrow()
expect(() => rpcErrorSchema.parse({ code: 'nope', message: 'm', details: {} })).toThrow()
})
})
describe('rpcResultSchema', () => {
it('accepts both result branches and rejects hybrids', () => {
const schema = rpcResultSchema(z.object({ n: z.number() }))
expect(schema.parse({ ok: true, value: { n: 1 } })).toEqual({ ok: true, value: { n: 1 } })
const err = schema.parse({ ok: false, error: { code: 'internal', message: 'x', details: {} } })
expect(err).toMatchObject({ ok: false })
expect(() => schema.parse({ ok: true, error: {} })).toThrow()
})
})
describe('wire full-form schemas', () => {
it('parses both carrier forms and the union discriminates on type', () => {
const cq = { type: 'client-request', rpcId: 'r1', method: 'host.describe', payload: {} }
const sr = { type: 'server-response', rpcId: 'r1', result: { ok: true, value: 1 } }
expect(clientRequestSchema.parse(cq).method).toBe('host.describe')
expect(serverResponseSchema.parse(sr).rpcId).toBe('r1')
for (const message of [cq, sr]) expect(rpcMessageSchema.parse(message)).toBeTruthy()
expect(() => rpcMessageSchema.parse({ type: 'other', rpcId: 'x' })).toThrow()
})
it('rejects a quadrant missing its members but accepts a valueless success result', () => {
expect(() => clientRequestSchema.parse({ type: 'client-request', rpcId: 'r1' })).toThrow()
expect(() => serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1' })).toThrow()
expect(() => serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1', result: {} })).toThrow()
// A void business result carries no value field; the endpoint's own second
// parse is what requires a value for methods that return data.
expect(serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1', result: { ok: true } }).rpcId)
.toBe('r1')
})
})
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, home: '/h', canOpenPath: true,
})
expect(value).toMatchObject({ provider: 'p', model: 'm', attachedSessions: 2, canOpenPath: true })
expect(hostDescribeValueSchema.parse({
version: '1', cwd: '/x', attachedSessions: 0, home: '/h', canOpenPath: false,
}).provider).toBeUndefined()
expect(() => hostDescribeValueSchema.parse({
version: '1', cwd: '/x', attachedSessions: 0,
})).toThrow()
expect(() => hostDescribeValueSchema.parse({
version: '1', cwd: '/x', attachedSessions: 0, canOpenPath: true,
})).toThrow()
})
})
@@ -1,708 +0,0 @@
/**
* session.export host path: the GET download endpoint streams a ZIP whose
* files are the stored artifacts verbatim (root + optional descendants), and
* the degenerate compositions fail loudly (missing services → 500, missing
* root → 404, missing descendant → errored stream).
*/
import { randomBytes } from 'node:crypto'
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { unzipSync, strFromU8 } from 'fflate'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionLineageNode } from '@deepseek-ai/dsh-session-query'
import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
import ApiProxyService, { createApiProxy, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
const sid = (id: string): SessionId => id as SessionId
function header(id: string, parentSession?: SessionId): SessionHeader {
return {
version: 0,
id: sid(id),
createdAt: 1000,
cwd: '/proj',
...parentSession === undefined ? {} : { parentSession },
delegationDepth: parentSession === undefined ? 0 : 1,
}
}
function artifact(id: string, parentSession?: SessionId, content?: string): SessionRawArtifact {
return {
meta: header(id, parentSession),
filename: 'session.jsonl',
content: content ?? `{"type":"session","version":0,"id":"${id}","createdAt":1000}\n{"type":"turn/start","seq":0,"time":2000,"data":{"turn":1}}\n`,
}
}
function node(id: string, ...descendants: SessionLineageNode[]): SessionLineageNode {
return { session: { header: header(id, sid('session-root')), live: false, persisted: true }, descendants }
}
/** One durable image object served by the fake attachment store. */
function storedImage(id: string, mediaType: ImageAttachmentRef['mediaType'] = 'image/png') {
return {
ref: { attachmentId: sid(id), mediaType, bytes: 4, width: 2, height: 2 } as unknown as ImageAttachmentRef,
data: new Uint8Array([1, 2, 3, 4]),
}
}
/** A user/message event line carrying one image reference. */
function imageEventLine(id: string, mediaType: ImageAttachmentRef['mediaType'] = 'image/png'): string {
return `{"type":"user/message","seq":1,"time":1000,"data":{"content":[{"type":"image","attachment":{"attachmentId":"${id}","mediaType":"${mediaType}","bytes":4,"width":2,"height":2}}]}}`
}
async function buildApi(
artifacts: Record<string, SessionRawArtifact>,
descendants: SessionLineageNode[] = [],
services: {
query?: boolean
persistence?: boolean | 'throw' | 'unsupported'
attachments?: boolean | ((ref: ImageAttachmentRef, signal?: AbortSignal) => Promise<ReturnType<typeof storedImage>>)
sessions?: {
get(id: SessionId): { readonly id: SessionId } | undefined
flush(session: { readonly id: SessionId }): Promise<boolean>
}
readRaw?: (id: SessionId, signal?: AbortSignal) => Promise<SessionRawArtifact | undefined>
traceSession?: (id: SessionId, signal?: AbortSignal) => Promise<{
target: { header: SessionHeader; live: boolean; persisted: boolean }
ancestors: readonly SessionLineageNode[]
complete: boolean
root: { header: SessionHeader; live: boolean; persisted: boolean }
descendants: readonly SessionLineageNode[]
}>
compressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
} = {},
) {
const ctx = new Context()
const query = services.query ?? true
const persistence = services.persistence ?? true
if (query) {
ctx.provide('sessionQuery', {
traceSession: services.traceSession ?? (async () => ({
target: { header: header('session-root'), live: false, persisted: true },
ancestors: [],
complete: true,
root: { header: header('session-root'), live: false, persisted: true },
descendants,
})),
} as never)
}
if (persistence) {
ctx.provide('sessionPersistence', {
supportsRawArtifacts: persistence !== 'unsupported',
readRaw: services.readRaw ?? (async (id: SessionId) => {
if (persistence === 'throw') throw new Error('/host/private/session.jsonl')
return artifacts[id]
}),
} as never)
}
if (services.attachments !== false) {
const readImage = typeof services.attachments === 'function'
? services.attachments
: async (ref: ImageAttachmentRef) => storedImage(String(ref.attachmentId), ref.mediaType)
ctx.provide('attachments', {
imageLimits: {} as never,
validateImage: async () => {},
saveImage: async () => { throw new Error('export never saves images') },
readImage,
} as never)
}
if (services.sessions !== undefined) ctx.provide('sessions', services.sessions as never)
return createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
cwd: '/tmp',
...services.compressionLevel === undefined
? {}
: { sessionExportCompressionLevel: services.compressionLevel },
})
}
async function responseBytes(response: Response): Promise<Uint8Array> {
return new Uint8Array(await response.arrayBuffer())
}
describe('session export compression config', () => {
it('defaults to level 6 and rejects values outside the integer 0-9 range', () => {
expect(ApiProxyService.Config({})).toEqual({
sessionExportCompressionLevel: 6,
})
expect(ApiProxyService.Config({ sessionExportCompressionLevel: 0 }))
.toEqual({ sessionExportCompressionLevel: 0 })
expect(ApiProxyService.Config({ sessionExportCompressionLevel: 9 }))
.toEqual({ sessionExportCompressionLevel: 9 })
for (const value of [-1, 10, 1.5]) {
expect(() => ApiProxyService.Config({ sessionExportCompressionLevel: value } as never)).toThrow()
}
})
})
describe('session.export download endpoint', () => {
it('streams a ZIP with the root artifact verbatim under its original filename', async () => {
const api = await buildApi({ 'session-root': artifact('session-root') })
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root'),
)
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toBe('application/zip')
expect(response.headers.get('content-disposition')).toContain('dsh-session-session-root.zip')
const files = unzipSync(await responseBytes(response))
expect(Object.keys(files)).toEqual(['session.jsonl'])
expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(artifact('session-root').content)
})
it('preflights root preparation through HEAD without streaming a body', async () => {
const readRaw = vi.fn(async () => artifact('session-root'))
const api = await buildApi({}, [], { readRaw })
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root', { method: 'HEAD' }),
)
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toBe('application/zip')
expect(response.headers.get('content-disposition')).toContain('dsh-session-session-root.zip')
expect(response.body).toBeNull()
expect(readRaw).toHaveBeenCalledOnce()
})
it('returns a bodyless preparation error from HEAD', async () => {
const api = await buildApi({})
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root', { method: 'HEAD' }),
)
expect(response.status).toBe(404)
expect(response.body).toBeNull()
})
it('uses the resolved compression level for ZIP entries', async () => {
const root = artifact('session-root', undefined, 'compressible\n'.repeat(32 * 1024))
const storedApi = await buildApi({ 'session-root': root }, [], { compressionLevel: 0 })
const compressedApi = await buildApi({ 'session-root': root }, [], { compressionLevel: 9 })
const stored = await storedApi.downloads.sessionLog(
{ sessionId: sid('session-root'), includeDescendants: false },
new AbortController().signal,
)
const compressed = await compressedApi.downloads.sessionLog(
{ sessionId: sid('session-root'), includeDescendants: false },
new AbortController().signal,
)
const storedBytes = await responseBytes(stored)
const compressedBytes = await responseBytes(compressed)
expect(compressedBytes.byteLength).toBeLessThan(storedBytes.byteLength)
expect(strFromU8(unzipSync(compressedBytes)['session.jsonl'] as Uint8Array)).toBe(root.content)
})
it('includes descendant artifacts under subagents/<id>/ when requested', async () => {
const api = await buildApi({
'session-root': artifact('session-root'),
'child-a': artifact('child-a', sid('session-root')),
'grandchild-a': artifact('grandchild-a', sid('child-a')),
}, [
node('child-a', node('grandchild-a')),
])
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
)
expect(response.status).toBe(200)
const files = unzipSync(await responseBytes(response))
expect(Object.keys(files).sort()).toEqual([
'session.jsonl',
'subagents/child-a/session.jsonl',
'subagents/grandchild-a/session.jsonl',
])
expect(strFromU8(files['subagents/child-a/session.jsonl'] as Uint8Array))
.toBe(artifact('child-a').content)
})
it('flushes each live root and descendant immediately before reading its artifact', async () => {
const stored: Record<string, SessionRawArtifact> = {
'session-root': artifact('session-root', undefined, 'stale root'),
'child-a': artifact('child-a', sid('session-root'), 'stale child'),
}
const durable: Record<string, SessionRawArtifact> = {
'session-root': artifact('session-root', undefined, 'durable root'),
'child-a': artifact('child-a', sid('session-root'), 'durable child'),
}
const flushed: SessionId[] = []
const api = await buildApi(stored, [node('child-a')], {
sessions: {
get: id => durable[id] === undefined ? undefined : { id },
flush: async (session) => {
const artifactAfterFlush = durable[session.id]
if (artifactAfterFlush === undefined) throw new Error('unexpected session')
flushed.push(session.id)
stored[session.id] = artifactAfterFlush
return true
},
},
})
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
)
const files = unzipSync(await responseBytes(response))
expect(flushed).toEqual([sid('session-root'), sid('child-a')])
expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe('durable root')
expect(strFromU8(files['subagents/child-a/session.jsonl'] as Uint8Array)).toBe('durable child')
})
it('reads a cold artifact without asking the live-session store to flush', async () => {
const flush = vi.fn(async () => true)
const root = artifact('session-root')
const api = await buildApi({ 'session-root': root }, [], {
sessions: {
get: () => undefined,
flush,
},
})
const response = await api.downloads.sessionLog(
{ sessionId: sid('session-root'), includeDescendants: false },
new AbortController().signal,
)
const files = unzipSync(await responseBytes(response))
expect(flush).not.toHaveBeenCalled()
expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content)
})
it('answers 404 for a missing root session', async () => {
const api = await buildApi({})
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root'),
)
expect(response.status).toBe(404)
})
it('answers 501 when the persistence backend has no per-session raw artifacts', async () => {
const api = await buildApi({}, [], { persistence: 'unsupported' })
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root'),
)
expect(response.status).toBe(501)
expect(await response.text()).toContain('does not expose per-session raw artifacts')
})
it('answers 400 when the sessionId query parameter is absent', async () => {
const api = await buildApi({ 'session-root': artifact('session-root') })
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?includeDescendants=true'),
)
expect(response.status).toBe(400)
})
it('answers 400 for an includeDescendants value other than true or false', async () => {
const api = await buildApi({ 'session-root': artifact('session-root') })
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=1'),
)
expect(response.status).toBe(400)
})
it('answers 500 when the deployment mounts no persistence or session-query service', async () => {
const api = await buildApi({}, [], { query: false, persistence: false })
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root'),
)
expect(response.status).toBe(500)
expect(await response.text()).toContain('session-query')
})
it('fails the whole export when a descendant has no stored artifact', async () => {
const api = await buildApi({
'session-root': artifact('session-root'),
}, [node('child-missing')])
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
)
expect(response.status).toBe(200)
// The stream errors before completing, so the body read rejects rather
// than returning a truncated-but-valid archive.
await expect(response.arrayBuffer()).rejects.toThrow()
})
it('keeps an astral character whole when its surrogate pair straddles a push boundary', async () => {
// The push loop slices by 2^16 code units and must back off one unit when
// the boundary lands inside a surrogate pair; otherwise the pair re-encodes
// as U+FFFD and the exported artifact is silently corrupted.
const root = { ...artifact('session-root'), content: `${'a'.repeat((1 << 16) - 1)}😀tail` }
const api = await buildApi({ 'session-root': root })
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root'),
)
const files = unzipSync(await responseBytes(response))
expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content)
})
it('splits a long artifact on a plain code-unit boundary without backoff', async () => {
// A boundary that lands on a BMP character needs no surrogate backoff; the
// round trip must still be byte-identical across the multi-chunk push.
const root = { ...artifact('session-root'), content: 'z'.repeat((1 << 16) + 4096) }
const api = await buildApi({ 'session-root': root })
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root'),
)
const files = unzipSync(await responseBytes(response))
expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content)
})
it('waits for response pull capacity before reading the next archive entry', async () => {
const root = artifact('session-root', undefined, [
imageEventLine('after-root'),
randomBytes(512 * 1024).toString('base64'),
].join('\n'))
let imageReads = 0
const api = await buildApi({ 'session-root': root }, [], {
attachments: async (ref) => {
imageReads += 1
return storedImage(String(ref.attachmentId), ref.mediaType)
},
})
vi.useFakeTimers()
let response: Response | undefined
try {
response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root'),
)
// Exhausting timer turns must not advance a producer whose byte queue is
// full; only a consumer pull can release it.
await vi.runAllTimersAsync()
expect(imageReads).toBe(0)
} finally {
vi.useRealTimers()
}
if (response === undefined) throw new Error('missing export response')
const files = unzipSync(await responseBytes(response))
expect(imageReads).toBe(1)
expect(files['media/after-root.png']).toEqual(storedImage('after-root').data)
})
it('exports an empty artifact as an empty zip entry', async () => {
const root = { ...artifact('session-root'), content: '' }
const api = await buildApi({ 'session-root': root })
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root'),
)
const files = unzipSync(await responseBytes(response))
expect(Object.keys(files)).toEqual(['session.jsonl'])
expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe('')
})
it('exports a shared lineage node once (seen-set dedup)', async () => {
const api = await buildApi({
'session-root': artifact('session-root'),
'child-a': artifact('child-a', sid('session-root')),
'child-b': artifact('child-b', sid('session-root')),
shared: artifact('shared', sid('child-a')),
}, [
node('child-a', node('shared')),
node('child-b', node('shared')),
])
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
)
const files = unzipSync(await responseBytes(response))
expect(Object.keys(files).sort()).toEqual([
'session.jsonl',
'subagents/child-a/session.jsonl',
'subagents/child-b/session.jsonl',
'subagents/shared/session.jsonl',
])
})
it('answers 500 without leaking the backend error when the root artifact read fails', async () => {
const api = await buildApi({}, [], { query: true, persistence: 'throw' })
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root'),
)
expect(response.status).toBe(500)
const body = await response.text()
expect(body).toBe('session log export failed to prepare the stored artifact')
expect(body).not.toContain('/host/private/')
})
it('answers the private-error-safe 500 when the live root flush fails', async () => {
const api = await buildApi({ 'session-root': artifact('session-root') }, [], {
sessions: {
get: id => ({ id }),
flush: async () => { throw new Error('/host/private/flush-state') },
},
})
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root'),
)
expect(response.status).toBe(500)
const body = await response.text()
expect(body).toBe('session log export failed to prepare the stored artifact')
expect(body).not.toContain('/host/private/')
})
it('forwards one request signal through root, lineage, and descendant reads', async () => {
const reads: Array<{ id: SessionId; signal: AbortSignal | undefined }> = []
const traces: AbortSignal[] = []
const api = await buildApi({}, [node('child-a')], {
readRaw: async (id, signal) => {
reads.push({ id, signal })
return id === sid('session-root')
? artifact('session-root')
: artifact('child-a', sid('session-root'))
},
traceSession: async (_id, signal) => {
if (signal !== undefined) traces.push(signal)
return {
target: { header: header('session-root'), live: false, persisted: true },
ancestors: [],
complete: true,
root: { header: header('session-root'), live: false, persisted: true },
descendants: [node('child-a')],
}
},
})
const controller = new AbortController()
const response = await api.downloads.sessionLog(
{ sessionId: sid('session-root'), includeDescendants: true },
controller.signal,
)
await response.arrayBuffer()
const producerSignal = traces[0]
if (producerSignal === undefined) throw new Error('missing lineage signal')
expect(reads[0]).toEqual({ id: sid('session-root'), signal: controller.signal })
expect(reads[1]).toEqual({ id: sid('child-a'), signal: producerSignal })
const cancellation = new Error('request cancelled after response')
controller.abort(cancellation)
expect(producerSignal.aborted).toBe(true)
expect(producerSignal.reason).toBe(cancellation)
})
it('preserves request cancellation instead of translating it to HTTP 500', async () => {
const api = await buildApi({ 'session-root': artifact('session-root') })
const controller = new AbortController()
const cancellation = new Error('request cancelled')
controller.abort(cancellation)
await expect(api.downloads.sessionLog(
{ sessionId: sid('session-root'), includeDescendants: false },
controller.signal,
)).rejects.toBe(cancellation)
})
it('aborts descendant work and terminates ZIP production when its reader cancels', async () => {
let reportDescendantStarted!: (signal: AbortSignal) => void
const descendantStarted = new Promise<AbortSignal>((resolve) => {
reportDescendantStarted = resolve
})
const api = await buildApi({}, [node('child-a')], {
readRaw: async (id, signal) => {
if (id === sid('session-root')) return artifact('session-root')
if (signal === undefined) throw new Error('missing descendant signal')
reportDescendantStarted(signal)
return new Promise((_, reject) => {
signal.addEventListener('abort', () => {
reject(signal.reason as Error)
}, { once: true })
})
},
})
const response = await api.downloads.sessionLog(
{ sessionId: sid('session-root'), includeDescendants: true },
new AbortController().signal,
)
const reader = response.body?.getReader()
if (reader === undefined) throw new Error('missing response body')
const descendantSignal = await descendantStarted
const cancellation = new Error('download consumer left')
await reader.cancel(cancellation)
expect(descendantSignal.aborted).toBe(true)
expect(descendantSignal.reason).toBe(cancellation)
})
it('aborts attachment reads when its reader cancels', async () => {
let reportAttachmentStarted!: (signal: AbortSignal) => void
const attachmentStarted = new Promise<AbortSignal>((resolve) => {
reportAttachmentStarted = resolve
})
const root = artifact('session-root', undefined, [
'{"type":"session","version":0,"id":"session-root","createdAt":1000}',
imageEventLine('slow-img'),
].join('\n') + '\n')
const api = await buildApi({ 'session-root': root }, [], {
attachments: async (_ref, signal) => {
if (signal === undefined) throw new Error('missing attachment signal')
reportAttachmentStarted(signal)
return new Promise((_, reject) => {
signal.addEventListener('abort', () => {
reject(signal.reason as Error)
}, { once: true })
})
},
})
const response = await api.downloads.sessionLog(
{ sessionId: sid('session-root'), includeDescendants: false },
new AbortController().signal,
)
const reader = response.body?.getReader()
if (reader === undefined) throw new Error('missing response body')
const attachmentSignal = await attachmentStarted
const cancellation = new Error('download consumer left during attachment read')
await reader.cancel(cancellation)
expect(attachmentSignal.aborted).toBe(true)
expect(attachmentSignal.reason).toBe(cancellation)
})
it('uses a stable Error reason when its reader cancels without one', async () => {
let reportDescendantStarted!: (signal: AbortSignal) => void
const descendantStarted = new Promise<AbortSignal>((resolve) => {
reportDescendantStarted = resolve
})
const api = await buildApi({}, [node('child-a')], {
readRaw: async (id, signal) => {
if (id === sid('session-root')) return artifact('session-root')
if (signal === undefined) throw new Error('missing descendant signal')
reportDescendantStarted(signal)
return new Promise((_, reject) => {
signal.addEventListener('abort', () => {
reject(signal.reason as Error)
}, { once: true })
})
},
})
const response = await api.downloads.sessionLog(
{ sessionId: sid('session-root'), includeDescendants: true },
new AbortController().signal,
)
const reader = response.body?.getReader()
if (reader === undefined) throw new Error('missing response body')
const descendantSignal = await descendantStarted
await reader.cancel()
expect(descendantSignal.reason).toEqual(new Error('session log export stream cancelled'))
})
it('normalizes a non-Error descendant failure before erroring the stream', async () => {
const api = await buildApi({}, [node('child-a')], {
readRaw: async (id) => {
if (id === sid('session-root')) return artifact('session-root')
throw 'descendant read failed'
},
})
const response = await api.downloads.sessionLog(
{ sessionId: sid('session-root'), includeDescendants: true },
new AbortController().signal,
)
await expect(response.arrayBuffer()).rejects.toEqual(new Error('descendant read failed'))
})
it('includes media objects referenced by the root log under media/<id>.<ext>', async () => {
const root = artifact('session-root', undefined, [
'{"type":"session","version":0,"id":"session-root","createdAt":1000}',
imageEventLine('img-1'),
].join('\n') + '\n')
const api = await buildApi({ 'session-root': root })
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root'),
)
expect(response.status).toBe(200)
const files = unzipSync(await responseBytes(response))
expect(Object.keys(files).sort()).toEqual(['media/img-1.png', 'session.jsonl'])
expect(files['media/img-1.png']).toEqual(storedImage('img-1').data)
})
it('collects media referenced from nested tool results', async () => {
const nested = '{"type":"assistant/message","seq":2,"time":2000,"data":{"content":[{"type":"tool-result","content":[{"type":"image","attachment":{"attachmentId":"nested-1","mediaType":"image/webp","bytes":4,"width":2,"height":2}}]}]}}'
const root = artifact('session-root', undefined, [
'{"type":"session","version":0,"id":"session-root","createdAt":1000}',
nested,
].join('\n') + '\n')
const api = await buildApi({ 'session-root': root })
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root'),
)
const files = unzipSync(await responseBytes(response))
expect(Object.keys(files).sort()).toEqual(['media/nested-1.webp', 'session.jsonl'])
})
it('scans the wrapped, inserted, and chunk carriers plus non-object content items', async () => {
const block = (id: string, mediaType: string) =>
`{"type":"image","attachment":{"attachmentId":"${id}","mediaType":"${mediaType}","bytes":4,"width":2,"height":2}}`
const wrapped = `{"type":"assistant/message","seq":2,"time":2000,"data":{"message":{"role":"assistant","content":["noise",${block('wrapped-1', 'image/jpeg')}]}}}`
const inserted = `{"type":"context/inserted","seq":3,"time":3000,"data":{"inserted":[{"content":[${block('inserted-1', 'image/gif')}]}]}}`
const chunk = `{"type":"assistant/chunk","seq":4,"time":4000,"data":{"chunk":{"type":"block-end","block":${block('chunk-1', 'image/png')}}}}`
const root = artifact('session-root', undefined, [
'{"type":"session","version":0,"id":"session-root","createdAt":1000}',
wrapped,
inserted,
chunk,
].join('\n') + '\n')
const api = await buildApi({ 'session-root': root })
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root'),
)
const files = unzipSync(await responseBytes(response))
expect(Object.keys(files).sort()).toEqual([
'media/chunk-1.png',
'media/inserted-1.gif',
'media/wrapped-1.jpg',
'session.jsonl',
])
})
it('deduplicates one media object referenced by several included logs', async () => {
const line = imageEventLine('shared-img')
const root = artifact('session-root', undefined, [
'{"type":"session","version":0,"id":"session-root","createdAt":1000}',
line,
].join('\n') + '\n')
const child = artifact('child-a', sid('session-root'), [
'{"type":"session","version":0,"id":"child-a","createdAt":1000}',
line,
].join('\n') + '\n')
const api = await buildApi({ 'session-root': root, 'child-a': child }, [node('child-a')])
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
)
const files = unzipSync(await responseBytes(response))
expect(files['media/shared-img.png']).toEqual(storedImage('shared-img').data)
expect(Object.keys(files).filter(name => name.startsWith('media/'))).toEqual(['media/shared-img.png'])
})
it('includes descendant media only when descendants are requested', async () => {
const child = artifact('child-a', sid('session-root'), [
'{"type":"session","version":0,"id":"child-a","createdAt":1000}',
imageEventLine('child-img'),
].join('\n') + '\n')
const api = await buildApi({ 'session-root': artifact('session-root'), 'child-a': child }, [node('child-a')])
const without = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root'),
)
expect(Object.keys(unzipSync(await responseBytes(without)))).toEqual(['session.jsonl'])
const withDescendants = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
)
expect(Object.keys(unzipSync(await responseBytes(withDescendants))).sort()).toEqual([
'media/child-img.png',
'session.jsonl',
'subagents/child-a/session.jsonl',
])
})
it('fails the whole export when a referenced image cannot be read', async () => {
const root = artifact('session-root', undefined, [
'{"type":"session","version":0,"id":"session-root","createdAt":1000}',
imageEventLine('gone-img'),
].join('\n') + '\n')
const api = await buildApi({ 'session-root': root }, [], {
attachments: async () => { throw new Error('attachment bytes missing') },
})
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root'),
)
expect(response.status).toBe(200)
await expect(response.arrayBuffer()).rejects.toThrow('attachment bytes missing')
})
it('answers 500 when the deployment mounts no attachments service', async () => {
const api = await buildApi({ 'session-root': artifact('session-root') }, [], { attachments: false })
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root'),
)
expect(response.status).toBe(500)
expect(await response.text()).toContain('attachments')
})
})
-54
View File
@@ -1,54 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../credentials/credentials"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../api/session-controller/tsconfig.host.json"
},
{
"path": "../../util/brand"
},
{
"path": "../../attachment/attachment"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/agent-default-model"
},
{
"path": "../../core/session"
},
{
"path": "../../session/session-persistence"
},
{
"path": "../../session-query/session-query"
},
{
"path": "../../runtime-diagnostics/invariants"
},
{
"path": "../../util/native-command"
},
{
"path": "../../util/crypto"
}
]
}
@@ -90,9 +90,6 @@ describe('Typert-backed Cordis catalog', () => {
// An interface-typed key is described by its Service Definition: that is where // An interface-typed key is described by its Service Definition: that is where
// the contract and, by repository convention, the member JSDoc live. // the contract and, by repository convention, the member JSDoc live.
expect(byKey.get('lsp')?.type).toBe('LspService') expect(byKey.get('lsp')?.type).toBe('LspService')
// The Service Definition may sit anywhere in the package, including a nested
// contract directory (`src/api/`), while the Context merge stays in `src`.
expect(byKey.get('apiProxy')?.type).toBe('ApiProxy')
// Two packages describe `ctx.typert` — a merge-extensible interface in // Two packages describe `ctx.typert` — a merge-extensible interface in
// type-meta and the implementing class in registry. The class wins: it is the // type-meta and the implementing class in registry. The class wins: it is the
// object a caller meets and it carries the documentation. // object a caller meets and it carries the documentation.
+15 -73
View File
@@ -376,9 +376,6 @@ importers:
'@deepseek-ai/dsh-fs-sandbox': '@deepseek-ai/dsh-fs-sandbox':
specifier: workspace:^ specifier: workspace:^
version: link:../../packages/fs/fs-sandbox version: link:../../packages/fs/fs-sandbox
'@deepseek-ai/dsh-host-apiproxy':
specifier: workspace:^
version: link:../../packages/host/apiproxy
'@deepseek-ai/dsh-host-frontend-static': '@deepseek-ai/dsh-host-frontend-static':
specifier: workspace:^ specifier: workspace:^
version: link:../../packages/host/frontend-static version: link:../../packages/host/frontend-static
@@ -1575,9 +1572,6 @@ importers:
'@deepseek-ai/dsh-file-reference-local': '@deepseek-ai/dsh-file-reference-local':
specifier: workspace:^ specifier: workspace:^
version: link:../../context/file-reference-local version: link:../../context/file-reference-local
'@deepseek-ai/dsh-host-apiproxy':
specifier: workspace:^
version: link:../../host/apiproxy
'@deepseek-ai/dsh-host-directory-picker-auto': '@deepseek-ai/dsh-host-directory-picker-auto':
specifier: workspace:^ specifier: workspace:^
version: link:../../host/directory-picker-auto version: link:../../host/directory-picker-auto
@@ -1654,6 +1648,9 @@ importers:
'@deepseek-ai/schemastery': '@deepseek-ai/schemastery':
specifier: link:../../../vendor/schemastery specifier: link:../../../vendor/schemastery
version: link:../../../vendor/schemastery version: link:../../../vendor/schemastery
zod:
specifier: ^4.4.3
version: 4.4.3
devDependencies: devDependencies:
'@deepseek-ai/cordis': '@deepseek-ai/cordis':
specifier: workspace:^ specifier: workspace:^
@@ -1661,15 +1658,15 @@ importers:
'@deepseek-ai/dsh-attachment': '@deepseek-ai/dsh-attachment':
specifier: workspace:^ specifier: workspace:^
version: link:../../attachment/attachment version: link:../../attachment/attachment
'@deepseek-ai/dsh-brand':
specifier: workspace:^
version: link:../../util/brand
'@deepseek-ai/dsh-commands': '@deepseek-ai/dsh-commands':
specifier: workspace:^ specifier: workspace:^
version: link:../../interaction/commands version: link:../../interaction/commands
'@deepseek-ai/dsh-credentials': '@deepseek-ai/dsh-credentials':
specifier: workspace:^ specifier: workspace:^
version: link:../../credentials/credentials version: link:../../credentials/credentials
'@deepseek-ai/dsh-host-apiproxy':
specifier: workspace:^
version: link:../../host/apiproxy
'@deepseek-ai/dsh-host-directory-picker': '@deepseek-ai/dsh-host-directory-picker':
specifier: workspace:^ specifier: workspace:^
version: link:../../host/directory-picker version: link:../../host/directory-picker
@@ -2274,12 +2271,18 @@ importers:
'@deepseek-ai/cordis': '@deepseek-ai/cordis':
specifier: workspace:^ specifier: workspace:^
version: link:../../../vendor/cordis version: link:../../../vendor/cordis
'@deepseek-ai/dsh-api-remotes':
specifier: workspace:^
version: link:../../api/remotes
'@deepseek-ai/dsh-client-connection': '@deepseek-ai/dsh-client-connection':
specifier: workspace:^ specifier: workspace:^
version: link:../connection version: link:../connection
'@deepseek-ai/dsh-client-locale': '@deepseek-ai/dsh-client-locale':
specifier: workspace:^ specifier: workspace:^
version: link:../locale version: link:../locale
'@deepseek-ai/dsh-client-store':
specifier: workspace:^
version: link:../store
'@deepseek-ai/dsh-client-test-runtime': '@deepseek-ai/dsh-client-test-runtime':
specifier: workspace:^ specifier: workspace:^
version: link:../../test-support/client-runtime version: link:../../test-support/client-runtime
@@ -4974,12 +4977,12 @@ importers:
'@deepseek-ai/dsh-bash-sandbox': '@deepseek-ai/dsh-bash-sandbox':
specifier: workspace:^ specifier: workspace:^
version: link:../../shell/bash-sandbox version: link:../../shell/bash-sandbox
'@deepseek-ai/dsh-client-connection':
specifier: workspace:^
version: link:../../client/connection
'@deepseek-ai/dsh-client-modules': '@deepseek-ai/dsh-client-modules':
specifier: workspace:^ specifier: workspace:^
version: link:../../client/modules version: link:../../client/modules
'@deepseek-ai/dsh-host-apiproxy':
specifier: workspace:^
version: link:../../host/apiproxy
'@deepseek-ai/dsh-host-webserver': '@deepseek-ai/dsh-host-webserver':
specifier: workspace:^ specifier: workspace:^
version: link:../../host/webserver version: link:../../host/webserver
@@ -5799,67 +5802,6 @@ importers:
specifier: workspace:^ specifier: workspace:^
version: link:../../core/tools version: link:../../core/tools
packages/host/apiproxy:
dependencies:
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
'@deepseek-ai/dsh-agent-default-model':
specifier: workspace:^
version: link:../../core/agent-default-model
'@deepseek-ai/dsh-api-session-controller':
specifier: workspace:^
version: link:../../api/session-controller
'@deepseek-ai/dsh-attachment':
specifier: workspace:^
version: link:../../attachment/attachment
'@deepseek-ai/dsh-brand':
specifier: workspace:^
version: link:../../util/brand
'@deepseek-ai/dsh-native-command':
specifier: workspace:^
version: link:../../util/native-command
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
'@deepseek-ai/dsh-session-persistence':
specifier: workspace:^
version: link:../../session/session-persistence
'@deepseek-ai/dsh-session-query':
specifier: workspace:^
version: link:../../session-query/session-query
'@deepseek-ai/dsh-util-crypto':
specifier: workspace:^
version: link:../../util/crypto
'@deepseek-ai/schemastery':
specifier: link:../../../vendor/schemastery
version: link:../../../vendor/schemastery
fflate:
specifier: ^0.8.2
version: 0.8.3
zod:
specifier: ^4.4.3
version: 4.4.3
devDependencies:
'@deepseek-ai/cordis':
specifier: workspace:^
version: link:../../../vendor/cordis
'@deepseek-ai/dsh-credentials':
specifier: workspace:^
version: link:../../credentials/credentials
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../runtime-diagnostics/invariants
'@deepseek-ai/dsh-settings':
specifier: workspace:^
version: link:../../settings/settings
'@deepseek-ai/dsh-typert-protocol':
specifier: workspace:^
version: link:../../typert/protocol
'@deepseek-ai/dsh-typert-registry':
specifier: workspace:^
version: link:../../typert/registry
packages/host/directory-picker: packages/host/directory-picker:
devDependencies: devDependencies:
'@deepseek-ai/cordis': '@deepseek-ai/cordis':
+1 -1
View File
@@ -198,7 +198,7 @@ export function expectedDshPackageFiles(manifest: PackageManifest): readonly str
...exportDefault(manifest, './worker') === './lib/worker.js' ? ['lib/worker.js'] : [], ...exportDefault(manifest, './worker') === './lib/worker.js' ? ['lib/worker.js'] : [],
// UI plugin packages ship their browser bundle beside the node lib // UI plugin packages ship their browser bundle beside the node lib
// (single-artifact ruling: dist/ retired, ./client resolves lib/client.js). // (single-artifact ruling: dist/ retired, ./client resolves lib/client.js).
// Keyed on the artifact path, not the subpath name: apiproxy's ./client is // Keyed on the artifact path, not the subpath name: a package's ./client is
// a browser-safe source channel, not a bundle. // a browser-safe source channel, not a bundle.
...exportDefault(manifest, './client') === './lib/client.js' ? ['lib/client.js'] : [], ...exportDefault(manifest, './client') === './lib/client.js' ? ['lib/client.js'] : [],
// runtime's shell-held loader subpath ships as its own bundle beside the client half. // runtime's shell-held loader subpath ships as its own bundle beside the client half.
+1 -1
View File
@@ -1,7 +1,7 @@
/** Map one workspace source alias target to its declaration-build target. */ /** Map one workspace source alias target to its declaration-build target. */
export function builtDeclarationPath(candidate: string): string { export function builtDeclarationPath(candidate: string): string {
// Two workspace path forms exist: whole-package entries end in /src, subpath // Two workspace path forms exist: whole-package entries end in /src, subpath
// wildcards (apiproxy's browser-safe /api and /client channels) in /src/*. // wildcards (browser-safe /types and /client channels) in /src/*.
if (candidate.endsWith('/src')) { if (candidate.endsWith('/src')) {
return `${candidate.slice(0, -'/src'.length)}/lib/types` return `${candidate.slice(0, -'/src'.length)}/lib/types`
} }
+3 -3
View File
@@ -58,7 +58,6 @@ export const SERVICE_PAGE: Record<string, string> = {
agentDefaultModel: 'core.md', agentDefaultModel: 'core.md',
agentPresets: 'core.md', agentPresets: 'core.md',
agents: 'core.md', agents: 'core.md',
apiProxy: 'typert.md',
approval: 'approval.md', approval: 'approval.md',
attachments: 'attachment.md', attachments: 'attachment.md',
shell: 'shell.md', shell: 'shell.md',
@@ -618,6 +617,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
DirectoryListing: 'workspace.md', DirectoryListing: 'workspace.md',
TypertContribution: 'invariants.md', TypertContribution: 'invariants.md',
TypertRemoteEventSource: 'typert.md', TypertRemoteEventSource: 'typert.md',
RemoteEventHostInfo: 'typert.md',
TypertFace: 'invariants.md', TypertFace: 'invariants.md',
TypertPackageFilter: 'invariants.md', TypertPackageFilter: 'invariants.md',
TypertPackageRecord: 'invariants.md', TypertPackageRecord: 'invariants.md',
@@ -661,7 +661,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
BashEnvVariableInfo: 'service-local metadata type is owned by packages/shell/tool-bash/src/index.ts', BashEnvVariableInfo: 'service-local metadata type is owned by packages/shell/tool-bash/src/index.ts',
CompactionAgentContext: 'compaction service input is owned by packages/compaction/compaction/src/index.ts', CompactionAgentContext: 'compaction service input is owned by packages/compaction/compaction/src/index.ts',
ManualCompactAgentContext: 'manual compaction service input is owned by packages/compaction/compaction/src/index.ts', ManualCompactAgentContext: 'manual compaction service input is owned by packages/compaction/compaction/src/index.ts',
ClientResponse: 'wire response message is owned by packages/host/apiproxy/src/api/rpc.ts', ClientResponse: 'wire response message is owned by packages/client/connection/src/rpc.ts',
ApprovalRequestId: 'dynamic Plugin approval identity is owned by packages/extensions/cordis-host-runner/src/types.ts', ApprovalRequestId: 'dynamic Plugin approval identity is owned by packages/extensions/cordis-host-runner/src/types.ts',
CordisErrorDetails: 'Cordis runtime error payload is owned by packages/extensions/cordis-host-runner/src/types.ts', CordisErrorDetails: 'Cordis runtime error payload is owned by packages/extensions/cordis-host-runner/src/types.ts',
CordisInspectPlatform: 'Cordis inspect platform identity is owned by packages/extensions/cordis-host-runner/src/types.ts', CordisInspectPlatform: 'Cordis inspect platform identity is owned by packages/extensions/cordis-host-runner/src/types.ts',
@@ -714,7 +714,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
PermissionSelect: 'permissions projection payload is owned by packages/interaction/permission-presets/src/types.ts', PermissionSelect: 'permissions projection payload is owned by packages/interaction/permission-presets/src/types.ts',
PromptAssembly: 'assembly result is owned by packages/core/system-prompt/README.md', PromptAssembly: 'assembly result is owned by packages/core/system-prompt/README.md',
RequestRunId: 'dynamic-package payload contract is owned by packages/extensions/cordis-host-runner/src/types.ts', RequestRunId: 'dynamic-package payload contract is owned by packages/extensions/cordis-host-runner/src/types.ts',
RpcReceipt: 'carrier-layer receipt is owned by packages/host/apiproxy/src/api/rpc.ts', RpcReceipt: 'carrier-layer receipt is owned by packages/client/connection/src/rpc.ts',
Sandbox: 'external E2B SDK handle is owned by packages/e2b/e2b/README.md', Sandbox: 'external E2B SDK handle is owned by packages/e2b/e2b/README.md',
SessionForkSource: 'service-local fork input is owned by packages/core/session/src/index.ts', SessionForkSource: 'service-local fork input is owned by packages/core/session/src/index.ts',
SubagentRunEndInfo: 'event payload contract is owned by packages/subagent/subagent/src/types.ts', SubagentRunEndInfo: 'event payload contract is owned by packages/subagent/subagent/src/types.ts',
+9 -17
View File
@@ -104,7 +104,7 @@ const SERVICE_ROLES: ServiceRole[] = [
title: 'Durable binary attachment storage', title: 'Durable binary attachment storage',
mode: 'seam', mode: 'seam',
implementations: ['attachment-local'], implementations: ['attachment-local'],
consumers: ['api-session-controller', 'host-apiproxy', 'tool-fs', 'llm-pi-ai', 'llm-deepseek'], consumers: ['api-session-controller', 'tool-fs', 'llm-pi-ai', 'llm-deepseek'],
note: 'The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content.', note: 'The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content.',
}, },
{ {
@@ -236,8 +236,8 @@ const SERVICE_ROLES: ServiceRole[] = [
title: 'User-settings seam', title: 'User-settings seam',
mode: 'seam', mode: 'seam',
implementations: ['settings-file'], implementations: ['settings-file'],
consumers: ['llm-deepseek', 'llm-pi-ai', 'host-apiproxy'], consumers: ['api-settings-controller', 'llm-deepseek', 'llm-pi-ai'],
note: 'Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section; the web gateway serves redacted layered descriptors and writes the user layer.', note: 'Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section; the settings controller serves redacted layered descriptors and writes the user layer.',
}, },
{ {
key: 'subagentModelSelection', key: 'subagentModelSelection',
@@ -253,8 +253,8 @@ const SERVICE_ROLES: ServiceRole[] = [
title: 'Credential seam', title: 'Credential seam',
mode: 'seam', mode: 'seam',
implementations: ['credentials-local'], implementations: ['credentials-local'],
consumers: ['llm-deepseek', 'llm-pi-ai', 'host-apiproxy'], consumers: ['api-settings-controller', 'llm-deepseek', 'llm-pi-ai'],
note: 'Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the web gateway exposes value-free views and write-only storage.', note: 'Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the settings controller exposes value-free views and write-only storage.',
}, },
{ {
key: 'authorization', key: 'authorization',
@@ -389,15 +389,15 @@ const SERVICE_ROLES: ServiceRole[] = [
pkg: 'session-projection', pkg: 'session-projection',
title: 'Session projection units', title: 'Session projection units',
mode: 'core', mode: 'core',
consumers: ['tool-todo', 'session-title', 'host-apiproxy'], consumers: ['api-session-controller', 'tool-todo', 'session-title'],
note: 'Domains register state-driven fold units; the eager drive keeps per-session watermark states and api-proxy serves baselines and pushes changed values.', note: 'Domains register state-driven fold units; the eager drive keeps per-session watermark states and the Session controller serves baselines and pushes changed values.',
}, },
{ {
key: 'sessionProjectionCache', key: 'sessionProjectionCache',
pkg: 'session-projection-cache', pkg: 'session-projection-cache',
title: 'Persisted projection cache', title: 'Persisted projection cache',
mode: 'core', mode: 'core',
consumers: ['host-apiproxy'], consumers: ['api-session-controller', 'session-query', 'session-reference', 'subagent'],
note: 'Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs.', note: 'Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs.',
}, },
{ {
@@ -422,7 +422,7 @@ const SERVICE_ROLES: ServiceRole[] = [
pkg: 'agent-default-model', pkg: 'agent-default-model',
title: 'Default Agent model selection', title: 'Default Agent model selection',
mode: 'core', mode: 'core',
consumers: ['headless', 'host-apiproxy'], consumers: ['api-session-controller', 'headless'],
note: 'Layers the default ModelSelection through settings so direct and Host-backed Agent entry points share one state owner.', note: 'Layers the default ModelSelection through settings so direct and Host-backed Agent entry points share one state owner.',
}, },
{ {
@@ -648,14 +648,6 @@ const SERVICE_ROLES: ServiceRole[] = [
consumers: ['tool-lsp'], consumers: ['tool-lsp'],
note: 'Provider registration and selection plus normalized query execution over exactly four operations; the seam offers no protocol escape hatch, so a backend translates into the normalized request and result.', note: 'Provider registration and selection plus normalized query execution over exactly four operations; the seam offers no protocol escape hatch, so a backend translates into the normalized request and result.',
}, },
{
key: 'apiProxy',
pkg: 'host-apiproxy',
title: 'Host API dispatch',
mode: 'core',
consumers: ['client-connection'],
note: 'The transport-agnostic host gateway face: it dispatches browser API calls, and each open host stream subscribes to the events it forwards rather than being pushed to through a broadcast verb.',
},
{ {
key: 'dynamicCordisRunner', key: 'dynamicCordisRunner',
pkg: 'cordis-host-runner', pkg: 'cordis-host-runner',
@@ -114,7 +114,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/e2b/fs-e2b': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' }, 'packages/e2b/fs-e2b': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' },
'packages/fs/fs-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' }, 'packages/fs/fs-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' },
'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' }, 'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' },
'packages/host/apiproxy': { kind: 'none', reason: 'The wire contract and fetch carriers move already-composed messages and register nothing model-facing.' },
'packages/host/directory-picker': { kind: 'none', reason: 'The GUI-host picking seam registers nothing model-facing.' }, 'packages/host/directory-picker': { kind: 'none', reason: 'The GUI-host picking seam registers nothing model-facing.' },
'packages/host/directory-picker-auto': { kind: 'none', reason: 'The GUI-host picking chooser only mounts a backend row; it registers nothing model-facing.' }, 'packages/host/directory-picker-auto': { kind: 'none', reason: 'The GUI-host picking chooser only mounts a backend row; it registers nothing model-facing.' },
'packages/host/directory-picker-browse': { kind: 'none', reason: 'The GUI-host picking backend registers nothing model-facing.' }, 'packages/host/directory-picker-browse': { kind: 'none', reason: 'The GUI-host picking backend registers nothing model-facing.' },
+6 -3
View File
@@ -136,7 +136,12 @@
"@deepseek-ai/dsh-headless/startup": ["./packages/bundle/headless/src/startup.ts"], "@deepseek-ai/dsh-headless/startup": ["./packages/bundle/headless/src/startup.ts"],
"@deepseek-ai/dsh-web-app/startup": ["./packages/bundle/web-app/src/startup.ts"], "@deepseek-ai/dsh-web-app/startup": ["./packages/bundle/web-app/src/startup.ts"],
"@deepseek-ai/dsh-client-*/client": ["./packages/client/*/src/client"], "@deepseek-ai/dsh-client-*/client": ["./packages/client/*/src/client"],
"@deepseek-ai/dsh-host-apiproxy": ["./packages/host/apiproxy/src"], // One wildcard maps every @deepseek-ai/dsh-<name> to its source. Package
// dir names are unique across groups, so first-on-disk-wins resolution is
// unambiguous; adding a package under an existing group needs no edit
// here. The aggregates' project references (tsconfig.host.json /
// tsconfig.client.json) stay explicit TS project references have no
// wildcard form.
"@deepseek-ai/dsh-host-directory-picker": ["./packages/host/directory-picker/src"], "@deepseek-ai/dsh-host-directory-picker": ["./packages/host/directory-picker/src"],
"@deepseek-ai/dsh-host-directory-picker/*": ["./packages/host/directory-picker/src/*"], "@deepseek-ai/dsh-host-directory-picker/*": ["./packages/host/directory-picker/src/*"],
"@deepseek-ai/dsh-host-directory-picker-browse": ["./packages/host/directory-picker-browse/src"], "@deepseek-ai/dsh-host-directory-picker-browse": ["./packages/host/directory-picker-browse/src"],
@@ -145,8 +150,6 @@
"@deepseek-ai/dsh-host-directory-picker-native/*": ["./packages/host/directory-picker-native/src/*"], "@deepseek-ai/dsh-host-directory-picker-native/*": ["./packages/host/directory-picker-native/src/*"],
"@deepseek-ai/dsh-host-directory-picker-auto": ["./packages/host/directory-picker-auto/src"], "@deepseek-ai/dsh-host-directory-picker-auto": ["./packages/host/directory-picker-auto/src"],
"@deepseek-ai/dsh-host-directory-picker-auto/*": ["./packages/host/directory-picker-auto/src/*"], "@deepseek-ai/dsh-host-directory-picker-auto/*": ["./packages/host/directory-picker-auto/src/*"],
"@deepseek-ai/dsh-host-apiproxy/client": ["./packages/host/apiproxy/src/fetch/client.ts"],
"@deepseek-ai/dsh-host-apiproxy/*": ["./packages/host/apiproxy/src/*"],
"@deepseek-ai/dsh-host-webserver": ["./packages/host/webserver/src"], "@deepseek-ai/dsh-host-webserver": ["./packages/host/webserver/src"],
"@deepseek-ai/dsh-host-frontend-static": ["./packages/host/frontend-static/src"], "@deepseek-ai/dsh-host-frontend-static": ["./packages/host/frontend-static/src"],
"@deepseek-ai/dsh-host-plugin-inventory": ["./packages/host/plugin-inventory/src"], "@deepseek-ai/dsh-host-plugin-inventory": ["./packages/host/plugin-inventory/src"],
+1 -1
View File
@@ -2,7 +2,7 @@
// Client-side typecheck aggregate: packages/client tests (.ts and .tsx). // Client-side typecheck aggregate: packages/client tests (.ts and .tsx).
// Split from the host aggregate because both sides merge cordis Context // Split from the host aggregate because both sides merge cordis Context
// under the same keys (sessions, loader) with different services; shared // under the same keys (sessions, loader) with different services; shared
// leaves (session/llm/tools/apiproxy/...) build once and are referenced by // leaves (session/llm/tools/...) build once and are referenced by
// both programs through each client package's own references. // both programs through each client package's own references.
"extends": "./tsconfig.base.client.json", "extends": "./tsconfig.base.client.json",
"compilerOptions": { "compilerOptions": {
-1
View File
@@ -313,7 +313,6 @@
{ "path": "./packages/hooks/hooks-claude-code" }, { "path": "./packages/hooks/hooks-claude-code" },
{ "path": "./packages/hooks/hooks-codex" }, { "path": "./packages/hooks/hooks-codex" },
{ "path": "./packages/mcp/mcp-client" }, { "path": "./packages/mcp/mcp-client" },
{ "path": "./packages/host/apiproxy" },
{ "path": "./packages/host/directory-picker" }, { "path": "./packages/host/directory-picker" },
{ "path": "./packages/host/directory-picker-auto" }, { "path": "./packages/host/directory-picker-auto" },
{ "path": "./packages/host/directory-picker-browse" }, { "path": "./packages/host/directory-picker-browse" },
-3
View File
@@ -330,9 +330,6 @@ export default defineConfig({
// by decision: its correctness signal is its uninstrumented suite and // by decision: its correctness signal is its uninstrumented suite and
// the packer's end-to-end image spec. // the packer's end-to-end image spec.
'packages/experimental/webworker-runtime/src/**/*.ts', 'packages/experimental/webworker-runtime/src/**/*.ts',
'packages/host/apiproxy/src/index.ts',
'packages/host/apiproxy/src/invariant.ts',
'packages/host/apiproxy/src/api-proxy.ts',
// Projection/command round: executor lifecycle branches and the // Projection/command round: executor lifecycle branches and the
// registry's drive tails need the same maturing lanes. TODO(gui): // registry's drive tails need the same maturing lanes. TODO(gui):
// cover and remove with the client test lane above. // cover and remove with the client test lane above.