bonecode 1.2.1 → 1.2.2
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/compat/opencode_adapter.ts +60 -21
- package/dist/compat/opencode_adapter.js +54 -15
- package/dist/compat/opencode_adapter.js.map +1 -1
- package/dist/src/cli.js +63 -30
- package/dist/src/cli.js.map +1 -1
- package/dist/src/engine/session/prompt.js +9 -0
- package/dist/src/engine/session/prompt.js.map +1 -1
- package/dist/src/engine/session/tool_registry.js +28 -2
- package/dist/src/engine/session/tool_registry.js.map +1 -1
- package/dist/src/stats.d.ts +3 -0
- package/dist/src/stats.js +43 -3
- package/dist/src/stats.js.map +1 -1
- package/package.json +1 -1
- package/scripts/test_v1_2.js +420 -0
- package/src/cli.ts +67 -33
- package/src/engine/session/prompt.ts +10 -0
- package/src/engine/session/tool_registry.ts +33 -2
- package/src/stats.ts +55 -3
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* v1.2 sanity tests
|
|
4
|
+
*
|
|
5
|
+
* Verifies:
|
|
6
|
+
* 1. CLI starts and shows --version, --help
|
|
7
|
+
* 2. Server boots (SQLite mode — no Docker needed)
|
|
8
|
+
* 3. Web UI is served at /ui
|
|
9
|
+
* 4. /v2 API endpoints respond correctly (sessions, providers, config, health)
|
|
10
|
+
* 5. Cursor pagination on sessions
|
|
11
|
+
* 6. Cancel endpoint exists
|
|
12
|
+
* 7. mDNS does not log noise
|
|
13
|
+
* 8. Stats command runs without crashing
|
|
14
|
+
* 9. Export command runs without crashing
|
|
15
|
+
* 10. TUI helpers (describeTool, makeCodeFenceCollapser) produce correct output
|
|
16
|
+
*
|
|
17
|
+
* Does NOT exercise the LLM end-to-end — that requires API keys and live providers.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
"use strict";
|
|
21
|
+
const path = require("path");
|
|
22
|
+
const fs = require("fs");
|
|
23
|
+
const { spawnSync, spawn } = require("child_process");
|
|
24
|
+
const http = require("http");
|
|
25
|
+
|
|
26
|
+
const ROOT = path.resolve(__dirname, "..");
|
|
27
|
+
const CLI = path.join(ROOT, "dist", "src", "cli.js");
|
|
28
|
+
const SERVER = path.join(ROOT, "dist", "src", "server.js");
|
|
29
|
+
|
|
30
|
+
// ANSI
|
|
31
|
+
const G = "\x1b[32m"; const R = "\x1b[31m"; const Y = "\x1b[33m";
|
|
32
|
+
const C = "\x1b[36m"; const D = "\x1b[2m"; const N = "\x1b[0m";
|
|
33
|
+
const B = "\x1b[1m";
|
|
34
|
+
|
|
35
|
+
let passed = 0;
|
|
36
|
+
let failed = 0;
|
|
37
|
+
const failures = [];
|
|
38
|
+
|
|
39
|
+
function ok(name, info = "") {
|
|
40
|
+
passed++;
|
|
41
|
+
console.log(` ${G}✓${N} ${name}${info ? ` ${D}${info}${N}` : ""}`);
|
|
42
|
+
}
|
|
43
|
+
function fail(name, msg) {
|
|
44
|
+
failed++;
|
|
45
|
+
failures.push(`${name}: ${msg}`);
|
|
46
|
+
console.log(` ${R}✗${N} ${name} ${R}${msg}${N}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function header(s) { console.log(`\n${C}${B}${s}${N}`); }
|
|
50
|
+
|
|
51
|
+
// HTTP helper
|
|
52
|
+
function get(url) {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const req = http.get(url, (res) => {
|
|
55
|
+
let body = "";
|
|
56
|
+
res.on("data", (c) => (body += c));
|
|
57
|
+
res.on("end", () => resolve({ status: res.statusCode, body, headers: res.headers }));
|
|
58
|
+
});
|
|
59
|
+
req.on("error", reject);
|
|
60
|
+
req.setTimeout(5000, () => { req.destroy(); reject(new Error("timeout")); });
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function post(url, data, headers = {}) {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const u = new URL(url);
|
|
67
|
+
const body = JSON.stringify(data);
|
|
68
|
+
const req = http.request({
|
|
69
|
+
hostname: u.hostname,
|
|
70
|
+
port: u.port,
|
|
71
|
+
path: u.pathname + u.search,
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body), ...headers },
|
|
74
|
+
}, (res) => {
|
|
75
|
+
let chunks = "";
|
|
76
|
+
res.on("data", (c) => (chunks += c));
|
|
77
|
+
res.on("end", () => resolve({ status: res.statusCode, body: chunks, headers: res.headers }));
|
|
78
|
+
});
|
|
79
|
+
req.on("error", reject);
|
|
80
|
+
req.setTimeout(10000, () => { req.destroy(); reject(new Error("timeout")); });
|
|
81
|
+
req.write(body); req.end();
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function waitForServer(port, maxMs = 20000) {
|
|
86
|
+
const start = Date.now();
|
|
87
|
+
while (Date.now() - start < maxMs) {
|
|
88
|
+
try {
|
|
89
|
+
const r = await get(`http://localhost:${port}/health`);
|
|
90
|
+
if (r.status === 200) return true;
|
|
91
|
+
} catch {}
|
|
92
|
+
await new Promise(r => setTimeout(r, 300));
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ─── Test 1: CLI artifacts exist ─────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
async function testArtifacts() {
|
|
100
|
+
header("[1] Build artifacts");
|
|
101
|
+
|
|
102
|
+
if (fs.existsSync(CLI)) ok("dist/src/cli.js exists");
|
|
103
|
+
else fail("dist/src/cli.js exists", "missing — run npm run build");
|
|
104
|
+
|
|
105
|
+
if (fs.existsSync(SERVER)) ok("dist/src/server.js exists");
|
|
106
|
+
else fail("dist/src/server.js exists", "missing");
|
|
107
|
+
|
|
108
|
+
if (fs.existsSync(path.join(ROOT, "dist", "src", "mdns.js"))) ok("dist/src/mdns.js exists");
|
|
109
|
+
else fail("dist/src/mdns.js exists", "missing");
|
|
110
|
+
|
|
111
|
+
if (fs.existsSync(path.join(ROOT, "dist", "src", "stats.js"))) ok("dist/src/stats.js exists");
|
|
112
|
+
else fail("dist/src/stats.js exists", "missing");
|
|
113
|
+
|
|
114
|
+
if (fs.existsSync(path.join(ROOT, "dist", "src", "export.js"))) ok("dist/src/export.js exists");
|
|
115
|
+
else fail("dist/src/export.js exists", "missing");
|
|
116
|
+
|
|
117
|
+
if (fs.existsSync(path.join(ROOT, "dist", "src", "tui.js"))) ok("dist/src/tui.js exists");
|
|
118
|
+
else fail("dist/src/tui.js exists", "missing");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ─── Test 2: --version and --help ────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
async function testCli() {
|
|
124
|
+
header("[2] CLI commands");
|
|
125
|
+
|
|
126
|
+
const v = spawnSync(process.execPath, [CLI, "--version"], { encoding: "utf-8", timeout: 5000 });
|
|
127
|
+
if (v.status === 0 && /v?\d+\.\d+\.\d+/.test(v.stdout)) {
|
|
128
|
+
ok("--version", v.stdout.trim());
|
|
129
|
+
} else {
|
|
130
|
+
fail("--version", `status=${v.status} stdout="${v.stdout}"`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const h = spawnSync(process.execPath, [CLI, "--help"], { encoding: "utf-8", timeout: 5000 });
|
|
134
|
+
const helpOk = h.status === 0 &&
|
|
135
|
+
h.stdout.includes("bonecode web") &&
|
|
136
|
+
h.stdout.includes("bonecode stats") &&
|
|
137
|
+
h.stdout.includes("bonecode export") &&
|
|
138
|
+
h.stdout.includes("bonecode pr");
|
|
139
|
+
if (helpOk) ok("--help lists all new commands");
|
|
140
|
+
else fail("--help", "missing one of: web, stats, export, pr");
|
|
141
|
+
|
|
142
|
+
// Status should report not running
|
|
143
|
+
const s = spawnSync(process.execPath, [CLI, "status"], { encoding: "utf-8", timeout: 5000 });
|
|
144
|
+
if (s.status === 0) ok("status (server not running)", s.stdout.includes("not running") ? "correctly reports not running" : "ran");
|
|
145
|
+
else fail("status", `status=${s.status}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─── Test 3: TUI helper functions ────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
async function testTuiHelpers() {
|
|
151
|
+
header("[3] TUI helpers");
|
|
152
|
+
|
|
153
|
+
// We can't import TUI directly because it would start a readline.
|
|
154
|
+
// Instead, exercise the logic via the compiled module's exported pieces.
|
|
155
|
+
// The TUI doesn't export describeTool externally — but the agent loop's
|
|
156
|
+
// tool_registry does the work. So instead, verify the module loads cleanly.
|
|
157
|
+
try {
|
|
158
|
+
const tui = require(path.join(ROOT, "dist", "src", "tui.js"));
|
|
159
|
+
if (typeof tui.runTUI === "function") ok("tui.runTUI is exported");
|
|
160
|
+
else fail("tui.runTUI", "not exported");
|
|
161
|
+
if (typeof tui.startInteractiveTUI === "function") ok("tui.startInteractiveTUI is exported");
|
|
162
|
+
else fail("tui.startInteractiveTUI", "not exported");
|
|
163
|
+
} catch (e) {
|
|
164
|
+
fail("require tui.js", e.message);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Verify mdns module loads without throwing
|
|
168
|
+
try {
|
|
169
|
+
const mdns = require(path.join(ROOT, "dist", "src", "mdns.js"));
|
|
170
|
+
if (typeof mdns.publish === "function" && typeof mdns.unpublish === "function") {
|
|
171
|
+
ok("mdns exports publish/unpublish");
|
|
172
|
+
} else {
|
|
173
|
+
fail("mdns exports", "publish or unpublish missing");
|
|
174
|
+
}
|
|
175
|
+
} catch (e) {
|
|
176
|
+
fail("require mdns.js", e.message);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ─── Test 4: Server boots ────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
let serverProc = null;
|
|
183
|
+
|
|
184
|
+
async function startServer(port) {
|
|
185
|
+
return new Promise((resolve, reject) => {
|
|
186
|
+
serverProc = spawn(process.execPath, [SERVER], {
|
|
187
|
+
env: {
|
|
188
|
+
...process.env,
|
|
189
|
+
PORT: String(port),
|
|
190
|
+
// Force SQLite mode — no Docker needed for tests
|
|
191
|
+
DATABASE_URL: "",
|
|
192
|
+
// Don't bother with mDNS during tests
|
|
193
|
+
MDNS: "false",
|
|
194
|
+
// Suppress JWT warnings about default secret
|
|
195
|
+
JWT_SECRET: "test-secret-for-bonecode-v1.2-ci",
|
|
196
|
+
},
|
|
197
|
+
cwd: ROOT,
|
|
198
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
let stdout = "";
|
|
202
|
+
let stderr = "";
|
|
203
|
+
serverProc.stdout.on("data", (c) => (stdout += c.toString()));
|
|
204
|
+
serverProc.stderr.on("data", (c) => (stderr += c.toString()));
|
|
205
|
+
|
|
206
|
+
serverProc.on("error", reject);
|
|
207
|
+
serverProc.on("exit", (code) => {
|
|
208
|
+
if (!resolved) {
|
|
209
|
+
resolve({ booted: false, stdout, stderr, exitCode: code });
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
let resolved = false;
|
|
214
|
+
waitForServer(port, 25000).then((up) => {
|
|
215
|
+
resolved = true;
|
|
216
|
+
resolve({ booted: up, stdout, stderr });
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function testServer() {
|
|
222
|
+
header("[4] Server boot (SQLite mode)");
|
|
223
|
+
|
|
224
|
+
const port = 13579;
|
|
225
|
+
const result = await startServer(port);
|
|
226
|
+
|
|
227
|
+
if (!result.booted) {
|
|
228
|
+
fail("server boot", `did not respond on port ${port} within 25s`);
|
|
229
|
+
if (result.stderr) console.log(`${D}stderr: ${result.stderr.slice(0, 500)}${N}`);
|
|
230
|
+
if (result.stdout) console.log(`${D}stdout: ${result.stdout.slice(0, 500)}${N}`);
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
ok("server boots on port " + port);
|
|
234
|
+
|
|
235
|
+
// Verify mDNS is silent (we set MDNS=false but also check no mdns lines appear)
|
|
236
|
+
const mdnsNoise = /\[mDNS\]/.test(result.stdout) || /\[mDNS\]/.test(result.stderr);
|
|
237
|
+
if (!mdnsNoise) ok("mDNS produces no log noise");
|
|
238
|
+
else fail("mDNS silence", "found [mDNS] in output");
|
|
239
|
+
|
|
240
|
+
return port;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ─── Test 5: API endpoints ───────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
async function testApi(port) {
|
|
246
|
+
header("[5] API endpoints");
|
|
247
|
+
|
|
248
|
+
// Health
|
|
249
|
+
const h = await get(`http://localhost:${port}/health`).catch(() => null);
|
|
250
|
+
if (h && h.status === 200) {
|
|
251
|
+
const j = JSON.parse(h.body);
|
|
252
|
+
ok("/health returns 200", `db=${j.db}`);
|
|
253
|
+
} else fail("/health", `status=${h?.status}`);
|
|
254
|
+
|
|
255
|
+
// Web UI mounted at /ui
|
|
256
|
+
const ui = await get(`http://localhost:${port}/ui`).catch(() => null);
|
|
257
|
+
if (ui && ui.status === 200 && /BoneCode/.test(ui.body)) {
|
|
258
|
+
ok("/ui serves the web UI", `${ui.body.length} bytes`);
|
|
259
|
+
} else if (ui && ui.status === 200) {
|
|
260
|
+
ok("/ui responds 200", "but no BoneCode marker found");
|
|
261
|
+
} else {
|
|
262
|
+
// Web UI is optional in CI; only fail if served but broken
|
|
263
|
+
ok("/ui (skipped — web/ may not be present)");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// /v2/provider
|
|
267
|
+
const p = await get(`http://localhost:${port}/v2/provider`).catch(() => null);
|
|
268
|
+
if (p && p.status === 200) {
|
|
269
|
+
const list = JSON.parse(p.body);
|
|
270
|
+
if (Array.isArray(list) && list.length > 0) {
|
|
271
|
+
ok("/v2/provider returns list", `${list.length} providers`);
|
|
272
|
+
} else {
|
|
273
|
+
fail("/v2/provider", "empty list");
|
|
274
|
+
}
|
|
275
|
+
} else fail("/v2/provider", `status=${p?.status}`);
|
|
276
|
+
|
|
277
|
+
// /v2/config
|
|
278
|
+
const c = await get(`http://localhost:${port}/v2/config`).catch(() => null);
|
|
279
|
+
if (c && c.status === 200) {
|
|
280
|
+
const cfg = JSON.parse(c.body);
|
|
281
|
+
if (cfg.model && cfg.provider) ok("/v2/config returns model+provider");
|
|
282
|
+
else fail("/v2/config", "missing model/provider");
|
|
283
|
+
} else fail("/v2/config", `status=${c?.status}`);
|
|
284
|
+
|
|
285
|
+
// Get a JWT token for authenticated endpoints
|
|
286
|
+
let token;
|
|
287
|
+
try {
|
|
288
|
+
const jwt = require("jsonwebtoken");
|
|
289
|
+
token = jwt.sign({ sub: "test-user" }, "test-secret-for-bonecode-v1.2-ci", { expiresIn: "1h" });
|
|
290
|
+
} catch (e) {
|
|
291
|
+
fail("jwt sign", e.message);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Create a session
|
|
296
|
+
const cs = await post(`http://localhost:${port}/v2/session`, {
|
|
297
|
+
title: "Test session",
|
|
298
|
+
directory: process.cwd(),
|
|
299
|
+
}, { Authorization: `Bearer ${token}` }).catch((e) => ({ error: e.message }));
|
|
300
|
+
|
|
301
|
+
if (cs.error) {
|
|
302
|
+
fail("POST /v2/session", cs.error);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (cs.status === 201 || cs.status === 200) {
|
|
306
|
+
const sess = JSON.parse(cs.body);
|
|
307
|
+
if (sess.id) ok("POST /v2/session creates a session", sess.id.slice(0, 8));
|
|
308
|
+
else fail("POST /v2/session", "no id returned");
|
|
309
|
+
} else {
|
|
310
|
+
fail("POST /v2/session", `status=${cs.status} body=${cs.body.slice(0, 200)}`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// List sessions (cursor pagination)
|
|
314
|
+
const ls = await get(`http://localhost:${port}/v2/session?limit=5`).catch(() => null);
|
|
315
|
+
if (ls && ls.status === 200) {
|
|
316
|
+
const list = JSON.parse(ls.body);
|
|
317
|
+
if (Array.isArray(list)) ok("GET /v2/session?limit=5 cursor pagination", `${list.length} items`);
|
|
318
|
+
else fail("GET /v2/session", "not an array");
|
|
319
|
+
} else fail("GET /v2/session", `status=${ls?.status}`);
|
|
320
|
+
|
|
321
|
+
// Cancel endpoint exists
|
|
322
|
+
const can = await post(`http://localhost:${port}/v2/session/test-id/cancel`, {}, {
|
|
323
|
+
Authorization: `Bearer ${token}`,
|
|
324
|
+
}).catch((e) => ({ error: e.message }));
|
|
325
|
+
if (can.status === 200 || can.status === 404) {
|
|
326
|
+
ok("POST /v2/session/:id/cancel endpoint exists");
|
|
327
|
+
} else if (can.error) {
|
|
328
|
+
fail("POST /v2/session/:id/cancel", can.error);
|
|
329
|
+
} else {
|
|
330
|
+
fail("POST /v2/session/:id/cancel", `unexpected status=${can.status}`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ─── Test 6: stats and export commands ────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
async function testStatsExport(port) {
|
|
337
|
+
header("[6] stats and export commands");
|
|
338
|
+
|
|
339
|
+
// Run stats against the running server
|
|
340
|
+
const s = spawnSync(process.execPath, [CLI, "stats"], {
|
|
341
|
+
encoding: "utf-8",
|
|
342
|
+
timeout: 15000,
|
|
343
|
+
env: { ...process.env, PORT: String(port), DATABASE_URL: "" },
|
|
344
|
+
cwd: ROOT,
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
if (s.status === 0) {
|
|
348
|
+
if (s.stdout.includes("OVERVIEW") || s.stdout.includes("Sessions") || s.stdout.includes("Cost")) {
|
|
349
|
+
ok("bonecode stats", "rendered cost/token table");
|
|
350
|
+
} else {
|
|
351
|
+
ok("bonecode stats", "ran (no data yet)");
|
|
352
|
+
}
|
|
353
|
+
} else {
|
|
354
|
+
fail("bonecode stats", `status=${s.status} stderr=${(s.stderr || "").slice(0, 200)}`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Export — should fail gracefully when no sessions exist
|
|
358
|
+
const e = spawnSync(process.execPath, [CLI, "export"], {
|
|
359
|
+
encoding: "utf-8",
|
|
360
|
+
timeout: 10000,
|
|
361
|
+
env: { ...process.env, PORT: String(port), DATABASE_URL: "" },
|
|
362
|
+
cwd: ROOT,
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// Exit 0 (with output) or 1 (with "no sessions") are both acceptable
|
|
366
|
+
if (e.status === 0 || (e.status === 1 && /no session/i.test(e.stderr + e.stdout))) {
|
|
367
|
+
ok("bonecode export", e.status === 0 ? "exported" : "no sessions yet");
|
|
368
|
+
} else {
|
|
369
|
+
// Allow non-zero if the error is graceful
|
|
370
|
+
if (e.stdout || e.stderr) ok("bonecode export", "ran with no crash");
|
|
371
|
+
else fail("bonecode export", `status=${e.status} no output`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ─── Cleanup ─────────────────────────────────────────────────────────────────
|
|
376
|
+
|
|
377
|
+
function cleanup() {
|
|
378
|
+
if (serverProc && !serverProc.killed) {
|
|
379
|
+
try {
|
|
380
|
+
if (process.platform === "win32") {
|
|
381
|
+
spawnSync("taskkill", ["/pid", String(serverProc.pid), "/f", "/t"], { stdio: "ignore" });
|
|
382
|
+
} else {
|
|
383
|
+
serverProc.kill("SIGTERM");
|
|
384
|
+
}
|
|
385
|
+
} catch {}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ─── Main ────────────────────────────────────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
async function main() {
|
|
392
|
+
console.log(`${B}${C}BoneCode v1.2 sanity tests${N}`);
|
|
393
|
+
|
|
394
|
+
await testArtifacts();
|
|
395
|
+
await testCli();
|
|
396
|
+
await testTuiHelpers();
|
|
397
|
+
|
|
398
|
+
const port = await testServer();
|
|
399
|
+
if (port) {
|
|
400
|
+
await testApi(port);
|
|
401
|
+
await testStatsExport(port);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
cleanup();
|
|
405
|
+
|
|
406
|
+
console.log();
|
|
407
|
+
if (failed === 0) {
|
|
408
|
+
console.log(`${G}${B}✓ All ${passed} tests passed${N}`);
|
|
409
|
+
process.exit(0);
|
|
410
|
+
} else {
|
|
411
|
+
console.log(`${R}${B}✗ ${failed} failed, ${passed} passed${N}`);
|
|
412
|
+
for (const f of failures) console.log(` ${R}- ${f}${N}`);
|
|
413
|
+
process.exit(1);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
process.on("SIGINT", () => { cleanup(); process.exit(130); });
|
|
418
|
+
process.on("uncaughtException", (e) => { console.error(`${R}uncaught:${N}`, e); cleanup(); process.exit(1); });
|
|
419
|
+
|
|
420
|
+
main().catch((e) => { console.error(`${R}error:${N}`, e); cleanup(); process.exit(1); });
|
package/src/cli.ts
CHANGED
|
@@ -384,17 +384,22 @@ async function cmdStats(args: string[]) {
|
|
|
384
384
|
const port = parseInt(process.env.PORT || "3000");
|
|
385
385
|
await ensureServer(port);
|
|
386
386
|
|
|
387
|
-
//
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
await
|
|
387
|
+
// Call the server's /v2/stats endpoint — uses the same DB the server is connected to
|
|
388
|
+
const params = new URLSearchParams();
|
|
389
|
+
if (days !== undefined) params.set("days", String(days));
|
|
390
|
+
if (projectFilter) params.set("project", projectFilter);
|
|
391
|
+
const url = `http://localhost:${port}/v2/stats${params.toString() ? "?" + params.toString() : ""}`;
|
|
392
|
+
|
|
393
|
+
console.log(`${c.dim}Aggregating stats...${c.reset}`);
|
|
394
|
+
const r = await fetch(url);
|
|
395
|
+
if (!r.ok) {
|
|
396
|
+
console.error(`${c.red}Error:${c.reset} ${await r.text()}`);
|
|
397
|
+
process.exit(1);
|
|
397
398
|
}
|
|
399
|
+
const stats = await r.json() as any;
|
|
400
|
+
|
|
401
|
+
const { displayStats } = await import("./stats");
|
|
402
|
+
displayStats(stats, toolLimit, modelLimit);
|
|
398
403
|
}
|
|
399
404
|
|
|
400
405
|
async function cmdExport(args: string[]) {
|
|
@@ -403,27 +408,31 @@ async function cmdExport(args: string[]) {
|
|
|
403
408
|
const port = parseInt(process.env.PORT || "3000");
|
|
404
409
|
await ensureServer(port);
|
|
405
410
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
// Pick the most recent session
|
|
414
|
-
const sessions = await listSessionsForExport(pool);
|
|
415
|
-
if (sessions.length === 0) {
|
|
416
|
-
console.error(`${c.red}No sessions found${c.reset}`);
|
|
417
|
-
process.exit(1);
|
|
418
|
-
}
|
|
419
|
-
sid = sessions[0].id;
|
|
420
|
-
console.error(`${c.dim}Exporting most recent session: ${sid}${c.reset}`);
|
|
411
|
+
// Resolve session ID — if not provided, list and pick the most recent
|
|
412
|
+
let sid = sessionId;
|
|
413
|
+
if (!sid) {
|
|
414
|
+
const r = await fetch(`http://localhost:${port}/v2/session?limit=1`);
|
|
415
|
+
if (!r.ok) {
|
|
416
|
+
console.error(`${c.red}Error:${c.reset} ${await r.text()}`);
|
|
417
|
+
process.exit(1);
|
|
421
418
|
}
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
419
|
+
const list = await r.json() as any[];
|
|
420
|
+
if (!Array.isArray(list) || list.length === 0) {
|
|
421
|
+
console.error(`${c.red}No sessions found${c.reset}`);
|
|
422
|
+
process.exit(1);
|
|
423
|
+
}
|
|
424
|
+
sid = list[0].id;
|
|
425
|
+
console.error(`${c.dim}Exporting most recent session: ${sid}${c.reset}`);
|
|
426
426
|
}
|
|
427
|
+
|
|
428
|
+
const url = `http://localhost:${port}/v2/session/${sid}/export${sanitize ? "?sanitize=true" : ""}`;
|
|
429
|
+
const r = await fetch(url);
|
|
430
|
+
if (!r.ok) {
|
|
431
|
+
console.error(`${c.red}Error:${c.reset} ${await r.text()}`);
|
|
432
|
+
process.exit(1);
|
|
433
|
+
}
|
|
434
|
+
const json = await r.text();
|
|
435
|
+
process.stdout.write(json + "\n");
|
|
427
436
|
}
|
|
428
437
|
|
|
429
438
|
async function cmdWeb(args: string[]) {
|
|
@@ -670,7 +679,32 @@ async function main() {
|
|
|
670
679
|
}
|
|
671
680
|
}
|
|
672
681
|
|
|
673
|
-
main()
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
682
|
+
main()
|
|
683
|
+
.then(() => {
|
|
684
|
+
// For short-lived commands (status, stats, export, etc.), set exit code 0
|
|
685
|
+
// and let Node terminate naturally. Calling process.exit(0) immediately
|
|
686
|
+
// after stdout writes can trigger a libuv UV_HANDLE_CLOSING assertion on
|
|
687
|
+
// Windows + Node v24.
|
|
688
|
+
const cmd = process.argv[2];
|
|
689
|
+
const longRunning = ["serve", "web", "run"].includes(cmd) ||
|
|
690
|
+
!cmd ||
|
|
691
|
+
cmd === undefined ||
|
|
692
|
+
(cmd && !cmd.startsWith("-") && !["status", "stats", "export", "compile", "migrate", "pr", "github", "help"].includes(cmd));
|
|
693
|
+
if (!longRunning) {
|
|
694
|
+
process.exitCode = 0;
|
|
695
|
+
// Schedule a hard exit after stdout drains, in case lingering handles
|
|
696
|
+
// (node-pg, etc.) keep the loop alive.
|
|
697
|
+
setImmediate(() => {
|
|
698
|
+
if ((process.stdout as any).writableNeedDrain) {
|
|
699
|
+
process.stdout.once("drain", () => process.exit(0));
|
|
700
|
+
} else {
|
|
701
|
+
// Give libuv a moment to drain async work, then exit
|
|
702
|
+
setTimeout(() => process.exit(0), 50);
|
|
703
|
+
}
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
})
|
|
707
|
+
.catch((e) => {
|
|
708
|
+
console.error(`${c.red}Error:${c.reset} ${e.message}`);
|
|
709
|
+
process.exit(1);
|
|
710
|
+
});
|
|
@@ -354,6 +354,16 @@ async function streamOnce(ctx: {
|
|
|
354
354
|
tool_name: event.toolName, tool_input: toolArgs, requested_at: new Date().toISOString(),
|
|
355
355
|
}, "AgentLoop");
|
|
356
356
|
|
|
357
|
+
// Also broadcast directly to part_stream so the TUI gets it inline
|
|
358
|
+
// with text deltas (same channel, guaranteed ordering)
|
|
359
|
+
broadcastToChannel("part_stream", {
|
|
360
|
+
type: "tool.requested",
|
|
361
|
+
session_id,
|
|
362
|
+
tool_call_id: toolCallDbId,
|
|
363
|
+
tool_name: event.toolName,
|
|
364
|
+
tool_input: toolArgs,
|
|
365
|
+
});
|
|
366
|
+
|
|
357
367
|
// Doom-loop detection: 3 identical consecutive tool calls
|
|
358
368
|
const recentSame = toolCallsThisTurn.filter(
|
|
359
369
|
tc => tc.name === event.toolName && JSON.stringify(tc.input) === JSON.stringify(toolArgs)
|
|
@@ -7,6 +7,8 @@ import { tool } from "ai";
|
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
import { execute_tool_calls } from "../../../bone/output/session/src/extensions";
|
|
9
9
|
import { pool } from "../../../bone/output/session/src/db";
|
|
10
|
+
import { broadcastToChannel } from "../../../bone/output/session/src/websocket";
|
|
11
|
+
import { eventBus } from "../../../bone/output/session/src/events";
|
|
10
12
|
|
|
11
13
|
export async function buildToolRegistry(
|
|
12
14
|
session_id: string,
|
|
@@ -15,18 +17,47 @@ export async function buildToolRegistry(
|
|
|
15
17
|
): Promise<Record<string, any>> {
|
|
16
18
|
const tools: Record<string, any> = {};
|
|
17
19
|
|
|
18
|
-
// Helper: wrap a tool so execution goes through our BoneCode extension point
|
|
20
|
+
// Helper: wrap a tool so execution goes through our BoneCode extension point.
|
|
21
|
+
// Broadcasts tool.completed to part_stream after each call so the TUI can
|
|
22
|
+
// show "✓ Edit src/foo.ts 0.3s" inline with the text stream.
|
|
19
23
|
const makeTool = (name: string, description: string, schema: z.ZodObject<any>) => {
|
|
20
24
|
return tool({
|
|
21
25
|
description,
|
|
22
26
|
parameters: schema,
|
|
23
27
|
execute: async (args: any, opts: any) => {
|
|
28
|
+
const callId = (opts as any)?.toolCallId || name + "-" + Date.now();
|
|
29
|
+
const startMs = Date.now();
|
|
30
|
+
|
|
24
31
|
const results = await execute_tool_calls(session_id, [{
|
|
25
|
-
id:
|
|
32
|
+
id: callId,
|
|
26
33
|
tool_name: name,
|
|
27
34
|
tool_input: args,
|
|
28
35
|
}]);
|
|
36
|
+
|
|
37
|
+
const durationMs = Date.now() - startMs;
|
|
29
38
|
const result = results[0]?.result;
|
|
39
|
+
const success = result?.success !== false;
|
|
40
|
+
|
|
41
|
+
// Broadcast completion so the TUI updates the tool line immediately
|
|
42
|
+
broadcastToChannel("part_stream", {
|
|
43
|
+
type: success ? "tool.completed" : "tool.failed",
|
|
44
|
+
session_id,
|
|
45
|
+
tool_call_id: callId,
|
|
46
|
+
tool_name: name,
|
|
47
|
+
tool_input: args,
|
|
48
|
+
duration_ms: durationMs,
|
|
49
|
+
...(success ? {} : { error: result?.error || "failed" }),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Also publish to eventBus for persistence / other subscribers
|
|
53
|
+
await eventBus.publish("ToolCallCompleted", {
|
|
54
|
+
tool_call_id: callId,
|
|
55
|
+
session_id,
|
|
56
|
+
tool_name: name,
|
|
57
|
+
duration_ms: durationMs,
|
|
58
|
+
completed_at: new Date().toISOString(),
|
|
59
|
+
}, "ToolRegistry").catch(() => {});
|
|
60
|
+
|
|
30
61
|
if (!result) return { output: "", title: name };
|
|
31
62
|
return { output: result.output || result.error || "", title: name };
|
|
32
63
|
},
|