@mzzsfy/dsh-context-manager 0.2.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 +2 -10
- package/package.json +2 -2
- package/src/client.js +3 -140
- package/src/core.mjs +0 -54
- package/test/client-help.test.mjs +0 -1
- 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
|
|
|
@@ -30,14 +30,6 @@
|
|
|
30
30
|
- 回填时序:宿主 open 可能先于草稿登记完成子会话挂载,消费侧以有限次延迟重查兜底
|
|
31
31
|
- 分叉后自动重发(开关,默认关):开启时分叉成功即以该轮原输入经 `remote.session.prompt` 发送到子会话立即开跑(重生成语义);prompt 失败仅通知,文本已在输入框可手动发送;旧宿主缺 prompt 通道自动降级为仅回填
|
|
32
32
|
|
|
33
|
-
### 家族版本计数(‹ n/m ›)
|
|
34
|
-
|
|
35
|
-
用户输入气泡操作排的家族版本环:当前会话在同源分叉家族(parentSession 链)中的序位/成员数,`‹ ›` 在家族成员间直接跳转(`sessions.open`,纯切换不分叉)。
|
|
36
|
-
|
|
37
|
-
- 家族判定:`sessions.list` 快照行的 `parentId` 建链投影(wire 形态字段名为 `parentSessionId`,客户端快照行映射为 `parentId`),断链视为独立根;单成员家族不注入
|
|
38
|
-
- 零新 RPC:数据源为既有会话列表快照,环序按 `updatedAt` 升序(与分叉先后一致)
|
|
39
|
-
- 启停:随「对话分叉」开关一并启停(环由同一 dock 注入),宿主缺 sessions.list 快照面不注入
|
|
40
|
-
|
|
41
33
|
### 标题栏复制 sessionId
|
|
42
34
|
|
|
43
35
|
会话标题栏(「标准模式」标签右侧)的复制按钮:点击复制当前会话 id 到剪贴板,便于粘贴给他人排查或脚本引用。
|
|
@@ -48,7 +40,7 @@
|
|
|
48
40
|
|
|
49
41
|
## 启停
|
|
50
42
|
|
|
51
|
-
设置页「插件」→「对话增强」卡片五开关(历史输入/插话撤回/对话分叉/分叉后自动重发/标题栏复制 sessionId),经宿主 settings
|
|
43
|
+
设置页「插件」→「对话增强」卡片五开关(历史输入/插话撤回/对话分叉/分叉后自动重发/标题栏复制 sessionId),经宿主 settings 持久,变更刷新页面生效。
|
|
52
44
|
|
|
53
45
|
## 依赖与门控
|
|
54
46
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mzzsfy/dsh-context-manager",
|
|
3
|
-
"description": "对话增强:历史输入浮层(范围导航/搜索/subagent排除)、常用提示词收藏、插话撤回、对话 fork(
|
|
4
|
-
"version": "0.2.
|
|
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
|
@@ -34,11 +34,6 @@ window.__ModuleLoader__.load({
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
const CSS = [
|
|
37
|
-
// 家族版本环:与官方 28px icon 按钮同高同隙,chevron 用官方线形 svg,
|
|
38
|
-
// label 小号 caption 色,不另起风格
|
|
39
|
-
'.cx-family-ring { display:inline-flex; align-items:center; gap:2px; }',
|
|
40
|
-
'.cx-family-ring .cx-family-ring__label { font:var(--dsw-font-xxs-12); color:var(--dsw-alias-label-caption);',
|
|
41
|
-
' min-width:24px; text-align:center; line-height:28px; }',
|
|
42
37
|
// 开关行(规约 switch 形态):track 胶囊 + thumb 圆点,状态选择器锚定 checkbox
|
|
43
38
|
'.cx-switch { display:inline-flex; align-items:center; gap:8px; margin-top:10px; cursor:pointer;',
|
|
44
39
|
' font:var(--dsw-font-xxs-12, 12px/18px sans-serif); color:var(--dsw-alias-label-caption, rgba(127,127,127,.9)); }',
|
|
@@ -241,50 +236,6 @@ function forkRetryText(data) {
|
|
|
241
236
|
return text.trim() === '' ? null : text
|
|
242
237
|
}
|
|
243
238
|
|
|
244
|
-
// 家族谱系投影镜像(与 core.mjs sessionFamilyMap/familyRing 同步维护):
|
|
245
|
-
// 客户端快照行主键为 id、父引用为 parentId(与 RPC wire 的 sessionId/parentSessionId 不同名),
|
|
246
|
-
// 编排派生会话(origin === 'subagent')剔除——家族只统计用户的 fork 分支;
|
|
247
|
-
// 断链视为独立根;环序按 updatedAt 升序
|
|
248
|
-
function sessionFamilyMap(items) {
|
|
249
|
-
const byId = new Map()
|
|
250
|
-
for (const item of Array.isArray(items) ? items : []) {
|
|
251
|
-
if (item && item.origin === 'subagent') continue
|
|
252
|
-
const id = item && item.id
|
|
253
|
-
if (typeof id === 'string' && id !== '') byId.set(id, item)
|
|
254
|
-
}
|
|
255
|
-
const chains = new Map()
|
|
256
|
-
for (const id of byId.keys()) {
|
|
257
|
-
const chain = []
|
|
258
|
-
let cursor = id
|
|
259
|
-
let root = cursor
|
|
260
|
-
while (cursor !== undefined) {
|
|
261
|
-
chain.unshift(cursor)
|
|
262
|
-
root = cursor
|
|
263
|
-
const parent = byId.get(cursor)
|
|
264
|
-
cursor = parent && typeof parent.parentId === 'string' && byId.has(parent.parentId)
|
|
265
|
-
? parent.parentId
|
|
266
|
-
: undefined
|
|
267
|
-
if (chain.includes(cursor)) break
|
|
268
|
-
}
|
|
269
|
-
chains.set(id, { root, chain, item: byId.get(id) })
|
|
270
|
-
}
|
|
271
|
-
return chains
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
function familyRing(chains, sessionId) {
|
|
275
|
-
const entry = chains instanceof Map ? chains.get(sessionId) : undefined
|
|
276
|
-
if (!entry) return null
|
|
277
|
-
const members = [...chains.entries()]
|
|
278
|
-
.filter(([, value]) => value.root === entry.root)
|
|
279
|
-
.sort((left, right) => {
|
|
280
|
-
const at = (record) => (record && record.item && typeof record.item.updatedAt === 'number' ? record.item.updatedAt : 0)
|
|
281
|
-
return at(left[1]) - at(right[1])
|
|
282
|
-
})
|
|
283
|
-
.map(([id]) => id)
|
|
284
|
-
if (members.length < 2) return null
|
|
285
|
-
return { index: members.indexOf(sessionId) + 1, total: members.length, members }
|
|
286
|
-
}
|
|
287
|
-
|
|
288
239
|
function HistoryDock({ session, inputActions }) {
|
|
289
240
|
const [open, setOpen] = useState(false)
|
|
290
241
|
const [items, setItems] = useState(null)
|
|
@@ -751,8 +702,6 @@ function forkSvg() {
|
|
|
751
702
|
// 注入按钮标记:识别自家节点与官方按钮,防止重复注入与误删官方节点
|
|
752
703
|
const STEER_BTN_FLAG = 'data-cx-steer-recall'
|
|
753
704
|
const FORK_BTN_FLAG = 'data-cx-fork'
|
|
754
|
-
// 家族版本环标记:识别自家 ‹n/m› 计数器节点,防重复注入
|
|
755
|
-
const FAMILY_RING_FLAG = 'data-cx-family-ring'
|
|
756
705
|
// 官方 pending steering 气泡的语义标记(官方同构:UserStyleBubble data 属性)与
|
|
757
706
|
// 其操作图标排的 CSS module 类名后缀(哈希前缀随构建漂移,后缀稳定);
|
|
758
707
|
// 消息节点的轮号标记(fork 锚点输入)
|
|
@@ -883,7 +832,7 @@ function SteerRecallDock({ session, useSession, inputActions, updateQueue }) {
|
|
|
883
832
|
// 轮号无映射 = 该轮未完成,首问无文本(纯图等)= 无法重试,均不注入;
|
|
884
833
|
// 首轮无前锚(宿主 fork 边界必须落在 turn/end 上,复制零事件不可表达),也不注入。
|
|
885
834
|
// 分叉动作经宿主 sessions 服务面 fork+open(与官方 chat 同构),成功 toast 并切换
|
|
886
|
-
function ForkDockWithBootstrap({ session, inputActions, forkSession, cancelSession,
|
|
835
|
+
function ForkDockWithBootstrap({ session, inputActions, forkSession, cancelSession, submitPrompt, loadTurnEnds }) {
|
|
887
836
|
// 依赖键 = 会话 id:session 快照身份随每次投影更新漂移,不能作 effect 依赖;
|
|
888
837
|
// loadTurnEnds 闭包身份同样不稳定,经 ref 取用
|
|
889
838
|
const sessionId = session && session.sessionId
|
|
@@ -936,17 +885,15 @@ function ForkDockWithBootstrap({ session, inputActions, forkSession, cancelSessi
|
|
|
936
885
|
}
|
|
937
886
|
return undefined
|
|
938
887
|
}, [sessionId, inputActions, draftPollTick])
|
|
939
|
-
return h(ForkDock, { session, forkSession, cancelSession,
|
|
888
|
+
return h(ForkDock, { session, forkSession, cancelSession, turnEnds })
|
|
940
889
|
}
|
|
941
890
|
|
|
942
|
-
function ForkDock({ session, forkSession, cancelSession,
|
|
891
|
+
function ForkDock({ session, forkSession, cancelSession, turnEnds }) {
|
|
943
892
|
const [enabled, setEnabled] = useState(true)
|
|
944
893
|
const [autoResend, setAutoResend] = useState(false)
|
|
945
894
|
const turnEndsRef = useRef(null)
|
|
946
895
|
const forkRef = useRef(null)
|
|
947
896
|
const cancelRef = useRef(null)
|
|
948
|
-
const familyRef = useRef(null)
|
|
949
|
-
const openRef = useRef(null)
|
|
950
897
|
const sessionRef = useRef(null)
|
|
951
898
|
const enabledRef = useRef(true)
|
|
952
899
|
const autoResendRef = useRef(false)
|
|
@@ -965,13 +912,10 @@ function ForkDock({ session, forkSession, cancelSession, openSession, loadFamily
|
|
|
965
912
|
turnEndsRef.current = turnEnds
|
|
966
913
|
forkRef.current = forkSession
|
|
967
914
|
cancelRef.current = cancelSession
|
|
968
|
-
familyRef.current = loadFamily
|
|
969
|
-
openRef.current = openSession
|
|
970
915
|
sessionRef.current = session
|
|
971
916
|
useEffect(() => {
|
|
972
917
|
function removeAll() {
|
|
973
918
|
document.querySelectorAll('[' + FORK_BTN_FLAG + ']').forEach((button) => button.remove())
|
|
974
|
-
document.querySelectorAll('[' + FAMILY_RING_FLAG + ']').forEach((node) => node.remove())
|
|
975
919
|
}
|
|
976
920
|
function scan() {
|
|
977
921
|
// 停用即不注入并移除已注入按钮(开关可在挂载后才到达停用值)
|
|
@@ -1053,73 +997,6 @@ function ForkDock({ session, forkSession, cancelSession, openSession, loadFamily
|
|
|
1053
997
|
button.appendChild(forkSvg())
|
|
1054
998
|
actionsRow.appendChild(button)
|
|
1055
999
|
})
|
|
1056
|
-
// 家族版本环:每个用户气泡操作排的 ‹n/m›,箭头在家族成员间跳转;
|
|
1057
|
-
// 单成员家族/快照面缺失不注入(与官方按钮并存,hover 显隐一致)
|
|
1058
|
-
const loadFamilyFn = familyRef.current
|
|
1059
|
-
const ring = (sessionRef.current && typeof loadFamilyFn === 'function')
|
|
1060
|
-
? loadFamilyFn(sessionRef.current.sessionId)
|
|
1061
|
-
: null
|
|
1062
|
-
if (ring && ring.total >= 2) {
|
|
1063
|
-
bubbles.forEach((bubble) => {
|
|
1064
|
-
if (bubble.getAttribute(FLOW_KIND_ATTR) !== 'user') return
|
|
1065
|
-
const actionsRow = bubble.querySelector(STEER_ACTIONS_SUFFIX)
|
|
1066
|
-
if (!actionsRow || actionsRow.querySelector('[' + FAMILY_RING_FLAG + ']')) return
|
|
1067
|
-
const official = actionsRow.querySelector('button:not([' + FORK_BTN_FLAG + ']):not([' + STEER_BTN_FLAG + '])')
|
|
1068
|
-
if (!official) return
|
|
1069
|
-
const ringEl = document.createElement('span')
|
|
1070
|
-
ringEl.setAttribute(FAMILY_RING_FLAG, '')
|
|
1071
|
-
ringEl.className = 'cx-family-ring'
|
|
1072
|
-
ringEl.title = '家族版本:本会话在同源分叉家族中的序位,箭头在各版本间切换'
|
|
1073
|
-
const makeChevron = (direction, tip) => {
|
|
1074
|
-
const chevron = document.createElement('button')
|
|
1075
|
-
chevron.type = 'button'
|
|
1076
|
-
chevron.className = official.className
|
|
1077
|
-
chevron.setAttribute(FAMILY_RING_FLAG, '')
|
|
1078
|
-
chevron.title = tip
|
|
1079
|
-
chevron.setAttribute('aria-label', tip)
|
|
1080
|
-
chevron.addEventListener('click', () => jumpFamilyMember(direction))
|
|
1081
|
-
chevron.appendChild(familyChevronSvg(direction))
|
|
1082
|
-
return chevron
|
|
1083
|
-
}
|
|
1084
|
-
const label = document.createElement('span')
|
|
1085
|
-
label.setAttribute(FAMILY_RING_FLAG, '')
|
|
1086
|
-
label.className = 'cx-family-ring__label'
|
|
1087
|
-
label.textContent = ring.index + '/' + ring.total
|
|
1088
|
-
ringEl.appendChild(makeChevron(-1, '上一个家族版本'))
|
|
1089
|
-
ringEl.appendChild(label)
|
|
1090
|
-
ringEl.appendChild(makeChevron(1, '下一个家族版本'))
|
|
1091
|
-
actionsRow.appendChild(ringEl)
|
|
1092
|
-
})
|
|
1093
|
-
}
|
|
1094
|
-
}
|
|
1095
|
-
// 家族环 chevron 图标:与官方 outline chevron 同款线形(12px 视窗)
|
|
1096
|
-
function familyChevronSvg(direction) {
|
|
1097
|
-
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
|
1098
|
-
svg.setAttribute('width', '12')
|
|
1099
|
-
svg.setAttribute('height', '12')
|
|
1100
|
-
svg.setAttribute('viewBox', '0 0 14 14')
|
|
1101
|
-
svg.setAttribute('fill', 'none')
|
|
1102
|
-
svg.setAttribute('aria-hidden', 'true')
|
|
1103
|
-
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
|
1104
|
-
path.setAttribute('d', direction < 0 ? 'M8.75 3.5L5.25 7L8.75 10.5' : 'M5.25 3.5L8.75 7L5.25 10.5')
|
|
1105
|
-
path.setAttribute('stroke', 'currentColor')
|
|
1106
|
-
path.setAttribute('stroke-width', '1.2')
|
|
1107
|
-
path.setAttribute('stroke-linecap', 'round')
|
|
1108
|
-
path.setAttribute('stroke-linejoin', 'round')
|
|
1109
|
-
svg.appendChild(path)
|
|
1110
|
-
return svg
|
|
1111
|
-
}
|
|
1112
|
-
function jumpFamilyMember(offset) {
|
|
1113
|
-
const loadFamilyFn = familyRef.current
|
|
1114
|
-
const openFn = openRef.current
|
|
1115
|
-
const current = sessionRef.current
|
|
1116
|
-
if (!current || typeof loadFamilyFn !== 'function' || typeof openFn !== 'function') return
|
|
1117
|
-
const ring = loadFamilyFn(current.sessionId)
|
|
1118
|
-
if (!ring) return
|
|
1119
|
-
const nextIndex = ring.index - 1 + offset
|
|
1120
|
-
if (nextIndex < 0 || nextIndex >= ring.members.length) return
|
|
1121
|
-
const target = ring.members[nextIndex]
|
|
1122
|
-
if (target && target !== current.sessionId) openFn(target)
|
|
1123
1000
|
}
|
|
1124
1001
|
if (typeof MutationObserver === 'undefined' || typeof document.querySelectorAll !== 'function') return undefined
|
|
1125
1002
|
const scanRef = { current: scan }
|
|
@@ -1290,18 +1167,6 @@ function ContextPanel() {
|
|
|
1290
1167
|
})
|
|
1291
1168
|
: null
|
|
1292
1169
|
|
|
1293
|
-
// 家族环通道:会话列表快照(parentSessionId 字段)投影为当前会话的 ‹n/m› 计数;
|
|
1294
|
-
// 快照面缺失(旧宿主)时返回 null,计数器整体不注入
|
|
1295
|
-
const loadFamily = (sessionId) => {
|
|
1296
|
-
if (!sessions || !sessions.list || typeof sessions.list.getSnapshot !== 'function') return null
|
|
1297
|
-
const snapshot = sessions.list.getSnapshot()
|
|
1298
|
-
const rows = snapshot && snapshot.byId
|
|
1299
|
-
? (Array.isArray(snapshot.ids) ? snapshot.ids : []).map((id) => snapshot.byId[id]).filter(Boolean)
|
|
1300
|
-
: (Array.isArray(snapshot) ? snapshot : [])
|
|
1301
|
-
if (rows.length === 0) return null
|
|
1302
|
-
return familyRing(sessionFamilyMap(rows), sessionId)
|
|
1303
|
-
}
|
|
1304
|
-
|
|
1305
1170
|
// 自动重发通道:开启开关时分叉后以该轮原输入 prompt 子会话;
|
|
1306
1171
|
// remote.session 缺失或缺 prompt(旧宿主/部分 stub)时返回 null,降级为仅回填;
|
|
1307
1172
|
// 请求形态与官方 client face 同源:content 为 text 分段数组(宿主 hasPromptContent
|
|
@@ -1387,8 +1252,6 @@ function ContextPanel() {
|
|
|
1387
1252
|
cancelSession: (typeof remoteSession.cancel === 'function')
|
|
1388
1253
|
? (opts) => remoteSession.cancel(opts)
|
|
1389
1254
|
: null,
|
|
1390
|
-
openSession: (sessions && typeof sessions.open === 'function') ? (id) => sessions.open(id) : null,
|
|
1391
|
-
loadFamily: () => loadFamily(sessionId),
|
|
1392
1255
|
submitPrompt,
|
|
1393
1256
|
loadTurnEnds: () => followOpening(remoteSession, sessionId).then((records) => {
|
|
1394
1257
|
// 轮号 → { 结束 seq, 该轮首问文本, open }:上个 turn/end 之后首条
|
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
|
-
}
|
|
@@ -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
|
|
@@ -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
|
-
})
|