rafcode 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/RAF/ahtahs-token-reaper/decisions.md +37 -0
  2. package/RAF/ahtahs-token-reaper/input.md +20 -0
  3. package/RAF/ahtahs-token-reaper/outcomes/01-extend-token-tracker-data-model.md +42 -0
  4. package/RAF/ahtahs-token-reaper/outcomes/02-accumulate-usage-in-retry-loop.md +31 -0
  5. package/RAF/ahtahs-token-reaper/outcomes/03-per-attempt-display-formatting.md +60 -0
  6. package/RAF/ahtahs-token-reaper/outcomes/04-add-model-name-to-claude-call-logs.md +57 -0
  7. package/RAF/ahtahs-token-reaper/outcomes/05-handle-invalid-config-in-raf-config.md +46 -0
  8. package/RAF/ahtahs-token-reaper/outcomes/06-fix-verbose-toggle-timer-display.md +38 -0
  9. package/RAF/ahtahs-token-reaper/plans/01-extend-token-tracker-data-model.md +36 -0
  10. package/RAF/ahtahs-token-reaper/plans/02-accumulate-usage-in-retry-loop.md +36 -0
  11. package/RAF/ahtahs-token-reaper/plans/03-per-attempt-display-formatting.md +43 -0
  12. package/RAF/ahtahs-token-reaper/plans/04-add-model-name-to-claude-call-logs.md +38 -0
  13. package/RAF/ahtahs-token-reaper/plans/05-handle-invalid-config-in-raf-config.md +36 -0
  14. package/RAF/ahtahs-token-reaper/plans/06-fix-verbose-toggle-timer-display.md +40 -0
  15. package/dist/commands/config.d.ts.map +1 -1
  16. package/dist/commands/config.js +27 -5
  17. package/dist/commands/config.js.map +1 -1
  18. package/dist/commands/do.js +17 -10
  19. package/dist/commands/do.js.map +1 -1
  20. package/dist/commands/plan.js +3 -2
  21. package/dist/commands/plan.js.map +1 -1
  22. package/dist/core/pull-request.d.ts.map +1 -1
  23. package/dist/core/pull-request.js +3 -1
  24. package/dist/core/pull-request.js.map +1 -1
  25. package/dist/utils/config.d.ts +6 -0
  26. package/dist/utils/config.d.ts.map +1 -1
  27. package/dist/utils/config.js +21 -0
  28. package/dist/utils/config.js.map +1 -1
  29. package/dist/utils/terminal-symbols.d.ts +8 -4
  30. package/dist/utils/terminal-symbols.d.ts.map +1 -1
  31. package/dist/utils/terminal-symbols.js +31 -6
  32. package/dist/utils/terminal-symbols.js.map +1 -1
  33. package/dist/utils/token-tracker.d.ts +11 -1
  34. package/dist/utils/token-tracker.d.ts.map +1 -1
  35. package/dist/utils/token-tracker.js +37 -2
  36. package/dist/utils/token-tracker.js.map +1 -1
  37. package/package.json +1 -1
  38. package/src/commands/config.ts +30 -4
  39. package/src/commands/do.ts +17 -10
  40. package/src/commands/plan.ts +3 -2
  41. package/src/core/pull-request.ts +3 -1
  42. package/src/utils/config.ts +22 -0
  43. package/src/utils/terminal-symbols.ts +42 -7
  44. package/src/utils/token-tracker.ts +44 -2
  45. package/tests/unit/config-command.test.ts +80 -1
  46. package/tests/unit/config.test.ts +24 -0
  47. package/tests/unit/terminal-symbols.test.ts +121 -33
  48. package/tests/unit/timer-verbose-integration.test.ts +170 -0
  49. package/tests/unit/token-tracker.test.ts +350 -17
@@ -16,6 +16,7 @@ import {
16
16
  getAutoCommit,
17
17
  getWorktreeDefault,
18
18
  getClaudeCommand,
19
+ getModelShortName,
19
20
  resetConfigCache,
20
21
  saveConfig,
21
22
  renderCommitMessage,
@@ -616,6 +617,29 @@ describe('Config', () => {
616
617
  });
617
618
  });
618
619
 
