@polderlabs/bizar 4.4.13 → 4.5.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.
Files changed (90) hide show
  1. package/bizar-dash/CHANGELOG.md +37 -276
  2. package/bizar-dash/dist/assets/main-CDFKHzBg.css +1 -0
  3. package/bizar-dash/dist/assets/main-NYFpS2wY.js +312 -0
  4. package/bizar-dash/dist/assets/main-NYFpS2wY.js.map +1 -0
  5. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js → mobile--0FBIKX3.js} +2 -2
  6. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js.map → mobile--0FBIKX3.js.map} +1 -1
  7. package/bizar-dash/dist/assets/mobile-OgRp8VIb.js +352 -0
  8. package/bizar-dash/dist/assets/mobile-OgRp8VIb.js.map +1 -0
  9. package/bizar-dash/dist/index.html +3 -3
  10. package/bizar-dash/dist/mobile.html +2 -2
  11. package/bizar-dash/skills/agent-baseline/SKILL.md +80 -0
  12. package/bizar-dash/skills/bizar/SKILL.md +96 -0
  13. package/bizar-dash/skills/chat/SKILL.md +74 -0
  14. package/bizar-dash/skills/lightrag/SKILL.md +75 -0
  15. package/bizar-dash/skills/minimax/SKILL.md +80 -0
  16. package/bizar-dash/skills/obsidian/SKILL.md +55 -0
  17. package/bizar-dash/skills/providers/SKILL.md +75 -0
  18. package/bizar-dash/skills/sdk/SKILL.md +138 -0
  19. package/bizar-dash/skills/self-improvement/SKILL.md +53 -0
  20. package/bizar-dash/skills/skills-cli/SKILL.md +94 -0
  21. package/bizar-dash/skills/usage/SKILL.md +62 -0
  22. package/bizar-dash/src/server/api.mjs +12 -0
  23. package/bizar-dash/src/server/memory-lightrag.mjs +5 -2
  24. package/bizar-dash/src/server/memory-store.mjs +38 -0
  25. package/bizar-dash/src/server/minimax-usage-store.mjs +372 -0
  26. package/bizar-dash/src/server/minimax.mjs +196 -5
  27. package/bizar-dash/src/server/providers-store.mjs +956 -0
  28. package/bizar-dash/src/server/routes/config.mjs +52 -1
  29. package/bizar-dash/src/server/routes/env-vars.mjs +165 -0
  30. package/bizar-dash/src/server/routes/lightrag.mjs +154 -0
  31. package/bizar-dash/src/server/routes/memory.mjs +241 -1
  32. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +14 -29
  33. package/bizar-dash/src/server/routes/opencode-sessions.mjs +205 -3
  34. package/bizar-dash/src/server/routes/providers.mjs +266 -5
  35. package/bizar-dash/src/server/routes/skills.mjs +32 -43
  36. package/bizar-dash/src/server/routes/update.mjs +340 -0
  37. package/bizar-dash/src/server/routes/usage.mjs +136 -0
  38. package/bizar-dash/src/server/serve-info.mjs +135 -4
  39. package/bizar-dash/src/server/server.mjs +4 -0
  40. package/bizar-dash/src/server/skills-store.mjs +152 -262
  41. package/bizar-dash/src/web/App.tsx +118 -29
  42. package/bizar-dash/src/web/components/EnvVarManager.tsx +247 -0
  43. package/bizar-dash/src/web/components/SettingsSearch.tsx +213 -0
  44. package/bizar-dash/src/web/components/Topbar.tsx +0 -1
  45. package/bizar-dash/src/web/components/UsageChart.tsx +250 -0
  46. package/bizar-dash/src/web/components/UsageTable.tsx +90 -0
  47. package/bizar-dash/src/web/components/chat/ChatComposer.tsx +21 -25
  48. package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +199 -37
  49. package/bizar-dash/src/web/components/chat/ChatThread.tsx +29 -17
  50. package/bizar-dash/src/web/components/chat/FloatingComposer.tsx +7 -1
  51. package/bizar-dash/src/web/components/chat/InfoPanel.tsx +71 -6
  52. package/bizar-dash/src/web/components/chat/useChat.ts +751 -257
  53. package/bizar-dash/src/web/lib/api.ts +43 -0
  54. package/bizar-dash/src/web/main.tsx +1 -0
  55. package/bizar-dash/src/web/mobile/views/MobileChat.tsx +110 -35
  56. package/bizar-dash/src/web/styles/chat.css +135 -1
  57. package/bizar-dash/src/web/styles/main.css +46 -0
  58. package/bizar-dash/src/web/styles/minimax-usage.css +335 -0
  59. package/bizar-dash/src/web/styles/settings.css +418 -0
  60. package/bizar-dash/src/web/styles/skills.css +302 -0
  61. package/bizar-dash/src/web/styles/tasks.css +288 -0
  62. package/bizar-dash/src/web/views/Chat.tsx +276 -48
  63. package/bizar-dash/src/web/views/Config.tsx +3 -2065
  64. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +476 -461
  65. package/bizar-dash/src/web/views/Settings.tsx +6 -0
  66. package/bizar-dash/src/web/views/Skills.tsx +208 -260
  67. package/bizar-dash/src/web/views/Tasks.tsx +348 -1119
  68. package/bizar-dash/tests/chat-session-create.test.mjs +391 -0
  69. package/bizar-dash/tests/chat-session-stream.test.mjs +308 -0
  70. package/bizar-dash/tests/env-vars-store.test.mjs +216 -0
  71. package/bizar-dash/tests/lightrag-defaults.node.test.mjs +118 -0
  72. package/bizar-dash/tests/minimax-chat-usage.test.mjs +178 -0
  73. package/bizar-dash/tests/minimax-usage-store.node.test.mjs +293 -0
  74. package/bizar-dash/tests/opencode-sessions-detail.test.mjs +12 -9
  75. package/bizar-dash/tests/providers-store-backup-keys.node.test.mjs +479 -3
  76. package/bizar-dash/tests/providers-store-search.node.test.mjs +166 -0
  77. package/bizar-dash/tests/skills-list.test.mjs +232 -0
  78. package/bizar-dash/tests/skills-search.test.mjs +222 -0
  79. package/bizar-dash/tests/tasks-create.test.mjs +187 -0
  80. package/bizar-dash/tests/update-check.test.mjs +127 -0
  81. package/bizar-dash/tests/update-run.test.mjs +266 -0
  82. package/cli/bin.mjs +82 -1
  83. package/cli/provision.mjs +118 -4
  84. package/config/agents/_shared/SKILLS.md +109 -0
  85. package/package.json +1 -1
  86. package/bizar-dash/dist/assets/main-BB5mJurD.js +0 -352
  87. package/bizar-dash/dist/assets/main-BB5mJurD.js.map +0 -1
  88. package/bizar-dash/dist/assets/main-BsnQLXdh.css +0 -1
  89. package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js +0 -354
  90. package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js.map +0 -1
