@yfwu2020/dsh-selection-explain 0.10.4 → 0.10.6
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 +13 -11
- package/cordis.patch.yml +1 -1
- package/lib/client.js +10698 -12424
- package/lib/client.js.map +7 -0
- package/lib/config-values.js +55 -0
- package/lib/config-values.js.map +1 -0
- package/lib/host/analyze.js +795 -0
- package/lib/host/analyze.js.map +1 -0
- package/lib/host/config.js +81 -0
- package/lib/host/config.js.map +1 -0
- package/lib/host/constants.js +25 -0
- package/lib/host/constants.js.map +1 -0
- package/lib/host/context.js +237 -0
- package/lib/host/context.js.map +1 -0
- package/lib/host/design-skill.js +42 -0
- package/lib/host/design-skill.js.map +1 -0
- package/lib/host/effort.js +239 -0
- package/lib/host/effort.js.map +1 -0
- package/lib/host/filters.js +132 -0
- package/lib/host/filters.js.map +1 -0
- package/lib/host/history-store.js +404 -0
- package/lib/host/history-store.js.map +1 -0
- package/lib/host/http.js +46 -0
- package/lib/host/http.js.map +1 -0
- package/lib/host/messages.js +14 -0
- package/lib/host/messages.js.map +1 -0
- package/lib/host/mic.js +246 -0
- package/lib/host/mic.js.map +1 -0
- package/lib/host/models.js +246 -0
- package/lib/host/models.js.map +1 -0
- package/lib/host/plugin.js +41 -0
- package/lib/host/plugin.js.map +1 -0
- package/lib/host/promote.js +219 -0
- package/lib/host/promote.js.map +1 -0
- package/lib/host/routes.js +237 -0
- package/lib/host/routes.js.map +1 -0
- package/lib/host/sessions.js +181 -0
- package/lib/host/sessions.js.map +1 -0
- package/lib/host/settings-store.js +140 -0
- package/lib/host/settings-store.js.map +1 -0
- package/lib/host/speech.js +320 -0
- package/lib/host/speech.js.map +1 -0
- package/lib/host/tools.js +179 -0
- package/lib/host/tools.js.map +1 -0
- package/lib/host/types.js +2 -0
- package/lib/host/types.js.map +1 -0
- package/lib/index.js +10 -3765
- package/lib/index.js.map +1 -1
- package/lib/prompts.js +1 -1
- package/lib/settings.js +17 -27
- package/lib/settings.js.map +1 -1
- package/lib/types/config-values.d.ts +175 -0
- package/lib/types/host/analyze.d.ts +10 -0
- package/lib/types/host/config.d.ts +147 -0
- package/lib/types/host/constants.d.ts +24 -0
- package/lib/types/host/context.d.ts +41 -0
- package/lib/types/host/design-skill.d.ts +1 -0
- package/lib/types/host/effort.d.ts +61 -0
- package/lib/types/host/filters.d.ts +20 -0
- package/lib/types/host/history-store.d.ts +13 -0
- package/lib/types/host/http.d.ts +8 -0
- package/lib/types/host/messages.d.ts +13 -0
- package/lib/types/host/mic.d.ts +17 -0
- package/lib/types/host/models.d.ts +57 -0
- package/lib/types/host/plugin.d.ts +3 -0
- package/lib/types/host/promote.d.ts +9 -0
- package/lib/types/host/routes.d.ts +14 -0
- package/lib/types/host/sessions.d.ts +14 -0
- package/lib/types/host/settings-store.d.ts +8 -0
- package/lib/types/host/speech.d.ts +23 -0
- package/lib/types/host/tools.d.ts +39 -0
- package/lib/types/host/types.d.ts +89 -0
- package/lib/types/index.d.ts +12 -441
- package/lib/types/prompts.d.ts +1 -1
- package/lib/types/settings.d.ts +1 -1
- package/package.json +31 -21
- package/assets/code.png +0 -0
- package/assets/detail.png +0 -0
- package/assets/quotes.png +0 -0
- package/assets/stage1.png +0 -0
- package/assets/stage2.png +0 -0
- package/assets/translation.png +0 -0
- package/assets/voice-card-light.png +0 -0
- package/assets/voice-inserted.png +0 -0
- package/assets/voice-settings.png +0 -0
- package/assets/voice.png +0 -0
- package/assets/web.png +0 -0
- package/scripts/build-demo-pages.mjs +0 -350
- package/scripts/build-vcard-lab.mjs +0 -834
- package/scripts/build.sh +0 -147
- package/scripts/crop-png.mjs +0 -142
- package/scripts/dump-prompt.mjs +0 -143
- package/scripts/fixtures/fake-mic-probe.sh +0 -9
- package/scripts/gen-star-path.mjs +0 -71
- package/scripts/measure-cost.mjs +0 -251
- package/scripts/smoke-bridge.mjs +0 -365
- package/scripts/smoke-panel.mjs +0 -1903
- package/scripts/smoke-voice.mjs +0 -774
- package/scripts/test-bridge.mjs +0 -412
- package/scripts/test-client.mjs +0 -6175
- package/scripts/test-effort-map.mjs +0 -129
- package/scripts/test-effort-retry.mjs +0 -109
- package/scripts/test-escape-focus.mjs +0 -101
- package/scripts/test-filters.mjs +0 -146
- package/scripts/test-format.mjs +0 -93
- package/scripts/test-guard.mjs +0 -48
- package/scripts/test-mic-build.mjs +0 -146
- package/scripts/test-mic-idle.mjs +0 -158
- package/scripts/test-model-follow.mjs +0 -193
- package/scripts/test-prompt.mjs +0 -193
- package/scripts/test-route-cache.mjs +0 -138
- package/scripts/test-settings-behavior.mjs +0 -188
- package/scripts/test-settings-host.mjs +0 -48
- package/scripts/test-settings.mjs +0 -329
- package/scripts/test-speech.mjs +0 -220
- package/scripts/test-transcript.mjs +0 -151
- package/src/client/index.js +0 -12580
- package/src/index.ts +0 -4254
- package/src/prompts.ts +0 -144
- package/src/settings.ts +0 -313
package/scripts/test-prompt.mjs
DELETED
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 提示词契约测试 —— 保护提示词里的**关键约束**不被静默删除。
|
|
3
|
-
*
|
|
4
|
-
* 为什么需要它:提示词是产品行为的主要来源(两节结构、结论条、音标规则、来源判断、
|
|
5
|
-
* 网页模式要求…),但它们只是字符串常量,改动时**没有类型检查也没有编译错误**。
|
|
6
|
-
* 以前唯一的"验证"是手工看 PROMPTS.md,而那份快照会过期。这里把关键约束写成断言:
|
|
7
|
-
* 谁误删了,CI 直接红。
|
|
8
|
-
*
|
|
9
|
-
* 读取方式:优先直接 import src/prompts.ts(Node ≥ 22.14 的类型剥离支持直接加载 .ts,
|
|
10
|
-
* 与 GitHub Actions 的 Node 22.14 一致,无需编译步骤);如果运行环境的 Node 太旧、
|
|
11
|
-
* 不支持类型剥离,则回退到"读源码文本 + 解析数组字面量",保证测试在任何 Node 上都能跑。
|
|
12
|
-
*/
|
|
13
|
-
import { readFileSync } from 'node:fs'
|
|
14
|
-
import { resolve, dirname } from 'node:path'
|
|
15
|
-
import { fileURLToPath } from 'node:url'
|
|
16
|
-
|
|
17
|
-
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
18
|
-
const PROMPTS_TS = resolve(HERE, '..', 'src', 'prompts.ts')
|
|
19
|
-
|
|
20
|
-
const NAMES = [
|
|
21
|
-
'PROMPT_HEAD',
|
|
22
|
-
'TRANSLATION_PROMPT',
|
|
23
|
-
'CODE_PROMPT',
|
|
24
|
-
'DETAIL_PROMPT',
|
|
25
|
-
'CHAT_SYSTEM_PROMPT',
|
|
26
|
-
]
|
|
27
|
-
|
|
28
|
-
/** 回退方案:从源码文本里解析 `export const X = [ '...', ... ].join('\n')`。 */
|
|
29
|
-
function loadFromSource() {
|
|
30
|
-
const src = readFileSync(PROMPTS_TS, 'utf8')
|
|
31
|
-
const out = {}
|
|
32
|
-
for (const name of NAMES) {
|
|
33
|
-
const re = new RegExp(`export const ${name} = \\[([\\s\\S]*?)\\]\\.join\\('\\\\n'\\)`)
|
|
34
|
-
const m = src.match(re)
|
|
35
|
-
if (!m) throw new Error(`回退解析失败:找不到 ${name}`)
|
|
36
|
-
const body = m[1]
|
|
37
|
-
// 元素既可能是字符串字面量,也可能是别的常量(片段复用),后者需要展开
|
|
38
|
-
const parts = []
|
|
39
|
-
const reElem = /'((?:[^'\\]|\\.)*)'|([A-Z_]+)/g
|
|
40
|
-
let em
|
|
41
|
-
while ((em = reElem.exec(body)) !== null) {
|
|
42
|
-
if (em[1] !== undefined) parts.push(em[1].replace(/\\'/g, "'").replace(/\\n/g, '\n'))
|
|
43
|
-
else parts.push(out[em[2]] ?? '')
|
|
44
|
-
}
|
|
45
|
-
out[name] = parts.join('\n')
|
|
46
|
-
}
|
|
47
|
-
return out
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
let P
|
|
51
|
-
let loader = 'import src/prompts.ts(Node 类型剥离)'
|
|
52
|
-
try {
|
|
53
|
-
P = await import(PROMPTS_TS)
|
|
54
|
-
} catch (error) {
|
|
55
|
-
loader = `源码文本解析回退(类型剥离不可用:${error && error.message})`
|
|
56
|
-
P = loadFromSource()
|
|
57
|
-
}
|
|
58
|
-
const { CHAT_SYSTEM_PROMPT, CODE_PROMPT, DETAIL_PROMPT, PROMPT_HEAD, TRANSLATION_PROMPT } = P
|
|
59
|
-
console.log(`提示词来源:${loader}\n`)
|
|
60
|
-
|
|
61
|
-
let failed = 0
|
|
62
|
-
let passed = 0
|
|
63
|
-
const assert = (label, ok, extra) => {
|
|
64
|
-
console.log(`${ok ? 'PASS' : 'FAIL'} ${label}${extra ? ' — ' + extra : ''}`)
|
|
65
|
-
if (ok) passed += 1
|
|
66
|
-
else failed += 1
|
|
67
|
-
}
|
|
68
|
-
const has = (text, ...needles) => needles.every((n) => text.includes(n))
|
|
69
|
-
|
|
70
|
-
// ───────────────────────── 公共前言 ─────────────────────────
|
|
71
|
-
assert('人设:跨领域解读专家(含领域判断要求)', has(PROMPT_HEAD, '跨领域', '先判断【选中文字】实际属于哪个领域'))
|
|
72
|
-
assert('人设:领域判断是内部动作,不许写出来', has(PROMPT_HEAD, '不要把判断过程写出来'))
|
|
73
|
-
assert('人设:说明用户消息的形态(会话背景 + 选中文字)', has(PROMPT_HEAD, '【会话背景】', '【选中文字】'))
|
|
74
|
-
|
|
75
|
-
// ───────────────────────── 翻译 / 解读(首轮)─────────────────────────
|
|
76
|
-
assert('翻译提示词拼进了公共前言', TRANSLATION_PROMPT.startsWith(PROMPT_HEAD))
|
|
77
|
-
assert(
|
|
78
|
-
'翻译:只输出一个章节(不许前言结语)',
|
|
79
|
-
has(TRANSLATION_PROMPT, '本轮只输出一个章节', '不要输出其它章节', '不要前言与结语'),
|
|
80
|
-
)
|
|
81
|
-
assert(
|
|
82
|
-
'结构契约:含英文 → 标题 `## 翻译`',
|
|
83
|
-
has(TRANSLATION_PROMPT, '## 翻译'),
|
|
84
|
-
'客户端按此标题切分渲染,改标题会同时破坏渲染',
|
|
85
|
-
)
|
|
86
|
-
assert(
|
|
87
|
-
'结构契约:全中文 → 标题 `## 解读`',
|
|
88
|
-
has(TRANSLATION_PROMPT, '## 解读'),
|
|
89
|
-
)
|
|
90
|
-
assert(
|
|
91
|
-
'结论条:固定写成引用行「在本句中:」',
|
|
92
|
-
has(TRANSLATION_PROMPT, '> 在本句中:'),
|
|
93
|
-
'面板据此渲染成高亮结论条,格式不能丢',
|
|
94
|
-
)
|
|
95
|
-
assert(
|
|
96
|
-
'音标规则:单词要标 IPA,且拿不准宁可不标',
|
|
97
|
-
has(TRANSLATION_PROMPT, '音标', 'IPA') && /拿不准就不写|宁可不标/.test(TRANSLATION_PROMPT),
|
|
98
|
-
)
|
|
99
|
-
assert(
|
|
100
|
-
'词义规则:词性与义项缩进两个空格作为子项',
|
|
101
|
-
has(TRANSLATION_PROMPT, '缩进两个空格'),
|
|
102
|
-
)
|
|
103
|
-
assert(
|
|
104
|
-
'语言自适应:按"有没有需要翻译的英文"分情形,只出一种',
|
|
105
|
-
has(TRANSLATION_PROMPT, '有没有需要翻译的英文') && has(TRANSLATION_PROMPT, '情形一', '情形二'),
|
|
106
|
-
)
|
|
107
|
-
|
|
108
|
-
// ───────────────────────── 代码注释(首轮)─────────────────────────
|
|
109
|
-
assert('代码提示词拼进了公共前言', CODE_PROMPT.startsWith(PROMPT_HEAD))
|
|
110
|
-
assert('结构契约:代码模式标题 `## 注释`', has(CODE_PROMPT, '## 注释'))
|
|
111
|
-
assert(
|
|
112
|
-
'代码模式:原代码逐字不变、注释插在语句前',
|
|
113
|
-
has(CODE_PROMPT, '原代码') && /逐字不变|原样/.test(CODE_PROMPT),
|
|
114
|
-
)
|
|
115
|
-
|
|
116
|
-
// ───────────────────────── 详解(第二阶段)─────────────────────────
|
|
117
|
-
assert('详解提示词拼进了公共前言', DETAIL_PROMPT.startsWith(PROMPT_HEAD))
|
|
118
|
-
assert(
|
|
119
|
-
'详解:只输出 `## 详解` 一节',
|
|
120
|
-
has(DETAIL_PROMPT, '## 详解', '不要输出「## 翻译」或其它章节'),
|
|
121
|
-
)
|
|
122
|
-
assert(
|
|
123
|
-
'详解:要求先判断这段文字的来源(真实内容 vs 示例/测试/引用)',
|
|
124
|
-
has(DETAIL_PROMPT, '真实对话/正文', '示例', '测试用例', '演示输出', '引用别人的话'),
|
|
125
|
-
'早期缺这条时,示例句会被当成真实陈述解释',
|
|
126
|
-
)
|
|
127
|
-
assert('详解:单词仍带音标', has(DETAIL_PROMPT, '音标', 'IPA'))
|
|
128
|
-
|
|
129
|
-
// ───────────────────────── 追问(小窗对话)─────────────────────────
|
|
130
|
-
assert(
|
|
131
|
-
'追问:不要求两节结构,问什么答什么',
|
|
132
|
-
has(CHAT_SYSTEM_PROMPT, '用户问什么就答什么', '不必把话题拉回选中文字'),
|
|
133
|
-
)
|
|
134
|
-
assert(
|
|
135
|
-
'追问:不许重复前情已给的翻译/详解',
|
|
136
|
-
has(CHAT_SYSTEM_PROMPT, '不用重复', '一句话带过'),
|
|
137
|
-
)
|
|
138
|
-
assert(
|
|
139
|
-
'追问:网页模式要给完整单文件 HTML(内联样式与脚本)',
|
|
140
|
-
has(CHAT_SYSTEM_PROMPT, '完整的单文件 HTML', '```html', '样式和脚本都内联'),
|
|
141
|
-
'小窗会当场渲染成可交互页面,只给片段就渲染不出来',
|
|
142
|
-
)
|
|
143
|
-
assert(
|
|
144
|
-
'追问:【引用 N】块要当成提问材料(不许认成上一轮回答)',
|
|
145
|
-
has(CHAT_SYSTEM_PROMPT, '【引用 N】', '当成提问的一部分来答'),
|
|
146
|
-
'小窗的「❝ 引用」把引用拼进用户消息,模型得知道那不是它自己的前情',
|
|
147
|
-
)
|
|
148
|
-
assert(
|
|
149
|
-
'追问:【引用处上下文】要被当成判断依据(引用部分用【】标出)',
|
|
150
|
-
has(CHAT_SYSTEM_PROMPT, '【引用处上下文】', '被引用的部分用【】标出'),
|
|
151
|
-
'主界面/网页的引用自带"当时所在的那一组对话 ± 一组",模型要知道那是上下文而不是正文',
|
|
152
|
-
)
|
|
153
|
-
assert(
|
|
154
|
-
'追问:只写出处的引用要在本轮上下文里找得到它',
|
|
155
|
-
has(CHAT_SYSTEM_PROMPT, '小窗第 2 轮回答', '本来就在本轮上下文里'),
|
|
156
|
-
'小窗自己的对话不重复进引用块,靠出处标签指路',
|
|
157
|
-
)
|
|
158
|
-
|
|
159
|
-
// ───────────────────────── 卫生检查(防低级事故)─────────────────────────
|
|
160
|
-
const ALL = [PROMPT_HEAD, TRANSLATION_PROMPT, CODE_PROMPT, DETAIL_PROMPT, CHAT_SYSTEM_PROMPT].join('\n')
|
|
161
|
-
assert(
|
|
162
|
-
'没有未替换的占位符({{…}} / ${…} / TODO)',
|
|
163
|
-
!/\{\{[^}]*\}\}|\$\{[^}]*\}|TODO/.test(ALL),
|
|
164
|
-
)
|
|
165
|
-
assert(
|
|
166
|
-
'没有残留的调试口吻(console.log / debugger)',
|
|
167
|
-
!/console\.log|debugger/.test(ALL),
|
|
168
|
-
)
|
|
169
|
-
assert(
|
|
170
|
-
'提示词不是空串(防止整体被清空)',
|
|
171
|
-
ALL.length > 2000,
|
|
172
|
-
`总长 ${ALL.length}`,
|
|
173
|
-
)
|
|
174
|
-
|
|
175
|
-
// ───────────────────────── 防复活:已弃用的规则不得重新接回 ─────────────────────────
|
|
176
|
-
// TWO_LAYER_RULE(「专业 + 说人话、别套模板」的写作规则)**已弃用**:提示词瘦身时
|
|
177
|
-
// 把"别写成同一个模子"那五条写法砍掉了,只留两条硬约束(只出 `## 详解` 一节 + 音标)。
|
|
178
|
-
// 它曾作为常量残留(只定义、不使用),已于 2026-09-24 删除。
|
|
179
|
-
// 这条断言防止有人把那段文字重新塞回提示词里 —— 那是被明确废弃的设计。
|
|
180
|
-
{
|
|
181
|
-
const resurrected = [TRANSLATION_PROMPT, CODE_PROMPT, DETAIL_PROMPT, CHAT_SYSTEM_PROMPT].some((p) =>
|
|
182
|
-
p.includes('别写成同一个模子') || p.includes('**写法:内容有要求,形式随内容走。**'),
|
|
183
|
-
)
|
|
184
|
-
assert(
|
|
185
|
-
'已弃用的「两层写法规则」没有被重新塞回提示词',
|
|
186
|
-
!resurrected,
|
|
187
|
-
resurrected ? '检测到已废弃规则的原文,请确认是否有意恢复该设计' : '符合当前设计(该规则已弃用并删除)',
|
|
188
|
-
)
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
console.log(`\n=== 提示词契约测试结束:${passed} 通过 / ${failed} 失败 ===`)
|
|
192
|
-
if (failed > 0) process.exitCode = 1
|
|
193
|
-
process.exit(process.exitCode ?? 0)
|
|
@@ -1,138 +0,0 @@
|
|
|
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
|
|
@@ -1,188 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,48 +0,0 @@
|
|
|
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
|
-
}
|