@aaronshaf/ger 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.
Files changed (91) hide show
  1. package/.ast-grep/rules/no-as-casting.yml +13 -0
  2. package/.eslintrc.js +12 -0
  3. package/.github/workflows/ci-simple.yml +53 -0
  4. package/.github/workflows/ci.yml +171 -0
  5. package/.github/workflows/claude-code-review.yml +78 -0
  6. package/.github/workflows/claude.yml +64 -0
  7. package/.github/workflows/dependency-update.yml +84 -0
  8. package/.github/workflows/release.yml +166 -0
  9. package/.github/workflows/security-scan.yml +113 -0
  10. package/.github/workflows/security.yml +96 -0
  11. package/.husky/pre-commit +16 -0
  12. package/.husky/pre-push +25 -0
  13. package/.lintstagedrc.json +6 -0
  14. package/.tool-versions +1 -0
  15. package/CLAUDE.md +103 -0
  16. package/DEVELOPMENT.md +361 -0
  17. package/LICENSE +21 -0
  18. package/README.md +325 -0
  19. package/bin/ger +3 -0
  20. package/biome.json +36 -0
  21. package/bun.lock +688 -0
  22. package/bunfig.toml +8 -0
  23. package/oxlint.json +24 -0
  24. package/package.json +55 -0
  25. package/scripts/check-coverage.ts +69 -0
  26. package/scripts/check-file-size.ts +38 -0
  27. package/scripts/fix-test-mocks.ts +55 -0
  28. package/src/api/gerrit.ts +466 -0
  29. package/src/cli/commands/abandon.ts +65 -0
  30. package/src/cli/commands/comment.ts +460 -0
  31. package/src/cli/commands/comments.ts +85 -0
  32. package/src/cli/commands/diff.ts +71 -0
  33. package/src/cli/commands/incoming.ts +226 -0
  34. package/src/cli/commands/init.ts +164 -0
  35. package/src/cli/commands/mine.ts +115 -0
  36. package/src/cli/commands/open.ts +57 -0
  37. package/src/cli/commands/review.ts +593 -0
  38. package/src/cli/commands/setup.ts +230 -0
  39. package/src/cli/commands/show.ts +303 -0
  40. package/src/cli/commands/status.ts +35 -0
  41. package/src/cli/commands/workspace.ts +200 -0
  42. package/src/cli/index.ts +420 -0
  43. package/src/prompts/default-review.md +80 -0
  44. package/src/prompts/system-inline-review.md +88 -0
  45. package/src/prompts/system-overall-review.md +152 -0
  46. package/src/schemas/config.test.ts +245 -0
  47. package/src/schemas/config.ts +75 -0
  48. package/src/schemas/gerrit.ts +455 -0
  49. package/src/services/ai-enhanced.ts +167 -0
  50. package/src/services/ai.ts +182 -0
  51. package/src/services/config.test.ts +414 -0
  52. package/src/services/config.ts +206 -0
  53. package/src/test-utils/mock-generator.ts +73 -0
  54. package/src/utils/comment-formatters.ts +153 -0
  55. package/src/utils/diff-context.ts +103 -0
  56. package/src/utils/diff-formatters.ts +141 -0
  57. package/src/utils/formatters.ts +85 -0
  58. package/src/utils/message-filters.ts +26 -0
  59. package/src/utils/shell-safety.ts +117 -0
  60. package/src/utils/status-indicators.ts +100 -0
  61. package/src/utils/url-parser.test.ts +123 -0
  62. package/src/utils/url-parser.ts +91 -0
  63. package/tests/abandon.test.ts +163 -0
  64. package/tests/ai-service.test.ts +489 -0
  65. package/tests/comment-batch-advanced.test.ts +431 -0
  66. package/tests/comment-gerrit-api-compliance.test.ts +414 -0
  67. package/tests/comment.test.ts +707 -0
  68. package/tests/comments.test.ts +323 -0
  69. package/tests/config-service-simple.test.ts +100 -0
  70. package/tests/diff.test.ts +419 -0
  71. package/tests/helpers/config-mock.ts +27 -0
  72. package/tests/incoming.test.ts +357 -0
  73. package/tests/interactive-incoming.test.ts +173 -0
  74. package/tests/mine.test.ts +318 -0
  75. package/tests/mocks/fetch-mock.ts +139 -0
  76. package/tests/mocks/msw-handlers.ts +80 -0
  77. package/tests/open.test.ts +233 -0
  78. package/tests/review.test.ts +669 -0
  79. package/tests/setup.ts +13 -0
  80. package/tests/show.test.ts +439 -0
  81. package/tests/unit/schemas/gerrit.test.ts +85 -0
  82. package/tests/unit/test-utils/mock-generator.test.ts +154 -0
  83. package/tests/unit/utils/comment-formatters.test.ts +415 -0
  84. package/tests/unit/utils/diff-context.test.ts +171 -0
  85. package/tests/unit/utils/diff-formatters.test.ts +165 -0
  86. package/tests/unit/utils/formatters.test.ts +411 -0
  87. package/tests/unit/utils/message-filters.test.ts +227 -0
  88. package/tests/unit/utils/prompt-helpers.test.ts +175 -0
  89. package/tests/unit/utils/shell-safety.test.ts +230 -0
  90. package/tests/unit/utils/status-indicators.test.ts +137 -0
  91. package/tsconfig.json +40 -0
