@bongos/core 1.25.12 → 1.25.13

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 (31) hide show
  1. package/.bongos-core.json +65 -30
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +11 -2
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/api/openapi.json +192 -11
  8. package/docs/api-reference.md +5 -2
  9. package/docs/file-map.md +1 -1
  10. package/docs/module-api-changelog.md +2 -0
  11. package/modules/front-door-guide/claude.js +11 -7
  12. package/modules/front-door-guide/context.js +54 -0
  13. package/modules/front-door-guide/db.js +71 -6
  14. package/modules/front-door-guide/guide.js +10 -1
  15. package/modules/front-door-guide/migrations/front_door_guide_002_plan_and_account.sql +37 -0
  16. package/modules/front-door-guide/module.json +7 -3
  17. package/modules/front-door-guide/plan.js +93 -0
  18. package/modules/front-door-guide/pollers/retention-sweep.js +67 -0
  19. package/modules/front-door-guide/routes/front-door-guide-plan.js +144 -0
  20. package/modules/front-door-guide/routes/front-door-guide.js +19 -29
  21. package/modules/front-door-guide/settings.js +4 -0
  22. package/modules/provisioning/module.json +2 -1
  23. package/modules/provisioning/seams.js +10 -0
  24. package/package-lock.json +2 -2
  25. package/package.json +1 -1
  26. package/release-notes.json +6 -0
  27. package/scripts/bongos/run-unit-tests.js +4 -0
  28. package/src/bongos/module-scope-map.js +1 -0
  29. package/src/module-api.js +1 -1
  30. package/tests/front_door_guide_db.mjs +110 -0
  31. package/tests/front_door_guide_plan.mjs +344 -0
