@joeytheman/gemini-mcp-tool 1.3.0 → 2.2.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 (115) hide show
  1. package/README.md +123 -34
  2. package/dist/__tests__/ask-gemini.test.js +99 -37
  3. package/dist/__tests__/ask-gemini.test.js.map +1 -1
  4. package/dist/__tests__/live-pass.test.d.ts +2 -0
  5. package/dist/__tests__/live-pass.test.d.ts.map +1 -0
  6. package/dist/__tests__/live-pass.test.js +725 -0
  7. package/dist/__tests__/live-pass.test.js.map +1 -0
  8. package/dist/__tests__/screen-review.test.d.ts +2 -0
  9. package/dist/__tests__/screen-review.test.d.ts.map +1 -0
  10. package/dist/__tests__/screen-review.test.js +444 -0
  11. package/dist/__tests__/screen-review.test.js.map +1 -0
  12. package/dist/__tests__/server.test.js +17 -12
  13. package/dist/__tests__/server.test.js.map +1 -1
  14. package/dist/__tests__/simple-tools.test.d.ts +2 -0
  15. package/dist/__tests__/simple-tools.test.d.ts.map +1 -0
  16. package/dist/__tests__/simple-tools.test.js +25 -0
  17. package/dist/__tests__/simple-tools.test.js.map +1 -0
  18. package/dist/__tests__/tools.test.js +63 -0
  19. package/dist/__tests__/tools.test.js.map +1 -1
  20. package/dist/__tests__/utils/agyExecutor.test.d.ts +2 -0
  21. package/dist/__tests__/utils/agyExecutor.test.d.ts.map +1 -0
  22. package/dist/__tests__/utils/agyExecutor.test.js +616 -0
  23. package/dist/__tests__/utils/agyExecutor.test.js.map +1 -0
  24. package/dist/__tests__/utils/agyTranscriptRecovery.test.d.ts +2 -0
  25. package/dist/__tests__/utils/agyTranscriptRecovery.test.d.ts.map +1 -0
  26. package/dist/__tests__/utils/agyTranscriptRecovery.test.js +127 -0
  27. package/dist/__tests__/utils/agyTranscriptRecovery.test.js.map +1 -0
  28. package/dist/__tests__/utils/chunkCache.test.js +26 -2
  29. package/dist/__tests__/utils/chunkCache.test.js.map +1 -1
  30. package/dist/__tests__/utils/commandExecutor.test.js +30 -6
  31. package/dist/__tests__/utils/commandExecutor.test.js.map +1 -1
  32. package/dist/__tests__/utils/fileReferences.test.d.ts +2 -0
  33. package/dist/__tests__/utils/fileReferences.test.d.ts.map +1 -0
  34. package/dist/__tests__/utils/fileReferences.test.js +87 -0
  35. package/dist/__tests__/utils/fileReferences.test.js.map +1 -0
  36. package/dist/__tests__/utils/gitStatusGuard.test.d.ts +2 -0
  37. package/dist/__tests__/utils/gitStatusGuard.test.d.ts.map +1 -0
  38. package/dist/__tests__/utils/gitStatusGuard.test.js +106 -0
  39. package/dist/__tests__/utils/gitStatusGuard.test.js.map +1 -0
  40. package/dist/__tests__/utils/responseCache.test.js +31 -11
  41. package/dist/__tests__/utils/responseCache.test.js.map +1 -1
  42. package/dist/__tests__/utils/test-helpers.d.ts +4 -0
  43. package/dist/__tests__/utils/test-helpers.d.ts.map +1 -1
  44. package/dist/__tests__/utils/test-helpers.js +16 -0
  45. package/dist/__tests__/utils/test-helpers.js.map +1 -1
  46. package/dist/constants.d.ts +68 -26
  47. package/dist/constants.d.ts.map +1 -1
  48. package/dist/constants.js +69 -28
  49. package/dist/constants.js.map +1 -1
  50. package/dist/index.js +2 -2
  51. package/dist/index.js.map +1 -1
  52. package/dist/tools/ask-gemini.tool.d.ts +34 -0
  53. package/dist/tools/ask-gemini.tool.d.ts.map +1 -1
  54. package/dist/tools/ask-gemini.tool.js +38 -22
  55. package/dist/tools/ask-gemini.tool.js.map +1 -1
  56. package/dist/tools/brainstorm.tool.d.ts.map +1 -1
  57. package/dist/tools/brainstorm.tool.js +5 -4
  58. package/dist/tools/brainstorm.tool.js.map +1 -1
  59. package/dist/tools/index.d.ts.map +1 -1
  60. package/dist/tools/index.js +3 -1
  61. package/dist/tools/index.js.map +1 -1
  62. package/dist/tools/live-pass.tool.d.ts +183 -0
  63. package/dist/tools/live-pass.tool.d.ts.map +1 -0
  64. package/dist/tools/live-pass.tool.js +548 -0
  65. package/dist/tools/live-pass.tool.js.map +1 -0
  66. package/dist/tools/registry.d.ts +4 -2
  67. package/dist/tools/registry.d.ts.map +1 -1
  68. package/dist/tools/registry.js +12 -9
  69. package/dist/tools/registry.js.map +1 -1
  70. package/dist/tools/screen-review.tool.d.ts +121 -0
  71. package/dist/tools/screen-review.tool.d.ts.map +1 -0
  72. package/dist/tools/screen-review.tool.js +415 -0
  73. package/dist/tools/screen-review.tool.js.map +1 -0
  74. package/dist/tools/simple-tools.d.ts.map +1 -1
  75. package/dist/tools/simple-tools.js +4 -2
  76. package/dist/tools/simple-tools.js.map +1 -1
  77. package/dist/utils/agyExecutor.d.ts +68 -0
  78. package/dist/utils/agyExecutor.d.ts.map +1 -0
  79. package/dist/utils/agyExecutor.js +443 -0
  80. package/dist/utils/agyExecutor.js.map +1 -0
  81. package/dist/utils/agyReport.d.ts +19 -0
  82. package/dist/utils/agyReport.d.ts.map +1 -0
  83. package/dist/utils/agyReport.js +62 -0
  84. package/dist/utils/agyReport.js.map +1 -0
  85. package/dist/utils/agyTranscriptRecovery.d.ts +16 -0
  86. package/dist/utils/agyTranscriptRecovery.d.ts.map +1 -0
  87. package/dist/utils/agyTranscriptRecovery.js +149 -0
  88. package/dist/utils/agyTranscriptRecovery.js.map +1 -0
  89. package/dist/utils/chunkCache.d.ts.map +1 -1
  90. package/dist/utils/chunkCache.js +19 -5
  91. package/dist/utils/chunkCache.js.map +1 -1
  92. package/dist/utils/commandExecutor.d.ts.map +1 -1
  93. package/dist/utils/commandExecutor.js +15 -7
  94. package/dist/utils/commandExecutor.js.map +1 -1
  95. package/dist/utils/fileReferences.d.ts +45 -0
  96. package/dist/utils/fileReferences.d.ts.map +1 -0
  97. package/dist/utils/fileReferences.js +169 -0
  98. package/dist/utils/fileReferences.js.map +1 -0
  99. package/dist/utils/gitStatusGuard.d.ts +35 -0
  100. package/dist/utils/gitStatusGuard.d.ts.map +1 -0
  101. package/dist/utils/gitStatusGuard.js +82 -0
  102. package/dist/utils/gitStatusGuard.js.map +1 -0
  103. package/dist/utils/responseCache.d.ts +3 -3
  104. package/dist/utils/responseCache.d.ts.map +1 -1
  105. package/dist/utils/responseCache.js +11 -6
  106. package/dist/utils/responseCache.js.map +1 -1
  107. package/package.json +10 -9
  108. package/dist/__tests__/utils/geminiExecutor.test.d.ts +0 -2
  109. package/dist/__tests__/utils/geminiExecutor.test.d.ts.map +0 -1
  110. package/dist/__tests__/utils/geminiExecutor.test.js +0 -337
  111. package/dist/__tests__/utils/geminiExecutor.test.js.map +0 -1
  112. package/dist/utils/geminiExecutor.d.ts +0 -17
  113. package/dist/utils/geminiExecutor.d.ts.map +0 -1
  114. package/dist/utils/geminiExecutor.js +0 -238
  115. package/dist/utils/geminiExecutor.js.map +0 -1
