@mzzsfy/dsh-context-manager 0.2.0 → 0.3.0

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/src/core.mjs CHANGED
@@ -17,6 +17,9 @@ export const HISTORY_INPUT_LIMIT = 200
17
17
  export const HISTORY_INPUT_MAX_CHARS = 20 * 1000
18
18
  export const HISTORY_SCOPES = ['prompts', 'session', 'workspace', 'global']
19
19
  export const HISTORY_PROMPTS_MAX = 100
20
+ // 提取器输出格式版本:升级即令全部 extracts 指纹缓存失效重解压(自愈补齐新格式条目);
21
+ // 已知边界——超 MAX_ARTIFACT 的巨产物会话对齐时直接跳过,其旧版条目不随升级补齐
22
+ export const HISTORY_EXTRACT_VERSION = 2
20
23
  export const HISTORY_ALIGN_THROTTLE_MS = 30 * 1000
21
24
  export const HISTORY_STARTUP_DELAY_MS = 30 * 1000
22
25
  export const HISTORY_STARTUP_SCAN_LIMIT = 100
@@ -53,15 +56,24 @@ export function isSessionRunning({ agents, sessionId }) {
53
56
  }
54
57
 
55
58
  /**
56
- * 从会话事件流提取人类输入:仅 user/message 且来源为用户本人,
57
- * 文本块按行拼接;工具结果回填、插件注入与空白输入(纯图/空/纯空白)均不产出。
59
+ * 从会话事件流提取人类输入:user/message(来源为用户本人,文本块按行拼接)与
60
+ * command/run(斜杠命令,重组为 /name+args;args 是命令名后 verbatim rawInput,
61
+ * 含分隔空白,recordInput: false 的命令无 args 产出裸命令名)。
62
+ * 工具结果回填、插件注入与空白输入(纯图/空/纯空白)均不产出。
58
63
  * @param events - readSession 返回的原始事件数组
59
64
  * @returns 条目 { text, at },时间取事件时间
60
65
  */
