Merge master at 35eeb4a112

This commit is contained in:
pku-xht
2026-08-10 20:25:58 +08:00
879 changed files with 11605 additions and 2942 deletions

View File

@@ -2,17 +2,17 @@
These package-specific rules supplement the repo-wide [conventions](../AGENTS.md#conventions).
- **Plugin export shape:** service packages default-export their service class; function plugins named-export `name` / `inject` / `Config` / `apply` and have no default export. Mixing the forms makes the Loader discard the function plugin's namespace ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)).
- **Plugin exports:** service packages default-export their service class; function plugins named-export `name` / `inject` / `Config` / `apply` and have no default export. Mixing the forms makes the Loader discard the function plugin's namespace ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)).
- **Optional services use `ctx.get(name)`.** Reserve `ctx.<name>` for declared injections; the property proxy is topology-sensitive, while strict `ctx.get` reads the global service store ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)).
- **Product-visible plugins require a non-unit REAL-composition test.** Hand-built `ctx.plugin(...)` suites are insufficient. Boot test-only `cordis.yml` through the Loader and app/process; mock only external/nondeterministic boundaries and assert model-visible, durable, or user-visible output. Keep opt-ins out of shipped defaults. [Policy](../docs/testing.md).
- **Product-visible plugins require a non-unit REAL-composition test.** Hand-built `ctx.plugin(...)` suites are insufficient. Boot test-only `cordis.yml` through the Loader and app/process; mock only external services or nondeterministic inputs and assert model-visible, durable, or user-visible output. Keep opt-ins out of shipped defaults. [Policy](../docs/testing.md).
- **Initiator-owned private chains derive, then capture.** Under `ctx.agents.withInitiator()`, recover the Agent at each orchestration entry, derive `agent.session`, and let operation-local helpers close over it. Keep `Agent` and `Session` explicit at lifecycle, session-log, service, authority, worker/process, persistence, and wire interfaces; do not widen a leaf helper from `Session` to `Context` merely to hide a parameter ([rationale](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)).
- **Represent one asynchronous operation with one lifecycle controller or transaction.** Separate readiness, cancellation, disposal, reservation, or sentinel state requires an independent owner or settlement boundary; otherwise fold it while preserving rollback, callback containment, and quiescence.
- **Shape Service Definitions around all current Consumers.** Keep tool-schema, Loader, UI, transport, and provider-specific behavior in the Consumer or provider; do not let one Consumer dictate the service contract ([capability-seam rationale](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)). Inverse smell: a public service method with one internal caller — pass a private capability closure instead (`RunCodeBridgeOptions`).
- **Represent one asynchronous operation with one lifecycle controller or transaction.** Separate readiness, cancellation, disposal, reservation, or sentinel state requires an independent owner or settlement point; otherwise fold it while preserving rollback, callback containment, and quiescence.
- **Design Service Definitions for all current Consumers.** Keep tool-schema, Loader, UI, transport, and provider-specific behavior in the Consumer or provider; do not let one Consumer dictate the service contract ([capability-seam rationale](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)). Inverse smell: a public service method with one internal caller — pass a private capability closure instead (`RunCodeBridgeOptions`).
- **Require a current owner and need.** Tie each abstraction, state machine, option, defensive copy, and compatibility path to a current contract or production consumer, and keep behavior in its owning plugin or service.
- **Require evidence for public choices.** Configurability does not justify an unsupported default, public operation set, format, or imported external concept. Use current-consumer evidence or relevant prior art; otherwise require an explicit value or defer the choice.
- **Write model-facing contracts from the model's perspective.** Prompts, tool schemas, results, and diagnostics contain only task-relevant concepts, not UI, transport, or implementation vocabulary. Pin stable model-visible text verbatim and dynamic behavior through snapshots or end-to-end coverage.
- **Enforce at the operation boundary that owns the decision.** Schema omission, prompt filtering, facades, wrappers, and listener order are not enforcement when direct or alternate callers can bypass them; test denial through the executor.
- **Publish state only at its commit point.** Emit each notification and update derived state only after the success boundary that makes it true; derive caches, prompts, UI echoes, replay, and query views from one authoritative source.
- **Enforce a decision in the operation that makes it.** Schema omission, prompt filtering, facades, wrappers, and listener order are not enforcement when direct or alternate callers can bypass them; test denial through the executor.
- **Publish state only at its commit point.** Emit each notification and update derived state only after the operation succeeds; derive caches, prompts, UI echoes, replay, and query views from one authoritative source.
- **Apply bounds to the complete result.** Enforce byte, token, item, and time limits where the complete emitted or retained value, including wrappers and metadata, is known; test tiny and exact limits, oversized single chunks, and multibyte byte limits.
- **Registry contributions prove disposal** through the HMR-safety test required by [testing policy](../docs/testing.md): dispose the fiber and observe removal.
- **Every package owns `./invariant`.** Register the manifest name; check an event/data relation or give empty installers package-specific `No runtime invariant:` reasons. Generated companions, unexplained empties, and ignored reporters fail [`verify-package-invariants`](../.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md).

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/README.md
README.md: c18a46b7131f7782be68f3c96fa99b89615de471
README.zh.md: 3cd766ed70b7847bef8229a48b65873540365851
README.md: c246534a26cd7297f2ba8885099c8b517a5dc2b0
README.zh.md: 4139874d3ebbf82fad8680a3721a1cf35a553706

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Packages use the `@deepseek-ai/dsh-*` scope. Cordis `Service` subclasses and function plugins contribute through `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Authoring rules: [package](AGENTS.md) and [root](../AGENTS.md#conventions).
npm scope: `@deepseek-ai/dsh-*`; Cordis `Service` subclasses and function plugins contribute through `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Rules: [package](AGENTS.md), [root](../AGENTS.md#conventions).
## Hierarchy
@@ -31,13 +31,14 @@ Groups hold `packages/<group>/<pkg>/`; names stay `@deepseek-ai/dsh-<pkg>`. **Gr
| [`tasks/`](tasks/README.md) | Generic background-task runtime and model-facing `task_*` control tools | Product — stable surface |
| [`workflow/`](workflow/README.md) | Workflow seam, worker-thread engine, and model-facing `workflow`/`ralph` tools | Product — stable surface |
| [`web/`](web/README.md) | Web capability family: seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface |
| [`attachment/`](attachment/README.md) | Durable attachment identity, validation, local content-addressed storage | Product — stable surface |
| [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy | Product — stable surface |
| [`todo/`](todo/README.md) | The model-facing `todo_write` tool | Product — stable surface |
| [`plan/`](plan/README.md) | Plan collaboration state with a direct entry command and reviewed exit | Product — stable surface |
| [`preset/`](preset/README.md) | Per-session agent composition from preset `cordis.yml` files | Product — stable surface |
| [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders + the `tools/execute` deadline enforcer | Product — stable surface |
| [`bundle/`](bundle/README.md) | Installable `dsh --profile` patch layers | Product — stable surface |
| [`self-modification/`](self-modification/README.md) | The agent modifies its own runtime: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) and restricted repository Plugin loading | Product — stable surface |
| [`self-modification/`](self-modification/README.md) | Agent runtime self-modification: live plugin/service inspection, model-written plugin mount/unmount ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)), restricted repository Plugin loading | Product — stable surface |
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
| [`session/`](session/README.md) | Durable session data plane: persistence seam + JSONL/SQLite backends, projection seam, log-backed titles, session reporting | Product — stable surface |
| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface |

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
所有包都使用 `@deepseek-ai/dsh-*` scope。Cordis `Service` 子类和函数插件的贡献通过 `ctx.effect()``ctx.on()``ctx.waterfall()` 注册。编写规则见[](AGENTS.md)与[根规则](../AGENTS.md#conventions)。
npm scope 为 `@deepseek-ai/dsh-*`Cordis `Service` 子类和函数插件通过 `ctx.effect()``ctx.on()``ctx.waterfall()` 注册。规则见[](AGENTS.md)与[根规则](../AGENTS.md#conventions)。
## 层级结构
@@ -31,13 +31,14 @@
| [`tasks/`](tasks/README.md) | 通用后台任务运行时和面向模型的 `task_*` 控制工具 | 产品:稳定接口 |
| [`workflow/`](workflow/README.md) | 工作流 seam、worker 线程引擎和面向模型的 `workflow`/`ralph` 工具 | 产品:稳定接口 |
| [`web/`](web/README.md) | Web 能力系列seam、搜索获取提供方实现和面向模型的 Web 工具 | 产品:稳定接口 |
| [`attachment/`](attachment/README.md) | 持久附件标识、校验、本地内容寻址存储 | 产品:稳定接口 |
| [`spill/`](spill/README.md) | spill 能力系列:存储 seam、本地实现、工具结果 spill 策略 | 产品:稳定接口 |
| [`todo/`](todo/README.md) | 面向模型的 `todo_write` 工具 | 产品:稳定接口 |
| [`plan/`](plan/README.md) | Plan 协作状态,提供直接进入命令与经评审的退出 | 产品:稳定接口 |
| [`preset/`](preset/README.md) | 由 preset `cordis.yml` 按会话组装 agent | 产品:稳定接口 |
| [`guard/`](guard/README.md) | 循环卫生守卫:建议性重复调用提醒 + `tools/execute` 截止时间强制执行器 | 产品:稳定接口 |
| [`bundle/`](bundle/README.md) | 可安装的 `dsh --profile` 补丁层 | 产品:稳定接口 |
| [`self-modification/`](self-modification/README.md) | agent 修改自身运行时:检查实时运行时的插件服务,挂载/卸载模型所写插件([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md),以及受限仓库插件加载 | 产品:稳定接口 |
| [`self-modification/`](self-modification/README.md) | agent 运行时自修改:实时插件服务检查、模型所写插件挂载/卸载[设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)受限仓库插件加载 | 产品:稳定接口 |
| [`hooks/`](hooks/README.md) | 钩子桥接 + 共享 Claude CodeCodex 协议格式库 | 产品:稳定接口 |
| [`session/`](session/README.md) | 持久会话数据平面:持久化 seam + JSONL/SQLite 后端、投影 seam、日志支持的标题、会话上报 | 产品:稳定接口 |
| [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定接口 |

View File

@@ -166,6 +166,17 @@ export function apply(ctx: Context, config: AcpConfig): void {
content: { type: 'text', text: block.text },
},
})
} else if (block.type === 'image') {
notify({
sessionId: record.agent.session.id,
update: {
sessionUpdate: 'agent_message_chunk',
content: {
type: 'text',
text: `[image attachment ${block.attachment.attachmentId}]`,
},
},
})
}
}
}

View File

