dsh-router-core 0.4.5 → 0.4.7
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 +152 -12
- package/docs/ext.md +1 -1
- package/docs/protocols.md +198 -0
- package/docs/suppliers.md +2 -2
- package/lib/client-registry.js +146 -106
- package/lib/client.js +146 -106
- package/lib/ext-prompt/index.js +182 -17
- package/lib/index.js +2764 -63
- package/lib/suppliers/opencode/index.js +58 -30
- package/lib/types/ext/contract.d.ts +20 -1
- package/lib/types/ext-prompt/content.d.ts +7 -1
- package/lib/types/ext-prompt/mount.d.ts +12 -1
- package/lib/types/ext-prompt/plugin.d.ts +10 -0
- package/lib/types/ext-prompt/render.d.ts +14 -3
- package/lib/types/ext-prompt/sections.d.ts +95 -0
- package/lib/types/protocol/anthropic.d.ts +35 -0
- package/lib/types/protocol/chat.d.ts +25 -0
- package/lib/types/protocol/entry.d.ts +137 -0
- package/lib/types/protocol/gemini.d.ts +57 -0
- package/lib/types/protocol/index.d.ts +13 -0
- package/lib/types/protocol/responses.d.ts +41 -0
- package/lib/types/protocol/sse.d.ts +41 -0
- package/lib/types/protocol/stream.d.ts +92 -0
- package/lib/types/protocol/types.d.ts +224 -0
- package/lib/types/router/account-pool.d.ts +7 -2
- package/lib/types/router/index.d.ts +42 -1
- package/lib/types/router/types.d.ts +26 -3
- package/lib/types/router/usage-store.d.ts +4 -2
- package/lib/types/shared.d.ts +24 -0
- package/lib/types/suppliers/contract.d.ts +15 -1
- package/package.json +6 -6
- package/src/client/ExtDetail.tsx +25 -5
- package/src/client/group-controls.test.ts +53 -0
- package/src/client/group-controls.ts +43 -0
- package/src/client/router.css +20 -1
- package/src/ext/contract.ts +20 -1
- package/src/ext-prompt/assembly.test.ts +63 -0
- package/src/ext-prompt/content.ts +8 -10
- package/src/ext-prompt/ext-prompt.test.ts +38 -28
- package/src/ext-prompt/index.ts +50 -2
- package/src/ext-prompt/mount.test.ts +18 -1
- package/src/ext-prompt/mount.ts +22 -7
- package/src/ext-prompt/plugin.ts +64 -1
- package/src/ext-prompt/render.test.ts +1 -1
- package/src/ext-prompt/render.ts +17 -6
- package/src/ext-prompt/section-controls.test.ts +148 -0
- package/src/ext-prompt/section-filter.test.ts +140 -0
- package/src/ext-prompt/sections.test.ts +123 -0
- package/src/ext-prompt/sections.ts +193 -0
- package/src/ext-route.test.ts +16 -0
- package/src/index.ts +117 -6
- package/src/llm/adapter.test.ts +54 -0
- package/src/llm/adapter.ts +24 -2
- package/src/protocol/anthropic.ts +662 -0
- package/src/protocol/chat.ts +272 -0
- package/src/protocol/entry.ts +440 -0
- package/src/protocol/gemini.ts +548 -0
- package/src/protocol/index.ts +31 -0
- package/src/protocol/protocol.test.ts +728 -0
- package/src/protocol/responses.ts +551 -0
- package/src/protocol/sse.ts +94 -0
- package/src/protocol/stream.ts +195 -0
- package/src/protocol/types.ts +249 -0
- package/src/protocol/wire-e2e.test.ts +208 -0
- package/src/protocol/wiring.test.ts +225 -0
- package/src/router/account-pool.test.ts +35 -0
- package/src/router/account-pool.ts +15 -4
- package/src/router/index.ts +143 -24
- package/src/router/types.ts +26 -3
- package/src/router/usage-store.ts +4 -2
- package/src/shared.test.ts +35 -0
- package/src/shared.ts +33 -0
- package/src/suppliers/contract.ts +15 -2
- package/src/suppliers/opencode/plugin.test.ts +79 -25
- package/src/suppliers/opencode/plugin.ts +60 -29
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 段落总控的**接线**判据 —— 三件事必须真的串上,缺一件就静默失效:
|
|
3
|
+
* ① 段落进了 `controls`(面板看得见);
|
|
4
|
+
* ② 段开关落盘到 `data.sections`,且**不碰** `categories`(两套开关不串味);
|
|
5
|
+
* ③ `system-prompt/assemble` waterfall 真的把关闭的段删掉了(这是"可开关"
|
|
6
|
+
* 的唯一真判据 —— 只测 ①② 会漏掉"面板能点、但 prompt 里照旧")。
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ 本文件里"真删段"的验证用**假宿主装配链**:注册一个监听器、手工跑一次
|
|
9
|
+
* waterfall(`ctx.waterfall` 真实现来自 cordis),而不是调真 `assemble()`。
|
|
10
|
+
* 理由:真 `assemble` 需要整套 `dsh-system-prompt` 服务与 scope 环境,
|
|
11
|
+
* 会把判据变成"宿主内部实现测试";而本插件的责任边界只是"监听器拿到
|
|
12
|
+
* assembly 后返回什么"。监听器的返回形状 = 宿主采信的形状(`return transformed`)。
|
|
13
|
+
*/
|
|
14
|
+
import test from 'node:test'
|
|
15
|
+
import assert from 'node:assert/strict'
|
|
16
|
+
import { createPromptExt, EXT_PROMPT_ID } from './plugin.ts'
|
|
17
|
+
import { OWN_SECTION_NAME, SECTION_CONTROL_PREFIX } from './sections.ts'
|
|
18
|
+
import type { PromptExtData } from './render.ts'
|
|
19
|
+
import type { ExtStoreService } from '../ext/contract.ts'
|
|
20
|
+
|
|
21
|
+
/** 记录写盘的内存 store。 */
|
|
22
|
+
function fakeStore(initial: { enabled?: boolean; data?: PromptExtData } = {}): ExtStoreService & {
|
|
23
|
+
data: () => PromptExtData | undefined
|
|
24
|
+
writes: PromptExtData[]
|
|
25
|
+
} {
|
|
26
|
+
let data = initial.data
|
|
27
|
+
let enabled = initial.enabled ?? true
|
|
28
|
+
const writes: PromptExtData[] = []
|
|
29
|
+
return {
|
|
30
|
+
isEnabled: () => enabled,
|
|
31
|
+
// 真实签名带 id(`setEnabled(id, enabled)`)——第一版漏了它,tsc 才报
|
|
32
|
+
// "string 不能赋给 boolean"。桩的形状必须与契约逐字一致,否则测的是
|
|
33
|
+
// 一个不存在的接口。
|
|
34
|
+
setEnabled: (_id: string, v: boolean) => { enabled = v },
|
|
35
|
+
readData: <T>() => data as T | undefined,
|
|
36
|
+
writeData: (_id, d) => { data = d as PromptExtData; writes.push(d as PromptExtData) },
|
|
37
|
+
data: () => data,
|
|
38
|
+
writes,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const SAMPLE = [
|
|
43
|
+
{ name: 'harness:identity', text: 'You are an AI agent.' },
|
|
44
|
+
{ name: 'tool:write', text: 'write tool spec' },
|
|
45
|
+
{ name: 'tool:edit', text: 'edit tool spec' },
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
test('★ 段落以 `sec:` 前缀进 controls,带组名、原文、且不可拖不可编辑', () => {
|
|
49
|
+
const ext = createPromptExt({
|
|
50
|
+
isSystemPromptReady: () => true,
|
|
51
|
+
getSections: () => SAMPLE,
|
|
52
|
+
store: fakeStore(),
|
|
53
|
+
})
|
|
54
|
+
const secRows = (ext.controls ?? []).filter((c) => c.id.startsWith(SECTION_CONTROL_PREFIX))
|
|
55
|
+
assert.equal(secRows.length, 3, '段落没进 controls ⇒ 面板列不出来')
|
|
56
|
+
const write = secRows.find((c) => c.id === `${SECTION_CONTROL_PREFIX}tool:write`)!
|
|
57
|
+
assert.equal(write.title, 'write', '标题应去掉组前缀')
|
|
58
|
+
assert.equal(write.detail, 'tool:write', '完整段名要能指认(报问题时用)')
|
|
59
|
+
assert.equal(write.body, 'write tool spec', '没原文 = 用户盲切')
|
|
60
|
+
assert.equal(write.group, '工具说明')
|
|
61
|
+
assert.equal(write.on, true, '默认全开')
|
|
62
|
+
assert.equal(write.sortable, false, '段 id 不在排序落盘范围,可拖就是假交互')
|
|
63
|
+
assert.notEqual(write.editable, true, '别人的段不给改写入口,只开关')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('准则分类仍在 controls 前列,且**不带** group(页头已是它的组头)', () => {
|
|
67
|
+
const ext = createPromptExt({
|
|
68
|
+
isSystemPromptReady: () => true,
|
|
69
|
+
getSections: () => SAMPLE,
|
|
70
|
+
store: fakeStore(),
|
|
71
|
+
})
|
|
72
|
+
const rows = ext.controls ?? []
|
|
73
|
+
const plain = rows.filter((c) => c.group === undefined)
|
|
74
|
+
assert.ok(plain.length > 0, '准则分类丢了 ⇒ 原功能被新功能挤掉')
|
|
75
|
+
assert.ok(plain.every((c) => c.editable === true), '准则分类应保持可编辑')
|
|
76
|
+
assert.equal(rows[0]!.group, undefined, '列表首条应是准则(无组节渲染在最前)')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('★ 段开关落盘到 data.sections:关 = 存 false,开 = 删键(只存覆盖值)', () => {
|
|
80
|
+
const store = fakeStore()
|
|
81
|
+
const ext = createPromptExt({
|
|
82
|
+
isSystemPromptReady: () => true,
|
|
83
|
+
getSections: () => SAMPLE,
|
|
84
|
+
store,
|
|
85
|
+
})
|
|
86
|
+
assert.equal(ext.setControl!(`${SECTION_CONTROL_PREFIX}tool:write`, false), true)
|
|
87
|
+
assert.deepEqual(store.data()?.sections, { 'tool:write': false })
|
|
88
|
+
// 开回来 ⇒ 键消失(不留 `true`,否则落盘会随用户点击无限膨胀)
|
|
89
|
+
assert.equal(ext.setControl!(`${SECTION_CONTROL_PREFIX}tool:write`, true), true)
|
|
90
|
+
assert.equal(store.data()?.sections, undefined, '全开后仍留着 sections 键')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('★ 段开关不碰分类开关(两套开关各自一个键)', () => {
|
|
94
|
+
const store = fakeStore({ data: { categories: { identity: false } } })
|
|
95
|
+
const ext = createPromptExt({
|
|
96
|
+
isSystemPromptReady: () => true,
|
|
97
|
+
getSections: () => SAMPLE,
|
|
98
|
+
store,
|
|
99
|
+
})
|
|
100
|
+
ext.setControl!(`${SECTION_CONTROL_PREFIX}tool:write`, false)
|
|
101
|
+
assert.deepEqual(store.data()?.categories, { identity: false }, '写段开关把分类开关冲掉了')
|
|
102
|
+
assert.deepEqual(store.data()?.sections, { 'tool:write': false })
|
|
103
|
+
// 反向:写分类开关不能冲掉段开关
|
|
104
|
+
ext.setControl!('identity', true)
|
|
105
|
+
assert.deepEqual(store.data()?.sections, { 'tool:write': false }, '写分类开关把段开关冲掉了')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('注入:清单外的段 id 拒写(旧数据/手改 ext.json 不该被面板认成有效开关)', () => {
|
|
109
|
+
const store = fakeStore()
|
|
110
|
+
const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store })
|
|
111
|
+
assert.equal(ext.setControl!(`${SECTION_CONTROL_PREFIX}tool:write`, false), false)
|
|
112
|
+
assert.equal(store.data(), undefined, '拒了却写了盘')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('★ 自己的准则段拒绝被当成段落开关(同一件事不能有两个开关)', () => {
|
|
116
|
+
const store = fakeStore()
|
|
117
|
+
const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => SAMPLE, store })
|
|
118
|
+
assert.equal(ext.setControl!(`${SECTION_CONTROL_PREFIX}${OWN_SECTION_NAME}`, false), false)
|
|
119
|
+
// 即使清单源里混进了它,controls 也不列
|
|
120
|
+
const ext2 = createPromptExt({
|
|
121
|
+
isSystemPromptReady: () => true,
|
|
122
|
+
getSections: () => [{ name: OWN_SECTION_NAME, text: 'guideline' }, ...SAMPLE],
|
|
123
|
+
store,
|
|
124
|
+
})
|
|
125
|
+
const ids = (ext2.controls ?? []).map((c) => c.id)
|
|
126
|
+
assert.equal(ids.includes(`${SECTION_CONTROL_PREFIX}${OWN_SECTION_NAME}`), false,
|
|
127
|
+
'准则段被列了 ⇒ 面板上同一件事出现两个开关')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('段清单为空 ⇒ 不产出段条目(宿主没段/预热没完成时面板不显示空组)', () => {
|
|
131
|
+
const ext = createPromptExt({ isSystemPromptReady: () => true, getSections: () => [], store: fakeStore() })
|
|
132
|
+
assert.equal((ext.controls ?? []).some((c) => c.id.startsWith(SECTION_CONTROL_PREFIX)), false)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('段条目的 on 反映已存关闭项(面板刷新要看到真实状态)', () => {
|
|
136
|
+
const ext = createPromptExt({
|
|
137
|
+
isSystemPromptReady: () => true,
|
|
138
|
+
getSections: () => SAMPLE,
|
|
139
|
+
store: fakeStore({ data: { sections: { 'tool:edit': false } } }),
|
|
140
|
+
})
|
|
141
|
+
const on = Object.fromEntries((ext.controls ?? []).map((c) => [c.id, c.on]))
|
|
142
|
+
assert.equal(on[`${SECTION_CONTROL_PREFIX}tool:edit`], false)
|
|
143
|
+
assert.equal(on[`${SECTION_CONTROL_PREFIX}tool:write`], true)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('ext id 未漂移(落盘键跟着它,改了用户关段表就全丢)', () => {
|
|
147
|
+
assert.equal(EXT_PROMPT_ID, 'prompt')
|
|
148
|
+
})
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `system-prompt/assemble` waterfall 的**真删段**判据。
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ 为什么这条闸门非有不可(本仓反复栽过的形状):面板能列、开关能落盘,
|
|
5
|
+
* 但"关掉之后模型到底还看不看得到这一段"是**另一件事**。只测前两者会得到一个
|
|
6
|
+
* 全绿的假象,而线上行为是"关了没用"。本仓同类教训:`docs` 里记的
|
|
7
|
+
* "apply 里 inject 不可靠 ⇒ 准则一次都没进过 prompt"(面板全正常、prompt 里没有)。
|
|
8
|
+
*
|
|
9
|
+
* 做法:用**真 cordis Context**(`ctx.on` + `ctx.waterfall` 真实现)跑一次
|
|
10
|
+
* 装配链,断言监听器返回的 assembly 里段真的少了。监听器的返回值就是宿主采信
|
|
11
|
+
* 的结果(宿主 `assemble()` 源码:`const transformed = await this.ctx.waterfall(...)`
|
|
12
|
+
* 后 `return transformed`),所以这里验的是**我们这一侧的责任边界**。
|
|
13
|
+
*/
|
|
14
|
+
import test from 'node:test'
|
|
15
|
+
import assert from 'node:assert/strict'
|
|
16
|
+
import { OWN_SECTION_NAME, disabledSectionNames, filterSections } from './sections.ts'
|
|
17
|
+
import type { PromptExtData } from './render.ts'
|
|
18
|
+
|
|
19
|
+
/** 装配出的一条段(宿主形状的最小面)。 */
|
|
20
|
+
interface Assembled {
|
|
21
|
+
name: string
|
|
22
|
+
text: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 按 `index.ts` 里监听器的**同一形状**接一条装配链,返回 waterfall 的结果。
|
|
27
|
+
*
|
|
28
|
+
* ⚠️ 刻意复制那 6 行而不是从 `index.ts` 导出一个可测函数:`index.ts` 的
|
|
29
|
+
* 监听器需要真 `systemPrompt` service 才能注册(它读 `systemPromptOf(ctx)`),
|
|
30
|
+
* 为它造全套宿主桩会让判据变成"测桩"。这里验的是**逻辑链**:先取 next()、
|
|
31
|
+
* 再按落盘过滤;复制带来的漂移风险由"两边注释互相指认"兜住。
|
|
32
|
+
*/
|
|
33
|
+
async function runAssembly(
|
|
34
|
+
ctx: { on: (n: string, f: unknown) => unknown; waterfall: (n: string, ...a: unknown[]) => Promise<unknown> },
|
|
35
|
+
opts: { enabled: boolean; data?: PromptExtData; sections: Assembled[] },
|
|
36
|
+
): Promise<Assembled[]> {
|
|
37
|
+
ctx.on('system-prompt/assemble', async (
|
|
38
|
+
_assembly: unknown,
|
|
39
|
+
_context: unknown,
|
|
40
|
+
next: () => Promise<{ sections: Assembled[] }>,
|
|
41
|
+
) => {
|
|
42
|
+
const result = await next()
|
|
43
|
+
if (!opts.enabled) return result
|
|
44
|
+
const off = disabledSectionNames(opts.data)
|
|
45
|
+
if (off.size === 0) return result
|
|
46
|
+
return { ...result, sections: filterSections(result.sections, off) }
|
|
47
|
+
})
|
|
48
|
+
// 真 waterfall:最后一个参数是终态回调(返回 assembly),前面的参数原样透传给监听器
|
|
49
|
+
// ⚠️ 派发的事件名必须与注册的**逐字相同**:我第一版这里写 'assemble',
|
|
50
|
+
// 而上面注册的是 'system-prompt/assemble' ⇒ 监听器根本没被调用,
|
|
51
|
+
// 断言报"关了却还在"。那种失败**看起来像功能没实现**,实际是判据自己
|
|
52
|
+
// 接错了线 —— 事件名是字符串、拼错不报错,只让闸门测了个空。
|
|
53
|
+
const out = await ctx.waterfall(
|
|
54
|
+
'system-prompt/assemble',
|
|
55
|
+
{ sections: opts.sections },
|
|
56
|
+
{},
|
|
57
|
+
async () => ({ sections: opts.sections }),
|
|
58
|
+
) as { sections: Assembled[] }
|
|
59
|
+
return out.sections
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const ALL: Assembled[] = [
|
|
63
|
+
{ name: OWN_SECTION_NAME, text: '准则正文' },
|
|
64
|
+
{ name: 'harness:identity', text: 'You are an AI agent.' },
|
|
65
|
+
{ name: 'tool:write', text: 'write spec' },
|
|
66
|
+
{ name: 'tool:edit', text: 'edit spec' },
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
async function freshCtx() {
|
|
70
|
+
const { Context } = await import('@deepseek-ai/cordis')
|
|
71
|
+
const ctx = new Context()
|
|
72
|
+
return ctx as unknown as {
|
|
73
|
+
on: (n: string, f: unknown) => unknown
|
|
74
|
+
waterfall: (n: string, ...a: unknown[]) => Promise<unknown>
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
test('★ 关掉的段真的从装配结果里消失("可开关"的唯一真判据)', async () => {
|
|
79
|
+
const ctx = await freshCtx()
|
|
80
|
+
const out = await runAssembly(ctx, {
|
|
81
|
+
enabled: true,
|
|
82
|
+
data: { sections: { 'tool:write': false } },
|
|
83
|
+
sections: ALL,
|
|
84
|
+
})
|
|
85
|
+
const names = out.map((s) => s.name)
|
|
86
|
+
assert.equal(names.includes('tool:write'), false, '关了却还在 ⇒ 面板开关是装饰')
|
|
87
|
+
assert.equal(names.includes('tool:edit'), true, '没关的被误删 ⇒ 开关粒度错了')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('★ 准则段永不被 waterfall 删掉(关它走的是总开关/分类开关)', async () => {
|
|
91
|
+
const ctx = await freshCtx()
|
|
92
|
+
const out = await runAssembly(ctx, {
|
|
93
|
+
enabled: true,
|
|
94
|
+
// 手改 ext.json 把准则段写进关闭项:也不许删
|
|
95
|
+
data: { sections: { [OWN_SECTION_NAME]: false, 'tool:write': false } },
|
|
96
|
+
sections: ALL,
|
|
97
|
+
})
|
|
98
|
+
const names = out.map((s) => s.name)
|
|
99
|
+
assert.equal(names.includes(OWN_SECTION_NAME), true, '准则段被删 ⇒ 分类开关全部失效')
|
|
100
|
+
assert.equal(names.includes('tool:write'), false)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('★ 总开关关 ⇒ 原样放行(扩展关 = 什么都不做,宿主段落自然注入)', async () => {
|
|
104
|
+
const ctx = await freshCtx()
|
|
105
|
+
const out = await runAssembly(ctx, {
|
|
106
|
+
enabled: false,
|
|
107
|
+
data: { sections: { 'tool:write': false } },
|
|
108
|
+
sections: ALL,
|
|
109
|
+
})
|
|
110
|
+
assert.deepEqual(out.map((s) => s.name), ALL.map((s) => s.name),
|
|
111
|
+
'总开关关掉后仍然删段 ⇒ 用户关了扩展但提示词还在被改')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('全开(落盘无关闭项)⇒ 一个段都不动', async () => {
|
|
115
|
+
const ctx = await freshCtx()
|
|
116
|
+
const out = await runAssembly(ctx, { enabled: true, sections: ALL })
|
|
117
|
+
assert.deepEqual(out.map((s) => s.name), ALL.map((s) => s.name))
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('关闭项全是失效名字 ⇒ 装配结果不受影响(宿主换版本后的旧数据)', async () => {
|
|
121
|
+
const ctx = await freshCtx()
|
|
122
|
+
const out = await runAssembly(ctx, {
|
|
123
|
+
enabled: true,
|
|
124
|
+
data: { sections: { 'gone:forever': false } },
|
|
125
|
+
sections: ALL,
|
|
126
|
+
})
|
|
127
|
+
assert.deepEqual(out.map((s) => s.name), ALL.map((s) => s.name))
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('多个监听器串联时我们的过滤不被前一个吞掉(waterfall 顺序语义)', async () => {
|
|
131
|
+
const ctx = await freshCtx()
|
|
132
|
+
// 先注册一个"什么都不做、原样返回"的旁观者
|
|
133
|
+
ctx.on('system-prompt/assemble', async (_a: unknown, _c: unknown, next: () => Promise<{ sections: Assembled[] }>) => next())
|
|
134
|
+
const out = await runAssembly(ctx, {
|
|
135
|
+
enabled: true,
|
|
136
|
+
data: { sections: { 'tool:edit': false } },
|
|
137
|
+
sections: ALL,
|
|
138
|
+
})
|
|
139
|
+
assert.equal(out.map((s) => s.name).includes('tool:edit'), false)
|
|
140
|
+
})
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 段落总控的**纯逻辑**判据(`sections.ts`)。
|
|
3
|
+
*
|
|
4
|
+
* 守三件事,每件都对着一种"静默失效":
|
|
5
|
+
* ① 控制条 id 与段名的**往返**(编码错了 ⇒ 面板点的开关落到别人的段上);
|
|
6
|
+
* ② 过滤**只删该删的**(自己那段恒放行、清单外的旧名字无害跳过);
|
|
7
|
+
* ③ 分组的**归组与次序**(组的先后 = prompt 次序,不能被字母序打乱)。
|
|
8
|
+
*/
|
|
9
|
+
import test from 'node:test'
|
|
10
|
+
import assert from 'node:assert/strict'
|
|
11
|
+
import {
|
|
12
|
+
OWN_SECTION_NAME,
|
|
13
|
+
SECTION_CONTROL_PREFIX,
|
|
14
|
+
createSectionCache,
|
|
15
|
+
disabledSectionNames,
|
|
16
|
+
filterSections,
|
|
17
|
+
groupLabelOf,
|
|
18
|
+
groupSections,
|
|
19
|
+
sectionControlId,
|
|
20
|
+
sectionNameOf,
|
|
21
|
+
sectionTitleOf,
|
|
22
|
+
} from './sections.ts'
|
|
23
|
+
|
|
24
|
+
test('控制条 id ↔ 段名往返(`sec:` 是传输层编码,落盘用裸名)', () => {
|
|
25
|
+
assert.equal(sectionControlId('tool:write'), `${SECTION_CONTROL_PREFIX}tool:write`)
|
|
26
|
+
assert.equal(sectionNameOf(sectionControlId('tool:write')), 'tool:write')
|
|
27
|
+
// 含冒号的段名不能被"按第一个冒号切"吃掉后半截
|
|
28
|
+
assert.equal(sectionNameOf(sectionControlId('mcp:my-server:tools')), 'mcp:my-server:tools')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('非段条目(准则分类)不被当成段落 —— 否则分类开关会写到 segments 里', () => {
|
|
32
|
+
assert.equal(sectionNameOf('identity'), null)
|
|
33
|
+
assert.equal(sectionNameOf('cu-1'), null)
|
|
34
|
+
assert.equal(sectionNameOf(''), null)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('★ 只认显式 false:缺项 = 没碰过 = 开(与分类开关同一纪律)', () => {
|
|
38
|
+
const off = disabledSectionNames({ sections: { 'tool:write': false, 'tool:read': true } })
|
|
39
|
+
assert.equal(off.has('tool:write'), true)
|
|
40
|
+
assert.equal(off.has('tool:read'), false, 'true 是"开着",不该算进关闭集合')
|
|
41
|
+
assert.equal(disabledSectionNames(undefined).size, 0, '没记录 = 全开')
|
|
42
|
+
assert.equal(disabledSectionNames({}).size, 0)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('★ 过滤只删关闭的段,且保持原序', () => {
|
|
46
|
+
const all = [{ name: 'a' }, { name: 'b' }, { name: 'c' }]
|
|
47
|
+
assert.deepEqual(filterSections(all, new Set(['b'])).map((s) => s.name), ['a', 'c'])
|
|
48
|
+
assert.deepEqual(filterSections(all, new Set()).map((s) => s.name), ['a', 'b', 'c'])
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('★ 自己的准则段恒放行(它已有总开关+分类开关,不能再被段开关关一次)', () => {
|
|
52
|
+
// 即使落盘里被写进了关闭项(手改 ext.json),也不许把它过滤掉
|
|
53
|
+
const all = [{ name: OWN_SECTION_NAME }, { name: 'tool:write' }]
|
|
54
|
+
const kept = filterSections(all, new Set([OWN_SECTION_NAME, 'tool:write']))
|
|
55
|
+
// 准则段必须留下、被关的 tool:write 必须走 —— 两个方向都断言,
|
|
56
|
+
// 只断"准则段在"的话,过滤器退化成"全都放行"也能过。
|
|
57
|
+
assert.deepEqual(kept.map((s) => s.name), [OWN_SECTION_NAME],
|
|
58
|
+
'准则段被过滤 ⇒ 分类开关全部看起来失效')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('清单外的旧名字无害:宿主换版本后旧关闭项不该报错', () => {
|
|
62
|
+
assert.doesNotThrow(() => filterSections([{ name: 'a' }], new Set(['gone:forever'])))
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('缓存:自己的段不进清单,且返回值是拷贝(调用方改不动缓存)', () => {
|
|
66
|
+
const cache = createSectionCache()
|
|
67
|
+
cache.update([{ name: OWN_SECTION_NAME, text: 'x' }, { name: 'tool:write', text: 'write spec' }])
|
|
68
|
+
const list = cache.list()
|
|
69
|
+
assert.deepEqual(list.map((e) => e.name), ['tool:write'], '准则段进了清单 ⇒ 面板多一个重复开关')
|
|
70
|
+
list[0]!.text = 'mutated'
|
|
71
|
+
assert.equal(cache.list()[0]!.text, 'write spec', 'list() 返回内部引用 ⇒ 面板改动污染后续读')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('分组:组的先后 = 首次出现序(不按字母重排)', () => {
|
|
75
|
+
const groups = groupSections([
|
|
76
|
+
{ name: 'deployment:persona-prefix', text: '' },
|
|
77
|
+
{ name: 'tool:bash', text: '' },
|
|
78
|
+
{ name: 'tool:edit', text: '' },
|
|
79
|
+
{ name: 'plan:policy', text: '' },
|
|
80
|
+
])
|
|
81
|
+
assert.deepEqual(groups.map((g) => g.label), ['部署人设', '工具说明', '计划模式'])
|
|
82
|
+
assert.deepEqual(groups[1]!.items.map((i) => i.name), ['tool:bash', 'tool:edit'], '组内顺序被改 ⇒ 面板顺序与 prompt 顺序脱钩')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('★ 分组落名以真宿主实测为准(2026-10-21,9 个段名逐个钉)', () => {
|
|
86
|
+
// 这组断言直接抄 3081 上真宿主返回的段名 —— 我第一版按"看起来像"的前缀
|
|
87
|
+
// 脑补过两处(harness / app),真机一看就对不上。
|
|
88
|
+
const actual: [string, string][] = [
|
|
89
|
+
['deployment:persona-prefix', '部署人设'],
|
|
90
|
+
['deployment:persona-suffix', '部署人设'],
|
|
91
|
+
['tool:pty', '工具说明'],
|
|
92
|
+
['tool:session-query', '工具说明'],
|
|
93
|
+
['mcp:rea', 'MCP 服务'],
|
|
94
|
+
['ui:deliverable-file-references', '界面与交付'],
|
|
95
|
+
['mcp-resource-servers', 'MCP 资源'],
|
|
96
|
+
// ⚠️ 这两个**刻意不并入任何美名组**:`harness:source` 是运行环境说明
|
|
97
|
+
// (不是开场身份),`app:web-surface` 是 Web GUI 说明(不是"界面与交付",
|
|
98
|
+
// 那个组是给 `ui:` 的)。归错组比留真实前缀更误导。
|
|
99
|
+
['harness:source', 'harness'],
|
|
100
|
+
['app:web-surface', 'app'],
|
|
101
|
+
]
|
|
102
|
+
for (const [name, label] of actual) {
|
|
103
|
+
assert.equal(groupLabelOf(name), label, `${name} 归组错了`)
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('已知前缀用中文组名、未知前缀原样、无冒号归「其他」', () => {
|
|
108
|
+
assert.equal(groupLabelOf('tool:write'), '工具说明')
|
|
109
|
+
assert.equal(groupLabelOf('plan:policy'), '计划模式')
|
|
110
|
+
// 宿主新加的段(映射表还没有):组名原样显示,条目照样能列能开关
|
|
111
|
+
assert.equal(groupLabelOf('web:surface'), 'web')
|
|
112
|
+
assert.equal(groupLabelOf('unknown-thing'), '其他')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('条目标题去掉组前缀(组名已经给了语境)', () => {
|
|
116
|
+
assert.equal(sectionTitleOf('tool:write'), 'write')
|
|
117
|
+
assert.equal(sectionTitleOf('mcp:my-server'), 'my-server')
|
|
118
|
+
assert.equal(sectionTitleOf('mcp-resource-servers'), 'mcp-resource-servers', '无冒号时不能被切成空串')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('空清单 ⇒ 空分组(不产出空壳组)', () => {
|
|
122
|
+
assert.deepEqual(groupSections([]), [])
|
|
123
|
+
})
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 系统提示词**段落级**总控的纯逻辑与清单缓存 —— 把整体系统提示词拆成
|
|
3
|
+
* 每一段(`harness:identity`、`tool:write`、`plan:policy` …),面板分组列出、
|
|
4
|
+
* 逐段开关,关掉的段在装配时被过滤掉(`index.ts` 的 waterfall)。
|
|
5
|
+
*
|
|
6
|
+
* ════════════════════════════════════════════════════════════════════
|
|
7
|
+
* 清单从哪来:`assemble()` 的真实产物,不写死宿主段名表
|
|
8
|
+
* ════════════════════════════════════════════════════════════════════
|
|
9
|
+
* 段名只有个别是宿主导出常量(`harness:identity`、`deployment:persona-*`),
|
|
10
|
+
* 其余(`tool:*`、`mcp:*`、第三方插件段)都埋在各自插件里 —— 写死清单必然漂移。
|
|
11
|
+
* 装配 waterfall(`system-prompt/assemble`)每轮都携带**全量已排序段落**,
|
|
12
|
+
* 顺路缓存一份就是面板的事实源;apply 时预热一次 assemble,没开始对话也有清单。
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ 清单是**最近一次装配的快照**(近似):`assemble()` 不带 scope 时只含
|
|
15
|
+
* global 段,真实对话是 agent scope;且多会话并发时缓存归"最后一次装配"。
|
|
16
|
+
* 面板展示可接受,**开关判定不受影响** —— 过滤按段名,名字缺席时无害跳过。
|
|
17
|
+
*
|
|
18
|
+
* ════════════════════════════════════════════════════════════════════
|
|
19
|
+
* 开关的生效面:waterfall 删段,不碰别人的注册
|
|
20
|
+
* ════════════════════════════════════════════════════════════════════
|
|
21
|
+
* 宿主没有"禁用别人的段"的 API。两条路里选了 waterfall:`assemble()` 的
|
|
22
|
+
* 监听器返回值是权威结果(宿主源码 `return transformed`,complete 段除外),
|
|
23
|
+
* 删掉的段不会被恢复。同名覆盖(scoped section 返回 `''`)只适合"顶掉某一个
|
|
24
|
+
* 已知名字",对同层自己注册的段还会 throw —— 删段才是对全部段通用的开关。
|
|
25
|
+
*/
|
|
26
|
+
import type { PromptExtData } from './render.ts'
|
|
27
|
+
|
|
28
|
+
/** 面板控制条 id 的段前缀(**传输层编码**;落盘用裸段名,不带前缀)。 */
|
|
29
|
+
export const SECTION_CONTROL_PREFIX = 'sec:'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 自己的准则段:**不进清单、不被过滤**。
|
|
33
|
+
*
|
|
34
|
+
* 它已有独立的开关体系(扩展总开关 + 14 个分类开关)——再列一条"段开关"
|
|
35
|
+
* 就是同一件事的第二个开关,两边状态会漂移;过滤它还会让分类开关看起来
|
|
36
|
+
* 失效(段没了,分类怎么切都看不到效果)。
|
|
37
|
+
*/
|
|
38
|
+
export const OWN_SECTION_NAME = 'dsh-router-ext-prompt:guideline'
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 段名前缀 → 面板分组名。**未知前缀原样当组名**(不硬编全部前缀):
|
|
42
|
+
* 宿主还会加新段(`web:`、`structured:`…),映射漏了顶多组名不雅,
|
|
43
|
+
* 条目照常列出照常开关 —— 映射表漂移不允许变成"段消失"。
|
|
44
|
+
*/
|
|
45
|
+
/**
|
|
46
|
+
* 段名前缀 → 面板分组名。
|
|
47
|
+
*
|
|
48
|
+
* ⚠️ 键是**段名的第一段**(冒号前),不是"看起来像"的前缀。2026-10-21 实测
|
|
49
|
+
* 真宿主(3081,23 条 controls / 9 段)纠正过两处脑补:
|
|
50
|
+
* - `harness:source`(运行时上下文里 Harness 检出的位置)第一段是 `harness`,
|
|
51
|
+
* 按前缀映射会被并入「开场身份」—— 而它不是身份,是运行环境说明;
|
|
52
|
+
* - `app:web-surface`(Web GUI 说明)第一段是 `app`,与"界面与交付"无关。
|
|
53
|
+
* 所以 `harness` 刻意**不映射**:`harness:identity` 是身份、`harness:source` 不是,
|
|
54
|
+
* 靠第一段分不开 —— 让它们各自留下真实前缀(`harness` / `app`)比归错组诚实。
|
|
55
|
+
* `ui:` 才是界面与交付那一组(`ui:deliverable-file-references`)。
|
|
56
|
+
*
|
|
57
|
+
* 映射漏了或故意不映射的前缀,组名**原样显示前缀**、条目照常能列能开关 ——
|
|
58
|
+
* 映射表漂移不允许变成"段消失"。
|
|
59
|
+
*/
|
|
60
|
+
const GROUP_LABELS: Record<string, string> = {
|
|
61
|
+
deployment: '部署人设',
|
|
62
|
+
plan: '计划模式',
|
|
63
|
+
team: '团队',
|
|
64
|
+
tool: '工具说明',
|
|
65
|
+
mcp: 'MCP 服务',
|
|
66
|
+
context: '运行上下文',
|
|
67
|
+
ui: '界面与交付',
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 无冒号段名的映射(宿主有几个段名整名不带冒号)。
|
|
72
|
+
*
|
|
73
|
+
* `harness:identity` 才是开场身份,它带冒号走前缀表;这里只收无冒号的整名。
|
|
74
|
+
*/
|
|
75
|
+
const WHOLE_NAME_LABELS: Record<string, string> = {
|
|
76
|
+
'mcp-resource-servers': 'MCP 资源',
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 一条已装配的段(面板清单用)。 */
|
|
80
|
+
export interface SectionEntry {
|
|
81
|
+
/** 段注册名(稳定;也是开关的落盘键)。 */
|
|
82
|
+
name: string
|
|
83
|
+
/** 面板副行显示的原文(装配时 provider 现算的结果,**未做 `{{变量}}` 插值**)。 */
|
|
84
|
+
text: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** `controlId`(`sec:…`)→ 段名;非段条目返回 null。 */
|
|
88
|
+
export function sectionNameOf(controlId: string): string | null {
|
|
89
|
+
return controlId.startsWith(SECTION_CONTROL_PREFIX) ? controlId.slice(SECTION_CONTROL_PREFIX.length) : null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 段名 → 面板控制条 id。 */
|
|
93
|
+
export function sectionControlId(name: string): string {
|
|
94
|
+
return SECTION_CONTROL_PREFIX + name
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 段名的分组名。
|
|
99
|
+
*
|
|
100
|
+
* 查表顺序:**整名**(无冒号段名,如 `mcp-resource-servers`)→ **第一段前缀**
|
|
101
|
+
* (`tool:write` → `tool`)。两级都查不到 ⇒ 有冒号时用第一段原样当组名、
|
|
102
|
+
* 无冒号归「其他」。
|
|
103
|
+
*/
|
|
104
|
+
export function groupLabelOf(name: string): string {
|
|
105
|
+
const whole = WHOLE_NAME_LABELS[name]
|
|
106
|
+
if (whole !== undefined) return whole
|
|
107
|
+
const i = name.indexOf(':')
|
|
108
|
+
if (i < 0) return '其他'
|
|
109
|
+
const head = name.slice(0, i)
|
|
110
|
+
return GROUP_LABELS[head] ?? head
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 面板条目标题:去掉组前缀的部分(`tool:write` → `write`;无冒号 = 全名)。 */
|
|
114
|
+
export function sectionTitleOf(name: string): string {
|
|
115
|
+
const i = name.indexOf(':')
|
|
116
|
+
return i >= 0 ? name.slice(i + 1) : name
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 当前被关掉的段名集合。**只认显式 `false`** —— 缺项是"没碰过"(默认开),
|
|
121
|
+
* 与 `resolveEnabledCategories` 对 `undefined` 的处置同一纪律。
|
|
122
|
+
*/
|
|
123
|
+
export function disabledSectionNames(data: PromptExtData | undefined): Set<string> {
|
|
124
|
+
const out = new Set<string>()
|
|
125
|
+
const saved = data?.sections
|
|
126
|
+
if (!saved) return out
|
|
127
|
+
for (const [name, on] of Object.entries(saved)) if (on === false) out.add(name)
|
|
128
|
+
return out
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 过滤掉关闭的段(保序)。自己那段**恒放行**(理由见 `OWN_SECTION_NAME`)。
|
|
133
|
+
*
|
|
134
|
+
* ⚠️ 名字不在清单里的关闭项静默跳过:宿主段随版本增删,旧数据里的名字
|
|
135
|
+
* 失效是正常生命周期,报错只会把面板变成红字噪音。
|
|
136
|
+
*/
|
|
137
|
+
export function filterSections<T extends { name: string }>(sections: readonly T[], off: ReadonlySet<string>): T[] {
|
|
138
|
+
// ⚠️ 条件是 `===` **或** 没被关 —— 我第一版写成 `!== OWN && !off.has(...)`,
|
|
139
|
+
// 把"是自己的段"当成了排除条件:结果是**准则段每次都被删掉**,而
|
|
140
|
+
// tool:write 这些反倒留着。方向写反不会抛错,只会让面板看起来全开、
|
|
141
|
+
// prompt 里准则凭空消失(本条判据就是靠两个方向同时断言抓出来的)。
|
|
142
|
+
return sections.filter((s) => s.name === OWN_SECTION_NAME || !off.has(s.name))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 段清单缓存:waterfall 监听器是**唯一写入者**(装配路径顺路喂),
|
|
147
|
+
* 面板 controls getter 只读。分组与排序都在读取端做 —— 缓存保持
|
|
148
|
+
* 装配原序(`assemble` 已按 order/name 排好),组的次序自然等于 prompt 次序。
|
|
149
|
+
*/
|
|
150
|
+
export function createSectionCache(): {
|
|
151
|
+
/** 用一次装配结果刷新(整表替换;自己的段不进清单)。 */
|
|
152
|
+
update: (sections: readonly { name: string; text: string }[]) => void
|
|
153
|
+
/** 当前清单(拷贝,防调用方改缓存内部)。 */
|
|
154
|
+
list: () => SectionEntry[]
|
|
155
|
+
} {
|
|
156
|
+
let entries: SectionEntry[] = []
|
|
157
|
+
return {
|
|
158
|
+
update(sections) {
|
|
159
|
+
entries = sections
|
|
160
|
+
.filter((s) => s.name !== OWN_SECTION_NAME)
|
|
161
|
+
.map((s) => ({ name: s.name, text: s.text }))
|
|
162
|
+
},
|
|
163
|
+
list() {
|
|
164
|
+
return entries.map((e) => ({ ...e }))
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** 一个分组(组名 + 组内条目,组内保持清单序)。 */
|
|
170
|
+
export interface SectionGroup {
|
|
171
|
+
label: string
|
|
172
|
+
items: SectionEntry[]
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 按段名前缀分组;**组的先后 = 首次出现序**(清单已按 prompt 序 ⇒ 组序即
|
|
177
|
+
* 段落在 prompt 里的先后)——不按字母重排,面板顺序与模型看到的顺序一致。
|
|
178
|
+
*/
|
|
179
|
+
export function groupSections(list: readonly SectionEntry[]): SectionGroup[] {
|
|
180
|
+
const out: SectionGroup[] = []
|
|
181
|
+
const byLabel = new Map<string, SectionGroup>()
|
|
182
|
+
for (const item of list) {
|
|
183
|
+
const label = groupLabelOf(item.name)
|
|
184
|
+
let g = byLabel.get(label)
|
|
185
|
+
if (!g) {
|
|
186
|
+
g = { label, items: [] }
|
|
187
|
+
byLabel.set(label, g)
|
|
188
|
+
out.push(g)
|
|
189
|
+
}
|
|
190
|
+
g.items.push(item)
|
|
191
|
+
}
|
|
192
|
+
return out
|
|
193
|
+
}
|
package/src/ext-route.test.ts
CHANGED
|
@@ -76,3 +76,19 @@ test('★ src 下不许有备份/临时文件(它们会被 `git add -A` 顺手
|
|
|
76
76
|
.filter((f) => /\.(bak|orig|rej|tmp|swp)$/.test(f) || /~$/.test(f))
|
|
77
77
|
assert.deepEqual(junk, [], `源码目录里有临时/备份文件:${junk.join(', ')}`)
|
|
78
78
|
})
|
|
79
|
+
|
|
80
|
+
// ── controls 序列化白名单(2026-10-21 段落总控)──────────────────
|
|
81
|
+
test('★ controls 透传 group / sortable(白名单漏字段 ⇒ 面板分组永远不生效)', () => {
|
|
82
|
+
// ⚠️ 这里存在一个**只在入口才暴露**的失效:`GET /ext` 用 `.map()` 逐字段
|
|
83
|
+
// 重建 controls(白名单),扩展在 `controls` 里给了 `group`,但白名单
|
|
84
|
+
// 不认它 ⇒ 字段被静默丢掉。而单测(直接读扩展对象的 `.controls`)**全绿**:
|
|
85
|
+
// 扩展那头确实给了。两端都"对",中间少一环 —— 只能在这里守。
|
|
86
|
+
//
|
|
87
|
+
// 判据用源码形状而非启服务器:与本文件既有做法一致(理由见文件头)。
|
|
88
|
+
const mapLine = src.split('\n').find((l) => l.includes('.map((c) => ({ id: c.id, title: c.title' ))
|
|
89
|
+
assert.ok(mapLine !== undefined, '找不到 controls 的序列化 map(形状变了 ⇒ 本判据要跟着改)')
|
|
90
|
+
assert.match(mapLine, /\.\.\.\(c\.group !== undefined \? \{ group: c\.group \}/,
|
|
91
|
+
'controls 序列化没透传 group ⇒ 段落分组在面板上永远不出现')
|
|
92
|
+
assert.match(mapLine, /\.\.\.\(c\.sortable !== undefined \? \{ sortable: c\.sortable \}/,
|
|
93
|
+
'controls 序列化没透传 sortable ⇒ 段落行仍可拖(假交互)')
|
|
94
|
+
})
|