61
66
  export function extractUserInputs(events) {
62
67
  const entries = []
63
68
  for (const event of events || []) {
64
- if (!event || event.type !== 'user/message') continue
69
+ if (!event) continue
70
+ if (event.type === 'command/run') {
71
+ const data = event.data
72
+ if (!data || !data.source || data.source.kind !== 'user' || typeof data.name !== 'string' || data.name === '') continue
73
+ entries.push({ text: '/' + data.name + (typeof data.args === 'string' ? data.args : ''), at: event.time })
74
+ continue
75
+ }
76
+ if (event.type !== 'user/message') continue
65
77
  const message = event.data
66
78
  if (!message || !message.source || message.source.kind !== 'user') continue
67
79
  const text = (message.content || [])
@@ -144,57 +156,3 @@ export function forkRetryText(data) {
144
156
  .join('\n')
145
157
  return text.trim() === '' ? null : text
146
158
  }
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,6 +1,6 @@
1
1
  // dsh-context-manager Host 半区:历史输入工作区缓存对齐 + 启停/数据路由
2
- // (inputs / prompts/toggle 与 history/steer-recall/fork/fork-auto-resend/copy-sid
3
- // 五条启停)。
2
+ // (inputs / prompts/toggle 与 history/history-button/steer-recall/fork/fork-auto-resend/copy-sid
3
+ // 六条启停)。
4
4
  // 浮层请求直接读缓存文件立即返回;对齐在后台解压范围内会话产物与缓存合并写回,
5
5
  // 运行中会话也参与(实时追加其新输入)。fork 分叉动作走 client 服务面,
6
6
  // host 侧只有其启停路由。
@@ -19,6 +19,7 @@ import {
19
19
  HISTORY_FOCUS_WAIT_MS,
20
20
  HISTORY_INPUT_LIMIT,
21
21
  HISTORY_INPUT_MAX_CHARS,
22
+ HISTORY_EXTRACT_VERSION,
22
23
  HISTORY_PROMPTS_MAX,
23
24
  HISTORY_SCOPES,
24
25
  HISTORY_SESSION_SCAN_LIMIT,
@@ -48,6 +49,8 @@ export const MESSAGES = {
48
49
  const SETTINGS_SCHEMA = schemastery.object({
49
50
  historyEnabled: schemastery.boolean().default(true)
50
51
  .description('历史输入浮层(Alt+↑),低频功能,关闭后输入框锚点与快捷键整体不渲染;变更刷新页面生效'),
52
+ historyButtonEnabled: schemastery.boolean().default(true)
53
+ .description('输入框工具排的历史输入按钮,点击开关浮层,与 Alt+↑ 等效;关闭仅隐藏按钮,快捷键与浮层不受影响;变更刷新页面生效'),
51
54
  steerRecallEnabled: schemastery.boolean().default(true)
52
55
  .description('插话撤回图标,低频功能,关闭后撤回图标整体不渲染;变更刷新页面生效'),
53
56
  forkEnabled: schemastery.boolean().default(true)
@@ -79,6 +82,7 @@ function readSettings(ctx) {
79
82
  const value = settings ? settings.get(NAMESPACE) : undefined
80
83
  return {
81
84
  historyEnabled: !value || value.historyEnabled !== false,
85
+ historyButtonEnabled: !value || value.historyButtonEnabled !== false,
82
86
  steerRecallEnabled: !value || value.steerRecallEnabled !== false,
83
87
  forkEnabled: !value || value.forkEnabled !== false,
84
88
  forkAutoResendEnabled: Boolean(value && value.forkAutoResendEnabled === true),
@@ -176,16 +180,18 @@ export function apply(ctx, config) {
176
180
  }
177
181
 
178
182
  // 单会话提取:产物 stat 指纹与缓存一致即复用(零解压);否则解压并更新缓存。
179
- // 运行中会话走内存指纹(runtimeExtracts),其余走磁盘 extracts
183
+ // 运行中会话走内存指纹(runtimeExtracts),其余走磁盘 extracts。
184
+ // 指纹之外校验提取器版本:版本不符即重解压(升级后旧条目自动补齐新格式)
180
185
  async function extractSession(recordItem, fingerprint, extracts, isRunning) {
181
186
  const sessionId = recordItem.header.id
182
187
  const known = isRunning ? runtimeExtracts.get(sessionId) : extracts[sessionId]
183
- if (fingerprint !== null && known && known.mtimeMs === fingerprint.mtimeMs && known.size === fingerprint.size) {
188
+ if (fingerprint !== null && known && known.v === HISTORY_EXTRACT_VERSION
189
+ && known.mtimeMs === fingerprint.mtimeMs && known.size === fingerprint.size) {
184
190
  return known.entries
185
191
  }
186
192
  const snapshot = await ctx.sessionQuery.readSession(sessionId)
187
193
  const entries = extractUserInputs(snapshot.events)
188
- const record = { mtimeMs: fingerprint === null ? 0 : fingerprint.mtimeMs, size: fingerprint === null ? 0 : fingerprint.size, entries }
194
+ const record = { v: HISTORY_EXTRACT_VERSION, mtimeMs: fingerprint === null ? 0 : fingerprint.mtimeMs, size: fingerprint === null ? 0 : fingerprint.size, entries }
189
195
  if (isRunning) runtimeExtracts.set(sessionId, record)
190
196
  else if (fingerprint !== null) extracts[sessionId] = record
191
197
  return entries
@@ -339,9 +345,10 @@ export function apply(ctx, config) {
339
345
  }
340
346
  }
341
347
  // 实时追加:产物 stat 与已知指纹不一致 = 会话有新输入未入缓存——聚焦对齐
342
- // 只解该会话并写回。路由有界等待本次对齐(首条 <3s 预算):正常会话单次
343
- // 请求内直接拿到新数据;超时(巨产物等)返回既有数据 aligned=false,后续
344
- // 轮询流式补齐。巨产物跳过聚焦(解压耗时与内存峰值失控),维持 aligned=true
348
+ // 只解该会话并写回。提取器版本不符同判 stale(升级后旧格式条目在首请求
349
+ // 内经聚焦对齐补齐,不等后台批量)。路由有界等待本次对齐(首条 <3s 预算):
350
+ // 正常会话单次请求内直接拿到新数据;超时(巨产物等)返回既有数据 aligned=false,
351
+ // 后续轮询流式补齐。巨产物跳过聚焦(解压耗时与内存峰值失控),维持 aligned=true
345
352
  const check = async (cached) => {
346
353
  const agents = ctx.get('agents')
347
354
  const running = isSessionRunning({ agents, sessionId })
@@ -349,7 +356,7 @@ export function apply(ctx, config) {
349
356
  const located = ctx.get('sessionPersistence') ? ctx.get('sessionPersistence').locate(header) : undefined
350
357
  const fingerprint = located ? await safeStat(located.path) : null
351
358
  return {
352
- stale: fingerprint !== null && !(known && known.mtimeMs === fingerprint.mtimeMs && known.size === fingerprint.size),
359
+ stale: fingerprint !== null && !(known && known.v === HISTORY_EXTRACT_VERSION && known.mtimeMs === fingerprint.mtimeMs && known.size === fingerprint.size),
353
360
  oversize: fingerprint !== null && fingerprint.size > maxArtifactBytes,
354
361
  }
355
362
  }
@@ -466,6 +473,32 @@ export function apply(ctx, config) {
466
473
  }
467
474
  },
468
475
  },
476
+ {
477
+ // 输入框历史按钮启停:与历史浮层开关同构,读走 settings,写经 settings.update
478
+ path: '/api/context/history-button-enabled',
479
+ handler: async (req, res) => {
480
+ try {
481
+ if (req.method === 'GET') {
482
+ sendJson(res, 200, { enabled: readSettings(ctx).historyButtonEnabled })
483
+ return
484
+ }
485
+ if (!rejectMethod(req, res, 'POST')) return
486
+ let body
487
+ try {
488
+ body = JSON.parse(await readBody(req))
489
+ } catch {
490
+ throw new Error(MESSAGES.badJsonBody)
491
+ }
492
+ const settings = ctx.get('settings')
493
+ if (!settings) throw new Error('宿主设置服务不可用')
494
+ // update 异步落盘后才提交新值:await 保证持久化完成后再读回
495
+ await settings.update(NAMESPACE, { historyButtonEnabled: Boolean(body && body.enabled) })
496
+ sendJson(res, 200, { enabled: readSettings(ctx).historyButtonEnabled })
497
+ } catch (error) {
498
+ respondError(ctx, res, error)
499
+ }
500
+ },
501
+ },
469
502
  {
470
503
  // 插话撤回启停:与历史浮层开关同构,读走 settings,写经 settings.update
471
504
  path: '/api/context/steer-recall-enabled',
@@ -33,7 +33,8 @@ test('源码契约:进行中轮的按钮说明标注进行中语义', () => {
33
33
  // F3 分叉后自动重发 的客户端契约:
34
34
  // - 开关经 fork-auto-resend-enabled 路由拉取,默认关
35
35
  // - 点击登记的草稿带 autoSubmit(跟随开关)
36
- // - 消费侧 autoSubmit 时经 prompt 通道提交,prompt 失败仅 toast
36
+ // - 消费侧 autoSubmit 时经 prompt 通道提交,提交即无草稿(官方发送语义:发送后输入框
37
+ // 不残留原文,残留会诱导用户再次发送造成重复消息);prompt 失败才回填文本供手动重发
37
38
  // - prompt 不可用(旧宿主)时 submitPrompt 为 null,自动降级仅回填
38
39
 
39
40
  test('源码契约:自动重发开关经新路由拉取且默认关', () => {
@@ -41,11 +42,16 @@ test('源码契约:自动重发开关经新路由拉取且默认关', () => {
41
42
  assert.ok(CLIENT_SRC.includes('setAutoResend(Boolean(payload && payload.enabled === true))'), '开关默认必须是关(严格 true 才开)')
42
43
  })
43
44
 
44
- test('源码契约:autoSubmit 草稿消费时回填后经 prompt 提交', () => {
45
+ test('源码契约:autoSubmit 消费即提交不留草稿,失败才回填', () => {
45
46
  const consume = CLIENT_SRC.slice(CLIENT_SRC.indexOf('const draft = pendingForkDrafts.get(sessionId)'), CLIENT_SRC.indexOf('return h(ForkDock'))
46
- assert.ok(consume.includes('inputActions.setDraft(text)'), '回填必须先行(prompt 前,失败也有兜底文本)')
47
- assert.ok(consume.includes('submitRef.current({ sessionId, text })'), 'autoSubmit 时应经 prompt 通道提交')
48
- assert.ok(consume.includes("toast('自动重发失败"), 'prompt 失败应 toast 且文本保留')
47
+ const autoIdx = consume.indexOf('if (autoSubmit && submitRef.current)')
48
+ assert.ok(autoIdx >= 0, 'autoSubmit 且通道可用时应走提交分支')
49
+ const draftIdx = consume.indexOf('inputActions.setDraft(text)')
50
+ assert.ok(draftIdx > autoIdx, '回填不得先于 prompt(提交成功路径输入框必须无残留)')
51
+ const autoBranch = consume.slice(autoIdx)
52
+ assert.ok(autoBranch.includes('submitRef.current({ sessionId, text })'), 'autoSubmit 时应经 prompt 通道提交')
53
+ assert.ok(autoBranch.includes("toast('自动重发失败"), 'prompt 失败应 toast 提示')
54
+ assert.ok(autoBranch.indexOf('setDraft(text)') > autoBranch.indexOf('catch'), '失败兜底回填必须落在 catch 分支内')
49
55
  })
50
56
 
51
57
  test('源码契约:prompt 通道参数形态(requestId 幂等 + queue + text 段)', () => {
@@ -58,3 +64,62 @@ test('源码契约:prompt 通道参数形态(requestId 幂等 + queue + text 段
58
64
  test('源码契约:旧宿主无 prompt 时通道降级为 null(仅回填)', () => {
59
65
  assert.ok(CLIENT_SRC.includes("(remoteSession && typeof remoteSession.prompt === 'function')"), 'prompt 通道应以能力探测守门(含面缺失)')
60
66
  })
67
+
68
+ // F4 回填不得覆盖用户输入 的客户端契约(实测 bug:点分叉后 200ms 内用户输入被 setDraft 吞掉;
69
+ // 且消费删除草稿后 effect 依赖变化再跑一次,取到 undefined 走进 setDraft("") 空写清空输入框):
70
+ // - 消费即删草稿后,该会话必须标记为「已消费」,effect 重跑直接短路,绝不空写
71
+ // - 回填走单次投递:非空输入框不覆盖(用户已输入优先),改为 toast 提示
72
+
73
+ test('源码契约:草稿消费后不得再对同会话写草稿(杜绝空写清空)', () => {
74
+ const consume = CLIENT_SRC.slice(CLIENT_SRC.indexOf('const draft = pendingForkDrafts.get(sessionId)'), CLIENT_SRC.indexOf('return h(ForkDock'))
75
+ const guardIdx = CLIENT_SRC.indexOf('consumedForkDrafts.has(sessionId)')
76
+ const addIdx = CLIENT_SRC.indexOf('consumedForkDrafts.add(sessionId)')
77
+ assert.ok(guardIdx >= 0, '消费后应经 consumedForkDrafts 短路重跑')
78
+ assert.ok(addIdx >= 0, '删除草稿的同时应登记已消费')
79
+ assert.ok(guardIdx < addIdx, '短路判断必须先于登记(登记发生在有草稿分支内)')
80
+ assert.ok(guardIdx < CLIENT_SRC.indexOf('const draft = pendingForkDrafts.get(sessionId)'), '短路必须先于取草稿')
81
+ assert.ok(consume.includes('consumedForkDrafts.add(sessionId)'), '登记应落在消费分支内')
82
+ })
83
+
84
+ test('源码契约:回填前经 useInput 快照探测输入框,非空不覆盖', () => {
85
+ const consume = CLIENT_SRC.slice(CLIENT_SRC.indexOf('const draft = pendingForkDrafts.get(sessionId)'), CLIENT_SRC.indexOf('return h(ForkDock'))
86
+ assert.ok(consume.includes('draftOccupied'), '应经 useInput 快照判定输入框是否已被用户占用')
87
+ assert.ok(consume.includes('原输入未回填'), '被占用时应 toast 说明未回填')
88
+ assert.ok(consume.includes('原输入未自动发送'), '自动重发被占用时应 toast 说明未发送')
89
+ assert.ok(consume.indexOf('draftOccupied') < consume.indexOf('inputActions.setDraft(text)'), '占用探测必须先于回填')
90
+ })
91
+
92
+ // F5 新轮注入 的客户端契约(实测 bug:轮号映射拉取一次即冻结,新轮 turn 不在映射中,
93
+ // 注入资格判空后直接跳过,按钮永不出现,必须刷新页面重拉才恢复):
94
+ // - 扫描发现「有轮号但映射无此轮」时必须重拉映射,而不是静默跳过
95
+ // - 重拉必须有在途门控,避免 MutationObserver 高频触发造成请求风暴
96
+ // - 重拉结果与在途基线对比,相同则不再 setState(否则 setState→重渲染→扫描无限循环)
97
+
98
+ test('源码契约:映射缺失的轮触发重拉而非静默跳过', () => {
99
+ const scan = CLIENT_SRC.slice(CLIENT_SRC.indexOf('function scan() {'), CLIENT_SRC.indexOf('if (typeof MutationObserver'))
100
+ assert.ok(scan.includes('entry === undefined'), '扫描应识别映射缺失的轮')
101
+ assert.ok(scan.includes('refreshRef.current()'), '映射缺失时应触发重拉')
102
+ })
103
+
104
+ test('源码契约:重拉有在途门控', () => {
105
+ const refresh = CLIENT_SRC.slice(CLIENT_SRC.indexOf('refreshRef.current = () =>'), CLIENT_SRC.indexOf('loadRef.current()\n .then((map) => {\n if (disposed) return'))
106
+ assert.ok(CLIENT_SRC.includes('refreshPendingRef'), '重拉必须有在途门控标志')
107
+ assert.ok(refresh.includes('if (refreshPendingRef.current) return'), '门控判断应位于重拉入口首位')
108
+ assert.ok(refresh.includes('refreshPendingRef.current = true'), '进入重拉即置在途')
109
+ assert.ok(refresh.includes('refreshPendingRef.current = false'), '重拉结束应清在途(含失败路径)')
110
+ })
111
+
112
+ test('源码契约:重拉结果与基线相同则不 setState(防扫描循环)', () => {
113
+ const refresh = CLIENT_SRC.slice(CLIENT_SRC.indexOf('refreshRef.current = () =>'), CLIENT_SRC.indexOf('loadRef.current()\n .then((map) => {\n if (disposed) return'))
114
+ assert.ok(refresh.includes('setTurnEnds'), '重拉应更新映射')
115
+ assert.ok(refresh.includes('if (turnEndsRef.current === map) return'), '同一映射引用应短路,避免 setState 触发扫描循环')
116
+ })
117
+
118
+ test('源码契约:重拉入口经 ref 跨组件传递(Bootstrap 装配,扫描处调用)', () => {
119
+ assert.ok(CLIENT_SRC.includes('const refreshRef = useRef(null)'), 'ForkDock 侧应有 refreshRef')
120
+ assert.ok(CLIENT_SRC.includes('refreshRef.current = () => {'), 'Bootstrap 应装配重拉入口')
121
+ })
122
+
123
+ test('源码契约:fork dock 订阅官方 input 快照取草稿', () => {
124
+ assert.ok(CLIENT_SRC.includes('useInput'), 'dock 应订阅宿主注入的 useInput 快照')
125
+ })
@@ -8,9 +8,10 @@ const CLIENT_SRC = readFileSync(fileURLToPath(new URL('../src/client.js', import
8
8
  // 设置项悬停说明契约:说明常量必须存在且逐项挂到对应开关(开关行工厂调用位)。
9
9
  // 断言以工厂调用为锚:行文本与说明常量必须出现在同一 switchRow(...) 调用内,
10
10
  // 防常量互换挂载或挂错行仍通过
11
- test('源码契约:三项设置的悬停说明齐备且挂到对应控件', () => {
11
+ test('源码契约:六项设置的悬停说明齐备且挂到对应控件', () => {
12
12
  const TITLES = {
13
13
  HISTORY_SWITCH_TITLE: '历史输入浮层开关',
14
+ HISTORY_BUTTON_SWITCH_TITLE: '输入框历史按钮开关',
14
15
  STEER_SWITCH_TITLE: '插话撤回开关',
15
16
  FORK_SWITCH_TITLE: '对话 fork 开关',
16
17
  FORK_AUTO_RESEND_SWITCH_TITLE: '分叉后自动重发开关',
@@ -20,6 +21,7 @@ test('源码契约:三项设置的悬停说明齐备且挂到对应控件', () =
20
21
  }
21
22
  const rowAnchors = [
22
23
  { constant: 'HISTORY_SWITCH_TITLE', text: "'历史输入浮层(Alt+↑)'", label: TITLES.HISTORY_SWITCH_TITLE },
24
+ { constant: 'HISTORY_BUTTON_SWITCH_TITLE', text: "'输入框历史按钮'", label: TITLES.HISTORY_BUTTON_SWITCH_TITLE },
23
25
  { constant: 'STEER_SWITCH_TITLE', text: "'插话撤回'", label: TITLES.STEER_SWITCH_TITLE },
24
26
  { constant: 'FORK_SWITCH_TITLE', text: "'对话 fork'", label: TITLES.FORK_SWITCH_TITLE },
25
27
  { constant: 'FORK_AUTO_RESEND_SWITCH_TITLE', text: "'分叉后自动重发'", label: TITLES.FORK_AUTO_RESEND_SWITCH_TITLE },
@@ -44,7 +46,6 @@ test('源码契约:悬停说明覆盖各设置的关键行为语义', () => {
44
46
  assert.ok(CLIENT_SRC.includes('回填子会话输入框'), 'fork 说明缺重试回填语义')
45
47
  assert.ok(CLIENT_SRC.includes('尾号递增'), 'fork 说明缺标题递增语义')
46
48
  assert.ok(CLIENT_SRC.includes('重生成语义'), '自动重发说明缺重生成语义')
47
- assert.ok(CLIENT_SRC.includes('家族版本'), '计数器说明缺家族版本语义')
48
49
  })
49
50
 
50
51
  // 搜索功能源码契约:query 双写 + 组合输入守卫 + 命中计数 + 两级 Esc
@@ -0,0 +1,158 @@
1
+ // 输入框历史按钮 BDD:寻行/注入纯函数切片(桩 DOM)+ 注入接线源码契约。
2
+ // client.js 单文件自包含无法 import,切片经 new Function 实例化后对桩 DOM 断言。
3
+ import test from 'node:test'
4
+ import assert from 'node:assert/strict'
5
+ import { readFileSync } from 'node:fs'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { dirname, join } from 'node:path'
8
+
9
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
10
+ const CLIENT_SRC = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
11
+
12
+ // 切片:按钮标记常量起点到历史浮层组件声明终点,范围内仅常量与纯函数
13
+ // (findToolsRow / ensureHistoryButton / histButtonSvg 全部经 doc 参数注入)
14
+ const PURE_START = CLIENT_SRC.indexOf('const HISTORY_BTN_FLAG = ')
15
+ const PURE_END = CLIENT_SRC.indexOf('function HistoryDock(')
16
+ assert.ok(PURE_START >= 0 && PURE_END > PURE_START, 'client.js 历史按钮切片定位失败')
17
+ const pure = new Function(
18
+ CLIENT_SRC.slice(PURE_START, PURE_END)
19
+ + '; return { findToolsRow: findToolsRow, ensureHistoryButton: ensureHistoryButton, HISTORY_BTN_FLAG: HISTORY_BTN_FLAG }',
20
+ )()
21
+
22
+ // ── DOM 桩:最小实现,行为断言所需。
23
+ // 建模局限(显式注明):stubButton 以 onclick 属性模拟 addEventListener(未建
24
+ // EventTarget 语义),插入位置仅覆盖行尾分支(stub 节点无 nextSibling)——
25
+ // 「插到最后一个 _add 之后但行尾尚有其他节点」的 ref 非空分支由源码契约
26
+ // (insertBefore 锚点)与 L3 人工回归兜底,桩不重复建模。 ──
27
+
28
+ function stubButton(className) {
29
+ return {
30
+ className,
31
+ type: undefined,
32
+ title: undefined,
33
+ appended: null,
34
+ addEventListener(type, fn) { this['on' + type] = fn },
35
+ appendChild(node) { this.appended = node },
36
+ setAttribute(name, value) { this['attr_' + name] = value },
37
+ remove() {},
38
+ }
39
+ }
40
+
41
+ function stubRow(buttons) {
42
+ return {
43
+ children: [...buttons],
44
+ inserted: null,
45
+ insertBefore(node, ref) { this.inserted = { node, ref }; this.children.push(node) },
46
+ querySelector(selector) {
47
+ // 注入标记选择器与生产同源:按导出常量拼锚,常量改名时桩同步失真暴露
48
+ if (selector.includes(pure.HISTORY_BTN_FLAG)) return this.children.find((child) => child['attr_' + pure.HISTORY_BTN_FLAG] !== undefined) || null
49
+ if (selector.includes('_add')) return this.children.find((child) => child.className.endsWith('_add')) || null
50
+ return null
51
+ },
52
+ querySelectorAll(selector) {
53
+ if (selector.includes('_add')) return this.children.filter((child) => child.className.endsWith('_add'))
54
+ return []
55
+ },
56
+ }
57
+ }
58
+
59
+ // createElement 与 createElementNS 分开计数,svg 与按钮各自可断言;
60
+ // createElementNS 真实返回 tag 形态的节点(svg 内嵌 path)
61
+ function stubDoc(createdButtons, createdSvg) {
62
+ return {
63
+ createElement: (tag) => { const node = stubButton(''); node.tagName = tag; createdButtons.push(node); return node },
64
+ createElementNS: (_ns, tag) => {
65
+ const node = stubButton('')
66
+ node.tagName = tag
67
+ if (tag === 'svg') createdSvg.push(node)
68
+ return node
69
+ },
70
+ }
71
+ }
72
+
73
+ // ── 场景6:寻行纯函数 ──
74
+
75
+ test('findToolsRow:行后缀 _tools 且直含 _add 按钮返回行,任一锚点漂移返回 null', () => {
76
+ const toolsRow = { className: 'uV2eYG_tools' }
77
+ const addInTools = { className: 'uV2eYG_add', parentElement: toolsRow }
78
+ const otherRow = { className: 'uV2eYG_toolbar' }
79
+ const addInOther = { className: 'uV2eYG_add', parentElement: otherRow }
80
+ const doc = (buttons) => ({ querySelectorAll: () => buttons })
81
+ assert.equal(pure.findToolsRow(doc([addInTools])), toolsRow, '双锚点一致应返回工具排行')
82
+ assert.equal(pure.findToolsRow(doc([addInOther])), null, '行类后缀漂移必须拒绝注入')
83
+ assert.equal(pure.findToolsRow(doc([])), null, '无 _add 按钮必须返回 null')
84
+ })
85
+
86
+ // ── 场景2:幂等注入 ──
87
+
88
+ test('ensureHistoryButton:克隆官方 _add 类名,插到最后一个 _add 之后,重复调用幂等', () => {
89
+ const addA = stubButton('uV2eYG_add')
90
+ const addB = stubButton('uV2eYG_add')
91
+ const row = stubRow([addA, addB])
92
+ const createdButtons = []
93
+ const createdSvg = []
94
+ const doc = stubDoc(createdButtons, createdSvg)
95
+ const toggles = []
96
+ const first = pure.ensureHistoryButton(doc, row, () => toggles.push(1))
97
+ assert.notEqual(first, null, '工具排齐备时应注入成功')
98
+ assert.equal(createdButtons.length, 1, '应创建恰好一个按钮')
99
+ const button = createdButtons[0]
100
+ assert.equal(button.className, 'uV2eYG_add', '必须克隆官方按钮类名获得原生视觉')
101
+ assert.equal(button['attr_' + pure.HISTORY_BTN_FLAG], '', '必须携带注入标记防重复')
102
+ assert.equal(button.type, 'button')
103
+ assert.equal(typeof button.onclick, 'function', '点击必须接线 toggle')
104
+ assert.deepEqual(row.inserted, { node: button, ref: null }, '应插到最后一个 _add 之后')
105
+ assert.equal(createdSvg.length, 1, '应创建历史图标 svg')
106
+ assert.equal(button.appended && button.appended.tagName, 'svg', '图标必须挂到按钮上')
107
+ const again = pure.ensureHistoryButton(doc, row, () => toggles.push(2))
108
+ assert.equal(again, first, '已注入时重复调用必须幂等返回既有按钮')
109
+ assert.equal(createdButtons.length, 1, '幂等调用不得重复创建')
110
+ button.onclick()
111
+ assert.equal(toggles.length, 1, '点击应触发 toggle')
112
+ })
113
+
114
+ test('ensureHistoryButton:工具排缺官方 _add 按钮时返回 null(干净禁用)', () => {
115
+ const row = stubRow([stubButton('uV2eYG_primary')])
116
+ const createdButtons = []
117
+ const createdSvg = []
118
+ const result = pure.ensureHistoryButton(stubDoc(createdButtons, createdSvg), row, () => {})
119
+ assert.equal(result, null, '无克隆源不得注入')
120
+ assert.equal(createdButtons.length, 0)
121
+ })
122
+
123
+ // ── 场景3/4 接线契约:源码断言(观察器主体无法离线实例化)──
124
+
125
+ test('源码契约:HistoryDock 注入 effect 具备扫描守卫/卸载清理/外点守卫/toggle 接线', () => {
126
+ assert.ok(CLIENT_SRC.includes("'button[class$=\"_add\"]'"), '扫描必须按官方 _add 按钮定位工具排')
127
+ const scopeStart = CLIENT_SRC.indexOf('const HISTORY_BTN_FLAG = ')
128
+ const scopeEnd = CLIENT_SRC.indexOf('function SteerRecallDock(')
129
+ const scope = CLIENT_SRC.slice(scopeStart, scopeEnd)
130
+ assert.ok(scope.includes("'[' + HISTORY_BTN_FLAG + ']'"), '移除与查询必须走注入标记选择器')
131
+ // 外点关闭守卫:点击注入按钮不得触发浮层外点关闭(mousedown 先关后开竞态)
132
+ assert.ok(scope.includes(".closest('[' + HISTORY_BTN_FLAG + ']')"), '外点关闭必须跳过注入按钮')
133
+ // toggle:开→关,关→openPopup;经 ref 接线供挂载一次的监听器读到最新状态
134
+ assert.ok(scope.includes('function toggleFromButton()'), '缺 toggle 接线函数')
135
+ assert.ok(scope.includes('= toggleFromButton') && scope.includes('openPopup()'), 'toggle 必须经 ref 接线并闭合 openPopup')
136
+ })
137
+
138
+ // ── 场景3(按钮开关)/场景5(设置卡行):开关读取与停用接线契约 ──
139
+
140
+ test('源码契约:按钮开关挂载拉取,停用即移除已注入且不再注入,状态到达触发重扫', () => {
141
+ assert.ok(CLIENT_SRC.includes("const HISTORY_BUTTON_ENABLED_URL = '/api/context/history-button-enabled'"), '缺按钮开关路由常量')
142
+ const scopeStart = CLIENT_SRC.indexOf('function HistoryDock(')
143
+ const scopeEnd = CLIENT_SRC.indexOf('function SteerRecallDock(')
144
+ const scope = CLIENT_SRC.slice(scopeStart, scopeEnd)
145
+ // 挂载拉取 + ref 双写(停用路径在观察器外也要即时生效)
146
+ assert.ok(scope.includes('api(HISTORY_BUTTON_ENABLED_URL)'), '开关值必须挂载拉取')
147
+ assert.ok(scope.includes('buttonEnabledRef.current = buttonEnabled'), '开关值必须双写 ref')
148
+ // 扫描守卫必须同时受总开关与按钮开关约束,停用移除已注入
149
+ assert.ok(/if \(enabledRef\.current === false \|\| buttonEnabledRef\.current === false\) \{\s*\n\s*removeAll\(\)/.test(scope), '扫描守卫缺按钮开关分支')
150
+ // 开关值异步到达(不触发 DOM 变更)时需主动重扫
151
+ assert.ok(/\[historyEnabled, buttonEnabled\]/.test(scope), '开关状态到达必须触发重扫')
152
+ })
153
+
154
+ test('源码契约:设置卡第六开关行走 switchRow 工厂,挂按钮开关路由与说明常量', () => {
155
+ assert.ok(CLIENT_SRC.includes("switchRow(HISTORY_BUTTON_ENABLED_URL, '输入框历史按钮'"), '设置卡缺输入框历史按钮开关行')
156
+ assert.ok(CLIENT_SRC.includes('h(HistoryButtonSwitchRow)'), 'ContextPanel 未挂载第六开关行')
157
+ assert.ok(CLIENT_SRC.includes('const HISTORY_BUTTON_SWITCH_TITLE ='), '缺按钮开关说明常量')
158
+ })
@@ -0,0 +1,134 @@
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
+ import { runInThisContext } from 'node:vm'
6
+
7
+ // client.js 运行时冒烟:契约测试只比对源码字符串,查不出跨组件作用域错误
8
+ // (实测漏过一个 ReferenceError:重拉入口引用了他组件的 ref,浏览器控制台才报,
9
+ // 143 条契约测试全绿)。这里真正加载 client.js、跑通 factory 模块求值、并把每个
10
+ // 已注册的插槽组件渲染一遍,让作用域/初始化类错误在测试阶段暴露。
11
+
12
+ const CLIENT_SRC = readFileSync(fileURLToPath(new URL('../src/client.js', import.meta.url)), 'utf8')
13
+
14
+ // 组件函数体不渲染真实 DOM,只消费 hooks(useState/useEffect/useRef)与 createElement:
15
+ // 自造最小桩,零依赖全平台可跑(真 react 反而是环境耦合:CI 无宿主 profile)
16
+ const ReactStub = {
17
+ createElement: (type, props, ...children) => ({ type, props, children }),
18
+ useState: (init) => [typeof init === 'function' ? init() : init, () => {}],
19
+ useEffect: () => {},
20
+ useRef: (value) => ({ current: value }),
21
+ }
22
+
23
+ const HOST_HOOK = (name) => () => { throw new Error('缺少 react,无法执行 ' + name) }
24
+
25
+ // __ModuleLoader__ 桩:捕获 factory 与注册的插槽条目
26
+ function loadPlugin() {
27
+ assert.ok(ReactStub, '运行时冒烟需要宿主的 react;未找到时本测试无法建立保障')
28
+ const slots = []
29
+ let captured = null
30
+ // client.js 以 window/document/ MutationObserver 为宿主全局:桩全局后即在同上下文求值
31
+ const saved = {
32
+ window: globalThis.window,
33
+ document: globalThis.document,
34
+ MutationObserver: globalThis.MutationObserver,
35
+ }
36
+ // client.js 在 factory 求值期访问 window(工厂在宿主窗口内执行),故全局桩在
37
+ // factory 调用期间也必须就位,不能只在 runInThisContext 期间挂载
38
+ const stubWindow = {
39
+ __ModuleLoader__: {
40
+ load(definition) {
41
+ captured = definition
42
+ },
43
+ },
44
+ // 导航图标声明入口:吞噬,避免污染桩 window
45
+ __navicIcons: { register() {} },
46
+ }
47
+ globalThis.window = stubWindow
48
+ globalThis.document = {
49
+ createElement: () => ({
50
+ style: {},
51
+ setAttribute() {},
52
+ appendChild() {},
53
+ addEventListener() {},
54
+ querySelector: () => null,
55
+ querySelectorAll: () => [],
56
+ }),
57
+ querySelector: () => null,
58
+ querySelectorAll: () => [],
59
+ addEventListener() {},
60
+ documentElement: { appendChild() {} },
61
+ head: { appendChild() {} },
62
+ }
63
+ globalThis.MutationObserver = class {
64
+ observe() {}
65
+ disconnect() {}
66
+ }
67
+ try {
68
+ runInThisContext(CLIENT_SRC, { filename: 'client.js' })
69
+ assert.ok(captured, 'client.js 必须调用 window.__ModuleLoader__.load 注册插件')
70
+ const ctx = {
71
+ get: () => undefined,
72
+ slots: {
73
+ inject: (name, register) => { register() },
74
+ register: (definition, component) => { slots.push({ definition, component }); return () => {} },
75
+ },
76
+ remote: undefined,
77
+ on: () => () => {},
78
+ effect: () => {},
79
+ }
80
+ const plugin = captured.factory((specifier) => {
81
+ if (specifier === 'react') return ReactStub
82
+ throw new Error('模块表缺依赖:' + specifier)
83
+ })
84
+ assert.ok(typeof plugin === 'object' && plugin !== null, 'factory 应返回插件对象')
85
+ return { plugin, slots, ctx, id: captured.id }
86
+ } finally {
87
+ globalThis.window = saved.window
88
+ globalThis.document = saved.document
89
+ globalThis.MutationObserver = saved.MutationObserver
90
+ }
91
+ }
92
+
93
+ test('运行时:client.js 可加载并注册插件对象', () => {
94
+ const { plugin, id } = loadPlugin()
95
+ assert.equal(id, '@mzzsfy/dsh-context-manager')
96
+ assert.equal(typeof plugin.apply, 'function', '插件应有 apply')
97
+ assert.ok(Array.isArray(plugin.inject), '插件应声明 inject 依赖面')
98
+ assert.ok(plugin.inject.includes('slots'), '插槽面是全部 UI 能力的载体')
99
+ })
100
+
101
+ test('运行时:apply 可执行且插槽全部注册成功(作用域错误在此暴露)', () => {
102
+ const { plugin, slots, ctx } = loadPlugin()
103
+ // 无远程服务面:走「干净禁用」路径,插槽注册仍应完成,不得抛错
104
+ plugin.apply(ctx)
105
+ assert.ok(slots.length > 0, 'apply 应至少注册一个插槽条目')
106
+ for (const { component } of slots) {
107
+ assert.equal(typeof component, 'function', '插槽条目必须是可渲染组件')
108
+ }
109
+ })
110
+
111
+ test('运行时:每个插槽组件均可求值(跨组件作用域错误在此暴露)', () => {
112
+ const { plugin, slots, ctx } = loadPlugin()
113
+ plugin.apply(ctx)
114
+ assert.ok(slots.length > 0)
115
+ for (const { definition, component } of slots) {
116
+ // 组件函数体在渲染期执行:跨组件变量引用错位会在此抛 ReferenceError
117
+ // (实测漏过的正是 ReferenceError;hooks 与宿主注入的 hook 面在组件体外调用
118
+ // 属宿主渲染器职责,本冒烟不模拟渲染器,故只对 ReferenceError 断言)
119
+ try {
120
+ component({ session: undefined, inputActions: undefined, useInput: undefined })
121
+ } catch (error) {
122
+ if (error instanceof ReferenceError) throw error
123
+ }
124
+ }
125
+ })
126
+
127
+ // 宿主侧无远程服务面时各入口的注册是「干净禁用」路径:插槽注册不得因缺面而中断
128
+ test('运行时:缺 remote.session 时仍完成全部插槽注册', () => {
129
+ const { plugin, slots, ctx } = loadPlugin()
130
+ assert.equal(ctx.remote, undefined)
131
+ plugin.apply(ctx)
132
+ const names = slots.map(({ definition }) => definition.name)
133
+ assert.ok(names.includes('conversation.input.dock'), '输入框 dock 是 fork/历史/撤回三入口的载体')
134
+ })