@davvidbaker/flambe-cli 0.1.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,951 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import test from 'node:test';
6
+
7
+ import { FlambeClient, configFromEnv } from '../src/client.mjs';
8
+ import { run } from '../src/cli.mjs';
9
+ import { loadProjectEnv } from '../src/env.mjs';
10
+
11
+ function jsonResponse(data, status = 200) {
12
+ return new Response(JSON.stringify(data), {
13
+ status,
14
+ headers: { 'content-type': 'application/json' },
15
+ });
16
+ }
17
+
18
+ function emptyTrace(traceId = 3) {
19
+ return {
20
+ id: traceId,
21
+ name: 'Work',
22
+ threads: [{ id: 1, name: 'Main', rank: 0 }],
23
+ events: [],
24
+ };
25
+ }
26
+
27
+ test('configFromEnv requires exactly the agent-facing configuration', () => {
28
+ assert.deepEqual(
29
+ configFromEnv({
30
+ FLAMBE_URL: 'http://localhost:4001/',
31
+ FLAMBE_API_TOKEN: 'flb_secret',
32
+ FLAMBE_TRACE_ID: '7',
33
+ }),
34
+ { baseUrl: 'http://localhost:4001', token: 'flb_secret', traceId: 7 },
35
+ );
36
+
37
+ assert.throws(() => configFromEnv({}), /FLAMBE_URL, FLAMBE_API_TOKEN, FLAMBE_TRACE_ID/);
38
+ assert.throws(
39
+ () => configFromEnv({ FLAMBE_URL: 'x', FLAMBE_API_TOKEN: 'y', FLAMBE_TRACE_ID: 'nope' }),
40
+ /positive integer/,
41
+ );
42
+ });
43
+
44
+ test('configFromEnv derives an agent identity from the Codex session when needed', () => {
45
+ const config = configFromEnv({
46
+ FLAMBE_URL: 'http://localhost:4001',
47
+ FLAMBE_API_TOKEN: 'flb_secret',
48
+ FLAMBE_TRACE_ID: '7',
49
+ CODEX_SESSION_ID: 'session-1',
50
+ CODEX_THREAD_ID: 'thread-2',
51
+ });
52
+
53
+ assert.equal(config.agentId, 'codex:session-1:thread-2');
54
+ assert.equal(config.agentName, undefined);
55
+ assert.equal(configFromEnv({
56
+ FLAMBE_URL: 'http://localhost:4001',
57
+ FLAMBE_API_TOKEN: 'flb_secret',
58
+ FLAMBE_TRACE_ID: '7',
59
+ CODEX_SESSION_ID: 'session-1',
60
+ CODEX_THREAD_ID: 'thread-2',
61
+ FLAMBE_AGENT_ID: 'steve',
62
+ }).agentId, 'steve');
63
+ });
64
+
65
+ test('configFromEnv derives a Cursor agent identity and optional display name', () => {
66
+ const config = configFromEnv({
67
+ FLAMBE_URL: 'http://localhost:4001',
68
+ FLAMBE_API_TOKEN: 'flb_secret',
69
+ FLAMBE_TRACE_ID: '7',
70
+ CURSOR_AGENT: '1',
71
+ CURSOR_CONVERSATION_ID: 'conv-99',
72
+ FLAMBE_AGENT_NAME: ' Grok ',
73
+ });
74
+
75
+ assert.equal(config.agentId, 'cursor:conv-99');
76
+ assert.equal(config.agentName, 'Grok');
77
+ assert.equal(configFromEnv({
78
+ FLAMBE_URL: 'http://localhost:4001',
79
+ FLAMBE_API_TOKEN: 'flb_secret',
80
+ FLAMBE_TRACE_ID: '7',
81
+ CURSOR_CONVERSATION_ID: 'conv-99',
82
+ }).agentId, undefined);
83
+ });
84
+
85
+ test('configFromEnv derives a Claude Code agent identity from the session', () => {
86
+ const config = configFromEnv({
87
+ FLAMBE_URL: 'http://localhost:4001',
88
+ FLAMBE_API_TOKEN: 'flb_secret',
89
+ FLAMBE_TRACE_ID: '7',
90
+ CLAUDECODE: '1',
91
+ CLAUDE_CODE_SESSION_ID: 'sess-42',
92
+ FLAMBE_AGENT_NAME: 'Claude',
93
+ });
94
+
95
+ assert.equal(config.agentId, 'claude:sess-42');
96
+ assert.equal(config.agentName, 'Claude');
97
+ assert.equal(configFromEnv({
98
+ FLAMBE_URL: 'http://localhost:4001',
99
+ FLAMBE_API_TOKEN: 'flb_secret',
100
+ FLAMBE_TRACE_ID: '7',
101
+ CLAUDECODE: '1',
102
+ }).agentId, undefined);
103
+ assert.equal(configFromEnv({
104
+ FLAMBE_URL: 'http://localhost:4001',
105
+ FLAMBE_API_TOKEN: 'flb_secret',
106
+ FLAMBE_TRACE_ID: '7',
107
+ CLAUDE_CODE_SESSION_ID: 'sess-42',
108
+ }).agentId, undefined);
109
+ assert.equal(configFromEnv({
110
+ FLAMBE_URL: 'http://localhost:4001',
111
+ FLAMBE_API_TOKEN: 'flb_secret',
112
+ FLAMBE_TRACE_ID: '7',
113
+ CLAUDE_CODE_CHILD_SESSION: '1',
114
+ CLAUDE_SESSION_ID: 'hook-session',
115
+ }).agentId, 'claude:hook-session');
116
+ });
117
+
118
+ test('loadProjectEnv reads .env from cwd without overriding existing environment values', () => {
119
+ const directory = mkdtempSync(join(tmpdir(), 'flambe-env-'));
120
+
121
+ try {
122
+ writeFileSync(
123
+ join(directory, '.env'),
124
+ [
125
+ 'FLAMBE_URL=http://from-dotenv:4001',
126
+ 'FLAMBE_API_TOKEN="flb_from_dotenv"',
127
+ 'FLAMBE_TRACE_ID=27',
128
+ ].join('\n'),
129
+ );
130
+
131
+ const env = { FLAMBE_URL: 'http://from-shell:4001' };
132
+ assert.equal(loadProjectEnv({ cwd: directory, env }), join(directory, '.env'));
133
+ assert.deepEqual(env, {
134
+ FLAMBE_URL: 'http://from-shell:4001',
135
+ FLAMBE_API_TOKEN: 'flb_from_dotenv',
136
+ FLAMBE_TRACE_ID: '27',
137
+ });
138
+ assert.deepEqual(configFromEnv(env), {
139
+ baseUrl: 'http://from-shell:4001',
140
+ token: 'flb_from_dotenv',
141
+ traceId: 27,
142
+ });
143
+ } finally {
144
+ rmSync(directory, { recursive: true, force: true });
145
+ }
146
+ });
147
+
148
+ test('loadProjectEnv is a no-op when the project has no .env', () => {
149
+ const directory = mkdtempSync(join(tmpdir(), 'flambe-no-env-'));
150
+
151
+ try {
152
+ const env = { KEEP_ME: 'yes' };
153
+ assert.equal(loadProjectEnv({ cwd: directory, env }), null);
154
+ assert.deepEqual(env, { KEEP_ME: 'yes' });
155
+ } finally {
156
+ rmSync(directory, { recursive: true, force: true });
157
+ }
158
+ });
159
+
160
+ test('start discovers the default thread and posts an authenticated begin event', async () => {
161
+ const requests = [];
162
+ const fetchImpl = async (url, options = {}) => {
163
+ requests.push({ url, options });
164
+ if (options.method === undefined || options.method === 'GET') {
165
+ return jsonResponse({ data: { id: 9, threads: [{ id: 12, name: 'Later', rank: 2 }, { id: 4, name: 'Main', rank: 0 }] } });
166
+ }
167
+ return jsonResponse({ data: { activity: { id: 42 }, event: { id: 50 } } }, 201);
168
+ };
169
+
170
+ const client = new FlambeClient({
171
+ baseUrl: 'http://flambe.test/',
172
+ token: 'flb_test_token',
173
+ traceId: 9,
174
+ fetchImpl,
175
+ now: () => 1_788_360_000_123,
176
+ });
177
+
178
+ assert.equal(await client.start({ name: 'Inspect authentication flow', categoryIds: ['5', '5', '8'] }), 42);
179
+ assert.equal(requests.length, 2);
180
+ assert.equal(requests[0].url, 'http://flambe.test/api/traces/9');
181
+ assert.equal(requests[1].url, 'http://flambe.test/api/activities');
182
+ assert.equal(requests[1].options.headers.authorization, 'Bearer flb_test_token');
183
+ assert.deepEqual(JSON.parse(requests[1].options.body), {
184
+ trace_id: 9,
185
+ thread_id: 4,
186
+ activity: { name: 'Inspect authentication flow', categories: [5, 8] },
187
+ event: { timestamp_integer: 1_788_360_000_123, phase: 'B' },
188
+ });
189
+ });
190
+
191
+ test('includes the stable agent instance ID when configured', async () => {
192
+ let request;
193
+ const client = new FlambeClient({
194
+ baseUrl: 'http://flambe.test', token: 'secret', traceId: 3, agentId: 'agent-session-7',
195
+ fetchImpl: async (url, options = {}) => {
196
+ request = options;
197
+ if (!options.method || options.method === 'GET') {
198
+ return jsonResponse({ data: emptyTrace(3) });
199
+ }
200
+ return jsonResponse({ data: { id: 77, phase: 'E' } }, 201);
201
+ },
202
+ });
203
+
204
+ await client.end({ activityId: 42 });
205
+ assert.equal(request.headers['x-flambe-agent-id'], 'agent-session-7');
206
+ assert.equal(request.headers['x-flambe-agent-name'], undefined);
207
+ });
208
+
209
+ test('sends the agent display name only together with an instance ID', async () => {
210
+ let namedRequest;
211
+ const named = new FlambeClient({
212
+ baseUrl: 'http://flambe.test',
213
+ token: 'secret',
214
+ traceId: 3,
215
+ agentId: 'agent-session-7',
216
+ agentName: 'Grok',
217
+ fetchImpl: async (_url, options = {}) => {
218
+ namedRequest = options;
219
+ if (!options.method || options.method === 'GET') {
220
+ return jsonResponse({ data: emptyTrace(3) });
221
+ }
222
+ return jsonResponse({ data: { id: 77, phase: 'E' } }, 201);
223
+ },
224
+ });
225
+
226
+ await named.end({ activityId: 42 });
227
+ assert.equal(namedRequest.headers['x-flambe-agent-id'], 'agent-session-7');
228
+ assert.equal(namedRequest.headers['x-flambe-agent-name'], 'Grok');
229
+
230
+ let namelessRequest;
231
+ const nameless = new FlambeClient({
232
+ baseUrl: 'http://flambe.test',
233
+ token: 'secret',
234
+ traceId: 3,
235
+ agentName: 'Grok',
236
+ fetchImpl: async (_url, options = {}) => {
237
+ namelessRequest = options;
238
+ if (!options.method || options.method === 'GET') {
239
+ return jsonResponse({ data: emptyTrace(3) });
240
+ }
241
+ return jsonResponse({ data: { id: 77, phase: 'E' } }, 201);
242
+ },
243
+ });
244
+
245
+ await nameless.end({ activityId: 42 });
246
+ assert.equal(namelessRequest.headers['x-flambe-agent-id'], undefined);
247
+ assert.equal(namelessRequest.headers['x-flambe-agent-name'], undefined);
248
+ });
249
+
250
+ test('explicit thread still discovers an active parent in that thread', async () => {
251
+ const requests = [];
252
+ const fetchImpl = async (url, options = {}) => {
253
+ requests.push({ url, options });
254
+ if (options.method === undefined || options.method === 'GET') {
255
+ return jsonResponse({ data: { id: 2, threads: [{ id: 11, name: 'Main', rank: 0 }], events: [] } });
256
+ }
257
+ return jsonResponse({ data: { activity: { id: 8 }, event: { id: 9 } } }, 201);
258
+ };
259
+
260
+ const client = new FlambeClient({ baseUrl: 'http://flambe.test', token: 't', traceId: 2, fetchImpl, now: () => 7 });
261
+ assert.equal(await client.start({ name: 'Run tests', threadId: '11', description: 'CI', categoryIds: ['4'] }), 8);
262
+ assert.equal(requests.length, 2);
263
+ assert.deepEqual(JSON.parse(requests[1].options.body), {
264
+ trace_id: 2,
265
+ thread_id: 11,
266
+ activity: { name: 'Run tests', description: 'CI', categories: [4] },
267
+ event: { timestamp_integer: 7, phase: 'B' },
268
+ });
269
+ });
270
+
271
+ test('start makes the latest active activity in its thread the parent by default', async () => {
272
+ let request;
273
+ const client = new FlambeClient({
274
+ baseUrl: 'http://flambe.test', token: 't', traceId: 2, now: () => 7,
275
+ fetchImpl: async (_url, options = {}) => {
276
+ if (options.method === undefined || options.method === 'GET') {
277
+ return jsonResponse({ data: {
278
+ id: 2,
279
+ threads: [{ id: 11, name: 'Main', rank: 0 }],
280
+ events: [{ id: 1, timestamp: '2026-09-02T10:00:00Z', phase: 'B', activity: { id: 6, name: 'Root task', thread: { id: 11 }, categories: [] } }],
281
+ } });
282
+ }
283
+ request = options;
284
+ return jsonResponse({ data: { activity: { id: 8 }, event: { id: 9 } } }, 201);
285
+ },
286
+ });
287
+
288
+ await client.start({ name: 'Inspect config', threadId: 11 });
289
+ assert.equal(JSON.parse(request.body).activity.parent_id, 6);
290
+ });
291
+
292
+ test('start can explicitly remain a root in a thread with active work', async () => {
293
+ let request;
294
+ const client = new FlambeClient({
295
+ baseUrl: 'http://flambe.test', token: 't', traceId: 2, now: () => 7,
296
+ fetchImpl: async (_url, options = {}) => {
297
+ if (options.method === undefined || options.method === 'GET') {
298
+ return jsonResponse({ data: {
299
+ id: 2,
300
+ threads: [{ id: 11, name: 'Main', rank: 0 }],
301
+ events: [{ id: 1, timestamp: '2026-09-02T10:00:00Z', phase: 'B', activity: { id: 6, name: 'Existing work', thread: { id: 11 }, categories: [] } }],
302
+ } });
303
+ }
304
+ request = options;
305
+ return jsonResponse({ data: { activity: { id: 8 }, event: { id: 9 } } }, 201);
306
+ },
307
+ });
308
+
309
+ await client.start({ name: 'New root', threadId: 11, parentId: null });
310
+ assert.equal(Object.hasOwn(JSON.parse(request.body).activity, 'parent_id'), false);
311
+ });
312
+
313
+ test('CLI parses --root as an explicit root activity', async () => {
314
+ let input;
315
+ const client = {
316
+ flushQueue: async () => {},
317
+ start: async value => {
318
+ input = value;
319
+ return 1;
320
+ },
321
+ };
322
+ const stdout = { write: () => {} };
323
+
324
+ await run(['start', 'New', 'root', '--root'], { client, stdout });
325
+ assert.equal(input.parentId, null);
326
+ });
327
+
328
+ test('start accepts a timezone-aware ISO timestamp for a backdated begin event', async () => {
329
+ let request;
330
+ const client = new FlambeClient({
331
+ baseUrl: 'http://flambe.test', token: 't', traceId: 2,
332
+ fetchImpl: async (_url, options = {}) => {
333
+ request = options;
334
+ return jsonResponse({ data: { activity: { id: 8 }, event: { id: 9 } } }, 201);
335
+ },
336
+ now: () => 7,
337
+ });
338
+
339
+ await client.start({ name: 'Last night', threadId: '11', startedAt: '2026-09-01T20:00:00-06:00' });
340
+
341
+ assert.equal(JSON.parse(request.body).event.timestamp_integer, Date.parse('2026-09-01T20:00:00-06:00'));
342
+ });
343
+
344
+ test('start rejects timestamps without a timezone before posting an activity', async () => {
345
+ const client = new FlambeClient({
346
+ baseUrl: 'http://flambe.test', token: 't', traceId: 2,
347
+ fetchImpl: async () => { throw new Error('should not make a request'); },
348
+ });
349
+
350
+ await assert.rejects(
351
+ client.start({ name: 'Last night', threadId: '11', startedAt: '2026-09-01T20:00:00' }),
352
+ /--started-at must be an ISO-8601 timestamp with a timezone/,
353
+ );
354
+ });
355
+
356
+ test('start rejects invalid category IDs before posting an activity', async () => {
357
+ const client = new FlambeClient({
358
+ baseUrl: 'http://flambe.test', token: 't', traceId: 2,
359
+ fetchImpl: async () => { throw new Error('should not make a request'); },
360
+ });
361
+
362
+ await assert.rejects(client.start({ name: 'Run tests', threadId: '11', categoryIds: ['nope'] }), /--category must be a positive integer/);
363
+ });
364
+
365
+ test('end posts an authenticated end event with the completion message', async () => {
366
+ let request;
367
+ const fetchImpl = async (url, options = {}) => {
368
+ if (!options.method || options.method === 'GET') {
369
+ return jsonResponse({ data: emptyTrace(3) });
370
+ }
371
+ request = { url, options };
372
+ return jsonResponse({ data: { id: 77, phase: 'E' } }, 201);
373
+ };
374
+
375
+ const client = new FlambeClient({
376
+ baseUrl: 'http://flambe.test',
377
+ token: 'secret',
378
+ traceId: 3,
379
+ fetchImpl,
380
+ now: () => 456,
381
+ });
382
+
383
+ assert.equal(await client.end({ activityId: 42, message: 'Found the issue' }), 77);
384
+ assert.equal(request.options.headers.authorization, 'Bearer secret');
385
+ assert.deepEqual(JSON.parse(request.options.body), {
386
+ trace_id: 3,
387
+ activity_id: 42,
388
+ event: { timestamp_integer: 456, phase: 'E', message: 'Found the issue' },
389
+ });
390
+ });
391
+
392
+ test('end refuses when the activity still has open children', async () => {
393
+ const client = new FlambeClient({
394
+ baseUrl: 'http://flambe.test',
395
+ token: 'secret',
396
+ traceId: 3,
397
+ now: () => 456,
398
+ fetchImpl: async (url, options = {}) => {
399
+ if (!options.method || options.method === 'GET') {
400
+ return jsonResponse({
401
+ data: {
402
+ id: 3,
403
+ name: 'Work',
404
+ threads: [{ id: 1, name: 'Main', rank: 0 }],
405
+ events: [
406
+ {
407
+ id: 1,
408
+ timestamp: '2026-09-03T12:00:00Z',
409
+ phase: 'B',
410
+ activity: {
411
+ id: 10,
412
+ name: 'Parent work',
413
+ parent_id: null,
414
+ thread: { id: 1 },
415
+ categories: [],
416
+ },
417
+ },
418
+ {
419
+ id: 2,
420
+ timestamp: '2026-09-03T12:01:00Z',
421
+ phase: 'B',
422
+ activity: {
423
+ id: 11,
424
+ name: 'Child work',
425
+ parent_id: 10,
426
+ thread: { id: 1 },
427
+ categories: [],
428
+ },
429
+ },
430
+ {
431
+ id: 3,
432
+ timestamp: '2026-09-03T12:02:00Z',
433
+ phase: 'B',
434
+ activity: {
435
+ id: 12,
436
+ name: 'Other child',
437
+ parent_id: 10,
438
+ thread: { id: 1 },
439
+ categories: [],
440
+ },
441
+ },
442
+ ],
443
+ },
444
+ });
445
+ }
446
+ throw new Error('should not post an end event');
447
+ },
448
+ });
449
+
450
+ await assert.rejects(
451
+ client.end({ activityId: 10, message: 'Too soon' }),
452
+ /Cannot end activity 10 while open children remain: 11 \(Child work\), 12 \(Other child\)\. End 11, 12 first/,
453
+ );
454
+ });
455
+
456
+ test('end --force closes a parent even when children are still open', async () => {
457
+ let posted;
458
+ const client = new FlambeClient({
459
+ baseUrl: 'http://flambe.test',
460
+ token: 'secret',
461
+ traceId: 3,
462
+ now: () => 456,
463
+ fetchImpl: async (_url, options = {}) => {
464
+ if (!options.method || options.method === 'GET') {
465
+ return jsonResponse({
466
+ data: {
467
+ id: 3,
468
+ name: 'Work',
469
+ threads: [{ id: 1, name: 'Main', rank: 0 }],
470
+ events: [
471
+ {
472
+ id: 1,
473
+ timestamp: '2026-09-03T12:00:00Z',
474
+ phase: 'B',
475
+ activity: {
476
+ id: 10,
477
+ name: 'Parent work',
478
+ parent_id: null,
479
+ thread: { id: 1 },
480
+ categories: [],
481
+ },
482
+ },
483
+ {
484
+ id: 2,
485
+ timestamp: '2026-09-03T12:01:00Z',
486
+ phase: 'B',
487
+ activity: {
488
+ id: 11,
489
+ name: 'Child work',
490
+ parent_id: 10,
491
+ thread: { id: 1 },
492
+ categories: [],
493
+ },
494
+ },
495
+ ],
496
+ },
497
+ });
498
+ }
499
+ posted = JSON.parse(options.body);
500
+ return jsonResponse({ data: { id: 99, phase: 'E' } }, 201);
501
+ },
502
+ });
503
+
504
+ assert.equal(await client.end({ activityId: 10, message: 'Forced', force: true }), 99);
505
+ assert.equal(posted.activity_id, 10);
506
+ assert.equal(posted.event.phase, 'E');
507
+ });
508
+
509
+ test('end reports descendants the reducer closed on its behalf', async () => {
510
+ const notes = [];
511
+ const client = new FlambeClient({
512
+ baseUrl: 'http://flambe.test',
513
+ token: 'secret',
514
+ traceId: 3,
515
+ now: () => 456,
516
+ onReducerNote: note => notes.push(note),
517
+ fetchImpl: async () => jsonResponse({
518
+ data: {
519
+ id: 99,
520
+ phase: 'E',
521
+ reducer: {
522
+ closed_descendants: [
523
+ { activity_id: 12, activity_name: 'Grandchild', event_id: 97 },
524
+ { activity_id: 11, activity_name: 'Child work', event_id: 98 },
525
+ ],
526
+ },
527
+ },
528
+ }, 201),
529
+ });
530
+
531
+ assert.equal(await client.end({ activityId: 10, message: 'Forced', force: true }), 99);
532
+ assert.deepEqual(notes, [{
533
+ type: 'closed_descendants',
534
+ activityId: 10,
535
+ closedDescendants: [
536
+ { activityId: 12, activityName: 'Grandchild', eventId: 97 },
537
+ { activityId: 11, activityName: 'Child work', eventId: 98 },
538
+ ],
539
+ }]);
540
+ });
541
+
542
+ test('cli end keeps the id on stdout and puts reducer closures on stderr', async () => {
543
+ const output = [];
544
+ const errors = [];
545
+ const client = {
546
+ flushQueue: async () => {},
547
+ async end() {
548
+ this.onReducerNote({
549
+ type: 'closed_descendants',
550
+ activityId: 10,
551
+ closedDescendants: [
552
+ { activityId: 12, activityName: 'Grandchild', eventId: 97 },
553
+ { activityId: 11, activityName: 'Child work', eventId: 98 },
554
+ ],
555
+ });
556
+ return 99;
557
+ },
558
+ };
559
+
560
+ await run(['end', '10', 'Forced close', '--force'], {
561
+ client,
562
+ stdout: { write: chunk => output.push(chunk) },
563
+ stderr: { write: chunk => errors.push(chunk) },
564
+ });
565
+
566
+ assert.deepEqual(output, ['99\n']);
567
+ assert.deepEqual(errors, ['reducer closed open descendants of 10: 12 (Grandchild), 11 (Child work)\n']);
568
+ });
569
+
570
+ test('cli message prints reducer actions when the reducer changed the stack', async () => {
571
+ const output = [];
572
+ await run(['message', 'Widening scope', '--activity', '9'], {
573
+ client: {
574
+ flushQueue: async () => {},
575
+ async message() {
576
+ return {
577
+ activityId: 9,
578
+ assessment: 'slightly_off_track',
579
+ direction: 'narrow_scope',
580
+ reply: 'Track the refactor separately.',
581
+ rationale: 'Refactor is its own unit.',
582
+ actions_applied: [{ type: 'create_child', activity_id: 31, parent_activity_id: 9 }],
583
+ reducer_model: 'gpt-5.6-terra',
584
+ escalated: true,
585
+ };
586
+ },
587
+ },
588
+ stdout: { write: chunk => output.push(chunk) },
589
+ });
590
+
591
+ assert.deepEqual(output, [
592
+ 'activity\t9\n',
593
+ 'assessment\tslightly_off_track\n',
594
+ 'direction\tnarrow_scope\n',
595
+ 'reply\tTrack the refactor separately.\n',
596
+ 'actions\tcreated child 31 under 9\n',
597
+ ]);
598
+ });
599
+
600
+ test('cli end --force passes through to the client', async () => {
601
+ const calls = [];
602
+ const output = [];
603
+ await run(['end', '10', 'Forced close', '--force'], {
604
+ client: {
605
+ flushQueue: async () => {},
606
+ end: async input => {
607
+ calls.push(input);
608
+ return 55;
609
+ },
610
+ },
611
+ stdout: { write: chunk => output.push(chunk) },
612
+ });
613
+ assert.deepEqual(calls, [{ activityId: '10', message: 'Forced close', force: true }]);
614
+ assert.deepEqual(output, ['55\n']);
615
+ });
616
+
617
+ test('suspend and resume post lifecycle events', async () => {
618
+ const requests = [];
619
+ const client = new FlambeClient({
620
+ baseUrl: 'http://flambe.test', token: 'secret', traceId: 3, now: () => 456,
621
+ fetchImpl: async (_url, options = {}) => {
622
+ requests.push(JSON.parse(options.body));
623
+ return jsonResponse({ data: { id: requests.length, phase: requests.length === 1 ? 'S' : 'R' } }, 201);
624
+ },
625
+ });
626
+
627
+ assert.equal(await client.suspend({ activityId: 42, message: 'Waiting' }), 1);
628
+ assert.equal(await client.resume({ activityId: 42, message: 'Back to it' }), 2);
629
+ assert.deepEqual(requests, [
630
+ { trace_id: 3, activity_id: 42, event: { timestamp_integer: 456, phase: 'S', message: 'Waiting' } },
631
+ { trace_id: 3, activity_id: 42, event: { timestamp_integer: 456, phase: 'R', message: 'Back to it' } },
632
+ ]);
633
+ });
634
+
635
+ test('queues the full offline activity lifecycle and replays it in order', async () => {
636
+ const directory = mkdtempSync(join(tmpdir(), 'flambe-queue-'));
637
+ const queuePath = join(directory, 'queue.json');
638
+ const requests = [];
639
+ let connected = false;
640
+
641
+ const client = new FlambeClient({
642
+ baseUrl: 'http://flambe.test',
643
+ token: 'do-not-store-me',
644
+ traceId: 3,
645
+ queuePath,
646
+ now: () => 456,
647
+ fetchImpl: async (url, options = {}) => {
648
+ if (!connected) throw new TypeError('network unavailable');
649
+ requests.push({ url, options });
650
+ if (options.method === undefined || options.method === 'GET') {
651
+ return jsonResponse({ data: { id: 3, threads: [{ id: 4, name: 'Main', rank: 0 }], events: [] } });
652
+ }
653
+ if (url.endsWith('/api/activities')) {
654
+ return jsonResponse({ data: { activity: { id: 42 }, event: { id: 50 } } }, 201);
655
+ }
656
+ return jsonResponse({ data: { id: 77, phase: JSON.parse(options.body).event.phase } }, 201);
657
+ },
658
+ });
659
+
660
+ try {
661
+ const activityId = await client.start({ name: 'Work offline', threadId: 4, categoryIds: [5] });
662
+ assert.match(activityId, /^offline-/);
663
+ connected = true;
664
+ assert.equal(await client.suspend({ activityId, message: 'Waiting offline' }), 'queued');
665
+ assert.equal(await client.resume({ activityId, message: 'Back offline' }), 'queued');
666
+ assert.equal(await client.end({ activityId, message: 'Finished offline' }), 'queued');
667
+
668
+ const queued = readFileSync(queuePath, 'utf8');
669
+ assert.doesNotMatch(queued, /do-not-store-me/);
670
+ assert.deepEqual(JSON.parse(queued).entries.map(entry => entry.type), ['start', 'suspend', 'resume', 'end']);
671
+
672
+ await client.flushQueue();
673
+
674
+ assert.equal(requests.length, 5);
675
+ assert.equal(requests[0].url, 'http://flambe.test/api/traces/3');
676
+ assert.deepEqual(JSON.parse(requests[1].options.body), {
677
+ trace_id: 3,
678
+ thread_id: 4,
679
+ activity: { name: 'Work offline', categories: [5] },
680
+ event: { timestamp_integer: 456, phase: 'B' },
681
+ });
682
+ assert.deepEqual(JSON.parse(requests[2].options.body), {
683
+ trace_id: 3,
684
+ activity_id: 42,
685
+ event: { timestamp_integer: 456, phase: 'S', message: 'Waiting offline' },
686
+ });
687
+ assert.deepEqual(JSON.parse(requests[3].options.body), {
688
+ trace_id: 3,
689
+ activity_id: 42,
690
+ event: { timestamp_integer: 456, phase: 'R', message: 'Back offline' },
691
+ });
692
+ assert.deepEqual(JSON.parse(requests[4].options.body), {
693
+ trace_id: 3,
694
+ activity_id: 42,
695
+ event: { timestamp_integer: 456, phase: 'E', message: 'Finished offline' },
696
+ });
697
+ assert.throws(() => readFileSync(queuePath, 'utf8'), /ENOENT/);
698
+ } finally {
699
+ rmSync(directory, { recursive: true, force: true });
700
+ }
701
+ });
702
+
703
+ test('status identifies active and suspended activities by their latest lifecycle event', async () => {
704
+ const client = new FlambeClient({
705
+ baseUrl: 'http://flambe.test',
706
+ token: 'secret',
707
+ traceId: 3,
708
+ fetchImpl: async () => jsonResponse({
709
+ data: {
710
+ id: 3,
711
+ name: 'Agent work',
712
+ events: [
713
+ { id: 3, timestamp: '2026-09-02T12:00:00Z', phase: 'E', message: 'Done', activity: { id: 10, name: 'Closed work', thread: { id: 2 }, categories: [7] } },
714
+ { id: 1, timestamp: '2026-09-02T10:00:00Z', phase: 'B', message: null, activity: { id: 10, name: 'Closed work', thread: { id: 2 }, categories: [7] } },
715
+ { id: 2, timestamp: '2026-09-02T11:00:00Z', phase: 'B', message: null, activity: { id: 11, name: 'Open work', thread: { id: 4 }, categories: [5] } },
716
+ { id: 4, timestamp: '2026-09-02T13:00:00Z', phase: 'S', message: 'Waiting', activity: { id: 12, name: 'Paused work', thread: { id: 4 }, categories: [] } },
717
+ { id: 5, timestamp: '2026-09-02T12:30:00Z', phase: 'B', message: null, activity: { id: 12, name: 'Paused work', thread: { id: 4 }, categories: [] } },
718
+ { id: 6, timestamp: '2026-09-02T14:00:00Z', phase: 'R', message: null, activity: { id: 13, name: 'Resumed work', thread: { id: 4 }, categories: [] } },
719
+ { id: 7, timestamp: '2026-09-02T12:15:00Z', phase: 'B', message: null, activity: { id: 13, name: 'Resumed work', thread: { id: 4 }, categories: [] } },
720
+ ],
721
+ threads: [{ id: 2, name: 'Closed' }, { id: 4, name: 'Open' }],
722
+ },
723
+ }),
724
+ });
725
+
726
+ assert.deepEqual(await client.status({ activeOnly: true }), {
727
+ trace: { id: 3, name: 'Agent work' },
728
+ activities: [{
729
+ id: 11,
730
+ name: 'Open work',
731
+ threadId: 4,
732
+ threadName: 'Open',
733
+ parentId: null,
734
+ agentId: null,
735
+ path: ['Open work'],
736
+ categoryIds: [5],
737
+ startedAt: '2026-09-02T11:00:00Z',
738
+ latestEvent: { id: 2, phase: 'B', timestamp: '2026-09-02T11:00:00Z' },
739
+ }, {
740
+ id: 13,
741
+ name: 'Resumed work',
742
+ threadId: 4,
743
+ threadName: 'Open',
744
+ parentId: null,
745
+ agentId: null,
746
+ path: ['Resumed work'],
747
+ categoryIds: [],
748
+ startedAt: '2026-09-02T12:15:00Z',
749
+ latestEvent: { id: 6, phase: 'R', timestamp: '2026-09-02T14:00:00Z' },
750
+ }],
751
+ });
752
+
753
+ assert.deepEqual((await client.status({ suspendedOnly: true })).activities, [{
754
+ id: 12,
755
+ name: 'Paused work',
756
+ threadId: 4,
757
+ threadName: 'Open',
758
+ parentId: null,
759
+ agentId: null,
760
+ path: ['Paused work'],
761
+ categoryIds: [],
762
+ startedAt: '2026-09-02T12:30:00Z',
763
+ latestEvent: { id: 4, phase: 'S', timestamp: '2026-09-02T13:00:00Z', message: 'Waiting' },
764
+ }]);
765
+ });
766
+
767
+ test('message asks the reducer about this agent\'s newest active activity without allowing stack changes', async () => {
768
+ const requests = [];
769
+ const client = new FlambeClient({
770
+ baseUrl: 'http://flambe.test',
771
+ token: 'secret',
772
+ traceId: 3,
773
+ agentId: 'cursor:conv-1',
774
+ fetchImpl: async (url, options) => {
775
+ requests.push({ url, options });
776
+ if (url.endsWith('/api/traces/3')) {
777
+ return jsonResponse({
778
+ data: {
779
+ id: 3,
780
+ name: 'Agent work',
781
+ threads: [{ id: 1, name: 'Main', rank: 0 }],
782
+ events: [
783
+ { id: 1, timestamp: '2026-09-02T10:00:00Z', phase: 'B', activity: { id: 20, name: 'Root', thread: { id: 1 }, categories: [], agent_id: null } },
784
+ { id: 2, timestamp: '2026-09-02T11:00:00Z', phase: 'B', activity: { id: 21, name: 'Mine', parent_id: 20, thread: { id: 1 }, categories: [], agent_id: 'cursor:conv-1' } },
785
+ { id: 3, timestamp: '2026-09-02T12:00:00Z', phase: 'B', activity: { id: 22, name: 'Someone else', parent_id: 20, thread: { id: 1 }, categories: [], agent_id: 'codex:x' } },
786
+ ],
787
+ },
788
+ });
789
+ }
790
+ return jsonResponse({
791
+ jsonrpc: '2.0',
792
+ id: 1,
793
+ result: {
794
+ isError: false,
795
+ content: [{ type: 'text', text: 'Direction: narrow_scope' }],
796
+ structuredContent: {
797
+ assessment: 'slightly_off_track',
798
+ direction: 'narrow_scope',
799
+ reply: 'Stay on the auth fix; leave the refactor.',
800
+ rationale: 'Refactor is outside the flame intent.',
801
+ actions_applied: [],
802
+ reducer_model: 'gpt-5.6-terra',
803
+ escalated: true,
804
+ },
805
+ },
806
+ });
807
+ },
808
+ });
809
+
810
+ const decision = await client.message({ text: 'I want to refactor the whole session module while here.' });
811
+
812
+ assert.equal(decision.activityId, 21);
813
+ assert.equal(decision.direction, 'narrow_scope');
814
+ assert.equal(decision.escalated, true);
815
+
816
+ const mcp = requests.at(-1);
817
+ assert.equal(mcp.url, 'http://flambe.test/mcp');
818
+ assert.equal(mcp.options.headers.authorization, 'Bearer secret');
819
+ assert.deepEqual(JSON.parse(mcp.options.body), {
820
+ jsonrpc: '2.0',
821
+ id: 1,
822
+ method: 'tools/call',
823
+ params: {
824
+ name: 'flambe_message',
825
+ arguments: {
826
+ trace_id: 3,
827
+ activity_id: 21,
828
+ agent_id: 'cursor:conv-1',
829
+ message: 'I want to refactor the whole session module while here.',
830
+ },
831
+ },
832
+ });
833
+ });
834
+
835
+ test('message surfaces reducer tool errors and refuses without an active activity', async () => {
836
+ const errorClient = new FlambeClient({
837
+ baseUrl: 'http://flambe.test',
838
+ token: 'secret',
839
+ traceId: 3,
840
+ fetchImpl: async () => jsonResponse({
841
+ jsonrpc: '2.0',
842
+ id: 1,
843
+ result: { isError: true, content: [{ type: 'text', text: 'Reducer Agent is not configured: OPENAI_API_KEY is missing' }] },
844
+ }),
845
+ });
846
+
847
+ await assert.rejects(errorClient.message({ activityId: 21, text: 'hello' }), /OPENAI_API_KEY is missing/);
848
+
849
+ const emptyClient = new FlambeClient({
850
+ baseUrl: 'http://flambe.test',
851
+ token: 'secret',
852
+ traceId: 3,
853
+ fetchImpl: async () => jsonResponse({ data: emptyTrace() }),
854
+ });
855
+
856
+ await assert.rejects(emptyClient.message({ text: 'hello' }), /No active activity/);
857
+ });
858
+
859
+ test('threads sorts by rank and identifies the default thread', async () => {
860
+ const client = new FlambeClient({
861
+ baseUrl: 'http://flambe.test', token: 'secret', traceId: 3,
862
+ fetchImpl: async () => jsonResponse({ data: { threads: [{ id: 8, name: 'Later', rank: 2 }, { id: 4, name: 'Main', rank: 0 }, { id: 3, name: 'Also main', rank: 0 }] } }),
863
+ });
864
+
865
+ assert.deepEqual(await client.threads(), [
866
+ { id: 3, name: 'Also main', rank: 0, default: true },
867
+ { id: 4, name: 'Main', rank: 0, default: false },
868
+ { id: 8, name: 'Later', rank: 2, default: false },
869
+ ]);
870
+ });
871
+
872
+ test('categories returns the authenticated user categories', async () => {
873
+ const client = new FlambeClient({
874
+ baseUrl: 'http://flambe.test', token: 'secret', traceId: 3,
875
+ fetchImpl: async () => jsonResponse({ data: [{ id: 5, name: 'Client', color_background: '#fff', color_text: '#000' }] }),
876
+ });
877
+
878
+ assert.deepEqual(await client.categories(), [{ id: 5, name: 'Client', color_background: '#fff', color_text: '#000' }]);
879
+ });
880
+
881
+ test('API errors are useful without echoing credentials', async () => {
882
+ const client = new FlambeClient({
883
+ baseUrl: 'http://flambe.test',
884
+ token: 'do-not-print-me',
885
+ traceId: 1,
886
+ fetchImpl: async () => jsonResponse({ error: 'UNAUTHENTICATED' }, 401),
887
+ });
888
+
889
+ await assert.rejects(client.getTrace(), error => {
890
+ assert.match(error.message, /401/);
891
+ assert.match(error.message, /UNAUTHENTICATED/);
892
+ assert.doesNotMatch(error.message, /do-not-print-me/);
893
+ return true;
894
+ });
895
+ });
896
+
897
+ test('CLI commands print machine-friendly output', async () => {
898
+ const output = [];
899
+ const stdout = { write(value) { output.push(value); } };
900
+ const calls = [];
901
+ const client = {
902
+ async flushQueue() { calls.push(['flushQueue']); },
903
+ async start(input) { calls.push(['start', input]); return 123; },
904
+ async end(input) { calls.push(['end', input]); return 456; },
905
+ async suspend(input) { calls.push(['suspend', input]); return 457; },
906
+ async resume(input) { calls.push(['resume', input]); return 458; },
907
+ async status(input) {
908
+ calls.push(['status', input]);
909
+ return { trace: { id: 1, name: 'Work' }, activities: [{ id: 9, name: 'Open work', threadId: 2, threadName: 'Main', categoryIds: [5], latestEvent: { id: 3, phase: 'B', timestamp: '2026-09-02T11:00:00Z' } }] };
910
+ },
911
+ async threads() { calls.push(['threads']); return [{ id: 2, name: 'Main', rank: 0, default: true }]; },
912
+ async categories() { calls.push(['categories']); return [{ id: 5, name: 'Work', color_background: '#fff', color_text: '#000' }]; },
913
+ async observe(input) { calls.push(['observe', input]); return 88; },
914
+ async message(input) {
915
+ calls.push(['message', input]);
916
+ return { activityId: 9, assessment: 'on_track', direction: null, reply: null, rationale: 'fine', actions_applied: [], reducer_model: 'gpt-5.6-luna', escalated: false };
917
+ },
918
+ };
919
+
920
+ await run(['start', 'Inspect', 'auth', '--description', 'Agent work', '--category', '5', '--started-at', '2026-09-01T20:00:00-06:00'], { stdout, client });
921
+ await run(['end', '123', 'Done'], { stdout, client });
922
+ await run(['suspend', '123', 'Waiting'], { stdout, client });
923
+ await run(['resume', '123', 'Continue'], { stdout, client });
924
+ await run(['status', '--active', '--json'], { stdout, client });
925
+ await run(['threads'], { stdout, client });
926
+ await run(['categories', '--json'], { stdout, client });
927
+ await run(['observe', 'carbon', '312.4', '--unit', 'gCO2eq/kWh', '--on', '2026-09-04', '--payload', '{"source":"us-ba-mean"}'], { stdout, client });
928
+ await run(['message', 'Scope', 'may', 'be', 'drifting', '--activity', '9'], { stdout, client });
929
+
930
+ assert.deepEqual(output, ['123\n', '456\n', '457\n', '458\n', '{"trace":{"id":1,"name":"Work"},"activities":[{"id":9,"name":"Open work","threadId":2,"threadName":"Main","categoryIds":[5],"latestEvent":{"id":3,"phase":"B","timestamp":"2026-09-02T11:00:00Z"}}]}\n', '2\t0\tMain\tdefault\n', '[{"id":5,"name":"Work","color_background":"#fff","color_text":"#000"}]\n', '88\n', 'activity\t9\n', 'assessment\ton_track\n', 'direction\t-\n', 'reply\t-\n']);
931
+ assert.deepEqual(calls, [
932
+ ['flushQueue'],
933
+ ['start', { name: 'Inspect auth', description: 'Agent work', threadId: undefined, categoryIds: ['5'], startedAt: '2026-09-01T20:00:00-06:00' }],
934
+ ['flushQueue'],
935
+ ['end', { activityId: '123', message: 'Done', force: false }],
936
+ ['flushQueue'],
937
+ ['suspend', { activityId: '123', message: 'Waiting' }],
938
+ ['flushQueue'],
939
+ ['resume', { activityId: '123', message: 'Continue' }],
940
+ ['flushQueue'],
941
+ ['status', { activeOnly: true, suspendedOnly: false }],
942
+ ['flushQueue'],
943
+ ['threads'],
944
+ ['flushQueue'],
945
+ ['categories'],
946
+ ['flushQueue'],
947
+ ['observe', { kind: 'carbon', value: 312.4, unit: 'gCO2eq/kWh', observedOn: '2026-09-04', payload: { source: 'us-ba-mean' }, at: undefined }],
948
+ ['flushQueue'],
949
+ ['message', { activityId: '9', text: 'Scope may be drifting' }],
950
+ ]);
951
+ });