rafcode 2.4.0 → 2.5.0-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 (108) hide show
  1. package/.claude/settings.local.json +3 -1
  2. package/CLAUDE.md +7 -5
  3. package/RAF/ahwidh-quick-fix-gremlin/decisions.md +37 -0
  4. package/RAF/ahwidh-quick-fix-gremlin/input.md +35 -0
  5. package/RAF/ahwidh-quick-fix-gremlin/outcomes/01-fix-name-generation-prompt.md +33 -0
  6. package/RAF/ahwidh-quick-fix-gremlin/outcomes/02-fix-amend-commit-scope.md +43 -0
  7. package/RAF/ahwidh-quick-fix-gremlin/outcomes/03-fix-diverged-main-branch-sync.md +32 -0
  8. package/RAF/ahwidh-quick-fix-gremlin/outcomes/04-wire-rate-limit-to-do-command.md +61 -0
  9. package/RAF/ahwidh-quick-fix-gremlin/outcomes/05-add-config-get-set-flags.md +125 -0
  10. package/RAF/ahwidh-quick-fix-gremlin/outcomes/06-sync-worktree-branch-before-execution.md +96 -0
  11. package/RAF/ahwidh-quick-fix-gremlin/outcomes/07-update-frontmatter-format.md +107 -0
  12. package/RAF/ahwidh-quick-fix-gremlin/outcomes/08-remove-plan-token-report.md +76 -0
  13. package/RAF/ahwidh-quick-fix-gremlin/plans/01-fix-name-generation-prompt.md +52 -0
  14. package/RAF/ahwidh-quick-fix-gremlin/plans/02-fix-amend-commit-scope.md +48 -0
  15. package/RAF/ahwidh-quick-fix-gremlin/plans/03-fix-diverged-main-branch-sync.md +49 -0
  16. package/RAF/ahwidh-quick-fix-gremlin/plans/04-wire-rate-limit-to-do-command.md +78 -0
  17. package/RAF/ahwidh-quick-fix-gremlin/plans/05-add-config-get-set-flags.md +101 -0
  18. package/RAF/ahwidh-quick-fix-gremlin/plans/06-sync-worktree-branch-before-execution.md +92 -0
  19. package/RAF/ahwidh-quick-fix-gremlin/plans/07-update-frontmatter-format.md +105 -0
  20. package/RAF/ahwidh-quick-fix-gremlin/plans/08-remove-plan-token-report.md +50 -0
  21. package/RAF/ahwqwq-model-whisperer/decisions.md +22 -0
  22. package/RAF/ahwqwq-model-whisperer/input.md +5 -0
  23. package/RAF/ahwqwq-model-whisperer/outcomes/01-show-model-on-task-line.md +49 -0
  24. package/RAF/ahwqwq-model-whisperer/outcomes/02-use-claude-cost-estimation.md +107 -0
  25. package/RAF/ahwqwq-model-whisperer/outcomes/03-add-plan-resume-flag.md +87 -0
  26. package/RAF/ahwqwq-model-whisperer/plans/01-show-model-on-task-line.md +45 -0
  27. package/RAF/ahwqwq-model-whisperer/plans/02-use-claude-cost-estimation.md +115 -0
  28. package/RAF/ahwqwq-model-whisperer/plans/03-add-plan-resume-flag.md +70 -0
  29. package/dist/commands/config.d.ts.map +1 -1
  30. package/dist/commands/config.js +209 -1
  31. package/dist/commands/config.js.map +1 -1
  32. package/dist/commands/do.d.ts.map +1 -1
  33. package/dist/commands/do.js +37 -8
  34. package/dist/commands/do.js.map +1 -1
  35. package/dist/commands/plan.d.ts.map +1 -1
  36. package/dist/commands/plan.js +92 -54
  37. package/dist/commands/plan.js.map +1 -1
  38. package/dist/core/claude-runner.d.ts +8 -6
  39. package/dist/core/claude-runner.d.ts.map +1 -1
  40. package/dist/core/claude-runner.js +73 -5
  41. package/dist/core/claude-runner.js.map +1 -1
  42. package/dist/core/worktree.d.ts +12 -0
  43. package/dist/core/worktree.d.ts.map +1 -1
  44. package/dist/core/worktree.js +33 -1
  45. package/dist/core/worktree.js.map +1 -1
  46. package/dist/parsers/stream-renderer.d.ts +2 -0
  47. package/dist/parsers/stream-renderer.d.ts.map +1 -1
  48. package/dist/parsers/stream-renderer.js +2 -0
  49. package/dist/parsers/stream-renderer.js.map +1 -1
  50. package/dist/prompts/amend.d.ts.map +1 -1
  51. package/dist/prompts/amend.js +3 -1
  52. package/dist/prompts/amend.js.map +1 -1
  53. package/dist/prompts/planning.d.ts.map +1 -1
  54. package/dist/prompts/planning.js +3 -1
  55. package/dist/prompts/planning.js.map +1 -1
  56. package/dist/types/config.d.ts +4 -24
  57. package/dist/types/config.d.ts.map +1 -1
  58. package/dist/types/config.js +0 -24
  59. package/dist/types/config.js.map +1 -1
  60. package/dist/utils/config.d.ts +1 -26
  61. package/dist/utils/config.d.ts.map +1 -1
  62. package/dist/utils/config.js +2 -98
  63. package/dist/utils/config.js.map +1 -1
  64. package/dist/utils/frontmatter.d.ts +13 -3
  65. package/dist/utils/frontmatter.d.ts.map +1 -1
  66. package/dist/utils/frontmatter.js +40 -10
  67. package/dist/utils/frontmatter.js.map +1 -1
  68. package/dist/utils/name-generator.d.ts.map +1 -1
  69. package/dist/utils/name-generator.js +7 -16
  70. package/dist/utils/name-generator.js.map +1 -1
  71. package/dist/utils/terminal-symbols.d.ts +7 -16
  72. package/dist/utils/terminal-symbols.d.ts.map +1 -1
  73. package/dist/utils/terminal-symbols.js +16 -42
  74. package/dist/utils/terminal-symbols.js.map +1 -1
  75. package/dist/utils/token-tracker.d.ts +4 -30
  76. package/dist/utils/token-tracker.d.ts.map +1 -1
  77. package/dist/utils/token-tracker.js +17 -98
  78. package/dist/utils/token-tracker.js.map +1 -1
  79. package/package.json +1 -1
  80. package/src/commands/config.ts +242 -0
  81. package/src/commands/do.ts +39 -7
  82. package/src/commands/plan.ts +101 -58
  83. package/src/core/claude-runner.ts +82 -12
  84. package/src/core/worktree.ts +37 -1
  85. package/src/parsers/stream-renderer.ts +4 -0
  86. package/src/prompts/amend.ts +3 -1
  87. package/src/prompts/config-docs.md +1 -72
  88. package/src/prompts/planning.ts +3 -1
  89. package/src/types/config.ts +4 -52
  90. package/src/utils/config.ts +2 -112
  91. package/src/utils/frontmatter.ts +41 -11
  92. package/src/utils/name-generator.ts +7 -16
  93. package/src/utils/terminal-symbols.ts +16 -46
  94. package/src/utils/token-tracker.ts +19 -113
  95. package/tests/unit/claude-runner.test.ts +1 -0
  96. package/tests/unit/commit-planning-artifacts-worktree.test.ts +6 -14
  97. package/tests/unit/commit-planning-artifacts.test.ts +4 -12
  98. package/tests/unit/config-command.test.ts +161 -0
  99. package/tests/unit/config.test.ts +6 -148
  100. package/tests/unit/frontmatter.test.ts +95 -1
  101. package/tests/unit/name-generator.test.ts +1 -1
  102. package/tests/unit/post-execution-picker.test.ts +1 -0
  103. package/tests/unit/stream-renderer.test.ts +82 -0
  104. package/tests/unit/terminal-symbols.test.ts +86 -124
  105. package/tests/unit/token-tracker.test.ts +159 -679
  106. package/tests/unit/worktree.test.ts +68 -1
  107. package/src/utils/session-parser.ts +0 -161
  108. package/tests/unit/session-parser.test.ts +0 -301
