@joeytheman/gemini-mcp-tool 1.3.0 → 2.3.0

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