@yfwu2020/dsh-selection-explain 0.7.1 → 0.9.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 +112 -5
- package/cordis.patch.yml +2 -2
- package/lib/client.js +1734 -77
- package/lib/index.js +513 -32
- 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 +37 -6
- package/lib/types/settings.d.ts +125 -0
- package/package.json +2 -2
- package/scripts/smoke-panel.mjs +333 -1
- package/scripts/smoke-voice.mjs +3 -1
- package/scripts/test-bridge.mjs +4 -0
- package/scripts/test-client.mjs +661 -10
- 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 +1734 -77
- package/src/index.ts +549 -28
- package/src/settings.ts +310 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Run client integration against the real plugin in an isolated settings/history directory. */
|
|
2
|
+
import { createServer } from 'node:http'
|
|
3
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { spawn } from 'node:child_process'
|
|
7
|
+
import { apply } from '../lib/index.js'
|
|
8
|
+
|
|
9
|
+
const temp = await mkdtemp(join(tmpdir(), 'dsh-settings-test-'))
|
|
10
|
+
const previous = process.env.DSH_HOME
|
|
11
|
+
process.env.DSH_HOME = temp
|
|
12
|
+
const routes = new Map()
|
|
13
|
+
const llm = {
|
|
14
|
+
listProviders: () => [{ id: 'fixture', name: 'Fixture' }],
|
|
15
|
+
listModels: async () => [{ id: 'fixture', name: 'Fixture' }],
|
|
16
|
+
async *stream() {
|
|
17
|
+
yield { type: 'text-delta', text: '这是测试模型的回答:所选文字表达了当前上下文中的意思。\n\n具体含义可结合前后文理解。' }
|
|
18
|
+
yield { type: 'finish', reason: { kind: 'stop' } }
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
const ctx = {
|
|
22
|
+
llm,
|
|
23
|
+
get: name => name === 'llm' ? llm : name === 'agentDefaultModel' ? {currentSelection: () => ({provider: 'fixture', model: 'fixture'})} : undefined,
|
|
24
|
+
on() { return () => {} }, effect(fn) { return fn() }, logger: {info() {}, warn() {}},
|
|
25
|
+
webServer: { register(route) { routes.set(route.path, route.handler); return () => routes.delete(route.path) } },
|
|
26
|
+
}
|
|
27
|
+
apply(ctx, {})
|
|
28
|
+
const server = createServer((req, res) => {
|
|
29
|
+
const handler = routes.get(new URL(req.url, 'http://localhost').pathname)
|
|
30
|
+
if (!handler) { res.writeHead(404); res.end(); return }
|
|
31
|
+
Promise.resolve(handler(req, res)).catch(error => { console.error(error); if (!res.headersSent) res.writeHead(500); res.end() })
|
|
32
|
+
})
|
|
33
|
+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
|
34
|
+
try {
|
|
35
|
+
const origin = 'http://127.0.0.1:' + server.address().port
|
|
36
|
+
const result = await new Promise(resolve => {
|
|
37
|
+
const child = spawn(process.execPath, ['scripts/test-client.mjs'], { stdio: 'inherit', env: {...process.env, SEL_ORIGIN: origin, SEL_SETTINGS_ONLY: '1'} })
|
|
38
|
+
child.on('exit', resolve)
|
|
39
|
+
})
|
|
40
|
+
process.exitCode = result ?? 1
|
|
41
|
+
} finally {
|
|
42
|
+
server.closeAllConnections()
|
|
43
|
+
await new Promise(resolve => server.close(resolve))
|
|
44
|
+
// Allow the plugin's debounced writes to complete before removing the fixture.
|
|
45
|
+
await new Promise(resolve => setTimeout(resolve, 500))
|
|
46
|
+
if (previous === undefined) delete process.env.DSH_HOME; else process.env.DSH_HOME = previous
|
|
47
|
+
await rm(temp, {recursive: true, force: true})
|
|
48
|
+
}
|