@mzzsfy/dsh-context-manager 0.1.0 → 0.2.1
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/README.md +18 -12
- package/package.json +2 -2
- package/src/client.js +93 -143
- package/src/core.mjs +0 -54
- package/src/index.js +31 -2
- package/test/client-copy-sid.test.mjs +252 -0
- package/test/client-help.test.mjs +0 -1
- package/test/index.test.mjs +20 -4
- package/test/client-fork-family.test.mjs +0 -48
- package/test/core-family.test.mjs +0 -122
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# dsh-context-manager
|
|
2
2
|
|
|
3
|
-
对话增强插件:历史输入回溯、插话撤回、对话分叉(
|
|
3
|
+
对话增强插件:历史输入回溯、插话撤回、对话分叉(进行中轮分叉/自动重发)、标题栏复制 sessionId。dsh-session-manager 历史输入功能的独立延续包(原包已移除该功能)。
|
|
4
4
|
|
|
5
5
|
## 功能
|
|
6
6
|
|
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
|
|
9
9
|
输入框按 `Alt+↑` 唤起历史输入浮层:
|
|
10
10
|
|
|
11
|
-
- 范围导航:`←/→`
|
|
11
|
+
- 范围导航:`←/→` 按 常用(prompts)→ 当前会话(session)→ 本工作区(workspace)→ 全部工作区(global) 依次切换(边界停住),列表时间倒序
|
|
12
12
|
- 搜索:输入即时过滤(IME 组合输入守卫,命中计数),`↑/↓` 选中、`Enter` 回填
|
|
13
|
-
- 回填经宿主公共契约 `inputActions.setDraft`;`Esc` 两级退出(
|
|
14
|
-
-
|
|
13
|
+
- 回填经宿主公共契约 `inputActions.setDraft`;`Esc` 两级退出(搜索词非空先清词,再按关闭浮层)
|
|
14
|
+
- 列表行悬停星标收藏/取消常用提示词;常用范围内点「编辑」进入编辑态(重命名/删除/新增),数据落全局 `prompts.json`,与自动历史互不覆写
|
|
15
15
|
|
|
16
16
|
数据链:Host 聚合工作区缓存(`~/.dsh/historyPrompt/`,产物解压 + stat 指纹增量对齐)→ `/api/context/inputs` → 浮层。
|
|
17
17
|
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
### 对话分叉(fork,重写式)
|
|
23
23
|
|
|
24
|
-
用户输入气泡操作排的分叉按钮:分叉出新会话到该轮之前(该轮不带入子会话消息流),该轮的用户输入自动回填子会话输入框供编辑重发——与插话撤回同构,作用于 fork
|
|
24
|
+
用户输入气泡操作排的分叉按钮:分叉出新会话到该轮之前(该轮不带入子会话消息流),该轮的用户输入自动回填子会话输入框供编辑重发——与插话撤回同构,作用于 fork 场景,原会话消息流不动(进行中轮分叉会停止原会话该轮未完成的回复,见下)。图标与官方分支按钮同源;官方「在新对话中分支」驻留轮尾操作排(分叉含该轮),两者语义互补、位置不重叠。
|
|
25
25
|
|
|
26
26
|
- 锚点:`remote.session.follow` 开场帧(回溯窗口 2000 事件)建轮号 → { 结束 seq, 该轮首问文本, open } 映射;重试轮 N 的分叉边界取轮 N-1 的 turn/end seq(宿主边界语义 inclusive,轮间切口合法)
|
|
27
27
|
- 轮内插话不误取:文本取本轮首条携带非空文本的用户本人消息(文本块按行拼接),插件注入/纯图/空白轮不注入按钮
|
|
@@ -30,20 +30,21 @@
|
|
|
30
30
|
- 回填时序:宿主 open 可能先于草稿登记完成子会话挂载,消费侧以有限次延迟重查兜底
|
|
31
31
|
- 分叉后自动重发(开关,默认关):开启时分叉成功即以该轮原输入经 `remote.session.prompt` 发送到子会话立即开跑(重生成语义);prompt 失败仅通知,文本已在输入框可手动发送;旧宿主缺 prompt 通道自动降级为仅回填
|
|
32
32
|
|
|
33
|
-
###
|
|
33
|
+
### 标题栏复制 sessionId
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
会话标题栏(「标准模式」标签右侧)的复制按钮:点击复制当前会话 id 到剪贴板,便于粘贴给他人排查或脚本引用。
|
|
36
36
|
|
|
37
|
-
-
|
|
38
|
-
-
|
|
37
|
+
- 平时隐藏(opacity 占位),鼠标悬停标题栏任意位置或键盘聚焦时显示;`prefers-reduced-motion` 下关闭过渡
|
|
38
|
+
- 复制优先走 `navigator.clipboard.writeText`,非安全上下文(如 LAN http 访问)自动回退隐藏文本域 `execCommand`
|
|
39
|
+
- 挂官方 `conversation.session.header.actions` 插槽,成功/失败均经 toast 反馈
|
|
39
40
|
|
|
40
41
|
## 启停
|
|
41
42
|
|
|
42
|
-
|
|
43
|
+
设置页「插件」→「对话增强」卡片五开关(历史输入/插话撤回/对话分叉/分叉后自动重发/标题栏复制 sessionId),经宿主 settings 持久,变更刷新页面生效。
|
|
43
44
|
|
|
44
45
|
## 依赖与门控
|
|
45
46
|
|
|
46
|
-
-
|
|
47
|
+
- client 半区宿主服务经 inject 声明(`slots`/`sessions`/`workspaces`/`remote`/`remote.session` 点分声明),namespace 未就绪整体保持未激活(干净禁用);host 半区 inject 为 `webServer`/`agents`/`sessionQuery`/`sessionPersistence`
|
|
47
48
|
- 公共依赖 `@mzzsfy/dsh-toast` 可选消费(模块缺失通知通道置空)
|
|
48
49
|
- 样式注入带 `data-plugin` 标记,开关为 `cx-switch` 形态(状态选择器锚定 `input[type="checkbox"]`)
|
|
49
50
|
|
|
@@ -57,5 +58,10 @@
|
|
|
57
58
|
| `/api/context/steer-recall-enabled` | GET/POST | 插话撤回启停 |
|
|
58
59
|
| `/api/context/fork-enabled` | GET/POST | 对话分叉启停 |
|
|
59
60
|
| `/api/context/fork-auto-resend-enabled` | GET/POST | 分叉后自动重发启停 |
|
|
61
|
+
| `/api/context/copy-sid-enabled` | GET/POST | 标题栏复制 sessionId 启停 |
|
|
60
62
|
|
|
61
|
-
|
|
63
|
+
设计文档:仓库根 `docs/上下文管理插件设计.md`
|
|
64
|
+
|
|
65
|
+
## dsh 版本兼容
|
|
66
|
+
|
|
67
|
+
三版本全部通过:样式注入 data-plugin 契约、Alt+↑ 历史浮层、对话 fork、设置「插件」页对话增强卡四开关、host 半区 API(history-enabled / fork-enabled)。0.1.6 设置分区卡片位于「插件」页,非独立导航项。
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mzzsfy/dsh-context-manager",
|
|
3
|
-
"description": "对话增强:历史输入浮层(范围导航/搜索/subagent排除)、常用提示词收藏、插话撤回、对话 fork(
|
|
4
|
-
"version": "0.1
|
|
3
|
+
"description": "对话增强:历史输入浮层(范围导航/搜索/subagent排除)、常用提示词收藏、插话撤回、对话 fork(进行中轮分叉/自动重发)",
|
|
4
|
+
"version": "0.2.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"exports": {
|
package/src/client.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
// dsh-context-manager Client 半区:「插件」设置页可配置卡片(
|
|
2
|
-
// + 历史输入浮层(Alt+↑ 唤起,范围导航/搜索/收藏回填) + 插话撤回 + 对话 fork
|
|
1
|
+
// dsh-context-manager Client 半区:「插件」设置页可配置卡片(五启停开关)
|
|
2
|
+
// + 历史输入浮层(Alt+↑ 唤起,范围导航/搜索/收藏回填) + 插话撤回 + 对话 fork
|
|
3
|
+
// + 标题栏复制 sessionId(悬停显隐)。
|
|
3
4
|
// 历史输入挂官方 conversation.input.dock 插槽(渲染为零高度锚点),回填走宿主公共
|
|
4
5
|
// 契约 inputActions.setDraft;插话撤回注入官方 pending steering 气泡操作图标排;
|
|
5
6
|
// fork 注入消息气泡操作排,分叉到该轮之前并把该轮用户输入回填子会话输入框(重试
|
|
6
7
|
// 语义),RPC 走宿主 sessions 服务面(fork+open),锚点经 remote.session.follow
|
|
7
|
-
// 开场帧建立轮号→{ 结束 seq, 首问文本 }
|
|
8
|
+
// 开场帧建立轮号→{ 结束 seq, 首问文本 } 映射;复制 sessionId 挂官方
|
|
9
|
+
// conversation.session.header.actions 插槽。浏览器半区经 webServer 路由
|
|
8
10
|
// ('/api/context/*')访问 Host。打包为单文件自包含格式,无法跨文件 require;
|
|
9
11
|
// 与 src/core.mjs 镜像的纯函数(filterHistoryInputs/forkFailureText/forkRetryText)
|
|
10
12
|
// 修改需两处同步。
|
|
@@ -32,11 +34,6 @@ window.__ModuleLoader__.load({
|
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
const CSS = [
|
|
35
|
-
// 家族版本环:与官方 28px icon 按钮同高同隙,chevron 用官方线形 svg,
|
|
36
|
-
// label 小号 caption 色,不另起风格
|
|
37
|
-
'.cx-family-ring { display:inline-flex; align-items:center; gap:2px; }',
|
|
38
|
-
'.cx-family-ring .cx-family-ring__label { font:var(--dsw-font-xxs-12); color:var(--dsw-alias-label-caption);',
|
|
39
|
-
' min-width:24px; text-align:center; line-height:28px; }',
|
|
40
37
|
// 开关行(规约 switch 形态):track 胶囊 + thumb 圆点,状态选择器锚定 checkbox
|
|
41
38
|
'.cx-switch { display:inline-flex; align-items:center; gap:8px; margin-top:10px; cursor:pointer;',
|
|
42
39
|
' font:var(--dsw-font-xxs-12, 12px/18px sans-serif); color:var(--dsw-alias-label-caption, rgba(127,127,127,.9)); }',
|
|
@@ -140,6 +137,18 @@ const CSS = [
|
|
|
140
137
|
// dock 本体零 DOM,仅承载注入逻辑
|
|
141
138
|
'.cx-steer-host { display:none; }',
|
|
142
139
|
'.cx-fork-host { display:none; }',
|
|
140
|
+
// 标题栏复制 sessionId:与官方 icon 按钮同尺寸,默认隐藏(opacity 保占位,
|
|
141
|
+
// 隐形盒自身仍可悬停/聚焦),悬停标题栏任一位置或按钮自身/键盘聚焦时显示;
|
|
142
|
+
// 宿主 CSS module 类名带哈希前缀但名称后缀稳定,子串选择器跨构建匹配,
|
|
143
|
+
// :has 不可用时自身悬停兜底
|
|
144
|
+
'.cx-sid { appearance:none; border:0; background:transparent; cursor:pointer; flex:none;',
|
|
145
|
+
' display:inline-flex; align-items:center; justify-content:center; width:24px; height:24px; padding:0;',
|
|
146
|
+
' border-radius:6px; color:var(--dsw-alias-label-tertiary, rgba(127,127,127,.9));',
|
|
147
|
+
' opacity:0; transition:opacity .15s, color .15s; }',
|
|
148
|
+
'.cx-sid:hover, .cx-sid:focus-visible { opacity:1; color:var(--dsw-alias-label-primary, inherit); }',
|
|
149
|
+
'.cx-sid:focus-visible { outline:2px solid #1677ff; outline-offset:1px; }',
|
|
150
|
+
'[class*="_header"]:has(.cx-sid):hover .cx-sid, [class*="_header"]:has(.cx-sid):focus-within .cx-sid { opacity:1; }',
|
|
151
|
+
'@media (prefers-reduced-motion: reduce) { .cx-sid { transition:none; } }',
|
|
143
152
|
].join('\n')
|
|
144
153
|
|
|
145
154
|
const INPUTS_URL = '/api/context/inputs'
|
|
@@ -148,6 +157,7 @@ const HISTORY_ENABLED_URL = '/api/context/history-enabled'
|
|
|
148
157
|
const STEER_ENABLED_URL = '/api/context/steer-recall-enabled'
|
|
149
158
|
const FORK_ENABLED_URL = '/api/context/fork-enabled'
|
|
150
159
|
const FORK_AUTO_RESEND_URL = '/api/context/fork-auto-resend-enabled'
|
|
160
|
+
const COPY_SID_ENABLED_URL = '/api/context/copy-sid-enabled'
|
|
151
161
|
|
|
152
162
|
// 请求默认超时:host 被批量解压等同步任务阻塞时路由会迟滞数秒,
|
|
153
163
|
// 无超时则浮层停在「正在读取…」假死;超时按错误抛出,由调用方兜底,
|
|
@@ -226,50 +236,6 @@ function forkRetryText(data) {
|
|
|
226
236
|
return text.trim() === '' ? null : text
|
|
227
237
|
}
|
|
228
238
|
|
|
229
|
-
// 家族谱系投影镜像(与 core.mjs sessionFamilyMap/familyRing 同步维护):
|
|
230
|
-
// 客户端快照行主键为 id、父引用为 parentId(与 RPC wire 的 sessionId/parentSessionId 不同名),
|
|
231
|
-
// 编排派生会话(origin === 'subagent')剔除——家族只统计用户的 fork 分支;
|
|
232
|
-
// 断链视为独立根;环序按 updatedAt 升序
|
|
233
|
-
function sessionFamilyMap(items) {
|
|
234
|
-
const byId = new Map()
|
|
235
|
-
for (const item of Array.isArray(items) ? items : []) {
|
|
236
|
-
if (item && item.origin === 'subagent') continue
|
|
237
|
-
const id = item && item.id
|
|
238
|
-
if (typeof id === 'string' && id !== '') byId.set(id, item)
|
|
239
|
-
}
|
|
240
|
-
const chains = new Map()
|
|
241
|
-
for (const id of byId.keys()) {
|
|
242
|
-
const chain = []
|
|
243
|
-
let cursor = id
|
|
244
|
-
let root = cursor
|
|
245
|
-
while (cursor !== undefined) {
|
|
246
|
-
chain.unshift(cursor)
|
|
247
|
-
root = cursor
|
|
248
|
-
const parent = byId.get(cursor)
|
|
249
|
-
cursor = parent && typeof parent.parentId === 'string' && byId.has(parent.parentId)
|
|
250
|
-
? parent.parentId
|
|
251
|
-
: undefined
|
|
252
|
-
if (chain.includes(cursor)) break
|
|
253
|
-
}
|
|
254
|
-
chains.set(id, { root, chain, item: byId.get(id) })
|
|
255
|
-
}
|
|
256
|
-
return chains
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
function familyRing(chains, sessionId) {
|
|
260
|
-
const entry = chains instanceof Map ? chains.get(sessionId) : undefined
|
|
261
|
-
if (!entry) return null
|
|
262
|
-
const members = [...chains.entries()]
|
|
263
|
-
.filter(([, value]) => value.root === entry.root)
|
|
264
|
-
.sort((left, right) => {
|
|
265
|
-
const at = (record) => (record && record.item && typeof record.item.updatedAt === 'number' ? record.item.updatedAt : 0)
|
|
266
|
-
return at(left[1]) - at(right[1])
|
|
267
|
-
})
|
|
268
|
-
.map(([id]) => id)
|
|
269
|
-
if (members.length < 2) return null
|
|
270
|
-
return { index: members.indexOf(sessionId) + 1, total: members.length, members }
|
|
271
|
-
}
|
|
272
|
-
|
|
273
239
|
function HistoryDock({ session, inputActions }) {
|
|
274
240
|
const [open, setOpen] = useState(false)
|
|
275
241
|
const [items, setItems] = useState(null)
|
|
@@ -736,8 +702,6 @@ function forkSvg() {
|
|
|
736
702
|
// 注入按钮标记:识别自家节点与官方按钮,防止重复注入与误删官方节点
|
|
737
703
|
const STEER_BTN_FLAG = 'data-cx-steer-recall'
|
|
738
704
|
const FORK_BTN_FLAG = 'data-cx-fork'
|
|
739
|
-
// 家族版本环标记:识别自家 ‹n/m› 计数器节点,防重复注入
|
|
740
|
-
const FAMILY_RING_FLAG = 'data-cx-family-ring'
|
|
741
705
|
// 官方 pending steering 气泡的语义标记(官方同构:UserStyleBubble data 属性)与
|
|
742
706
|
// 其操作图标排的 CSS module 类名后缀(哈希前缀随构建漂移,后缀稳定);
|
|
743
707
|
// 消息节点的轮号标记(fork 锚点输入)
|
|
@@ -868,7 +832,7 @@ function SteerRecallDock({ session, useSession, inputActions, updateQueue }) {
|
|
|
868
832
|
// 轮号无映射 = 该轮未完成,首问无文本(纯图等)= 无法重试,均不注入;
|
|
869
833
|
// 首轮无前锚(宿主 fork 边界必须落在 turn/end 上,复制零事件不可表达),也不注入。
|
|
870
834
|
// 分叉动作经宿主 sessions 服务面 fork+open(与官方 chat 同构),成功 toast 并切换
|
|
871
|
-
function ForkDockWithBootstrap({ session, inputActions, forkSession, cancelSession,
|
|
835
|
+
function ForkDockWithBootstrap({ session, inputActions, forkSession, cancelSession, submitPrompt, loadTurnEnds }) {
|
|
872
836
|
// 依赖键 = 会话 id:session 快照身份随每次投影更新漂移,不能作 effect 依赖;
|
|
873
837
|
// loadTurnEnds 闭包身份同样不稳定,经 ref 取用
|
|
874
838
|
const sessionId = session && session.sessionId
|
|
@@ -921,17 +885,15 @@ function ForkDockWithBootstrap({ session, inputActions, forkSession, cancelSessi
|
|
|
921
885
|
}
|
|
922
886
|
return undefined
|
|
923
887
|
}, [sessionId, inputActions, draftPollTick])
|
|
924
|
-
return h(ForkDock, { session, forkSession, cancelSession,
|
|
888
|
+
return h(ForkDock, { session, forkSession, cancelSession, turnEnds })
|
|
925
889
|
}
|
|
926
890
|
|
|
927
|
-
function ForkDock({ session, forkSession, cancelSession,
|
|
891
|
+
function ForkDock({ session, forkSession, cancelSession, turnEnds }) {
|
|
928
892
|
const [enabled, setEnabled] = useState(true)
|
|
929
893
|
const [autoResend, setAutoResend] = useState(false)
|
|
930
894
|
const turnEndsRef = useRef(null)
|
|
931
895
|
const forkRef = useRef(null)
|
|
932
896
|
const cancelRef = useRef(null)
|
|
933
|
-
const familyRef = useRef(null)
|
|
934
|
-
const openRef = useRef(null)
|
|
935
897
|
const sessionRef = useRef(null)
|
|
936
898
|
const enabledRef = useRef(true)
|
|
937
899
|
const autoResendRef = useRef(false)
|
|
@@ -950,13 +912,10 @@ function ForkDock({ session, forkSession, cancelSession, openSession, loadFamily
|
|
|
950
912
|
turnEndsRef.current = turnEnds
|
|
951
913
|
forkRef.current = forkSession
|
|
952
914
|
cancelRef.current = cancelSession
|
|
953
|
-
familyRef.current = loadFamily
|
|
954
|
-
openRef.current = openSession
|
|
955
915
|
sessionRef.current = session
|
|
956
916
|
useEffect(() => {
|
|
957
917
|
function removeAll() {
|
|
958
918
|
document.querySelectorAll('[' + FORK_BTN_FLAG + ']').forEach((button) => button.remove())
|
|
959
|
-
document.querySelectorAll('[' + FAMILY_RING_FLAG + ']').forEach((node) => node.remove())
|
|
960
919
|
}
|
|
961
920
|
function scan() {
|
|
962
921
|
// 停用即不注入并移除已注入按钮(开关可在挂载后才到达停用值)
|
|
@@ -1038,73 +997,6 @@ function ForkDock({ session, forkSession, cancelSession, openSession, loadFamily
|
|
|
1038
997
|
button.appendChild(forkSvg())
|
|
1039
998
|
actionsRow.appendChild(button)
|
|
1040
999
|
})
|
|
1041
|
-
// 家族版本环:每个用户气泡操作排的 ‹n/m›,箭头在家族成员间跳转;
|
|
1042
|
-
// 单成员家族/快照面缺失不注入(与官方按钮并存,hover 显隐一致)
|
|
1043
|
-
const loadFamilyFn = familyRef.current
|
|
1044
|
-
const ring = (sessionRef.current && typeof loadFamilyFn === 'function')
|
|
1045
|
-
? loadFamilyFn(sessionRef.current.sessionId)
|
|
1046
|
-
: null
|
|
1047
|
-
if (ring && ring.total >= 2) {
|
|
1048
|
-
bubbles.forEach((bubble) => {
|
|
1049
|
-
if (bubble.getAttribute(FLOW_KIND_ATTR) !== 'user') return
|
|
1050
|
-
const actionsRow = bubble.querySelector(STEER_ACTIONS_SUFFIX)
|
|
1051
|
-
if (!actionsRow || actionsRow.querySelector('[' + FAMILY_RING_FLAG + ']')) return
|
|
1052
|
-
const official = actionsRow.querySelector('button:not([' + FORK_BTN_FLAG + ']):not([' + STEER_BTN_FLAG + '])')
|
|
1053
|
-
if (!official) return
|
|
1054
|
-
const ringEl = document.createElement('span')
|
|
1055
|
-
ringEl.setAttribute(FAMILY_RING_FLAG, '')
|
|
1056
|
-
ringEl.className = 'cx-family-ring'
|
|
1057
|
-
ringEl.title = '家族版本:本会话在同源分叉家族中的序位,箭头在各版本间切换'
|
|
1058
|
-
const makeChevron = (direction, tip) => {
|
|
1059
|
-
const chevron = document.createElement('button')
|
|
1060
|
-
chevron.type = 'button'
|
|
1061
|
-
chevron.className = official.className
|
|
1062
|
-
chevron.setAttribute(FAMILY_RING_FLAG, '')
|
|
1063
|
-
chevron.title = tip
|
|
1064
|
-
chevron.setAttribute('aria-label', tip)
|
|
1065
|
-
chevron.addEventListener('click', () => jumpFamilyMember(direction))
|
|
1066
|
-
chevron.appendChild(familyChevronSvg(direction))
|
|
1067
|
-
return chevron
|
|
1068
|
-
}
|
|
1069
|
-
const label = document.createElement('span')
|
|
1070
|
-
label.setAttribute(FAMILY_RING_FLAG, '')
|
|
1071
|
-
label.className = 'cx-family-ring__label'
|
|
1072
|
-
label.textContent = ring.index + '/' + ring.total
|
|
1073
|
-
ringEl.appendChild(makeChevron(-1, '上一个家族版本'))
|
|
1074
|
-
ringEl.appendChild(label)
|
|
1075
|
-
ringEl.appendChild(makeChevron(1, '下一个家族版本'))
|
|
1076
|
-
actionsRow.appendChild(ringEl)
|
|
1077
|
-
})
|
|
1078
|
-
}
|
|
1079
|
-
}
|
|
1080
|
-
// 家族环 chevron 图标:与官方 outline chevron 同款线形(12px 视窗)
|
|
1081
|
-
function familyChevronSvg(direction) {
|
|
1082
|
-
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
|
1083
|
-
svg.setAttribute('width', '12')
|
|
1084
|
-
svg.setAttribute('height', '12')
|
|
1085
|
-
svg.setAttribute('viewBox', '0 0 14 14')
|
|
1086
|
-
svg.setAttribute('fill', 'none')
|
|
1087
|
-
svg.setAttribute('aria-hidden', 'true')
|
|
1088
|
-
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
|
1089
|
-
path.setAttribute('d', direction < 0 ? 'M8.75 3.5L5.25 7L8.75 10.5' : 'M5.25 3.5L8.75 7L5.25 10.5')
|
|
1090
|
-
path.setAttribute('stroke', 'currentColor')
|
|
1091
|
-
path.setAttribute('stroke-width', '1.2')
|
|
1092
|
-
path.setAttribute('stroke-linecap', 'round')
|
|
1093
|
-
path.setAttribute('stroke-linejoin', 'round')
|
|
1094
|
-
svg.appendChild(path)
|
|
1095
|
-
return svg
|
|
1096
|
-
}
|
|
1097
|
-
function jumpFamilyMember(offset) {
|
|
1098
|
-
const loadFamilyFn = familyRef.current
|
|
1099
|
-
const openFn = openRef.current
|
|
1100
|
-
const current = sessionRef.current
|
|
1101
|
-
if (!current || typeof loadFamilyFn !== 'function' || typeof openFn !== 'function') return
|
|
1102
|
-
const ring = loadFamilyFn(current.sessionId)
|
|
1103
|
-
if (!ring) return
|
|
1104
|
-
const nextIndex = ring.index - 1 + offset
|
|
1105
|
-
if (nextIndex < 0 || nextIndex >= ring.members.length) return
|
|
1106
|
-
const target = ring.members[nextIndex]
|
|
1107
|
-
if (target && target !== current.sessionId) openFn(target)
|
|
1108
1000
|
}
|
|
1109
1001
|
if (typeof MutationObserver === 'undefined' || typeof document.querySelectorAll !== 'function') return undefined
|
|
1110
1002
|
const scanRef = { current: scan }
|
|
@@ -1128,6 +1020,62 @@ const HISTORY_SWITCH_TITLE = '在输入框按 Alt+↑ 唤起历史输入浮层,
|
|
|
1128
1020
|
const STEER_SWITCH_TITLE = '插话发送后、尚未被智能体应用期间,在该插话气泡的操作图标排显示撤回按钮,点击撤回并把原文填回输入框(覆盖输入框现有草稿);含附件的插话不可撤回;消息被应用后按钮随气泡消失,恰在应用瞬间点击会提示已应用且不动草稿。停用即不再注入,刷新页面生效。'
|
|
1129
1021
|
const FORK_SWITCH_TITLE = '消息气泡操作排显示分叉按钮,点击分叉出新会话到该轮之前(该轮不带入子会话),该轮的用户输入自动回填子会话输入框供编辑重发,子会话自动打开且标题尾号递增;进行中的轮(回复尚未完成)同样可分叉,分叉后自动停止本会话该轮未完成的回复;首轮(无更早上下文可继承,新建会话即为同义操作)与无文本输入的轮(纯图等,无从重发)不注入;停用即不再注入,刷新页面生效。'
|
|
1130
1022
|
const FORK_AUTO_RESEND_SWITCH_TITLE = '分叉成功后自动把该轮原输入发送到子会话立即开跑(重生成语义,相当于原输入重跑一遍);关闭时分叉仅把原输入回填子会话输入框,由你编辑后再手动发送。'
|
|
1023
|
+
const COPY_SID_SWITCH_TITLE = '会话标题栏显示复制 sessionId 按钮,平时隐藏,鼠标移到标题栏任意位置时显示,点击复制当前会话 id 到剪贴板(便于粘贴给他人排查或脚本引用);关闭后按钮整体不渲染,刷新页面生效。'
|
|
1024
|
+
|
|
1025
|
+
// 复制动作(纯函数,剪贴板与文档依赖注入):clipboard.writeText 优先,缺失或
|
|
1026
|
+
// 抛错(LAN http 非安全上下文无 navigator.clipboard)回退隐藏文本域 execCommand;
|
|
1027
|
+
// 全部失败返回 false,由调用方 toast 反馈
|
|
1028
|
+
async function copySidText(text, clipboard, doc) {
|
|
1029
|
+
if (clipboard && typeof clipboard.writeText === 'function') {
|
|
1030
|
+
try {
|
|
1031
|
+
await clipboard.writeText(text)
|
|
1032
|
+
return true
|
|
1033
|
+
} catch {
|
|
1034
|
+
// 非安全上下文拒绝等,落回退路径
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
try {
|
|
1038
|
+
const area = doc.createElement('textarea')
|
|
1039
|
+
area.value = text
|
|
1040
|
+
area.setAttribute('readonly', '')
|
|
1041
|
+
area.style.position = 'fixed'
|
|
1042
|
+
area.style.opacity = '0'
|
|
1043
|
+
doc.body.appendChild(area)
|
|
1044
|
+
try {
|
|
1045
|
+
area.select()
|
|
1046
|
+
return doc.execCommand('copy') === true
|
|
1047
|
+
} finally {
|
|
1048
|
+
// 含会话 id 的临时节点必须无条件清理,异常路径不得残留 DOM
|
|
1049
|
+
area.remove()
|
|
1050
|
+
}
|
|
1051
|
+
} catch {
|
|
1052
|
+
return false
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// 标题栏复制 sessionId 按钮:挂官方 conversation.session.header.actions 插槽
|
|
1057
|
+
// (标准模式标签右侧,sessionId 由插槽 standardProps 下发),开关停用即整体
|
|
1058
|
+
// 不渲染;启停读取失败按启用兜底(与 HistoryDock/switchRow 一致)
|
|
1059
|
+
function CopySidButton({ sessionId }) {
|
|
1060
|
+
const [enabled, setEnabled] = useState(true)
|
|
1061
|
+
useEffect(() => {
|
|
1062
|
+
api(COPY_SID_ENABLED_URL)
|
|
1063
|
+
.then((payload) => setEnabled(payload ? payload.enabled !== false : true))
|
|
1064
|
+
.catch(() => {})
|
|
1065
|
+
}, [])
|
|
1066
|
+
if (!sessionId || !enabled) return null
|
|
1067
|
+
// copySidText 为 async 函数(内部全路径吞异常),必 resolve,失败以 false 表达
|
|
1068
|
+
const copy = () => {
|
|
1069
|
+
copySidText(sessionId, navigator.clipboard, document)
|
|
1070
|
+
.then((ok) => toast(ok ? '已复制 sessionId' : '复制失败', ok ? undefined : { kind: 'error' }))
|
|
1071
|
+
}
|
|
1072
|
+
return h('button', { type: 'button', className: 'cx-sid', title: '复制 sessionId', 'aria-label': '复制 sessionId', onClick: copy },
|
|
1073
|
+
h('svg', { width: 14, height: 14, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': 'true' },
|
|
1074
|
+
h('rect', { x: 5.75, y: 5.75, width: 7.5, height: 7.5, rx: 1.5, stroke: 'currentColor', 'stroke-width': '1.2' }),
|
|
1075
|
+
h('path', { d: 'M10.25 3.25H4.25a1.5 1.5 0 0 0-1.5 1.5v6', stroke: 'currentColor', 'stroke-width': '1.2', 'stroke-linecap': 'round' }),
|
|
1076
|
+
),
|
|
1077
|
+
)
|
|
1078
|
+
}
|
|
1131
1079
|
|
|
1132
1080
|
// 启停开关行工厂:三个开关同构(受控 checkbox + cx-switch 形态),值存宿主 settings,
|
|
1133
1081
|
// 切换经本插件路由中转,变更刷新页面生效
|
|
@@ -1166,6 +1114,7 @@ const HistorySwitchRow = switchRow(HISTORY_ENABLED_URL, '历史输入浮层(Alt+
|
|
|
1166
1114
|
const SteerSwitchRow = switchRow(STEER_ENABLED_URL, '插话撤回', STEER_SWITCH_TITLE, '插话撤回')
|
|
1167
1115
|
const ForkSwitchRow = switchRow(FORK_ENABLED_URL, '对话 fork', FORK_SWITCH_TITLE, '对话 fork')
|
|
1168
1116
|
const ForkAutoResendSwitchRow = switchRow(FORK_AUTO_RESEND_URL, '分叉后自动重发', FORK_AUTO_RESEND_SWITCH_TITLE, '分叉后自动重发')
|
|
1117
|
+
const CopySidSwitchRow = switchRow(COPY_SID_ENABLED_URL, '标题栏复制 sessionId', COPY_SID_SWITCH_TITLE, '标题栏复制 sessionId')
|
|
1169
1118
|
|
|
1170
1119
|
// 「插件」设置页卡片:与官方 PluginCard 同款折叠卡(header 展开/收起 + body),
|
|
1171
1120
|
// 视觉值取自宿主实测(见 CSS 注释),组件因缺宿主 i18n/表单状态而自绘
|
|
@@ -1189,6 +1138,7 @@ function ContextPanel() {
|
|
|
1189
1138
|
h(SteerSwitchRow),
|
|
1190
1139
|
h(ForkSwitchRow),
|
|
1191
1140
|
h(ForkAutoResendSwitchRow),
|
|
1141
|
+
h(CopySidSwitchRow),
|
|
1192
1142
|
),
|
|
1193
1143
|
)
|
|
1194
1144
|
}
|
|
@@ -1217,18 +1167,6 @@ function ContextPanel() {
|
|
|
1217
1167
|
})
|
|
1218
1168
|
: null
|
|
1219
1169
|
|
|
1220
|
-
// 家族环通道:会话列表快照(parentSessionId 字段)投影为当前会话的 ‹n/m› 计数;
|
|
1221
|
-
// 快照面缺失(旧宿主)时返回 null,计数器整体不注入
|
|
1222
|
-
const loadFamily = (sessionId) => {
|
|
1223
|
-
if (!sessions || !sessions.list || typeof sessions.list.getSnapshot !== 'function') return null
|
|
1224
|
-
const snapshot = sessions.list.getSnapshot()
|
|
1225
|
-
const rows = snapshot && snapshot.byId
|
|
1226
|
-
? (Array.isArray(snapshot.ids) ? snapshot.ids : []).map((id) => snapshot.byId[id]).filter(Boolean)
|
|
1227
|
-
: (Array.isArray(snapshot) ? snapshot : [])
|
|
1228
|
-
if (rows.length === 0) return null
|
|
1229
|
-
return familyRing(sessionFamilyMap(rows), sessionId)
|
|
1230
|
-
}
|
|
1231
|
-
|
|
1232
1170
|
// 自动重发通道:开启开关时分叉后以该轮原输入 prompt 子会话;
|
|
1233
1171
|
// remote.session 缺失或缺 prompt(旧宿主/部分 stub)时返回 null,降级为仅回填;
|
|
1234
1172
|
// 请求形态与官方 client face 同源:content 为 text 分段数组(宿主 hasPromptContent
|
|
@@ -1314,8 +1252,6 @@ function ContextPanel() {
|
|
|
1314
1252
|
cancelSession: (typeof remoteSession.cancel === 'function')
|
|
1315
1253
|
? (opts) => remoteSession.cancel(opts)
|
|
1316
1254
|
: null,
|
|
1317
|
-
openSession: (sessions && typeof sessions.open === 'function') ? (id) => sessions.open(id) : null,
|
|
1318
|
-
loadFamily: () => loadFamily(sessionId),
|
|
1319
1255
|
submitPrompt,
|
|
1320
1256
|
loadTurnEnds: () => followOpening(remoteSession, sessionId).then((records) => {
|
|
1321
1257
|
// 轮号 → { 结束 seq, 该轮首问文本, open }:上个 turn/end 之后首条
|
|
@@ -1354,6 +1290,20 @@ function ContextPanel() {
|
|
|
1354
1290
|
console.warn('[context-manager] 对话 fork 入口未注册(宿主无 conversation.input.dock 插槽)', error)
|
|
1355
1291
|
}
|
|
1356
1292
|
}
|
|
1293
|
+
|
|
1294
|
+
// 标题栏复制 sessionId 入口:官方 conversation.session.header.actions 插槽
|
|
1295
|
+
// (list,order -9 紧随官方 agent-preset 标签 -10,即「标准模式」右侧);
|
|
1296
|
+
// sessionId 由该插槽 standardProps 自动下发(与官方 agent-preset 标签同源),
|
|
1297
|
+
// 组件缺失时自行判空不渲染。宿主缺该插槽时仅禁用本功能,不阻塞其余能力
|
|
1298
|
+
try {
|
|
1299
|
+
ctx.slots.inject('conversation.session.header.actions', () =>
|
|
1300
|
+
ctx.slots.register(
|
|
1301
|
+
{ name: 'conversation.session.header.actions', id: 'context-manager-copy-sid', order: -9 },
|
|
1302
|
+
CopySidButton,
|
|
1303
|
+
))
|
|
1304
|
+
} catch (error) {
|
|
1305
|
+
console.warn('[context-manager] 复制 sessionId 入口未注册(宿主无 conversation.session.header.actions 插槽)', error)
|
|
1306
|
+
}
|
|
1357
1307
|
},
|
|
1358
1308
|
}
|
|
1359
1309
|
},
|
package/src/core.mjs
CHANGED
|
@@ -144,57 +144,3 @@ export function forkRetryText(data) {
|
|
|
144
144
|
.join('\n')
|
|
145
145
|
return text.trim() === '' ? null : text
|
|
146
146
|
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* 家族谱系投影:按 parentSessionId 把会话行组装成「根 → 后代」链,
|
|
150
|
-
* parentSessionId 断链(引用的父不在集合内)的行视为独立根。
|
|
151
|
-
* 编排派生会话(origin === 'subagent')不是用户的 fork 版本,整行剔除,
|
|
152
|
-
* 其后代随之断链成独立根——家族只统计用户可跳转的对话分支。
|
|
153
|
-
* @param items - sessions.list 行数组(至少含 sessionId/parentSessionId)
|
|
154
|
-
* @returns Map<sessionId, { root, chain: string[] }>——chain 为根到自身的 id 序列
|
|
155
|
-
*/
|
|
156
|
-
export function sessionFamilyMap(items) {
|
|
157
|
-
const byId = new Map()
|
|
158
|
-
for (const item of Array.isArray(items) ? items : []) {
|
|
159
|
-
if (item && item.origin === 'subagent') continue
|
|
160
|
-
const id = item && (item.sessionId ?? item.id)
|
|
161
|
-
if (typeof id === 'string' && id !== '') byId.set(id, item)
|
|
162
|
-
}
|
|
163
|
-
const chains = new Map()
|
|
164
|
-
for (const id of byId.keys()) {
|
|
165
|
-
const chain = []
|
|
166
|
-
let cursor = id
|
|
167
|
-
let root = cursor
|
|
168
|
-
while (cursor !== undefined) {
|
|
169
|
-
chain.unshift(cursor)
|
|
170
|
-
root = cursor
|
|
171
|
-
const parent = byId.get(cursor)
|
|
172
|
-
cursor = parent && typeof parent.parentSessionId === 'string' && byId.has(parent.parentSessionId)
|
|
173
|
-
? parent.parentSessionId
|
|
174
|
-
: undefined
|
|
175
|
-
if (chain.includes(cursor)) break
|
|
176
|
-
}
|
|
177
|
-
chains.set(id, { root, chain, item: byId.get(id) })
|
|
178
|
-
}
|
|
179
|
-
return chains
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* 家族环计数:某会话在所属家族中的序位与成员数(按 updatedAt 升序,与分叉先后一致)。
|
|
184
|
-
* 单成员家族返回 null(不渲染计数器)。
|
|
185
|
-
* @param chains - sessionFamilyMap 的结果
|
|
186
|
-
* @param sessionId - 目标会话
|
|
187
|
-
*/
|
|
188
|
-
export function familyRing(chains, sessionId) {
|
|
189
|
-
const entry = chains instanceof Map ? chains.get(sessionId) : undefined
|
|
190
|
-
if (!entry) return null
|
|
191
|
-
const members = [...chains.entries()]
|
|
192
|
-
.filter(([, value]) => value.root === entry.root)
|
|
193
|
-
.sort((left, right) => {
|
|
194
|
-
const at = (record) => (record && record.item && typeof record.item.updatedAt === 'number' ? record.item.updatedAt : 0)
|
|
195
|
-
return at(left[1]) - at(right[1])
|
|
196
|
-
})
|
|
197
|
-
.map(([id]) => id)
|
|
198
|
-
if (members.length < 2) return null
|
|
199
|
-
return { index: members.indexOf(sessionId) + 1, total: members.length, members }
|
|
200
|
-
}
|
package/src/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
// dsh-context-manager Host 半区:历史输入工作区缓存对齐 +
|
|
2
|
-
// (inputs / prompts/toggle
|
|
1
|
+
// dsh-context-manager Host 半区:历史输入工作区缓存对齐 + 启停/数据路由
|
|
2
|
+
// (inputs / prompts/toggle 与 history/steer-recall/fork/fork-auto-resend/copy-sid
|
|
3
|
+
// 五条启停)。
|
|
3
4
|
// 浮层请求直接读缓存文件立即返回;对齐在后台解压范围内会话产物与缓存合并写回,
|
|
4
5
|
// 运行中会话也参与(实时追加其新输入)。fork 分叉动作走 client 服务面,
|
|
5
6
|
// host 侧只有其启停路由。
|
|
@@ -53,6 +54,8 @@ const SETTINGS_SCHEMA = schemastery.object({
|
|
|
53
54
|
.description('消息气泡操作排的分叉按钮,关闭后按钮整体不渲染;变更刷新页面生效'),
|
|
54
55
|
forkAutoResendEnabled: schemastery.boolean().default(false)
|
|
55
56
|
.description('分叉成功后自动把该轮原输入发送到子会话(重生成语义);关闭时仅回填子会话输入框供编辑重发'),
|
|
57
|
+
copySidEnabled: schemastery.boolean().default(true)
|
|
58
|
+
.description('会话标题栏的复制 sessionId 按钮,平时隐藏,鼠标悬停标题栏时显示,点击复制当前会话 id;关闭后按钮整体不渲染;变更刷新页面生效'),
|
|
56
59
|
})
|
|
57
60
|
|
|
58
61
|
function sendJson(res, status, payload) {
|
|
@@ -79,6 +82,7 @@ function readSettings(ctx) {
|
|
|
79
82
|
steerRecallEnabled: !value || value.steerRecallEnabled !== false,
|
|
80
83
|
forkEnabled: !value || value.forkEnabled !== false,
|
|
81
84
|
forkAutoResendEnabled: Boolean(value && value.forkAutoResendEnabled === true),
|
|
85
|
+
copySidEnabled: !value || value.copySidEnabled !== false,
|
|
82
86
|
}
|
|
83
87
|
}
|
|
84
88
|
|
|
@@ -539,6 +543,31 @@ export function apply(ctx, config) {
|
|
|
539
543
|
}
|
|
540
544
|
},
|
|
541
545
|
},
|
|
546
|
+
{
|
|
547
|
+
// 标题栏复制 sessionId 启停:与历史浮层开关同构,读走 settings,写经 settings.update
|
|
548
|
+
path: '/api/context/copy-sid-enabled',
|
|
549
|
+
handler: async (req, res) => {
|
|
550
|
+
try {
|
|
551
|
+
if (req.method === 'GET') {
|
|
552
|
+
sendJson(res, 200, { enabled: readSettings(ctx).copySidEnabled })
|
|
553
|
+
return
|
|
554
|
+
}
|
|
555
|
+
if (!rejectMethod(req, res, 'POST')) return
|
|
556
|
+
let body
|
|
557
|
+
try {
|
|
558
|
+
body = JSON.parse(await readBody(req))
|
|
559
|
+
} catch {
|
|
560
|
+
throw new Error(MESSAGES.badJsonBody)
|
|
561
|
+
}
|
|
562
|
+
const settings = ctx.get('settings')
|
|
563
|
+
if (!settings) throw new Error('宿主设置服务不可用')
|
|
564
|
+
await settings.update(NAMESPACE, { copySidEnabled: Boolean(body && body.enabled) })
|
|
565
|
+
sendJson(res, 200, { enabled: readSettings(ctx).copySidEnabled })
|
|
566
|
+
} catch (error) {
|
|
567
|
+
respondError(ctx, res, error)
|
|
568
|
+
}
|
|
569
|
+
},
|
|
570
|
+
},
|
|
542
571
|
]
|
|
543
572
|
|
|
544
573
|
for (const route of routes) {
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// 标题栏复制 sessionId:接线与门控测试。加载真实 src/client.js,断言 header.actions
|
|
2
|
+
// 插槽注册、inject 契约、开关门控(停用不渲染)与复制动作(toast 反馈);
|
|
3
|
+
// 复制本体 copySidText 为纯函数切片(剪贴板/文档依赖注入)。
|
|
4
|
+
import test from 'node:test'
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
import { readFileSync } from 'node:fs'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
import { dirname, join } from 'node:path'
|
|
9
|
+
|
|
10
|
+
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
11
|
+
const CLIENT_SRC = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
|
|
12
|
+
|
|
13
|
+
// fetch 桩:按 URL 返回预设 payload;未预设的 URL 抛错(暴露意外请求)
|
|
14
|
+
function withFetchStub(routes, run) {
|
|
15
|
+
const original = globalThis.fetch
|
|
16
|
+
globalThis.fetch = async (url) => {
|
|
17
|
+
const hit = routes.find((route) => String(url).startsWith(route.path))
|
|
18
|
+
if (!hit) throw new Error('意外请求: ' + String(url))
|
|
19
|
+
if (hit.reject) throw new Error('network down')
|
|
20
|
+
return { ok: true, status: 200, json: async () => hit.body }
|
|
21
|
+
}
|
|
22
|
+
return Promise.resolve()
|
|
23
|
+
.then(run)
|
|
24
|
+
.finally(() => { globalThis.fetch = original })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// client.js 装载:react 桩为可驱动微型模拟(useState 槽位 / useEffect 收集 /
|
|
28
|
+
// createElement 记录),apply 后返回捕获的插槽注册表
|
|
29
|
+
function loadClientHarness({ documentStub } = {}) {
|
|
30
|
+
const modules = []
|
|
31
|
+
const registered = []
|
|
32
|
+
const toasts = []
|
|
33
|
+
const elements = []
|
|
34
|
+
const hookSlots = []
|
|
35
|
+
const effects = []
|
|
36
|
+
// hook 游标:每次渲染前置 0,useState 按调用序读写槽位(模拟 React hook 链)
|
|
37
|
+
let hookCursor = 0
|
|
38
|
+
const reactStub = {
|
|
39
|
+
useState: (init) => {
|
|
40
|
+
let cell = hookSlots[hookCursor]
|
|
41
|
+
if (!cell) {
|
|
42
|
+
cell = { value: init }
|
|
43
|
+
hookSlots[hookCursor] = cell
|
|
44
|
+
}
|
|
45
|
+
hookCursor += 1
|
|
46
|
+
return [cell.value, (next) => { cell.value = typeof next === 'function' ? next(cell.value) : next }]
|
|
47
|
+
},
|
|
48
|
+
useEffect: (fn) => { effects.push(fn) },
|
|
49
|
+
useRef: (value) => ({ current: value }),
|
|
50
|
+
createElement: (type, props, ...children) => {
|
|
51
|
+
const element = { type, props: props || {}, children }
|
|
52
|
+
elements.push(element)
|
|
53
|
+
return element
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
const requireStub = (name) => {
|
|
57
|
+
if (name === '@mzzsfy/dsh-toast/client') return { show: (text, opts) => toasts.push({ text, opts }) }
|
|
58
|
+
return reactStub
|
|
59
|
+
}
|
|
60
|
+
const document = documentStub || {
|
|
61
|
+
createElement: () => ({ style: {}, remove() {} }),
|
|
62
|
+
head: { appendChild: () => {} },
|
|
63
|
+
body: { appendChild: () => {} },
|
|
64
|
+
}
|
|
65
|
+
const factory = new Function('window', 'require', 'document', CLIENT_SRC + '\n;return null')
|
|
66
|
+
factory({ __ModuleLoader__: { load: (module) => modules.push(module) }, addEventListener: () => {} }, requireStub, document)
|
|
67
|
+
assert.equal(modules.length, 1, 'client.js 模块未被捕获')
|
|
68
|
+
const mod = modules[0].factory(requireStub)
|
|
69
|
+
const sessions = { binding: () => undefined, fork: () => Promise.resolve('child-1'), open: () => {} }
|
|
70
|
+
const ctx = {
|
|
71
|
+
get: (name) => (name === 'sessions' ? sessions : {}),
|
|
72
|
+
effect: () => {},
|
|
73
|
+
slots: {
|
|
74
|
+
inject: (name, register) => register(),
|
|
75
|
+
register: (options, component) => registered.push({ options, component }),
|
|
76
|
+
},
|
|
77
|
+
remote: { session: { follow: () => (async function* () { yield { type: 'snapshot', cursor: 0, records: [] }; await new Promise(() => {}) })() } },
|
|
78
|
+
}
|
|
79
|
+
mod.apply(ctx)
|
|
80
|
+
return {
|
|
81
|
+
registered,
|
|
82
|
+
toasts,
|
|
83
|
+
// 首渲染:清空 hook 槽位与效果队列后跑组件(全新挂载);效果同步执行
|
|
84
|
+
render(component, props) {
|
|
85
|
+
effects.length = 0
|
|
86
|
+
elements.length = 0
|
|
87
|
+
hookSlots.length = 0
|
|
88
|
+
hookCursor = 0
|
|
89
|
+
const output = component(props)
|
|
90
|
+
for (const effect of effects.splice(0)) effect()
|
|
91
|
+
return output
|
|
92
|
+
},
|
|
93
|
+
// 重渲染:保留 hook 槽位(setter 已写入的状态生效),不重跑效果——忠实
|
|
94
|
+
// React 空 deps effect 只跑一次的语义
|
|
95
|
+
rerender(component, props) {
|
|
96
|
+
elements.length = 0
|
|
97
|
+
hookCursor = 0
|
|
98
|
+
return component(props)
|
|
99
|
+
},
|
|
100
|
+
async settle() { await new Promise((resolve) => setTimeout(resolve, 0)) },
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function findCopySidEntry(harness) {
|
|
105
|
+
const entry = harness.registered.find((item) => item.options.id === 'context-manager-copy-sid')
|
|
106
|
+
assert.ok(entry, '未注册 id=context-manager-copy-sid 的标题栏条目')
|
|
107
|
+
return entry
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
test('Given client.js 装载, When apply, Then header.actions 注册复制条目', () => {
|
|
111
|
+
const harness = loadClientHarness()
|
|
112
|
+
const entry = findCopySidEntry(harness)
|
|
113
|
+
assert.equal(entry.options.name, 'conversation.session.header.actions', '应注册进标题栏 actions 插槽')
|
|
114
|
+
assert.equal(entry.options.order, -9, '应紧随官方 agent-preset 标签(-10)之后')
|
|
115
|
+
assert.equal(typeof entry.component, 'function', '条目缺组件')
|
|
116
|
+
assert.equal(entry.options.inject, undefined, 'sessionId 由插槽 standardProps 下发,不需 inject')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('Given 开关启用, When 渲染, Then 渲染 cx-sid 复制按钮', async () => {
|
|
120
|
+
const harness = loadClientHarness()
|
|
121
|
+
const { component } = findCopySidEntry(harness)
|
|
122
|
+
let output
|
|
123
|
+
await withFetchStub([{ path: '/api/context/copy-sid-enabled', body: { enabled: true } }], async () => {
|
|
124
|
+
output = harness.render(component, { sessionId: 's1' })
|
|
125
|
+
await harness.settle()
|
|
126
|
+
})
|
|
127
|
+
output = harness.rerender(component, { sessionId: 's1' })
|
|
128
|
+
assert.equal(output.type, 'button', '启用时应渲染按钮')
|
|
129
|
+
assert.equal(output.props.className, 'cx-sid')
|
|
130
|
+
assert.equal(output.props.title, '复制 sessionId')
|
|
131
|
+
assert.equal(output.props['aria-label'], '复制 sessionId')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('Given 开关停用, When 渲染, Then 不渲染(整体 null)', async () => {
|
|
135
|
+
const harness = loadClientHarness()
|
|
136
|
+
const { component } = findCopySidEntry(harness)
|
|
137
|
+
let output
|
|
138
|
+
await withFetchStub([{ path: '/api/context/copy-sid-enabled', body: { enabled: false } }], async () => {
|
|
139
|
+
output = harness.render(component, { sessionId: 's1' })
|
|
140
|
+
await harness.settle()
|
|
141
|
+
})
|
|
142
|
+
output = harness.rerender(component, { sessionId: 's1' })
|
|
143
|
+
assert.equal(output, null, '停用后按钮整体不渲染')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('Given 启停读取失败, When 渲染, Then 按启用兜底(fail-open,与既有开关一致)', async () => {
|
|
147
|
+
const harness = loadClientHarness()
|
|
148
|
+
const { component } = findCopySidEntry(harness)
|
|
149
|
+
await withFetchStub([{ path: '/api/context/copy-sid-enabled', reject: true }], async () => {
|
|
150
|
+
harness.render(component, { sessionId: 's1' })
|
|
151
|
+
await harness.settle()
|
|
152
|
+
})
|
|
153
|
+
const output = harness.rerender(component, { sessionId: 's1' })
|
|
154
|
+
assert.equal(output.type, 'button', '读取失败应按启用兜底')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('Given 无 sessionId, When 渲染, Then 不渲染', () => {
|
|
158
|
+
const harness = loadClientHarness()
|
|
159
|
+
const { component } = findCopySidEntry(harness)
|
|
160
|
+
const output = harness.render(component, {})
|
|
161
|
+
assert.equal(output, null, '无会话 id 应不渲染')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
// ── 纯函数切片:copySidText(剪贴板优先,execCommand 回退) ──
|
|
165
|
+
|
|
166
|
+
const PURE_START = CLIENT_SRC.indexOf('async function copySidText(')
|
|
167
|
+
const PURE_END = CLIENT_SRC.indexOf('function CopySidButton(')
|
|
168
|
+
assert.ok(PURE_START >= 0 && PURE_END > PURE_START, 'client.js copySidText 切片定位失败')
|
|
169
|
+
const { copySidText } = new Function(CLIENT_SRC.slice(PURE_START, PURE_END) + '; return { copySidText }')()
|
|
170
|
+
|
|
171
|
+
const FAKE_DOC = (execResult, execThrows) => {
|
|
172
|
+
const appended = []
|
|
173
|
+
return {
|
|
174
|
+
appended,
|
|
175
|
+
createElement: () => ({ style: {}, value: '', setAttribute() {}, select() {}, remove() { appended.push('removed') } }),
|
|
176
|
+
body: { appendChild: (node) => appended.push(node) },
|
|
177
|
+
execCommand: () => { if (execThrows) throw new Error('exec down'); return execResult },
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
test('copySidText:clipboard.writeText 可用即成功', async () => {
|
|
182
|
+
const writes = []
|
|
183
|
+
const done = await copySidText('s1', { writeText: async (text) => { writes.push(text) } }, FAKE_DOC(true))
|
|
184
|
+
assert.equal(done, true)
|
|
185
|
+
assert.deepEqual(writes, ['s1'])
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test('copySidText:writeText 抛错回退 execCommand 成功', async () => {
|
|
189
|
+
const doc = FAKE_DOC(true)
|
|
190
|
+
const done = await copySidText('s1', { writeText: async () => { throw new Error('denied') } }, doc)
|
|
191
|
+
assert.equal(done, true)
|
|
192
|
+
// 临时文本域被 append 且随后移除(无残留)
|
|
193
|
+
assert.equal(doc.appended.filter((node) => typeof node === 'string' && node === 'removed').length, 1, '回退路径应清理临时文本域')
|
|
194
|
+
assert.equal(doc.appended.length, 2, 'append 后必须紧跟 remove')
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('copySidText:无 clipboard 时走 execCommand 回退', async () => {
|
|
198
|
+
assert.equal(await copySidText('s1', undefined, FAKE_DOC(true)), true)
|
|
199
|
+
assert.equal(await copySidText('s1', undefined, FAKE_DOC(false)), false)
|
|
200
|
+
const throwing = FAKE_DOC(null, true)
|
|
201
|
+
assert.equal(await copySidText('s1', undefined, throwing), false)
|
|
202
|
+
// finally 清理语义:execCommand 抛错路径临时节点同样必须移除,不得残留 DOM
|
|
203
|
+
assert.deepEqual(
|
|
204
|
+
throwing.appended.filter((entry) => entry === 'removed').length,
|
|
205
|
+
throwing.appended.filter((entry) => typeof entry !== 'string').length,
|
|
206
|
+
'每个 append 的临时节点都必须紧跟一次 remove',
|
|
207
|
+
)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
// ── 点击动作:toast 反馈 ──
|
|
211
|
+
|
|
212
|
+
test('Given 点击按钮, When 复制成功, Then toast 已复制 sessionId', async () => {
|
|
213
|
+
const harness = loadClientHarness({ documentStub: FAKE_DOC(true) })
|
|
214
|
+
const { component } = findCopySidEntry(harness)
|
|
215
|
+
let output
|
|
216
|
+
await withFetchStub([{ path: '/api/context/copy-sid-enabled', body: { enabled: true } }], async () => {
|
|
217
|
+
output = harness.render(component, { sessionId: 's1' })
|
|
218
|
+
await harness.settle()
|
|
219
|
+
})
|
|
220
|
+
output = harness.rerender(component, { sessionId: 's1' })
|
|
221
|
+
output.props.onClick()
|
|
222
|
+
await harness.settle()
|
|
223
|
+
assert.deepEqual(harness.toasts, [{ text: '已复制 sessionId', opts: undefined }])
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
test('Given 点击按钮, When 复制失败, Then toast 复制失败(error)且不误报成功', async () => {
|
|
227
|
+
const harness = loadClientHarness({ documentStub: FAKE_DOC(false) })
|
|
228
|
+
const { component } = findCopySidEntry(harness)
|
|
229
|
+
let output
|
|
230
|
+
await withFetchStub([{ path: '/api/context/copy-sid-enabled', body: { enabled: true } }], async () => {
|
|
231
|
+
output = harness.render(component, { sessionId: 's1' })
|
|
232
|
+
await harness.settle()
|
|
233
|
+
})
|
|
234
|
+
output = harness.rerender(component, { sessionId: 's1' })
|
|
235
|
+
output.props.onClick()
|
|
236
|
+
await harness.settle()
|
|
237
|
+
assert.deepEqual(harness.toasts, [{ text: '复制失败', opts: { kind: 'error' } }])
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
// ── 源码契约:显隐 CSS 与设置卡开关行 ──
|
|
241
|
+
|
|
242
|
+
test('CSS 显隐契约:默认隐藏,整行悬停/自身悬停/键盘聚焦显示,reduced-motion 关过渡', () => {
|
|
243
|
+
assert.match(CLIENT_SRC, /\.cx-sid \{[^}]*opacity:0/, '按钮默认必须隐藏')
|
|
244
|
+
assert.match(CLIENT_SRC, /\[class\*="_header"\]:has\(\.cx-sid\):hover \.cx-sid/, '标题栏整行悬停应显示按钮')
|
|
245
|
+
assert.match(CLIENT_SRC, /\.cx-sid:hover, \.cx-sid:focus-visible \{ opacity:1/, '自身悬停与键盘聚焦必须兜底显示')
|
|
246
|
+
assert.match(CLIENT_SRC, /@media \(prefers-reduced-motion: reduce\) \{ \.cx-sid \{ transition:none/, 'reduced-motion 应关闭过渡')
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
test('设置卡契约:第五开关行走 switchRow 工厂,挂 copy-sid-enabled 路由', () => {
|
|
250
|
+
assert.ok(CLIENT_SRC.includes("switchRow(COPY_SID_ENABLED_URL, '标题栏复制 sessionId'"), '设置卡缺复制 sessionId 开关行')
|
|
251
|
+
assert.ok(CLIENT_SRC.includes('h(CopySidSwitchRow)'), 'ContextPanel 未挂载第五开关行')
|
|
252
|
+
})
|
|
@@ -44,7 +44,6 @@ test('源码契约:悬停说明覆盖各设置的关键行为语义', () => {
|
|
|
44
44
|
assert.ok(CLIENT_SRC.includes('回填子会话输入框'), 'fork 说明缺重试回填语义')
|
|
45
45
|
assert.ok(CLIENT_SRC.includes('尾号递增'), 'fork 说明缺标题递增语义')
|
|
46
46
|
assert.ok(CLIENT_SRC.includes('重生成语义'), '自动重发说明缺重生成语义')
|
|
47
|
-
assert.ok(CLIENT_SRC.includes('家族版本'), '计数器说明缺家族版本语义')
|
|
48
47
|
})
|
|
49
48
|
|
|
50
49
|
// 搜索功能源码契约:query 双写 + 组合输入守卫 + 命中计数 + 两级 Esc
|
package/test/index.test.mjs
CHANGED
|
@@ -661,12 +661,12 @@ test('常用提示词:超长截断、上限裁剪、空 text 拒绝', skipMissin
|
|
|
661
661
|
|
|
662
662
|
// ── 路由层横切守卫:方法白名单 / JSON 体 / 系统级错误收敛 / 非字符串 text ──
|
|
663
663
|
|
|
664
|
-
test('横切守卫:
|
|
664
|
+
test('横切守卫:6 条路由方法白名单,白名单外一律 405', skipMissingDeps, async () => {
|
|
665
665
|
await withHistoryCacheDir(async () => {
|
|
666
666
|
const { handlers } = makeCtx({ headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
|
|
667
667
|
const getOnly = '/api/context/inputs'
|
|
668
668
|
const postOnly = '/api/context/prompts/toggle'
|
|
669
|
-
const dualMethods = ['/api/context/history-enabled', '/api/context/steer-recall-enabled', '/api/context/fork-enabled']
|
|
669
|
+
const dualMethods = ['/api/context/history-enabled', '/api/context/steer-recall-enabled', '/api/context/fork-enabled', '/api/context/copy-sid-enabled']
|
|
670
670
|
// GET-only 路由:POST 拒绝
|
|
671
671
|
const inputsPost = response()
|
|
672
672
|
await handlers.get(getOnly)(request('s1'), inputsPost)
|
|
@@ -684,10 +684,10 @@ test('横切守卫:5 条路由方法白名单,白名单外一律 405', skipMissi
|
|
|
684
684
|
})
|
|
685
685
|
})
|
|
686
686
|
|
|
687
|
-
test('横切守卫:
|
|
687
|
+
test('横切守卫:5 条 POST 路由的非法 JSON 请求体统一 400(badJsonBody)', skipMissingDeps, async () => {
|
|
688
688
|
await withHistoryCacheDir(async () => {
|
|
689
689
|
const { handlers } = makeCtx({ headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
|
|
690
|
-
const postPaths = ['/api/context/prompts/toggle', '/api/context/history-enabled', '/api/context/steer-recall-enabled', '/api/context/fork-enabled']
|
|
690
|
+
const postPaths = ['/api/context/prompts/toggle', '/api/context/history-enabled', '/api/context/steer-recall-enabled', '/api/context/fork-enabled', '/api/context/copy-sid-enabled']
|
|
691
691
|
for (const routePath of postPaths) {
|
|
692
692
|
const res = await postRaw(handlers, routePath, '{oops')
|
|
693
693
|
assert.equal(res.status, 400, routePath + ' 非法 JSON 应回 400')
|
|
@@ -788,3 +788,19 @@ test('fork 启停:GET 默认启用,POST 切换经 settings 持久,GET 反映新
|
|
|
788
788
|
await handlers.get('/api/context/fork-enabled')(getRequest2('/api/context/fork-enabled', 'GET'), restored)
|
|
789
789
|
assert.equal(restored.body.enabled, true)
|
|
790
790
|
})
|
|
791
|
+
|
|
792
|
+
test('标题栏复制 sessionId 启停:GET 默认启用,POST 切换经 settings 持久,GET 反映新值', skipMissingDeps, async () => {
|
|
793
|
+
const { handlers } = makeCtx({ headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
|
|
794
|
+
const initial = response()
|
|
795
|
+
await handlers.get('/api/context/copy-sid-enabled')(getRequest2('/api/context/copy-sid-enabled', 'GET'), initial)
|
|
796
|
+
assert.equal(initial.body.enabled, true, '默认启用')
|
|
797
|
+
const off = await postJson(handlers, '/api/context/copy-sid-enabled', { enabled: false })
|
|
798
|
+
assert.equal(off.body.enabled, false)
|
|
799
|
+
const reread = response()
|
|
800
|
+
await handlers.get('/api/context/copy-sid-enabled')(getRequest2('/api/context/copy-sid-enabled', 'GET'), reread)
|
|
801
|
+
assert.equal(reread.body.enabled, false, 'settings 持久化关闭态')
|
|
802
|
+
await postJson(handlers, '/api/context/copy-sid-enabled', { enabled: true })
|
|
803
|
+
const restored = response()
|
|
804
|
+
await handlers.get('/api/context/copy-sid-enabled')(getRequest2('/api/context/copy-sid-enabled', 'GET'), restored)
|
|
805
|
+
assert.equal(restored.body.enabled, true)
|
|
806
|
+
})
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
import { test } from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
import { readFileSync } from 'node:fs'
|
|
4
|
-
import { fileURLToPath } from 'node:url'
|
|
5
|
-
|
|
6
|
-
const CLIENT_SRC = readFileSync(fileURLToPath(new URL('../src/client.js', import.meta.url)), 'utf8')
|
|
7
|
-
|
|
8
|
-
// F1 家族版本环 的客户端契约(源码切片锁定):
|
|
9
|
-
// - 数据源是 sessions.list 快照行(parentSessionId),零新 RPC
|
|
10
|
-
// - 单成员家族/快照缺失不注入
|
|
11
|
-
// - 箭头跳转经 sessions.open(纯打开,不 fork)
|
|
12
|
-
// - 计数器防重复注入,停用 fork 时随 removeAll 一并清除
|
|
13
|
-
|
|
14
|
-
test('源码契约:家族环数据源为 sessions.list 快照(零新 RPC)', () => {
|
|
15
|
-
assert.ok(CLIENT_SRC.includes('sessions.list.getSnapshot()'), '家族投影应读会话列表快照')
|
|
16
|
-
assert.ok(CLIENT_SRC.includes('parent.parentId'), '投影应按快照行 parentId 建链(快照字段名与 wire 不同)')
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
test('源码契约:快照行主键为 id(与服务 wire 字段名不同源)', () => {
|
|
20
|
-
const slice = CLIENT_SRC.slice(CLIENT_SRC.indexOf('function sessionFamilyMap'), CLIENT_SRC.indexOf('function familyRing'))
|
|
21
|
-
assert.ok(slice.includes('item.id'), '快照行主键应取 id')
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
test('源码契约:编排派生会话(subagent)剔除出家族投影', () => {
|
|
25
|
-
const slice = CLIENT_SRC.slice(CLIENT_SRC.indexOf('function sessionFamilyMap'), CLIENT_SRC.indexOf('function familyRing'))
|
|
26
|
-
assert.ok(slice.includes("item.origin === 'subagent'"), 'subagent 会话不是用户的 fork 版本,应整行剔除')
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
test('源码契约:单成员家族与快照缺失不注入计数器', () => {
|
|
30
|
-
const slice = CLIENT_SRC.slice(CLIENT_SRC.indexOf('// 家族版本环'), CLIENT_SRC.indexOf('function jumpFamilyMember'))
|
|
31
|
-
assert.ok(slice.includes('ring && ring.total >= 2'), '成员不足 2 不注入')
|
|
32
|
-
assert.ok(slice.includes('actionsRow.querySelector'), '注入前应查重')
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
test('源码契约:箭头跳转经 sessions.open 且不 fork', () => {
|
|
36
|
-
const jump = CLIENT_SRC.slice(CLIENT_SRC.indexOf('function jumpFamilyMember'), CLIENT_SRC.indexOf('if (typeof MutationObserver'))
|
|
37
|
-
assert.ok(jump.includes('openFn(target)'), '跳转应调用 open 通道')
|
|
38
|
-
assert.ok(jump.includes('target !== current.sessionId'), '目标为自身时不切换')
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
test('源码契约:家族环标记与清除齐备(停用开关一并移除)', () => {
|
|
42
|
-
assert.ok(CLIENT_SRC.includes("const FAMILY_RING_FLAG = 'data-cx-family-ring'"), '应有家族环标记常量')
|
|
43
|
-
assert.ok(CLIENT_SRC.includes("querySelectorAll('[' + FORK_BTN_FLAG + ']')"), '清除逻辑存在(环与按钮同排,按钮清除即整排消失)')
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
test('源码契约:loadFamily 通道旧宿主降级返回 null', () => {
|
|
47
|
-
assert.ok(CLIENT_SRC.includes('typeof sessions.list.getSnapshot'), '快照面应以能力探测守门')
|
|
48
|
-
})
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
import test from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
forkFailureText,
|
|
6
|
-
forkRetryText,
|
|
7
|
-
sessionFamilyMap,
|
|
8
|
-
familyRing,
|
|
9
|
-
} from '../src/core.mjs'
|
|
10
|
-
|
|
11
|
-
// ── fork 锚点解析 ──
|
|
12
|
-
|
|
13
|
-
test('Given 插件注入消息, When forkRetryText, Then 返回 null', () => {
|
|
14
|
-
assert.equal(forkRetryText({ source: { kind: 'plugin' }, content: [{ type: 'text', text: 'hi' }] }), null)
|
|
15
|
-
})
|
|
16
|
-
|
|
17
|
-
test('Given 纯图或空白 user 消息, When forkRetryText, Then 返回 null', () => {
|
|
18
|
-
assert.equal(forkRetryText({ source: { kind: 'user' }, content: [{ type: 'image', attachment: {} }] }), null)
|
|
19
|
-
assert.equal(forkRetryText({ source: { kind: 'user' }, content: [{ type: 'text', text: ' ' }] }), null)
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
test('Given undefined 或畸形 data, When forkRetryText, Then 返回 null', () => {
|
|
23
|
-
assert.equal(forkRetryText(undefined), null)
|
|
24
|
-
assert.equal(forkRetryText({}), null)
|
|
25
|
-
assert.equal(forkRetryText({ source: { kind: 'user' } }), null)
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
test('Given 多文本块 user 消息, When forkRetryText, Then 按行拼接且跳过图块', () => {
|
|
29
|
-
const text = forkRetryText({
|
|
30
|
-
source: { kind: 'user' },
|
|
31
|
-
content: [
|
|
32
|
-
{ type: 'text', text: '第一行' },
|
|
33
|
-
{ type: 'image', attachment: {} },
|
|
34
|
-
{ type: 'text', text: '第二行' },
|
|
35
|
-
],
|
|
36
|
-
})
|
|
37
|
-
assert.equal(text, '第一行\n第二行')
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
test('Given user 消息先于 turn/start(无门), When forkRetryText, Then 照常提取', () => {
|
|
41
|
-
assert.equal(forkRetryText({ source: { kind: 'user' }, content: [{ type: 'text', text: '首问' }] }), '首问')
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
// ── 家族谱系投影 ──
|
|
45
|
-
|
|
46
|
-
test('Given 空/畸形输入, When sessionFamilyMap, Then 返回空 Map', () => {
|
|
47
|
-
assert.equal(sessionFamilyMap(undefined).size, 0)
|
|
48
|
-
assert.equal(sessionFamilyMap([]).size, 0)
|
|
49
|
-
assert.equal(sessionFamilyMap([null, {}, { sessionId: '' }]).size, 0)
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
test('Given 直链家族 根→A→B, When sessionFamilyMap, Then 各成员链正确且同根', () => {
|
|
53
|
-
const items = [
|
|
54
|
-
{ sessionId: 'root', updatedAt: 3 },
|
|
55
|
-
{ sessionId: 'a', parentSessionId: 'root', updatedAt: 1 },
|
|
56
|
-
{ sessionId: 'b', parentSessionId: 'a', updatedAt: 2 },
|
|
57
|
-
]
|
|
58
|
-
const chains = sessionFamilyMap(items)
|
|
59
|
-
assert.deepEqual(chains.get('b').chain, ['root', 'a', 'b'])
|
|
60
|
-
assert.equal(chains.get('b').root, 'root')
|
|
61
|
-
assert.equal(chains.get('a').root, 'root')
|
|
62
|
-
assert.equal(chains.get('root').root, 'root')
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
test('Given parentSessionId 指向不存在的会话(孤儿), When sessionFamilyMap, Then 视为独立根', () => {
|
|
66
|
-
const chains = sessionFamilyMap([{ sessionId: 'x', parentSessionId: 'ghost' }])
|
|
67
|
-
assert.equal(chains.get('x').root, 'x')
|
|
68
|
-
assert.deepEqual(chains.get('x').chain, ['x'])
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
test('Given 循环引用, When sessionFamilyMap, Then 不死循环', () => {
|
|
72
|
-
const chains = sessionFamilyMap([
|
|
73
|
-
{ sessionId: 'p', parentSessionId: 'q' },
|
|
74
|
-
{ sessionId: 'q', parentSessionId: 'p' },
|
|
75
|
-
])
|
|
76
|
-
assert.ok(chains.get('p').chain.length <= 2)
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
test('Given 编排派生会话(origin=subagent)挂在根下, When sessionFamilyMap, Then subagent 行剔除且不计数', () => {
|
|
80
|
-
const chains = sessionFamilyMap([
|
|
81
|
-
{ sessionId: 'root', updatedAt: 30 },
|
|
82
|
-
{ sessionId: 'task', parentSessionId: 'root', origin: 'subagent', updatedAt: 20 },
|
|
83
|
-
{ sessionId: 'fork', parentSessionId: 'root', updatedAt: 10 },
|
|
84
|
-
])
|
|
85
|
-
assert.equal(chains.has('task'), false)
|
|
86
|
-
const ring = familyRing(chains, 'root')
|
|
87
|
-
assert.deepEqual(ring, { index: 2, total: 2, members: ['fork', 'root'] })
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
test('Given 整链均为 subagent, When familyRing, Then 单成员返回 null', () => {
|
|
91
|
-
const chains = sessionFamilyMap([{ sessionId: 'task', origin: 'subagent' }])
|
|
92
|
-
assert.equal(familyRing(chains, 'task'), null)
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
// ── 家族环计数 ──
|
|
96
|
-
|
|
97
|
-
test('Given 家族 3 成员, When familyRing(中间 updatedAt), Then index=2 total=3 members 按 updatedAt 升序', () => {
|
|
98
|
-
const chains = sessionFamilyMap([
|
|
99
|
-
{ sessionId: 'root', updatedAt: 30 },
|
|
100
|
-
{ sessionId: 'a', parentSessionId: 'root', updatedAt: 10 },
|
|
101
|
-
{ sessionId: 'b', parentSessionId: 'root', updatedAt: 20 },
|
|
102
|
-
])
|
|
103
|
-
const ring = familyRing(chains, 'b')
|
|
104
|
-
assert.deepEqual(ring, { index: 2, total: 3, members: ['a', 'b', 'root'] })
|
|
105
|
-
})
|
|
106
|
-
|
|
107
|
-
test('Given 单成员家族, When familyRing, Then 返回 null', () => {
|
|
108
|
-
const chains = sessionFamilyMap([{ sessionId: 'only' }])
|
|
109
|
-
assert.equal(familyRing(chains, 'only'), null)
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
test('Given 目标不在投影内, When familyRing, Then 返回 null', () => {
|
|
113
|
-
assert.equal(familyRing(new Map(), 'nope'), null)
|
|
114
|
-
assert.equal(familyRing(undefined, 'nope'), null)
|
|
115
|
-
})
|
|
116
|
-
|
|
117
|
-
test('Given fork 错误码, When forkFailureText, Then 已知码映射中文未知码透出', () => {
|
|
118
|
-
assert.equal(forkFailureText('session/fork-unavailable'), '该轮尚未完成,不可分叉')
|
|
119
|
-
assert.equal(forkFailureText('session/workspace-attach-failed'), '分叉成功,但挂载到工作区失败')
|
|
120
|
-
assert.equal(forkFailureText('gateway/internal'), '分叉失败: gateway/internal')
|
|
121
|
-
assert.equal(forkFailureText(undefined), '分叉失败: 未知错误')
|
|
122
|
-
})
|