@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 +7 -5
- package/package.json +1 -1
- package/src/client.js +186 -15
- package/src/core.mjs +15 -3
- package/src/index.js +42 -9
- package/test/client-fork-enhance.test.mjs +70 -5
- package/test/client-help.test.mjs +3 -1
- package/test/client-history-button.test.mjs +158 -0
- package/test/client-runtime.test.mjs +134 -0
- package/test/core.test.mjs +50 -0
- package/test/index.test.mjs +99 -4
package/README.md
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
# dsh-context-manager
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
|
69
|
+
三版本全部通过:样式注入 data-plugin 契约、历史输入浮层(Alt+↑ 或输入框按钮)、对话 fork、设置「插件」页对话增强卡开关组、host 半区 API(八条 `/api/context/*`)。0.1.6 设置分区卡片位于「插件」页,非独立导航项。
|
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
// dsh-context-manager Client 半区:「插件」设置页可配置卡片(
|
|
2
|
-
// + 历史输入浮层(Alt+↑
|
|
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, 首问文本 }
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
47
|
-
assert.ok(
|
|
48
|
-
|
|
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
|
+
})
|
package/test/core.test.mjs
CHANGED
|
@@ -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('历史输入聚合:同文本去重保留最新时间,按时间倒序', () => {
|
package/test/index.test.mjs
CHANGED
|
@@ -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('横切守卫:
|
|
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('横切守卫:
|
|
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
|
+
})
|