Merge pull request #472 from omdsh-dev/fix/dsh-0.1.2-alpha.2

feat!: alpha track v0.18.0-alpha.0 — adapt to DSH 0.1.2-alpha.2, drop pre-alpha hosts, fix blank Side Chat transcripts
This commit is contained in:
Atlas Zeng
2026-08-30 23:45:47 +08:00
committed by GitHub
35 changed files with 1067 additions and 1820 deletions
+4 -5
View File
@@ -81,12 +81,11 @@ jobs:
# Pin the DSH CLI the lane mounts into; the profile's bundle stack
# (dsh-base / dsh-web-app) resolves from this installation. Keep in
# step with the plugin's @deepseek-ai/* peer range when bumping.
# NOTE: DSH 0.1.2-alpha.1 (Remote gateway slash endpoints + one-time-
# token browser auth) is NOT on npm yet — the lanes speak BOTH dialects
# (tests/e2e/host-protocol.ts, dot-first with slash fallback), so bump
# this pin once @deepseek-ai/dsh@0.1.2-alpha.1 publishes.
# DSH 0.1.2-alpha.x is published under the `alpha` dist-tag; the lanes
# speak BOTH RPC dialects (tests/e2e/host-protocol.ts, dot-first with
# slash fallback), so pre-alpha npm releases keep working too.
- name: Install dsh CLI (pinned)
run: npm install -g @deepseek-ai/dsh@0.1.1-rc.2
run: npm install -g @deepseek-ai/dsh@0.1.2-alpha.2
# lib/ is gitignored; the tarball is produced from this build.
- name: Build and pack the npm tarball
+9 -1
View File
@@ -6,6 +6,11 @@ name: Release
# Trigger: a GitHub Release is published (tag vX.Y.Z). The tag MUST match
# package.json `version`; the workflow verifies this before publishing.
#
# Dist-tag policy: a PRERELEASE version (e.g. v0.18.0-alpha.0 — the DSH
# 0.1.2-alpha adapter track) publishes under the npm `alpha` dist-tag so the
# `latest` install target keeps pointing at the last stable cut (which
# supports the stable DSH line); stable versions publish as `latest`.
#
# One-time npm-side setup (manual, on npmjs.com):
# 1. package `dsh-better-sidebar` -> Settings -> Publishing access
# -> Trusted Publishers -> "Add Trusted Publisher".
@@ -94,4 +99,7 @@ jobs:
- name: Publish to npm
if: github.event_name == 'release' || github.event.inputs.dry_run != 'true'
run: pnpm publish --provenance --access public --no-git-checks
run: |
TAG=$(node -p "require('./package.json').version.includes('-') ? 'alpha' : 'latest'")
echo "publishing to npm dist-tag: ${TAG}"
pnpm publish --provenance --access public --no-git-checks --tag "$TAG"
+14 -8
View File
@@ -19,18 +19,24 @@
「npm 打包 → 真实挂载 → 无头渲染」门禁(证明打包产物在真实 DSH 挂载后不 crash):`pnpm build && pnpm pack` 产 tarball → `scripts/e2e-mount.sh` 装进全新 scratch profile(`dsh plugin --profile web add file:<tarball>`)并启动真实 `dsh web`(keyless,`--port 0`)→ `tests/e2e/mount.e2e.ts`(Playwright)断言 `[data-dsh-better-sidebar]` 挂载、无错误条/pageerror/console 错误,经「+ 菜单」逐个打开内置 tab(含终端懒加载 chunk),再经文件树打开 seed 文件强制加载 editor chunk(`client-editor.js`)。
本地:`pnpm build && pnpm pack && pnpm exec playwright install chromium && pnpm test:mount`。CI 钉 `@deepseek-ai/dsh@0.1.1-rc.2`(peer 下限 `^0.1.0-rc.8`,双向兼容)。e2e spec 命名 `*.e2e.ts` + vitest `exclude` 双保险;**改 `exclude` 必须保留默认排除项**(exclude 整体替换默认值)。
本地:`pnpm build && pnpm pack && pnpm exec playwright install chromium && pnpm test:mount`。CI 钉 `@deepseek-ai/dsh@0.1.2-alpha.2`(`alpha` dist-tag;peer 下限 `^0.1.2-alpha.2`)。e2e spec 命名 `*.e2e.ts` + vitest `exclude` 双保险;**改 `exclude` 必须保留默认排除项**(exclude 整体替换默认值)。
---
## 3. DSH 0.1.2-alpha.1 适配要点(双协议 e2e)
## 3. DSH 0.1.2-alpha 适配要点(alpha 通道)
tag `dsh-v0.1.2-alpha.1`,**尚未发布 npm**(CI 钉版暂不动)。破坏性变更:
**v0.18.0-alpha.0 起插件进入 alpha 通道,仅支持 DSH 0.1.2-alpha.x**(peer 下限 `^0.1.2-alpha.2`,CI 钉 `@deepseek-ai/dsh@0.1.2-alpha.2`,npm 走 `alpha` dist-tag)。对 0.1.0-rc.8 ~ 0.1.1-rc.2 的兼容层已删除——双方言 RPC(点分回退)、MarkdownText 双形状 labels、`dsh-client-runtime` externals/inject 残留行;stable DSH 用户请装 v0.17.1(npm `latest`)。发版:release.yml 按版本号是否含 `-` 自动选 `alpha`/`latest` dist-tag。
1. **一次性 token 鉴权**:就绪行变为 `dsh web: http://127.0.0.1:<port>/?token=<43字符>`(导航换签名 cookie,干净 URL 401)。`e2e-mount.sh` 的 URL grep 必须延伸到空白(`[^ ]*`,在 `/` 截断丢 token);e2e 统一走 `tests/e2e/host.ts`:`PAGE_URL`/`pageUrl({...})` goto(合并 query 与 token),`createHostApi()` 先 token 换 `Set-Cookie`(`redirect: 'manual'`)。token 换 cookie 的 303 会**丢弃同 URL 其它 query 参数**——带 stamps 导航走 `host.ts` 的 `gotoPage()`(先 addCookies 再直达)。插件 `/sidebar/*` 路由不受影响,同源 fetch 照旧。
2. **Remote gateway 取代 ApiProxy**:点分 `POST /api/workspace.create`(裸参数)→ 斜杠 `POST /api/workspace/create`,payload 恰为 `{args: {...}}`,**args 按控制器 TS 参数名包装**(`workspace/create`、`session/create` → `{args:{request:{...}}}`;`session/list` 参数名 `_request` **不可省略**——`{}` 也被拒 `args fields do not match the descriptor`);envelope `method` 与路径一致,点分 404。`hostRpc(api, 'workspace.create', args)` 点分优先、404 回退斜杠并缓存;`SLASH_ARGS_KEY` 由 `tests/e2e-host-protocol.spec.ts` 锁定。
3. **`@deepseek-ai/dsh-client-runtime` 已移除**(继任 seed 是裸名 `@deepseek-ai/dsh-client-store`,无 `/client` 子路径)。插件源码零引用(externals 白名单解析失败被 `buildExternalsRequire` 宽容跳过,见 `src/client/chunk-loader.ts` 头注释),已从 peerDependencies 移除;`dsh.client.inject` 保留该条目无害。
4. **`MarkdownText` labels 契约(真机验证的破坏)**:labels prop 从扁平可选 `codeLabels` 改为**必填嵌套** `labels: { code: { copyLabel, copiedLabel }, footnotes }`——只传旧 prop 时 fence 渲染抛 `Cannot read properties of undefined (reading 'code')`。四个渲染点(mermaid.tsx / MarkdownHtml.tsx / TextEditor.tsx / SideChatView.tsx)统一走 `src/client/markdown-labels.tsx` 的 `markdownTextProps()`(双形状 + 双 prop 名两版通吃;漏传回退硬编码中文)。
宿主契约(均经真机挂载冒烟 14/14 验证):
1. **一次性 token 鉴权**:就绪行 `dsh web: http://127.0.0.1:<port>/?token=<43字符>`(导航换签名 cookie,干净 URL 401)。`e2e-mount.sh` 的 URL grep 必须延伸到空白(`[^ ]*`,在 `/` 截断丢 token);e2e 统一走 `tests/e2e/host.ts`(token 必选:`parseLaunchUrl` 对裸 origin 直接抛错),带 stamps 导航走 `gotoPage()`(先 addCookies 再直达——token 换 cookie 的 303 会丢弃同 URL 其它 query 参数)。插件 `/sidebar/*` 路由不受影响,同源 fetch 照旧。
2. **Remote gateway 斜杠 RPC(唯一方言)**:`POST /api/workspace/create`,payload 恰为 `{args: {...}}`,**args 按控制器 TS 参数名包装**(`workspace/create`、`session/create` → `{args:{request:{...}}}`;`session/list` 参数名 `_request` **不可省略**——`{}` 也被拒 `args fields do not match the descriptor`);envelope `method` 与路径一致,点分路径 404。请求由 `tests/e2e/host-protocol.ts` 的 `rpcAttempt` 构造、`tests/e2e-host-protocol.spec.ts` 锁定;要调新方法先在真机验参数名再进 `RPC_ARGS_KEY`。
3. **`MarkdownText` labels 嵌套契约**:必填 `labels: { code: { copyLabel, copiedLabel }, footnotes }`(漏传回退硬编码中文)。四个渲染点(mermaid.tsx / MarkdownHtml.tsx / TextEditor.tsx / SideChatView.tsx)统一走 `src/client/markdown-labels.tsx` 的 `markdownTextProps()`。
4. **侧边对话转录走自有路由,不碰客户端宿主 RPC**:`ctx.connection.api`(含 `sessions.history`)在 alpha.1 整体移除,继任 `session/follow|page` 又对 `origin:'subagent'` 会话强制 subagent 地址(普通 `{kind:'session'}` 被 `agent-busy` 拒)且分页 `throughSeq` 不得超当前游标。转录因此由 **`sidechat.events`** 插件路由供给(`src/sidechat-routes.ts`:live 读 `agent.session.events`、冷读 `sessionPersistence.inspect`,服务端 `session/end-seed` 切割 + `afterSeq` 增量);`ctx.connection` 镜像与 inject 已删。设计见 [docs/plans/2026-08-20-sidechat-tab-design.md](docs/plans/2026-08-20-sidechat-tab-design.md) §10。
5. **`dsh-settings` 无运行时 `settingsNamespace`**:命名空间合法性校验转为编译期模板字面量 `SettingsNamespaceInput`(小写字母开头 + `[a-z0-9-]` 尾部),`'dsh-better-sidebar'` 字面量直接过——宿主侧直接传常量(`src/index.ts` 的 settings inject)。
6. **`dsh-subagent` 的 `SUBAGENT_DESCRIPTOR_VERSION` 2 → 3**:sidechat 种子的 `subagent/descriptor` 版本由宿主包盖章,插件不硬编码;测试断言跟随常量(`tests/sidechat-routes.spec.ts`),勿钉字面量。
7. **`@deepseek-ai/dsh-client-runtime` 包已消亡**(继任 seed 是裸名 `dsh-client-store`,无 `/client` 子路径):peerDependencies、devDependencies、`dsh.client.inject`、chunk externals 白名单(`src/client/chunk-loader.ts` / `tsdown.config.ts` / `tests/chunk-loader.spec.ts` / `tests/manifest-consistency.spec.ts` 四处同步)均已无该条目。
8. **e2e scratch profile 的 `minimumReleaseAgeExclude` 含 `'@deepseek-ai/*'`**(`scripts/e2e-mount.sh` / `e2e-aggregate-mount.sh`,与仓库根 `pnpm-workspace.yaml` 同策):alpha 版本常在发布后 24h 内跑 lane,pnpm 11 的 `minimumReleaseAge` 默认会拒装新鲜包。
---
@@ -49,7 +55,7 @@ tag `dsh-v0.1.2-alpha.1`,**尚未发布 npm**(CI 钉版暂不动)。破坏
- **构建纯度门**:client bundle 禁止 value-import `@dsh-external/*` 或非白名单 `@deepseek-ai/*`(`tsdown.config.ts` 拦截);`import type {}` 被擦除不触发——类型可共享,运行时符号不行;跨插件交互走 `ctx.betterSidebar` 方法调用。
- **懒加载 chunk**:重依赖(xterm/CodeMirror/mermaid)在独立 bundle(`lib/client-<name>.js`),经 `/sidebar/bundle` 按需下发、`globalThis.__dshChunks__` 物化(`src/client/chunk-loader.ts`),**核心 bundle 禁止静态 import `src/client/chunks/*`**。
- **i18n**:词典在 `betterSidebar` 命名空间,跟随 DSH `ctx.locale`;**新增 zh key 必须同步 `src/client/locales-ja.ts` 的 ja 翻译**(否则 ja 下回退 en)。渲染 `MarkdownText` 必须经 `markdownTextProps()`(§3 第 4 条)。
- **i18n**:词典在 `betterSidebar` 命名空间,跟随 DSH `ctx.locale`;**新增 zh key 必须同步 `src/client/locales-ja.ts` 的 ja 翻译**(否则 ja 下回退 en)。渲染 `MarkdownText` 必须经 `markdownTextProps()`(§3 第 3 条)。
- **皮肤契约**:视觉值只消费 `--dsw-alias-*` / `--dsw-font-*` / `--ds-*` 令牌,无硬编码颜色;契约全文与 titleBar 四方案模型见[指南 §12](docs/external-plugin-guide.md),改动必须同步该节与 `tests/theme.spec.ts`。
- **契约反向引用**:皮肤契约被 `src/client/shell-presets.ts`、`tests/e2e/mount.e2e.ts` 的注释以「指南 §12」引用——调整指南章节结构时同步检查这两处。
- **接入 API 即文档**:`src/client/service.ts` 与 `src/client/builtins/` 的任何行为变更,必须同步 [docs/external-plugin-guide.md](docs/external-plugin-guide.md)(唯一权威接入文档,不再双份维护)。
+15 -5
View File
@@ -9,7 +9,7 @@
<a href="https://github.com/omdsh-dev/DSH-better-sidebar/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/omdsh-dev/DSH-better-sidebar" /></a>
<a href="https://opensource.org/licenses/MIT"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-yellow.svg" /></a>
<a href="https://dshfind.com/zh/plugins/omdsh-dev/DSH-better-sidebar?ref=badge"><img alt="dshfind" src="https://dshfind.com/api/badge/omdsh-dev/DSH-better-sidebar?lang=zh" /></a><br /><br />
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本:0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2 · 0.1.2-alpha.1" src="https://img.shields.io/badge/DSH-0.1.0--rc.8_%C2%B7_0.1.1--rc.1_%C2%B7_0.1.1--rc.2_%C2%B7_0.1.2--alpha.1-4d6bfe" /></a>
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.18.0-alpha.0 alpha 通道):0.1.2-alpha.2" src="https://img.shields.io/badge/DSH-0.1.2--alpha.2_(alpha%20%E9%80%9A%E9%81%93)-4d6bfe" /></a>
<a href="https://github.com/topics/dsh-better-sidebar"><img alt="插件生态:GitHub topic dsh-better-sidebar" src="https://img.shields.io/badge/%E6%8F%92%E4%BB%B6%E7%94%9F%E6%80%81-topic%20dsh--better--sidebar-4d6bfe" /></a><br /><br />
<img alt="文件管理" src="https://img.shields.io/badge/-文件管理-4d6bfe" /> <img alt="编辑预览" src="https://img.shields.io/badge/-编辑预览-4d6bfe" /> <img alt="内嵌浏览器" src="https://img.shields.io/badge/-内嵌浏览器-4d6bfe" /> <img alt="真实终端" src="https://img.shields.io/badge/-真实终端-4d6bfe" /> <img alt="Git 面板" src="https://img.shields.io/badge/-Git%20面板-4d6bfe" /> <img alt="后台任务" src="https://img.shields.io/badge/-后台任务-4d6bfe" /> <img alt="侧边对话" src="https://img.shields.io/badge/-侧边对话-4d6bfe" /> <img alt="插件接入" src="https://img.shields.io/badge/-插件接入-4d6bfe" /><br /><br />
<b>右侧栏 + 底部面板双工作台</b>,并把 <code>ctx.betterSidebar</code> 服务开放给所有插件——<br />
@@ -62,7 +62,9 @@
**前置**:已装好 DSH(`dsh web` 能正常运行),Node.js ≥ 20、pnpm ≥ 10。
**支持的 DSH 版本**:
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本:0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2 · 0.1.2-alpha.1" src="https://img.shields.io/badge/DSH-0.1.0--rc.8_%C2%B7_0.1.1--rc.1_%C2%B7_0.1.1--rc.2_%C2%B7_0.1.2--alpha.1-4d6bfe" /></a>
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.18.0-alpha.0 alpha 通道):0.1.2-alpha.2" src="https://img.shields.io/badge/DSH-0.1.2--alpha.2_(alpha%20%E9%80%9A%E9%81%93)-4d6bfe" /></a>
> 🧪 **alpha 通道**:`v0.18.0-alpha.0` 起适配 DSH **0.1.2-alpha.x**(npm dist-tag `alpha`),不再支持 0.1.0-rc.8 ~ 0.1.1-rc.2——DSH stable 用户请继续用 `@latest`(v0.17.1);DSH 0.1.2-alpha.2 用户安装 `dsh-better-sidebar@alpha`。
```sh
dsh plugin --profile web add dsh-better-sidebar@latest # 首次会因 pnpm 11 拦截 node-pty 构建脚本而失败(依赖已写入)
@@ -256,11 +258,19 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
## 🆕 最近更新
**支持的 DSH 版本**:<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本:0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2 · 0.1.2-alpha.1" src="https://img.shields.io/badge/DSH-0.1.0--rc.8_%C2%B7_0.1.1--rc.1_%C2%B7_0.1.1--rc.2_%C2%B7_0.1.2--alpha.1-4d6bfe" /></a> · 完整发布历史见 [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases)
**支持的 DSH 版本**:<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本(v0.18.0-alpha.0 alpha 通道):0.1.2-alpha.2" src="https://img.shields.io/badge/DSH-0.1.2--alpha.2_(alpha%20%E9%80%9A%E9%81%93)-4d6bfe" /></a> · 完整发布历史见 [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases)
### v0.18.0-alpha.0
> 🧪 **alpha 通道**:本版仅支持 **DSH 0.1.2-alpha.x**(peer 下限 `^0.1.2-alpha.2`,npm dist-tag `alpha`,安装 `dsh-better-sidebar@alpha`);不再支持 0.1.0-rc.8 ~ 0.1.1-rc.2——stable DSH 用户请用 v0.17.1(npm `latest`)。
- 🔗 **适配 DSH 0.1.2-alpha.2(npm 已发布,`alpha` dist-tag)**:CI 挂载门禁钉版与 `@deepseek-ai/*` devDependencies 基线升至 0.1.2-alpha.2(真机挂载冒烟 14/14 验证)。适配点:`dsh-settings` 移除运行时导出 `settingsNamespace`(命名空间改为编译期校验,宿主侧直接传常量);`dsh-subagent` 描述符版本 2→3(由宿主包盖章,测试断言跟随 `SUBAGENT_DESCRIPTOR_VERSION` 常量);`SessionEvent.ignorable` 恢复与 Remote 网关 `RemoteError` 封装经核实对本插件无破坏。
- 🐛 **修复 DSH 0.1.2-alpha.1+ 上侧边对话转录空白**:转录轮询此前仍走 alpha.1 已移除的 `ctx.connection.api`(错误被静默吞掉,tab 永远渲染空转录),现改由插件自有 `sidechat.events` 路由供给(活线程读内存事件日志、冷线程读会话持久化,`afterSeq` 增量拉取,[sidechat-routes.ts](./src/sidechat-routes.ts))。
- 🧹 **删除对 0.1.1-rc.x 及更早的兼容层**:e2e 宿主 RPC 从点分/斜杠双方言收敛为斜杠单方言(token URL 必选,[host-protocol.ts](./tests/e2e/host-protocol.ts));`MarkdownText` labels 收敛为嵌套单形状([markdown-labels.tsx](./src/client/markdown-labels.tsx),不再双 prop 名);peerDependencies / devDependencies / `dsh.client.inject` / chunk externals 白名单四处同步清除已消亡的 `@deepseek-ai/dsh-client-runtime`。
### v0.17.1
- 🔗 **DSH 0.1.2-alpha.1 适配(双版本兼容)**:DSH 0.1.2-alpha.1 的 Remote gateway、一次性 token 浏览器鉴权与 `MarkdownText` labels 契约变更已全量适配,插件在 0.1.0-rc.8 ~ 0.1.1-rc.2 与 0.1.2-alpha.1 上一致工作(后者经 GitHub tag 源码构建的真机挂载冒烟 14/14 验证;该版本 npm 尚未发布,CI 钉版暂保持 0.1.1-rc.2,发布后平移)。要点:`MarkdownText` 四个渲染点统一改走双形状 labels helper([markdown-labels.tsx](./src/client/markdown-labels.tsx)),修复 alpha.1 上 markdown/mermaid 预览的 `reading 'code'` 崩溃;e2e 冒烟双协议化(token URL 换 cookie、`/api` 斜杠端点 + 按参数名包装 args,[tests/e2e/host-protocol.ts](./tests/e2e/host-protocol.ts));移除已在 0.1.2-alpha.1 消亡的 `@deepseek-ai/dsh-client-runtime` peer
- 🔗 **DSH 0.1.2-alpha.1 适配(双版本兼容)**:DSH 0.1.2-alpha.1 的 Remote gateway、一次性 token 浏览器鉴权与 `MarkdownText` labels 契约变更已全量适配,插件在 0.1.0-rc.8 ~ 0.1.1-rc.2 与 0.1.2-alpha.1 上一致工作(后者经 GitHub tag 源码构建的真机挂载冒烟 14/14 验证;alpha.1 至今未发布 npm,CI 钉版已在 v0.18.0-alpha.0 升至 npm 发布的 0.1.2-alpha.2)。要点:`MarkdownText` 四个渲染点统一改走双形状 labels helper([markdown-labels.tsx](./src/client/markdown-labels.tsx)),修复 alpha.1 上 markdown/mermaid 预览的 `reading 'code'` 崩溃;e2e 冒烟双协议化(token URL 换 cookie、`/api` 斜杠端点 + 按参数名包装 args,[tests/e2e/host-protocol.ts](./tests/e2e/host-protocol.ts));移除已在 0.1.2-alpha.1 消亡的 `@deepseek-ai/dsh-client-runtime` peer
### v0.16.1
@@ -462,7 +472,7 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
## 🛠️ 开发与构建
```sh
pnpm install # @deepseek-ai/* devDependencies 已发布 0.1.1-rc.1,直接解析、无需令牌
pnpm install # @deepseek-ai/* devDependencies 已发布(基线 0.1.2-alpha.2,alpha dist-tag),直接解析、无需令牌
pnpm typecheck # tsc --noEmit
pnpm build # → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types
pnpm test # vitest(含 manifest 一致性守卫,需先 build)
+15 -5
View File
@@ -9,7 +9,7 @@
<a href="https://github.com/omdsh-dev/DSH-better-sidebar/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/omdsh-dev/DSH-better-sidebar" /></a>
<a href="https://opensource.org/licenses/MIT"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-yellow.svg" /></a>
<a href="https://dshfind.com/en/plugins/omdsh-dev/DSH-better-sidebar?ref=badge"><img alt="dshfind" src="https://dshfind.com/api/badge/omdsh-dev/DSH-better-sidebar?lang=en" /></a><br /><br />
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions: 0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2 · 0.1.2-alpha.1" src="https://img.shields.io/badge/DSH-0.1.0--rc.8_%C2%B7_0.1.1--rc.1_%C2%B7_0.1.1--rc.2_%C2%B7_0.1.2--alpha.1-4d6bfe" /></a>
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions (v0.18.0-alpha.0 alpha track): 0.1.2-alpha.2" src="https://img.shields.io/badge/DSH-0.1.2--alpha.2_(alpha%20track)-4d6bfe" /></a>
<a href="https://github.com/topics/dsh-better-sidebar"><img alt="Plugin ecosystem: GitHub topic dsh-better-sidebar" src="https://img.shields.io/badge/plugin%20ecosystem-topic%20dsh--better--sidebar-4d6bfe" /></a><br /><br />
<img alt="File management" src="https://img.shields.io/badge/-File%20management-4d6bfe" /> <img alt="Edit &amp; preview" src="https://img.shields.io/badge/-Edit%20%26%20preview-4d6bfe" /> <img alt="Embedded browser" src="https://img.shields.io/badge/-Embedded%20browser-4d6bfe" /> <img alt="Real terminal" src="https://img.shields.io/badge/-Real%20terminal-4d6bfe" /> <img alt="Git panel" src="https://img.shields.io/badge/-Git%20panel-4d6bfe" /> <img alt="Background tasks" src="https://img.shields.io/badge/-Background%20tasks-4d6bfe" /> <img alt="Side Chat" src="https://img.shields.io/badge/-Side%20Chat-4d6bfe" /> <img alt="Plugin integration" src="https://img.shields.io/badge/-Plugin%20integration-4d6bfe" /><br /><br />
<b>A dual workbench (right sidebar + bottom panel)</b> that opens its <code>ctx.betterSidebar</code> service to every plugin —<br />
@@ -62,7 +62,9 @@
**Prerequisites**: DSH installed (`dsh web` boots), Node.js ≥ 20, pnpm ≥ 10.
**Supported DSH versions**:
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions: 0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2 · 0.1.2-alpha.1" src="https://img.shields.io/badge/DSH-0.1.0--rc.8_%C2%B7_0.1.1--rc.1_%C2%B7_0.1.1--rc.2_%C2%B7_0.1.2--alpha.1-4d6bfe" /></a>
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions (v0.18.0-alpha.0 alpha track): 0.1.2-alpha.2" src="https://img.shields.io/badge/DSH-0.1.2--alpha.2_(alpha%20track)-4d6bfe" /></a>
> 🧪 **Alpha track**: starting with `v0.18.0-alpha.0` the plugin targets DSH **0.1.2-alpha.x** (npm dist-tag `alpha`) and drops 0.1.0-rc.8 ~ 0.1.1-rc.2 — stable-DSH users should stay on `@latest` (v0.17.1); DSH 0.1.2-alpha.2 users install `dsh-better-sidebar@alpha`.
```sh
dsh plugin --profile web add dsh-better-sidebar@latest # first run fails: pnpm 11 blocks node-pty build scripts (the dependency is still written)
@@ -261,11 +263,19 @@ The GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sid
<a href="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0"><img width="33%" alt="Service API base screenshot" src="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0" /></a>
</div>
**Supported DSH versions**: <a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions: 0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2 · 0.1.2-alpha.1" src="https://img.shields.io/badge/DSH-0.1.0--rc.8_%C2%B7_0.1.1--rc.1_%C2%B7_0.1.1--rc.2_%C2%B7_0.1.2--alpha.1-4d6bfe" /></a> · full release history on the [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases) page
**Supported DSH versions**: <a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions (v0.18.0-alpha.0 alpha track): 0.1.2-alpha.2" src="https://img.shields.io/badge/DSH-0.1.2--alpha.2_(alpha%20track)-4d6bfe" /></a> · full release history on the [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases) page
### v0.18.0-alpha.0
> 🧪 **Alpha track**: this release targets **DSH 0.1.2-alpha.x only** (peer floor `^0.1.2-alpha.2`, npm dist-tag `alpha`, install `dsh-better-sidebar@alpha`); 0.1.0-rc.8 ~ 0.1.1-rc.2 are no longer supported — stable-DSH users should stay on v0.17.1 (npm `latest`).
- 🔗 **Adapted to DSH 0.1.2-alpha.2 (published to npm, `alpha` dist-tag)**: the CI mount gate's pin and the `@deepseek-ai/*` devDependencies baseline moved up to it (verified by a real-host mount smoke 14/14). Adaptation points: `dsh-settings` dropped the runtime `settingsNamespace` export (namespaces are now validated at compile time — the host passes the constant directly); the `dsh-subagent` descriptor version went 2→3 (stamped by the host package; the test assertion follows the `SUBAGENT_DESCRIPTOR_VERSION` constant); the restored `SessionEvent.ignorable` and the Remote gateway's unified `RemoteError` wrapping were verified to not affect this plugin.
- 🐛 **Fixed blank Side Chat transcripts on DSH 0.1.2-alpha.1+**: transcript polling still called the `ctx.connection.api` face removed in alpha.1 (the error was silently swallowed, so the tab rendered an empty transcript forever). Transcripts now come from the plugin's own `sidechat.events` route (live threads read the in-memory event log, cold threads read session persistence, with `afterSeq` delta pulls, [sidechat-routes.ts](./src/sidechat-routes.ts)).
- 🧹 **Dropped the pre-alpha (0.1.1-rc.x and older) compatibility layer**: the e2e host RPC collapsed from the dot/slash dual dialect to slash-only (token URL required, [host-protocol.ts](./tests/e2e/host-protocol.ts)); `MarkdownText` labels collapsed to the nested single shape ([markdown-labels.tsx](./src/client/markdown-labels.tsx), no more dual prop names); peerDependencies / devDependencies / `dsh.client.inject` / the chunk externals allowlist all dropped the defunct `@deepseek-ai/dsh-client-runtime` (four synchronized spots).
### v0.17.1
- 🔗 **DSH 0.1.2-alpha.1 adaptation (dual-version compatible)**: fully adapted to DSH 0.1.2-alpha.1's Remote gateway, one-time-token browser authentication, and the `MarkdownText` labels contract change — the plugin works identically on 0.1.0-rc.8 ~ 0.1.1-rc.2 and 0.1.2-alpha.1 (the latter verified by a real-host mount smoke 14/14 against a source build of the GitHub tag; that version is not on npm yet, so the CI pin stays at 0.1.1-rc.2 until it publishes). Highlights: all four `MarkdownText` render sites now go through the dual-shape labels helper ([markdown-labels.tsx](./src/client/markdown-labels.tsx)), fixing the `reading 'code'` crash in markdown/mermaid previews on alpha.1; the e2e mount smoke speaks both wire dialects (token-URL cookie exchange, slash `/api` endpoints with parameter-named args, [tests/e2e/host-protocol.ts](./tests/e2e/host-protocol.ts)); dropped the `@deepseek-ai/dsh-client-runtime` peer removed upstream in 0.1.2-alpha.1
- 🔗 **DSH 0.1.2-alpha.1 adaptation (dual-version compatible)**: fully adapted to DSH 0.1.2-alpha.1's Remote gateway, one-time-token browser authentication, and the `MarkdownText` labels contract change — the plugin works identically on 0.1.0-rc.8 ~ 0.1.1-rc.2 and 0.1.2-alpha.1 (the latter verified by a real-host mount smoke 14/14 against a source build of the GitHub tag; alpha.1 was never published to npm, and the CI pin moved to the npm-published 0.1.2-alpha.2 in v0.18.0-alpha.0). Highlights: all four `MarkdownText` render sites now go through the dual-shape labels helper ([markdown-labels.tsx](./src/client/markdown-labels.tsx)), fixing the `reading 'code'` crash in markdown/mermaid previews on alpha.1; the e2e mount smoke speaks both wire dialects (token-URL cookie exchange, slash `/api` endpoints with parameter-named args, [tests/e2e/host-protocol.ts](./tests/e2e/host-protocol.ts)); dropped the `@deepseek-ai/dsh-client-runtime` peer removed upstream in 0.1.2-alpha.1
### v0.16.1
@@ -458,7 +468,7 @@ The dashed cards at the end of the "Sidebar content" / "File viewers" grids in t
## 🛠️ Development & Build
```sh
pnpm install # @deepseek-ai/* devDependencies resolve to 0.1.1-rc.1 (published) — no token needed
pnpm install # @deepseek-ai/* devDependencies resolve (baseline 0.1.2-alpha.2, alpha dist-tag) — no token needed
pnpm typecheck # tsc --noEmit
pnpm build # → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types
pnpm test # vitest (includes manifest consistency guard; build first)
+1 -1
View File
@@ -2,7 +2,7 @@
> 面向 **消费插件开发者**:如何让你的插件向 better-sidebar 注册新的侧边栏页面(tab)和文件类型预览器。
>
> 适用版本:**v0.4.0+**(`ctx.betterSidebar` 服务);声明式设置 **v0.4.1+**;text/number 设置行 **v0.11.0+**;badge/生命周期/定向打开/插件设置/版本探测 **v0.12.0+**;select 设置行(`settingSelect`)与外链认领(`urlTarget`)**v0.13.0+**;统一 `@deepseek-ai/cordis` 类型基底 **v0.15.2+**;自由窗口(`floatWindows`)**v0.16.0+**;终端固定(pin)**v0.17.0+**。当前版本 v0.17.1。
> 适用版本:**v0.4.0+**(`ctx.betterSidebar` 服务);声明式设置 **v0.4.1+**;text/number 设置行 **v0.11.0+**;badge/生命周期/定向打开/插件设置/版本探测 **v0.12.0+**;select 设置行(`settingSelect`)与外链认领(`urlTarget`)**v0.13.0+**;统一 `@deepseek-ai/cordis` 类型基底 **v0.15.2+**;自由窗口(`floatWindows`)**v0.16.0+**;终端固定(pin)**v0.17.0+**。当前版本 **v0.18.0-alpha.0**(alpha 通道,仅支持 DSH 0.1.2-alpha.x;stable 线为 v0.17.1)。
> 权威代码:`src/client/service.ts`(服务实现)、`src/client/builtins/`(内置 7 tab + 6 viewer 参考实现)、`lib/types/client/service.d.ts`(类型声明)。
> 仓库开发规则(硬约束 / CI / 发版)见 [AGENTS.md](../AGENTS.md)。
@@ -95,3 +95,4 @@
2. 图标语义:线程切换菜单改自绘 `IconHistoryOutline16`(逆时针时钟)、保存按钮改自绘 `IconSaveOutline16`(软盘),沿用 1.5px 描边规范(primitives 无现成 history/save 图标)。
3. 重开线程 Tab 丢工具行:根因**不在宿主**(`session.history` cold 路径原样返回 `tool/call`,见 `api-proxy.ts:749` 与 `chunk-rows.ts` 的无损 chunk 压缩)而在首挂回源窗口——旧实现 8 事件/页 × 32 页 = 256 事件上限,而 cold 读会把 chunk-rows 展开回每个流式 delta 一条事件,一轮回答动辄数百事件,较早的 `tool/call` 掉出窗口(settled 文本靠最终 `assistant/message` 幸存)。修复 = 抽出 `collectOwnEvents`(200 事件/页 × 40 页帽、日志穷尽/无标记时落定 `seedBoundary:0` 停止每轮重走),单测覆盖四分支。
4. Subagent 页「目录损坏」行:见 §5 修正——种子追加 descriptor + SubagentView/subagent-detect 按前缀过滤(`isSideThreadSummary` 同时修好 autoOpenSubagent 误触发与计数膨胀)。旧版创建的线程仍是 corrupt 诊断行,由 SubagentView 的标题前缀过滤兜底隐藏。
- **转录传输迁至自有路由(DSH 0.1.2-alpha.1 适配)**:§6 原设计的时间线轮询走通用 `connection.api.sessions.history` RPC——该客户端面(`ctx.connection.api`)在 0.1.2-alpha.1 的 Remote-gateway 迁移中被整体移除,且其继任者(`session/follow`/`session/page`)对 `origin:'subagent'` 会话强制 subagent 地址寻址(普通 `{kind:'session'}` 地址被 `agent-busy` 拒绝),纯分页也拿不到最新尾页(`throughSeq` 不得超当前游标)。修复 = 新增 **`sidechat.events`** 路由(`src/sidechat-routes.ts`):host 侧读线程日志(live 走 `agent.session.events`,冷线程走 `ctx.get('sessionPersistence').inspect`——两版 DSH 同形,alpha.1 的 inspect 返回展开后的逻辑日志而非磁盘上的打包 chunk-row),服务端切 `session/end-seed`(`threadOwnLogEvents`,`collectOwnEvents` 的 walk 语义下沉),`afterSeq` 增量 + 8000 事件尾部帽(对齐旧 walk 上限 40×200)。客户端 `fetchThread` 单路径化,删除 `collectOwnEvents` 与 `ctx.connection` 镜像/inject。种子切割仍由 `transcriptRows` 客户端二次防御。挂载冒烟补 live+冷读两条断言。
+2 -2
View File
@@ -1,10 +1,10 @@
{
"id": "dsh-external/dsh-better-sidebar",
"version": "0.17.1",
"version": "0.18.0-alpha.0",
"main": "./lib/index.js",
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
"engines": {
"dsh": ">=0.0.1"
"dsh": ">=0.1.2-alpha.2"
},
"contributes": {
"tools": [],
+27 -29
View File
@@ -1,6 +1,6 @@
{
"name": "dsh-better-sidebar",
"version": "0.17.1",
"version": "0.18.0-alpha.0",
"description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.",
"type": "module",
"repository": {
@@ -45,7 +45,6 @@
},
"client": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-slots",
"@deepseek-ai/dsh-client-ui-conversation",
@@ -85,19 +84,19 @@
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-agent": "^0.1.0-rc.8",
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.8",
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.8",
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.8",
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.8",
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.8",
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.8",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.8",
"@deepseek-ai/dsh-session": "^0.1.0-rc.8",
"@deepseek-ai/dsh-settings": "^0.1.0-rc.8",
"@deepseek-ai/dsh-subagent": "^0.1.0-rc.8",
"@deepseek-ai/dsh-tools": "^0.1.0-rc.8",
"@deepseek-ai/dsh-agent": "^0.1.2-alpha.2",
"@deepseek-ai/dsh-client-locale": "^0.1.2-alpha.2",
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.2-alpha.2",
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.2-alpha.2",
"@deepseek-ai/dsh-client-ui-settings": "^0.1.2-alpha.2",
"@deepseek-ai/dsh-client-ui-slots": "^0.1.2-alpha.2",
"@deepseek-ai/dsh-host-webserver": "^0.1.2-alpha.2",
"@deepseek-ai/dsh-invariants": "^0.1.2-alpha.2",
"@deepseek-ai/dsh-llm": "^0.1.2-alpha.2",
"@deepseek-ai/dsh-session": "^0.1.2-alpha.2",
"@deepseek-ai/dsh-settings": "^0.1.2-alpha.2",
"@deepseek-ai/dsh-subagent": "^0.1.2-alpha.2",
"@deepseek-ai/dsh-tools": "^0.1.2-alpha.2",
"@huanlin/dsh-plugin-better-locale": "^0.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
@@ -141,20 +140,19 @@
},
"devDependencies": {
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-agent": "0.1.1-rc.1",
"@deepseek-ai/dsh-client-locale": "0.1.1-rc.1",
"@deepseek-ai/dsh-client-runtime": "0.1.1-rc.1",
"@deepseek-ai/dsh-client-ui-conversation": "0.1.1-rc.1",
"@deepseek-ai/dsh-client-ui-primitives": "0.1.1-rc.1",
"@deepseek-ai/dsh-client-ui-settings": "0.1.1-rc.1",
"@deepseek-ai/dsh-client-ui-slots": "0.1.1-rc.1",
"@deepseek-ai/dsh-host-webserver": "0.1.1-rc.1",
"@deepseek-ai/dsh-invariants": "0.1.1-rc.1",
"@deepseek-ai/dsh-llm": "0.1.1-rc.1",
"@deepseek-ai/dsh-session": "0.1.1-rc.1",
"@deepseek-ai/dsh-settings": "0.1.1-rc.1",
"@deepseek-ai/dsh-subagent": "0.1.1-rc.1",
"@deepseek-ai/dsh-tools": "0.1.1-rc.1",
"@deepseek-ai/dsh-agent": "0.1.2-alpha.2",
"@deepseek-ai/dsh-client-locale": "0.1.2-alpha.2",
"@deepseek-ai/dsh-client-ui-conversation": "0.1.2-alpha.2",
"@deepseek-ai/dsh-client-ui-primitives": "0.1.2-alpha.2",
"@deepseek-ai/dsh-client-ui-settings": "0.1.2-alpha.2",
"@deepseek-ai/dsh-client-ui-slots": "0.1.2-alpha.2",
"@deepseek-ai/dsh-host-webserver": "0.1.2-alpha.2",
"@deepseek-ai/dsh-invariants": "0.1.2-alpha.2",
"@deepseek-ai/dsh-llm": "0.1.2-alpha.2",
"@deepseek-ai/dsh-session": "0.1.2-alpha.2",
"@deepseek-ai/dsh-settings": "0.1.2-alpha.2",
"@deepseek-ai/dsh-subagent": "0.1.2-alpha.2",
"@deepseek-ai/dsh-tools": "0.1.2-alpha.2",
"@huanlin/dsh-plugin-better-locale": "^0.1.0",
"@playwright/test": "^1.62.1",
"@types/node": "^24.0.0",
+561 -1274
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -124,6 +124,7 @@ allowBuilds:
minimumReleaseAgeExclude:
- dsh-better-sidebar
- '@deepseek-ai/*'
EOF
# ── 组装 profile:聚合先行,插件后到 ──────────────────────────────────────
+4 -1
View File
@@ -95,7 +95,9 @@ trap cleanup EXIT
# 步骤 1:引导 scratch profile(web 模板,镜像 dsh initProfile;先写
# pnpm-workspace.yaml 的 allowBuilds / minimumReleaseAgeExclude,避免 pnpm 11
# strict-dep-builds 拦截 node-pty/protobufjs 或拒绝 <24h 新版本——同 install.sh)
# strict-dep-builds 拦截 node-pty/protobufjs 或拒绝 <24h 新版本——同 install.sh。
# @deepseek-ai/* 通配与仓库根 pnpm-workspace.yaml 同策:钉的 DSH alpha 常在
# 发布后 24h 内跑 lane,没有豁免会被 minimumReleaseAge 直接拒装)
PROFILE_DIR="$DSH_HOME/profiles/web"
cat > "$PROFILE_DIR/package.json" <<EOF
{
@@ -123,6 +125,7 @@ allowBuilds:
minimumReleaseAgeExclude:
- dsh-better-sidebar
- '@deepseek-ai/*'
EOF
# 步骤 2:官方 CLI 安装 tarball + bundle 协调(真实挂载路径)
+28 -48
View File
@@ -13,12 +13,15 @@
*
* Each side thread is a child session the plugin created itself with a
* custom seed (the parent's full log up to the click moment — see
* sidechat-core.ts). Transport: thread creation/follow-up/cancel/dispose/
* info go through the plugin's own /sidebar/api sidechat.* routes
* (subagent-origin identities are fenced from the generic session RPCs);
* the transcript is polled from the generic session.history RPC (seed-cut
* at session/end-seed, boundary row dropped, chunk streaming accumulated)
* — see sidechat-transcript.ts.
* sidechat-core.ts). Transport: EVERY thread operation — creation,
* follow-up, cancel, dispose, info, and the transcript itself — goes
* through the plugin's own /sidebar/api sidechat.* routes (subagent-origin
* identities are fenced from the generic session RPCs, and DSH
* 0.1.2-alpha.1's Remote-gateway migration removed the client
* session-history face the transcript used to poll). The transcript route
* cuts the inherited seed host-side and answers afterSeq deltas; the
* mapping (boundary row dropped, chunk streaming accumulated) lives in
* sidechat-transcript.ts.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useSyncExternalStore } from 'react'
@@ -36,7 +39,7 @@ import {
} from '@deepseek-ai/dsh-client-ui-primitives'
import { markdownTextProps } from './markdown-labels.tsx'
import { IconHistoryOutline16, IconSaveOutline16 } from './icons.tsx'
import type { Context, SidebarHistoryEntry } from '../context-types.ts'
import type { Context, SidebarHistoryEntry, SidebarSessionEvent } from '../context-types.ts'
import {
SIDE_LABEL_PREFIX,
SIDE_NEW_THREAD_TITLE,
@@ -45,20 +48,13 @@ import {
threadTrailingPending,
type SidechatThreadInfo,
} from '../sidechat-core.ts'
import { collectOwnEvents, toolArgsSummary, transcriptRows, type SidechatTranscriptRow } from './sidechat-transcript.ts'
import { toolArgsSummary, transcriptRows, type SidechatTranscriptRow } from './sidechat-transcript.ts'
import { api } from './api.ts'
import { t } from './locales.ts'
import type { SessionScope } from './api.ts'
import type { SidebarTab } from './state.ts'
import css from './SideChatView.module.css'
/** Tail-page size for one transcript poll (events per page). Small on
* purpose: streaming polls ride the tail and merge by seq. */
const PAGE_MESSAGES = 8
/** First-attach walk page size: cold reads re-expand chunk-rows into one
* event per streamed delta, so a single answer can be hundreds of events —
* the walk must page big or earlier tool/call rows fall out of the window. */
const WALK_PAGE_EVENTS = 200
/** Poll cadence while the selected thread is running and the tab visible. */
const POLL_MS = 2000
/** Textarea auto-grow ceiling (px) — the composer scrolls beyond it. */
@@ -92,10 +88,9 @@ export function consumeSidechatSeed(): string | undefined {
* StrictMode / HMR must not mint two threads for one tab). */
const inFlightStarts = new Set<string>()
/** Per-thread transcript cache: seed boundary + thread-own events merged by
* seq (streaming polls never re-download the inherited seed). */
/** Per-thread transcript cache: thread-own events merged by seq (polls ride
* the afterSeq delta and never re-download what they already hold). */
interface ThreadCache {
seedBoundary: number | null
entries: SidebarHistoryEntry[]
}
@@ -274,7 +269,7 @@ export function SideChatView(props: {
const [info, setInfo] = useState<SidechatThreadInfo | null>(null)
const [menuOpen, setMenuOpen] = useState(false)
const cacheRef = useRef<ThreadCache>({ seedBoundary: null, entries: [] })
const cacheRef = useRef<ThreadCache>({ entries: [] })
const controllerRef = useRef<AbortController | null>(null)
const scrollRef = useRef<HTMLDivElement | null>(null)
const composerRef = useRef<HTMLTextAreaElement | null>(null)
@@ -327,43 +322,28 @@ export function SideChatView(props: {
}
}, [summary, tab.id, tab.title, ctx])
/** One transcript pull: the first read walks back to the seed boundary
* (big pages — chunk deltas re-expand on cold reads), later reads fetch
* one tail page and merge (seq-deduped). */
/** One transcript pull: the thread's own events beyond the cached tail
* (first attach = the whole seed-cut slice; polls = afterSeq deltas),
* merged by seq. */
const fetchThread = useCallback(async (childId: string): Promise<void> => {
controllerRef.current?.abort()
const controller = new AbortController()
controllerRef.current = controller
const cache = cacheRef.current
try {
if (cache.seedBoundary === null) {
const walk = await collectOwnEvents(async (beforeSeq) => {
const response = await ctx.connection.api.sessions.history(
{
sessionId: childId,
maxMessages: WALK_PAGE_EVENTS,
...(beforeSeq === undefined ? {} : { beforeSeq }),
},
controller.signal,
)
if (!response.result.ok) throw new Error('history walk failed')
return response.result.value.events
})
cache.seedBoundary = walk.seedBoundary
cache.entries = mergeBySeq(cache.entries, walk.entries)
} else {
const response = await ctx.connection.api.sessions.history(
{ sessionId: childId, maxMessages: PAGE_MESSAGES },
controller.signal,
)
if (!response.result.ok) return
cache.entries = mergeBySeq(cache.entries, response.result.value.events)
const cache = cacheRef.current
const afterSeq = cache.entries.at(-1)?.event.seq
const { events } = await api.sidechatEvents(childId, afterSeq, controller.signal)
if (events.length > 0) {
// Wire events arrive as parsed JSON; the mirror narrows data to the
// record the mapping reads.
const incoming = events.map(event => ({ event: event as SidebarSessionEvent }))
cache.entries = mergeBySeq(cache.entries, incoming)
setRevision(value => value + 1)
}
setRevision(value => value + 1)
} catch {
// Aborted by a newer pull or a wire failure: keep the last rows.
}
}, [ctx])
}, [])
/** The thread header badge pull (live state + preset/model identity). */
const fetchInfo = useCallback(async (childId: string): Promise<void> => {
@@ -377,7 +357,7 @@ export function SideChatView(props: {
// Reset the transcript cache whenever the binding changes, then focus
// the composer — it owns the first message of a fresh thread.
useEffect(() => {
cacheRef.current = { seedBoundary: null, entries: [] }
cacheRef.current = { entries: [] }
controllerRef.current?.abort()
setError(null)
setSaved(false)
+9 -1
View File
@@ -8,7 +8,7 @@
*/
import { encodeHtmlUrl } from '../html-route.ts'
import type { LastActivity } from '../subagent-activity.ts'
import type { SidechatThreadInfo } from '../sidechat-core.ts'
import type { SidechatLogEvent, SidechatThreadInfo } from '../sidechat-core.ts'
import type { BrowserProbeResult } from './browser.ts'
/** One wire failure. */
@@ -317,6 +317,14 @@ export const api = {
/** Live state + agent identity (provider/model/preset) of a thread. */
sidechatInfo: (childId: string) =>
call<SidechatThreadInfo>('sidechat.info', { childId }),
/** One transcript pull of a Side Chat thread: the thread's OWN events
* (the inherited seed is cut host-side and never crosses the wire).
* `afterSeq` narrows the response to the delta beyond it (poll tail). */
sidechatEvents: (childId: string, afterSeq?: number, signal?: AbortSignal) =>
call<{ events: SidechatLogEvent[] }>('sidechat.events', {
childId,
...(afterSeq !== undefined ? { afterSeq } : {}),
}, signal),
/** The effective terminal shell and its display name (plugin-global). */
shellGet: () =>
call<{ shell: string; name: string }>('shell.get', {}),
+5 -13
View File
@@ -62,18 +62,11 @@ type ChunkFactory = (require: (spec: string) => unknown) => ChunkExports
* CLIENT_EXTERNALS in tsdown.config.ts — the chunk builds keep these
* external and the loader resolves them here). A superset is safe: the
* require only answers what the chunk actually asks for. The shell's static
* module table seeds React, Cordis, and the UI libraries (primitives/slots);
* `dsh-client-runtime/client` normalizes onto the runtime package row
* (stripClientSuffix). dsh-client-web-react / dsh-client-schema-form were
* dropped in DSH 0.1.0-rc.8 (no rc.8 publish, nothing requires them) — the
* chunks never asked for them, so they no longer belong here.
*
* DSH 0.1.2-alpha.1 removed the `dsh-client-runtime` package outright (the
* seed table gained bare-name `@deepseek-ai/dsh-client-store` instead); the
* runtime/client row below stays for 0.1.1-rc.x hosts — no chunk requires
* it, and {@link buildExternalsRequire} keeps an unresolvable spec
* undefined until a chunk actually asks (only then is it a loud error), so
* the entry is inert on 0.1.2-alpha.1+.
* module table seeds React, Cordis, and the UI libraries (primitives/slots).
* `@deepseek-ai/dsh-client-runtime` was removed upstream in DSH 0.1.2-alpha
* (its seed row became bare-name `@deepseek-ai/dsh-client-store`) and no
* chunk ever required it, so its row is gone; so are dsh-client-web-react /
* dsh-client-schema-form, dropped back in DSH 0.1.0-rc.8.
*/
export const CHUNK_EXTERNALS: readonly string[] = [
'react',
@@ -83,7 +76,6 @@ export const CHUNK_EXTERNALS: readonly string[] = [
'cordis',
'@deepseek-ai/dsh-client-ui-slots',
'@deepseek-ai/dsh-client-ui-primitives',
'@deepseek-ai/dsh-client-runtime/client',
]
/** Chunk script endpoint served by the plugin host half (src/bundle-route.ts). */
+1 -1
View File
@@ -35,7 +35,7 @@ import './layout.css'
* locale service backs the sidebar's copy — see locales.ts). `modules`
* (rc.8+) is the client module system the chunk loader resolves its
* externals through — Cordis guards service access without inject. */
export const inject = ['slots', 'sessions', 'connection', 'workspaces', 'locale', 'modules']
export const inject = ['slots', 'sessions', 'workspaces', 'locale', 'modules']
/**
* Error boundary over the sidebar tree (root scope): a render error in the
+18 -39
View File
@@ -1,52 +1,31 @@
/**
* Copy-button / chrome labels for DSH's shared `MarkdownText`, shaped for
* BOTH prop generations the plugin supports:
*
* - 0.1.1-rc.x: optional flat prop `codeLabels` — the renderer reads
* `labels.copyLabel` / `labels.copiedLabel` directly.
* - 0.1.2-alpha.1+: renamed to a REQUIRED `labels` prop with a nested shape
* (`labels.code.copyLabel` + a screen-reader-only `labels.footnotes`
* heading). Passing only the old prop crashes the fence render with
* "Cannot read properties of undefined (reading 'code')" — the exact
* regression the mount lane caught on a real 0.1.2-alpha.1 host.
*
* The union object satisfies both readers, and {@link markdownTextProps}
* passes it under BOTH prop names — each host ignores the one it does not
* know. `footnotes` is left empty (the heading is sr-only; give it a real
* string only if a locale key ever earns its place in all 19 dictionaries).
* Copy-button / chrome labels for DSH's shared `MarkdownText` (DSH
* 0.1.2-alpha contract): the renderer takes a REQUIRED nested `labels` prop
* — `labels.code.copyLabel` / `labels.code.copiedLabel` for the fence copy
* buttons plus a screen-reader-only `labels.footnotes` heading — and the
* MarkdownText/CodeBlock are cordis-free, falling back to HARDCODED Chinese
* when the labels are omitted, so every render site threads the plugin
* dictionary's localized pair through here (re-evaluated per render).
* `footnotes` is left empty (the heading is sr-only; give it a real string
* only if a locale key ever earns its place in all 19 dictionaries).
*/
import type { ComponentProps } from 'react'
import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
/** The flat copy-button pair the plugin threads through its own props
* (e.g. MermaidMarkdownProps.codeLabels — the chunk contract stays put). */
/** The flat copy-button pair the plugin threads through its own props (e.g.
* MermaidMarkdownProps.codeLabels — the chunk contract stays put). */
export interface MarkdownCopyLabels {
copyLabel: string
copiedLabel: string
}
/** The dual-generation chrome labels object (see module doc). */
export interface MarkdownChromeLabels extends MarkdownCopyLabels {
/** 0.1.2-alpha.1+ nested reads. */
code: MarkdownCopyLabels
/** 0.1.2-alpha.1+ sr-only footnotes heading. */
footnotes: string
}
/** Build the dual-shape chrome labels from a flat copy-button pair. */
export function markdownChromeLabels(labels: MarkdownCopyLabels): MarkdownChromeLabels {
/** MarkdownText props carrying the nested chrome labels. */
export function markdownTextProps(text: string, labels: MarkdownCopyLabels): ComponentProps<typeof MarkdownText> {
return {
copyLabel: labels.copyLabel,
copiedLabel: labels.copiedLabel,
code: { copyLabel: labels.copyLabel, copiedLabel: labels.copiedLabel },
footnotes: '',
text,
labels: {
code: { copyLabel: labels.copyLabel, copiedLabel: labels.copiedLabel },
footnotes: '',
},
}
}
/** MarkdownText props carrying the labels under BOTH prop names. The cast is
* load-bearing: the plugin builds against the 0.1.1-rc.x declaration, where
* `labels` does not exist yet (and vice versa on a 0.1.2-alpha.1+ host). */
export function markdownTextProps(text: string, labels: MarkdownCopyLabels): ComponentProps<typeof MarkdownText> {
const chrome = markdownChromeLabels(labels)
return { text, codeLabels: chrome, labels: chrome } as unknown as ComponentProps<typeof MarkdownText>
}
+1 -1
View File
@@ -472,7 +472,7 @@ export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDes
* The plugin version this service instance reports. Keep in lockstep with
* `package.json`'s version — `tests/service.spec.ts` asserts the pair.
*/
export const SIDEBAR_SERVICE_VERSION = '0.17.1'
export const SIDEBAR_SERVICE_VERSION = '0.18.0-alpha.0'
/**
* Monotonic capability list consumers use to gate new API usage (features
+10 -60
View File
@@ -1,13 +1,16 @@
/**
* Side Chat transcript mapping (browser half): turns a thread child's
* history rows (`session.history` — the generic RPC, which reads the durable
* log without activating the child) into compact display rows.
* Side Chat transcript mapping (browser half): turns a thread child's own
* events (`sidechat.events` — the plugin route, which reads the log without
* activating the child and cuts the inherited seed host-side) into compact
* display rows.
*
* A thread child's log starts with the ENTIRE inherited parent log as its
* fork seed. The mapping therefore cuts everything up to the LAST
* `session/end-seed` marker and maps context injections (the "Side
* conversation boundary" prompt, plugin-sourced context) onto a collapsible
* injection row, so the view shows only the thread's own conversation.
* fork seed. The route already cuts everything up to the LAST
* `session/end-seed` marker; the mapping re-applies the same cut so any
* seed event that somehow crosses the wire still never renders, and maps
* context injections (the "Side conversation boundary" prompt,
* plugin-sourced context) onto a collapsible injection row, so the view
* shows only the thread's own conversation.
*
* Live streaming: `assistant/message` events only land when a step
* completes, but `assistant/chunk` events stream token-level text and
@@ -124,59 +127,6 @@ function lastSeedEnd(events: readonly { type: string }[]): number {
return -1
}
/**
* Collect the thread's OWN events on first attach: walk backward from the
* log tail (oldest-first accumulation) until the `session/end-seed` marker
* surfaces, then keep everything after it.
*
* Page size matters: cold reads re-expand persisted chunk-rows into one
* `assistant/chunk` event per delta, so a single streamed answer can be
* HUNDREDS of events. A small walk window (the old 8×32 = 256 events) let
* earlier `tool/call` events fall out of the loaded window — the tool rows
* vanished on re-entry while the settled text survived. The walk therefore
* pages big; tail polls stay small.
*
* Exhaustion (log start reached without a marker — a thread created before
* seeding existed, or a pathological log) returns `seedBoundary: 0` so the
* caller stops re-walking and renders the window as-is.
*
* @param fetchPage - one history page (newest-first window ending at
* `beforeSeq`, exclusive; omit for the tail page).
* @param pageCap - safety bound on backward pages.
*/
export async function collectOwnEvents(
fetchPage: (beforeSeq?: number) => Promise<readonly SidebarHistoryEntry[]>,
pageCap = 40,
): Promise<{ seedBoundary: number; entries: SidebarHistoryEntry[] }> {
const collected: SidebarHistoryEntry[] = []
let beforeSeq: number | undefined
for (let page = 0; page < pageCap; page++) {
const events = await fetchPage(beforeSeq)
if (events.length === 0) {
// Log start reached without a marker: the window IS the whole log.
return { seedBoundary: 0, entries: collected }
}
const olderThan = collected.length > 0 ? collected[0]!.event.seq : undefined
const fresh = olderThan === undefined
? [...events]
: events.filter(entry => entry.event.seq < olderThan)
const seedEnd = fresh.findLastIndex(entry => entry.event.type === 'session/end-seed')
if (seedEnd >= 0) {
collected.unshift(...fresh.slice(seedEnd + 1))
return { seedBoundary: fresh[seedEnd]!.event.seq, entries: collected }
}
collected.unshift(...fresh)
if (fresh.length === 0) {
// The page overlaps entirely with what we have: nothing older exists.
return { seedBoundary: 0, entries: collected }
}
beforeSeq = fresh[0]!.event.seq
}
// Cap hit: accept the window (it is overwhelmingly the thread's own tail)
// rather than re-walking on every poll.
return { seedBoundary: 0, entries: collected }
}
/**
* Map a thread child's history rows onto compact transcript rows: the
* inherited fork seed is cut at the last `session/end-seed`, context
-35
View File
@@ -304,39 +304,6 @@ export interface SidebarSessionPersistenceService {
}>
}
/** RPC result slot mirror (`RpcResult<T>` on the wire). */
export type SidebarRpcResult<T> = { ok: true; value: T } | { ok: false; error: { code: string; message: string } }
/** Unary response mirror (`RpcResponse<T>` on the wire). */
export interface SidebarRpcResponse<T> {
rpcId: unknown
result: SidebarRpcResult<T>
}
/** The generic session-history RPC face the Side Chat transcript polls
* (subagent.history verifies subagent-catalog membership, which our custom
* side-thread children do not have — the generic session.history reads any
* durable log directly). */
export interface SidebarSessionHistoryRpc {
history(
payload: { sessionId: string; beforeSeq?: number; maxMessages?: number },
signal?: AbortSignal,
): Promise<SidebarRpcResponse<{ events: SidebarHistoryEntry[]; hasMore: boolean }>>
}
/** The wire face the Subagent activity summary needs (subset of `ctx.connection`). */
export interface SidebarConnectionHandle {
api: {
sessions: SidebarSessionHistoryRpc
subagents: {
history(
payload: SidebarSubagentAddress & { beforeSeq?: number; maxMessages?: number },
signal?: AbortSignal,
): Promise<SidebarRpcResponse<{ events: SidebarHistoryEntry[]; hasMore: boolean }>>
}
}
}
/** The client session list snapshot the sidebar subscribes to. */
export interface SidebarSessionList {
current: string | undefined
@@ -527,8 +494,6 @@ export interface SidebarContextShape {
webServer: SidebarWebServer
/** The session store (host `.get`) and the client list feed (`.list`) faces. */
sessions: SidebarSessionStore & SidebarSessionsService
/** The wire handle the Side Chat transcript polls through. */
connection: SidebarConnectionHandle
/** The web runtime trust list (bind-derived). */
webRuntime: SidebarWebRuntime
/** The client slot registry (register/inject). */
+5 -2
View File
@@ -39,7 +39,7 @@ import { isTrustedApiRequest, isLoopbackHostname } from './trust-fence.ts'
import { registerBundleRoute } from './bundle-route.ts'
import { launchExternal } from './open-external.ts'
import * as git from './git.ts'
import { SettingsConflictError, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
import { SettingsConflictError } from '@deepseek-ai/dsh-settings'
import { defaultShell, ensureSpawnHelper, PtyManager, shellDisplayName } from './pty-manager.ts'
import { AgentPtyRegistry, clampDims, type AgentTerminalHandle } from './agent-pty.ts'
import {
@@ -715,7 +715,10 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
}
}
ctx.inject(['settings'], (sctx) => {
const ns: SettingsNamespace = settingsNamespace(SIDEBAR_PREFS_NS)
// DSH 0.1.2-alpha.2 validates namespaces at compile time
// (SettingsNamespaceInput); the 'dsh-better-sidebar' literal passes, so the
// runtime helper this used to call (settingsNamespace) is gone upstream.
const ns = SIDEBAR_PREFS_NS
// The structural settings mirror types `schema` as unknown, so the
// generic is not inferred here; the real service resolves it from the
// schemastery schema (PrefsSchema) — narrow the owner scope explicitly.
+9 -4
View File
@@ -416,13 +416,18 @@ export interface SidechatThreadInfo {
}
/** The events a thread produced itself: everything after the LAST
* `session/end-seed` marker (the fork-seed boundary). */
export function threadOwnEvents(entries: readonly SidebarHistoryEntry[]): SidechatLogEvent[] {
const events = entries.map(entry => entry.event)
* `session/end-seed` marker (the fork-seed boundary). A log with no marker
* (a thread created before seeding existed) is returned whole. */
export function threadOwnLogEvents(events: readonly SidechatLogEvent[]): SidechatLogEvent[] {
for (let index = events.length - 1; index >= 0; index--) {
if (events[index]?.type === 'session/end-seed') return events.slice(index + 1)
}
return events
return [...events]
}
/** {@link threadOwnLogEvents} over history rows (the client cache shape). */
export function threadOwnEvents(entries: readonly SidebarHistoryEntry[]): SidechatLogEvent[] {
return threadOwnLogEvents(entries.map(entry => entry.event))
}
/**
+58 -2
View File
@@ -1,6 +1,7 @@
/**
* Side Chat routes of the /sidebar JSON API ('sidechat.start' /
* 'sidechat.prompt' / 'sidechat.cancel' / 'sidechat.dispose').
* 'sidechat.prompt' / 'sidechat.cancel' / 'sidechat.dispose' /
* 'sidechat.info' / 'sidechat.events').
*
* A side thread is a child session the plugin creates ITSELF with a custom
* seed — the parent's full event log up to the click moment, honestly closed
@@ -43,10 +44,11 @@ import {
type SeedEvent,
type SidechatLogEvent,
type SidechatThreadInfo,
threadOwnLogEvents,
} from './sidechat-core.ts'
import { requireString, SidebarError } from './wire.ts'
/** The five Side Chat routes of the sidebar API (wire method names). */
/** The six Side Chat routes of the sidebar API (wire method names). */
export interface SidechatRoutes {
/** Create a side thread child seeded with the parent's log up to now.
* `question` is optional: empty creates an EMPTY thread (Codex-style
@@ -61,12 +63,22 @@ export interface SidechatRoutes {
'sidechat.dispose'(payload: unknown): Promise<{ accepted: true }>
/** Live state + agent identity for the thread header. */
'sidechat.info'(payload: unknown): Promise<SidechatThreadInfo>
/** The thread's OWN transcript events, seed-cut host-side (the inherited
* parent log never crosses the wire); `afterSeq` narrows the response to
* the delta beyond it (poll tail). */
'sidechat.events'(payload: unknown): Promise<{ events: SidechatLogEvent[] }>
}
/** Timeout guarding the create call (the registry detaches it before the
* handle becomes visible, so the child is never cancelled by it). */
const CREATE_TIMEOUT_MS = 15_000
/** Head cap of one `sidechat.events` response (the ceiling the old
* client-side walk could load: 40 pages × 200 events). A pathological
* thread beyond it renders its tail window — the same degradation the
* capped walk had, never a failed poll. */
const EVENTS_CAP = 8_000
/** Per-activation disposers of created thread agents (the dispose route
* releases them; the session and its history always stay persisted). */
const threadDisposers = new Map<string, () => Promise<void>>()
@@ -151,6 +163,37 @@ function liveThreadAgent(ctx: Context, childId: string): Agent | undefined {
return agents?.get(childId)
}
/**
* The thread's event log (seed + its own events, already expanded): the live
* agent's in-memory log while the thread is attached — the freshest read,
* including events not yet flushed — else the persisted logical log. Both
* DSH generations expose these seams with the same shape (the 0.1.2
* persistence layer packs chunk rows on disk but expands them on inspect),
* which is why the transcript reads here instead of the client's
* session-history RPC: that face (`ctx.connection.api`) was removed in
* 0.1.2-alpha.1's Remote-gateway migration.
*/
async function threadLogEvents(ctx: Context, childId: string): Promise<readonly SidechatLogEvent[]> {
const agent = liveThreadAgent(ctx, childId)
if (agent !== undefined) {
return agent.session.events as unknown as readonly SidechatLogEvent[]
}
const persistence = ctx.get('sessionPersistence') as SidebarSessionPersistenceService | undefined
if (persistence === undefined) {
throw new SidebarError('sidechat-error', 'the session persistence service is unavailable', 503)
}
try {
const inspected = await persistence.inspect(childId)
return inspected.events as unknown as readonly SidechatLogEvent[]
} catch (error: unknown) {
throw new SidebarError(
'not-found',
`thread "${childId}" is not available: ${error instanceof Error ? error.message : String(error)}`,
404,
)
}
}
/** Build the Side Chat routes (all optional services degrade to a wire
* error the tab surfaces inline). The record keys are the FULL wire method
* names the /sidebar/api dispatcher looks up (`api[method]`). */
@@ -340,5 +383,18 @@ export function buildSidechatApi(ctx: Context): SidechatRoutes {
}
return { live: false }
},
'sidechat.events': async (payload: unknown) => {
const childId = requireString(payload, 'childId')
const rawAfter = (payload as { afterSeq?: unknown }).afterSeq
if (rawAfter !== undefined
&& (typeof rawAfter !== 'number' || !Number.isSafeInteger(rawAfter) || rawAfter < 0)) {
throw new SidebarError('bad-request', 'afterSeq must be a non-negative integer')
}
const events = await threadLogEvents(ctx, childId)
const own = threadOwnLogEvents(events)
const fresh = rawAfter === undefined ? own : own.filter(event => event.seq > rawAfter)
return { events: fresh.length > EVENTS_CAP ? fresh.slice(fresh.length - EVENTS_CAP) : fresh }
},
}
}
-1
View File
@@ -417,7 +417,6 @@ describe('externals contract', () => {
'cordis',
'@deepseek-ai/dsh-client-ui-slots',
'@deepseek-ai/dsh-client-ui-primitives',
'@deepseek-ai/dsh-client-runtime/client',
])
})
})
+35 -83
View File
@@ -1,23 +1,22 @@
/**
* Unit tests for the e2e lanes' host-transport contracts (tests/e2e/
* host-protocol.ts + host.ts), locking in the two DSH web dialects the lanes
* must speak:
* host-protocol.ts + host.ts), locking in the DSH 0.1.2-alpha web dialect
* the lanes speak:
*
* - 0.1.1-rc.x: bare-origin launch URL, no browser auth, ApiProxy dot
* endpoints (`POST /api/workspace.create`, payload = the bare args).
* - 0.1.2-alpha.1+ (Remote gateway + one-time-token browser auth):
* `/?token=<43 chars>` launch URL (303 → signed cookie), slash endpoints
* (`POST /api/workspace/create`, payload must be exactly `{ args }`, the
* envelope method equal to the path endpoint; a dot path is no longer
* claimed → 404).
* - one-time-token browser auth: `/?token=<43 chars>` launch URL
* (303 → signed cookie; a token-less URL is a pre-alpha host and must
* fail loudly),
* - Remote-gateway slash endpoints (`POST /api/workspace/create`, payload
* must be exactly `{ args }` keyed by the controller's parameter name,
* the envelope method equal to the path endpoint).
*
* The shapes were transcribed from the deepseek-harness sources at tags
* dsh-v0.1.1-rc.2 and dsh-v0.1.2-alpha.1 (packages/client/connection,
* packages/api/gateway, packages/bundle/web-app).
* The shapes were transcribed from the deepseek-harness sources at tag
* dsh-v0.1.2-alpha.1 and re-verified on the npm-published 0.1.2-alpha.2
* (packages/client/connection, packages/api/gateway, packages/bundle/web-app).
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeAll, describe, expect, it, vi } from 'vitest'
import type { APIRequestContext } from '@playwright/test'
import { parseLaunchUrl, pageUrlWith, rpcAttempts } from './e2e/host-protocol'
import { parseLaunchUrl, pageUrlWith, rpcAttempt } from './e2e/host-protocol'
const BARE_URL = 'http://127.0.0.1:4199'
const TOKEN_URL = 'http://127.0.0.1:4199/?token=AbCdEf0123456789_-AbCdEf0123456789_-AbCd'
@@ -27,32 +26,24 @@ const TOKEN_URL = 'http://127.0.0.1:4199/?token=AbCdEf0123456789_-AbCdEf01234567
let host: typeof import('./e2e/host')
beforeAll(async () => {
vi.stubEnv('DSH_E2E_URL', BARE_URL)
vi.stubEnv('DSH_E2E_URL', TOKEN_URL)
host = await import('./e2e/host')
})
describe('host-protocol: launch URL parsing', () => {
it('splits a bare-origin launch URL (0.1.1-rc.x hosts)', () => {
const launch = parseLaunchUrl(BARE_URL)
expect(launch.origin).toBe(BARE_URL)
expect(launch.pageUrl).toBe(BARE_URL)
expect(launch.token).toBeUndefined()
})
it('extracts the one-time token of an authenticated launch URL (0.1.2-alpha.1+)', () => {
it('extracts the one-time token of an authenticated launch URL', () => {
const launch = parseLaunchUrl(TOKEN_URL)
expect(launch.origin).toBe(BARE_URL)
expect(launch.pageUrl).toBe(TOKEN_URL)
expect(launch.token).toBe('AbCdEf0123456789_-AbCdEf0123456789_-AbCd')
})
it('rejects a token-less launch URL (a pre-0.1.2-alpha host the plugin no longer supports)', () => {
expect(() => parseLaunchUrl(BARE_URL)).toThrow(/no \?token=/)
})
})
describe('host-protocol: page URL query merge', () => {
it('appends stamps to a bare-origin URL', () => {
expect(pageUrlWith(BARE_URL, { 'dsh-desktop-mode': 'advanced' }))
.toBe(`${BARE_URL}/?dsh-desktop-mode=advanced`)
})
it('merges stamps into a token URL without breaking the token (never append ?…?…)', () => {
const merged = pageUrlWith(TOKEN_URL, { 'dsh-desktop-mode': 'advanced', 'dsh-desktop-platform': 'win32' })
const parsed = new URL(merged)
@@ -63,45 +54,33 @@ describe('host-protocol: page URL query merge', () => {
})
})
describe('host-protocol: dual-dialect RPC attempts', () => {
it('orders the 0.1.1-rc.x dot dialect first (published hosts never pay a probe)', () => {
describe('host-protocol: slash RPC attempts', () => {
it('targets the slash endpoint with args wrapped by the parameter name', () => {
const args = { path: '/tmp/w' }
const [dot, slash] = rpcAttempts('workspace.create', args)
expect(dot).toEqual({
protocol: 'dot',
path: '/api/workspace.create',
method: 'workspace.create',
payload: args,
})
// The dot payload is the BARE args object (same reference — no wrapper).
expect(dot!.payload).toBe(args)
expect(slash).toEqual({
protocol: 'slash',
expect(rpcAttempt('workspace.create', args)).toEqual({
path: '/api/workspace/create',
method: 'workspace/create',
payload: { args: { request: args } },
})
})
it('keys slash args by the parameter name — session/list is literally `_request` (verified on a live 0.1.2-alpha.1 host)', () => {
it('keys args by the parameter name — session/list is literally `_request` (verified on a live 0.1.2-alpha host)', () => {
// typert gateway: session/list declares `_request` and rejects `{}` /
// `{request:{}}` — the wrapper table must reproduce the exact shape.
expect(rpcAttempts('session.list', {})[1]).toEqual({
protocol: 'slash',
expect(rpcAttempt('session.list', {})).toEqual({
path: '/api/session/list',
method: 'session/list',
payload: { args: { _request: {} } },
})
expect(rpcAttempts('session.create', { workspaceId: 'w' })[1]).toEqual({
protocol: 'slash',
expect(rpcAttempt('session.create', { workspaceId: 'w' })).toEqual({
path: '/api/session/create',
method: 'session/create',
payload: { args: { request: { workspaceId: 'w' } } },
})
})
it('fails loudly for methods without a verified slash args key', () => {
expect(() => rpcAttempts('settings.describe', {})).toThrow(/no slash-dialect args key/)
it('fails loudly for methods without a verified args key', () => {
expect(() => rpcAttempt('settings.describe', {})).toThrow(/no args key/)
})
})
@@ -136,43 +115,16 @@ function stubApi(responses: StubResponse[]): APIRequestContext & { paths: string
return api as unknown as APIRequestContext & { paths: string[]; bodies: unknown[] }
}
describe('host glue: hostRpc dialect negotiation', () => {
beforeEach(() => {
host.resetHostRpcForTests()
})
it('uses the dot dialect directly when the host answers it (0.1.1-rc.x)', async () => {
const api = stubApi([{ status: 200, body: { type: 'server-response', rpcId: 'x', result: { ok: true, value: { items: [] } } } }])
const first = await host.hostRpc<{ items: unknown[] }>(api, 'session.list', {})
expect(first.value.items).toEqual([])
// Second call must NOT probe again: the resolved dialect is cached.
await host.hostRpc(api, 'session.list', {})
expect(api.paths).toEqual(['/api/session.list', '/api/session.list'])
})
it('falls back to the slash dialect on 404 and caches it (0.1.2-alpha.1+)', async () => {
const api = stubApi([
{ status: 404, body: { error: 'not found' } },
{ status: 200, body: { type: 'server-response', rpcId: 'x', result: { ok: true, value: { sessionId: 'session-1' } } } },
])
describe('host glue: hostRpc', () => {
it('posts the Remote-gateway envelope to the slash endpoint and unwraps the result', async () => {
const api = stubApi([{ status: 200, body: { type: 'server-response', rpcId: 'x', result: { ok: true, value: { sessionId: 'session-1' } } } }])
const seeded = await host.hostRpc<{ sessionId: string }>(api, 'session.create', { workspaceId: 'w' })
expect(seeded.value.sessionId).toBe('session-1')
expect(api.paths).toEqual(['/api/session.create', '/api/session/create'])
// The slash attempt carries the Remote-gateway payload shape: args keyed
// by the controller's parameter name (`request` for session/create),
// method equal to the path endpoint.
const slashEnvelope = api.bodies[1] as { type: string; method: string; payload: Record<string, unknown> }
expect(slashEnvelope.type).toBe('client-request')
expect(slashEnvelope.method).toBe('session/create')
expect(slashEnvelope.payload).toEqual({ args: { request: { workspaceId: 'w' } } })
// Cached: the next call goes straight to the slash endpoint.
await host.hostRpc(api, 'session.list', {})
expect(api.paths[2]).toBe('/api/session/list')
})
it('fails loudly when neither dialect is claimed', async () => {
const api = stubApi([{ status: 404, body: {} }])
await expect(host.hostRpc(api, 'workspace.create', { path: '/tmp/w' })).rejects.toThrow(/no dialect answered/)
expect(api.paths).toEqual(['/api/session/create'])
const envelope = api.bodies[0] as { type: string; method: string; payload: Record<string, unknown> }
expect(envelope.type).toBe('client-request')
expect(envelope.method).toBe('session/create')
expect(envelope.payload).toEqual({ args: { request: { workspaceId: 'w' } } })
})
it('surfaces an envelope error instead of returning it', async () => {
+49 -57
View File
@@ -1,28 +1,28 @@
/**
* Pure host-transport contracts for the e2e lanes, spanning the DSH
* 0.1.1-rc.x → 0.1.2-alpha.1 breaking split (Remote gateway + one-time-token
* browser auth). No Playwright imports here — these shapes are unit-testable
* Pure host-transport contracts for the e2e lanes against the DSH
* 0.1.2-alpha.x web host (Remote gateway + one-time-token browser auth). No
* Playwright imports here — these shapes are unit-testable
* (tests/e2e-host-protocol.spec.ts); ./host.ts wires them to a request
* context.
*
* What changed in 0.1.2-alpha.1 (deepseek-harness tag dsh-v0.1.2-alpha.1):
* Host contract (deepseek-harness tags dsh-v0.1.2-alpha.1/2, verified on
* live hosts; the pre-alpha dual-dialect layer was dropped in the plugin's
* v0.18.0-alpha.0 alpha track):
*
* - `dsh web` prints an AUTHENTICATED launch URL — `dsh web:
* http://127.0.0.1:<port>/?token=<43 base64url chars>`. Navigating it
* exchanges the token for a signed cookie (HttpOnly, SameSite=Strict) that
* every `/api` request must then carry; the clean URL answers 401. Older
* hosts print a bare origin and have no browser auth.
* - The legacy ApiProxy dot-method endpoints (`POST /api/workspace.create`
* with `payload: <args>`) were replaced by the Remote gateway's slash
* endpoints — `POST /api/workspace/create` with `payload: { args }` whose
* args object is keyed by the controller's TypeScript PARAMETER name
* (`workspace/create` → `{ request: {...} }`; `session/list`'s unused
* parameter is literally `_request` and is not omissible); a dot path is
* no longer claimed (404). The `{type:'client-request', rpcId, method,
* payload}` / `{type:'server-response', rpcId, result}` envelopes are
* unchanged, and the plugin's own `/sidebar/*` routes stay public (the
* webserver carrier owns no authentication — only `/api`, the index HTML,
* and the remote mux upgrade sit behind the browser auth).
* every `/api` request must then carry; the clean URL answers 401.
* - The Remote gateway serves SLASH endpoints — `POST /api/workspace/create`
* with `payload: { args }` whose args object is keyed by the controller's
* TypeScript PARAMETER name (`workspace/create` → `{ request: {...} }`;
* `session/list`'s unused parameter is literally `_request` and is NOT
* omissible); the legacy dot path is no longer claimed (404). The
* `{type:'client-request', rpcId, method, payload}` /
* `{type:'server-response', rpcId, result}` envelopes are unchanged, and
* the plugin's own `/sidebar/*` routes stay public (the webserver carrier
* owns no authentication — only `/api`, the index HTML, and the remote mux
* upgrade sit behind the browser auth).
*/
/** The pieces of a `dsh web` launch URL the lanes need. */
@@ -30,22 +30,23 @@ export interface LaunchUrl {
/** Scheme + host + port — the base for URL construction (a token URL would
* corrupt path concatenation). */
origin: string
/** The URL as printed (token query included on 0.1.2-alpha.1+); the correct
* page.goto target — navigating it performs the token→cookie exchange. */
/** The URL as printed (token query included) — the correct page.goto
* target; navigating it performs the token→cookie exchange. */
pageUrl: string
/** The one-time launch token, when the host printed one. */
token: string | undefined
/** The one-time launch token. */
token: string
}
/** Split a `dsh web` launch URL (bare origin on 0.1.1-rc.x, `/?token=` on
* 0.1.2-alpha.1+) into the pieces the lanes address. */
/** Split a `dsh web` launch URL (`/?token=` — every supported host prints
* one) into the pieces the lanes address. Throws without a token: a bare
* origin is a pre-0.1.2-alpha host this plugin no longer supports. */
export function parseLaunchUrl(raw: string): LaunchUrl {
const url = new URL(raw)
return {
origin: url.origin,
pageUrl: raw,
token: url.searchParams.get('token') ?? undefined,
const token = url.searchParams.get('token')
if (token === null) {
throw new Error(`launch URL carries no ?token= — not a DSH 0.1.2-alpha+ authenticated launch URL: ${raw}`)
}
return { origin: url.origin, pageUrl: raw, token }
}
/** Page navigation URL with extra query stamps (e.g. desktop-shell URL
@@ -57,48 +58,39 @@ export function pageUrlWith(raw: string, extra: Record<string, string>): string
return url.toString()
}
/** One host-RPC protocol attempt. */
/** One host-RPC request. */
export interface RpcAttempt {
/** Which host dialect this attempt speaks. */
protocol: 'dot' | 'slash'
/** Request path, relative to the origin, starting with `/api/`. */
path: string
/** Envelope `method` string — must equal the endpoint the path selects. */
method: string
/** Envelope `payload` — the bare args object on dot hosts, `{ args }` on
* slash hosts (the gateway rejects any other payload shape). */
/** Envelope `payload` — `{ args }` keyed by the controller parameter name
* (the gateway rejects any other payload shape). */
payload: Record<string, unknown>
}
/** The slash-dialect args key per host method: the 0.1.2-alpha.1 Remote
* controllers key the args object by the TypeScript PARAMETER name, not by
* the endpoint's own naming — workspace/create and session/create declare a
* single `request` parameter, while session/list's unused parameter is
* literally named `_request` (and is NOT omissible: the typert gateway
* rejects `{}` and `{request:{}}` alike). Verified against a live
* dsh-v0.1.2-alpha.1 host: every other shape fails with `args fields do not
* match the descriptor`. */
const SLASH_ARGS_KEY: Record<string, string> = {
/** The args key per host method: the 0.1.2-alpha Remote controllers key the
* args object by the TypeScript PARAMETER name, not by the endpoint's own
* naming — workspace/create and session/create declare a single `request`
* parameter, while session/list's unused parameter is literally named
* `_request` (and is NOT omissible: the typert gateway rejects `{}` and
* `{request:{}}` alike). Verified against live dsh-v0.1.2-alpha hosts:
* every other shape fails with `args fields do not match the descriptor`. */
const RPC_ARGS_KEY: Record<string, string> = {
'workspace.create': 'request',
'session.create': 'request',
'session.list': '_request',
}
/** Build the two dialects of one host RPC (method in dot form, e.g.
* `'workspace.create'`): the 0.1.1-rc.x dot endpoint first — the dialect
* every currently-published host speaks, so the deployed lanes never pay a
* probe round-trip — then the 0.1.2-alpha.1+ slash endpoint as the 404
* fallback. Throws for methods without a known slash args key: the wrapper
* is per-parameter, so an unverified shape must fail loudly at build time
* instead of as a confusing gateway error mid-lane. */
export function rpcAttempts(method: string, args: Record<string, unknown>): RpcAttempt[] {
const slashKey = SLASH_ARGS_KEY[method]
if (slashKey === undefined) {
throw new Error(`host-protocol: no slash-dialect args key for ${JSON.stringify(method)} — verify the parameter name on the host and extend SLASH_ARGS_KEY`)
/** Build one host RPC request (method in dot form, e.g.
* `'workspace.create'`). Throws for methods without a known args key: the
* wrapper is per-parameter, so an unverified shape must fail loudly at
* build time instead of as a confusing gateway error mid-lane. */
export function rpcAttempt(method: string, args: Record<string, unknown>): RpcAttempt {
const key = RPC_ARGS_KEY[method]
if (key === undefined) {
throw new Error(`host-protocol: no args key for ${JSON.stringify(method)} — verify the parameter name on the host and extend RPC_ARGS_KEY`)
}
const slash = method.split('.').join('/')
return [
{ protocol: 'dot', path: `/api/${method}`, method, payload: args },
{ protocol: 'slash', path: `/api/${slash}`, method: slash, payload: { args: { [slashKey]: args } } },
]
const endpoint = method.split('.').join('/')
return { path: `/api/${endpoint}`, method: endpoint, payload: { args: { [key]: args } } }
}
+42 -62
View File
@@ -1,21 +1,18 @@
/**
* Host glue for the e2e lanes: page/asset URL construction, the authenticated
* request context, and the dual-protocol host RPC. The cross-version shapes
* (bare origin vs one-time-token URL, dot vs slash `/api` endpoints) live in
* ./host-protocol.ts; this module only wires them to Playwright.
* request context, and the host RPC. The transport contracts (one-time-token
* launch URL, slash `/api` endpoints) live in ./host-protocol.ts; this
* module only wires them to Playwright.
*
* Every lane consumes the same contract regardless of host version:
* Every lane consumes the same contract:
* - `PAGE_URL` / `pageUrl({...})` for page.goto (the token URL performs the
* browser's token→cookie exchange on 0.1.2-alpha.1+; a bare origin is a
* no-op on older hosts),
* browser's token→cookie exchange),
* - `sidebarApi(path)` for the plugin's own public routes,
* - `createHostApi()` for an APIRequestContext that carries the auth cookie
* when the host printed a token,
* - `hostRpc(api, 'workspace.create', {...})` for the host's unary RPC —
* dot endpoint first, slash endpoint on 404 fallback (see host-protocol).
* - `createHostApi()` for an APIRequestContext carrying the auth cookie,
* - `hostRpc(api, 'workspace.create', {...})` for the host's unary RPC.
*/
import { request, type APIRequestContext, type Page } from '@playwright/test'
import { parseLaunchUrl, pageUrlWith, rpcAttempts } from './host-protocol'
import { parseLaunchUrl, pageUrlWith, rpcAttempt } from './host-protocol'
const envUrl = process.env.DSH_E2E_URL
if (!envUrl) {
@@ -30,8 +27,8 @@ const LAUNCH = parseLaunchUrl(RAW_URL)
/** Origin for URL construction (a token URL would corrupt path joins). */
export const ORIGIN = LAUNCH.origin
/** page.goto target: the launch URL as printed (token included on
* 0.1.2-alpha.1+ — navigating it exchanges the token for the cookie). */
/** page.goto target: the launch URL as printed (token included —
* navigating it exchanges the token for the cookie). */
export const PAGE_URL = LAUNCH.pageUrl
/** page.goto target with extra query stamps merged (token preserved). */
@@ -64,14 +61,14 @@ export async function exchangeLaunchCookie(
let cookieHeader: string | undefined
/** page.goto through the host auth. A plain navigation goes to PAGE_URL (the
* token URL performs the browser's exchange on 0.1.2-alpha.1+), but a
* STAMPED navigation cannot: the exchange answers `303 → /` and silently
* DROPS every sibling query param (the mount lane caught the desktop-shell
* stamps vanishing this way), so pre-seed the browser context with the
* exchanged cookie and navigate the stamped URL directly. Playwright gives
* every test a fresh context, so the seeding is per-call and idempotent. */
* token URL performs the browser's exchange), but a STAMPED navigation
* cannot: the exchange answers `303 → /` and silently DROPS every sibling
* query param (the mount lane caught the desktop-shell stamps vanishing
* this way), so pre-seed the browser context with the exchanged cookie and
* navigate the stamped URL directly. Playwright gives every test a fresh
* context, so the seeding is per-call and idempotent. */
export async function gotoPage(page: Page, extra: Record<string, string> = {}): Promise<void> {
if (LAUNCH.token !== undefined && Object.keys(extra).length > 0) {
if (Object.keys(extra).length > 0) {
if (cookieHeader === undefined) cookieHeader = await exchangeLaunchCookie(RAW_URL)
const eq = cookieHeader.indexOf('=')
await page.context().addCookies([{
@@ -85,15 +82,14 @@ export async function gotoPage(page: Page, extra: Record<string, string> = {}):
await page.goto(pageUrl(extra), { waitUntil: 'domcontentloaded' })
}
/** Authenticated request context for host RPC seeding (cookie attached when
* the launch URL carried a one-time token; plain on older hosts). */
/** Authenticated request context for host RPC seeding (cookie attached). */
export async function createHostApi(): Promise<APIRequestContext> {
if (cookieHeader === undefined && LAUNCH.token !== undefined) {
if (cookieHeader === undefined) {
cookieHeader = await exchangeLaunchCookie(RAW_URL)
}
return request.newContext({
baseURL: ORIGIN,
extraHTTPHeaders: cookieHeader === undefined ? {} : { cookie: cookieHeader },
extraHTTPHeaders: { cookie: cookieHeader },
})
}
@@ -103,52 +99,36 @@ export interface HostRpcOk<T> {
value: T
}
/** Resolved dialect for hostRpc — cached after the first call so only the
* first RPC ever pays the 404 probe (dot-first: published hosts answer it
* directly; 0.1.2-alpha.1+ 404s the dot path and answers the slash one). */
let protocol: 'dot' | 'slash' | undefined
/** Test hook: forget the resolved dialect between unit-test cases. */
export function resetHostRpcForTests(): void {
protocol = undefined
}
let rpcCounter = 0
/** Call a host unary RPC (`'workspace.create'`, `'session.create'`, …) with
* the args object both dialects accept. Throws with the response body when
* the HTTP call or the envelope result fails — the lanes' seeding treats any
* failure as fatal, so the error text carries everything a report needs. */
/** Call a host unary RPC (`'workspace.create'`, `'session.create'`, …).
* Throws with the response body when the HTTP call or the envelope result
* fails — the lanes' seeding treats any failure as fatal, so the error text
* carries everything a report needs. */
export async function hostRpc<T = unknown>(
api: APIRequestContext,
method: string,
args: Record<string, unknown> = {},
): Promise<HostRpcOk<T>> {
const attempts = rpcAttempts(method, args)
const ordered = protocol === 'slash' ? [attempts[1]!, attempts[0]!] : attempts
const attempt = rpcAttempt(method, args)
rpcCounter += 1
const rpcId = `e2e-${method}-${rpcCounter}`
for (const attempt of ordered) {
const res = await api.post(attempt.path, {
data: { type: 'client-request', rpcId, method: attempt.method, payload: attempt.payload },
})
const bodyText = await res.text()
if (res.status() === 404 && protocol === undefined) continue // endpoint not claimed on this host → other dialect
if (!res.ok()) {
throw new Error(`hostRpc ${method} [${attempt.protocol}] HTTP ${res.status()}: ${bodyText.slice(0, 400)}`)
}
let envelope: { type?: string; result?: { ok: true; value: T } | { ok: false; error: unknown } }
try {
envelope = JSON.parse(bodyText) as typeof envelope
} catch {
throw new Error(`hostRpc ${method} [${attempt.protocol}]: non-JSON response: ${bodyText.slice(0, 400)}`)
}
const result = envelope.result
if (result === undefined || result.ok !== true) {
throw new Error(`hostRpc ${method} [${attempt.protocol}] envelope error: ${bodyText.slice(0, 400)}`)
}
protocol = attempt.protocol
return result
const res = await api.post(attempt.path, {
data: { type: 'client-request', rpcId, method: attempt.method, payload: attempt.payload },
})
const bodyText = await res.text()
if (!res.ok()) {
throw new Error(`hostRpc ${method} [${attempt.path}] HTTP ${res.status()}: ${bodyText.slice(0, 400)}`)
}
throw new Error(`hostRpc ${method}: no dialect answered (dot and slash both 404) — is this a DSH web host?`)
let envelope: { type?: string; result?: { ok: true; value: T } | { ok: false; error: unknown } }
try {
envelope = JSON.parse(bodyText) as typeof envelope
} catch {
throw new Error(`hostRpc ${method} [${attempt.path}]: non-JSON response: ${bodyText.slice(0, 400)}`)
}
const result = envelope.result
if (result === undefined || result.ok !== true) {
throw new Error(`hostRpc ${method} [${attempt.path}] envelope error: ${bodyText.slice(0, 400)}`)
}
return result
}
+19
View File
@@ -288,6 +288,13 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
await assertNoCrash()
}
// The sweep opened the Side Chat tab, whose view auto-creates a thread and
// polls the transcript — that poll MUST ride the plugin's own
// sidechat.events route (the DSH-0.1.2-safe transport this lane locks).
const polledTranscript = await page.evaluate(() =>
performance.getEntriesByType('resource').some(entry => entry.name.includes('/sidebar/api/sidechat.events')))
expect(polledTranscript, 'the Side Chat tab must poll sidechat.events for its transcript').toBe(true)
// Side Chat host-route smoke against the REAL host: create a thread child
// under the seeded session (custom-seed creation through AgentRegistry),
// deliver a follow-up, cancel, and release it. The turn itself cannot run
@@ -315,12 +322,24 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
listedItems.some(item => item.sessionId === childId),
'the thread child must appear in the host session list',
).toBe(true)
// Transcript read from the LIVE agent's log (the transport the tab polls;
// the inherited seed is cut host-side, so with no model route the own
// slice carries no message events).
const eventsLive = await api.post(sidebarApi('sidechat.events'), { data: { childId } })
expect(eventsLive.ok(), `sidechat.events (live): ${eventsLive.status()} ${await eventsLive.text()}`).toBe(true)
const eventsLiveBody = (await eventsLive.json()) as { ok: boolean; value?: { events?: Array<{ type: string }> } }
expect(eventsLiveBody.ok, `sidechat.events envelope: ${JSON.stringify(eventsLiveBody)}`).toBe(true)
expect(Array.isArray(eventsLiveBody.value?.events), 'sidechat.events must answer an events array').toBe(true)
for (const method of ['sidechat.prompt', 'sidechat.cancel', 'sidechat.dispose']) {
const response = await api.post(sidebarApi(method), {
data: method === 'sidechat.prompt' ? { childId, text: 'follow-up' } : { childId },
})
expect(response.ok(), `${method}: ${response.status()} ${await response.text()}`).toBe(true)
}
// After dispose the agent is gone: the same read must fall back to the
// PERSISTED log (the cold path a re-opened tab polls).
const eventsCold = await api.post(sidebarApi('sidechat.events'), { data: { childId } })
expect(eventsCold.ok(), `sidechat.events (cold): ${eventsCold.status()} ${await eventsCold.text()}`).toBe(true)
// The Codex-style immediate-create flow: a blank question creates an
// EMPTY thread (no prompt admitted), sidechat.info reports the live
-1
View File
@@ -51,7 +51,6 @@ const CLIENT_REQUIRE_ALLOWED = new Set([
'cordis',
'@deepseek-ai/dsh-client-ui-slots',
'@deepseek-ai/dsh-client-ui-primitives',
'@deepseek-ai/dsh-client-runtime/client',
])
/** The registered `__ModuleLoader__.load({ id })` value of a built client bundle. */
+8 -5
View File
@@ -14,7 +14,9 @@
* - a safe, bund-visible `dsh.bundle.patch` path (`./cordis.patch.yml`),
* - a repository identity that normalizes to a credential-free HTTPS GitHub
* owner/repo URL (the catalog ↔ npm backlink comparison),
* - an exact stable SemVer version (no ranges, no tags, no prerelease).
* - an exact SemVer version (no ranges, no tags; a prerelease suffix is
* allowed — prerelease releases ride the npm `alpha` dist-tag and are not
* the market's verified `latest` install target until a stable cut).
*
* The packed-manifest checks run `pnpm pack` into a temp dir (the `prepare`
* script rebuilds the gitignored lib/, like the release flow does).
@@ -43,8 +45,9 @@ const pkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf8')) as {
/** The market's lifecycle-script reject list (install-and-uninstall.zh.md). */
const LIFECYCLE_SCRIPTS = ['preinstall', 'install', 'postinstall', 'prepare'] as const
/** Stable exact SemVer: exactly three numeric segments (no tag / prerelease). */
const STABLE_SEMVER = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/u
/** Exact SemVer: exactly three numeric segments, optional prerelease suffix
* (no range / tag — prerelease cuts publish under the npm `alpha` dist-tag). */
const EXACT_SEMVER = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u
/** Read `package.json` from a fresh `pnpm pack` tarball (the publish surface). */
function packedManifest(): Record<string, unknown> {
@@ -105,7 +108,7 @@ describe('DSH community-market manifest compatibility', () => {
expect(url.pathname.split('/').filter(Boolean)).toHaveLength(2)
})
it('declares an exact stable SemVer version (no range, tag, or prerelease)', () => {
expect(pkg.version).toMatch(STABLE_SEMVER)
it('declares an exact SemVer version (no range or tag; prerelease allowed for the alpha track)', () => {
expect(pkg.version).toMatch(EXACT_SEMVER)
})
})
+109 -6
View File
@@ -1,11 +1,14 @@
/**
* Host route tests for the Side Chat API ('sidechat.start' / 'sidechat.prompt'
* / 'sidechat.cancel' / 'sidechat.dispose'): the custom-seed thread creation
* (with the in-progress-turn synthetic close and the dangling-tool-call
* snapshot fallback), the boundary+question first prompt, follow-ups on live
* and cold (resumed) agents, cancel, and dispose.
* / 'sidechat.cancel' / 'sidechat.dispose' / 'sidechat.info' /
* 'sidechat.events'): the custom-seed thread creation (with the
* in-progress-turn synthetic close and the dangling-tool-call snapshot
* fallback), the boundary+question first prompt, follow-ups on live and cold
* (resumed) agents, cancel, dispose, and the transcript reads (seed cut,
* afterSeq deltas, tail cap, live/persisted sources).
*/
import { describe, expect, it, vi } from 'vitest'
import { SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent'
import { buildSidechatApi } from '../src/sidechat-routes.ts'
import { SidebarError } from '../src/wire.ts'
import { SIDE_BOUNDARY_PROMPT, SIDE_INJECTION_PLUGIN, SIDE_NEW_THREAD_TITLE, sideLabel } from '../src/sidechat-core.ts'
@@ -45,7 +48,7 @@ function happyServices(parent: AgentLike | undefined, child: AgentLike) {
const rename = vi.fn((_session: unknown, _title: string) => ({ title: 'x', eventSeq: 1 }))
const resolve = vi.fn(async (_id?: string) => ({ id: 'preset-a' }))
const mount = vi.fn(async () => {})
const inspect = vi.fn(async () => ({ meta: { agentPreset: 'preset-a' }, events: [] }))
const inspect = vi.fn(async (_id?: string): Promise<{ meta: Record<string, unknown>; events: unknown[] }> => ({ meta: { agentPreset: 'preset-a' }, events: [] }))
return {
agents: { get, create, resume },
agentPresets: { resolve, mount },
@@ -132,7 +135,10 @@ describe('sidechat.start', () => {
'subagent/descriptor',
])
expect(options.seed.at(-1)?.data).toMatchObject({
version: 2,
// The descriptor version is stamped by the host package's
// snapshotSubagentDescriptor — follow it instead of pinning a literal
// (bumped 2 → 3 in DSH 0.1.2-alpha.2).
version: SUBAGENT_DESCRIPTOR_VERSION,
mode: 'continuable',
provider: 'sidechat',
label: sideLabel('explain the event flow'),
@@ -359,3 +365,100 @@ describe('sidechat.info', () => {
await expect(api['sidechat.info']({ childId: 'ghost' })).resolves.toEqual({ live: false })
})
})
/** A realistic thread log: the inherited seed, the end-seed marker, the
* descriptor the seed appends, and the thread's own conversation. */
function threadLog(): Array<ReturnType<typeof ev>> {
return [
ev('user/message', 0, { content: [{ type: 'text', text: 'inherited parent question' }], source: { kind: 'user' } }),
ev('turn/end', 1, { turn: 0, reason: { kind: 'completed' } }),
ev('session/end-seed', 2),
ev('subagent/descriptor', 3, { mode: 'continuable' }),
ev('user/message', 4, { content: [{ type: 'text', text: 'Side conversation boundary.' }], source: { kind: 'plugin', plugin: 'dsh-better-sidebar' } }),
ev('user/message', 5, { content: [{ type: 'text', text: 'the side question' }], source: { kind: 'user' } }),
ev('assistant/chunk', 6, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'an' } }),
ev('assistant/chunk', 7, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'swer' } }),
]
}
describe('sidechat.events', () => {
it('returns the thread-own slice from the LIVE agent log (seed cut host-side)', async () => {
const child = agent('child', { events: threadLog() })
const services = happyServices(undefined, child)
const api = buildSidechatApi(ctxWith(services))
const result = await api['sidechat.events']({ childId: 'child' })
expect(result.events.map(event => event.seq)).toEqual([3, 4, 5, 6, 7])
expect(result.events[0]).toMatchObject({ type: 'subagent/descriptor', seq: 3 })
expect(services.inspect).not.toHaveBeenCalled()
})
it('narrows to the afterSeq delta on polls (and never resurrects the seed)', async () => {
const child = agent('child', { events: threadLog() })
const services = happyServices(undefined, child)
const api = buildSidechatApi(ctxWith(services))
const delta = await api['sidechat.events']({ childId: 'child', afterSeq: 6 })
expect(delta.events.map(event => event.seq)).toEqual([7])
// An afterSeq below the boundary must still not return the inherited seed.
const fromZero = await api['sidechat.events']({ childId: 'child', afterSeq: 0 })
expect(fromZero.events.map(event => event.seq)).toEqual([3, 4, 5, 6, 7])
})
it('reads a COLD thread from persistence when no agent is live', async () => {
const child = agent('child')
const services = happyServices(undefined, child)
services.agents.get = vi.fn((_id: unknown) => undefined)
services.inspect.mockImplementation(async () => ({ meta: { agentPreset: 'preset-a' }, events: threadLog() }))
const api = buildSidechatApi(ctxWith(services))
const result = await api['sidechat.events']({ childId: 'child' })
expect(result.events.map(event => event.seq)).toEqual([3, 4, 5, 6, 7])
expect(services.inspect).toHaveBeenCalledWith('child')
})
it('returns a marker-less legacy log whole', async () => {
const child = agent('child', {
events: [
ev('user/message', 0, { content: [{ type: 'text', text: 'legacy' }], source: { kind: 'user' } }),
ev('assistant/message', 1, { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'a' }] } }),
],
})
const services = happyServices(undefined, child)
const api = buildSidechatApi(ctxWith(services))
const result = await api['sidechat.events']({ childId: 'child' })
expect(result.events.map(event => event.seq)).toEqual([0, 1])
})
it('caps a pathological response at its tail (8000 events)', async () => {
const events = Array.from({ length: 8_003 }, (_, index) => ev('assistant/chunk', index, { turn: 1, step: 1 }))
const child = agent('child', { events })
const services = happyServices(undefined, child)
const api = buildSidechatApi(ctxWith(services))
const result = await api['sidechat.events']({ childId: 'child' })
expect(result.events).toHaveLength(8_000)
expect(result.events[0]).toMatchObject({ seq: 3 })
expect(result.events.at(-1)).toMatchObject({ seq: 8_002 })
})
it('rejects an invalid afterSeq and reports a missing thread as not-found', async () => {
const child = agent('child', { events: threadLog() })
const services = happyServices(undefined, child)
const api = buildSidechatApi(ctxWith(services))
await expect(api['sidechat.events']({ childId: 'child', afterSeq: -1 }))
.rejects.toMatchObject({ code: 'bad-request' })
await expect(api['sidechat.events']({ childId: 'child', afterSeq: 1.5 }))
.rejects.toMatchObject({ code: 'bad-request' })
services.agents.get = vi.fn((_id: unknown) => undefined)
services.inspect.mockImplementation(async () => { throw new Error('no such session') })
await expect(api['sidechat.events']({ childId: 'ghost' }))
.rejects.toMatchObject({ code: 'not-found', status: 404 })
})
it('fails loudly when a cold thread has no persistence service to read', async () => {
const services = happyServices(undefined, agent('child'))
const api = buildSidechatApi(ctxWith({
agents: { ...services.agents, get: vi.fn((_id: unknown) => undefined) },
}))
await expect(api['sidechat.events']({ childId: 'child' }))
.rejects.toMatchObject({ code: 'sidechat-error', status: 503 })
})
})
+1 -64
View File
@@ -8,7 +8,7 @@
import { describe, expect, it } from 'vitest'
import type { SidebarHistoryEntry, SidebarSessionEvent } from '../src/context-types.ts'
import { SIDE_BOUNDARY_PREFIX, SIDE_BOUNDARY_PROMPT, SIDE_INJECTION_PLUGIN } from '../src/sidechat-core.ts'
import { collectOwnEvents, toolArgsSummary, transcriptRows, type SidechatTranscriptRow } from '../src/client/sidechat-transcript.ts'
import { toolArgsSummary, transcriptRows, type SidechatTranscriptRow } from '../src/client/sidechat-transcript.ts'
/** One history entry (event + optional view). */
function entry(event: SidebarSessionEvent): SidebarHistoryEntry {
@@ -164,69 +164,6 @@ describe('transcriptRows', () => {
})
})
describe('collectOwnEvents', () => {
/** A paginated fake log: fetchPage(beforeSeq) returns the ≤pageSize events
* ENDING before beforeSeq (exclusive); the tail page when omitted. */
function pagedLog(events: SidebarSessionEvent[], pageSize: number) {
const calls: Array<number | undefined> = []
const fetchPage = async (beforeSeq?: number): Promise<SidebarHistoryEntry[]> => {
calls.push(beforeSeq)
const window = beforeSeq === undefined ? events : events.filter(e => e.seq < beforeSeq)
return window.slice(-pageSize).map(entry)
}
return { fetchPage, calls }
}
it('finds the seed boundary on the tail page in one fetch', async () => {
const { fetchPage, calls } = pagedLog([
ev('user/message', 0, { content: textBlocks('inherited'), source: { kind: 'user' } }),
ev('session/end-seed', 1),
ev('user/message', 2, { content: textBlocks('own'), source: { kind: 'user' } }),
ev('assistant/message', 3, { turn: 1, step: 1, message: { content: textBlocks('a') } }),
], 200)
const result = await collectOwnEvents(fetchPage)
expect(calls).toEqual([undefined])
expect(result.seedBoundary).toBe(1)
expect(result.entries.map(e => e.event.seq)).toEqual([2, 3])
})
it('walks back across pages until the boundary surfaces', async () => {
// A dense chunk tail (cold reads re-expand chunk-rows): the boundary is
// several pages back and earlier tool/call events must NOT fall out of
// the loaded window (the re-entry tool-row regression).
const events = [
ev('session/end-seed', 0),
ev('tool/call', 1, { turn: 1, step: 1, callId: 'c1', name: 'bash' }),
...Array.from({ length: 12 }, (_, i) => ev('assistant/chunk', 2 + i, { turn: 1, step: 1 })),
]
const { fetchPage, calls } = pagedLog(events, 5)
const result = await collectOwnEvents(fetchPage)
expect(calls.length).toBeGreaterThan(1)
expect(result.seedBoundary).toBe(0)
// Everything after the marker, oldest-first, deduped.
expect(result.entries.map(e => e.event.seq)).toEqual(events.slice(1).map(e => e.seq))
})
it('stops at the log start when no marker exists and reports boundary 0', async () => {
const { fetchPage } = pagedLog([
ev('user/message', 0, { content: textBlocks('legacy'), source: { kind: 'user' } }),
ev('assistant/message', 1, { turn: 1, step: 1, message: { content: textBlocks('a') } }),
], 200)
const result = await collectOwnEvents(fetchPage)
expect(result.seedBoundary).toBe(0)
expect(result.entries.map(e => e.event.seq)).toEqual([0, 1])
})
it('honours the page cap instead of re-walking forever', async () => {
const events = Array.from({ length: 30 }, (_, i) => ev('assistant/chunk', i, { turn: 1, step: 1 }))
const { fetchPage, calls } = pagedLog(events, 5)
const result = await collectOwnEvents(fetchPage, 2)
expect(calls).toEqual([undefined, 25])
expect(result.seedBoundary).toBe(0)
expect(result.entries.map(e => e.event.seq)).toEqual([20, 21, 22, 23, 24, 25, 26, 27, 28, 29])
})
})
describe('toolArgsSummary', () => {
it('picks the most identifying string field', () => {
expect(toolArgsSummary('{"command":"ls -la","timeout":1000}')).toBe('ls -la')
+2 -2
View File
@@ -8,7 +8,7 @@ import { spawnSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve as resolvePath } from 'node:path'
import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-settings'
import { SettingsConflictError, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
import { apply, mediaTypeForPath } from '../src/index.ts'
import { encodeHtmlUrl } from '../src/html-route.ts'
import * as git from '../src/git.ts'
@@ -844,7 +844,7 @@ describe('side card settings routes', () => {
const entry = namespaces.get(ns)
if (entry === undefined) throw new Error(`settings namespace "${ns}" is not registered`)
if (expectedRevision !== undefined && expectedRevision !== entry.revision) {
throw new SettingsConflictError(settingsNamespace(ns), expectedRevision, entry.revision)
throw new SettingsConflictError(ns as SettingsNamespace, expectedRevision, entry.revision)
}
entry.value = { ...entry.value, ...patch }
entry.revision += 1
+1 -2
View File
@@ -55,7 +55,7 @@ const NODE_BUILTINS = new Set([
...builtinModules.map(id => `node:${id}`),
])
/** Module specifiers the web shell shares into the frozen module table (the official PLATFORM_MODULES list, plus the runtime/client exemption — removed in DSH 0.1.2-alpha.1 along with the runtime package, but no chunk requires it, so the entry stays inert there and live on 0.1.1-rc.x hosts). */
/** Module specifiers the web shell shares into the frozen module table (the official PLATFORM_MODULES list; `dsh-client-runtime` was removed upstream in DSH 0.1.2-alpha and no chunk requires it). */
const CLIENT_EXTERNALS = [
'react',
'react/jsx-runtime',
@@ -64,7 +64,6 @@ const CLIENT_EXTERNALS = [
'cordis',
'@deepseek-ai/dsh-client-ui-slots',
'@deepseek-ai/dsh-client-ui-primitives',
'@deepseek-ai/dsh-client-runtime/client',
]
/**
+3
View File
@@ -26,6 +26,9 @@ export default defineConfig({
// node_modules/dist/etc. excludes must be restated here.
exclude: [
'tests/e2e/**',
// Local dev worktrees (pnpm/DSH-style task branches) may carry stale
// code against this checkout's node_modules — never collect them.
'**/.worktrees/**',
'**/node_modules/**',
'**/dist/**',
'**/cypress/**',