@harness-mix/cli 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +469 -467
  3. package/output/native-build/desktop-controller.mjs +1 -1
  4. package/output/native-build/renderer-extension.js +23 -4
  5. package/package.json +11 -9
  6. package/scripts/antigravity-adapter-test.cjs +647 -626
  7. package/scripts/codex-adapter-test.cjs +162 -127
  8. package/scripts/collaboration-test.cjs +274 -262
  9. package/scripts/jsonl-stdin-test.cjs +40 -31
  10. package/scripts/kiro-cursor-adapters-test.cjs +124 -100
  11. package/scripts/native-acp-depth-test.cjs +30 -5
  12. package/scripts/native-update-apply-test.cjs +269 -215
  13. package/scripts/native-update.cjs +78 -0
  14. package/scripts/native-vendor-adapters-test.cjs +196 -154
  15. package/scripts/salvage-rollout-writes.cjs +72 -0
  16. package/scripts/zcode-adapter-test.cjs +329 -0
  17. package/scripts/zcode-live-probe.cjs +66 -0
  18. package/src/main/adapters/antigravity.js +1428 -1418
  19. package/src/main/adapters/codex.js +656 -649
  20. package/src/main/adapters/native-acp-command.js +51 -48
  21. package/src/main/adapters/native-acp.js +47 -12
  22. package/src/main/adapters/qoder.js +12 -8
  23. package/src/main/adapters/zcode.js +921 -10
  24. package/src/main/host/collaboration.js +723 -715
  25. package/src/main/host/jsonl.js +130 -120
  26. package/src/main/native/config.js +9 -9
  27. package/src/main/native/launcher.js +252 -237
  28. package/src/main/native/process-utils.js +157 -57
  29. package/src/main/native/protocol.js +1221 -1187
  30. package/src/main/native/update-state.js +123 -110
  31. package/src/main/native/updater.js +460 -394
  32. package/src/native-ui/desktop-control/src/renderer-cdp-control-session.ts +358 -358
  33. package/src/native-ui/renderer-extension/src/renderer-binding-probe.ts +3211 -3181
  34. package/src/native-ui/renderer-extension/src/settings/connections-page.ts +2 -2