@@ -41,6 +41,35 @@ describe('ACP prompt lifecycle', () => {
await vi.waitFor(() => { expect(messageText(harness!)).toBe('cut off') })
})
it('renders an assistant image as an explicit attachment placeholder', async () => {
const attachmentId = `sha256:${'a'.repeat(64)}` as never
harness = await makeBridgeHarness({
script: [[
{ type: 'block-start', index: 0, blockType: 'image' },
{
type: 'block-end',
index: 0,
block: {
type: 'image',
attachment: {
attachmentId,
mediaType: 'image/png',
bytes: 1,
width: 1,
height: 1,
},
},
},
{ type: 'finish', reason: { kind: 'stop' } },
]],
})
const sessionId = await newSession(harness)
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'show it' }] })
await vi.waitFor(() => {
expect(messageText(harness!)).toBe(`[image attachment ${String(attachmentId)}]`)
})
})
it('rejects a failed turn and never publishes its partial chunks', async () => {
harness = await makeBridgeHarness({ script: [errorResponse('provider boom')] })
const sessionId = await newSession(harness)

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/attachment/README.md
README.md: 61b4e5c602f475f85bbe859b8483e30b518e06c8
README.zh.md: b4a80917e166a3b0836e6e9c79d536d9dd8e1a41

View File

@@ -0,0 +1,12 @@
# attachment/ - durable attachment capability family
English | [中文](README.zh.md)
The durable binary attachment seam and its local filesystem implementation. Both are product packages.
| Package | Role | ctx key |
|---|---|---|
| `attachment/` | Immutable attachment references, image limits, and storage service | `ctx.attachments` |
| `attachment-local/` | Content-addressed private storage below `DSH_HOME` | (registers on `ctx.attachments`) |
Unsent browser drafts are intentionally outside this capability. Bytes enter durable storage only when a user prompt is submitted or when a provider adapter commits structured model output.

View File

@@ -0,0 +1,12 @@
# attachment/:持久附件能力族
[English](README.md) | 中文
持久二进制附件服务边界及其本地文件系统实现。两者均为产品包package
| 包 | 角色 | ctx 键 |
|---|---|---|
| `attachment/` | 不可变附件引用、图片限制和存储服务 | `ctx.attachments` |
| `attachment-local/` | `DSH_HOME` 下的私有内容寻址存储 | (注册至 `ctx.attachments` |
未发送的浏览器草稿刻意位于这项能力之外。只有用户提交提示词,或提供方适配器提交结构化模型输出时,字节才进入持久存储。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/attachment/attachment-local/README.md
README.md: 80001b29b392fe1c8b663f46d47f1ec0726e6d0f
README.zh.md: c3b95ace06b9f5ada156f20f33a1740a235400aa

View File

@@ -0,0 +1,21 @@
# @deepseek-ai/dsh-attachment-local
English | [中文](README.zh.md)
The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `<DSH_HOME>/attachments/v1/objects/<sha256-prefix>/<sha256>` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root, so a directory another process created but has not yet synced is never mistaken for a safe boundary. Writes then use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. Write admission and reads fully decode the raster before accepting its format and dimensions; reads also re-check the digest and logged metadata. Byte and pixel limits are write-time admission policy, so a later policy reduction does not make already-admitted history unreadable.
`DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path.
## Model Experience
Indirectly, through durable replay of historical user images and structured model image output after restart and fork.
#### KV Cache effect
None beyond the image block owned by the requesting adapter.
## Known Limitations and Deferred Work
- Objects are retained indefinitely; reference-aware garbage collection is deferred.
- The local backend assumes the host and provider adapter share this filesystem service.
- Animated GIF metadata is validated from the logical screen; frame-level decoding policy is provider-owned.

View File

@@ -0,0 +1,21 @@
# @deepseek-ai/dsh-attachment-local
[English](README.md) | 中文
这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `<DSH_HOME>/attachments/v1/objects/<sha256-prefix>/<sha256>`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会通过将每个祖先目录项逐级同步到文件系统根目录,为某个 home 一次性证明其持久性,因此绝不会把另一个进程已经创建但尚未同步的目录误认为安全边界。随后,写入过程使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIXWindows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。写入准入与读取都会完整解码光栅图片,之后才接受其格式和尺寸;读取还会重新校验摘要和已记录的元数据。字节和像素限制属于写入时的准入策略,因此后续收紧限制不会导致已经接纳的历史记录变得不可读。
`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。
## 模型体验
该包通过重启和 fork 后对历史用户图片与结构化模型图片输出的持久回放间接影响模型。
#### KV 缓存影响
除发起请求的适配器所持有的图片块外,不产生其他影响。
## 已知限制与待完成工作
- 对象会无限期保留;基于引用的垃圾回收尚未实现。
- 本地后端假定宿主与提供方适配器共享同一个文件系统服务。
- 动态 GIF 的元数据根据逻辑屏幕进行校验;逐帧解码策略由提供方持有。

View File

@@ -0,0 +1,33 @@
{
"name": "@deepseek-ai/dsh-attachment-local",
"description": "Private content-addressed DSH_HOME attachment storage",
"version": "0.0.1",
"private": true,
"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"
},
"files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.d.ts"],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-attachment": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-paths": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0",
"sharp": "^0.35.3"
},
"devDependencies": {
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,66 @@
/** Raster inspection: full decode at admission, header-only probe on verified reads. */
import sharp, { type Sharp } from 'sharp'
import { AttachmentError } from '@deepseek-ai/dsh-attachment'
import type { ImageMediaType } from '@deepseek-ai/dsh-attachment'
/** Decoded metadata from a supported image. */
export interface DetectedImage {
mediaType: ImageMediaType
width: number
height: number
}
const MEDIA_TYPES: Readonly<Record<string, ImageMediaType>> = {
png: 'image/png',
jpeg: 'image/jpeg',
webp: 'image/webp',
gif: 'image/gif',
}
async function imageMetadata(image: Sharp): Promise<DetectedImage> {
const metadata = await image.metadata()
const mediaType = MEDIA_TYPES[metadata.format as string]
if (mediaType === undefined) {
throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE')
}
return { mediaType, width: metadata.width, height: metadata.height }
}
/**
* Parse a supported raster's header and return its intrinsic metadata without
* decoding pixels. Digest-verified reads use this: admission already proved
* that these exact bytes decode completely, so the read path only re-derives
* the reference fields instead of paying the full-raster decode again.
* @param data - complete encoded image bytes.
* @returns verified format and dimensions.
*/
export async function probeImage(data: Uint8Array): Promise<DetectedImage> {
try {
return await imageMetadata(sharp(data, { failOn: 'error', limitInputPixels: false }))
} catch (error) {
if (error instanceof AttachmentError) throw error
throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE', { cause: error })
}
}
/**
* Fully decode a supported raster and return its intrinsic metadata.
* @param data - complete encoded image bytes.
* @param maxPixels - decoded-pixel admission limit.
* @returns verified format and dimensions.
*/
export async function detectImage(data: Uint8Array, maxPixels?: number): Promise<DetectedImage> {
try {
const image = sharp(data, { failOn: 'error', limitInputPixels: false })
const detected = await imageMetadata(image)
if (maxPixels !== undefined && detected.width * detected.height > maxPixels) {
throw new AttachmentError('Image exceeds the configured decoded-pixel limit.', 'IMAGE_TOO_MANY_PIXELS')
}
await image.raw().toBuffer()
return detected
} catch (error) {
if (error instanceof AttachmentError) throw error
throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE', { cause: error })
}
}

View File

@@ -0,0 +1,76 @@
/** Local durable attachment backend rooted below `DSH_HOME`. @module @deepseek-ai/dsh-attachment-local */
import { join, resolve } from 'node:path'
import { Context } from 'cordis'
import z from 'schemastery'
import { AttachmentStore } from '@deepseek-ai/dsh-attachment'
import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import { readImageFile, saveImageFile, validateImageFile } from './store.ts'
export { detectImage } from './image.ts'
export { readImageFile, saveImageFile, validateImageFile } from './store.ts'
/** Default maximum encoded bytes for one image. */
export const DEFAULT_MAX_IMAGE_BYTES = 5 * 1024 * 1024
/** Default maximum images in one prompt. */
export const DEFAULT_MAX_IMAGES_PER_MESSAGE = 10
/** Default maximum aggregate image bytes in one prompt. */
export const DEFAULT_MAX_MESSAGE_IMAGE_BYTES = 20 * 1024 * 1024
/** Default maximum intrinsic pixels for one image. */
export const DEFAULT_MAX_IMAGE_PIXELS = 40_000_000
/** Local attachment backend configuration. */
export interface Config {
/** Explicit harness home; omitted follows `DSH_HOME`, then `~/.dsh`. */
dshHome?: string
/** Maximum encoded bytes accepted for one image. */
maxImageBytes?: number
/** Maximum image count accepted in one submitted message. */
maxImagesPerMessage?: number
/** Maximum aggregate encoded image bytes accepted in one submitted message. */
maxMessageImageBytes?: number
/** Maximum intrinsic width multiplied by height accepted for one image. */
maxImagePixels?: number
}
/** Persistent content-addressed local attachment store. */
export class LocalAttachmentStore extends AttachmentStore {
static Config: z<Config> = z.object({
dshHome: z.string(),
maxImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_BYTES),
maxImagesPerMessage: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGES_PER_MESSAGE),
maxMessageImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_MESSAGE_IMAGE_BYTES),
maxImagePixels: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_PIXELS),
})
/** Absolute versioned storage root. */
readonly root: string
readonly imageLimits: ImageAttachmentLimits
constructor(ctx: Context, config: Config) {
super(ctx)
this.root = resolve(join(resolveDshHome(config.dshHome), 'attachments', 'v1'))
this.imageLimits = Object.freeze({
maxImageBytes: config.maxImageBytes ?? DEFAULT_MAX_IMAGE_BYTES,
maxImagesPerMessage: config.maxImagesPerMessage ?? DEFAULT_MAX_IMAGES_PER_MESSAGE,
maxMessageImageBytes: config.maxMessageImageBytes ?? DEFAULT_MAX_MESSAGE_IMAGE_BYTES,
maxImagePixels: config.maxImagePixels ?? DEFAULT_MAX_IMAGE_PIXELS,
mediaTypes: Object.freeze(['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const),
})
}
async validateImage(input: SaveImageAttachment): Promise<void> {
await validateImageFile(input, this.imageLimits)
}
async saveImage(input: SaveImageAttachment): Promise<ImageAttachmentRef> {
return saveImageFile(this.root, input, this.imageLimits)
}
async readImage(ref: ImageAttachmentRef): Promise<StoredImageAttachment> {
return readImageFile(this.root, ref)
}
}
export default LocalAttachmentStore

View File

@@ -0,0 +1,20 @@
/** Package-owned invariant companion for `@deepseek-ai/dsh-attachment-local`. @module @deepseek-ai/dsh-attachment-local/invariant */
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-attachment-local'
/** Cordis companion plugin name. */
export const name = 'attachment-local-invariant'
/** Services required before package ownership can be reserved. */
export const inject = ['invariants', 'attachments']
/** No runtime invariant: immutable writes and verified reads are enforced directly at the backend boundary. */
const install: InvariantInstaller = () => {}
/**
* Register the package invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the registration disposer.
*/
export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,221 @@
/** Content-addressed, owner-private local attachment storage. */
import { createHash, randomUUID } from 'node:crypto'
import { constants } from 'node:fs'
import { chmod, link, mkdir, open, readFile, unlink } from 'node:fs/promises'
import { dirname, join, parse, resolve } from 'node:path'
import {
AttachmentError,
AttachmentId,
} from '@deepseek-ai/dsh-attachment'
import type {
ImageAttachmentLimits,
ImageAttachmentRef,
SaveImageAttachment,
StoredImageAttachment,
} from '@deepseek-ai/dsh-attachment'
import { detectImage, probeImage } from './image.ts'
const ID_PATTERN = /^sha256:([a-f0-9]{64})$/
const durableHomes = new Set<string>()
function digest(data: Uint8Array): string {
return createHash('sha256').update(data).digest('hex')
}
function displayName(value: string | undefined): string | undefined {
if (value === undefined) return undefined
// Strip both separator styles by hand: a POSIX host treats `\` as an
// ordinary character, so path.basename would keep a Windows client's full
// local path and leak it into the reference and the session log.
const leaf = value.slice(Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\')) + 1)
const clean = leaf.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 255)
return clean === '' ? undefined : clean
}
function objectPath(root: string, sha256: string): string {
return join(root, 'objects', sha256.slice(0, 2), sha256)
}
function ensureReference(ref: ImageAttachmentRef): string {
const match = ID_PATTERN.exec(String(ref.attachmentId))
if (match?.[1] === undefined) throw new AttachmentError('Attachment reference is invalid.', 'INVALID_ATTACHMENT_REF')
return match[1]
}
async function inspectMetadata(
data: Uint8Array,
declaredMediaType: ImageAttachmentRef['mediaType'],
maxPixels?: number,
): Promise<Omit<ImageAttachmentRef, 'attachmentId' | 'name'>> {
if (data.byteLength === 0) throw new AttachmentError('Image is empty.', 'INVALID_IMAGE')
const detected = await detectImage(data, maxPixels)
if (detected.mediaType !== declaredMediaType) throw new AttachmentError('Declared image type does not match its bytes.', 'IMAGE_TYPE_MISMATCH')
return { ...detected, bytes: data.byteLength }
}
/**
* Run the full admission policy for one image without touching storage.
* @param input - encoded bytes and declared metadata.
* @param limits - resolved storage policy.
* @returns completion after the encoded raster has been fully decoded.
*/
export async function validateImageFile(input: SaveImageAttachment, limits: ImageAttachmentLimits): Promise<void> {
if (input.data.byteLength > limits.maxImageBytes) {
throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE')
}
await inspectMetadata(input.data, input.mediaType, limits.maxImagePixels)
}
/**
* Make a directory's entries durable (fsync on a read-only directory handle).
* A synced file alone does not survive a crash when its directory entry never
* reached storage, so the publication directory is synced before a durable
* reference is reported.
*/
async function syncDirectory(path: string): Promise<void> {
/* v8 ignore next -- Windows cannot open directory handles; NTFS metadata journaling owns entry durability there. */
if (process.platform === 'win32') return
/* v8 ignore start -- Windows cannot exercise directory fsync; POSIX behavior tests enforce this peer. */
const handle = await open(path, constants.O_RDONLY)
try {
await handle.sync()
} finally {
await handle.close()
}
/* v8 ignore stop */
}
/**
* Create one private directory tree and persist every ancestor entry up to a
* caller-vouched durable boundary. The walk deliberately ignores what mkdir
* reports as newly created: a concurrent first save can create a level this
* process then merely observes, so "already existed" is not "already durable"
* — the entry may still be unsynced in the creator, and a crash would drop a
* directory the session checkpoint already references. Re-syncing a durable
* entry is harmless; skipping an unsynced one is not.
* @param path - absolute directory to create.
* @param boundary - absolute ancestor the caller vouches is already durable.
*/
async function ensureDurableDirectory(path: string, boundary: string): Promise<void> {
const target = resolve(path)
const stop = resolve(boundary)
await mkdir(target, { recursive: true, mode: 0o700 })
await chmod(target, 0o700)
let level = target
while (level !== stop) {
const parent = dirname(level)
await syncDirectory(parent)
/* v8 ignore next -- filesystem-root guard: callers pass a boundary that is an ancestor of path, so the walk reaches it first. */
if (parent === level) return
level = parent
}
}
/**
* Establish this process's proof that one DSH_HOME entry and every ancestor
* below the filesystem root are durable. Mere existence is insufficient: a
* concurrent process may have created the directory but not synced its parent.
*/
async function ensureDurableHome(path: string): Promise<string> {
const home = resolve(path)
if (!durableHomes.has(home)) {
await ensureDurableDirectory(home, parse(home).root)
durableHomes.add(home)
}
return home
}
/**
* Save and verify immutable image bytes below a versioned attachment root.
* @param root - absolute `DSH_HOME/attachments/v1` root.
* @param input - encoded bytes and declared metadata.
* @param limits - resolved storage policy.
* @returns durable content-addressed reference.
*/
export async function saveImageFile(root: string, input: SaveImageAttachment, limits: ImageAttachmentLimits): Promise<ImageAttachmentRef> {
if (input.data.byteLength > limits.maxImageBytes) throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE')
const metadata = await inspectMetadata(input.data, input.mediaType, limits.maxImagePixels)
const sha256 = digest(input.data)
const bucket = join(root, 'objects', sha256.slice(0, 2))
const staging = join(root, 'tmp')
// Establish DSH_HOME itself against the filesystem root once per process.
// Every process performs that proof independently, so observing a directory
// another process created can never be mistaken for durable publication.
const boundary = await ensureDurableHome(dirname(dirname(resolve(root))))
await ensureDurableDirectory(bucket, boundary)
await ensureDurableDirectory(staging, boundary)
const temporary = join(staging, randomUUID())
const target = objectPath(root, sha256)
let handle
try {
handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600)
await handle.writeFile(input.data)
await handle.sync()
await handle.close()
handle = undefined
try {
await link(temporary, target)
} catch (error) {
/* v8 ignore next -- Private same-filesystem directories make EEXIST the only recoverable link race. */
if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error
const existing = new Uint8Array(await readFile(target))
if (digest(existing) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT')
}
// Persist the target entry and close a concurrent bucket-creation window
// before the reference can reach a session checkpoint. The dedup path
// repeats both syncs because it may observe another writer's link before
// that writer reaches its own durability boundary.
await syncDirectory(bucket)
await syncDirectory(join(root, 'objects'))
await unlink(temporary)
} catch (error) {
/* v8 ignore next -- A descriptor can remain open only when the underlying write/sync/close operation fails. */
if (handle !== undefined) await handle.close().catch(
/* v8 ignore next -- Close failure is superseded by the storage operation that entered cleanup. */
() => {},
)
await unlink(temporary).catch(
/* v8 ignore next -- The callback requires a second independent staging-unlink failure. */
(cleanupError: unknown) => {
/* v8 ignore next -- Cleanup is best-effort only for a staging file already removed by a failed operation. */
if (!(cleanupError instanceof Error && 'code' in cleanupError && cleanupError.code === 'ENOENT')) throw cleanupError
},
)
if (error instanceof AttachmentError) throw error
throw new AttachmentError('Unable to persist image attachment.', 'ATTACHMENT_WRITE_FAILED', { cause: error })
}
const name = displayName(input.name)
return {
attachmentId: AttachmentId(`sha256:${sha256}`),
...metadata,
...(name !== undefined ? { name } : {}),
}
}
/**
* Read and verify one content-addressed image.
* @param root - absolute `DSH_HOME/attachments/v1` root.
* @param ref - reference recorded in the session log.
* @returns verified bytes and reference.
*/
export async function readImageFile(root: string, ref: ImageAttachmentRef): Promise<StoredImageAttachment> {
const sha256 = ensureReference(ref)
let data: Uint8Array
try {
data = new Uint8Array(await readFile(objectPath(root, sha256)))
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') throw new AttachmentError('Attachment object is missing.', 'ATTACHMENT_NOT_FOUND')
throw new AttachmentError('Unable to read image attachment.', 'ATTACHMENT_READ_FAILED', { cause: error })
}
if (digest(data) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT')
// The digest proves these are the exact bytes admission fully decoded, so
// the read path only re-derives the header fields (no raster decode, no
// per-request pixel amplification on history replay).
const metadata = await probeImage(data)
if (metadata.mediaType !== ref.mediaType || data.byteLength !== ref.bytes
|| metadata.width !== ref.width || metadata.height !== ref.height) {
throw new AttachmentError('Stored attachment metadata does not match its reference.', 'ATTACHMENT_CORRUPT')
}
return { ref, data }
}

View File

@@ -0,0 +1,51 @@
import sharp from 'sharp'
import { describe, expect, it } from 'vitest'
import { detectImage, probeImage } from '../src/image.ts'
async function raster(format: 'png' | 'jpeg' | 'webp' | 'gif'): Promise<Uint8Array> {
const image = sharp({
create: { width: 3, height: 2, channels: 4, background: { r: 1, g: 2, b: 3, alpha: 1 } },
})
return new Uint8Array(await image.toFormat(format).toBuffer())
}
describe('raster decoding', () => {
it('decodes every supported format and its intrinsic dimensions', async () => {
for (const [format, mediaType] of [
['png', 'image/png'],
['jpeg', 'image/jpeg'],
['webp', 'image/webp'],
['gif', 'image/gif'],
] as const) {
await expect(detectImage(await raster(format)))
.resolves.toEqual({ mediaType, width: 3, height: 2 })
}
})
it('rejects excess decoded pixels before decoding', async () => {
await expect(detectImage(await raster('png'), 5))
.rejects.toMatchObject({ code: 'IMAGE_TOO_MANY_PIXELS' })
})
it('rejects malformed bytes and truncated payloads with readable headers', async () => {
await expect(detectImage(Uint8Array.of(1, 2, 3)))
.rejects.toMatchObject({ code: 'INVALID_IMAGE' })
const unsupported = await sharp({
create: { width: 1, height: 1, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 1 } },
}).tiff().toBuffer()
await expect(detectImage(unsupported)).rejects.toMatchObject({ code: 'INVALID_IMAGE' })
const complete = await raster('png')
const truncated = complete.subarray(0, 62)
await expect(sharp(truncated).metadata()).resolves.toMatchObject({ width: 3, height: 2 })
await expect(detectImage(truncated)).rejects.toMatchObject({ code: 'INVALID_IMAGE' })
})
it('probes malformed bytes and unsupported formats into the same stable error', async () => {
await expect(probeImage(Uint8Array.of(1, 2, 3)))
.rejects.toMatchObject({ code: 'INVALID_IMAGE' })
const unsupported = await sharp({
create: { width: 1, height: 1, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 1 } },
}).tiff().toBuffer()
await expect(probeImage(unsupported)).rejects.toMatchObject({ code: 'INVALID_IMAGE' })
})
})

View File

@@ -0,0 +1,60 @@
import { Context } from 'cordis'
import { existsSync } from 'node:fs'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import LocalAttachmentStore, {
DEFAULT_MAX_IMAGE_BYTES,
DEFAULT_MAX_IMAGE_PIXELS,
DEFAULT_MAX_IMAGES_PER_MESSAGE,
DEFAULT_MAX_MESSAGE_IMAGE_BYTES,
} from '../src/index.ts'
describe('local attachment service', () => {
it('resolves every omitted admission limit explicitly', () => {
const service = new LocalAttachmentStore(new Context(), {})
expect(service.imageLimits).toEqual({
maxImageBytes: DEFAULT_MAX_IMAGE_BYTES,
maxImagesPerMessage: DEFAULT_MAX_IMAGES_PER_MESSAGE,
maxMessageImageBytes: DEFAULT_MAX_MESSAGE_IMAGE_BYTES,
maxImagePixels: DEFAULT_MAX_IMAGE_PIXELS,
mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
})
})
it('saves and reads through the service boundary', async () => {
const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-service-'))
try {
const service = new LocalAttachmentStore(new Context(), { dshHome })
const data = Uint8Array.from(Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64',
))
const ref = await service.saveImage({ data, mediaType: 'image/png' })
await expect(service.readImage(ref)).resolves.toEqual({ ref, data })
} finally {
await rm(dshHome, { recursive: true, force: true })
}
})
it('validates without persisting: a rejected image leaves no storage root behind', async () => {
const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-validate-'))
try {
const service = new LocalAttachmentStore(new Context(), { dshHome })
await expect(service.validateImage({ data: Uint8Array.of(1, 2, 3), mediaType: 'image/png' }))
.rejects.toThrow(/Unsupported or malformed image data/)
const valid = Uint8Array.from(Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64',
))
const limited = new LocalAttachmentStore(new Context(), { dshHome, maxImageBytes: 1 })
await expect(limited.validateImage({ data: valid, mediaType: 'image/png' }))
.rejects.toMatchObject({ code: 'IMAGE_TOO_LARGE' })
await expect(service.validateImage({ data: valid, mediaType: 'image/png' })).resolves.toBeUndefined()
expect(existsSync(service.root)).toBe(false)
} finally {
await rm(dshHome, { recursive: true, force: true })
}
})
})

View File

@@ -0,0 +1,208 @@
import { createHash } from 'node:crypto'
import { constants } from 'node:fs'
import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, parse, resolve } from 'node:path'
import { mkdtemp, rm } from 'node:fs/promises'
import { afterEach, describe, expect, it, vi } from 'vitest'
import sharp from 'sharp'
import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment'
import { readImageFile, saveImageFile } from '../src/store.ts'
const fsControl = vi.hoisted(() => ({ syncedDirectories: [] as string[] }))
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>()
return {
...actual,
async open(...args: Parameters<typeof actual.open>): ReturnType<typeof actual.open> {
if (args[1] === constants.O_RDONLY) fsControl.syncedDirectories.push(String(args[0]))
return actual.open(...args)
},
}
})
const PNG = Uint8Array.from(Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64',
))
const LIMITS: ImageAttachmentLimits = {
maxImageBytes: 1024,
maxImagesPerMessage: 2,
maxMessageImageBytes: 2048,
maxImagePixels: 16,
mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
}
const roots: string[] = []
async function root(): Promise<string> {
const value = await mkdtemp(join(tmpdir(), 'dsh-attachment-'))
roots.push(value)
return join(value, 'attachments', 'v1')
}
function parentChainToRoot(path: string): string[] {
const parents: string[] = []
let level = resolve(path)
const root = parse(level).root
while (level !== root) {
level = dirname(level)
parents.push(level)
}
return parents
}
afterEach(async () => {
await Promise.all(roots.splice(0).map(path => rm(path, { recursive: true, force: true })))
})
describe('local attachment store', () => {
it.skipIf(process.platform === 'win32')('syncs every object ancestor up to the durable boundary before returning', async () => {
const storageRoot = await root()
const base = join(storageRoot, '..', '..')
const sha256 = createHash('sha256').update(PNG).digest('hex')
const objects = join(storageRoot, 'objects')
const bucket = join(objects, sha256.slice(0, 2))
fsControl.syncedDirectories.length = 0
await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)
// Each process first proves DSH_HOME durable all the way to the filesystem
// root; existence alone cannot vouch for a concurrent creator's fsync.
// Later directory creation can then stop at that process-proven boundary.
expect(fsControl.syncedDirectories).toEqual([
...parentChainToRoot(base),
// bucket chain: every parent entry between the bucket and the boundary.
objects,
storageRoot,
join(storageRoot, '..'),
base,
// staging chain re-walks the shared ancestors after creating tmp.
storageRoot,
join(storageRoot, '..'),
base,
// publication: the settled object's bucket and its parent for the rename.
bucket,
objects,
])
})
it('creates and persists a missing nested home directory against the filesystem root', async () => {
const storageRoot = join(await root(), 'home', 'attachments', 'v1')
const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)
await expect(readImageFile(storageRoot, ref)).resolves.toEqual({ ref, data: PNG })
})
it('publishes one private content-addressed object and deduplicates equal bytes', async () => {
const storageRoot = await root()
const first = await saveImageFile(storageRoot, {
data: PNG, mediaType: 'image/png', name: '/private/tmp/pixel.png',
}, LIMITS)
const second = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)
const sha256 = createHash('sha256').update(PNG).digest('hex')
const object = join(storageRoot, 'objects', sha256.slice(0, 2), sha256)
expect(first).toEqual({
attachmentId: `sha256:${sha256}`,
mediaType: 'image/png',
bytes: PNG.byteLength,
width: 1,
height: 1,
name: 'pixel.png',
})
expect(second.attachmentId).toBe(first.attachmentId)
expect(new Uint8Array(await readFile(object))).toEqual(PNG)
if (process.platform !== 'win32') {
expect((await stat(object)).mode & 0o777).toBe(0o600)
expect((await stat(join(storageRoot, 'objects', sha256.slice(0, 2)))).mode & 0o777).toBe(0o700)
}
await expect(readImageFile(storageRoot, first)).resolves.toEqual({ ref: first, data: PNG })
})
it('keeps admitted history readable after deployment limits become stricter', async () => {
const storageRoot = await root()
const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)
await expect(readImageFile(storageRoot, ref)).resolves.toEqual({ ref, data: PNG })
})
it('rejects malformed bytes, mismatched declarations, byte limits, and decoded-pixel limits', async () => {
const storageRoot = await root()
await expect(saveImageFile(storageRoot, {
data: new Uint8Array(0), mediaType: 'image/png',
}, LIMITS)).rejects.toMatchObject({ code: 'INVALID_IMAGE' })
await expect(saveImageFile(storageRoot, {
data: Uint8Array.of(1, 2, 3), mediaType: 'image/png',
}, LIMITS)).rejects.toMatchObject({ code: 'INVALID_IMAGE' })
await expect(saveImageFile(storageRoot, {
data: PNG, mediaType: 'image/jpeg',
}, LIMITS)).rejects.toMatchObject({ code: 'IMAGE_TYPE_MISMATCH' })
await expect(saveImageFile(storageRoot, {
data: PNG, mediaType: 'image/png',
}, { ...LIMITS, maxImageBytes: 1 })).rejects.toMatchObject({ code: 'IMAGE_TOO_LARGE' })
const wide = new Uint8Array(await sharp({
create: { width: 5, height: 5, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 1 } },
}).png().toBuffer())
await expect(saveImageFile(storageRoot, {
data: wide, mediaType: 'image/png',
}, LIMITS)).rejects.toMatchObject({ code: 'IMAGE_TOO_MANY_PIXELS' })
const unnamed = await saveImageFile(storageRoot, {
data: PNG, mediaType: 'image/png', name: '\u0000',
}, LIMITS)
expect(unnamed).not.toHaveProperty('name')
})
it('fails closed when an object is missing, corrupted, or addressed by an invalid reference', async () => {
const storageRoot = await root()
const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)
const sha256 = String(ref.attachmentId).slice('sha256:'.length)
const object = join(storageRoot, 'objects', sha256.slice(0, 2), sha256)
await chmod(object, 0o600)
await writeFile(object, Uint8Array.of(1, 2, 3))
await expect(readImageFile(storageRoot, ref))
.rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' })
await expect(readImageFile(storageRoot, { ...ref, attachmentId: 'bad' as never }))
.rejects.toMatchObject({ code: 'INVALID_ATTACHMENT_REF' })
const missingRoot = await root()
await mkdir(missingRoot, { recursive: true })
await expect(readImageFile(missingRoot, ref))
.rejects.toMatchObject({ code: 'ATTACHMENT_NOT_FOUND' })
const unreadableRoot = await root()
const target = join(unreadableRoot, 'objects', sha256.slice(0, 2), sha256)
await mkdir(target, { recursive: true })
await expect(readImageFile(unreadableRoot, ref))
.rejects.toMatchObject({ code: 'ATTACHMENT_READ_FAILED' })
})
it('rejects conflicting existing objects and reference metadata mismatches', async () => {
const storageRoot = await root()
const sha256 = createHash('sha256').update(PNG).digest('hex')
const target = join(storageRoot, 'objects', sha256.slice(0, 2), sha256)
await mkdir(join(storageRoot, 'objects', sha256.slice(0, 2)), { recursive: true })
await writeFile(target, Uint8Array.of(1, 2, 3))
await expect(saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS))
.rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' })
await writeFile(target, PNG)
const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)
await expect(readImageFile(storageRoot, { ...ref, width: ref.width + 1 }))
.rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' })
})
it('maps unexpected publication failures to a stable storage error', async () => {
const storageRoot = await root()
const sha256 = createHash('sha256').update(PNG).digest('hex')
const target = join(storageRoot, 'objects', sha256.slice(0, 2), sha256)
await mkdir(target, { recursive: true })
await expect(saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS))
.rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED' })
})
})

