@dsh-plugin/dsh-synapse 0.1.0-dev.41044228975 → 0.1.0-dev.41051739681
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 +12 -24
- package/lib/host/agent.d.ts +8 -4
- package/lib/host/agent.js +16 -99
- package/lib/host/agent.js.map +1 -1
- package/lib/host/bot.js +9 -7
- package/lib/host/bot.js.map +1 -1
- package/lib/host/config-migration.d.ts +3 -0
- package/lib/host/config-migration.js +103 -0
- package/lib/host/config-migration.js.map +1 -0
- package/lib/host/config.d.ts +4 -18
- package/lib/host/config.js +77 -130
- package/lib/host/config.js.map +1 -1
- package/lib/host/feature-config.d.ts +20 -0
- package/lib/host/feature-config.js +264 -0
- package/lib/host/feature-config.js.map +1 -0
- package/lib/host/gateway.d.ts +4 -6
- package/lib/host/gateway.js +42 -104
- package/lib/host/gateway.js.map +1 -1
- package/lib/host/github.d.ts +2 -2
- package/lib/host/harness.d.ts +23 -0
- package/lib/host/harness.js +209 -0
- package/lib/host/harness.js.map +1 -0
- package/lib/host/index.js +22 -12
- package/lib/host/index.js.map +1 -1
- package/lib/host/mcp.d.ts +1 -1
- package/lib/host/mcp.js +15 -11
- package/lib/host/mcp.js.map +1 -1
- package/lib/host/panel.html +2 -1
- package/lib/host/panel.js +40 -24
- package/lib/host/panel.js.map +1 -1
- package/lib/host/types.d.ts +104 -16
- package/package.json +1 -1
- package/scripts/dump-npm-latest.mjs +29 -10
- package/scripts/host-smoke.mjs +139 -0
- package/src/agent.ts +34 -114
- package/src/bot.ts +9 -7
- package/src/config-migration.ts +148 -0
- package/src/config.ts +116 -152
- package/src/feature-config.ts +351 -0
- package/src/gateway.ts +48 -115
- package/src/harness.ts +253 -0
- package/src/index.ts +25 -18
- package/src/mcp.ts +23 -11
- package/src/panel.html +2 -1
- package/src/panel.ts +53 -24
- package/src/types.ts +75 -13
- package/test/config-migration.test.mjs +137 -0
- package/test/core.test.mjs +82 -16
- package/test/fixtures/host.mjs +133 -0
- package/test/harness.test.mjs +90 -0
- package/test/integration.test.mjs +10 -12
- package/test/panel.test.mjs +71 -3
package/src/panel.ts
CHANGED
|
@@ -79,20 +79,24 @@ async function saveConfig() {
|
|
|
79
79
|
view = saved.value;
|
|
80
80
|
draft = structuredClone(view.value);
|
|
81
81
|
dirty = false;
|
|
82
|
+
await render();
|
|
82
83
|
notify("已保存。通道正在重新连接;可刷新状态检查结果。");
|
|
83
84
|
}
|
|
84
|
-
function field(parent, f, obj, key = f.key) {
|
|
85
|
+
function field(parent, f, obj, key = f.key, path: string[] = []) {
|
|
86
|
+
const at = f.type === "group" ? path : [...path, String(key)];
|
|
85
87
|
if (f.type === "group" || f.type === "object") {
|
|
86
88
|
const box = node("fieldset");
|
|
87
89
|
box.append(node("legend", f.label));
|
|
90
|
+
box.dataset.path = at.join(".");
|
|
88
91
|
parent.append(box);
|
|
89
92
|
const target = f.type === "object" ? (obj[key] ??= {}) : obj;
|
|
90
|
-
for (const child of f.fields) field(box, child, target);
|
|
93
|
+
for (const child of f.fields) field(box, child, target, child.key, at);
|
|
91
94
|
return;
|
|
92
95
|
}
|
|
93
96
|
if (f.type === "array") {
|
|
94
97
|
const box = node("fieldset");
|
|
95
98
|
box.append(node("legend", f.label));
|
|
99
|
+
box.dataset.path = at.join(".");
|
|
96
100
|
parent.append(box);
|
|
97
101
|
const list = node("div");
|
|
98
102
|
box.append(list);
|
|
@@ -100,7 +104,13 @@ function field(parent, f, obj, key = f.key) {
|
|
|
100
104
|
list.replaceChildren();
|
|
101
105
|
(obj[key] ||= []).forEach((_, i) => {
|
|
102
106
|
const row = node("div", undefined, { className: "row" });
|
|
103
|
-
field(
|
|
107
|
+
field(
|
|
108
|
+
row,
|
|
109
|
+
{ ...f.item, label: `${f.item.label} ${i + 1}` },
|
|
110
|
+
obj[key],
|
|
111
|
+
i,
|
|
112
|
+
at,
|
|
113
|
+
);
|
|
104
114
|
row.append(
|
|
105
115
|
button("删除", () => {
|
|
106
116
|
obj[key].splice(i, 1);
|
|
@@ -131,6 +141,25 @@ function field(parent, f, obj, key = f.key) {
|
|
|
131
141
|
obj[key] = control.checked;
|
|
132
142
|
dirty = true;
|
|
133
143
|
};
|
|
144
|
+
} else if (f.type === "multiselect") {
|
|
145
|
+
control = node("select", undefined, {
|
|
146
|
+
multiple: true,
|
|
147
|
+
size: f.options.length,
|
|
148
|
+
});
|
|
149
|
+
for (const opt of f.options)
|
|
150
|
+
control.append(
|
|
151
|
+
node("option", opt.label, {
|
|
152
|
+
value: opt.value,
|
|
153
|
+
selected: (obj[key] || []).includes(opt.value),
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
control.onchange = () => {
|
|
157
|
+
obj[key] = Array.from(
|
|
158
|
+
control.selectedOptions,
|
|
159
|
+
(option: HTMLOptionElement) => option.value,
|
|
160
|
+
);
|
|
161
|
+
dirty = true;
|
|
162
|
+
};
|
|
134
163
|
} else if (f.type === "select") {
|
|
135
164
|
control = node("select");
|
|
136
165
|
for (const opt of f.options)
|
|
@@ -141,8 +170,7 @@ function field(parent, f, obj, key = f.key) {
|
|
|
141
170
|
dirty = true;
|
|
142
171
|
};
|
|
143
172
|
} else {
|
|
144
|
-
|
|
145
|
-
control = node(f.type === "textarea" || isJSON ? "textarea" : "input");
|
|
173
|
+
control = node(f.type === "textarea" ? "textarea" : "input");
|
|
146
174
|
if (control.tagName === "INPUT")
|
|
147
175
|
control.type =
|
|
148
176
|
f.type === "password"
|
|
@@ -153,7 +181,9 @@ function field(parent, f, obj, key = f.key) {
|
|
|
153
181
|
const secret = obj[key] && typeof obj[key] === "object";
|
|
154
182
|
control.value = secret ? "" : (obj[key] ?? "");
|
|
155
183
|
if (secret)
|
|
156
|
-
control.placeholder =
|
|
184
|
+
control.placeholder = obj[key].set
|
|
185
|
+
? "已保存(不回显)。留空保持不变;输入替换。"
|
|
186
|
+
: "尚未设置";
|
|
157
187
|
control.oninput = () => {
|
|
158
188
|
obj[key] = f.type === "number" ? Number(control.value) : control.value;
|
|
159
189
|
dirty = true;
|
|
@@ -161,6 +191,7 @@ function field(parent, f, obj, key = f.key) {
|
|
|
161
191
|
if (f.min !== undefined) control.min = f.min;
|
|
162
192
|
if (f.max !== undefined) control.max = f.max;
|
|
163
193
|
}
|
|
194
|
+
control.setAttribute("name", at.join("."));
|
|
164
195
|
labeled(parent, f.label, control);
|
|
165
196
|
if (f.description)
|
|
166
197
|
parent.append(node("div", f.description, { className: "muted" }));
|
|
@@ -178,7 +209,7 @@ function defaultOf(f) {
|
|
|
178
209
|
if (f.default !== undefined) return structuredClone(f.default);
|
|
179
210
|
if (f.type === "object")
|
|
180
211
|
return Object.fromEntries(f.fields.map((x) => [x.key, defaultOf(x)]));
|
|
181
|
-
if (f.type === "array") return [];
|
|
212
|
+
if (f.type === "array" || f.type === "multiselect") return [];
|
|
182
213
|
if (f.type === "boolean") return false;
|
|
183
214
|
if (f.type === "select") return f.options[0].value;
|
|
184
215
|
if (f.type === "number") return f.min || 0;
|
|
@@ -191,34 +222,32 @@ async function configPanel(root, onlyMcp = false) {
|
|
|
191
222
|
"p",
|
|
192
223
|
onlyMcp
|
|
193
224
|
? "添加或修改 MCP 服务,保存后可预览工具。仅允许列表中的工具可被聊天自动调用。"
|
|
194
|
-
: "
|
|
225
|
+
: "按功能填写下方各项设置。聊天与审查使用 DSH 已配置的默认模型,保存后重新连接通道。",
|
|
195
226
|
),
|
|
196
227
|
);
|
|
197
228
|
for (const f of view.template.fields)
|
|
198
|
-
if (onlyMcp ? f.key === "
|
|
229
|
+
if (onlyMcp ? f.key === "mcpServers" : f.key !== "mcpServers")
|
|
230
|
+
field(root, f, draft);
|
|
199
231
|
root.append(button("保存配置", saveConfig));
|
|
200
232
|
if (!onlyMcp) {
|
|
201
233
|
root.append(
|
|
202
|
-
button("
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
234
|
+
button("同步聊天命令修改的功能设置", async () => {
|
|
235
|
+
if (
|
|
236
|
+
dirty &&
|
|
237
|
+
!confirm("当前修改尚未保存,同步将丢弃这些修改,是否继续?")
|
|
238
|
+
)
|
|
239
|
+
return;
|
|
240
|
+
await api("features/refresh", { expectedRevision: view.revision });
|
|
241
|
+
dirty = false;
|
|
209
242
|
root.replaceChildren();
|
|
210
|
-
root
|
|
211
|
-
|
|
212
|
-
);
|
|
213
|
-
for (const f of view.template.fields)
|
|
214
|
-
if (f.key !== "mcp") field(root, f, draft);
|
|
215
|
-
root.append(button("保存配置", saveConfig));
|
|
243
|
+
await configPanel(root);
|
|
244
|
+
notify("已同步当前功能设置,可继续在表单中修改。");
|
|
216
245
|
}),
|
|
217
246
|
);
|
|
218
247
|
} else {
|
|
219
248
|
const box = node("section");
|
|
220
249
|
root.append(box);
|
|
221
|
-
for (const s of draft.
|
|
250
|
+
for (const s of draft.mcpServers) {
|
|
222
251
|
box.append(
|
|
223
252
|
button(`预览 ${s.name} 工具`, async () => {
|
|
224
253
|
preview.textContent = JSON.stringify(
|
|
@@ -297,7 +326,7 @@ async function skillPanel(root) {
|
|
|
297
326
|
});
|
|
298
327
|
revision = saved.revision;
|
|
299
328
|
dirty = false;
|
|
300
|
-
notify("Skill
|
|
329
|
+
notify("Skill 已保存,可被对应通道的 DSH 任务检索。");
|
|
301
330
|
await refresh();
|
|
302
331
|
}),
|
|
303
332
|
button("删除 Skill", async () => {
|
package/src/types.ts
CHANGED
|
@@ -11,11 +11,11 @@ export interface McpSpec {
|
|
|
11
11
|
enabled: boolean;
|
|
12
12
|
transport: "stdio" | "http" | "sse";
|
|
13
13
|
command: string;
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
args: string[];
|
|
15
|
+
env: { name: string; value: string }[];
|
|
16
16
|
url: string;
|
|
17
|
-
|
|
18
|
-
allowedTools: string;
|
|
17
|
+
headers: { name: string; value: string }[];
|
|
18
|
+
allowedTools: string[];
|
|
19
19
|
}
|
|
20
20
|
export interface SynapseConfig {
|
|
21
21
|
enabled: boolean;
|
|
@@ -26,15 +26,11 @@ export interface SynapseConfig {
|
|
|
26
26
|
httpToken: string;
|
|
27
27
|
wsToken: string;
|
|
28
28
|
chromePath: string;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
model: string;
|
|
29
|
+
userAgent: string;
|
|
30
|
+
logLevel: string;
|
|
31
|
+
tokenParams: string;
|
|
33
32
|
timeoutSec: number;
|
|
34
33
|
maxToolRounds: number;
|
|
35
|
-
externalHermes: boolean;
|
|
36
|
-
hermesUrl: string;
|
|
37
|
-
hermesKey: string;
|
|
38
34
|
githubEnabled: boolean;
|
|
39
35
|
githubToken: string;
|
|
40
36
|
webhookSecret: string;
|
|
@@ -48,8 +44,74 @@ export interface SynapseConfig {
|
|
|
48
44
|
learnMemory: boolean;
|
|
49
45
|
learnSkills: boolean;
|
|
50
46
|
maxMemories: number;
|
|
51
|
-
|
|
52
|
-
|
|
47
|
+
modules: {
|
|
48
|
+
management: {
|
|
49
|
+
groups: number[];
|
|
50
|
+
operators: number[];
|
|
51
|
+
discordOperators: string[];
|
|
52
|
+
};
|
|
53
|
+
parentheses: { groups: number[] };
|
|
54
|
+
poke: { groups: number[] };
|
|
55
|
+
minecraft: { groups: number[] };
|
|
56
|
+
modrinth: { groups: number[] };
|
|
57
|
+
command: { groups: number[] };
|
|
58
|
+
bili: { groups: number[]; follow: number[] };
|
|
59
|
+
github: { subscriptions: { repository: string; groups: number[] }[] };
|
|
60
|
+
welcome: { welcomes: { group: number[]; msg: string }[] };
|
|
61
|
+
discord: {
|
|
62
|
+
token: string;
|
|
63
|
+
bridges: {
|
|
64
|
+
guildId: string;
|
|
65
|
+
channelName: string;
|
|
66
|
+
group: string;
|
|
67
|
+
needReply: boolean;
|
|
68
|
+
needCmd: boolean;
|
|
69
|
+
}[];
|
|
70
|
+
};
|
|
71
|
+
chat: {
|
|
72
|
+
botName: string;
|
|
73
|
+
groups: number[];
|
|
74
|
+
admins: number[];
|
|
75
|
+
allowedUsers: number[];
|
|
76
|
+
blockedUsers: number[];
|
|
77
|
+
requireMention: boolean;
|
|
78
|
+
keywordTriggers: string[];
|
|
79
|
+
progressRateLimitSec: number;
|
|
80
|
+
progressAsImage: boolean;
|
|
81
|
+
progressMaxTools: number;
|
|
82
|
+
maxMessageLength: number;
|
|
83
|
+
forwardImages: boolean;
|
|
84
|
+
replyPerSentence: boolean;
|
|
85
|
+
compactMaxChars: number;
|
|
86
|
+
compactMaxLines: number;
|
|
87
|
+
localHistoryMaxMessages: number;
|
|
88
|
+
persistHistoryEnabled: boolean;
|
|
89
|
+
persistHistoryMax: number;
|
|
90
|
+
approvalEnabled: boolean;
|
|
91
|
+
approvalTimeoutSec: number;
|
|
92
|
+
reactionEnabled: boolean;
|
|
93
|
+
reactionEmojiQq: string;
|
|
94
|
+
reactionEmojiDiscord: string;
|
|
95
|
+
privateReactionFallbackText: boolean;
|
|
96
|
+
privateReceiptText: string;
|
|
97
|
+
};
|
|
98
|
+
peakValley: {
|
|
99
|
+
groups: number[];
|
|
100
|
+
mode: string;
|
|
101
|
+
time: {
|
|
102
|
+
start: string;
|
|
103
|
+
end: string;
|
|
104
|
+
days: string[];
|
|
105
|
+
mode: string;
|
|
106
|
+
msg: string;
|
|
107
|
+
}[];
|
|
108
|
+
peak_msg: string;
|
|
109
|
+
valley_msg: string;
|
|
110
|
+
cmd_peak_msg: string;
|
|
111
|
+
cmd_valley_msg: string;
|
|
112
|
+
};
|
|
113
|
+
};
|
|
114
|
+
mcpServers: McpSpec[];
|
|
53
115
|
}
|
|
54
116
|
export type ConfigSource = () => SynapseConfig;
|
|
55
117
|
export type ErrorSink = (error: Error) => void;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtemp, mkdir, writeFile, readFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import { defaultsFor } from "@dsh-plugin/dsh-loader/config";
|
|
7
|
+
import { ConfigStore } from "../node_modules/@dsh-plugin/dsh-loader/dist/services/config.js";
|
|
8
|
+
import { template } from "../lib/host/config.js";
|
|
9
|
+
import { registerConfig } from "../lib/host/config-migration.js";
|
|
10
|
+
import { featureFiles } from "../lib/host/feature-config.js";
|
|
11
|
+
|
|
12
|
+
test("real loader migrates legacy JSON, preserves live commands, backs up and redacts secrets", async () => {
|
|
13
|
+
const home = await mkdtemp(path.join(os.tmpdir(), "synapse-migrate-"));
|
|
14
|
+
const profile = path.join(home, "profiles", "test");
|
|
15
|
+
const directory = path.join(profile, "config", "dsh-synapse");
|
|
16
|
+
const bot = path.join(
|
|
17
|
+
profile,
|
|
18
|
+
"data",
|
|
19
|
+
"dsh-synapse",
|
|
20
|
+
"bot",
|
|
21
|
+
"configs",
|
|
22
|
+
"features",
|
|
23
|
+
);
|
|
24
|
+
await mkdir(directory, { recursive: true });
|
|
25
|
+
await mkdir(bot, { recursive: true });
|
|
26
|
+
const old = {
|
|
27
|
+
...defaultsFor(template.fields),
|
|
28
|
+
profileDir: profile,
|
|
29
|
+
homeDir: home,
|
|
30
|
+
llmUrl: "https://obsolete.example/v1",
|
|
31
|
+
llmKey: "old-key",
|
|
32
|
+
externalHermes: true,
|
|
33
|
+
botJson: JSON.stringify({ logLevel: "warn" }),
|
|
34
|
+
features: [
|
|
35
|
+
{
|
|
36
|
+
id: "hermes",
|
|
37
|
+
json: JSON.stringify({
|
|
38
|
+
groups: [123],
|
|
39
|
+
apiKey: "old-api-key",
|
|
40
|
+
requireMention: false,
|
|
41
|
+
}),
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: "discord-bridge",
|
|
45
|
+
json: JSON.stringify({
|
|
46
|
+
token: "discord-secret",
|
|
47
|
+
bridges: {
|
|
48
|
+
"123#general": {
|
|
49
|
+
group: "456",
|
|
50
|
+
need_reply: "true",
|
|
51
|
+
need_cmd: "false",
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
}),
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: "peak-valley-timer",
|
|
58
|
+
json: JSON.stringify({ time: ["9:00-12:00 Mon-Fri"] }),
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
mcp: [
|
|
62
|
+
{
|
|
63
|
+
name: "docs",
|
|
64
|
+
enabled: true,
|
|
65
|
+
transport: "stdio",
|
|
66
|
+
command: "node",
|
|
67
|
+
argsJson: '["server.mjs"]',
|
|
68
|
+
envJson: '{"TOKEN":"mcp-secret"}',
|
|
69
|
+
headersJson: "{}",
|
|
70
|
+
allowedTools: "read search",
|
|
71
|
+
url: "",
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
await writeFile(
|
|
76
|
+
path.join(directory, "config.json"),
|
|
77
|
+
JSON.stringify({ formatVersion: 1, revision: 7, value: old }),
|
|
78
|
+
);
|
|
79
|
+
await writeFile(
|
|
80
|
+
path.join(bot, "hermes.json"),
|
|
81
|
+
JSON.stringify({ groups: [789], requireMention: true }),
|
|
82
|
+
);
|
|
83
|
+
const store = new ConfigStore(profile);
|
|
84
|
+
const ctx = { dshLoader: { config: store } };
|
|
85
|
+
const handle = await registerConfig(ctx);
|
|
86
|
+
try {
|
|
87
|
+
const { value: c, revision } = handle.get();
|
|
88
|
+
assert.equal(revision, 8);
|
|
89
|
+
assert.equal(c.logLevel, "warn");
|
|
90
|
+
assert.deepEqual(c.modules.chat.groups, [789]);
|
|
91
|
+
assert.equal(c.modules.discord.bridges[0].needReply, true);
|
|
92
|
+
assert.equal(c.modules.discord.bridges[0].needCmd, false);
|
|
93
|
+
assert.deepEqual(c.modules.peakValley.time[0].days, [
|
|
94
|
+
"Mon",
|
|
95
|
+
"Tue",
|
|
96
|
+
"Wed",
|
|
97
|
+
"Thu",
|
|
98
|
+
"Fri",
|
|
99
|
+
]);
|
|
100
|
+
assert.deepEqual(c.mcpServers[0].allowedTools, ["read", "search"]);
|
|
101
|
+
for (const key of [
|
|
102
|
+
"llmUrl",
|
|
103
|
+
"llmKey",
|
|
104
|
+
"externalHermes",
|
|
105
|
+
"features",
|
|
106
|
+
"botJson",
|
|
107
|
+
"mcp",
|
|
108
|
+
])
|
|
109
|
+
assert.equal(key in c, false);
|
|
110
|
+
assert(
|
|
111
|
+
!JSON.stringify(store.read("dsh-synapse")).includes("discord-secret"),
|
|
112
|
+
);
|
|
113
|
+
assert(!JSON.stringify(store.read("dsh-synapse")).includes("mcp-secret"));
|
|
114
|
+
const discord = featureFiles(c).find(
|
|
115
|
+
(f) => f.id === "discord-bridge",
|
|
116
|
+
).value;
|
|
117
|
+
assert.equal(discord.bridges["123#general"].need_reply, "true");
|
|
118
|
+
const backup = JSON.parse(
|
|
119
|
+
await readFile(
|
|
120
|
+
path.join(profile, "data", "dsh-synapse", "config-before-forms.json"),
|
|
121
|
+
"utf8",
|
|
122
|
+
),
|
|
123
|
+
);
|
|
124
|
+
assert.equal(backup.revision, 7);
|
|
125
|
+
assert.equal(backup.value.llmKey, "old-key");
|
|
126
|
+
assert.equal(
|
|
127
|
+
JSON.parse(await readFile(path.join(directory, "config.json"), "utf8"))
|
|
128
|
+
.value.llmKey,
|
|
129
|
+
undefined,
|
|
130
|
+
);
|
|
131
|
+
} finally {
|
|
132
|
+
handle.dispose();
|
|
133
|
+
}
|
|
134
|
+
const again = await registerConfig(ctx);
|
|
135
|
+
assert.equal(again.get().revision, 8);
|
|
136
|
+
again.dispose();
|
|
137
|
+
});
|
package/test/core.test.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
repositoryAllowed,
|
|
15
15
|
Reviews,
|
|
16
16
|
} from "../lib/host/github.js";
|
|
17
|
+
import { hostFixture } from "./fixtures/host.mjs";
|
|
17
18
|
import { Agent } from "../lib/host/agent.js";
|
|
18
19
|
import { Gateway } from "../lib/host/gateway.js";
|
|
19
20
|
import { browserAuthorized } from "../lib/host/http.js";
|
|
@@ -39,10 +40,10 @@ const inference = {
|
|
|
39
40
|
return docs.map((t) => (t.includes("重排") ? 1 : 0));
|
|
40
41
|
},
|
|
41
42
|
};
|
|
42
|
-
test("defaults form valid config and reject
|
|
43
|
+
test("defaults form valid config and reject invalid structured fields", () => {
|
|
43
44
|
const c = config();
|
|
44
45
|
assert.equal(validateConfig(c), c);
|
|
45
|
-
assert.throws(() => validateConfig({ ...c,
|
|
46
|
+
assert.throws(() => validateConfig({ ...c, mcpServers: [{ name: "x" }] }));
|
|
46
47
|
assert.throws(() => validateConfig({ ...c, githubEnabled: true }));
|
|
47
48
|
assert.throws(() => validateConfig({ ...c, recallK: 1.5 }));
|
|
48
49
|
});
|
|
@@ -171,7 +172,22 @@ test("agent injects SOUL, retrieved skill and memory; tools execute only in chat
|
|
|
171
172
|
await writeFile(path.join(p.profile, "SOUL.md"), "shared soul");
|
|
172
173
|
const c = { ...config(), learnSkills: false, learnMemory: false };
|
|
173
174
|
let calls = 0;
|
|
175
|
+
const host = hostFixture(
|
|
176
|
+
async ({ variables, tools, restrictions, options }) => {
|
|
177
|
+
assert.deepEqual(restrictions, [{ allow: [] }]);
|
|
178
|
+
assert.equal(options.agentOptions.model, "configured-model");
|
|
179
|
+
assert.match(
|
|
180
|
+
variables.get("synapse_context")(),
|
|
181
|
+
/shared soul|Review changes/,
|
|
182
|
+
);
|
|
183
|
+
assert.match(variables.get("synapse_context")(), /durable/);
|
|
184
|
+
if (tools.length)
|
|
185
|
+
await tools[0].execute({}, { signal: new AbortController().signal });
|
|
186
|
+
return "done";
|
|
187
|
+
},
|
|
188
|
+
);
|
|
174
189
|
const a = new Agent({
|
|
190
|
+
loader: host.loader,
|
|
175
191
|
paths: p,
|
|
176
192
|
config: () => c,
|
|
177
193
|
memory: { search: async () => [{ text: "durable", source: "test" }] },
|
|
@@ -191,24 +207,12 @@ test("agent injects SOUL, retrieved skill and memory; tools execute only in chat
|
|
|
191
207
|
},
|
|
192
208
|
},
|
|
193
209
|
});
|
|
194
|
-
let rounds = 0;
|
|
195
|
-
a.completion = async (messages) => {
|
|
196
|
-
assert.match(messages[0].content, /shared soul|Review changes/);
|
|
197
|
-
assert.match(messages[0].content, /durable/);
|
|
198
|
-
return rounds++ === 0
|
|
199
|
-
? {
|
|
200
|
-
role: "assistant",
|
|
201
|
-
tool_calls: [
|
|
202
|
-
{ id: "a", function: { name: "mcp_0", arguments: "{}" } },
|
|
203
|
-
],
|
|
204
|
-
}
|
|
205
|
-
: { role: "assistant", content: "done" };
|
|
206
|
-
};
|
|
207
210
|
assert.equal(await a.run("question"), "done");
|
|
208
211
|
assert.equal(calls, 1);
|
|
209
|
-
rounds = 1;
|
|
210
212
|
assert.equal(await a.run("question", "review"), "done");
|
|
211
213
|
assert.equal(calls, 1);
|
|
214
|
+
assert.equal(host.disposed.length, 2);
|
|
215
|
+
await a.close();
|
|
212
216
|
});
|
|
213
217
|
test("Gateway authenticates, serves replayable completion events, and stops runs", async () => {
|
|
214
218
|
const g = new Gateway({ run: async () => "hello" }, config, () => {});
|
|
@@ -234,6 +238,68 @@ test("Gateway authenticates, serves replayable completion events, and stops runs
|
|
|
234
238
|
await g.close();
|
|
235
239
|
}
|
|
236
240
|
});
|
|
241
|
+
test("Gateway routes single-use DSH approval and cancels pending approval on stop", async () => {
|
|
242
|
+
const g = new Gateway(
|
|
243
|
+
{
|
|
244
|
+
run: async (
|
|
245
|
+
_input,
|
|
246
|
+
_channel,
|
|
247
|
+
_history,
|
|
248
|
+
signal,
|
|
249
|
+
_event,
|
|
250
|
+
_instructions,
|
|
251
|
+
ask,
|
|
252
|
+
) => ask({ toolName: "docs/read", signal }),
|
|
253
|
+
},
|
|
254
|
+
config,
|
|
255
|
+
() => {},
|
|
256
|
+
);
|
|
257
|
+
await g.start();
|
|
258
|
+
const headers = {
|
|
259
|
+
Authorization: `Bearer ${g.token}`,
|
|
260
|
+
"Content-Type": "application/json",
|
|
261
|
+
};
|
|
262
|
+
const post = (url, value = {}) =>
|
|
263
|
+
fetch(g.url + url, {
|
|
264
|
+
method: "POST",
|
|
265
|
+
headers,
|
|
266
|
+
body: JSON.stringify(value),
|
|
267
|
+
});
|
|
268
|
+
try {
|
|
269
|
+
const { run_id: first } = await (
|
|
270
|
+
await post("/v1/runs", { input: "read" })
|
|
271
|
+
).json();
|
|
272
|
+
assert.equal(
|
|
273
|
+
(await post(`/v1/runs/${first}/approval`, { choice: "always" })).status,
|
|
274
|
+
400,
|
|
275
|
+
);
|
|
276
|
+
assert.equal(
|
|
277
|
+
(await post(`/v1/runs/${first}/approval`, { choice: "once" })).status,
|
|
278
|
+
200,
|
|
279
|
+
);
|
|
280
|
+
const events = await (
|
|
281
|
+
await fetch(g.url + `/v1/runs/${first}/events`, { headers })
|
|
282
|
+
).text();
|
|
283
|
+
assert.match(events, /approval.request/);
|
|
284
|
+
assert.match(events, /allowed-once/);
|
|
285
|
+
assert.equal(
|
|
286
|
+
(await post(`/v1/runs/${first}/approval`, { choice: "once" })).status,
|
|
287
|
+
409,
|
|
288
|
+
);
|
|
289
|
+
const { run_id: second } = await (
|
|
290
|
+
await post("/v1/runs", { input: "read" })
|
|
291
|
+
).json();
|
|
292
|
+
await post(`/v1/runs/${second}/stop`);
|
|
293
|
+
assert.match(
|
|
294
|
+
await (
|
|
295
|
+
await fetch(g.url + `/v1/runs/${second}/events`, { headers })
|
|
296
|
+
).text(),
|
|
297
|
+
/cancelled/,
|
|
298
|
+
);
|
|
299
|
+
} finally {
|
|
300
|
+
await g.close();
|
|
301
|
+
}
|
|
302
|
+
});
|
|
237
303
|
test("durable GitHub queue creates head-bound review, deduplicates publication and skips stale heads", async () => {
|
|
238
304
|
const memory = new Memory(":memory:", inference, config);
|
|
239
305
|
const c = {
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
// Contract fixture for the public native Agent lifecycle (not a second model API).
|
|
4
|
+
export function hostFixture(execute = async () => "done") {
|
|
5
|
+
const created = [],
|
|
6
|
+
disposed = [],
|
|
7
|
+
attachments = [];
|
|
8
|
+
let selection = {
|
|
9
|
+
provider: "configured-provider",
|
|
10
|
+
model: "configured-model",
|
|
11
|
+
};
|
|
12
|
+
const loader = {
|
|
13
|
+
llm: {
|
|
14
|
+
createUserMessage: (input) => ({
|
|
15
|
+
id: randomUUID(),
|
|
16
|
+
role: "user",
|
|
17
|
+
...input,
|
|
18
|
+
}),
|
|
19
|
+
},
|
|
20
|
+
services: {
|
|
21
|
+
get: (name) =>
|
|
22
|
+
({
|
|
23
|
+
agentDefaultModel: { currentSelection: () => ({ ...selection }) },
|
|
24
|
+
attachments: {
|
|
25
|
+
saveImage: async (image) => {
|
|
26
|
+
attachments.push(image);
|
|
27
|
+
return { attachmentId: "image-1" };
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
agents: {
|
|
31
|
+
create: async (options) => {
|
|
32
|
+
const listeners = new Map(),
|
|
33
|
+
tools = [],
|
|
34
|
+
sections = [],
|
|
35
|
+
variables = new Map(),
|
|
36
|
+
injected = [],
|
|
37
|
+
restrictions = [];
|
|
38
|
+
const controller = new AbortController();
|
|
39
|
+
let task = Promise.resolve();
|
|
40
|
+
const emit = (name, data) =>
|
|
41
|
+
listeners.get(name)?.({ agent, ...data });
|
|
42
|
+
const scope = {
|
|
43
|
+
get: (name) =>
|
|
44
|
+
({
|
|
45
|
+
systemPrompt: {
|
|
46
|
+
section: (s) => sections.push(s),
|
|
47
|
+
variable: (name, fn) => variables.set(name, fn),
|
|
48
|
+
},
|
|
49
|
+
tools: {
|
|
50
|
+
restrict: (filter) => restrictions.push(filter),
|
|
51
|
+
register: (tool) => tools.push(tool),
|
|
52
|
+
},
|
|
53
|
+
})[name],
|
|
54
|
+
on: (name, fn) => listeners.set(name, fn),
|
|
55
|
+
};
|
|
56
|
+
const agent = {
|
|
57
|
+
id: options.sessionId,
|
|
58
|
+
ctx: scope,
|
|
59
|
+
inject: (message) => injected.push(message),
|
|
60
|
+
cancel: () => controller.abort(),
|
|
61
|
+
followup: (message) => {
|
|
62
|
+
task = (async () => {
|
|
63
|
+
try {
|
|
64
|
+
await listeners.get("agent/pre-step")?.(
|
|
65
|
+
{ agent },
|
|
66
|
+
async () => ({ kind: "enter" }),
|
|
67
|
+
);
|
|
68
|
+
const output = await execute({
|
|
69
|
+
options,
|
|
70
|
+
message,
|
|
71
|
+
injected,
|
|
72
|
+
tools,
|
|
73
|
+
sections,
|
|
74
|
+
variables,
|
|
75
|
+
restrictions,
|
|
76
|
+
signal: controller.signal,
|
|
77
|
+
emit,
|
|
78
|
+
approve: (req) =>
|
|
79
|
+
listeners.get("approval/request")?.(
|
|
80
|
+
{ agent, ...req },
|
|
81
|
+
async () => "unavailable",
|
|
82
|
+
),
|
|
83
|
+
});
|
|
84
|
+
emit("agent/assistant-stream", {
|
|
85
|
+
frame: { type: "start" },
|
|
86
|
+
});
|
|
87
|
+
emit("agent/assistant-stream", {
|
|
88
|
+
frame: {
|
|
89
|
+
type: "chunk",
|
|
90
|
+
chunk: { type: "text-delta", text: output },
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
emit("agent/assistant-stream", {
|
|
94
|
+
frame: {
|
|
95
|
+
type: "end",
|
|
96
|
+
outcome: {
|
|
97
|
+
kind: "committed",
|
|
98
|
+
eventType: "assistant/message",
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
} catch (error) {
|
|
103
|
+
emit("agent/error", { error });
|
|
104
|
+
}
|
|
105
|
+
})();
|
|
106
|
+
},
|
|
107
|
+
whenIdle: () => task,
|
|
108
|
+
};
|
|
109
|
+
await options.setup(scope, agent);
|
|
110
|
+
created.push({ options, agent, tools, restrictions, injected });
|
|
111
|
+
return {
|
|
112
|
+
agent,
|
|
113
|
+
dispose: async () => {
|
|
114
|
+
disposed.push(agent.id);
|
|
115
|
+
controller.abort();
|
|
116
|
+
await task;
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
})[name],
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
return {
|
|
125
|
+
loader,
|
|
126
|
+
created,
|
|
127
|
+
disposed,
|
|
128
|
+
attachments,
|
|
129
|
+
select: (value) => {
|
|
130
|
+
selection = value;
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|