@polderlabs/bizar 4.2.4 → 4.3.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,273 @@
1
+ /**
2
+ * tests/opencode-sessions-detail.test.mjs — v4.2.4
3
+ *
4
+ * Tests the opencode session-detail router (GET messages, POST send,
5
+ * GET stream SSE proxy). Strategy: stand up a fake opencode serve child
6
+ * on a random port, point readServeInfo() at it by writing a tmp
7
+ * serve.json to the first candidate path (~/.cache/bizar/serve.json).
8
+ * For the "plugin offline" case, move our tmp serve.json aside so
9
+ * readServeInfo() finds nothing.
10
+ *
11
+ * We restore the original serve.json (if any) in `after()`.
12
+ */
13
+
14
+ import { test, before, after, beforeEach } from 'node:test';
15
+ import assert from 'node:assert/strict';
16
+ import {
17
+ mkdtempSync,
18
+ writeFileSync,
19
+ rmSync,
20
+ existsSync,
21
+ renameSync,
22
+ readFileSync,
23
+ mkdirSync,
24
+ } from 'node:fs';
25
+ import { join } from 'node:path';
26
+ import { tmpdir, homedir } from 'node:os';
27
+ import { createServer } from 'node:http';
28
+ import express from 'express';
29
+
30
+ import { createOpencodeSessionDetailRouter } from '../src/server/routes/opencode-session-detail.mjs';
31
+
32
+ // ── fake upstream (opencode serve child) ──────────────────────────────────
33
+
34
+ let upstreamServer, upstreamPort;
35
+ const upstream = { promptHits: [], lastPromptBody: null, streamChunks: [] };
36
+
37
+ function startUpstream() {
38
+ return new Promise((resolve) => {
39
+ upstreamServer = createServer((req, res) => {
40
+ const url = new URL(req.url, 'http://127.0.0.1');
41
+ if (url.pathname === '/event') {
42
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
43
+ for (const c of upstream.streamChunks) res.write(c);
44
+ setTimeout(() => res.end(), 50);
45
+ return;
46
+ }
47
+ const promptM = url.pathname.match(/^\/api\/session\/([^/]+)\/prompt$/);
48
+ if (promptM && req.method === 'POST') {
49
+ let body = '';
50
+ req.on('data', (c) => { body += c; });
51
+ req.on('end', () => {
52
+ upstream.promptHits.push({ sessionId: promptM[1], body });
53
+ try { upstream.lastPromptBody = JSON.parse(body); } catch { /* ignore */ }
54
+ if (promptM[1] === 'sess-fail') {
55
+ res.writeHead(404, { 'Content-Type': 'application/json' }); res.end('not found'); return;
56
+ }
57
+ res.writeHead(200, { 'Content-Type': 'application/json' }); res.end('{}');
58
+ });
59
+ return;
60
+ }
61
+ if (url.pathname === '/api/session' && req.method === 'GET') {
62
+ res.writeHead(200, { 'Content-Type': 'application/json' });
63
+ res.end(JSON.stringify({ data: [{ id: 'sess-1', title: 'Test', time: { created: 1, updated: 2, archived: null }, location: { directory: '/tmp/session-worktree' } }] }));
64
+ return;
65
+ }
66
+ const msgM = url.pathname.match(/^\/api\/session\/([^/]+)\/message$/);
67
+ if (msgM && req.method === 'GET') {
68
+ res.writeHead(200, { 'Content-Type': 'application/json' });
69
+ res.end(JSON.stringify({ data: [
70
+ { info: { id: 'm1', role: 'user', time: { created: 1000 } }, parts: [{ type: 'text', text: 'hello' }] },
71
+ { info: { id: 'm2', role: 'assistant', time: { created: 2000 } }, parts: [{ type: 'text', text: 'hi' }] },
72
+ ] }));
73
+ return;
74
+ }
75
+ res.writeHead(404); res.end('not found');
76
+ });
77
+ upstreamServer.listen(0, '127.0.0.1', () => {
78
+ upstreamPort = upstreamServer.address().port;
79
+ resolve();
80
+ });
81
+ });
82
+ }
83
+
84
+ async function startDashboard() {
85
+ const app = express();
86
+ app.use(express.json({ limit: '2mb' }));
87
+ app.use('/api', createOpencodeSessionDetailRouter());
88
+ dashboardServer = createServer(app);
89
+ await new Promise((r) => dashboardServer.listen(0, '127.0.0.1', r));
90
+ dashboardBaseUrl = `http://127.0.0.1:${dashboardServer.address().port}`;
91
+ }
92
+
93
+ let dashboardServer, dashboardBaseUrl, tmpDir;
94
+ const SERVE_JSON_PATH = join(homedir(), '.cache', 'bizar', 'serve.json');
95
+ let originalServeJson;
96
+
97
+ before(async () => {
98
+ await startUpstream();
99
+ await startDashboard();
100
+ tmpDir = mkdtempSync(join(tmpdir(), 'opencode-sessions-detail-'));
101
+ // Snapshot any existing serve.json so we can restore it.
102
+ if (existsSync(SERVE_JSON_PATH)) {
103
+ originalServeJson = readFileSync(SERVE_JSON_PATH, 'utf8');
104
+ }
105
+ mkdirSync(join(homedir(), '.cache', 'bizar'), { recursive: true });
106
+ });
107
+
108
+ after(async () => {
109
+ // Restore the real serve.json (or remove ours) before exit so we
110
+ // never leave a fake file behind in the user's home.
111
+ if (originalServeJson !== undefined) {
112
+ writeFileSync(SERVE_JSON_PATH, originalServeJson, 'utf8');
113
+ } else if (existsSync(SERVE_JSON_PATH)) {
114
+ rmSync(SERVE_JSON_PATH);
115
+ }
116
+ if (dashboardServer) await new Promise((r) => dashboardServer.close(r));
117
+ if (upstreamServer) await new Promise((r) => upstreamServer.close(r));
118
+ if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
119
+ });
120
+
121
+ beforeEach(() => {
122
+ upstream.promptHits = [];
123
+ upstream.lastPromptBody = null;
124
+ upstream.streamChunks = [];
125
+ });
126
+
127
+ function writeServeJson(worktree) {
128
+ writeFileSync(SERVE_JSON_PATH, JSON.stringify({ port: upstreamPort, password: 'test-pw', worktree, pid: 99999, startedAt: Date.now() }), 'utf8');
129
+ }
130
+
131
+ /** Move serve.json aside so readServeInfo() returns null. */
132
+ function moveServeJsonAside() {
133
+ if (!existsSync(SERVE_JSON_PATH)) return;
134
+ const tmp = join(tmpDir, `serve-aside-${Date.now()}.json`);
135
+ renameSync(SERVE_JSON_PATH, tmp);
136
+ }
137
+
138
+ function parseSseStream(text) {
139
+ const out = [];
140
+ let event = null, dataLines = [];
141
+ for (const line of text.split(/\r?\n/)) {
142
+ if (line === '') {
143
+ if (event !== null || dataLines.length > 0) out.push({ event, data: dataLines.join('\n') });
144
+ event = null; dataLines = [];
145
+ continue;
146
+ }
147
+ if (line.startsWith(':')) continue;
148
+ const colon = line.indexOf(':');
149
+ if (colon < 0) continue;
150
+ const field = line.slice(0, colon);
151
+ let value = line.slice(colon + 1);
152
+ if (value.startsWith(' ')) value = value.slice(1);
153
+ if (field === 'event') event = value;
154
+ else if (field === 'data') dataLines.push(value);
155
+ }
156
+ return out;
157
+ }
158
+
159
+ // ── tests ─────────────────────────────────────────────────────────────────
160
+
161
+ test('GET messages returns 503 plugin_offline when no serve.json', async () => {
162
+ moveServeJsonAside();
163
+ try {
164
+ const res = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/sess-1/messages`);
165
+ assert.equal(res.status, 503);
166
+ assert.equal((await res.json()).error, 'plugin_offline');
167
+ } finally {
168
+ // Restore — the next test wants serve.json present again.
169
+ if (originalServeJson !== undefined) writeServeJson(JSON.parse(originalServeJson).worktree);
170
+ }
171
+ });
172
+
173
+ test('GET messages returns 200 with normalized message list', async () => {
174
+ writeServeJson('/tmp/session-worktree');
175
+ const res = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/sess-1/messages`);
176
+ assert.equal(res.status, 200);
177
+ const body = await res.json();
178
+ assert.equal(body.messages.length, 2);
179
+ assert.equal(body.messages[0].id, 'm1');
180
+ assert.equal(body.messages[0].role, 'user');
181
+ assert.equal(body.messages[0].content, 'hello');
182
+ assert.equal(body.messages[0].ts, 1000);
183
+ assert.equal(body.messages[1].role, 'assistant');
184
+ assert.equal(body.messages[1].content, 'hi');
185
+ assert.equal(body.messages[1].ts, 2000);
186
+ });
187
+
188
+ test('POST send returns 400 when body is empty', async () => {
189
+ writeServeJson('/tmp/session-worktree');
190
+ const r = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/sess-1/send`, {
191
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}),
192
+ });
193
+ assert.equal(r.status, 400);
194
+ });
195
+
196
+ test('POST send returns 400 when agent is missing', async () => {
197
+ writeServeJson('/tmp/session-worktree');
198
+ const r = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/sess-1/send`, {
199
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'hi' }),
200
+ });
201
+ assert.equal(r.status, 400);
202
+ });
203
+
204
+ test('POST send returns 400 when message is empty string', async () => {
205
+ writeServeJson('/tmp/session-worktree');
206
+ const r = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/sess-1/send`, {
207
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: '', agent: 'odin' }),
208
+ });
209
+ assert.equal(r.status, 400);
210
+ });
211
+
212
+ test('POST send returns ok=true with synthesized messageId', async () => {
213
+ writeServeJson('/tmp/session-worktree');
214
+ const r = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/sess-1/send`, {
215
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'hello there', agent: 'odin' }),
216
+ });
217
+ assert.equal(r.status, 200);
218
+ const body = await r.json();
219
+ assert.equal(body.ok, true);
220
+ assert.ok(body.messageId.startsWith('msg_'));
221
+ assert.equal(upstream.promptHits.length, 1);
222
+ assert.equal(upstream.lastPromptBody.prompt.text, 'hello there');
223
+ assert.equal(upstream.lastPromptBody.agent, 'odin');
224
+ assert.equal(upstream.lastPromptBody.id, body.messageId);
225
+ });
226
+
227
+ test('POST send returns 502 opencode_error when upstream 404s', async () => {
228
+ writeServeJson('/tmp/session-worktree');
229
+ const r = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/sess-fail/send`, {
230
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'hi', agent: 'odin' }),
231
+ });
232
+ assert.equal(r.status, 502);
233
+ const body = await r.json();
234
+ assert.equal(body.error, 'opencode_error');
235
+ assert.ok(body.message.length > 0);
236
+ });
237
+
238
+ test('SSE stream forwards only events for the requested session', async () => {
239
+ writeServeJson('/tmp/session-worktree');
240
+ upstream.streamChunks = [
241
+ 'event: message.updated\ndata: {"type":"message.updated","properties":{"sessionID":"our-id","messageID":"m1"}}\n\n',
242
+ 'event: message.updated\ndata: {"type":"message.updated","properties":{"sessionID":"OTHER-id","messageID":"m9"}}\n\n',
243
+ 'event: sync\ndata: {"type":"sync","syncEvent":{"type":"session.idle.1","data":{"sessionID":"our-id"}}}\n\n',
244
+ ];
245
+ const res = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/our-id/stream`);
246
+ assert.equal(res.status, 200);
247
+ assert.match(res.headers.get('content-type'), /text\/event-stream/);
248
+ assert.match(res.headers.get('cache-control'), /no-cache/);
249
+ assert.equal(res.headers.get('x-accel-buffering'), 'no');
250
+ const events = parseSseStream(await res.text());
251
+ assert.equal(events.length, 2);
252
+ assert.equal(events[0].event, 'message.updated');
253
+ const d0 = JSON.parse(events[0].data);
254
+ assert.equal(d0.sessionID, 'our-id');
255
+ assert.equal(d0.messageID, 'm1');
256
+ assert.equal(events[1].event, 'session.idle');
257
+ assert.equal(JSON.parse(events[1].data).sessionID, 'our-id');
258
+ });
259
+
260
+ test('SSE stream unwraps sync envelopes and strips version suffix', async () => {
261
+ writeServeJson('/tmp/session-worktree');
262
+ upstream.streamChunks = [
263
+ 'event: sync\ndata: {"type":"sync","syncEvent":{"type":"message.updated.1","data":{"sessionID":"our-id","messageID":"m3"}}}\n\n',
264
+ ];
265
+ const res = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/our-id/stream`);
266
+ assert.equal(res.status, 200);
267
+ const events = parseSseStream(await res.text());
268
+ assert.equal(events.length, 1);
269
+ assert.equal(events[0].event, 'message.updated');
270
+ const d = JSON.parse(events[0].data);
271
+ assert.equal(d.sessionID, 'our-id');
272
+ assert.equal(d.messageID, 'm3');
273
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polderlabs/bizar",
3
- "version": "4.2.4",
3
+ "version": "4.3.0",
4
4
  "description": "Norse-pantheon multi-agent system for opencode — 13 agents across 4 cost tiers with cost-aware routing, plans, and a configurable agent harness. v4 ships as a single npm package bundling the dashboard server, opencode plugin, and typed SDK.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,7 +21,7 @@
21
21
  "build:sdk": "tsc -p packages/sdk/tsconfig.json",
22
22
  "test:sdk": "node_modules/.bin/vitest run --root packages/sdk",
23
23
  "test:sdk:watch": "node_modules/.bin/vitest --root packages/sdk",
24
- "test": "npm run typecheck && npm run test:sdk && bun test plugins/bizar/tests/loop.test.ts plugins/bizar/tests/block.test.ts plugins/bizar/tests/stall-think.test.ts plugins/bizar/tests/tools/bg-get-comments.test.ts plugins/bizar/tests/tools/bg-spawn-delegation.test.ts plugins/bizar/tests/tools/opencode-runner.test.ts plugins/bizar/tests/settings.test.ts plugins/bizar/tests/commands.test.ts plugins/bizar/tests/commands-impl.test.ts plugins/bizar/tests/tools/plan-action.test.ts plugins/bizar/tests/tools/wait-for-feedback.test.ts plugins/bizar/tests/tools/read-glyph-feedback.test.ts plugins/bizar/tests/reasoning-clean.test.ts plugins/bizar/tests/key-rotation.test.ts && node bizar-dash/tests/smoke-v2.mjs && node --test bizar-dash/tests/path-safe.test.mjs bizar-dash/tests/tmux-wrap.test.mjs bizar-dash/tests/opencode-runner.test.mjs bizar-dash/tests/mod-instructions.node.test.mjs bizar-dash/tests/mod-upgrade.node.test.mjs bizar-dash/tests/graphify-mod-spawn.node.test.mjs bizar-dash/tests/no-agent-browser.node.test.mjs bizar-dash/tests/providers-store-backup-keys.node.test.mjs bizar-dash/tests/dashboard-ports.test.mjs bizar-dash/tests/submit-feedback.test.mjs bizar-dash/tests/yaml.test.mjs bizar-dash/tests/memory-store.test.mjs bizar-dash/tests/memory-schema.test.mjs bizar-dash/tests/memory-secrets.test.mjs bizar-dash/tests/memory-git.test.mjs bizar-dash/tests/memory-sync.test.mjs bizar-dash/tests/obsidian-back-compat.test.mjs bizar-dash/tests/memory-lightrag.test.mjs bizar-dash/tests/memory-config.test.mjs bizar-dash/tests/memory-cli.test.mjs bizar-dash/tests/memory-cli-readlistdelete.test.mjs bizar-dash/tests/memory-cli-setup.test.mjs bizar-dash/tests/memory-conflicts.test.mjs bizar-dash/tests/memory-namespace.test.mjs bizar-dash/tests/memory-path-safety.test.mjs bizar-dash/tests/memory-protocol-drift.test.mjs bizar-dash/tests/memory-roundtrip.test.mjs",
24
+ "test": "npm run typecheck && npm run test:sdk && bun test plugins/bizar/tests/loop.test.ts plugins/bizar/tests/block.test.ts plugins/bizar/tests/stall-think.test.ts plugins/bizar/tests/tools/bg-get-comments.test.ts plugins/bizar/tests/tools/bg-spawn-delegation.test.ts plugins/bizar/tests/tools/opencode-runner.test.ts plugins/bizar/tests/settings.test.ts plugins/bizar/tests/commands.test.ts plugins/bizar/tests/commands-impl.test.ts plugins/bizar/tests/tools/plan-action.test.ts plugins/bizar/tests/tools/wait-for-feedback.test.ts plugins/bizar/tests/tools/read-glyph-feedback.test.ts plugins/bizar/tests/reasoning-clean.test.ts plugins/bizar/tests/key-rotation.test.ts && node bizar-dash/tests/smoke-v2.mjs && node --test bizar-dash/tests/path-safe.test.mjs bizar-dash/tests/tmux-wrap.test.mjs bizar-dash/tests/opencode-sessions-detail.test.mjs bizar-dash/tests/opencode-runner.test.mjs bizar-dash/tests/mod-instructions.node.test.mjs bizar-dash/tests/mod-upgrade.node.test.mjs bizar-dash/tests/graphify-mod-spawn.node.test.mjs bizar-dash/tests/no-agent-browser.node.test.mjs bizar-dash/tests/providers-store-backup-keys.node.test.mjs bizar-dash/tests/dashboard-ports.test.mjs bizar-dash/tests/submit-feedback.test.mjs bizar-dash/tests/yaml.test.mjs bizar-dash/tests/memory-store.test.mjs bizar-dash/tests/memory-schema.test.mjs bizar-dash/tests/memory-secrets.test.mjs bizar-dash/tests/memory-git.test.mjs bizar-dash/tests/memory-sync.test.mjs bizar-dash/tests/obsidian-back-compat.test.mjs bizar-dash/tests/memory-lightrag.test.mjs bizar-dash/tests/memory-config.test.mjs bizar-dash/tests/memory-cli.test.mjs bizar-dash/tests/memory-cli-readlistdelete.test.mjs bizar-dash/tests/memory-cli-setup.test.mjs bizar-dash/tests/memory-conflicts.test.mjs bizar-dash/tests/memory-namespace.test.mjs bizar-dash/tests/memory-path-safety.test.mjs bizar-dash/tests/memory-protocol-drift.test.mjs bizar-dash/tests/memory-roundtrip.test.mjs",
25
25
  "build": "npm run build:sdk"
26
26
  },
27
27
  "keywords": [