@vibescope/mcp-server 0.2.2 → 0.2.4

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 (80) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +35 -20
  3. package/dist/api-client.d.ts +276 -8
  4. package/dist/api-client.js +128 -9
  5. package/dist/handlers/blockers.d.ts +11 -0
  6. package/dist/handlers/blockers.js +37 -2
  7. package/dist/handlers/bodies-of-work.d.ts +2 -0
  8. package/dist/handlers/bodies-of-work.js +30 -1
  9. package/dist/handlers/connectors.js +2 -2
  10. package/dist/handlers/decisions.d.ts +11 -0
  11. package/dist/handlers/decisions.js +37 -2
  12. package/dist/handlers/deployment.d.ts +6 -0
  13. package/dist/handlers/deployment.js +33 -5
  14. package/dist/handlers/discovery.js +27 -11
  15. package/dist/handlers/fallback.js +12 -6
  16. package/dist/handlers/file-checkouts.d.ts +1 -0
  17. package/dist/handlers/file-checkouts.js +17 -2
  18. package/dist/handlers/findings.d.ts +5 -0
  19. package/dist/handlers/findings.js +19 -2
  20. package/dist/handlers/git-issues.js +4 -2
  21. package/dist/handlers/ideas.d.ts +5 -0
  22. package/dist/handlers/ideas.js +19 -2
  23. package/dist/handlers/progress.js +2 -2
  24. package/dist/handlers/project.d.ts +1 -0
  25. package/dist/handlers/project.js +35 -2
  26. package/dist/handlers/requests.js +6 -3
  27. package/dist/handlers/roles.js +13 -2
  28. package/dist/handlers/session.d.ts +12 -0
  29. package/dist/handlers/session.js +288 -25
  30. package/dist/handlers/sprints.d.ts +2 -0
  31. package/dist/handlers/sprints.js +30 -1
  32. package/dist/handlers/tasks.d.ts +25 -2
  33. package/dist/handlers/tasks.js +228 -35
  34. package/dist/handlers/tool-docs.js +72 -5
  35. package/dist/templates/agent-guidelines.d.ts +18 -0
  36. package/dist/templates/agent-guidelines.js +207 -0
  37. package/dist/tools.js +478 -125
  38. package/dist/utils.d.ts +5 -2
  39. package/dist/utils.js +90 -51
  40. package/package.json +51 -46
  41. package/scripts/version-bump.ts +203 -0
  42. package/src/api-client.test.ts +8 -3
  43. package/src/api-client.ts +376 -13
  44. package/src/handlers/__test-setup__.ts +5 -0
  45. package/src/handlers/blockers.test.ts +76 -0
  46. package/src/handlers/blockers.ts +56 -2
  47. package/src/handlers/bodies-of-work.ts +59 -1
  48. package/src/handlers/connectors.ts +2 -2
  49. package/src/handlers/decisions.test.ts +71 -2
  50. package/src/handlers/decisions.ts +56 -2
  51. package/src/handlers/deployment.test.ts +81 -0
  52. package/src/handlers/deployment.ts +38 -5
  53. package/src/handlers/discovery.ts +27 -11
  54. package/src/handlers/fallback.test.ts +11 -10
  55. package/src/handlers/fallback.ts +14 -8
  56. package/src/handlers/file-checkouts.test.ts +83 -3
  57. package/src/handlers/file-checkouts.ts +22 -2
  58. package/src/handlers/findings.test.ts +2 -2
  59. package/src/handlers/findings.ts +38 -2
  60. package/src/handlers/git-issues.test.ts +2 -2
  61. package/src/handlers/git-issues.ts +4 -2
  62. package/src/handlers/ideas.test.ts +1 -1
  63. package/src/handlers/ideas.ts +34 -2
  64. package/src/handlers/progress.ts +2 -2
  65. package/src/handlers/project.ts +47 -2
  66. package/src/handlers/requests.test.ts +38 -7
  67. package/src/handlers/requests.ts +6 -3
  68. package/src/handlers/roles.test.ts +1 -1
  69. package/src/handlers/roles.ts +20 -2
  70. package/src/handlers/session.test.ts +303 -4
  71. package/src/handlers/session.ts +335 -28
  72. package/src/handlers/sprints.ts +61 -1
  73. package/src/handlers/tasks.test.ts +0 -73
  74. package/src/handlers/tasks.ts +269 -40
  75. package/src/handlers/tool-docs.ts +77 -5
  76. package/src/handlers/types.test.ts +259 -0
  77. package/src/templates/agent-guidelines.ts +210 -0
  78. package/src/tools.ts +479 -125
  79. package/src/utils.test.ts +7 -5
  80. package/src/utils.ts +95 -51
