dsh-client-ui-quote 0.12.3-lab.29 → 0.12.3-lab.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -1
- package/README.md +17 -17
- package/lib/client.js +13 -14
- package/lib/index.js +84 -99
- package/package.json +1 -1
- package/test/capsule.mjs +11 -4
- package/test/host.mjs +91 -86
package/CHANGELOG.md
CHANGED
|
@@ -6,10 +6,13 @@
|
|
|
6
6
|
|
|
7
7
|
- 侧边对话默认内置隐藏线程,不再需要安装 dsh-better-sidebar:宿主半注册 `POST /ui-quote/api/sidechat.start`(同源围栏),从在线父会话复制种子,用宿主 `agents.create` 建 `origin: "subagent"` 子会话,并在宿主内置 `subagentchat` 页签打开;descriptor 与会话标题使用与 better-sidebar 共享的 `Side: ` 约定,装不装它都不冲突。宿主半不可用(旧宿主 / 父会话不在线 / 创建被拒)时自动退回 `sessions.fork` + 本插件原生 `quote-sidechat` 页签;`sideChatBackend: "better"` 才优先走 better-sidebar。
|
|
8
8
|
- 侧边线程记忆与清单:新增 `dsh-client-ui-quote:v1:sidechat-target` 记录隐藏线程的父会话;读取线程时用 subagent 地址 `{parentSessionId, childSessionId, mode:"continuable"}`;线程列表同时扫描会话列表里的 `Side:` 子会话,所以 better-sidebar(或旧版本)创建的线程也能在本插件里打开、带回和导出。
|
|
9
|
-
- 离线断言 **
|
|
9
|
+
- 离线断言 **649 组**(check 12 + capsule 443 + panel 153 + host 41);新增原生页签的地址解析、标题、资源 retain/release、页签注册与卸载断言,以及宿主半的种子复制 / 继承切点 / 回合收口 / 悬挂工具调用截断 / `agents.create` 参数 / 探针与围栏断言。
|
|
10
10
|
|
|
11
11
|
### Fixed
|
|
12
12
|
|
|
13
|
+
- 侧边对话只剩**一项**:浮层里「侧边对话」与「另开侧边对话」两个按钮长得一样、语义要读第二行才分得清,现在只留「侧边对话」,一条主会话一条线程(早期按引用分线程的记录仍会识别并复用)。
|
|
14
|
+
- 侧聊打开时不再把主会话的历史铺在界面上:种子从「复制父会话整条日志」改成**一条隐性上下文**——先是 session/end-seed{inherited:true} 切点,再是一条 source.kind = session-context 的 user/message(只收用户与助手的原话、最新在后、上限 8000 字,跳过工具事件与注入上下文),最后是 descriptor。模型照旧拿到上下文,侧聊里只折叠成一行「上下文」;同时不再为复制整条日志付一遍 token。
|
|
15
|
+
- 侧聊草稿里不再写 `Side conversation boundary.` 那段元文案:「只作背景、别继续执行」的指令挪进隐性上下文,输入框里只剩这条引用本身。
|
|
13
16
|
- 隐藏线程的侧聊页一打开就「历史加载失败:subagent descriptor is unavailable」:种子把 `subagent/descriptor` 放在继承前缀里、又用整段种子的长度当 `inheritedEventCount`,宿主的 `api-session-controller` 要求 descriptor 的 `seq` 不小于继承切点,于是整条子会话的历史都读不出来。现在种子先落一条 `session/end-seed { inherited: true }` 标出切点,回合收口、工作目录与 descriptor 全部排在切点之后;嵌套侧聊的父 descriptor 留在继承前缀里,我们自己的那条排在后面(投影按最后一次生效)。
|
|
14
17
|
- 修复前造出的坏线程会被反复打开:`sessions.retain` 对它们**照样成功**(只有历史 RPC 拒绝),所以「试开一次」判断不出来。现在客户端给新建的线程盖形状版本戳(`dsh-client-ui-quote:v1:sidechat-target` 里的 `v: 2`),只复用自己盖过章的线程;better-sidebar 的、旧版本的、扫描发现的线程一律丢弃重建——一次误杀换永久自愈。
|
|
15
18
|
- 正文以 ``` 或 `~~~` 开头的普通引用在回读时丢了首行:识别代码引用只看首行围栏,却没有要求块尾有配对的收尾围栏,于是引用一段 Markdown 源码时那行被当围栏剥掉(只有一行的引用会整条消失,胶囊、悬停卡片与引用清单都少字)。现在围栏必须成对才算传输层——写出去的代码引用收尾围栏始终成对,旧消息解析不变;不成对的孤围栏按正文还原。
|
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
2. 在消息、文件预览、终端或 diff 里**选中一段文字**,浮层选「评论」或「添加到对话」。
|
|
13
13
|
3. 发送后回原文看高亮,悬停胶囊可**跳回**;右侧栏「引用清单」(`Alt+L`)可搜索、导出、审阅。
|
|
14
14
|
|
|
15
|
-
「侧边对话」默认**不需要 `dsh-better-sidebar
|
|
15
|
+
「侧边对话」默认**不需要 `dsh-better-sidebar`**:本插件的宿主半会建一个隐藏子会话(主会话的对话摘要作为一条**隐性上下文**注入,不在侧聊里铺开)(`origin: "subagent"`,不出现在会话列表),用宿主内置的 `subagentchat` 页签打开——仍然只写草稿,不替你发送。宿主半不可用时自动退回 fork + 本插件页签;想优先用 better-sidebar,把 `sideChatBackend` 设成 `"better"`。
|
|
16
16
|
|
|
17
17
|
选中正文 → 浮层动作 → 引用变成输入框里的行内胶囊(不是纯文本),评论也放在同一颗芯片里;发送之后,引用和评论**合成一颗胶囊**留在消息里,不会摊成一屏 `>` 行。第三项「侧边对话」则把引用写进右侧栏一条**独立的侧边线程的输入框**(一条主会话一条线程,反复点就反复追加),问什么由你写,主会话一点都不动。评论框里**引用正文本身也可编辑**(选多了当场裁);从**文件预览、终端或 diff**划选的引用以**围栏代码块**的线格式发给模型,模型看到的是真正的代码块。引用落进草稿后,**原句在来源消息里留下高亮与序号**,输入框上方出现 **`引用 ×N`** 的聚合条;发送块里的坐标行前面还带一段人可读的 **`引自第 N 轮 · 助手`**,模型不用解码就知道这段话从哪来。
|
|
18
18
|
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
| --- | --- |
|
|
43
43
|
| **评论** | 浮层就地变成评论框(`写一句评论…` + `取消` / `添加到对话`)。评论框里**引用正文也在自己的输入框里、可以改**——选多了就当场裁掉。评论框打开期间,原选区用 Chromium 的 `CSS.highlights` 保持高亮,焦点进了输入框也不会丢参照;不支持该 API 的宿主静默退化,评论框里仍有原文。`Enter` 提交、`Esc` 关闭、引用或评论为空时确认按钮禁用;输入法组合输入不会误触发。 |
|
|
44
44
|
| **添加到对话** | 直接把引用加进输入框,不弹评论框。输入框里已经写好的文字会留在原位:引用落到草稿**最上方**,你的文字另起一行接在下面,光标停在末尾,接着写正式提问即可。 |
|
|
45
|
-
| **侧边对话** | 把引用写进一条**独立的侧边对话**的输入框。默认走**内置隐藏线程**:本插件的宿主半用 `agents.create`
|
|
45
|
+
| **侧边对话** | 把引用写进一条**独立的侧边对话**的输入框。默认走**内置隐藏线程**:本插件的宿主半用 `agents.create` 建一个 `origin: "subagent"` 子会话(会话列表不显示它),主会话最近几轮的原话作为**一条可折叠的隐性上下文**注进去,而不是把整条历史铺出来,用宿主内置的 `subagentchat` 页签打开——聊天记录、输入框、流式输出与工具调用全是 DSH 原生的。宿主半不可用(旧宿主 / 父会话不在线 / 创建被拒)时自动退回 `sessions.fork` + 本插件 `quote-sidechat` 页签;把 `sideChatBackend` 设成 `"better"` 才优先走 dsh-better-sidebar。**不装 better-sidebar 也有隐藏线程**;装了也不会冲突,两边共享 `Side: ` 持久约定但路由、存储与 descriptor 各自独立。所有后端都**什么都不发送**,线程独立运行、不污染主会话,页签自动在右侧栏打开。同一条主会话只开一条线程,之后每次点都在同一条线程的草稿后面接着追加。鲸鱼蓝(`#4d6bfe`)的图标是它在浮层里的标识。浮层里**只有一项**「侧边对话」——两个长得一样的入口只会让人犹豫;早期版本按引用分过线程的记录仍会被识别并复用。引用清单页底部会列出这个会话的**全部侧边线程**,每条都能重新打开。 |
|
|
46
46
|
| **键盘直达** | `Alt+Q` 引用 / `Alt+C` 评论 / `Alt+B` 侧边对话——同一个浮层的三个动作,不弹浮层直接办。走 DSH 的快捷键服务注册,所以出现在设置里、可改键、与宿主键位冲突会在注册时报错;没有选区时返回 `pass`,按键不会被吞掉。 |
|
|
47
47
|
| **原文侧高亮** | 引用落进草稿或发出后,被引用的原句回到**来源消息**里留下淡黄高亮与**序号角标**(序号就是坐标里的同句序号,和 `「引用N」` 对得上)。支持 `CSS.highlights` 的宿主由浏览器**直接绘制高亮**,滚动、流式输出、虚拟列表重排都跟手,不靠逐帧重画去追;不支持时才退回浮层矩形(只画序号徽章)。行不在视口里、文字已经变了、宿主不给 `Range` 时就静默跳过,**不改宿主的 DOM**(高亮不包 `mark`,也不搬节点)。表格单元格、表格行、段落、列表项、`<br>` 这些块级边界按浏览器选区报告的 `\t` / `\n` 对齐,所以跨单元格、跨行、跨段落的引用也能画回来。 |
|
|
48
48
|
| **聚合引用条** | 输入框上方一条 **`引用 ×N`**:每颗芯片一个摘要胶囊,点一下跳回原文(跳不到就把光标放到芯片上,一次退格删掉),悬停就是完整卡片;每条末尾有 **`✎` 编辑**与 **`×` 移除**:编辑把原引用与评论回填进注释框,确认后旧芯片让位、新芯片原地插入;移除优先走宿主自己的 `consumeToken`(按芯片的 detect 区间与 `draftRev` 做 CAS 精确删除,不会碰错别的文本),宿主没有这个读取面或同名芯片无法区分时才退回「聚焦芯片 + 退格」并提示。**整条索引存在本机**:宿主会跨刷新恢复草稿里的芯片,插件的索引也一起恢复,所以刷新后条、卡片与「打开文件」都还在。主会话与侧边线程各自的输入框各有一条;芯片删光时条自己消失。整条带 `role=toolbar`,每颗胶囊的可访问名是完整引用+评论,不是截断后的标签。 |
|
|
@@ -139,7 +139,7 @@ link 的优点是改完刷新即生效;**日常运行请用方式一**,不
|
|
|
139
139
|
|
|
140
140
|
本机切换这三档(这台开发机专用的辅助脚本,不在仓库里):`node .dsh-quote-work/profile-mode.mjs link|lab|stable|status`。换完在窗口里 `Ctrl+Shift+R` 生效;只有宿主半有改动时才需要重启桌面端。
|
|
141
141
|
|
|
142
|
-
两条线的断言入口相同:`npm test`(
|
|
142
|
+
两条线的断言入口相同:`npm test`(649 项:check 12 + capsule 443 + panel 153 + host 41);`.github/workflows/test.yml` 在每次 push / PR 上用 Node 18/20/22 各跑一遍,`publish.yml` 里 `prepublishOnly` 再跑一次才发。
|
|
143
143
|
|
|
144
144
|
**推送通道**:`git push` 优先,但本机 `github.com:443` **时通时不通**(2026-10-19 实测:短时可用、随后连续超时,每次失败等约 21 秒)。日常用工作区根的 `promote.ps1`:先试 git,失败自动转 REST 兜底(`.dsh-quote-work/publish-release.mjs`、`.dsh-quote-work/push-branch.mjs`,`-Channel rest` 可跳过探测),并核对远端 SHA。**别混用两条通道**:REST 重建的提交时区归一到 `+0000`,内容相同但 SHA 不同,同一分支混推会分叉(`git status` 看着干净,远端却多一条平行提交);恢复就先 `git fetch`、确认 `git diff <本地> <远端>` 为空,再把本地线 reset 到远端。
|
|
145
145
|
|
|
@@ -158,16 +158,16 @@ link 的优点是改完刷新即生效;**日常运行请用方式一**,不
|
|
|
158
158
|
## 使用
|
|
159
159
|
|
|
160
160
|
1. 在会话正文、**文件预览**或**终端**里**拖动选中**一段文字(后两者没有自己的会话 id,引用会落进当前屏幕上那条会话,并且以**围栏代码块**的线格式发给模型;输入框内的选区属于改稿,不触发)。
|
|
161
|
-
2. 选区**下方**浮出浮层(下方空间不够时才改到上方),选择
|
|
161
|
+
2. 选区**下方**浮出浮层(下方空间不够时才改到上方),选择 **评论**、**添加到对话** 或 **侧边对话**。同一组动作也有键位:**`Alt+Q`** 直接引用、**`Alt+C`** 直接开评论框、**`Alt+B`** 直接开侧边对话(都在设置里可改;没有选区时按键原样让给其它快捷键)。
|
|
162
162
|
3. 前两个动作把引用放进输入框(评论模式下引用与评论在同一颗芯片里;**评论框上半格是引用正文本身,可以当场裁剪**;评论框里还有一格 **`让模型逐条回应`** 勾选项,勾上这条引用就带着逐条指令发出去),页面选区被清除、焦点交给输入框,可以接着写正式提问。输入框里已经有文字时,引用落在**最上方**,你的文字另起一行接在下面。
|
|
163
|
-
4.
|
|
163
|
+
4. **侧边对话**写的是另一条会话的输入框:右侧栏打开该线程的页签(标题取引用首行),草稿里**只有这条引用**(纯块引用形态),**不发送**;你接着在这条线程里写自己的问题、自己按发送。同一条主会话只会有一条侧边线程,第二次点第三项就把新引用接在草稿后面;上一次请求还在进行时,再次点击会并入第一次,不会开出两条重复线程。线程打开时的上下文是**一条隐性消息**(主会话最近若干轮的文字,按「上下文」折叠显示),所以侧聊里看不到主会话那一长串历史。
|
|
164
164
|
5. 浮层收起条件:单击别处、选区清空、按 `Esc`、滚动会话、窗口尺寸变化,以及动作完成之后。双击选词不会把浮层关掉。
|
|
165
165
|
6. 发送出去以后,消息里那段块会显示成一颗**胶囊**:收起时是单行 `❝ 引用摘要 │ 评论摘要`,**鼠标移上去**会浮出一张卡片显示完整的引用与评论
|
|
166
166
|
(输入框里那颗芯片同样可以悬停查看),点一下就在原地展开成全文,胶囊上悬停出现 `复制`。
|
|
167
167
|
你自己写的话照旧显示在它下面;一条消息里引用了几处,就会出现几颗胶囊,中间的原话留在原来的位置。
|
|
168
168
|
7. 悬停卡片底部有 **`跳回原文`**:只对那些**本插件看着被选中**的引用出现(插入时记下了它所在的消息行 `data-chat-anchor-key` 与轮次),点一下就把那条消息**停到视野上方 24px**——和你用宿主自己的「跳到某一轮」是同一个落点——并闪烁一下。搜索只在这条引用**自己的会话面板**里进行(同一个锚点键在别的会话里也有时不会误跳),被 `hidden` 藏起来的行不算目标、会让位给同轮次的可见行,还没被虚拟列表挂载的行会一屏一屏地滚出来找——**先往上**(引用总在更早的消息里),到顶仍未找到就从最新处**再来一遍**;这套落点是宿主自己的做法:读 `[data-conversation-scroll]` 的 `scrollTop` 再连补几帧(虚拟列表挂载邻居时会改写偏移,一次性的 `scrollIntoView` 会落短)。真找不到时给一条 toast(`原文不在当前视图里`),而不是留一个点了没反应的按钮。
|
|
169
169
|
8. **引用清单**:右侧栏的「引用清单」页把这条会话发出去的所有引用与评论列成一张表(**导出 Markdown** 给人读,**导出 JSON** 给工具读;本机还留一份索引,上下文压缩把消息裁掉后清单仍列得出来)——**搜索框**按引用或评论过滤,点某一条就**跳回原文**(和悬停卡片上的按钮同一套落点),右上角**导出 Markdown** 存成文件;历史还没读完时列表上方会出现 **`加载更早的消息`**。入口有两个:右侧栏默认的**指南页**里有它的胶囊(点一下就在当前栏打开),或按 **`Alt+L`**(同样在设置里可改)。
|
|
170
|
-
9.
|
|
170
|
+
9. **侧边线程回流**:同一个页面底部是这条会话的侧边线程(默认一条主会话一条;早期按引用分线程的记录也会列在这里)——显示它最后问的那个问题与消息条数。**`带回主会话`** 把线程最后的「问题 + 答案」写成主会话输入框里的草稿(**不发送**,你自己按发送;输入框里已有的内容会保留,回流内容接在下面);**`导出 Markdown`** 把整条线程按顺序落成一个文件。没有线程时这一节只说明「这条会话还没有侧边线程」。
|
|
171
171
|
10. **回合级引用**:每条回合的尾部有 **引用提问 / 引用回答** 两个按钮——把整轮的提问或回答按原文取出,进同一个评论框(可改、可评论),确认后成为一条带坐标的引用。
|
|
172
172
|
11. **草稿里的引用**:输入框上方的 **`引用 ×N`** 条里,每条都能 `✎` 编辑(回填原引用与评论,旧芯片让位、新芯片原地插入)或 `×` 移除;刷新之后条、悬停卡与「打开文件」都还在。
|
|
173
173
|
12. **改这条引用**:悬停一张胶囊卡片,点 **`改这条引用`** —— 评论框把那条引用当初的**引用与评论都填回来**(各自一个输入框,都能改),你改完按 `⏎`(或点「添加到对话」)。发出的是一条**新的**引用:DSH 的消息是只追加的,没有编辑 API,所以「修改」只能这样表达。旧胶囊会立刻挂上 **`已修订`** 标记,它的卡片里显示改后的评论;连正文一起改过的,卡片里也显示改后的原文。标记按引用原文记在本机(`localStorage`),刷新后仍在。
|
|
@@ -295,7 +295,7 @@ transcript 侧的 `.dshq-cap` 胶囊:一颗胶囊同时装引用与评论,`
|
|
|
295
295
|
| 同类 | 它做什么 | 我们的取舍 |
|
|
296
296
|
| --- | --- | --- |
|
|
297
297
|
| `@changfenhuang/dsh-annotation`(3513/月,148★) | 最短路径「选中→批注→逐条回应」;文件预览也能批注 | 保留评论+引用同一颗胶囊,多出清单/侧线程/修订;它的低摩擦值得学 |
|
|
298
|
-
| `dsh-sidenote`(3752/月) | 原生侧栏 + 多侧聊 + 划选注释 + 结论回流/存为正式会话 |
|
|
298
|
+
| `dsh-sidenote`(3752/月) | 原生侧栏 + 多侧聊 + 划选注释 + 结论回流/存为正式会话 | 已做线程清单与「带回主会话」;默认就是内置隐藏线程(宿主半 + 内置 subagentchat 页 + 隐性上下文),fork 是退路 |
|
|
299
299
|
| `@kirkchinese/dsh-citeciter`(2028/月) | 每次划选一个持久只读 Topic + 草稿自动保存/多窗口合并 | 不做完整 Topic 系统;「新开线程」与草稿持久化已补 |
|
|
300
300
|
| `dsh-quote-annotate-pro`(2126/月) | 发送后引用块常显 3 行 + 自动编号 + 回合尾引用 | 坚持胶囊+悬停卡;回合尾引用已补,常显块不做默认 |
|
|
301
301
|
| `dsh-select-quote`(1212/月) | 待发批注按会话持久化,逐条编辑/删除/清空 | 聚合条已支持逐条编辑/移除、完整无障碍名与跨刷新恢复;删除优先用宿主 `consumeToken` 精确区间 |
|
|
@@ -306,7 +306,7 @@ transcript 侧的 `.dshq-cap` 胶囊:一颗胶囊同时装引用与评论,`
|
|
|
306
306
|
**2026-10-20 复扫补记**(同一份 6,084 条市场快照,补读上轮未收录的 11 款):
|
|
307
307
|
|
|
308
308
|
- `dsh-sidecard-ask`(3,217/月,10-10 上架):把「划词 → 独立子代理作答 → 侧边卡片」做成完整承载链(better-sidebar → 原生右栏/内置浮层兜底),说明侧边问答已是独立赛道;我们的侧边对话仍是引用附属,不扩成通用问答。
|
|
309
|
-
- `dsh-side-chat-plus`(1,920/月)与 `dsh-sidenote` 0.6
|
|
309
|
+
- `dsh-side-chat-plus`(1,920/月)与 `dsh-sidenote` 0.6:都把侧聊放进原生右栏;我们的「一条主会话一条线程 + 隐性上下文」仍是差异化,默认已改成宿主半创建的隐藏子会话 + 宿主内置 `subagentchat` 页(fork 与 better-sidebar 都是退路/增强)。
|
|
310
310
|
- `dsh-quote-reply`(GitHub 安装):把引用放到输入框上方的独立层,经 `systemPrompt.context` 注入、回合尾显示「引用(N)」;我们坚持把坐标写进消息文本,换取导出/审阅/跨刷新,不改架构。
|
|
311
311
|
- `dsh-selection-quote`(594/月)与 `dsh-xwide-annotation`(1,148/月):分别补了右键菜单/持续选区高亮与每条颜色/大纲;我们的清单已是大纲,颜色不是主线,右键菜单也不比现有浮层快。
|
|
312
312
|
- `dsh-code-quote`(776/月,粘贴 `路径:行` + 代码 → chip + pre-step 快照)与 `dsh-quote-to-agent`(引用分派子代理)是相邻赛道,不并入;`dsh-web-annotator`/`dsh-image-annotate` 进一步证明图片/网页批注应独立成插件。
|
|
@@ -317,7 +317,7 @@ transcript 侧的 `.dshq-cap` 胶囊:一颗胶囊同时装引用与评论,`
|
|
|
317
317
|
- **「引用 + 评论合成单一可交互胶囊」**:仍是本插件独有;`dsh-annotation` 证明「选中→批注→逐条回应」有需求,差异在我们的**胶囊 + 清单 + 线程**组合,而不是单点机制。
|
|
318
318
|
- **待发引用管理**:`dsh-select-quote` 的按会话持久化、逐条编辑/删除/清空是这一格的老大;本插件现在补齐了**逐条编辑**(`✎` 回填原引用与评论,旧芯片让位、新芯片原地插入)与**跨刷新恢复**(芯片索引落本机,宿主恢复草稿芯片后它们重新算是我们的引用),我们不提供「一键清空全部」——按条删除更不容易误伤,删除与编辑都在草稿上方的聚合条里做。
|
|
319
319
|
- **代码引用回跳**:`@u2a5f` 已经读源文件算精确行号,`dsh-annotation` 能对文件预览批注并恢复;我们的卡片/清单现在显示 `文件预览 · path#Lx-Ly` 并能打开预览定位起始行、标出被引的行,还差 Markdown 预览的读源行号(要声明 `remote`,风险大于收益,先不做)。
|
|
320
|
-
- **多侧线程/原生侧栏**:`dsh-sidenote`/`CiteCiter` 已把多 Topic
|
|
320
|
+
- **多侧线程/原生侧栏**:`dsh-sidenote`/`CiteCiter` 已把多 Topic、草稿保存做完整;我们现在是**一条主会话一条线程**(清单页列出全部线程),默认后端是**宿主半创建的隐藏子会话**(`origin: "subagent"` + 宿主内置 `subagentchat` 页,不出现在会话列表),fork 与 better-sidebar 是退路/增强。
|
|
321
321
|
- **图片/截图**:`dsh-selection-toolbar` 与 Kimi 桌面端已经支持;我们仍是纯文本引用。
|
|
322
322
|
- **「原地编辑已发送的批注」**:DSH 仍没有编辑已发送消息的 API(侦察确认 `editMessage`/`updateMessage`/`rewriteMessage` 全树 0 命中),本插件走**追加式修订**;`dsh-plugin-message-edit` 走的是另一条路(原地保存 + 分支重生成 + 版本时间线),两条路线不冲突。
|
|
323
323
|
|
|
@@ -326,7 +326,7 @@ transcript 侧的 `.dshq-cap` 胶囊:一颗胶囊同时装引用与评论,`
|
|
|
326
326
|
1. **语义而不是机制**:引用与评论是同一颗节点(对齐 Kimi 的 `quote` 节点 `{text, comment}`),一颗芯片 → 一个块 → 一颗胶囊 → 一张卡片 → 一次复制;机制(reference chip、DOM 增强)谁都能抄,这条链子不好抄。
|
|
327
327
|
2. **引用是带坐标的活对象**:`跳回原文` 在自己的会话面板里找行、按宿主的方式对齐到 24px,行被虚拟列表藏起来或没挂载都能找回来;坐标以「人可读来源头 + base64 尾巴」随消息走——前者让模型知道出处,后者让插件跨刷新回读。base64 确实占一点 prompt,这是用「不依赖本地表」换来的取舍。
|
|
328
328
|
3. **引用有下落**:清单页(搜索 / 跳转 / 导出 Markdown)+ 侧边线程回流(结论写回主会话草稿 / 导出线程),这两件事在同类里仍是少数。
|
|
329
|
-
4. **工程化**:
|
|
329
|
+
4. **工程化**:649 项离线断言(含真跑 `sessions.retain` / `setDraft` / `agents.create` 参数 / 下载链路)、每个宿主契约都在 README 里写明出处、每个版本都有可回读的 release。同类里做到同等断言规模的仍是少数。
|
|
330
330
|
|
|
331
331
|
反过来也要说清楚:**本插件不做的**——不做翻译/解释/改写/飞书投递类选区动作;不做图片/截图引用(**明确取舍**:图片引用要的是另一套胶囊/卡片/附件语义,是第二个产品的量级,塞进这里只会把「文本引用带坐标」这条主线冲淡;`dsh-selection-toolbar` 与 Kimi 桌面端已经做了,真要做建议独立成插件);不做完整 Topic 系统;不做常显大块引用。它只把「引用」这一件事做穿——但「做穿」包括发送后的管理、来源闭环和审阅,不只是浮层。
|
|
332
332
|
|
|
@@ -363,9 +363,9 @@ locale/zh.json 插件页卡片文案(中文)
|
|
|
363
363
|
locale/en.json 插件页卡片文案(英文)
|
|
364
364
|
test/check.mjs 离线自检(清单 + 组件契约)
|
|
365
365
|
test/dom.mjs 给离线自检用的最小 DOM / MutationObserver 垫片
|
|
366
|
-
test/capsule.mjs 离线自检(发送后胶囊 + 悬停卡片 + 跳回原文 + 键位 + 改这条引用 + 代码引用 + 文件路径打开 + 聊天代码块划选 + 选区保结构 + 回合级引用 + 逐条回应双语 + 草稿引用编辑/持久化 + 聚合条移除/无障碍 + 行范围标记 + 浮层不随座位失效,
|
|
366
|
+
test/capsule.mjs 离线自检(发送后胶囊 + 悬停卡片 + 跳回原文 + 键位 + 改这条引用 + 代码引用 + 文件路径打开 + 聊天代码块划选 + 选区保结构 + 回合级引用 + 逐条回应双语 + 草稿引用编辑/持久化 + 聚合条移除/无障碍 + 行范围标记 + 浮层不随座位失效,443 项断言)
|
|
367
367
|
test/panel.mjs 离线自检(引用清单:解析 / 搜索 / Markdown / 读取 / 导出 / 页面注册 + 文件路径打开 + 侧边线程回流 + 审阅工作台(状态 / 标签 / 批量)+ JSON 导出 + 本地索引,153 项断言)
|
|
368
|
-
test/host.mjs 离线自检(宿主半:标签 /
|
|
368
|
+
test/host.mjs 离线自检(宿主半:标签 / 隐性上下文种子(只有三件事、只带用户与助手的原话、8000 字上限、跳过注入上下文)/ 空日志兜底 / 继承切点与 descriptor 顺序 / agents.create 参数 / 探针 / 围栏 / 路由注册与卸载,41 项断言)
|
|
369
369
|
test/capsule.html 真浏览器里跑同一套胶囊断言(51 项,可选)
|
|
370
370
|
```
|
|
371
371
|
|
|
@@ -416,7 +416,7 @@ React 重渲染不会出现第二颗、引用变了就地重建、消息被删
|
|
|
416
416
|
修订不改写引用原文本身、清空引用正文时确认按钮禁用、改后的正文随新芯片发出;
|
|
417
417
|
**浮层不跟着座位生死**(`conversation.input.overlay` 没挂载也能选中出栏、挂上两个会话座位既不叠加也不丢监听、
|
|
418
418
|
座位不在时用输入框自己的 `actions` 落芯片、无处可插时给 toast 而不是静默返回、effect 卸载/重装后监听恰好一份);
|
|
419
|
-
(共
|
|
419
|
+
(共 443 项,含文件路径与逐条回应的线上形态、`parseCodeSource`/`fileAddressFor` 与「卡片/清单点击打开 session 文件并带上起始行」、**表格单元格 / 表格行 / 段落 / 列表项 / `<br>` 边界的引用高亮**、**输入框已有文字时引用置顶并将原文另起一行**、聊天代码块划选的语言探测与跨界降级、选区的链接/行内代码保真与纯文本回退、回合级引用的取文与坐标、英文逐条指令与「Quote N」徽章、草稿芯片索引的跨刷新恢复与聚合条编辑/移除、回复徽章的建立/重建/卸载与悬停卡片复用、原生 `quote-sidechat` 页签的注册 / 地址 / 标题 / retain-release、内置 subagentchat 的降级与复用,以及评论框两个输入区的自适应高度——引用正文那格曾经在被挂进栏之前就量高,读到 0 后被写成 `height:0`,首行因此被裁掉)。
|
|
420
420
|
`test/capsule.html` 是同一套断言的真浏览器版(51 项),用 `file://` 打开即可(页面会把结果写进标题与 `#probe-out`)。这一版修好了它**缺失的 `<body>` 标签**——脚本在 `<head>` 里执行时 `document.body` 为 null,这个页面以前在真浏览器里是直接坏的;并补上真 `Range` 的链接/行内代码断言(真浏览器的 `cloneContents()` 返回的是 DocumentFragment,不是元素——序列化器只认元素的话,链接在真浏览器里会退化成纯文本;离线桩现在也用真片段,不再放过这一类),另外补了 `CSS.highlights` 选区保持与关闭清理。
|
|
421
421
|
|
|
422
422
|
`test/panel.mjs` 单独跑引用清单页与侧边线程回流(153 项):只认**持久**的 `user/message`(`transient`、助手消息、`data: null` 都跳过)、
|
|
@@ -435,14 +435,14 @@ Markdown 的标题/编号/块引用/评论行/时间戳与空列表兜底、超
|
|
|
435
435
|
`bringThreadBack` 把结论写成**草稿**(原有草稿保留、空行隔开)、外壳没有 `setDraft` 时如实失败、没有线程时不乱写;
|
|
436
436
|
`exportThread` 的文件名与整条线程内容、以及 MIME 是 Markdown。
|
|
437
437
|
|
|
438
|
-
`test/host.mjs` 不需要浏览器:它直接 import 宿主半,用桩 `agents` / `agentPresets` / `sessionTitle` / `webServer` 驱动。断言包括:`Side: `
|
|
438
|
+
`test/host.mjs` 不需要浏览器:它直接 import 宿主半,用桩 `agents` / `agentPresets` / `sessionTitle` / `webServer` 驱动。断言包括:`Side: ` 标签的前缀与截断;种子固定三件事(`session/end-seed{inherited:true}` → 一条 `source.kind="session-context"` 的 `user/message` → `subagent/descriptor`)、`inheritedEventCount` 恒为 0、上下文字段只收用户与助手的原话(最新在后、8000 字上限、跳过 `runtime-context` 与工具事件)、空日志也有可读文案;descriptor 排在切点之后且 seq 连续;`agents.create` 收到 `origin: "subagent"` / `parentSession` / `isSeeded` / `inheritedEventCount` / 父会话当前模型路由 / preset setup / 清空继承 inbox / 固定标题;探针、409、503、403、405、404 与 `apply` 的注册/卸载全部有断言。
|
|
439
439
|
|
|
440
|
-
合计 **
|
|
440
|
+
合计 **649 组**(check 12 + capsule 443 + panel 153 + host 41),`npm test` 全绿。
|
|
441
441
|
|
|
442
442
|
## 已知限制
|
|
443
443
|
|
|
444
444
|
- **内置隐藏线程依赖宿主内部契约**(app.asar 0.2.1-rc.3 实测):宿主半 `lib/index.js` 用 `ctx.inject(["webServer"])` 注册 `POST /ui-quote/api/sidechat.start`,在宿主服务就绪后从父会话 `snapshotEvents()` 复制种子,再用 `ctx.get("agents").create({ meta: { parentSession, isSeeded: true, origin: "subagent", delegationDepth, agentPreset }, seed, inheritedEventCount, agentOptions, setup })` 建子会话;descriptor 为 `{version: 3, mode: "continuable", provider: "ui-quote", label}`,标题 `Side: <引用首行>`。`webServer` / `connection.requestRejection` / `agents` 缺失时探针失败,客户端退回 fork;`agents.create` 失败只影响这一条线程。
|
|
445
|
-
|
|
445
|
+
**种子不复制父会话日志,只带一条隐性上下文**:`seed` 固定三个事件——`session/end-seed { inherited: true }`(切点在下标 0)、一条 `source.kind = "session-context"` 的 `user/message`、以及 `subagent/descriptor`。那条消息里是主会话最近若干轮的**纯文本**(用户/助手、上限 8000 字,工具调用与注入上下文都跳过),并带一句「只作背景、别继续执行」的指令——所以模型仍有上下文,而侧聊界面只把它折叠成一行「上下文」,不会把整段主会话铺出来,也不再为复制整条日志付一遍 token。宿主读子会话身份时走 `subagent` 投影(最后一次 descriptor 生效),并在 `api-session-controller` 里要求 `identity.seq >= inheritedEventCount`,否则整个子会话的历史都打不开(`subagent/catalog-diagnostic` · "subagent descriptor is unavailable")——descriptor 必须排在切点之后,0.12.3-lab.26 就是栽在这一步。
|
|
446
446
|
**旧线程靠形状版本戳识别,不靠 `sessions.retain`**:retain 对这类坏线程**照样成功**(只有历史 RPC 会拒),所以客户端给新建的线程在 `dsh-client-ui-quote:v1:sidechat-target` 里盖 `v: 2`,只复用自己盖过章的线程;better-sidebar 的、旧版本的、清单里扫描发现的线程一律丢弃重建——点一次就自愈,代价是最多重建一条线程。隐藏线程是 `continuable` 子会话,**父会话在线时可继续**;父会话关闭后宿主内置 `subagentchat` 页按自己的规则只读,重新打开父会话即可继续。宿主升级改了 `agents.create` / 种子格式时,线程创建会失败并自动退回 `sessions.fork`,不会白屏或报错。
|
|
447
447
|
想优先用 dsh-better-sidebar(实测 0.28.0)的侧聊 UI,把 `sideChatBackend` 设成 `"better"`;它仍走自己的 `POST /sidebar/api/sidechat.start`,父会话未运行时会收到 409。两边共享 `Side: ` 标题约定,装不装都不会互相抢自动打开的焦点。
|
|
448
448
|
- **「改这条引用」是追加,不是原地编辑**:DSH 的 transcript 只追加(侦察确认全树没有 `editMessage` / `updateMessage` / `rewriteMessage` / 回收 API,只有**尚未进入会话**的队列条目可改)。所以修订发出一条新引用(评论、引用正文都可改),旧胶囊只挂 `已修订` 标记——标记存在**本机** `localStorage`(`dsh-client-ui-quote:v1:quote-revised`,按 **`ref + 引用原文`** 归一键、上限 60 条),同一句话从两个不同位置引用时互不影响;没有 ref 的旧引用仍按原文键读写。它不会跨标签页、跨设备同步,也不进入模型上下文;模型看到的是那条改后的新引用。正文改过后键仍是旧原文:`已修订` 挂在**旧**胶囊上(它的卡片里显示改后的原文与评论),改后的新胶囊不挂标。
|
|
@@ -481,7 +481,7 @@ Markdown 的标题/编号/块引用/评论行/时间戳与空列表兜底、超
|
|
|
481
481
|
- **Markdown 预览的行号**:要精修就得读源文件,而客户端必须声明 `remote` 才能拿到它;声明错会让 client bundle 在不匹配的宿主上**静默失效**(`dsh-quote-annotate` 上游就是这么死的)。所以先不声明——文件仍然打开,只是落在顶部而不是行上。
|
|
482
482
|
- **表格/列表/LaTeX 的结构保真**:链接与行内代码已做;表格要还原成 Markdown 表格,列表要还原成缩进,成本明显更高,留到有人真的需要时再做。
|
|
483
483
|
- **图片/截图引用**:明确不做(理由见「本插件不做的」)。真要做请独立成插件:图片引用要的是另一套胶囊/卡片/附件语义。
|
|
484
|
-
- **真机验证**:这一轮的宿主契约全部来自 `app.asar` 的一手文档,功能路径有
|
|
484
|
+
- **真机验证**:这一轮的宿主契约全部来自 `app.asar` 的一手文档,功能路径有 649 项离线断言;隐藏线程的 `agents.create` / 种子格式已在**隔离的 DSH web 实例**(临时 `DSH_HOME`,端口 8791,CLI 0.2.1-rc.3)里确认:宿主半通过 `ctx.inject(["webServer"])` 晚到注册,`POST /ui-quote/api/sidechat.start` 的探针返回 `{"ok":true,"value":{"available":true,"version":1}}`,说明 `agents` 服务与同源围栏都就绪。本轮又在**运行中的桌面端**(link 模式,重启后加载 `lib/index.js`)核对过一次:`/ui-quote/api/*` 下任意路径都先被围栏拦成 `403 {"code":"forbidden"}`,而同样方法打一个不存在的顶层路径是 `404`,说明前缀路由确实挂上了、并且先过围栏。原生侧聊页签的 `SessionProvider` / `conversation.content` 嵌入契约还需要在真实窗口里重开一次侧边对话确认(离线桩只覆盖注册、地址、标题与 retain/release,不渲染宿主组件)。真浏览器(1600×2000 视口)同一套断言本轮复核 **51/51**。欢迎页的交互点击被安全闸门拦下,因此下面五条仍需在真实窗口逐项点:聚合条编辑、回合尾的两个按钮、打开文件与行范围标记、另开侧边对话、JSON 导出。
|
|
485
485
|
- **市场收录与首屏**:README 已有截图、徽章、「30 秒上手」与 24 秒静态轮播 GIF;还差把插件提交到 dsh.market / dsh-plugin.org / dsh.pub / awesome-deepseek-harness —— 这些需要外部账号,是人工动作。
|
|
486
486
|
|
|
487
487
|
## 变更记录
|
package/lib/client.js
CHANGED
|
@@ -501,13 +501,12 @@ window.__ModuleLoader__.load({
|
|
|
501
501
|
const SIDECHAT_TARGET_KEY = "dsh-client-ui-quote:v1:sidechat-target";
|
|
502
502
|
const SIDECHAT_TARGET_LIMIT = 200;
|
|
503
503
|
/**
|
|
504
|
-
* Shape version stamped on a hidden thread this build minted. Version
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
* the next click instead of reopened.
|
|
504
|
+
* Shape version stamped on a hidden thread this build minted. Version 3
|
|
505
|
+
* opens with one injected-context message instead of a copy of the parent
|
|
506
|
+
* log; version 2 copied the log and painted the whole main conversation
|
|
507
|
+
* into the thread. Only the current version is ever reused.
|
|
509
508
|
*/
|
|
510
|
-
const HIDDEN_THREAD_VERSION =
|
|
509
|
+
const HIDDEN_THREAD_VERSION = 3;
|
|
511
510
|
const SIDECHAT_PROBE_MS = 1500;
|
|
512
511
|
const SIDECHAT_START_MS = 15000;
|
|
513
512
|
/**
|
|
@@ -4886,7 +4885,6 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
|
|
4886
4885
|
if (action === "comment") openComment();
|
|
4887
4886
|
else if (action === "add") commit("");
|
|
4888
4887
|
else if (action === "sidechat") startSideChat();
|
|
4889
|
-
else if (action === "sidechatNew") startSideChat("new");
|
|
4890
4888
|
else if (action === "cancel") close();
|
|
4891
4889
|
else if (action === "confirm") commit(state.comment);
|
|
4892
4890
|
}
|
|
@@ -4931,14 +4929,12 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
|
|
4931
4929
|
row(doc, "comment", ICON_MESSAGE, translate("comment"), "menuitem"),
|
|
4932
4930
|
row(doc, "add", ICON_PLUS, translate("addToChat"), "menuitem")
|
|
4933
4931
|
];
|
|
4934
|
-
// The third row is the plugin's own extension (Kimi ships two):
|
|
4935
|
-
//
|
|
4936
|
-
//
|
|
4932
|
+
// The third row is the plugin's own extension (Kimi ships two): one
|
|
4933
|
+
// side thread per main session, opened next to the quote. There is no
|
|
4934
|
+
// second "open another" row — a reader asking about a quote should not
|
|
4935
|
+
// have to choose between two identical-looking entries.
|
|
4937
4936
|
if (sideChatAvailable()) {
|
|
4938
4937
|
rows.push(row(doc, "sidechat", ICON_SIDEBAR, translate("sideChat"), "menuitem", "dshq-row-sidechat"));
|
|
4939
|
-
// 另开侧边对话 — a thread of its own for this quote, so two
|
|
4940
|
-
// questions about two quotes do not end up in one thread.
|
|
4941
|
-
rows.push(row(doc, "sidechatNew", ICON_SIDEBAR, translate("sideChatNew"), "menuitem", "dshq-row-sidechat dshq-row-sidechat-new"));
|
|
4942
4938
|
}
|
|
4943
4939
|
bar.replaceChildren(...rows);
|
|
4944
4940
|
}
|
|
@@ -5810,7 +5806,10 @@ box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
|
|
5810
5806
|
toast(translateFor(sessionId, "sideChatFailed"));
|
|
5811
5807
|
return true;
|
|
5812
5808
|
}
|
|
5813
|
-
|
|
5809
|
+
// No visible boundary line: the thread's own first message carries
|
|
5810
|
+
// the "background only" instruction, so the composer holds nothing
|
|
5811
|
+
// but the quote the reader is about to ask about.
|
|
5812
|
+
if (!(await prefillDraft(threadId, sideQuestion(text, code === true, path, lang)))) {
|
|
5814
5813
|
console.warn("[ui-quote] hidden sidechat draft not written:", threadId);
|
|
5815
5814
|
toast(translateFor(sessionId, "sideChatDraftFailed"));
|
|
5816
5815
|
}
|
package/lib/index.js
CHANGED
|
@@ -30,6 +30,8 @@ const PROVIDER = "ui-quote";
|
|
|
30
30
|
const BODY_LIMIT = 64 * 1024;
|
|
31
31
|
const CREATE_TIMEOUT_MS = 15000;
|
|
32
32
|
const PROBE_VERSION = 1;
|
|
33
|
+
/** Transcript budget for the child's one context message (characters). */
|
|
34
|
+
const CONTEXT_MAX_CHARS = 8000;
|
|
33
35
|
|
|
34
36
|
class SidechatError extends Error {
|
|
35
37
|
constructor(code, message, status) {
|
|
@@ -67,62 +69,6 @@ export function threadLabel(title) {
|
|
|
67
69
|
return THREAD_LABEL_PREFIX + clipped;
|
|
68
70
|
}
|
|
69
71
|
|
|
70
|
-
/** One event envelope copied without sharing the host's immutable object. */
|
|
71
|
-
export function copySeedEvent(event) {
|
|
72
|
-
const copy = { type: event.type, seq: event.seq, time: event.time, data: event.data };
|
|
73
|
-
if (event.surfaceOp !== undefined) copy.surfaceOp = event.surfaceOp;
|
|
74
|
-
if (event.sourceEventSeqs !== undefined) copy.sourceEventSeqs = event.sourceEventSeqs;
|
|
75
|
-
if (event.ignorable !== undefined) copy.ignorable = event.ignorable;
|
|
76
|
-
return copy;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function numberAt(data, key) {
|
|
80
|
-
const value = data === null || data === undefined ? undefined : data[key];
|
|
81
|
-
return typeof value === "number" && Number.isSafeInteger(value) ? value : 0;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** Index of the last turn boundary, or -1 when the log never opened a turn. */
|
|
85
|
-
function lastTurnBoundary(events) {
|
|
86
|
-
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
87
|
-
const type = events[index]?.type;
|
|
88
|
-
if (type === "turn/start" || type === "turn/end") return index;
|
|
89
|
-
}
|
|
90
|
-
return -1;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** The step number still open inside the turn starting at `turnStart`. */
|
|
94
|
-
function openStepOf(events, turnStart) {
|
|
95
|
-
let open;
|
|
96
|
-
for (let index = turnStart + 1; index < events.length; index += 1) {
|
|
97
|
-
const event = events[index];
|
|
98
|
-
if (event?.type === "step/start") open = numberAt(event.data, "step");
|
|
99
|
-
else if (event?.type === "step/end") open = undefined;
|
|
100
|
-
}
|
|
101
|
-
return open;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/** Whether the open turn ends with a tool call that never got its result. */
|
|
105
|
-
function hasDanglingToolCall(events, turnStart) {
|
|
106
|
-
const pending = new Set();
|
|
107
|
-
for (let index = turnStart + 1; index < events.length; index += 1) {
|
|
108
|
-
const event = events[index];
|
|
109
|
-
if (event?.type === "step/end") {
|
|
110
|
-
pending.clear();
|
|
111
|
-
continue;
|
|
112
|
-
}
|
|
113
|
-
if (event?.type === "tool/call") {
|
|
114
|
-
const callId = event.data?.callId;
|
|
115
|
-
if (typeof callId === "string" && callId !== "") pending.add(callId);
|
|
116
|
-
continue;
|
|
117
|
-
}
|
|
118
|
-
if (event?.type === "tool/result") {
|
|
119
|
-
const callId = event.data?.message?.source?.callId;
|
|
120
|
-
if (typeof callId === "string") pending.delete(callId);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
return pending.size > 0;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
72
|
/** The model route the parent currently requests with, from its own log. */
|
|
127
73
|
export function parentSelection(events) {
|
|
128
74
|
let pending;
|
|
@@ -172,58 +118,97 @@ function descriptorOf(options) {
|
|
|
172
118
|
};
|
|
173
119
|
}
|
|
174
120
|
|
|
121
|
+
/** The plain text one message event carries, or an empty string. */
|
|
122
|
+
function messageTextOf(event) {
|
|
123
|
+
const content = event?.type === "user/message" ? event.data?.content : event.data?.message?.content;
|
|
124
|
+
if (!Array.isArray(content)) return "";
|
|
125
|
+
const parts = [];
|
|
126
|
+
for (const block of content) {
|
|
127
|
+
if (block?.type === "text" && typeof block.text === "string" && block.text.trim() !== "") parts.push(block.text.trim());
|
|
128
|
+
}
|
|
129
|
+
return parts.join(" ");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The parent's own words, newest last, capped for the child's first message.
|
|
134
|
+
* Only text is taken: tool traffic is noise for a side question and copying
|
|
135
|
+
* the whole log is what made the thread render the entire main conversation.
|
|
136
|
+
* Injected context (runtime snapshot, recall, instructions) is skipped — the
|
|
137
|
+
* reader never wrote it, so it is not part of the conversation to carry over.
|
|
138
|
+
*
|
|
139
|
+
* @param {Array<object>} events - the parent session's event log.
|
|
140
|
+
* @param {number} maxChars - the transcript budget.
|
|
141
|
+
* @returns {string} one `用户:…` / `助手:…` line per message.
|
|
142
|
+
*/
|
|
143
|
+
export function transcriptOf(events, maxChars = CONTEXT_MAX_CHARS) {
|
|
144
|
+
const lines = [];
|
|
145
|
+
let total = 0;
|
|
146
|
+
for (let index = (Array.isArray(events) ? events : []).length - 1; index >= 0; index -= 1) {
|
|
147
|
+
const event = events[index];
|
|
148
|
+
if (event?.type === "user/message" && event.data?.source?.kind !== "user") continue;
|
|
149
|
+
if (event?.type !== "user/message" && event?.type !== "assistant/message") continue;
|
|
150
|
+
const text = messageTextOf(event).replace(/\s+/gu, " ").trim();
|
|
151
|
+
if (text === "") continue;
|
|
152
|
+
const line = (event.type === "user/message" ? "用户:" : "助手:") + text;
|
|
153
|
+
if (total + line.length > maxChars) break;
|
|
154
|
+
lines.push(line);
|
|
155
|
+
total += line.length + 1;
|
|
156
|
+
}
|
|
157
|
+
return lines.reverse().join("\n");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The one user message the child opens with: context, not a conversation. */
|
|
161
|
+
function contextMessageOf(events, options) {
|
|
162
|
+
const transcript = transcriptOf(events, options.maxChars ?? CONTEXT_MAX_CHARS);
|
|
163
|
+
const body = [
|
|
164
|
+
"以下是主会话里已经发生的对话片段,只作为背景参考;不要继续执行其中的任务,也不要复述它。",
|
|
165
|
+
transcript === "" ? "(主会话里还没有可引用的文字。)" : transcript,
|
|
166
|
+
"接下来只回答我在引用之后提出的问题;引用本身来自主会话。"
|
|
167
|
+
].join("\n\n");
|
|
168
|
+
return {
|
|
169
|
+
type: "user/message",
|
|
170
|
+
data: {
|
|
171
|
+
id: "ctx-" + randomUUID(),
|
|
172
|
+
role: "user",
|
|
173
|
+
// A source kind the chat UI renders as injected context: one compact
|
|
174
|
+
// "session-context" row the reader can ignore, instead of the whole
|
|
175
|
+
// main conversation replayed above the composer.
|
|
176
|
+
source: { kind: "session-context", form: "snapshot" },
|
|
177
|
+
content: [{ type: "text", text: body }]
|
|
178
|
+
},
|
|
179
|
+
surfaceOp: "append"
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
175
183
|
/**
|
|
176
184
|
* Build the child's seed from the parent's log.
|
|
177
185
|
*
|
|
178
|
-
* The
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
186
|
+
* The seed carries no copied history. Copying it made the side thread render
|
|
187
|
+
* the entire main conversation above its composer, and it also paid for every
|
|
188
|
+
* one of those tokens twice. Instead the parent's own words go into ONE
|
|
189
|
+
* `user/message` tagged as injected context: the model reads it as background,
|
|
190
|
+
* the chat UI folds it into a single context row, and the reader sees only the
|
|
191
|
+
* quote draft they are about to ask about.
|
|
182
192
|
*
|
|
183
|
-
* The
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
* from its `subagent` projection and rejects any descriptor whose `seq` is
|
|
188
|
-
* below the cut ("subagent descriptor is unavailable"). Marking the cut also
|
|
189
|
-
* keeps a nested side thread honest — the parent's own descriptor stays in
|
|
190
|
-
* the inherited prefix while ours is the last one the projection folds.
|
|
193
|
+
* The seed still marks its inherited cut first: the host folds a child's
|
|
194
|
+
* identity from `subagent/descriptor` and rejects any descriptor whose `seq`
|
|
195
|
+
* is below `inheritedEventCount` ("subagent descriptor is unavailable"), so
|
|
196
|
+
* the marker sits at index 0 and everything the child owns follows it.
|
|
191
197
|
*
|
|
192
198
|
* @param {Array<object>} events - the parent session's event log.
|
|
193
|
-
* @param {object} options -
|
|
199
|
+
* @param {object} options - label, model route, transcript budget, clock.
|
|
194
200
|
* @returns {{seed: Array<object>, inheritedEventCount: number}} the seed,
|
|
195
|
-
* seq-contiguous from 0, plus the index of its inherited cut.
|
|
201
|
+
* seq-contiguous from 0, plus the index of its inherited cut (always 0).
|
|
196
202
|
*/
|
|
197
203
|
export function buildThreadSeed(events, options = {}) {
|
|
198
|
-
const source = Array.isArray(events) ? events : [];
|
|
199
|
-
for (let index = 0; index < source.length; index += 1) {
|
|
200
|
-
if (source[index] === null || source[index] === undefined || source[index].seq !== index) {
|
|
201
|
-
throw new SidechatError("bad-session", "the parent session log is not contiguous", 400);
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
204
|
const now = options.now ?? Date.now();
|
|
205
|
-
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
while (seed.length > 0 && seed[seed.length - 1]?.type === "agent/inbox/spliced") seed.pop();
|
|
213
|
-
} else {
|
|
214
|
-
const step = openStepOf(seed, boundary);
|
|
215
|
-
if (step !== undefined) closers.push({ type: "step/end", time: now, data: { turn, step } });
|
|
216
|
-
closers.push({ type: "turn/end", time: now, data: { turn, reason: { kind: "interrupted" } } });
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
const inheritedEventCount = seed.length;
|
|
220
|
-
seed.push({ type: "session/end-seed", seq: inheritedEventCount, time: now, data: { inherited: true } });
|
|
221
|
-
for (const closer of closers) seed.push({ ...closer, seq: seed.length });
|
|
222
|
-
if (typeof options.cwd === "string" && options.cwd !== "") {
|
|
223
|
-
seed.push({ type: "working-directory/change", seq: seed.length, time: now, data: { cwd: options.cwd } });
|
|
224
|
-
}
|
|
225
|
-
seed.push({ type: "subagent/descriptor", seq: seed.length, time: now, data: descriptorOf(options) });
|
|
226
|
-
return { seed, inheritedEventCount };
|
|
205
|
+
const context = contextMessageOf(Array.isArray(events) ? events : [], options);
|
|
206
|
+
const seed = [
|
|
207
|
+
{ type: "session/end-seed", seq: 0, time: now, data: { inherited: true } },
|
|
208
|
+
{ ...context, seq: 1, time: now },
|
|
209
|
+
{ type: "subagent/descriptor", seq: 2, time: now, data: descriptorOf(options) }
|
|
210
|
+
];
|
|
211
|
+
return { seed, inheritedEventCount: 0 };
|
|
227
212
|
}
|
|
228
213
|
|
|
229
214
|
/** The host's preset composition for the child (a no-op without the service). */
|
|
@@ -514,7 +499,7 @@ export const __testing = {
|
|
|
514
499
|
START_METHOD,
|
|
515
500
|
SidechatError,
|
|
516
501
|
threadLabel,
|
|
517
|
-
|
|
502
|
+
transcriptOf,
|
|
518
503
|
buildThreadSeed,
|
|
519
504
|
parentSelection,
|
|
520
505
|
presetOf,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-client-ui-quote",
|
|
3
|
-
"version": "0.12.3-lab.
|
|
3
|
+
"version": "0.12.3-lab.30",
|
|
4
4
|
"description": "Turn any DSH selection into a reviewable quote — comment / add to chat / side chat, source highlights and jump-back, a searchable review list, side threads, revisions, and per-quote reply badges.",
|
|
5
5
|
"icon": "icon.svg",
|
|
6
6
|
"license": "MIT",
|
package/test/capsule.mjs
CHANGED
|
@@ -952,8 +952,8 @@ try {
|
|
|
952
952
|
JSON.stringify(openedResources)
|
|
953
953
|
);
|
|
954
954
|
check(
|
|
955
|
-
"the hidden draft
|
|
956
|
-
hiddenState.draft.startsWith("
|
|
955
|
+
"the hidden draft holds only the quote, not a boundary preamble",
|
|
956
|
+
hiddenState.draft.startsWith("> 隐藏引用") && hiddenState.draft.includes("Side conversation boundary.") === false,
|
|
957
957
|
JSON.stringify(hiddenState.draft)
|
|
958
958
|
);
|
|
959
959
|
check(
|
|
@@ -983,8 +983,8 @@ try {
|
|
|
983
983
|
JSON.stringify(hiddenState.draft)
|
|
984
984
|
);
|
|
985
985
|
check(
|
|
986
|
-
"
|
|
987
|
-
hiddenState.draft.
|
|
986
|
+
"no boundary text is ever written into the thread's composer",
|
|
987
|
+
hiddenState.draft.includes("Side conversation boundary.") === false,
|
|
988
988
|
JSON.stringify(hiddenState.draft)
|
|
989
989
|
);
|
|
990
990
|
|
|
@@ -2241,6 +2241,13 @@ try {
|
|
|
2241
2241
|
"the bar offers the actions",
|
|
2242
2242
|
barOf()?.querySelector('[data-action="add"]') !== null && barOf()?.querySelector('[data-action="comment"]') !== null
|
|
2243
2243
|
);
|
|
2244
|
+
check(
|
|
2245
|
+
"the side chat is one row, never two competing entries",
|
|
2246
|
+
barOf()?.querySelector('[data-action="sidechatNew"]') === null &&
|
|
2247
|
+
barOf()?.querySelector(".dshq-row-sidechat-new") === null &&
|
|
2248
|
+
(barOf()?.querySelectorAll('[data-action^="sidechat"]') ?? []).length <= 1,
|
|
2249
|
+
JSON.stringify((barOf()?.querySelectorAll('[data-action^="sidechat"]') ?? []).map((row) => row.getAttribute("data-action")))
|
|
2250
|
+
);
|
|
2244
2251
|
|
|
2245
2252
|
// 2. A slot comes and goes — the listeners are not tied to it.
|
|
2246
2253
|
const overlay = registered.find((entry) => entry.spec.name === "conversation.input.overlay");
|
package/test/host.mjs
CHANGED
|
@@ -26,7 +26,7 @@ function check(name, ok, detail) {
|
|
|
26
26
|
return false;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
const { buildThreadSeed, threadLabel, parentSelection, presetOf, startSideChat, handleSideChatRequest } = __testing;
|
|
29
|
+
const { buildThreadSeed, transcriptOf, threadLabel, parentSelection, presetOf, startSideChat, handleSideChatRequest } = __testing;
|
|
30
30
|
|
|
31
31
|
// ---- labels and the parent's route ---------------------------------------
|
|
32
32
|
check("a label carries the shared side-thread prefix", threadLabel(" 第一段\n引用 ") === "Side: 第一段 引用", threadLabel(" 第一段\n引用 "));
|
|
@@ -55,38 +55,89 @@ check(
|
|
|
55
55
|
check("a header preset is the fallback", presetOf({ agentPreset: "header" }, []) === "header");
|
|
56
56
|
|
|
57
57
|
// ---- the seed ------------------------------------------------------------
|
|
58
|
+
// A side thread opens with ONE injected-context message, not a copy of the
|
|
59
|
+
// parent log: the chat UI folds that into a single context row, and the model
|
|
60
|
+
// still reads the parent's own words as background.
|
|
58
61
|
const closedLog = [
|
|
59
62
|
{ type: "turn/start", seq: 0, time: 1, data: { turn: 1 } },
|
|
60
63
|
{ type: "step/start", seq: 1, time: 2, data: { turn: 1, step: 1 } },
|
|
61
|
-
{ type: "user/message", seq: 2, time: 3, data: { content: [{ type: "text", text: "
|
|
62
|
-
{ type: "assistant/message", seq: 3, time: 4, data: { message: { content: [{ type: "text", text: "
|
|
64
|
+
{ type: "user/message", seq: 2, time: 3, data: { id: "m1", role: "user", source: { kind: "user" }, content: [{ type: "text", text: "第一个问题" }] }, surfaceOp: "append" },
|
|
65
|
+
{ type: "assistant/message", seq: 3, time: 4, data: { message: { id: "m2", role: "assistant", source: { kind: "model", provider: "p", model: "m" }, content: [{ type: "text", text: "第一个回答" }] } }, surfaceOp: "append" },
|
|
63
66
|
{ type: "step/end", seq: 4, time: 5, data: { turn: 1, step: 1 } },
|
|
64
|
-
{ type: "turn/end", seq: 5, time: 6, data: { turn: 1, reason: { kind: "done" } } }
|
|
67
|
+
{ type: "turn/end", seq: 5, time: 6, data: { turn: 1, reason: { kind: "done" } } },
|
|
68
|
+
{ type: "user/message", seq: 6, time: 7, data: { id: "m3", role: "user", source: { kind: "runtime-context", form: "snapshot" }, content: [{ type: "text", text: "Current runtime context. 不该出现在侧聊里" }] }, surfaceOp: "append" },
|
|
69
|
+
{ type: "tool/call", seq: 7, time: 8, data: { callId: "c1", name: "read" } },
|
|
70
|
+
{ type: "user/message", seq: 8, time: 9, data: { id: "m4", role: "user", source: { kind: "user" }, content: [{ type: "text", text: "最后一句问题" }] }, surfaceOp: "append" }
|
|
65
71
|
];
|
|
66
|
-
const { seed: plainSeed, inheritedEventCount: plainCut } = buildThreadSeed(closedLog, {
|
|
72
|
+
const { seed: plainSeed, inheritedEventCount: plainCut } = buildThreadSeed(closedLog, { label: "Side: 第一段", now: 100, agentProvider: "p", agentModel: "m", agentReasoningEffort: "high" });
|
|
67
73
|
check(
|
|
68
|
-
"
|
|
69
|
-
|
|
70
|
-
|
|
74
|
+
"the seed carries no copied history",
|
|
75
|
+
plainSeed.length === 3 &&
|
|
76
|
+
plainSeed.map((event) => event.type).join(",") === "session/end-seed,user/message,subagent/descriptor",
|
|
77
|
+
plainSeed.map((event) => event.type).join(",")
|
|
71
78
|
);
|
|
72
79
|
check(
|
|
73
|
-
"the
|
|
74
|
-
plainCut ===
|
|
75
|
-
|
|
76
|
-
plainSeed[plainCut].data.inherited === true,
|
|
77
|
-
JSON.stringify({ cut: plainCut, marker: plainSeed[plainCut] })
|
|
80
|
+
"the inherited cut is the empty prefix at zero",
|
|
81
|
+
plainCut === 0 && plainSeed[0].type === "session/end-seed" && plainSeed[0].data.inherited === true,
|
|
82
|
+
JSON.stringify({ cut: plainCut, marker: plainSeed[0] })
|
|
78
83
|
);
|
|
84
|
+
const contextEvent = plainSeed[1];
|
|
85
|
+
const contextText = contextEvent?.data?.content?.[0]?.text ?? "";
|
|
79
86
|
check(
|
|
80
|
-
"the
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
87
|
+
"the one message is injected context, not a user prompt",
|
|
88
|
+
contextEvent?.type === "user/message" &&
|
|
89
|
+
contextEvent.surfaceOp === "append" &&
|
|
90
|
+
contextEvent.data.role === "user" &&
|
|
91
|
+
contextEvent.data.source.kind === "session-context" &&
|
|
92
|
+
typeof contextEvent.data.id === "string" &&
|
|
93
|
+
contextEvent.data.id.startsWith("ctx-"),
|
|
94
|
+
JSON.stringify(contextEvent?.data?.source)
|
|
95
|
+
);
|
|
96
|
+
check(
|
|
97
|
+
"the context carries the parent's own words, newest last",
|
|
98
|
+
contextText.includes("用户:第一个问题") &&
|
|
99
|
+
contextText.includes("助手:第一个回答") &&
|
|
100
|
+
contextText.indexOf("第一个问题") < contextText.indexOf("最后一句问题") &&
|
|
101
|
+
contextText.trimEnd().endsWith("接下来只回答我在引用之后提出的问题;引用本身来自主会话。"),
|
|
102
|
+
contextText.slice(0, 240)
|
|
103
|
+
);
|
|
104
|
+
check(
|
|
105
|
+
"injected context and tool traffic stay out of the transcript",
|
|
106
|
+
!contextText.includes("Current runtime context") && !contextText.includes("read"),
|
|
107
|
+
contextText.slice(0, 240)
|
|
108
|
+
);
|
|
109
|
+
check(
|
|
110
|
+
"the transcript is capped from the newest end",
|
|
111
|
+
(() => {
|
|
112
|
+
const long = Array.from({ length: 40 }, (_, index) => ({
|
|
113
|
+
type: "user/message",
|
|
114
|
+
seq: index,
|
|
115
|
+
time: index,
|
|
116
|
+
data: { id: "m" + index, role: "user", source: { kind: "user" }, content: [{ type: "text", text: "第" + index + "条" + "x".repeat(50) }] },
|
|
117
|
+
surfaceOp: "append"
|
|
118
|
+
}));
|
|
119
|
+
const text = transcriptOf(long, 400);
|
|
120
|
+
return text.length <= 400 && text.includes("第39条") && !text.includes("第0条");
|
|
121
|
+
})(),
|
|
122
|
+
"bounded"
|
|
123
|
+
);
|
|
124
|
+
check(
|
|
125
|
+
"an empty parent still yields a usable context",
|
|
126
|
+
(() => {
|
|
127
|
+
const { seed } = buildThreadSeed([], { label: "Side: 空" });
|
|
128
|
+
const text = seed[1].data.content[0].text;
|
|
129
|
+
return text.includes("还没有可引用的文字") && seed.length === 3;
|
|
130
|
+
})(),
|
|
131
|
+
"ok"
|
|
132
|
+
);
|
|
133
|
+
check(
|
|
134
|
+
"the descriptor is last and names the quote and our provider",
|
|
135
|
+
plainSeed[2].type === "subagent/descriptor" &&
|
|
136
|
+
plainSeed[2].data.version === 3 &&
|
|
137
|
+
plainSeed[2].data.mode === "continuable" &&
|
|
138
|
+
plainSeed[2].data.provider === "ui-quote" &&
|
|
139
|
+
plainSeed[2].data.label === "Side: 第一段",
|
|
140
|
+
JSON.stringify(plainSeed[2].data)
|
|
90
141
|
);
|
|
91
142
|
check(
|
|
92
143
|
"the descriptor sits after the inherited cut, which the host validates",
|
|
@@ -95,10 +146,10 @@ check(
|
|
|
95
146
|
);
|
|
96
147
|
check(
|
|
97
148
|
"the descriptor carries the parent's route",
|
|
98
|
-
plainSeed[
|
|
99
|
-
plainSeed[
|
|
100
|
-
plainSeed[
|
|
101
|
-
JSON.stringify(plainSeed[
|
|
149
|
+
plainSeed[2].data.agentProvider === "p" &&
|
|
150
|
+
plainSeed[2].data.agentModel === "m" &&
|
|
151
|
+
plainSeed[2].data.agentReasoningEffort === "high",
|
|
152
|
+
JSON.stringify(plainSeed[2].data)
|
|
102
153
|
);
|
|
103
154
|
check(
|
|
104
155
|
"the seed's sequence stays contiguous from zero",
|
|
@@ -106,70 +157,22 @@ check(
|
|
|
106
157
|
plainSeed.map((event) => event.seq).join(",")
|
|
107
158
|
);
|
|
108
159
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
check(
|
|
112
|
-
"an open step is closed after the inherited cut",
|
|
113
|
-
openCut === openLog.length &&
|
|
114
|
-
openSeed.length === openLog.length + 4 &&
|
|
115
|
-
openSeed[openCut].type === "session/end-seed" &&
|
|
116
|
-
openSeed[5].type === "step/end" &&
|
|
117
|
-
openSeed[5].data.turn === 1 &&
|
|
118
|
-
openSeed[5].data.step === 1 &&
|
|
119
|
-
openSeed[6].type === "turn/end" &&
|
|
120
|
-
openSeed[6].data.reason.kind === "interrupted" &&
|
|
121
|
-
openSeed[7].type === "subagent/descriptor",
|
|
122
|
-
JSON.stringify(openSeed.slice(openLog.length))
|
|
123
|
-
);
|
|
124
|
-
|
|
125
|
-
const danglingLog = closedLog.concat([
|
|
126
|
-
{ type: "turn/start", seq: 6, time: 7, data: { turn: 2 } },
|
|
127
|
-
{ type: "step/start", seq: 7, time: 8, data: { turn: 2, step: 1 } },
|
|
128
|
-
{ type: "tool/call", seq: 8, time: 9, data: { callId: "call-1", name: "read" } },
|
|
129
|
-
{ type: "agent/inbox/spliced", seq: 9, time: 10, data: { target: "next-turn", start: 0, inserted: [] } }
|
|
130
|
-
]);
|
|
131
|
-
const { seed: danglingSeed, inheritedEventCount: danglingCut } = buildThreadSeed(danglingLog, { label: "Side: 悬挂", now: 60 });
|
|
132
|
-
check(
|
|
133
|
-
"a dangling tool call cuts the open turn, not the whole log",
|
|
134
|
-
danglingCut === closedLog.length &&
|
|
135
|
-
danglingSeed.length === closedLog.length + 2 &&
|
|
136
|
-
danglingSeed[danglingCut].type === "session/end-seed" &&
|
|
137
|
-
danglingSeed[closedLog.length + 1].type === "subagent/descriptor" &&
|
|
138
|
-
danglingSeed.every((event, index) => event.seq === index),
|
|
139
|
-
JSON.stringify(danglingSeed.slice(closedLog.length))
|
|
140
|
-
);
|
|
141
|
-
check(
|
|
142
|
-
"the trailing inbox splice leaves with the cut turn",
|
|
143
|
-
danglingSeed.every((event) => event.type !== "agent/inbox/spliced") && danglingSeed.some((event) => event.type === "turn/end"),
|
|
144
|
-
danglingSeed.map((event) => event.type).join(",")
|
|
145
|
-
);
|
|
146
|
-
|
|
160
|
+
// The parent may itself be a side thread: no descriptor is copied either way,
|
|
161
|
+
// so a nested thread is the same three events.
|
|
147
162
|
const nestedLog = closedLog.concat([
|
|
148
|
-
{ type: "subagent/descriptor", seq:
|
|
163
|
+
{ type: "subagent/descriptor", seq: 9, time: 10, data: { version: 3, mode: "continuable", provider: "sidechat", label: "Side: parent" } }
|
|
149
164
|
]);
|
|
150
165
|
const { seed: nestedSeed, inheritedEventCount: nestedCut } = buildThreadSeed(nestedLog, { label: "Side: child", now: 70 });
|
|
151
166
|
check(
|
|
152
|
-
"a nested
|
|
153
|
-
nestedCut ===
|
|
154
|
-
nestedSeed
|
|
155
|
-
nestedSeed.
|
|
156
|
-
nestedSeed.
|
|
157
|
-
nestedSeed
|
|
158
|
-
|
|
159
|
-
JSON.stringify(nestedSeed.slice(nestedLog.length))
|
|
167
|
+
"a nested parent yields the same three-event seed",
|
|
168
|
+
nestedCut === 0 &&
|
|
169
|
+
nestedSeed.length === 3 &&
|
|
170
|
+
nestedSeed.filter((event) => event.type === "subagent/descriptor").length === 1 &&
|
|
171
|
+
nestedSeed[2].data.label === "Side: child" &&
|
|
172
|
+
!nestedSeed[1].data.content[0].text.includes("Side: parent"),
|
|
173
|
+
JSON.stringify(nestedSeed.map((event) => event.type))
|
|
160
174
|
);
|
|
161
175
|
|
|
162
|
-
let gapError = null;
|
|
163
|
-
try {
|
|
164
|
-
buildThreadSeed([
|
|
165
|
-
{ type: "turn/start", seq: 0, time: 1, data: { turn: 1 } },
|
|
166
|
-
{ type: "turn/end", seq: 2, time: 2, data: { turn: 1 } }
|
|
167
|
-
], { label: "Side: gap" });
|
|
168
|
-
} catch (error) {
|
|
169
|
-
gapError = error;
|
|
170
|
-
}
|
|
171
|
-
check("a windowed log is refused instead of mis-seeded", gapError !== null && gapError.status === 400, gapError === null ? "no error" : String(gapError.status));
|
|
172
|
-
|
|
173
176
|
// ---- creating one hidden child ------------------------------------------
|
|
174
177
|
const parentEvents = closedLog.concat([
|
|
175
178
|
{ type: "model/selection", seq: 6, time: 7, data: { provider: "route", model: "m-route", reasoningEffort: "low" } }
|
|
@@ -239,7 +242,9 @@ check(
|
|
|
239
242
|
);
|
|
240
243
|
check(
|
|
241
244
|
"the inherited count is the marked cut, not the whole seed",
|
|
242
|
-
options.inheritedEventCount ===
|
|
245
|
+
options.inheritedEventCount === 0 &&
|
|
246
|
+
options.seed[0].type === "session/end-seed" &&
|
|
247
|
+
options.seed[0].data.inherited === true &&
|
|
243
248
|
options.inheritedEventCount < options.seed.length &&
|
|
244
249
|
options.seed.every((event, index) => event.seq === index),
|
|
245
250
|
String(options.inheritedEventCount)
|