@@ -0,0 +1,391 @@
1
+ /**
2
+ * tests/chat-session-create.test.mjs — v4.2.5
3
+ *
4
+ * Tests the session-mutation endpoints on the opencode-sessions router:
5
+ *
6
+ * POST /api/opencode-sessions/new
7
+ * PATCH /api/opencode-sessions/:id
8
+ * DELETE /api/opencode-sessions/:id
9
+ *
10
+ * Strategy: stand up a fake opencode serve child on a random port,
11
+ * point readServeInfo() at it by writing a tmp serve.json to
12
+ * ~/.cache/bizar/serve.json. The fake serves:
13
+ *
14
+ * POST /api/session?directory=... → returns { data: { id, ... } }
15
+ * PATCH /api/session/:id?directory=. → echoes the body as { id }
16
+ * DELETE /api/session/:id?directory=. → returns 200 or 404
17
+ *
18
+ * For "plugin offline" tests we move serve.json aside, then restore.
19
+ */
20
+
21
+ import { test, before, after, beforeEach } from 'node:test';
22
+ import assert from 'node:assert/strict';
23
+ import {
24
+ mkdtempSync,
25
+ writeFileSync,
26
+ rmSync,
27
+ existsSync,
28
+ copyFileSync,
29
+ unlinkSync,
30
+ readFileSync,
31
+ } from 'node:fs';
32
+ import { join } from 'node:path';
33
+ import { tmpdir } from 'node:os';
34
+ import { createServer } from 'node:http';
35
+ import express from 'express';
36
+
37
+ import { createOpencodeSessionsRouter } from '../src/server/routes/opencode-sessions.mjs';
38
+
39
+ // ── fake upstream (opencode serve child) ──────────────────────────────────
40
+
41
+ let upstreamServer, upstreamPort;
42
+ const upstream = {
43
+ createHits: [],
44
+ patchHits: [],
45
+ deleteHits: [],
46
+ nextCreateStatus: 200,
47
+ nextCreateId: null,
48
+ // Behavior knobs tests can flip:
49
+ failNextCreate: false,
50
+ };
51
+
52
+ function startUpstream() {
53
+ return new Promise((resolve) => {
54
+ upstreamServer = createServer((req, res) => {
55
+ const url = new URL(req.url, 'http://127.0.0.1');
56
+
57
+ // CREATE: POST /api/session?directory=...
58
+ if (
59
+ url.pathname === '/api/session' &&
60
+ req.method === 'POST'
61
+ ) {
62
+ let body = '';
63
+ req.on('data', (c) => {
64
+ body += c;
65
+ });
66
+ req.on('end', () => {
67
+ let parsed;
68
+ try { parsed = JSON.parse(body); } catch { parsed = {}; }
69
+ upstream.createHits.push({ url: req.url, body: parsed });
70
+ if (upstream.failNextCreate) {
71
+ upstream.failNextCreate = false;
72
+ res.writeHead(500, { 'Content-Type': 'application/json' });
73
+ res.end(JSON.stringify({ error: 'fake-upstream-failure' }));
74
+ return;
75
+ }
76
+ res.writeHead(upstream.nextCreateStatus, {
77
+ 'Content-Type': 'application/json',
78
+ });
79
+ const id =
80
+ upstream.nextCreateId ||
81
+ `sess-new-${upstream.createHits.length}`;
82
+ upstream.nextCreateId = null;
83
+ res.end(JSON.stringify({ data: { id, title: parsed.title, agent: parsed.agent } }));
84
+ });
85
+ return;
86
+ }
87
+
88
+ // RENAME: PATCH /api/session/:id?directory=...
89
+ const patchM = url.pathname.match(/^\/api\/session\/([^/]+)$/);
90
+ if (patchM && req.method === 'PATCH') {
91
+ let body = '';
92
+ req.on('data', (c) => {
93
+ body += c;
94
+ });
95
+ req.on('end', () => {
96
+ let parsed;
97
+ try { parsed = JSON.parse(body); } catch { parsed = {}; }
98
+ upstream.patchHits.push({ id: patchM[1], url: req.url, body: parsed });
99
+ if (patchM[1] === 'sess-missing') {
100
+ res.writeHead(404, { 'Content-Type': 'application/json' });
101
+ res.end(JSON.stringify({ error: 'not found' }));
102
+ return;
103
+ }
104
+ res.writeHead(200, { 'Content-Type': 'application/json' });
105
+ res.end(JSON.stringify({ id: patchM[1], title: parsed.title }));
106
+ });
107
+ return;
108
+ }
109
+
110
+ // DELETE: DELETE /api/session/:id?directory=...
111
+ const deleteM = url.pathname.match(/^\/api\/session\/([^/]+)$/);
112
+ if (deleteM && req.method === 'DELETE') {
113
+ upstream.deleteHits.push({ id: deleteM[1], url: req.url });
114
+ if (deleteM[1] === 'sess-gone') {
115
+ res.writeHead(404, { 'Content-Type': 'application/json' });
116
+ res.end(JSON.stringify({ error: 'gone' }));
117
+ return;
118
+ }
119
+ res.writeHead(200, { 'Content-Type': 'application/json' });
120
+ res.end(JSON.stringify({ id: deleteM[1] }));
121
+ return;
122
+ }
123
+
124
+ res.writeHead(404);
125
+ res.end('not found');
126
+ });
127
+ upstreamServer.listen(0, '127.0.0.1', () => {
128
+ upstreamPort = upstreamServer.address().port;
129
+ resolve();
130
+ });
131
+ });
132
+ }
133
+
134
+ async function startDashboard() {
135
+ const app = express();
136
+ app.use(express.json({ limit: '2mb' }));
137
+ app.use('/api', createOpencodeSessionsRouter());
138
+ dashboardServer = createServer(app);
139
+ await new Promise((r) => dashboardServer.listen(0, '127.0.0.1', r));
140
+ dashboardBaseUrl = `http://127.0.0.1:${dashboardServer.address().port}`;
141
+ }
142
+
143
+ let dashboardServer, dashboardBaseUrl, tmpDir;
144
+ let SERVE_JSON_PATH;
145
+ let originalServeJson;
146
+
147
+ before(async () => {
148
+ SERVE_JSON_PATH = `${tmpdir()}/bizar-test-serve-create-${process.pid}-${Date.now()}.json`;
149
+ process.env.BIZAR_SERVE_JSON_PATH = SERVE_JSON_PATH;
150
+ await startUpstream();
151
+ await startDashboard();
152
+ tmpDir = mkdtempSync(join(tmpdir(), 'opencode-sessions-create-'));
153
+ if (existsSync(SERVE_JSON_PATH)) {
154
+ originalServeJson = readFileSync(SERVE_JSON_PATH, 'utf8');
155
+ }
156
+ });
157
+
158
+ after(async () => {
159
+ if (originalServeJson !== undefined) {
160
+ writeFileSync(SERVE_JSON_PATH, originalServeJson, 'utf8');
161
+ } else if (existsSync(SERVE_JSON_PATH)) {
162
+ rmSync(SERVE_JSON_PATH);
163
+ }
164
+ if (dashboardServer) await new Promise((r) => dashboardServer.close(r));
165
+ if (upstreamServer) await new Promise((r) => upstreamServer.close(r));
166
+ if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
167
+ delete process.env.BIZAR_SERVE_JSON_PATH;
168
+ });
169
+
170
+ beforeEach(() => {
171
+ upstream.createHits = [];
172
+ upstream.patchHits = [];
173
+ upstream.deleteHits = [];
174
+ upstream.nextCreateStatus = 200;
175
+ upstream.nextCreateId = null;
176
+ upstream.failNextCreate = false;
177
+ });
178
+
179
+ function writeServeJson(worktree = '/tmp/new-session-worktree') {
180
+ writeFileSync(
181
+ SERVE_JSON_PATH,
182
+ JSON.stringify({
183
+ port: upstreamPort,
184
+ password: 'test-pw',
185
+ worktree,
186
+ pid: 99999,
187
+ startedAt: Date.now(),
188
+ }),
189
+ 'utf8',
190
+ );
191
+ }
192
+
193
+ /** Move serve.json aside so readServeInfo() returns null.
194
+ * Uses copy + unlink because rename across filesystems (e.g.
195
+ * /home/* → /tmp/*) throws EXDEV on Linux. */
196
+ function moveServeJsonAside() {
197
+ if (!existsSync(SERVE_JSON_PATH)) return;
198
+ const tmp = join(tmpDir, `serve-aside-${Date.now()}-${process.pid}.json`);
199
+ copyFileSync(SERVE_JSON_PATH, tmp);
200
+ unlinkSync(SERVE_JSON_PATH);
201
+ }
202
+
203
+ // ── tests ─────────────────────────────────────────────────────────────────
204
+
205
+ test('POST /api/opencode-sessions/new returns 201 with id+title+agent on happy path', async () => {
206
+ writeServeJson();
207
+ upstream.nextCreateId = 'sess-fresh-1';
208
+ const r = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/new`, {
209
+ method: 'POST',
210
+ headers: { 'Content-Type': 'application/json' },
211
+ body: JSON.stringify({ title: 'Hello world', agent: 'odin' }),
212
+ });
213
+ assert.equal(r.status, 201);
214
+ const body = await r.json();
215
+ assert.equal(body.id, 'sess-fresh-1');
216
+ assert.equal(body.title, 'Hello world');
217
+ assert.equal(body.agent, 'odin');
218
+ assert.ok(typeof body.directory === 'string' && body.directory.length > 0);
219
+ assert.ok(typeof body.createdAt === 'number' && body.createdAt > 0);
220
+ assert.equal(upstream.createHits.length, 1);
221
+ assert.equal(upstream.createHits[0].body.title, 'Hello world');
222
+ assert.equal(upstream.createHits[0].body.agent, 'odin');
223
+ });
224
+
225
+ test('POST /api/opencode-sessions/new returns 400 when body is missing', async () => {
226
+ writeServeJson();
227
+ const r = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/new`, {
228
+ method: 'POST',
229
+ headers: { 'Content-Type': 'application/json' },
230
+ });
231
+ assert.equal(r.status, 400);
232
+ const body = await r.json();
233
+ assert.match(body.message, /agent.*required/i);
234
+ });
235
+
236
+ test('POST /api/opencode-sessions/new returns 400 when agent missing', async () => {
237
+ writeServeJson();
238
+ const r = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/new`, {
239
+ method: 'POST',
240
+ headers: { 'Content-Type': 'application/json' },
241
+ body: JSON.stringify({ title: 'no-agent' }),
242
+ });
243
+ assert.equal(r.status, 400);
244
+ });
245
+
246
+ test('POST /api/opencode-sessions/new returns 400 when agent is invalid', async () => {
247
+ writeServeJson();
248
+ const r = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/new`, {
249
+ method: 'POST',
250
+ headers: { 'Content-Type': 'application/json' },
251
+ body: JSON.stringify({ agent: 'has space and !' }),
252
+ });
253
+ assert.equal(r.status, 400);
254
+ });
255
+
256
+ test('POST /api/opencode-sessions/new returns 502 when upstream 500s', async () => {
257
+ writeServeJson();
258
+ upstream.failNextCreate = true;
259
+ const r = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/new`, {
260
+ method: 'POST',
261
+ headers: { 'Content-Type': 'application/json' },
262
+ body: JSON.stringify({ agent: 'odin' }),
263
+ });
264
+ assert.equal(r.status, 502);
265
+ const body = await r.json();
266
+ assert.equal(body.error, 'opencode_error');
267
+ assert.ok(body.message.length > 0);
268
+ });
269
+
270
+ test('POST /api/opencode-sessions/new returns 503 plugin_offline when no serve.json', async () => {
271
+ moveServeJsonAside();
272
+ try {
273
+ const r = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/new`, {
274
+ method: 'POST',
275
+ headers: { 'Content-Type': 'application/json' },
276
+ body: JSON.stringify({ agent: 'odin' }),
277
+ });
278
+ assert.equal(r.status, 503);
279
+ assert.equal((await r.json()).error, 'plugin_offline');
280
+ } finally {
281
+ if (originalServeJson !== undefined)
282
+ writeFileSync(SERVE_JSON_PATH, originalServeJson, 'utf8');
283
+ else writeServeJson('/tmp/session-worktree');
284
+ }
285
+ });
286
+
287
+ test('POST /api/opencode-sessions/new defaults title to "Chat: <agent>"', async () => {
288
+ writeServeJson();
289
+ upstream.nextCreateId = 'sess-defaulted-1';
290
+ const r = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/new`, {
291
+ method: 'POST',
292
+ headers: { 'Content-Type': 'application/json' },
293
+ body: JSON.stringify({ agent: 'tyr' }),
294
+ });
295
+ assert.equal(r.status, 201);
296
+ const body = await r.json();
297
+ assert.equal(body.title, 'Chat: tyr');
298
+ assert.equal(upstream.createHits[0].body.title, 'Chat: tyr');
299
+ });
300
+
301
+ test('PATCH /api/opencode-sessions/:id renames the session (200)', async () => {
302
+ writeServeJson();
303
+ const r = await fetch(
304
+ `${dashboardBaseUrl}/api/opencode-sessions/sess-1`,
305
+ {
306
+ method: 'PATCH',
307
+ headers: { 'Content-Type': 'application/json' },
308
+ body: JSON.stringify({ title: 'New title' }),
309
+ },
310
+ );
311
+ assert.equal(r.status, 200);
312
+ const body = await r.json();
313
+ assert.equal(body.id, 'sess-1');
314
+ assert.equal(body.title, 'New title');
315
+ assert.equal(upstream.patchHits.length, 1);
316
+ assert.equal(upstream.patchHits[0].body.title, 'New title');
317
+ });
318
+
319
+ test('PATCH /api/opencode-sessions/:id returns 400 when title missing', async () => {
320
+ writeServeJson();
321
+ const r = await fetch(
322
+ `${dashboardBaseUrl}/api/opencode-sessions/sess-1`,
323
+ {
324
+ method: 'PATCH',
325
+ headers: { 'Content-Type': 'application/json' },
326
+ body: JSON.stringify({}),
327
+ },
328
+ );
329
+ assert.equal(r.status, 400);
330
+ });
331
+
332
+ test('PATCH /api/opencode-sessions/:id returns 400 when title is empty', async () => {
333
+ writeServeJson();
334
+ const r = await fetch(
335
+ `${dashboardBaseUrl}/api/opencode-sessions/sess-1`,
336
+ {
337
+ method: 'PATCH',
338
+ headers: { 'Content-Type': 'application/json' },
339
+ body: JSON.stringify({ title: ' ' }),
340
+ },
341
+ );
342
+ assert.equal(r.status, 400);
343
+ });
344
+
345
+ test('PATCH /api/opencode-sessions/:id returns 404 when upstream 404s', async () => {
346
+ writeServeJson();
347
+ const r = await fetch(
348
+ `${dashboardBaseUrl}/api/opencode-sessions/sess-missing`,
349
+ {
350
+ method: 'PATCH',
351
+ headers: { 'Content-Type': 'application/json' },
352
+ body: JSON.stringify({ title: 'whatever' }),
353
+ },
354
+ );
355
+ assert.equal(r.status, 404);
356
+ });
357
+
358
+ test('DELETE /api/opencode-sessions/:id returns 200 with { deleted: true }', async () => {
359
+ writeServeJson();
360
+ const r = await fetch(
361
+ `${dashboardBaseUrl}/api/opencode-sessions/sess-1`,
362
+ { method: 'DELETE' },
363
+ );
364
+ assert.equal(r.status, 200);
365
+ const body = await r.json();
366
+ assert.deepEqual(body, { id: 'sess-1', deleted: true });
367
+ assert.equal(upstream.deleteHits.length, 1);
368
+ });
369
+
370
+ test('DELETE /api/opencode-sessions/:id is idempotent (returns 200 even when upstream 404s)', async () => {
371
+ // Idempotent semantics: a "session already gone" response is the
372
+ // desired terminal state — we don't surface that to the client as
373
+ // an error.
374
+ writeServeJson();
375
+ const r = await fetch(
376
+ `${dashboardBaseUrl}/api/opencode-sessions/sess-gone`,
377
+ { method: 'DELETE' },
378
+ );
379
+ assert.equal(r.status, 200);
380
+ const body = await r.json();
381
+ assert.deepEqual(body, { id: 'sess-gone', deleted: true });
382
+ });
383
+
384
+ test('DELETE /api/opencode-sessions/:id returns 400 when id is invalid', async () => {
385
+ writeServeJson();
386
+ const r = await fetch(
387
+ `${dashboardBaseUrl}/api/opencode-sessions/has space`,
388
+ { method: 'DELETE' },
389
+ );
390
+ assert.equal(r.status, 400);
391
+ });
@@ -0,0 +1,308 @@
1
+ /**
2
+ * tests/chat-session-stream.test.mjs — v4.2.5
3
+ *
4
+ * End-to-end tests for the opencode SSE streaming endpoints. The
5
+ * opencode-session-detail router proxies the upstream
6
+ * `/event?directory=...` stream and forwards one canonical
7
+ * envelope per upstream event, filtered by `sessionID`.
8
+ *
9
+ * Tests cover three guarantees:
10
+ *
11
+ * 1. `message.updated` events for the right session are forwarded,
12
+ * and events for other sessions are dropped.
13
+ * 2. Sync envelopes (the v2 wire format) are unwrapped: the inner
14
+ * `syncEvent.type` with version suffix (`.<n>`) becomes the
15
+ * canonical event name on the dashboard-side SSE stream.
16
+ * 3. The proxy emits a heartbeat (`: keepalive`) within the
17
+ * configured SSE_HEARTBEAT_MS (default 25s — we set it to 250ms
18
+ * via env override so the test runs in well under a second).
19
+ *
20
+ * Strategy: stand up a fake opencode serve child on a random port,
21
+ * point readServeInfo() at it. The fake emits three events then
22
+ * holds the connection open long enough for the heartbeat to land.
23
+ */
24
+
25
+ import { test, before, after, beforeEach } from 'node:test';
26
+ import assert from 'node:assert/strict';
27
+ import {
28
+ mkdtempSync,
29
+ writeFileSync,
30
+ rmSync,
31
+ existsSync,
32
+ copyFileSync,
33
+ unlinkSync,
34
+ readFileSync,
35
+ } from 'node:fs';
36
+ import { join } from 'node:path';
37
+ import { tmpdir } from 'node:os';
38
+ import { createServer } from 'node:http';
39
+ import express from 'express';
40
+
41
+ import { createOpencodeSessionDetailRouter } from '../src/server/routes/opencode-session-detail.mjs';
42
+
43
+ // ── fake upstream ─────────────────────────────────────────────────────────
44
+
45
+ let upstreamServer, upstreamPort;
46
+ const upstream = {
47
+ streamChunks: [],
48
+ /** When non-null, hold the SSE connection open this many ms before
49
+ * ending it. The heartbeat test uses this. */
50
+ holdMs: 0,
51
+ };
52
+
53
+ function startUpstream() {
54
+ return new Promise((resolve) => {
55
+ upstreamServer = createServer((req, res) => {
56
+ const url = new URL(req.url, 'http://127.0.0.1');
57
+ if (url.pathname === '/event') {
58
+ res.writeHead(200, {
59
+ 'Content-Type': 'text/event-stream',
60
+ 'Cache-Control': 'no-cache',
61
+ Connection: 'keep-alive',
62
+ });
63
+ for (const c of upstream.streamChunks) res.write(c);
64
+ const hold = upstream.holdMs;
65
+ setTimeout(() => res.end(), hold);
66
+ return;
67
+ }
68
+ res.writeHead(404);
69
+ res.end('not found');
70
+ });
71
+ upstreamServer.listen(0, '127.0.0.1', () => {
72
+ upstreamPort = upstreamServer.address().port;
73
+ resolve();
74
+ });
75
+ });
76
+ }
77
+
78
+ async function startDashboard() {
79
+ const app = express();
80
+ app.use(express.json({ limit: '2mb' }));
81
+ app.use('/api', createOpencodeSessionDetailRouter());
82
+ dashboardServer = createServer(app);
83
+ await new Promise((r) => dashboardServer.listen(0, '127.0.0.1', r));
84
+ dashboardBaseUrl = `http://127.0.0.1:${dashboardServer.address().port}`;
85
+ }
86
+
87
+ let dashboardServer, dashboardBaseUrl, tmpDir;
88
+ let SERVE_JSON_PATH;
89
+ let originalServeJson;
90
+
91
+ before(async () => {
92
+ // Shrink the SSE heartbeat from 25s → 250ms so the heartbeat test
93
+ // actually exercises the path within the test runner's timeout.
94
+ process.env.BIZAR_SSE_HEARTBEAT_MS = '250';
95
+ // Use a per-PID isolated serve.json path so multiple test files
96
+ // don't contaminate each other.
97
+ SERVE_JSON_PATH = `${tmpdir()}/bizar-test-serve-stream-${process.pid}-${Date.now()}.json`;
98
+ process.env.BIZAR_SERVE_JSON_PATH = SERVE_JSON_PATH;
99
+ await startUpstream();
100
+ await startDashboard();
101
+ tmpDir = mkdtempSync(join(tmpdir(), 'opencode-stream-'));
102
+ if (existsSync(SERVE_JSON_PATH)) {
103
+ originalServeJson = readFileSync(SERVE_JSON_PATH, 'utf8');
104
+ }
105
+ });
106
+
107
+ after(async () => {
108
+ if (originalServeJson !== undefined) {
109
+ writeFileSync(SERVE_JSON_PATH, originalServeJson, 'utf8');
110
+ } else if (existsSync(SERVE_JSON_PATH)) {
111
+ rmSync(SERVE_JSON_PATH);
112
+ }
113
+ if (dashboardServer) await new Promise((r) => dashboardServer.close(r));
114
+ if (upstreamServer) await new Promise((r) => upstreamServer.close(r));
115
+ if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
116
+ delete process.env.BIZAR_SSE_HEARTBEAT_MS;
117
+ delete process.env.BIZAR_SERVE_JSON_PATH;
118
+ });
119
+
120
+ beforeEach(() => {
121
+ upstream.streamChunks = [];
122
+ upstream.holdMs = 0;
123
+ });
124
+
125
+ function writeServeJson() {
126
+ writeFileSync(
127
+ SERVE_JSON_PATH,
128
+ JSON.stringify({
129
+ port: upstreamPort,
130
+ password: 'test-pw',
131
+ worktree: '/tmp/stream-worktree',
132
+ pid: 99999,
133
+ startedAt: Date.now(),
134
+ }),
135
+ 'utf8',
136
+ );
137
+ }
138
+
139
+ function moveServeJsonAside() {
140
+ if (!existsSync(SERVE_JSON_PATH)) return;
141
+ const tmp = join(tmpDir, `serve-aside-${Date.now()}-${process.pid}.json`);
142
+ copyFileSync(SERVE_JSON_PATH, tmp);
143
+ unlinkSync(SERVE_JSON_PATH);
144
+ }
145
+
146
+ function parseSseStream(text) {
147
+ const out = [];
148
+ let event = null, dataLines = [];
149
+ for (const line of text.split(/\r?\n/)) {
150
+ if (line === '') {
151
+ if (event !== null || dataLines.length > 0) {
152
+ out.push({ event, data: dataLines.join('\n') });
153
+ }
154
+ event = null; dataLines = [];
155
+ continue;
156
+ }
157
+ if (line.startsWith(':')) continue;
158
+ const colon = line.indexOf(':');
159
+ if (colon < 0) continue;
160
+ const field = line.slice(0, colon);
161
+ let value = line.slice(colon + 1);
162
+ if (value.startsWith(' ')) value = value.slice(1);
163
+ if (field === 'event') event = value;
164
+ else if (field === 'data') dataLines.push(value);
165
+ }
166
+ return out;
167
+ }
168
+
169
+ // ── tests ─────────────────────────────────────────────────────────────────
170
+
171
+ test('SSE forwards message.updated for the requested session, drops others', async () => {
172
+ writeServeJson();
173
+ upstream.streamChunks = [
174
+ // Direct envelope (older wire format). properties.sessionID.
175
+ 'event: message.updated\ndata: {"type":"message.updated","properties":{"sessionID":"our-id","messageID":"m1"}}\n\n',
176
+ // Wrong session: must be filtered.
177
+ 'event: message.updated\ndata: {"type":"message.updated","properties":{"sessionID":"OTHER-id","messageID":"m9"}}\n\n',
178
+ // Sync envelope (newer wire format).
179
+ 'event: sync\ndata: {"type":"sync","syncEvent":{"type":"message.updated.1","data":{"sessionID":"our-id","messageID":"m3"}}}\n\n',
180
+ ];
181
+ const res = await fetch(
182
+ `${dashboardBaseUrl}/api/opencode-sessions/our-id/stream`,
183
+ );
184
+ assert.equal(res.status, 200);
185
+ assert.match(res.headers.get('content-type'), /text\/event-stream/);
186
+ assert.match(res.headers.get('cache-control'), /no-cache/);
187
+ assert.equal(res.headers.get('x-accel-buffering'), 'no');
188
+
189
+ const events = parseSseStream(await res.text());
190
+ // 1: message.updated (our-id)
191
+ // 2: message.updated from sync envelope (our-id)
192
+ // The OTHER-id message.updated is dropped.
193
+ assert.equal(events.length, 2);
194
+ assert.equal(events[0].event, 'message.updated');
195
+ const d0 = JSON.parse(events[0].data);
196
+ assert.equal(d0.sessionID, 'our-id');
197
+ assert.equal(d0.messageID, 'm1');
198
+ assert.equal(events[1].event, 'message.updated');
199
+ const d1 = JSON.parse(events[1].data);
200
+ assert.equal(d1.sessionID, 'our-id');
201
+ assert.equal(d1.messageID, 'm3');
202
+ });
203
+
204
+ test('SSE unwraps sync envelopes and strips the version suffix from the event name', async () => {
205
+ writeServeJson();
206
+ upstream.streamChunks = [
207
+ 'event: sync\ndata: {"type":"sync","syncEvent":{"type":"session.idle.1","data":{"sessionID":"our-id"}}}\n\n',
208
+ ];
209
+ const res = await fetch(
210
+ `${dashboardBaseUrl}/api/opencode-sessions/our-id/stream`,
211
+ );
212
+ assert.equal(res.status, 200);
213
+ const events = parseSseStream(await res.text());
214
+ assert.equal(events.length, 1);
215
+ assert.equal(events[0].event, 'session.idle');
216
+ const d = JSON.parse(events[0].data);
217
+ assert.equal(d.sessionID, 'our-id');
218
+ });
219
+
220
+ test('SSE does NOT emit redundant chat:delta / chat:status aliases (only canonical events)', async () => {
221
+ // v4.2.5 fix: prior version emitted chat:delta/chat:status aliases
222
+ // in ADDITION to the canonical event, causing downstream listeners
223
+ // to see each idle/delta event twice. The proxy now emits one
224
+ // canonical envelope per upstream event — no aliases.
225
+ writeServeJson();
226
+ upstream.streamChunks = [
227
+ 'event: message.part.updated\ndata: {"type":"message.part.updated.1","properties":{"sessionID":"our-id","messageID":"m1","part":{"type":"text","text":"hello"}}}\n\n',
228
+ 'event: sync\ndata: {"type":"sync","syncEvent":{"type":"session.idle.1","data":{"sessionID":"our-id"}}}\n\n',
229
+ ];
230
+ const res = await fetch(
231
+ `${dashboardBaseUrl}/api/opencode-sessions/our-id/stream`,
232
+ );
233
+ const events = parseSseStream(await res.text());
234
+ // Only 2 events expected — one part.updated and one session.idle.
235
+ // We do NOT want chat:delta or chat:status emitted here.
236
+ assert.equal(events.length, 2);
237
+ const evNames = events.map((e) => e.event);
238
+ assert.ok(evNames.includes('message.part.updated'));
239
+ assert.ok(evNames.includes('session.idle'));
240
+ assert.equal(evNames.filter((n) => n === 'chat:status').length, 0);
241
+ assert.equal(evNames.filter((n) => n === 'chat:delta').length, 0);
242
+ });
243
+
244
+ test('SSE emits a heartbeat (": keepalive") within BIZAR_SSE_HEARTBEAT_MS', async () => {
245
+ // Override forces a 250ms heartbeat (set in `before`). We hold
246
+ // the upstream connection open for 600ms so the heartbeat fires
247
+ // at least once after the initial chunks land.
248
+ writeServeJson();
249
+ upstream.holdMs = 600;
250
+ upstream.streamChunks = [
251
+ 'event: message.updated\ndata: {"type":"message.updated","properties":{"sessionID":"our-id","messageID":"m1"}}\n\n',
252
+ ];
253
+ const t0 = Date.now();
254
+ const res = await fetch(
255
+ `${dashboardBaseUrl}/api/opencode-sessions/our-id/stream`,
256
+ );
257
+ const text = await res.text();
258
+ const elapsed = Date.now() - t0;
259
+ // Body should be at least one blank line-separated chunk containing
260
+ // a `: keepalive` heartbeat comment. We assert the raw body has
261
+ // `: keepalive` somewhere and the total wait was longer than the
262
+ // heartbeat interval (250ms).
263
+ assert.ok(
264
+ text.includes(': keepalive'),
265
+ `expected ": keepalive" in body — got: ${text.slice(0, 500)}`,
266
+ );
267
+ assert.ok(elapsed >= 250, `expected >= 250ms elapsed, got ${elapsed}ms`);
268
+ });
269
+
270
+ test('SSE returns the response in <50ms when upstream immediately errors (plugin offline)', async () => {
271
+ // Move serve.json aside so the proxy sees `plugin_offline`.
272
+ moveServeJsonAside();
273
+ try {
274
+ const t0 = Date.now();
275
+ const res = await fetch(
276
+ `${dashboardBaseUrl}/api/opencode-sessions/our-id/stream`,
277
+ );
278
+ const elapsed = Date.now() - t0;
279
+ assert.equal(res.status, 200);
280
+ assert.match(res.headers.get('content-type'), /text\/event-stream/);
281
+ const text = await res.text();
282
+ assert.ok(text.includes('plugin_offline'));
283
+ assert.ok(elapsed < 200, `expected < 200ms, got ${elapsed}ms`);
284
+ } finally {
285
+ if (originalServeJson !== undefined) {
286
+ writeFileSync(SERVE_JSON_PATH, originalServeJson, 'utf8');
287
+ } else {
288
+ writeServeJson();
289
+ }
290
+ }
291
+ });
292
+
293
+ test('SSE handles whitespace-only session id gracefully (no 500 crash)', async () => {
294
+ writeServeJson();
295
+ // Whitespace session id: server resolves `decodeURIComponent('%20')`
296
+ // to a single space, which is non-empty; the proxy tries to filter
297
+ // events and never matches → empty stream, but never 5xx.
298
+ const res = await fetch(
299
+ `${dashboardBaseUrl}/api/opencode-sessions/%20/stream`,
300
+ );
301
+ // The handler may return 200 (empty stream) or 503 (directory
302
+ // unknown because the worktree doesn't contain ` ` session).
303
+ // Critically it must NOT 5xx or return undefined.
304
+ assert.ok(
305
+ res.status === 200 || res.status === 503,
306
+ `unexpected ${res.status} for whitespace session id`,
307
+ );
308
+ });