@@ -22,7 +22,7 @@ const logProgressSchema = {
22
22
 
23
23
  const getActivityFeedSchema = {
24
24
  project_id: { type: 'string' as const, required: true as const, validate: uuidValidator },
25
- limit: { type: 'number' as const, default: 50 },
25
+ limit: { type: 'number' as const, default: 10 },
26
26
  since: { type: 'string' as const },
27
27
  types: { type: 'array' as const },
28
28
  created_by: { type: 'string' as const },
@@ -52,7 +52,7 @@ export const getActivityFeed: Handler = async (args, _ctx) => {
52
52
  const { project_id, limit, since, types, created_by } = parseArgs(args, getActivityFeedSchema);
53
53
 
54
54
  const apiClient = getApiClient();
55
- const effectiveLimit = Math.min(limit ?? 50, 200);
55
+ const effectiveLimit = Math.min(limit ?? 10, 200);
56
56
 
57
57
  const response = await apiClient.getActivityFeed(project_id, {
58
58
  limit: effectiveLimit,
@@ -55,6 +55,15 @@ const updateProjectSchema = {
55
55
  git_auto_branch: { type: 'boolean' as const },
56
56
  git_auto_tag: { type: 'boolean' as const },
57
57
  deployment_instructions: { type: 'string' as const },
58
+ // New project settings
59
+ git_delete_branch_on_merge: { type: 'boolean' as const },
60
+ require_pr_for_validation: { type: 'boolean' as const },
61
+ auto_merge_on_approval: { type: 'boolean' as const },
62
+ validation_required: { type: 'boolean' as const },
63
+ default_task_priority: { type: 'number' as const },
64
+ require_time_estimates: { type: 'boolean' as const },
65
+ fallback_activities_enabled: { type: 'boolean' as const },
66
+ preferred_fallback_activities: { type: 'array' as const },
58
67
  };
59
68
 
60
69
  const updateProjectReadmeSchema = {
@@ -62,6 +71,10 @@ const updateProjectReadmeSchema = {
62
71
  readme_content: { type: 'string' as const, required: true as const },
63
72
  };
64
73
 
74
+ const getProjectSummarySchema = {
75
+ project_id: { type: 'string' as const, required: true as const, validate: uuidValidator },
76
+ };
77
+
65
78
  export const getProjectContext: Handler = async (args, _ctx) => {
66
79
  const { project_id, git_url } = parseArgs(args, getProjectContextSchema);
67
80
 
@@ -143,7 +156,16 @@ export const updateProject: Handler = async (args, _ctx) => {
143
156
  git_develop_branch,
144
157
  git_auto_branch,
145
158
  git_auto_tag,
146
- deployment_instructions
159
+ deployment_instructions,
160
+ // New project settings
161
+ git_delete_branch_on_merge,
162
+ require_pr_for_validation,
163
+ auto_merge_on_approval,
164
+ validation_required,
165
+ default_task_priority,
166
+ require_time_estimates,
167
+ fallback_activities_enabled,
168
+ preferred_fallback_activities
147
169
  } = parseArgs(args, updateProjectSchema);
148
170
 
149
171
  const apiClient = getApiClient();
@@ -159,7 +181,16 @@ export const updateProject: Handler = async (args, _ctx) => {
159
181
  git_develop_branch,
160
182
  git_auto_branch,
161
183
  git_auto_tag,
162
- deployment_instructions
184
+ deployment_instructions,
185
+ // New project settings
186
+ git_delete_branch_on_merge,
187
+ require_pr_for_validation,
188
+ auto_merge_on_approval,
189
+ validation_required,
190
+ default_task_priority,
191
+ require_time_estimates,
192
+ fallback_activities_enabled,
193
+ preferred_fallback_activities: preferred_fallback_activities as string[] | undefined
163
194
  });
164
195
 
165
196
  if (!response.ok) {
@@ -182,6 +213,19 @@ export const updateProjectReadme: Handler = async (args, _ctx) => {
182
213
  return { result: response.data };
183
214
  };
184
215
 
216
+ export const getProjectSummary: Handler = async (args, _ctx) => {
217
+ const { project_id } = parseArgs(args, getProjectSummarySchema);
218
+
219
+ const apiClient = getApiClient();
220
+ const response = await apiClient.getProjectSummary(project_id);
221
+
222
+ if (!response.ok) {
223
+ return { result: { error: response.error || 'Failed to get project summary' }, isError: true };
224
+ }
225
+
226
+ return { result: response.data };
227
+ };
228
+
185
229
  /**
186
230
  * Project handlers registry
187
231
  */
@@ -191,4 +235,5 @@ export const projectHandlers: HandlerRegistry = {
191
235
  create_project: createProject,
192
236
  update_project: updateProject,
193
237
  update_project_readme: updateProjectReadme,
238
+ get_project_summary: getProjectSummary,
194
239
  };
@@ -28,7 +28,7 @@ describe('getPendingRequests', () => {
28
28
  it('should return empty list when no requests', async () => {
29
29
  mockApiClient.getPendingRequests.mockResolvedValue({
30
30
  ok: true,
31
- data: { requests: [] },
31
+ data: { requests: [], total_count: 0, has_more: false },
32
32
  });
33
33
  const ctx = createMockContext();
34
34
 
@@ -39,11 +39,12 @@ describe('getPendingRequests', () => {
39
39
 
40
40
  expect(result.result).toMatchObject({
41
41
  requests: [],
42
- count: 0,
42
+ total_count: 0,
43
+ has_more: false,
43
44
  });
44
45
  });
45
46
 
46
- it('should return pending requests', async () => {
47
+ it('should return pending requests with pagination info', async () => {
47
48
  const mockRequests = [
48
49
  {
49
50
  id: 'r1',
@@ -57,7 +58,7 @@ describe('getPendingRequests', () => {
57
58
 
58
59
  mockApiClient.getPendingRequests.mockResolvedValue({
59
60
  ok: true,
60
- data: { requests: mockRequests },
61
+ data: { requests: mockRequests, total_count: 5, has_more: true },
61
62
  });
62
63
  const ctx = createMockContext();
63
64
 
@@ -66,13 +67,17 @@ describe('getPendingRequests', () => {
66
67
  ctx
67
68
  );
68
69
 
69
- expect((result.result as { count: number }).count).toBe(1);
70
+ expect(result.result).toMatchObject({
71
+ requests: mockRequests,
72
+ total_count: 5,
73
+ has_more: true,
74
+ });
70
75
  });
71
76
 
72
77
  it('should call API client with project_id and session_id', async () => {
73
78
  mockApiClient.getPendingRequests.mockResolvedValue({
74
79
  ok: true,
75
- data: { requests: [] },
80
+ data: { requests: [], total_count: 0, has_more: false },
76
81
  });
77
82
  const ctx = createMockContext({ sessionId: 'my-session' });
78
83
 
@@ -83,7 +88,33 @@ describe('getPendingRequests', () => {
83
88
 
84
89
  expect(mockApiClient.getPendingRequests).toHaveBeenCalledWith(
85
90
  '123e4567-e89b-12d3-a456-426614174000',
86
- 'my-session'
91
+ 'my-session',
92
+ 50,
93
+ 0
94
+ );
95
+ });
96
+
97
+ it('should pass limit and offset to API client', async () => {
98
+ mockApiClient.getPendingRequests.mockResolvedValue({
99
+ ok: true,
100
+ data: { requests: [], total_count: 100, has_more: true },
101
+ });
102
+ const ctx = createMockContext({ sessionId: 'my-session' });
103
+
104
+ await getPendingRequests(
105
+ {
106
+ project_id: '123e4567-e89b-12d3-a456-426614174000',
107
+ limit: 10,
108
+ offset: 20,
109
+ },
110
+ ctx
111
+ );
112
+
113
+ expect(mockApiClient.getPendingRequests).toHaveBeenCalledWith(
114
+ '123e4567-e89b-12d3-a456-426614174000',
115
+ 'my-session',
116
+ 10,
117
+ 20
87
118
  );
88
119
  });
89
120
 
@@ -16,6 +16,8 @@ import { getApiClient } from '../api-client.js';
16
16
  // Argument schemas for type-safe parsing
17
17
  const getPendingRequestsSchema = {
18
18
  project_id: { type: 'string' as const, required: true as const, validate: uuidValidator },
19
+ limit: { type: 'number' as const, default: 50 },
20
+ offset: { type: 'number' as const, default: 0 },
19
21
  };
20
22
 
21
23
  const acknowledgeRequestSchema = {
@@ -28,12 +30,12 @@ const answerQuestionSchema = {
28
30
  };
29
31
 
30
32
  export const getPendingRequests: Handler = async (args, ctx) => {
31
- const { project_id } = parseArgs(args, getPendingRequestsSchema);
33
+ const { project_id, limit, offset } = parseArgs(args, getPendingRequestsSchema);
32
34
 
33
35
  const { session } = ctx;
34
36
  const apiClient = getApiClient();
35
37
 
36
- const response = await apiClient.getPendingRequests(project_id, session.currentSessionId || undefined);
38
+ const response = await apiClient.getPendingRequests(project_id, session.currentSessionId || undefined, Math.min(limit ?? 50, 50), offset);
37
39
 
38
40
  if (!response.ok) {
39
41
  return { result: { error: response.error || 'Failed to get pending requests' }, isError: true };
@@ -42,7 +44,8 @@ export const getPendingRequests: Handler = async (args, ctx) => {
42
44
  return {
43
45
  result: {
44
46
  requests: response.data?.requests || [],
45
- count: response.data?.requests?.length || 0,
47
+ total_count: response.data?.total_count || 0,
48
+ has_more: response.data?.has_more || false,
46
49
  },
47
50
  };
48
51
  };
@@ -280,7 +280,7 @@ describe('getAgentsByRole', () => {
280
280
  });
281
281
  expect(mockApiClient.proxy).toHaveBeenCalledWith(
282
282
  'get_agents_by_role',
283
- { project_id: '123e4567-e89b-12d3-a456-426614174000' }
283
+ { project_id: '123e4567-e89b-12d3-a456-426614174000', counts_only: true }
284
284
  );
285
285
  });
286
286
 
@@ -167,7 +167,7 @@ export const setSessionRole: Handler = async (args, ctx) => {
167
167
  };
168
168
 
169
169
  export const getAgentsByRole: Handler = async (args, _ctx) => {
170
- const { project_id } = args as { project_id: string };
170
+ const { project_id, counts_only = true } = args as { project_id: string; counts_only?: boolean };
171
171
 
172
172
  if (!project_id) {
173
173
  return {
@@ -176,6 +176,24 @@ export const getAgentsByRole: Handler = async (args, _ctx) => {
176
176
  }
177
177
 
178
178
  const apiClient = getApiClient();
179
+
180
+ // Type varies based on counts_only
181
+ if (counts_only) {
182
+ const response = await apiClient.proxy<{
183
+ agents_by_role: Record<AgentRole, number>;
184
+ total_active: number;
185
+ }>('get_agents_by_role', { project_id, counts_only: true });
186
+
187
+ if (!response.ok) {
188
+ return {
189
+ result: { error: response.error || 'Failed to get agents by role' },
190
+ };
191
+ }
192
+
193
+ return { result: response.data };
194
+ }
195
+
196
+ // Full details mode
179
197
  const response = await apiClient.proxy<{
180
198
  agents_by_role: Record<AgentRole, Array<{
181
199
  session_id: string;
@@ -186,7 +204,7 @@ export const getAgentsByRole: Handler = async (args, _ctx) => {
186
204
  last_synced_at: string;
187
205
  }>>;
188
206
  total_active: number;
189
- }>('get_agents_by_role', { project_id });
207
+ }>('get_agents_by_role', { project_id, counts_only: false });
190
208
 
191
209
  if (!response.ok) {
192
210
  return {
@@ -5,6 +5,7 @@ import {
5
5
  endWorkSession,
6
6
  getHelp,
7
7
  getTokenUsage,
8
+ reportTokenUsage,
8
9
  } from './session.js';
9
10
  import { createMockContext } from './__test-utils__.js';
10
11
  import { mockApiClient } from './__test-setup__.js';
@@ -31,7 +32,7 @@ describe('heartbeat', () => {
31
32
  session_id: 'session-123',
32
33
  });
33
34
  expect(result.result).toHaveProperty('timestamp');
34
- expect(mockApiClient.heartbeat).toHaveBeenCalledWith('session-123', { current_worktree_path: undefined });
35
+ expect(mockApiClient.heartbeat).toHaveBeenCalledWith('session-123', expect.objectContaining({ current_worktree_path: undefined }));
35
36
  });
36
37
 
37
38
  it('should use provided session_id over current session', async () => {
@@ -48,7 +49,7 @@ describe('heartbeat', () => {
48
49
  success: true,
49
50
  session_id: 'other-session-456',
50
51
  });
51
- expect(mockApiClient.heartbeat).toHaveBeenCalledWith('other-session-456', { current_worktree_path: undefined });
52
+ expect(mockApiClient.heartbeat).toHaveBeenCalledWith('other-session-456', expect.objectContaining({ current_worktree_path: undefined }));
52
53
  });
53
54
 
54
55
  it('should pass worktree_path to API', async () => {
@@ -61,7 +62,7 @@ describe('heartbeat', () => {
61
62
 
62
63
  await heartbeat({ current_worktree_path: '../project-task-abc123' }, ctx);
63
64
 
64
- expect(mockApiClient.heartbeat).toHaveBeenCalledWith('session-123', { current_worktree_path: '../project-task-abc123' });
65
+ expect(mockApiClient.heartbeat).toHaveBeenCalledWith('session-123', expect.objectContaining({ current_worktree_path: '../project-task-abc123' }));
65
66
  });
66
67
 
67
68
  it('should return error when no active session', async () => {
@@ -80,7 +81,7 @@ describe('heartbeat', () => {
80
81
  callCount: 10,
81
82
  totalTokens: 5000,
82
83
  byTool: {
83
- get_tasks: { calls: 3, tokens: 1500 },
84
+ get_task: { calls: 3, tokens: 1500 },
84
85
  update_task: { calls: 4, tokens: 2000 },
85
86
  complete_task: { calls: 3, tokens: 1500 },
86
87
  },
@@ -573,4 +574,302 @@ describe('startWorkSession', () => {
573
574
  expect(result.result).not.toHaveProperty('pending_requests');
574
575
  expect(result.result).not.toHaveProperty('pending_requests_count');
575
576
  });
577
+
578
+ it('should surface awaiting_validation tasks when present', async () => {
579
+ const ctx = createMockContext({ sessionId: null });
580
+ const mockValidationTasks = [
581
+ { id: 'task-1', title: 'Implement login' },
582
+ { id: 'task-2', title: 'Add tests' },
583
+ ];
584
+ mockApiClient.startSession.mockResolvedValue({
585
+ ok: true,
586
+ data: {
587
+ session_started: true,
588
+ session_id: 'new-session-123',
589
+ persona: 'Wave',
590
+ role: 'developer',
591
+ project: { id: 'project-123', name: 'Test Project' },
592
+ awaiting_validation: mockValidationTasks,
593
+ validation_count: 2,
594
+ validation_priority: 'VALIDATE FIRST: 2 task(s) need review before starting new work.',
595
+ directive: 'VALIDATE FIRST: 2 task(s) need review before starting new work. Call claim_validation(task_id) to start reviewing.',
596
+ },
597
+ });
598
+
599
+ const result = await startWorkSession({ project_id: 'project-123' }, ctx);
600
+
601
+ expect(result.result).toHaveProperty('awaiting_validation');
602
+ expect(result.result).toHaveProperty('validation_count', 2);
603
+ expect(result.result).toHaveProperty('validation_priority');
604
+ const awaitingValidation = (result.result as { awaiting_validation: typeof mockValidationTasks }).awaiting_validation;
605
+ expect(awaitingValidation.length).toBe(2);
606
+ expect(awaitingValidation[0].id).toBe('task-1');
607
+ });
608
+
609
+ it('should include next_action when validation tasks are present', async () => {
610
+ const ctx = createMockContext({ sessionId: null });
611
+ mockApiClient.startSession.mockResolvedValue({
612
+ ok: true,
613
+ data: {
614
+ session_started: true,
615
+ session_id: 'new-session-123',
616
+ persona: 'Wave',
617
+ role: 'developer',
618
+ project: { id: 'project-123', name: 'Test Project' },
619
+ awaiting_validation: [{ id: 'task-abc', title: 'Fix bug' }],
620
+ validation_count: 1,
621
+ next_action: 'claim_validation(task_id: "task-abc")',
622
+ },
623
+ });
624
+
625
+ const result = await startWorkSession({ project_id: 'project-123' }, ctx);
626
+
627
+ expect(result.result).toHaveProperty('next_action');
628
+ expect((result.result as { next_action: string }).next_action).toContain('task-abc');
629
+ });
630
+ });
631
+
632
+ // ============================================================================
633
+ // reportTokenUsage Tests
634
+ // ============================================================================
635
+
636
+ describe('reportTokenUsage', () => {
637
+ beforeEach(() => vi.clearAllMocks());
638
+
639
+ it('should report token usage successfully with session', async () => {
640
+ const ctx = createMockContext();
641
+ mockApiClient.reportTokenUsage.mockResolvedValue({
642
+ ok: true,
643
+ data: {
644
+ success: true,
645
+ reported: {
646
+ session_id: 'session-123',
647
+ model: 'sonnet',
648
+ input_tokens: 1000,
649
+ output_tokens: 500,
650
+ total_tokens: 1500,
651
+ estimated_cost_usd: 0.0105,
652
+ },
653
+ task_attributed: true,
654
+ task_id: 'task-123',
655
+ },
656
+ });
657
+
658
+ const result = await reportTokenUsage(
659
+ { input_tokens: 1000, output_tokens: 500, model: 'sonnet' },
660
+ ctx
661
+ );
662
+
663
+ expect(result.result).toMatchObject({
664
+ success: true,
665
+ task_attributed: true,
666
+ task_id: 'task-123',
667
+ });
668
+ expect(result.result).toHaveProperty('reported');
669
+ expect(mockApiClient.reportTokenUsage).toHaveBeenCalledWith('session-123', {
670
+ input_tokens: 1000,
671
+ output_tokens: 500,
672
+ model: 'sonnet',
673
+ });
674
+ });
675
+
676
+ it('should default to sonnet model when not specified', async () => {
677
+ const ctx = createMockContext();
678
+ mockApiClient.reportTokenUsage.mockResolvedValue({
679
+ ok: true,
680
+ data: {
681
+ success: true,
682
+ reported: {
683
+ session_id: 'session-123',
684
+ model: 'sonnet',
685
+ input_tokens: 1000,
686
+ output_tokens: 500,
687
+ total_tokens: 1500,
688
+ estimated_cost_usd: 0.0105,
689
+ },
690
+ task_attributed: false,
691
+ },
692
+ });
693
+
694
+ await reportTokenUsage({ input_tokens: 1000, output_tokens: 500 }, ctx);
695
+
696
+ expect(mockApiClient.reportTokenUsage).toHaveBeenCalledWith('session-123', {
697
+ input_tokens: 1000,
698
+ output_tokens: 500,
699
+ model: 'sonnet',
700
+ });
701
+ });
702
+
703
+ it('should use session currentModel if available', async () => {
704
+ const ctx = createMockContext({
705
+ tokenUsage: {
706
+ callCount: 0,
707
+ totalTokens: 0,
708
+ byTool: {},
709
+ byModel: {},
710
+ currentModel: 'opus',
711
+ },
712
+ });
713
+ mockApiClient.reportTokenUsage.mockResolvedValue({
714
+ ok: true,
715
+ data: {
716
+ success: true,
717
+ reported: {
718
+ session_id: 'session-123',
719
+ model: 'opus',
720
+ input_tokens: 1000,
721
+ output_tokens: 500,
722
+ total_tokens: 1500,
723
+ estimated_cost_usd: 0.0525,
724
+ },
725
+ task_attributed: false,
726
+ },
727
+ });
728
+
729
+ await reportTokenUsage({ input_tokens: 1000, output_tokens: 500 }, ctx);
730
+
731
+ expect(mockApiClient.reportTokenUsage).toHaveBeenCalledWith('session-123', {
732
+ input_tokens: 1000,
733
+ output_tokens: 500,
734
+ model: 'opus',
735
+ });
736
+ });
737
+
738
+ it('should return error for negative token counts', async () => {
739
+ const ctx = createMockContext();
740
+
741
+ const result = await reportTokenUsage({ input_tokens: -100, output_tokens: 500 }, ctx);
742
+
743
+ expect(result.result).toMatchObject({
744
+ error: 'Token counts must be non-negative',
745
+ });
746
+ expect(mockApiClient.reportTokenUsage).not.toHaveBeenCalled();
747
+ });
748
+
749
+ it('should handle local-only reporting when no session', async () => {
750
+ const ctx = createMockContext({ sessionId: null });
751
+
752
+ const result = await reportTokenUsage(
753
+ { input_tokens: 1000, output_tokens: 500, model: 'haiku' },
754
+ ctx
755
+ );
756
+
757
+ expect(result.result).toMatchObject({
758
+ success: true,
759
+ });
760
+ expect(result.result).toHaveProperty('reported');
761
+ const reported = (result.result as { reported: { model: string } }).reported;
762
+ expect(reported.model).toBe('haiku');
763
+ expect(result.result).toHaveProperty('note');
764
+ expect(mockApiClient.reportTokenUsage).not.toHaveBeenCalled();
765
+ });
766
+
767
+ it('should update local token tracking', async () => {
768
+ const ctx = createMockContext({
769
+ tokenUsage: {
770
+ callCount: 5,
771
+ totalTokens: 2500,
772
+ byTool: {},
773
+ byModel: { sonnet: { input: 1000, output: 500 } },
774
+ currentModel: null,
775
+ },
776
+ });
777
+ mockApiClient.reportTokenUsage.mockResolvedValue({
778
+ ok: true,
779
+ data: {
780
+ success: true,
781
+ reported: {
782
+ session_id: 'session-123',
783
+ model: 'sonnet',
784
+ input_tokens: 1000,
785
+ output_tokens: 500,
786
+ total_tokens: 1500,
787
+ estimated_cost_usd: 0.0105,
788
+ },
789
+ task_attributed: false,
790
+ },
791
+ });
792
+
793
+ await reportTokenUsage({ input_tokens: 1000, output_tokens: 500 }, ctx);
794
+
795
+ expect(ctx.updateSession).toHaveBeenCalledWith(
796
+ expect.objectContaining({
797
+ tokenUsage: expect.objectContaining({
798
+ callCount: 6,
799
+ totalTokens: 4000,
800
+ byModel: { sonnet: { input: 2000, output: 1000 } },
801
+ }),
802
+ })
803
+ );
804
+ });
805
+
806
+ it('should handle backend failure gracefully', async () => {
807
+ const ctx = createMockContext();
808
+ mockApiClient.reportTokenUsage.mockResolvedValue({
809
+ ok: false,
810
+ error: 'Server error',
811
+ });
812
+
813
+ const result = await reportTokenUsage({ input_tokens: 1000, output_tokens: 500 }, ctx);
814
+
815
+ // Should still succeed with local calculation
816
+ expect(result.result).toMatchObject({
817
+ success: true,
818
+ });
819
+ expect(result.result).toHaveProperty('warning');
820
+ });
821
+
822
+ it('should indicate when task attribution succeeds', async () => {
823
+ const ctx = createMockContext();
824
+ mockApiClient.reportTokenUsage.mockResolvedValue({
825
+ ok: true,
826
+ data: {
827
+ success: true,
828
+ reported: {
829
+ session_id: 'session-123',
830
+ model: 'sonnet',
831
+ input_tokens: 1000,
832
+ output_tokens: 500,
833
+ total_tokens: 1500,
834
+ estimated_cost_usd: 0.0105,
835
+ },
836
+ task_attributed: true,
837
+ task_id: 'task-abc123',
838
+ },
839
+ });
840
+
841
+ const result = await reportTokenUsage({ input_tokens: 1000, output_tokens: 500 }, ctx);
842
+
843
+ expect(result.result).toMatchObject({
844
+ task_attributed: true,
845
+ task_id: 'task-abc123',
846
+ });
847
+ expect((result.result as { note: string }).note).toContain('attributed to current task');
848
+ });
849
+
850
+ it('should indicate when no task to attribute to', async () => {
851
+ const ctx = createMockContext();
852
+ mockApiClient.reportTokenUsage.mockResolvedValue({
853
+ ok: true,
854
+ data: {
855
+ success: true,
856
+ reported: {
857
+ session_id: 'session-123',
858
+ model: 'sonnet',
859
+ input_tokens: 1000,
860
+ output_tokens: 500,
861
+ total_tokens: 1500,
862
+ estimated_cost_usd: 0.0105,
863
+ },
864
+ task_attributed: false,
865
+ },
866
+ });
867
+
868
+ const result = await reportTokenUsage({ input_tokens: 1000, output_tokens: 500 }, ctx);
869
+
870
+ expect(result.result).toMatchObject({
871
+ task_attributed: false,
872
+ });
873
+ expect((result.result as { note: string }).note).toContain('No active task');
874
+ });
576
875
  });