View File

@@ -0,0 +1,12 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": { "rootDir": "src", "outDir": "lib/types" },
"include": ["src"],
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../attachment" },
{ "path": "../../util/paths" },
{ "path": "../../support/invariants" }
]
}

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/attachment/attachment/README.md
README.md: 4f450316294e554396adb9a8454051a08d9befd3
README.zh.md: fe51b0003cdf1659c7c56106b97c6f3139ebe890

View File

@@ -0,0 +1,21 @@
# @deepseek-ai/dsh-attachment
English | [中文](README.zh.md)
The durable attachment seam. `ctx.attachments` validates and atomically commits immutable image bytes, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events.
Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting; batch writers validate every member first so a malformed member cannot strand earlier members as unreferenced objects. `saveImage` commits each accepted image before any model-visible session event is published, and `readImage` verifies the content-addressed object against its logged metadata.
## Model Experience
Indirectly, through the role-neutral core `ImageBlock` and provider adapters that resolve its durable reference.
#### KV Cache effect
Adding an image changes the provider request and therefore invalidates the affected request suffix.
## Known Limitations and Deferred Work
- Version one accepts PNG, JPEG, WebP, and GIF only.
- Retention and garbage collection are deferred because resumed and forked sessions may share immutable objects.
- Generic files, audio, video, and persistent unsent drafts require separate lifecycle and provider contracts.

View File

@@ -0,0 +1,21 @@
# @deepseek-ai/dsh-attachment
[English](README.md) | 中文
持久附件服务边界。`ctx.attachments` 校验并以原子方式提交不可变图片字节,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。
未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化;批量写入方会先校验每个成员,避免某个格式错误的成员使较早的成员成为无引用对象。`saveImage` 会在发布任何模型可见的会话事件前提交每张已接受的图片,`readImage` 则根据已记录的元数据校验内容寻址对象。
## 模型体验
该包通过角色无关的核心 `ImageBlock`,以及解析其持久引用的提供方适配器,间接影响模型。
#### KV 缓存影响
添加图片会改变提供方请求,因此会使受影响的请求后缀失效。
## 已知限制与待完成工作
- 第一版仅接受 PNG、JPEG、WebP 和 GIF。
- 保留策略与垃圾回收尚未实现,因为恢复和 fork 后的会话可能共享不可变对象。
- 通用文件、音频、视频和持久的未发送草稿需要单独的生命周期与提供方契约。

View File

@@ -0,0 +1,27 @@
{
"name": "@deepseek-ai/dsh-attachment",
"description": "Durable immutable attachment storage seam for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"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"
},
"files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.d.ts"],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,15 @@
/** Attachment identifier brand. @module @deepseek-ai/dsh-attachment/brand */
import type { Branded } from '@deepseek-ai/dsh-brand'
/** Opaque content-addressed identifier for one immutable attachment object. */
export type AttachmentId = Branded<'AttachmentId'>
/**
* Brand a validated storage identifier.
* @param value - backend-produced opaque identifier.
* @returns the branded identifier.
*/
export function AttachmentId(value: string): AttachmentId {
return value as AttachmentId
}

View File

@@ -0,0 +1,26 @@
/** Attachment failure class. @module @deepseek-ai/dsh-attachment/error */
/**
* Stable failures suitable for host RPC error mapping.
*
* Deliberately re-implements the `HarnessError` shape instead of extending it:
* the base lives in `@deepseek-ai/dsh-llm`, which itself depends on this
* package (`ImageBlock` references `ImageAttachmentRef`), so sharing the base
* would create a dependency cycle. Consumers route on `code`, never on the
* prototype chain, so the shapes stay interchangeable at the wire boundary.
*/
export class AttachmentError extends Error {
/** Stable machine-routing failure code. */
readonly code: string
/**
* @param message - human-readable failure description without raw bytes or host paths.
* @param code - stable machine-routing code.
* @param options - optional chained cause.
*/
constructor(message: string, code: string, options?: ErrorOptions) {
super(message, options)
this.name = 'AttachmentError'
this.code = code
}
}

View File

@@ -0,0 +1,60 @@
/** Durable attachment storage seam (`ctx.attachments`). @module @deepseek-ai/dsh-attachment */
import { Context, Service } from 'cordis'
import type {
ImageAttachmentLimits,
ImageAttachmentRef,
SaveImageAttachment,
StoredImageAttachment,
} from './types.ts'
export { AttachmentId } from './brand.ts'
export { AttachmentError } from './error.ts'
export type {
AttachmentId as AttachmentIdType,
ImageAttachmentLimits,
ImageAttachmentRef,
ImageMediaType,
SaveImageAttachment,
StoredImageAttachment,
} from './types.ts'
declare module 'cordis' {
interface Context {
attachments: AttachmentStore
}
}
/** Immutable binary attachment service. Implementations validate bytes before publishing a reference. */
export abstract class AttachmentStore extends Service {
constructor(ctx: Context) {
super(ctx, 'attachments')
}
/** Deployment-resolved image policy used by authoritative and fast-path validation. */
abstract readonly imageLimits: ImageAttachmentLimits
/**
* Validate one image without persisting it.
* Batch callers validate every member before saving any member.
* @param input - encoded bytes, declared media type, and optional display name.
* @returns completion after the encoded raster has been fully decoded.
*/
abstract validateImage(input: SaveImageAttachment): Promise<void>
/**
* Validate and durably commit one image before its owning session event is appended.
* @param input - encoded bytes, declared media type, and optional display name.
* @returns a durable content-addressed reference.
*/
abstract saveImage(input: SaveImageAttachment): Promise<ImageAttachmentRef>
/**
* Read one image and verify that bytes still match the recorded reference.
* @param ref - durable reference from the session log.
* @returns the verified bytes and canonical reference.
*/
abstract readImage(ref: ImageAttachmentRef): Promise<StoredImageAttachment>
}
export default AttachmentStore

View File

@@ -0,0 +1,20 @@
/** Package-owned invariant companion for `@deepseek-ai/dsh-attachment`. @module @deepseek-ai/dsh-attachment/invariant */
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-attachment'
/** Cordis companion plugin name. */
export const name = 'attachment-invariant'
/** Service required before package ownership can be reserved. */
export const inject = ['invariants']
/** No runtime invariant: this stateless seam owns types while implementations enforce immutable-store checks. */
const install: InvariantInstaller = () => {}
/**
* Register the package invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the registration disposer.
*/
export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,48 @@
/** Durable attachment vocabulary. @module @deepseek-ai/dsh-attachment/types */
import type { AttachmentId } from './brand.ts'
export type { AttachmentId } from './brand.ts'
/** Raster image formats accepted by the version-one attachment path. */
export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif'
/** Durable, serializable metadata for one immutable image object. */
export interface ImageAttachmentRef {
/** Opaque storage identifier; never a filesystem path or bearer URL. */
attachmentId: AttachmentId
/** Media type verified from the stored bytes. */
mediaType: ImageMediaType
/** Exact encoded byte length. */
bytes: number
/** Intrinsic encoded width in pixels. */
width: number
/** Intrinsic encoded height in pixels. */
height: number
/** Optional display name stripped of local path information. */
name?: string
}
/** Deployment-resolved limits used by upload admission and request buffering. */
export interface ImageAttachmentLimits {
maxImageBytes: number
maxImagesPerMessage: number
maxMessageImageBytes: number
maxImagePixels: number
mediaTypes: readonly ImageMediaType[]
}
/** Request to validate and durably commit one image. */
export interface SaveImageAttachment {
data: Uint8Array
/** Caller-declared media type, checked against fully decoded bytes. */
mediaType: ImageMediaType
/** Optional browser/provider display name; it is never interpreted as a path. */
name?: string
}
/** Stored image bytes returned after reference and digest verification. */
export interface StoredImageAttachment {
ref: ImageAttachmentRef
data: Uint8Array
}

View File

@@ -0,0 +1,11 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": { "rootDir": "src", "outDir": "lib/types" },
"include": ["src"],
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../util/brand" },
{ "path": "../../support/invariants" }
]
}

View File

@@ -125,7 +125,7 @@ export class SandboxBashExecutor extends LocalBashExecutor {
proc = this.startArgv(spec, confined.argv)
} catch (error) {
// LocalSubprocessService reports ENOENT/EACCES with the failed executable path through async
// `done` rejection; this covers alternatives that throw that shape synchronously.
// `done` rejection; this covers alternatives that throw the same error synchronously.
if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) {
throw new SandboxUnavailableError(mode, String(error))
}

View File

@@ -536,7 +536,7 @@ describe('background execution through the task runtime', () => {
const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
expect(result.isError).toBe(true)
expect(text(result)).toContain('no control surface is attached')
expect(text(result)).toContain('no control surface serves this agent')
// Declare-then-execute: the failed preflight means no process ever ran.
expect((ctx.bash as CountingStartExecutor).starts).toBe(0)
})

View File

@@ -777,7 +777,7 @@ describe('background execution through the task runtime', () => {
const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true })
expect(result.isError).toBe(true)
expect(text(result)).toContain('no control surface is attached')
expect(text(result)).toContain('no control surface serves this agent')
// Declare-then-execute: the failed preflight means no process ever ran.
expect(bash.startCalls).toBe(0)
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md
README.md: 49c75bac1b6335459cedeb6c2c6c3435d444dbb0
README.zh.md: 93adc52c11c375849cdcbf3ad7e199ef89fc384c
README.md: be03bceb39935fafb7acc7d3a99c1fe3af686f94
README.zh.md: 10165486712fc078cdf1f4147522397a15c88955

View File

