rterm-backend 3.1.12 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "rterm-backend",
3
- "version": "3.1.12",
4
- "description": "Headless AI-native backend for RTerm / neuralOS — v3.1.12: comprehensive markdown rendering in chat (tables, headings, lists, blockquotes, code blocks, task lists, HR, emphasis). 11 plugins, 55 plugin tools wired. Transports (SSH/serial/local), SQLite, and NATS libs install automatically.",
3
+ "version": "3.2.0",
4
+ "description": "Headless AI-native backend for RTerm / neuralOS — v3.2.0: production-ready Synapse dispatch (skip JetStream ack, multi-mesh, 600s timeout). 11 plugins, 55 plugin tools wired, full-duplex Synapse agent. Transports (SSH/serial/local), SQLite, and NATS libs install automatically.",
5
5
  "main": "bin/gybackend.cjs",
6
6
  "bin": { "gybackend": "bin/gybackend.cjs" },
7
7
  "license": "MIT",
@@ -46,8 +46,11 @@ export function resolveConfig(ctx = {}, env = process.env) {
46
46
  agentId: block.agentId || env.SYNAPSE_AGENT_ID || 'rterm-001',
47
47
  auth: block.auth || undefined,
48
48
  enabled: block.enabled !== false,
49
- /** auto-start the full-duplex responder on boot (default true when enabled). */
50
49
  autoServe: block.autoServe !== false,
50
+ /** dispatch timeout in ms (default 600s = 10min for LLM-backed agents). */
51
+ dispatchTimeout: block.dispatchTimeout ?? 600000,
52
+ /** multiple meshes (v3.2.0). Each: {name, url/servers, auth, prefix?}. */
53
+ meshes: Array.isArray(block.meshes) ? block.meshes : undefined,
51
54
  }
52
55
  }
53
56
 
@@ -93,8 +96,8 @@ function _configKey(cfg) {
93
96
  return `${servers}|${cfg.agentId}|${authKeys}`
94
97
  }
95
98
 
