@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.
Files changed (52) hide show
  1. package/README.md +12 -24
  2. package/lib/host/agent.d.ts +8 -4
  3. package/lib/host/agent.js +16 -99
  4. package/lib/host/agent.js.map +1 -1
  5. package/lib/host/bot.js +9 -7
  6. package/lib/host/bot.js.map +1 -1
  7. package/lib/host/config-migration.d.ts +3 -0
  8. package/lib/host/config-migration.js +103 -0
  9. package/lib/host/config-migration.js.map +1 -0
  10. package/lib/host/config.d.ts +4 -18
  11. package/lib/host/config.js +77 -130
  12. package/lib/host/config.js.map +1 -1
  13. package/lib/host/feature-config.d.ts +20 -0
  14. package/lib/host/feature-config.js +264 -0
  15. package/lib/host/feature-config.js.map +1 -0
  16. package/lib/host/gateway.d.ts +4 -6
  17. package/lib/host/gateway.js +42 -104
  18. package/lib/host/gateway.js.map +1 -1
  19. package/lib/host/github.d.ts +2 -2
  20. package/lib/host/harness.d.ts +23 -0
  21. package/lib/host/harness.js +209 -0
  22. package/lib/host/harness.js.map +1 -0
  23. package/lib/host/index.js +22 -12
  24. package/lib/host/index.js.map +1 -1
  25. package/lib/host/mcp.d.ts +1 -1
  26. package/lib/host/mcp.js +15 -11
  27. package/lib/host/mcp.js.map +1 -1
  28. package/lib/host/panel.html +2 -1
  29. package/lib/host/panel.js +40 -24
  30. package/lib/host/panel.js.map +1 -1
  31. package/lib/host/types.d.ts +104 -16
  32. package/package.json +1 -1
  33. package/scripts/dump-npm-latest.mjs +29 -10
  34. package/scripts/host-smoke.mjs +139 -0
  35. package/src/agent.ts +34 -114
  36. package/src/bot.ts +9 -7
  37. package/src/config-migration.ts +148 -0
  38. package/src/config.ts +116 -152
  39. package/src/feature-config.ts +351 -0
  40. package/src/gateway.ts +48 -115
  41. package/src/harness.ts +253 -0
  42. package/src/index.ts +25 -18
  43. package/src/mcp.ts +23 -11
  44. package/src/panel.html +2 -1
  45. package/src/panel.ts +53 -24
  46. package/src/types.ts +75 -13
  47. package/test/config-migration.test.mjs +137 -0
  48. package/test/core.test.mjs +82 -16
  49. package/test/fixtures/host.mjs +133 -0
  50. package/test/harness.test.mjs +90 -0
  51. package/test/integration.test.mjs +10 -12
  52. 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(row, { ...f.item, label: `第 ${i + 1} 项` }, obj[key], i);
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
- const isJSON = String(key).toLowerCase().includes("json");
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
- : "所有通道和上游功能均保存在当前 profile;保存使用修订号检查并发修改。",
225
+ : "按功能填写下方各项设置。聊天与审查使用 DSH 已配置的默认模型,保存后重新连接通道。",
195
226
  ),
196
227
  );
197
228
  for (const f of view.template.fields)
198
- if (onlyMcp ? f.key === "mcp" : f.key !== "mcp") field(root, f, draft);
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("载入上游功能 JSON 以预览 / 修改", async () => {
203
- const rows = await api("features");
204
- for (const row of rows) {
205
- const f = draft.features.find((f) => f.id === row.id);
206
- if (f) f.json = JSON.stringify(row.value, null, 2);
207
- }
208
- dirty = true;
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.append(
211
- node("p", "已载入当前功能配置,可能包含密钥。修改后保存。"),
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.mcp) {
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
- argsJson: string;
15
- envJson: string;
14
+ args: string[];
15
+ env: { name: string; value: string }[];
16
16
  url: string;
17
- headersJson: string;
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
- botJson: string;
30
- llmUrl: string;
31
- llmKey: string;
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
- features: { id: string; json: string }[];
52
- mcp: McpSpec[];
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
+ });
@@ -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 malformed JSON / duplicate MCP", () => {
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, botJson: "[]" }));
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
+ }