@@ -0,0 +1,163 @@
1
+ import { beforeEach, describe, expect, it, mock } from 'bun:test'
2
+ import type { ChangeInfo } from '@/schemas/gerrit'
3
+
4
+ const mockChange: ChangeInfo = {
5
+ id: 'test-project~master~I123',
6
+ _number: 12345,
7
+ change_id: 'I123',
8
+ project: 'test-project',
9
+ branch: 'master',
10
+ subject: 'Test change to abandon',
11
+ status: 'NEW',
12
+ created: '2024-01-01 10:00:00.000000000',
13
+ updated: '2024-01-01 12:00:00.000000000',
14
+ owner: {
15
+ _account_id: 1000,
16
+ name: 'Test User',
17
+ email: 'test@example.com',
18
+ },
19
+ labels: {
20
+ 'Code-Review': {
21
+ value: 0,
22
+ },
23
+ Verified: {
24
+ value: 0,
25
+ },
26
+ },
27
+ work_in_progress: false,
28
+ submittable: false,
29
+ }
30
+
31
+ describe('abandon command', () => {
32
+ let mockFetch: ReturnType<typeof mock>
33
+
34
+ beforeEach(() => {
35
+ // Reset fetch mock for each test
36
+ mockFetch = mock(() =>
37
+ Promise.resolve({
38
+ ok: true,
39
+ status: 200,
40
+ text: () => Promise.resolve(')]}\n{}'),
41
+ }),
42
+ )
43
+ global.fetch = mockFetch as unknown as typeof fetch
44
+ })
45
+
46
+ it('should call abandon API endpoint with correct parameters', async () => {
47
+ // Mock successful responses
48
+ mockFetch
49
+ .mockResolvedValueOnce({
50
+ ok: true,
51
+ text: async () => `)]}'
52
+ {
53
+ "id": "test-project~master~I123",
54
+ "project": "test-project",
55
+ "branch": "master",
56
+ "change_id": "I123",
57
+ "subject": "Test change to abandon",
58
+ "status": "NEW",
59
+ "_number": 12345
60
+ }`,
61
+ })
62
+ .mockResolvedValueOnce({
63
+ ok: true,
64
+ text: async () => ')]}\n{}',
65
+ })
66
+
67
+ // Note: This is a unit test demonstrating the API calls
68
+ // Actual integration would require running the full command
69
+ // which we avoid to prevent hitting production
70
+
71
+ // Verify the mock setup
72
+ const response = await mockFetch('https://test.gerrit.com/a/changes/12345')
73
+ const text = await response.text()
74
+ expect(text).toContain('Test change to abandon')
75
+
76
+ // Verify abandon endpoint would be called
77
+ const abandonResponse = await mockFetch('https://test.gerrit.com/a/changes/12345/abandon', {
78
+ method: 'POST',
79
+ body: JSON.stringify({ message: 'No longer needed' }),
80
+ })
81
+ expect(abandonResponse.ok).toBe(true)
82
+
83
+ // Verify calls were made
84
+ expect(mockFetch).toHaveBeenCalledTimes(2)
85
+ })
86
+
87
+ it('should handle abandon without message', async () => {
88
+ mockFetch.mockResolvedValueOnce({
89
+ ok: true,
90
+ text: async () => ')]}\n{}',
91
+ })
92
+
93
+ const response = await mockFetch('https://test.gerrit.com/a/changes/12345/abandon', {
94
+ method: 'POST',
95
+ body: JSON.stringify({}),
96
+ })
97
+
98
+ expect(response.ok).toBe(true)
99
+ expect(mockFetch).toHaveBeenCalledWith('https://test.gerrit.com/a/changes/12345/abandon', {
100
+ method: 'POST',
101
+ body: JSON.stringify({}),
102
+ })
103
+ })
104
+
105
+ it('should handle API errors', async () => {
106
+ mockFetch.mockResolvedValueOnce({
107
+ ok: false,
108
+ status: 404,
109
+ text: async () => 'Change not found',
110
+ })
111
+
112
+ const response = await mockFetch('https://test.gerrit.com/a/changes/99999/abandon')
113
+ expect(response.ok).toBe(false)
114
+ expect(response.status).toBe(404)
115
+
116
+ const errorText = await response.text()
117
+ expect(errorText).toBe('Change not found')
118
+ })
119
+
120
+ it('should format message correctly in request body', () => {
121
+ const testCases = [
122
+ { input: undefined, expected: {} },
123
+ { input: '', expected: {} },
124
+ { input: 'Abandoning this change', expected: { message: 'Abandoning this change' } },
125
+ { input: 'Multi\nline\nmessage', expected: { message: 'Multi\nline\nmessage' } },
126
+ ]
127
+
128
+ for (const testCase of testCases) {
129
+ const body = testCase.input ? { message: testCase.input } : {}
130
+ expect(body).toEqual(testCase.expected)
131
+ }
132
+ })
133
+
134
+ describe('interactive mode API patterns', () => {
135
+ it('should fetch changes for interactive mode', async () => {
136
+ mockFetch.mockResolvedValueOnce({
137
+ ok: true,
138
+ text: async () => `)]}'\n[${JSON.stringify(mockChange)}]`,
139
+ })
140
+
141
+ // Test the API call pattern for interactive mode
142
+ const response = await mockFetch(
143
+ 'https://test.gerrit.com/a/changes/?q=owner:self+status:open',
144
+ )
145
+ const text = await response.text()
146
+ expect(text).toContain('Test change to abandon')
147
+ })
148
+
149
+ it('should handle empty changes list response', async () => {
150
+ mockFetch.mockResolvedValueOnce({
151
+ ok: true,
152
+ text: async () => ")]}'\n[]",
153
+ })
154
+
155
+ const response = await mockFetch(
156
+ 'https://test.gerrit.com/a/changes/?q=owner:self+status:open',
157
+ )
158
+ const text = await response.text()
159
+ const parsed = JSON.parse(text.replace(")]}'\n", ''))
160
+ expect(parsed).toEqual([])
161
+ })
162
+ })
163
+ })
@@ -0,0 +1,489 @@
1
+ import { describe, test, expect, beforeEach, mock } from 'bun:test'
2
+ import { Effect, Layer } from 'effect'
3
+ import {
4
+ AiService,
5
+ AiServiceError,
6
+ NoAiToolFoundError,
7
+ AiResponseParseError,
8
+ AiServiceLive,
9
+ } from '@/services/ai'
10
+ import { ConfigService } from '@/services/config'
11
+ import { createMockConfigService } from './helpers/config-mock'
12
+
13
+ describe('AI Service', () => {
14
+ describe('extractResponseTag', () => {
15
+ test('should extract content from response tags', async () => {
16
+ const input = `Some text before
17
+ <response>
18
+ This is the response content
19
+ </response>
20
+ Some text after`
21
+
22
+ const result = await Effect.runPromise(
23
+ Effect.gen(function* () {
24
+ const service = yield* AiService
25
+ return yield* service.extractResponseTag(input)
26
+ }).pipe(
27
+ Effect.provide(
28
+ Layer.succeed(
29
+ AiService,
30
+ AiService.of({
31
+ detectAiTool: () =>
32
+ Effect.fail(new NoAiToolFoundError({ message: 'Not implemented' })),
33
+ extractResponseTag: (output: string) =>
34
+ Effect.gen(function* () {
35
+ const responseMatch = output.match(/<response>([\s\S]*?)<\/response>/i)
36
+
37
+ if (!responseMatch || !responseMatch[1]) {
38
+ return yield* Effect.fail(
39
+ new AiResponseParseError({
40
+ message: 'No <response> tag found in AI output',
41
+ rawOutput: output,
42
+ }),
43
+ )
44
+ }
45
+
46
+ return responseMatch[1].trim()
47
+ }),
48
+ runPrompt: () => Effect.fail(new AiServiceError({ message: 'Not implemented' })),
49
+ }),
50
+ ),
51
+ ),
52
+ ),
53
+ )
54
+
55
+ expect(result).toBe('This is the response content')
56
+ })
57
+
58
+ test('should handle case-insensitive response tags', async () => {
59
+ const input = `<RESPONSE>Content here</RESPONSE>`
60
+
61
+ const result = await Effect.runPromise(
62
+ Effect.gen(function* () {
63
+ const service = yield* AiService
64
+ return yield* service.extractResponseTag(input)
65
+ }).pipe(
66
+ Effect.provide(
67
+ Layer.succeed(
68
+ AiService,
69
+ AiService.of({
70
+ detectAiTool: () =>
71
+ Effect.fail(new NoAiToolFoundError({ message: 'Not implemented' })),
72
+ extractResponseTag: (output: string) =>
73
+ Effect.gen(function* () {
74
+ const responseMatch = output.match(/<response>([\s\S]*?)<\/response>/i)
75
+
76
+ if (!responseMatch || !responseMatch[1]) {
77
+ return yield* Effect.fail(
78
+ new AiResponseParseError({
79
+ message: 'No <response> tag found in AI output',
80
+ rawOutput: output,
81
+ }),
82
+ )
83
+ }
84
+
85
+ return responseMatch[1].trim()
86
+ }),
87
+ runPrompt: () => Effect.fail(new AiServiceError({ message: 'Not implemented' })),
88
+ }),
89
+ ),
90
+ ),
91
+ ),
92
+ )
93
+
94
+ expect(result).toBe('Content here')
95
+ })
96
+
97
+ test('should fail when no response tag is found', async () => {
98
+ const input = 'This is just plain text without tags'
99
+
100
+ const result = await Effect.runPromise(
101
+ Effect.either(
102
+ Effect.gen(function* () {
103
+ const service = yield* AiService
104
+ return yield* service.extractResponseTag(input)
105
+ }).pipe(
106
+ Effect.provide(
107
+ Layer.succeed(
108
+ AiService,
109
+ AiService.of({
110
+ detectAiTool: () =>
111
+ Effect.fail(new NoAiToolFoundError({ message: 'Not implemented' })),
112
+ extractResponseTag: (output: string) =>
113
+ Effect.gen(function* () {
114
+ const responseMatch = output.match(/<response>([\s\S]*?)<\/response>/i)
115
+
116
+ if (!responseMatch || !responseMatch[1]) {
117
+ return yield* Effect.fail(
118
+ new AiResponseParseError({
119
+ message: 'No <response> tag found in AI output',
120
+ rawOutput: output,
121
+ }),
122
+ )
123
+ }
124
+
125
+ return responseMatch[1].trim()
126
+ }),
127
+ runPrompt: () => Effect.fail(new AiServiceError({ message: 'Not implemented' })),
128
+ }),
129
+ ),
130
+ ),
131
+ ),
132
+ ),
133
+ )
134
+
135
+ expect(result._tag).toBe('Left')
136
+ if (result._tag === 'Left') {
137
+ expect(result.left).toBeInstanceOf(AiResponseParseError)
138
+ expect((result.left as AiResponseParseError).rawOutput).toBe(input)
139
+ }
140
+ })
141
+
142
+ test('should handle multiline content in response tags', async () => {
143
+ const input = `<response>
144
+ Line 1
145
+ Line 2
146
+ Line 3
147
+ </response>`
148
+
149
+ const result = await Effect.runPromise(
150
+ Effect.gen(function* () {
151
+ const service = yield* AiService
152
+ return yield* service.extractResponseTag(input)
153
+ }).pipe(
154
+ Effect.provide(
155
+ Layer.succeed(
156
+ AiService,
157
+ AiService.of({
158
+ detectAiTool: () =>
159
+ Effect.fail(new NoAiToolFoundError({ message: 'Not implemented' })),
160
+ extractResponseTag: (output: string) =>
161
+ Effect.gen(function* () {
162
+ const responseMatch = output.match(/<response>([\s\S]*?)<\/response>/i)
163
+
164
+ if (!responseMatch || !responseMatch[1]) {
165
+ return yield* Effect.fail(
166
+ new AiResponseParseError({
167
+ message: 'No <response> tag found in AI output',
168
+ rawOutput: output,
169
+ }),
170
+ )
171
+ }
172
+
173
+ return responseMatch[1].trim()
174
+ }),
175
+ runPrompt: () => Effect.fail(new AiServiceError({ message: 'Not implemented' })),
176
+ }),
177
+ ),
178
+ ),
179
+ ),
180
+ )
181
+
182
+ expect(result).toBe('Line 1\nLine 2\nLine 3')
183
+ })
184
+ })
185
+
186
+ describe('Error Types', () => {
187
+ test('NoAiToolFoundError should have correct message', () => {
188
+ const error = new NoAiToolFoundError({
189
+ message: 'No AI tool found. Please install claude, llm, or opencode CLI.',
190
+ })
191
+ expect(error.message).toBe('No AI tool found. Please install claude, llm, or opencode CLI.')
192
+ })
193
+
194
+ test('AiResponseParseError should include raw output', () => {
195
+ const error = new AiResponseParseError({
196
+ message: 'Failed to parse response',
197
+ rawOutput: 'Some raw output',
198
+ })
199
+ expect(error.message).toBe('Failed to parse response')
200
+ expect(error.rawOutput).toBe('Some raw output')
201
+ })
202
+
203
+ test('AiServiceError should have message and optional cause', () => {
204
+ const cause = new Error('Original error')
205
+ const error = new AiServiceError({
206
+ message: 'Service failed',
207
+ cause,
208
+ })
209
+ expect(error.message).toBe('Service failed')
210
+ expect(error.cause).toBe(cause)
211
+ })
212
+ })
213
+
214
+ describe('detectAiTool', () => {
215
+ test('should detect claude tool when available', async () => {
216
+ // Mock which command to return success for claude
217
+ const mockExecAsync = mock(() =>
218
+ Promise.resolve({ stdout: '/usr/local/bin/claude\n', stderr: '' }),
219
+ )
220
+
221
+ const mockAiService = AiService.of({
222
+ detectAiTool: () => Effect.succeed('claude'),
223
+ extractResponseTag: (output: string) => Effect.succeed(output),
224
+ runPrompt: () => Effect.succeed('mock response'),
225
+ })
226
+
227
+ const result = await Effect.runPromise(
228
+ Effect.gen(function* () {
229
+ const service = yield* AiService
230
+ return yield* service.detectAiTool()
231
+ }).pipe(Effect.provide(Layer.succeed(AiService, mockAiService))),
232
+ )
233
+
234
+ expect(result).toBe('claude')
235
+ })
236
+
237
+ test('should detect llm tool when claude not available', async () => {
238
+ const mockAiService = AiService.of({
239
+ detectAiTool: () => Effect.succeed('llm'),
240
+ extractResponseTag: (output: string) => Effect.succeed(output),
241
+ runPrompt: () => Effect.succeed('mock response'),
242
+ })
243
+
244
+ const result = await Effect.runPromise(
245
+ Effect.gen(function* () {
246
+ const service = yield* AiService
247
+ return yield* service.detectAiTool()
248
+ }).pipe(Effect.provide(Layer.succeed(AiService, mockAiService))),
249
+ )
250
+
251
+ expect(result).toBe('llm')
252
+ })
253
+
254
+ test('should detect opencode tool when others not available', async () => {
255
+ const mockAiService = AiService.of({
256
+ detectAiTool: () => Effect.succeed('opencode'),
257
+ extractResponseTag: (output: string) => Effect.succeed(output),
258
+ runPrompt: () => Effect.succeed('mock response'),
259
+ })
260
+
261
+ const result = await Effect.runPromise(
262
+ Effect.gen(function* () {
263
+ const service = yield* AiService
264
+ return yield* service.detectAiTool()
265
+ }).pipe(Effect.provide(Layer.succeed(AiService, mockAiService))),
266
+ )
267
+
268
+ expect(result).toBe('opencode')
269
+ })
270
+
271
+ test('should detect gemini tool when others not available', async () => {
272
+ const mockAiService = AiService.of({
273
+ detectAiTool: () => Effect.succeed('gemini'),
274
+ extractResponseTag: (output: string) => Effect.succeed(output),
275
+ runPrompt: () => Effect.succeed('mock response'),
276
+ })
277
+
278
+ const result = await Effect.runPromise(
279
+ Effect.gen(function* () {
280
+ const service = yield* AiService
281
+ return yield* service.detectAiTool()
282
+ }).pipe(Effect.provide(Layer.succeed(AiService, mockAiService))),
283
+ )
284
+
285
+ expect(result).toBe('gemini')
286
+ })
287
+
288
+ test('should fail when no AI tools are available', async () => {
289
+ const mockAiService = AiService.of({
290
+ detectAiTool: () =>
291
+ Effect.fail(
292
+ new NoAiToolFoundError({
293
+ message: 'No AI tool found. Please install claude, llm, opencode, or gemini CLI.',
294
+ }),
295
+ ),
296
+ extractResponseTag: (output: string) => Effect.succeed(output),
297
+ runPrompt: () => Effect.succeed('mock response'),
298
+ })
299
+
300
+ const result = await Effect.runPromise(
301
+ Effect.either(
302
+ Effect.gen(function* () {
303
+ const service = yield* AiService
304
+ return yield* service.detectAiTool()
305
+ }).pipe(Effect.provide(Layer.succeed(AiService, mockAiService))),
306
+ ),
307
+ )
308
+
309
+ expect(result._tag).toBe('Left')
310
+ if (result._tag === 'Left') {
311
+ expect(result.left).toBeInstanceOf(NoAiToolFoundError)
312
+ expect((result.left as NoAiToolFoundError).message).toContain('No AI tool found')
313
+ }
314
+ })
315
+ })
316
+
317
+ describe('runPrompt', () => {
318
+ test('should successfully run prompt with claude', async () => {
319
+ const mockAiService = AiService.of({
320
+ detectAiTool: () => Effect.succeed('claude'),
321
+ extractResponseTag: (output: string) => Effect.succeed('extracted response'),
322
+ runPrompt: (prompt: string, input: string) => {
323
+ expect(prompt).toBe('Test prompt')
324
+ expect(input).toBe('Test input')
325
+ return Effect.succeed('extracted response')
326
+ },
327
+ })
328
+
329
+ const result = await Effect.runPromise(
330
+ Effect.gen(function* () {
331
+ const service = yield* AiService
332
+ return yield* service.runPrompt('Test prompt', 'Test input')
333
+ }).pipe(Effect.provide(Layer.succeed(AiService, mockAiService))),
334
+ )
335
+
336
+ expect(result).toBe('extracted response')
337
+ })
338
+
339
+ test('should successfully run prompt with llm', async () => {
340
+ const mockAiService = AiService.of({
341
+ detectAiTool: () => Effect.succeed('llm'),
342
+ extractResponseTag: (output: string) => Effect.succeed('llm response'),
343
+ runPrompt: (prompt: string, input: string) => {
344
+ return Effect.succeed('llm response')
345
+ },
346
+ })
347
+
348
+ const result = await Effect.runPromise(
349
+ Effect.gen(function* () {
350
+ const service = yield* AiService
351
+ return yield* service.runPrompt('Test prompt', 'Test input')
352
+ }).pipe(Effect.provide(Layer.succeed(AiService, mockAiService))),
353
+ )
354
+
355
+ expect(result).toBe('llm response')
356
+ })
357
+
358
+ test('should handle AI tool execution failure', async () => {
359
+ const mockAiService = AiService.of({
360
+ detectAiTool: () => Effect.succeed('claude'),
361
+ extractResponseTag: (output: string) => Effect.succeed(output),
362
+ runPrompt: () =>
363
+ Effect.fail(
364
+ new AiServiceError({
365
+ message: 'Failed to run AI tool: Command not found',
366
+ cause: new Error('ENOENT'),
367
+ }),
368
+ ),
369
+ })
370
+
371
+ const result = await Effect.runPromise(
372
+ Effect.either(
373
+ Effect.gen(function* () {
374
+ const service = yield* AiService
375
+ return yield* service.runPrompt('Test prompt', 'Test input')
376
+ }).pipe(Effect.provide(Layer.succeed(AiService, mockAiService))),
377
+ ),
378
+ )
379
+
380
+ expect(result._tag).toBe('Left')
381
+ if (result._tag === 'Left') {
382
+ expect(result.left).toBeInstanceOf(AiServiceError)
383
+ expect((result.left as AiServiceError).message).toContain('Failed to run AI tool')
384
+ }
385
+ })
386
+
387
+ test('should handle missing response tag in AI output', async () => {
388
+ const mockAiService = AiService.of({
389
+ detectAiTool: () => Effect.succeed('claude'),
390
+ extractResponseTag: (output: string) =>
391
+ Effect.fail(
392
+ new AiResponseParseError({
393
+ message: 'No <response> tag found in AI output',
394
+ rawOutput: 'Raw AI output without response tags',
395
+ }),
396
+ ),
397
+ runPrompt: () =>
398
+ Effect.fail(
399
+ new AiResponseParseError({
400
+ message: 'No <response> tag found in AI output',
401
+ rawOutput: 'Raw AI output without response tags',
402
+ }),
403
+ ),
404
+ })
405
+
406
+ const result = await Effect.runPromise(
407
+ Effect.either(
408
+ Effect.gen(function* () {
409
+ const service = yield* AiService
410
+ return yield* service.runPrompt('Test prompt', 'Test input')
411
+ }).pipe(Effect.provide(Layer.succeed(AiService, mockAiService))),
412
+ ),
413
+ )
414
+
415
+ expect(result._tag).toBe('Left')
416
+ if (result._tag === 'Left') {
417
+ expect(result.left).toBeInstanceOf(AiResponseParseError)
418
+ expect((result.left as AiResponseParseError).rawOutput).toBe(
419
+ 'Raw AI output without response tags',
420
+ )
421
+ }
422
+ })
423
+
424
+ test('should handle no AI tool found during prompt execution', async () => {
425
+ const mockAiService = AiService.of({
426
+ detectAiTool: () =>
427
+ Effect.fail(
428
+ new NoAiToolFoundError({
429
+ message: 'No AI tool found',
430
+ }),
431
+ ),
432
+ extractResponseTag: (output: string) => Effect.succeed(output),
433
+ runPrompt: () =>
434
+ Effect.fail(
435
+ new NoAiToolFoundError({
436
+ message: 'No AI tool found',
437
+ }),
438
+ ),
439
+ })
440
+
441
+ const result = await Effect.runPromise(
442
+ Effect.either(
443
+ Effect.gen(function* () {
444
+ const service = yield* AiService
445
+ return yield* service.runPrompt('Test prompt', 'Test input')
446
+ }).pipe(Effect.provide(Layer.succeed(AiService, mockAiService))),
447
+ ),
448
+ )
449
+
450
+ expect(result._tag).toBe('Left')
451
+ if (result._tag === 'Left') {
452
+ expect(result.left).toBeInstanceOf(NoAiToolFoundError)
453
+ }
454
+ })
455
+
456
+ test('should format input correctly for AI tool', async () => {
457
+ const mockAiService = AiService.of({
458
+ detectAiTool: () => Effect.succeed('claude'),
459
+ extractResponseTag: (output: string) => Effect.succeed('response content'),
460
+ runPrompt: (prompt: string, input: string) => {
461
+ // Verify the prompt and input are passed correctly
462
+ expect(prompt).toBe('System: Analyze this code')
463
+ expect(input).toBe('function test() { return 42; }')
464
+ return Effect.succeed('response content')
465
+ },
466
+ })
467
+
468
+ const result = await Effect.runPromise(
469
+ Effect.gen(function* () {
470
+ const service = yield* AiService
471
+ return yield* service.runPrompt(
472
+ 'System: Analyze this code',
473
+ 'function test() { return 42; }',
474
+ )
475
+ }).pipe(Effect.provide(Layer.succeed(AiService, mockAiService))),
476
+ )
477
+
478
+ expect(result).toBe('response content')
479
+ })
480
+ })
481
+
482
+ describe('AiServiceLive integration', () => {
483
+ test('should be able to create live service layer', () => {
484
+ // Test that the live service layer can be created without errors
485
+ expect(AiServiceLive).toBeDefined()
486
+ expect(typeof AiServiceLive).toBe('object')
487
+ })
488
+ })
489
+ })