@@ -0,0 +1,344 @@
1
+ // tests/front_door_guide_plan.mjs — where the start-a-project chat ends (task 1005273): a
2
+ // plan from structured output that names only modules in the live catalog, saved and
3
+ // readable again after a browser restart, attached to the account at sign-up, and swept
4
+ // away when nobody signs up.
5
+ //
6
+ // The REAL routers over an in-memory db and a mocked Claude client (no key, no network, no
7
+ // spend), with the catalog served by the REAL provisioning port, so "only modules in the
8
+ // catalog" is checked against the list the project-creation picker actually offers.
9
+ // The SQL itself (the migration, the sweep, the attach rule) is in
10
+ // tests/front_door_guide_db.mjs, against a real Postgres.
11
+
12
+ import { test } from 'node:test';
13
+ import { strict as assert } from 'node:assert';
14
+ import { createRequire } from 'node:module';
15
+ import http from 'node:http';
16
+
17
+ const require = createRequire(import.meta.url);
18
+ const api = require('../src/module-api.js');
19
+ const db = require('../modules/front-door-guide/db.js');
20
+ const claude = require('../modules/front-door-guide/claude.js');
21
+ const G = require('../modules/front-door-guide/guide.js');
22
+ const P = require('../modules/front-door-guide/plan.js');
23
+ const sweep = require('../modules/front-door-guide/pollers/retention-sweep.js');
24
+ const { optionalModules } = require('../modules/provisioning/starter-bundles.js');
25
+
26
+ const ENV = 'BONGOS_FRONT_DOOR_GUIDE_';
27
+
28
+ // ---- the live catalog: the provisioning module's own port --------------------------------
29
+
30
+ require('../modules/provisioning/seams.js').registerProvisioningSeams();
31
+ const CATALOG_KEYS = optionalModules().map((m) => m.key);
32
+
33
+ // ---- an in-memory db with the SQL's rules ------------------------------------------------
34
+
35
+ const convs = new Map();
36
+ let calls = [];
37
+ let seq = 0;
38
+ const copy = (c) => c && JSON.parse(JSON.stringify(c));
39
+ const nowIso = () => new Date().toISOString();
40
+ Object.assign(db, {
41
+ createConversation: async (tokenHash, visitorHash) => {
42
+ const c = { id: String(++seq), token_hash: tokenHash, visitor_hash: visitorHash, messages: [], visitor_messages: 0, spend_micro: 0, status: 'open', end_reason: null, created_at: new Date(Date.now() - 10_000).toISOString(), updated_at: nowIso(), plan: null, planned_at: null, builder_id: null, attached_at: null };
43
+ convs.set(c.id, c);
44
+ return copy(c);
45
+ },
46
+ getConversation: async (id, tokenHash) => { const c = convs.get(String(id)); return c && c.token_hash === tokenHash ? copy(c) : null; },
47
+ siteSpendMicro: async () => calls.reduce((s, r) => s + r.cost_micro, 0),
48
+ visitorUsage: async (v) => {
49
+ const mine = calls.filter((r) => r.visitor_hash === v);
50
+ return { messages: mine.filter((r) => r.kind === 'reply').length, spendMicro: mine.reduce((s, r) => s + r.cost_micro, 0) };
51
+ },
52
+ recordTurn: async (id, { messages, status, endReason }, call) => {
53
+ calls.push({ conversation_id: String(id), visitor_hash: call.visitorHash, cost_micro: call.costMicro, kind: 'reply' });
54
+ const c = convs.get(String(id));
55
+ Object.assign(c, { messages, status, end_reason: endReason, visitor_messages: c.visitor_messages + 1, updated_at: nowIso() });
56
+ return copy(c);
57
+ },
58
+ endConversation: async (id, reason) => Object.assign(convs.get(String(id)), { status: 'ended', end_reason: reason }),
59
+ savePlan: async (id, plan, call) => {
60
+ calls.push({ conversation_id: String(id), visitor_hash: call.visitorHash, cost_micro: call.costMicro, kind: 'plan' });
61
+ const c = convs.get(String(id));
62
+ Object.assign(c, { plan: c.plan || plan, planned_at: c.planned_at || nowIso(), status: 'ended', end_reason: 'planned', updated_at: nowIso() });
63
+ return copy(c);
64
+ },
65
+ recordPlanSpend: async (id, call) => { calls.push({ conversation_id: String(id), visitor_hash: call.visitorHash, cost_micro: call.costMicro, kind: 'plan' }); },
66
+ attachToBuilder: async (id, tokenHash, builderId) => {
67
+ const c = convs.get(String(id));
68
+ if (!c || c.token_hash !== tokenHash || (c.builder_id != null && c.builder_id !== builderId)) return null;
69
+ Object.assign(c, { builder_id: builderId, attached_at: c.attached_at || nowIso() });
70
+ return copy(c);
71
+ },
72
+ latestForBuilder: async (builderId) => copy([...convs.values()].filter((c) => c.builder_id === builderId).pop() || null),
73
+ });
74
+
75
+ // ---- the mocked Claude client --------------------------------------------------------
76
+
77
+ let requests = [];
78
+ let planReply = null;
79
+ const chatReply = () => ({ model: 'claude-opus-5-5', stop_reason: 'end_turn', content: [{ type: 'text', text: 'Who is it for?' }], usage: { input_tokens: 700, output_tokens: 150 } });
80
+ const goodPlan = (modules) => JSON.stringify({
81
+ summary: 'A booking site for a small yoga studio.',
82
+ project_type: 'business',
83
+ how_it_works: ['You describe what you want.', 'AI builders do small tasks.'],
84
+ modules,
85
+ steps: [{ title: 'Sign up', detail: 'Create your account.' }, { title: 'Create the project', detail: 'Name it.' }],
86
+ needs: [{ item: 'A payments account', detail: 'To take bookings.' }],
87
+ open_questions: ['Do classes repeat weekly?'],
88
+ });
89
+ claude.createClient = () => ({ messages: { create: async (req) => {
90
+ requests.push(req);
91
+ if (req.output_config && req.output_config.format) {
92
+ return planReply ? planReply(req) : { model: 'claude-opus-5-5', stop_reason: 'end_turn', content: [{ type: 'text', text: goodPlan([{ key: CATALOG_KEYS[0], why: 'It fits.' }]) }], usage: { input_tokens: 2000, output_tokens: 600 } };
93
+ }
94
+ return chatReply();
95
+ } } });
96
+
97
+ // ---- a stand-in sign-in: x-test-builder is the signed-in account --------------------------
98
+
99
+ api.requireBuilder = (req, res, next) => {
100
+ const id = req.get('x-test-builder');
101
+ if (!id) return res.status(401).json({ error: 'unauthenticated' });
102
+ req.builder = { id: Number(id) };
103
+ return next();
104
+ };
105
+
106
+ const express = require('express');
107
+ const app = express();
108
+ app.set('trust proxy', true);
109
+ app.use(express.json({ limit: '64kb' }));
110
+ app.use('/api/bongos', require('../modules/front-door-guide/routes/front-door-guide.js')());
111
+ app.use('/api/bongos', require('../modules/front-door-guide/routes/front-door-guide-plan.js')());
112
+ const server = app.listen(0);
113
+ await new Promise((r) => server.once('listening', r));
114
+ const PORT = server.address().port;
115
+ test.after(() => server.close());
116
+
117
+ let ipSeq = 0;
118
+ function call(method, path, { body, ip, headers = {} } = {}) {
119
+ return new Promise((resolve, reject) => {
120
+ const data = body === undefined ? null : JSON.stringify(body);
121
+ const h = { 'content-type': 'application/json', 'user-agent': 'Mozilla/5.0 (test browser)', 'x-forwarded-for': ip || `10.9.0.${++ipSeq}`, ...headers };
122
+ const req = http.request({ host: '127.0.0.1', port: PORT, method, path: `/api/bongos${path}`, headers: h }, (res) => {
123
+ let buf = '';
124
+ res.on('data', (c) => { buf += c; });
125
+ res.on('end', () => resolve({ status: res.statusCode, body: buf ? JSON.parse(buf) : null }));
126
+ });
127
+ req.on('error', reject);
128
+ if (data) req.write(data);
129
+ req.end();
130
+ });
131
+ }
132
+
133
+ function reset(over = {}) {
134
+ for (const k of Object.keys(process.env)) if (k.startsWith(ENV)) delete process.env[k];
135
+ const all = { ANTHROPIC_API_KEY: 'sk-ant-TEST-ONLY-0123456789abcdef', MIN_FIRST_MESSAGE_MS: '1', STARTS_PER_HOUR: '1000', MESSAGES_PER_MINUTE: '1000', ...over };
136
+ for (const [k, v] of Object.entries(all)) if (v !== null) process.env[ENV + k] = String(v);
137
+ convs.clear(); calls = []; requests = []; planReply = null;
138
+ }
139
+
140
+ async function chatWithOneMessage(ip) {
141
+ const st = await call('POST', '/front-door-guide/conversations', { body: {}, ip });
142
+ const conv = st.body.conversation;
143
+ await call('POST', `/front-door-guide/conversations/${conv.id}/messages`, { body: { token: conv.token, text: 'A booking site for my yoga studio.' }, ip });
144
+ return { conv, start: st.body };
145
+ }
146
+ const askPlan = (conv, ip) => call('POST', `/front-door-guide/conversations/${conv.id}/plan`, { body: { token: conv.token }, ip });
147
+
148
+ // ---- the plan -----------------------------------------------------------------------------
149
+
150
+ test('the catalog the plan reads is the provisioning picker\'s own list', async () => {
151
+ const port = api.resolveOptional('provisioning.moduleCatalog');
152
+ assert.equal(typeof port, 'function');
153
+ const c = await port();
154
+ assert.deepEqual(c.optional.map((m) => m.key), CATALOG_KEYS);
155
+ assert.ok(CATALOG_KEYS.length > 0);
156
+ for (const m of c.optional) assert.ok(m.label && m.summary, `${m.key} carries owner-facing words`);
157
+ });
158
+
159
+ test('the plan is structured output and names only catalog modules, in the catalog\'s words', async () => {
160
+ reset();
161
+ const ip = '10.8.0.1';
162
+ const { conv } = await chatWithOneMessage(ip);
163
+ planReply = () => ({
164
+ model: 'claude-opus-5-5', stop_reason: 'end_turn', usage: { input_tokens: 2000, output_tokens: 600 },
165
+ content: [{ type: 'text', text: goodPlan([
166
+ { key: CATALOG_KEYS[0], why: 'Fits.' },
167
+ { key: 'made-up-module', why: 'Invented.' },
168
+ { key: 'lifecycle', why: 'Core, not optional.' },
169
+ { key: CATALOG_KEYS[0], why: 'Twice.' },
170
+ ]) }],
171
+ });
172
+ const r = await askPlan(conv, ip);
173
+ assert.equal(r.status, 200);
174
+ assert.equal(r.body.mode, 'plan');
175
+ assert.deepEqual(r.body.next, { action: 'sign_up', label: 'Sign up and get started' });
176
+ const real = optionalModules().find((m) => m.key === CATALOG_KEYS[0]);
177
+ assert.deepEqual(r.body.plan.modules, [{ key: real.key, label: real.label, summary: real.summary, why: 'Fits.' }]);
178
+ assert.equal(r.body.plan.project_type, 'business');
179
+ assert.equal(r.body.plan.cost_and_time, P.COST_AND_TIME, 'cost and time are named, never estimated');
180
+ assert.ok(r.body.plan.steps.length && r.body.plan.needs.length);
181
+
182
+ // The request asked for JSON against a schema whose module keys ARE the catalog.
183
+ const req = requests.find((q) => q.output_config && q.output_config.format);
184
+ assert.equal(req.output_config.format.type, 'json_schema');
185
+ assert.deepEqual(req.output_config.format.schema.properties.modules.items.properties.key.enum, CATALOG_KEYS);
186
+ assert.equal(req.max_tokens, 4000);
187
+ for (const k of CATALOG_KEYS) assert.ok(req.system.includes(`- ${k}:`), `the instructions list ${k}`);
188
+ assert.equal(calls.filter((c) => c.kind === 'plan').length, 1, 'the plan call is recorded as spend');
189
+ });
190
+
191
+ test('a plan is written once: asking again re-reads it, and the chat now ends with sign-up', async () => {
192
+ reset();
193
+ const ip = '10.8.0.2';
194
+ const { conv } = await chatWithOneMessage(ip);
195
+ const first = await askPlan(conv, ip);
196
+ const again = await askPlan(conv, ip);
197
+ assert.deepEqual(again.body.plan, first.body.plan);
198
+ assert.equal(requests.filter((q) => q.output_config.format).length, 1);
199
+ const more = await call('POST', `/front-door-guide/conversations/${conv.id}/messages`, { body: { token: conv.token, text: 'one more thing' }, ip });
200
+ assert.equal(more.body.mode, 'ended');
201
+ assert.equal(more.body.reason, 'planned');
202
+ assert.equal(more.body.next_step, 'sign_up');
203
+ });
204
+
205
+ test('no plan before the visitor has said anything; a plan does not use up a message', async () => {
206
+ reset({ MAX_MESSAGES: 2 });
207
+ const ip = '10.8.0.3';
208
+ const st = await call('POST', '/front-door-guide/conversations', { body: {}, ip });
209
+ assert.equal((await askPlan(st.body.conversation, ip)).status, 409);
210
+ const { conv } = await chatWithOneMessage(ip);
211
+ await askPlan(conv, ip);
212
+ const used = await db.visitorUsage(convs.get(conv.id).visitor_hash);
213
+ assert.equal(used.messages, 1, 'only the reply counts as a message');
214
+ });
215
+
216
+ test('a plan that cannot be used (refused, cut off, not JSON) costs what it cost and offers the form', async () => {
217
+ for (const reply of [
218
+ { stop_reason: 'refusal', content: [] },
219
+ { stop_reason: 'max_tokens', content: [{ type: 'text', text: '{"summary": "half' }] },
220
+ { stop_reason: 'end_turn', content: [{ type: 'text', text: 'not json' }] },
221
+ ]) {
222
+ reset();
223
+ const ip = `10.8.1.${calls.length + Math.floor(Math.random() * 200)}`;
224
+ const { conv } = await chatWithOneMessage(ip);
225
+ planReply = () => ({ model: 'claude-opus-5-5', usage: { input_tokens: 2000, output_tokens: 50 }, ...reply });
226
+ const r = await askPlan(conv, ip);
227
+ assert.deepEqual(r.body, G.formAnswer('unavailable'));
228
+ assert.equal(convs.get(conv.id).plan, null);
229
+ assert.equal(calls.filter((c) => c.kind === 'plan').length, 1, `${reply.stop_reason}: the spend is recorded`);
230
+ }
231
+ });
232
+
233
+ test('the plan is held to the site budget like a reply', async () => {
234
+ reset({ DAILY_BUDGET_CENTS: 1 });
235
+ const ip = '10.8.0.4';
236
+ const st = await call('POST', '/front-door-guide/conversations', { body: {}, ip });
237
+ convs.get(st.body.conversation.id).visitor_messages = 1; // as if they had talked
238
+ const r = await askPlan(st.body.conversation, ip);
239
+ assert.deepEqual(r.body, G.formAnswer('daily_budget'));
240
+ assert.equal(requests.length, 0);
241
+ });
242
+
243
+ // ---- saved, resumed, attached --------------------------------------------------------------
244
+
245
+ test('the visitor is told the chat is saved, and after a browser restart it all comes back', async () => {
246
+ reset({ RETENTION_DAYS: 21 });
247
+ const ip = '10.8.0.5';
248
+ const { conv, start } = await chatWithOneMessage(ip);
249
+ assert.deepEqual(start.saved, G.savedNotice(21));
250
+ assert.match(start.saved.message, /saved/);
251
+ assert.match(start.saved.message, /21 days/);
252
+ await askPlan(conv, ip);
253
+
254
+ // A new browser session: nothing but the id and token the page kept, from a new address.
255
+ const back = await call('GET', `/front-door-guide/conversations/${conv.id}`, { ip: '10.8.200.1', headers: { 'x-guide-token': conv.token } });
256
+ assert.equal(back.status, 200);
257
+ assert.equal(back.body.transcript.length, 2);
258
+ assert.equal(back.body.plan.summary, 'A booking site for a small yoga studio.');
259
+ assert.deepEqual(back.body.next, P.NEXT_STEP);
260
+ assert.equal(back.body.attached, false);
261
+ assert.equal(back.body.saved.keep_days, 21);
262
+ });
263
+
264
+ test('at sign-up the chat and plan move onto the new account, and only that account', async () => {
265
+ reset();
266
+ const ip = '10.8.0.6';
267
+ const { conv } = await chatWithOneMessage(ip);
268
+ await askPlan(conv, ip);
269
+ const attach = (builder, token = conv.token) => call('POST', `/front-door-guide/conversations/${conv.id}/attach`,
270
+ { body: { token }, headers: builder ? { 'x-test-builder': String(builder) } : {} });
271
+
272
+ assert.equal((await attach(null)).status, 401, 'attaching needs a signed-in account');
273
+ assert.equal((await attach(77, 'Z'.repeat(32))).status, 404, 'and the chat\'s token');
274
+ const ok = await attach(77);
275
+ assert.equal(ok.status, 200);
276
+ assert.equal(ok.body.conversation.plan.summary, 'A booking site for a small yoga studio.');
277
+ assert.ok(!('visitor_hash' in ok.body.conversation));
278
+ assert.equal((await attach(77)).status, 200, 'attaching again is harmless');
279
+ assert.equal((await attach(88)).status, 404, 'another account cannot take it');
280
+
281
+ const mine = await call('GET', '/front-door-guide/mine', { headers: { 'x-test-builder': '77' } });
282
+ assert.equal(mine.body.conversation.id, conv.id);
283
+ assert.deepEqual(mine.body.conversation.next, P.NEXT_STEP);
284
+ assert.equal(mine.body.conversation.transcript[0].role, 'visitor');
285
+ const theirs = await call('GET', '/front-door-guide/mine', { headers: { 'x-test-builder': '88' } });
286
+ assert.equal(theirs.body.conversation, null);
287
+ assert.equal((await call('GET', '/front-door-guide/mine')).status, 401);
288
+ });
289
+
290
+ // ---- the retention sweep ---------------------------------------------------------------
291
+
292
+ test('the retention sweep deletes after the owner\'s number of days, and only unclaimed chats', async () => {
293
+ reset({ RETENTION_DAYS: 14 });
294
+ const DAY = 86_400_000;
295
+ const rows = [
296
+ { id: 'old', builder_id: null, updated_at: Date.now() - 15 * DAY },
297
+ { id: 'recent', builder_id: null, updated_at: Date.now() - 13 * DAY },
298
+ { id: 'attached', builder_id: 5, updated_at: Date.now() - 400 * DAY },
299
+ ];
300
+ const spend = [{ conversation_id: 'old', cost_micro: 9000 }];
301
+ let askedDays = null;
302
+ // The same rule as db.deleteAbandoned's SQL, over the rows above.
303
+ const deleteAbandoned = async (days) => {
304
+ askedDays = days;
305
+ const gone = rows.filter((r) => r.builder_id == null && r.updated_at < Date.now() - days * DAY);
306
+ for (const g of gone) {
307
+ rows.splice(rows.indexOf(g), 1);
308
+ for (const s of spend) if (s.conversation_id === g.id) s.conversation_id = null;
309
+ }
310
+ return gone.length;
311
+ };
312
+ const r = await sweep.tick({ deleteAbandoned });
313
+ assert.deepEqual(r, { deleted: 1 });
314
+ assert.equal(askedDays, 14, 'the sweep reads the retention setting');
315
+ assert.deepEqual(rows.map((x) => x.id), ['recent', 'attached']);
316
+ assert.deepEqual(spend, [{ conversation_id: null, cost_micro: 9000 }], 'the money stays on the books');
317
+ assert.equal(sweep.TICK_MS, 3_600_000);
318
+
319
+ const failed = await sweep.tick({ deleteAbandoned: async () => { throw new Error('connection lost'); } });
320
+ assert.equal(failed.skipped, 'error');
321
+
322
+ // db.deleteAbandoned sends the setting as the day count and spares claimed chats.
323
+ let sent = null;
324
+ await db.deleteAbandoned.call(null, 14, async (sql, params) => { sent = { sql, params }; return { rowCount: 0 }; });
325
+ assert.deepEqual(sent.params, [14]);
326
+ assert.match(sent.sql, /builder_id IS NULL/);
327
+ });
328
+
329
+ test('plan sanitising: strings trimmed and capped, lists capped, an unknown type is not-sure', () => {
330
+ const catalog = { optional: [{ key: 'discord', label: 'Discord', summary: 'Chat.' }] };
331
+ const plan = P.sanitizePlan({
332
+ summary: ` ${'x'.repeat(2000)} `, project_type: 'casino',
333
+ how_it_works: Array(20).fill('point'), modules: [{ key: 'discord', why: 'yes' }],
334
+ steps: Array(30).fill({ title: 't', detail: 'd' }), needs: [{ item: '', detail: 'dropped' }], open_questions: [],
335
+ }, catalog);
336
+ assert.equal(plan.summary.length, 600);
337
+ assert.equal(plan.project_type, 'not-sure');
338
+ assert.equal(plan.how_it_works.length, 6);
339
+ assert.equal(plan.steps.length, 10);
340
+ assert.deepEqual(plan.needs, []);
341
+ assert.equal(P.sanitizePlan({ summary: '' }, catalog), null);
342
+ const noCatalog = P.planSchema([]);
343
+ assert.equal(noCatalog.properties.modules.items.properties.key.enum, undefined);
344
+ });