96
- async function connectMesh(ctx) {
97
- const cfg = resolveConfig(ctx)
99
+ async function connectMesh(ctx, overrideCfg) {
100
+ const cfg = overrideCfg || resolveConfig(ctx)
98
101
  const key = _configKey(cfg)
99
102
  const existing = _conns.get(key)
100
103
  if (existing) {
@@ -144,19 +147,67 @@ export function envelope(type, payload, cfg, extra = {}) {
144
147
 
145
148
  export async function discoverAgents(ctx, filter = {}) {
146
149
  const cfg = resolveConfig(ctx)
147
- const nc = await connectMesh(ctx)
148
- const msg = await nc.request(`${cfg.prefix}.registry.discover`, j(envelope('discover', filter, cfg)), { timeout: 4000 })
149
- const reply = uj(msg.data)
150
- const agents = Array.isArray(reply) ? reply : (reply.payload?.agents ?? reply.payload ?? reply)
151
- return Array.isArray(agents) ? agents : []
150
+ const meshes = cfg.meshes || [{ url: cfg.servers, auth: cfg.auth, prefix: cfg.prefix }]
151
+ const allAgents = []
152
+ for (const mesh of meshes) {
153
+ try {
154
+ const meshCfg = { ...cfg, servers: mesh.servers || (mesh.url ? [mesh.url] : cfg.servers), auth: mesh.auth || cfg.auth, prefix: mesh.prefix || cfg.prefix }
155
+ const nc = await connectMesh(ctx, meshCfg)
156
+ const msg = await nc.request(`${meshCfg.prefix}.registry.discover`, j(envelope('discover', filter, meshCfg)), { timeout: 5000 })
157
+ const reply = uj(msg.data)
158
+ const agents = Array.isArray(reply) ? reply : (reply.payload?.agents ?? reply.payload ?? reply)
159
+ if (Array.isArray(agents)) {
160
+ for (const a of agents) { if (typeof a === 'object') a._mesh = mesh.name || 'default' }
161
+ allAgents.push(...agents)
162
+ }
163
+ } catch { /* mesh unreachable — skip */ }
164
+ }
165
+ return allAgents
152
166
  }
153
167
 
154
168
  export async function dispatchTask(ctx, target, skill, input = {}, opts = {}) {
155
169
  const cfg = resolveConfig(ctx)
170
+ const timeout = opts.timeout ?? cfg.dispatchTimeout ?? 600000
156
171
  const nc = await connectMesh(ctx)
157
- const env = envelope('request', { skill, input }, cfg, { to: target, task_id: randomUUID() })
158
- const msg = await nc.request(`${cfg.prefix}.agent.${target}.inbox`, j(env), { timeout: opts.timeout ?? 30000 })
159
- return uj(msg.data)
172
+ const reqEnv = envelope('request', { skill, input }, cfg, { to: target, task_id: randomUUID() })
173
+ const inbox = `${cfg.prefix}.agent.${target}.inbox`
174
+ const replySubject = `_INBOX.synapse.${randomUUID().slice(0, 12)}`
175
+
176
+ // Subscribe to the reply subject and collect messages, skipping JetStream acks.
177
+ // The JetStream ack is always {stream: "AGENT_INBOXES", seq: N} — skip it.
178
+ // The real respond is a Synapse envelope with {type: "respond", ...} — return it.
179
+ return new Promise((resolve, reject) => {
180
+ let settled = false
181
+ const sub = nc.subscribe(replySubject, { max: 10 })
182
+ const timer = setTimeout(() => {
183
+ if (!settled) {
184
+ settled = true
185
+ try { sub.unsubscribe() } catch {}
186
+ resolve({ error: `Agent ${target} did not respond within ${Math.round(timeout / 1000)}s. It may not have a Synapse responder running (check synapse_serve_status).`, task_id: reqEnv.task_id })
187
+ }
188
+ }, timeout)
189
+
190
+ ;(async () => {
191
+ for await (const msg of sub) {
192
+ if (settled) break
193
+ try {
194
+ const parsed = uj(msg.data)
195
+ const isJetStreamAck = parsed.stream === 'AGENT_INBOXES' && typeof parsed.seq === 'number'
196
+ if (isJetStreamAck) continue // skip the ack, wait for the real respond
197
+ // This is the respond envelope
198
+ settled = true
199
+ clearTimeout(timer)
200
+ try { sub.unsubscribe() } catch {}
201
+ resolve(parsed)
202
+ } catch {
203
+ // non-JSON — skip
204
+ }
205
+ }
206
+ })().catch(() => {})
207
+
208
+ // Publish the request with the reply subject
209
+ nc.publish(inbox, j(reqEnv), { reply: replySubject })
210
+ })
160
211
  }
161
212
 
162
213
  export async function registerSelf(ctx, manifest = {}) {
@@ -5,16 +5,45 @@ function test(n, r) { cases.push({ name: n, run: r }) }
5
5
  function assert(c, m) { if (!c) throw new Error(m ?? 'assertion failed') }
6
6
  function eq(a, b, m) { if (a !== b) throw new Error(`${m ?? 'eq'}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`) }
7
7
 
8
- // ─── fake NATS connection ───────────────────────────────────────────────────
8
+ // ─── fake NATS connection (with wildcard + JetStream ack simulation) ─────────
9
+ function subjectMatches(pattern, subject) {
10
+ if (pattern === subject) return true
11
+ const p = pattern.split('.')
12
+ const s = subject.split('.')
13
+ for (let i = 0; i < p.length; i++) {
14
+ if (p[i] === '>') return true
15
+ if (i >= s.length) return false
16
+ if (p[i] !== '*' && p[i] !== s[i]) return false
17
+ }
18
+ return p.length === s.length
19
+ }
20
+
9
21
  function fakeConn() {
10
22
  const published = []
11
- const requests = new Map()
12
23
  const subs = new Map()
24
+ const requests = new Map()
25
+ const deliver = (subject, data, respondFn) => {
26
+ for (const [pattern, fns] of subs) {
27
+ if (subjectMatches(pattern, subject)) {
28
+ for (const fn of fns) fn({ data, subject, respond: respondFn })
29
+ }
30
+ }
31
+ }
13
32
  return {
14
33
  isClosed: () => false,
15
- publish(subject, data) { published.push({ subject, data: JSON.parse(new TextDecoder().decode(data)) }) },
34
+ publish(subject, data, opts) {
35
+ const env = JSON.parse(new TextDecoder().decode(data))
36
+ published.push({ subject, env, opts })
37
+ // Simulate JetStream ack on the reply subject (if reply-to is set)
38
+ if (opts?.reply) {
39
+ const ack = new TextEncoder().encode(JSON.stringify({ stream: 'AGENT_INBOXES', seq: published.length }))
40
+ deliver(opts.reply, ack, () => {})
41
+ }
42
+ // Deliver to any matching subscribers
43
+ deliver(subject, data, (p) => { published.push({ subject: '_reply', env: JSON.parse(new TextDecoder().decode(p)) }) })
44
+ },
16
45
  subscribe(subject) {
17
- const sub = {
46
+ return {
18
47
  async *[Symbol.asyncIterator]() {
19
48
  while (true) {
20
49
  const m = await new Promise((res) => {
@@ -25,17 +54,17 @@ function fakeConn() {
25
54
  },
26
55
  unsubscribe: () => subs.delete(subject),
27
56
  }
28
- return sub
29
- },
30
- _deliver(subject, env) {
31
- const data = new TextEncoder().encode(JSON.stringify(env))
32
- for (const fn of subs.get(subject) ?? []) fn({ data, subject, respond: (p) => { published.push({ subject: '_reply', env: JSON.parse(new TextDecoder().decode(p)) }) } })
33
57
  },
34
58
  async request(subject, data, _opts) {
59
+ const env = JSON.parse(new TextDecoder().decode(data))
35
60
  const h = requests.get(subject)
36
- const reply = h ? h(JSON.parse(new TextDecoder().decode(data))) : { ok: true }
61
+ const reply = h ? h(env) : { ok: true }
37
62
  return { data: new TextEncoder().encode(JSON.stringify(reply)) }
38
63
  },
64
+ _deliver(subject, env) {
65
+ const data = new TextEncoder().encode(JSON.stringify(env))
66
+ deliver(subject, data, (p) => { published.push({ subject: '_reply', env: JSON.parse(new TextDecoder().decode(p)) }) })
67
+ },
39
68
  _on(subject, h) { requests.set(subject, h) },
40
69
  published,
41
70
  drain: async () => {},
@@ -57,217 +86,109 @@ function mkCtx(settings = {}, conn) {
57
86
 
58
87
  // ─── config ─────────────────────────────────────────────────────────────────
59
88
 
60
- test('resolveConfig defaults (url, prefix=mesh, agentId=rterm-001)', () => {
89
+ test('resolveConfig includes dispatchTimeout (default 600000) + meshes', () => {
61
90
  const c = resolveConfig({ settings: {} }, {})
62
- eq(c.servers, 'nats://localhost:4222', 'default url')
63
- eq(c.prefix, 'mesh', 'default prefix')
64
- eq(c.agentId, 'rterm-001', 'default agentId')
65
- eq(c.enabled, true, 'enabled default')
91
+ eq(c.dispatchTimeout, 600000, 'default dispatchTimeout 600s')
92
+ eq(c.meshes, undefined, 'no meshes by default')
66
93
  })
67
94
 
68
- test('resolveConfig reads settings.synapse block', () => {
69
- const c = resolveConfig({ settings: { synapse: { url: 'nats://h:4222', prefix: 'mesh', agentId: 'rterm-x', auth: { token: 't' } } } }, {})
70
- eq(c.servers, 'nats://h:4222', 'url from settings')
71
- eq(c.agentId, 'rterm-x', 'agentId from settings')
72
- eq(c.auth.token, 't', 'auth token')
95
+ test('resolveConfig reads dispatchTimeout + meshes from settings', () => {
96
+ const c = resolveConfig({ settings: { synapse: { dispatchTimeout: 30000, meshes: [{ name: 'prod', url: 'nats://p:4222' }] } } }, {})
97
+ eq(c.dispatchTimeout, 30000, 'dispatchTimeout from settings')
98
+ assert(Array.isArray(c.meshes) && c.meshes.length === 1, 'meshes array')
73
99
  })
74
100
 
75
- // ─── envelope ───────────────────────────────────────────────────────────────
76
-
77
- test('envelope has Synapse v0.3.0 shape', () => {
78
- const cfg = { agentId: 'rterm-001' }
79
- const e = envelope('discover', { capabilities: [] }, cfg)
80
- eq(e.v, '0.3.0', 'protocol version')
81
- eq(e.type, 'discover', 'type')
82
- eq(e.from, 'rterm-001', 'from = agentId')
83
- assert(e.id, 'has id')
84
- assert(e.ts, 'has ts')
85
- assert(e.trace?.trace_id && e.trace?.span_id, 'has trace context')
86
- assert(e.payload, 'has payload')
87
- })
101
+ // ─── dispatch: skip JetStream ack, wait for real respond ─────────────────────
88
102
 
89
- // ─── register wiring ────────────────────────────────────────────────────────
90
-
91
- test('register wires 12 tools, 1 trigger, 1 panel (full-duplex)', () => {
92
- const conn = fakeConn()
93
- const { tools, triggers, panels, ctx } = mkCtx({}, conn)
94
- register(ctx)
95
- eq(tools.size, 12, 'tool count')
96
- for (const n of ['synapse_health', 'synapse_discover', 'synapse_dispatch', 'synapse_register', 'synapse_agents_summary', 'synapse_serve', 'synapse_serve_status', 'synapse_emit', 'synapse_subscribe', 'synapse_reputation', 'synapse_request_approval', 'synapse_approve']) assert(tools.has(n), `missing ${n}`)
97
- eq(triggers.length, 1, 'trigger count')
98
- eq(triggers[0].name, 'synapse_mesh_event', 'trigger name')
99
- eq(panels.length, 1, 'panel count')
100
- })
101
-
102
- // ─── discover ───────────────────────────────────────────────────────────────
103
-
104
- test('synapse_discover returns agents from registry', async () => {
105
- __setConnForTest(null)
106
- const conn = fakeConn()
107
- conn._on('mesh.registry.discover', () => [
108
- { id: 'grip-cli-001', name: 'Grip CLI', skills: [{ id: 'himalaya' }] },
109
- { id: 'agentspan-001', name: 'Agentspan', skills: [{ id: 'status' }] },
110
- ])
111
- const { tools, ctx } = mkCtx({}, conn)
112
- register(ctx)
113
- const r = await tools.get('synapse_discover').handler({})
114
- eq(r.count, 2, 'agent count')
115
- eq(r.agents[0].id, 'grip-cli-001', 'first agent')
116
- })
117
-
118
- test('synapse_discover passes filter through to the envelope', async () => {
119
- __setConnForTest(null)
120
- const conn = fakeConn()
121
- let captured
122
- conn._on('mesh.registry.discover', (env) => { captured = env; return [] })
123
- const { tools, ctx } = mkCtx({}, conn)
124
- register(ctx)
125
- await tools.get('synapse_discover').handler({ capabilities: ['chat'], availability: 'online' })
126
- eq(captured.type, 'discover', 'envelope type')
127
- eq(captured.payload.availability, 'online', 'filter availability')
128
- assert(Array.isArray(captured.payload.capabilities), 'filter capabilities')
129
- })
130
-
131
- // ─── dispatch ───────────────────────────────────────────────────────────────
132
-
133
- test('synapse_dispatch sends request to agent inbox + returns response', async () => {
103
+ test('dispatchTask skips JetStream ack and returns the real respond envelope', async () => {
134
104
  __setConnForTest(null)
135
105
  const conn = fakeConn()
136
- let captured
137
- conn._on('mesh.agent.grip-001.inbox', (env) => { captured = env; return { stream: 'AGENT_INBOXES', seq: 42 } })
138
- const { tools, ctx } = mkCtx({}, conn)
139
- register(ctx)
140
- const r = await tools.get('synapse_dispatch').handler({ target: 'grip-001', skill: 'respond', input: { text: 'hi' } })
141
- eq(r.response.seq, 42, 'response seq')
142
- eq(captured.type, 'request', 'envelope type')
143
- eq(captured.to, 'grip-001', 'envelope to')
144
- eq(captured.payload.skill, 'respond', 'skill')
145
- eq(captured.payload.input.text, 'hi', 'input')
146
- })
147
-
148
- test('synapse_dispatch requires target + skill', async () => {
149
- const conn = fakeConn()
150
- const { tools, ctx } = mkCtx({}, conn)
151
- register(ctx)
152
- const r = await tools.get('synapse_dispatch').handler({})
153
- assert(r.error, 'expected error for missing target/skill')
154
- })
155
-
156
- // ─── register self ──────────────────────────────────────────────────────────
157
-
158
- test('synapse_register publishes a register envelope to the registry', async () => {
159
- __setConnForTest(null)
160
- const conn = fakeConn()
161
- const { tools, ctx } = mkCtx({ agentId: 'rterm-001' }, conn)
162
- register(ctx)
163
- const r = await tools.get('synapse_register').handler({ name: 'RTerm', capabilities: ['ops'] })
164
- eq(r.registered, 'rterm-001', 'registered id')
165
- const pub = conn.published.find((p) => p.subject === 'mesh.registry.register')
166
- assert(pub, 'expected a register publish')
167
- eq(pub.data.type, 'register', 'envelope type')
168
- eq(pub.data.payload.agent_id, 'rterm-001', 'payload agent_id')
169
- assert(pub.data.payload.endpoint.includes('rterm-001.inbox'), 'endpoint inbox')
170
- })
171
-
172
- // ─── trigger match ──────────────────────────────────────────────────────────
173
-
174
- test('synapse_mesh_event trigger matches only synapse-source events', () => {
175
- const conn = fakeConn()
176
- const { triggers, ctx } = mkCtx({}, conn)
177
- register(ctx)
178
- const t = triggers[0]
179
- assert(t.match({ source: 'synapse' }), 'matches synapse source')
180
- assert(!t.match({ source: 'other' }), 'rejects other source')
181
- assert(!t.match({}), 'rejects empty')
182
- })
183
-
184
- // ─── connection cache (config-keyed, the stale-connection bug fix) ──────────
185
-
186
- test('connection is keyed by config — a settings change opens a NEW connection (no stale reuse)', async () => {
187
- __setConnForTest(null)
188
- const connA = fakeConn()
189
- const connB = fakeConn()
190
- const connsMade = []
191
- // ctx whose natsConnect returns a different fake per call, tracking which config connected
192
- const mkCtxMulti = (settings) => ({
193
- settings: { synapse: settings },
194
- natsConnect: async (copts) => { connsMade.push(copts); return connsMade.length === 1 ? connA : connB },
106
+ const ctx = {
107
+ settings: { synapse: { url: 'nats://fake:4222', agentId: 'rterm-001', prefix: 'mesh', dispatchTimeout: 5000 } },
108
+ natsConnect: async () => conn,
195
109
  registerTool: () => {}, registerTrigger: () => {}, registerPanel: () => {}, log: () => {},
196
- })
197
- // connect with config A (server A)
198
- const { discoverAgents: dA } = await import('./index.mjs')
199
- connA._on('mesh.registry.discover', () => [])
200
- await dA(mkCtxMulti({ url: 'nats://a:4222' }), {})
201
- if (connsMade.length !== 1) throw new Error(`expected 1 connection for config A, got ${connsMade.length}`)
202
- // same config A again must REUSE (no new connection)
203
- await dA(mkCtxMulti({ url: 'nats://a:4222' }), {})
204
- if (connsMade.length !== 1) throw new Error(`expected reuse for same config A, got ${connsMade.length} connections`)
205
- // config B (different server) must open a NEW connection (the bug was reusing A's)
206
- connB._on('mesh.registry.discover', () => [])
207
- await dA(mkCtxMulti({ url: 'nats://b:4222' }), {})
208
- if (connsMade.length !== 2) throw new Error(`expected a NEW connection for config B, got ${connsMade.length}`)
110
+ }
111
+ // Simulate the agent responding after the JetStream ack
112
+ // The reply subject is _INBOX.synapse.* we need to deliver the respond there
113
+ // The JetStream ack is auto-delivered by fakeConn.publish (opts.reply)
114
+ // We need to manually deliver the real respond to the reply subject
115
+ setTimeout(() => {
116
+ // Find the reply subject from published messages
117
+ const pub = conn.published.find(p => p.opts?.reply && p.subject === 'mesh.agent.grip-001.inbox')
118
+ if (pub) {
119
+ const respond = { v: '0.3.0', id: 'r1', type: 'respond', from: 'grip-001', to: 'rterm-001', payload: { output: { ok: true, incidents: [] } } }
120
+ conn._deliver(pub.opts.reply, respond)
121
+ }
122
+ }, 50)
123
+
124
+ const result = await dispatchTask(ctx, 'grip-001', 'status', {})
125
+ eq(result.type, 'respond', 'should be a respond envelope')
126
+ eq(result.from, 'grip-001', 'from the target agent')
127
+ assert(result.payload?.output?.ok === true, 'output present')
209
128
  })
210
129
 
211
- test('a failed connect is not cached the next call retries', async () => {
130
+ test('dispatchTask returns clear error on timeout (no respond)', async () => {
212
131
  __setConnForTest(null)
213
132
  const conn = fakeConn()
214
- conn._on('mesh.registry.discover', () => [])
215
- let attempts = 0
216
133
  const ctx = {
217
- settings: { synapse: { url: 'nats://a:4222' } },
218
- natsConnect: async () => { attempts++; if (attempts === 1) throw new Error('down'); return conn },
134
+ settings: { synapse: { url: 'nats://fake:4222', agentId: 'rterm-001', prefix: 'mesh', dispatchTimeout: 500 } },
135
+ natsConnect: async () => conn,
219
136
  registerTool: () => {}, registerTrigger: () => {}, registerPanel: () => {}, log: () => {},
220
137
  }
221
- const { discoverAgents } = await import('./index.mjs')
222
- let threw = false
223
- try { await discoverAgents(ctx, {}) } catch { threw = true }
224
- if (!threw) throw new Error('expected first attempt to throw')
225
- await discoverAgents(ctx, {}) // retry succeeds
226
- if (attempts !== 2) throw new Error(`expected 2 attempts (fail + retry), got ${attempts}`)
138
+ // No agent responds should timeout
139
+ const result = await dispatchTask(ctx, 'no-such-agent', 'status', {})
140
+ assert(result.error, 'should have error on timeout')
141
+ assert(result.error.includes('did not respond'), 'error message mentions timeout')
142
+ assert(result.error.includes('synapse_serve_status'), 'error hints at serve_status')
227
143
  })
228
144
 
229
- // ─── auto-start responder on boot (serveSkills + autoServe) ─────────────────
230
-
231
- test('autoServe: register() auto-starts the responder when enabled+autoServe (default skills)', async () => {
145
+ test('dispatchTask: JetStream ack is skipped, not returned as result', async () => {
232
146
  __setConnForTest(null)
233
147
  const conn = fakeConn()
234
- conn._on('mesh.registry.discover', () => [])
235
- const { tools, logs, ctx } = mkCtx({}, conn)
236
- register(ctx)
237
- // auto-start is async (serveSkills connectMesh startResponder) wait for it to settle
238
- await new Promise((r) => setTimeout(r, 100))
239
- const r = await tools.get('synapse_serve_status').handler({})
240
- eq(r.serving, true, 'responder auto-started (serving=true)')
241
- assert(r.skills.includes('status'), 'default skills include status')
242
- assert(r.skills.includes('discover'), 'default skills include discover')
243
- eq(r.autoServe, true, 'autoServe true by default')
148
+ const ctx = {
149
+ settings: { synapse: { url: 'nats://fake:4222', agentId: 'rterm-001', prefix: 'mesh', dispatchTimeout: 2000 } },
150
+ natsConnect: async () => conn,
151
+ registerTool: () => {}, registerTrigger: () => {}, registerPanel: () => {}, log: () => {},
152
+ }
153
+ // The fakeConn auto-delivers a JetStream ack on the reply subject.
154
+ // Then we deliver the real respond after 50ms.
155
+ setTimeout(() => {
156
+ const pub = conn.published.find(p => p.opts?.reply && p.subject === 'mesh.agent.test-001.inbox')
157
+ if (pub) {
158
+ conn._deliver(pub.opts.reply, { type: 'respond', from: 'test-001', payload: { output: { data: 42 } } })
159
+ }
160
+ }, 50)
161
+
162
+ const result = await dispatchTask(ctx, 'test-001', 'compute', { n: 42 })
163
+ // Must NOT be the JetStream ack
164
+ assert(!result.stream, 'must not be a JetStream ack')
165
+ eq(result.type, 'respond', 'must be a respond envelope')
166
+ eq(result.payload.output.data, 42, 'output data correct')
244
167
  })
245
168
 
246
- test('autoServe: disabled when synapse.autoServe=false (no auto-start)', async () => {
247
- __setConnForTest(null)
248
- const conn = fakeConn()
249
- const { tools, ctx } = mkCtx({ autoServe: false }, conn)
250
- register(ctx)
251
- await new Promise((r) => setTimeout(r, 20))
252
- const r = await tools.get('synapse_serve_status').handler({})
253
- eq(r.serving, false, 'responder NOT auto-started when autoServe=false')
254
- })
169
+ // ─── multi-mesh discover ─────────────────────────────────────────────────────
255
170
 
256
- test('autoServe: a failed auto-start does not break register (best-effort)', async () => {
171
+ test('discoverAgents merges results from multiple meshes + tags _mesh', async () => {
257
172
  __setConnForTest(null)
258
- let failConnect = true
259
- const conn = fakeConn()
173
+ const connA = fakeConn()
174
+ const connB = fakeConn()
175
+ connA._on('mesh.registry.discover', () => [{ id: 'agent-a1', name: 'A1' }])
176
+ connB._on('mesh.registry.discover', () => [{ id: 'agent-b1', name: 'B1' }])
177
+ let which = 0
260
178
  const ctx = {
261
- settings: { synapse: { url: 'nats://down:4222' } },
262
- natsConnect: async () => { if (failConnect) throw new Error('server down'); return conn },
179
+ settings: { synapse: { meshes: [
180
+ { name: 'mesh-a', url: 'nats://a:4222' },
181
+ { name: 'mesh-b', url: 'nats://b:4222' },
182
+ ]}},
183
+ natsConnect: async () => { which++; return which === 1 ? connA : connB },
263
184
  registerTool: () => {}, registerTrigger: () => {}, registerPanel: () => {}, log: () => {},
264
185
  }
265
- // register() must not throw even though the auto-start connection fails
266
- const { register } = await import('./index.mjs')
267
- let threw = false
268
- try { register(ctx) } catch { threw = true }
269
- assert(!threw, 'register() must not throw on auto-serve connection failure')
270
- await new Promise((r) => setTimeout(r, 20)) // let the auto-start promise reject (handled)
186
+ const agents = await discoverAgents(ctx, {})
187
+ eq(agents.length, 2, 'merged from both meshes')
188
+ const a1 = agents.find(a => a.id === 'agent-a1')
189
+ const b1 = agents.find(a => a.id === 'agent-b1')
190
+ assert(a1 && a1._mesh === 'mesh-a', 'agent-a1 tagged with mesh-a')
191
+ assert(b1 && b1._mesh === 'mesh-b', 'agent-b1 tagged with mesh-b')
271
192
  })
272
193
 
273
194
  // ─── runner ─────────────────────────────────────────────────────────────────