@polderlabs/bizar 10.5.0 → 10.6.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.
@@ -0,0 +1,312 @@
1
+ /**
2
+ * tests/loop-runtime.test.mjs
3
+ *
4
+ * Self-check for loop-runtime.mjs (G-autoloop Phase 2).
5
+ * In-memory tasks + injected dispatch — no real bg instances.
6
+ */
7
+
8
+ import { test, before, after, beforeEach } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import { mkdtempSync, rmSync, existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+
14
+ const TMP = mkdtempSync(join(tmpdir(), 'bizar-loops-'));
15
+ process.env.BIZAR_LOOPS_ROOT = TMP;
16
+
17
+ const {
18
+ LOOPS_ROOT,
19
+ genLoopId,
20
+ stateFile,
21
+ createLoop,
22
+ readLoop,
23
+ patchLoop,
24
+ deleteLoop,
25
+ listLoops,
26
+ snapshotLoops,
27
+ pickReadyTasks,
28
+ runTick,
29
+ startLoop,
30
+ stopLoop,
31
+ _dropInProcessTimers,
32
+ } = await import('../src/server/loop-runtime.mjs');
33
+
34
+ after(() => {
35
+ _dropInProcessTimers();
36
+ try { rmSync(TMP, { recursive: true, force: true }); } catch { /* */ }
37
+ });
38
+
39
+ // ---- CRUD -----------------------------------------------------------------
40
+
41
+ test('createLoop: requires projectId, writes state.json, returns state', () => {
42
+ assert.throws(() => createLoop({}), TypeError);
43
+ const s = createLoop({ projectId: 'p1', name: 'nightly', intervalMs: 5000 });
44
+ assert.ok(s.loopId.startsWith('loop_'));
45
+ assert.equal(s.projectId, 'p1');
46
+ assert.equal(s.name, 'nightly');
47
+ assert.equal(s.intervalMs, 5000);
48
+ assert.equal(s.status, 'pending');
49
+ assert.equal(s.iteration, 0);
50
+ assert.ok(existsSync(stateFile(s.loopId)));
51
+ });
52
+
53
+ test('createLoop: defaults intervalMs to 30000 and maxIterations to Infinity', () => {
54
+ const s = createLoop({ projectId: 'p1' });
55
+ assert.equal(s.intervalMs, 30000);
56
+ assert.equal(s.maxIterations, Infinity);
57
+ });
58
+
59
+ test('readLoop: returns null for unknown id, state for known', () => {
60
+ assert.equal(readLoop('nope'), null);
61
+ const s = createLoop({ projectId: 'p1' });
62
+ const back = readLoop(s.loopId);
63
+ assert.equal(back.loopId, s.loopId);
64
+ assert.equal(back.projectId, 'p1');
65
+ });
66
+
67
+ test('patchLoop: merges fields, updates updatedAt, rejects unknown', () => {
68
+ const s = createLoop({ projectId: 'p1' });
69
+ const oldUpdatedAt = s.updatedAt;
70
+ // Force monotonic separation in the timestamp
71
+ const waited = Date.now() + 2;
72
+ while (Date.now() < waited) { /* */ }
73
+ const next = patchLoop(s.loopId, { status: 'running', lastTickAt: new Date().toISOString() });
74
+ assert.ok(next);
75
+ assert.equal(next.status, 'running');
76
+ assert.notEqual(next.updatedAt, oldUpdatedAt);
77
+ assert.equal(patchLoop('ghost', { status: 'stopped' }), null);
78
+ });
79
+
80
+ test('deleteLoop: removes the directory and is idempotent', () => {
81
+ const s = createLoop({ projectId: 'p1' });
82
+ assert.ok(existsSync(stateFile(s.loopId)));
83
+ deleteLoop(s.loopId);
84
+ assert.equal(existsSync(stateFile(s.loopId)), false);
85
+ deleteLoop(s.loopId); // no throw
86
+ });
87
+
88
+ test('listLoops + snapshotLoops: enumerate known dirs only', () => {
89
+ const a = createLoop({ projectId: 'p1' });
90
+ const b = createLoop({ projectId: 'p1' });
91
+ const list = listLoops();
92
+ assert.ok(list.includes(a.loopId));
93
+ assert.ok(list.includes(b.loopId));
94
+ const snap = snapshotLoops();
95
+ assert.ok(snap[a.loopId]);
96
+ assert.ok(snap[b.loopId]);
97
+ });
98
+
99
+ test('listLoops: tolerates a stray file at the root (no dir)', () => {
100
+ createLoop({ projectId: 'p1' });
101
+ mkdirSync(LOOPS_ROOT, { recursive: true });
102
+ // Stray non-dir entry should be skipped, not crash.
103
+ writeFileSync(join(LOOPS_ROOT, 'stray.txt'), 'x');
104
+ const list = listLoops();
105
+ assert.ok(!list.includes('stray.txt'));
106
+ });
107
+
108
+ // ---- pickReadyTasks -------------------------------------------------------
109
+
110
+ test('pickReadyTasks: filters status + archived + unmet deps + sorts by priority then age', () => {
111
+ const t = (id, o = {}) => ({
112
+ id,
113
+ status: o.status || 'queued',
114
+ priority: o.priority ?? 5,
115
+ createdAt: o.createdAt || new Date().toISOString(),
116
+ archived: !!o.archived,
117
+ dependencies: o.dependencies || [],
118
+ });
119
+ const tasks = [
120
+ t('a', { status: 'doing' }),
121
+ t('b', { status: 'queued', archived: true }),
122
+ t('c', { status: 'queued', priority: 2 }),
123
+ t('d', { status: 'queued', priority: 2, createdAt: '2026-01-01T00:00:00Z' }),
124
+ t('e', { status: 'queued', dependencies: ['x'] }), // dep not in set
125
+ t('f', { status: 'queued', dependencies: ['c'] }), // c will be queued (not done) — still blocks
126
+ t('g', { status: 'queued', dependencies: ['a'] }), // a is 'doing' — still blocks
127
+ t('h', { status: 'queued', priority: 1 }),
128
+ ];
129
+ const ready = pickReadyTasks(tasks).map((r) => r.id);
130
+ // c, d are priority 2 and have no deps; h is priority 1 and has no deps.
131
+ // Expected order: h (pri 1), then by age among c/d at pri 2.
132
+ assert.deepEqual(ready, ['h', 'd', 'c']);
133
+ });
134
+
135
+ test('pickReadyTasks: dep that is "done" no longer blocks', () => {
136
+ const t = (id, o = {}) => ({ id, status: o.status || 'queued', dependencies: o.dependencies || [], archived: !!o.archived });
137
+ const tasks = [
138
+ t('dep', { status: 'done' }),
139
+ t('child', { status: 'queued', dependencies: ['dep'] }),
140
+ ];
141
+ const ids = pickReadyTasks(tasks).map((r) => r.id);
142
+ assert.deepEqual(ids, ['child']);
143
+ });
144
+
145
+ test('pickReadyTasks: empty or non-array input returns empty', () => {
146
+ assert.deepEqual(pickReadyTasks([]), []);
147
+ assert.deepEqual(pickReadyTasks(null), []);
148
+ assert.deepEqual(pickReadyTasks(undefined), []);
149
+ });
150
+
151
+ // ---- runTick --------------------------------------------------------------
152
+
153
+ function fixture(extra) {
154
+ const tasks = extra || [
155
+ { id: 't1', status: 'queued', priority: 1, createdAt: new Date().toISOString() },
156
+ { id: 't2', status: 'doing' },
157
+ { id: 't3', status: 'queued', priority: 3, createdAt: new Date(Date.now() - 1000).toISOString() },
158
+ ];
159
+ let claimed = null;
160
+ const dispatched = [];
161
+ return {
162
+ tasks,
163
+ getTasks: () => tasks.slice(),
164
+ claimTask: (id) => {
165
+ const t = tasks.find((x) => x.id === id);
166
+ if (!t) return null;
167
+ t.status = 'doing';
168
+ t.assignee = 'tyr';
169
+ claimed = id;
170
+ return t;
171
+ },
172
+ dispatch: async (task) => {
173
+ dispatched.push(task.id);
174
+ return { ok: true, bgInstanceId: 'bg_' + task.id };
175
+ },
176
+ getClaimed: () => claimed,
177
+ getDispatched: () => dispatched,
178
+ };
179
+ }
180
+
181
+ test('runTick: picks highest-priority queued, claims, dispatches', async () => {
182
+ const f = fixture();
183
+ const result = await runTick(f);
184
+ assert.equal(result.picked, 't1');
185
+ assert.equal(result.dispatched.taskId, 't1');
186
+ assert.equal(result.dispatched.bgInstanceId, 'bg_t1');
187
+ assert.equal(result.iteration, 1);
188
+ assert.equal(result.lastError, null);
189
+ assert.equal(f.getClaimed(), 't1');
190
+ assert.deepEqual(f.getDispatched(), ['t1']);
191
+ });
192
+
193
+ test('runTick: returns null dispatch when no ready tasks', async () => {
194
+ const f = fixture([
195
+ { id: 'x', status: 'doing' },
196
+ { id: 'y', status: 'done' },
197
+ ]);
198
+ const result = await runTick(f);
199
+ assert.equal(result.picked, null);
200
+ assert.equal(result.dispatched, null);
201
+ assert.equal(result.lastError, null);
202
+ });
203
+
204
+ test('runTick: dispatch failure surfaces lastError', async () => {
205
+ const f = fixture();
206
+ f.dispatch = async () => ({ ok: false, error: 'sdk offline' });
207
+ const result = await runTick(f);
208
+ assert.equal(result.picked, 't1');
209
+ assert.equal(result.dispatched, null);
210
+ assert.equal(result.lastError, 'sdk offline');
211
+ });
212
+
213
+ test('runTick: dispatch throwing is caught and converted to lastError', async () => {
214
+ const f = fixture();
215
+ f.dispatch = async () => { throw new Error('boom'); };
216
+ const result = await runTick(f);
217
+ assert.equal(result.picked, 't1');
218
+ assert.equal(result.lastError, 'boom');
219
+ });
220
+
221
+ test('runTick: rejects missing functions', async () => {
222
+ await assert.rejects(() => runTick({}), TypeError);
223
+ await assert.rejects(() => runTick({ getTasks: () => [] }), TypeError);
224
+ await assert.rejects(() => runTick({ getTasks: () => [], claimTask: () => null }), TypeError);
225
+ });
226
+
227
+ // ---- startLoop / stopLoop (in-process scheduler) --------------------------
228
+
229
+ test('startLoop: marks running, fires onTick, persists state, then stopLoop cancels', async () => {
230
+ const s = createLoop({ projectId: 'p1', intervalMs: 50, maxIterations: 5 });
231
+ const f = fixture();
232
+ const ticks = [];
233
+ startLoop(s.loopId, {
234
+ getTasks: f.getTasks,
235
+ claimTask: f.claimTask,
236
+ dispatch: f.dispatch,
237
+ onTick: (result, state) => ticks.push({ result, state }),
238
+ });
239
+ // Wait for at least one tick.
240
+ await new Promise((r) => setTimeout(r, 120));
241
+ stopLoop(s.loopId);
242
+ assert.ok(ticks.length >= 1);
243
+ const last = readLoop(s.loopId);
244
+ assert.equal(last.status, 'stopped');
245
+ assert.ok(last.iteration >= 1);
246
+ assert.ok(Array.isArray(last.dispatched));
247
+ assert.ok(last.dispatched[0].taskId);
248
+ });
249
+
250
+ test('startLoop: second start is a no-op (timer not duplicated)', () => {
251
+ const s = createLoop({ projectId: 'p1', intervalMs: 1000000 });
252
+ startLoop(s.loopId, { getTasks: () => [], claimTask: () => null, dispatch: async () => ({ ok: true }) });
253
+ const firstRead = readLoop(s.loopId);
254
+ startLoop(s.loopId, { getTasks: () => [], claimTask: () => null, dispatch: async () => ({ ok: true }) });
255
+ const secondRead = readLoop(s.loopId);
256
+ assert.equal(firstRead.status, 'running');
257
+ assert.equal(secondRead.status, 'running');
258
+ stopLoop(s.loopId);
259
+ });
260
+
261
+ test('startLoop: error from getTasks flips status to error and calls onError', async () => {
262
+ const s = createLoop({ projectId: 'p1', intervalMs: 40 });
263
+ const errors = [];
264
+ startLoop(s.loopId, {
265
+ getTasks: () => { throw new Error('disk full'); },
266
+ claimTask: () => null,
267
+ dispatch: async () => ({ ok: true }),
268
+ onError: (err, id) => errors.push({ err: err.message, id }),
269
+ });
270
+ await new Promise((r) => setTimeout(r, 100));
271
+ stopLoop(s.loopId);
272
+ const last = readLoop(s.loopId);
273
+ assert.equal(last.status, 'error');
274
+ assert.equal(last.lastError, 'disk full');
275
+ // Interval is 40ms; with a 100ms wait we expect 2-3 error firings.
276
+ assert.ok(errors.length >= 1, `expected at least 1 error, got ${errors.length}`);
277
+ assert.equal(errors[0].err, 'disk full');
278
+ });
279
+
280
+ test('startLoop: respects maxIterations and stops itself', async () => {
281
+ // Each runTick here always succeeds and advances iteration by 1.
282
+ const s = createLoop({ projectId: 'p1', intervalMs: 30, maxIterations: 2 });
283
+ const f = fixture();
284
+ startLoop(s.loopId, {
285
+ getTasks: f.getTasks,
286
+ claimTask: f.claimTask,
287
+ dispatch: f.dispatch,
288
+ });
289
+ // Wait long enough for a couple of ticks.
290
+ await new Promise((r) => setTimeout(r, 200));
291
+ const last = readLoop(s.loopId);
292
+ assert.equal(last.status, 'stopped');
293
+ assert.ok(last.iteration >= 2);
294
+ });
295
+
296
+ test('stopLoop: idempotent on already-stopped loops', () => {
297
+ const s = createLoop({ projectId: 'p1' });
298
+ patchLoop(s.loopId, { status: 'stopped' });
299
+ const out = stopLoop(s.loopId);
300
+ assert.equal(out.status, 'stopped');
301
+ });
302
+
303
+ test('_dropInProcessTimers: clears every active timer', () => {
304
+ const s = createLoop({ projectId: 'p1', intervalMs: 1000000 });
305
+ startLoop(s.loopId, { getTasks: () => [], claimTask: () => null, dispatch: async () => ({ ok: true }) });
306
+ _dropInProcessTimers();
307
+ // Should be a clean no-op now.
308
+ _dropInProcessTimers();
309
+ // State shouldn't be auto-mutated by dropInProcessTimers.
310
+ const last = readLoop(s.loopId);
311
+ assert.equal(last.status, 'running');
312
+ });
@@ -0,0 +1,293 @@
1
+ /**
2
+ * tests/loops-agent-bus-routes.test.mjs
3
+ *
4
+ * Integration check for /api/loops and /api/agent-bus routers (Phase 5).
5
+ * Pure HTTP — Express + supertest-free; we wrap the router and fire
6
+ * fetch via the running process is overkill. Just call the router
7
+ * with a fake req/res pair. Both routers mount under /api/<path>.
8
+ */
9
+
10
+ import { test, before, after, beforeEach } from 'node:test';
11
+ import assert from 'node:assert/strict';
12
+ import { mkdtempSync, rmSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { join } from 'node:path';
15
+
16
+ // Point both modules at the same temp dir BEFORE importing them.
17
+ const TMP = mkdtempSync(join(tmpdir(), 'bizar-routes-'));
18
+ process.env.BIZAR_LOOPS_ROOT = TMP;
19
+ process.env.BIZAR_AGENT_BUS_ROOT = TMP;
20
+
21
+ const { createLoopsRouter } = await import('../src/server/routes/loops.mjs');
22
+ const { createAgentBusRouter } = await import('../src/server/routes/agent-bus.mjs');
23
+ const { _dropInProcessTimers } = await import('../src/server/loop-runtime.mjs');
24
+ const { _resetForTests: busReset } = await import('../src/server/agent-bus.mjs');
25
+
26
+ after(() => {
27
+ _dropInProcessTimers();
28
+ try { rmSync(TMP, { recursive: true, force: true }); } catch { /* */ }
29
+ });
30
+
31
+ beforeEach(() => busReset());
32
+
33
+ // ---- fake req/res helpers ------------------------------------------------
34
+
35
+ function handler(router, method, path, body) {
36
+ return new Promise((resolve, reject) => {
37
+ const req = {
38
+ method,
39
+ url: path,
40
+ originalUrl: path,
41
+ params: extractParams(router, path),
42
+ body: body || {},
43
+ query: extractQuery(path),
44
+ };
45
+ const headers = {};
46
+ const res = {
47
+ statusCode: 200,
48
+ headers,
49
+ setHeader: (k, v) => { headers[k.toLowerCase()] = v; },
50
+ status(code) { this.statusCode = code; return this; },
51
+ json(payload) {
52
+ this.headers['content-type'] = 'application/json';
53
+ resolve({ status: this.statusCode, body: payload, headers });
54
+ },
55
+ end() { resolve({ status: this.statusCode, body: null, headers }); },
56
+ };
57
+ try {
58
+ router(req, res, (err) => {
59
+ if (err) reject(err);
60
+ else reject(new Error(`unhandled: ${method} ${path}`));
61
+ });
62
+ } catch (err) {
63
+ reject(err);
64
+ }
65
+ });
66
+ }
67
+
68
+ function extractParams(router, path) {
69
+ // Naive: scan router.stack for routes with the same path-pattern.
70
+ const stack = router.stack || [];
71
+ for (const layer of stack) {
72
+ if (!layer.route) continue;
73
+ const keys = [];
74
+ const re = layer.route.path.replace(/:([a-zA-Z_]+)/g, (_, k) => { keys.push(k); return '([^/]+)'; });
75
+ const m = new RegExp(`^${re}$`).exec(path);
76
+ if (m) {
77
+ const params = {};
78
+ keys.forEach((k, i) => { params[k] = decodeURIComponent(m[i + 1]); });
79
+ return params;
80
+ }
81
+ }
82
+ return {};
83
+ }
84
+
85
+ function extractQuery(path) {
86
+ const i = path.indexOf('?');
87
+ if (i < 0) return {};
88
+ const out = {};
89
+ for (const part of path.slice(i + 1).split('&')) {
90
+ const [k, v] = part.split('=');
91
+ if (k) out[decodeURIComponent(k)] = v == null ? '' : decodeURIComponent(v);
92
+ }
93
+ return out;
94
+ }
95
+
96
+ // ---- /api/loops ----------------------------------------------------------
97
+
98
+ test('loops: GET /loops returns empty registry', async () => {
99
+ const r = createLoopsRouter();
100
+ const res = await handler(r, 'GET', '/loops');
101
+ assert.equal(res.status, 200);
102
+ assert.deepEqual(res.body, { loops: [], count: 0 });
103
+ });
104
+
105
+ test('loops: POST /loops creates a pending loop', async () => {
106
+ const r = createLoopsRouter();
107
+ const res = await handler(r, 'POST', '/loops', { projectId: 'p1', name: 'nightly', intervalMs: 5000 });
108
+ assert.equal(res.status, 201);
109
+ assert.equal(res.body.projectId, 'p1');
110
+ assert.equal(res.body.name, 'nightly');
111
+ assert.equal(res.body.status, 'pending');
112
+ assert.equal(res.body.intervalMs, 5000);
113
+ });
114
+
115
+ test('loops: POST /loops rejects missing projectId with 400', async () => {
116
+ const r = createLoopsRouter();
117
+ const res = await handler(r, 'POST', '/loops', {});
118
+ assert.equal(res.status, 400);
119
+ assert.equal(res.body.error, 'bad_request');
120
+ });
121
+
122
+ test('loops: GET /loops/:id returns 404 for unknown', async () => {
123
+ const r = createLoopsRouter();
124
+ const res = await handler(r, 'GET', '/loops/loop_nope');
125
+ assert.equal(res.status, 404);
126
+ });
127
+
128
+ test('loops: GET /loops/:id returns the loop after POST', async () => {
129
+ const r = createLoopsRouter();
130
+ const created = await handler(r, 'POST', '/loops', { projectId: 'p1' });
131
+ const res = await handler(r, 'GET', `/loops/${created.body.loopId}`);
132
+ assert.equal(res.status, 200);
133
+ assert.equal(res.body.loopId, created.body.loopId);
134
+ });
135
+
136
+ test('loops: PATCH /loops/:id merges allowed fields', async () => {
137
+ const r = createLoopsRouter();
138
+ const created = await handler(r, 'POST', '/loops', { projectId: 'p1' });
139
+ const res = await handler(r, 'PATCH', `/loops/${created.body.loopId}`, {
140
+ name: 'renamed',
141
+ intervalMs: 1000,
142
+ illegalField: 'should not appear',
143
+ });
144
+ assert.equal(res.status, 200);
145
+ assert.equal(res.body.name, 'renamed');
146
+ assert.equal(res.body.intervalMs, 1000);
147
+ assert.equal(res.body.illegalField, undefined);
148
+ });
149
+
150
+ test('loops: POST /loops/:id/start flips to running and stop flips to stopped', async () => {
151
+ const r = createLoopsRouter();
152
+ const created = await handler(r, 'POST', '/loops', { projectId: 'p1', intervalMs: 60000 });
153
+ const start = await handler(r, 'POST', `/loops/${created.body.loopId}/start`, { dry: true });
154
+ assert.equal(start.status, 200);
155
+ assert.equal(start.body.status, 'running');
156
+ const stop = await handler(r, 'POST', `/loops/${created.body.loopId}/stop`, {});
157
+ assert.equal(stop.status, 200);
158
+ assert.equal(stop.body.status, 'stopped');
159
+ });
160
+
161
+ test('loops: DELETE /loops/:id removes the loop and is 404 on subsequent GET', async () => {
162
+ const r = createLoopsRouter();
163
+ const created = await handler(r, 'POST', '/loops', { projectId: 'p1' });
164
+ const del = await handler(r, 'DELETE', `/loops/${created.body.loopId}`, {});
165
+ assert.equal(del.status, 204);
166
+ const after = await handler(r, 'GET', `/loops/${created.body.loopId}`);
167
+ assert.equal(after.status, 404);
168
+ });
169
+
170
+ // ---- /api/agent-bus ------------------------------------------------------
171
+
172
+ test('agent-bus: GET /agent-bus/presence returns empty when nobody announced', async () => {
173
+ const r = createAgentBusRouter();
174
+ const res = await handler(r, 'GET', '/agent-bus/presence');
175
+ assert.equal(res.status, 200);
176
+ assert.deepEqual(res.body, { agents: {}, count: 0 });
177
+ });
178
+
179
+ test('agent-bus: POST /agent-bus/announce adds an agent to presence', async () => {
180
+ const r = createAgentBusRouter();
181
+ const res = await handler(r, 'POST', '/agent-bus/announce', {
182
+ name: 'odin', sessionId: 'sess_1', capabilities: ['plan'], model: 'opus',
183
+ });
184
+ assert.equal(res.status, 200);
185
+ const p = await handler(r, 'GET', '/agent-bus/presence');
186
+ assert.equal(p.body.agents.odin.sessionId, 'sess_1');
187
+ });
188
+
189
+ test('agent-bus: POST /agent-bus/announce rejects missing sessionId', async () => {
190
+ const r = createAgentBusRouter();
191
+ const res = await handler(r, 'POST', '/agent-bus/announce', { name: 'odin' });
192
+ assert.equal(res.status, 400);
193
+ });
194
+
195
+ test('agent-bus: DELETE /agent-bus/agents/:name removes from presence', async () => {
196
+ const r = createAgentBusRouter();
197
+ await handler(r, 'POST', '/agent-bus/announce', { name: 'odin', sessionId: 'sess_1' });
198
+ const del = await handler(r, 'DELETE', '/agent-bus/agents/odin', {});
199
+ assert.equal(del.status, 204);
200
+ const p = await handler(r, 'GET', '/agent-bus/presence');
201
+ assert.equal(p.body.agents.odin, undefined);
202
+ });
203
+
204
+ test('agent-bus: GET /agent-bus/channels enumerates *.jsonl files', async () => {
205
+ const r = createAgentBusRouter();
206
+ // Publish to two channels to create JSONL logs.
207
+ await handler(r, 'POST', '/agent-bus/channels/chanA/publish', {
208
+ from: 'agent://odin', to: 'agent://thor', kind: 'request', payload: {},
209
+ });
210
+ await handler(r, 'POST', '/agent-bus/channels/chanB/publish', {
211
+ from: 'agent://thor', to: 'agent://odin', kind: 'ack', payload: {},
212
+ });
213
+ const res = await handler(r, 'GET', '/agent-bus/channels', {});
214
+ assert.equal(res.status, 200);
215
+ const names = res.body.channels.map((c) => c.name).sort();
216
+ assert.deepEqual(names, ['chanA', 'chanB']);
217
+ });
218
+
219
+ test('agent-bus: GET /agent-bus/channels/:name returns recent messages', async () => {
220
+ const r = createAgentBusRouter();
221
+ await handler(r, 'POST', '/agent-bus/channels/chanR/publish', {
222
+ from: 'agent://odin', to: 'agent://thor', kind: 'request', payload: { q: 'hi' },
223
+ });
224
+ const res = await handler(r, 'GET', '/agent-bus/channels/chanR?limit=10', {});
225
+ assert.equal(res.status, 200);
226
+ assert.equal(res.body.count, 1);
227
+ assert.equal(res.body.messages[0].payload.q, 'hi');
228
+ });
229
+
230
+ test('agent-bus: POST /agent-bus/channels/:name/publish rejects bad addresses', async () => {
231
+ const r = createAgentBusRouter();
232
+ const res = await handler(r, 'POST', '/agent-bus/channels/x/publish', {
233
+ from: 'odin', to: 'agent://thor', kind: 'request', payload: {},
234
+ });
235
+ assert.equal(res.status, 400);
236
+ });
237
+
238
+ test('agent-bus: POST /agent-bus/channels/:name/publish rejects bad kind', async () => {
239
+ const r = createAgentBusRouter();
240
+ const res = await handler(r, 'POST', '/agent-bus/channels/x/publish', {
241
+ from: 'agent://odin', to: 'agent://thor', kind: 'bogus', payload: {},
242
+ });
243
+ assert.equal(res.status, 400);
244
+ });
245
+
246
+ test('agent-bus: POST /agent-bus/channels/:name/publish accepts valid request', async () => {
247
+ const r = createAgentBusRouter();
248
+ const res = await handler(r, 'POST', '/agent-bus/channels/chanP/publish', {
249
+ from: 'agent://odin', to: 'agent://thor', kind: 'request', payload: { a: 1 },
250
+ });
251
+ assert.equal(res.status, 202);
252
+ assert.ok(res.body.id);
253
+ });
254
+
255
+ test('agent-bus: steer/correct/handoff convenience endpoints', async () => {
256
+ const r = createAgentBusRouter();
257
+ const s = await handler(r, 'POST', '/agent-bus/steer', {
258
+ from: 'agent://odin', to: 'agent://thor', note: 'focus on tests',
259
+ });
260
+ assert.equal(s.status, 202);
261
+ const c = await handler(r, 'POST', '/agent-bus/correct', {
262
+ from: 'agent://odin', to: 'agent://thor', note: 'use new endpoint',
263
+ before: 'a()', after: 'b()',
264
+ });
265
+ assert.equal(c.status, 202);
266
+ const h = await handler(r, 'POST', '/agent-bus/handoff', {
267
+ from: 'agent://odin', to: 'agent://thor', taskId: 'tsk_1', reason: 'specialty',
268
+ });
269
+ assert.equal(h.status, 202);
270
+ });
271
+
272
+ test('agent-bus: steer rejects missing to/from', async () => {
273
+ const r = createAgentBusRouter();
274
+ const res = await handler(r, 'POST', '/agent-bus/steer', { from: 'agent://odin' });
275
+ assert.equal(res.status, 400);
276
+ });
277
+
278
+ test('agent-bus: handoff rejects missing taskId', async () => {
279
+ const r = createAgentBusRouter();
280
+ const res = await handler(r, 'POST', '/agent-bus/handoff', {
281
+ from: 'agent://odin', to: 'agent://thor',
282
+ });
283
+ assert.equal(res.status, 400);
284
+ });
285
+
286
+ test('agent-bus: publish broadcasts a message event', async () => {
287
+ const events = [];
288
+ const r = createAgentBusRouter({ broadcast: (e) => events.push(e) });
289
+ await handler(r, 'POST', '/agent-bus/channels/chanB/publish', {
290
+ from: 'agent://odin', to: 'agent://thor', kind: 'request', payload: {},
291
+ });
292
+ assert.ok(events.find((e) => e.type === 'agent-bus:message' && e.channel === 'chanB'));
293
+ });