@mzzsfy/dsh-context-manager 0.2.1 → 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/README.md CHANGED
@@ -1,14 +1,15 @@
1
1
  # dsh-context-manager
2
2
 
3
- 对话增强插件:历史输入回溯、插话撤回、对话分叉(进行中轮分叉/自动重发)、标题栏复制 sessionId。dsh-session-manager 历史输入功能的独立延续包(原包已移除该功能)。
3
+ 对话增强插件:历史输入回溯(Alt+↑ 或输入框工具排按钮唤起)、插话撤回、对话分叉(进行中轮分叉/自动重发)、标题栏复制 sessionId。dsh-session-manager 历史输入功能的独立延续包(原包已移除该功能)。
4
4
 
5
5
  ## 功能
6
6
 
7
- ### 历史输入浮层(Alt+↑)
7
+ ### 历史输入浮层(Alt+↑ 或输入框按钮)
8
8
 
9
- 输入框按 `Alt+↑` 唤起历史输入浮层:
9
+ 输入框按 `Alt+↑`,或点击输入框下方工具排的历史按钮(指令/附件右侧,与 Alt+↑ 等效,再点关闭)唤起历史输入浮层:
10
10
 
11
11
  - 范围导航:`←/→` 按 常用(prompts)→ 当前会话(session)→ 本工作区(workspace)→ 全部工作区(global) 依次切换(边界停住),列表时间倒序
12
+ - 收录范围:普通输入与斜杠命令(如 `/goal …`,按会话日志的 command/run 重组为完整命令行),工具结果回填与插件注入不收录
12
13
  - 搜索:输入即时过滤(IME 组合输入守卫,命中计数),`↑/↓` 选中、`Enter` 回填
13
14
  - 回填经宿主公共契约 `inputActions.setDraft`;`Esc` 两级退出(搜索词非空先清词,再按关闭浮层)
14
15
  - 列表行悬停星标收藏/取消常用提示词;常用范围内点「编辑」进入编辑态(重命名/删除/新增),数据落全局 `prompts.json`,与自动历史互不覆写
@@ -40,7 +41,7 @@
40
41
 
41
42
  ## 启停
42
43
 
43
- 设置页「插件」→「对话增强」卡片五开关(历史输入/插话撤回/对话分叉/分叉后自动重发/标题栏复制 sessionId),经宿主 settings 持久,变更刷新页面生效。
44
+ 设置页「插件」→「对话增强」卡片六开关(历史输入/输入框历史按钮/插话撤回/对话分叉/分叉后自动重发/标题栏复制 sessionId),经宿主 settings 持久,变更刷新页面生效。「输入框历史按钮」仅控制工具排按钮显隐,停用后 Alt+↑ 与浮层不受影响。
44
45
 
45
46
  ## 依赖与门控
46
47
 
@@ -55,6 +56,7 @@
55
56
  | `/api/context/inputs` | GET | 历史输入(`?sessionId=&scope=`) |
56
57
  | `/api/context/prompts/toggle` | POST | 收藏/取消收藏(按截断后文本查重) |
57
58
  | `/api/context/history-enabled` | GET/POST | 历史浮层启停 |
59
+ | `/api/context/history-button-enabled` | GET/POST | 输入框历史按钮启停 |
58
60
  | `/api/context/steer-recall-enabled` | GET/POST | 插话撤回启停 |
59
61
  | `/api/context/fork-enabled` | GET/POST | 对话分叉启停 |
60
62
  | `/api/context/fork-auto-resend-enabled` | GET/POST | 分叉后自动重发启停 |
@@ -64,4 +66,4 @@
64
66
 
65
67
  ## dsh 版本兼容
66
68
 
67
- 三版本全部通过:样式注入 data-plugin 契约、Alt+↑ 历史浮层、对话 fork、设置「插件」页对话增强卡四开关、host 半区 API(history-enabled / fork-enabled)。0.1.6 设置分区卡片位于「插件」页,非独立导航项。
69
+ 三版本全部通过:样式注入 data-plugin 契约、历史输入浮层(Alt+↑ 或输入框按钮)、对话 fork、设置「插件」页对话增强卡开关组、host 半区 API(八条 `/api/context/*`)。0.1.6 设置分区卡片位于「插件」页,非独立导航项。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-context-manager",
3
3
  "description": "对话增强:历史输入浮层(范围导航/搜索/subagent排除)、常用提示词收藏、插话撤回、对话 fork(进行中轮分叉/自动重发)",
4
- "version": "0.2.1",
4
+ "version": "0.3.0",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -1,11 +1,13 @@
1
- // dsh-context-manager Client 半区:「插件」设置页可配置卡片(五启停开关)
2
- // + 历史输入浮层(Alt+↑ 唤起,范围导航/搜索/收藏回填) + 插话撤回 + 对话 fork
1
+ // dsh-context-manager Client 半区:「插件」设置页可配置卡片(六启停开关)
2
+ // + 历史输入浮层(Alt+↑ 或输入框工具排按钮唤起,范围导航/搜索/收藏回填)
3
+ // + 插话撤回 + 对话 fork
3
4
  // + 标题栏复制 sessionId(悬停显隐)。
4
5
  // 历史输入挂官方 conversation.input.dock 插槽(渲染为零高度锚点),回填走宿主公共
5
6
  // 契约 inputActions.setDraft;插话撤回注入官方 pending steering 气泡操作图标排;