@@ -14,12 +14,12 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md) and [`ds
| `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure |
| `assertEntriesActivated(ctx, binName)` | Include the `assertEntriesLoaded` check, then await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services |
| `loadOptionalPatches(binName, file)` | Parse an optional patch-list file (a profile's `cordis.patch.yml`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws |
| `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape; a missing file also throws, because the caller named it |
| `loadOverlayPatches(binName, file)` | Parse a required top-level YAML array containing the same include `PatchOptions` entries described above; a missing file also throws because the caller named it |
| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | Register the statically imported `cordis:include` and `cordis:group` builtins, mount the include, and retain the exact root entry used by user patch-layer HMR |
| `watchUserPatches(ctx, options)` | Register the named patch file with the existing Cordis HMR service; each add/change/removal transactionally recomposes the full patch list through the caller's `compose` closure (app-owned layers around the current user layer) and returns an async disposer |
| `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile machinery (see [Profiles](#profiles)) |
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots), then mount and await the include tree, assert entries loaded and activated, and return the root context — or dispose the partial context and reject a labelled error |
| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts and render YAML with `!!js` expressions verbatim; each run of rows from the same file and patch layers is preceded by a `# ==` comment naming them, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), read/parse/shape failures throw |
| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline with the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts, and render YAML with `!!js` expressions verbatim; each run of rows that shares one source file and the same patch layers is preceded by a `# ==` comment naming that file and those layers, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), and read, parse, or field validation failures throw |
| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to the DSH implementation checkout while warning it not to infer the current working directory from that path and to use `pwd` instead; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
| `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under |
@@ -57,4 +57,4 @@ No direct invalidation from `boot()`; a consumer that calls `addHarnessSourceSec
- **Bare package specifiers depend on Loader internals** — production bins need Loader's optional native helper; an in-process caller without it must use resolvable relative/file specifiers or provide its own module-resolution hook.
- **Snapshot replay swapping is basename-specific** — only a config ending in `cordis.yml` or `cordis.yaml` maps to the sibling `cordis.snapshot.yml`; custom config names require caller-managed selection.
- **Environment discovery is launch-scoped** — `loadLayeredEnv` reads only the invocation directory and Harness home once; it does not search parents or follow a workspace selected later. `loadEnv` remains the one-directory helper for non-product bins.
- **User patch layers are patch-shaped** — an id-targeted patch replaces the entry's whole `config` rather than deep-merging, so a profile override restates the bundle fields it keeps.
- **A user patch replaces the whole matched config** — an id-targeted patch does not deep-merge, so a profile override restates the bundle fields it keeps.

View File

@@ -14,12 +14,12 @@
| `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 |
| `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 |
| `loadOptionalPatches(binName, file)` | 解析一份可选的 patch 列表文件(即 profile 的 `cordis.patch.yml`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 |
| `loadOverlayPatches(binName, file)` | 解析一份形状相同的必需 patch 列表文件;文件缺失同样抛出异常,因为该文件是调用方指名的 |
| `loadOverlayPatches(binName, file)` | 解析必需的顶层 YAML 数组,其中包含与上文相同的 include `PatchOptions` 条目;文件缺失也会抛出异常,因为该文件是调用方指名的 |
| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | 注册静态导入的 `cordis:include``cordis:group` builtin挂载 include并保留用户 patch 层 HMR热模块替换使用的确切根配置项 |
| `watchUserPatches(ctx, options)` | 向现有 Cordis HMR 服务注册指名的 patch 文件;每次新增、变更或移除都会通过调用方的 `compose` 闭包(应用自有层围绕当前用户层)以事务方式重新组合完整 patch 列表,并返回异步清理函数 |
| `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile 机制(见 [Profile](#profiles) |
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader在配置树条目挂载前执行可选的宿主准备操作`prepare` 可以使用 Loader也可以提供由启动器拥有的上下文插槽再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文——失败时 dispose资源释放部分构造的上下文并以带标签的错误 reject |
| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 离线合成基础配置与带标签的覆盖层——使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`,因此结果与 `boot()` 挂载的内容一致——并渲染为 YAML`!!js` 表达式原样保留;每段来同一文件且相同补丁层修改的连续行之前都有一条 `# ==` 注释,标明该文件和这些补丁层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr读取解析/形状失败则抛出 |
| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`离线合成基础配置与带标签的覆盖层,使结果与 `boot()` 挂载的内容一致,再渲染为 YAML并原样保留 `!!js` 表达式;每段来源于同一文件且相同补丁层修改的连续行之前都有一条 `# ==` 注释,标明该文件和这些补丁层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr读取解析或字段验证失败则抛出 |
| `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent智能体DSH 实现代码 checkout 的磁盘路径,同时提醒它不得据此推断当前工作目录,而应使用 `pwd`;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber因此开发环境 HMR热模块替换重新加载系统提示词后它会消失直至下次启动 |
| `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 |
@@ -57,4 +57,4 @@ profile 是位于 `$DSH_HOME/profiles/<name>` 下的目录Harness home 由 [`
- **裸包 specifier 依赖 Loader 内部机制**:生产 bin 需要 Loader 的可选原生辅助组件没有该辅助组件的进程内调用方必须使用可解析的相对file specifier或提供自己的模块解析钩子。
- **快照回放替换仅识别特定 basename**:只有以 `cordis.yml``cordis.yaml` 结尾的配置会映射到同级 `cordis.snapshot.yml`;自定义配置名称需要调用方自行选择。
- **环境发现以启动为界**`loadLayeredEnv` 只读取一次调用目录与 Harness home 中的 `.env`;它不搜索父目录,也不跟随之后选择的 workspace。`loadEnv` 仍是非产品 bin 使用的单目录 helper。
- **用户 patch 层采用 patch 形式**:按 id 定位的 patch 会替换条目的整个 `config`,而不是深度合并,因此 profile 覆盖必须重述需要保留的组合包字段。
- **用户 patch 会替换匹配到的整个配置**:按 id 定位的 patch 不做深度合并,因此 profile 覆盖必须重述需要保留的组合包字段。

View File

@@ -306,7 +306,7 @@ export function loadOverlayPatches(binName: string, file: string): PatchOptions[
/**
* Parse one loader patch list: a top-level YAML array of
* `@cordisjs/plugin-include` `PatchOptions` (id-targeted config overrides and
* `insert` lists, `!!js` expressions allowed). Every shape failure throws,
* `insert` lists, `!!js` expressions allowed). Every invalid field or value throws,
* because a patch file that cannot be applied at all is a misconfiguration; a
* single patch whose target row is absent stays a per-entry Loader warning, so
* one overlay shared across surfaces does not have to match every tree.
@@ -397,12 +397,12 @@ export function renderConfigDump(
throw new Error(`${binName}: config ${absoluteConfigPath} must be a top-level YAML array of entries`)
}
const baseLabel = basename(absoluteConfigPath)
// The YAML boundary yields untyped rows; the include validates entry shape
// YAML parsing yields untyped rows; the include validates each entry
// at mount, and the dump prints whatever the file holds, so `EntryOptions`
// here is structural trust in the same file `boot()` would include.
const base = parsed as Parameters<typeof applyEntryPatches>[0]
// snapshot_k = ONE application of layers 1..k flattened — boot's exact call
// shape for that prefix. snapshot_N is therefore the mounted composition.
// snapshot_k = ONE application of layers 1..k flattened, using the exact
// arguments boot passes for that prefix. snapshot_N is the mounted composition.
// The patches are cloned per call: applyEntryPatches detaches the entry
// list but pushes `insert` rows by reference from the patch list, so
// sharing patch objects across snapshot calls would leak a later

View File

@@ -267,7 +267,7 @@ export function readProfileManifest(binName: string, dir: string): ProfileManife
} catch (error) {
throw new Error(`${binName}: failed to read profile manifest ${path}: ${String(error)}`)
}
// File boundary: the shape check below validates what the parse type asserts.
// The field checks below validate the file data before trusting the parse type.
const parsed = JSON.parse(raw) as ProfileManifest | null
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(`${binName}: profile manifest ${path} must hold a JSON object`)

View File

@@ -107,6 +107,12 @@
config:
root: !!js dshHomePath('sessions')
# Durable image bytes live outside the append-only session log. Messages
# keep content-addressed references that this shared backend resolves for
# provider requests and authorized history reads.
- id: attachment-local
name: '@deepseek-ai/dsh-attachment-local'
# Raw configs can supply a process-local path or disable this shared session
# capability. The neutral default is process-local and opens only when used.
- id: session-query-sqlite

View File

@@ -39,6 +39,7 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-default-model": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-attachment-local": "workspace:^",
"@deepseek-ai/dsh-bash-env": "workspace:^",
"@deepseek-ai/dsh-bash-sandbox": "workspace:^",
"@deepseek-ai/dsh-command-compact": "workspace:^",

View File

@@ -4,7 +4,7 @@
#
# A patch replaces the targeted row's whole `config`, so each row below
# restates every key it owns. The `dsh web` launcher alias turns --host/--port/
# --dev/--workspace-root/--trusted-host into further patches over these rows
# --dev/--trusted-host into further patches over these rows
# (`--dev` inserts the dsh-client-hmr row).
# ── surface-specific values the base deliberately omits ─────────────────────
@@ -218,10 +218,17 @@
- id: tool-bash
disabled: true
- id: tool-tasks
disabled: true
# The background-task REGISTRY stays on the host plane; only the model-facing
# `task_*` controls move. Its producers — `tool-bash` here, `tool-pty` and a
# non-continuable `tool-subagent` elsewhere — are preset rows that resolve it
# with `ctx.get`, and an entry-local realm around the registry is invisible to
# every sibling row outside that realm, so `run_in_background` answered
# "background tasks unavailable" while the controls sat in the catalog. That is
# the `goals` criterion read from inside the preset: a Service a row outside its
# realm READS belongs to the plane both can see. The registry is keyed by owning
# agent, so one host instance serves every session exactly as before presets.
- id: tasks
- id: tool-tasks
disabled: true
- id: tool-fs

View File

@@ -14,24 +14,24 @@ The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-
4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five standing seats, plus the `use<Name>` hooks the renderer binds from provide contributions and inject `hooks` compartments. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.)
5. **Live data has exactly three channels**: parent knows it → owner props at the renderSlot site; only the component knows it → local state; shared across entries or survives remounts → a store declared at register. Derived data is a pure function over framework-hook data (`useMemo`), never its own subscription.
6. **Stores: read `props.useStore`, write `props.actions.*`** — the declared actions are the complete mutation surface. Write the store as an exported `createXXXStore()` factory (module-level handles are forbidden — de-facto singletons); share by passing one handle to several registers inside `apply`. Production code never calls the factory or `.create()` outside `apply`; tests do (that is the sanctioned zero-machinery path).
7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hand-made hooks, no ReactNode producers, no whole-service objects. A registrant-private reactive fact rides the reserved `hooks` compartment (bare observables the renderer binds to `use<Name>`; components never see the sources). Its capability boundary is the plugin's declared `inject` topology; there is no wider ctx to reach for.
7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hand-made hooks, no ReactNode producers, no whole-service objects. A registrant-private reactive fact uses the reserved `hooks` compartment (bare observables the renderer binds to `use<Name>`; components never see the sources). The plugin may use only the dependencies named by its `inject` declaration; there is no wider ctx to reach for.
## Reactive read and contract-currency discipline
How live data reaches render code, and what may cross a business boundary:
How live data reaches render code, and what UI domains may share:
1. **Everything a render reads that can change outside React arrives through a framework hook** (rule 4 above). Event-handler code may read live snapshots (e.g. `keyboard.snapshot`); render code subscribes.
2. **Business components contain no subscription machinery** — no `useSyncExternalStore`, no manual subscribe wiring, no mirroring an external snapshot into local state or a second store. Give each reactive fact its owning channel instead: registrant-private → the inject `hooks` compartment; cross-entry or remount-surviving → a declared store; per-session standard → `sessions.provide`.
3. **Data-access ladder** — resolve needs in this order: framework hooks (standing seats + provide/inject-bound `use<Name>`) → a declared store (`useStore`/`actions`) → inject callbacks → anything else is a new framework extension point and needs main-thread arbitration.
4. **Contract currency is JSON-able data and callbacks.** Everything crossing a business boundary (owner props, inject faces, store state, provide contributions) is plain serializable data or a callback over such data; the inject `hooks` compartment is the one sanctioned carrier of bare observables, and components never see those either. ReactNode is not a currency: route render content through a slot; no new ReactNode-valued owner props or inject members (the composer's existing `accessory`/`overlay`/`leftItems`/`rightItems` seats are exceptions pending migration to slots).
4. **UI domains share only JSON-compatible data and callbacks.** Owner props, injected values, store state, and provide contributions are plain serializable data or callbacks over such data. The injected `hooks` compartment is the only place for bare observables, and components never receive those sources directly. Route ReactNode content through a slot; do not add ReactNode-valued owner props or injected members (the composer's existing `accessory`/`overlay`/`leftItems`/`rightItems` fields remain until they move to slots).
5. **An observable source keeps two identities stable**: the source object itself (hook binding is cached per source), and its snapshot between changes (`getSnapshot` returns the same reference until the fact moves).
6. **Whoever rebuilds a published value republishes it through the same source in the same step**, and a registration path that can run after consumers exist notifies the live consumers as part of registering.
## Export discipline (client plugin packages)
The `/client` surface of a UI plugin package is a contract face, not a convenience barrel. Three rules, enforced package-wide (do not restate them as per-file comments):
The `/client` entrypoint of a UI plugin package is its public browser API, not a convenience barrel. Three rules apply package-wide (do not restate them as per-file comments):
1. **A UI plugin exports no values beyond what cordis loading needs**`apply` / `inject` (and `Config` where present), plus store factories consumed type-only by components (`ReturnType<typeof createXXXStore>`). Types are the extra allowance: contract types (owner shares, injected shapes, composed props aliases) export freely. Implementation components, pure helpers, constants, and store handles stay internal. Adding any new value export requires user sign-off, not a matching consumer.
1. **A UI plugin exports no values beyond what cordis loading needs**`apply` / `inject` (and `Config` where present), plus store factories consumed type-only by components (`ReturnType<typeof createXXXStore>`). Shared types (owner data, injected values, composed prop aliases) may also be exported. Implementation components, pure helpers, constants, and store handles stay internal. Adding any new value export requires user sign-off, not a matching consumer.
2. **Same-package tests import internals directly** — relative `../src/client/xxx.ts` from package tests, or the `./src/*` subpath where a spec lives outside the package. Never widen the public surface to make a test compile.
3. **Cross-package imports of another plugin's symbols are in principle forbidden.** The sanctioned routes are the slot system (register/renderSlot) and ctx services. If neither fits, stop and escalate — do not add an export to unblock yourself.
@@ -44,7 +44,7 @@ The `/client` surface of a UI plugin package is a contract face, not a convenien
The stack has one-way knowledge, settled in the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md):
1. **Data object layer** (`runtime`, React-free): `ConnectionController``SessionManager``Session` own all business state (event windows, streaming accumulation, reconnect machine), and the snapshot-store engine (zustand/immer, `defineStore`, `shallowEqual`) lives here too — store products are bare observable sources with no hook members. Zero React imports — grep-assertable.
2. **Render machinery** (`web-react`, shell-only glue): the whole ctx↔React boundary — slot renderer/outlets, `SessionProvider`, the uSES bridge. Every hook is composed here at the binding site from bare sources; business plugin packages carry no web-react dependency at all.
2. **Render machinery** (`web-react`, shell-only glue): all ctx-to-React integration — slot renderer/outlets, `SessionProvider`, and the uSES adapter. Every hook is composed here at the binding site from bare sources; business plugin packages carry no web-react dependency at all.
3. **Presentation components** (plugin packages' `src/client/`, pure props): consumables, expected to be rewritten wholesale. Business logic must not leak into them; everything arrives through the four props shares.
Non-negotiables across the layers:
@@ -62,7 +62,7 @@ Non-negotiables across the layers:
## Directory regime (plugin packages)
One UI feature = one plugin package (`src/client/` browser half). A multi-domain package splits by future package boundaries — ui-conversation is the exemplar: `contract/` (the only shared face), domain directories that never import a sibling domain, and `apply.ts` as the single cross-domain assembly point; `scripts/verify-client-domain-graph.ts` enforces the levels. Registration goes through `slots.register` in `apply` — never module-level side effects.
One UI feature = one plugin package (`src/client/` browser half). A multi-domain package splits where its code could later become separate packages — ui-conversation is the example: `contract/` (the only shared API), domain directories that never import a sibling domain, and `apply.ts` as the single cross-domain assembly point; `scripts/verify-client-domain-graph.ts` enforces the levels. Registration goes through `slots.register` in `apply` — never module-level side effects.
## Styling
@@ -99,9 +99,9 @@ Bringing up a new `packages/client/<name>` plugin package (ui-workspace is a com
## New component checklist
1. Compose through register: merge the slot contract into `SlotMap`, declare the slot in its parent entry's `children`, register your component — see the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). No other composition route exists.
1. Compose through register: add the slot to `SlotMap`, declare it in its parent entry's `children`, and register your component — see the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). No other composition route exists.
2. Type the props as the four shares (`PropsRuntime` & `PropsRenderSlots` & `PropsStore` & inject face) — derive, don't hand-write. Shared/surviving state goes in a `createXXXStore()` factory declared at register; component-private state stays local.
3. Component tests feed props directly (`createXXXStore().create()` for the store share; plain stubs for framework hooks) — behavior-shaped assertions, no render machinery.
3. Component tests feed props directly (`createXXXStore().create()` for the store data; plain stubs for framework hooks) and assert behavior without render machinery.
4. Tokens only in CSS; Chinese product copy; English comments.
5. `pnpm run test:gui` green; if the component changes visible assembled output, also run `DSH_SNAPSHOT=replay pnpm run test:web`.
6. Non-trivial change? It needs an Agent Note in the same PR (repo-wide rule) — the GUI notes above are the precedents to extend.

View File

@@ -29,6 +29,7 @@
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",

View File

@@ -6,10 +6,10 @@
// The ./api and ./client subpath exports are the browser-safe channels.
export type {
ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, PromptContentPart, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
DirectoryEntry, DirectoryListing,
WorkspaceApi, WorkspaceId, WorkspaceView,
ResponseValue, WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels,
@@ -37,6 +37,9 @@ export type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session/types'
export type { MessageId } from '@deepseek-ai/dsh-llm/brand'
export type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm/types'
/** Successful value returned by the connection-generation host handshake. */
export type HostDescription = import('@deepseek-ai/dsh-host-apiproxy/api').ResponseValue<'host.describe'>
import type { RpcResponse, RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api'
/**

View File

@@ -131,11 +131,15 @@ export class ConnectionController {
// subscribed baseline. The timeout guards against a carrier that never fires onOpen
// (see ConnectionConfig.streamOpenTimeoutMs).
const timeout = new AbortController()
await Promise.all([
const [description] = await Promise.all([
this.api.host.describe({}),
Promise.race([streamsOpen, sleep(this.config.streamOpenTimeoutMs, timeout.signal)]),
])
timeout.abort()
const descriptionResult = description.result
if (!descriptionResult.ok) {
throw new Error(`host.describe failed: ${descriptionResult.error.code}: ${descriptionResult.error.message}`)
}
if (ac.signal.aborted) throw new Error('generation aborted during readiness handshake')
this.attempt = 0
this.emitState('connected')

View File

@@ -1,7 +1,7 @@
// FixtureApi: standalone UI development without a server. Real contract shape: unary takes
// RpcRequest<P> and returns RpcResponse<T> (echoing the rpcId); streams yield RpcRequest<frame>
// (the fixture IS the fake server, so it mints frame rpcIds); root respond takes ClientResponse
// and returns RpcReceipt. fx-alpha carries a hand-built history script (60 turns, pageable);
// and returns RpcReceipt. fx-alpha carries a hand-built history script (74 turns, pageable);
// prompt triggers a chunked streaming replay; cancel stops the replay; resident pending
// approval/question requests exercise replay and composer takeover with stable rpcIds.
@@ -19,6 +19,7 @@ import type {
ToolResultMessage,
UserMessage,
} from '@deepseek-ai/dsh-llm'
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type {
SessionEvent,
SessionId,
@@ -51,10 +52,10 @@ function userMessage(content: ContentBlock[], source: MessageSource = { kind: 'u
return createUserMessage({ content, source })
}
function assistantMessage(content: ContentBlock[]): AssistantMessage {
function assistantMessage(content: ContentBlock[], model = 'fx-1'): AssistantMessage {
return createAssistantMessage({
content,
source: { provider: 'fixture', model: 'fx-1' },
source: { provider: 'fixture', model },
})
}
@@ -330,6 +331,16 @@ function sid(id: string): SessionId {
return id as SessionId
}
const FIXTURE_IMAGE_DATA = 'iVBORw0KGgoAAAANSUhEUgAAAKAAAABaCAYAAAA/xl1SAAAAvklEQVR42u3SMQ0AAAjAMIyhELM4AAe8PD1qYFlk9cCXEAEDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGxIBCYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIAYEAyIAcGAGBAMiAHBgBgQDIgBwYAYEAyIAcGAGBAMiAHBgBgQDIgB4bYWLb6pnOb1xAAAAABJRU5ErkJggg=='
const FIXTURE_IMAGE_REF: ImageAttachmentRef = {
attachmentId: 'fixture:image' as AttachmentIdType,
mediaType: 'image/png',
bytes: 247,
width: 160,
height: 90,
name: 'fixture-image.png',
}
/** Deterministic provider billing attached to fixture assistant messages. */
function fixtureUsage(turn: number, step: number): TokenUsage {
return {
@@ -340,7 +351,7 @@ function fixtureUsage(turn: number, step: number): TokenUsage {
}
}
/** fx-alpha history script: 60 turns (~130+ messages -> 3 pages at PAGE_MESSAGES=50),
/** fx-alpha history script: 74 turns (~150+ messages -> 4 pages at PAGE_MESSAGES=50),
* mixing reasoning blocks / tool call+result / context. */
function buildAlphaLog(): SessionEvent[] {
const events: Record<string, unknown>[] = []
@@ -476,7 +487,7 @@ function buildAlphaLog(): SessionEvent[] {
push({ type: 'step/end', data: { turn, step: 0 } })
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
}
// Turn 72: todo_write sample — the TodoRow toolview in the flow plus the
// Turn 73: todo_write sample — the TodoRow toolview in the flow plus the
// todo/write snapshot event feeding the TodoPanel plan strip. Two items are
// in_progress: this fixture chooses the parallel policy, so both surfaces
// must render a parallel plan rather than the first active item alone.
@@ -535,8 +546,32 @@ function buildAlphaLog(): SessionEvent[] {
toolTurn(70, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.')
toolTurn(71, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
// Turn 72: user and assistant images share one durable fixture object.
// The todo turn remains last so its standing projection stays visible.
push({ type: 'turn/start', data: { turn: 72 } })
push({
type: 'user/message',
surfaceOp: 'append',
data: userMessage([{ type: 'image', attachment: FIXTURE_IMAGE_REF }, ...text('历史用户图片')]),
})
push({ type: 'step/start', data: { turn: 72, step: 0 } })
push({
type: 'assistant/message',
surfaceOp: 'append',
data: {
turn: 72,
step: 0,
message: assistantMessage(
[...text('结构化模型图片:'), { type: 'image', attachment: FIXTURE_IMAGE_REF }],
'fx-vision',
),
},
})
push({ type: 'step/end', data: { turn: 72, step: 0 } })
push({ type: 'turn/end', data: { turn: 72, reason: { kind: 'completed' } } })
const todoArgs = JSON.stringify({ todos: fixtureTodos })
toolTurn(72, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.')
toolTurn(73, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.')
// The real tool appends the snapshot mid-execution — between tool/call and
// tool/result — so the fixture reproduces that exact ordering (the last
// toolTurn events run ... tool/call, tool/result, step/end, turn/end).
@@ -855,7 +890,6 @@ function estimateFixtureContent(blocks: readonly ContentBlock[]): number {
// ContentBlockMap is merge-extensible: this client graph sees only the
// base four members, but fixture turns do carry extended blocks at
// runtime, so the structural JSON fallback below is live code.
// oxlint-disable-next-line typescript/no-unnecessary-condition -- the type collapses without the out-of-graph merges (see above).
if (block.type === 'tool-result') {
return tokens + estimateFixtureContent(block.content) + BLOCK_OVERHEAD
}
@@ -1057,6 +1091,18 @@ function pageOf(
return { events, hasMore: start > 0 }
}
/** Fixture mirror of host session-scoped attachment authorization. */
function logReferencesAttachment(log: readonly SessionEvent[], attachmentId: string): boolean {
const visit = (value: unknown): boolean => {
if (Array.isArray(value)) return value.some(visit)
if (typeof value !== 'object' || value === null) return false
const record = value as Record<string, unknown>
if (record.attachmentId === attachmentId) return true
return Object.values(record).some(visit)
}
return log.some(event => visit(event.data))
}
/** Fixture mirror of first-party message extraction used by session-query. */
function searchBlockText(block: ContentBlock): string[] {
switch (block.type) {
@@ -1351,6 +1397,10 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
session.sessionId,
{ provider: 'deepseek-official', model: 'deepseek-v4-flash' },
]))
const attachments = new Map<string, { attachment: ImageAttachmentRef; data: string }>([[
String(FIXTURE_IMAGE_REF.attachmentId),
{ attachment: FIXTURE_IMAGE_REF, data: FIXTURE_IMAGE_DATA },
]])
/** Credential store double: set/unset flip the describe badge, values never read back. */
const fixtureCredentials = new Map<string, true>([
// The assembled fixture represents an already-configured shipped
@@ -1368,7 +1418,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
['my-agent', { trust: 'user', content: "- id: tool-read\n name: '@deepseek-ai/dsh-tool-read'\n" }],
])
let fixtureDefaultPreset = 'standard'
const nextTurn = new Map<SessionId, number>([[sid('fx-alpha'), 60]])
const nextTurn = new Map<SessionId, number>([[sid('fx-alpha'), 74]])
let nextSession = 1
let nextRpc = 1
let attachedSessions = options.empty ? 0 : 1
@@ -2145,9 +2195,26 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
// First accepted prompt appends events: the summary stops being blank.
summary.blank = false
const userText = content.map(b => (b.type === 'text' ? b.text : '')).join('')
const durable: ContentBlock[] = content.map((block) => {
if (block.type === 'text') return block
const attachment: ImageAttachmentRef = {
attachmentId: `fixture:${randomUuid()}` as AttachmentIdType,
mediaType: block.mediaType,
bytes: Math.max(
1,
Math.floor(block.data.length * 3 / 4)
- (block.data.endsWith('==') ? 2 : block.data.endsWith('=') ? 1 : 0),
),
width: 160,
height: 90,
...block.name === undefined ? {} : { name: block.name },
}
attachments.set(String(attachment.attachmentId), { attachment, data: block.data })
return { type: 'image', attachment }
})
if (mode === 'steer' && replays.has(id)) {
// Steering: the durable user/message lands inside the current turn; the replay continues.
append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(content) })
append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable) })
return ok(request, { accepted: true as const })
}
const turn = nextTurn.get(id) ?? 0
@@ -2160,7 +2227,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
if (plan.wanted !== null && plan.wanted !== plan.active) {
append(id, { type: 'plan/mode', data: { active: plan.wanted } })
}
append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(content) })
append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable) })
// Capacity parallel of the host token-meter's request/context record:
// log-only, appended inside the open turn, and deduplicated against the
// route already recorded (the fixture never varies contextWindow).
@@ -2186,6 +2253,27 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
)
return ok(request, { accepted: true as const })
},
attachment: (request) => {
const stored = attachments.get(String(request.payload.attachmentId))
if (stored === undefined) {
return err(request, {
code: 'attachment-error',
message: 'fixture attachment missing',
details: { reason: 'ATTACHMENT_NOT_FOUND' },
})
}
if (!logReferencesAttachment(
logs.get(request.payload.sessionId) ?? [],
String(request.payload.attachmentId),
)) {
return err(request, {
code: 'attachment-error',
message: 'fixture attachment is not referenced by this session',
details: { reason: 'ATTACHMENT_NOT_REFERENCED' },
})
}
return ok(request, stored)
},
updateQueue: request => err(request, {
code: 'queue-item-not-found',
message: 'fixture has no pending queue item',
@@ -2262,15 +2350,14 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
archivedSessionIds: [...archivedSessionIds],
}),
create: (request) => {
const { path, name } = request.payload
const target = path ?? `/tmp/fixture-workspaces/${name ?? ''}`
const existing = workspaces.find(w => w.path === target)
const { path } = request.payload
const existing = workspaces.find(w => w.path === path)
if (existing !== undefined) return ok(request, { workspace: { ...existing }, created: false })
const now = new Date().toISOString()
const created: WorkspaceView = {
workspaceId: wid(`fx-ws-${nextWorkspace++}`),
path: target,
title: name ?? target.split('/').filter(Boolean).at(-1) ?? target,
path,
title: path.split('/').filter(Boolean).at(-1) ?? path,
sessionIds: [],
createdAt: now,
updatedAt: now,
@@ -2837,6 +2924,7 @@ export class FixtureApiClient extends AbstractApiClient {
case 'session.rename': return this.api.sessions.rename(request)
case 'session.fork': return this.api.sessions.fork(request)
case 'session.prompt': return this.api.sessions.prompt(request)
case 'session.attachment': return this.api.sessions.attachment(request)
case 'session.updateQueue': return this.api.sessions.updateQueue(request)
case 'session.cancel': return this.api.sessions.cancel(request)
case 'subagent.list': return this.api.subagents.list(request)

View File

@@ -14,7 +14,7 @@ import type { ClientConnectionRpc } from '../rpc.ts'
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
export type {
ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, PromptContentPart, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
DirectoryEntry, DirectoryListing,
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
@@ -24,7 +24,7 @@ export type {
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
HostDescription, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
GoalsApi, GoalRef,
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi,

View File

@@ -21,8 +21,14 @@ export interface FetchHandler {
* @param req - incoming node:http request (fully read before dispatch).
* @param res - node:http response the bridge writes and owns to completion.
* @param apiHandler - fetch-shaped API carrier the request is dispatched to.
* @param maxRequestBodyBytes - maximum body bytes buffered before dispatch.
*/
export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: FetchHandler): Promise<void> {
export async function bridge(
req: IncomingMessage,
res: ServerResponse,
apiHandler: FetchHandler,
maxRequestBodyBytes = 32 * 1024 * 1024,
): Promise<void> {
const abort = new AbortController()
// Client-disconnect detection MUST hang off the response, not the request:
// since Node 16, IncomingMessage 'close' fires as soon as the request body is
@@ -32,8 +38,26 @@ export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandl
res.on('close', () => {
if (!res.writableEnded) abort.abort()
})
const declaredLength = req.headers['content-length']
if (declaredLength !== undefined && Number(declaredLength) > maxRequestBodyBytes) {
res.writeHead(413, { connection: 'close' })
res.end()
req.destroy()
return
}
const chunks: Buffer[] = []
for await (const chunk of req) chunks.push(chunk as Buffer)
let received = 0
for await (const chunk of req) {
const buffer = chunk as Buffer
received += buffer.byteLength
if (received > maxRequestBodyBytes) {
res.writeHead(413, { connection: 'close' })
res.end()
req.destroy()
return
}
chunks.push(buffer)
}
/* v8 ignore next 3 -- `??` arms: node:http always sets url/method on server
requests; the fields are only optional on the client-side IncomingMessage type */
const request = new Request(new URL(req.url ?? '/', 'http://dsh.internal'), {

View File

@@ -1,6 +1,7 @@
/** Host HTTP bridge for browser-client RPC. */
import type { Context } from 'cordis'
import z from 'schemastery'
import type {} from '@deepseek-ai/dsh-attachment'
// Activates the httpServer Context merge used below.
import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
@@ -25,6 +26,25 @@ export { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts'
/** Stable Cordis plugin name. */
export const name = 'client-connection'
/** Headroom for RPC JSON fields around aggregate base64 image payloads. */
const REQUEST_ENVELOPE_HEADROOM_BYTES = 1024 * 1024
function assertImageBodyCapacity(ctx: Context, maxRequestBodyBytes: number): void {
const attachments = ctx.get('attachments')
if (attachments === undefined) return
const requiredImageBodyBytes = Math.ceil(
attachments.imageLimits.maxMessageImageBytes * 4 / 3,
) + REQUEST_ENVELOPE_HEADROOM_BYTES
if (maxRequestBodyBytes < requiredImageBodyBytes) {
throw new Error(
`client-connection maxRequestBodyBytes (${String(maxRequestBodyBytes)}) must be at least `
+ `${String(requiredImageBodyBytes)} for the configured aggregate image limit`,
)
}
}
/** Default carrier cap for all HTTP RPC bodies. */
const DEFAULT_MAX_REQUEST_BODY_BYTES = 32 * 1024 * 1024
/** Services required before providing Connection; API Proxy is an optional `/api` fallback. */
export const inject = ['httpServer']
@@ -39,10 +59,13 @@ export interface ConnectionConfig {
* that is not a bare, canonical authority fails the plugin load.
*/
trustedHosts?: string[]
/** Maximum buffered JSON body for every `/api` request. */
maxRequestBodyBytes?: number
}
export const Config: z<ConnectionConfig> = z.object({
trustedHosts: z.array(String).default([]),
maxRequestBodyBytes: z.natural().min(1).default(DEFAULT_MAX_REQUEST_BODY_BYTES),
})
/**
@@ -109,9 +132,11 @@ const PRIVILEGED_METHODS = new Set([
export function apply(ctx: Context, config?: ConnectionConfig): void {
// The Loader resolves schema defaults; hand-built test contexts may pass none.
const trustedHosts = config?.trustedHosts ?? []
const maxRequestBodyBytes = config?.maxRequestBodyBytes ?? DEFAULT_MAX_REQUEST_BODY_BYTES
// Config boundary: a malformed entry fails the load loudly here rather than
// silently authorizing its hostname prefix at request time.
for (const entry of trustedHosts) assertTrustedAuthority(entry)
if (ctx.get('apiProxy') !== undefined) assertImageBodyCapacity(ctx, maxRequestBodyBytes)
const connection = new HostConnectionService(ctx, trustedHosts)
const fetchHandler = connection.createSharedFetchHandler(API_PATH, {
async fetch(request) {
@@ -144,11 +169,12 @@ export function apply(ctx: Context, config?: ConnectionConfig): void {
res.end('forbidden')
return
}
await bridge(req, res, fetchHandler)
await bridge(req, res, fetchHandler, maxRequestBodyBytes)
},
}
ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route')
ctx.inject(['apiProxy'], (apiCtx) => {
assertImageBodyCapacity(apiCtx, maxRequestBodyBytes)
const downlinks = new WebSocketDownlinks(apiCtx.apiProxy)
const registerDownlink = (
path: string,

View File

@@ -83,6 +83,35 @@ describe('connection lifecycle', () => {
}
})
it('treats a host.describe business error as generation failure', async () => {
const api = new FakeApiClient()
let describeCalls = 0
api.onDescribe = () => {
describeCalls += 1
if (describeCalls === 1) {
return Promise.resolve({
rpcId: 'bad-describe' as never,
result: {
ok: false as const,
error: { code: 'internal' as const, message: 'not ready', details: {} },
},
})
}
return Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 }))
}
let connected = 0
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST)
controller.start()
try {
await vi.waitFor(() => { expect(describeCalls).toBe(2) })
await vi.waitFor(() => { expect(connected).toBe(1) })
} finally {
controller.stop()
warnSpy.mockRestore()
}
})
it('converges stream/error frames into reconnect instead of dispatching them', async () => {
const api = new FakeApiClient()
const muxSeen: string[] = []

View File

@@ -67,6 +67,8 @@ export class FakeApiClient implements IApiClient {
=> Promise<RpcResponse<{ selected: ModelSelection }>> =
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onAttachment: (payload: unknown) => Promise<RpcResponse<{ attachment: { attachmentId: never; mediaType: 'image/png'; bytes: number; width: number; height: number }; data: string }>> =
() => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' }))
onUpdateQueue: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
@@ -110,6 +112,7 @@ export class FakeApiClient implements IApiClient {
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)),
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
attachment: (payload: unknown) => this.record('session.attachment', payload, this.onAttachment(payload)),
updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
}

View File

@@ -535,7 +535,7 @@ describe('createFixtureApi', () => {
expect(reused.result.value).toMatchObject({ created: false, workspace: { workspaceId: 'fx-ws-fixture' } })
})
it('workspace.create by name mints a new entity and pushes host/workspace-changed', async () => {
it('workspace.create on a fresh path mints a new entity and pushes host/workspace-changed', async () => {
const api = createFixtureApi()
const abort = new AbortController()
const seen: HostFrame[] = []
@@ -546,7 +546,7 @@ describe('createFixtureApi', () => {
}
})()
await new Promise(resolve => setTimeout(resolve, 10))
const created = await api.workspace.create(req({ name: 'nova' }))
const created = await api.workspace.create(req({ path: '/tmp/fixture-workspaces/nova' }))
if (!created.result.ok) throw new Error('create failed')
expect(created.result.value.created).toBe(true)
expect(created.result.value.workspace).toMatchObject({
@@ -554,16 +554,7 @@ describe('createFixtureApi', () => {
})
await consuming
expect(seen).toEqual([{ type: 'host/workspace-changed', workspace: created.result.value.workspace }])
// path spelling falls back to the basename when no title/name rides along.
const pathOnly = await api.workspace.create(req({ path: '/tmp/fixture-elsewhere/base' }))
if (!pathOnly.result.ok) throw new Error('pathOnly failed')
expect(pathOnly.result.value.workspace.title).toBe('base')
// Degenerate spellings reach the impl unfiltered (the fixture carrier has
// no schema gate): both-absent falls back to the bucket dir, and a
// basename-less path serves as its own title.
const bare = await api.workspace.create(req({}))
if (!bare.result.ok) throw new Error('bare failed')
expect(bare.result.value.workspace).toMatchObject({ path: '/tmp/fixture-workspaces/', title: 'fixture-workspaces' })
// A basename-less path serves as its own title.
const rootPath = await api.workspace.create(req({ path: '/' }))
if (!rootPath.result.ok) throw new Error('rootPath failed')
expect(rootPath.result.value.workspace.title).toBe('/')
@@ -584,7 +575,7 @@ describe('createFixtureApi', () => {
const missing = await api.workspace.rename(req({ workspaceId: 'fx-ws-void' as WorkspaceId, title: 'x' }))
expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'fx-ws-void' } } })
await api.workspace.create(req({ name: 'occupied' }))
await api.workspace.create(req({ path: '/tmp/fixture-workspaces/occupied' }))
const conflict = await api.workspace.rename(req({ workspaceId: wsid, title: ' occupied ' }))
expect(conflict.result).toMatchObject({ ok: false, error: { code: 'workspace-name-conflict', details: { name: 'occupied' } } })
@@ -722,7 +713,7 @@ describe('createFixtureApi', () => {
expect(initialSessions.result).toMatchObject({ ok: true, value: { items: [] } })
expect(initialWorkspaces.result).toMatchObject({ ok: true, value: { items: [] } })
const made = await api.workspace.create(req({ name: 'nova' }))
const made = await api.workspace.create(req({ path: '/tmp/fixture-workspaces/nova' }))
if (!made.result.ok) throw new Error('workspace create failed')
const abort = new AbortController()
const framesPromise = collect(api.events.host(req({}), abort.signal), abort, frames => frames.length === 2)
@@ -991,7 +982,7 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
expect((await client.sessions.cancel({ sessionId: id })).result.ok).toBe(true)
expect((await client.host.describe({})).result.ok).toBe(true)
expect((await client.workspace.list({})).result.ok).toBe(true)
const workspace = await client.workspace.create({ name: 'via-client' })
const workspace = await client.workspace.create({ path: '/tmp/fixture-workspaces/via-client' })
if (!workspace.result.ok) throw new Error('workspace create failed')
expect(workspace.result.value.workspace.title).toBe('via-client')
const wsid = workspace.result.value.workspace.workspaceId
@@ -1049,7 +1040,7 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
})
const client = new FixtureApiClient()
await expect(client.sessions.list({})).resolves.toMatchObject({ result: { ok: true, value: { items: [] } } })
const made = await client.workspace.create({ name: 'query-workspace' })
const made = await client.workspace.create({ path: '/tmp/fixture-workspaces/query-workspace' })
if (!made.result.ok) throw new Error('workspace create failed')
const abort = new AbortController()
const framesPromise = collect(client.events.host({}, abort.signal), abort, frames => frames.length === 2)

View File

@@ -5,6 +5,34 @@ import { describe, expect, it } from 'vitest'
import { bridge } from '../src/http-bridge.ts'
describe('HTTP bridge abort', () => {
it('destroys a declared-oversize request instead of draining it', async () => {
const destroyed: true[] = []
const request = Readable.from([]) as unknown as IncomingMessage
Object.assign(request, {
url: '/api/session.prompt',
method: 'POST',
headers: { 'content-type': 'application/json', 'content-length': '999999' },
destroy: () => { destroyed.push(true) },
})
let status: number | undefined
let headers: unknown
const response = Object.assign(new EventEmitter(), {
writableEnded: false,
writeHead(code: number, values?: unknown) { status = code; headers = values; return this },
write() { return true },
end(this: { writableEnded: boolean }) { this.writableEnded = true; return this },
}) as unknown as ServerResponse
await bridge(request, response, {
fetch: () => { throw new Error('a rejected request must never reach the handler') },
}, 1000)
// The socket must not stay parked draining a body the client can trickle
// at will after the rejection — same discipline as the chunked overrun.
expect(status).toBe(413)
expect(headers).toMatchObject({ connection: 'close' })
expect(destroyed).toHaveLength(1)
})
it('aborts a pending native picker request when the browser disconnects', async () => {
const body = JSON.stringify({
type: 'client-request', rpcId: 'picker-1', method: 'host.pickDirectory', payload: {},
@@ -38,7 +66,7 @@ describe('HTTP bridge abort', () => {
}
return Response.json({ aborted: fetchRequest.signal.aborted })
},
})
}, Number.MAX_SAFE_INTEGER)
await started
response.emit('close')
await pending

View File

@@ -7,6 +7,7 @@ import { describe, expect, it } from 'vitest'
import type { AddressInfo } from 'node:net'
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { AttachmentStore } from '@deepseek-ai/dsh-attachment'
import { RpcId, type ClientRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { HttpServerService, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH, type HostConnectionHandle } from '../src/index.ts'
@@ -89,6 +90,19 @@ async function mounted(config?: { trustedHosts?: string[] }): Promise<{
}
describe('connection node half', () => {
it('fails loud when the carrier cap cannot hold the configured image batch', () => {
const ctx = new Context()
const routes: WebRoute[] = []
ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService)
ctx.provide('attachments', {
imageLimits: { maxMessageImageBytes: 20 * 1024 * 1024 },
} as AttachmentStore)
ctx.provide('apiProxy', {} as ApiProxy)
expect(() => { apply(ctx, { maxRequestBodyBytes: 1024 }) })
.toThrow(/must be at least .* aggregate image limit/)
expect(routes).toHaveLength(0)
})
it('fails the load on a trustedHosts entry that is not a bare authority', async () => {
const routes: WebRoute[] = []
const upgrades: WebUpgradeRoute[] = []

View File

@@ -9,6 +9,9 @@
"src"
],
"references": [
{
"path": "../../attachment/attachment"
},
{
"path": "../../llm/llm"
},

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/hmr/README.md
README.md: 454c03cc3cd11722943efd025d164d9ca8233d25
README.md: 9228292547376d3fbb0ea5ce56b9e0a35ced17b2
README.zh.md: ea62600911458556a3dcc7c46854e97db751c3ef

View File

@@ -18,4 +18,4 @@ None; this package neither assembles nor sends a provider request.
- **Reload is coarse by design** — a fresh fiber and fresh components; React state inside the reloaded plugin is lost while the data layer (connection/runtime fibers, Session objects) is untouched. react-refresh-grade state preservation conflicts with "re-executing the bundle re-runs the factory" and is deliberately out.
- **No failure rollback** — a reload that fails leaves the entry FAILED and visible in the loader status projection; the previous bundle is not restored automatically.
- **Graph rev is not refreshed by rebuilt frames** — the stale rev is harmless because the bundle endpoint serves no-cache; reconnect is the only refresh boundary.
- **Graph rev is not refreshed by rebuilt frames** — the stale rev is harmless because the bundle endpoint serves no-cache; only reconnect refreshes it.

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/locale/README.md
README.md: f1efefde4557e1c29c0556f8b670f1534430ab79
README.zh.md: a8b5704d28ea121e668cbd500dd3d217d4f96291
README.md: 5bea46cd4e3ace61bd2251610abdf0812ded9604
README.zh.md: 2333bc7c2b2f5918c35286064c50131153ee8711

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Locale plugin: LocaleService — the browser locale preference (`zh`/`en`, persisted under `dsh.locale`; with nothing persisted a fresh browser opens in the language `navigator` asks for — matched on the primary subtag, `zh` when it asks for none this app ships; `locale/change` fires on switches only) plus the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)``TranslateNS<ns>`; lookup chain ns → common → zh → key). The service implements the slot system's `LocaleFace` and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience).
Locale plugin: LocaleService — the `zh`/`en` preference stored as `locale.preference` in `$DSH_HOME/settings.yaml`; when that explicit Host value is absent, a fresh browser starts provisionally in the language `navigator` asks for (primary-subtag matching, with `zh` when it asks for no language this app ships). The Host read runs after plugin activation so an unavailable settings service cannot block the page; its result replaces the provisional browser value live. Remote browsers retain only a process-local selection because the settings API is loopback-only. `locale/change` fires on switches. The service also owns the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)``TranslateNS<ns>`; lookup chain ns → common → zh → key), implements the slot system's `LocaleFace`, and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience). The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary.
## Model Experience

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
locale 插件LocaleService——浏览器 locale 偏好(`zh``en``dsh.locale` 持久化;未持久化偏好时,全新浏览器 `navigator` 请求的语言开场——按主子标签匹配若其请求的语言本应用都不提供则为 `zh``locale/change` 仅在切换语言时触发),加上 ns×locale 字典注册表(类型化 `register(ns, {zh, en})``LocaleNamespaceMap` 校验,`bind(ns)``TranslateNS<ns>`;查找链 ns → common → zh → key。该服务实现 slot 系统的 `LocaleFace` 并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate``TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。
locale 插件LocaleService——`zh``en` 偏好`locale.preference` 存储在 `$DSH_HOME/settings.yaml` 中;若没有显式 Host 值,全新浏览器会暂时使用 `navigator` 请求的语言按主子标签匹配若其请求的语言本应用都不提供,则使用 `zh`。Host 读取在插件激活后执行,因此 settings 服务不可用不会阻塞页面读取结果会实时替换浏览器暂定值。settings API 仅限回环请求,因此远程浏览器的选择仅保留在进程内。`locale/change` 仅在切换语言时触发。该服务还拥有 ns×locale 字典注册表(类型化 `register(ns, {zh, en})``LocaleNamespaceMap` 校验,`bind(ns)``TranslateNS<ns>`;查找链 ns → common → zh → key实现 slot 系统的 `LocaleFace`并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate``TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md)拥有。
## 模型体验

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-locale",
"description": "Locale plugin: LocaleService (zh/en preference with getter/setter/change event + persistence; ns x locale dictionaries, bind(ns) -> t); registers the Language settings row",
"description": "Locale plugin: Host-backed zh/en preference, browser-derived fallback, locale snapshots, and typed namespace dictionaries",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -24,6 +24,7 @@
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-runtime"
],
"platform": "web",
@@ -31,6 +32,7 @@
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-client-connection": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
@@ -47,6 +49,10 @@
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"dependencies": {
"@deepseek-ai/dsh-settings": "workspace:^",
"schemastery": "^3.18.0"
},
"files": [
"lib/index.js",
"lib/invariant.js",

View File

@@ -13,7 +13,12 @@ import type { Context } from 'cordis'
import {
type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import {
bindSettingsScope, type ClientContext, type SettingsScope,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, type LocaleSettings,
} from '../locale-settings.ts'
import { en, zh, type CommonKey } from '../locales/index.ts'
import {
en as settingsEn, zh as settingsZh, type SettingsLocaleKey,
@@ -26,6 +31,7 @@ export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageR
export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts'
export type { SettingsGeneralItemOwnerProps } from './settings-contract.ts'
export type { CommonKey } from '../locales/index.ts'
export type { LocaleId, LocaleSettings } from '../locale-settings.ts'
// The translate currency lives in ui-slots (the render machinery synthesizes
// the seat); re-exported here so dictionary owners import one package.
@@ -44,9 +50,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** Locale dictionary: flat key to template string ({name} placeholders). */
export type LocaleDict = Record<string, string>
/** Locale identifier: the two shipped locales. */
export type LocaleId = 'zh' | 'en'
/** One selectable locale: id plus its self-described display name. */
export interface LocaleDefinition {
/** Locale id (persisted; the setLocale argument). */
@@ -91,9 +94,6 @@ export const COMMON_NS = 'common'
/** Namespace owning this feature's settings-row copy. */
export const SETTINGS_NS = 'settings.locale'
/** localStorage key holding the persisted locale id. */
export const STORAGE_KEY = 'dsh.locale'
/** The two shipped locales. */
const LOCALES: readonly LocaleDefinition[] = Object.freeze([
{ id: 'zh', label: '中文' },
@@ -116,13 +116,25 @@ export class LocaleService {
private snapshot: LocaleSnapshot
private listeners = new Set<() => void>()
private readonly ctx: Context
private readonly host: SettingsScope<LocaleSettings> | undefined
/** Browser-derived locale standing wherever no explicit Host selection does. */
private readonly provisional: LocaleId
/**
* @param ctx - owning context (change events are emitted on it).
* @param ctx - owning context (change events are emitted on it; the scope
* listener is released through ctx.effect on dispose).
* @param host - durable preference scope owned by the providing plugin;
* absent compositions (standalone dictionary registries) stay process-local.
*/
constructor(ctx: Context) {
constructor(ctx: Context, host?: SettingsScope<LocaleSettings>) {
this.ctx = ctx
this.snapshot = Object.freeze({ active: resolveInitialLocale(), locales: LOCALES, revision: 0 })
this.host = host
this.provisional = resolveInitialLocale()
this.snapshot = Object.freeze({ active: this.provisional, locales: LOCALES, revision: 0 })
if (host !== undefined) {
ctx.effect(() => host.subscribe(() => { this.adopt(host) }), 'locale: settings scope adoption')
this.adopt(host)
}
}
/**
@@ -155,16 +167,28 @@ export class LocaleService {
}
/**
* Switch the active locale — the only preference write entry. Persists the
* id and emits `locale/change`.
* Switch the active locale — the only user preference write entry.
* @param id - a registered locale id; unknown ids throw.
*/
setLocale(id: string): void {
const match = this.snapshot.locales.find(l => l.id === id)
if (match === undefined) throw new Error(`locale "${id}" is not registered`)
if (this.snapshot.active === match.id) return
persistPreference(match.id)
this.publish(match.id, true)
void this.host?.set(LOCALE_PREFERENCE_FIELD, match.id)
}
/**
* Adopt the scope's accepted durable selection without writing it back; an
* absent selection returns to the browser-derived locale.
* @param host - the constructor-narrowed scope driving this adoption.
*/
private adopt(host: SettingsScope<LocaleSettings>): void {
const section = host.getSnapshot().value
if (section === undefined) return
const target = section.preference ?? this.provisional
if (this.snapshot.active === target) return
this.publish(target, true)
}
/**
@@ -288,27 +312,11 @@ export class LocaleService {
}
/**
* The locale a fresh service opens with: an explicit preference the user
* already chose wins over the browser's own language, which in turn wins over
* {@link FALLBACK_LOCALE} (non-browser boots and browsers set to a language
* this app does not ship).
* The browser's own language wins over {@link FALLBACK_LOCALE}; an explicit
* Host preference may replace this provisional value after plugin activation.
*/
function resolveInitialLocale(): LocaleId {
return restorePreference() ?? detectBrowserLocale() ?? FALLBACK_LOCALE
}
/** Read the persisted locale id; unknown or unreadable values read as no preference. */
function restorePreference(): LocaleId | undefined {
// Non-browser runs (node e2e booting the client tree) have no localStorage.
if (typeof localStorage === 'undefined') return undefined
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored === 'zh' || stored === 'en') return stored
} catch {
// Storage access can throw (privacy mode); an unreadable store simply
// records no preference, and the browser language decides instead.
}
return undefined
return detectBrowserLocale() ?? FALLBACK_LOCALE
}
/**
@@ -325,8 +333,7 @@ function detectBrowserLocale(): LocaleId | undefined {
/* oxlint-disable-next-line typescript/no-unnecessary-condition --
* The DOM lib types `languages` as always present; embedders and older
* WebViews ship a Navigator without it, and spreading undefined would
* throw at boot. Same environment-boundary distrust as the localStorage
* guards below. */
* throw at boot. */
for (const tag of [...(navigator.languages ?? []), navigator.language]) {
const primary = tag.toLowerCase().split('-')[0]
const match = LOCALES.find(locale => locale.id === primary)
@@ -335,19 +342,8 @@ function detectBrowserLocale(): LocaleId | undefined {
return undefined
}
/** Persist the locale id; storage failures are non-fatal (preference resets next boot). */
function persistPreference(id: LocaleId): void {
if (typeof localStorage === 'undefined') return
try {
localStorage.setItem(STORAGE_KEY, id)
} catch {
// Storage access can throw (privacy mode / quota); the preference simply
// does not survive the session.
}
}
/** Required services: the slot registry (the feature registers its own settings row). */
export const inject = ['slots']
/** Required services: slot registration plus the settings transport. */
export const inject = ['slots', 'connection']
/**
* Client plugin body: provide the locale service with base dictionaries and
@@ -356,7 +352,8 @@ export const inject = ['slots']
* @param ctx - client cordis context.
*/
export function apply(ctx: ClientContext): void {
const locale = new LocaleService(ctx)
const host = bindSettingsScope<LocaleSettings>(ctx, { namespace: LOCALE_SETTINGS_NAMESPACE })
const locale = new LocaleService(ctx, host)
locale.register(COMMON_NS, { zh, en })
locale.register(SETTINGS_NS, { zh: settingsZh, en: settingsEn })
ctx.provide('locale', locale)

View File

@@ -1,4 +1,23 @@
/** Host loader entry for the browser implementation exported from `./client`. */
/** Host registration for the browser locale preference. */
/** Host plugin body — no host-side behavior for the locale plugin. */
export function apply(): void {}
import type { Context } from 'cordis'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import { LOCALE_SETTINGS_NAMESPACE, LocaleSettingsSchema } from './locale-settings.ts'
export {
LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE,
type LocaleId, type LocaleSettings,
} from './locale-settings.ts'
/**
* Register the durable locale section when a settings provider exists.
* @param ctx - Host context whose optional settings service owns the section.
*/
export function apply(ctx: Context): void {
ctx.inject(['settings'], (settingsCtx) => {
settingsCtx.settings.register(
settingsNamespace(LOCALE_SETTINGS_NAMESPACE),
LocaleSettingsSchema,
)
})
}

View File

@@ -0,0 +1,26 @@
/** Locale preference stored in the Host user-settings document. */
import z from 'schemastery'
/** Settings namespace owned by the locale plugin. */
export const LOCALE_SETTINGS_NAMESPACE = 'locale'
/** Field carrying an explicit locale selection; absence delegates to the browser. */
export const LOCALE_PREFERENCE_FIELD = 'preference'
/** Locale identifiers shipped by the browser client. */
export const LOCALE_IDS = ['zh', 'en'] as const
/** Shipped locale identifier. */
export type LocaleId = typeof LOCALE_IDS[number]
/** Durable locale section shared by the Host schema and the browser scope. */
export interface LocaleSettings {
/** Explicit locale selection; absence delegates to the browser. */
preference?: LocaleId
}
/** Durable locale schema; also the wire envelope the browser scope validates against. */
export const LocaleSettingsSchema: z<LocaleSettings> = z.object({
[LOCALE_PREFERENCE_FIELD]: z.union([...LOCALE_IDS]).required(false),
})

View File

@@ -4,8 +4,11 @@
import { Context } from 'cordis'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-locale/client'
import {
apply, inject, SETTINGS_NS,
} from '@deepseek-ai/dsh-client-locale/client'
import type { LanguageRowInjected, LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { LOCALE_SETTINGS_NAMESPACE, LocaleSettingsSchema } from '../src/locale-settings.ts'
import { LanguageRow } from '../src/client/LanguageRow.tsx'
import type { createLanguageRowStore } from '../src/client/settings-store.ts'
@@ -14,7 +17,36 @@ const SLOT = 'settings.general.item'
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
return { ctx, slots: ctx.get('slots') as SlotsService }
let preference: string | undefined
let revision = 0
const namespace = () => ({
ns: LOCALE_SETTINGS_NAMESPACE,
schema: LocaleSettingsSchema.toJSON(),
value: preference === undefined ? {} : { preference },
applies: 'live' as const,
secrets: [],
revision,
})
const describe = vi.fn(async () => ({
rpcId: 'locale-describe' as never,
result: {
ok: true as const,
value: { writable: true, hasDocument: true, namespaces: [namespace()] },
},
}))
const mutate = vi.fn(async (request: { ops: { value: string }[] }) => {
preference = request.ops[0]!.value
revision += 1
return {
rpcId: 'locale-mutate' as never,
result: { ok: true as const, value: namespace() },
}
})
ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback: true } as never)
return {
ctx, slots: ctx.get('slots') as SlotsService, describe, mutate,
setHostPreference: (next: string | undefined) => { preference = next; revision += 1 },
}
}
/** Stand in for the settings shell: declare the General item slot from root. */
@@ -47,7 +79,7 @@ describe('locale apply', () => {
})
it('declares the slot service', () => {
expect(inject).toEqual(['slots'])
expect(inject).toEqual(['slots', 'connection'])
})
it('provides the service with base + settings dictionaries and registers the row (declaration before or after apply)', async () => {
@@ -91,6 +123,23 @@ describe('locale apply', () => {
expect(locale.getLocale().active).toBe('zh')
expect(instance.getSnapshot().active).toBe('zh')
expect(locale.bind(SETTINGS_NS)('language.title')).toBe('语言')
await vi.waitFor(() => { expect(b.mutate).toHaveBeenCalledTimes(2) })
})
it('loads and refreshes the explicit Host preference after nonblocking activation', async () => {
const b = await bench()
b.setHostPreference('en')
declareItems(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const locale = b.ctx.get('locale') as LocaleService
await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') })
b.setHostPreference(undefined)
b.ctx.emit('settings/changed', LOCALE_SETTINGS_NAMESPACE)
await vi.waitFor(() => { expect(locale.getLocale().active).toBe('zh') })
b.setHostPreference('en')
b.ctx.emit('settings/changed', LOCALE_SETTINGS_NAMESPACE)
await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') })
expect(b.describe).toHaveBeenCalledTimes(3)
})
it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => {

View File

@@ -0,0 +1,30 @@
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
import {
LOCALE_SETTINGS_NAMESPACE, apply,
} from '@deepseek-ai/dsh-client-locale'
class MemorySettings extends Settings {
readonly writable = true
protected load(): Promise<Record<string, unknown>> { return Promise.resolve({}) }
protected persist(_ns: SettingsNamespace, _section: Record<string, unknown>): Promise<void> {
return Promise.resolve()
}
}
describe('locale host', () => {
it('registers an optional explicit locale preference with the Host settings lifecycle', async () => {
const ctx = new Context()
await ctx.plugin(MemorySettings).await()
const fiber = ctx.plugin({ apply })
await fiber.await()
const ns = settingsNamespace(LOCALE_SETTINGS_NAMESPACE)
expect(ctx.settings.get(ns)).toEqual({})
await ctx.settings.update(ns, { preference: 'en' })
expect(ctx.settings.get(ns)).toEqual({ preference: 'en' })
await expect(ctx.settings.update(ns, { preference: 'fr' })).rejects.toThrow()
await fiber.dispose()
expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns)
})
})

View File

@@ -14,16 +14,16 @@ describe('invariant companion', () => {
await expect(ctx.plugin(LocaleInvariant).await()).resolves.toBeDefined()
})
it('node-half apply is a no-op host placeholder', () => {
nodeApply()
expect(true).toBe(true) // reaching here without throw is the contract
it('node-half apply tolerates a Host without settings', () => {
nodeApply(new Context())
})
it('client apply provides ctx.locale seeded with the zh/en common namespace', async () => {
// The feature registers its own Language settings row, hence the slots edge.
expect(inject).toEqual(['slots'])
expect(inject).toEqual(['slots', 'connection'])
const ctx = new Context()
new SlotsService(ctx)
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
await ctx.plugin({ inject, apply: clientApply }).await()
const locale = ctx.get('locale')
expect(locale).toBeInstanceOf(LocaleService)

View File

@@ -1,14 +1,19 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client'
import { LocaleService, STORAGE_KEY } from '@deepseek-ai/dsh-client-locale/client'
import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import type { LocaleSettings, LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
const make = (): { ctx: Context; svc: LocaleService; events: LocaleSnapshot[] } => {
const make = (host?: StubSettingsScope<LocaleSettings>): {
ctx: Context
svc: LocaleService
events: LocaleSnapshot[]
} => {
const ctx = new Context()
const events: LocaleSnapshot[] = []
ctx.on('locale/change', (snapshot) => { events.push(snapshot) })
return { ctx, svc: new LocaleService(ctx), events }
return { ctx, svc: new LocaleService(ctx, host?.scope), events }
}
/**
@@ -24,7 +29,6 @@ const stubLanguages = (...tags: string[]): void => {
describe('LocaleService', () => {
beforeEach(() => {
localStorage.clear()
// A Chinese browser is the baseline these specs assert their zh state on.
stubLanguages('zh-CN')
})
@@ -132,16 +136,25 @@ describe('LocaleService', () => {
expect(svc.getSnapshot().revision).toBe(before + 1)
})
it('setLocale persists, republishes an immutable snapshot, and no-ops on same value', () => {
const { svc, events } = make()
it('setLocale writes through the scope, republishes an immutable snapshot, and no-ops on same value', () => {
const host = stubSettingsScope<LocaleSettings>()
const { svc, events } = make(host)
svc.setLocale('en')
expect(svc.getLocale().active).toBe('en')
expect(localStorage.getItem(STORAGE_KEY)).toBe('en')
expect(host.set).toHaveBeenCalledWith('preference', 'en')
expect(events).toHaveLength(1)
expect(events[0]).toBe(svc.getLocale())
expect(events[0]!.revision).toBe(1)
svc.setLocale('en')
expect(events).toHaveLength(1)
expect(host.set).toHaveBeenCalledOnce()
})
it('setLocale without a host scope stays process-local', () => {
const { svc, events } = make()
svc.setLocale('en')
expect(svc.getLocale().active).toBe('en')
expect(events).toHaveLength(1)
})
it('throws on unknown locale ids', () => {
@@ -149,14 +162,37 @@ describe('LocaleService', () => {
expect(() => { svc.setLocale('fr') }).toThrow('not registered')
})
it('restores a persisted locale over the browser language, and garbage reads as no preference', () => {
localStorage.setItem(STORAGE_KEY, 'en')
expect(make().svc.getLocale().active).toBe('en')
localStorage.setItem(STORAGE_KEY, 'fr')
expect(make().svc.getLocale().active).toBe('zh')
it('adopts a Host preference over the browser language without writing it back', () => {
const host = stubSettingsScope<LocaleSettings>()
const { svc, events } = make(host)
host.publish({ status: 'ready', value: { preference: 'en' }, revision: 1, writable: true })
expect(svc.getLocale().active).toBe('en')
expect(events).toHaveLength(1)
expect(host.set).not.toHaveBeenCalled()
host.publish({ value: { preference: 'en' }, revision: 2 })
expect(events).toHaveLength(1)
})
it('opens in the browser language when nothing is persisted, matching regional variants on their primary subtag', () => {
it('an absent Host preference returns to the browser-derived locale', () => {
const host = stubSettingsScope<LocaleSettings>()
const { svc } = make(host)
host.publish({ status: 'ready', value: { preference: 'en' }, revision: 1, writable: true })
expect(svc.getLocale().active).toBe('en')
host.publish({ value: {}, revision: 2 })
expect(svc.getLocale().active).toBe('zh')
})
it('adopts a section already standing at construction and releases its subscription on dispose', async () => {
const host = stubSettingsScope<LocaleSettings>()
host.publish({ status: 'ready', value: { preference: 'en' }, revision: 1, writable: true })
const { ctx, svc } = make(host)
expect(svc.getLocale().active).toBe('en')
expect(host.listenerCount()).toBe(1)
await ctx.fiber.dispose()
expect(host.listenerCount()).toBe(0)
})
it('opens provisionally in the browser language, matching regional variants on their primary subtag', () => {
stubLanguages('en-GB', 'zh-CN')
expect(make().svc.getLocale().active).toBe('en')
stubLanguages('zh-Hant-TW')
@@ -176,8 +212,7 @@ describe('LocaleService', () => {
expect(make().svc.getLocale().active).toBe('zh')
})
it('runs outside a browser (node boots): the fallback decides, the machine language does not, writes no-op', () => {
vi.stubGlobal('localStorage', undefined)
it('runs outside a browser (node boots): the fallback decides and the machine language does not', () => {
vi.stubGlobal('window', undefined)
// Node exposes its own global navigator; without a window it must not
// reach the resolution at all.
@@ -188,12 +223,11 @@ describe('LocaleService', () => {
expect(svc.getLocale().active).toBe('en')
})
it('keeps the browser language out of the way once a preference exists', () => {
it('lets an explicit in-process preference replace the browser-derived value', () => {
stubLanguages('en-US')
const { svc } = make()
svc.setLocale('zh')
expect(localStorage.getItem(STORAGE_KEY)).toBe('zh')
expect(make().svc.getLocale().active).toBe('zh')
expect(svc.getLocale().active).toBe('zh')
})
it('exposes the two shipped locales with self-described labels', () => {

View File

@@ -20,6 +20,9 @@
{
"path": "../../../vendor/cordis"
},
{
"path": "../../settings/settings"
},
{
"path": "../../support/invariants"
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/modules/README.md
README.md: 1d327c7252f4b3001ad758b7a4db01e9907c3060
README.zh.md: a97672b909c98367e8c1287e3b341fb612f2d110
README.md: 7b4c9b72e782dbdbb69d711ae7e022771afebace
README.zh.md: 6420f6324f38979af5428a9ad428f33525009f1f

View File

@@ -20,5 +20,5 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Flat module graph by design** — every bundle is one module node whose edges point only at table leaves; the interface (loadCache/edges/invalidate) is shaped for a general module graph so the externalization granularity can change without an interface change.
- **Flat module graph by design** — every bundle is one module node whose edges point only at table leaves; the interface (`loadCache`/`edges`/`invalidate`) already supports a general module graph, so the externalization granularity can change without an interface change.
- **No unload bookkeeping of its own** — style removal and fiber teardown ordering live with the HMR driver (`@deepseek-ai/dsh-client-hmr`); the loader only inventories owned style tag ids per record.

View File

@@ -20,5 +20,5 @@ Node 侧会扫描已启用的 Loader 配置项以发现 web `dshClient` 包,
## 已知限制与暂缓事项
- **有意采用扁平模块图**每个组合包是一个模块节点其边只指向表中的叶节点接口loadCache/edges/invalidate)按通用模块图塑形,因此可以改变 externalization 粒度而不更改接口。
- **有意采用扁平模块图**:每个组合包是一个模块节点,其边只指向表中的叶节点;接口(`loadCache`/`edges`/`invalidate`)已经支持通用模块图,因此可以改变 externalization 粒度而不更改接口。
- **自身不记录卸载账目**:样式移除与 fiber 拆卸顺序属于 HMR 驱动器(`@deepseek-ai/dsh-client-hmr`loader 只在每条记录中登记其拥有的样式标签 id。

View File

@@ -43,7 +43,7 @@ declare module 'cordis' {
}
}
/** package.json `dshClient` declaration shape (file boundary — validated field by field). */
/** package.json `dshClient` declaration fields, validated one by one after reading the file. */
interface DshClientDeclaration {
inject?: string[]
platform: string
@@ -138,7 +138,7 @@ function clientExportOf(pkgName: string, exportsField: unknown): string | undefi
const fallback = (client as Record<string, unknown>).default
if (typeof fallback === 'string') return fallback
}
throw new Error(`client-modules: ${pkgName} exports["./client"] has an unsupported shape`)
throw new Error(`client-modules: ${pkgName} exports["./client"] must be a string or an object with a string default`)
}
/** sha1 content hash shortened to 12 hex chars (bundle rev / graph rev). */

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: 0a7d9975093da558af623ee9940f4be398526821
README.zh.md: 41388e4ba564baa61cfdaaacb74f4f5ea053d41a
README.md: 1ec6cc38aed1bebff6b6ecb40faee7ae3ba9e412
README.zh.md: 6602152790a1d433371e27b274a4eb8c9e3cfcd8

View File

@@ -2,7 +2,9 @@
English | [中文](README.zh.md)
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `session/preset-changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. `host/session-preset-changed` also folds its preset into the session row, because the switch's RPC echo reaches only the client that issued it. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
`bindSettingsScope` is the browser mirror of the Host-side settings owner seam for one domain-owned namespace. It subscribes before starting a nonblocking initial read, publishes a uSES snapshot (status, section value, revision, writability, host/memory mode), serializes `set` writes with the latest known namespace revision, suppresses stale publications, recovers a rejected latest write from Host state, and reaches quiescence on plugin disposal. The default decoder validates each section against the namespace's own serialized wire schema (rehydrated through dsh-client-schema-form), so a domain adds a decoder only to narrow beyond that schema. Loopback pages use the Host settings API; remote pages stay in memory mode. Domain packages own the namespace schema, default, and live service rather than putting product policy in runtime.
## Slot declaration injection

View File

@@ -2,7 +2,9 @@
[English](README.md) | 中文
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed``settings/changed``credentials/changed``models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent智能体和 cwd客户端不持有任何实体化之前的会话状态——agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf``useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed``session/preset-changed``settings/changed``credentials/changed``models/changed`),使各表面缓存无需触碰流即可重拉。`host/session-preset-changed` 还会把其中的 preset 折进会话行,因为这次切换的 RPC 回执只会到达发起它的那个客户端。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent智能体和 cwd客户端不持有任何实体化之前的会话状态——agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf``useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
`bindSettingsScope` 面向单个由领域持有的 namespace是 Host 侧 settings owner seam 的浏览器镜像。它在开始非阻塞初始读取前建立订阅,发布 uSES 快照状态、分节值、revision、可写性、host内存模式使用已知最新 namespace revision 串行执行 `set` 写入,抑制陈旧发布,并在最新写入被拒时从 Host 状态恢复;插件释放时,它会达到完全停稳。默认解码器会对照该 namespace 自身的序列化 wire schema经 dsh-client-schema-form 还原)校验每个分节,因此领域只有在需要比该 schema 进一步收窄时才添加解码器。回环页面使用 Host settings API远程页面则停留在内存模式。namespace schema、默认值与实时服务归领域包所有而非把产品政策放入运行时。
## Slot 声明注入

View File

@@ -33,7 +33,9 @@
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-schema-form": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
@@ -60,7 +62,8 @@
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7"
"cordis": "^4.0.0-rc.7",
"schemastery": "^3.18.0"
},
"files": [
"lib/index.js",

View File

@@ -7,9 +7,9 @@
* must stub); runtime-internal entry points (history staging, wire-frame
* dispatch) stay on the class, invisible out here.
*/
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type {
MessageId, QueueAction, RpcResult, SessionId,
MessageId, PromptContentPart, QueueAction, RpcResult, SessionId,
} from '@deepseek-ai/dsh-client-connection/client'
import type { ConversationSnapshot } from '../sessions/conversation.ts'
import type { ObservableSnapshot } from './store.ts'
@@ -33,11 +33,19 @@ export interface ISession {
readonly projections: ProjectionsFace
/**
* Send a prompt into the session.
* @param content - model-facing content blocks.
* @param content - text plus browser-owned temporary image uploads.
* @param mode - 'queue' appends a turn; 'steer' interrupts the running one.
* @returns acceptance, or the business error (also mirrored into snapshot.promptError).
*/
prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>>
prompt(content: PromptContentPart[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>>
/**
* Resolve one durable image referenced by this session.
* @param attachmentId - opaque id found in the folded session log.
* @returns the authenticated reference and decoded bytes.
*/
readAttachment(
attachmentId: AttachmentIdType,
): Promise<RpcResult<{ attachment: ImageAttachmentRef; data: Uint8Array }>>
/**
* Apply one edit, remove, or strict steer action to a still-pending queue occurrence.
* @param itemId - agent-owned inbox occurrence identity.

View File

@@ -27,11 +27,11 @@ export interface IWorkspaces {
*/
startSession(workspaceId?: WorkspaceId): void
/**
* Create a Workspace by name or register an existing path.
* @param input - exactly one Host create spelling.
* Register an existing path as a Workspace.
* @param input - the Host create payload.
* @returns the created or idempotently resolved Workspace.
*/
create(input: { name: string } | { path: string }): Promise<WorkspaceView>
create(input: { path: string }): Promise<WorkspaceView>
/**
* Open the Host's native directory picker.
* @returns the selected path, or null when the user cancelled.

View File

@@ -43,6 +43,8 @@ export type { SessionProvideChannelHost } from './sessions/provide.ts'
export { createScope } from './agents/scope.ts'
export type { AgentScopeHandle } from './agents/scope.ts'
export { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts'
export { bindSettingsScope, SettingsScopeController } from './settings-scope.ts'
export type { SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec } from './settings-scope.ts'
export { resolveWorkspacePath } from './workspaces/path.ts'
export type { Session } from './sessions/session.ts'
export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts'
@@ -181,6 +183,18 @@ declare module 'cordis' {
* @mode emit
*/
'models/changed'(): void
/**
* One session's agent preset changed (host/session-preset-changed
* passthrough), so everything its composition decides — the command
* catalog, the skill catalog — is stale for that session and no other.
* Every connected client observes it, not only the one that issued the
* switch. Subscribers refetch their own session-keyed caches; the frame
* carries no catalog.
* @mode emit
* @param sessionId - the session whose composition changed.
* @param agentPreset - the preset it now runs.
*/
'session/preset-changed'(sessionId: SessionId, agentPreset: string): void
/**
* A connection generation was (re-)established. Wire-derived caches must
* treat their state as stale and repull (commands directory; the queue
@@ -244,6 +258,9 @@ export function apply(ctx: Context): void {
// and model surfaces) subscribe on ctx.
const frame = envelope.payload
if (frame.type === 'host/commands-changed') ctx.emit('commands/changed')
else if (frame.type === 'host/session-preset-changed') {
ctx.emit('session/preset-changed', frame.sessionId, frame.agentPreset)
}
else if (frame.type === 'host/settings-changed') ctx.emit('settings/changed', frame.ns)
else if (frame.type === 'host/credentials-changed') ctx.emit('credentials/changed', frame.ref)
else if (frame.type === 'host/models-changed') ctx.emit('models/changed')

View File

@@ -8,6 +8,7 @@
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
import type {
@@ -43,6 +44,7 @@ export interface AssistantProvenanceView {
export type AssistantBlock =
| { kind: 'text'; text: string }
| { kind: 'reasoning'; text: string }
| { kind: 'image'; attachment: ImageAttachmentRef }
| { kind: 'tool-call'; callId: string; name: string; argsRaw: string }
| { kind: 'other'; block: unknown }
@@ -64,6 +66,7 @@ export function toAssistantBlock(block: ContentBlock): AssistantBlock {
switch (block.type) {
case 'text': return { kind: 'text', text: block.text }
case 'reasoning': return { kind: 'reasoning', text: block.text }
case 'image': return { kind: 'image', attachment: block.attachment }
case 'tool-call': return { kind: 'tool-call', callId: String(block.id), name: block.name, argsRaw: block.arguments }
default: return { kind: 'other', block }
}

View File

@@ -780,6 +780,14 @@ export class SessionManager {
}
return
}
case 'host/session-preset-changed': {
// Every connected client observes the switch here; only the tab that
// issued it also gets the RPC echo. The merge keeps the row's own
// updatedAt and lowers `blank` only, so re-applying the switching
// tab's own frame is a no-op.
this.noteAgentPreset(frame.sessionId, frame.agentPreset)
return
}
case 'host/session-removed': {
const summary = this.summaries.find(candidate => candidate.sessionId === frame.sessionId)
const durableSubagent = summary?.origin === 'subagent' || this.addresses.has(frame.sessionId)
@@ -1005,7 +1013,7 @@ export class SessionManager {
const prev = this.entryCache.get(entry.sessionId)
if (
prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running
&& prev.blank === entry.blank
&& prev.blank === entry.blank && prev.agentPreset === entry.agentPreset
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
&& prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth
&& prev.pendingInteraction === entry.pendingInteraction

View File

@@ -1,10 +1,10 @@
// Sessions remain resident after creation so they continue consuming mux frames off-screen.
import type { Context } from 'cordis'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
HistoryEntry, IApiClient, MessageId, MuxFrame, QueueAction, RpcError,
HistoryEntry, IApiClient, MessageId, MuxFrame, PromptContentPart, QueueAction, RpcError,
RpcId, RpcResponse, RpcResult, SessionId, SubagentAddress, ToolEventView,
} from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
@@ -178,11 +178,11 @@ export class Session implements SessionFace {
/**
* Send (queue/steer passed through 1:1); failures land in the snapshot's promptError.
* @param content - core content blocks verbatim.
* @param content - text plus browser-owned temporary image uploads.
* @param mode - queue appends after the current turn; steer interrupts it.
* @returns the prompt result (also mirrored into promptError on failure).
*/
async prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>> {
async prompt(content: PromptContentPart[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>> {
this.promptError = null
this.lastAgentError = null
// Synchronous, before the first await: the blank → engaging edge must be
@@ -205,8 +205,24 @@ export class Session implements SessionFace {
},
}
} else {
const routed = (await this.api.subagents.prompt({ ...this.address, content })).result
result = routed.ok ? { ok: true, value: { accepted: true } } : routed
if (content.some(part => part.type === 'image')) {
result = {
ok: false,
error: {
code: 'attachment-error',
message: 'Image input is unavailable for subagent continuations.',
details: { reason: 'SUBAGENT_IMAGE_UNSUPPORTED' },
},
}
} else {
const routed = (await this.api.subagents.prompt({
...this.address,
content: content.flatMap(part => part.type === 'text'
? [{ type: 'text' as const, text: part.text }]
: []),
})).result
result = routed.ok ? { ok: true, value: { accepted: true } } : routed
}
}
} catch (error) {
result = transportError(error)
@@ -232,6 +248,28 @@ export class Session implements SessionFace {
return result
}
/**
* Resolve one image referenced by this session into browser-consumable bytes.
* @param attachmentId - opaque id found in the folded session log.
* @returns the authenticated reference and decoded bytes.
*/
async readAttachment(
attachmentId: AttachmentIdType,
): Promise<RpcResult<{ attachment: ImageAttachmentRef; data: Uint8Array }>> {
try {
const result = (await this.api.sessions.attachment({
sessionId: this.sessionId,
attachmentId,
})).result
if (!result.ok) return result
const binary = atob(result.value.data)
const data = Uint8Array.from(binary, char => char.charCodeAt(0))
return { ok: true, value: { attachment: result.value.attachment, data } }
} catch (error) {
return transportError(error)
}
}
/** Apply one operation to a still-pending queue occurrence. */
async updateQueue(itemId: MessageId, action: QueueAction): Promise<RpcResult<{ accepted: true }>> {
try {

View File

@@ -0,0 +1,261 @@
/** Host-backed settings-namespace synchronization for browser plugins. */
import type { Context } from 'cordis'
import type {
ConnectionHandle, IApiClient, SettingsNamespaceView,
} from '@deepseek-ai/dsh-client-connection/client'
import { rehydrateSchema, validateDraft } from '@deepseek-ai/dsh-client-schema-form'
import { createSnapshotStore, type SnapshotStore } from './contract/store.ts'
/** Client-side sync state of one settings namespace. */
export interface SettingsScopeSnapshot<T> {
/**
* `loading` until the first accepted section, `ready` while one stands, and
* `unavailable` when the namespace is not exposed to this client or the
* connection keeps preferences process-local (memory mode).
*/
status: 'loading' | 'ready' | 'unavailable'
/** Last accepted schema-resolved section; undefined before the first acceptance. */
value: T | undefined
/** Namespace revision fencing the next write; undefined before the first Host view. */
revision: number | undefined
/** Whether the Host document accepts writes; memory mode never does. */
writable: boolean
/** `host` syncs with the Host document; `memory` keeps a remote browser process-local. */
mode: 'host' | 'memory'
}
/** Domain-owned description of one settings namespace consumed by a browser plugin. */
export interface SettingsScopeSpec<T> {
/** Settings namespace registered by the owning Host plugin. */
namespace: string
/**
* Narrow one wire section; undefined keeps the last accepted value. The
* default validates the section against the namespace's own serialized wire
* schema, so domains add a decoder only to narrow beyond that schema.
*/
decode?: (section: unknown) => T | undefined
}
/**
* Reactive owner handle over one namespace's durable section — the browser
* mirror of the Host-side `SettingsScope` owner seam. Domain services read
* and observe the snapshot and route explicit user choices through `set`.
*/
export interface SettingsScope<T> {
/** @returns the current sync snapshot (stable reference until the next change). */
getSnapshot(): SettingsScopeSnapshot<T>
/**
* Observe snapshot replacements.
* @param listener - invoked after each snapshot change.
* @returns the disposer removing this listener.
*/
subscribe(listener: () => void): () => void
/**
* Queue one field write. Rapid writes preserve mutation order, each carries
* the latest known namespace revision, and only the latest settlement may
* publish; a rejected or failed latest write reloads Host state instead.
* @param field - scalar field inside the namespace section.
* @param value - JSON-shaped value selected by the user.
* @returns settlement after the write and any latest-write recovery read.
*/
set(field: string, value: unknown): Promise<void>
}
type SettingsFace = Pick<IApiClient, 'settings'>
/**
* Serializes one namespace's Host reads and writes behind a snapshot store.
* Reads never block plugin activation; writes carry the latest known
* namespace revision and teardown waits for the operation already crossing
* the wire.
*/
export class SettingsScopeController<T> implements SettingsScope<T> {
private readonly store: SnapshotStore<SettingsScopeSnapshot<T>>
private tail: Promise<void> = Promise.resolve()
private readGeneration = 0
private writeGeneration = 0
private disposed = false
/**
* @param api - settings wire face.
* @param spec - namespace identity and optional narrowing decoder.
* @param persistence - remote browsers remain process-local because settings RPCs are loopback-only.
*/
constructor(
private readonly api: SettingsFace,
private readonly spec: SettingsScopeSpec<T>,
private readonly persistence: 'host' | 'memory' = 'host',
) {
this.store = createSnapshotStore<SettingsScopeSnapshot<T>>({
status: persistence === 'host' ? 'loading' : 'unavailable',
value: undefined,
revision: undefined,
writable: false,
mode: persistence,
})
}
/** @returns the current sync snapshot (stable reference until the next change). */
getSnapshot(): SettingsScopeSnapshot<T> {
return this.store.getSnapshot()
}
/**
* Observe snapshot replacements.
* @param listener - invoked after each snapshot change.
* @returns the disposer removing this listener.
*/
subscribe(listener: () => void): () => void {
return this.store.subscribe(listener)
}
/**
* Queue a Host refresh; a newer read or user write suppresses stale publication.
* @returns settlement after the queued read completes or is skipped.
*/
load(): Promise<void> {
const generation = ++this.readGeneration
return this.enqueue(() => this.read(generation))
}
/**
* Queue one field write; see {@link SettingsScope.set} for the ordering,
* revision, and recovery contract.
* @param field - scalar field inside the namespace section.
* @param value - JSON-shaped value selected by the user.
* @returns settlement after the write and any latest-write recovery read.
*/
set(field: string, value: unknown): Promise<void> {
this.readGeneration += 1
const generation = ++this.writeGeneration
return this.enqueue(async () => {
const revision = this.getSnapshot().revision
let response: Awaited<ReturnType<SettingsFace['settings']['mutate']>>
try {
response = await this.api.settings.mutate({
ns: this.spec.namespace,
ops: [{ op: 'set', path: [field], value }],
...(revision === undefined ? {} : { expectedRevision: revision }),
})
} catch (_settingsWriteFailure) {
if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration)
return
}
if (!response.result.ok) {
if (!this.disposed && generation === this.writeGeneration) await this.read(++this.readGeneration)
return
}
this.accept(response.result.value, generation === this.writeGeneration)
})
}
/**
* Stop queued operations and wait for the current wire call to settle.
* @returns settlement after the controller reaches quiescence.
*/
async dispose(): Promise<void> {
this.disposed = true
this.readGeneration += 1
this.writeGeneration += 1
await this.tail
}
private enqueue(operation: () => Promise<void>): Promise<void> {
if (this.persistence === 'memory' || this.disposed) return Promise.resolve()
const task = this.tail.then(async () => {
if (this.disposed) return
await operation()
})
// The returned task carries its own settlement to the caller; the queue
// tail is kept fulfilled so one failed subscriber cannot strand later operations.
this.tail = task.catch(() => {})
return task
}
private async read(generation: number): Promise<void> {
let response: Awaited<ReturnType<SettingsFace['settings']['describe']>>
try {
response = await this.api.settings.describe({})
} catch (_settingsReadFailure) {
return
}
if (!response.result.ok || this.disposed) return
const { namespaces, writable } = response.result.value
const view = namespaces.find(candidate => candidate.ns === this.spec.namespace)
const publish = generation === this.readGeneration
if (view === undefined) {
if (publish) {
this.store.update((draft) => {
draft.status = 'unavailable'
draft.writable = writable
})
}
return
}
this.accept(view, publish, writable)
}
private accept(view: SettingsNamespaceView, publish: boolean, writable?: boolean): void {
const decoded = publish ? this.decode(view) : undefined
this.store.update((draft) => {
draft.revision = view.revision
if (writable !== undefined) draft.writable = writable
if (decoded === undefined) return
draft.status = 'ready'
draft.value = decoded
})
}
private decode(view: SettingsNamespaceView): T | undefined {
if (this.spec.decode !== undefined) return this.spec.decode(view.value)
// Sections are plain objects by construction; schemastery alone would
// resolve null or an array through object defaults instead of refusing.
if (typeof view.value !== 'object' || view.value === null || Array.isArray(view.value)) return undefined
let failure: string | undefined
try {
failure = validateDraft(rehydrateSchema(view.schema), view.value)
} catch (_malformedSchemaEnvelope) {
// A schema envelope this client cannot rehydrate vouches for no section;
// the value is treated exactly like a schema-invalid one.
return undefined
}
return failure === undefined ? view.value as T : undefined
}
}
/**
* Bind one namespace scope to settings and connection invalidations on the
* caller's plugin lifecycle. Listeners exist before the initial background
* read starts, so activation never blocks on the settings transport.
* @param ctx - owning browser plugin context.
* @param spec - domain-owned namespace contract.
* @returns the bound scope consumed by the domain's services and rows.
*/
export function bindSettingsScope<T>(
ctx: Context,
spec: SettingsScopeSpec<T>,
): SettingsScope<T> {
const connection = ctx.get('connection') as ConnectionHandle
const controller = new SettingsScopeController<T>(
connection.api,
spec,
connection.isLoopback ? 'host' : 'memory',
)
ctx.effect(() => {
const refresh = (namespace?: string): void => {
if (namespace !== undefined && namespace !== spec.namespace) return
void controller.load()
}
const disposers = [
ctx.on('settings/changed', refresh),
ctx.on('connection/reset', () => { refresh() }),
]
void controller.load()
return async () => {
for (const dispose of disposers) dispose()
await controller.dispose()
}
}, `runtime: ${spec.namespace} settings scope`)
return controller
}

View File

@@ -120,7 +120,7 @@ export class WorkspaceManager {
/**
* Create or resolve a real Workspace, then publish its returned snapshot
* without waiting for the changed frame.
* @param input - name under workspaceRoot or an existing absolute path.
* @param input - the existing absolute path to adopt.
* @returns the wire result.
*/
async create(input: WorkspaceCreateInput): Promise<RpcResult<{ workspace: WorkspaceView; created: boolean }>> {

View File

@@ -186,11 +186,11 @@ export class WorkspacesService implements IWorkspaces {
}
/**
* Create a Workspace by name or register an existing path.
* @param input - exactly one Host create spelling.
* Register an existing path as a Workspace.
* @param input - the Host create payload.
* @returns the created or idempotently resolved Workspace.
*/
async create(input: { name: string } | { path: string }): Promise<WorkspaceView> {
async create(input: { path: string }): Promise<WorkspaceView> {
const result = await this.manager.create(input)
if (!result.ok) throw new WorkspaceCreateError(result.error)
return result.value.workspace

View File

@@ -8,7 +8,7 @@ import type { ObservableSnapshot } from '../contract/store.ts'
import { Notifier } from '../sessions/notifier.ts'
/** Host input retained by a local Workspace until materialization succeeds. */
export type WorkspaceCreateInput = { name: string } | { path: string }
export type WorkspaceCreateInput = { path: string }
/** Observable state of a client-local Workspace intent. */
export interface WorkspaceIntentSnapshot {
@@ -137,7 +137,6 @@ export class Workspace implements ObservableSnapshot<WorkspaceSnapshot> {
}
function intentName(input: WorkspaceCreateInput): string {
if ('name' in input) return input.name
const trimmed = input.path.replace(/[\\/]+$/, '')
return trimmed.split(/[\\/]/).pop() ?? input.path
}

View File

@@ -1,22 +1,30 @@
/** Assistant block classifier (moved here with sessions/conversation.ts). */
import { describe, expect, it } from 'vitest'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import type { ContentBlock } from '@deepseek-ai/dsh-client-connection/client'
import { toAssistantBlock, toAssistantBlocks } from '../src/client/sessions/conversation.ts'
describe('toAssistantBlock', () => {
it('classifies the four block shapes', () => {
const attachment = {
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
mediaType: 'image/png' as const,
bytes: 68,
width: 1,
height: 1,
}
const blocks: ContentBlock[] = [
{ type: 'text', text: '正文' },
{ type: 'reasoning', text: '思考' },
{ type: 'tool-call', id: 'c1', name: 'echo', arguments: '{}' } as ContentBlock,
{ type: 'image', data: 'x' } as unknown as ContentBlock,
{ type: 'image', attachment },
]
expect(toAssistantBlocks(blocks)).toEqual([
{ kind: 'text', text: '正文' },
{ kind: 'reasoning', text: '思考' },
{ kind: 'tool-call', callId: 'c1', name: 'echo', argsRaw: '{}' },
{ kind: 'other', block: blocks[3] },
{ kind: 'image', attachment },
])
expect(toAssistantBlock(blocks[0] as ContentBlock)).toEqual({ kind: 'text', text: '正文' })
})

View File

@@ -85,6 +85,8 @@ export class FakeApiClient implements IApiClient {
Promise<RpcResponse<{ selected: ModelSelection }>> =
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onAttachment: (payload: unknown) => Promise<RpcResponse<{ attachment: { attachmentId: never; mediaType: 'image/png'; bytes: number; width: number; height: number }; data: string }>> =
() => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' }))
onUpdateQueue: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
@@ -129,6 +131,7 @@ export class FakeApiClient implements IApiClient {
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)),
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
attachment: (payload: unknown) => this.record('session.attachment', payload, this.onAttachment(payload)),
updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
}

View File

@@ -531,6 +531,21 @@ describe('prompt and cancel errors', () => {
expect(result.ok).toBe(false)
expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'internal' } })
})
it('reads session-authorized attachment bytes and keeps the opaque id on the wire', async () => {
const { api, session } = makeSession()
const result = await session.readAttachment('attachment-1' as never)
expect(result).toEqual({
ok: true,
value: {
attachment: { attachmentId: 'a', mediaType: 'image/png', bytes: 1, width: 1, height: 1 },
data: Uint8Array.of(0),
},
})
expect(api.callsOf('session.attachment')).toEqual([{
sessionId: SID, attachmentId: 'attachment-1',
}])
})
})
describe('rename', () => {

View File

@@ -35,6 +35,7 @@ type FeedRow = {
origin?: 'subagent'
running?: boolean
blank?: boolean
agentPreset?: string
}
async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
@@ -44,6 +45,7 @@ async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
...(r.parentId !== undefined ? { parentSessionId: sid(r.parentId) } : {}),
...(r.origin !== undefined ? { origin: r.origin } : {}),
...(r.agentPreset !== undefined ? { agentPreset: r.agentPreset } : {}),
})),
}) as never)
await b.svc.refresh()
@@ -70,6 +72,38 @@ describe('list store projection', () => {
expect(state.byId[sid('s2')]?.title).toBeUndefined()
})
it('reprojects a blank session whose composition switched and nothing else moved', async () => {
const b = bench()
await feedList(b, [{ id: 's1', blank: true, agentPreset: 'standard' }])
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('standard')
// A confirmed switch moves the preset alone: the row keeps its updatedAt,
// title, running, and blank bits, so an identity guard blind to the preset
// would serve the old row forever — and every reader (the hero chip's own
// no-op check, the header label) would keep the composition it replaced.
b.svc.noteAgentPreset(sid('s1'), 'minimal')
await Promise.resolve()
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal')
})
it('learns a preset switch from the host frame, not only from the tab that issued it', async () => {
const b = bench()
await feedList(b, [{ id: 's1', blank: true, agentPreset: 'standard' }])
// Every connected client gets this frame; only the switching tab gets the
// RPC echo. A client that ignored the payload would keep labelling the
// session with the composition it replaced.
b.svc.handleHostEnvelope({
rpcId: 'r1' as never,
payload: { type: 'host/session-preset-changed', sessionId: sid('s1'), agentPreset: 'minimal' } as never,
})
await Promise.resolve()
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal')
expect(b.svc.list.getSnapshot().byId[sid('s1')]?.blank).toBe(true)
})
it('reflects live increments (host stream via manager) into the store', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])

View File

@@ -0,0 +1,352 @@
import { Context } from 'cordis'
import z from 'schemastery'
import { describe, expect, it, vi } from 'vitest'
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
import {
bindSettingsScope, SettingsScopeController, type SettingsScope,
} from '../src/client/settings-scope.ts'
interface UiTestSettings {
preference: 'light' | 'dark' | 'system'
}
const ENVELOPE = z.object({
preference: z.union(['light', 'dark', 'system']).default('system'),
}).toJSON()
let rpc = 0
function ok<T>(value: T): RpcResponse<T> {
return { rpcId: `scope-${rpc++}` as never, result: { ok: true, value } }
}
function rejected<T>(): RpcResponse<T> {
return {
rpcId: `scope-${rpc++}` as never,
result: {
ok: false,
error: { code: 'settings-rejected', message: 'conflict', details: { ns: 'ui-test' } },
},
}
}
function view(value: unknown, revision = 0): SettingsNamespaceView {
return {
ns: 'ui-test',
schema: ENVELOPE,
value,
applies: 'live',
secrets: [],
revision,
}
}
function described(value: unknown, revision = 0) {
return ok({ writable: true, hasDocument: true, namespaces: [view(value, revision)] })
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
return { promise, resolve, reject }
}
/** Record each distinct published section, starting from the current one. */
function trackValues(scope: SettingsScope<UiTestSettings>): Array<UiTestSettings | undefined> {
const seen: Array<UiTestSettings | undefined> = [scope.getSnapshot().value]
scope.subscribe(() => {
const value = scope.getSnapshot().value
if (value !== seen[seen.length - 1]) seen.push(value)
})
return seen
}
describe('SettingsScopeController', () => {
it('starts loading and publishes a schema-valid section with revision and writability', async () => {
const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'dark' }, 3))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{ namespace: 'ui-test' },
)
expect(scope.getSnapshot()).toEqual({
status: 'loading', value: undefined, revision: undefined, writable: false, mode: 'host',
})
await scope.load()
expect(scope.getSnapshot()).toEqual({
status: 'ready', value: { preference: 'dark' }, revision: 3, writable: true, mode: 'host',
})
})
it('keeps the last good value across invalid, rejected, and failed reads while tracking revisions', async () => {
const describeCall = vi.fn()
.mockResolvedValueOnce(described({ preference: 'dark' }, 3))
.mockResolvedValueOnce(described({ preference: 'sepia' }, 4))
.mockResolvedValueOnce(described(null, 5))
.mockResolvedValueOnce(described('scalar', 6))
.mockResolvedValueOnce(described(['queue'], 7))
.mockResolvedValueOnce(rejected())
.mockRejectedValueOnce(new Error('offline'))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{ namespace: 'ui-test' },
)
const good = trackValues(scope)
for (let i = 0; i < 7; i++) await scope.load()
expect(scope.getSnapshot()).toMatchObject({
status: 'ready', value: { preference: 'dark' }, revision: 7,
})
expect(good).toEqual([undefined, { preference: 'dark' }])
})
it('treats a schema envelope it cannot rehydrate as vouching for no section', async () => {
const broken = { ...view({ preference: 'dark' }, 2), schema: null }
const describeCall = vi.fn()
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [broken] }))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{ namespace: 'ui-test' },
)
await scope.load()
expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 2 })
})
it('suppresses a superseded read of an unexposed namespace', async () => {
const describeCall = vi.fn()
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] }))
.mockResolvedValueOnce(described({ preference: 'dark' }, 1))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{ namespace: 'ui-test' },
)
const statuses: string[] = []
scope.subscribe(() => { statuses.push(scope.getSnapshot().status) })
const stale = scope.load()
const fresh = scope.load()
await Promise.all([stale, fresh])
expect(statuses).not.toContain('unavailable')
expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' } })
})
it('reports an unexposed namespace as unavailable and recovers when it reappears', async () => {
const describeCall = vi.fn()
.mockResolvedValueOnce(described({ preference: 'light' }, 1))
.mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [] }))
.mockResolvedValueOnce(described({ preference: 'system' }, 2))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{ namespace: 'ui-test' },
)
await scope.load()
expect(scope.getSnapshot().status).toBe('ready')
await scope.load()
expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', value: { preference: 'light' } })
await scope.load()
expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'system' }, revision: 2 })
})
it('applies a custom decode override in place of the wire schema', async () => {
const describeCall = vi.fn()
.mockResolvedValueOnce(described({ preference: 'light' }, 1))
.mockResolvedValueOnce(described({ preference: 'dark' }, 2))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{
namespace: 'ui-test',
decode: section => (section as UiTestSettings).preference === 'dark'
? section as UiTestSettings
: undefined,
},
)
await scope.load()
expect(scope.getSnapshot()).toMatchObject({ status: 'loading', value: undefined, revision: 1 })
await scope.load()
expect(scope.getSnapshot()).toMatchObject({ status: 'ready', value: { preference: 'dark' }, revision: 2 })
})
it('serializes rapid set writes, carries revisions, and publishes only the latest settlement', async () => {
const first = deferred<RpcResponse<SettingsNamespaceView>>()
const describeCall = vi.fn().mockResolvedValue(described({ preference: 'system' }, 4))
const mutate = vi.fn()
.mockReturnValueOnce(first.promise)
.mockResolvedValueOnce(ok(view({ preference: 'light' }, 6)))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall, mutate } } as never,
{ namespace: 'ui-test' },
)
const published = trackValues(scope)
await scope.load()
const dark = scope.set('preference', 'dark')
const light = scope.set('preference', 'light')
await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
first.resolve(ok(view({ preference: 'dark' }, 5)))
await Promise.all([dark, light])
expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light'])
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 6 })
expect(mutate).toHaveBeenNthCalledWith(1, {
ns: 'ui-test',
ops: [{ op: 'set', path: ['preference'], value: 'dark' }],
expectedRevision: 4,
})
expect(mutate).toHaveBeenNthCalledWith(2, {
ns: 'ui-test',
ops: [{ op: 'set', path: ['preference'], value: 'light' }],
expectedRevision: 5,
})
})
it('recovers the latest rejected or thrown write from Host state', async () => {
const describeCall = vi.fn()
.mockResolvedValueOnce(described({ preference: 'system' }, 2))
.mockResolvedValueOnce(described({ preference: 'light' }, 3))
const mutate = vi.fn()
.mockResolvedValueOnce(rejected())
.mockRejectedValueOnce(new Error('offline'))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall, mutate } } as never,
{ namespace: 'ui-test' },
)
const published = trackValues(scope)
await scope.set('preference', 'dark')
await scope.set('preference', 'system')
expect(published.map(section => section?.preference)).toEqual([undefined, 'system', 'light'])
})
it('does not recover superseded rejected or thrown writes', async () => {
const describeCall = vi.fn()
const mutate = vi.fn()
.mockResolvedValueOnce(rejected())
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValueOnce(ok(view({ preference: 'light' }, 3)))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall, mutate } } as never,
{ namespace: 'ui-test' },
)
const published = trackValues(scope)
await Promise.all([
scope.set('preference', 'dark'),
scope.set('preference', 'system'),
scope.set('preference', 'light'),
])
expect(describeCall).not.toHaveBeenCalled()
expect(published.map(section => section?.preference)).toEqual([undefined, 'light'])
})
it('keeps the write queue usable when a subscriber throws', async () => {
const describeCall = vi.fn()
.mockResolvedValueOnce(described({ preference: 'dark' }, 1))
.mockResolvedValueOnce(described({ preference: 'light' }, 2))
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall } } as never,
{ namespace: 'ui-test' },
)
let thrown = false
scope.subscribe(() => {
if (thrown) return
thrown = true
throw new Error('subscriber failed')
})
await expect(scope.load()).rejects.toThrow('subscriber failed')
await expect(scope.load()).resolves.toBeUndefined()
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 2 })
})
it('cancels queued and post-dispose writes while draining the in-flight mutation', async () => {
const first = deferred<RpcResponse<SettingsNamespaceView>>()
const mutate = vi.fn().mockReturnValue(first.promise)
const describeCall = vi.fn()
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall, mutate } } as never,
{ namespace: 'ui-test' },
)
const published = trackValues(scope)
const dark = scope.set('preference', 'dark')
await vi.waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
const light = scope.set('preference', 'light')
let stopped = false
const stop = scope.dispose().then(() => { stopped = true })
await Promise.resolve()
expect(stopped).toBe(false)
first.resolve(ok(view({ preference: 'dark' }, 1)))
await Promise.all([dark, light, stop])
await scope.set('preference', 'system')
await scope.load()
expect(mutate).toHaveBeenCalledOnce()
expect(describeCall).not.toHaveBeenCalled()
expect(published).toEqual([undefined])
})
it('keeps a remote browser in memory mode without Host calls', async () => {
const describeCall = vi.fn()
const mutate = vi.fn()
const scope = new SettingsScopeController<UiTestSettings>(
{ settings: { describe: describeCall, mutate } } as never,
{ namespace: 'ui-test' },
'memory',
)
expect(scope.getSnapshot()).toEqual({
status: 'unavailable', value: undefined, revision: undefined, writable: false, mode: 'memory',
})
await scope.load()
await scope.set('preference', 'dark')
await scope.dispose()
expect(describeCall).not.toHaveBeenCalled()
expect(mutate).not.toHaveBeenCalled()
})
})
describe('bindSettingsScope', () => {
it('subscribes before the initial read and converges to the latest queued invalidation', async () => {
const initial = deferred<ReturnType<typeof described>>()
const describeCall = vi.fn()
.mockReturnValueOnce(initial.promise)
.mockResolvedValueOnce(described({ preference: 'light' }, 2))
.mockResolvedValueOnce(described({ preference: 'system' }, 3))
const ctx = new Context()
ctx.provide('connection', {
api: { settings: { describe: describeCall } },
isLoopback: true,
} as never)
let scope!: SettingsScope<UiTestSettings>
const fiber = ctx.plugin({
inject: ['connection'],
apply: (plugin: Context) => {
scope = bindSettingsScope<UiTestSettings>(plugin, { namespace: 'ui-test' })
},
})
await fiber.await()
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledOnce() })
ctx.emit('settings/changed', 'unrelated')
ctx.emit('settings/changed', 'ui-test')
ctx.emit('connection/reset')
initial.resolve(described({ preference: 'dark' }, 1))
await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(3) })
await vi.waitFor(() => {
expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'system' }, revision: 3 })
})
await fiber.dispose()
ctx.emit('settings/changed', 'ui-test')
await Promise.resolve()
expect(describeCall).toHaveBeenCalledTimes(3)
})
it('binds a remote browser in memory mode without starting a settings read', async () => {
const describeCall = vi.fn()
const ctx = new Context()
ctx.provide('connection', {
api: { settings: { describe: describeCall } },
isLoopback: false,
} as never)
let scope!: SettingsScope<UiTestSettings>
const fiber = ctx.plugin({
inject: ['connection'],
apply: (plugin: Context) => {
scope = bindSettingsScope<UiTestSettings>(plugin, { namespace: 'ui-test' })
},
})
await fiber.await()
expect(scope.getSnapshot()).toMatchObject({ status: 'unavailable', mode: 'memory', writable: false })
await fiber.dispose()
expect(describeCall).not.toHaveBeenCalled()
})
})