@@ -25,9 +25,6 @@ import {
25
25
  saveConfig,
26
26
  renderCommitMessage,
27
27
  isValidModelName,
28
- resolveModelPricingCategory,
29
- getPricing,
30
- getPricingConfig,
31
28
  } from '../../src/utils/config.js';
32
29
  import { DEFAULT_CONFIG } from '../../src/types/config.js';
33
30
 
@@ -538,97 +535,6 @@ describe('Config', () => {
538
535
  });
539
536
  });
540
537
 
541
- describe('validateConfig - pricing', () => {
542
- it('should accept valid pricing config', () => {
543
- expect(() => validateConfig({
544
- pricing: {
545
- opus: { inputPerMTok: 15, outputPerMTok: 75 },
546
- },
547
- })).not.toThrow();
548
- });
549
-
550
- it('should accept partial pricing override', () => {
551
- expect(() => validateConfig({
552
- pricing: {
553
- haiku: { outputPerMTok: 4 },
554
- },
555
- })).not.toThrow();
556
- });
557
-
558
- it('should reject non-object pricing', () => {
559
- expect(() => validateConfig({ pricing: 'expensive' })).toThrow('pricing must be an object');
560
- });
561
-
562
- it('should reject unknown pricing categories', () => {
563
- expect(() => validateConfig({ pricing: { gpt4: { inputPerMTok: 10 } } })).toThrow('Unknown config key: pricing.gpt4');
564
- });
565
-
566
- it('should reject non-object category value', () => {
567
- expect(() => validateConfig({ pricing: { opus: 'expensive' } })).toThrow('pricing.opus must be an object');
568
- });
569
-
570
- it('should reject unknown pricing fields', () => {
571
- expect(() => validateConfig({ pricing: { opus: { unknownField: 5 } } })).toThrow('Unknown config key: pricing.opus.unknownField');
572
- });
573
-
574
- it('should reject negative pricing values', () => {
575
- expect(() => validateConfig({ pricing: { opus: { inputPerMTok: -1 } } })).toThrow('pricing.opus.inputPerMTok must be a non-negative number');
576
- });
577
-
578
- it('should reject non-number pricing values', () => {
579
- expect(() => validateConfig({ pricing: { opus: { inputPerMTok: 'fifteen' } } })).toThrow('pricing.opus.inputPerMTok must be a non-negative number');
580
- });
581
-
582
- it('should accept zero pricing values', () => {
583
- expect(() => validateConfig({ pricing: { haiku: { inputPerMTok: 0 } } })).not.toThrow();
584
- });
585
-
586
- it('should reject Infinity pricing values', () => {
587
- expect(() => validateConfig({ pricing: { opus: { inputPerMTok: Infinity } } })).toThrow('must be a non-negative number');
588
- });
589
- });
590
-
591
- describe('resolveModelPricingCategory', () => {
592
- it('should map short aliases directly', () => {
593
- expect(resolveModelPricingCategory('opus')).toBe('opus');
594
- expect(resolveModelPricingCategory('sonnet')).toBe('sonnet');
595
- expect(resolveModelPricingCategory('haiku')).toBe('haiku');
596
- });
597
-
598
- it('should extract family from full model IDs', () => {
599
- expect(resolveModelPricingCategory('claude-opus-4-6')).toBe('opus');
600
- expect(resolveModelPricingCategory('claude-sonnet-4-5-20250929')).toBe('sonnet');
601
- expect(resolveModelPricingCategory('claude-haiku-4-5-20251001')).toBe('haiku');
602
- });
603
-
604
- it('should return null for unknown model families', () => {
605
- expect(resolveModelPricingCategory('claude-unknown-3-0')).toBeNull();
606
- expect(resolveModelPricingCategory('gpt-4')).toBeNull();
607
- expect(resolveModelPricingCategory('')).toBeNull();
608
- });
609
- });
610
-
611
- describe('resolveConfig - pricing', () => {
612
- it('should include default pricing when no config file', () => {
613
- const config = resolveConfig(path.join(tempDir, 'nonexistent.json'));
614
- expect(config.pricing.opus.inputPerMTok).toBe(15);
615
- expect(config.pricing.sonnet.inputPerMTok).toBe(3);
616
- expect(config.pricing.haiku.inputPerMTok).toBe(1);
617
- });
618
-
619
- it('should deep-merge partial pricing override', () => {
620
- const configPath = path.join(tempDir, 'pricing.json');
621
- fs.writeFileSync(configPath, JSON.stringify({
622
- pricing: { opus: { inputPerMTok: 10 } },
623
- }));
624
-
625
- const config = resolveConfig(configPath);
626
- expect(config.pricing.opus.inputPerMTok).toBe(10);
627
- expect(config.pricing.opus.outputPerMTok).toBe(75); // default preserved
628
- expect(config.pricing.sonnet.inputPerMTok).toBe(3); // default preserved
629
- });
630
- });
631
-
632
538
  describe('getModelShortName', () => {
633
539
  it('should return short aliases as-is', () => {
634
540
  expect(getModelShortName('opus')).toBe('opus');
@@ -709,7 +615,6 @@ describe('Config', () => {
709
615
  it('should accept valid display config', () => {
710
616
  expect(() => validateConfig({
711
617
  display: {
712
- showRateLimitEstimate: true,
713
618
  showCacheTokens: false,
714
619
  },
715
620
  })).not.toThrow();
@@ -717,7 +622,7 @@ describe('Config', () => {
717
622
 
718
623
  it('should accept partial display override', () => {
719
624
  expect(() => validateConfig({
720
- display: { showRateLimitEstimate: false },
625
+ display: { showCacheTokens: false },
721
626
  })).not.toThrow();
722
627
  });
723
628
 
@@ -730,78 +635,31 @@ describe('Config', () => {
730
635
  });
731
636
 
732
637
  it('should reject non-boolean display values', () => {
733
- expect(() => validateConfig({ display: { showRateLimitEstimate: 'yes' } })).toThrow('display.showRateLimitEstimate must be a boolean');
734
- });
735
- });
736
-
737
- describe('validateConfig - rateLimitWindow', () => {
738
- it('should accept valid rateLimitWindow config', () => {
739
- expect(() => validateConfig({
740
- rateLimitWindow: { sonnetTokenCap: 100000 },
741
- })).not.toThrow();
742
- });
743
-
744
- it('should reject non-object rateLimitWindow', () => {
745
- expect(() => validateConfig({ rateLimitWindow: 88000 })).toThrow('rateLimitWindow must be an object');
746
- });
747
-
748
- it('should reject unknown rateLimitWindow keys', () => {
749
- expect(() => validateConfig({ rateLimitWindow: { unknownKey: 50000 } })).toThrow('Unknown config key: rateLimitWindow.unknownKey');
750
- });
751
-
752
- it('should reject non-positive sonnetTokenCap', () => {
753
- expect(() => validateConfig({ rateLimitWindow: { sonnetTokenCap: 0 } })).toThrow('rateLimitWindow.sonnetTokenCap must be a positive number');
754
- expect(() => validateConfig({ rateLimitWindow: { sonnetTokenCap: -100 } })).toThrow('rateLimitWindow.sonnetTokenCap must be a positive number');
755
- });
756
-
757
- it('should reject non-number sonnetTokenCap', () => {
758
- expect(() => validateConfig({ rateLimitWindow: { sonnetTokenCap: '88000' } })).toThrow('rateLimitWindow.sonnetTokenCap must be a positive number');
638
+ expect(() => validateConfig({ display: { showCacheTokens: 'yes' } })).toThrow('display.showCacheTokens must be a boolean');
759
639
  });
760
640
  });
761
641
 
762
- describe('resolveConfig - display and rateLimitWindow', () => {
642
+ describe('resolveConfig - display', () => {
763
643
  it('should include default display when no config file', () => {
764
644
  const config = resolveConfig(path.join(tempDir, 'nonexistent.json'));
765
- expect(config.display.showRateLimitEstimate).toBe(true);
766
645
  expect(config.display.showCacheTokens).toBe(true);
767
646
  });
768
647
 
769
- it('should include default rateLimitWindow when no config file', () => {
770
- const config = resolveConfig(path.join(tempDir, 'nonexistent.json'));
771
- expect(config.rateLimitWindow.sonnetTokenCap).toBe(88000);
772
- });
773
-
774
648
  it('should deep-merge partial display override', () => {
775
649
  const configPath = path.join(tempDir, 'display.json');
776
650
  fs.writeFileSync(configPath, JSON.stringify({
777
- display: { showRateLimitEstimate: false },
651
+ display: { showCacheTokens: false },
778
652
  }));
779
653
 
780
654
  const config = resolveConfig(configPath);
781
- expect(config.display.showRateLimitEstimate).toBe(false);
782
- expect(config.display.showCacheTokens).toBe(true); // default preserved
783
- });
784
-
785
- it('should deep-merge partial rateLimitWindow override', () => {
786
- const configPath = path.join(tempDir, 'rateLimit.json');
787
- fs.writeFileSync(configPath, JSON.stringify({
788
- rateLimitWindow: { sonnetTokenCap: 100000 },
789
- }));
790
-
791
- const config = resolveConfig(configPath);
792
- expect(config.rateLimitWindow.sonnetTokenCap).toBe(100000);
655
+ expect(config.display.showCacheTokens).toBe(false);
793
656
  });
794
657
  });
795
658
 
796
- describe('DEFAULT_CONFIG - display and rateLimitWindow', () => {
659
+ describe('DEFAULT_CONFIG - display', () => {
797
660
  it('should have default display settings', () => {
798
- expect(DEFAULT_CONFIG.display.showRateLimitEstimate).toBe(true);
799
661
  expect(DEFAULT_CONFIG.display.showCacheTokens).toBe(true);
800
662
  });
801
-
802
- it('should have default rateLimitWindow settings', () => {
803
- expect(DEFAULT_CONFIG.rateLimitWindow.sonnetTokenCap).toBe(88000);
804
- });
805
663
  });
806
664
 
807
665
  describe('getModelTier', () => {
@@ -1,7 +1,101 @@
1
1
  import { parsePlanFrontmatter } from '../../src/utils/frontmatter.js';
2
2
 
3
3
  describe('parsePlanFrontmatter', () => {
4
- describe('valid frontmatter', () => {
4
+ describe('standard format (---/---)', () => {
5
+ it('should parse effort field', () => {
6
+ const content = `---
7
+ effort: medium
8
+ ---
9
+ # Task: Test Task`;
10
+ const result = parsePlanFrontmatter(content);
11
+ expect(result.hasFrontmatter).toBe(true);
12
+ expect(result.frontmatter.effort).toBe('medium');
13
+ expect(result.warnings).toHaveLength(0);
14
+ });
15
+
16
+ it('should parse model field', () => {
17
+ const content = `---
18
+ model: sonnet
19
+ ---
20
+ # Task: Test Task`;
21
+ const result = parsePlanFrontmatter(content);
22
+ expect(result.hasFrontmatter).toBe(true);
23
+ expect(result.frontmatter.model).toBe('sonnet');
24
+ expect(result.warnings).toHaveLength(0);
25
+ });
26
+
27
+ it('should parse both effort and model', () => {
28
+ const content = `---
29
+ effort: high
30
+ model: opus
31
+ ---
32
+ # Task: Test Task`;
33
+ const result = parsePlanFrontmatter(content);
34
+ expect(result.hasFrontmatter).toBe(true);
35
+ expect(result.frontmatter.effort).toBe('high');
36
+ expect(result.frontmatter.model).toBe('opus');
37
+ });
38
+
39
+ it('should handle empty frontmatter block', () => {
40
+ const content = `---
41
+ ---
42
+ # Task: Test Task`;
43
+ const result = parsePlanFrontmatter(content);
44
+ expect(result.hasFrontmatter).toBe(false);
45
+ });
46
+
47
+ it('should handle whitespace before opening delimiter', () => {
48
+ const content = `
49
+ ---
50
+ effort: low
51
+ ---
52
+ # Task: Test Task`;
53
+ const result = parsePlanFrontmatter(content);
54
+ expect(result.hasFrontmatter).toBe(true);
55
+ expect(result.frontmatter.effort).toBe('low');
56
+ });
57
+
58
+ it('should handle empty lines in frontmatter', () => {
59
+ const content = `---
60
+ effort: low
61
+
62
+ model: haiku
63
+ ---
64
+ # Task: Test`;
65
+ const result = parsePlanFrontmatter(content);
66
+ expect(result.hasFrontmatter).toBe(true);
67
+ expect(result.frontmatter.effort).toBe('low');
68
+ expect(result.frontmatter.model).toBe('haiku');
69
+ });
70
+
71
+ it('should handle opening delimiter with trailing spaces', () => {
72
+ const content = `---
73
+ effort: medium
74
+ ---
75
+ # Task: Test Task`;
76
+ const result = parsePlanFrontmatter(content);
77
+ expect(result.hasFrontmatter).toBe(true);
78
+ expect(result.frontmatter.effort).toBe('medium');
79
+ });
80
+
81
+ it('should return empty for missing closing delimiter', () => {
82
+ const content = `---
83
+ effort: medium
84
+ # Task: Test Task`;
85
+ const result = parsePlanFrontmatter(content);
86
+ expect(result.hasFrontmatter).toBe(false);
87
+ });
88
+
89
+ it('should return empty for opening delimiter without newline', () => {
90
+ const content = `---effort: medium
91
+ ---
92
+ # Task: Test Task`;
93
+ const result = parsePlanFrontmatter(content);
94
+ expect(result.hasFrontmatter).toBe(false);
95
+ });
96
+ });
97
+
98
+ describe('legacy format (closing --- only)', () => {
5
99
  it('should parse effort field', () => {
6
100
  const content = `effort: medium
7
101
  ---
@@ -170,7 +170,7 @@ describe('Name Generator', () => {
170
170
  expect(mockSpawn).toHaveBeenCalledTimes(1);
171
171
  // Verify the prompt contains the multi-name generation prompt
172
172
  const promptArg = (mockSpawn.mock.calls[0][1] as string[]).at(-1);
173
- expect(promptArg).toContain('Generate 5 creative project names');
173
+ expect(promptArg).toContain('Output EXACTLY 5 project names');
174
174
  });
175
175
 
176
176
  it('should handle names with numbering prefixes', async () => {
@@ -65,6 +65,7 @@ jest.unstable_mockModule('../../src/core/worktree.js', () => ({
65
65
  pullMainBranch: mockPullMainBranch,
66
66
  pushMainBranch: mockPushMainBranch,
67
67
  detectMainBranch: jest.fn(),
68
+ rebaseOntoMain: jest.fn(),
68
69
  }));
69
70
 
70
71
  // Import after mocking
@@ -267,6 +267,7 @@ describe('renderStreamEvent', () => {
267
267
  outputTokens: 500,
268
268
  cacheReadInputTokens: 200,
269
269
  cacheCreationInputTokens: 100,
270
+ costUsd: 0, // costUSD is extracted from modelUsage data
270
271
  });
271
272
  });
272
273
 
@@ -318,6 +319,87 @@ describe('renderStreamEvent', () => {
318
319
  expect(result.usageData!.modelUsage['claude-opus-4-6'].inputTokens).toBe(1500);
319
320
  expect(result.usageData!.modelUsage['claude-haiku-4-5-20251001'].inputTokens).toBe(500);
320
321
  });
322
+
323
+ it('should extract total_cost_usd from result event', () => {
324
+ const line = JSON.stringify({
325
+ type: 'result',
326
+ subtype: 'success',
327
+ result: 'Done',
328
+ usage: {
329
+ input_tokens: 1000,
330
+ output_tokens: 500,
331
+ },
332
+ total_cost_usd: 18.5,
333
+ });
334
+ const result = renderStreamEvent(line);
335
+ expect(result.usageData).toBeDefined();
336
+ expect(result.usageData!.totalCostUsd).toBe(18.5);
337
+ });
338
+
339
+ it('should default totalCostUsd to 0 when no cost provided', () => {
340
+ const line = JSON.stringify({
341
+ type: 'result',
342
+ subtype: 'success',
343
+ result: 'Done',
344
+ usage: {
345
+ input_tokens: 1000,
346
+ output_tokens: 500,
347
+ },
348
+ // No cost field - costUSD is in modelUsage, not at top level
349
+ });
350
+ const result = renderStreamEvent(line);
351
+ expect(result.usageData).toBeDefined();
352
+ expect(result.usageData!.totalCostUsd).toBe(0);
353
+ });
354
+
355
+ it('should prioritize total_cost_usd over costUSD', () => {
356
+ const line = JSON.stringify({
357
+ type: 'result',
358
+ subtype: 'success',
359
+ result: 'Done',
360
+ usage: {
361
+ input_tokens: 1000,
362
+ output_tokens: 500,
363
+ },
364
+ total_cost_usd: 18.5,
365
+ costUSD: 20.0,
366
+ });
367
+ const result = renderStreamEvent(line);
368
+ expect(result.usageData).toBeDefined();
369
+ expect(result.usageData!.totalCostUsd).toBe(18.5);
370
+ });
371
+
372
+ it('should handle missing cost fields gracefully', () => {
373
+ const line = JSON.stringify({
374
+ type: 'result',
375
+ subtype: 'success',
376
+ result: 'Done',
377
+ usage: {
378
+ input_tokens: 1000,
379
+ output_tokens: 500,
380
+ },
381
+ });
382
+ const result = renderStreamEvent(line);
383
+ expect(result.usageData).toBeDefined();
384
+ // When no cost is provided, defaults to 0
385
+ expect(result.usageData!.totalCostUsd).toBe(0);
386
+ });
387
+
388
+ it('should extract zero cost correctly', () => {
389
+ const line = JSON.stringify({
390
+ type: 'result',
391
+ subtype: 'success',
392
+ result: 'Done',
393
+ usage: {
394
+ input_tokens: 0,
395
+ output_tokens: 0,
396
+ },
397
+ total_cost_usd: 0,
398
+ });
399
+ const result = renderStreamEvent(line);
400
+ expect(result.usageData).toBeDefined();
401
+ expect(result.usageData!.totalCostUsd).toBe(0);
402
+ });
321
403
  });
322
404
 
323
405
  describe('edge cases', () => {