Merge remote-tracking branch 'origin/master' into xtr/sidebar-workspace-controls
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/README.md
|
||||
README.md: 257ed4647c74ef45973d717278cdaa420968b3f6
|
||||
README.zh.md: 03cd02510267d3abdd414bed6ec1f42773d0811a
|
||||
README.md: aea083505cf84207a12086361e5d7f41176c0241
|
||||
README.zh.md: 013806e802f524b34757bb2de073625eb8b0f768
|
||||
|
||||
@@ -46,7 +46,7 @@ Groups hold `packages/<group>/<pkg>/`; names stay `@deepseek-ai/dsh-<pkg>`. **Gr
|
||||
| [`credentials/`](credentials/README.md) | Credential-reference seam + env-over-`.env` provider | Product — stable API |
|
||||
| [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable API |
|
||||
| [`workspace/`](workspace/README.md) | Workspace entity | Product — stable API |
|
||||
| [`scaffold/`](scaffold/README.md) | Create/launch/drive project tooling: helper, launcher, initializer, wire protocol with both ends, launcher telemetry | Product — stable API |
|
||||
| [`sdk/`](sdk/README.md) | Out-of-process runtime SDK: JSON-RPC protocol, TypeScript client, and server plugin | Product — stable API |
|
||||
| [`acp/`](acp/README.md) | Automation-only Agent Client Protocol server | Product — stable API |
|
||||
| [`interaction/`](interaction/README.md) | Human-collaboration plane: approval/interaction seams, permission preset, commands, ask-user tool | Product — stable API |
|
||||
| [`boot/`](boot/README.md) | Shared app-bin boot glue | Product — stable API |
|
||||
|
||||
@@ -46,7 +46,7 @@ npm scope 为 `@deepseek-ai/dsh-*`;Cordis `Service` 子类和函数插件通
|
||||
| [`credentials/`](credentials/README.md) | 凭据引用 seam + 环境叠加 `.env` 提供方 | 产品:稳定接口 |
|
||||
| [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定接口 |
|
||||
| [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定接口 |
|
||||
| [`scaffold/`](scaffold/README.md) | 创建/启动/驱动项目的工具:helper、启动器、初始化器、带两端的通信协议、启动器 telemetry | 产品:稳定接口 |
|
||||
| [`sdk/`](sdk/README.md) | 进程外运行时 SDK:JSON-RPC 协议、TypeScript 客户端和服务器插件 | 产品:稳定接口 |
|
||||
| [`acp/`](acp/README.md) | 仅面向自动化的 Agent Client Protocol 服务器 | 产品:稳定接口 |
|
||||
| [`interaction/`](interaction/README.md) | 人机协作平面:批准/交互 seam、权限预设、命令、用户问答工具 | 产品:稳定接口 |
|
||||
| [`boot/`](boot/README.md) | 共享的 app bin 启动粘合层 | 产品:稳定接口 |
|
||||
|
||||
@@ -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/attachment/attachment-local/README.md
|
||||
README.md: 80001b29b392fe1c8b663f46d47f1ec0726e6d0f
|
||||
README.zh.md: c3b95ace06b9f5ada156f20f33a1740a235400aa
|
||||
README.md: ba0b9efb2cf51bfef671020bed4a2c16f6ee0119
|
||||
README.zh.md: 8e2474357a0dbb5e8834a3b25de7a977827a29e3
|
||||
|
||||
@@ -4,7 +4,7 @@ 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.
|
||||
`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. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `<DSH_HOME>/attachments/v1/objects/<sha256-prefix>/<sha256>`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会通过将每个祖先目录项逐级同步到文件系统根目录,为某个 home 一次性证明其持久性,因此绝不会把另一个进程已经创建但尚未同步的目录误认为安全边界。随后,写入过程使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。写入准入与读取都会完整解码光栅图片,之后才接受其格式和尺寸;读取还会重新校验摘要和已记录的元数据。字节和像素限制属于写入时的准入策略,因此后续收紧限制不会导致已经接纳的历史记录变得不可读。
|
||||
|
||||
`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。
|
||||
`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -68,8 +68,8 @@ export class LocalAttachmentStore extends AttachmentStore {
|
||||
return saveImageFile(this.root, input, this.imageLimits)
|
||||
}
|
||||
|
||||
async readImage(ref: ImageAttachmentRef): Promise<StoredImageAttachment> {
|
||||
return readImageFile(this.root, ref)
|
||||
async readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise<StoredImageAttachment> {
|
||||
return readImageFile(this.root, ref, signal)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -197,22 +197,32 @@ export async function saveImageFile(root: string, input: SaveImageAttachment, li
|
||||
* Read and verify one content-addressed image.
|
||||
* @param root - absolute `DSH_HOME/attachments/v1` root.
|
||||
* @param ref - reference recorded in the session log.
|
||||
* @param signal - optional cancellation for filesystem and verification work.
|
||||
* @returns verified bytes and reference.
|
||||
* @throws the signal reason when aborted, or an AttachmentError when verification fails.
|
||||
*/
|
||||
export async function readImageFile(root: string, ref: ImageAttachmentRef): Promise<StoredImageAttachment> {
|
||||
export async function readImageFile(
|
||||
root: string,
|
||||
ref: ImageAttachmentRef,
|
||||
signal?: AbortSignal,
|
||||
): Promise<StoredImageAttachment> {
|
||||
signal?.throwIfAborted()
|
||||
const sha256 = ensureReference(ref)
|
||||
let data: Uint8Array
|
||||
try {
|
||||
data = new Uint8Array(await readFile(objectPath(root, sha256)))
|
||||
data = new Uint8Array(await readFile(objectPath(root, sha256), { signal }))
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted()
|
||||
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 })
|
||||
}
|
||||
signal?.throwIfAborted()
|
||||
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)
|
||||
signal?.throwIfAborted()
|
||||
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')
|
||||
|
||||
@@ -9,12 +9,23 @@ 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[] }))
|
||||
const fsControl = vi.hoisted(() => ({
|
||||
readSignals: [] as AbortSignal[],
|
||||
syncedDirectories: [] as string[],
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return {
|
||||
...actual,
|
||||
readFile(...args: Parameters<typeof actual.readFile>): ReturnType<typeof actual.readFile> {
|
||||
const options = args[1]
|
||||
if (typeof options === 'object' && options !== null) {
|
||||
const signal = (options as { signal?: AbortSignal }).signal
|
||||
if (signal !== undefined) fsControl.readSignals.push(signal)
|
||||
}
|
||||
return actual.readFile(...args)
|
||||
},
|
||||
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)
|
||||
@@ -130,6 +141,20 @@ describe('local attachment store', () => {
|
||||
await expect(readImageFile(storageRoot, ref)).resolves.toEqual({ ref, data: PNG })
|
||||
})
|
||||
|
||||
it('forwards read cancellation to the filesystem and preserves its reason', async () => {
|
||||
const storageRoot = await root()
|
||||
const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)
|
||||
const controller = new AbortController()
|
||||
fsControl.readSignals.length = 0
|
||||
|
||||
await expect(readImageFile(storageRoot, ref, controller.signal)).resolves.toEqual({ ref, data: PNG })
|
||||
expect(fsControl.readSignals).toEqual([controller.signal])
|
||||
|
||||
const cancellation = new Error('attachment read cancelled')
|
||||
controller.abort(cancellation)
|
||||
await expect(readImageFile(storageRoot, ref, controller.signal)).rejects.toBe(cancellation)
|
||||
})
|
||||
|
||||
it('rejects malformed bytes, mismatched declarations, byte limits, and decoded-pixel limits', async () => {
|
||||
const storageRoot = await root()
|
||||
await expect(saveImageFile(storageRoot, {
|
||||
|
||||
@@ -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/attachment/attachment/README.md
|
||||
README.md: 4f450316294e554396adb9a8454051a08d9befd3
|
||||
README.zh.md: fe51b0003cdf1659c7c56106b97c6f3139ebe890
|
||||
README.md: baeeca0cf939f1a3d4608769b362d532507b90f5
|
||||
README.zh.md: 238b90794c510e71fffe34d62b044a5c2ece8a6e
|
||||
|
||||
@@ -4,7 +4,7 @@ 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.
|
||||
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. Callers may cancel `readImage`; implementations observe cancellation around backend and verification work and preserve it instead of translating it into a storage failure.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
持久附件服务边界。`ctx.attachments` 校验并以原子方式提交不可变图片字节,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。
|
||||
|
||||
未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化;批量写入方会先校验每个成员,避免某个格式错误的成员使较早的成员成为无引用对象。`saveImage` 会在发布任何模型可见的会话事件前提交每张已接受的图片,`readImage` 则根据已记录的元数据校验内容寻址对象。
|
||||
未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化;批量写入方会先校验每个成员,避免某个格式错误的成员使较早的成员成为无引用对象。`saveImage` 会在发布任何模型可见的会话事件前提交每张已接受的图片,`readImage` 则根据已记录的元数据校验内容寻址对象。调用方可以取消 `readImage`;实现会在后端读取与校验工作的边界观察取消,并保留取消语义,而不会将其转换为存储失败。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -52,9 +52,11 @@ export abstract class AttachmentStore extends Service {
|
||||
/**
|
||||
* Read one image and verify that bytes still match the recorded reference.
|
||||
* @param ref - durable reference from the session log.
|
||||
* @param signal - optional cancellation for backend read and verification work.
|
||||
* @returns the verified bytes and canonical reference.
|
||||
* @throws the signal reason when aborted, or a storage error when verification fails.
|
||||
*/
|
||||
abstract readImage(ref: ImageAttachmentRef): Promise<StoredImageAttachment>
|
||||
abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise<StoredImageAttachment>
|
||||
}
|
||||
|
||||
export default AttachmentStore
|
||||
|
||||
@@ -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/README.md
|
||||
README.md: 79d653260ea4a9d9a4c71a593b41a6a7e17efa14
|
||||
README.zh.md: 839be164328ef168cd6ac18bf2f1dcb930dfce3e
|
||||
README.md: cdf551729567a7ad4be9dbd99861db4ad57cd5d7
|
||||
README.zh.md: f775e9aac291ce176516c20eca773d6520050e23
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The channel-neutral boot library the app bins share: `apps/cli`, the [`scaffold/`](../scaffold/README.md) launcher, and the [`examples/`](../examples/README.md) demo bins all consume it.
|
||||
The channel-neutral boot library shared by `apps/cli` and the [`examples/`](../examples/README.md) demo bins.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
各 app bin 共享的、与渠道无关的启动库:`apps/cli`、[`scaffold/`](../scaffold/README.md) 启动器与 [`examples/`](../examples/README.md) demo bin 都消费它。
|
||||
由 `apps/cli` 和 [`examples/`](../examples/README.md) demo bin 共享、与渠道无关的启动库。
|
||||
|
||||
| 包 | 职责 | ctx 键 |
|
||||
|---|---|---|
|
||||
|
||||
@@ -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: 9639f1c0a2ffe91fd509a2ffdf04be5f0895b700
|
||||
README.zh.md: e8bf0374aad2be2311b6e72e91e41f02f403b48a
|
||||
README.md: 4c82aa749edbeada0a344b0b119d1644544d9732
|
||||
README.zh.md: 38298e091af4aa1b09c31dfa8141ce68fa1d3f50
|
||||
|
||||
@@ -42,7 +42,7 @@ User-level machine-local preferences also live in the Harness home:
|
||||
- **`.env`** — the product CLI's ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. `loadLayeredEnv` snapshots each value's source, rejects [bootstrap-only file variables](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md#decision) case-insensitively, and materializes accepted values into `process.env` for Loader expressions and third-party libraries. Managed credentials live separately in [`.credentials.yaml`](../../credentials/credentials-local/README.md); a credential left in either `.env` remains a lower-priority fallback.
|
||||
- **`cordis.patch.yml`** (home level) and **`profiles/<name>/cordis.patch.yml`** — the user patch layers, applied after every bundle layer (per-profile first, then the home-level file, which therefore outranks it): an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount. A patch naming an entry id absent from the composed tree is a stderr warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the layer with `[]`.
|
||||
|
||||
Long-lived surfaces keep `cordis.patch.yml` live through `watchUserPatches`; one-shot runs read only the startup value. The watcher targets the exact path even when the file or immediate parent does not exist, serializes bursts, and recomposes the user patches inside the caller's layer order (bundle layers below, overlays above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh.
|
||||
Every profile boot keeps `cordis.patch.yml` live through `watchUserPatches` (a one-shot surface disposes the watcher through its bounded shutdown). The watcher targets the exact path even when the file or immediate parent does not exist, serializes bursts, and recomposes the user patches inside the caller's layer order (bundle layers below, overlays above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ profile 是位于 `$DSH_HOME/profiles/<name>` 下的目录(Harness home 由 [`
|
||||
- **`.env`**:产品 CLI 的普通环境层;调用目录的文件优先于 Harness home 的文件,两者都低于继承环境。`loadLayeredEnv` 记录每个值的来源,按不区分大小写的方式拒绝 [bootstrap-only 文件变量](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md#decision),并把其余值物化进 `process.env`,供 Loader 表达式和第三方库使用。受管凭据另存于 [`.credentials.yaml`](../../credentials/credentials-local/README.md);留在任一 `.env` 中的凭据仍是低优先级后备值。
|
||||
- **`cordis.patch.yml`**(home 级)与 **`profiles/<name>/cordis.patch.yml`**:用户 patch 层,应用在所有组合包层之后(先应用逐 profile 的文件,再应用 home 级文件,因此后者优先级更高):按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在组合后的树中,则输出一条 stderr 警告。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用该层,请使用 `[]`。
|
||||
|
||||
长期运行的界面会持续应用 `cordis.patch.yml` 的变更,具体由 `watchUserPatches` 负责;一次性运行只读取启动时的值。即使该文件或其直接父目录不存在,监视器仍会监视确切路径;它会串行处理突发变更,并按调用方的层次顺序重新组合用户 patch(组合包层在下、overlay 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离观察方的失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。
|
||||
每次 profile 启动都由 `watchUserPatches` 持续应用 `cordis.patch.yml` 的变更(一次性 surface 经由有界关闭 dispose 监视器)。即使该文件或其直接父目录不存在,监视器仍会监视确切路径;它会串行处理突发变更,并按调用方的层次顺序重新组合用户 patch(组合包层在下、overlay 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离观察方的失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -110,21 +110,33 @@ function entryConfig(ctx: Context, id: string): unknown {
|
||||
}
|
||||
|
||||
describe('Loader config interpolation', () => {
|
||||
it("resolves Include's own !!js options", async () => {
|
||||
it("keeps Include's config literal — a nested row's !!js belongs to that row's fiber", async () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n')
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n')
|
||||
writeFileSync(join(dir, 'reader.mjs'), [
|
||||
'export const name = "reader"',
|
||||
'export function apply(ctx, config) { ctx.provide("observedValue", config.value) }',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: reader\n name: ./reader.mjs\n')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
ctx.provide('includePath', pathToFileURL(join(dir, 'cordis.yml')).href)
|
||||
ctx.provide('answer', 42)
|
||||
try {
|
||||
// The include is a tree carrier: its own config (path, patches) stays
|
||||
// literal, and the expression nested inside the patched row's config
|
||||
// resolves against the row's fiber, not the include's.
|
||||
await ctx.loader.create({
|
||||
name: 'cordis:include',
|
||||
config: { path: { __jsExpr: "ctx.get('includePath')" } },
|
||||
config: {
|
||||
path: pathToFileURL(join(dir, 'cordis.yml')).href,
|
||||
patches: [{ id: 'reader', name: './reader.mjs', config: { value: { __jsExpr: "ctx.get('answer')" } } }],
|
||||
},
|
||||
})
|
||||
await ctx.loader.await()
|
||||
expect([...ctx.loader.entries()].some(entry => entry.options.id === 'noop')).toBe(true)
|
||||
const reader = [...ctx.loader.entries()].find(entry => entry.options.id === 'reader')
|
||||
expect(reader?.options.config).toEqual({ value: { __jsExpr: "ctx.get('answer')" } })
|
||||
expect(ctx.get('observedValue')).toBe(42)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
|
||||
@@ -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/cmdline/README.md
|
||||
README.md: 98335e901bdf8fe33e14c1ad4c1a320d77f30c96
|
||||
README.zh.md: 28ea749943c60089c6b4725cb61e121f82aa0114
|
||||
README.md: 2e8e58b23785fa78bd2663a459817669309a81be
|
||||
README.zh.md: c04d76905edb4afa6b18b36b8284b14990be6bdd
|
||||
|
||||
@@ -51,8 +51,6 @@ Every row configured from those values uses ordinary service injection and direc
|
||||
|
||||
Loader defers a row's `!!js` interpolation until that row's declared injections are active, then evaluates against the row's plugin context. The example above can therefore read `ctx.webStartup` directly: Cordis has already populated that injected service before Loader asks for `webserver`'s config. Include trees preserve nested expression nodes until each target row reaches this point. Provider replacement and live patch reload repeat interpolation against the current injected services, so a launch flag cannot be silently reset.
|
||||
|
||||
`enableRow(ctx, id)` turns on a row a bundle ships disabled because only some invocations want it (`dsh web --dev` and its client-plugin reload chain). The activation is an in-memory override: it does not rewrite the row's configured `disabled` value and survives config reapplication for that mounted entry. Loader applies the enabled row's ordinary injection ordering.
|
||||
|
||||
### Shared immutable arguments
|
||||
|
||||
`get()` does not consume or mutate argv. Multiple plugins can parse the same snapshot and independently provide services. The launcher does not inspect the composition for a command-line owner; a profile with no reader simply ignores its app arguments.
|
||||
|
||||
@@ -51,8 +51,6 @@ export function apply(ctx: Context): void {
|
||||
|
||||
Loader 会把一行的 `!!js` 插值推迟到该行声明的注入全部激活之后,再基于该行的插件上下文求值。所以上例可以直接读取 `ctx.webStartup`:Loader 索取 `webserver` 的配置之前,Cordis 已经填入了这个注入服务。Include 树会保留嵌套表达式节点,直到各个目标行到达这一时点。提供方替换与活动 patch 重载都会针对当前注入服务重新插值,因此启动 flag 不会被悄悄重置。
|
||||
|
||||
`enableRow(ctx, id)` 打开某个组合包以禁用状态交付、只有部分调用才需要的行(`dsh web --dev` 及其客户端插件重载链路)。该激活是内存中的覆盖:它不会改写行所配置的 `disabled` 值,并会在已挂载条目的配置重新应用后继续生效。Loader 会对启用后的行应用普通的注入顺序。
|
||||
|
||||
### 共享不可变参数
|
||||
|
||||
`get()` 不会消费或修改 argv。多个插件可以解析同一份快照,并分别提供服务。启动器不会检查组合中的命令行所有者;没有读取方的 profile 只会忽略自己的应用参数。
|
||||
|
||||
@@ -17,8 +17,6 @@
|
||||
|
||||
import type { Command } from 'commander'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
// Empty type import carries the Loader Context merge used by enableRow.
|
||||
import type {} from '@deepseek-ai/cordis-plugin-loader'
|
||||
|
||||
/**
|
||||
* The invocation's inner arguments: everything after the launcher's own flags,
|
||||
@@ -133,28 +131,6 @@ export function parseCmdline<T>(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn on a row this composition ships disabled, because this invocation asked
|
||||
* for it (`dsh web --dev` and its client-plugin reload chain).
|
||||
*
|
||||
* A row cannot be inserted from inside a mounting plugin — the Loader returns a
|
||||
* prefixed id it then fails to resolve — so a conditional row ships disabled
|
||||
* and a row mounted beside it enables it after startup resolves the invocation.
|
||||
* The Loader keeps that activation in memory, separate from serialized options,
|
||||
* so reapplying the composition cannot restore the invocation's row to disabled.
|
||||
* @param ctx - plugin context whose Loader tree carries the row.
|
||||
* @param id - the row id.
|
||||
* @returns nothing once the row has started or is waiting for its dependencies.
|
||||
* @throws when the Loader or named row is absent.
|
||||
*/
|
||||
export async function enableRow(ctx: Context, id: string): Promise<void> {
|
||||
const loader = ctx.get('loader')
|
||||
if (loader === undefined) throw new Error('dsh-cmdline: enabling a row requires the Loader service')
|
||||
const entry = [...loader.entries()].find(candidate => candidate.options.id === id)
|
||||
if (entry === undefined) throw new Error(`dsh-cmdline: the composition has no ${JSON.stringify(id)} row to enable`)
|
||||
await entry.enableRuntime()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a thrown value is commander's own control-flow error (help, version,
|
||||
* a parse error, or `program.error`).
|
||||
|
||||
@@ -14,9 +14,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import Include from '@deepseek-ai/cordis-plugin-include'
|
||||
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
enableRow, internals, parseCmdline, provideCmdline, type CmdlinePlan,
|
||||
} from '../src/index.ts'
|
||||
import { internals, parseCmdline, provideCmdline, type CmdlinePlan } from '../src/index.ts'
|
||||
|
||||
/** Every value one boot of the fixture tree observed. */
|
||||
interface Observed {
|
||||
@@ -175,71 +173,6 @@ describe('parseCmdline', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('enableRow', () => {
|
||||
it('enables the named Loader row and fails loud when the Loader or row is absent', async () => {
|
||||
const withoutLoader = new Context()
|
||||
await expect(enableRow(withoutLoader, 'client-hmr')).rejects.toThrow('requires the Loader service')
|
||||
|
||||
const ctx = new Context()
|
||||
let enabled = false
|
||||
ctx.provide('loader', {
|
||||
entries: () => [{
|
||||
options: { id: 'client-hmr' },
|
||||
enableRuntime: async () => { enabled = true },
|
||||
}],
|
||||
} as never)
|
||||
await enableRow(ctx, 'client-hmr')
|
||||
expect(enabled).toBe(true)
|
||||
await expect(enableRow(ctx, 'absent')).rejects.toThrow('no "absent" row to enable')
|
||||
})
|
||||
|
||||
it('keeps invocation-only activation through config reapplication', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-runtime-enable-'))
|
||||
const observed = { starts: 0, stops: 0 }
|
||||
;(globalThis as unknown as { __runtimeEnableObserved: typeof observed }).__runtimeEnableObserved = observed
|
||||
writeFileSync(join(dir, 'conditional.mjs'), `
|
||||
export function apply(ctx) {
|
||||
globalThis.__runtimeEnableObserved.starts += 1
|
||||
ctx.effect(() => () => { globalThis.__runtimeEnableObserved.stops += 1 })
|
||||
}
|
||||
`)
|
||||
writeFileSync(join(dir, 'cordis.yml'), [
|
||||
'- id: conditional',
|
||||
` name: ${pathToFileURL(join(dir, 'conditional.mjs')).href}`,
|
||||
' disabled: true',
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
await ctx.loader.create({
|
||||
name: 'cordis:include',
|
||||
config: { path: pathToFileURL(join(dir, 'cordis.yml')).href },
|
||||
})
|
||||
await ctx.loader.await()
|
||||
const conditional = [...ctx.loader.entries()].find(entry => entry.options.id === 'conditional')
|
||||
const include = [...ctx.loader.entries()].find(entry => entry.options.name === 'cordis:include')
|
||||
expect(conditional).toBeDefined()
|
||||
expect(include?.fiber).toBeDefined()
|
||||
expect(conditional?.options.disabled).toBe(true)
|
||||
expect(observed).toEqual({ starts: 0, stops: 0 })
|
||||
|
||||
await enableRow(ctx, 'conditional')
|
||||
await ctx.loader.await()
|
||||
expect(conditional?.disabled).toBe(false)
|
||||
expect(conditional?.options.disabled).toBe(true)
|
||||
expect(observed).toEqual({ starts: 1, stops: 0 })
|
||||
|
||||
await include!.fiber!.update(include!.options.config, true)
|
||||
await ctx.loader.await()
|
||||
expect(conditional?.disabled).toBe(false)
|
||||
expect(conditional?.options.disabled).toBe(true)
|
||||
expect(observed).toEqual({ starts: 1, stops: 0 })
|
||||
disposers.push(async () => { await ctx.fiber.dispose() })
|
||||
})
|
||||
})
|
||||
|
||||
describe('provideCmdline', () => {
|
||||
it('hands the app a snapshot the caller cannot mutate afterwards', () => {
|
||||
const ctx = new Context()
|
||||
|
||||
@@ -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/bundle/headless/README.md
|
||||
README.md: 31a4894dbb191d2244371ca7272339e96e253053
|
||||
README.zh.md: 6e8d28f10071fbab175c4f14f1aaa9618b8f598a
|
||||
README.md: 3d9ca350f5f8891e60cfc57c9ca89ef57d9790d3
|
||||
README.zh.md: 1dcba9635b37efebeb0cc1129cc67bc7c01d0d1d
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides directly over [`dsh-base`](../base/README.md): it supplies the coding persona and tool mode, disables HMR, mounts Code Mode's worker as a core execution capability, and inserts this package's `headless-runner` plugin (config `{task}`, resolved from the injected `headlessStartup` provider). It mounts no Host, HTTP server, Web runtime, or browser plugin.
|
||||
|
||||
After the Loader settles, the runner reads the shared [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md), creates one fresh persisted Agent through `ctx.agents`, submits the task as an ordinary user message, and waits for quiescence. It flushes the Session before folding the owned durable event interval, writes the last non-empty assistant text to stdout, and requests exit through the launcher-provided `ctx.headlessIo` host hook (final `turn/end` completed → 0, otherwise 1). A terminal `error` reason also writes its code and message to stderr; successful runs keep stderr empty. The process opens no listening port. The task text is this app's command line: the ordinary `headless-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), reads the positional argument of `dsh --profile headless "task"`, prints the app's `--help`, and provides `headlessStartup`; the runner injects that service and reads its task from lazy config. A missing or whitespace-only task is rejected before the runner activates.
|
||||
After the Loader settles, the runner reads the shared [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md), creates one fresh persisted Agent through `ctx.agents`, submits the task as an ordinary user message, and waits for quiescence. It flushes the Session before folding the owned durable event interval, writes the last non-empty assistant text to stdout, and requests exit through the launcher-provided `ctx.appExit` host hook ([`dsh-cmdline`](../../boot/cmdline/README.md)) (final `turn/end` completed → 0, otherwise 1). A terminal `error` reason also writes its code and message to stderr; successful runs keep stderr empty. The process opens no listening port. The task text is this app's command line: the ordinary `headless-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), reads the positional argument of `dsh --profile headless "task"`, prints the app's `--help`, and provides `headlessStartup`; the runner injects that service and reads its task from lazy config. A missing or whitespace-only task is rejected before the runner activates.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -17,4 +17,4 @@ None; the runner adds nothing to the request prefix.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **One submitted task only** — the runner has no interactive follow-up surface; it waits through any work the Agent completes before returning to idle and prints the last non-empty assistant message in that interval.
|
||||
- **`ctx.headlessIo` is launcher-owned** — booting the headless profile outside the `dsh` launcher fails loud at activation until the host provides the hook.
|
||||
- **`ctx.appExit` is launcher-owned** — booting the headless profile outside the `dsh` launcher fails loud at activation until the host provides the exit request.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 直接叠加在 [`dsh-base`](../base/README.md) 之上:提供编码 persona 和工具模式、禁用 HMR(热模块替换)、将 Code Mode 的 worker 作为核心执行能力挂载,并插入本包的 `headless-runner` 插件(配置为 `{task}`,从注入的 `headlessStartup` 提供方解析)。它不挂载任何 Host、HTTP server、Web runtime 或浏览器插件。
|
||||
|
||||
Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md),通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),将任务作为普通用户消息提交,并等待完全停稳。它对 Session 执行 flush 后再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout,再经启动器提供的 `ctx.headlessIo` 宿主钩子请求退出(最终 `turn/end` 完成 → 0,否则为 1)。最终 reason 为 `error` 时,还会将持久化的 code 与 message 写入 stderr;成功运行时 stderr 保持为空。进程不会打开监听端口。任务文本就是这个应用的命令行:普通 `headless-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),读取 `dsh --profile headless "task"` 的位置参数、打印应用自己的 `--help`,并提供 `headlessStartup`;runner 注入该服务,再从惰性配置中读取任务。缺失或只有空白的任务会在 runner 激活前被拒绝。
|
||||
Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md),通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),将任务作为普通用户消息提交,并等待完全停稳。它对 Session 执行 flush 后再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout,再经启动器提供的 `ctx.appExit` 宿主钩子([`dsh-cmdline`](../../boot/cmdline/README.md))请求退出(最终 `turn/end` 完成 → 0,否则为 1)。最终 reason 为 `error` 时,还会将持久化的 code 与 message 写入 stderr;成功运行时 stderr 保持为空。进程不会打开监听端口。任务文本就是这个应用的命令行:普通 `headless-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),读取 `dsh --profile headless "task"` 的位置参数、打印应用自己的 `--help`,并提供 `headlessStartup`;runner 注入该服务,再从惰性配置中读取任务。缺失或只有空白的任务会在 runner 激活前被拒绝。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -17,4 +17,4 @@ Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/a
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **只提交一个任务**:runner 没有用于交互式后续输入的 surface;它会等待 Agent 在返回 idle 前完成的所有工作,并打印该区间内最后一条非空 assistant 消息。
|
||||
- **`ctx.headlessIo` 由启动器持有**:在 `dsh` 启动器之外启动 headless profile 会在激活时明确报错,直到宿主提供该钩子。
|
||||
- **`ctx.appExit` 由启动器持有**:在 `dsh` 启动器之外启动 headless profile 会在激活时明确报错,直到宿主提供该退出请求。
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
persona: >-
|
||||
You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.
|
||||
|
||||
# One-shot runs never watch or reload their composition.
|
||||
# The shared module-reload HMR row stays off; the launcher's watch-only
|
||||
# fallback still keeps the user patch layers live until the run exits.
|
||||
- id: hmr
|
||||
disabled: true
|
||||
|
||||
|
||||
@@ -16,8 +16,10 @@ import type {} from '@deepseek-ai/dsh-agent-default-model'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
// Empty type import carries the loader Context merge for the settlement await.
|
||||
// Empty type imports carry the loader Context merge for the settlement await
|
||||
// and the cmdline Context merge for the appExit host value.
|
||||
import type {} from '@deepseek-ai/cordis-plugin-loader'
|
||||
import type {} from '@deepseek-ai/dsh-cmdline'
|
||||
|
||||
/** Stable Cordis plugin name. */
|
||||
export const name = 'headless-runner'
|
||||
@@ -41,22 +43,18 @@ interface RunOutcome {
|
||||
reason: SessionEvent<'turn/end'>['data']['reason'] | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Process-facing effects of one run, injectable for tests. The launcher owns
|
||||
* bounded tree shutdown and wires `exit()` to it.
|
||||
*/
|
||||
export interface HeadlessIo {
|
||||
/** Process-facing effects of one run: output streams plus the launcher's bounded exit request. */
|
||||
interface HeadlessIo {
|
||||
stdout: { write(chunk: string): unknown }
|
||||
stderr: { write(chunk: string): unknown }
|
||||
/** Request process exit with `code` after the tree disposes. */
|
||||
exit(code: number): void
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** Process-facing effects provided before the headless tree mounts. */
|
||||
headlessIo?: HeadlessIo
|
||||
}
|
||||
/** The process streams the runner writes to; tests substitute captures. */
|
||||
export const internals: { stdout: HeadlessIo['stdout']; stderr: HeadlessIo['stderr'] } = {
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
}
|
||||
|
||||
/** Aggregate the last assistant text and turn outcome in one owned interval. */
|
||||
@@ -137,13 +135,16 @@ async function run(ctx: Context, task: string, io: HeadlessIo): Promise<void> {
|
||||
|
||||
/**
|
||||
* Mount the one-shot direct driver.
|
||||
* @param ctx - plugin context carrying core services and the launcher-owned IO seam.
|
||||
* @param ctx - plugin context carrying core services and the launcher-provided exit request.
|
||||
* @param config - validated task config.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const io = ctx.headlessIo
|
||||
if (io === undefined) {
|
||||
throw new Error('headless-runner: the launcher must provide ctx.headlessIo before the tree mounts')
|
||||
// Read through the global service store, not the property proxy: appExit is
|
||||
// an optional host value, never an injected dependency.
|
||||
const exit = ctx.get('appExit')
|
||||
if (exit === undefined) {
|
||||
throw new Error('headless-runner: the launcher must provide ctx.appExit before the tree mounts')
|
||||
}
|
||||
const io: HeadlessIo = { stdout: internals.stdout, stderr: internals.stderr, exit }
|
||||
void run(ctx, config.task, io).catch((error: unknown) => { fail(io, error) })
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Direct one-shot Agent driving, durable aggregation, flushing, and exit mapping. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
@@ -8,7 +8,10 @@ import AgentDefaultModelService from '@deepseek-ai/dsh-agent-default-model'
|
||||
import { createAssistantMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { apply, Config, type HeadlessIo } from '../src/index.ts'
|
||||
import { apply, Config, internals } from '../src/index.ts'
|
||||
|
||||
const originalInternals = { ...internals }
|
||||
afterEach(() => { Object.assign(internals, originalInternals) })
|
||||
|
||||
interface Script {
|
||||
before?(session: Session): void
|
||||
@@ -93,13 +96,10 @@ async function bench(script: Script): Promise<{
|
||||
let err = ''
|
||||
const order: string[] = []
|
||||
ctx.on('session/flush', () => { order.push('flush') })
|
||||
internals.stdout = { write: (chunk: string) => { out += chunk; return true } }
|
||||
internals.stderr = { write: (chunk: string) => { err += chunk; return true } }
|
||||
const exited = new Promise<number>((resolve) => {
|
||||
const io: HeadlessIo = {
|
||||
stdout: { write: (chunk: string) => { out += chunk; return true } },
|
||||
stderr: { write: (chunk: string) => { err += chunk; return true } },
|
||||
exit: (code) => { order.push('exit'); resolve(code) },
|
||||
}
|
||||
ctx.provide('headlessIo', io)
|
||||
ctx.provide('appExit', (code: number) => { order.push('exit'); resolve(code) })
|
||||
})
|
||||
apply(ctx, { task: 'do the thing' })
|
||||
return { code: await exited, out, err, order }
|
||||
@@ -181,12 +181,10 @@ describe('headless runner', () => {
|
||||
it('reports a direct Agent creation failure', async () => {
|
||||
const ctx = new Context()
|
||||
let err = ''
|
||||
internals.stdout = { write: () => true }
|
||||
internals.stderr = { write: (chunk: string) => { err += chunk; return true } }
|
||||
const exited = new Promise<number>((resolve) => {
|
||||
ctx.provide('headlessIo', {
|
||||
stdout: { write: () => true },
|
||||
stderr: { write: (chunk: string) => { err += chunk; return true } },
|
||||
exit: resolve,
|
||||
} satisfies HeadlessIo)
|
||||
ctx.provide('appExit', resolve)
|
||||
})
|
||||
ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never)
|
||||
ctx.provide('sessions', { flush: () => Promise.resolve(true) } as never)
|
||||
@@ -200,12 +198,10 @@ describe('headless runner', () => {
|
||||
it('stringifies a non-Error Agent creation failure', async () => {
|
||||
const ctx = new Context()
|
||||
let err = ''
|
||||
internals.stdout = { write: () => true }
|
||||
internals.stderr = { write: (chunk: string) => { err += chunk; return true } }
|
||||
const exited = new Promise<number>((resolve) => {
|
||||
ctx.provide('headlessIo', {
|
||||
stdout: { write: () => true },
|
||||
stderr: { write: (chunk: string) => { err += chunk; return true } },
|
||||
exit: resolve,
|
||||
} satisfies HeadlessIo)
|
||||
ctx.provide('appExit', resolve)
|
||||
})
|
||||
ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never)
|
||||
ctx.provide('sessions', { flush: () => Promise.resolve(true) } as never)
|
||||
@@ -224,11 +220,9 @@ describe('headless runner', () => {
|
||||
it('abandons a run when the tree is disposed during Loader settlement', async () => {
|
||||
const ctx = new Context()
|
||||
let exited = false
|
||||
ctx.provide('headlessIo', {
|
||||
stdout: { write: () => true },
|
||||
stderr: { write: () => true },
|
||||
exit: () => { exited = true },
|
||||
} satisfies HeadlessIo)
|
||||
internals.stdout = { write: () => true }
|
||||
internals.stderr = { write: () => true }
|
||||
ctx.provide('appExit', () => { exited = true })
|
||||
const services = ctx.plugin((child: Context) => {
|
||||
child.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never)
|
||||
child.provide('sessions', {} as never)
|
||||
@@ -246,9 +240,9 @@ describe('headless runner', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('fails loud without the launcher-owned headlessIo seam', () => {
|
||||
it('fails loud without the launcher-provided exit request', () => {
|
||||
const ctx = new Context()
|
||||
expect(() => { apply(ctx, { task: 't' }) }).toThrow('must provide ctx.headlessIo')
|
||||
expect(() => { apply(ctx, { task: 't' }) }).toThrow('must provide ctx.appExit')
|
||||
})
|
||||
|
||||
it('validates config: the task is required', () => {
|
||||
|
||||
@@ -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/bundle/web-app/README.md
|
||||
README.md: b6fa225f5e0a0a079605a4fb9064b79287ab21cd
|
||||
README.zh.md: 68af959719b9bd146eddd143aa9d98400e65fa68
|
||||
README.md: 06856a47cd8ccc2c6ee5a53c40928b1bd2933cc7
|
||||
README.zh.md: 8befc7c7404ea1b082842f122769967fff32df2f
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, trustedHosts}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, enables the optional HMR row before client-module discovery so the first development graph contains its reload receiver, samples bind-dependent LAN trust once, provides it as `webRuntime` to the browser-trust fence and client roster, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true, after its Loader tree settles so a sibling failure cannot announce a dead app. This bundle also owns the app command line: the ordinary `web-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), parses `--host`, `--port`, `--dev`, repeatable `--trusted-host`, and the app's `--help`, then provides `webStartup`. Flag-configured rows inject that service and read it directly from lazy config, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle.
|
||||
The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, the always-on client-plugin reload chain ([`dsh-client-hmr`](../../client/hmr/README.md), idle until a rebuild watcher rewrites client bundles), and mounts this package's `web-runtime` glue plugin (config `{printUrl, surfaceContext, trustedHosts}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, samples bind-dependent LAN trust once, provides it as `webRuntime` to the browser-trust fence and client roster, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL` runtime variable when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true, after its Loader tree settles so a sibling failure cannot announce a dead app. This bundle also owns the app command line: the ordinary `web-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), parses `--host`, `--port`, repeatable `--trusted-host`, and the app's `--help`, then provides `webStartup`. Flag-configured rows inject that service and read it directly from lazy config, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -10,7 +10,7 @@ The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides ove
|
||||
|
||||
#### What the model sees
|
||||
|
||||
When `surfaceContext` is true, the `harness:source` section identifies the on-disk Harness implementation without claiming it is the working directory, and the `app:web-surface` global section (order −98) orients the model to the GUI: the canonical local URL, the "this page" referent, the HMR/rebuild update contract for the active mode, and the instruction not to start replacement servers. `DSH_WEB_URL` and `DSH_WEB_MODE` additionally appear in the managed bash environment with their descriptions, resolved per invocation from the live server. When it is false, neither section nor the variables are registered.
|
||||
When `surfaceContext` is true, the `harness:source` section identifies the on-disk Harness implementation without claiming it is the working directory, and the `app:web-surface` global section (order −98) orients the model to the GUI: the canonical local URL, the "this page" referent, the update contract (the reload receiver is always on; no-refresh reloads additionally need the `pnpm run dev:web` watcher), and the instruction not to start replacement servers. `DSH_WEB_URL` additionally appears in the managed bash environment with its description, resolved per invocation from the live server. When it is false, neither section nor the variable is registered.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -18,7 +18,7 @@ One source line and one prompt paragraph per session plus two managed-environmen
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
The prompt section sits near the system prompt's head and is stable for the life of the process (port and mode are boot facts), so it does not invalidate the cache across turns.
|
||||
The prompt section sits near the system prompt's head and is stable for the life of the process (the port is a boot fact), so it does not invalidate the cache across turns.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, trustedHosts}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,在客户端模块发现前启用可选的 HMR 行,确保首份开发模式图中包含它的重载接收端,只采样一次依赖 bind 的 LAN 信任信息并将其作为 `webRuntime` 提供给浏览器信任栅栏和客户端名录,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时等自身的 Loader 配置树结算后再打印 `dsh web:` URL 行,避免兄弟行失败时公告一个已失效的应用。本组合包还持有应用命令行:普通 `web-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),解析 `--host`、`--port`、`--dev`、可重复的 `--trusted-host` 以及应用自己的 `--help`,再提供 `webStartup`。由 flag 配置的行会注入该服务,并在惰性配置中直接读取它,因此参数解析完成前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。
|
||||
dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)、浏览器插件名录与始终挂载的客户端插件重载链([`dsh-client-hmr`](../../client/hmr/README.md),在重建 watcher 改写客户端 bundle 之前保持空闲),并挂载本包的 `web-runtime` 粘合插件(配置为 `{printUrl, surfaceContext, trustedHosts}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,只采样一次依赖 bind 的 LAN 信任信息并将其作为 `webRuntime` 提供给浏览器信任栅栏和客户端名录,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL` 运行时变量,并在 `printUrl` 为 true 时等自身的 Loader 配置树结算后再打印 `dsh web:` URL 行,避免兄弟行失败时公告一个已失效的应用。本组合包还持有应用命令行:普通 `web-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),解析 `--host`、`--port`、可重复的 `--trusted-host` 以及应用自己的 `--help`,再提供 `webStartup`。由 flag 配置的行会注入该服务,并在惰性配置中直接读取它,因此参数解析完成前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -10,7 +10,7 @@ dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
当 `surfaceContext` 为 true 时,`harness:source` 段落标明磁盘上的 Harness 实现,但不会声称它就是工作目录;全局段落 `app:web-surface`(顺序 −98)则向模型说明 GUI:规范的本地 URL、「this page」指代什么、当前模式下 HMR(热模块替换)/重建的更新约定,以及不要启动替代服务器的指令。`DSH_WEB_URL` 与 `DSH_WEB_MODE` 还会连同各自描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。当它为 false 时,这两个段落和这些变量都不会注册。
|
||||
当 `surfaceContext` 为 true 时,`harness:source` 段落标明磁盘上的 Harness 实现,但不会声称它就是工作目录;全局段落 `app:web-surface`(顺序 −98)则向模型说明 GUI:规范的本地 URL、「this page」指代什么、更新约定(重载接收端始终开启;无刷新重载还需要 `pnpm run dev:web` watcher),以及不要启动替代服务器的指令。`DSH_WEB_URL` 还会连同描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。当它为 false 时,这两个段落和该变量都不会注册。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
@@ -18,7 +18,7 @@ dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
该提示词段落位于系统提示词靠前位置,且在进程整个生命周期内稳定(端口与模式是启动期事实),因此不会使跨轮次缓存失效。
|
||||
该提示词段落位于系统提示词靠前位置,且在进程整个生命周期内稳定(端口是启动期事实),因此不会使跨轮次缓存失效。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
# The web-startup plugin injects `cmdlineArgs` and provides `webStartup` as an
|
||||
# ordinary Cordis service. Rows configured from flags inject that service, so
|
||||
# Loader resolves their expressions only after it exists. The web runtime then
|
||||
# provides bind-dependent `webRuntime` values to the trust fence and client
|
||||
# roster. `dsh --profile web --help` provides neither service, so no server binds.
|
||||
# provides bind-dependent `webRuntime` values to the trust fence.
|
||||
# `dsh --profile web --help` provides neither service, so no server binds.
|
||||
|
||||
# ── surface-specific values the base deliberately omits ─────────────────────
|
||||
|
||||
@@ -105,39 +105,34 @@
|
||||
# Web glue owned by this bundle: resolves the built frontend dist (an
|
||||
# assembly fact of dsh-web-app, never user config), mounts the
|
||||
# frontend-static fallback owner, registers the web-surface prompt
|
||||
# section and bash runtime variables, and prints the URL line. The webStartup
|
||||
# provider supplies invocation-only values; after the server binds, this row
|
||||
# samples LAN trust once and provides `webRuntime`. A complete agent-preset
|
||||
# persona suppresses the prompt section for that agent while retaining
|
||||
# these host-owned shell variables.
|
||||
# section and the bash runtime variable, and prints the URL line. The
|
||||
# webStartup provider supplies invocation-only values; after the server
|
||||
# binds, this row samples LAN trust once and provides `webRuntime`. A
|
||||
# complete agent-preset persona suppresses the prompt section for that
|
||||
# agent while retaining the host-owned shell variable.
|
||||
- id: web-runtime
|
||||
name: '@deepseek-ai/dsh-web-app'
|
||||
inject: [webStartup]
|
||||
config:
|
||||
mode: !!js ctx.webStartup.mode
|
||||
printUrl: true
|
||||
surfaceContext: true
|
||||
trustedHosts: !!js ctx.webStartup.trustedHosts
|
||||
|
||||
# The client-plugin reload chain: a dev-only row this bundle ships off,
|
||||
# which the runtime row turns on before client discovery. It is a row rather
|
||||
# than a child of web-runtime because its node half is a client-side package,
|
||||
# which a host-side bundle cannot import.
|
||||
# The client-plugin reload chain, always mounted: it is idle until a
|
||||
# rebuild watcher (pnpm run dev:web) actually rewrites client bundles. It
|
||||
# is a row rather than a child of web-runtime because its node half is a
|
||||
# client-side package, which a host-side bundle cannot import.
|
||||
- id: client-hmr
|
||||
name: '@deepseek-ai/dsh-client-hmr'
|
||||
inject: [webStartup]
|
||||
disabled: true
|
||||
|
||||
# ── browser plugin roster (dsh.client rows; node halves are layer-2 hosts) ──
|
||||
|
||||
# Dual-face: this waits for the runtime row to decide whether HMR belongs
|
||||
# in the first graph. The node half then scans this tree, composes
|
||||
# window.__DSH_BOOT__, and serves /plugins/<id>/client.js; the browser half
|
||||
# is the module table the shell kernel constructs before cordis exists
|
||||
# (adopted as a plugin entry by the kernel, never fetched).
|
||||
# Dual-face: the node half scans this tree, composes window.__DSH_BOOT__,
|
||||
# and serves /plugins/<id>/client.js; the browser half is the module table
|
||||
# the shell kernel constructs before cordis exists (adopted as a plugin
|
||||
# entry by the kernel, never fetched).
|
||||
- id: modules
|
||||
name: '@deepseek-ai/dsh-client-modules'
|
||||
inject: [webRuntime]
|
||||
|
||||
# Owns both ends of the web transport: node half binds the gateway to the
|
||||
# webserver under /api; browser half is the fetch/SSE client.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* the built frontend dist (workspace knowledge of this bundle, never user
|
||||
* config), mounts the `frontend-static` fallback owner over it, registers the
|
||||
* harness-source and web-surface prompt sections, the bash-visible web runtime
|
||||
* variables, and the URL line. App command-line values arrive through the
|
||||
* variable, and the URL line. App command-line values arrive through the
|
||||
* `webStartup` service expressions in the bundle patch.
|
||||
* @module @deepseek-ai/dsh-web-app
|
||||
*/
|
||||
@@ -16,7 +16,6 @@ import { fileURLToPath } from 'node:url'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot'
|
||||
import { enableRow } from '@deepseek-ai/dsh-cmdline'
|
||||
import * as FrontendStatic from '@deepseek-ai/dsh-frontend-static'
|
||||
import type {} from '@deepseek-ai/cordis-plugin-loader'
|
||||
import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
@@ -28,7 +27,6 @@ export const name = 'web-app'
|
||||
|
||||
/** This dsh installation's root, from either this package's source or built entry. */
|
||||
const SOURCE_ROOT = fileURLToPath(new URL('../../../..', import.meta.url))
|
||||
const HMR_ROW_ID = 'client-hmr'
|
||||
|
||||
/** Runtime service that releases Web rows after bind-dependent values resolve. */
|
||||
const WEB_RUNTIME_SERVICE = 'webRuntime'
|
||||
@@ -36,19 +34,14 @@ const WEB_RUNTIME_SERVICE = 'webRuntime'
|
||||
/** Services required before the web runtime can mount. */
|
||||
export const inject = ['httpServer']
|
||||
|
||||
/** Web runtime mode: production, or development when the client-plugin HMR receiver is active. */
|
||||
export type WebMode = 'production' | 'development'
|
||||
|
||||
/** Plugin config: composed deployment settings plus per-invocation command-line values. */
|
||||
export interface Config {
|
||||
/** Whether this process mounted the client-plugin HMR receiver (`dsh web --dev`). */
|
||||
mode: WebMode
|
||||
/** Print the URL line on activation; a non-interactive layer can turn it off. */
|
||||
printUrl: boolean
|
||||
/**
|
||||
* Register the model-visible surface context (the `app:web-surface` prompt
|
||||
* section and the `DSH_WEB_URL`/`DSH_WEB_MODE` bash variables). A one-shot
|
||||
* non-interactive layer can turn it off when its user is not in the GUI, so the
|
||||
* section and the `DSH_WEB_URL` bash variable). A one-shot non-interactive
|
||||
* layer can turn it off when its user is not in the GUI, so the
|
||||
* orientation text would be false.
|
||||
*/
|
||||
surfaceContext: boolean
|
||||
@@ -57,7 +50,6 @@ export interface Config {
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
mode: z.union([z.const('production'), z.const('development')]).default('production'),
|
||||
printUrl: z.boolean().default(true),
|
||||
surfaceContext: z.boolean().default(true),
|
||||
trustedHosts: z.array(String).default([]),
|
||||
@@ -73,8 +65,6 @@ export interface WebRuntimeValues {
|
||||
|
||||
/** Environment variable naming the canonical local URL of this Web GUI. */
|
||||
const DSH_WEB_URL = 'DSH_WEB_URL' as const
|
||||
/** Environment variable naming the Web runtime mode. */
|
||||
const DSH_WEB_MODE = 'DSH_WEB_MODE' as const
|
||||
|
||||
// Display-only mirror of the webserver schema's loopback host: the address the
|
||||
// local URL always prints. Not a source of truth — the schema is.
|
||||
@@ -102,13 +92,10 @@ export function resolveLanTrust(bindHost: string, extra: readonly string[]): Web
|
||||
}
|
||||
|
||||
/** Model-visible orientation and acceptance boundary for sessions created through `dsh web`. */
|
||||
function webSurfacePrompt(webUrl: string, mode: WebMode): string {
|
||||
const updateContract = mode === 'development'
|
||||
? 'This Web process was launched with `dsh web --dev`, so its client-plugin HMR receiver is active. '
|
||||
+ 'No-refresh updates occur only when `pnpm run dev:web` is also running from this same checkout to rebuild client-plugin bundles; verify that watcher before promising automatic updates. '
|
||||
+ 'Client-plugin changes then reload automatically, while apps/web shell and other plain-package changes still require a rebuild and page refresh. '
|
||||
: 'This Web process was launched without `--dev`, so HMR is inactive: rebuild the affected Web artifacts and verify this existing URL after a page refresh. '
|
||||
+ 'If the user wants no-refresh client-plugin updates, explain that this GUI must be restarted with `dsh web --dev` and `pnpm run dev:web` must also run from this same checkout; do not present either command alone as sufficient. '
|
||||
function webSurfacePrompt(webUrl: string): string {
|
||||
const updateContract = 'The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while '
|
||||
+ '`pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. '
|
||||
+ 'Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. '
|
||||
return `You are interacting with the user through the DeepSeek Harness Web GUI at ${webUrl}. `
|
||||
+ 'When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. '
|
||||
+ 'The browser provides no implicit DOM, route, or screenshot context. '
|
||||
@@ -140,20 +127,14 @@ function resolveDistIndex(): string {
|
||||
export const internals: { resolveDistIndex: () => string } = { resolveDistIndex }
|
||||
|
||||
/**
|
||||
* Mount the Web runtime: dist serving, surface prompt, bash runtime
|
||||
* variables, and the URL line.
|
||||
* Mount the Web runtime: dist serving, surface prompt, the bash runtime
|
||||
* variable, and the URL line.
|
||||
* @param ctx - plugin context carrying the httpServer service.
|
||||
* @param config - validated {@link Config}.
|
||||
* @returns nothing once the invocation's client roster and runtime contributions are registered.
|
||||
*/
|
||||
export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
// Client discovery must start after the optional HMR row has a pending
|
||||
// fiber. Otherwise its first browser graph omits the reload receiver, which
|
||||
// cannot use that receiver to discover itself later.
|
||||
if (config.mode === 'development') await enableRow(ctx, HMR_ROW_ID)
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const runtime = resolveLanTrust(ctx.httpServer.host, config.trustedHosts)
|
||||
// Release dependent rows only after the optional row has a pending fiber and
|
||||
// bind-dependent trust has been sampled once.
|
||||
// Release dependent rows only after bind-dependent trust has been sampled once.
|
||||
ctx.provide(WEB_RUNTIME_SERVICE, runtime)
|
||||
ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() })
|
||||
if (config.surfaceContext) {
|
||||
@@ -162,7 +143,7 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
promptCtx.systemPrompt.section({
|
||||
name: 'app:web-surface',
|
||||
order: -98,
|
||||
text: () => webSurfacePrompt(localWebUrl(promptCtx), config.mode),
|
||||
text: () => webSurfacePrompt(localWebUrl(promptCtx)),
|
||||
})
|
||||
})
|
||||
ctx.inject(['bashEnv'], (runtimeCtx) => {
|
||||
@@ -170,9 +151,8 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
name: 'web-runtime',
|
||||
variables: {
|
||||
[DSH_WEB_URL]: { description: 'Canonical local URL of the DeepSeek Harness Web GUI serving this session.' },
|
||||
[DSH_WEB_MODE]: { description: 'Web runtime mode: production, or development when the client-plugin HMR receiver is active.' },
|
||||
},
|
||||
resolve: () => ({ [DSH_WEB_URL]: localWebUrl(runtimeCtx), [DSH_WEB_MODE]: config.mode }),
|
||||
resolve: () => ({ [DSH_WEB_URL]: localWebUrl(runtimeCtx) }),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The web app's command-line provider: it parses the `dsh --profile web` flag
|
||||
* family (`--host`, `--port`, `--dev`, `--trusted-host`) and its `--help`
|
||||
* family (`--host`, `--port`, `--trusted-host`) and its `--help`
|
||||
* text, then provides the immutable values as {@link WEB_STARTUP_SERVICE}.
|
||||
* Ordinary rows inject that service before reading it from lazy config.
|
||||
* @module @deepseek-ai/dsh-web-app/startup
|
||||
@@ -25,8 +25,6 @@ export interface WebStartupValues {
|
||||
host?: string
|
||||
/** `--port`, absent when the invocation did not name one. */
|
||||
port?: number
|
||||
/** Web runtime mode; `--dev` selects development, which also mounts the client-plugin reload chain. */
|
||||
mode: 'production' | 'development'
|
||||
/** Explicit `--trusted-host` authorities, in argument order. */
|
||||
trustedHosts: string[]
|
||||
}
|
||||
@@ -35,7 +33,6 @@ export interface WebStartupValues {
|
||||
interface WebOptions {
|
||||
host?: string
|
||||
port?: string
|
||||
dev?: boolean
|
||||
trustedHost?: string[]
|
||||
}
|
||||
|
||||
@@ -50,14 +47,12 @@ function webCommand(): Command {
|
||||
.helpOption('-h, --help', 'show this help')
|
||||
.option('--host <host>', 'bind host; pass 0.0.0.0 to reach it from another machine')
|
||||
.option('--port <port>', 'listen port; pass 0 to let the OS pick a free one')
|
||||
.option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)')
|
||||
.option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
|
||||
.addHelpText('after', `
|
||||
Examples:
|
||||
dsh --profile web serve on the composed host and port
|
||||
dsh --profile web --port 8080 serve on another port
|
||||
dsh --profile web --host 0.0.0.0 reach it from another machine on the LAN
|
||||
dsh --profile web --dev mount the client-plugin HMR receiver
|
||||
`)
|
||||
}
|
||||
|
||||
@@ -74,7 +69,6 @@ function planWebStartup(program: Command): WebStartupValues {
|
||||
return {
|
||||
...options.host !== undefined && { host: options.host },
|
||||
...options.port !== undefined && { port: Number(options.port) },
|
||||
mode: options.dev === true ? 'development' : 'production',
|
||||
trustedHosts: options.trustedHost ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,6 @@ export const apply = ctx => globalThis.__webStartupApply(ctx)
|
||||
' config:',
|
||||
" host: !!js ctx.webStartup.host ?? '127.0.0.1'",
|
||||
' port: !!js ctx.webStartup.port ?? 3080',
|
||||
' mode: !!js ctx.webStartup.mode',
|
||||
' trustedHosts: !!js ctx.webStartup.trustedHosts',
|
||||
'- id: provider',
|
||||
` name: ${pathToFileURL(join(dir, 'provider.mjs')).href}`,
|
||||
@@ -91,14 +90,12 @@ describe('web command-line provider', () => {
|
||||
const { values, observed } = await bootProvider([
|
||||
'--host', '0.0.0.0',
|
||||
'--port', '8080',
|
||||
'--dev',
|
||||
'--trusted-host', 'lab.internal', 'lab-2.internal',
|
||||
'--trusted-host', '10.0.0.9',
|
||||
])
|
||||
expect(values).toEqual({
|
||||
host: '0.0.0.0',
|
||||
port: 8080,
|
||||
mode: 'development',
|
||||
trustedHosts: ['lab.internal', 'lab-2.internal', '10.0.0.9'],
|
||||
})
|
||||
expect(observed.readerConfig).toEqual(values)
|
||||
@@ -107,11 +104,10 @@ describe('web command-line provider', () => {
|
||||
|
||||
it('leaves deployment values to each consumer when flags omit them', async () => {
|
||||
const { values, observed } = await bootProvider([])
|
||||
expect(values).toEqual({ mode: 'production', trustedHosts: [] })
|
||||
expect(values).toEqual({ trustedHosts: [] })
|
||||
expect(observed.readerConfig).toEqual({
|
||||
host: '127.0.0.1',
|
||||
port: 3080,
|
||||
mode: 'production',
|
||||
trustedHosts: [],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -58,17 +58,9 @@ function fakeHttpServer(host: '127.0.0.1' | '0.0.0.0' = '127.0.0.1'): { server:
|
||||
return { server, seat: () => fallback }
|
||||
}
|
||||
|
||||
/** Install the optional HMR row the runtime sequences before client discovery. */
|
||||
function provideHmrRow(ctx: Context, settle: () => Promise<void> = async () => {}): string[] {
|
||||
const updates: string[] = []
|
||||
ctx.provide('loader', {
|
||||
entries: () => [{
|
||||
options: { id: 'client-hmr' },
|
||||
enableRuntime: async () => { updates.push('client-hmr') },
|
||||
}],
|
||||
await: settle,
|
||||
} as never)
|
||||
return updates
|
||||
/** A fake Loader whose settlement the test controls (the URL line waits on it). */
|
||||
function provideLoader(ctx: Context, settle: () => Promise<void> = async () => {}): void {
|
||||
ctx.provide('loader', { await: settle } as never)
|
||||
}
|
||||
|
||||
interface BashContribution {
|
||||
@@ -90,15 +82,14 @@ describe('web-app runtime glue', () => {
|
||||
return () => {}
|
||||
},
|
||||
} as never)
|
||||
const enabledRows = provideHmrRow(ctx)
|
||||
provideLoader(ctx)
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
await apply(ctx, new Config({ mode: 'development', printUrl: true, surfaceContext: true, trustedHosts: ['lab.internal'] }))
|
||||
apply(ctx, new Config({ printUrl: true, surfaceContext: true, trustedHosts: ['lab.internal'] }))
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
// Settle the injected registrations.
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
expect(seat()).toBeDefined() // frontend-static claimed the fallback
|
||||
expect(enabledRows).toEqual(['client-hmr'])
|
||||
expect(ctx.get('webRuntime')).toEqual({
|
||||
lanAddresses: ['192.168.1.5'],
|
||||
trustedHosts: ['192.168.1.5', 'lab.internal'],
|
||||
@@ -108,24 +99,26 @@ describe('web-app runtime glue', () => {
|
||||
expect(assembly.sections.find(entry => entry.name === 'harness:source')?.text).toContain('DeepSeek Harness implementation checkout')
|
||||
const section = assembly.sections.find(entry => entry.name === 'app:web-surface')
|
||||
expect(section?.text).toContain('http://127.0.0.1:4567')
|
||||
expect(section?.text).toContain('--dev')
|
||||
// The single update contract: the receiver is always on; no-refresh
|
||||
// reloads additionally need the rebuild watcher.
|
||||
expect(section?.text).toContain('pnpm run dev:web')
|
||||
const webRuntime = contributions.find(contribution => contribution.name === 'web-runtime')
|
||||
expect(webRuntime?.resolve()).toEqual({ DSH_WEB_URL: 'http://127.0.0.1:4567', DSH_WEB_MODE: 'development' })
|
||||
expect(webRuntime?.resolve()).toEqual({ DSH_WEB_URL: 'http://127.0.0.1:4567' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('stays quiet in production mode with printUrl off and reports the production update contract', async () => {
|
||||
it('stays quiet with printUrl off', async () => {
|
||||
stageDist()
|
||||
const ctx = new Context()
|
||||
ctx.provide('httpServer', fakeHttpServer().server)
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, trustedHosts: [] }))
|
||||
apply(ctx, new Config({ printUrl: false, surfaceContext: true, trustedHosts: [] }))
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(log).not.toHaveBeenCalled()
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
expect(assembly.sections.find(entry => entry.name === 'app:web-surface')?.text)
|
||||
.toContain('without `--dev`')
|
||||
.toContain('rebuilding the affected Web artifacts')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -140,7 +133,7 @@ describe('web-app runtime glue', () => {
|
||||
return () => {}
|
||||
},
|
||||
} as never)
|
||||
await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: false, trustedHosts: [] }))
|
||||
apply(ctx, new Config({ printUrl: false, surfaceContext: false, trustedHosts: [] }))
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
@@ -155,7 +148,7 @@ describe('web-app runtime glue', () => {
|
||||
const ctx = new Context()
|
||||
ctx.provide('httpServer', fakeHttpServer().server)
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
await apply(ctx, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] }))
|
||||
apply(ctx, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] }))
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567')
|
||||
await ctx.fiber.dispose()
|
||||
@@ -169,9 +162,9 @@ describe('web-app runtime glue', () => {
|
||||
settled.provide('httpServer', fakeHttpServer().server)
|
||||
let release: () => void
|
||||
const settlement = new Promise<void>((resolve) => { release = resolve })
|
||||
provideHmrRow(settled, () => settlement)
|
||||
provideLoader(settled, () => settlement)
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
await apply(settled, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] }))
|
||||
apply(settled, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] }))
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(log).not.toHaveBeenCalled()
|
||||
release!()
|
||||
@@ -184,8 +177,8 @@ describe('web-app runtime glue', () => {
|
||||
log.mockClear()
|
||||
const failed = new Context()
|
||||
failed.provide('httpServer', fakeHttpServer().server)
|
||||
provideHmrRow(failed, async () => { throw new Error('boot failed') })
|
||||
await apply(failed, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] }))
|
||||
provideLoader(failed, async () => { throw new Error('boot failed') })
|
||||
apply(failed, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] }))
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(log).not.toHaveBeenCalled()
|
||||
await failed.fiber.dispose()
|
||||
@@ -200,8 +193,8 @@ describe('web-app runtime glue', () => {
|
||||
await child
|
||||
let releaseTorn: () => void
|
||||
const tornSettlement = new Promise<void>((resolve) => { releaseTorn = resolve })
|
||||
provideHmrRow(torn, () => tornSettlement)
|
||||
await apply(torn, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] }))
|
||||
provideLoader(torn, () => tornSettlement)
|
||||
apply(torn, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] }))
|
||||
await child.dispose() // the httpServer service goes away
|
||||
releaseTorn!()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
@@ -217,7 +210,7 @@ describe('web-app runtime glue', () => {
|
||||
const { server } = fakeHttpServer()
|
||||
Object.defineProperty(server, 'port', { get: () => undefined })
|
||||
ctx.provide('httpServer', server)
|
||||
await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, trustedHosts: [] }))
|
||||
apply(ctx, new Config({ printUrl: false, surfaceContext: true, trustedHosts: [] }))
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
await expect(ctx.systemPrompt.assemble()).rejects.toThrow('httpServer service missing')
|
||||
|
||||
@@ -2867,8 +2867,8 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
return Promise.resolve({ accepted: true })
|
||||
},
|
||||
// Satisfies the ApiProxy contract type only: the browser export button
|
||||
// fetches GET /api/session.export directly (window.fetch), so this stub is
|
||||
// never reached through the fixture's dispatch.
|
||||
// hands GET /api/session.export to the native download manager, so this
|
||||
// stub is never reached through the fixture's dispatch.
|
||||
downloads: {
|
||||
sessionLog: () => Promise.resolve(new Response('fixture mode does not serve session export', { status: 404 })),
|
||||
},
|
||||
|
||||
@@ -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: 9228292547376d3fbb0ea5ce56b9e0a35ced17b2
|
||||
README.zh.md: ea62600911458556a3dcc7c46854e97db751c3ef
|
||||
README.md: c355595dd53ddcb74be629a6d5e730c6c5fcebbf
|
||||
README.zh.md: 6ed4d0e79cb755f84784823749994b448ff209b8
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Hot reload for script-loaded client plugins. A static-arrival entry composed only into `--dev` graphs (`dsh web --dev`); production graphs omit the row, so the shell-bundled code stays inert.
|
||||
Hot reload for script-loaded client plugins. The web bundle mounts the row unconditionally; without a rebuild watcher (`pnpm run dev:web`) rewriting client bundles, the poll observes no changes and the chain stays idle.
|
||||
|
||||
The browser half subscribes to the system SSE channel (`GET /plugins/events`) and reloads one plugin per `rebuilt` frame through a serialized queue. The sequence per frame — `invalidate`, `prefetch` (load and register the new bundle while the old fiber still serves), `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `<style data-plugin>` tags, `entry.refresh()` re-imports and remounts, `fiber.await()` rethrows startup failures loud. Dependents reload through cordis itself: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber cascades every dependent with zero client-side graph analysis. The node half detects rebuilds with one interval that stat-polls each graph bundle from a synchronous baseline, immediately re-hashes after adding a row, retains missing rows as dirty, and broadcasts only real rev changes; any tsdown watch process producing the bundle therefore triggers HMR with no builder→host channel.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
为通过脚本加载的客户端插件提供热重载。该静态加载配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略该项,因此打包进 shell 的代码保持不活动。
|
||||
为通过脚本加载的客户端插件提供热重载。web 组合包无条件挂载该行;没有重建 watcher(`pnpm run dev:web`)改写客户端 bundle 时,轮询观察不到变化,链路保持空闲。
|
||||
|
||||
浏览器侧订阅系统 SSE(Server-Sent Events)通道(`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行。每帧的顺序是:`invalidate`、`prefetch`(旧 fiber 仍在服务时加载并注册新组合包)、`registry.delete`(在 fiber dispose(资源释放)之前执行:仅 dispose fiber 会触发 vendored Loader 的 self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签、通过 `entry.refresh()` 重新导入并挂载、通过 `fiber.await()` 直接重新抛出启动失败。依赖方由 Cordis 自身重载:fiber 的激活 epoch 会串联其服务提供方的 uid,因此替换提供方 fiber 会级联所有依赖方,无需客户端图分析。node 侧使用一个 interval 检测重建:从同步基线开始 stat-poll 每个图组合包;新增一行后立即重新计算 hash;缺失行保持 dirty;只广播真实 rev 变更。因此,任何生成组合包的 tsdown watch 进程都能触发 HMR(热模块替换),无需 builder→host 通道。
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
* mounts deliver no inotify events), reports content changes through
|
||||
* `clientModuleHost.rebuilt(id)`, and serves the `/plugins/events` SSE channel
|
||||
* broadcasting graph/rebuilt frames to the browser half (src/client/).
|
||||
* Dev-only row: prod compositions never mount this plugin.
|
||||
* The web bundle mounts this row unconditionally: without a rebuild
|
||||
* watcher rewriting client bundles, the poll observes no changes and the
|
||||
* chain stays idle.
|
||||
*/
|
||||
import { statSync } from 'node:fs'
|
||||
import type { ServerResponse } from 'node:http'
|
||||
|
||||
@@ -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: ef07addd9955ee6158013f3932ea407969ddf314
|
||||
README.zh.md: 1d154d1470a7864a4bdbb4b36c5143fa23a9dc68
|
||||
README.md: e5eadf7757794f3197a42f470e4429c69c64ee4c
|
||||
README.zh.md: 7ebb89910daf3fc154939af3eed31ca9151d0a3c
|
||||
|
||||
@@ -33,6 +33,8 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
|
||||
|
||||
`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone, so a cwd-matching unaccounted blank session is never hijacked) or calls `session.create({workspaceId})`, returning the session id for the caller to open. The shared `startSession` action targets an explicit Workspace first, then the current Session's Workspace, then the derived recent Workspace; with no Workspace it clears into the blank New Session page. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure.
|
||||
|
||||
`Session.composerPhase` treats any visible non-command Chat Node as conversation content, so a client plugin can project durable human input without opening a turn while a window containing only generic command rows retains the Host blank posture. List hiding and blank-session reuse still follow the Host blank bit. A history window that lacks the plugin-owned input Node returns to that blank posture until an older page restores it.
|
||||
|
||||
## Pending queue projection
|
||||
|
||||
`ConversationSnapshot.queue` is the Host's authoritative transient snapshot of `agent.inbox.nextTurn`; pending next-step steering stays outside this projection. Each row carries its `MessageId`, complete editable text when every content block is text, and a flattened preview. The Host derives whole `session/queue` snapshots from durable `agent/inbox/spliced` mutations and sends a baseline on reconnect; the message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications are not used to reconstruct this projection. `Session.updateQueue()` sends edit/remove operations through Host-side `Inbox.splice()` without optimistic client mutation, so the next Host snapshot is the sole visible commit and a claim race can surface `queue-item-not-found`.
|
||||
|
||||
@@ -33,6 +33,8 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
|
||||
|
||||
`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd,避免劫持 cwd 匹配但未入账的空白会话),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。共享的 `startSession` 操作优先使用明确指定的 Workspace,其次使用当前 Session 所属 Workspace,再其次使用派生的最近活跃 Workspace;一个 Workspace 都没有时则清空选择,进入空白 New Session 页面。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表界面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。
|
||||
|
||||
`Session.composerPhase` 把任何可见的非命令 Chat Node 视为对话内容,因此客户端插件可以在不打开轮次的情况下投影持久用户输入,而仅包含通用命令行的窗口仍保持 Host blank 状态。列表隐藏和空白会话复用仍遵循 Host blank 位。缺少插件输入 Node 的历史窗口会恢复该空白状态,直到加载更早页面后该 Node 恢复。
|
||||
|
||||
## 待处理队列投影
|
||||
|
||||
`ConversationSnapshot.queue` 是 Host 提供的 `agent.inbox.nextTurn` 权威瞬态快照;待处理的 next-step steering(中途引导)不进入此投影。每行携带其 `MessageId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。Host 根据持久 `agent/inbox/spliced` 变更派生完整 `session/queue` 快照,并在重连时发送基线;面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知不用于重建该投影。`Session.updateQueue()` 经 Host 侧 `Inbox.splice()` 发送编辑/移除操作,客户端不做乐观变更,因此下一份 Host 快照是唯一可见的提交结果,claim 竞态则会返回 `queue-item-not-found`。
|
||||
|
||||
@@ -324,8 +324,9 @@ export type OpenState = 'cold' | 'loading' | 'open' | 'error'
|
||||
* - `engaging`: a first prompt was attempted, but no accepted turn or other
|
||||
* authoritative activity signal has arrived — the UI keeps the composer
|
||||
* visible through admission and error frames.
|
||||
* - `active`: the session is non-blank beyond its pending first prompt, is
|
||||
* running, or owns a pending interaction — the ordinary conversation view.
|
||||
* - `active`: the session is non-blank beyond its pending first prompt,
|
||||
* contains visible non-command Chat content, is running, or owns a pending
|
||||
* interaction — the ordinary conversation view.
|
||||
*
|
||||
* A failed first prompt stays `engaging` (composer + error strip — retry
|
||||
* semantics; returning to the hero would discard the error context).
|
||||
|
||||
@@ -741,7 +741,8 @@ export class Session implements SessionFace {
|
||||
? null
|
||||
: { address: this.address, parentAvailable: this.parentAvailable },
|
||||
composerPhase: derivePhase(
|
||||
(!this.blankBit && !this.firstPromptPendingTurn)
|
||||
hasVisibleConversationContent(chat)
|
||||
|| (!this.blankBit && !this.firstPromptPendingTurn)
|
||||
|| this.running
|
||||
|| this.pendingCache.value.length > 0,
|
||||
this.promptAttempted,
|
||||
@@ -774,13 +775,18 @@ function conversationInput(entry: HistoryEntry): ConversationEventInput {
|
||||
return { event: entry.event, view: entry.view }
|
||||
}
|
||||
|
||||
/** A generic command row alone remains control-plane content; every other visible Chat Node activates the conversation. */
|
||||
function hasVisibleConversationContent(chat: ChatSnapshot): boolean {
|
||||
return chat.order.some(key => chat.nodes.get(key)?.kind !== 'command')
|
||||
}
|
||||
|
||||
/**
|
||||
* The composerPhase judgment — the single site that knows the predicate
|
||||
* (consumers switch on the result, never re-derive). A failed first prompt
|
||||
* stays engaging until an authoritative accepted-turn, running, or pending
|
||||
* signal arrives (retry semantics — see ComposerPhase).
|
||||
* @param hasContent - authoritative non-blank activity beyond a pending first
|
||||
* prompt, a running turn, or a pending interaction.
|
||||
* prompt, visible non-command Chat content, a running turn, or a pending interaction.
|
||||
* @param promptAttempted - a prompt was initiated on this session object.
|
||||
* @returns the derived phase.
|
||||
*/
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {} from '@deepseek-ai/dsh-commands/types'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { Session } from '../src/client/sessions/session.ts'
|
||||
import type {
|
||||
@@ -132,7 +133,11 @@ const TEST_EVENT_DEFINITION: ConversationNodeDefinition<TestEventState> = {
|
||||
if (context.state === undefined || context.start === undefined) return null
|
||||
return {
|
||||
key: context.key,
|
||||
kind: 'runtime-test-event',
|
||||
kind: context.start.event.type === 'command/run' && context.start.event.data.name === 'goal'
|
||||
? 'command-input'
|
||||
: context.start.event.type === 'command/run' || context.start.event.type === 'command/done'
|
||||
? 'command'
|
||||
: 'runtime-test-event',
|
||||
id: context.id,
|
||||
target: 'chat',
|
||||
anchorSeq: context.start.event.seq,
|
||||
@@ -272,6 +277,24 @@ describe('live event path', () => {
|
||||
expect(snapshot.composerPhase).toBe('blank')
|
||||
})
|
||||
|
||||
it('activates a fresh conversation for a command-input View Node without opening a model turn', async () => {
|
||||
const { session } = await opened([])
|
||||
session.handleBlank(true)
|
||||
const feed = (event: SessionEvent) => {
|
||||
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event })
|
||||
}
|
||||
feed(ev.commandRun(0, 'cmd-goal', 'goal', ' '))
|
||||
feed(ev.commandDone(1, 'cmd-goal', 'success', 'No goal is currently set.'))
|
||||
|
||||
expect(session.getSnapshot()).toMatchObject({
|
||||
blank: true,
|
||||
composerPhase: 'active',
|
||||
})
|
||||
expect(session.getSnapshot().chat.order.map(
|
||||
key => session.getSnapshot().chat.nodes.get(key)?.kind,
|
||||
)).toContain('command-input')
|
||||
})
|
||||
|
||||
it('publishes animation-frame Definitions once per frame and lets an immediate event supersede the pending frame', async () => {
|
||||
const frames: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-goal/README.md
|
||||
README.md: f0446aa0637bc181f7fdc22e5d0d3192e0ac20cf
|
||||
README.zh.md: 1ad9f50aee5b103f6455e4d4b7d29fa9eb29a108
|
||||
README.md: c79d6f5a68f1b4b40f4b57f5745feeed63a25fcd
|
||||
README.zh.md: c2d000dd8141a989c67f2e8dc6786ed2b5067a6b
|
||||
|
||||
@@ -4,6 +4,8 @@ English | [中文](README.zh.md)
|
||||
|
||||
Goal surface plugin, browser half: the `GoalBar` strip is the second standalone card in the `conversation.input.dock` composer-context stack (order 10, after Todo and before Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no domain store, refresh chain, or event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear through `ctx.remote.goals` — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the rejected Remote error inline. The strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing.
|
||||
|
||||
The plugin separately projects each durable `/goal` `command/run` through its own Conversation Definition. It builds a `command-input` Chat Node before the generic command result Node and registers that Node's keyed renderer as a right-aligned 14px/22px monospace user-style bubble with the localized group name `Command input` / `命令输入` and no timestamp, copy, or branch actions. The visible non-command Node activates fresh Chat; reload reconstructs it from the run, while a history window containing only `command/done` keeps only the generic result row. This projection never creates `user/message` or a model turn.
|
||||
|
||||
The `/client` exports are the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
Goal 界面插件(浏览器端部分):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第二张独立卡片(order 10,位于 Todo 之后、Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,经 `ctx.remote.goals` 调用——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并将 Remote 调用的拒绝错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。
|
||||
|
||||
该插件还会通过自有 Conversation Definition 投影每条持久 `/goal` `command/run`。它在通用命令结果 Node 之前构建一个 `command-input` Chat Node,并为该 Node 注册 keyed renderer;renderer 将其呈现为右对齐、使用 14px/22px 等宽字体的用户样式气泡,使用本地化分组名称 `Command input`/`命令输入`,且不含时间戳、复制或分支操作。可见的非命令 Node 会激活新 Chat;重新加载时会根据 run 重建该 Node,而仅包含 `command/done` 的历史窗口只保留通用结果行。该投影绝不会创建 `user/message` 或模型轮次。
|
||||
|
||||
`/client` 的导出接口包括插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
@@ -65,6 +66,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
min-width: 0;
|
||||
max-width: min(525px, 82%);
|
||||
}
|
||||
|
||||
.bubble {
|
||||
max-width: 100%;
|
||||
padding: 10px 16px;
|
||||
overflow-wrap: anywhere;
|
||||
border-radius: 22px;
|
||||
background: var(--dsw-specific-bubble);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: var(--dsw-font-markdown-code);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
30
packages/client/ui-goal/src/client/GoalCommandInputView.tsx
Normal file
30
packages/client/ui-goal/src/client/GoalCommandInputView.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { memo } from 'react'
|
||||
import { MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { GoalCommandInputData } from './goal-command-input.ts'
|
||||
import css from './GoalCommandInputView.module.css'
|
||||
|
||||
type GoalCommandInputViewProps =
|
||||
PropsRuntime<'conversation.chat.node', 'command-input'>
|
||||
& PropsLocale<'goal'>
|
||||
|
||||
/** Right-aligned `/goal` input bubble without ordinary message actions. */
|
||||
export const GoalCommandInputView = memo(function GoalCommandInputView({
|
||||
node, t,
|
||||
}: GoalCommandInputViewProps) {
|
||||
const data: GoalCommandInputData = node.data
|
||||
return (
|
||||
<div
|
||||
className={css.row}
|
||||
data-command-input=""
|
||||
role="group"
|
||||
aria-label={t('commandInput.aria')}
|
||||
>
|
||||
<div className={css.stack}>
|
||||
<div className={css.bubble}>
|
||||
<MessageText text={data.text} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
71
packages/client/ui-goal/src/client/goal-command-input.ts
Normal file
71
packages/client/ui-goal/src/client/goal-command-input.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type {} from '@deepseek-ai/dsh-commands/types'
|
||||
import type {
|
||||
ConversationNodeDefinition,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Goal-owned human command input projected independently of model messages. */
|
||||
export interface GoalCommandInputData {
|
||||
readonly commandId: CommandId
|
||||
readonly text: string
|
||||
readonly time: number
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
interface ChatNodeDataMap {
|
||||
/** Human-entered `/goal` command input. */
|
||||
'command-input': GoalCommandInputData
|
||||
}
|
||||
}
|
||||
|
||||
interface GoalCommandInputState extends GoalCommandInputData {
|
||||
readonly seq: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the visible command line from its structured durable run.
|
||||
* @param event - `/goal` command run.
|
||||
* @returns command text with trailing parser whitespace removed.
|
||||
*/
|
||||
export function goalCommandText(event: SessionEvent<'command/run'>): string {
|
||||
return `/${event.data.name}${(event.data.args ?? '').trimEnd()}`
|
||||
}
|
||||
|
||||
/** Goal-owned command input projection; the generic command Definition retains the result row. */
|
||||
export const goalCommandInputDefinition: ConversationNodeDefinition<GoalCommandInputState> = {
|
||||
kind: 'goal-command-input',
|
||||
target: 'chat',
|
||||
match: event => event.type === 'command/run' && event.data.name === 'goal'
|
||||
? { id: String(event.data.commandId), role: 'start' }
|
||||
: null,
|
||||
start: (_context, match) => {
|
||||
if (match.event.type !== 'command/run') {
|
||||
throw new Error('goal-command-input start requires command/run')
|
||||
}
|
||||
return {
|
||||
commandId: match.event.data.commandId,
|
||||
seq: match.event.seq,
|
||||
time: match.event.time,
|
||||
text: goalCommandText(match.event),
|
||||
}
|
||||
},
|
||||
update: context => context.state,
|
||||
buildViewNode: (context) => {
|
||||
if (context.state === undefined) return null
|
||||
return {
|
||||
key: context.key,
|
||||
kind: 'command-input',
|
||||
id: context.id,
|
||||
target: 'chat',
|
||||
anchorSeq: context.state.seq - 0.1,
|
||||
location: context.start?.location ?? { kind: 'unresolved' },
|
||||
visibility: 'visible',
|
||||
data: {
|
||||
commandId: context.state.commandId,
|
||||
text: context.state.text,
|
||||
time: context.state.time,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { GoalProjection, GoalRef } from '@deepseek-ai/dsh-goal/client'
|
||||
import type { GoalActionResult, GoalBarActions } from './slots.ts'
|
||||
import { GoalDock } from './GoalBar.tsx'
|
||||
import { GoalCommandInputView } from './GoalCommandInputView.tsx'
|
||||
import { goalCommandInputDefinition } from './goal-command-input.ts'
|
||||
import { en, zh, type GoalKey } from './locales.ts'
|
||||
|
||||
export { GoalBar, GoalDock } from './GoalBar.tsx'
|
||||
@@ -35,8 +37,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
/** Dictionary namespace owned by this plugin. */
|
||||
const NS = 'goal'
|
||||
|
||||
/** Required services: slots for the dock entry, sessions for the projected ref, API for Remote mutations, locale for the copy. */
|
||||
export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale']
|
||||
/** Required services for the Goal dock, command-input projection, Remote mutations, and copy. */
|
||||
export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale', 'conversationEvents']
|
||||
|
||||
/** Map one generated Remote call, including synchronous namespace lookup failures, to the fields rendered by the goal strip. */
|
||||
async function settle(invoke: () => Promise<unknown>): Promise<GoalActionResult> {
|
||||
@@ -68,8 +70,15 @@ function isRemoteError(value: unknown): value is { readonly code: string; readon
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.conversationEvents.register(goalCommandInputDefinition)
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-goal: dictionaries')
|
||||
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
|
||||
name: 'conversation.chat.node',
|
||||
key: 'command-input',
|
||||
locale: NS,
|
||||
}, GoalCommandInputView))
|
||||
|
||||
const sessions = ctx.sessions
|
||||
|
||||
/** The session's current projected CAS ref, read at verb call time (no staleness fence: the RPC's CAS is the guard). */
|
||||
|
||||
@@ -6,6 +6,7 @@ export const zh = {
|
||||
'phase.paused': '已暂停的目标',
|
||||
'phase.blocked': '受阻的目标',
|
||||
'objective.aria': '目标内容',
|
||||
'commandInput.aria': '命令输入',
|
||||
'action.save': '保存目标',
|
||||
'action.cancel': '取消编辑',
|
||||
'action.pause': '暂停目标',
|
||||
@@ -23,6 +24,7 @@ export const en = {
|
||||
'phase.paused': 'Paused Goal',
|
||||
'phase.blocked': 'Blocked Goal',
|
||||
'objective.aria': 'Goal objective',
|
||||
'commandInput.aria': 'Command input',
|
||||
'action.save': 'Save goal',
|
||||
'action.cancel': 'Cancel edit',
|
||||
'action.pause': 'Pause goal',
|
||||
|
||||
@@ -15,6 +15,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { afterEach } from 'vitest'
|
||||
import { SlotsService, type SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { ConversationEventRegistry } from '@deepseek-ai/dsh-client-runtime/src/client/conversation/event-registry.ts'
|
||||
import type { GoalProjection } from '@deepseek-ai/dsh-goal/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
@@ -52,6 +53,7 @@ async function bench(options: {
|
||||
} = {}) {
|
||||
const ctx = new Context()
|
||||
const calls: { method: string; args: unknown[] }[] = []
|
||||
const conversationEvents = new ConversationEventRegistry(ctx)
|
||||
function answer<T>(method: string, value: T) {
|
||||
return (...args: unknown[]) => {
|
||||
calls.push({ method, args })
|
||||
@@ -85,7 +87,10 @@ async function bench(options: {
|
||||
})
|
||||
await ctx.plugin(SlotsService).await()
|
||||
ctx.slots.register({
|
||||
name: 'root', children: { 'conversation.input.dock': { kind: 'list', scope: 'session' } },
|
||||
name: 'root', children: {
|
||||
'conversation.input.dock': { kind: 'list', scope: 'session' },
|
||||
'conversation.chat.node': { kind: 'keyed', scope: 'session' },
|
||||
},
|
||||
} as never, (() => null) as never)
|
||||
ctx.provide('locale', new LocaleService(ctx))
|
||||
ctx.provide('sessions', {
|
||||
@@ -103,6 +108,7 @@ async function bench(options: {
|
||||
ctx,
|
||||
fiber,
|
||||
calls,
|
||||
definitions: () => conversationEvents.entries(),
|
||||
remountGoals: () => { activeGoals = goals('remounted-goals') },
|
||||
unmountGoals: () => { activeGoals = undefined },
|
||||
entry: () => {
|
||||
@@ -114,15 +120,19 @@ async function bench(options: {
|
||||
inject: entry.inject as unknown as ((sessionId: SessionId) => GoalBarActions) | undefined,
|
||||
}
|
||||
},
|
||||
chatEntry: () => ctx.slots.entries('conversation.chat.node')[0],
|
||||
}
|
||||
}
|
||||
|
||||
describe('ui-goal browser plugin', () => {
|
||||
it('registers the GoalBar dock entry with the documented id and order', async () => {
|
||||
it('registers the GoalBar dock, command input Definition, and keyed Chat renderer', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
expect(b.entry()).toMatchObject({ id: 'goal', order: 10, locale: 'goal' })
|
||||
expect(b.entry()?.inject).toBeTypeOf('function')
|
||||
expect(b.definitions().map(definition => definition.kind)).toEqual(['goal-command-input'])
|
||||
expect(b.chatEntry()?.options).toMatchObject({ key: 'command-input' })
|
||||
expect(b.chatEntry()?.locale).toBe('goal')
|
||||
})
|
||||
|
||||
it('verbs read the CAS ref from the current projected value at call time', async () => {
|
||||
@@ -199,8 +209,12 @@ describe('ui-goal browser plugin', () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
expect(b.entry()).toBeDefined()
|
||||
expect(b.chatEntry()).toBeDefined()
|
||||
expect(b.definitions()).toHaveLength(1)
|
||||
await b.fiber.dispose()
|
||||
expect(b.entry()).toBeUndefined()
|
||||
expect(b.chatEntry()).toBeUndefined()
|
||||
expect(b.definitions()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
134
packages/client/ui-goal/tests/goal-command-input.spec.tsx
Normal file
134
packages/client/ui-goal/tests/goal-command-input.spec.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, within } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
ChatConversationViewNode, ChatSnapshot, ConversationEventInput,
|
||||
ConversationNodeDefinition, ConversationViewDefinition,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { commandDefinition } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation-nodes/command.ts'
|
||||
import { chatViewDefinition } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts'
|
||||
import { GoalCommandInputView } from '../src/client/GoalCommandInputView.tsx'
|
||||
import {
|
||||
goalCommandInputDefinition, goalCommandText,
|
||||
} from '../src/client/goal-command-input.ts'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
class TestEventDefinitions {
|
||||
entries(): readonly ConversationNodeDefinition[] {
|
||||
return [commandDefinition, goalCommandInputDefinition]
|
||||
}
|
||||
|
||||
fallbackEntry(): undefined {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
class TestViewDefinitions {
|
||||
entries(): readonly ConversationViewDefinition[] {
|
||||
return [chatViewDefinition]
|
||||
}
|
||||
}
|
||||
|
||||
function entry(seq: number, type: string, data: unknown): ConversationEventInput {
|
||||
return {
|
||||
event: { seq, time: 1_700_000_000_000 + seq, type, data } as ConversationEventInput['event'],
|
||||
view: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function snapshot(entries: readonly ConversationEventInput[], hasMore = false): ChatSnapshot {
|
||||
const assembler = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions())
|
||||
assembler.replaceWindow(entries, hasMore)
|
||||
assembler.flush()
|
||||
const value = assembler.snapshot('chat') as ChatSnapshot | undefined
|
||||
if (value === undefined) throw new Error('chat view was not registered')
|
||||
return value
|
||||
}
|
||||
|
||||
function node(value: ChatSnapshot, kind: string): ChatConversationViewNode | undefined {
|
||||
return value.nodes.values().find(candidate => candidate.kind === kind)
|
||||
}
|
||||
|
||||
describe('goal command input projection', () => {
|
||||
it('builds a separate input Node before the generic command result and restores it on replay', () => {
|
||||
const run = entry(1, 'command/run', {
|
||||
commandId: 'command-goal', name: 'goal', args: ' ', source: { kind: 'user' },
|
||||
})
|
||||
const done = entry(2, 'command/done', {
|
||||
commandId: 'command-goal', kind: 'success', text: 'No goal is currently set.',
|
||||
})
|
||||
const value = snapshot([run, done])
|
||||
|
||||
expect(value.order.map(key => value.nodes.get(key)?.kind)).toEqual(['command-input', 'command'])
|
||||
expect(node(value, 'command-input')).toMatchObject({
|
||||
anchorSeq: 0.9,
|
||||
data: { commandId: 'command-goal', text: '/goal' },
|
||||
})
|
||||
expect(node(value, 'command')?.data).toMatchObject({
|
||||
name: 'goal', args: ' ', outcome: { kind: 'success', text: 'No goal is currently set.' },
|
||||
})
|
||||
|
||||
const doneOnly = snapshot([done], true)
|
||||
expect(node(doneOnly, 'command-input')).toBeUndefined()
|
||||
expect(node(doneOnly, 'command')?.data).toMatchObject({ name: null, args: null })
|
||||
})
|
||||
|
||||
it('ignores other commands and preserves internal multiline arguments', () => {
|
||||
const plan = entry(1, 'command/run', {
|
||||
commandId: 'command-plan', name: 'plan', args: '', source: { kind: 'user' },
|
||||
})
|
||||
const goal = entry(2, 'command/run', {
|
||||
commandId: 'command-goal', name: 'goal', args: '\nfirst line\nsecond line \n', source: { kind: 'user' },
|
||||
})
|
||||
|
||||
expect(goalCommandInputDefinition.match(plan.event)).toBeNull()
|
||||
expect(goalCommandText(goal.event as SessionEvent<'command/run'>))
|
||||
.toBe('/goal\nfirst line\nsecond line')
|
||||
})
|
||||
|
||||
it('keeps the Definition total across required interface and window fallback paths', () => {
|
||||
const run = entry(3, 'command/run', {
|
||||
commandId: 'command-goal', name: 'goal', source: { kind: 'user' },
|
||||
})
|
||||
const match = {
|
||||
...run,
|
||||
role: 'start' as const,
|
||||
location: { kind: 'session' as const },
|
||||
}
|
||||
const state = goalCommandInputDefinition.start({} as never, match, {} as never)
|
||||
|
||||
expect(state.text).toBe('/goal')
|
||||
expect(goalCommandInputDefinition.update({ state } as never, match)).toBe(state)
|
||||
expect(goalCommandInputDefinition.buildViewNode!({ state: undefined } as never)).toBeNull()
|
||||
expect(goalCommandInputDefinition.buildViewNode!({
|
||||
key: 'goal-command-input', id: 'command-goal', state, start: undefined,
|
||||
} as never)).toMatchObject({ location: { kind: 'unresolved' } })
|
||||
|
||||
const done = entry(4, 'command/done', { commandId: 'command-goal', kind: 'success' })
|
||||
expect(() => goalCommandInputDefinition.start({} as never, {
|
||||
...done, role: 'start', location: { kind: 'session' },
|
||||
} as never, {} as never)).toThrow('goal-command-input start requires command/run')
|
||||
})
|
||||
|
||||
it('renders the user-style command bubble without ordinary message actions', () => {
|
||||
const t = makeTranslate(zh, commonZh)
|
||||
const props = {
|
||||
node: {
|
||||
key: 'goal-command-input:one',
|
||||
data: { commandId: 'command-goal', text: '/goal ship it', time: 1_700_000_000_000 },
|
||||
},
|
||||
t,
|
||||
} as unknown as Parameters<typeof GoalCommandInputView>[0]
|
||||
const view = render(<GoalCommandInputView {...props} />)
|
||||
const bubble = view.getByRole('group', { name: '命令输入' })
|
||||
|
||||
expect(bubble.textContent).toBe('/goal ship it')
|
||||
expect(within(bubble).queryByRole('button')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -29,6 +29,9 @@
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../../interaction/commands"
|
||||
},
|
||||
{
|
||||
"path": "../../goal/goal"
|
||||
},
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-theme/README.md
|
||||
README.md: cab9961a6d703d600a856e71339cda7062d20d62
|
||||
README.zh.md: 81b64c356749b6ffe12694b218e92eaa483ff739
|
||||
README.md: df4d5e0370962bf6f2a8ac0a7b88d669225dc5c5
|
||||
README.zh.md: e0f614645a16b44e374e450bb8dd1e3c5805ea42
|
||||
|
||||
@@ -4,6 +4,8 @@ English | [中文](README.zh.md)
|
||||
|
||||
Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the live theme preference (`light`/`dark`/`system`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`html { color-scheme }`, `body[data-ds-dark-theme]`, and inline alias tokens). A loopback browser provides the service immediately with `system`, then loads `ui-theme.preference` in the background and writes each built-in selection through the Host settings API, whose local provider stores it in `$DSH_HOME/settings.yaml` by default; pushed settings changes and reconnects refetch it, rapid selections are serialized in gesture order with namespace revisions, and a rejected latest write reloads the durable value. A remote browser cannot access the privileged settings API, so its selection remains process-local. Third-party registered theme ids remain an in-process extension and do not cross the built-in settings schema; removing one never overwrites the last durable built-in preference. The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary.
|
||||
|
||||
When the host composition includes an HTTP server, the host half injects a synchronous bootstrap immediately after the opening `<body>` tag. Each index response embeds the registered Host setting for `ui-theme.preference`, or `system` when no settings provider is present; the browser resolves `system` from the OS scheme, then sets `color-scheme` and `body[data-ds-dark-theme]` before the shell loading page renders. Compositions without an HTTP server remain unaffected, and ThemeService and ui-layout remain authoritative for client state and subsequent DOM updates after the plugin tree activates.
|
||||
|
||||
`src/styles/` holds five sheets, all imported by the web shell's `base.css`: `base.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. `scrollbar.css` is the sole consumer of the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them.
|
||||
|
||||
Scrollbar rebinding contract: `scrollbar.css` binds `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover` on `body` to the l1 (base-surface) tokens, and both rendering paths read that pair. An elevated surface (menu, popover, dialog) sets `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` and `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)` on its own container; one rebind retints whichever path the engine took. The pair's other legal target is `transparent`, which draws no thumb at all — [ui-sidebar](../ui-sidebar/README.md) rebinds its column that way while the pointer is elsewhere. A rebind to the l1 pair is not a rebind; it restates the base-surface default.
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有实时主题偏好(`light`/`dark`/`system`),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(`html { color-scheme }`、`body[data-ds-dark-theme]`,以及主题的别名 token 内联变量)。来自回环地址的浏览器会先以 `system` 立即提供该服务,随后在后台加载 `ui-theme.preference`,并将每次内置主题选择通过 Host settings API 写入;其本地提供方默认将设置存入 `$DSH_HOME/settings.yaml`。收到推送的 settings 变更时或重连后,浏览器都会重新拉取该设置;连续快速选择会按操作顺序携带 namespace revision 串行写入,最新写入被拒时则重新加载持久化值。远程浏览器无法访问特权 settings API,因此它的选择仅保留在进程内。已注册的第三方主题 id 仍是进程内扩展,不会跨越内置 settings schema;移除其中任意一个都绝不会覆盖最后一个持久化的内置偏好。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md)拥有。
|
||||
|
||||
当主机组合包含 HTTP 服务器时,主机侧紧接 `<body>` 起始标签注入同步引导代码。每份 index 响应会嵌入已注册的 Host 设置 `ui-theme.preference`,没有 settings provider 时则嵌入 `system`;浏览器按操作系统配色解析 `system`,随后在外壳加载页面渲染前设置 `color-scheme` 和 `body[data-ds-dark-theme]`。不含 HTTP 服务器的组合不受影响,插件树激活后,ThemeService 与 ui-layout 仍分别是客户端状态和后续 DOM 更新的权威来源。
|
||||
|
||||
`src/styles/` 下有五张样式表,全部由 web 壳的 `base.css` 导入:`base.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。`scrollbar.css` 是 `--dsw-alias-scrollbar-*` token 的唯一消费方,必须排在声明这些 token 的 `design-platform.css` 之后。
|
||||
|
||||
滚动条重新绑定约定:`scrollbar.css` 在 `body` 上把 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover` 绑定到 l1(基础表面)token,两条渲染路径都读取这一组变量。高层级表面(菜单、浮层、对话框)在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` 与 `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`;一次重新绑定即可为引擎实际走的那条路径换色。这组变量的另一个合法目标是 `transparent`,即完全不绘制滑块——[ui-sidebar](../ui-sidebar/README.md) 在指针不在栏内时就这样重新绑定自己的列。绑回 l1 那组不算重新绑定,它只是重述基础表面的默认值。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-theme",
|
||||
"description": "Theme plugin: ThemeService (light/dark/system preference, prefers-color-scheme resolution, theme/change snapshots; no DOM), --dsw-* token base stylesheets; registers the Appearance settings row",
|
||||
"description": "Theme plugin: Host bootstrap for the pre-plugin palette; DOM-free ThemeService for light/dark/system state; --dsw-* token styles and Appearance settings row",
|
||||
"version": "0.0.1-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
@@ -48,6 +48,7 @@
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
@@ -58,6 +59,7 @@
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
|
||||
40
packages/client/ui-theme/src/boot-theme.ts
Normal file
40
packages/client/ui-theme/src/boot-theme.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Host-rendered theme bootstrap for the browser's pre-plugin interval. Each
|
||||
* index response embeds the current durable built-in preference; the browser
|
||||
* resolves only `system`, then writes the same DOM fields ui-layout's
|
||||
* ThemePresenter owns after the client plugin tree activates.
|
||||
*/
|
||||
|
||||
import { DEFAULT_PREFERENCE, type ThemePreference } from './theme-settings.ts'
|
||||
|
||||
/** Build the inline script for one schema-validated built-in preference. */
|
||||
function bootThemeScript(preference: ThemePreference): string {
|
||||
return `<script>(() => {
|
||||
const preference = ${JSON.stringify(preference)}
|
||||
const systemDark = preference === 'system'
|
||||
&& typeof matchMedia !== 'undefined'
|
||||
&& matchMedia('(prefers-color-scheme: dark)').matches
|
||||
const dark = preference === 'dark' || systemDark
|
||||
document.documentElement.style.colorScheme = dark ? 'dark' : 'light'
|
||||
document.body.toggleAttribute('data-ds-dark-theme', dark)
|
||||
})()</script>`
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert the theme bootstrap immediately after the opening body tag, before
|
||||
* the shell mount and module script. Body-less fragments receive it at the
|
||||
* end, where the HTML parser has already synthesized a body.
|
||||
* @param html - Raw application index HTML.
|
||||
* @param preference - Current Host-backed built-in preference.
|
||||
* @returns HTML containing the theme bootstrap.
|
||||
*/
|
||||
export function injectBootTheme(
|
||||
html: string,
|
||||
preference: ThemePreference = DEFAULT_PREFERENCE,
|
||||
): string {
|
||||
const script = bootThemeScript(preference)
|
||||
const body = /<body(?:\s[^>]*)?>/i.exec(html)
|
||||
if (body === null) return `${html}${script}`
|
||||
const at = body.index + body[0].length
|
||||
return `${html.slice(0, at)}${script}${html.slice(at)}`
|
||||
}
|
||||
@@ -1,23 +1,43 @@
|
||||
/** Host registration for the browser theme preference. */
|
||||
/** Host registration for the browser theme preference and pre-plugin palette. */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from './theme-settings.ts'
|
||||
import { injectBootTheme } from './boot-theme.ts'
|
||||
import {
|
||||
DEFAULT_PREFERENCE, THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema,
|
||||
type ThemePreference, type ThemeSettings,
|
||||
} from './theme-settings.ts'
|
||||
|
||||
export {
|
||||
DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE,
|
||||
type ThemePreference, type ThemeSettings,
|
||||
} from './theme-settings.ts'
|
||||
|
||||
const THEME_NAMESPACE = settingsNamespace(THEME_SETTINGS_NAMESPACE)
|
||||
|
||||
/** Read the registered preference or use the schema default without a settings provider. */
|
||||
function readPreference(ctx: Context): ThemePreference {
|
||||
const settings = ctx.get('settings')
|
||||
if (settings === undefined) return DEFAULT_PREFERENCE
|
||||
const section = settings.get(THEME_NAMESPACE) as ThemeSettings | undefined
|
||||
if (section === undefined) return DEFAULT_PREFERENCE
|
||||
return section.preference
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the durable theme section when a settings provider exists.
|
||||
* @param ctx - Host context whose optional settings service owns the section.
|
||||
* Register the durable theme section and initial-theme index transform when
|
||||
* their optional Host services are composed.
|
||||
* @param ctx - Host context that may acquire settings and HTTP services.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.inject(['settings'], (settingsCtx) => {
|
||||
settingsCtx.settings.register(
|
||||
settingsNamespace(THEME_SETTINGS_NAMESPACE),
|
||||
ThemeSettingsSchema,
|
||||
settingsCtx.settings.register(THEME_NAMESPACE, ThemeSettingsSchema)
|
||||
})
|
||||
ctx.inject(['httpServer'], (httpCtx) => {
|
||||
httpCtx.effect(
|
||||
() => httpCtx.httpServer.tapIndex(html => injectBootTheme(html, readPreference(ctx))),
|
||||
'client-ui-theme: initial theme bootstrap',
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
71
packages/client/ui-theme/tests/boot-theme.spec.ts
Normal file
71
packages/client/ui-theme/tests/boot-theme.spec.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Host index injection and the resulting pre-plugin browser theme. */
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { injectBootTheme } from '../src/boot-theme.ts'
|
||||
import type { ThemePreference } from '../src/theme-settings.ts'
|
||||
|
||||
const DARK_ATTRIBUTE = 'data-ds-dark-theme'
|
||||
|
||||
function mockSystemDark(matches: boolean): void {
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches }) as MediaQueryList))
|
||||
}
|
||||
|
||||
function executeBootstrap(
|
||||
preference?: ThemePreference,
|
||||
html = '<html><body><div id="root"></div><script type="module"></script></body></html>',
|
||||
): string {
|
||||
const injected = injectBootTheme(html, preference)
|
||||
const source = /<script>([\s\S]*?)<\/script>/.exec(injected)?.[1]
|
||||
if (source === undefined) throw new Error('theme bootstrap script missing')
|
||||
runInNewContext(source, { document, matchMedia: globalThis.matchMedia })
|
||||
return injected
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
document.documentElement.style.removeProperty('color-scheme')
|
||||
document.body.removeAttribute(DARK_ATTRIBUTE)
|
||||
})
|
||||
|
||||
describe('theme boot index transform', () => {
|
||||
it('runs immediately inside the body before the shell mount', () => {
|
||||
mockSystemDark(false)
|
||||
const html = executeBootstrap('dark', '<html><body class="app"><div id="root"></div></body></html>')
|
||||
expect(html.indexOf('<script>')).toBeGreaterThan(html.indexOf('<body class="app">'))
|
||||
expect(html.indexOf('<script>')).toBeLessThan(html.indexOf('<div id="root">'))
|
||||
expect(document.documentElement.style.colorScheme).toBe('dark')
|
||||
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(true)
|
||||
})
|
||||
|
||||
it('lets durable light override a dark OS and clears stale dark state', () => {
|
||||
document.body.setAttribute(DARK_ATTRIBUTE, '')
|
||||
mockSystemDark(true)
|
||||
executeBootstrap('light')
|
||||
expect(document.documentElement.style.colorScheme).toBe('light')
|
||||
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[true, 'dark', true],
|
||||
[false, 'light', false],
|
||||
] as const)('resolves system=%s to %s', (matches, colorScheme, dark) => {
|
||||
mockSystemDark(matches)
|
||||
executeBootstrap('system')
|
||||
expect(document.documentElement.style.colorScheme).toBe(colorScheme)
|
||||
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(dark)
|
||||
})
|
||||
|
||||
it('defaults to system and falls back to light when matchMedia is unavailable', () => {
|
||||
vi.stubGlobal('matchMedia', undefined)
|
||||
executeBootstrap()
|
||||
expect(document.documentElement.style.colorScheme).toBe('light')
|
||||
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
|
||||
})
|
||||
|
||||
it('appends the script to a body-less fragment', () => {
|
||||
const html = injectBootTheme('<main>loading</main>', 'dark')
|
||||
expect(html.startsWith('<main>loading</main><script>')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { HttpServerService } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import {
|
||||
DEFAULT_PREFERENCE, THEME_SETTINGS_NAMESPACE, apply,
|
||||
@@ -27,4 +28,38 @@ describe('ui-theme host', () => {
|
||||
await fiber.dispose()
|
||||
expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns)
|
||||
})
|
||||
|
||||
it('renders the current durable preference and disposes the index transform', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(MemorySettings).await()
|
||||
let transform: ((html: string) => string) | undefined
|
||||
let disposed = false
|
||||
ctx.provide('httpServer', {
|
||||
tapIndex: (next: (html: string) => string) => {
|
||||
transform = next
|
||||
return () => { disposed = true }
|
||||
},
|
||||
} as HttpServerService)
|
||||
const fiber = ctx.plugin({ apply })
|
||||
await fiber.await()
|
||||
expect(transform?.('<body></body>')).toContain('const preference = "system"')
|
||||
await ctx.settings.update(settingsNamespace(THEME_SETTINGS_NAMESPACE), { preference: 'dark' })
|
||||
expect(transform?.('<body></body>')).toContain('const preference = "dark"')
|
||||
await fiber.dispose()
|
||||
expect(disposed).toBe(true)
|
||||
expect(transform?.('<body></body>')).toContain('const preference = "system"')
|
||||
})
|
||||
|
||||
it('uses the system preference when only an HTTP server exists', async () => {
|
||||
const ctx = new Context()
|
||||
let transform: ((html: string) => string) | undefined
|
||||
ctx.provide('httpServer', {
|
||||
tapIndex: (next: (html: string) => string) => {
|
||||
transform = next
|
||||
return () => undefined
|
||||
},
|
||||
} as HttpServerService)
|
||||
await ctx.plugin({ apply }).await()
|
||||
expect(transform?.('<body></body>')).toContain('const preference = "system"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('invariant companion', () => {
|
||||
await expect(ctx.plugin(ThemeInvariant).await()).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('node-half waits for an optional settings provider', () => {
|
||||
it('node-half waits for optional Host services', () => {
|
||||
nodeApply(new Context())
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../../host/webserver"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md
|
||||
README.md: e82b2cc9d4a65c3095aeee7002fb6c43a43b695d
|
||||
README.zh.md: a1ba62393c2aae3f6baa7c481dd80f04dbbb477d
|
||||
README.md: f4b3bd223c2872f0341d49bdaa102440d73b4f29
|
||||
README.zh.md: 9bcb3b6ad98d672cc524c168f2024be9ba56b577
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. The toolbar's Export button downloads the session log — the root plus every subagent descendant — as a ZIP streamed by the host (`GET /api/session.export`): every file is the session's stored artifact text verbatim (`session.jsonl` at the root, `subagents/<id>/session.jsonl` for descendants; no manifest, byte-identical to the backend's durable artifact), and every image any included log references sits under `media/<attachmentId>.<ext>`. Fixture mode (no host) answers 404 for the export. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including cancellation-frozen Assistant and Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8.
|
||||
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. The toolbar's Export button hands the session log — the root plus every subagent descendant — directly to the browser download manager as a ZIP streamed by the host (`GET /api/session.export`), so JavaScript never buffers the response: every file is the session's stored artifact text verbatim (`session.jsonl` at the root, `subagents/<id>/session.jsonl` for descendants; no manifest, byte-identical to the backend's durable artifact), and every image any included log references sits under `media/<attachmentId>.<ext>`. Fixture mode (no host) answers 404 for the export. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including cancellation-frozen Assistant and Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。工具栏的 “Export” 按钮会将会话日志——根会话及其全部子代理——下载为宿主流式返回的 ZIP(`GET /api/session.export`):每个文件都是会话存储工件的逐字原文(根为 `session.jsonl`,子代理为 `subagents/<id>/session.jsonl`;无清单,与后端持久化工件逐字节一致),每个被包含日志引用的图片则放在 `media/<attachmentId>.<ext>` 下。fixture 模式(无宿主)对导出应答 404。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定:api-contracts v3 §8。
|
||||
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。工具栏的 “Export” 按钮会将会话日志——根会话及其全部子代理——作为宿主流式返回的 ZIP(`GET /api/session.export`)直接交给浏览器下载管理器,因此 JavaScript 不会缓冲响应:每个文件都是会话存储工件的逐字原文(根为 `session.jsonl`,子代理为 `subagents/<id>/session.jsonl`;无清单,与后端持久化工件逐字节一致),每个被包含日志引用的图片则放在 `media/<attachmentId>.<ext>` 下。fixture 模式(无宿主)对导出应答 404。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定:api-contracts v3 §8。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Session log export: browser download of the host-streamed ZIP. The archive
|
||||
* itself is produced and streamed by the host (GET /api/session.export); this
|
||||
* module only derives the download filename and triggers the browser save.
|
||||
* Session log export delivery. The host streams the archive from
|
||||
* `GET /api/session.export`; this module owns the browser-native download
|
||||
* handoff so the browser can stream the response directly to its download
|
||||
* manager instead of buffering the ZIP in JavaScript.
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -27,16 +28,18 @@ export function sessionLogZipFilename(sessionId: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a browser download of a blob response.
|
||||
* @param blob - the response body to save (passed straight through, no copy).
|
||||
* @param filename - the download filename.
|
||||
* Hand one host-streamed session archive to the browser download manager.
|
||||
* The operation resolves after dispatching the native download; HTTP delivery
|
||||
* continues outside JavaScript and is reported by the browser itself.
|
||||
* @param sessionId - the root session id to export with all descendants.
|
||||
* @returns a promise that rejects if the browser handoff itself fails.
|
||||
*/
|
||||
export function downloadBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
anchor.click()
|
||||
// Revoke one tick later: some browsers read the blob URL after click().
|
||||
setTimeout(() => { URL.revokeObjectURL(url) }, 0)
|
||||
export function downloadSessionLog(sessionId: string): Promise<void> {
|
||||
return Promise.resolve().then(() => {
|
||||
const query = new URLSearchParams({ sessionId, includeDescendants: 'true' })
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = `/api/session.export?${query.toString()}`
|
||||
anchor.download = sessionLogZipFilename(sessionId)
|
||||
anchor.click()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
// owning package) must be in the program for the register calls to type.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { createTrajectoryDurationStore } from './duration-store.ts'
|
||||
import { downloadBlob, sessionLogZipFilename } from './export-log.ts'
|
||||
import { downloadSessionLog } from './export-log.ts'
|
||||
import { en, NS, zh } from './locales.ts'
|
||||
import { registerTrajectoryAssistantDefinition } from './trajectory-assistant-definition.ts'
|
||||
import { registerTrajectoryCompactionDefinitions } from './trajectory-compaction-definition.ts'
|
||||
@@ -60,23 +60,7 @@ export function apply(ctx: Context): void {
|
||||
return session.getSnapshot().views.get('trajectory') !== before
|
||||
},
|
||||
setActualDuration: (value) => { duration.set(value) },
|
||||
exportLog: async () => {
|
||||
// The host streams the ZIP (root + descendant artifacts verbatim)
|
||||
// from GET /api/session.export; the browser downloads the response.
|
||||
// A null origin (no-location Node contexts) falls back like the
|
||||
// carrier's resolveBase so the URL stays valid.
|
||||
const loc = (globalThis as { location?: { origin?: string } }).location
|
||||
const origin = loc?.origin !== undefined && loc.origin !== 'null' ? loc.origin : 'http://dsh.internal'
|
||||
const url = new URL('/api/session.export', origin)
|
||||
url.searchParams.set('sessionId', sessionId)
|
||||
url.searchParams.set('includeDescendants', 'true')
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '')
|
||||
throw new Error(`Export failed: HTTP ${response.status}${detail === '' ? '' : ` ${detail}`}`)
|
||||
}
|
||||
downloadBlob(await response.blob(), sessionLogZipFilename(sessionId))
|
||||
},
|
||||
exportLog: () => downloadSessionLog(sessionId),
|
||||
}
|
||||
},
|
||||
}, TrajectoryView))
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
// @vitest-environment node
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Session-log export filename derivation. The archive itself is produced and
|
||||
* streamed by the host (GET /api/session.export); this package only derives
|
||||
* the download filename and triggers the browser save.
|
||||
* Session-log export browser delivery: safe filename derivation and a native
|
||||
* download handoff that leaves the streamed response outside JavaScript.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { sessionLogZipFilename } from '../src/client/export-log.ts'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { downloadSessionLog, sessionLogZipFilename } from '../src/client/export-log.ts'
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('sessionLogZipFilename', () => {
|
||||
it('keeps safe session ids verbatim', () => {
|
||||
@@ -22,3 +25,27 @@ describe('sessionLogZipFilename', () => {
|
||||
expect(sessionLogZipFilename('..')).toBe('dsh-session-__.zip')
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloadSessionLog', () => {
|
||||
it('hands the descendant-inclusive endpoint directly to the browser', async () => {
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
|
||||
await downloadSessionLog('session/with spaces')
|
||||
|
||||
expect(click).toHaveBeenCalledOnce()
|
||||
const anchor = click.mock.contexts[0] as HTMLAnchorElement
|
||||
const url = new URL(anchor.href)
|
||||
expect(url.pathname).toBe('/api/session.export')
|
||||
expect(url.searchParams.get('sessionId')).toBe('session/with spaces')
|
||||
expect(url.searchParams.get('includeDescendants')).toBe('true')
|
||||
expect(anchor.download).toBe('dsh-session-session_with_spaces.zip')
|
||||
})
|
||||
|
||||
it('rejects when the browser download handoff fails', async () => {
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {
|
||||
throw new Error('download denied')
|
||||
})
|
||||
|
||||
await expect(downloadSessionLog('session-root')).rejects.toThrow('download denied')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1141,39 +1141,26 @@ describe('timeline projection', () => {
|
||||
describe('session log export', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
Reflect.deleteProperty(URL, 'createObjectURL')
|
||||
Reflect.deleteProperty(HTMLAnchorElement.prototype, 'click')
|
||||
})
|
||||
|
||||
it('downloads the host-streamed ZIP with descendants on click', async () => {
|
||||
// exportLog always fetches a URL instance, so the mock's shape stays narrow.
|
||||
const fetchMock = vi.fn(async (input: URL) => {
|
||||
expect(input.pathname).toBe('/api/session.export')
|
||||
expect(input.searchParams.get('sessionId')).toBe(SID)
|
||||
expect(input.searchParams.get('includeDescendants')).toBe('true')
|
||||
return new Response('zip-bytes')
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const createObjectURL = vi.fn(() => 'blob:export')
|
||||
URL.createObjectURL = createObjectURL
|
||||
const clickAnchor = vi.fn()
|
||||
HTMLAnchorElement.prototype.click = clickAnchor
|
||||
const b = await bench(historySnapshot(NODES))
|
||||
mount(b.slots)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Export session log' }))
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
// The blob download lands a few microtasks after the fetch settles.
|
||||
await vi.waitFor(() => {
|
||||
expect(createObjectURL).toHaveBeenCalled()
|
||||
})
|
||||
expect(clickAnchor).toHaveBeenCalled()
|
||||
await vi.waitFor(() => { expect(clickAnchor).toHaveBeenCalledOnce() })
|
||||
const anchor = clickAnchor.mock.contexts[0] as HTMLAnchorElement
|
||||
const url = new URL(anchor.href)
|
||||
expect(url.pathname).toBe('/api/session.export')
|
||||
expect(url.searchParams.get('sessionId')).toBe(SID)
|
||||
expect(url.searchParams.get('includeDescendants')).toBe('true')
|
||||
})
|
||||
|
||||
it('surfaces the download failure in the visible alert bar', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('boom', { status: 404 })))
|
||||
it('surfaces a browser handoff failure in the visible alert bar', async () => {
|
||||
HTMLAnchorElement.prototype.click = vi.fn(() => { throw new Error('download denied') })
|
||||
const b = await bench(historySnapshot(NODES))
|
||||
mount(b.slots)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
@@ -1181,7 +1168,7 @@ describe('session log export', () => {
|
||||
await vi.waitFor(() => {
|
||||
const alert = screen.queryByRole('alert')
|
||||
expect(alert).not.toBeNull()
|
||||
expect(alert!.textContent).toContain('HTTP 404')
|
||||
expect(alert!.textContent).toContain('download denied')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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/examples/jsonrpc-demo/README.md
|
||||
README.md: 40ced3ee1fe2d3eac69b82501d417130267d7634
|
||||
README.zh.md: 451bdf7428f8265750082af240418d4653bb8595
|
||||
README.md: b2035b4caf2cadcbd5e93b27ac4aad2bdadcdb4d
|
||||
README.zh.md: 40867d03c31cb44ed7b2cc20643b3838de294b92
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Bin-only app that boots an external `cordis.yml`; its [`jsonrpc`](../../scaffold/server/README.md) entry serves SDK clients over newline-delimited stdio. The config composes the spine, backends, and serving plugin. The published `dsh-jsonrpc-agent` bin resolves bare plugins from the configuration project. The Python SDK's `dsh-jsonrpc-agent-pkg` [single-executable runtime](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) uses `lib/packaged-bin.js` instead: packaged bare plugins resolve from its closed runtime tree, while relative plugins remain configuration-relative.
|
||||
Bin-only app that boots an external `cordis.yml`; its [`jsonrpc`](../../sdk/server/README.md) entry serves SDK clients over newline-delimited stdio. The config composes the spine, backends, and serving plugin. The published `dsh-jsonrpc-agent` bin resolves bare plugins from the configuration project. The Python SDK's `dsh-jsonrpc-agent-pkg` [single-executable runtime](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) uses `lib/packaged-bin.js` instead: packaged bare plugins resolve from its closed runtime tree, while relative plugins remain configuration-relative.
|
||||
|
||||
## Config discovery
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
只包含 bin 的应用,启动外部 `cordis.yml`;其 [`jsonrpc`](../../scaffold/server/README.md) 入口通过按换行分隔的 stdio 为 SDK 客户端提供服务。配置负责组合主干、后端和服务插件。发布的 `dsh-jsonrpc-agent` bin 从配置项目解析裸插件。Python SDK 的 `dsh-jsonrpc-agent-pkg` [单文件可执行运行时](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md)改用 `lib/packaged-bin.js`:已打包的裸插件从封闭运行时包树解析,相对插件仍以配置目录为基准。
|
||||
只包含 bin 的应用,启动外部 `cordis.yml`;其 [`jsonrpc`](../../sdk/server/README.md) 入口通过按换行分隔的 stdio 为 SDK 客户端提供服务。配置负责组合主干、后端和服务插件。发布的 `dsh-jsonrpc-agent` bin 从配置项目解析裸插件。Python SDK 的 `dsh-jsonrpc-agent-pkg` [单文件可执行运行时](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md)改用 `lib/packaged-bin.js`:已打包的裸插件从封闭运行时包树解析,相对插件仍以配置目录为基准。
|
||||
|
||||
## 配置发现
|
||||
|
||||
|
||||
@@ -109,6 +109,8 @@ export function messageFixture(
|
||||
|
||||
/** Minimal controllable persistence provider for service-level tests. */
|
||||
class TestPersistence extends SessionPersistence {
|
||||
override readonly supportsRawArtifacts = false
|
||||
|
||||
static inject = ['sessions']
|
||||
|
||||
readonly durable = new Map<SessionId, SessionInspection>()
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
|
||||
README.md: f7023407c4fad559847f71e56804fe0737e09966
|
||||
README.zh.md: 2502f550c8bf76137337d879b5013ba38e734bb6
|
||||
README.md: 53935bf5565cdbfdc9f99980069afb36a993e758
|
||||
README.zh.md: 30d5db4efc547b8bca1f61d3f85d0b45ad57c891
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The API gateway shared by every client consists of the TypeScript API contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{nativeOpen?}`, provides `ctx.apiProxy`). This package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped Web composition lives in [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml), while its default Agent model selection belongs to [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md) in the base bundle.
|
||||
The API gateway shared by every client consists of the TypeScript API contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{nativeOpen?, sessionExportCompressionLevel?}`, provides `ctx.apiProxy`). This package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped Web composition lives in [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml), while its default Agent model selection belongs to [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md) in the base bundle.
|
||||
|
||||
## The shared Agent default (`agent-default-model` Settings section)
|
||||
|
||||
@@ -28,7 +28,7 @@ Question responses are validated against their pending request before the first
|
||||
|
||||
`session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface.
|
||||
|
||||
Session-log export is a host-only download surface, not an RPC: `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's `readRaw` — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under `subagents/<id>/`, and every image any included log references under `media/<attachmentId>.<ext>` (read and verified from the attachment store; a shared image appears once). Compression runs on the host with fflate's streaming Zip API, so the response is chunked as it is produced and the host never holds the whole archive in one buffer, and production yields whenever the response queue fills, so a slow consumer bounds the accumulation (fflate's callback is synchronous — the drain point is the only backpressure). It requires the persistence, session-query, and attachment services: a deployment without any answers 500, a missing root session 404, and a descendant without a stored artifact or a referenced image that cannot be read fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; `ApiProxy.downloads.sessionLog` implements it.
|
||||
Session-log export is a host-only download surface, not an RPC: `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's `readRaw` — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under `subagents/<id>/`, and every image any included log references under `media/<attachmentId>.<ext>` (read and verified from the attachment store; a shared image appears once). Each live root or descendant crosses the authoritative `SessionStore.flush` durability barrier immediately before its raw artifact read; cold sessions have no in-memory work to flush. Compression runs on the host with fflate's streaming Zip API at validated `sessionExportCompressionLevel` 0–9 (default 6), so deployments can trade CPU and latency against archive size; the response is chunked as it is produced and the host never holds the whole archive in one buffer. Once the response queue reaches its 64 KiB byte high-water mark, production waits until consumer pull restores positive capacity; fflate's synchronous callback can overshoot that bound only by the output of one bounded input push. Request abort and response-body cancellation stop lineage and artifact work, terminate the active compressor, and propagate as cancellation rather than an HTTP 500. It requires the persistence, session-query, and attachment services: a deployment without any answers 500, a persistence backend without per-session raw artifacts answers 501, a missing root session answers 404, and a descendant without a stored artifact or a referenced image that cannot be read fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; `ApiProxy.downloads.sessionLog` implements it.
|
||||
|
||||
Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key. Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
所有客户端共用的 API 网关由三部分组成:TypeScript API 约定(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{nativeOpen?}`,提供 `ctx.apiProxy`)。该包不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。随发行版交付的 Web 组合位于 [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml),其默认 Agent(智能体)模型选择属于 base 组合包中的 [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md)。
|
||||
所有客户端共用的 API 网关由三部分组成:TypeScript API 约定(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{nativeOpen?, sessionExportCompressionLevel?}`,提供 `ctx.apiProxy`)。该包不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。随发行版交付的 Web 组合位于 [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml),其默认 Agent(智能体)模型选择属于 base 组合包中的 [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md)。
|
||||
|
||||
## 共享 Agent 默认值(`agent-default-model` Settings 分节)
|
||||
|
||||
@@ -28,7 +28,7 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中
|
||||
|
||||
`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。
|
||||
|
||||
会话日志导出是宿主侧的下载面,不是 RPC:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP,其中每个文件都是会话存储工件的逐字原文(持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents/<id>/` 下,每个被任何包含的日志引用的图片放在 `media/<attachmentId>.<ext>` 下(从附件存储读取并校验;共享图片只出现一次)。压缩在宿主侧用 fflate 的流式 Zip API 完成,响应边生成边分块写出,宿主从不把整个归档放进单个缓冲区,且每当响应队列填满时生产会让出,慢消费者因此只产生有界的积压(fflate 的回调是同步的——让出点是唯一的背压手段)。它要求同时挂载持久化、session-query 与附件服务:任一缺失应答 500,根会话缺失应答 404,后代缺少存储工件或引用的图片无法读取则整个流失败(fail-loud,绝不静默少导出)。端点由传输层挂载,`ApiProxy.downloads.sessionLog` 实现它。
|
||||
会话日志导出是宿主侧的下载面,不是 RPC:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP,其中每个文件都是会话存储工件的逐字原文(持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents/<id>/` 下,每个被任何包含的日志引用的图片放在 `media/<attachmentId>.<ext>` 下(从附件存储读取并校验;共享图片只出现一次)。每个实时根会话或后代都会在读取原始工件前立即通过权威的 `SessionStore.flush` 持久性屏障;冷会话没有需要 flush 的内存工作。压缩在宿主侧使用 fflate 流式 Zip API 和已验证的 `sessionExportCompressionLevel` 0–9(默认 6),使部署可以在 CPU/延迟与归档大小之间取舍;响应边生成边分块写出,宿主从不把整个归档放进单个缓冲区。响应队列达到 64 KiB 字节高水位后,生产会等待 Consumer pull 恢复正容量;fflate 的同步回调最多只会让该界限多出一次有界输入 push 的输出。请求中止或响应 body 取消会停止血缘与工件工作、终止活跃压缩器,并继续按取消传播,而不会变成 HTTP 500。它要求同时挂载持久化、session-query 与附件服务:任一缺失应答 500,持久化后端不提供每会话原始工件时应答 501,根会话缺失时应答 404,后代缺少存储工件或引用的图片无法读取则整个流失败(fail-loud,绝不静默少导出)。端点由传输层挂载,`ApiProxy.downloads.sessionLog` 实现它。
|
||||
|
||||
会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。
|
||||
|
||||
|
||||
@@ -43,10 +43,13 @@ import type {
|
||||
WorkspaceId, WorkspaceView,
|
||||
} from './api/index.ts'
|
||||
import {
|
||||
DEFAULT_SESSION_LOG_COMPRESSION_LEVEL,
|
||||
flushLiveSessionLog,
|
||||
sessionLogExportDeps,
|
||||
sessionLogZipFilename,
|
||||
streamSessionLogZip,
|
||||
type SessionLogExportReady,
|
||||
type SessionLogCompressionLevel,
|
||||
} from './session-export.ts'
|
||||
import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
|
||||
import {
|
||||
@@ -543,6 +546,8 @@ export interface ApiProxyDefaults {
|
||||
openPath?: (path: string, signal: AbortSignal) => Promise<void>
|
||||
/** Native text-editor handoff; injectable for settings-document tests. */
|
||||
openTextFile?: (path: string, signal: AbortSignal) => Promise<void>
|
||||
/** Validated DEFLATE level for session-log ZIP entries; defaults to 6. */
|
||||
sessionExportCompressionLevel?: SessionLogCompressionLevel
|
||||
/**
|
||||
* Whether handing a path to the native opener can work at all — the
|
||||
* `hasDocument` capability the preset roster reports, and the switch
|
||||
@@ -988,6 +993,8 @@ function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceVie
|
||||
* @returns the ApiProxy implementation.
|
||||
*/
|
||||
export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
|
||||
const sessionExportCompressionLevel = defaults.sessionExportCompressionLevel
|
||||
?? DEFAULT_SESSION_LOG_COMPRESSION_LEVEL
|
||||
/** The seed model each create/resume declares; re-read so it never goes stale. */
|
||||
const agentOptions = (): AgentOptions => {
|
||||
const { provider, model } = defaults.defaultModelSelection()
|
||||
@@ -3515,24 +3522,41 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
if (!deps.sessionPersistence.supportsRawArtifacts) {
|
||||
return new Response(
|
||||
'session log export is unavailable: the persistence backend does not expose per-session raw artifacts',
|
||||
{ status: 501 },
|
||||
)
|
||||
}
|
||||
const ready: SessionLogExportReady = {
|
||||
sessionQuery: deps.sessionQuery,
|
||||
sessionPersistence: deps.sessionPersistence,
|
||||
attachments: deps.attachments,
|
||||
sessions: deps.sessions,
|
||||
}
|
||||
let root: SessionRawArtifact | undefined
|
||||
try {
|
||||
await flushLiveSessionLog(deps, request.sessionId, signal)
|
||||
root = await deps.sessionPersistence.readRaw(request.sessionId, signal)
|
||||
signal.throwIfAborted()
|
||||
} catch {
|
||||
// Backend read failure: answer 500 without echoing the error, which
|
||||
// may carry absolute host paths into the browser error bar.
|
||||
return new Response('session log export failed to read the stored artifact', { status: 500 })
|
||||
signal.throwIfAborted()
|
||||
// Root preparation failure: answer 500 without echoing the error,
|
||||
// which may carry absolute host paths into the browser error bar.
|
||||
return new Response('session log export failed to prepare the stored artifact', { status: 500 })
|
||||
}
|
||||
if (root === undefined) {
|
||||
return new Response('session not found', { status: 404 })
|
||||
}
|
||||
return new Response(
|
||||
streamSessionLogZip(ready, root, request.sessionId, request.includeDescendants === true, signal),
|
||||
streamSessionLogZip(
|
||||
ready,
|
||||
root,
|
||||
request.sessionId,
|
||||
request.includeDescendants === true,
|
||||
sessionExportCompressionLevel,
|
||||
signal,
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
'content-type': 'application/zip',
|
||||
|
||||
@@ -17,6 +17,10 @@ import z from '@deepseek-ai/schemastery'
|
||||
import type {} from '@deepseek-ai/dsh-agent-default-model'
|
||||
import type { ApiProxy } from './api/index.ts'
|
||||
import { createApiProxy } from './api-proxy.ts'
|
||||
import {
|
||||
DEFAULT_SESSION_LOG_COMPRESSION_LEVEL,
|
||||
type SessionLogCompressionLevel,
|
||||
} from './session-export.ts'
|
||||
|
||||
export type * from './api/index.ts'
|
||||
export { RpcId } from './api/rpc.ts'
|
||||
@@ -33,7 +37,7 @@ declare module '@deepseek-ai/cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
/** Gateway plugin config for native Host integration. */
|
||||
/** Gateway plugin configuration. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Whether this deployment can hand paths to a native desktop opener —
|
||||
@@ -43,6 +47,12 @@ export interface Config {
|
||||
* container whose DISPLAY points nowhere a user can see.
|
||||
*/
|
||||
nativeOpen?: boolean
|
||||
/**
|
||||
* DEFLATE level for every session-log ZIP entry: `0` stores without
|
||||
* compression, `1` favors CPU/latency, and `9` favors archive size.
|
||||
* @default 6
|
||||
*/
|
||||
sessionExportCompressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,6 +68,8 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
|
||||
static Config: z<Config> = z.object({
|
||||
nativeOpen: z.boolean(),
|
||||
sessionExportCompressionLevel: z.number().step(1).min(0).max(9)
|
||||
.default(DEFAULT_SESSION_LOG_COMPRESSION_LEVEL) as z<SessionLogCompressionLevel>,
|
||||
})
|
||||
|
||||
readonly sessions: ApiProxy['sessions']
|
||||
@@ -82,6 +94,9 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
saveDefaultModelSelection: selection => ctx.agentDefaultModel.saveSelection(selection),
|
||||
cwd: process.cwd(),
|
||||
...config.nativeOpen === undefined ? {} : { canOpenPath: () => config.nativeOpen as boolean },
|
||||
...(config.sessionExportCompressionLevel === undefined
|
||||
? {}
|
||||
: { sessionExportCompressionLevel: config.sessionExportCompressionLevel }),
|
||||
})
|
||||
this.sessions = api.sessions
|
||||
this.subagents = api.subagents
|
||||
|
||||
@@ -6,13 +6,16 @@
|
||||
* by any included log under `media/<attachmentId>.<ext>` (content-addressed,
|
||||
* so one archive never duplicates a shared image). No manifest is written —
|
||||
* every file is byte-identical to the backend's durable artifact or attachment
|
||||
* store and self-describing through its own header line or media type.
|
||||
* store and self-describing through its own header line or media type. Before
|
||||
* each live session's artifact read, the SessionStore flush barrier makes the
|
||||
* current in-memory log durable; cold sessions need no barrier. Request abort
|
||||
* and response-consumer cancellation share one producer signal and terminate
|
||||
* the active compressor.
|
||||
* Compression runs on the host with fflate's streaming Zip API, so the archive
|
||||
* bytes are produced incrementally and the host never holds the whole archive
|
||||
* in one buffer; production yields to the consumer whenever the response queue
|
||||
* fills past its high-water mark, so a slow consumer bounds the accumulation
|
||||
* instead of piling up the whole archive (fflate's callback is synchronous —
|
||||
* this drain point is the only backpressure available).
|
||||
* in one buffer; production waits for consumer pull whenever the response queue
|
||||
* reaches its byte high-water mark, so a slow consumer bounds accumulation to
|
||||
* the fixed 64 KiB response queue plus one synchronous fflate push.
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -20,14 +23,21 @@ import { Zip, ZipDeflate } from 'fflate'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { SessionLineageNode, SessionQueryService } from '@deepseek-ai/dsh-session-query'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionId, SessionStore } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistence, SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
|
||||
|
||||
/** The services a session-log export needs (absent → the export is unavailable). */
|
||||
/** Valid fflate DEFLATE levels accepted by session-log export. */
|
||||
export type SessionLogCompressionLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
|
||||
|
||||
/** Balanced default used when a direct createApiProxy caller omits deployment config. */
|
||||
export const DEFAULT_SESSION_LOG_COMPRESSION_LEVEL: SessionLogCompressionLevel = 6
|
||||
|
||||
/** The services a session-log export needs (the live-session store is optional). */
|
||||
export interface SessionLogExportDeps {
|
||||
readonly sessionQuery: SessionQueryService | undefined
|
||||
readonly sessionPersistence: SessionPersistence | undefined
|
||||
readonly attachments: AttachmentStore | undefined
|
||||
readonly sessions: SessionStore | undefined
|
||||
}
|
||||
|
||||
/** The export services narrowed to the mounted ones streaming actually reads. */
|
||||
@@ -35,6 +45,7 @@ export interface SessionLogExportReady {
|
||||
readonly sessionQuery: SessionQueryService
|
||||
readonly sessionPersistence: SessionPersistence
|
||||
readonly attachments: AttachmentStore
|
||||
readonly sessions: SessionStore | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,9 +58,32 @@ export function sessionLogExportDeps(ctx: Context): SessionLogExportDeps {
|
||||
sessionQuery: ctx.get('sessionQuery'),
|
||||
sessionPersistence: ctx.get('sessionPersistence'),
|
||||
attachments: ctx.get('attachments'),
|
||||
sessions: ctx.get('sessions'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush one currently live session through the store's authoritative durability
|
||||
* barrier immediately before its raw artifact is read. A cold or absent id has
|
||||
* no in-memory work to flush.
|
||||
* @param deps - export services, including the optional live-session store.
|
||||
* @param id - the session whose artifact is about to be read.
|
||||
* @param signal - optional cancellation observed around the flush barrier.
|
||||
*/
|
||||
export async function flushLiveSessionLog(
|
||||
deps: Pick<SessionLogExportDeps, 'sessions'>,
|
||||
id: SessionId,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
signal?.throwIfAborted()
|
||||
const sessions = deps.sessions
|
||||
if (sessions === undefined) return
|
||||
const session = sessions.get(id)
|
||||
if (session === undefined) return
|
||||
await sessions.flush(session)
|
||||
signal?.throwIfAborted()
|
||||
}
|
||||
|
||||
/** One exported file: a stored artifact text or one referenced media object. */
|
||||
export type SessionLogZipEntry =
|
||||
| { readonly path: string; readonly content: string }
|
||||
@@ -168,9 +202,9 @@ export function sessionLogZipFilename(sessionId: string): string {
|
||||
|
||||
/**
|
||||
* Yield the export entries in zip order: the preloaded root artifact first,
|
||||
* then every subagent descendant in lineage order (each read from the
|
||||
* persistence backend right before it is yielded and dropped after the
|
||||
* consumer moves on), then every distinct media object referenced by any of
|
||||
* then every subagent descendant in lineage order (each flushed when live,
|
||||
* read from the persistence backend right before it is yielded, and dropped
|
||||
* after the consumer moves on), then every distinct media object referenced by any of
|
||||
* the included logs (read and verified from the attachment store, one archive
|
||||
* entry per attachment id). The host holds at most one descendant's artifact
|
||||
* text and one media object at a time beyond the root.
|
||||
@@ -179,7 +213,7 @@ export function sessionLogZipFilename(sessionId: string): string {
|
||||
* missing-session path can answer cleanly before streaming starts).
|
||||
* @param sessionId - the root session id.
|
||||
* @param includeDescendants - whether to include every subagent descendant.
|
||||
* @param signal - optional cancellation for read work.
|
||||
* @param signal - optional cancellation forwarded to lineage, persistence, and attachment reads.
|
||||
* @returns the export entries in zip order.
|
||||
*/
|
||||
export async function* sessionLogZipEntries(
|
||||
@@ -205,7 +239,9 @@ export async function* sessionLogZipEntries(
|
||||
const id = node.session.header.id
|
||||
if (seen.has(id)) continue
|
||||
seen.add(id)
|
||||
const raw = await deps.sessionPersistence.readRaw(id)
|
||||
await flushLiveSessionLog(deps, id, signal)
|
||||
const raw = await deps.sessionPersistence.readRaw(id, signal)
|
||||
signal?.throwIfAborted()
|
||||
if (raw === undefined) {
|
||||
throw new Error(`subagent "${id}" has no stored log artifact`)
|
||||
}
|
||||
@@ -217,12 +253,14 @@ export async function* sessionLogZipEntries(
|
||||
yield* collect(node.descendants)
|
||||
}
|
||||
}
|
||||
const lineage = await deps.sessionQuery.traceSession(sessionId)
|
||||
const lineage = await deps.sessionQuery.traceSession(sessionId, signal)
|
||||
signal?.throwIfAborted()
|
||||
yield* collect(lineage.descendants)
|
||||
}
|
||||
for (const ref of media.values()) {
|
||||
signal?.throwIfAborted()
|
||||
const stored = await deps.attachments.readImage(ref)
|
||||
const stored = await deps.attachments.readImage(ref, signal)
|
||||
signal?.throwIfAborted()
|
||||
yield { path: mediaEntryPath(ref), data: stored.data }
|
||||
}
|
||||
}
|
||||
@@ -233,30 +271,66 @@ const PUSH_CHUNK_CODE_UNITS = 1 << 16
|
||||
/** How many bytes of media one zip push carries (bounded memory; images are already size-capped). */
|
||||
const PUSH_CHUNK_BYTES = 1 << 16
|
||||
|
||||
/** Byte capacity retained by the response stream before ZIP production waits for pull. */
|
||||
const RESPONSE_HIGH_WATER_MARK_BYTES = 1 << 16
|
||||
|
||||
/** One producer waiter released only when ReadableStream pull restores capacity. */
|
||||
class ResponseCapacityGate {
|
||||
private releasePending: (() => void) | undefined
|
||||
|
||||
/**
|
||||
* Wait until the response queue has positive byte capacity or cancellation wins.
|
||||
* @param controller - response controller whose desired size owns capacity.
|
||||
* @param signal - combined request/consumer cancellation.
|
||||
*/
|
||||
async wait(
|
||||
controller: ReadableStreamDefaultController<Uint8Array>,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
signal.throwIfAborted()
|
||||
if (controller.desiredSize === null || controller.desiredSize > 0) return
|
||||
await new Promise<void>((resolve) => {
|
||||
const release = (): void => {
|
||||
this.releasePending = undefined
|
||||
signal.removeEventListener('abort', release)
|
||||
resolve()
|
||||
}
|
||||
this.releasePending = release
|
||||
signal.addEventListener('abort', release, { once: true })
|
||||
})
|
||||
signal.throwIfAborted()
|
||||
}
|
||||
|
||||
/** Release the current producer waiter after a consumer pull. */
|
||||
pulled(): void {
|
||||
this.releasePending?.()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push one media object's bytes into a deflate stream in bounded chunks,
|
||||
* yielding to a slow consumer between chunks like the artifact path does.
|
||||
* waiting for consumer capacity between chunks like the artifact path does.
|
||||
* @param deflate - the zip entry's deflate stream.
|
||||
* @param data - the stored image bytes.
|
||||
* @param signal - optional cancellation; throws when aborted.
|
||||
* @param controller - response queue controller.
|
||||
* @param capacity - pull-driven response-capacity gate.
|
||||
* @param signal - cancellation; throws when aborted.
|
||||
*/
|
||||
async function pushBinaryChunks(
|
||||
deflate: ZipDeflate,
|
||||
data: Uint8Array,
|
||||
controller: ReadableStreamDefaultController<Uint8Array>,
|
||||
signal?: AbortSignal,
|
||||
capacity: ResponseCapacityGate,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
let offset = 0
|
||||
do {
|
||||
signal?.throwIfAborted()
|
||||
signal.throwIfAborted()
|
||||
const end = Math.min(offset + PUSH_CHUNK_BYTES, data.byteLength)
|
||||
const finalChunk = end >= data.byteLength
|
||||
deflate.push(data.subarray(offset, end), finalChunk)
|
||||
offset = end
|
||||
/* v8 ignore next 2 -- only fires when a slow consumer leaves the queue over-full */
|
||||
if (controller.desiredSize !== null && controller.desiredSize < 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
await capacity.wait(controller, signal)
|
||||
} while (offset < data.byteLength)
|
||||
}
|
||||
|
||||
@@ -266,19 +340,22 @@ async function pushBinaryChunks(
|
||||
* re-encodes as U+FFFD and would silently corrupt the exported artifact).
|
||||
* @param deflate - the zip entry's deflate stream.
|
||||
* @param content - the artifact text verbatim.
|
||||
* @param signal - optional cancellation; throws when aborted.
|
||||
* @param controller - response queue controller.
|
||||
* @param capacity - pull-driven response-capacity gate.
|
||||
* @param signal - cancellation; throws when aborted.
|
||||
*/
|
||||
async function pushArtifactChunks(
|
||||
deflate: ZipDeflate,
|
||||
content: string,
|
||||
controller: ReadableStreamDefaultController<Uint8Array>,
|
||||
signal?: AbortSignal,
|
||||
capacity: ResponseCapacityGate,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
const encoder = new TextEncoder()
|
||||
let offset = 0
|
||||
let finalChunk: boolean
|
||||
do {
|
||||
signal?.throwIfAborted()
|
||||
signal.throwIfAborted()
|
||||
let end = Math.min(offset + PUSH_CHUNK_CODE_UNITS, content.length)
|
||||
if (end < content.length && end - offset > 1) {
|
||||
// Back off one code unit when the boundary lands inside a surrogate
|
||||
@@ -289,10 +366,7 @@ async function pushArtifactChunks(
|
||||
finalChunk = end >= content.length
|
||||
deflate.push(encoder.encode(content.slice(offset, end)), finalChunk)
|
||||
offset = end
|
||||
/* v8 ignore next 2 -- only fires when a slow consumer leaves the queue over-full */
|
||||
if (controller.desiredSize !== null && controller.desiredSize < 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
await capacity.wait(controller, signal)
|
||||
} while (!finalChunk)
|
||||
}
|
||||
|
||||
@@ -307,7 +381,8 @@ async function pushArtifactChunks(
|
||||
* @param root - the already-read root artifact (first zip entry).
|
||||
* @param sessionId - the root session id.
|
||||
* @param includeDescendants - whether to include every subagent descendant.
|
||||
* @param signal - optional cancellation for read work.
|
||||
* @param compressionLevel - validated fflate DEFLATE level for every ZIP entry.
|
||||
* @param signal - request cancellation combined with response-consumer cancellation.
|
||||
* @returns the zip byte stream.
|
||||
*/
|
||||
export function streamSessionLogZip(
|
||||
@@ -315,15 +390,26 @@ export function streamSessionLogZip(
|
||||
root: SessionRawArtifact,
|
||||
sessionId: SessionId,
|
||||
includeDescendants: boolean,
|
||||
signal?: AbortSignal,
|
||||
compressionLevel: SessionLogCompressionLevel,
|
||||
signal: AbortSignal,
|
||||
): ReadableStream<Uint8Array> {
|
||||
const consumerAbort = new AbortController()
|
||||
const producerSignal = AbortSignal.any([signal, consumerAbort.signal])
|
||||
let zip: Zip | undefined
|
||||
let zipTerminated = false
|
||||
const capacity = new ResponseCapacityGate()
|
||||
const terminateZip = (): void => {
|
||||
if (zip === undefined || zipTerminated) return
|
||||
zipTerminated = true
|
||||
zip.terminate()
|
||||
}
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
// fflate invokes the callback synchronously per compressed chunk, so a
|
||||
// single push can enqueue ahead of a slow consumer; pushArtifactChunks
|
||||
// yields between chunks once the queue is over-full, bounding the
|
||||
// accumulation to the queue high-water mark plus one push.
|
||||
const zip = new Zip((error, data, final) => {
|
||||
// single push can enqueue ahead of a slow consumer; the capacity gate
|
||||
// waits for pull between pushes once the byte queue is full, bounding
|
||||
// accumulation to the queue high-water mark plus one synchronous push.
|
||||
const archive = new Zip((error, data, final) => {
|
||||
/* v8 ignore next 3 -- fflate reports only internal zip failures, unreachable for valid inputs */
|
||||
if (error) {
|
||||
controller.error(error)
|
||||
@@ -333,25 +419,39 @@ export function streamSessionLogZip(
|
||||
if (data.byteLength > 0) controller.enqueue(data)
|
||||
if (final) controller.close()
|
||||
})
|
||||
zip = archive
|
||||
void (async () => {
|
||||
try {
|
||||
for await (const entry of sessionLogZipEntries(deps, root, sessionId, includeDescendants, signal)) {
|
||||
const deflate = new ZipDeflate(entry.path, { level: 6 })
|
||||
zip.add(deflate)
|
||||
for await (const entry of sessionLogZipEntries(deps, root, sessionId, includeDescendants, producerSignal)) {
|
||||
const deflate = new ZipDeflate(entry.path, { level: compressionLevel })
|
||||
archive.add(deflate)
|
||||
if ('content' in entry) {
|
||||
await pushArtifactChunks(deflate, entry.content, controller, signal)
|
||||
await pushArtifactChunks(deflate, entry.content, controller, capacity, producerSignal)
|
||||
} else {
|
||||
await pushBinaryChunks(deflate, entry.data, controller, signal)
|
||||
await pushBinaryChunks(deflate, entry.data, controller, capacity, producerSignal)
|
||||
}
|
||||
}
|
||||
zip.end()
|
||||
archive.end()
|
||||
} catch (error) {
|
||||
// A mid-stream failure (missing descendant, cancellation, read
|
||||
// error) must fail the download rather than ship a truncated archive.
|
||||
/* v8 ignore next -- typed backends reject with Error, and DOMException is one in Node */
|
||||
terminateZip()
|
||||
controller.error(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
})()
|
||||
},
|
||||
pull() {
|
||||
capacity.pulled()
|
||||
},
|
||||
cancel(reason) {
|
||||
consumerAbort.abort(
|
||||
reason instanceof Error ? reason : new Error('session log export stream cancelled'),
|
||||
)
|
||||
terminateZip()
|
||||
},
|
||||
}, {
|
||||
highWaterMark: RESPONSE_HIGH_WATER_MARK_BYTES,
|
||||
size: chunk => chunk.byteLength,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* root → 404, missing descendant → errored stream).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { unzipSync, strFromU8 } from 'fflate'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
@@ -13,8 +14,7 @@ import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionLineageNode } from '@deepseek-ai/dsh-session-query'
|
||||
import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
|
||||
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
import ApiProxyService, { createApiProxy, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
|
||||
const sid = (id: string): SessionId => id as SessionId
|
||||
|
||||
@@ -59,8 +59,21 @@ async function buildApi(
|
||||
descendants: SessionLineageNode[] = [],
|
||||
services: {
|
||||
query?: boolean
|
||||
persistence?: boolean | 'throw'
|
||||
attachments?: boolean | ((ref: ImageAttachmentRef) => Promise<ReturnType<typeof storedImage>>)
|
||||
persistence?: boolean | 'throw' | 'unsupported'
|
||||
attachments?: boolean | ((ref: ImageAttachmentRef, signal?: AbortSignal) => Promise<ReturnType<typeof storedImage>>)
|
||||
sessions?: {
|
||||
get(id: SessionId): { readonly id: SessionId } | undefined
|
||||
flush(session: { readonly id: SessionId }): Promise<boolean>
|
||||
}
|
||||
readRaw?: (id: SessionId, signal?: AbortSignal) => Promise<SessionRawArtifact | undefined>
|
||||
traceSession?: (id: SessionId, signal?: AbortSignal) => Promise<{
|
||||
target: { header: SessionHeader; live: boolean; persisted: boolean }
|
||||
ancestors: readonly SessionLineageNode[]
|
||||
complete: boolean
|
||||
root: { header: SessionHeader; live: boolean; persisted: boolean }
|
||||
descendants: readonly SessionLineageNode[]
|
||||
}>
|
||||
compressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
|
||||
} = {},
|
||||
) {
|
||||
const ctx = new Context()
|
||||
@@ -69,21 +82,22 @@ async function buildApi(
|
||||
const persistence = services.persistence ?? true
|
||||
if (query) {
|
||||
ctx.provide('sessionQuery', {
|
||||
traceSession: async () => ({
|
||||
traceSession: services.traceSession ?? (async () => ({
|
||||
target: { header: header('session-root'), live: false, persisted: true },
|
||||
ancestors: [],
|
||||
complete: true,
|
||||
root: { header: header('session-root'), live: false, persisted: true },
|
||||
descendants,
|
||||
}),
|
||||
})),
|
||||
} as never)
|
||||
}
|
||||
if (persistence) {
|
||||
ctx.provide('sessionPersistence', {
|
||||
readRaw: async (id: SessionId) => {
|
||||
supportsRawArtifacts: persistence !== 'unsupported',
|
||||
readRaw: services.readRaw ?? (async (id: SessionId) => {
|
||||
if (persistence === 'throw') throw new Error('/host/private/session.jsonl')
|
||||
return artifacts[id]
|
||||
},
|
||||
}),
|
||||
} as never)
|
||||
}
|
||||
if (services.attachments !== false) {
|
||||
@@ -97,9 +111,13 @@ async function buildApi(
|
||||
readImage,
|
||||
} as never)
|
||||
}
|
||||
if (services.sessions !== undefined) ctx.provide('sessions', services.sessions as never)
|
||||
return createApiProxy(ctx, {
|
||||
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
|
||||
cwd: '/tmp',
|
||||
...services.compressionLevel === undefined
|
||||
? {}
|
||||
: { sessionExportCompressionLevel: services.compressionLevel },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -107,6 +125,19 @@ async function responseBytes(response: Response): Promise<Uint8Array> {
|
||||
return new Uint8Array(await response.arrayBuffer())
|
||||
}
|
||||
|
||||
describe('session export compression config', () => {
|
||||
it('defaults to level 6 and rejects values outside the integer 0-9 range', () => {
|
||||
expect(ApiProxyService.Config({})).toEqual({ sessionExportCompressionLevel: 6 })
|
||||
expect(ApiProxyService.Config({ sessionExportCompressionLevel: 0 }))
|
||||
.toEqual({ sessionExportCompressionLevel: 0 })
|
||||
expect(ApiProxyService.Config({ sessionExportCompressionLevel: 9 }))
|
||||
.toEqual({ sessionExportCompressionLevel: 9 })
|
||||
for (const value of [-1, 10, 1.5]) {
|
||||
expect(() => ApiProxyService.Config({ sessionExportCompressionLevel: value } as never)).toThrow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('session.export download endpoint', () => {
|
||||
it('streams a ZIP with the root artifact verbatim under its original filename', async () => {
|
||||
const api = await buildApi({ 'session-root': artifact('session-root') })
|
||||
@@ -121,6 +152,24 @@ describe('session.export download endpoint', () => {
|
||||
expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(artifact('session-root').content)
|
||||
})
|
||||
|
||||
it('uses the resolved compression level for ZIP entries', async () => {
|
||||
const root = artifact('session-root', undefined, 'compressible\n'.repeat(32 * 1024))
|
||||
const storedApi = await buildApi({ 'session-root': root }, [], { compressionLevel: 0 })
|
||||
const compressedApi = await buildApi({ 'session-root': root }, [], { compressionLevel: 9 })
|
||||
const stored = await storedApi.downloads.sessionLog(
|
||||
{ sessionId: sid('session-root'), includeDescendants: false },
|
||||
new AbortController().signal,
|
||||
)
|
||||
const compressed = await compressedApi.downloads.sessionLog(
|
||||
{ sessionId: sid('session-root'), includeDescendants: false },
|
||||
new AbortController().signal,
|
||||
)
|
||||
const storedBytes = await responseBytes(stored)
|
||||
const compressedBytes = await responseBytes(compressed)
|
||||
expect(compressedBytes.byteLength).toBeLessThan(storedBytes.byteLength)
|
||||
expect(strFromU8(unzipSync(compressedBytes)['session.jsonl'] as Uint8Array)).toBe(root.content)
|
||||
})
|
||||
|
||||
it('includes descendant artifacts under subagents/<id>/ when requested', async () => {
|
||||
const api = await buildApi({
|
||||
'session-root': artifact('session-root'),
|
||||
@@ -143,6 +192,55 @@ describe('session.export download endpoint', () => {
|
||||
.toBe(artifact('child-a').content)
|
||||
})
|
||||
|
||||
it('flushes each live root and descendant immediately before reading its artifact', async () => {
|
||||
const stored: Record<string, SessionRawArtifact> = {
|
||||
'session-root': artifact('session-root', undefined, 'stale root'),
|
||||
'child-a': artifact('child-a', sid('session-root'), 'stale child'),
|
||||
}
|
||||
const durable: Record<string, SessionRawArtifact> = {
|
||||
'session-root': artifact('session-root', undefined, 'durable root'),
|
||||
'child-a': artifact('child-a', sid('session-root'), 'durable child'),
|
||||
}
|
||||
const flushed: SessionId[] = []
|
||||
const api = await buildApi(stored, [node('child-a')], {
|
||||
sessions: {
|
||||
get: id => durable[id] === undefined ? undefined : { id },
|
||||
flush: async (session) => {
|
||||
const artifactAfterFlush = durable[session.id]
|
||||
if (artifactAfterFlush === undefined) throw new Error('unexpected session')
|
||||
flushed.push(session.id)
|
||||
stored[session.id] = artifactAfterFlush
|
||||
return true
|
||||
},
|
||||
},
|
||||
})
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
|
||||
)
|
||||
const files = unzipSync(await responseBytes(response))
|
||||
expect(flushed).toEqual([sid('session-root'), sid('child-a')])
|
||||
expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe('durable root')
|
||||
expect(strFromU8(files['subagents/child-a/session.jsonl'] as Uint8Array)).toBe('durable child')
|
||||
})
|
||||
|
||||
it('reads a cold artifact without asking the live-session store to flush', async () => {
|
||||
const flush = vi.fn(async () => true)
|
||||
const root = artifact('session-root')
|
||||
const api = await buildApi({ 'session-root': root }, [], {
|
||||
sessions: {
|
||||
get: () => undefined,
|
||||
flush,
|
||||
},
|
||||
})
|
||||
const response = await api.downloads.sessionLog(
|
||||
{ sessionId: sid('session-root'), includeDescendants: false },
|
||||
new AbortController().signal,
|
||||
)
|
||||
const files = unzipSync(await responseBytes(response))
|
||||
expect(flush).not.toHaveBeenCalled()
|
||||
expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content)
|
||||
})
|
||||
|
||||
it('answers 404 for a missing root session', async () => {
|
||||
const api = await buildApi({})
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
@@ -151,6 +249,15 @@ describe('session.export download endpoint', () => {
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('answers 501 when the persistence backend has no per-session raw artifacts', async () => {
|
||||
const api = await buildApi({}, [], { persistence: 'unsupported' })
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
expect(response.status).toBe(501)
|
||||
expect(await response.text()).toContain('does not expose per-session raw artifacts')
|
||||
})
|
||||
|
||||
it('answers 400 when the sessionId query parameter is absent', async () => {
|
||||
const api = await buildApi({ 'session-root': artifact('session-root') })
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
@@ -214,6 +321,37 @@ describe('session.export download endpoint', () => {
|
||||
expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content)
|
||||
})
|
||||
|
||||
it('waits for response pull capacity before reading the next archive entry', async () => {
|
||||
const root = artifact('session-root', undefined, [
|
||||
imageEventLine('after-root'),
|
||||
randomBytes(512 * 1024).toString('base64'),
|
||||
].join('\n'))
|
||||
let imageReads = 0
|
||||
const api = await buildApi({ 'session-root': root }, [], {
|
||||
attachments: async (ref) => {
|
||||
imageReads += 1
|
||||
return storedImage(String(ref.attachmentId), ref.mediaType)
|
||||
},
|
||||
})
|
||||
vi.useFakeTimers()
|
||||
let response: Response | undefined
|
||||
try {
|
||||
response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
// Exhausting timer turns must not advance a producer whose byte queue is
|
||||
// full; only a consumer pull can release it.
|
||||
await vi.runAllTimersAsync()
|
||||
expect(imageReads).toBe(0)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
if (response === undefined) throw new Error('missing export response')
|
||||
const files = unzipSync(await responseBytes(response))
|
||||
expect(imageReads).toBe(1)
|
||||
expect(files['media/after-root.png']).toEqual(storedImage('after-root').data)
|
||||
})
|
||||
|
||||
it('exports an empty artifact as an empty zip entry', async () => {
|
||||
const root = { ...artifact('session-root'), content: '' }
|
||||
const api = await buildApi({ 'session-root': root })
|
||||
@@ -254,10 +392,179 @@ describe('session.export download endpoint', () => {
|
||||
)
|
||||
expect(response.status).toBe(500)
|
||||
const body = await response.text()
|
||||
expect(body).toBe('session log export failed to read the stored artifact')
|
||||
expect(body).toBe('session log export failed to prepare the stored artifact')
|
||||
expect(body).not.toContain('/host/private/')
|
||||
})
|
||||
|
||||
it('answers the private-error-safe 500 when the live root flush fails', async () => {
|
||||
const api = await buildApi({ 'session-root': artifact('session-root') }, [], {
|
||||
sessions: {
|
||||
get: id => ({ id }),
|
||||
flush: async () => { throw new Error('/host/private/flush-state') },
|
||||
},
|
||||
})
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
expect(response.status).toBe(500)
|
||||
const body = await response.text()
|
||||
expect(body).toBe('session log export failed to prepare the stored artifact')
|
||||
expect(body).not.toContain('/host/private/')
|
||||
})
|
||||
|
||||
it('forwards one request signal through root, lineage, and descendant reads', async () => {
|
||||
const reads: Array<{ id: SessionId; signal: AbortSignal | undefined }> = []
|
||||
const traces: AbortSignal[] = []
|
||||
const api = await buildApi({}, [node('child-a')], {
|
||||
readRaw: async (id, signal) => {
|
||||
reads.push({ id, signal })
|
||||
return id === sid('session-root')
|
||||
? artifact('session-root')
|
||||
: artifact('child-a', sid('session-root'))
|
||||
},
|
||||
traceSession: async (_id, signal) => {
|
||||
if (signal !== undefined) traces.push(signal)
|
||||
return {
|
||||
target: { header: header('session-root'), live: false, persisted: true },
|
||||
ancestors: [],
|
||||
complete: true,
|
||||
root: { header: header('session-root'), live: false, persisted: true },
|
||||
descendants: [node('child-a')],
|
||||
}
|
||||
},
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const response = await api.downloads.sessionLog(
|
||||
{ sessionId: sid('session-root'), includeDescendants: true },
|
||||
controller.signal,
|
||||
)
|
||||
await response.arrayBuffer()
|
||||
const producerSignal = traces[0]
|
||||
if (producerSignal === undefined) throw new Error('missing lineage signal')
|
||||
expect(reads[0]).toEqual({ id: sid('session-root'), signal: controller.signal })
|
||||
expect(reads[1]).toEqual({ id: sid('child-a'), signal: producerSignal })
|
||||
const cancellation = new Error('request cancelled after response')
|
||||
controller.abort(cancellation)
|
||||
expect(producerSignal.aborted).toBe(true)
|
||||
expect(producerSignal.reason).toBe(cancellation)
|
||||
})
|
||||
|
||||
it('preserves request cancellation instead of translating it to HTTP 500', async () => {
|
||||
const api = await buildApi({ 'session-root': artifact('session-root') })
|
||||
const controller = new AbortController()
|
||||
const cancellation = new Error('request cancelled')
|
||||
controller.abort(cancellation)
|
||||
await expect(api.downloads.sessionLog(
|
||||
{ sessionId: sid('session-root'), includeDescendants: false },
|
||||
controller.signal,
|
||||
)).rejects.toBe(cancellation)
|
||||
})
|
||||
|
||||
it('aborts descendant work and terminates ZIP production when its reader cancels', async () => {
|
||||
let reportDescendantStarted!: (signal: AbortSignal) => void
|
||||
const descendantStarted = new Promise<AbortSignal>((resolve) => {
|
||||
reportDescendantStarted = resolve
|
||||
})
|
||||
const api = await buildApi({}, [node('child-a')], {
|
||||
readRaw: async (id, signal) => {
|
||||
if (id === sid('session-root')) return artifact('session-root')
|
||||
if (signal === undefined) throw new Error('missing descendant signal')
|
||||
reportDescendantStarted(signal)
|
||||
return new Promise((_, reject) => {
|
||||
signal.addEventListener('abort', () => {
|
||||
reject(signal.reason as Error)
|
||||
}, { once: true })
|
||||
})
|
||||
},
|
||||
})
|
||||
const response = await api.downloads.sessionLog(
|
||||
{ sessionId: sid('session-root'), includeDescendants: true },
|
||||
new AbortController().signal,
|
||||
)
|
||||
const reader = response.body?.getReader()
|
||||
if (reader === undefined) throw new Error('missing response body')
|
||||
const descendantSignal = await descendantStarted
|
||||
const cancellation = new Error('download consumer left')
|
||||
await reader.cancel(cancellation)
|
||||
expect(descendantSignal.aborted).toBe(true)
|
||||
expect(descendantSignal.reason).toBe(cancellation)
|
||||
})
|
||||
|
||||
it('aborts attachment reads when its reader cancels', async () => {
|
||||
let reportAttachmentStarted!: (signal: AbortSignal) => void
|
||||
const attachmentStarted = new Promise<AbortSignal>((resolve) => {
|
||||
reportAttachmentStarted = resolve
|
||||
})
|
||||
const root = artifact('session-root', undefined, [
|
||||
'{"type":"session","version":0,"id":"session-root","createdAt":1000}',
|
||||
imageEventLine('slow-img'),
|
||||
].join('\n') + '\n')
|
||||
const api = await buildApi({ 'session-root': root }, [], {
|
||||
attachments: async (_ref, signal) => {
|
||||
if (signal === undefined) throw new Error('missing attachment signal')
|
||||
reportAttachmentStarted(signal)
|
||||
return new Promise((_, reject) => {
|
||||
signal.addEventListener('abort', () => {
|
||||
reject(signal.reason as Error)
|
||||
}, { once: true })
|
||||
})
|
||||
},
|
||||
})
|
||||
const response = await api.downloads.sessionLog(
|
||||
{ sessionId: sid('session-root'), includeDescendants: false },
|
||||
new AbortController().signal,
|
||||
)
|
||||
const reader = response.body?.getReader()
|
||||
if (reader === undefined) throw new Error('missing response body')
|
||||
const attachmentSignal = await attachmentStarted
|
||||
const cancellation = new Error('download consumer left during attachment read')
|
||||
await reader.cancel(cancellation)
|
||||
expect(attachmentSignal.aborted).toBe(true)
|
||||
expect(attachmentSignal.reason).toBe(cancellation)
|
||||
})
|
||||
|
||||
it('uses a stable Error reason when its reader cancels without one', async () => {
|
||||
let reportDescendantStarted!: (signal: AbortSignal) => void
|
||||
const descendantStarted = new Promise<AbortSignal>((resolve) => {
|
||||
reportDescendantStarted = resolve
|
||||
})
|
||||
const api = await buildApi({}, [node('child-a')], {
|
||||
readRaw: async (id, signal) => {
|
||||
if (id === sid('session-root')) return artifact('session-root')
|
||||
if (signal === undefined) throw new Error('missing descendant signal')
|
||||
reportDescendantStarted(signal)
|
||||
return new Promise((_, reject) => {
|
||||
signal.addEventListener('abort', () => {
|
||||
reject(signal.reason as Error)
|
||||
}, { once: true })
|
||||
})
|
||||
},
|
||||
})
|
||||
const response = await api.downloads.sessionLog(
|
||||
{ sessionId: sid('session-root'), includeDescendants: true },
|
||||
new AbortController().signal,
|
||||
)
|
||||
const reader = response.body?.getReader()
|
||||
if (reader === undefined) throw new Error('missing response body')
|
||||
const descendantSignal = await descendantStarted
|
||||
await reader.cancel()
|
||||
expect(descendantSignal.reason).toEqual(new Error('session log export stream cancelled'))
|
||||
})
|
||||
|
||||
it('normalizes a non-Error descendant failure before erroring the stream', async () => {
|
||||
const api = await buildApi({}, [node('child-a')], {
|
||||
readRaw: async (id) => {
|
||||
if (id === sid('session-root')) return artifact('session-root')
|
||||
throw 'descendant read failed'
|
||||
},
|
||||
})
|
||||
const response = await api.downloads.sessionLog(
|
||||
{ sessionId: sid('session-root'), includeDescendants: true },
|
||||
new AbortController().signal,
|
||||
)
|
||||
await expect(response.arrayBuffer()).rejects.toEqual(new Error('descendant read failed'))
|
||||
})
|
||||
|
||||
it('includes media objects referenced by the root log under media/<id>.<ext>', async () => {
|
||||
const root = artifact('session-root', undefined, [
|
||||
'{"type":"session","version":0,"id":"session-root","createdAt":1000}',
|
||||
|
||||
@@ -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/interaction/README.md
|
||||
README.md: 8fe77ae845390359e2c1e3302400c08828a186a1
|
||||
README.zh.md: 68e13be7c55e64305bbe0b87ede8d37e55a7cd36
|
||||
README.md: 4640bf015723d03257eae3a360201c8cbe122e63
|
||||
README.zh.md: c7b9cd8f30ba12cf7c7ef3affc24e4ca72010b25
|
||||
|
||||
@@ -14,4 +14,4 @@ The services and plugins through which a human collaborates with a running agent
|
||||
|
||||
These packages integrate through existing agent and session contracts rather than changing the loop. Interactive applications provide the concrete command, approval, and question adapters; automation uses [`acp/`](../acp/README.md), and runnable demo bundles live under [`examples/`](../examples/README.md). The product [`dsh`](../../apps/cli/README.md) CLI composes these packages directly.
|
||||
|
||||
The subsystem references: [approval.md](../../docs/subsystems/approval.md), [permission.md](../../docs/subsystems/permission.md), [user-interaction.md](../../docs/subsystems/user-interaction.md), and [commands.md](../../docs/subsystems/commands.md). The automation-only ACP transport is [`acp/`](../acp/README.md), the SDK's JSON-RPC server half [`scaffold/server`](../scaffold/README.md), and the shared bin boot glue [`boot/`](../boot/README.md).
|
||||
The subsystem references: [approval.md](../../docs/subsystems/approval.md), [permission.md](../../docs/subsystems/permission.md), [user-interaction.md](../../docs/subsystems/user-interaction.md), and [commands.md](../../docs/subsystems/commands.md). The automation-only ACP transport is [`acp/`](../acp/README.md), the SDK's JSON-RPC server half is [`sdk/server`](../sdk/README.md), and the shared bin boot glue is [`boot/`](../boot/README.md).
|
||||
|
||||
@@ -14,4 +14,4 @@
|
||||
|
||||
这些包通过现有的 agent(智能体)和会话约定集成,而不改变循环。交互式应用提供具体的命令、审批和提问适配器;自动化使用 [`acp/`](../acp/README.md),可运行的演示组合包位于 [`examples/`](../examples/README.md)。产品 [`dsh`](../../apps/cli/README.md) CLI(命令行界面)直接组合这些包。
|
||||
|
||||
子系统参考:[approval.md](../../docs/subsystems/approval.md)、[permission.md](../../docs/subsystems/permission.md)、[user-interaction.md](../../docs/subsystems/user-interaction.md)与 [commands.md](../../docs/subsystems/commands.md)。仅自动化的 ACP 传输在 [`acp/`](../acp/README.md),SDK 的 JSON-RPC 服务器一半在 [`scaffold/server`](../scaffold/README.md),共享 bin 启动胶水在 [`boot/`](../boot/README.md)。
|
||||
子系统参考:[approval.md](../../docs/subsystems/approval.md)、[permission.md](../../docs/subsystems/permission.md)、[user-interaction.md](../../docs/subsystems/user-interaction.md)与 [commands.md](../../docs/subsystems/commands.md)。仅自动化的 ACP 传输是 [`acp/`](../acp/README.md),SDK 的 JSON-RPC 服务器一半是 [`sdk/server`](../sdk/README.md),共享 bin 启动胶水是 [`boot/`](../boot/README.md)。
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
# scaffold/ — create, launch, and drive projects from outside
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
This group contains developer tooling for Harness projects and the client stack for driving a Harness runtime from another process. Folders are role-named; npm names converge on `dsh-sdk-*` through the FIXME-tracked renames in the [regrouping Agent Note](../../.agents/notes/implemented/architecture/2026-07-29-package-regrouping.md).
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| [`helper/`](helper/README.md) | Provides the shared project-editing domain |
|
||||
| [`scripts/`](scripts/README.md) | Provides the `dsh-sdk` project commands |
|
||||
| [`create-sdk/`](create-sdk/README.md) | Creates new SDK projects |
|
||||
| [`protocol/`](protocol/README.md) | Defines the SDK runtime wire protocol |
|
||||
| [`client/`](client/README.md) | Drives a Harness runtime through the TypeScript client API |
|
||||
| [`server/`](server/README.md) | Serves out-of-process SDK clients over stdio JSON-RPC |
|
||||
| [`telemetry/`](telemetry/README.md) | Provides launcher telemetry, consent, and redaction primitives |
|
||||
|
||||
`@deepseek-ai/create-sdk` follows npm's scoped initializer naming convention; the other packages follow the repository's `@deepseek-ai/dsh-*` convention. See the [developer-project workflow](../../.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.md), [project-editing architecture](../../.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md), and [TypeScript SDK design](../../.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md).
|
||||
@@ -1,17 +0,0 @@
|
||||
# scaffold/:从外部创建、启动、驱动项目
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
本组包含 Harness 项目的开发者工具,以及从另一进程驱动 Harness 运行时的客户端栈。目录按角色命名;npm 名则经由[重新分组 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-29-package-regrouping.md)中 FIXME 跟踪的改名收敛为 `dsh-sdk-*`。
|
||||
|
||||
| 包 | 职责 |
|
||||
|---|---|
|
||||
| [`helper/`](helper/README.md) | 提供共享的项目编辑领域 |
|
||||
| [`scripts/`](scripts/README.md) | 提供 `dsh-sdk` 项目命令 |
|
||||
| [`create-sdk/`](create-sdk/README.md) | 创建新的 SDK 项目 |
|
||||
| [`protocol/`](protocol/README.md) | 定义 SDK 运行时通信协议 |
|
||||
| [`client/`](client/README.md) | 通过 TypeScript 客户端 API 驱动 Harness 运行时 |
|
||||
| [`server/`](server/README.md) | 通过 stdio JSON-RPC 为进程外 SDK 客户端提供服务 |
|
||||
| [`telemetry/`](telemetry/README.md) | 提供启动器 telemetry、同意与脱敏原语 |
|
||||
|
||||
`@deepseek-ai/create-sdk` 遵循 npm 的 scoped initializer 命名约定;其余包遵循仓库的 `@deepseek-ai/dsh-*` 约定。参见[开发者项目工作流](../../.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.md)、[项目编辑架构](../../.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md)与 [TypeScript SDK 设计](../../.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md)。
|
||||
@@ -1,6 +0,0 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/scaffold/create-sdk/README.md
|
||||
README.md: 7d0d9415c74a4c3b4514bb90ab81c5895b9e347f
|
||||
README.zh.md: a4685e66bc3eb7924dd247a7b6b420f6bcc70990
|
||||
@@ -1,25 +0,0 @@
|
||||
# `@deepseek-ai/create-sdk`
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Interactive initializer for `npm create @deepseek-ai/sdk [directory]`. Directory/name/description have visible editable defaults. A tree picker selects features and configures finite options with Right/Left navigation; secret text follows only for selected options. Local plugin creation is one none/plugin/tool choice.
|
||||
|
||||
The supported package entry point is the `create-sdk` bin. The package root exports no symbols, and workflow, bin, source, and package-manifest subpaths are not exported.
|
||||
|
||||
The initializer rejects every existing target path, creates one `SdkProject` edit session, validates and commits it, then asks whether to install NPM dependencies and build. Install or build failures keep the generated project and print a retry command.
|
||||
|
||||
Public flags are `[directory]`, `--description`, `--provider`, `--base-url`, `--api-key`, `--model`, `--interface`, `--pm`, `--install`/`--no-install`, plus the headless flags `--config <path>` / `--config-json <json>` and `--json`. Interactive flags prefill matching questions; a headless spec (`--config`/`--config-json`) supplies every answer and its feature plan up front, so creation runs without a TTY and drives through a `HeadlessPromptPort` that fails loud on any missing required answer. `--json` emits NDJSON lifecycle events (`done` / `action-required` / `error`) so an agent can fill the named missing input and re-run.
|
||||
|
||||
The provider choice is DeepSeek or a custom endpoint backed by `llm-pi-ai`. DeepSeek asks only for an API key and uses the public endpoint plus `deepseek-v4-flash`; custom also asks for a base URL. An empty key requires confirmation and creates a commented empty `.env` variable so provider startup fails clearly until it is filled. Existing plugin defaults are omitted; required SDK presets remain typed against the owning package's Config.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the generated project composition and its selected runtime plugins; the headless `--config-json` + `--json` interface additionally lets an agent create a project end to end and react to `action-required` events.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Headless local plugins** — the headless spec supplies project answers and the feature plan; scaffolding a local plugin (the interactive none/plugin/tool choice) is not yet expressible in the spec and defaults to none.
|
||||
@@ -1,25 +0,0 @@
|
||||
# `@deepseek-ai/create-sdk`
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
用于 `npm create @deepseek-ai/sdk [directory]` 的交互式初始化器。目录/名称/描述都提供可见且可编辑的默认值。树形选择器用于选择功能,并通过 Right/Left 导航配置取值有限的选项;只有选中相应选项后才会询问密钥文本。本地插件创建提供 none/plugin/tool 三选一。
|
||||
|
||||
受支持的包接口是 `create-sdk` bin。包根不导出任何符号,也不导出 workflow、bin、source 或 package-manifest 子路径。
|
||||
|
||||
初始化器拒绝任何已经存在的目标路径,创建一个 `SdkProject` 编辑会话,验证并提交该会话,然后询问是否安装 NPM 依赖并构建。安装或构建失败时会保留生成的项目,并打印重试命令。
|
||||
|
||||
公开标志包括 `[directory]`、`--description`、`--provider`、`--base-url`、`--api-key`、`--model`、`--interface`、`--pm`、`--install`/`--no-install`,以及无头模式标志 `--config <path>`/`--config-json <json>` 和 `--json`。交互式标志会预填对应问题;无头 spec(`--config`/`--config-json`)会预先提供所有答案和功能方案,因此创建过程无需 TTY,并通过 `HeadlessPromptPort` 驱动;若缺少任何必填答案,该端口会明确失败。`--json` 会发送 NDJSON 生命周期事件(`done`/`action-required`/`error`),使 agent(智能体)能够补充其中点名的缺失输入并重新运行。
|
||||
|
||||
提供方可以选择 DeepSeek,也可以选择由 `llm-pi-ai` 支持的自定义端点。选择 DeepSeek 时只询问 API key,并使用公共端点与 `deepseek-v4-flash`;自定义端点还会询问 base URL。密钥为空时必须确认;系统会在 `.env` 中创建一个被注释掉的空变量,从而使提供方在为该变量填入值之前启动时明确失败。现有插件的默认值会被省略;必填 SDK 预设仍按所属包的 Config 保持类型约束。
|
||||
|
||||
## 模型体验
|
||||
|
||||
通过生成的项目组合及其所选运行时插件间接提供;此外,无头 `--config-json` + `--json` 接口允许 agent 端到端创建项目,并响应 `action-required` 事件。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
不会直接导致 KV Cache 失效;由具名消费方负责请求前缀变更。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **无头本地插件**:无头 spec 会提供项目答案和功能方案;目前还不能在 spec 中表达本地插件脚手架(交互式 none/plugin/tool 选择),默认使用 none。
|
||||
@@ -1,49 +0,0 @@
|
||||
{
|
||||
"name": "@deepseek-ai/create-sdk",
|
||||
"description": "Create a DeepSeek Harness SDK project with npm create @deepseek-ai/sdk",
|
||||
"version": "0.0.1-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/scaffold/create-sdk"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"bin": {
|
||||
"create-sdk": "lib/bin.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/bin.js",
|
||||
"lib/assets",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-helper": "workspace:^",
|
||||
"commander": "^15.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
/**
|
||||
* Commander adapter for the create-sdk command interface.
|
||||
*
|
||||
* @module @deepseek-ai/create-sdk/args
|
||||
*/
|
||||
|
||||
import { Command, Option } from 'commander'
|
||||
import type { PackageManagerName, RunInterface } from '@deepseek-ai/dsh-helper'
|
||||
|
||||
/** Parsed create command flags before interactive resolution. */
|
||||
export interface CreateArgs {
|
||||
directory?: string
|
||||
description?: string
|
||||
provider?: 'deepseek-official' | 'custom'
|
||||
baseURL?: string
|
||||
apiKey?: string
|
||||
model?: string
|
||||
runInterface?: RunInterface
|
||||
packageManager?: PackageManagerName
|
||||
install?: boolean
|
||||
linkWorkspace?: boolean
|
||||
config?: string
|
||||
configJson?: string
|
||||
json?: boolean
|
||||
help: boolean
|
||||
}
|
||||
|
||||
interface CommanderCreateOptions {
|
||||
description?: string
|
||||
provider?: 'deepseek-official' | 'custom'
|
||||
baseUrl?: string
|
||||
apiKey?: string
|
||||
model?: string
|
||||
interface?: RunInterface
|
||||
pm?: PackageManagerName
|
||||
install?: boolean
|
||||
linkWorkspace?: boolean
|
||||
config?: string
|
||||
configJson?: string
|
||||
json?: boolean
|
||||
help?: boolean
|
||||
}
|
||||
|
||||
function createProgram(): Command {
|
||||
return new Command()
|
||||
.name('create-sdk')
|
||||
.description('Create a DeepSeek Harness SDK project')
|
||||
.helpOption(false)
|
||||
.showHelpAfterError(false)
|
||||
.exitOverride()
|
||||
.configureOutput({
|
||||
/* v8 ignore next -- the command wrapper renders the package-owned usage template */
|
||||
writeOut: () => {},
|
||||
/* v8 ignore next -- Commander output is deliberately suppressed; errors are returned to the bin wrapper */
|
||||
writeErr: () => {},
|
||||
})
|
||||
.argument('[directory]')
|
||||
.option('-h, --help')
|
||||
.option('--description <text>')
|
||||
.addOption(new Option('--provider <name>').choices(['deepseek-official', 'custom']))
|
||||
.option('--base-url <url>')
|
||||
.option('--api-key <key>')
|
||||
.option('--model <name>')
|
||||
.addOption(new Option('--interface <name>').choices(['acp', 'embed']))
|
||||
.addOption(new Option('--pm <name>').choices(['npm', 'pnpm', 'yarn']))
|
||||
.addOption(new Option('--install').default(undefined))
|
||||
.addOption(new Option('--no-install').default(undefined))
|
||||
.option('--link-workspace')
|
||||
.option('--config <path>')
|
||||
.option('--config-json <json>')
|
||||
.addOption(new Option('--json').default(undefined))
|
||||
}
|
||||
|
||||
/** Parse create-sdk positionals/options through Commander into a domain-neutral value. */
|
||||
export function parseCreateArgs(argv: readonly string[]): CreateArgs {
|
||||
const program = createProgram()
|
||||
program.parse([...argv], { from: 'user' })
|
||||
const options = program.opts<CommanderCreateOptions>()
|
||||
const directory = program.processedArgs[0] as string | undefined
|
||||
return {
|
||||
...directory === undefined ? {} : { directory },
|
||||
...options.description === undefined ? {} : { description: options.description },
|
||||
...options.provider === undefined ? {} : { provider: options.provider },
|
||||
...options.baseUrl === undefined ? {} : { baseURL: options.baseUrl },
|
||||
...options.apiKey === undefined ? {} : { apiKey: options.apiKey },
|
||||
...options.model === undefined ? {} : { model: options.model },
|
||||
...options.interface === undefined ? {} : { runInterface: options.interface },
|
||||
...options.pm === undefined ? {} : { packageManager: options.pm },
|
||||
...options.install === undefined ? {} : { install: options.install },
|
||||
...options.linkWorkspace ? { linkWorkspace: true } : {},
|
||||
...options.config === undefined ? {} : { config: options.config },
|
||||
...options.configJson === undefined ? {} : { configJson: options.configJson },
|
||||
...options.json === undefined ? {} : { json: options.json },
|
||||
help: options.help ?? false,
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user