View File

@@ -1,6 +1,7 @@
/**
* Wire-to-typed-event bridge: host/commands-changed
* → ctx 'commands/changed'; each established connection generation
* → ctx 'commands/changed'; host/session-preset-changed
* ctx 'session/preset-changed'; each established connection generation →
* ctx 'connection/reset' (the forced cache-invalidation broadcast).
*/
import { Context } from 'cordis'
@@ -67,6 +68,17 @@ describe('wire event bridge', () => {
])
})
it('broadcasts session/preset-changed with the recomposed session and its new preset', async () => {
const bench = await mount()
const seen: Array<[string, string]> = []
bench.ctx.on('session/preset-changed', (sessionId, agentPreset) => { seen.push([sessionId, agentPreset]) })
bench.sinks?.onHostEnvelope?.({
rpcId: 'r1' as never,
payload: { type: 'host/session-preset-changed', sessionId: 's1' as never, agentPreset: 'minimal' },
})
expect(seen).toEqual([['s1', 'minimal']])
})
it('broadcasts connection/reset on every established generation (reconnect invalidation)', async () => {
const bench = await mount()
let resets = 0

View File

@@ -59,7 +59,7 @@ describe('WorkspaceManager', () => {
expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'error', error: { message: 'wire down' } })
})
it('creates by name/path, prepends a new row, and folds failures', async () => {
it('creates by path, prepends a new row, and folds failures', async () => {
const api = new FakeApiClient()
const manager = new WorkspaceManager(api)
api.onWorkspaceCreate = payload => Promise.resolve(ok({
@@ -67,8 +67,8 @@ describe('WorkspaceManager', () => {
created: true,
payload,
} as never))
await expect(manager.create({ name: 'created' })).resolves.toMatchObject({ ok: true })
expect(api.callsOf('workspace.create')).toEqual([{ name: 'created' }])
await expect(manager.create({ path: '/w/created' })).resolves.toMatchObject({ ok: true })
expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/created' }])
expect(manager.getSnapshot().items[0]?.workspaceId).toBe('created')
api.onWorkspaceCreate = () => Promise.reject(new Error('create transport'))

View File

@@ -8,6 +8,9 @@
"src"
],
"references": [
{
"path": "../../attachment/attachment"
},
{
"path": "../../../vendor/cordis"
},
@@ -20,6 +23,9 @@
{
"path": "../connection"
},
{
"path": "../schema-form"
},
{
"path": "../../host/apiproxy"
},

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/test-runtime/README.md
README.md: 74da8fde7fd9cc3733d2d1ae03dd3d213e4d553e
README.zh.md: 1df28f7b25c35333e91476e10480c22a728cdab3
README.md: 455d6f564cea2cb8f88165a8bba1047c762d2fb0
README.zh.md: 7c4bd0e552c71f55e3766a0c64580cc178461310

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
jsdom slot test runtime for client feature specs: a real Cordis `Context`, the production `SlotsService` and web-react renderer, assembled around typed session/workspace doubles. Feature suites exercise declaration, registration, scope, store, inject, rendering, updates, and disposal without hand-building the machinery per suite — and without a second implementation of any production logic.
The doubles implement the same outward faces features receive through ctx (`TestSessions implements ISessions`, `TestWorkspaces implements IWorkspaces`; each fixture session is a `FixtureSession implements SessionFace`), so a production face change breaks the bench at compile time instead of silently drifting. Provide-bundle materialization runs the production `SessionProvideChannel` — the one implementation shared with `SessionsService`. Fixtures feed plain data: list rows, conversation snapshots (immer-patched via `updateSnapshot`), projection values, and `ISession`-typed behavior stubs that fail loud when a spec calls an unstubbed verb. The typed `provide()` constrains fakes for declared service names to `Partial` of that service's outward face.
The doubles implement the same outward faces features receive through ctx (`TestSessions implements ISessions`, `TestWorkspaces implements IWorkspaces`; each fixture session is a `FixtureSession implements SessionFace`; `stubSettingsScope` is a `SettingsScope` with test-driven publications and a write spy), so a production face change breaks the bench at compile time instead of silently drifting. Provide-bundle materialization runs the production `SessionProvideChannel` — the one implementation shared with `SessionsService`. Fixtures feed plain data: list rows, conversation snapshots (immer-patched via `updateSnapshot`), projection values, and `ISession`-typed behavior stubs that fail loud when a spec calls an unstubbed verb. The typed `provide()` constrains fakes for declared service names to `Partial` of that service's outward face.
Local DOM snapshots: `declare(children)` registers an auto frame whose per-key `<div data-slot>` wrappers are snapshot roots; `renderSlot(key, owner)` returns the slot-local view (container, scoped Testing Library queries, in-place `update(owner)`); a registered snapshot serializer folds CSS-module class hashes (`_frame_a1b2c3``frame`) to keep `.snap` files structural and collapses `<svg>` internals to a `data-content` fingerprint. Suites needing a custom page frame use `root.declare(children, Frame)` instead; `mount(plugin)` runs a real fiber with fail-loud service prechecks, and `dispose()` tears down views, feature fibers, minted scopes, and persisted store state on one axis.

View File

@@ -4,7 +4,7 @@
面向客户端功能测试的 jsdom slot 测试运行时:真实 Cordis `Context`、生产 `SlotsService` 与 web-react 渲染器,围绕带类型的 session/workspace 测试替身组装。功能套件无需逐套件手搭机器即可测遍声明、注册、scope、store、inject、渲染、更新与销毁——且不存在任何生产逻辑的第二份实现。
替身实现的正是功能通过 ctx 获得的对外接口(`TestSessions implements ISessions``TestWorkspaces implements IWorkspaces`;每个 fixture session 是 `FixtureSession implements SessionFace`生产面一旦改形测试台在编译期即断而非静默漂移。provide bundle 材料化直接运行生产 `SessionProvideChannel`——与 `SessionsService` 共用同一份实现。fixture 灌入的是普通数据:列表行、会话快照(经 `updateSnapshot` 以 immer 补丁改写、projection 值,以及按 `ISession` 取型的行为桩——spec 调用未打桩的动词时报错自明。带类型的 `provide()` 将已声明服务名的 fake 约束为该服务对外面的 `Partial` 子集。
替身实现的正是功能通过 ctx 获得的对外接口(`TestSessions implements ISessions``TestWorkspaces implements IWorkspaces`;每个 fixture session 是 `FixtureSession implements SessionFace``stubSettingsScope` 是发布由测试驱动、带写入 spy 的 `SettingsScope`生产面一旦改形测试台在编译期即断而非静默漂移。provide bundle 材料化直接运行生产 `SessionProvideChannel`——与 `SessionsService` 共用同一份实现。fixture 灌入的是普通数据:列表行、会话快照(经 `updateSnapshot` 以 immer 补丁改写、projection 值,以及按 `ISession` 取型的行为桩——spec 调用未打桩的动词时报错自明。带类型的 `provide()` 将已声明服务名的 fake 约束为该服务对外面的 `Partial` 子集。
局部 DOM 快照:`declare(children)` 注册自动 frame逐 key 的 `<div data-slot>` 包裹层即快照根;`renderSlot(key, owner)` 返回该 slot 的局部视图container、限定范围的 Testing Library 查询、原位 `update(owner)`);注册的快照序列化器把 CSS-module 哈希类名折回语义名(`_frame_a1b2c3``frame`)保持 `.snap` 只含结构,并把 `<svg>` 内部折叠为 `data-content` 指纹。需要自定义页面 frame 的套件改用 `root.declare(children, Frame)``mount(plugin)` 在真实 fiber 上运行并对缺失服务先行报错;`dispose()` 沿单一轴拆除视图、feature fiber、已铸 scope 与持久化 store 状态。

View File

@@ -36,6 +36,8 @@ import type { Stabilizer } from './fixtures.ts'
export { domSnapshotSerializer, registerDomSnapshotSerializer } from './snapshot.ts'
export { FixtureSession, TestSessions } from './sessions.ts'
export { stubSettingsScope } from './settings-scope.ts'
export type { StubSettingsScope } from './settings-scope.ts'
export { TestWorkspaces } from './workspaces.ts'
export { conversationSnapshot, workspaceListState } from './fixtures.ts'
export type { SessionBehaviorOverrides, SessionFixture, Stabilizer } from './fixtures.ts'

Some files were not shown because too many files have changed in this diff Show More