@mzzsfy/dsh-context-manager 0.1.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.
@@ -0,0 +1,265 @@
1
+ // 插话撤回(steer recall)接线与纯函数测试:加载真实 src/client.js,
2
+ // 捕获 slots.register 断言 dock 条目结构,切片实例化纯函数锁定撤回动作与失败文案。
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
+ // 加载 client.js 并执行 apply(mock 最小服务面),返回插槽注册捕获。
13
+ // slots.inject 收到注册回调即同步执行;binding 桩仅对会话 s1 生效
14
+ function loadClient({ binding, remoteSession } = {}) {
15
+ const modules = []
16
+ const registered = []
17
+ const injectedNames = []
18
+ const windowStub = { __ModuleLoader__: { load: (module) => modules.push(module) }, addEventListener: () => {} }
19
+ const reactStub = {
20
+ useState: (value) => [value, () => {}],
21
+ useEffect: () => {},
22
+ useRef: (value) => ({ current: value }),
23
+ createElement: () => null,
24
+ }
25
+ const requireStub = (name) => {
26
+ if (name === '@mzzsfy/dsh-toast/client') return { show: () => 0 }
27
+ return reactStub
28
+ }
29
+ const factory = new Function('window', 'require', 'document', CLIENT_SRC + '\n;return null')
30
+ factory(
31
+ windowStub,
32
+ requireStub,
33
+ { createElement: () => ({ style: {}, remove() {} }), head: { appendChild: () => {} }, body: { appendChild: () => {} } },
34
+ )
35
+ assert.equal(modules.length, 1, 'client.js 模块未被捕获')
36
+ const mod = modules[0].factory(requireStub)
37
+
38
+ const sessions = {
39
+ binding: (sessionId) => (sessionId === 's1' ? binding : undefined),
40
+ fork: () => Promise.resolve('child-1'),
41
+ open: () => {},
42
+ }
43
+ const remote = { session: remoteSession }
44
+ const ctx = {
45
+ get: (name) => (name === 'sessions' ? sessions : {}),
46
+ effect: () => {},
47
+ slots: {
48
+ inject: (name, register) => { injectedNames.push(name); register() },
49
+ register: (options, component) => registered.push({ options, component }),
50
+ },
51
+ get remote() { return remote },
52
+ }
53
+ mod.apply(ctx)
54
+ return { registered, injectedNames }
55
+ }
56
+
57
+ // 插话撤回 dock 条目捕获:与官方 queue dock(order 20)、goal dock(order 10)同槽位
58
+ function findSteerEntry(options) {
59
+ const { registered, injectedNames } = loadClient(options)
60
+ assert.ok(injectedNames.includes('conversation.input.dock'), 'apply 未注入 conversation.input.dock 插槽')
61
+ const entries = registered.filter((item) => item.options.name === 'conversation.input.dock')
62
+ const entry = entries.find((item) => item.options.id === 'context-manager-steer')
63
+ assert.ok(entry, '未注册 id=context-manager-steer 的插话撤回 dock 条目')
64
+ return { entry, entries }
65
+ }
66
+
67
+ test('Given client.js 装载, When apply, Then 注册插话撤回与历史浮层 dock 条目', () => {
68
+ const { entry, entries } = findSteerEntry()
69
+ assert.equal(typeof entry.component, 'function', 'dock 条目缺组件')
70
+ assert.equal(entry.options.order, 25, '插话撤回条目 order 应紧邻官方队列面板(20)之后')
71
+ assert.ok(entries.some((item) => item.options.id === 'context-manager-history'), '历史浮层条目被挤掉')
72
+ })
73
+
74
+ test('Given 会话绑定存在, When dock inject, Then updateQueue 路由到 binding.session.updateQueue', async () => {
75
+ const calls = []
76
+ const binding = {
77
+ session: {
78
+ updateQueue: async (itemId, action) => {
79
+ calls.push({ itemId, action })
80
+ return { ok: true }
81
+ },
82
+ },
83
+ }
84
+ const { entry } = findSteerEntry({ binding })
85
+ const props = entry.options.inject('s1')
86
+ assert.equal(typeof props.updateQueue, 'function', 'inject 未返回 updateQueue')
87
+ const result = await props.updateQueue('m1', { kind: 'remove' })
88
+ assert.deepEqual(calls, [{ itemId: 'm1', action: { kind: 'remove' } }], 'updateQueue 未按 remove 动作透传')
89
+ assert.deepEqual(result, { ok: true })
90
+ })
91
+
92
+ test('Given 会话绑定缺失, When dock inject, Then 返回空 props(干净禁用)', () => {
93
+ const { entry } = findSteerEntry()
94
+ assert.deepEqual(entry.options.inject('s1'), {}, '绑定缺失应返回空 props')
95
+ assert.deepEqual(entry.options.inject('missing'), {}, '未知会话应返回空 props')
96
+ })
97
+
98
+ test('Given 会话面无 updateQueue(旧宿主), When dock inject, Then 返回空 props(干净禁用)', () => {
99
+ const { entry } = findSteerEntry({ binding: { session: {} } })
100
+ assert.deepEqual(entry.options.inject('s1'), {}, '会话面无 updateQueue 应返回空 props')
101
+ })
102
+
103
+ // 纯函数切片:steerRowsOf / withdrawFailureText / withdrawSteer(无外部依赖,动作经注入)
104
+ const PURE_START = CLIENT_SRC.indexOf('function steerRowsOf(')
105
+ const PURE_END = CLIENT_SRC.indexOf('function SteerRecallDock(')
106
+ assert.ok(PURE_START >= 0 && PURE_END > PURE_START, 'client.js 插话撤回纯函数切片定位失败')
107
+ const pure = new Function(
108
+ CLIENT_SRC.slice(PURE_START, PURE_END)
109
+ + '; return { steerRowsOf: steerRowsOf, withdrawFailureText: withdrawFailureText, withdrawSteer: withdrawSteer }',
110
+ )()
111
+
112
+ const STEERING_ROW = { id: 'm1', placement: 'steering', preview: '帮我看下', text: '帮我看下' }
113
+
114
+ test('steerRowsOf:仅保留 placement=steering,防非数组与非行形态', () => {
115
+ const queue = [
116
+ { id: 'q1', placement: 'queued' },
117
+ STEERING_ROW,
118
+ { id: 'c1', placement: 'context' },
119
+ STEERING_ROW,
120
+ null,
121
+ ]
122
+ assert.deepEqual(pure.steerRowsOf(queue), [STEERING_ROW, STEERING_ROW])
123
+ assert.deepEqual(pure.steerRowsOf([]), [])
124
+ assert.deepEqual(pure.steerRowsOf(undefined), [])
125
+ })
126
+
127
+ test('withdrawSteer 主路径:remove 成功后回填草稿并提示,返回 true', async () => {
128
+ const calls = []
129
+ const actions = {
130
+ updateQueue: async (itemId, action) => { calls.push({ itemId, action }); return { ok: true } },
131
+ setDraft: (text) => calls.push({ setDraft: text }),
132
+ notify: (text, opts) => calls.push({ notify: text, opts }),
133
+ }
134
+ const done = await pure.withdrawSteer(STEERING_ROW, actions)
135
+ assert.equal(done, true)
136
+ assert.deepEqual(calls, [
137
+ { itemId: 'm1', action: { kind: 'remove' } },
138
+ { setDraft: '帮我看下' },
139
+ { notify: '已撤回到输入框', opts: undefined },
140
+ ])
141
+ })
142
+
143
+ test('withdrawSteer 已被应用:queue-item-not-found 不回填草稿,专用文案报错', async () => {
144
+ const calls = []
145
+ const actions = {
146
+ updateQueue: async () => ({ ok: false, error: { code: 'session/queue-item-not-found', message: 'queued item is no longer pending' } }),
147
+ setDraft: (text) => calls.push({ setDraft: text }),
148
+ notify: (text, opts) => calls.push({ notify: text, opts }),
149
+ }
150
+ const done = await pure.withdrawSteer(STEERING_ROW, actions)
151
+ assert.equal(done, false)
152
+ assert.deepEqual(calls, [{ notify: '插话已被应用,无法撤回', opts: { kind: 'error' } }])
153
+ })
154
+
155
+ test('withdrawSteer 其他失败:透传错误消息且不回填草稿', async () => {
156
+ const calls = []
157
+ const actions = {
158
+ updateQueue: async () => ({ ok: false, error: { code: 'session/steer-unavailable', message: 'busy' } }),
159
+ setDraft: (text) => calls.push({ setDraft: text }),
160
+ notify: (text, opts) => calls.push({ notify: text, opts }),
161
+ }
162
+ const done = await pure.withdrawSteer(STEERING_ROW, actions)
163
+ assert.equal(done, false)
164
+ assert.deepEqual(calls, [{ notify: '撤回失败: busy', opts: { kind: 'error' } }])
165
+ })
166
+
167
+ test('withdrawSteer 传输层拒绝:按错误处理且不回填草稿', async () => {
168
+ const calls = []
169
+ const actions = {
170
+ updateQueue: async () => { throw new Error('network down') },
171
+ setDraft: (text) => calls.push({ setDraft: text }),
172
+ notify: (text, opts) => calls.push({ notify: text, opts }),
173
+ }
174
+ const done = await pure.withdrawSteer(STEERING_ROW, actions)
175
+ assert.equal(done, false)
176
+ assert.deepEqual(calls, [{ notify: '撤回失败: network down', opts: { kind: 'error' } }])
177
+ })
178
+
179
+ test('withdrawSteer 摘除成功但回填抛错:兜底展示原文,不报成功', async () => {
180
+ const calls = []
181
+ const actions = {
182
+ updateQueue: async () => ({ ok: true }),
183
+ setDraft: () => { throw new Error('shell released') },
184
+ notify: (text, opts) => calls.push({ notify: text, opts }),
185
+ }
186
+ const done = await pure.withdrawSteer(STEERING_ROW, actions)
187
+ assert.equal(done, false)
188
+ assert.deepEqual(calls, [{ notify: '草稿回填失败,原文: 帮我看下', opts: { kind: 'error' } }])
189
+ })
190
+
191
+ // ── 组件级门控:干净禁用规约的落地断言(整源加载 + 记录型 createElement 直接调组件) ──
192
+
193
+ // createElement 桩记录调用树:h(type, props, ...children) -> {type, props, children}
194
+ function loadComponent() {
195
+ const modules = []
196
+ const registered = []
197
+ const windowStub = { __ModuleLoader__: { load: (module) => modules.push(module) }, addEventListener: () => {} }
198
+ const element = (type, props, ...children) => ({ type, props, children })
199
+ const reactStub = {
200
+ useState: (value) => [value, () => {}],
201
+ useEffect: () => {},
202
+ useRef: (value) => ({ current: value }),
203
+ createElement: element,
204
+ }
205
+ const requireStub = (name) => {
206
+ if (name === '@mzzsfy/dsh-toast/client') return { show: () => 0 }
207
+ return reactStub
208
+ }
209
+ const factory = new Function('window', 'require', 'document', CLIENT_SRC + '\n;return null')
210
+ factory(
211
+ windowStub,
212
+ requireStub,
213
+ { createElement: () => ({ style: {}, remove() {} }), head: { appendChild: () => {} }, body: { appendChild: () => {} } },
214
+ )
215
+ const mod = modules[0].factory(requireStub)
216
+ const sessions = { binding: () => undefined, fork: () => Promise.resolve('c'), open: () => {} }
217
+ const ctx = {
218
+ get: (name) => (name === 'sessions' ? sessions : {}),
219
+ effect: () => {},
220
+ slots: {
221
+ inject: (name, register) => register(),
222
+ register: (options, component) => registered.push({ options, component }),
223
+ },
224
+ get remote() { return { session: { page: () => Promise.resolve({ records: [] }) } } },
225
+ }
226
+ mod.apply(ctx)
227
+ const entry = registered.find((item) => item.options.id === 'context-manager-steer')
228
+ assert.ok(entry, '组件级测试未捕获插话撤回条目')
229
+ return entry.component
230
+ }
231
+
232
+ const DOCK_COMPONENT = loadComponent()
233
+ const NO_ACTIONS = { setDraft: () => {} }
234
+ const OK_UPDATE = async () => ({ ok: true })
235
+
236
+ // useSession 桩:两次 selector 读取共用同一快照(与官方 SessionSnapshotSelector 单快照同构)
237
+ function dockWith(snapshot) {
238
+ const useSession = (selector) => selector(snapshot)
239
+ return (props) => DOCK_COMPONENT({ session: {}, useSession, inputActions: NO_ACTIONS, updateQueue: OK_UPDATE, ...props })
240
+ }
241
+
242
+ test('SteerRecallDock 干净禁用:依赖任一缺失即返回 null', () => {
243
+ const dock = dockWith({ queue: [STEERING_ROW], subagent: null })
244
+ assert.equal(dock({ session: undefined }), null, 'session 缺失应不渲染')
245
+ assert.equal(dock({ inputActions: undefined }), null, 'inputActions 缺失应不渲染')
246
+ assert.equal(dock({ updateQueue: undefined }), null, 'updateQueue 缺失应不渲染')
247
+ })
248
+
249
+ test('SteerRecallDock 干净禁用:subagent 非 continuable 即返回 null,启用时零占位宿主', () => {
250
+ const locked = dockWith({ queue: [STEERING_ROW], subagent: { address: { mode: 'prompt' } } })
251
+ assert.equal(locked({}), null, 'queueMutable 为假应不渲染')
252
+ const host = dockWith({ queue: [], subagent: null })({})
253
+ assert.equal(host.type, 'div')
254
+ assert.equal(host.props.className, 'cx-steer-host', '启用时应渲染 display:none 宿主承载注入逻辑')
255
+ })
256
+
257
+ // 注入契约(源码级守卫):选择器/标记/清理必须存在,官方 DOM 结构漂移时静默降级
258
+ test('SteerRecallDock 注入契约:语义标记选择器、按钮标记与卸载清理齐备', () => {
259
+ assert.ok(CLIENT_SRC.includes('[data-pending-steering]'), '应定位官方气泡语义标记')
260
+ assert.ok(CLIENT_SRC.includes('[class$="_actions"]'), '应按 CSS module 类名后缀定位官方图标排')
261
+ assert.ok(CLIENT_SRC.includes('data-cx-steer-recall'), '应有自家按钮标记防重复注入')
262
+ assert.ok(CLIENT_SRC.includes('new MutationObserver'), '应经 MutationObserver 跟随气泡出现')
263
+ assert.ok(CLIENT_SRC.includes('official.className'), '应克隆官方按钮类名保证样式原生')
264
+ assert.ok(CLIENT_SRC.includes('observer.disconnect()'), '卸载应断开观察')
265
+ })
@@ -0,0 +1,33 @@
1
+ // cx-switch 开关样式守卫 BDD:状态选择器回退为裸 input 会静默隐藏 label 内其他 input,
2
+ // 属无报错的 UI 损坏,故对源文本做静态断言锁死结构
3
+ import { readFileSync } from 'node:fs'
4
+ import { dirname, join } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import test from 'node:test'
7
+ import assert from 'node:assert/strict'
8
+
9
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
10
+ const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
11
+
12
+ test('switch 隐藏规则以 input[type="checkbox"] 精确匹配', () => {
13
+ assert.match(source, /\.cx-switch input\[type="checkbox"\] \{ position:absolute/)
14
+ })
15
+
16
+ test('switch 状态选择器禁止裸 input 锚定(防误伤 label 内其他 input)', () => {
17
+ const bare = source.match(/\.cx-switch input:(?!\[type)[a-z-]+/g)
18
+ assert.equal(bare, null, `裸 input 状态选择器: ${bare}`)
19
+ })
20
+
21
+ test('checkbox 仅允许出现在 switchRow 工厂内,禁止裸 checkbox 直出', () => {
22
+ const occurrences = [...source.matchAll(/type: 'checkbox'/g)].map((match) => match.index)
23
+ assert.equal(occurrences.length, 1, `checkbox 字面量出现 ${occurrences.length} 次`)
24
+ const factoryStart = source.indexOf('function switchRow(')
25
+ assert.ok(factoryStart >= 0, '缺 switchRow 工厂')
26
+ const factoryEnd = source.indexOf('cx-switch__label', factoryStart)
27
+ assert.ok(occurrences[0] > factoryStart && occurrences[0] < factoryEnd, 'checkbox 字面量不在 switchRow 工厂内')
28
+ })
29
+
30
+ test('switchRow 产出顺序为 input 在前 track 在后', () => {
31
+ const factory = source.slice(source.indexOf('function switchRow('))
32
+ assert.ok(factory.indexOf("h('input'") < factory.indexOf('cx-switch__track'), '工厂内 input 必须先于 track')
33
+ })
@@ -0,0 +1,122 @@
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
+ })
@@ -0,0 +1,223 @@
1
+ // core 纯函数测试:历史输入提取(G1-G3)/聚合(G4-G5)/搜索过滤/fork 错误文案/巨产物跳过线
2
+
3
+ import test from 'node:test'
4
+ import assert from 'node:assert/strict'
5
+
6
+ const {
7
+ extractUserInputs,
8
+ aggregateInputs,
9
+ filterHistoryInputs,
10
+ forkFailureText,
11
+ forkRetryText,
12
+ maxArtifactBytesForHost,
13
+ HISTORY_ALIGN_LEGACY_MAX_ARTIFACT_BYTES,
14
+ HISTORY_ALIGN_MODERN_MAX_ARTIFACT_BYTES,
15
+ HISTORY_SCOPES,
16
+ HISTORY_INPUT_LIMIT,
17
+ HISTORY_INPUT_MAX_CHARS,
18
+ HISTORY_PROMPTS_MAX,
19
+ } = await import('../src/core.mjs')
20
+
21
+ function userEvent(overrides, text, at) {
22
+ return {
23
+ type: 'user/message',
24
+ seq: 1,
25
+ time: at,
26
+ ...overrides,
27
+ data: {
28
+ id: 'm1',
29
+ role: 'user',
30
+ source: { kind: 'user' },
31
+ content: text === undefined ? [] : [{ type: 'text', text }],
32
+ ...overrides?.data,
33
+ },
34
+ }
35
+ }
36
+
37
+ // ── 历史输入:事件提取(G1-G3)──
38
+
39
+ test('历史输入提取:user/message 且 kind=user 产出文本与时间', () => {
40
+ const events = [userEvent(null, '修复归档面板', 500)]
41
+ assert.deepEqual(extractUserInputs(events), [{ text: '修复归档面板', at: 500 }])
42
+ })
43
+
44
+ test('历史输入提取:tool 结果与 plugin 注入不产出', () => {
45
+ const tool = userEvent({ data: { source: { kind: 'tool', callId: 'c1' }, content: [{ type: 'tool-result', toolCallId: 'c1', content: [] }] } }, undefined, 100)
46
+ const plugin = userEvent({ data: { source: { kind: 'plugin', plugin: 'x', form: 'instructions' }, content: [{ type: 'text', text: '注入' }] } }, undefined, 200)
47
+ assert.deepEqual(extractUserInputs([tool, plugin]), [])
48
+ })
49
+
50
+ test('历史输入提取:纯图与空文本跳过', () => {
51
+ const image = userEvent({ data: { content: [{ type: 'image', attachment: { id: 'a1' } }] } }, undefined, 100)
52
+ const blank = userEvent(null, '', 200)
53
+ const whitespace = userEvent(null, ' \n ', 300)
54
+ assert.deepEqual(extractUserInputs([image, blank, whitespace]), [])
55
+ })
56
+
57
+ test('历史输入提取:多 text block 按行拼接为单条', () => {
58
+ const events = [userEvent({ data: { content: [{ type: 'text', text: '第一段' }, { type: 'text', text: '第二段' }] } }, undefined, 300)]
59
+ assert.deepEqual(extractUserInputs(events), [{ text: '第一段\n第二段', at: 300 }])
60
+ })
61
+
62
+ // ── 历史输入:聚合(G4-G5)──
63
+
64
+ test('历史输入聚合:同文本去重保留最新时间,按时间倒序', () => {
65
+ const merged = aggregateInputs([
66
+ { text: 'a', at: 1 },
67
+ { text: 'b', at: 5 },
68
+ { text: 'a', at: 9 },
69
+ ], { limit: 10, maxChars: 100 })
70
+ assert.deepEqual(merged, [{ text: 'a', at: 9 }, { text: 'b', at: 5 }])
71
+ })
72
+
73
+ test('历史输入聚合:limit 裁剪与单条截断', () => {
74
+ const merged = aggregateInputs([
75
+ { text: 'abcdef', at: 1 },
76
+ { text: 'xy', at: 2 },
77
+ ], { limit: 1, maxChars: 3 })
78
+ assert.deepEqual(merged, [{ text: 'xy', at: 2 }])
79
+ })
80
+
81
+ // ── 历史输入:每会话首条保护(挤出不做永久丢弃)──
82
+
83
+ test('历史输入聚合:超 limit 挤出时每个会话最早条目仍保留', () => {
84
+ const merged = aggregateInputs([
85
+ { text: '开场提示词', at: 1, sid: 's-old' },
86
+ { text: '继续', at: 2, sid: 's-old' },
87
+ { text: '新会话开场', at: 3, sid: 's-new' },
88
+ { text: '收尾', at: 4, sid: 's-new' },
89
+ ], { limit: 2, maxChars: 100 })
90
+ assert.deepEqual(merged, [
91
+ { text: '收尾', at: 4, sid: 's-new' },
92
+ { text: '新会话开场', at: 3, sid: 's-new' },
93
+ { text: '开场提示词', at: 1, sid: 's-old' },
94
+ ])
95
+ })
96
+
97
+ test('历史输入聚合:首条已在 limit 内不多余追加,结果仍为时间倒序', () => {
98
+ const merged = aggregateInputs([
99
+ { text: 'a', at: 1, sid: 's1' },
100
+ { text: 'b', at: 2, sid: 's2' },
101
+ ], { limit: 10, maxChars: 100 })
102
+ assert.deepEqual(merged, [{ text: 'b', at: 2, sid: 's2' }, { text: 'a', at: 1, sid: 's1' }])
103
+ })
104
+
105
+ test('历史输入聚合:首条保护在去重后集合上计算,与 limit 内文本重复不产生双条目', () => {
106
+ // s-old 的首条文本与 s-new 的某条相同:去重保留最新,归属 s-new,不再重复追加
107
+ const merged = aggregateInputs([
108
+ { text: '继续', at: 1, sid: 's-old' },
109
+ { text: '继续', at: 9, sid: 's-new' },
110
+ { text: '其他', at: 5, sid: 's-new' },
111
+ ], { limit: 1, maxChars: 100 })
112
+ assert.deepEqual(merged, [{ text: '继续', at: 9, sid: 's-new' }, { text: '其他', at: 5, sid: 's-new' }])
113
+ })
114
+
115
+ test('历史输入聚合:无 sid 条目不触发首条保护', () => {
116
+ const merged = aggregateInputs([
117
+ { text: 'old', at: 1 },
118
+ { text: 'new', at: 2 },
119
+ ], { limit: 1, maxChars: 100 })
120
+ assert.deepEqual(merged, [{ text: 'new', at: 2 }])
121
+ })
122
+
123
+ test('历史输入聚合:首条保护同样受 maxChars 截断', () => {
124
+ const merged = aggregateInputs([
125
+ { text: 'abcdef', at: 1, sid: 's1' },
126
+ { text: 'xy', at: 2, sid: 's2' },
127
+ ], { limit: 1, maxChars: 3 })
128
+ assert.deepEqual(merged, [{ text: 'xy', at: 2, sid: 's2' }, { text: 'abc', at: 1, sid: 's1' }])
129
+ })
130
+
131
+ // ── 搜索过滤(与 client.js 镜像,行为逐条对齐)──
132
+
133
+ test('历史搜索:空查询原样返回全量,非数组入参按空处理', () => {
134
+ const entries = [{ text: 'abc' }, { text: 'def' }]
135
+ assert.deepEqual(filterHistoryInputs(entries, ''), entries)
136
+ assert.deepEqual(filterHistoryInputs(entries, ' '), entries)
137
+ assert.deepEqual(filterHistoryInputs(undefined, 'a'), [])
138
+ assert.deepEqual(filterHistoryInputs(null, 'a'), [])
139
+ })
140
+
141
+ test('历史搜索:大小写不敏感子串匹配,保留原序', () => {
142
+ const entries = [{ text: 'Fix ARCHIVE bug' }, { text: '归档面板' }, { text: 'unrelated' }]
143
+ assert.deepEqual(filterHistoryInputs(entries, 'archive'), [entries[0]])
144
+ assert.deepEqual(filterHistoryInputs(entries, '归档'), [entries[1]])
145
+ assert.deepEqual(filterHistoryInputs(entries, 'un'), [entries[2]])
146
+ })
147
+
148
+ test('历史搜索:查询词去首尾空白后匹配,无命中返回空数组', () => {
149
+ const entries = [{ text: 'abc' }]
150
+ assert.deepEqual(filterHistoryInputs(entries, ' abc '), entries)
151
+ assert.deepEqual(filterHistoryInputs(entries, 'zzz'), [])
152
+ })
153
+
154
+ test('历史搜索:条目缺 text 按空串参与匹配,仅命中空查询', () => {
155
+ const entries = [{ at: 1 }, { text: 'abc' }]
156
+ assert.deepEqual(filterHistoryInputs(entries, ''), entries)
157
+ assert.deepEqual(filterHistoryInputs(entries, 'abc'), [entries[1]])
158
+ assert.deepEqual(filterHistoryInputs(entries, 'a'), [entries[1]])
159
+ })
160
+
161
+ // ── fork 错误文案 ──
162
+
163
+ test('fork 错误文案:未完成轮/挂载失败/未知码分通道', () => {
164
+ assert.equal(forkFailureText('session/fork-unavailable'), '该轮尚未完成,不可分叉')
165
+ assert.equal(forkFailureText('session/workspace-attach-failed'), '分叉成功,但挂载到工作区失败')
166
+ assert.equal(forkFailureText('gateway/internal'), '分叉失败: gateway/internal')
167
+ assert.equal(forkFailureText(undefined), '分叉失败: 未知错误')
168
+ })
169
+
170
+ // ── fork 重试文本提取 ──
171
+
172
+ test('Given 用户本人多文本块消息, When forkRetryText, Then 文本块按行拼接', () => {
173
+ const data = {
174
+ source: { kind: 'user' },
175
+ content: [{ type: 'text', text: '第一行' }, { type: 'image', attachment: {} }, { type: 'text', text: '第二行' }],
176
+ }
177
+ assert.equal(forkRetryText(data), '第一行\n第二行')
178
+ })
179
+
180
+ test('Given 插件注入/空白/纯图片消息, When forkRetryText, Then 返回 null 不提供重试', () => {
181
+ assert.equal(forkRetryText({ source: { kind: 'plugin', plugin: 'x' }, content: [{ type: 'text', text: '注入' }] }), null)
182
+ assert.equal(forkRetryText({ source: { kind: 'user' }, content: [] }), null)
183
+ assert.equal(forkRetryText({ source: { kind: 'user' }, content: [{ type: 'text', text: ' ' }] }), null)
184
+ assert.equal(forkRetryText({ source: { kind: 'user' }, content: [{ type: 'image', attachment: {} }] }), null)
185
+ })
186
+
187
+ test('Given 非对象或畸形 data, When forkRetryText, Then 返回 null 不抛错', () => {
188
+ assert.equal(forkRetryText(undefined), null)
189
+ assert.equal(forkRetryText({}), null)
190
+ assert.equal(forkRetryText({ source: { kind: 'user' } }), null)
191
+ })
192
+
193
+ // ── 巨产物跳过线:宿主版本黑名单 ──
194
+
195
+ test('巨产物跳过线:0.1.1 与 0.1.2 系列(含 prerelease)按旧线', () => {
196
+ for (const version of ['0.1.1', '0.1.1-rc.2', '0.1.2', '0.1.2-rc.1', '0.1.2-alpha.2']) {
197
+ assert.equal(maxArtifactBytesForHost(version), HISTORY_ALIGN_LEGACY_MAX_ARTIFACT_BYTES, version)
198
+ }
199
+ })
200
+
201
+ test('巨产物跳过线:0.1.3 起与未知未来版本按新线', () => {
202
+ for (const version of ['0.1.3', '0.1.5', '0.1.5-rc.1', '0.2.0', '1.0.0']) {
203
+ assert.equal(maxArtifactBytesForHost(version), HISTORY_ALIGN_MODERN_MAX_ARTIFACT_BYTES, version)
204
+ }
205
+ })
206
+
207
+ test('巨产物跳过线:版本缺失或非法保守回退旧线', () => {
208
+ for (const version of [null, undefined, '', 'garbage', '0.1']) {
209
+ assert.equal(maxArtifactBytesForHost(version), HISTORY_ALIGN_LEGACY_MAX_ARTIFACT_BYTES, String(version))
210
+ }
211
+ })
212
+
213
+ // ── 常量自检 ──
214
+
215
+ test('历史范围常量:四范围固定序,默认落点为当前会话', () => {
216
+ assert.deepEqual(HISTORY_SCOPES, ['prompts', 'session', 'workspace', 'global'])
217
+ })
218
+
219
+ test('历史容量常量:输入 200 条/单条 2 万字符/收藏 100 条', () => {
220
+ assert.equal(HISTORY_INPUT_LIMIT, 200)
221
+ assert.equal(HISTORY_INPUT_MAX_CHARS, 20 * 1000)
222
+ assert.equal(HISTORY_PROMPTS_MAX, 100)
223
+ })