@mzzsfy/dsh-context-manager 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ })
@@ -1,48 +0,0 @@
1
- import { test } from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import { readFileSync } from 'node:fs'
4
- import { fileURLToPath } from 'node:url'
5
-
6
- const CLIENT_SRC = readFileSync(fileURLToPath(new URL('../src/client.js', import.meta.url)), 'utf8')
7
-
8
- // F1 家族版本环 的客户端契约(源码切片锁定):
9
- // - 数据源是 sessions.list 快照行(parentSessionId),零新 RPC
10
- // - 单成员家族/快照缺失不注入
11
- // - 箭头跳转经 sessions.open(纯打开,不 fork)
12
- // - 计数器防重复注入,停用 fork 时随 removeAll 一并清除
13
-
14
- test('源码契约:家族环数据源为 sessions.list 快照(零新 RPC)', () => {
15
- assert.ok(CLIENT_SRC.includes('sessions.list.getSnapshot()'), '家族投影应读会话列表快照')
16
- assert.ok(CLIENT_SRC.includes('parent.parentId'), '投影应按快照行 parentId 建链(快照字段名与 wire 不同)')
17
- })
18
-
19
- test('源码契约:快照行主键为 id(与服务 wire 字段名不同源)', () => {
20
- const slice = CLIENT_SRC.slice(CLIENT_SRC.indexOf('function sessionFamilyMap'), CLIENT_SRC.indexOf('function familyRing'))
21
- assert.ok(slice.includes('item.id'), '快照行主键应取 id')
22
- })
23
-
24
- test('源码契约:编排派生会话(subagent)剔除出家族投影', () => {
25
- const slice = CLIENT_SRC.slice(CLIENT_SRC.indexOf('function sessionFamilyMap'), CLIENT_SRC.indexOf('function familyRing'))
26
- assert.ok(slice.includes("item.origin === 'subagent'"), 'subagent 会话不是用户的 fork 版本,应整行剔除')
27
- })
28
-
29
- test('源码契约:单成员家族与快照缺失不注入计数器', () => {
30
- const slice = CLIENT_SRC.slice(CLIENT_SRC.indexOf('// 家族版本环'), CLIENT_SRC.indexOf('function jumpFamilyMember'))
31
- assert.ok(slice.includes('ring && ring.total >= 2'), '成员不足 2 不注入')
32
- assert.ok(slice.includes('actionsRow.querySelector'), '注入前应查重')
33
- })
34
-
35
- test('源码契约:箭头跳转经 sessions.open 且不 fork', () => {
36
- const jump = CLIENT_SRC.slice(CLIENT_SRC.indexOf('function jumpFamilyMember'), CLIENT_SRC.indexOf('if (typeof MutationObserver'))
37
- assert.ok(jump.includes('openFn(target)'), '跳转应调用 open 通道')
38
- assert.ok(jump.includes('target !== current.sessionId'), '目标为自身时不切换')
39
- })
40
-
41
- test('源码契约:家族环标记与清除齐备(停用开关一并移除)', () => {
42
- assert.ok(CLIENT_SRC.includes("const FAMILY_RING_FLAG = 'data-cx-family-ring'"), '应有家族环标记常量')
43
- assert.ok(CLIENT_SRC.includes("querySelectorAll('[' + FORK_BTN_FLAG + ']')"), '清除逻辑存在(环与按钮同排,按钮清除即整排消失)')
44
- })
45
-
46
- test('源码契约:loadFamily 通道旧宿主降级返回 null', () => {
47
- assert.ok(CLIENT_SRC.includes('typeof sessions.list.getSnapshot'), '快照面应以能力探测守门')
48
- })
@@ -1,122 +0,0 @@
1
- import test from 'node:test'
2
- import assert from 'node:assert/strict'
3
-
4
- import {
5
- forkFailureText,
6
- forkRetryText,
7
- sessionFamilyMap,
8
- familyRing,
9
- } from '../src/core.mjs'
10
-
11
- // ── fork 锚点解析 ──
12
-
13
- test('Given 插件注入消息, When forkRetryText, Then 返回 null', () => {
14
- assert.equal(forkRetryText({ source: { kind: 'plugin' }, content: [{ type: 'text', text: 'hi' }] }), null)
15
- })
16
-
17
- test('Given 纯图或空白 user 消息, When forkRetryText, Then 返回 null', () => {
18
- assert.equal(forkRetryText({ source: { kind: 'user' }, content: [{ type: 'image', attachment: {} }] }), null)
19
- assert.equal(forkRetryText({ source: { kind: 'user' }, content: [{ type: 'text', text: ' ' }] }), null)
20
- })
21
-
22
- test('Given undefined 或畸形 data, When forkRetryText, Then 返回 null', () => {
23
- assert.equal(forkRetryText(undefined), null)
24
- assert.equal(forkRetryText({}), null)
25
- assert.equal(forkRetryText({ source: { kind: 'user' } }), null)
26
- })
27
-
28
- test('Given 多文本块 user 消息, When forkRetryText, Then 按行拼接且跳过图块', () => {
29
- const text = forkRetryText({
30
- source: { kind: 'user' },
31
- content: [
32
- { type: 'text', text: '第一行' },
33
- { type: 'image', attachment: {} },
34
- { type: 'text', text: '第二行' },
35
- ],
36
- })
37
- assert.equal(text, '第一行\n第二行')
38
- })
39
-
40
- test('Given user 消息先于 turn/start(无门), When forkRetryText, Then 照常提取', () => {
41
- assert.equal(forkRetryText({ source: { kind: 'user' }, content: [{ type: 'text', text: '首问' }] }), '首问')
42
- })
43
-
44
- // ── 家族谱系投影 ──
45
-
46
- test('Given 空/畸形输入, When sessionFamilyMap, Then 返回空 Map', () => {
47
- assert.equal(sessionFamilyMap(undefined).size, 0)
48
- assert.equal(sessionFamilyMap([]).size, 0)
49
- assert.equal(sessionFamilyMap([null, {}, { sessionId: '' }]).size, 0)
50
- })
51
-
52
- test('Given 直链家族 根→A→B, When sessionFamilyMap, Then 各成员链正确且同根', () => {
53
- const items = [
54
- { sessionId: 'root', updatedAt: 3 },
55
- { sessionId: 'a', parentSessionId: 'root', updatedAt: 1 },
56
- { sessionId: 'b', parentSessionId: 'a', updatedAt: 2 },
57
- ]
58
- const chains = sessionFamilyMap(items)
59
- assert.deepEqual(chains.get('b').chain, ['root', 'a', 'b'])
60
- assert.equal(chains.get('b').root, 'root')
61
- assert.equal(chains.get('a').root, 'root')
62
- assert.equal(chains.get('root').root, 'root')
63
- })
64
-
65
- test('Given parentSessionId 指向不存在的会话(孤儿), When sessionFamilyMap, Then 视为独立根', () => {
66
- const chains = sessionFamilyMap([{ sessionId: 'x', parentSessionId: 'ghost' }])
67
- assert.equal(chains.get('x').root, 'x')
68
- assert.deepEqual(chains.get('x').chain, ['x'])
69
- })
70
-
71
- test('Given 循环引用, When sessionFamilyMap, Then 不死循环', () => {
72
- const chains = sessionFamilyMap([
73
- { sessionId: 'p', parentSessionId: 'q' },
74
- { sessionId: 'q', parentSessionId: 'p' },
75
- ])
76
- assert.ok(chains.get('p').chain.length <= 2)
77
- })
78
-
79
- test('Given 编排派生会话(origin=subagent)挂在根下, When sessionFamilyMap, Then subagent 行剔除且不计数', () => {
80
- const chains = sessionFamilyMap([
81
- { sessionId: 'root', updatedAt: 30 },
82
- { sessionId: 'task', parentSessionId: 'root', origin: 'subagent', updatedAt: 20 },
83
- { sessionId: 'fork', parentSessionId: 'root', updatedAt: 10 },
84
- ])
85
- assert.equal(chains.has('task'), false)
86
- const ring = familyRing(chains, 'root')
87
- assert.deepEqual(ring, { index: 2, total: 2, members: ['fork', 'root'] })
88
- })
89
-
90
- test('Given 整链均为 subagent, When familyRing, Then 单成员返回 null', () => {
91
- const chains = sessionFamilyMap([{ sessionId: 'task', origin: 'subagent' }])
92
- assert.equal(familyRing(chains, 'task'), null)
93
- })
94
-
95
- // ── 家族环计数 ──
96
-
97
- test('Given 家族 3 成员, When familyRing(中间 updatedAt), Then index=2 total=3 members 按 updatedAt 升序', () => {
98
- const chains = sessionFamilyMap([
99
- { sessionId: 'root', updatedAt: 30 },
100
- { sessionId: 'a', parentSessionId: 'root', updatedAt: 10 },
101
- { sessionId: 'b', parentSessionId: 'root', updatedAt: 20 },
102
- ])
103
- const ring = familyRing(chains, 'b')
104
- assert.deepEqual(ring, { index: 2, total: 3, members: ['a', 'b', 'root'] })
105
- })
106
-
107
- test('Given 单成员家族, When familyRing, Then 返回 null', () => {
108
- const chains = sessionFamilyMap([{ sessionId: 'only' }])
109
- assert.equal(familyRing(chains, 'only'), null)
110
- })
111
-
112
- test('Given 目标不在投影内, When familyRing, Then 返回 null', () => {
113
- assert.equal(familyRing(new Map(), 'nope'), null)
114
- assert.equal(familyRing(undefined, 'nope'), null)
115
- })
116
-
117
- test('Given fork 错误码, When forkFailureText, Then 已知码映射中文未知码透出', () => {
118
- assert.equal(forkFailureText('session/fork-unavailable'), '该轮尚未完成,不可分叉')
119
- assert.equal(forkFailureText('session/workspace-attach-failed'), '分叉成功,但挂载到工作区失败')
120
- assert.equal(forkFailureText('gateway/internal'), '分叉失败: gateway/internal')
121
- assert.equal(forkFailureText(undefined), '分叉失败: 未知错误')
122
- })