6
7
  // fork 注入消息气泡操作排,分叉到该轮之前并把该轮用户输入回填子会话输入框(重试
7
8
  // 语义),RPC 走宿主 sessions 服务面(fork+open),锚点经 remote.session.follow
8
- // 开场帧建立轮号→{ 结束 seq, 首问文本 } 映射;复制 sessionId 挂官方
9
+ // 开场帧建立轮号→{ 结束 seq, 首问文本 } 映射;回填前经官方 input 快照(useInput)
10
+ // 探测输入框占用,已被用户输入则不覆盖(权限高于回填);复制 sessionId 挂官方
9
11
  // conversation.session.header.actions 插槽。浏览器半区经 webServer 路由
10
12
  // ('/api/context/*')访问 Host。打包为单文件自包含格式,无法跨文件 require;
11
13
  // 与 src/core.mjs 镜像的纯函数(filterHistoryInputs/forkFailureText/forkRetryText)
@@ -154,6 +156,7 @@ const CSS = [
154
156
  const INPUTS_URL = '/api/context/inputs'
155
157
  const PROMPTS_TOGGLE_URL = '/api/context/prompts/toggle'
156
158
  const HISTORY_ENABLED_URL = '/api/context/history-enabled'
159
+ const HISTORY_BUTTON_ENABLED_URL = '/api/context/history-button-enabled'
157
160
  const STEER_ENABLED_URL = '/api/context/steer-recall-enabled'
158
161
  const FORK_ENABLED_URL = '/api/context/fork-enabled'
159
162
  const FORK_AUTO_RESEND_URL = '/api/context/fork-auto-resend-enabled'
@@ -236,6 +239,63 @@ function forkRetryText(data) {
236
239
  return text.trim() === '' ? null : text
237
240
  }
238
241
 
242
+ // ── 输入框历史按钮:注入官方输入工具排,点击开关浮层(与 Alt+↑ 等效)──
243
+
244
+ // 注入标记:识别自家节点,防重复注入;停用/卸载按标记全量移除
245
+ const HISTORY_BTN_FLAG = 'data-cx-hist-trigger'
246
+ // 官方输入工具排定位锚点:行类名后缀 + 官方 _add 图标按钮,双锚点一致才认;
247
+ // 官方类名漂移时寻行返回 null,注入静默跳过(干净禁用)
248
+ const TOOLS_ROW_SUFFIX = '_tools'
249
+ // 按钮悬停说明:点击与 Alt+↑ 等效
250
+ const HISTORY_BUTTON_TITLE = '历史输入(Alt+↑)'
251
+
252
+ // 官方输入工具排寻行:遍历官方 _add 图标按钮取其所属行
253
+ function findToolsRow(doc) {
254
+ for (const button of doc.querySelectorAll('button[class$="_add"]')) {
255
+ const row = button.parentElement
256
+ if (row && typeof row.className === 'string' && row.className.endsWith(TOOLS_ROW_SUFFIX)) return row
257
+ }
258
+ return null
259
+ }
260
+
261
+ // 历史图标 svg(时钟回拨):fill 继承 currentColor,视觉重量与官方 14px 图标一致
262
+ function histButtonSvg(doc) {
263
+ const namespace = 'http://www.w3.org/2000/svg'
264
+ const svg = doc.createElementNS(namespace, 'svg')
265
+ svg.setAttribute('width', '14')
266
+ svg.setAttribute('height', '14')
267
+ svg.setAttribute('viewBox', '0 0 24 24')
268
+ svg.setAttribute('fill', 'none')
269
+ svg.setAttribute('aria-hidden', 'true')
270
+ const path = doc.createElementNS(namespace, 'path')
271
+ path.setAttribute('fill', 'currentColor')
272
+ path.setAttribute('d', 'M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89.07.14L8.85 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42C8.27 16.99 10.51 18 13 18c4.97 0 9-4.03 9-9s-4.03-9-9-9zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z')
273
+ svg.appendChild(path)
274
+ return svg
275
+ }
276
+
277
+ // 幂等注入:未注入时克隆官方 _add 类名创建按钮(尺寸/hover 全部原生),位置
278
+ // 固定在最后一个官方 _add 之后(与指令/附件同簇);已注入原样返回。
279
+ // doc 注入便于测试与宿主文档解耦
280
+ function ensureHistoryButton(doc, row, onToggle) {
281
+ const existing = row.querySelector('[' + HISTORY_BTN_FLAG + ']')
282
+ if (existing) return existing
283
+ const official = row.querySelector('button[class$="_add"]')
284
+ if (!official) return null
285
+ const button = doc.createElement('button')
286
+ button.type = 'button'
287
+ button.setAttribute(HISTORY_BTN_FLAG, '')
288
+ button.className = official.className
289
+ button.title = HISTORY_BUTTON_TITLE
290
+ button.setAttribute('aria-label', '历史输入')
291
+ button.addEventListener('click', onToggle)
292
+ button.appendChild(histButtonSvg(doc))
293
+ const officialButtons = row.querySelectorAll('button[class$="_add"]')
294
+ const last = officialButtons[officialButtons.length - 1]
295
+ row.insertBefore(button, (last && last.nextSibling) || null)
296
+ return button
297
+ }
298
+
239
299
  function HistoryDock({ session, inputActions }) {
240
300
  const [open, setOpen] = useState(false)
241
301
  const [items, setItems] = useState(null)
@@ -262,8 +322,13 @@ function HistoryDock({ session, inputActions }) {
262
322
  // 历史浮层启停(设置开关):挂载拉取一次,停用即整体不渲染、键盘放行
263
323
  const enabledRef = React.useRef(true)
264
324
  const [historyEnabled, setHistoryEnabled] = useState(true)
325
+ // 输入框历史按钮启停(设置开关):false 仅隐藏按钮,快捷键与浮层不受影响
326
+ const buttonEnabledRef = React.useRef(true)
327
+ const [buttonEnabled, setButtonEnabled] = useState(true)
265
328
  sessionRef.current = session
266
329
  inputActionsRef.current = inputActions
330
+ // 条件 return 之前同步:停用路径的扫描守卫也要看到最新值(与 SteerRecallDock 同构)
331
+ buttonEnabledRef.current = buttonEnabled
267
332
  useEffect(() => {
268
333
  api(HISTORY_ENABLED_URL)
269
334
  .then((payload) => {
@@ -272,6 +337,9 @@ function HistoryDock({ session, inputActions }) {
272
337
  setHistoryEnabled(on)
273
338
  })
274
339
  .catch(() => {})
340
+ api(HISTORY_BUTTON_ENABLED_URL)
341
+ .then((payload) => setButtonEnabled(payload ? payload.enabled !== false : true))
342
+ .catch(() => {})
275
343
  }, [])
276
344
 
277
345
  function syncView(patch) {
@@ -493,11 +561,15 @@ function HistoryDock({ session, inputActions }) {
493
561
  if (row) row.scrollIntoView({ block: 'nearest' })
494
562
  }, [open, cursor, items])
495
563
 
496
- // 浮层外点击关闭(双写:漏写 viewRef 会让键盘层误判浮层仍开,吞掉输入框方向键)
564
+ // 浮层外点击关闭(双写:漏写 viewRef 会让键盘层误判浮层仍开,吞掉输入框方向键)。
565
+ // 注入按钮在本容器外,closest 守卫跳过:否则 mousedown 先关、click 再开,
566
+ // 浮层开时点按钮等于常开,toggle 语义失效
497
567
  useEffect(() => {
498
568
  if (!open) return undefined
499
569
  function onPointerDown(event) {
500
- if (rootRef.current && !rootRef.current.contains(event.target)) {
570
+ const target = event.target
571
+ if (target && typeof target.closest === 'function' && target.closest('[' + HISTORY_BTN_FLAG + ']')) return
572
+ if (rootRef.current && !rootRef.current.contains(target)) {
501
573
  syncView({ open: false })
502
574
  setOpen(false)
503
575
  }
@@ -506,6 +578,49 @@ function HistoryDock({ session, inputActions }) {
506
578
  return () => document.removeEventListener('mousedown', onPointerDown, true)
507
579
  }, [open])
508
580
 
581
+ // 注入按钮点击:开→关,关→唤起;经 ref 接线,挂载一次的监听器恒读最新闭包
582
+ function toggleFromButton() {
583
+ if (viewRef.current.open) {
584
+ syncView({ open: false })
585
+ setOpen(false)
586
+ return
587
+ }
588
+ openPopup()
589
+ }
590
+ const toggleRef = React.useRef(() => {})
591
+ toggleRef.current = toggleFromButton
592
+
593
+ // 输入框工具排按钮(设置开关):挂载后扫描官方工具排注入;官方工具排随布局
594
+ // 状态被 React 重建时经观察器补注,开关值异步到达(无 DOM 变更)经重扫对账。
595
+ // 启停读 ref(开关值挂载拉取后即时反映),停用/卸载按标记全量移除
596
+ const buttonScanRef = React.useRef(null)
597
+ useEffect(() => {
598
+ function removeAll() {
599
+ document.querySelectorAll('[' + HISTORY_BTN_FLAG + ']').forEach((button) => button.remove())
600
+ }
601
+ function sync() {
602
+ if (enabledRef.current === false || buttonEnabledRef.current === false) {
603
+ removeAll()
604
+ return
605
+ }
606
+ const row = findToolsRow(document)
607
+ if (row) ensureHistoryButton(document, row, () => toggleRef.current())
608
+ }
609
+ buttonScanRef.current = sync
610
+ const observer = new MutationObserver(sync)
611
+ observer.observe(document.documentElement, { childList: true, subtree: true })
612
+ sync()
613
+ return () => {
614
+ buttonScanRef.current = null
615
+ observer.disconnect()
616
+ removeAll()
617
+ }
618
+ }, [])
619
+ // 开关状态/浮层启停变化即时重扫(观察器只覆盖 DOM 变更,状态变化需主动对账)
620
+ useEffect(() => {
621
+ if (buttonScanRef.current) buttonScanRef.current()
622
+ }, [historyEnabled, buttonEnabled])
623
+
509
624
  // 零高度锚点:平时不占任何界面空间,仅浮层打开时渲染;设置停用整体不渲染
510
625
  if (session === undefined || inputActions === undefined || historyEnabled === false) return null
511
626
  const promptsIdx = HISTORY_SCOPES.indexOf('prompts')
@@ -717,6 +832,9 @@ const FORK_PAGE_MAX_MESSAGES = 2000
717
832
  // 重试草稿通道:分叉成功后子会话尚未挂载,原输入先按子会话 id 暂存,
718
833
  // 子会话的 dock 挂载(inputActions 就绪)时消费回填并清除
719
834
  const pendingForkDrafts = new Map()
835
+ // 已消费会话:effect 依赖(inputActions 身份)变化会重跑,重跑时草稿已被 delete,
836
+ // 若不做标记会落入「无草稿」路径并对输入框产生二次写入(空写即清空)
837
+ const consumedForkDrafts = new Set()
720
838
  // 草稿消费重查:覆盖 open 切换期间「挂载先于登记」的窗口(单次即可,上限留裕量)
721
839
  const FORK_DRAFT_POLL_MS = 400
722
840
  const FORK_DRAFT_POLLS = 3
@@ -832,7 +950,7 @@ function SteerRecallDock({ session, useSession, inputActions, updateQueue }) {
832
950
  // 轮号无映射 = 该轮未完成,首问无文本(纯图等)= 无法重试,均不注入;
833
951
  // 首轮无前锚(宿主 fork 边界必须落在 turn/end 上,复制零事件不可表达),也不注入。
834
952
  // 分叉动作经宿主 sessions 服务面 fork+open(与官方 chat 同构),成功 toast 并切换
835
- function ForkDockWithBootstrap({ session, inputActions, forkSession, cancelSession, submitPrompt, loadTurnEnds }) {
953
+ function ForkDockWithBootstrap({ session, inputActions, useInput, forkSession, cancelSession, submitPrompt, loadTurnEnds }) {
836
954
  // 依赖键 = 会话 id:session 快照身份随每次投影更新漂移,不能作 effect 依赖;
837
955
  // loadTurnEnds 闭包身份同样不稳定,经 ref 取用
838
956
  const sessionId = session && session.sessionId
@@ -842,15 +960,39 @@ function ForkDockWithBootstrap({ session, inputActions, forkSession, cancelSessi
842
960
  const submitRef = useRef(null)
843
961
  submitRef.current = submitPrompt
844
962
  const [turnEnds, setTurnEnds] = useState(null)
963
+ // 映射基线:重拉结果与基线比对,相同则不 setState——setState 会触发重渲染并再次
964
+ // 触发扫描,新轮在映射中缺失时形成 拉取→setState→扫描→拉取 的无限循环
965
+ const turnEndsRef = useRef(null)
966
+ // 在途门控:MutationObserver 在流式输出期间高频触发,无门控会造成请求风暴
967
+ const refreshPendingRef = useRef(false)
968
+ // 重拉入口装配到 ref 供扫描调用:新轮 turn/end 落在拉取窗口之后时映射里没有该轮,
969
+ // 注入资格判空即跳过,按钮永不出现(实测:必须刷新页面才恢复)。门控保证同时在途
970
+ // 最多一次;结果与基线相同则不 setState,避免 setState→扫描→拉取的循环
971
+ const refreshRef = useRef(null)
972
+ refreshRef.current = () => {
973
+ if (refreshPendingRef.current) return
974
+ refreshPendingRef.current = true
975
+ Promise.resolve(loadRef.current())
976
+ .then((map) => {
977
+ refreshPendingRef.current = false
978
+ if (turnEndsRef.current === map) return
979
+ turnEndsRef.current = map
980
+ setTurnEnds(map)
981
+ })
982
+ .catch(() => { refreshPendingRef.current = false })
983
+ }
845
984
  useEffect(() => {
846
985
  if (sessionId === undefined) return undefined
847
986
  let disposed = false
848
987
  // 拉取前置空:会话切换后旧会话的映射不得继续服务新会话——轮号跨会话重叠,
849
988
  // 旧 seq 会让分叉锚点错位;置空期间 ForkDock 以 null 短路,不注入按钮
850
989
  setTurnEnds(null)
990
+ turnEndsRef.current = null
851
991
  loadRef.current()
852
992
  .then((map) => {
853
- if (!disposed) setTurnEnds(map)
993
+ if (disposed) return
994
+ turnEndsRef.current = map
995
+ setTurnEnds(map)
854
996
  })
855
997
  .catch((error) => {
856
998
  // 映射拉取失败(网络/宿主繁忙):fork 无锚点可用,保持禁用不注入;
@@ -866,8 +1008,14 @@ function ForkDockWithBootstrap({ session, inputActions, forkSession, cancelSessi
866
1008
  // 自动重发(开关开):草稿带 autoSubmit 标记,setDraft 后经 remote.session.prompt
867
1009
  // 立即提交原文(重生成语义);prompt 失败仅 toast——文本已在输入框,可手动发送
868
1010
  const [draftPollTick, setDraftPollTick] = useState(0)
1011
+ // 回填前探测输入框当前草稿:宿主输入快照(useInput)只在快照面可用,
1012
+ // 缺失时按空草稿处理(旧宿主降级为原行为)
1013
+ const liveDraft = typeof useInput === 'function' ? useInput((state) => state.draft) : ''
1014
+ const liveDraftRef = useRef('')
1015
+ liveDraftRef.current = typeof liveDraft === 'string' ? liveDraft : ''
869
1016
  useEffect(() => {
870
1017
  if (sessionId === undefined || !inputActions || typeof inputActions.setDraft !== 'function') return undefined
1018
+ if (consumedForkDrafts.has(sessionId)) return undefined
871
1019
  const draft = pendingForkDrafts.get(sessionId)
872
1020
  if (draft === undefined) {
873
1021
  if (draftPollTick >= FORK_DRAFT_POLLS) return undefined
@@ -875,20 +1023,34 @@ function ForkDockWithBootstrap({ session, inputActions, forkSession, cancelSessi
875
1023
  return () => clearTimeout(timer)
876
1024
  }
877
1025
  pendingForkDrafts.delete(sessionId)
1026
+ consumedForkDrafts.add(sessionId)
878
1027
  const text = draft && typeof draft === 'object' ? draft.text : draft
879
1028
  const autoSubmit = Boolean(draft && typeof draft === 'object' && draft.autoSubmit === true)
880
- inputActions.setDraft(text)
1029
+ // 用户已开始输入:回填是破坏性整体替换(setDraft 清根重建),优先级低于用户输入;
1030
+ // 放弃回填并提示,避免吞掉用户在 dock 挂载窗口内的击键
1031
+ const draftOccupied = liveDraftRef.current.trim() !== ''
881
1032
  if (autoSubmit && submitRef.current) {
1033
+ // 提交即无草稿(官方发送语义):原文已发出,输入框不得残留,否则用户再发即重复消息;
1034
+ // 提交失败才回填文本,供手动重发
1035
+ if (draftOccupied) {
1036
+ toast('子会话已有输入,原输入未自动发送')
1037
+ return undefined
1038
+ }
882
1039
  Promise.resolve(submitRef.current({ sessionId, text })).catch((error) => {
883
- toast('自动重发失败,文本已在输入框可手动发送: ' + String(error && error.message || error), { kind: 'error' })
1040
+ if (typeof inputActions.setDraft === 'function') inputActions.setDraft(text)
1041
+ toast('自动重发失败,文本已回填输入框可手动发送: ' + String(error && error.message || error), { kind: 'error' })
884
1042
  })
1043
+ } else if (draftOccupied) {
1044
+ toast('子会话已有输入,原输入未回填')
1045
+ } else {
1046
+ inputActions.setDraft(text)
885
1047
  }
886
1048
  return undefined
887
1049
  }, [sessionId, inputActions, draftPollTick])
888
- return h(ForkDock, { session, forkSession, cancelSession, turnEnds })
1050
+ return h(ForkDock, { session, forkSession, cancelSession, turnEnds, refreshRef })
889
1051
  }
890
1052
 
891
- function ForkDock({ session, forkSession, cancelSession, turnEnds }) {
1053
+ function ForkDock({ session, forkSession, cancelSession, turnEnds, refreshRef }) {
892
1054
  const [enabled, setEnabled] = useState(true)
893
1055
  const [autoResend, setAutoResend] = useState(false)
894
1056
  const turnEndsRef = useRef(null)
@@ -937,13 +1099,18 @@ function ForkDock({ session, forkSession, cancelSession, turnEnds }) {
937
1099
  if (rawTurn === null || rawTurn.trim() === '') return
938
1100
  const turn = Number(rawTurn)
939
1101
  if (!Number.isInteger(turn)) return
940
- // 重试资格三查:映射缺失的轮(超窗口)不可分叉;首轮无前锚(宿主
1102
+ // 重试资格三查:映射缺失的轮(超窗口/新轮)不可分叉;首轮无前锚(宿主
941
1103
  // fork 边界必须落在 turn/end,复制零事件不可表达);无首问文本(纯图等)
942
1104
  // 回填无从谈起。open=true 的进行中轮 turn/end 未落账,同样可分叉——
943
1105
  // 锚点取其前一个闭合轮,该轮未完成的回复分叉后停掉(见点击处理器)。
944
1106
  // 按钮在场即暗示可用,禁用态文案无法区分成因,不注入即误导
945
1107
  const entry = turnEndsRef.current.get(turn)
946
- if (entry === undefined || entry.text === null) return
1108
+ if (entry === undefined) {
1109
+ // 新轮:映射是挂载时的一次快照,补拉后由 setState 触发重扫
1110
+ if (refreshRef.current) refreshRef.current()
1111
+ return
1112
+ }
1113
+ if (entry.text === null) return
947
1114
  let previous = null
948
1115
  for (const key of turnEndsRef.current.keys()) {
949
1116
  const candidate = turnEndsRef.current.get(key)
@@ -976,9 +1143,10 @@ function ForkDock({ session, forkSession, cancelSession, turnEnds }) {
976
1143
  .then((childId) => {
977
1144
  // 暂存先于 toast:open 的子会话挂载可能紧随 resolve,回填承诺必须先就位;
978
1145
  // open 失败时暂存保留,用户手动打开子会话仍兑现;
979
- // 自动重发开关开:登记带标记草稿,子会话消费时回填后经 prompt 提交
1146
+ // 自动重发开关开:登记带标记草稿,子会话消费时直接提交(不留草稿)
980
1147
  if (typeof childId === 'string' && childId !== '') {
981
1148
  pendingForkDrafts.set(childId, { text: currentEntry.text, autoSubmit: autoResendOn })
1149
+ consumedForkDrafts.delete(childId)
982
1150
  }
983
1151
  // 进行中轮分叉后原会话该轮无人再读,停掉止损;失败不影响分叉成功事实
984
1152
  if (currentEntry.open === true && typeof cancelSessionFn === 'function') {
@@ -986,7 +1154,7 @@ function ForkDock({ session, forkSession, cancelSession, turnEnds }) {
986
1154
  console.warn('[context-manager] 分叉后停止原会话进行中回复失败', error)
987
1155
  })
988
1156
  }
989
- toast('已分叉,原输入已填入子会话输入框')
1157
+ toast(autoResendOn ? '已分叉,原输入已自动发送到子会话' : '已分叉,原输入已填入子会话输入框')
990
1158
  return childId
991
1159
  })
992
1160
  .catch((error) => {
@@ -1017,6 +1185,7 @@ function ForkDock({ session, forkSession, cancelSession, turnEnds }) {
1017
1185
  // 面板设置项悬停说明:原生 title(设置侧栏为滚动容器,CSS 气泡会被 overflow
1018
1186
  // 裁剪,JS 定位复杂度不成比例);文案与功能行为同源维护,由源码契约测试锁定
1019
1187
  const HISTORY_SWITCH_TITLE = '在输入框按 Alt+↑ 唤起历史输入浮层,浏览并回填历史输入;浮层内 ←/→ 切换范围(常用 / 当前会话 / 本工作区 / 全部工作区),顶部搜索框过滤条目,行悬停星标可收藏常用提示词。停用后快捷键与浮层整体关闭,刷新页面生效。'
1188
+ const HISTORY_BUTTON_SWITCH_TITLE = '输入框下方工具排显示历史输入按钮,点击打开浮层,再点关闭,与 Alt+↑ 等效;停用仅隐藏按钮,快捷键与浮层不受影响;刷新页面生效。'
1020
1189
  const STEER_SWITCH_TITLE = '插话发送后、尚未被智能体应用期间,在该插话气泡的操作图标排显示撤回按钮,点击撤回并把原文填回输入框(覆盖输入框现有草稿);含附件的插话不可撤回;消息被应用后按钮随气泡消失,恰在应用瞬间点击会提示已应用且不动草稿。停用即不再注入,刷新页面生效。'
1021
1190
  const FORK_SWITCH_TITLE = '消息气泡操作排显示分叉按钮,点击分叉出新会话到该轮之前(该轮不带入子会话),该轮的用户输入自动回填子会话输入框供编辑重发,子会话自动打开且标题尾号递增;进行中的轮(回复尚未完成)同样可分叉,分叉后自动停止本会话该轮未完成的回复;首轮(无更早上下文可继承,新建会话即为同义操作)与无文本输入的轮(纯图等,无从重发)不注入;停用即不再注入,刷新页面生效。'
1022
1191
  const FORK_AUTO_RESEND_SWITCH_TITLE = '分叉成功后自动把该轮原输入发送到子会话立即开跑(重生成语义,相当于原输入重跑一遍);关闭时分叉仅把原输入回填子会话输入框,由你编辑后再手动发送。'
@@ -1111,6 +1280,7 @@ function switchRow(url, label, title, okText) {
1111
1280
  }
1112
1281
 
1113
1282
  const HistorySwitchRow = switchRow(HISTORY_ENABLED_URL, '历史输入浮层(Alt+↑)', HISTORY_SWITCH_TITLE, '历史输入浮层')
1283
+ const HistoryButtonSwitchRow = switchRow(HISTORY_BUTTON_ENABLED_URL, '输入框历史按钮', HISTORY_BUTTON_SWITCH_TITLE, '输入框历史按钮')
1114
1284
  const SteerSwitchRow = switchRow(STEER_ENABLED_URL, '插话撤回', STEER_SWITCH_TITLE, '插话撤回')
1115
1285
  const ForkSwitchRow = switchRow(FORK_ENABLED_URL, '对话 fork', FORK_SWITCH_TITLE, '对话 fork')
1116
1286
  const ForkAutoResendSwitchRow = switchRow(FORK_AUTO_RESEND_URL, '分叉后自动重发', FORK_AUTO_RESEND_SWITCH_TITLE, '分叉后自动重发')
@@ -1135,6 +1305,7 @@ function ContextPanel() {
1135
1305
  open && h('div', { className: 'cx-card__body' },
1136
1306
  h('span', { className: 'cx-panel__hint' }, '变更刷新页面生效。'),
1137
1307
  h(HistorySwitchRow),
1308
+ h(HistoryButtonSwitchRow),
1138
1309
  h(SteerSwitchRow),
1139
1310
  h(ForkSwitchRow),
1140
1311
  h(ForkAutoResendSwitchRow),
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 || [])
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 },
@@ -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
+ })
@@ -59,6 +59,56 @@ test('历史输入提取:多 text block 按行拼接为单条', () => {
59
59
  assert.deepEqual(extractUserInputs(events), [{ text: '第一段\n第二段', at: 300 }])
60
60
  })
61
61
 
62
+ // ── 历史输入:斜杠命令提取(command/run)──
63
+
64
+ test('历史输入提取:command/run 重组为完整命令行,args 为命令名后 verbatim rawInput', () => {
65
+ const events = [{
66
+ type: 'command/run',
67
+ seq: 2,
68
+ time: 600,
69
+ data: { commandId: 'c1', name: 'goal', args: ' 完成目标文档', source: { kind: 'user' } },
70
+ }]
71
+ assert.deepEqual(extractUserInputs(events), [{ text: '/goal 完成目标文档', at: 600 }])
72
+ })
73
+
74
+ test('历史输入提取:command/run 无 args(recordInput: false)产出裸命令名', () => {
75
+ const events = [{
76
+ type: 'command/run',
77
+ seq: 2,
78
+ time: 600,
79
+ data: { commandId: 'c1', name: 'think', source: { kind: 'user' } },
80
+ }]
81
+ assert.deepEqual(extractUserInputs(events), [{ text: '/think', at: 600 }])
82
+ })
83
+
84
+ test('历史输入提取:非用户来源的 command/run 不产出', () => {
85
+ const events = [{
86
+ type: 'command/run',
87
+ seq: 2,
88
+ time: 600,
89
+ data: { commandId: 'c1', name: 'goal', args: ' x', source: { kind: 'agent' } },
90
+ }]
91
+ assert.deepEqual(extractUserInputs(events), [])
92
+ })
93
+
94
+ test('历史输入提取:畸形 command/run(data 缺失/name 空串/args 非字符串)安全跳过或归一', () => {
95
+ const noData = { type: 'command/run', seq: 1, time: 100 }
96
+ const emptyName = { type: 'command/run', seq: 2, time: 200, data: { commandId: 'c1', name: '', source: { kind: 'user' } } }
97
+ const nonStringArgs = { type: 'command/run', seq: 3, time: 300, data: { commandId: 'c1', name: 'goal', args: 42, source: { kind: 'user' } } }
98
+ assert.deepEqual(extractUserInputs([noData, emptyName, nonStringArgs]), [{ text: '/goal', at: 300 }])
99
+ })
100
+
101
+ test('历史输入提取:普通输入与命令混合按事件序全部产出', () => {
102
+ const events = [
103
+ userEvent(null, '先看看日志', 100),
104
+ { type: 'command/run', seq: 2, time: 200, data: { commandId: 'c1', name: 'goal', args: ' 收尾', source: { kind: 'user' } } },
105
+ ]
106
+ assert.deepEqual(extractUserInputs(events), [
107
+ { text: '先看看日志', at: 100 },
108
+ { text: '/goal 收尾', at: 200 },
109
+ ])
110
+ })
111
+
62
112
  // ── 历史输入:聚合(G4-G5)──
63
113
 
64
114
  test('历史输入聚合:同文本去重保留最新时间,按时间倒序', () => {
@@ -482,6 +482,84 @@ test('历史输入路由:产物未变的会话跨对齐零解压(磁盘 extracts
482
482
  })
483
483
  })
484
484
 
485
+ test('历史输入路由:旧版本提取缓存自动重解压(命令历史自愈)', skipMissingDeps, async () => {
486
+ await withHistoryCacheDir(async (cacheDir) => {
487
+ const dir = await mkdtemp(path.join(tmpdir(), 'cx-hist-ver-'))
488
+ const artifactPath = path.join(dir, 'session.jsonl.zstd')
489
+ await writeFile(artifactPath, 'log-bytes')
490
+ try {
491
+ const { stat } = await import('node:fs/promises')
492
+ const info = await stat(artifactPath)
493
+ await ensureCacheDir(cacheDir)
494
+ // 预置旧版本提取器写下的缓存:指纹与产物一致但条目缺命令输入(无 v 字段)
495
+ await writeWorkspaceCache(cacheDir, 'C:\\x', {
496
+ entries: [{ text: '旧输入', at: 100, sid: 's1' }],
497
+ extracts: {
498
+ s1: { mtimeMs: info.mtimeMs, size: info.size, entries: [{ text: '旧输入', at: 100 }] },
499
+ },
500
+ })
501
+ const commandEvent = {
502
+ type: 'command/run',
503
+ seq: 2,
504
+ time: 200,
505
+ data: { commandId: 'c1', name: 'goal', args: ' 收尾', source: { kind: 'user' } },
506
+ }
507
+ const { handlers, readCounts } = makeCtx({
508
+ headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }],
509
+ agents: new Map(),
510
+ sessionPersistence: { locate: (header) => ({ path: artifactPath }) },
511
+ readSessions: { s1: [userMessageEvent('旧输入', 100), commandEvent] },
512
+ })
513
+ const res = await requestUntil(handlers, '?sessionId=s1&scope=workspace',
514
+ (body) => body.aligned && body.inputs.some((item) => item.text === '/goal 收尾'))
515
+ assert.ok(readCounts.get('s1') >= 1, '版本不符触发重解压')
516
+ assert.deepEqual(res.body.inputs.map((item) => item.text), ['/goal 收尾', '旧输入'])
517
+ } finally {
518
+ await rm(dir, { recursive: true, force: true })
519
+ }
520
+ })
521
+ })
522
+
523
+ test('历史输入路由:session 范围版本不符判 stale,聚焦对齐首请求内补齐', skipMissingDeps, async () => {
524
+ await withHistoryCacheDir(async (cacheDir) => {
525
+ const dir = await mkdtemp(path.join(tmpdir(), 'cx-hist-ver2-'))
526
+ const artifactPath = path.join(dir, 'session.jsonl.zstd')
527
+ await writeFile(artifactPath, 'log-bytes')
528
+ try {
529
+ const { stat } = await import('node:fs/promises')
530
+ const info = await stat(artifactPath)
531
+ await ensureCacheDir(cacheDir)
532
+ // 预置旧版本缓存:指纹与产物一致(不改条目则指纹校验判新鲜)但缺 v 字段
533
+ await writeWorkspaceCache(cacheDir, 'C:\\x', {
534
+ entries: [{ text: '旧输入', at: 100, sid: 's1' }],
535
+ extracts: {
536
+ s1: { mtimeMs: info.mtimeMs, size: info.size, entries: [{ text: '旧输入', at: 100 }] },
537
+ },
538
+ })
539
+ const commandEvent = {
540
+ type: 'command/run',
541
+ seq: 2,
542
+ time: 200,
543
+ data: { commandId: 'c1', name: 'goal', args: ' 收尾', source: { kind: 'user' } },
544
+ }
545
+ const { handlers, readCounts } = makeCtx({
546
+ headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }],
547
+ agents: new Map(),
548
+ sessionPersistence: { locate: (header) => ({ path: artifactPath }) },
549
+ readSessions: { s1: [userMessageEvent('旧输入', 100), commandEvent] },
550
+ })
551
+ // 首请求即 aligned:true 且含命令条目——版本不符走聚焦对齐,单次请求内补齐
552
+ const res = await requestUntil(handlers, '?sessionId=s1&scope=session',
553
+ (body) => body.aligned && body.inputs.some((item) => item.text === '/goal 收尾'))
554
+ assert.equal(res.body.aligned, true, '聚焦对齐快于等待时限,首请求即就绪')
555
+ assert.deepEqual(res.body.inputs.map((item) => item.text), ['/goal 收尾', '旧输入'])
556
+ assert.ok(readCounts.get('s1') >= 1, '版本不符触发聚焦重解压')
557
+ } finally {
558
+ await rm(dir, { recursive: true, force: true })
559
+ }
560
+ })
561
+ })
562
+
485
563
  // ── 历史输入路由:窗口与 subagent 排除 ──
486
564
 
487
565
  test('历史输入路由:启动对齐只回溯最近 STARTUP_SCAN 个会话,老会话不解压', skipMissingDeps, async () => {
@@ -661,12 +739,12 @@ test('常用提示词:超长截断、上限裁剪、空 text 拒绝', skipMissin
661
739
 
662
740
  // ── 路由层横切守卫:方法白名单 / JSON 体 / 系统级错误收敛 / 非字符串 text ──
663
741
 
664
- test('横切守卫:6 条路由方法白名单,白名单外一律 405', skipMissingDeps, async () => {
742
+ test('横切守卫:8 条路由方法白名单,白名单外一律 405', skipMissingDeps, async () => {
665
743
  await withHistoryCacheDir(async () => {
666
744
  const { handlers } = makeCtx({ headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
667
745
  const getOnly = '/api/context/inputs'
668
746
  const postOnly = '/api/context/prompts/toggle'
669
- const dualMethods = ['/api/context/history-enabled', '/api/context/steer-recall-enabled', '/api/context/fork-enabled', '/api/context/copy-sid-enabled']
747
+ const dualMethods = ['/api/context/history-enabled', '/api/context/history-button-enabled', '/api/context/steer-recall-enabled', '/api/context/fork-enabled', '/api/context/fork-auto-resend-enabled', '/api/context/copy-sid-enabled']
670
748
  // GET-only 路由:POST 拒绝
671
749
  const inputsPost = response()
672
750
  await handlers.get(getOnly)(request('s1'), inputsPost)
@@ -684,10 +762,10 @@ test('横切守卫:6 条路由方法白名单,白名单外一律 405', skipMissi
684
762
  })
685
763
  })
686
764
 
687
- test('横切守卫:5 条 POST 路由的非法 JSON 请求体统一 400(badJsonBody)', skipMissingDeps, async () => {
765
+ test('横切守卫:7 条 POST 路由的非法 JSON 请求体统一 400(badJsonBody)', skipMissingDeps, async () => {
688
766
  await withHistoryCacheDir(async () => {
689
767
  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', '/api/context/copy-sid-enabled']
768
+ const postPaths = ['/api/context/prompts/toggle', '/api/context/history-enabled', '/api/context/history-button-enabled', '/api/context/steer-recall-enabled', '/api/context/fork-enabled', '/api/context/fork-auto-resend-enabled', '/api/context/copy-sid-enabled']
691
769
  for (const routePath of postPaths) {
692
770
  const res = await postRaw(handlers, routePath, '{oops')
693
771
  assert.equal(res.status, 400, routePath + ' 非法 JSON 应回 400')
@@ -804,3 +882,20 @@ test('标题栏复制 sessionId 启停:GET 默认启用,POST 切换经 settings
804
882
  await handlers.get('/api/context/copy-sid-enabled')(getRequest2('/api/context/copy-sid-enabled', 'GET'), restored)
805
883
  assert.equal(restored.body.enabled, true)
806
884
  })
885
+
886
+ test('输入框历史按钮启停:GET 默认启用,POST 切换经 settings 持久,GET 反映新值', skipMissingDeps, async () => {
887
+ const { handlers } = makeCtx({ headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
888
+ const initial = response()
889
+ await handlers.get('/api/context/history-button-enabled')(getRequest2('/api/context/history-button-enabled', 'GET'), initial)
890
+ assert.equal(initial.body.enabled, true, '默认启用')
891
+ const off = await postJson(handlers, '/api/context/history-button-enabled', { enabled: false })
892
+ assert.equal(off.body.enabled, false)
893
+ // settings 持久:重读反映关闭态;POST 回 true 恢复
894
+ const reread = response()
895
+ await handlers.get('/api/context/history-button-enabled')(getRequest2('/api/context/history-button-enabled', 'GET'), reread)
896
+ assert.equal(reread.body.enabled, false, 'settings 持久化关闭态')
897
+ await postJson(handlers, '/api/context/history-button-enabled', { enabled: true })
898
+ const restored = response()
899
+ await handlers.get('/api/context/history-button-enabled')(getRequest2('/api/context/history-button-enabled', 'GET'), restored)
900
+ assert.equal(restored.body.enabled, true)
901
+ })