@@ -1,626 +1,647 @@
1
- const assert = require('node:assert/strict');
2
- const { EventEmitter } = require('node:events');
3
- const fs = require('node:fs');
4
- const os = require('node:os');
5
- const path = require('node:path');
6
- const { PassThrough } = require('node:stream');
7
- const antigravity = require('../src/main/adapters/antigravity');
8
-
9
- (async () => {
10
- const { manifest, create, parseModelsOutput, parseUsage, formatPrompt, prepareImageAttachments, formatAntigravityResultError, modelSupportsEffort, ANTIGRAVITY_PERMISSION_MODES } = antigravity;
11
-
12
- // 1. Manifest
13
- assert.equal(manifest.id, 'antigravity');
14
- assert.equal(manifest.name, 'Antigravity');
15
- assert.equal(manifest.icon, 'antigravity-color.svg');
16
- assert.equal(manifest.capabilities.streaming, true);
17
- assert.equal(manifest.capabilities.thinking, true);
18
- assert.equal(manifest.capabilities.tools, true);
19
- assert.equal(manifest.capabilities.approvals, true);
20
- assert.equal(manifest.capabilities.questions, true);
21
- assert.equal(manifest.capabilities.models, true);
22
- assert.equal(manifest.capabilities.thinkingLevels, true);
23
- assert.equal(manifest.capabilities.permissionModes, true);
24
- assert.equal(manifest.capabilities.resume, true);
25
- assert.equal(manifest.capabilities.fork, true);
26
- assert.equal(manifest.capabilities.forkFromMessage, true);
27
- assert.equal(manifest.capabilities.attachments, true);
28
-
29
- // 2. parseModelsOutput
30
- const sampleModelsOutput = `
31
- Fetching available models...
32
- gemini-3.8-flash-high\tGemini 3.8 Flash (High)
33
- gemini-3.8-flash-medium\tGemini 3.8 Flash (Medium)
34
- gemini-3.8-flash-low\tGemini 3.8 Flash (Low)
35
- gemini-3.1-pro-high\tGemini 3.1 Pro (High)
36
- gemini-3.1-pro-low\tGemini 3.1 Pro (Low)
37
- claude-sonnet-4-6\tClaude Sonnet 4.6 (Thinking)
38
- gpt-oss-120b-medium\tGPT-OSS 120B (Medium)
39
- `;
40
- const models = parseModelsOutput(sampleModelsOutput);
41
- assert.ok(models.length >= 4);
42
-
43
- const flash = models.find(m => m.id === 'gemini-3.8-flash');
44
- assert.ok(flash);
45
- assert.equal(flash.name, 'Gemini 3.8 Flash');
46
- assert.equal(flash.provider, 'google');
47
- assert.equal(flash.efforts.length, 3);
48
- assert.equal(flash.efforts[0].id, 'low');
49
- assert.equal(flash.efforts[2].id, 'high');
50
- assert.equal(flash.defaultEffort, 'high');
51
- assert.equal(flash.contextWindow, 1_048_576);
52
- assert.equal(modelSupportsEffort(flash), true);
53
-
54
- const claude = models.find(m => m.id === 'claude-sonnet-4-6');
55
- assert.ok(claude);
56
- assert.equal(claude.provider, 'anthropic');
57
- assert.equal(claude.contextWindow, 200_000);
58
- assert.equal(modelSupportsEffort(claude), false);
59
- assert.equal(modelSupportsEffort({ id: 'claude-sonnet-4-6' }), false);
60
-
61
- // 3. parseUsage
62
- const usage = parseUsage({
63
- input_tokens: 1200,
64
- output_tokens: 300,
65
- thinking_tokens: 150,
66
- total_tokens: 1650,
67
- context_used_tokens: 25000,
68
- }, 'gemini-3.8-flash');
69
- assert.equal(usage.inputTokens, 1200);
70
- assert.equal(usage.outputTokens, 300);
71
- assert.equal(usage.reasoningOutputTokens, 150);
72
- assert.equal(usage.contextWindow, 1_048_576);
73
- assert.equal(usage.tokens, 25000);
74
- assert.ok(usage.contextPercent > 2.3 && usage.contextPercent < 2.5);
75
-
76
- // 4. formatPrompt
77
- const rawPrompt = '帮我修改 app.js';
78
- const formatted = formatPrompt(rawPrompt);
79
- assert.ok(formatted.includes('write_to_file'));
80
- assert.ok(formatted.includes('replace_file_content'));
81
- assert.ok(formatted.includes(rawPrompt));
82
-
83
- // Slash commands / already instruction formatted should not duplicate
84
- assert.equal(formatPrompt('/usage'), '/usage');
85
- assert.equal(formatPrompt(formatted), formatted);
86
-
87
- // 5. Adapter lifecycle and Session
88
- const adapter = create(() => {});
89
- assert.equal(typeof adapter.open, 'function');
90
- assert.equal(typeof adapter.send, 'function');
91
- assert.equal(typeof adapter.cancel, 'function');
92
- assert.equal(typeof adapter.close, 'function');
93
- assert.equal(typeof adapter.respond, 'function');
94
- assert.equal(typeof adapter.fork, 'function');
95
- assert.equal(typeof adapter.listModelsFor, 'function');
96
- assert.equal(typeof adapter.setModel, 'function');
97
- assert.equal(typeof adapter.setThinkingLevel, 'function');
98
- assert.equal(typeof adapter.setPermissionMode, 'function');
99
-
100
- // 6. Inspect
101
- const inspection = await adapter.inspect();
102
- assert.ok(typeof inspection.available === 'boolean');
103
- assert.ok(typeof inspection.detail === 'string');
104
-
105
- // 7. Open session
106
- const openEvents = [];
107
- const session = await adapter.open({
108
- thread: {
109
- id: 'thread-1',
110
- nativeSessionId: 'conv-12345',
111
- cwd: 'E:\\harness-mix',
112
- restore: true,
113
- options: {
114
- model: { id: 'gemini-3.8-flash', name: 'Gemini 3.8 Flash' },
115
- thinking: 'medium',
116
- permissionMode: 'desktop',
117
- },
118
- },
119
- emit: (event) => openEvents.push(event),
120
- diagnostic: () => {},
121
- });
122
- assert.equal(session.nativeSessionId, 'conv-12345');
123
- assert.equal(session.thinkingLevel, 'medium');
124
- assert.equal(session.permissionMode, 'desktop');
125
- assert.ok(openEvents.some(e => e.kind === 'session' && e.nativeSessionId === 'conv-12345'));
126
-
127
- // 8. setModel, setThinkingLevel, setPermissionMode
128
- await adapter.setModel(session, { id: 'gemini-3.1-pro', name: 'Gemini 3.1 Pro' });
129
- assert.equal(session.model.id, 'gemini-3.1-pro');
130
- await adapter.setThinkingLevel(session, 'high');
131
- assert.equal(session.thinkingLevel, 'high');
132
- await adapter.setPermissionMode(session, 'skip');
133
- assert.equal(session.permissionMode, 'skip');
134
-
135
- // 9. Describe
136
- const desc = await adapter.describe();
137
- assert.ok(Array.isArray(desc.models));
138
- assert.equal(desc.thinkingLevels.length, 3);
139
- assert.equal(desc.permissionModes.length, 3);
140
-
141
- // 10. Fork session
142
- const forkEvents = [];
143
- const forked = await adapter.fork({
144
- id: 'thread-1',
145
- nativeSessionId: 'conv-12345',
146
- cwd: 'E:\\harness-mix',
147
- model: { id: 'gemini-3.8-flash', name: 'Gemini 3.8 Flash' },
148
- options: { thinking: 'high', permissionMode: 'default' },
149
- }, {
150
- emit: (e) => forkEvents.push(e),
151
- diagnostic: () => {},
152
- });
153
- assert.ok(forked.nativeSessionId);
154
- assert.notEqual(forked.nativeSessionId, 'conv-12345');
155
- assert.equal(forked.session.nativeSessionId, forked.nativeSessionId);
156
- assert.ok(forkEvents.some(e => e.kind === 'session' && e.nativeSessionId === forked.nativeSessionId));
157
-
158
- // 11. Image attachments
159
- const tmpRoot = path.join(os.tmpdir(), `agy-attach-test-${Date.now()}`);
160
- await fs.promises.mkdir(tmpRoot, { recursive: true });
161
- const localImg = path.join(tmpRoot, 'test.png');
162
- await fs.promises.writeFile(localImg, Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=', 'base64'));
163
- const prepared = await prepareImageAttachments([
164
- { name: 'test.png', path: localImg },
165
- { name: 'inline.png', mime: 'image/png', data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=' },
166
- ], tmpRoot);
167
- assert.equal(prepared.imageEntries.length, 2);
168
- assert.equal(prepared.imageEntries[0].name, 'test.png');
169
- assert.notEqual(prepared.imageEntries[0].path, localImg.replace(/\\/g, '/'));
170
- assert.match(prepared.imageEntries[0].path, /[\\/]\.gemini[\\/]attachments[\\/]/);
171
- assert.equal(fs.readFileSync(prepared.imageEntries[0].path).toString('base64'), 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=');
172
- assert.ok(fs.existsSync(prepared.imageEntries[1].path));
173
- await fs.promises.unlink(localImg);
174
- assert.ok(fs.existsSync(prepared.imageEntries[0].path));
175
- const legacyPath = path.join(os.tmpdir(), `codex-clipboard-legacy-${Date.now()}.png`);
176
- await fs.promises.rm(legacyPath, { force: true }).catch(() => {});
177
- const legacySession = await adapter.open({
178
- thread: {
179
- id: 'legacy-image-thread',
180
- nativeSessionId: 'legacy-image-session',
181
- cwd: tmpRoot,
182
- messages: [{ role: 'user', attachments: [{ kind: 'image', path: legacyPath, data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=' }] }],
183
- options: {},
184
- },
185
- emit: () => {},
186
- diagnostic: () => {},
187
- });
188
- assert.equal(fs.readFileSync(legacyPath).toString('base64'), 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=');
189
- await adapter.close(legacySession);
190
- await fs.promises.unlink(legacyPath).catch(() => {});
191
- await fs.promises.rm(tmpRoot, { recursive: true, force: true }).catch(() => {});
192
-
193
- // 12. mergePendingStep
194
- const { mergePendingStep, cloneDatabase } = antigravity;
195
- const activeStep = {
196
- step_index: 2,
197
- state: 'ACTIVE',
198
- step_type: 'tool',
199
- tool_name: 'write_to_file',
200
- tool_info: {
201
- name: 'write_to_file',
202
- parameters: { TargetFile: 'test.txt', Description: 'create file' },
203
- },
204
- };
205
- const doneStep = {
206
- step_index: 2,
207
- state: 'DONE',
208
- step_type: 'tool',
209
- tool_info: {
210
- output: 'File written successfully',
211
- },
212
- };
213
- const merged = mergePendingStep(activeStep, doneStep);
214
- assert.equal(merged.state, 'DONE');
215
- assert.equal(merged.tool_name, 'write_to_file');
216
- assert.equal(merged.tool_info.name, 'write_to_file');
217
- assert.equal(merged.tool_info.parameters.TargetFile, 'test.txt');
218
- assert.equal(merged.tool_info.output, 'File written successfully');
219
-
220
- // 13. cloneDatabase with message turn pruning and summaries.db
221
- const mockHome = path.join(os.tmpdir(), `agy-mock-home-${Date.now()}`);
222
- const convDir = path.join(mockHome, '.gemini', 'antigravity-cli', 'conversations');
223
- await fs.promises.mkdir(convDir, { recursive: true });
224
- const { DatabaseSync } = require('node:sqlite');
225
- const sourceDbPath = path.join(convDir, 'source-conv.db');
226
- const db = new DatabaseSync(sourceDbPath);
227
- db.exec(`
228
- CREATE TABLE trajectory_meta (cascade_id TEXT);
229
- INSERT INTO trajectory_meta VALUES ('source-conv');
230
- CREATE TABLE steps (idx INTEGER PRIMARY KEY, step_type INTEGER);
231
- INSERT INTO steps VALUES (1, 14), (2, 1), (3, 14), (4, 1);
232
- CREATE TABLE gen_metadata (idx INTEGER PRIMARY KEY);
233
- INSERT INTO gen_metadata VALUES (0), (1);
234
- CREATE TABLE executor_metadata (idx INTEGER PRIMARY KEY);
235
- INSERT INTO executor_metadata VALUES (0), (1);
236
- CREATE TABLE parent_references (idx INTEGER PRIMARY KEY);
237
- INSERT INTO parent_references VALUES (0), (1);
238
- CREATE TABLE battle_mode_infos (idx INTEGER PRIMARY KEY);
239
- INSERT INTO battle_mode_infos VALUES (0), (1);
240
- `);
241
- db.close();
242
-
243
- const sumDbPath = path.join(mockHome, '.gemini', 'antigravity-cli', 'conversation_summaries.db');
244
- const sumDb = new DatabaseSync(sumDbPath);
245
- sumDb.exec(`
246
- CREATE TABLE conversation_summaries (conversation_id TEXT PRIMARY KEY, title TEXT, step_count INTEGER, last_modified_time TEXT);
247
- INSERT INTO conversation_summaries VALUES ('source-conv', 'Test Session', 4, '2026-09-01T00:00:00.000Z');
248
- `);
249
- sumDb.close();
250
-
251
- const clonedOk = await cloneDatabase('source-conv', 'derived-conv', 1, mockHome);
252
- assert.equal(clonedOk, true);
253
-
254
- const derivedDb = new DatabaseSync(path.join(convDir, 'derived-conv.db'));
255
- const meta = derivedDb.prepare('SELECT cascade_id FROM trajectory_meta').get();
256
- assert.equal(meta.cascade_id, 'derived-conv');
257
- const remainingSteps = derivedDb.prepare('SELECT count(*) as c FROM steps').get();
258
- assert.equal(remainingSteps.c, 2); // only first turn retained
259
- derivedDb.close();
260
-
261
- const sumDbCheck = new DatabaseSync(sumDbPath);
262
- const sumRow = sumDbCheck.prepare('SELECT * FROM conversation_summaries WHERE conversation_id = ?').get('derived-conv');
263
- assert.ok(sumRow);
264
- assert.equal(sumRow.title, 'Test Session');
265
- assert.equal(sumRow.step_count, 2);
266
- sumDbCheck.close();
267
-
268
- await fs.promises.rm(mockHome, { recursive: true, force: true }).catch(() => {});
269
-
270
- // 14. Quota and Credits parsing
271
- const mockUsageCommand = {
272
- name: 'usage',
273
- data: {
274
- groups: [
275
- {
276
- name: 'Gemini Models',
277
- buckets: [
278
- { id: 'gemini-weekly', name: 'Weekly Limit', window: 'weekly', remaining_fraction: 0.45, reset_time: '2026-09-16T01:12:46Z' },
279
- { id: 'gemini-5h', name: '5-Hour Limit', window: '5h', remaining_fraction: 0.80, reset_time: '2026-09-12T14:45:59Z' },
280
- ],
281
- },
282
- ],
283
- },
284
- };
285
- const { parseAntigravityUsageCommand } = antigravity;
286
- const quotaSnapshot = parseAntigravityUsageCommand(mockUsageCommand);
287
- assert.ok(quotaSnapshot);
288
- assert.equal(quotaSnapshot.periodType, 'weekly');
289
- assert.equal(quotaSnapshot.usedPercent, 55); // (1 - 0.45) * 100
290
- assert.equal(quotaSnapshot.resetsAt, '2026-09-16T01:12:46Z');
291
- assert.ok(Array.isArray(quotaSnapshot.productUsage));
292
- assert.equal(quotaSnapshot.productUsage[0].usagePercent, 20); // (1 - 0.8) * 100
293
-
294
- // 15. Native result failures must be visible instead of becoming an empty
295
- // successful turn. This is the real error returned by agy 1.2.2 for an
296
- // unsupported API location.
297
- const locationError = formatAntigravityResultError({
298
- status: 'ERROR',
299
- error: { code: 'FAILED_PRECONDITION', message: 'User location is not supported for the API use.' },
300
- });
301
- assert.equal(locationError, 'Antigravity 回合失败:FAILED_PRECONDITION: User location is not supported for the API use.');
302
-
303
- const errorChild = new EventEmitter();
304
- errorChild.stdin = new PassThrough();
305
- errorChild.stdout = new PassThrough();
306
- errorChild.stderr = new PassThrough();
307
- let errorCommandArgs = [];
308
- let errorClosed = false;
309
- const closeErrorChild = () => {
310
- if (errorClosed) return;
311
- errorClosed = true;
312
- errorChild.stdout.end();
313
- errorChild.stderr.end();
314
- errorChild.emit('close', 0);
315
- };
316
- errorChild.stdin.once('data', () => setTimeout(() => {
317
- if (!errorClosed) errorChild.stdout.write(`${JSON.stringify({
318
- event: 'result',
319
- result: {
320
- conversation_id: 'fake-location-error',
321
- status: 'ERROR',
322
- error: { code: 'FAILED_PRECONDITION', message: 'User location is not supported for the API use.' },
323
- },
324
- })}\n`);
325
- }, 1));
326
- errorChild.stdin.once('finish', () => setTimeout(closeErrorChild, 0));
327
- errorChild.kill = closeErrorChild;
328
- const errorAdapter = create(() => {}, { spawnProcess: (_bin, args) => { errorCommandArgs = args; return errorChild; }, resultDrainMs: 10, resultDrainMaxMs: 30 });
329
- const errorSession = await errorAdapter.open({
330
- thread: { id: 'fake-error-thread', cwd: os.tmpdir(), options: { model: { id: 'claude-sonnet-4-6' }, thinking: 'high', permissionMode: 'skip' } },
331
- emit: () => {},
332
- diagnostic: () => {},
333
- });
334
- const errorEvents = [];
335
- await assert.rejects(
336
- errorAdapter.send(errorSession, 'return an error', { emit: event => errorEvents.push(event) }),
337
- /User location is not supported for the API use/,
338
- );
339
- const errorEvent = errorEvents.find(event => event.kind === 'error');
340
- assert.equal(errorEvent?.message, locationError);
341
- assert.equal(errorCommandArgs.includes('--effort'), false);
342
- assert.equal(errorEvents.some(event => event.kind === 'completed'), false);
343
- assert.equal(errorSession.activeTurn, null);
344
- await errorAdapter.close(errorSession);
345
-
346
- // 16. A clean process exit without any result is also a protocol failure.
347
- const noResultChild = new EventEmitter();
348
- noResultChild.stdin = new PassThrough();
349
- noResultChild.stdout = new PassThrough();
350
- noResultChild.stderr = new PassThrough();
351
- let noResultClosed = false;
352
- const closeNoResultChild = () => {
353
- if (noResultClosed) return;
354
- noResultClosed = true;
355
- noResultChild.stdout.end();
356
- noResultChild.stderr.end();
357
- noResultChild.emit('close', 0);
358
- };
359
- noResultChild.stdin.once('data', () => setTimeout(closeNoResultChild, 1));
360
- noResultChild.kill = closeNoResultChild;
361
- const noResultAdapter = create(() => {}, { spawnProcess: () => noResultChild });
362
- const noResultSession = await noResultAdapter.open({
363
- thread: { id: 'fake-no-result-thread', cwd: os.tmpdir(), options: { permissionMode: 'skip' } },
364
- emit: () => {},
365
- diagnostic: () => {},
366
- });
367
- const noResultEvents = [];
368
- await assert.rejects(
369
- noResultAdapter.send(noResultSession, 'return nothing', { emit: event => noResultEvents.push(event) }),
370
- /未返回 result/,
371
- );
372
- assert.match(noResultEvents.find(event => event.kind === 'error')?.message || '', /未返回 result/);
373
- assert.equal(noResultEvents.some(event => event.kind === 'completed'), false);
374
- assert.equal(noResultSession.activeTurn, null);
375
- await noResultAdapter.close(noResultSession);
376
-
377
- // 17. A SUCCESS result with no assistant text triggers exactly one automatic
378
- // nudge retry in the same native conversation; only a repeated empty result
379
- // surfaces as an error.
380
- const makeEmptySuccessChild = (onPrompt) => {
381
- const child = new EventEmitter();
382
- child.stdin = new PassThrough();
383
- child.stdout = new PassThrough();
384
- child.stderr = new PassThrough();
385
- let closed = false;
386
- const closeChild = () => {
387
- if (closed) return;
388
- closed = true;
389
- child.stdout.end();
390
- child.stderr.end();
391
- child.emit('close', 0);
392
- };
393
- child.isClosed = () => closed;
394
- child.stdin.once('data', (chunk) => {
395
- const content = JSON.parse(String(chunk).trim()).message?.content || '';
396
- setTimeout(() => onPrompt(child, content, closeChild), 1);
397
- });
398
- child.stdin.once('finish', () => setTimeout(closeChild, 0));
399
- child.kill = closeChild;
400
- return child;
401
- };
402
- const writeStream = (child, event) => {
403
- if (!child.isClosed()) child.stdout.write(`${JSON.stringify(event)}\n`);
404
- };
405
-
406
- // 17a. First attempt empty, nudge retry answers → turn completes, no error.
407
- const retryChildren = [];
408
- const retryArgs = [];
409
- const retryAdapter = create(() => {}, {
410
- spawnProcess: (_bin, args) => {
411
- retryArgs.push(args);
412
- const attempt = retryChildren.length;
413
- const child = makeEmptySuccessChild((c, content) => {
414
- if (attempt === 0) {
415
- writeStream(c, { event: 'init', conversation_id: 'fake-empty-success' });
416
- writeStream(c, { event: 'result', result: { conversation_id: 'fake-empty-success', status: 'SUCCESS', response: '' } });
417
- } else {
418
- c.nudgePrompt = content;
419
- writeStream(c, { event: 'init', conversation_id: 'fake-empty-success' });
420
- writeStream(c, {
421
- event: 'step_update',
422
- step_update: { conversation_id: 'fake-empty-success', step_index: 1, state: 'DONE', step_type: 'agent_response', text_delta: 'late recovered answer' },
423
- });
424
- writeStream(c, { event: 'result', result: { conversation_id: 'fake-empty-success', status: 'SUCCESS', num_turns: 2, response: 'late recovered answer' } });
425
- }
426
- });
427
- retryChildren.push(child);
428
- return child;
429
- },
430
- resultDrainMs: 10,
431
- resultDrainMaxMs: 20,
432
- });
433
- const retrySession = await retryAdapter.open({
434
- thread: { id: 'fake-empty-retry-thread', cwd: os.tmpdir(), options: { permissionMode: 'skip' } },
435
- emit: () => {},
436
- diagnostic: () => {},
437
- });
438
- const retryEvents = [];
439
- await retryAdapter.send(retrySession, 'return an empty success', { emit: event => retryEvents.push(event) });
440
- assert.equal(retryChildren.length, 2);
441
- assert.match(retryChildren[1].nudgePrompt || '', /previous turn ended WITHOUT any visible assistant response/);
442
- const resumeIdx = retryArgs[1].indexOf('--conversation');
443
- assert.ok(resumeIdx >= 0);
444
- assert.equal(retryArgs[1][resumeIdx + 1], 'fake-empty-success');
445
- assert.equal(retryEvents.filter(event => event.kind === 'error').length, 0);
446
- assert.ok(retryEvents.some(event => event.kind === 'status' && /自动补问/.test(event.text || '')));
447
- assert.equal(retryEvents.filter(event => event.kind === 'text-delta').map(event => event.text).join(''), 'late recovered answer');
448
- assert.ok(retryEvents.some(event => event.kind === 'completed'));
449
- assert.equal(retrySession.activeTurn, null);
450
- await retryAdapter.close(retrySession);
451
-
452
- // 17b. Every attempt empty → rejects, error emitted exactly once (final attempt).
453
- let alwaysEmptySpawns = 0;
454
- const alwaysEmptyAdapter = create(() => {}, {
455
- spawnProcess: () => {
456
- alwaysEmptySpawns += 1;
457
- return makeEmptySuccessChild((c) => {
458
- writeStream(c, { event: 'init', conversation_id: 'fake-empty-success' });
459
- writeStream(c, { event: 'result', result: { conversation_id: 'fake-empty-success', status: 'SUCCESS', response: '' } });
460
- });
461
- },
462
- resultDrainMaxMs: 20,
463
- });
464
- const alwaysEmptySession = await alwaysEmptyAdapter.open({
465
- thread: { id: 'fake-always-empty-thread', cwd: os.tmpdir(), options: { permissionMode: 'skip' } },
466
- emit: () => {},
467
- diagnostic: () => {},
468
- });
469
- const alwaysEmptyEvents = [];
470
- await assert.rejects(
471
- alwaysEmptyAdapter.send(alwaysEmptySession, 'return an empty success', { emit: event => alwaysEmptyEvents.push(event) }),
472
- /没有 assistant 文本/,
473
- );
474
- assert.equal(alwaysEmptySpawns, 2);
475
- assert.equal(alwaysEmptyEvents.filter(event => event.kind === 'error').length, 1);
476
- assert.match(alwaysEmptyEvents.find(event => event.kind === 'error')?.message || '', /没有 assistant 文本/);
477
- assert.equal(alwaysEmptyEvents.some(event => event.kind === 'completed'), false);
478
- assert.equal(alwaysEmptySession.activeTurn, null);
479
- await alwaysEmptyAdapter.close(alwaysEmptySession);
480
-
481
- // 17c. Automatic retry disabled via env → single attempt, immediate error.
482
- process.env.HARNESSMIX_ANTIGRAVITY_EMPTY_RESULT_RETRIES = '0';
483
- try {
484
- let noRetrySpawns = 0;
485
- const noRetryAdapter = create(() => {}, {
486
- spawnProcess: () => {
487
- noRetrySpawns += 1;
488
- return makeEmptySuccessChild((c) => {
489
- writeStream(c, { event: 'init', conversation_id: 'fake-empty-success' });
490
- writeStream(c, { event: 'result', result: { conversation_id: 'fake-empty-success', status: 'SUCCESS', response: '' } });
491
- });
492
- },
493
- resultDrainMaxMs: 20,
494
- });
495
- const noRetrySession = await noRetryAdapter.open({
496
- thread: { id: 'fake-no-retry-thread', cwd: os.tmpdir(), options: { permissionMode: 'skip' } },
497
- emit: () => {},
498
- diagnostic: () => {},
499
- });
500
- const noRetryEvents = [];
501
- await assert.rejects(
502
- noRetryAdapter.send(noRetrySession, 'return an empty success', { emit: event => noRetryEvents.push(event) }),
503
- /没有 assistant 文本/,
504
- );
505
- assert.equal(noRetrySpawns, 1);
506
- assert.equal(noRetryEvents.filter(event => event.kind === 'error').length, 1);
507
- await noRetryAdapter.close(noRetrySession);
508
- } finally {
509
- delete process.env.HARNESSMIX_ANTIGRAVITY_EMPTY_RESULT_RETRIES;
510
- }
511
-
512
- // 18. An empty view_file result must not close the Core turn before a late
513
- // assistant response. This is the stream shape produced for image files by
514
- // agy: the terminal tool step has no tool_info.output field.
515
- const fakeChild = new EventEmitter();
516
- fakeChild.stdin = new PassThrough();
517
- fakeChild.stdout = new PassThrough();
518
- fakeChild.stderr = new PassThrough();
519
- let fakePrompt = '';
520
- let fakeClosed = false;
521
- const closeFakeChild = () => {
522
- if (fakeClosed) return;
523
- fakeClosed = true;
524
- fakeChild.stdout.end();
525
- fakeChild.stderr.end();
526
- fakeChild.emit('close', 0);
527
- };
528
- fakeChild.stdin.once('data', (chunk) => {
529
- fakePrompt = JSON.parse(String(chunk).trim()).message?.content || '';
530
- const emit = (event, delay = 0) => setTimeout(() => {
531
- if (!fakeClosed) fakeChild.stdout.write(`${JSON.stringify(event)}\n`);
532
- }, delay);
533
- emit({ event: 'init', conversation_id: 'fake-empty-view-file' });
534
- emit({
535
- event: 'step_update',
536
- step_update: {
537
- conversation_id: 'fake-empty-view-file',
538
- step_index: 0,
539
- state: 'DONE',
540
- step_type: 'agent_response',
541
- text_delta: 'checking image... ',
542
- },
543
- }, 1);
544
- emit({
545
- event: 'step_update',
546
- step_update: {
547
- conversation_id: 'fake-empty-view-file',
548
- step_index: 1,
549
- state: 'ACTIVE',
550
- step_type: 'tool',
551
- tool_name: 'view_file',
552
- tool_info: { name: 'view_file', parameters: { AbsolutePath: 'image.png' } },
553
- },
554
- }, 5);
555
- emit({
556
- event: 'step_update',
557
- step_update: {
558
- conversation_id: 'fake-empty-view-file',
559
- step_index: 1,
560
- state: 'DONE',
561
- step_type: 'tool',
562
- tool_name: 'view_file',
563
- tool_info: { name: 'view_file', parameters: { AbsolutePath: 'image.png' } },
564
- },
565
- }, 10);
566
- emit({
567
- event: 'result',
568
- result: {
569
- conversation_id: 'fake-empty-view-file',
570
- status: 'SUCCESS',
571
- num_turns: 1,
572
- response: '',
573
- },
574
- }, 15);
575
- emit({
576
- event: 'step_update',
577
- step_update: {
578
- conversation_id: 'fake-empty-view-file',
579
- step_index: 2,
580
- state: 'DONE',
581
- step_type: 'agent_response',
582
- text_delta: 'late answer',
583
- },
584
- }, 70);
585
- });
586
- fakeChild.stdin.once('finish', () => setTimeout(closeFakeChild, 0));
587
- fakeChild.kill = closeFakeChild;
588
- const fakeAdapter = create(() => {}, {
589
- spawnProcess: () => fakeChild,
590
- resultDrainMs: 20,
591
- resultDrainMaxMs: 250,
592
- });
593
- const fakeCwd = path.join(os.tmpdir(), `agy-empty-view-file-${Date.now()}`);
594
- const fakeImagePath = path.join(fakeCwd, 'codex-clipboard-stale.png');
595
- const tinyPng = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=';
596
- await fs.promises.mkdir(fakeCwd, { recursive: true });
597
- await fs.promises.writeFile(fakeImagePath, Buffer.from(tinyPng, 'base64'));
598
- const fakeSession = await fakeAdapter.open({
599
- thread: { id: 'fake-thread', cwd: fakeCwd, options: { permissionMode: 'skip' } },
600
- emit: () => {},
601
- diagnostic: () => {},
602
- });
603
- const fakeEvents = [];
604
- await fakeAdapter.send(fakeSession, 'inspect image', { emit: event => fakeEvents.push(event) }, {
605
- images: [{ name: 'codex-clipboard-stale.png', path: fakeImagePath, data: tinyPng }],
606
- });
607
- assert.match(fakePrompt, /[\\/]\.gemini[\\/]attachments[\\/]/);
608
- assert.ok(!fakePrompt.includes(fakeImagePath.replace(/\\/g, '/')));
609
- await fs.promises.unlink(fakeImagePath);
610
- const fakeTool = fakeEvents.filter(event => event.kind === 'tool').at(-1);
611
- assert.equal(fakeTool?.title, 'view_file');
612
- assert.equal(fakeTool?.state, 'done');
613
- assert.equal(fakeTool?.output, undefined);
614
- assert.equal(fakeEvents.filter(event => event.kind === 'text-delta').map(event => event.text).join(''), 'checking image... late answer');
615
- assert.ok(fakeEvents.findIndex(event => event.kind === 'text-delta') < fakeEvents.findIndex(event => event.kind === 'completed'));
616
- await fakeAdapter.close(fakeSession);
617
- await fs.promises.rm(fakeCwd, { recursive: true, force: true }).catch(() => {});
618
-
619
- // 19. Close session
620
- await adapter.close(session);
621
-
622
- console.log('antigravity adapter: manifest, models catalog, usage projection, quota/credits, prompt formatting, image attachments, session lifecycle, model switching, describe, fork, step merging, and turn pruning passed');
623
- })().catch((err) => {
624
- console.error(err);
625
- process.exitCode = 1;
626
- });
1
+ const assert = require('node:assert/strict');
2
+ const { EventEmitter } = require('node:events');
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+ const { PassThrough } = require('node:stream');
7
+ const antigravity = require('../src/main/adapters/antigravity');
8
+
9
+ (async () => {
10
+ const { manifest, create, parseModelsOutput, parseUsage, formatPrompt, prepareImageAttachments, formatAntigravityResultError, modelSupportsEffort, ANTIGRAVITY_PERMISSION_MODES } = antigravity;
11
+
12
+ // 1. Manifest
13
+ assert.equal(manifest.id, 'antigravity');
14
+ assert.equal(manifest.name, 'Antigravity');
15
+ assert.equal(manifest.icon, 'antigravity-color.svg');
16
+ assert.equal(manifest.capabilities.streaming, true);
17
+ assert.equal(manifest.capabilities.thinking, true);
18
+ assert.equal(manifest.capabilities.tools, true);
19
+ assert.equal(manifest.capabilities.approvals, true);
20
+ assert.equal(manifest.capabilities.questions, true);
21
+ assert.equal(manifest.capabilities.models, true);
22
+ assert.equal(manifest.capabilities.thinkingLevels, true);
23
+ assert.equal(manifest.capabilities.permissionModes, true);
24
+ assert.equal(manifest.capabilities.resume, true);
25
+ assert.equal(manifest.capabilities.fork, true);
26
+ assert.equal(manifest.capabilities.forkFromMessage, true);
27
+ assert.equal(manifest.capabilities.attachments, true);
28
+
29
+ // 2. parseModelsOutput
30
+ const sampleModelsOutput = `
31
+ Fetching available models...
32
+ gemini-3.8-flash-high\tGemini 3.8 Flash (High)
33
+ gemini-3.8-flash-medium\tGemini 3.8 Flash (Medium)
34
+ gemini-3.8-flash-low\tGemini 3.8 Flash (Low)
35
+ gemini-3.1-pro-high\tGemini 3.1 Pro (High)
36
+ gemini-3.1-pro-low\tGemini 3.1 Pro (Low)
37
+ claude-sonnet-4-6\tClaude Sonnet 4.6 (Thinking)
38
+ gpt-oss-120b-medium\tGPT-OSS 120B (Medium)
39
+ `;
40
+ const models = parseModelsOutput(sampleModelsOutput);
41
+ assert.ok(models.length >= 4);
42
+
43
+ const flash = models.find(m => m.id === 'gemini-3.8-flash');
44
+ assert.ok(flash);
45
+ assert.equal(flash.name, 'Gemini 3.8 Flash');
46
+ assert.equal(flash.provider, 'google');
47
+ assert.equal(flash.efforts.length, 3);
48
+ assert.equal(flash.efforts[0].id, 'low');
49
+ assert.equal(flash.efforts[2].id, 'high');
50
+ assert.equal(flash.defaultEffort, 'high');
51
+ assert.equal(flash.contextWindow, 1_048_576);
52
+ assert.equal(modelSupportsEffort(flash), true);
53
+
54
+ const claude = models.find(m => m.id === 'claude-sonnet-4-6');
55
+ assert.ok(claude);
56
+ assert.equal(claude.provider, 'anthropic');
57
+ assert.equal(claude.contextWindow, 200_000);
58
+ assert.equal(modelSupportsEffort(claude), false);
59
+ assert.equal(modelSupportsEffort({ id: 'claude-sonnet-4-6' }), false);
60
+
61
+ // 3. parseUsage
62
+ const usage = parseUsage({
63
+ input_tokens: 1200,
64
+ output_tokens: 300,
65
+ thinking_tokens: 150,
66
+ total_tokens: 1650,
67
+ context_used_tokens: 25000,
68
+ }, 'gemini-3.8-flash');
69
+ assert.equal(usage.inputTokens, 1200);
70
+ assert.equal(usage.outputTokens, 300);
71
+ assert.equal(usage.reasoningOutputTokens, 150);
72
+ assert.equal(usage.contextWindow, 1_048_576);
73
+ assert.equal(usage.tokens, 25000);
74
+ assert.ok(usage.contextPercent > 2.3 && usage.contextPercent < 2.5);
75
+
76
+ // 4. formatPrompt
77
+ const rawPrompt = '帮我修改 app.js';
78
+ const formatted = formatPrompt(rawPrompt);
79
+ assert.ok(formatted.includes('write_to_file'));
80
+ assert.ok(formatted.includes('replace_file_content'));
81
+ assert.ok(formatted.includes(rawPrompt));
82
+
83
+ // Slash commands / already instruction formatted should not duplicate
84
+ assert.equal(formatPrompt('/usage'), '/usage');
85
+ assert.equal(formatPrompt(formatted), formatted);
86
+
87
+ // 5. Adapter lifecycle and Session
88
+ const adapter = create(() => {});
89
+ assert.equal(typeof adapter.open, 'function');
90
+ assert.equal(typeof adapter.send, 'function');
91
+ assert.equal(typeof adapter.cancel, 'function');
92
+ assert.equal(typeof adapter.close, 'function');
93
+ assert.equal(typeof adapter.respond, 'function');
94
+ assert.equal(typeof adapter.fork, 'function');
95
+ assert.equal(typeof adapter.listModelsFor, 'function');
96
+ assert.equal(typeof adapter.setModel, 'function');
97
+ assert.equal(typeof adapter.setThinkingLevel, 'function');
98
+ assert.equal(typeof adapter.setPermissionMode, 'function');
99
+
100
+ // 6. Inspect
101
+ const inspection = await adapter.inspect();
102
+ assert.ok(typeof inspection.available === 'boolean');
103
+ assert.ok(typeof inspection.detail === 'string');
104
+
105
+ // 7. Open session
106
+ const openEvents = [];
107
+ const session = await adapter.open({
108
+ thread: {
109
+ id: 'thread-1',
110
+ nativeSessionId: 'conv-12345',
111
+ cwd: 'E:\\harness-mix',
112
+ restore: true,
113
+ options: {
114
+ model: { id: 'gemini-3.8-flash', name: 'Gemini 3.8 Flash' },
115
+ thinking: 'medium',
116
+ permissionMode: 'desktop',
117
+ },
118
+ },
119
+ emit: (event) => openEvents.push(event),
120
+ diagnostic: () => {},
121
+ });
122
+ assert.equal(session.nativeSessionId, 'conv-12345');
123
+ assert.equal(session.thinkingLevel, 'medium');
124
+ assert.equal(session.permissionMode, 'desktop');
125
+ assert.ok(openEvents.some(e => e.kind === 'session' && e.nativeSessionId === 'conv-12345'));
126
+
127
+ // 8. setModel, setThinkingLevel, setPermissionMode
128
+ await adapter.setModel(session, { id: 'gemini-3.1-pro', name: 'Gemini 3.1 Pro' });
129
+ assert.equal(session.model.id, 'gemini-3.1-pro');
130
+ await adapter.setThinkingLevel(session, 'high');
131
+ assert.equal(session.thinkingLevel, 'high');
132
+ await adapter.setPermissionMode(session, 'skip');
133
+ assert.equal(session.permissionMode, 'skip');
134
+
135
+ // 9. Describe
136
+ const desc = await adapter.describe();
137
+ assert.ok(Array.isArray(desc.models));
138
+ assert.equal(desc.thinkingLevels.length, 3);
139
+ assert.equal(desc.permissionModes.length, 3);
140
+
141
+ // 10. Fork session
142
+ const forkEvents = [];
143
+ const forked = await adapter.fork({
144
+ id: 'thread-1',
145
+ nativeSessionId: 'conv-12345',
146
+ cwd: 'E:\\harness-mix',
147
+ model: { id: 'gemini-3.8-flash', name: 'Gemini 3.8 Flash' },
148
+ options: { thinking: 'high', permissionMode: 'default' },
149
+ }, {
150
+ emit: (e) => forkEvents.push(e),
151
+ diagnostic: () => {},
152
+ });
153
+ assert.ok(forked.nativeSessionId);
154
+ assert.notEqual(forked.nativeSessionId, 'conv-12345');
155
+ assert.equal(forked.session.nativeSessionId, forked.nativeSessionId);
156
+ assert.ok(forkEvents.some(e => e.kind === 'session' && e.nativeSessionId === forked.nativeSessionId));
157
+
158
+ // 11. Image attachments
159
+ const tmpRoot = path.join(os.tmpdir(), `agy-attach-test-${Date.now()}`);
160
+ await fs.promises.mkdir(tmpRoot, { recursive: true });
161
+ const localImg = path.join(tmpRoot, 'test.png');
162
+ await fs.promises.writeFile(localImg, Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=', 'base64'));
163
+ const prepared = await prepareImageAttachments([
164
+ { name: 'test.png', path: localImg },
165
+ { name: 'inline.png', mime: 'image/png', data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=' },
166
+ ], tmpRoot);
167
+ assert.equal(prepared.imageEntries.length, 2);
168
+ assert.equal(prepared.imageEntries[0].name, 'test.png');
169
+ assert.notEqual(prepared.imageEntries[0].path, localImg.replace(/\\/g, '/'));
170
+ assert.match(prepared.imageEntries[0].path, /[\\/]\.gemini[\\/]attachments[\\/]/);
171
+ assert.equal(fs.readFileSync(prepared.imageEntries[0].path).toString('base64'), 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=');
172
+ assert.ok(fs.existsSync(prepared.imageEntries[1].path));
173
+ await fs.promises.unlink(localImg);
174
+ assert.ok(fs.existsSync(prepared.imageEntries[0].path));
175
+ const legacyPath = path.join(os.tmpdir(), `codex-clipboard-legacy-${Date.now()}.png`);
176
+ await fs.promises.rm(legacyPath, { force: true }).catch(() => {});
177
+ const legacySession = await adapter.open({
178
+ thread: {
179
+ id: 'legacy-image-thread',
180
+ nativeSessionId: 'legacy-image-session',
181
+ cwd: tmpRoot,
182
+ messages: [{ role: 'user', attachments: [{ kind: 'image', path: legacyPath, data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=' }] }],
183
+ options: {},
184
+ },
185
+ emit: () => {},
186
+ diagnostic: () => {},
187
+ });
188
+ assert.equal(fs.readFileSync(legacyPath).toString('base64'), 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=');
189
+ await adapter.close(legacySession);
190
+ await fs.promises.unlink(legacyPath).catch(() => {});
191
+ await fs.promises.rm(tmpRoot, { recursive: true, force: true }).catch(() => {});
192
+
193
+ // 12. mergePendingStep
194
+ const { mergePendingStep, cloneDatabase } = antigravity;
195
+ const activeStep = {
196
+ step_index: 2,
197
+ state: 'ACTIVE',
198
+ step_type: 'tool',
199
+ tool_name: 'write_to_file',
200
+ tool_info: {
201
+ name: 'write_to_file',
202
+ parameters: { TargetFile: 'test.txt', Description: 'create file' },
203
+ },
204
+ };
205
+ const doneStep = {
206
+ step_index: 2,
207
+ state: 'DONE',
208
+ step_type: 'tool',
209
+ tool_info: {
210
+ output: 'File written successfully',
211
+ },
212
+ };
213
+ const merged = mergePendingStep(activeStep, doneStep);
214
+ assert.equal(merged.state, 'DONE');
215
+ assert.equal(merged.tool_name, 'write_to_file');
216
+ assert.equal(merged.tool_info.name, 'write_to_file');
217
+ assert.equal(merged.tool_info.parameters.TargetFile, 'test.txt');
218
+ assert.equal(merged.tool_info.output, 'File written successfully');
219
+
220
+ // 13. cloneDatabase with message turn pruning and summaries.db
221
+ const mockHome = path.join(os.tmpdir(), `agy-mock-home-${Date.now()}`);
222
+ const convDir = path.join(mockHome, '.gemini', 'antigravity-cli', 'conversations');
223
+ await fs.promises.mkdir(convDir, { recursive: true });
224
+ const { DatabaseSync } = require('node:sqlite');
225
+ const sourceDbPath = path.join(convDir, 'source-conv.db');
226
+ const db = new DatabaseSync(sourceDbPath);
227
+ db.exec(`
228
+ CREATE TABLE trajectory_meta (cascade_id TEXT);
229
+ INSERT INTO trajectory_meta VALUES ('source-conv');
230
+ CREATE TABLE steps (idx INTEGER PRIMARY KEY, step_type INTEGER);
231
+ INSERT INTO steps VALUES (1, 14), (2, 1), (3, 14), (4, 1);
232
+ CREATE TABLE gen_metadata (idx INTEGER PRIMARY KEY);
233
+ INSERT INTO gen_metadata VALUES (0), (1);
234
+ CREATE TABLE executor_metadata (idx INTEGER PRIMARY KEY);
235
+ INSERT INTO executor_metadata VALUES (0), (1);
236
+ CREATE TABLE parent_references (idx INTEGER PRIMARY KEY);
237
+ INSERT INTO parent_references VALUES (0), (1);
238
+ CREATE TABLE battle_mode_infos (idx INTEGER PRIMARY KEY);
239
+ INSERT INTO battle_mode_infos VALUES (0), (1);
240
+ `);
241
+ db.close();
242
+
243
+ const sumDbPath = path.join(mockHome, '.gemini', 'antigravity-cli', 'conversation_summaries.db');
244
+ const sumDb = new DatabaseSync(sumDbPath);
245
+ sumDb.exec(`
246
+ CREATE TABLE conversation_summaries (conversation_id TEXT PRIMARY KEY, title TEXT, step_count INTEGER, last_modified_time TEXT);
247
+ INSERT INTO conversation_summaries VALUES ('source-conv', 'Test Session', 4, '2026-09-01T00:00:00.000Z');
248
+ `);
249
+ sumDb.close();
250
+
251
+ const clonedOk = await cloneDatabase('source-conv', 'derived-conv', 1, mockHome);
252
+ assert.equal(clonedOk, true);
253
+
254
+ const derivedDb = new DatabaseSync(path.join(convDir, 'derived-conv.db'));
255
+ const meta = derivedDb.prepare('SELECT cascade_id FROM trajectory_meta').get();
256
+ assert.equal(meta.cascade_id, 'derived-conv');
257
+ const remainingSteps = derivedDb.prepare('SELECT count(*) as c FROM steps').get();
258
+ assert.equal(remainingSteps.c, 2); // only first turn retained
259
+ derivedDb.close();
260
+
261
+ const sumDbCheck = new DatabaseSync(sumDbPath);
262
+ const sumRow = sumDbCheck.prepare('SELECT * FROM conversation_summaries WHERE conversation_id = ?').get('derived-conv');
263
+ assert.ok(sumRow);
264
+ assert.equal(sumRow.title, 'Test Session');
265
+ assert.equal(sumRow.step_count, 2);
266
+ sumDbCheck.close();
267
+
268
+ await fs.promises.rm(mockHome, { recursive: true, force: true }).catch(() => {});
269
+
270
+ // 14. Quota and Credits parsing
271
+ const mockUsageCommand = {
272
+ name: 'usage',
273
+ data: {
274
+ groups: [
275
+ {
276
+ name: 'Gemini Models',
277
+ buckets: [
278
+ { id: 'gemini-weekly', name: 'Weekly Limit', window: 'weekly', remaining_fraction: 0.45, reset_time: '2026-09-16T01:12:46Z' },
279
+ { id: 'gemini-5h', name: '5-Hour Limit', window: '5h', remaining_fraction: 0.80, reset_time: '2026-09-12T14:45:59Z' },
280
+ ],
281
+ },
282
+ ],
283
+ },
284
+ };
285
+ const { parseAntigravityUsageCommand } = antigravity;
286
+ const quotaSnapshot = parseAntigravityUsageCommand(mockUsageCommand);
287
+ assert.ok(quotaSnapshot);
288
+ assert.equal(quotaSnapshot.periodType, 'weekly');
289
+ assert.equal(quotaSnapshot.usedPercent, 55); // (1 - 0.45) * 100
290
+ assert.equal(quotaSnapshot.resetsAt, '2026-09-16T01:12:46Z');
291
+ assert.ok(Array.isArray(quotaSnapshot.productUsage));
292
+ assert.equal(quotaSnapshot.productUsage[0].usagePercent, 20); // (1 - 0.8) * 100
293
+
294
+ // 15. Native result failures must be visible instead of becoming an empty
295
+ // successful turn. This is the real error returned by agy 1.2.2 for an
296
+ // unsupported API location.
297
+ const locationError = formatAntigravityResultError({
298
+ status: 'ERROR',
299
+ error: { code: 'FAILED_PRECONDITION', message: 'User location is not supported for the API use.' },
300
+ });
301
+ assert.equal(locationError, 'Antigravity 回合失败:FAILED_PRECONDITION: User location is not supported for the API use.');
302
+
303
+ const errorChild = new EventEmitter();
304
+ errorChild.stdin = new PassThrough();
305
+ errorChild.stdout = new PassThrough();
306
+ errorChild.stderr = new PassThrough();
307
+ let errorCommandArgs = [];
308
+ let errorClosed = false;
309
+ const closeErrorChild = () => {
310
+ if (errorClosed) return;
311
+ errorClosed = true;
312
+ errorChild.stdout.end();
313
+ errorChild.stderr.end();
314
+ errorChild.emit('close', 0);
315
+ };
316
+ errorChild.stdin.once('data', () => setTimeout(() => {
317
+ if (!errorClosed) errorChild.stdout.write(`${JSON.stringify({
318
+ event: 'result',
319
+ result: {
320
+ conversation_id: 'fake-location-error',
321
+ status: 'ERROR',
322
+ error: { code: 'FAILED_PRECONDITION', message: 'User location is not supported for the API use.' },
323
+ },
324
+ })}\n`);
325
+ }, 1));
326
+ errorChild.stdin.once('finish', () => setTimeout(closeErrorChild, 0));
327
+ errorChild.kill = closeErrorChild;
328
+ const errorAdapter = create(() => {}, { spawnProcess: (_bin, args) => { errorCommandArgs = args; return errorChild; }, resultDrainMs: 10, resultDrainMaxMs: 30 });
329
+ const errorSession = await errorAdapter.open({
330
+ thread: { id: 'fake-error-thread', cwd: os.tmpdir(), options: { model: { id: 'claude-sonnet-4-6' }, thinking: 'high', permissionMode: 'skip' } },
331
+ emit: () => {},
332
+ diagnostic: () => {},
333
+ });
334
+ const errorEvents = [];
335
+ await assert.rejects(
336
+ errorAdapter.send(errorSession, 'return an error', { emit: event => errorEvents.push(event) }),
337
+ /User location is not supported for the API use/,
338
+ );
339
+ const errorEvent = errorEvents.find(event => event.kind === 'error');
340
+ assert.equal(errorEvent?.message, locationError);
341
+ assert.equal(errorCommandArgs.includes('--effort'), false);
342
+ assert.equal(errorEvents.some(event => event.kind === 'completed'), false);
343
+ assert.equal(errorSession.activeTurn, null);
344
+ await errorAdapter.close(errorSession);
345
+
346
+ // 16. A clean process exit without any result is also a protocol failure.
347
+ const noResultChild = new EventEmitter();
348
+ noResultChild.stdin = new PassThrough();
349
+ noResultChild.stdout = new PassThrough();
350
+ noResultChild.stderr = new PassThrough();
351
+ let noResultClosed = false;
352
+ const closeNoResultChild = () => {
353
+ if (noResultClosed) return;
354
+ noResultClosed = true;
355
+ noResultChild.stdout.end();
356
+ noResultChild.stderr.end();
357
+ noResultChild.emit('close', 0);
358
+ };
359
+ noResultChild.stdin.once('data', () => setTimeout(closeNoResultChild, 1));
360
+ noResultChild.kill = closeNoResultChild;
361
+ const noResultAdapter = create(() => {}, { spawnProcess: () => noResultChild });
362
+ const noResultSession = await noResultAdapter.open({
363
+ thread: { id: 'fake-no-result-thread', cwd: os.tmpdir(), options: { permissionMode: 'skip' } },
364
+ emit: () => {},
365
+ diagnostic: () => {},
366
+ });
367
+ const noResultEvents = [];
368
+ await assert.rejects(
369
+ noResultAdapter.send(noResultSession, 'return nothing', { emit: event => noResultEvents.push(event) }),
370
+ /未返回 result/,
371
+ );
372
+ assert.match(noResultEvents.find(event => event.kind === 'error')?.message || '', /未返回 result/);
373
+ assert.equal(noResultEvents.some(event => event.kind === 'completed'), false);
374
+ assert.equal(noResultSession.activeTurn, null);
375
+ await noResultAdapter.close(noResultSession);
376
+
377
+ // 17. A SUCCESS result with no assistant text triggers exactly one automatic
378
+ // nudge retry in the same native conversation; only a repeated empty result
379
+ // surfaces as an error.
380
+ const makeEmptySuccessChild = (onPrompt) => {
381
+ const child = new EventEmitter();
382
+ child.stdin = new PassThrough();
383
+ child.stdout = new PassThrough();
384
+ child.stderr = new PassThrough();
385
+ let closed = false;
386
+ const closeChild = () => {
387
+ if (closed) return;
388
+ closed = true;
389
+ child.stdout.end();
390
+ child.stderr.end();
391
+ child.emit('close', 0);
392
+ };
393
+ child.isClosed = () => closed;
394
+ child.stdin.once('data', (chunk) => {
395
+ const content = JSON.parse(String(chunk).trim()).message?.content || '';
396
+ setTimeout(() => onPrompt(child, content, closeChild), 1);
397
+ });
398
+ child.stdin.once('finish', () => setTimeout(closeChild, 0));
399
+ child.kill = closeChild;
400
+ return child;
401
+ };
402
+ const writeStream = (child, event) => {
403
+ if (!child.isClosed()) child.stdout.write(`${JSON.stringify(event)}\n`);
404
+ };
405
+
406
+ // 17a. First attempt empty, nudge retry answers → turn completes, no error.
407
+ const retryChildren = [];
408
+ const retryArgs = [];
409
+ const retryAdapter = create(() => {}, {
410
+ spawnProcess: (_bin, args) => {
411
+ retryArgs.push(args);
412
+ const attempt = retryChildren.length;
413
+ const child = makeEmptySuccessChild((c, content) => {
414
+ if (attempt === 0) {
415
+ writeStream(c, { event: 'init', conversation_id: 'fake-empty-success' });
416
+ writeStream(c, { event: 'result', result: { conversation_id: 'fake-empty-success', status: 'SUCCESS', response: '' } });
417
+ } else {
418
+ c.nudgePrompt = content;
419
+ writeStream(c, { event: 'init', conversation_id: 'fake-empty-success' });
420
+ writeStream(c, {
421
+ event: 'step_update',
422
+ step_update: { conversation_id: 'fake-empty-success', step_index: 1, state: 'DONE', step_type: 'agent_response', text_delta: 'late recovered answer' },
423
+ });
424
+ writeStream(c, { event: 'result', result: { conversation_id: 'fake-empty-success', status: 'SUCCESS', num_turns: 2, response: 'late recovered answer' } });
425
+ }
426
+ });
427
+ retryChildren.push(child);
428
+ return child;
429
+ },
430
+ resultDrainMs: 10,
431
+ resultDrainMaxMs: 20,
432
+ });
433
+ const retrySession = await retryAdapter.open({
434
+ thread: { id: 'fake-empty-retry-thread', cwd: os.tmpdir(), options: { permissionMode: 'skip' } },
435
+ emit: () => {},
436
+ diagnostic: () => {},
437
+ });
438
+ const retryEvents = [];
439
+ await retryAdapter.send(retrySession, 'return an empty success', { emit: event => retryEvents.push(event) });
440
+ assert.equal(retryChildren.length, 2);
441
+ assert.match(retryChildren[1].nudgePrompt || '', /previous turn ended WITHOUT any visible assistant response/);
442
+ const resumeIdx = retryArgs[1].indexOf('--conversation');
443
+ assert.ok(resumeIdx >= 0);
444
+ assert.equal(retryArgs[1][resumeIdx + 1], 'fake-empty-success');
445
+ assert.equal(retryEvents.filter(event => event.kind === 'error').length, 0);
446
+ assert.ok(retryEvents.some(event => event.kind === 'status' && /自动补问/.test(event.text || '')));
447
+ assert.equal(retryEvents.filter(event => event.kind === 'text-delta').map(event => event.text).join(''), 'late recovered answer');
448
+ assert.ok(retryEvents.some(event => event.kind === 'completed'));
449
+ assert.equal(retrySession.activeTurn, null);
450
+ await retryAdapter.close(retrySession);
451
+
452
+ // 17b. Every attempt empty → rejects, error emitted exactly once (final attempt).
453
+ let alwaysEmptySpawns = 0;
454
+ const alwaysEmptyAdapter = create(() => {}, {
455
+ spawnProcess: () => {
456
+ alwaysEmptySpawns += 1;
457
+ return makeEmptySuccessChild((c) => {
458
+ writeStream(c, { event: 'init', conversation_id: 'fake-empty-success' });
459
+ writeStream(c, { event: 'result', result: { conversation_id: 'fake-empty-success', status: 'SUCCESS', response: '' } });
460
+ });
461
+ },
462
+ resultDrainMaxMs: 20,
463
+ });
464
+ const alwaysEmptySession = await alwaysEmptyAdapter.open({
465
+ thread: { id: 'fake-always-empty-thread', cwd: os.tmpdir(), options: { permissionMode: 'skip' } },
466
+ emit: () => {},
467
+ diagnostic: () => {},
468
+ });
469
+ const alwaysEmptyEvents = [];
470
+ await assert.rejects(
471
+ alwaysEmptyAdapter.send(alwaysEmptySession, 'return an empty success', { emit: event => alwaysEmptyEvents.push(event) }),
472
+ /没有 assistant 文本/,
473
+ );
474
+ assert.equal(alwaysEmptySpawns, 2);
475
+ assert.equal(alwaysEmptyEvents.filter(event => event.kind === 'error').length, 1);
476
+ assert.match(alwaysEmptyEvents.find(event => event.kind === 'error')?.message || '', /没有 assistant 文本/);
477
+ assert.equal(alwaysEmptyEvents.some(event => event.kind === 'completed'), false);
478
+ assert.equal(alwaysEmptySession.activeTurn, null);
479
+ await alwaysEmptyAdapter.close(alwaysEmptySession);
480
+
481
+ // 17c. Automatic retry disabled via env → single attempt, immediate error.
482
+ process.env.HARNESSMIX_ANTIGRAVITY_EMPTY_RESULT_RETRIES = '0';
483
+ try {
484
+ let noRetrySpawns = 0;
485
+ const noRetryAdapter = create(() => {}, {
486
+ spawnProcess: () => {
487
+ noRetrySpawns += 1;
488
+ return makeEmptySuccessChild((c) => {
489
+ writeStream(c, { event: 'init', conversation_id: 'fake-empty-success' });
490
+ writeStream(c, { event: 'result', result: { conversation_id: 'fake-empty-success', status: 'SUCCESS', response: '' } });
491
+ });
492
+ },
493
+ resultDrainMaxMs: 20,
494
+ });
495
+ const noRetrySession = await noRetryAdapter.open({
496
+ thread: { id: 'fake-no-retry-thread', cwd: os.tmpdir(), options: { permissionMode: 'skip' } },
497
+ emit: () => {},
498
+ diagnostic: () => {},
499
+ });
500
+ const noRetryEvents = [];
501
+ await assert.rejects(
502
+ noRetryAdapter.send(noRetrySession, 'return an empty success', { emit: event => noRetryEvents.push(event) }),
503
+ /没有 assistant 文本/,
504
+ );
505
+ assert.equal(noRetrySpawns, 1);
506
+ assert.equal(noRetryEvents.filter(event => event.kind === 'error').length, 1);
507
+ await noRetryAdapter.close(noRetrySession);
508
+ } finally {
509
+ delete process.env.HARNESSMIX_ANTIGRAVITY_EMPTY_RESULT_RETRIES;
510
+ }
511
+
512
+ // 18. An empty view_file result must not close the Core turn before a late
513
+ // assistant response. This is the stream shape produced for image files by
514
+ // agy: the terminal tool step has no tool_info.output field.
515
+ const fakeChild = new EventEmitter();
516
+ fakeChild.stdin = new PassThrough();
517
+ fakeChild.stdout = new PassThrough();
518
+ fakeChild.stderr = new PassThrough();
519
+ let fakePrompt = '';
520
+ let fakeClosed = false;
521
+ const closeFakeChild = () => {
522
+ if (fakeClosed) return;
523
+ fakeClosed = true;
524
+ fakeChild.stdout.end();
525
+ fakeChild.stderr.end();
526
+ fakeChild.emit('close', 0);
527
+ };
528
+ fakeChild.stdin.once('data', (chunk) => {
529
+ fakePrompt = JSON.parse(String(chunk).trim()).message?.content || '';
530
+ const emit = (event, delay = 0) => setTimeout(() => {
531
+ if (!fakeClosed) fakeChild.stdout.write(`${JSON.stringify(event)}\n`);
532
+ }, delay);
533
+ emit({ event: 'init', conversation_id: 'fake-empty-view-file' });
534
+ emit({
535
+ event: 'step_update',
536
+ step_update: {
537
+ conversation_id: 'fake-empty-view-file',
538
+ step_index: 0,
539
+ state: 'DONE',
540
+ step_type: 'agent_response',
541
+ text_delta: 'checking image... ',
542
+ },
543
+ }, 1);
544
+ emit({
545
+ event: 'step_update',
546
+ step_update: {
547
+ conversation_id: 'fake-empty-view-file',
548
+ step_index: 1,
549
+ state: 'ACTIVE',
550
+ step_type: 'tool',
551
+ tool_name: 'view_file',
552
+ tool_info: { name: 'view_file', parameters: { AbsolutePath: 'image.png' } },
553
+ },
554
+ }, 5);
555
+ emit({
556
+ event: 'step_update',
557
+ step_update: {
558
+ conversation_id: 'fake-empty-view-file',
559
+ step_index: 1,
560
+ state: 'DONE',
561
+ step_type: 'tool',
562
+ tool_name: 'view_file',
563
+ tool_info: { name: 'view_file', parameters: { AbsolutePath: 'image.png' } },
564
+ },
565
+ }, 10);
566
+ emit({
567
+ event: 'result',
568
+ result: {
569
+ conversation_id: 'fake-empty-view-file',
570
+ status: 'SUCCESS',
571
+ num_turns: 1,
572
+ response: '',
573
+ },
574
+ }, 15);
575
+ emit({
576
+ event: 'step_update',
577
+ step_update: {
578
+ conversation_id: 'fake-empty-view-file',
579
+ step_index: 2,
580
+ state: 'DONE',
581
+ step_type: 'agent_response',
582
+ text_delta: 'late answer',
583
+ },
584
+ }, 70);
585
+ });
586
+ fakeChild.stdin.once('finish', () => setTimeout(closeFakeChild, 0));
587
+ fakeChild.kill = closeFakeChild;
588
+ const fakeAdapter = create(() => {}, {
589
+ spawnProcess: () => fakeChild,
590
+ resultDrainMs: 20,
591
+ resultDrainMaxMs: 250,
592
+ });
593
+ const fakeCwd = path.join(os.tmpdir(), `agy-empty-view-file-${Date.now()}`);
594
+ const fakeImagePath = path.join(fakeCwd, 'codex-clipboard-stale.png');
595
+ const tinyPng = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=';
596
+ await fs.promises.mkdir(fakeCwd, { recursive: true });
597
+ await fs.promises.writeFile(fakeImagePath, Buffer.from(tinyPng, 'base64'));
598
+ const fakeSession = await fakeAdapter.open({
599
+ thread: { id: 'fake-thread', cwd: fakeCwd, options: { permissionMode: 'skip' } },
600
+ emit: () => {},
601
+ diagnostic: () => {},
602
+ });
603
+ const fakeEvents = [];
604
+ await fakeAdapter.send(fakeSession, 'inspect image', { emit: event => fakeEvents.push(event) }, {
605
+ images: [{ name: 'codex-clipboard-stale.png', path: fakeImagePath, data: tinyPng }],
606
+ });
607
+ assert.match(fakePrompt, /[\\/]\.gemini[\\/]attachments[\\/]/);
608
+ assert.ok(!fakePrompt.includes(fakeImagePath.replace(/\\/g, '/')));
609
+ await fs.promises.unlink(fakeImagePath);
610
+ const fakeTool = fakeEvents.filter(event => event.kind === 'tool').at(-1);
611
+ assert.equal(fakeTool?.title, 'view_file');
612
+ assert.equal(fakeTool?.state, 'done');
613
+ assert.equal(fakeTool?.output, undefined);
614
+ assert.equal(fakeEvents.filter(event => event.kind === 'text-delta').map(event => event.text).join(''), 'checking image... late answer');
615
+ assert.ok(fakeEvents.findIndex(event => event.kind === 'text-delta') < fakeEvents.findIndex(event => event.kind === 'completed'));
616
+ await fakeAdapter.close(fakeSession);
617
+ await fs.promises.rm(fakeCwd, { recursive: true, force: true }).catch(() => {});
618
+
619
+ // 19. Close session
620
+ await adapter.close(session);
621
+
622
+ // 20. WinINET system proxy env passthrough. Console children cannot see
623
+ // the Windows system proxy; agy only honors HTTP(S)_PROXY env vars, so
624
+ // without this passthrough its OAuth refresh black-holes and every spawn
625
+ // re-triggers interactive login.
626
+ const { systemProxyEnv, parseWininetProxyTarget, parseWininetProxyOverride, withProxyScheme } = require('../src/main/native/process-utils');
627
+ assert.deepEqual(parseWininetProxyTarget('127.0.0.1:7897'), { http: '127.0.0.1:7897', https: '127.0.0.1:7897' });
628
+ assert.deepEqual(parseWininetProxyTarget('http=1.2.3.4:8080;https=5.6.7.8:8443;ftp=9.9.9.9:21'), { http: '1.2.3.4:8080', https: '5.6.7.8:8443' });
629
+ assert.deepEqual(parseWininetProxyTarget('socks=127.0.0.1:7890'), { http: 'socks5://127.0.0.1:7890', https: 'socks5://127.0.0.1:7890' });
630
+ assert.equal(parseWininetProxyTarget(''), null);
631
+ assert.equal(withProxyScheme('127.0.0.1:7897'), 'http://127.0.0.1:7897');
632
+ assert.equal(withProxyScheme('socks5://127.0.0.1:7890'), 'socks5://127.0.0.1:7890');
633
+ assert.equal(
634
+ parseWininetProxyOverride('localhost;127.*;192.168.*;10.*;172.16.*;172.31.*;<local>'),
635
+ 'localhost,127.0.0.1,::1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12',
636
+ );
637
+ assert.equal(parseWininetProxyOverride('*.corp.example;internal.example'), 'localhost,127.0.0.1,::1,.corp.example,internal.example');
638
+ assert.equal(parseWininetProxyOverride(''), null);
639
+ const proxyEnv = await systemProxyEnv();
640
+ assert.equal(typeof proxyEnv, 'object');
641
+ for (const value of Object.values(proxyEnv)) assert.equal(typeof value, 'string');
642
+
643
+ console.log('antigravity adapter: manifest, models catalog, usage projection, quota/credits, prompt formatting, image attachments, session lifecycle, model switching, describe, fork, step merging, turn pruning, and system proxy passthrough passed');
644
+ })().catch((err) => {
645
+ console.error(err);
646
+ process.exitCode = 1;
647
+ });