@@ -0,0 +1,616 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ vi.mock('../../utils/logger.js', () => ({
3
+ Logger: {
4
+ debug: vi.fn(),
5
+ warn: vi.fn(),
6
+ error: vi.fn(),
7
+ log: vi.fn(),
8
+ commandExecution: vi.fn(),
9
+ commandComplete: vi.fn(),
10
+ },
11
+ }));
12
+ const mockExecuteCommand = vi.fn();
13
+ vi.mock('../../utils/commandExecutor.js', () => ({
14
+ executeCommand: (...args) => mockExecuteCommand(...args),
15
+ }));
16
+ const mockIsCacheEnabled = vi.fn().mockReturnValue(false);
17
+ const mockGenerateCacheKey = vi.fn().mockReturnValue('mock-cache-key');
18
+ const mockGetCachedResponse = vi.fn().mockReturnValue(undefined);
19
+ const mockCacheResponse = vi.fn();
20
+ vi.mock('../../utils/responseCache.js', () => ({
21
+ isCacheEnabled: () => mockIsCacheEnabled(),
22
+ generateCacheKey: (...args) => mockGenerateCacheKey(...args),
23
+ getCachedResponse: (...args) => mockGetCachedResponse(...args),
24
+ cacheResponse: (...args) => mockCacheResponse(...args),
25
+ }));
26
+ vi.mock('../../utils/chunkCache.js', () => ({
27
+ cacheChunks: vi.fn().mockReturnValue('chunk123'),
28
+ getChunks: vi.fn().mockReturnValue(null),
29
+ }));
30
+ const mockRecover = vi.fn();
31
+ vi.mock('../../utils/agyTranscriptRecovery.js', () => ({
32
+ isRecoverableEmptyOutput: (s) => {
33
+ const t = s.trim();
34
+ return t === '' || t === 'Error: timed out waiting for response';
35
+ },
36
+ recoverFromTranscript: (...args) => mockRecover(...args),
37
+ }));
38
+ import { executeAgyCLI, executeAgyJson, processChangeModeOutput, parseAgyJsonEnvelope, renderAgyJsonEnvelope, deniedActionsSummary, formatAgyUsageLine, formatConversationLine, } from '../../utils/agyExecutor.js';
39
+ import { CLI, MODELS, ERROR_MESSAGES } from '../../constants.js';
40
+ describe('executeAgyCLI', () => {
41
+ beforeEach(() => {
42
+ vi.clearAllMocks();
43
+ mockIsCacheEnabled.mockReturnValue(false);
44
+ mockGenerateCacheKey.mockReturnValue('mock-cache-key');
45
+ mockGetCachedResponse.mockReturnValue(undefined);
46
+ mockExecuteCommand.mockResolvedValue('Gemini response');
47
+ });
48
+ describe('argument building', () => {
49
+ it('should use agy with the default Gemini model and print mode', async () => {
50
+ await executeAgyCLI('test', {});
51
+ expect(MODELS.DEFAULT).toBe('Gemini 4 Argon (High)');
52
+ expect(mockExecuteCommand).toHaveBeenCalledWith(CLI.COMMANDS.AGY, [CLI.FLAGS.MODEL, MODELS.DEFAULT, CLI.FLAGS.PRINT, 'test'], undefined, undefined);
53
+ });
54
+ it.each([
55
+ 'Gemini 4 Argon (Low)',
56
+ 'Gemini 4 Argon (Medium)',
57
+ 'Gemini 4 Argon (High)',
58
+ 'Gemini 3.8 Flash (Medium)',
59
+ 'gemini-4-argon-medium',
60
+ ])('should pass the explicit model %s unchanged', async (model) => {
61
+ await executeAgyCLI('test', { model });
62
+ const args = mockExecuteCommand.mock.calls[0][1];
63
+ expect(args.slice(0, 2)).toEqual([CLI.FLAGS.MODEL, model]);
64
+ });
65
+ it('should add sandbox flag when specified', async () => {
66
+ await executeAgyCLI('test', { sandbox: true });
67
+ const args = mockExecuteCommand.mock.calls[0][1];
68
+ expect(args).toContain(CLI.FLAGS.SANDBOX);
69
+ });
70
+ it('should map yolo to dangerously skip permissions', async () => {
71
+ await executeAgyCLI('test', { yolo: true });
72
+ const args = mockExecuteCommand.mock.calls[0][1];
73
+ expect(args).toContain(CLI.FLAGS.YOLO);
74
+ });
75
+ it('should map approvalMode yolo to dangerously skip permissions', async () => {
76
+ await executeAgyCLI('test', { approvalMode: 'yolo' });
77
+ const args = mockExecuteCommand.mock.calls[0][1];
78
+ expect(args).toContain(CLI.FLAGS.YOLO);
79
+ });
80
+ it('should map includeDirectories array to repeated add-dir flags', async () => {
81
+ await executeAgyCLI('test', { includeDirectories: ['src', 'lib'] });
82
+ const args = mockExecuteCommand.mock.calls[0][1];
83
+ expect(args).toEqual([
84
+ CLI.FLAGS.MODEL,
85
+ MODELS.DEFAULT,
86
+ CLI.FLAGS.ADD_DIR,
87
+ 'src',
88
+ CLI.FLAGS.ADD_DIR,
89
+ 'lib',
90
+ CLI.FLAGS.PRINT,
91
+ 'test',
92
+ ]);
93
+ });
94
+ it('should split includeDirectories string and map to repeated add-dir flags', async () => {
95
+ await executeAgyCLI('test', { includeDirectories: 'src, lib' });
96
+ const args = mockExecuteCommand.mock.calls[0][1];
97
+ expect(args).toContain(CLI.FLAGS.ADD_DIR);
98
+ expect(args.filter((arg) => arg === CLI.FLAGS.ADD_DIR)).toHaveLength(2);
99
+ expect(args).toContain('src');
100
+ expect(args).toContain('lib');
101
+ });
102
+ it('should add print timeout when specified', async () => {
103
+ await executeAgyCLI('test', { printTimeout: '10m' });
104
+ const args = mockExecuteCommand.mock.calls[0][1];
105
+ expect(args).toContain(CLI.FLAGS.PRINT_TIMEOUT);
106
+ expect(args).toContain('10m');
107
+ });
108
+ it('should map latest resume values to continue', async () => {
109
+ await executeAgyCLI('test', { resume: 'latest' });
110
+ const args = mockExecuteCommand.mock.calls[0][1];
111
+ expect(args).toContain(CLI.FLAGS.CONTINUE);
112
+ expect(args).not.toContain(CLI.FLAGS.CONVERSATION);
113
+ });
114
+ it('should map conversation IDs to conversation flag', async () => {
115
+ await executeAgyCLI('test', { resume: 'conversation-123' });
116
+ const args = mockExecuteCommand.mock.calls[0][1];
117
+ expect(args).toContain(CLI.FLAGS.CONVERSATION);
118
+ expect(args).toContain('conversation-123');
119
+ });
120
+ it('should reject blank resume values', async () => {
121
+ await expect(executeAgyCLI('test', { resume: ' ' }))
122
+ .rejects.toThrow('resume must be true, latest, continue, or a non-empty conversation ID');
123
+ expect(mockExecuteCommand).not.toHaveBeenCalled();
124
+ });
125
+ it('should pass prompt through --print rather than a positional prompt', async () => {
126
+ await executeAgyCLI('analyze @file.ts', {});
127
+ const args = mockExecuteCommand.mock.calls[0][1];
128
+ expect(args[args.length - 2]).toBe(CLI.FLAGS.PRINT);
129
+ expect(args[args.length - 1]).toBe('analyze @file.ts');
130
+ });
131
+ it('should pass cwd when specified', async () => {
132
+ await executeAgyCLI('test', { cwd: '/some/path' });
133
+ const cwd = mockExecuteCommand.mock.calls[0][3];
134
+ expect(cwd).toBe('/some/path');
135
+ });
136
+ it('should handle string options for model compatibility', async () => {
137
+ await executeAgyCLI('test', 'Gemini 3.8 Flash (Low)');
138
+ const args = mockExecuteCommand.mock.calls[0][1];
139
+ expect(args).toContain(CLI.FLAGS.MODEL);
140
+ expect(args).toContain('Gemini 3.8 Flash (Low)');
141
+ });
142
+ });
143
+ describe('unsupported legacy Gemini CLI options', () => {
144
+ it.each([
145
+ [{ debug: true }, 'debug'],
146
+ [{ extensions: ['ts'] }, 'extensions'],
147
+ [{ promptInteractive: 'hello' }, 'promptInteractive'],
148
+ [{ approvalMode: 'auto_edit' }, 'approvalMode:auto_edit'],
149
+ ])('should reject unsupported option %s', async (options, optionName) => {
150
+ await expect(executeAgyCLI('test', options)).rejects.toThrow(String(optionName));
151
+ expect(mockExecuteCommand).not.toHaveBeenCalled();
152
+ });
153
+ });
154
+ describe('no fallback behavior', () => {
155
+ it('should surface an unavailable default model without retrying', async () => {
156
+ mockExecuteCommand.mockRejectedValueOnce(new Error('invalid model selection: Gemini 4 Argon (High)'));
157
+ await expect(executeAgyCLI('test', {}))
158
+ .rejects.toThrow('invalid model selection: Gemini 4 Argon (High)');
159
+ expect(mockExecuteCommand).toHaveBeenCalledTimes(1);
160
+ });
161
+ it('should not retry when the agy backend reports resource exhaustion', async () => {
162
+ mockExecuteCommand.mockRejectedValueOnce(new Error('RESOURCE_EXHAUSTED'));
163
+ await expect(executeAgyCLI('test', { model: 'Gemini 3.8 Flash (Medium)' }))
164
+ .rejects.toThrow('RESOURCE_EXHAUSTED');
165
+ expect(mockExecuteCommand).toHaveBeenCalledTimes(1);
166
+ });
167
+ it('should return install guidance when agy is missing', async () => {
168
+ mockExecuteCommand.mockRejectedValueOnce(new Error('Failed to spawn command: ENOENT'));
169
+ await expect(executeAgyCLI('test', {})).rejects.toThrow(ERROR_MESSAGES.AGY_NOT_FOUND);
170
+ });
171
+ });
172
+ describe('output recovery', () => {
173
+ it('recovers from the transcript when stdout is empty', async () => {
174
+ mockExecuteCommand.mockResolvedValue('');
175
+ mockRecover.mockReturnValue('RECOVERED ANSWER');
176
+ const result = await executeAgyCLI('test', {});
177
+ expect(result).toBe('RECOVERED ANSWER');
178
+ expect(mockRecover).toHaveBeenCalled();
179
+ });
180
+ it('recovers when stdout is the timeout sentinel', async () => {
181
+ mockExecuteCommand.mockResolvedValue('Error: timed out waiting for response');
182
+ mockRecover.mockReturnValue('RECOVERED ANSWER');
183
+ const result = await executeAgyCLI('test', {});
184
+ expect(result).toBe('RECOVERED ANSWER');
185
+ });
186
+ it('throws AGY_NO_OUTPUT for the timeout sentinel when recovery fails', async () => {
187
+ mockIsCacheEnabled.mockReturnValue(true);
188
+ mockExecuteCommand.mockResolvedValue('Error: timed out waiting for response');
189
+ mockRecover.mockReturnValue(null);
190
+ await expect(executeAgyCLI('test', {})).rejects.toThrow(ERROR_MESSAGES.AGY_NO_OUTPUT);
191
+ expect(mockCacheResponse).not.toHaveBeenCalled();
192
+ });
193
+ it('returns empty (soft) and does not cache for genuinely empty output without recovery', async () => {
194
+ mockIsCacheEnabled.mockReturnValue(true);
195
+ mockExecuteCommand.mockResolvedValue('');
196
+ mockRecover.mockReturnValue(null);
197
+ const result = await executeAgyCLI('test', {});
198
+ expect(result).toBe('');
199
+ expect(mockCacheResponse).not.toHaveBeenCalled();
200
+ });
201
+ it('does not attempt recovery for normal output', async () => {
202
+ mockExecuteCommand.mockResolvedValue('a real answer');
203
+ const result = await executeAgyCLI('test', {});
204
+ expect(result).toBe('a real answer');
205
+ expect(mockRecover).not.toHaveBeenCalled();
206
+ });
207
+ });
208
+ describe('safety guards', () => {
209
+ it('refuses yolo with a filesystem-root workingDirectory', async () => {
210
+ await expect(executeAgyCLI('test', { yolo: true, cwd: '/' }))
211
+ .rejects.toThrow(ERROR_MESSAGES.UNSAFE_ROOT_YOLO);
212
+ expect(mockExecuteCommand).not.toHaveBeenCalled();
213
+ });
214
+ });
215
+ describe('cache integration', () => {
216
+ it('should return cached response when cache is enabled', async () => {
217
+ mockIsCacheEnabled.mockReturnValue(true);
218
+ mockGetCachedResponse.mockReturnValue('cached result');
219
+ const result = await executeAgyCLI('test', {});
220
+ expect(result).toBe('cached result');
221
+ expect(mockExecuteCommand).not.toHaveBeenCalled();
222
+ });
223
+ it('should cache successful responses when cache is enabled', async () => {
224
+ mockIsCacheEnabled.mockReturnValue(true);
225
+ mockGetCachedResponse.mockReturnValue(undefined);
226
+ mockExecuteCommand.mockResolvedValue('fresh result');
227
+ await executeAgyCLI('test', {});
228
+ expect(mockCacheResponse).toHaveBeenCalledWith('mock-cache-key', 'fresh result');
229
+ });
230
+ it('should not cache changeMode responses', async () => {
231
+ mockIsCacheEnabled.mockReturnValue(true);
232
+ mockGetCachedResponse.mockReturnValue(undefined);
233
+ mockExecuteCommand.mockResolvedValue('edit result');
234
+ await executeAgyCLI('test', { changeMode: true });
235
+ expect(mockCacheResponse).not.toHaveBeenCalled();
236
+ });
237
+ it('should not use cache when disabled', async () => {
238
+ mockIsCacheEnabled.mockReturnValue(false);
239
+ await executeAgyCLI('test', {});
240
+ expect(mockGetCachedResponse).not.toHaveBeenCalled();
241
+ });
242
+ });
243
+ });
244
+ describe('processChangeModeOutput', () => {
245
+ beforeEach(() => {
246
+ vi.clearAllMocks();
247
+ });
248
+ it('should parse raw result and return formatted edits', async () => {
249
+ const raw = `**FILE: src/a.ts:10**
250
+ \`\`\`
251
+ OLD:
252
+ const x = 1;
253
+ NEW:
254
+ const x = 2;
255
+ \`\`\``;
256
+ const result = await processChangeModeOutput(raw);
257
+ expect(result).toContain('[CHANGEMODE OUTPUT');
258
+ expect(result).toContain('src/a.ts');
259
+ expect(result).toContain('const x = 1;');
260
+ expect(result).toContain('const x = 2;');
261
+ });
262
+ it('should return "No edits found" for unparseable input', async () => {
263
+ const result = await processChangeModeOutput('just some random text');
264
+ expect(result).toContain('No edits found');
265
+ });
266
+ it('should return validation errors for invalid edits', async () => {
267
+ const raw = `**FILE: src/a.ts:10**
268
+ \`\`\`
269
+ OLD:
270
+ old code
271
+ NEW:
272
+ new code
273
+ \`\`\``;
274
+ const result = await processChangeModeOutput(raw);
275
+ expect(result).not.toContain('validation failed');
276
+ });
277
+ it('should handle cached chunks when cacheKey provided', async () => {
278
+ const { getChunks } = await import('../../utils/chunkCache.js');
279
+ vi.mocked(getChunks).mockReturnValueOnce([
280
+ {
281
+ edits: [{
282
+ filename: 'cached.ts',
283
+ oldStartLine: 1,
284
+ oldEndLine: 1,
285
+ oldCode: 'cached old',
286
+ newStartLine: 1,
287
+ newEndLine: 1,
288
+ newCode: 'cached new',
289
+ }],
290
+ chunkIndex: 1,
291
+ totalChunks: 2,
292
+ hasMore: true,
293
+ estimatedChars: 500,
294
+ },
295
+ {
296
+ edits: [{
297
+ filename: 'cached2.ts',
298
+ oldStartLine: 1,
299
+ oldEndLine: 1,
300
+ oldCode: 'old2',
301
+ newStartLine: 1,
302
+ newEndLine: 1,
303
+ newCode: 'new2',
304
+ }],
305
+ chunkIndex: 2,
306
+ totalChunks: 2,
307
+ hasMore: false,
308
+ estimatedChars: 400,
309
+ },
310
+ ]);
311
+ const result = await processChangeModeOutput('ignored', 1, 'cache-key');
312
+ expect(result).toContain('cached.ts');
313
+ expect(result).toContain('Chunk 1 of 2');
314
+ });
315
+ it('should add summary for cached chunk 1 with >5 edits', async () => {
316
+ const { getChunks } = await import('../../utils/chunkCache.js');
317
+ const manyEdits = Array.from({ length: 6 }, (_, i) => ({
318
+ filename: `file${i}.ts`,
319
+ oldStartLine: 1,
320
+ oldEndLine: 1,
321
+ oldCode: `old${i}`,
322
+ newStartLine: 1,
323
+ newEndLine: 1,
324
+ newCode: `new${i}`,
325
+ }));
326
+ vi.mocked(getChunks).mockReturnValueOnce([
327
+ {
328
+ edits: manyEdits,
329
+ chunkIndex: 1,
330
+ totalChunks: 2,
331
+ hasMore: true,
332
+ estimatedChars: 2000,
333
+ },
334
+ {
335
+ edits: [{
336
+ filename: 'extra.ts',
337
+ oldStartLine: 1,
338
+ oldEndLine: 1,
339
+ oldCode: 'old',
340
+ newStartLine: 1,
341
+ newEndLine: 1,
342
+ newCode: 'new',
343
+ }],
344
+ chunkIndex: 2,
345
+ totalChunks: 2,
346
+ hasMore: false,
347
+ estimatedChars: 200,
348
+ },
349
+ ]);
350
+ const result = await processChangeModeOutput('ignored', 1, 'cache-key');
351
+ expect(result).toContain('ChangeMode Summary');
352
+ expect(result).toContain('Chunk 1 of 2');
353
+ });
354
+ it('should cache multi-chunk results and return first chunk with summary', async () => {
355
+ const editBlocks = Array.from({ length: 7 }, (_, i) => `**FILE: src/file${i}.ts:1**
356
+ \`\`\`
357
+ OLD:
358
+ ${'x'.repeat(3000)}
359
+ NEW:
360
+ ${'y'.repeat(3000)}
361
+ \`\`\``).join('\n\n');
362
+ const result = await processChangeModeOutput(editBlocks, undefined, undefined, 'original prompt');
363
+ expect(result).toContain('[CHANGEMODE OUTPUT');
364
+ expect(result).toContain('ChangeMode Summary');
365
+ expect(result).toContain('Total edits: 7');
366
+ });
367
+ it('should process new result when cache misses', async () => {
368
+ const { getChunks } = await import('../../utils/chunkCache.js');
369
+ vi.mocked(getChunks).mockReturnValueOnce(null);
370
+ const raw = `**FILE: src/fresh.ts:1**
371
+ \`\`\`
372
+ OLD:
373
+ old
374
+ NEW:
375
+ new
376
+ \`\`\``;
377
+ const result = await processChangeModeOutput(raw, 1, 'bad-key');
378
+ expect(result).toContain('src/fresh.ts');
379
+ });
380
+ });
381
+ const ENVELOPE = {
382
+ conversation_id: 'conv-abc',
383
+ status: 'SUCCESS',
384
+ response: 'plain answer',
385
+ structured_output: { ok: true },
386
+ num_turns: 2,
387
+ duration_seconds: 12.5,
388
+ usage: { input_tokens: 100, output_tokens: 20, thinking_tokens: 5, cache_read_tokens: 0, total_tokens: 125 },
389
+ denied_actions: [],
390
+ };
391
+ function envelopeLine(overrides = {}) {
392
+ return JSON.stringify({ ...ENVELOPE, ...overrides });
393
+ }
394
+ describe('executeAgyCLI json mode', () => {
395
+ beforeEach(() => {
396
+ vi.clearAllMocks();
397
+ mockIsCacheEnabled.mockReturnValue(false);
398
+ mockGenerateCacheKey.mockReturnValue('mock-cache-key');
399
+ mockGetCachedResponse.mockReturnValue(undefined);
400
+ mockExecuteCommand.mockResolvedValue(envelopeLine());
401
+ });
402
+ it('adds --output-format json without a schema', async () => {
403
+ await executeAgyCLI('test', { outputFormat: 'json' });
404
+ const args = mockExecuteCommand.mock.calls[0][1];
405
+ expect(args).toContain(CLI.FLAGS.OUTPUT_FORMAT);
406
+ expect(args).toContain('json');
407
+ expect(args).not.toContain(CLI.FLAGS.JSON_SCHEMA);
408
+ });
409
+ it('adds --json-schema inline when a schema is given', async () => {
410
+ const schema = '{"type":"object"}';
411
+ await executeAgyCLI('test', { outputFormat: 'json', jsonSchema: schema });
412
+ const args = mockExecuteCommand.mock.calls[0][1];
413
+ expect(args).toContain(CLI.FLAGS.JSON_SCHEMA);
414
+ expect(args).toContain(schema);
415
+ });
416
+ it('returns the raw envelope line', async () => {
417
+ const raw = envelopeLine();
418
+ mockExecuteCommand.mockResolvedValue(raw);
419
+ expect(await executeAgyCLI('test', { outputFormat: 'json' })).toBe(raw);
420
+ });
421
+ it('surfaces a fatal ERROR envelope that carries no conversation id', async () => {
422
+ mockExecuteCommand.mockResolvedValue('{"status":"ERROR","error":"agy could not start"}');
423
+ await expect(executeAgyCLI('test', { outputFormat: 'json' })).rejects.toThrow('agy could not start');
424
+ });
425
+ it('throws the envelope error text on status ERROR', async () => {
426
+ mockExecuteCommand.mockResolvedValue(envelopeLine({
427
+ status: 'ERROR',
428
+ error: '--effort is not supported for model "Gemini 3.8 Flash (High)"',
429
+ response: '',
430
+ }));
431
+ await expect(executeAgyCLI('test', { outputFormat: 'json' }))
432
+ .rejects.toThrow('--effort is not supported for model "Gemini 3.8 Flash (High)"');
433
+ });
434
+ it('throws AGY_JSON_PARSE for empty stdout and never runs transcript recovery', async () => {
435
+ mockExecuteCommand.mockResolvedValue('');
436
+ await expect(executeAgyCLI('test', { outputFormat: 'json' }))
437
+ .rejects.toThrow(ERROR_MESSAGES.AGY_JSON_PARSE);
438
+ expect(mockRecover).not.toHaveBeenCalled();
439
+ });
440
+ it('throws AGY_NO_OUTPUT for the timeout sentinel', async () => {
441
+ mockExecuteCommand.mockResolvedValue('Error: timed out waiting for response');
442
+ await expect(executeAgyCLI('test', { outputFormat: 'json' }))
443
+ .rejects.toThrow(ERROR_MESSAGES.AGY_NO_OUTPUT);
444
+ });
445
+ it('throws with the denied action when agy produced no response', async () => {
446
+ mockIsCacheEnabled.mockReturnValue(true);
447
+ mockExecuteCommand.mockResolvedValue(envelopeLine({
448
+ response: '',
449
+ structured_output: null,
450
+ denied_actions: [{ action: 'command', display_name: 'RunCommand' }],
451
+ }));
452
+ await expect(executeAgyCLI('test', { outputFormat: 'json' }))
453
+ .rejects.toThrow('RunCommand (command)');
454
+ expect(mockCacheResponse).not.toHaveBeenCalled();
455
+ });
456
+ it('resolves when actions were denied but a response still came back', async () => {
457
+ const raw = envelopeLine({ denied_actions: [{ action: 'command', display_name: 'RunCommand' }] });
458
+ mockExecuteCommand.mockResolvedValue(raw);
459
+ expect(await executeAgyCLI('test', { outputFormat: 'json' })).toBe(raw);
460
+ });
461
+ it('throws CONVERSATION_NOT_RESUMED when agy silently started a new conversation', async () => {
462
+ mockIsCacheEnabled.mockReturnValue(true);
463
+ mockExecuteCommand.mockResolvedValue(envelopeLine({ conversation_id: 'conv-other' }));
464
+ await expect(executeAgyCLI('test', { outputFormat: 'json', conversationId: 'conv-abc' }))
465
+ .rejects.toThrow(`${ERROR_MESSAGES.CONVERSATION_NOT_RESUMED} conv-abc`);
466
+ expect(mockCacheResponse).not.toHaveBeenCalled();
467
+ });
468
+ it('checks a resume-by-id the same way as conversationId', async () => {
469
+ mockExecuteCommand.mockResolvedValue(envelopeLine({ conversation_id: 'conv-other' }));
470
+ await expect(executeAgyCLI('test', { outputFormat: 'json', resume: 'conv-abc' }))
471
+ .rejects.toThrow(`${ERROR_MESSAGES.CONVERSATION_NOT_RESUMED} conv-abc`);
472
+ });
473
+ it('does not check the conversation for resume: true, which has no requested id', async () => {
474
+ mockExecuteCommand.mockResolvedValue(envelopeLine({ conversation_id: 'conv-whatever' }));
475
+ await expect(executeAgyCLI('test', { outputFormat: 'json', resume: true })).resolves.toBeTruthy();
476
+ });
477
+ it('caches a verified envelope', async () => {
478
+ mockIsCacheEnabled.mockReturnValue(true);
479
+ const raw = envelopeLine();
480
+ mockExecuteCommand.mockResolvedValue(raw);
481
+ await executeAgyCLI('test', { outputFormat: 'json' });
482
+ expect(mockCacheResponse).toHaveBeenCalledWith('mock-cache-key', raw);
483
+ });
484
+ });
485
+ describe('option validation', () => {
486
+ beforeEach(() => {
487
+ vi.clearAllMocks();
488
+ mockIsCacheEnabled.mockReturnValue(false);
489
+ mockExecuteCommand.mockResolvedValue(envelopeLine());
490
+ });
491
+ it.each([
492
+ [{ outputFormat: 'stream-json' }, ERROR_MESSAGES.UNSUPPORTED_OUTPUT_FORMAT],
493
+ [{ jsonSchema: '{}' }, ERROR_MESSAGES.JSON_SCHEMA_REQUIRES_JSON],
494
+ [{ effort: 'extreme' }, ERROR_MESSAGES.INVALID_EFFORT],
495
+ [{ changeMode: true, outputFormat: 'json' }, 'changeMode'],
496
+ [{ conversationId: ' ' }, ERROR_MESSAGES.INVALID_CONVERSATION_ID],
497
+ ])('rejects %s', async (options, message) => {
498
+ await expect(executeAgyCLI('test', options)).rejects.toThrow(message);
499
+ expect(mockExecuteCommand).not.toHaveBeenCalled();
500
+ });
501
+ it('passes --effort through untouched', async () => {
502
+ await executeAgyCLI('test', { effort: 'medium' });
503
+ const args = mockExecuteCommand.mock.calls[0][1];
504
+ expect(args).toContain(CLI.FLAGS.EFFORT);
505
+ expect(args).toContain('medium');
506
+ });
507
+ it('leaves text mode arguments unchanged when outputFormat is text', async () => {
508
+ await executeAgyCLI('test', { outputFormat: 'text' });
509
+ expect(mockExecuteCommand.mock.calls[0][1]).toEqual([
510
+ CLI.FLAGS.MODEL, MODELS.DEFAULT, CLI.FLAGS.PRINT, 'test',
511
+ ]);
512
+ });
513
+ });
514
+ describe('conversation resolution', () => {
515
+ beforeEach(() => {
516
+ vi.clearAllMocks();
517
+ mockIsCacheEnabled.mockReturnValue(false);
518
+ mockExecuteCommand.mockResolvedValue(envelopeLine());
519
+ });
520
+ it('prefers an explicit conversationId over resume', async () => {
521
+ await executeAgyCLI('test', { conversationId: 'conv-abc', resume: true });
522
+ const args = mockExecuteCommand.mock.calls[0][1];
523
+ expect(args).toContain(CLI.FLAGS.CONVERSATION);
524
+ expect(args).toContain('conv-abc');
525
+ expect(args).not.toContain(CLI.FLAGS.CONTINUE);
526
+ });
527
+ it('hands the explicit conversation id to transcript recovery', async () => {
528
+ mockExecuteCommand.mockResolvedValue('');
529
+ mockRecover.mockReturnValue('RECOVERED');
530
+ await executeAgyCLI('test', { conversationId: 'conv-abc' });
531
+ expect(mockRecover).toHaveBeenCalledWith(expect.objectContaining({ conversationId: 'conv-abc' }));
532
+ });
533
+ it.each([
534
+ ['resume', { resume: true }],
535
+ ['conversationId', { conversationId: 'conv-abc' }],
536
+ ['noCache', { noCache: true }],
537
+ ])('never touches the cache for %s', async (_label, options) => {
538
+ mockIsCacheEnabled.mockReturnValue(true);
539
+ await executeAgyCLI('test', options);
540
+ expect(mockGetCachedResponse).not.toHaveBeenCalled();
541
+ expect(mockCacheResponse).not.toHaveBeenCalled();
542
+ });
543
+ });
544
+ describe('executeAgyJson', () => {
545
+ beforeEach(() => {
546
+ vi.clearAllMocks();
547
+ mockIsCacheEnabled.mockReturnValue(false);
548
+ mockExecuteCommand.mockResolvedValue(envelopeLine());
549
+ });
550
+ it('forces json output and returns the parsed envelope', async () => {
551
+ const envelope = await executeAgyJson('test', { model: 'Gemini 3.8 Flash (Medium)' });
552
+ const args = mockExecuteCommand.mock.calls[0][1];
553
+ expect(args).toContain(CLI.FLAGS.OUTPUT_FORMAT);
554
+ expect(envelope.conversation_id).toBe('conv-abc');
555
+ expect(envelope.structured_output).toEqual({ ok: true });
556
+ });
557
+ it('uses Argon High when the model is omitted in json mode', async () => {
558
+ await executeAgyJson('test', {});
559
+ const args = mockExecuteCommand.mock.calls[0][1];
560
+ expect(args.slice(0, 2)).toEqual([CLI.FLAGS.MODEL, 'Gemini 4 Argon (High)']);
561
+ expect(args).toContain(CLI.FLAGS.OUTPUT_FORMAT);
562
+ });
563
+ });
564
+ describe('parseAgyJsonEnvelope', () => {
565
+ it('takes the last line that starts with {', () => {
566
+ const stdout = `starting up\n{"status":"IGNORED"}\n${envelopeLine()}`;
567
+ expect(parseAgyJsonEnvelope(stdout).conversation_id).toBe('conv-abc');
568
+ });
569
+ it('parses an envelope whose response contains a raw JSON block', () => {
570
+ const stdout = JSON.stringify({
571
+ conversation_id: 'conv-abc',
572
+ status: 'SUCCESS',
573
+ response: 'PLACEHOLDER',
574
+ }).replace('"PLACEHOLDER"', '"Use this config:\n{\n \\"a\\": 1\n}\nDone."');
575
+ const envelope = parseAgyJsonEnvelope(stdout);
576
+ expect(envelope.conversation_id).toBe('conv-abc');
577
+ expect(envelope.response).toContain('"a": 1');
578
+ });
579
+ it('retries after escaping raw control characters inside strings', () => {
580
+ const stdout = '{"conversation_id":"conv-abc","status":"SUCCESS","response":"line one\nline two"}';
581
+ expect(parseAgyJsonEnvelope(stdout).response).toBe('line one\nline two');
582
+ });
583
+ it.each([
584
+ ['an empty object', '{}'],
585
+ ['an object with no status', '{"conversation_id":"conv-abc","response":"hi"}'],
586
+ ['an unknown status', '{"conversation_id":"conv-abc","status":"PENDING"}'],
587
+ ['a SUCCESS with no conversation id', '{"status":"SUCCESS","response":"hi"}'],
588
+ ])('refuses %s as an envelope', (_label, stdout) => {
589
+ expect(() => parseAgyJsonEnvelope(stdout)).toThrow(ERROR_MESSAGES.AGY_JSON_PARSE);
590
+ });
591
+ it('accepts an ERROR envelope that never got a conversation id', () => {
592
+ const envelope = parseAgyJsonEnvelope('{"status":"ERROR","error":"invalid model selection"}');
593
+ expect(envelope.error).toBe('invalid model selection');
594
+ });
595
+ it('throws AGY_JSON_PARSE with an excerpt when there is no envelope', () => {
596
+ expect(() => parseAgyJsonEnvelope('total nonsense')).toThrow(ERROR_MESSAGES.AGY_JSON_PARSE);
597
+ expect(() => parseAgyJsonEnvelope('total nonsense')).toThrow('total nonsense');
598
+ });
599
+ });
600
+ describe('envelope rendering', () => {
601
+ it('uses structured_output only when this call passed a schema', () => {
602
+ const envelope = { response: 'plain answer', structured_output: { ok: true } };
603
+ expect(renderAgyJsonEnvelope(envelope, { hadSchema: true })).toContain('"ok": true');
604
+ expect(renderAgyJsonEnvelope(envelope)).toBe('plain answer');
605
+ });
606
+ it('summarizes denied actions', () => {
607
+ expect(deniedActionsSummary({ denied_actions: [{ action: 'command', display_name: 'RunCommand' }] }))
608
+ .toBe('RunCommand (command)');
609
+ expect(deniedActionsSummary({})).toBe('');
610
+ });
611
+ it('formats the usage line and the conversation line', () => {
612
+ expect(formatAgyUsageLine(ENVELOPE)).toContain('total 125');
613
+ expect(formatConversationLine('conv-abc')).toBe('[GEMINI_CONVERSATION_ID=conv-abc]');
614
+ });
615
+ });
616
+ //# sourceMappingURL=agyExecutor.test.js.map