620
+ describe('getModelShortName', () => {
621
+ it('should return short aliases as-is', () => {
622
+ expect(getModelShortName('opus')).toBe('opus');
623
+ expect(getModelShortName('sonnet')).toBe('sonnet');
624
+ expect(getModelShortName('haiku')).toBe('haiku');
625
+ });
626
+
627
+ it('should extract family from full model IDs', () => {
628
+ expect(getModelShortName('claude-opus-4-6')).toBe('opus');
629
+ expect(getModelShortName('claude-opus-4-5-20251101')).toBe('opus');
630
+ expect(getModelShortName('claude-sonnet-4-5-20250929')).toBe('sonnet');
631
+ expect(getModelShortName('claude-sonnet-4-5')).toBe('sonnet');
632
+ expect(getModelShortName('claude-haiku-4-5-20251001')).toBe('haiku');
633
+ });
634
+
635
+ it('should return unknown model IDs as-is', () => {
636
+ expect(getModelShortName('gpt-4')).toBe('gpt-4');
637
+ expect(getModelShortName('claude-unknown-3-0')).toBe('claude-unknown-3-0');
638
+ expect(getModelShortName('')).toBe('');
639
+ expect(getModelShortName('some-random-model')).toBe('some-random-model');
640
+ });
641
+ });
642
+
619
643
  describe('config integration - overrides work', () => {
620
644
  it('should use custom model when configured', () => {
621
645
  const configPath = path.join(tempDir, 'custom-models.json');
@@ -11,7 +11,7 @@ import {
11
11
  TaskStatus,
12
12
  } from '../../src/utils/terminal-symbols.js';
13
13
  import type { UsageData } from '../../src/types/config.js';
14
- import type { CostBreakdown } from '../../src/utils/token-tracker.js';
14
+ import type { CostBreakdown, TaskUsageEntry } from '../../src/utils/token-tracker.js';
15
15
 
16
16
  describe('Terminal Symbols', () => {
17
17
  describe('SYMBOLS', () => {
@@ -296,38 +296,126 @@ describe('Terminal Symbols', () => {
296
296
  totalCost: total,
297
297
  });
298
298
 
299
- it('should format basic token summary without cache', () => {
300
- const result = formatTaskTokenSummary(makeUsage(), makeCost(0.42));
301
- expect(result).toBe(' Tokens: 5,234 in / 1,023 out | Est. cost: $0.42');
302
- });
303
-
304
- it('should include cache read tokens', () => {
305
- const result = formatTaskTokenSummary(
306
- makeUsage({ cacheReadInputTokens: 18500 }),
307
- makeCost(0.42)
308
- );
309
- expect(result).toBe(' Tokens: 5,234 in / 1,023 out | Cache: 18,500 read | Est. cost: $0.42');
310
- });
311
-
312
- it('should include cache creation tokens', () => {
313
- const result = formatTaskTokenSummary(
314
- makeUsage({ cacheCreationInputTokens: 5000 }),
315
- makeCost(0.55)
316
- );
317
- expect(result).toBe(' Tokens: 5,234 in / 1,023 out | Cache: 5,000 created | Est. cost: $0.55');
318
- });
319
-
320
- it('should include both cache read and creation tokens', () => {
321
- const result = formatTaskTokenSummary(
322
- makeUsage({ cacheReadInputTokens: 18500, cacheCreationInputTokens: 5000 }),
323
- makeCost(0.75)
324
- );
325
- expect(result).toBe(' Tokens: 5,234 in / 1,023 out | Cache: 18,500 read / 5,000 created | Est. cost: $0.75');
326
- });
327
-
328
- it('should format small costs with 4 decimal places', () => {
329
- const result = formatTaskTokenSummary(makeUsage(), makeCost(0.0042));
330
- expect(result).toBe(' Tokens: 5,234 in / 1,023 out | Est. cost: $0.0042');
299
+ const makeEntry = (usage: UsageData, cost: CostBreakdown, attempts?: UsageData[]): TaskUsageEntry => ({
300
+ taskId: '01',
301
+ usage,
302
+ cost,
303
+ attempts: attempts ?? [usage],
304
+ });
305
+
306
+ describe('single-attempt tasks', () => {
307
+ it('should format basic token summary without cache', () => {
308
+ const usage = makeUsage();
309
+ const result = formatTaskTokenSummary(makeEntry(usage, makeCost(0.42)));
310
+ expect(result).toBe(' Tokens: 5,234 in / 1,023 out | Est. cost: $0.42');
311
+ });
312
+
313
+ it('should include cache read tokens', () => {
314
+ const usage = makeUsage({ cacheReadInputTokens: 18500 });
315
+ const result = formatTaskTokenSummary(makeEntry(usage, makeCost(0.42)));
316
+ expect(result).toBe(' Tokens: 5,234 in / 1,023 out | Cache: 18,500 read | Est. cost: $0.42');
317
+ });
318
+
319
+ it('should include cache creation tokens', () => {
320
+ const usage = makeUsage({ cacheCreationInputTokens: 5000 });
321
+ const result = formatTaskTokenSummary(makeEntry(usage, makeCost(0.55)));
322
+ expect(result).toBe(' Tokens: 5,234 in / 1,023 out | Cache: 5,000 created | Est. cost: $0.55');
323
+ });
324
+
325
+ it('should include both cache read and creation tokens', () => {
326
+ const usage = makeUsage({ cacheReadInputTokens: 18500, cacheCreationInputTokens: 5000 });
327
+ const result = formatTaskTokenSummary(makeEntry(usage, makeCost(0.75)));
328
+ expect(result).toBe(' Tokens: 5,234 in / 1,023 out | Cache: 18,500 read / 5,000 created | Est. cost: $0.75');
329
+ });
330
+
331
+ it('should format small costs with 4 decimal places', () => {
332
+ const usage = makeUsage();
333
+ const result = formatTaskTokenSummary(makeEntry(usage, makeCost(0.0042)));
334
+ expect(result).toBe(' Tokens: 5,234 in / 1,023 out | Est. cost: $0.0042');
335
+ });
336
+
337
+ it('should format single-attempt task with empty attempts array as single-line', () => {
338
+ const usage = makeUsage();
339
+ const result = formatTaskTokenSummary(makeEntry(usage, makeCost(0.42), []));
340
+ expect(result).toBe(' Tokens: 5,234 in / 1,023 out | Est. cost: $0.42');
341
+ });
342
+ });
343
+
344
+ describe('multi-attempt tasks', () => {
345
+ it('should show per-attempt breakdown with costs when calculateAttemptCost provided', () => {
346
+ const attempt1 = makeUsage({ inputTokens: 1234, outputTokens: 567 });
347
+ const attempt2 = makeUsage({ inputTokens: 2345, outputTokens: 890 });
348
+ const totalUsage = makeUsage({ inputTokens: 3579, outputTokens: 1457 });
349
+ const entry = makeEntry(totalUsage, makeCost(0.06), [attempt1, attempt2]);
350
+
351
+ const calculateCost = (usage: UsageData): CostBreakdown => ({
352
+ inputCost: 0,
353
+ outputCost: 0,
354
+ cacheReadCost: 0,
355
+ cacheCreateCost: 0,
356
+ totalCost: usage.inputTokens === 1234 ? 0.02 : 0.04,
357
+ });
358
+
359
+ const result = formatTaskTokenSummary(entry, calculateCost);
360
+ const lines = result.split('\n');
361
+
362
+ expect(lines).toHaveLength(3);
363
+ expect(lines[0]).toBe(' Attempt 1: 1,234 in / 567 out | Est. cost: $0.02');
364
+ expect(lines[1]).toBe(' Attempt 2: 2,345 in / 890 out | Est. cost: $0.04');
365
+ expect(lines[2]).toBe(' Total: 3,579 in / 1,457 out | Est. cost: $0.06');
366
+ });
367
+
368
+ it('should show per-attempt breakdown without costs when no calculateAttemptCost', () => {
369
+ const attempt1 = makeUsage({ inputTokens: 1000, outputTokens: 200 });
370
+ const attempt2 = makeUsage({ inputTokens: 2000, outputTokens: 400 });
371
+ const totalUsage = makeUsage({ inputTokens: 3000, outputTokens: 600 });
372
+ const entry = makeEntry(totalUsage, makeCost(0.05), [attempt1, attempt2]);
373
+
374
+ const result = formatTaskTokenSummary(entry);
375
+ const lines = result.split('\n');
376
+
377
+ expect(lines).toHaveLength(3);
378
+ expect(lines[0]).toBe(' Attempt 1: 1,000 in / 200 out | Est. cost: $0.00');
379
+ expect(lines[1]).toBe(' Attempt 2: 2,000 in / 400 out | Est. cost: $0.00');
380
+ expect(lines[2]).toBe(' Total: 3,000 in / 600 out | Est. cost: $0.05');
381
+ });
382
+
383
+ it('should include cache tokens in per-attempt breakdown', () => {
384
+ const attempt1 = makeUsage({ inputTokens: 1000, outputTokens: 200, cacheReadInputTokens: 5000 });
385
+ const attempt2 = makeUsage({ inputTokens: 1500, outputTokens: 300, cacheCreationInputTokens: 2000 });
386
+ const totalUsage = makeUsage({
387
+ inputTokens: 2500,
388
+ outputTokens: 500,
389
+ cacheReadInputTokens: 5000,
390
+ cacheCreationInputTokens: 2000,
391
+ });
392
+ const entry = makeEntry(totalUsage, makeCost(0.08), [attempt1, attempt2]);
393
+
394
+ const result = formatTaskTokenSummary(entry);
395
+ const lines = result.split('\n');
396
+
397
+ expect(lines).toHaveLength(3);
398
+ expect(lines[0]).toContain('Cache: 5,000 read');
399
+ expect(lines[1]).toContain('Cache: 2,000 created');
400
+ expect(lines[2]).toContain('Cache: 5,000 read / 2,000 created');
401
+ });
402
+
403
+ it('should handle three or more attempts', () => {
404
+ const attempt1 = makeUsage({ inputTokens: 500, outputTokens: 100 });
405
+ const attempt2 = makeUsage({ inputTokens: 600, outputTokens: 120 });
406
+ const attempt3 = makeUsage({ inputTokens: 700, outputTokens: 140 });
407
+ const totalUsage = makeUsage({ inputTokens: 1800, outputTokens: 360 });
408
+ const entry = makeEntry(totalUsage, makeCost(0.10), [attempt1, attempt2, attempt3]);
409
+
410
+ const result = formatTaskTokenSummary(entry);
411
+ const lines = result.split('\n');
412
+
413
+ expect(lines).toHaveLength(4);
414
+ expect(lines[0]).toContain('Attempt 1');
415
+ expect(lines[1]).toContain('Attempt 2');
416
+ expect(lines[2]).toContain('Attempt 3');
417
+ expect(lines[3]).toContain('Total');
418
+ });
331
419
  });
332
420
  });
333
421
 
@@ -0,0 +1,170 @@
1
+ import { jest } from '@jest/globals';
2
+ import { createStatusLine } from '../../src/utils/status-line.js';
3
+ import { createTaskTimer } from '../../src/utils/timer.js';
4
+
5
+ /**
6
+ * Tests the interaction between the timer callback, status line, and verbose toggle.
7
+ * This mirrors the logic in do.ts where the onTick callback checks verboseToggle.isVerbose
8
+ * and clears/skips the status line when verbose is on.
9
+ */
10
+ describe('Timer-Verbose Integration', () => {
11
+ let originalIsTTY: boolean | undefined;
12
+ let originalWrite: typeof process.stdout.write;
13
+ let writeOutput: string[];
14
+
15
+ beforeEach(() => {
16
+ jest.useFakeTimers();
17
+ originalIsTTY = process.stdout.isTTY;
18
+ originalWrite = process.stdout.write;
19
+ writeOutput = [];
20
+
21
+ // Mock stdout.write to capture output
22
+ process.stdout.isTTY = true;
23
+ process.stdout.write = jest.fn((data: string | Uint8Array) => {
24
+ writeOutput.push(data.toString());
25
+ return true;
26
+ }) as typeof process.stdout.write;
27
+ });
28
+
29
+ afterEach(() => {
30
+ jest.useRealTimers();
31
+ process.stdout.isTTY = originalIsTTY;
32
+ process.stdout.write = originalWrite;
33
+ });
34
+
35
+ describe('onTick callback with verbose check', () => {
36
+ it('should update status line when verbose is off', () => {
37
+ const statusLine = createStatusLine();
38
+ let isVerbose = false;
39
+
40
+ const timer = createTaskTimer((elapsed) => {
41
+ if (isVerbose) {
42
+ statusLine.clear();
43
+ return;
44
+ }
45
+ statusLine.update(`Task running: ${elapsed}ms`);
46
+ });
47
+
48
+ timer.start();
49
+ expect(writeOutput.length).toBeGreaterThan(0);
50
+ expect(writeOutput.some(output => output.includes('Task running'))).toBe(true);
51
+
52
+ timer.stop();
53
+ });
54
+
55
+ it('should clear status line and skip update when verbose is toggled on', () => {
56
+ const statusLine = createStatusLine();
57
+ let isVerbose = false;
58
+
59
+ const timer = createTaskTimer((elapsed) => {
60
+ if (isVerbose) {
61
+ statusLine.clear();
62
+ return;
63
+ }
64
+ statusLine.update(`Task running: ${elapsed}ms`);
65
+ });
66
+
67
+ timer.start();
68
+ // Initial tick should update
69
+ expect(writeOutput.some(output => output.includes('Task running: 0ms'))).toBe(true);
70
+
71
+ // Toggle verbose on
72
+ isVerbose = true;
73
+ writeOutput = [];
74
+
75
+ // Next tick should clear the line instead of updating
76
+ jest.advanceTimersByTime(1000);
77
+
78
+ // Should have cleared (written empty/reset) but not updated with task info
79
+ expect(writeOutput.length).toBeGreaterThan(0);
80
+ // The clear writes spaces to overwrite, not "Task running"
81
+ expect(writeOutput.some(output => output.includes('Task running'))).toBe(false);
82
+
83
+ timer.stop();
84
+ });
85
+
86
+ it('should resume updating status line when verbose is toggled back off', () => {
87
+ const statusLine = createStatusLine();
88
+ let isVerbose = false;
89
+
90
+ const timer = createTaskTimer((elapsed) => {
91
+ if (isVerbose) {
92
+ statusLine.clear();
93
+ return;
94
+ }
95
+ statusLine.update(`Task running: ${elapsed}ms`);
96
+ });
97
+
98
+ timer.start();
99
+ // Toggle verbose on
100
+ isVerbose = true;
101
+ jest.advanceTimersByTime(1000);
102
+
103
+ // Toggle verbose back off
104
+ isVerbose = false;
105
+ writeOutput = [];
106
+
107
+ // Next tick should update normally
108
+ jest.advanceTimersByTime(1000);
109
+
110
+ expect(writeOutput.some(output => output.includes('Task running'))).toBe(true);
111
+
112
+ timer.stop();
113
+ });
114
+
115
+ it('should track elapsed time correctly regardless of verbose state', () => {
116
+ const statusLine = createStatusLine();
117
+ let isVerbose = false;
118
+
119
+ const timer = createTaskTimer((elapsed) => {
120
+ if (isVerbose) {
121
+ statusLine.clear();
122
+ return;
123
+ }
124
+ statusLine.update(`Task running: ${elapsed}ms`);
125
+ });
126
+
127
+ timer.start();
128
+
129
+ // Run some time with verbose off
130
+ jest.advanceTimersByTime(2000);
131
+ expect(timer.getElapsed()).toBeGreaterThanOrEqual(2000);
132
+
133
+ // Toggle verbose on - timer keeps running
134
+ isVerbose = true;
135
+ jest.advanceTimersByTime(3000);
136
+ expect(timer.getElapsed()).toBeGreaterThanOrEqual(5000);
137
+
138
+ // Toggle verbose off - elapsed time is accurate
139
+ isVerbose = false;
140
+ writeOutput = [];
141
+ jest.advanceTimersByTime(1000);
142
+
143
+ // The elapsed time should be around 6000ms now, visible in the output
144
+ expect(writeOutput.some(output => output.includes('Task running'))).toBe(true);
145
+ expect(timer.getElapsed()).toBeGreaterThanOrEqual(6000);
146
+
147
+ timer.stop();
148
+ });
149
+
150
+ it('should not create timer callback when started with verbose flag', () => {
151
+ // This mirrors the do.ts logic: verbose ? undefined : (elapsed) => {...}
152
+ const initialVerbose = true;
153
+ const onTick = initialVerbose ? undefined : jest.fn();
154
+
155
+ const timer = createTaskTimer(onTick);
156
+ timer.start();
157
+
158
+ jest.advanceTimersByTime(5000);
159
+
160
+ // No callback should have been called
161
+ if (onTick) {
162
+ expect(onTick).not.toHaveBeenCalled();
163
+ }
164
+ // Timer still tracks time
165
+ expect(timer.getElapsed()).toBeGreaterThanOrEqual(5000);
166
+
167
+ timer.stop();
168
+ });
169
+ });
170
+ });