@yfwu2020/dsh-selection-explain 0.7.0 → 0.8.3
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 +77 -6
- package/cordis.patch.yml +2 -2
- package/lib/client.js +1440 -74
- package/lib/index.js +390 -29
- package/lib/index.js.map +1 -1
- package/lib/settings.js +228 -0
- package/lib/settings.js.map +1 -0
- package/lib/types/index.d.ts +55 -6
- package/lib/types/settings.d.ts +125 -0
- package/package.json +3 -2
- package/scripts/smoke-panel.mjs +46 -1
- package/scripts/smoke-voice.mjs +3 -1
- package/scripts/test-bridge.mjs +4 -0
- package/scripts/test-client.mjs +643 -7
- package/scripts/test-format.mjs +93 -0
- package/scripts/test-model-follow.mjs +193 -0
- package/scripts/test-route-cache.mjs +138 -0
- package/scripts/test-settings-behavior.mjs +188 -0
- package/scripts/test-settings-host.mjs +48 -0
- package/scripts/test-settings.mjs +329 -0
- package/src/client/index.js +1440 -74
- package/src/index.ts +427 -24
- package/src/settings.ts +310 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话格式契约:**写出去的那几个 shape 必须是当前内核认的**。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独一个文件:升格(`↗ 升格`)是往会话日志里**写**事件,写完由内核按
|
|
5
|
+
* 会话格式做准入校验 —— 校验发生在**真实内核跑起来的那一刻**,不在构建期,
|
|
6
|
+
* 所以离线测试全绿也照样能在用户机器上炸。v0.7.0 就是这么坏的:
|
|
7
|
+
*
|
|
8
|
+
* `source: { kind: 'plugin', plugin: name }` 是会话格式 v3 的写法;
|
|
9
|
+
* v4 起该包装已退役,准入直接抛
|
|
10
|
+
* format v4 message requires a producer-owned source kind
|
|
11
|
+
* 表现:升格请求返回 `ok: true`(只写进了内存里的会话面)→ 随后那轮 opener
|
|
12
|
+
* 「本轮运行失败」→ 整个 artifact 一个字都没落盘,新会话只剩表头。
|
|
13
|
+
*
|
|
14
|
+
* 这里钉住两件**离线可判**的事实(都是当初真正踩坏的):
|
|
15
|
+
* ① 上下文消息的 source.kind 是生产者自有的(`plugin:<插件名>`:非空、非裸 'plugin'),
|
|
16
|
+
* 且真的被用在 `form: 'snapshot'` 的上下文消息上(不是写了个没人用的常量);
|
|
17
|
+
* ② 构建产物里不再出现退役的 plugin 包装,以及升格那套 turn/step 骨架还在
|
|
18
|
+
* (骨架丢了消息就没有坐标,客户端不会把它显示成历史对话)。
|
|
19
|
+
*
|
|
20
|
+
* 跑法:node scripts/test-format.mjs(已挂进 npm test)
|
|
21
|
+
*/
|
|
22
|
+
import { readFileSync } from 'node:fs'
|
|
23
|
+
import { fileURLToPath } from 'node:url'
|
|
24
|
+
import { dirname, resolve } from 'node:path'
|
|
25
|
+
import { PLUGIN_SOURCE_KIND, name } from '../lib/index.js'
|
|
26
|
+
|
|
27
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
28
|
+
const BUNDLE = readFileSync(resolve(HERE, '..', 'lib', 'index.js'), 'utf8')
|
|
29
|
+
|
|
30
|
+
let pass = 0
|
|
31
|
+
let fail = 0
|
|
32
|
+
const assert = (label, ok, detail) => {
|
|
33
|
+
if (ok) {
|
|
34
|
+
pass += 1
|
|
35
|
+
console.log(`PASS ${label}${detail ? ` — ${detail}` : ''}`)
|
|
36
|
+
} else {
|
|
37
|
+
fail += 1
|
|
38
|
+
console.log(`FAIL ${label}${detail ? ` — ${detail}` : ''}`)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ───────────────────────── ① 来源归属(v4 准入的那条线) ─────────────────────────
|
|
43
|
+
assert(
|
|
44
|
+
'来源归属:上下文消息的 kind 与「插件名」一致(plugin:<插件名>)',
|
|
45
|
+
PLUGIN_SOURCE_KIND === `plugin:${name}`,
|
|
46
|
+
JSON.stringify({ PLUGIN_SOURCE_KIND, name }),
|
|
47
|
+
)
|
|
48
|
+
assert(
|
|
49
|
+
'来源归属:kind 是生产者自有的(非空、非裸 plugin)',
|
|
50
|
+
typeof PLUGIN_SOURCE_KIND === 'string' && PLUGIN_SOURCE_KIND.length > 'plugin:'.length && PLUGIN_SOURCE_KIND !== 'plugin',
|
|
51
|
+
JSON.stringify(PLUGIN_SOURCE_KIND),
|
|
52
|
+
)
|
|
53
|
+
assert(
|
|
54
|
+
'来源归属:第三方插件带 `plugin:` 命名空间(与内核 v3→v4 迁移的写法一致)',
|
|
55
|
+
PLUGIN_SOURCE_KIND.startsWith('plugin:'),
|
|
56
|
+
JSON.stringify(PLUGIN_SOURCE_KIND),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
// ───────────────────────── ② 真的用上了(不是写了没人用的常量) ─────────────────────────
|
|
60
|
+
const used = BUNDLE.indexOf('kind: PLUGIN_SOURCE_KIND')
|
|
61
|
+
assert('使用点:源码里真的以 PLUGIN_SOURCE_KIND 作为 kind 写出消息', used >= 0, used >= 0 ? `偏移 ${used}` : '构建产物里找不到')
|
|
62
|
+
{
|
|
63
|
+
// 同一个 source 字面量里:kind 之后要跟着 snapshot 呈现(客户端据此折叠成「上下文」行)
|
|
64
|
+
const window = used >= 0 ? BUNDLE.slice(used, used + 240) : ''
|
|
65
|
+
assert('使用点:同一条 source 带 `form: \'snapshot\'`(客户端才折叠成上下文行)', /form:\s*'snapshot'/.test(window), window ? JSON.stringify(window.slice(0, 80)) : '')
|
|
66
|
+
assert('使用点:同一条 source 带 sections(snapshot 的命名分节,模型读的就是这些字)', /sections:\s*\[/.test(window), '')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ───────────────────────── ③ 退役写法不再出现 ─────────────────────────
|
|
70
|
+
{
|
|
71
|
+
// v3 的 plugin 包装:kind:'plugin' 后面紧跟一个 plugin 字段。
|
|
72
|
+
// 注意这是**文本级**断言(连注释里都别写出那个形状)—— 宁可误报,也别漏报:
|
|
73
|
+
// 这正是本次回归的形态,写进注释里也会把将来的人带沟里。
|
|
74
|
+
const retired = /kind:\s*(["'])plugin\1\s*,\s*plugin\s*:/.exec(BUNDLE)
|
|
75
|
+
assert(
|
|
76
|
+
'退役写法:构建产物里没有 kind: plugin + plugin 字段的包装(v4 会当场拒掉)',
|
|
77
|
+
retired === null,
|
|
78
|
+
retired ? `offset ${retired.index}:${JSON.stringify(retired[0])}` : '',
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ───────────────────────── ④ 升格那套 turn/step 骨架还在 ─────────────────────────
|
|
83
|
+
{
|
|
84
|
+
// 客户端按 turn/step 事件给消息定位;少一个,历史消息就没有坐标、不显示成对话
|
|
85
|
+
const skeleton = ['turn/start', 'step/start', 'user/message', 'assistant/message', 'step/end', 'turn/end']
|
|
86
|
+
const missing = skeleton.filter((type) => !BUNDLE.includes(`'${type}'`))
|
|
87
|
+
assert('升格骨架:turn/step 六个事件都还写着', missing.length === 0, missing.length ? `缺:${missing.join(', ')}` : '')
|
|
88
|
+
assert('升格骨架:turn/end 带 reason(否则那轮不算收尾)', /reason:\s*\{\s*kind:\s*'completed'/.test(BUNDLE), '')
|
|
89
|
+
assert('升格骨架:历史轮号从 1000 起(避开真实第一轮的号段)', /turnNo\s*=\s*1000/.test(BUNDLE), '')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.log(`\n=== 会话格式契约测试结束:${pass} 通过 / ${fail} 失败 ===`)
|
|
93
|
+
process.exit(fail === 0 ? 0 : 1)
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/** Model following regressions: execute the client functions against real HTTP responses. */
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { createServer } from 'node:http'
|
|
5
|
+
import { resolve } from 'node:path'
|
|
6
|
+
import { test } from 'node:test'
|
|
7
|
+
|
|
8
|
+
const source = readFileSync(resolve(process.argv[2] || 'src/client/index.js'), 'utf8')
|
|
9
|
+
|
|
10
|
+
// Extract executable functions, never assert on their spelling or implementation.
|
|
11
|
+
function functionSource(name) {
|
|
12
|
+
const start = source.indexOf('function ' + name + '(')
|
|
13
|
+
assert.ok(start >= 0, 'Missing client function: ' + name)
|
|
14
|
+
let depth = 0
|
|
15
|
+
for (let i = source.indexOf('{', start); i < source.length; i += 1) {
|
|
16
|
+
const ch = source[i]
|
|
17
|
+
if (ch === '{') depth += 1
|
|
18
|
+
else if (ch === '}' && --depth === 0) return source.slice(start, i + 1)
|
|
19
|
+
else if (ch === "'" || ch === '"' || ch === '`') {
|
|
20
|
+
for (i += 1; i < source.length; i += 1) {
|
|
21
|
+
if (source[i] === '\\') i += 1
|
|
22
|
+
else if (source[i] === ch) break
|
|
23
|
+
}
|
|
24
|
+
} else if (ch === '/' && source[i + 1] === '/') {
|
|
25
|
+
while (i < source.length && source[i] !== '\n') i += 1
|
|
26
|
+
} else if (ch === '/' && source[i + 1] === '*') {
|
|
27
|
+
i = source.indexOf('*/', i) + 1
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
throw new Error('Unclosed function: ' + name)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function currentSession(services) {
|
|
34
|
+
return new Function('ctx', functionSource('currentSessionId') + '; return currentSessionId()')({
|
|
35
|
+
get: (name) => services[name],
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test('reads the current session from the modern DSH session binding, not catalog.current', () => {
|
|
40
|
+
assert.equal(currentSession({
|
|
41
|
+
uiSession: { adapter: { current: { getSnapshot: () => ({ key: 'session-a', props: { sessionId: 'session-a' } }) } } },
|
|
42
|
+
sessions: { list: { getSnapshot: () => ({ ids: ['session-a'], byId: {}, phase: 'ready', projectionsBySession: {} }) } },
|
|
43
|
+
}), 'session-a')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('an absent modern binding cannot revive a stale legacy current session', () => {
|
|
47
|
+
assert.equal(currentSession({
|
|
48
|
+
uiSession: { adapter: { current: { getSnapshot: () => ({ key: undefined, props: { sessionId: undefined } }) } } },
|
|
49
|
+
sessions: { list: { getSnapshot: () => ({ current: 'stale-session' }) } },
|
|
50
|
+
}), '')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('supports older DSH runtimes without the session binding service', () => {
|
|
54
|
+
assert.equal(currentSession({ sessions: { list: { getSnapshot: () => ({ current: 'legacy-session' }) } } }), 'legacy-session')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const server = createServer((req, res) => {
|
|
58
|
+
const id = new URL(req.url, 'http://localhost').searchParams.get('sessionId')
|
|
59
|
+
const send = () => {
|
|
60
|
+
res.setHeader('content-type', 'application/json')
|
|
61
|
+
res.end(JSON.stringify({ ok: true, current: { provider: 'provider', model: id }, models: [{ provider: 'provider', model: id }] }))
|
|
62
|
+
}
|
|
63
|
+
// A finishes after B, reproducing a switch while A's catalog is loading.
|
|
64
|
+
setTimeout(send, id === 'session-a' ? 40 : 0)
|
|
65
|
+
})
|
|
66
|
+
await new Promise((done) => server.listen(0, '127.0.0.1', done))
|
|
67
|
+
const origin = 'http://127.0.0.1:' + server.address().port
|
|
68
|
+
|
|
69
|
+
function catalogClient(currentId) {
|
|
70
|
+
return new Function('MODELS', 'fetch', 'currentSessionId', 'panelOrCurrentSessionId', `
|
|
71
|
+
var modelCatalog = { at: 0, items: [], current: null, stages: null, loading: false, pending: null, error: '', leakyOff: [], sessionId: null }
|
|
72
|
+
var modelCatalogRequestSeq = 0
|
|
73
|
+
${functionSource('loadModelCatalog')}
|
|
74
|
+
return { load: loadModelCatalog, snapshot: () => modelCatalog }
|
|
75
|
+
`)(origin + '/models', fetch, () => currentId, () => currentId)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
await test('catalog requests use the panel session even after navigation to another session', async () => {
|
|
80
|
+
const client = catalogClient('session-b')
|
|
81
|
+
const result = await client.load(true, 'session-a')
|
|
82
|
+
assert.equal(result.current.model, 'session-a')
|
|
83
|
+
assert.equal(result.sessionId, 'session-a')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
await test('concurrent catalogs for different sessions cannot share or overwrite each other', async () => {
|
|
87
|
+
const client = catalogClient('session-a')
|
|
88
|
+
const a = client.load(true, 'session-a')
|
|
89
|
+
const b = client.load(true, 'session-b')
|
|
90
|
+
const [resultA, resultB] = await Promise.all([a, b])
|
|
91
|
+
assert.equal(resultA.current.model, 'session-a')
|
|
92
|
+
assert.equal(resultB.current.model, 'session-b')
|
|
93
|
+
assert.equal(client.snapshot().current.model, 'session-b')
|
|
94
|
+
const cachedB = await client.load(false, 'session-b')
|
|
95
|
+
assert.equal(cachedB.current.model, 'session-b')
|
|
96
|
+
})
|
|
97
|
+
} finally {
|
|
98
|
+
server.closeAllConnections()
|
|
99
|
+
await new Promise((done) => server.close(done))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
await test('legacy follow-up model cannot override the model used by first and second rounds', async () => {
|
|
103
|
+
const ts = await import('typescript')
|
|
104
|
+
const host = readFileSync(resolve('src/index.ts'), 'utf8')
|
|
105
|
+
const start = host.indexOf(' const resolveRoute = (')
|
|
106
|
+
const end = host.indexOf(' /** 分钟级限流。 */', start)
|
|
107
|
+
const js = ts.default.transpileModule(host.slice(start, end), { compilerOptions: { target: ts.default.ScriptTarget.ES2022 } }).outputText
|
|
108
|
+
const config = { provider: 'shared', model: 'chosen', chatProvider: 'legacy', chatModel: 'wrong' }
|
|
109
|
+
const route = new Function('config', 'modelCache', 'ctx', 'captured', js + '; return resolveRoute')(config, { items: [] }, { get: () => null }, null)
|
|
110
|
+
assert.deepEqual(route(undefined, null, false), { provider: 'shared', model: 'chosen' })
|
|
111
|
+
assert.deepEqual(route(undefined, null, true), { provider: 'shared', model: 'chosen' })
|
|
112
|
+
config.provider = ''; config.model = ''
|
|
113
|
+
assert.deepEqual(route(undefined, { provider: 'session', model: 'current' }, true), { provider: 'session', model: 'current' })
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
await test('first and second rounds use the same selected model without borrowing follow-up effort', () => {
|
|
117
|
+
const payload = new Function('state', 'document', 'location', 'panelOrCurrentSessionId', 'selectionKind', functionSource('buildPayload') + '; return buildPayload')(
|
|
118
|
+
{ modelChoice: { provider: 'shared', model: 'chosen' }, effort: 'max' }, { title: 'test' }, { href: 'http://local' }, () => 'session', () => 'text')
|
|
119
|
+
for (const stage of ['translation', 'detail']) {
|
|
120
|
+
const body = payload('text', 'context', 'label', false, stage)
|
|
121
|
+
assert.equal(body.provider, 'shared')
|
|
122
|
+
assert.equal(body.model, 'chosen')
|
|
123
|
+
assert.equal(body.effort, undefined)
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
function settingsClient(send) {
|
|
128
|
+
return new Function('fetch', `
|
|
129
|
+
var SETTINGS = '/settings', settingsSpec = {}, settingsSaving = false, settingsResetting = false,
|
|
130
|
+
settingsSaveRequest = null, settingsSaveSeq = 0, settingsSaveTimer = 0,
|
|
131
|
+
settingsLive = {provider: 'p', model: 'old'}, settingsSaved = {provider: 'p', model: 'old'},
|
|
132
|
+
settingsByKey = {}, state = {}, bridgeOn = true, EFFORT_KEY = 'effort', scheduled = 0, MAX_SELECTION = 4000;
|
|
133
|
+
var settingsNoteText = {textContent: ''}, settingsNote = {setAttribute: function(){}};
|
|
134
|
+
function renderSettingsNote(note) { settingsNoteText.textContent = note === 'error' ? '保存失败,改动未生效' : note }
|
|
135
|
+
function settingsDirtyCount() { return Object.keys(settingsLive).filter(k => settingsLive[k] !== settingsSaved[k]).length }
|
|
136
|
+
function scheduleSettingsSave() { scheduled++ }
|
|
137
|
+
function paintModelPick(){} function repaintModelMenu(){} function setControlValue(){}
|
|
138
|
+
function updateSettingDependencies(){} function syncComposerModel(){ state.modelChoice = settingsLive.model ? {provider: settingsLive.provider, model: settingsLive.model} : null }
|
|
139
|
+
function refreshModelRow(){} function paintModelPill(){} function writeStore(){} function applyPillPrefs(){} function scheduleBridgeScan(){}
|
|
140
|
+
${functionSource('applySelectionLimit')}
|
|
141
|
+
${functionSource('applyServerValues')}
|
|
142
|
+
${functionSource('flushSettingsSave')}
|
|
143
|
+
${functionSource('resetSettings')}
|
|
144
|
+
return { edit(model) { settingsLive.model = model }, save: flushSettingsSave, reset: resetSettings,
|
|
145
|
+
snapshot() { return {live: {...settingsLive}, saved: {...settingsSaved}, scheduled, resetting: settingsResetting, choice: state.modelChoice} } }
|
|
146
|
+
`)(send)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
await test('a slow save preserves a newer model choice and schedules its save', async () => {
|
|
150
|
+
let finish
|
|
151
|
+
const client = settingsClient(() => new Promise(resolve => { finish = resolve }))
|
|
152
|
+
client.edit('first')
|
|
153
|
+
const pending = client.save()
|
|
154
|
+
client.edit('second')
|
|
155
|
+
finish({json: async () => ({ok: true, values: {provider: 'p', model: 'first'}})})
|
|
156
|
+
await pending
|
|
157
|
+
assert.equal(client.snapshot().live.model, 'second')
|
|
158
|
+
assert.equal(client.snapshot().saved.model, 'first')
|
|
159
|
+
assert.equal(client.snapshot().choice.model, 'second')
|
|
160
|
+
assert.equal(client.snapshot().scheduled, 1)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
await test('reset waits for an active save before clearing the shared model', async () => {
|
|
164
|
+
let finish; const requests = []
|
|
165
|
+
const client = settingsClient((url, init) => {
|
|
166
|
+
const body = JSON.parse(init.body); requests.push(body)
|
|
167
|
+
if (!body.reset) return new Promise(resolve => { finish = resolve })
|
|
168
|
+
return Promise.resolve({json: async () => ({ok: true, values: {provider: '', model: '', chatReasoningEffort: 'high'}})})
|
|
169
|
+
})
|
|
170
|
+
client.edit('changed'); const pending = client.save(); client.reset()
|
|
171
|
+
await Promise.resolve()
|
|
172
|
+
assert.equal(requests.length, 1)
|
|
173
|
+
finish({json: async () => ({ok: true, values: {provider: 'p', model: 'changed'}})})
|
|
174
|
+
await pending
|
|
175
|
+
for (let i = 0; i < 20 && client.snapshot().resetting; i++) await new Promise(resolve => setTimeout(resolve, 0))
|
|
176
|
+
assert.equal(requests.length, 2)
|
|
177
|
+
assert.equal(requests[1].reset, true)
|
|
178
|
+
assert.equal(client.snapshot().live.model, '')
|
|
179
|
+
assert.equal(client.snapshot().choice, null)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
await test('reset also clears a value normalized by the in-flight save', async () => {
|
|
183
|
+
let finish
|
|
184
|
+
const client = settingsClient((url, init) => JSON.parse(init.body).reset
|
|
185
|
+
? Promise.resolve({json: async () => ({ok: true, values: {provider: '', model: ''}})})
|
|
186
|
+
: new Promise(resolve => { finish = resolve }))
|
|
187
|
+
client.edit(' padded '); const pending = client.save(); client.reset()
|
|
188
|
+
finish({json: async () => ({ok: true, values: {provider: 'p', model: 'padded'}})})
|
|
189
|
+
await pending
|
|
190
|
+
for (let i = 0; i < 20 && client.snapshot().resetting; i++) await new Promise(resolve => setTimeout(resolve, 0))
|
|
191
|
+
assert.equal(client.snapshot().live.model, '')
|
|
192
|
+
assert.equal(client.snapshot().scheduled, 0)
|
|
193
|
+
})
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /route 的会话级短缓存。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单测它:跟随状态下**小窗一开着**客户端就每 2 秒问一次 /route,
|
|
5
|
+
* 而 host 侧解析"本会话在用哪个模型"要 readSession —— 那会把整份日志重放成 Session、
|
|
6
|
+
* 再逐条深拷贝一遍(本机实测 1.5MB 会话一次 80~125ms)。不缓存等于每 2 秒重放一次日志。
|
|
7
|
+
*
|
|
8
|
+
* 验的是三条:
|
|
9
|
+
* ① 事件没涨(live 会话的 seq 不变)→ 不重读,直接回缓存;
|
|
10
|
+
* ② seq 涨了但离上次重读太近 → 先扛住(防"主会话在连续对话时每拍重放");
|
|
11
|
+
* ③ 用户动作路径(/models、/ping、analyze)不吃缓存 —— 必须是此刻那一份。
|
|
12
|
+
*
|
|
13
|
+
* 用法:node scripts/test-route-cache.mjs
|
|
14
|
+
*/
|
|
15
|
+
import assert from 'node:assert/strict'
|
|
16
|
+
import { createServer } from 'node:http'
|
|
17
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
18
|
+
import { tmpdir } from 'node:os'
|
|
19
|
+
import { join } from 'node:path'
|
|
20
|
+
import { apply } from '../lib/index.js'
|
|
21
|
+
|
|
22
|
+
const home = await mkdtemp(join(tmpdir(), 'dsh-route-cache-'))
|
|
23
|
+
const previousHome = process.env.DSH_HOME
|
|
24
|
+
process.env.DSH_HOME = home
|
|
25
|
+
|
|
26
|
+
/** 会话自己记的模型选择(每次"主会话换模型"都改它 + 让 seq 涨一格)。 */
|
|
27
|
+
let selection = { provider: 'p1', model: 'm-fast' }
|
|
28
|
+
/** live 会话的事件数(Session.seq = 日志长度,append 一次涨一格)。 */
|
|
29
|
+
let liveSeq = 12
|
|
30
|
+
/** readSession 被真正调用了几次 —— 缓存命中就该一次都不涨。 */
|
|
31
|
+
let reads = 0
|
|
32
|
+
|
|
33
|
+
const services = {
|
|
34
|
+
llm: { listProviders: () => [{ id: 'p1', models: [{ id: 'm-fast' }] }] },
|
|
35
|
+
agentDefaultModel: { currentSelection: () => ({ provider: 'p9', model: 'm-global' }) },
|
|
36
|
+
sessions: { get: () => ({ header: { cwd: '/fixture' }, seq: liveSeq }) },
|
|
37
|
+
sessionQuery: {
|
|
38
|
+
readSurface: async () => ({ events: [] }),
|
|
39
|
+
readSession: async () => {
|
|
40
|
+
reads += 1
|
|
41
|
+
return {
|
|
42
|
+
events: [
|
|
43
|
+
{ type: 'user/message', data: {} },
|
|
44
|
+
{ type: 'model/selection', data: { provider: 'p1', model: 'm-old' } },
|
|
45
|
+
{ type: 'assistant/message', data: {} },
|
|
46
|
+
{ type: 'model/selection', data: selection },
|
|
47
|
+
],
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const routes = new Map()
|
|
54
|
+
const ctx = {
|
|
55
|
+
llm: services.llm,
|
|
56
|
+
get: (name) => services[name],
|
|
57
|
+
on: () => () => {},
|
|
58
|
+
effect: (fn) => fn(),
|
|
59
|
+
logger: { info() {}, warn() {} },
|
|
60
|
+
webServer: { register: (route) => { routes.set(route.path, route.handler); return () => routes.delete(route.path) } },
|
|
61
|
+
}
|
|
62
|
+
apply(ctx, {})
|
|
63
|
+
|
|
64
|
+
const server = createServer((req, res) => {
|
|
65
|
+
const handler = routes.get(new URL(req.url, 'http://localhost').pathname)
|
|
66
|
+
if (!handler) { res.writeHead(404); res.end(); return }
|
|
67
|
+
Promise.resolve(handler(req, res)).catch((error) => {
|
|
68
|
+
console.error(error)
|
|
69
|
+
if (!res.headersSent) res.writeHead(500)
|
|
70
|
+
res.end()
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
|
74
|
+
const origin = 'http://127.0.0.1:' + server.address().port
|
|
75
|
+
|
|
76
|
+
let passed = 0
|
|
77
|
+
let failed = 0
|
|
78
|
+
function check(label, ok, detail) {
|
|
79
|
+
if (ok) { passed += 1; console.log(`PASS ${label}${detail ? ' — ' + detail : ''}`) }
|
|
80
|
+
else { failed += 1; console.log(`FAIL ${label}${detail ? ' — ' + detail : ''}`) }
|
|
81
|
+
}
|
|
82
|
+
const route = async () => (await fetch(`${origin}/selection-explain/api/route?sessionId=session-1`)).json()
|
|
83
|
+
const models = async () => (await fetch(`${origin}/selection-explain/api/models?sessionId=session-1`)).json()
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
// ① 第一次:真读一次
|
|
87
|
+
const first = await route()
|
|
88
|
+
check('第一次 /route 真读会话,拿到本会话的模型',
|
|
89
|
+
first.ok === true && first.model === 'm-fast' && reads === 1,
|
|
90
|
+
`model=${first.model} reads=${reads}`)
|
|
91
|
+
|
|
92
|
+
// ① 事件没涨(seq 不变)→ 命中缓存,一次都不重读
|
|
93
|
+
const second = await route()
|
|
94
|
+
const third = await route()
|
|
95
|
+
check('事件没涨时不重读会话日志(seq 没变 → 结论不可能变)',
|
|
96
|
+
second.model === 'm-fast' && third.model === 'm-fast' && reads === 1,
|
|
97
|
+
`reads=${reads}`)
|
|
98
|
+
|
|
99
|
+
// ② seq 涨了,但离上次重读太近 → 先扛住(挡住"主会话连续对话时每 2 秒重放一次")
|
|
100
|
+
selection = { provider: 'p1', model: 'm-strong' }
|
|
101
|
+
liveSeq += 1
|
|
102
|
+
const throttled = await route()
|
|
103
|
+
check('seq 刚涨、距上次重读不足下限时不重读(防重放风暴)',
|
|
104
|
+
throttled.model === 'm-fast' && reads === 1,
|
|
105
|
+
`model=${throttled.model} reads=${reads}`)
|
|
106
|
+
|
|
107
|
+
// ② 过了下限 → 重读,跟上新模型
|
|
108
|
+
await new Promise((resolve) => setTimeout(resolve, 3200))
|
|
109
|
+
const caught = await route()
|
|
110
|
+
check('过了最短间隔后重读并跟上新模型',
|
|
111
|
+
caught.model === 'm-strong' && reads === 2,
|
|
112
|
+
`model=${caught.model} reads=${reads}`)
|
|
113
|
+
|
|
114
|
+
// ③ 用户动作路径不吃缓存:/models 每次都直读(刚换完模型划词不能按旧模型解析)
|
|
115
|
+
selection = { provider: 'p2', model: 'm-other' }
|
|
116
|
+
liveSeq += 1
|
|
117
|
+
const beforeModels = reads
|
|
118
|
+
await models()
|
|
119
|
+
await models()
|
|
120
|
+
check('/models(用户动作路径)每次都直读,不走缓存',
|
|
121
|
+
reads === beforeModels + 2,
|
|
122
|
+
`reads=${beforeModels} → ${reads}`)
|
|
123
|
+
|
|
124
|
+
// ③ 直读顺手把结论种进缓存(seq 对得上)→ 紧接着的轮询不必再读一遍
|
|
125
|
+
const seeded = await route()
|
|
126
|
+
check('直读种进缓存后,轮询不再重复读(seq 对得上就是同一个结论)',
|
|
127
|
+
seeded.model === 'm-other' && reads === beforeModels + 2,
|
|
128
|
+
`model=${seeded.model} reads=${reads}`)
|
|
129
|
+
} finally {
|
|
130
|
+
server.closeAllConnections()
|
|
131
|
+
await new Promise((resolve) => server.close(resolve))
|
|
132
|
+
if (previousHome === undefined) delete process.env.DSH_HOME
|
|
133
|
+
else process.env.DSH_HOME = previousHome
|
|
134
|
+
await rm(home, { recursive: true, force: true })
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
console.log(`=== /route 缓存测试结束:${passed} 通过 / ${failed} 失败 ===`)
|
|
138
|
+
if (failed > 0) process.exitCode = 1
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/** Exercise setting effects through the real plugin HTTP handlers in disposable storage. */
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import {createServer} from 'node:http'
|
|
4
|
+
import {mkdtemp, mkdir, readFile, rm, writeFile} from 'node:fs/promises'
|
|
5
|
+
import {tmpdir} from 'node:os'
|
|
6
|
+
import {join} from 'node:path'
|
|
7
|
+
import {apply} from '../lib/index.js'
|
|
8
|
+
|
|
9
|
+
const home=await mkdtemp(join(tmpdir(),'dsh-settings-behavior-'))
|
|
10
|
+
const oldHome=process.env.DSH_HOME
|
|
11
|
+
process.env.DSH_HOME=home
|
|
12
|
+
await mkdir(join(home,'selection-explain'))
|
|
13
|
+
await writeFile(join(home,'selection-explain/settings.json'),JSON.stringify({values:{maxTokens:1234,temperature:0.4,resultCacheTtlMs:600000,historyMaxEntries:0}}))
|
|
14
|
+
const longSelection='长'.repeat(4500)+'末尾标记'
|
|
15
|
+
await writeFile(join(home,'selection-explain/history.json'),JSON.stringify({entries:[{key:'existing-long',text:longSelection}]}))
|
|
16
|
+
const routes=new Map(), calls=[], executions=[]
|
|
17
|
+
let mode='answer', toolPath='inside.txt', checks=0, serial=0
|
|
18
|
+
const events=[]
|
|
19
|
+
for(let i=1;i<=10;i++){
|
|
20
|
+
events.push({type:'user/message',data:{role:'user',source:{kind:'user'},content:[{type:'text',text:`Q${String(i).padStart(2,'0')} `+'甲'.repeat(300)}]}})
|
|
21
|
+
events.push({type:'assistant/message',data:{message:{content:[{type:'text',text:`A${String(i).padStart(2,'0')} `+'乙'.repeat(300)}]}}})
|
|
22
|
+
}
|
|
23
|
+
const llm={listProviders:()=>[{id:'fixture',name:'Fixture'}],listModels:async()=>[{id:'a',name:'A'},{id:'b',name:'B'}],
|
|
24
|
+
async *stream(options){
|
|
25
|
+
calls.push(options)
|
|
26
|
+
if(mode==='timeout'){
|
|
27
|
+
await new Promise((resolve,reject)=>{
|
|
28
|
+
if(options.signal.aborted)reject(new DOMException('aborted','AbortError'))
|
|
29
|
+
else options.signal.addEventListener('abort',()=>reject(new DOMException('aborted','AbortError')),{once:true})
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
const tool=mode==='unexpected-tools'?(options.tools?.[0]??{name:'read'}):mode==='unlisted-tool'?{name:'grep'}:options.tools?.[0]
|
|
33
|
+
// 工具结果消息的 shape 跟运行时版本有关,两种都要认(见下面 textOfBlocks 的注释)
|
|
34
|
+
const hasResult=options.messages.some(m=>m.role==='tool'||(m.content||[]).some(b=>b.type==='tool-result'))
|
|
35
|
+
if(tool && (mode==='unexpected-tools' || mode==='unlisted-tool' || mode==='repeat-tools' || mode==='one-tool' && !hasResult)){
|
|
36
|
+
yield {type:'block-start',index:0,blockType:'tool-call'}
|
|
37
|
+
yield {type:'tool-call-delta',index:0,id:'call-'+calls.length,name:tool.name,argumentsDelta:JSON.stringify({file_path:toolPath})}
|
|
38
|
+
yield {type:'block-end',index:0,block:{type:'tool-call',id:'call-'+calls.length,name:tool.name,arguments:JSON.stringify({file_path:toolPath})}}
|
|
39
|
+
yield {type:'finish',reason:{kind:'tool-calls'}}
|
|
40
|
+
}else{
|
|
41
|
+
yield {type:'text-delta',index:0,text:'这是验证设置行为的回答,内容完整。'}
|
|
42
|
+
yield {type:'finish',reason:{kind:'stop'}}
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
const registry={schemas:()=>['read','grep','web_search','platform_search'].map(name=>({name,description:name,parameters:{type:'object',properties:{}}})),
|
|
47
|
+
async execute(input){executions.push(input);return {content:[{type:'text',text:'工具结果'.repeat(400)}],isError:false}}}
|
|
48
|
+
const services={llm,tools:registry,agentDefaultModel:{currentSelection:()=>({provider:'fixture',model:'a'})},sessions:{get:()=>({header:{cwd:'/fixture/project'}})},
|
|
49
|
+
sessionQuery:{readSurface:async()=>({events}),readSession:async()=>({events:[]})}}
|
|
50
|
+
const ctx={llm,get:name=>services[name],on(){return ()=>{}},effect:fn=>fn(),logger:{info(){},warn(){}},
|
|
51
|
+
webServer:{register(route){routes.set(route.path,route.handler);return ()=>routes.delete(route.path)}}}
|
|
52
|
+
apply(ctx,{})
|
|
53
|
+
const server=createServer((req,res)=>{
|
|
54
|
+
const handler=routes.get(new URL(req.url,'http://localhost').pathname)
|
|
55
|
+
if(!handler){res.writeHead(404);res.end();return}
|
|
56
|
+
Promise.resolve(handler(req,res)).catch(error=>{console.error(error);if(!res.headersSent)res.writeHead(500);res.end()})
|
|
57
|
+
})
|
|
58
|
+
await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve))
|
|
59
|
+
const origin='http://127.0.0.1:'+server.address().port
|
|
60
|
+
async function json(path,body){const r=await fetch(origin+'/selection-explain/api/'+path,body?{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)}:{});return r.json()}
|
|
61
|
+
async function set(values){const result=await json('settings',{values});assert.equal(result.ok,true);assert.deepEqual(result.rejected,[]);return result}
|
|
62
|
+
async function analyze(body={}){
|
|
63
|
+
const start=calls.length
|
|
64
|
+
const r=await fetch(origin+'/selection-explain/api/analyze',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({text:'probe-'+(++serial),refresh:true,sessionId:'fixture-session',...body})})
|
|
65
|
+
const text=await r.text()
|
|
66
|
+
const chunks=text.split('\n').filter(s=>s.startsWith('data: ')).map(s=>JSON.parse(s.slice(6)))
|
|
67
|
+
return {status:r.status,text,chunks,calls:calls.slice(start)}
|
|
68
|
+
}
|
|
69
|
+
function check(label,fn){try{fn();checks++;console.log('PASS '+label)}catch(error){console.error('FAIL '+label);throw error}}
|
|
70
|
+
/**
|
|
71
|
+
* 取一段消息里的**全部文本**(含嵌在工具结果里的)。
|
|
72
|
+
*
|
|
73
|
+
* 工具结果消息的 shape 随运行时版本不同,两种都得认:
|
|
74
|
+
* · `dsh-llm@0.1.7-rc.x`(本机运行时):`role:'tool'`,`content` 就是 `[{type:'text',…}]`;
|
|
75
|
+
* · `dsh-llm@0.1.5-rc.3`(`devDependencies` 里那份、也是 peer 范围的下界,**CI 装的就是它**):
|
|
76
|
+
* `role:'user'`,`content` 是 `[{type:'tool-result', content:[{type:'text',…}]}]` —— 嵌一层。
|
|
77
|
+
* 只认顶层 `type:'text'` 的话,在 CI 上"工具结果里有没有『已截断』"会**假失败**
|
|
78
|
+
* (v0.8.3 发布时实测踩到:截断标记明明在,只是藏在 tool-result 块里)。
|
|
79
|
+
*/
|
|
80
|
+
function textOfBlocks(blocks){return (blocks||[]).flatMap(b=>b.type==='text'?[String(b.text)]:(b.type==='tool-result'?textOfBlocks(b.content):[]))}
|
|
81
|
+
function prompt(call){return (call?.messages||[]).filter(m=>m.role!=='system').flatMap(m=>textOfBlocks(m.content)).join('\n')}
|
|
82
|
+
function background(call){const text=prompt(call);return text.includes('【会话背景】')?text.split('【会话背景】')[1].split('上面是会话背景')[0]:''}
|
|
83
|
+
try{
|
|
84
|
+
await json('history',{key:'startup',text:'启动时历史设置'})
|
|
85
|
+
const initialHistory=await json('history')
|
|
86
|
+
check('首次直接保存历史也读取已持久化的设置',()=>assert.deepEqual(initialHistory.entries.map(e=>e.key),['existing-long']))
|
|
87
|
+
const loadedLong=await json('history?key=existing-long')
|
|
88
|
+
check('加载已有长划词历史不会按旧默认截断',()=>assert(loadedLong.entry.text===longSelection,`实际保存 ${loadedLong.entry.text.length} 字,期望 ${longSelection.length} 字`))
|
|
89
|
+
await json('settings')
|
|
90
|
+
await set({historyMaxEntries:20})
|
|
91
|
+
await set({provider:'fixture',model:'b',translationReasoningEffort:'max',reasoningEffort:'low',chatReasoningEffort:'off',tools:false})
|
|
92
|
+
for(const [label,body,effort] of [['首轮',{stage:'translation'},'max'],['次轮',{stage:'detail'},'low'],['追问',{question:'继续解释'},'off']]){
|
|
93
|
+
const r=await analyze(body)
|
|
94
|
+
check(label+'实际采用同一模型与对应思考强度',()=>{assert.equal(r.calls[0].model,'b');assert.equal(r.calls[0].reasoningEffort,effort)})
|
|
95
|
+
}
|
|
96
|
+
check('隐藏的输出上限、温度仍进入模型调用',()=>{assert.equal(calls[0].maxTokens,1234);assert.equal(calls[0].temperature,0.4)})
|
|
97
|
+
await set({maxSelectionChars:20})
|
|
98
|
+
const over=await analyze({text:'长'.repeat(21)})
|
|
99
|
+
check('选中文字上限拒绝过长请求',()=>assert.equal(over.status,413))
|
|
100
|
+
await set({maxSelectionChars:6000})
|
|
101
|
+
const longer=await analyze({text:'长'.repeat(4500)})
|
|
102
|
+
check('调高选中文字上限后 host 接受超过旧默认的文字',()=>assert.equal(longer.status,200))
|
|
103
|
+
await json('history',{key:'saved-long',text:longSelection})
|
|
104
|
+
const savedLong=await json('history?key=saved-long')
|
|
105
|
+
check('保存长划词历史保留选中文字及末尾',()=>assert(savedLong.entry.text===longSelection,`实际保存 ${savedLong.entry.text.length} 字,期望 ${longSelection.length} 字`))
|
|
106
|
+
await set({maxContextChars:200,sessionContext:false})
|
|
107
|
+
const local=await analyze({context:'局'.repeat(400)})
|
|
108
|
+
check('局部上下文按上限截断',()=>{assert(prompt(local.calls[0]).includes('局'.repeat(200)));assert(!prompt(local.calls[0]).includes('局'.repeat(201)))})
|
|
109
|
+
check('关闭会话背景后不注入会话消息',()=>assert.equal(background(local.calls[0]),''))
|
|
110
|
+
await set({sessionContext:true,sessionContextFastMessages:2,sessionContextMaxMessages:4,sessionContextMaxChars:0})
|
|
111
|
+
const fast=await analyze({text:'Q09',stage:'translation'})
|
|
112
|
+
const detail=await analyze({text:'Q09',stage:'detail'})
|
|
113
|
+
const chat=await analyze({text:'Q09',question:'继续'})
|
|
114
|
+
const count=r=>(background(r.calls[0]).match(/(?:▶\s*)?(?:用户|助手):/g)||[]).length
|
|
115
|
+
check('首轮按设置取两条背景消息',()=>assert.equal(count(fast),2))
|
|
116
|
+
check('次轮与追问按设置取四条背景消息',()=>{assert.equal(count(detail),4);assert.equal(count(chat),4)})
|
|
117
|
+
await set({sessionContextMaxChars:1000})
|
|
118
|
+
const cap=await analyze({text:'Q09',stage:'detail'})
|
|
119
|
+
check('会话上下文字符上限改变实际注入量',()=>assert(background(cap.calls[0]).length<background(detail.calls[0]).length))
|
|
120
|
+
await set({quoteContextRounds:0,quoteContextMaxCharsPerTurn:200,quoteContextMaxChars:6000})
|
|
121
|
+
const quote0=await json('quote-context',{text:'Q05',sessionId:'fixture-session'})
|
|
122
|
+
await set({quoteContextRounds:2,quoteContextMaxCharsPerTurn:2000})
|
|
123
|
+
const quote2=await json('quote-context',{text:'Q05',sessionId:'fixture-session'})
|
|
124
|
+
check('引用上下文轮数控制实际取出的轮数',()=>{assert.equal(quote0.rounds,1);assert.equal(quote2.rounds,5)})
|
|
125
|
+
check('引用单条消息上限控制长消息裁剪',()=>{assert(quote0.context.length<600);assert(quote2.context.includes('甲'.repeat(300)))})
|
|
126
|
+
await set({quoteContextMaxChars:600})
|
|
127
|
+
const quoteCap=await json('quote-context',{text:'Q05',sessionId:'fixture-session'})
|
|
128
|
+
check('引用总字符上限减少上下文并保留引用',()=>{assert(quoteCap.context.length<quote2.context.length);assert(quoteCap.context.includes('【Q05】'))})
|
|
129
|
+
await set({tools:true,maxToolRounds:1,toolNames:'read',fallbackToolNames:'web_search',toolReadRoot:'/fixture/project',toolResultMaxChars:200})
|
|
130
|
+
mode='one-tool'; toolPath='/fixture/project/inside.txt'
|
|
131
|
+
const tool=await analyze()
|
|
132
|
+
check('工具允许列表控制提供给模型的工具',()=>assert.deepEqual(tool.calls[0].tools.map(t=>t.name),['read','web_search']))
|
|
133
|
+
check('工具返回字符上限作用于回灌模型的结果',()=>{const next=prompt(tool.calls[1]);assert(next.includes('已截断'));assert(!next.includes('工具结果'.repeat(60)))})
|
|
134
|
+
const beforeDenied=executions.length;toolPath='/fixture/outside.txt'
|
|
135
|
+
const denied=await analyze()
|
|
136
|
+
check('指定文件目录拒绝目录外调用',()=>{assert.equal(executions.length,beforeDenied);assert(denied.text.includes('越界已拒绝'))})
|
|
137
|
+
await set({toolReadRoot:''});toolPath='/fixture/outside.txt'
|
|
138
|
+
const beforeDefault=executions.length;await analyze()
|
|
139
|
+
check('文件目录留空时按会话工作目录限制',()=>assert.equal(executions.length,beforeDefault))
|
|
140
|
+
await set({toolReadRoot:'*'});await analyze()
|
|
141
|
+
check('文件目录星号允许跨目录调用',()=>assert.equal(executions.length,beforeDefault+1))
|
|
142
|
+
await set({toolNames:'unavailable',fallbackToolNames:'web_search'})
|
|
143
|
+
const fallback=await analyze()
|
|
144
|
+
check('首选工具不可用时加入备用搜索工具',()=>assert.deepEqual(fallback.calls[0].tools.map(t=>t.name),['web_search']))
|
|
145
|
+
await set({tools:false})
|
|
146
|
+
const noTools=await analyze()
|
|
147
|
+
check('关闭工具调用后模型拿不到工具',()=>assert.equal(noTools.calls[0].tools,undefined))
|
|
148
|
+
await set({tools:true,maxToolRounds:0})
|
|
149
|
+
const zero=await analyze()
|
|
150
|
+
check('调用轮数为零时不提供工具',()=>assert.equal(zero.calls[0].tools,undefined))
|
|
151
|
+
await set({maxToolRounds:1});mode='repeat-tools'
|
|
152
|
+
const beforeRounds=executions.length;await analyze()
|
|
153
|
+
check('调用轮数为一时只执行一轮工具',()=>assert.equal(executions.length-beforeRounds,1))
|
|
154
|
+
mode='unexpected-tools'
|
|
155
|
+
const beforeUnexpected=executions.length;await analyze()
|
|
156
|
+
check('收尾轮即使返回工具调用也不突破轮数上限',()=>assert.equal(executions.length-beforeUnexpected,1))
|
|
157
|
+
await set({maxToolRounds:0})
|
|
158
|
+
const beforeZeroUnexpected=executions.length;await analyze()
|
|
159
|
+
check('轮数为零时即使返回工具调用也不执行',()=>assert.equal(executions.length,beforeZeroUnexpected))
|
|
160
|
+
await set({maxToolRounds:1,tools:false})
|
|
161
|
+
const beforeDisabledUnexpected=executions.length;await analyze()
|
|
162
|
+
check('关闭工具后即使返回工具调用也不执行',()=>assert.equal(executions.length,beforeDisabledUnexpected))
|
|
163
|
+
await set({tools:true,toolNames:'read',fallbackToolNames:''});mode='unlisted-tool'
|
|
164
|
+
const beforeUnlisted=executions.length;await analyze()
|
|
165
|
+
check('模型返回未允许的工具也不执行',()=>assert.equal(executions.length,beforeUnlisted))
|
|
166
|
+
mode='answer';await set({tools:false})
|
|
167
|
+
const cacheText='cache-probe';const first=await analyze({text:cacheText,refresh:false});const second=await analyze({text:cacheText,refresh:false})
|
|
168
|
+
check('隐藏缓存设置后仍复用相同请求的结果',()=>{assert(first.calls.length>0);assert.equal(second.calls.length,0);assert(second.chunks.some(c=>c.cached===true))})
|
|
169
|
+
await set({historyMaxEntries:1})
|
|
170
|
+
await json('history',{key:'one',text:'一',turns:[]});await json('history',{key:'two',text:'二',turns:[]})
|
|
171
|
+
const history=await json('history')
|
|
172
|
+
check('历史保留段数实际淘汰旧对话',()=>assert.deepEqual(history.entries.map(e=>e.key),['two']))
|
|
173
|
+
await set({historyMaxEntries:0});await json('history',{key:'three',text:'三'})
|
|
174
|
+
const unchangedHistory=await json('history')
|
|
175
|
+
check('历史上限为零停止新保存并保留已有记录',()=>assert.deepEqual(unchangedHistory.entries.map(e=>e.key),['two']))
|
|
176
|
+
await set({timeoutMs:5000});mode='timeout'
|
|
177
|
+
const started=Date.now(), timed=await analyze()
|
|
178
|
+
check('生成时限实际取消模型流',()=>{assert(timed.chunks.some(c=>c.type==='error'&&c.message.includes('超时')));assert(Date.now()-started>=4900);assert(Date.now()-started<9000)})
|
|
179
|
+
mode='answer';await set({maxRequestsPerMinute:1})
|
|
180
|
+
const rate=await analyze()
|
|
181
|
+
check('每分钟请求上限实际拒绝超限请求',()=>assert.equal(rate.status,429))
|
|
182
|
+
console.log(`全部 ${checks} 项实际行为验证通过`)
|
|
183
|
+
}finally{
|
|
184
|
+
server.closeAllConnections();await new Promise(resolve=>server.close(resolve))
|
|
185
|
+
await new Promise(resolve=>setTimeout(resolve,900))
|
|
186
|
+
if(oldHome===undefined)delete process.env.DSH_HOME;else process.env.DSH_HOME=oldHome
|
|
187
|
+
await rm(home,{recursive:true,force:true})
|
|
188
|
+
}
|