@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-context-manager
2
2
 
3
- 对话增强插件:历史输入回溯、插话撤回、对话分叉(家族版本切换/进行中轮分叉/自动重发)、标题栏复制 sessionId。dsh-session-manager 历史输入功能的独立延续包(原包已移除该功能)。
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.0",
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, openSession, loadFamily, submitPrompt, loadTurnEnds }) {
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, openSession, loadFamily, turnEnds })
888
+ return h(ForkDock, { session, forkSession, cancelSession, turnEnds })
940
889
  }
941
890
 
942
- function ForkDock({ session, forkSession, cancelSession, openSession, loadFamily, turnEnds }) {
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
- })