@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.
- package/bizar-dash/CHANGELOG.md +37 -276
- package/bizar-dash/dist/assets/main-CDFKHzBg.css +1 -0
- package/bizar-dash/dist/assets/main-NYFpS2wY.js +312 -0
- package/bizar-dash/dist/assets/main-NYFpS2wY.js.map +1 -0
- package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js → mobile--0FBIKX3.js} +2 -2
- package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js.map → mobile--0FBIKX3.js.map} +1 -1
- package/bizar-dash/dist/assets/mobile-OgRp8VIb.js +352 -0
- package/bizar-dash/dist/assets/mobile-OgRp8VIb.js.map +1 -0
- package/bizar-dash/dist/index.html +3 -3
- package/bizar-dash/dist/mobile.html +2 -2
- package/bizar-dash/skills/agent-baseline/SKILL.md +80 -0
- package/bizar-dash/skills/bizar/SKILL.md +96 -0
- package/bizar-dash/skills/chat/SKILL.md +74 -0
- package/bizar-dash/skills/lightrag/SKILL.md +75 -0
- package/bizar-dash/skills/minimax/SKILL.md +80 -0
- package/bizar-dash/skills/obsidian/SKILL.md +55 -0
- package/bizar-dash/skills/providers/SKILL.md +75 -0
- package/bizar-dash/skills/sdk/SKILL.md +138 -0
- package/bizar-dash/skills/self-improvement/SKILL.md +53 -0
- package/bizar-dash/skills/skills-cli/SKILL.md +94 -0
- package/bizar-dash/skills/usage/SKILL.md +62 -0
- package/bizar-dash/src/server/api.mjs +12 -0
- package/bizar-dash/src/server/memory-lightrag.mjs +5 -2
- package/bizar-dash/src/server/memory-store.mjs +38 -0
- package/bizar-dash/src/server/minimax-usage-store.mjs +372 -0
- package/bizar-dash/src/server/minimax.mjs +196 -5
- package/bizar-dash/src/server/providers-store.mjs +956 -0
- package/bizar-dash/src/server/routes/config.mjs +52 -1
- package/bizar-dash/src/server/routes/env-vars.mjs +165 -0
- package/bizar-dash/src/server/routes/lightrag.mjs +154 -0
- package/bizar-dash/src/server/routes/memory.mjs +241 -1
- package/bizar-dash/src/server/routes/opencode-session-detail.mjs +14 -29
- package/bizar-dash/src/server/routes/opencode-sessions.mjs +205 -3
- package/bizar-dash/src/server/routes/providers.mjs +266 -5
- package/bizar-dash/src/server/routes/skills.mjs +32 -43
- package/bizar-dash/src/server/routes/update.mjs +340 -0
- package/bizar-dash/src/server/routes/usage.mjs +136 -0
- package/bizar-dash/src/server/serve-info.mjs +135 -4
- package/bizar-dash/src/server/server.mjs +4 -0
- package/bizar-dash/src/server/skills-store.mjs +152 -262
- package/bizar-dash/src/web/App.tsx +118 -29
- package/bizar-dash/src/web/components/EnvVarManager.tsx +247 -0
- package/bizar-dash/src/web/components/SettingsSearch.tsx +213 -0
- package/bizar-dash/src/web/components/Topbar.tsx +0 -1
- package/bizar-dash/src/web/components/UsageChart.tsx +250 -0
- package/bizar-dash/src/web/components/UsageTable.tsx +90 -0
- package/bizar-dash/src/web/components/chat/ChatComposer.tsx +21 -25
- package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +199 -37
- package/bizar-dash/src/web/components/chat/ChatThread.tsx +29 -17
- package/bizar-dash/src/web/components/chat/FloatingComposer.tsx +7 -1
- package/bizar-dash/src/web/components/chat/InfoPanel.tsx +71 -6
- package/bizar-dash/src/web/components/chat/useChat.ts +751 -257
- package/bizar-dash/src/web/lib/api.ts +43 -0
- package/bizar-dash/src/web/main.tsx +1 -0
- package/bizar-dash/src/web/mobile/views/MobileChat.tsx +110 -35
- package/bizar-dash/src/web/styles/chat.css +135 -1
- package/bizar-dash/src/web/styles/main.css +46 -0
- package/bizar-dash/src/web/styles/minimax-usage.css +335 -0
- package/bizar-dash/src/web/styles/settings.css +418 -0
- package/bizar-dash/src/web/styles/skills.css +302 -0
- package/bizar-dash/src/web/styles/tasks.css +288 -0
- package/bizar-dash/src/web/views/Chat.tsx +276 -48
- package/bizar-dash/src/web/views/Config.tsx +3 -2065
- package/bizar-dash/src/web/views/MiniMaxUsage.tsx +476 -461
- package/bizar-dash/src/web/views/Settings.tsx +6 -0
- package/bizar-dash/src/web/views/Skills.tsx +208 -260
- package/bizar-dash/src/web/views/Tasks.tsx +348 -1119
- package/bizar-dash/tests/chat-session-create.test.mjs +391 -0
- package/bizar-dash/tests/chat-session-stream.test.mjs +308 -0
- package/bizar-dash/tests/env-vars-store.test.mjs +216 -0
- package/bizar-dash/tests/lightrag-defaults.node.test.mjs +118 -0
- package/bizar-dash/tests/minimax-chat-usage.test.mjs +178 -0
- package/bizar-dash/tests/minimax-usage-store.node.test.mjs +293 -0
- package/bizar-dash/tests/opencode-sessions-detail.test.mjs +12 -9
- package/bizar-dash/tests/providers-store-backup-keys.node.test.mjs +479 -3
- package/bizar-dash/tests/providers-store-search.node.test.mjs +166 -0
- package/bizar-dash/tests/skills-list.test.mjs +232 -0
- package/bizar-dash/tests/skills-search.test.mjs +222 -0
- package/bizar-dash/tests/tasks-create.test.mjs +187 -0
- package/bizar-dash/tests/update-check.test.mjs +127 -0
- package/bizar-dash/tests/update-run.test.mjs +266 -0
- package/cli/bin.mjs +82 -1
- package/cli/provision.mjs +118 -4
- package/config/agents/_shared/SKILLS.md +109 -0
- package/package.json +1 -1
- package/bizar-dash/dist/assets/main-BB5mJurD.js +0 -352
- package/bizar-dash/dist/assets/main-BB5mJurD.js.map +0 -1
- package/bizar-dash/dist/assets/main-BsnQLXdh.css +0 -1
- package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js +0 -354
- 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
|
+
});
|