dr-claw 1.1.2 → 1.1.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.
- package/README.md +286 -177
- package/README.zh-CN.md +286 -174
- package/dist/assets/{_basePickBy-C0n1uo4u.js → _basePickBy-7CEpiVqc.js} +1 -1
- package/dist/assets/{_baseUniq-BRNmhFkc.js → _baseUniq-yh8GoKah.js} +1 -1
- package/dist/assets/{arc-DOMBmC_A.js → arc-xxYot-So.js} +1 -1
- package/dist/assets/{architectureDiagram-VXUJARFQ-CtMWglbD.js → architectureDiagram-VXUJARFQ-Bb1NqOFB.js} +1 -1
- package/dist/assets/{blockDiagram-VD42YOAC-DK_gTkJy.js → blockDiagram-VD42YOAC-D8xMHk8C.js} +1 -1
- package/dist/assets/{c4Diagram-YG6GDRKO-DrDAvsn7.js → c4Diagram-YG6GDRKO-69UiBMVa.js} +1 -1
- package/dist/assets/channel-BJdHHkhR.js +1 -0
- package/dist/assets/{chunk-4BX2VUAB-CGbhdZcN.js → chunk-4BX2VUAB-DXrPhE6K.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-sGeFSxen.js → chunk-55IACEB6-B3sw4AGX.js} +1 -1
- package/dist/assets/{chunk-B4BG7PRW-RxgHAU_P.js → chunk-B4BG7PRW-Dd4n17kR.js} +1 -1
- package/dist/assets/{chunk-DI55MBZ5-CvHUXZrx.js → chunk-DI55MBZ5-DuTb9gb8.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-Sn7cXVNU.js → chunk-FMBD7UC4-CGErQ-ud.js} +1 -1
- package/dist/assets/{chunk-QN33PNHL-bDKOHppN.js → chunk-QN33PNHL-Bk5fuYbR.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-B9kN5LGm.js → chunk-QZHKN3VN-2UdZTbge.js} +1 -1
- package/dist/assets/{chunk-TZMSLE5B-CCBe6len.js → chunk-TZMSLE5B-Bl-UYKEK.js} +1 -1
- package/dist/assets/{classDiagram-2ON5EDUG-DQoMNYlY.js → classDiagram-2ON5EDUG-EaoBDWqJ.js} +1 -1
- package/dist/assets/{classDiagram-v2-WZHVMYZB-DQoMNYlY.js → classDiagram-v2-WZHVMYZB-EaoBDWqJ.js} +1 -1
- package/dist/assets/clone-CwXTbwxl.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-r6sLiHBW.js → cose-bilkent-S5V4N54A-qXNKLX2R.js} +1 -1
- package/dist/assets/{dagre-6UL2VRFP-AbsaR1Oa.js → dagre-6UL2VRFP-DmHvDWep.js} +1 -1
- package/dist/assets/{diagram-PSM6KHXK-DjCvdUyy.js → diagram-PSM6KHXK-C_Rj4DX3.js} +1 -1
- package/dist/assets/{diagram-QEK2KX5R-CqiocXkH.js → diagram-QEK2KX5R-BVr3mPXW.js} +1 -1
- package/dist/assets/{diagram-S2PKOQOG-D5ALeoII.js → diagram-S2PKOQOG-DVx6i0Qe.js} +1 -1
- package/dist/assets/{erDiagram-Q2GNP2WA-C1AeOsiz.js → erDiagram-Q2GNP2WA-BJQyv9_t.js} +1 -1
- package/dist/assets/{flowDiagram-NV44I4VS-C98ve_DO.js → flowDiagram-NV44I4VS-DCR8sPAE.js} +1 -1
- package/dist/assets/{ganttDiagram-JELNMOA3-3zdLvcxD.js → ganttDiagram-JELNMOA3-BcXFAthK.js} +1 -1
- package/dist/assets/{gitGraphDiagram-V2S2FVAM-nDuDMsHh.js → gitGraphDiagram-V2S2FVAM-CnS56w1x.js} +1 -1
- package/dist/assets/{graph-Blm-emkR.js → graph-DGtZXuDY.js} +1 -1
- package/dist/assets/index-C-sL-ag0.css +1 -0
- package/dist/assets/index-VdqxIFPL.js +670 -0
- package/dist/assets/{infoDiagram-HS3SLOUP-CmzE3q0R.js → infoDiagram-HS3SLOUP-DtI272ZD.js} +1 -1
- package/dist/assets/{journeyDiagram-XKPGCS4Q-CYaW7LrP.js → journeyDiagram-XKPGCS4Q-DHp_Kica.js} +1 -1
- package/dist/assets/{kanban-definition-3W4ZIXB7-mkmW7p0j.js → kanban-definition-3W4ZIXB7-CjgHMNku.js} +1 -1
- package/dist/assets/{layout-Ct2kFIId.js → layout-Cnc-DCSb.js} +1 -1
- package/dist/assets/{linear-CKG9EwIJ.js → linear-BtRgmV19.js} +1 -1
- package/dist/assets/{mermaid.core-CaKcUnnd.js → mermaid.core-B20uzv6N.js} +5 -5
- package/dist/assets/{mindmap-definition-VGOIOE7T-Bct-pYin.js → mindmap-definition-VGOIOE7T-bbcLXkWp.js} +1 -1
- package/dist/assets/{pieDiagram-ADFJNKIX-C8wFD4Jr.js → pieDiagram-ADFJNKIX-B4fEzWKO.js} +1 -1
- package/dist/assets/{quadrantDiagram-AYHSOK5B-CuiE_f46.js → quadrantDiagram-AYHSOK5B-B5G0SMbK.js} +1 -1
- package/dist/assets/{requirementDiagram-UZGBJVZJ-C4v1CqU8.js → requirementDiagram-UZGBJVZJ-DsqtOFgj.js} +1 -1
- package/dist/assets/{sankeyDiagram-TZEHDZUN-keAqqs-Y.js → sankeyDiagram-TZEHDZUN-CiGemFRE.js} +1 -1
- package/dist/assets/{sequenceDiagram-WL72ISMW-ClNbvKTB.js → sequenceDiagram-WL72ISMW-hEMvYbe9.js} +1 -1
- package/dist/assets/{stateDiagram-FKZM4ZOC-D8We-2vq.js → stateDiagram-FKZM4ZOC-Cn8VB7b1.js} +1 -1
- package/dist/assets/stateDiagram-v2-4FDKWEC3-DaH0PZOq.js +1 -0
- package/dist/assets/{timeline-definition-IT6M3QCI-DsvEMT5T.js → timeline-definition-IT6M3QCI-DChT1GUr.js} +1 -1
- package/dist/assets/{treemap-GDKQZRPO-TJrzN7Bj.js → treemap-GDKQZRPO-Dflv6a8z.js} +1 -1
- package/dist/assets/{xychartDiagram-PRI3JC2R-BdATmY5F.js → xychartDiagram-PRI3JC2R-Dr4urXR8.js} +1 -1
- package/dist/icons/nanoclaude.png +0 -0
- package/dist/index.html +2 -2
- package/dist/screenshots/screenshot_v1_2.png +0 -0
- package/package.json +61 -4
- package/public/icons/nanoclaude.png +0 -0
- package/public/screenshots/screenshot_v1_2.png +0 -0
- package/scripts/build-electron-icons.mjs +40 -0
- package/scripts/check-deps.js +28 -0
- package/scripts/native-runtime.mjs +128 -0
- package/server/__tests__/gemini-api.test.mjs +699 -0
- package/server/__tests__/gemini-session-index.test.mjs +95 -0
- package/server/__tests__/project-sync-dedup.test.mjs +173 -0
- package/server/__tests__/session-delete.test.mjs +152 -0
- package/server/claude-sdk.js +175 -14
- package/server/cli-chat.js +28 -17
- package/server/cli.js +48 -11
- package/server/data/news-log-arxiv.json +1 -0
- package/server/data/news-log-huggingface.json +1 -0
- package/server/data/news-results-arxiv.json +460 -0
- package/server/data/news-results-huggingface.json +817 -0
- package/server/data/research_interests_arxiv.json +50 -0
- package/server/data/research_interests_huggingface.json +4 -0
- package/server/database/db.js +101 -5
- package/server/database/init.sql +17 -1
- package/server/gemini-api.js +1838 -0
- package/server/gemini-cli.js +13 -2
- package/server/index.js +94 -16
- package/server/load-env.js +13 -1
- package/server/local-gpu.js +166 -24
- package/server/mcp-compute.js +223 -0
- package/server/middleware/auth.js +25 -6
- package/server/nano-claude-code.js +340 -0
- package/server/nanoSessionPaths.js +41 -0
- package/server/openai-codex.js +6 -2
- package/server/openrouter.js +37 -22
- package/server/projects.js +516 -110
- package/server/routes/agent.js +32 -7
- package/server/routes/auto-research.js +1 -1
- package/server/routes/claude-btw.js +55 -0
- package/server/routes/cli-auth.js +422 -58
- package/server/routes/commands.js +25 -1
- package/server/routes/community-tools.js +398 -0
- package/server/routes/compute.js +155 -0
- package/server/routes/git.js +9 -11
- package/server/routes/memory.js +143 -0
- package/server/routes/news.js +3 -0
- package/server/routes/skills.js +35 -0
- package/server/routes/user.js +3 -5
- package/server/utils/__tests__/safePath.test.js +93 -0
- package/server/utils/cliResolution.js +13 -1
- package/server/utils/codexSessionMessages.js +285 -0
- package/server/utils/geminiOAuth.js +333 -0
- package/server/utils/memoryPrompt.js +78 -0
- package/server/utils/safePath.js +51 -0
- package/server/utils/sessionIndex.js +2 -0
- package/server/utils/skillExpander.js +142 -0
- package/server/utils/spawnAsync.js +78 -0
- package/shared/modelConstants.js +19 -1
- package/dist/assets/channel-BFOE7OyC.js +0 -1
- package/dist/assets/clone-BulQSblH.js +0 -1
- package/dist/assets/index-B8Yutsnm.css +0 -1
- package/dist/assets/index-Cf6gyhc3.js +0 -654
- package/dist/assets/stateDiagram-v2-4FDKWEC3-BUURfjrE.js +0 -1
- package/scripts/install-python-tools.js +0 -64
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { ReadableStream } from 'node:stream/web';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { promises as fs } from 'fs';
|
|
6
|
+
|
|
7
|
+
const ensureProjectSkillLinks = vi.fn();
|
|
8
|
+
const reconcileGeminiSessionIndex = vi.fn();
|
|
9
|
+
const writeProjectTemplates = vi.fn();
|
|
10
|
+
const applyStageTagsToSession = vi.fn();
|
|
11
|
+
const recordIndexedSession = vi.fn();
|
|
12
|
+
const getGeminiAuthHeaders = vi.fn();
|
|
13
|
+
const buildGeminiThinkingConfig = vi.fn();
|
|
14
|
+
const spawnGemini = vi.fn();
|
|
15
|
+
|
|
16
|
+
vi.mock('../projects.js', () => ({
|
|
17
|
+
encodeProjectPath: (projectPath) => `encoded:${projectPath}`,
|
|
18
|
+
ensureProjectSkillLinks,
|
|
19
|
+
reconcileGeminiSessionIndex,
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock('../templates/index.js', () => ({
|
|
23
|
+
writeProjectTemplates,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
vi.mock('../../shared/errorClassifier.js', () => ({
|
|
27
|
+
classifyError: (message) => ({
|
|
28
|
+
errorType: String(message || '').includes('auth') ? 'auth' : 'api',
|
|
29
|
+
isRetryable: false,
|
|
30
|
+
}),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
vi.mock('../utils/sessionIndex.js', () => ({
|
|
34
|
+
applyStageTagsToSession,
|
|
35
|
+
recordIndexedSession,
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
vi.mock('../utils/permissions.js', async () => {
|
|
39
|
+
const actual = await vi.importActual('../utils/permissions.js');
|
|
40
|
+
return {
|
|
41
|
+
...actual,
|
|
42
|
+
waitForToolApproval: vi.fn(),
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
vi.mock('../utils/geminiOAuth.js', () => ({
|
|
47
|
+
getGeminiAuthHeaders,
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
vi.mock('../gemini-cli.js', () => ({
|
|
51
|
+
spawnGemini,
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
vi.mock('../../shared/geminiThinkingSupport.js', () => ({
|
|
55
|
+
buildGeminiThinkingConfig,
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
const encoder = new TextEncoder();
|
|
59
|
+
const originalRetryBaseMs = process.env.GEMINI_API_RETRY_BASE_MS;
|
|
60
|
+
const originalRetryMaxAttempts = process.env.GEMINI_API_MAX_ATTEMPTS;
|
|
61
|
+
|
|
62
|
+
function makeSseResponse(lines) {
|
|
63
|
+
return {
|
|
64
|
+
body: new ReadableStream({
|
|
65
|
+
start(controller) {
|
|
66
|
+
controller.enqueue(encoder.encode(lines.join('\n')));
|
|
67
|
+
controller.close();
|
|
68
|
+
},
|
|
69
|
+
}),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function createTestWriter() {
|
|
74
|
+
const sent = [];
|
|
75
|
+
return {
|
|
76
|
+
sent,
|
|
77
|
+
writer: {
|
|
78
|
+
send(payload) {
|
|
79
|
+
sent.push(typeof payload === 'string' ? JSON.parse(payload) : payload);
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function getGeminiCliChatsDir(projectIdentifier = path.basename(process.cwd())) {
|
|
86
|
+
return path.join(os.homedir(), '.gemini', 'tmp', projectIdentifier, 'chats');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function removeGeminiCliConversation(sessionId, projectIdentifier = path.basename(process.cwd())) {
|
|
90
|
+
const chatsDir = getGeminiCliChatsDir(projectIdentifier);
|
|
91
|
+
try {
|
|
92
|
+
const files = await fs.readdir(chatsDir);
|
|
93
|
+
await Promise.all(files.map(async (fileName) => {
|
|
94
|
+
if (!fileName.startsWith('session-') || !fileName.endsWith('.json')) return;
|
|
95
|
+
const filePath = path.join(chatsDir, fileName);
|
|
96
|
+
try {
|
|
97
|
+
const conversation = JSON.parse(await fs.readFile(filePath, 'utf8'));
|
|
98
|
+
if (conversation?.sessionId === sessionId) {
|
|
99
|
+
await fs.unlink(filePath);
|
|
100
|
+
}
|
|
101
|
+
} catch {}
|
|
102
|
+
}));
|
|
103
|
+
} catch {}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function readGeminiCliConversation(sessionId, projectIdentifier = path.basename(process.cwd())) {
|
|
107
|
+
const chatsDir = getGeminiCliChatsDir(projectIdentifier);
|
|
108
|
+
const files = await fs.readdir(chatsDir);
|
|
109
|
+
|
|
110
|
+
for (const fileName of files) {
|
|
111
|
+
if (!fileName.startsWith('session-') || !fileName.endsWith('.json')) continue;
|
|
112
|
+
const filePath = path.join(chatsDir, fileName);
|
|
113
|
+
try {
|
|
114
|
+
const conversation = JSON.parse(await fs.readFile(filePath, 'utf8'));
|
|
115
|
+
if (conversation?.sessionId === sessionId) {
|
|
116
|
+
return conversation;
|
|
117
|
+
}
|
|
118
|
+
} catch {}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const geminiApiModulePromise = import('../gemini-api.js');
|
|
125
|
+
|
|
126
|
+
describe('gemini-api', () => {
|
|
127
|
+
beforeEach(() => {
|
|
128
|
+
vi.clearAllMocks();
|
|
129
|
+
buildGeminiThinkingConfig.mockReturnValue(null);
|
|
130
|
+
spawnGemini.mockResolvedValue(undefined);
|
|
131
|
+
process.env.GEMINI_API_RETRY_BASE_MS = '0';
|
|
132
|
+
process.env.GEMINI_API_MAX_ATTEMPTS = '3';
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
afterEach(() => {
|
|
136
|
+
if (originalRetryBaseMs === undefined) delete process.env.GEMINI_API_RETRY_BASE_MS;
|
|
137
|
+
else process.env.GEMINI_API_RETRY_BASE_MS = originalRetryBaseMs;
|
|
138
|
+
|
|
139
|
+
if (originalRetryMaxAttempts === undefined) delete process.env.GEMINI_API_MAX_ATTEMPTS;
|
|
140
|
+
else process.env.GEMINI_API_MAX_ATTEMPTS = originalRetryMaxAttempts;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('returns authFailed without emitting UI noise when direct auth is unavailable', async () => {
|
|
144
|
+
getGeminiAuthHeaders.mockResolvedValue({ headers: null, authMethod: null });
|
|
145
|
+
|
|
146
|
+
const { queryGeminiApi } = await geminiApiModulePromise;
|
|
147
|
+
const { sent, writer } = createTestWriter();
|
|
148
|
+
|
|
149
|
+
const result = await queryGeminiApi('Hello Gemini', {
|
|
150
|
+
cwd: '/tmp/project',
|
|
151
|
+
model: 'gemini-3-flash-preview',
|
|
152
|
+
sessionId: 'gemini-authless',
|
|
153
|
+
permissionMode: 'bypassPermissions',
|
|
154
|
+
}, writer);
|
|
155
|
+
|
|
156
|
+
expect(result).toEqual({ authFailed: true });
|
|
157
|
+
expect(sent).toEqual([]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('builds Gemini request bodies with tools and thinking config', async () => {
|
|
161
|
+
buildGeminiThinkingConfig.mockReturnValue({ thinkingLevel: 'HIGH' });
|
|
162
|
+
|
|
163
|
+
const { GEMINI_TOOL_DECLARATIONS, buildRequestBody } = await geminiApiModulePromise;
|
|
164
|
+
const body = buildRequestBody(
|
|
165
|
+
'gemini-3-flash-preview',
|
|
166
|
+
[{ role: 'user', parts: [{ text: 'Inspect the repo' }] }],
|
|
167
|
+
{ parts: [{ text: 'System prompt' }] },
|
|
168
|
+
'high',
|
|
169
|
+
GEMINI_TOOL_DECLARATIONS.slice(0, 2),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
expect(body).toEqual(expect.objectContaining({
|
|
173
|
+
systemInstruction: { parts: [{ text: 'System prompt' }] },
|
|
174
|
+
contents: [{ role: 'user', parts: [{ text: 'Inspect the repo' }] }],
|
|
175
|
+
toolConfig: { functionCallingConfig: { mode: 'AUTO' } },
|
|
176
|
+
generationConfig: { thinkingConfig: { thinkingLevel: 'HIGH' } },
|
|
177
|
+
}));
|
|
178
|
+
expect(body.tools).toEqual([{
|
|
179
|
+
functionDeclarations: GEMINI_TOOL_DECLARATIONS.slice(0, 2),
|
|
180
|
+
}]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('parses Gemini SSE text, function calls, finish reason, and usage metadata', async () => {
|
|
184
|
+
const { consumeGeminiStream } = await geminiApiModulePromise;
|
|
185
|
+
const deltas = [];
|
|
186
|
+
const response = makeSseResponse([
|
|
187
|
+
'data: {"candidates":[{"content":{"role":"model","parts":[{"text":"Hello "},{"functionCall":{"name":"list_directory","args":{"path":"."}}}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":3,"candidatesTokenCount":7}}',
|
|
188
|
+
'data: {"candidates":[{"content":{"role":"model","parts":[{"text":"world"}]}}]}',
|
|
189
|
+
'',
|
|
190
|
+
]);
|
|
191
|
+
|
|
192
|
+
const result = await consumeGeminiStream(response, {
|
|
193
|
+
onText(delta) {
|
|
194
|
+
deltas.push(delta);
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
expect(deltas).toEqual(['Hello ', 'world']);
|
|
199
|
+
expect(result).toEqual({
|
|
200
|
+
content: 'Hello world',
|
|
201
|
+
functionCalls: [{ name: 'list_directory', args: { path: '.' } }],
|
|
202
|
+
finishReason: 'STOP',
|
|
203
|
+
usageMetadata: { promptTokenCount: 3, candidatesTokenCount: 7 },
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('adds a synthetic thought signature to oauth function-call turns when the model omitted one', async () => {
|
|
208
|
+
const { SYNTHETIC_THOUGHT_SIGNATURE, buildModelTurnParts } = await geminiApiModulePromise;
|
|
209
|
+
|
|
210
|
+
expect(buildModelTurnParts('', [{
|
|
211
|
+
name: 'run_shell_command',
|
|
212
|
+
args: { command: 'pwd' },
|
|
213
|
+
}], { requireSyntheticThoughtSignatures: true })).toEqual([
|
|
214
|
+
{
|
|
215
|
+
functionCall: {
|
|
216
|
+
name: 'run_shell_command',
|
|
217
|
+
args: { command: 'pwd' },
|
|
218
|
+
},
|
|
219
|
+
thoughtSignature: SYNTHETIC_THOUGHT_SIGNATURE,
|
|
220
|
+
},
|
|
221
|
+
]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('uses the Code Assist endpoint for oauth auth and still emits standard gemini websocket messages', async () => {
|
|
225
|
+
getGeminiAuthHeaders.mockResolvedValue({
|
|
226
|
+
headers: { Authorization: 'Bearer token' },
|
|
227
|
+
authMethod: 'oauth',
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
global.fetch = vi.fn()
|
|
231
|
+
.mockResolvedValueOnce({
|
|
232
|
+
ok: true,
|
|
233
|
+
json: async () => ({
|
|
234
|
+
cloudaicompanionProject: 'test-project',
|
|
235
|
+
currentTier: { id: 'standard-tier' },
|
|
236
|
+
}),
|
|
237
|
+
})
|
|
238
|
+
.mockResolvedValueOnce({
|
|
239
|
+
ok: true,
|
|
240
|
+
body: makeSseResponse([
|
|
241
|
+
'data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"Hello from API"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5,"totalTokenCount":15}}}',
|
|
242
|
+
'',
|
|
243
|
+
]).body,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const { queryGeminiApi } = await geminiApiModulePromise;
|
|
247
|
+
const { sent, writer } = createTestWriter();
|
|
248
|
+
|
|
249
|
+
await queryGeminiApi('Say hello', {
|
|
250
|
+
cwd: '/tmp/project',
|
|
251
|
+
projectPath: '/tmp/project',
|
|
252
|
+
model: 'gemini-3-flash-preview',
|
|
253
|
+
sessionId: 'gemini-stream-test',
|
|
254
|
+
permissionMode: 'bypassPermissions',
|
|
255
|
+
}, writer);
|
|
256
|
+
|
|
257
|
+
expect(ensureProjectSkillLinks).toHaveBeenCalledWith('/tmp/project');
|
|
258
|
+
expect(writeProjectTemplates).toHaveBeenCalledWith('/tmp/project');
|
|
259
|
+
expect(recordIndexedSession).toHaveBeenCalled();
|
|
260
|
+
expect(global.fetch).toHaveBeenCalledTimes(2);
|
|
261
|
+
expect(global.fetch.mock.calls[0][0]).toBe('https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist');
|
|
262
|
+
expect(global.fetch.mock.calls[1][0]).toBe('https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse');
|
|
263
|
+
expect(JSON.parse(global.fetch.mock.calls[1][1].body)).toEqual(expect.objectContaining({
|
|
264
|
+
model: 'gemini-3-flash-preview',
|
|
265
|
+
project: 'test-project',
|
|
266
|
+
request: expect.objectContaining({
|
|
267
|
+
session_id: 'gemini-stream-test',
|
|
268
|
+
}),
|
|
269
|
+
}));
|
|
270
|
+
expect(sent[0]).toEqual(expect.objectContaining({
|
|
271
|
+
type: 'session-created',
|
|
272
|
+
sessionId: 'gemini-stream-test',
|
|
273
|
+
provider: 'gemini',
|
|
274
|
+
}));
|
|
275
|
+
expect(sent).toContainEqual(expect.objectContaining({
|
|
276
|
+
type: 'gemini-response',
|
|
277
|
+
sessionId: 'gemini-stream-test',
|
|
278
|
+
data: {
|
|
279
|
+
type: 'content_block_delta',
|
|
280
|
+
index: 0,
|
|
281
|
+
delta: { type: 'text_delta', text: 'Hello from API' },
|
|
282
|
+
},
|
|
283
|
+
}));
|
|
284
|
+
expect(sent).toContainEqual(expect.objectContaining({
|
|
285
|
+
type: 'token-budget',
|
|
286
|
+
sessionId: 'gemini-stream-test',
|
|
287
|
+
data: { used: 15, total: 2000000 },
|
|
288
|
+
}));
|
|
289
|
+
expect(sent.at(-1)).toEqual({
|
|
290
|
+
type: 'gemini-complete',
|
|
291
|
+
sessionId: 'gemini-stream-test',
|
|
292
|
+
exitCode: 0,
|
|
293
|
+
});
|
|
294
|
+
expect(reconcileGeminiSessionIndex).toHaveBeenCalledWith('/tmp/project', expect.objectContaining({
|
|
295
|
+
sessionId: 'gemini-stream-test',
|
|
296
|
+
projectName: 'encoded:/tmp/project',
|
|
297
|
+
}));
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('prefers an explicit API key over oauth when both auth paths are available', async () => {
|
|
301
|
+
getGeminiAuthHeaders.mockResolvedValue({
|
|
302
|
+
headers: { Authorization: 'Bearer oauth-token' },
|
|
303
|
+
authMethod: 'oauth',
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
global.fetch = vi.fn()
|
|
307
|
+
.mockResolvedValueOnce({
|
|
308
|
+
ok: true,
|
|
309
|
+
body: makeSseResponse([
|
|
310
|
+
'data: {"candidates":[{"content":{"role":"model","parts":[{"text":"Hello from public API"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":8,"candidatesTokenCount":4,"totalTokenCount":12}}',
|
|
311
|
+
'',
|
|
312
|
+
]).body,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const { queryGeminiApi } = await geminiApiModulePromise;
|
|
316
|
+
const { sent, writer } = createTestWriter();
|
|
317
|
+
|
|
318
|
+
await queryGeminiApi('Prefer API key', {
|
|
319
|
+
cwd: '/tmp/project',
|
|
320
|
+
projectPath: '/tmp/project',
|
|
321
|
+
model: 'gemini-2.5-flash',
|
|
322
|
+
sessionId: 'gemini-api-key-preferred',
|
|
323
|
+
permissionMode: 'bypassPermissions',
|
|
324
|
+
env: {
|
|
325
|
+
GEMINI_API_KEY: 'env-gemini-key',
|
|
326
|
+
},
|
|
327
|
+
}, writer);
|
|
328
|
+
|
|
329
|
+
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
330
|
+
expect(global.fetch.mock.calls[0][0]).toBe('https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse');
|
|
331
|
+
expect(global.fetch.mock.calls[0][1].headers).toEqual(expect.objectContaining({
|
|
332
|
+
'x-goog-api-key': 'env-gemini-key',
|
|
333
|
+
}));
|
|
334
|
+
expect(global.fetch.mock.calls[0][1].headers.Authorization).toBeUndefined();
|
|
335
|
+
expect(sent.at(-1)).toEqual({
|
|
336
|
+
type: 'gemini-complete',
|
|
337
|
+
sessionId: 'gemini-api-key-preferred',
|
|
338
|
+
exitCode: 0,
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('retries transient Gemini capacity errors before surfacing a failure', async () => {
|
|
343
|
+
getGeminiAuthHeaders.mockResolvedValue({
|
|
344
|
+
headers: { Authorization: 'Bearer retry-token' },
|
|
345
|
+
authMethod: 'oauth',
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
global.fetch = vi.fn()
|
|
349
|
+
.mockResolvedValueOnce({
|
|
350
|
+
ok: true,
|
|
351
|
+
json: async () => ({
|
|
352
|
+
cloudaicompanionProject: 'test-project',
|
|
353
|
+
currentTier: { id: 'standard-tier' },
|
|
354
|
+
}),
|
|
355
|
+
})
|
|
356
|
+
.mockResolvedValueOnce({
|
|
357
|
+
ok: false,
|
|
358
|
+
status: 429,
|
|
359
|
+
headers: new Headers(),
|
|
360
|
+
text: async () => 'RESOURCE_EXHAUSTED: No capacity available for model gemini-2.5-flash-lite',
|
|
361
|
+
})
|
|
362
|
+
.mockResolvedValueOnce({
|
|
363
|
+
ok: true,
|
|
364
|
+
body: makeSseResponse([
|
|
365
|
+
'data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"Recovered after retry."}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":20,"candidatesTokenCount":5,"totalTokenCount":25}}}',
|
|
366
|
+
'',
|
|
367
|
+
]).body,
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
const { queryGeminiApi } = await geminiApiModulePromise;
|
|
371
|
+
const { sent, writer } = createTestWriter();
|
|
372
|
+
|
|
373
|
+
await queryGeminiApi('Retry once, then succeed.', {
|
|
374
|
+
cwd: '/tmp/project',
|
|
375
|
+
projectPath: '/tmp/project',
|
|
376
|
+
model: 'gemini-2.5-flash-lite',
|
|
377
|
+
sessionId: 'gemini-retry-success',
|
|
378
|
+
permissionMode: 'bypassPermissions',
|
|
379
|
+
}, writer);
|
|
380
|
+
|
|
381
|
+
expect(global.fetch).toHaveBeenCalledTimes(3);
|
|
382
|
+
expect(sent.find((entry) => entry.type === 'gemini-error')).toBeUndefined();
|
|
383
|
+
expect(sent).toContainEqual(expect.objectContaining({
|
|
384
|
+
type: 'gemini-response',
|
|
385
|
+
sessionId: 'gemini-retry-success',
|
|
386
|
+
data: {
|
|
387
|
+
type: 'content_block_delta',
|
|
388
|
+
index: 0,
|
|
389
|
+
delta: { type: 'text_delta', text: 'Recovered after retry.' },
|
|
390
|
+
},
|
|
391
|
+
}));
|
|
392
|
+
expect(sent.at(-1)).toEqual({
|
|
393
|
+
type: 'gemini-complete',
|
|
394
|
+
sessionId: 'gemini-retry-success',
|
|
395
|
+
exitCode: 0,
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('falls back to a smaller direct Gemini model before invoking the CLI on capacity exhaustion', async () => {
|
|
400
|
+
process.env.GEMINI_API_MAX_ATTEMPTS = '1';
|
|
401
|
+
|
|
402
|
+
global.fetch = vi.fn()
|
|
403
|
+
.mockResolvedValueOnce({
|
|
404
|
+
ok: false,
|
|
405
|
+
status: 429,
|
|
406
|
+
headers: new Headers(),
|
|
407
|
+
text: async () => 'RESOURCE_EXHAUSTED: No capacity available for model gemini-3-flash-preview',
|
|
408
|
+
})
|
|
409
|
+
.mockResolvedValueOnce({
|
|
410
|
+
ok: true,
|
|
411
|
+
body: makeSseResponse([
|
|
412
|
+
'data: {"candidates":[{"content":{"role":"model","parts":[{"text":"Recovered on fallback model."}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":8,"candidatesTokenCount":4,"totalTokenCount":12}}',
|
|
413
|
+
'',
|
|
414
|
+
]).body,
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
const { queryGeminiApi } = await geminiApiModulePromise;
|
|
418
|
+
const { sent, writer } = createTestWriter();
|
|
419
|
+
|
|
420
|
+
await queryGeminiApi('Say hello after capacity fallback.', {
|
|
421
|
+
cwd: '/tmp/project',
|
|
422
|
+
projectPath: '/tmp/project',
|
|
423
|
+
model: 'gemini-3-flash-preview',
|
|
424
|
+
sessionId: 'gemini-direct-model-fallback',
|
|
425
|
+
permissionMode: 'bypassPermissions',
|
|
426
|
+
env: {
|
|
427
|
+
GEMINI_API_KEY: 'env-gemini-key',
|
|
428
|
+
},
|
|
429
|
+
}, writer);
|
|
430
|
+
|
|
431
|
+
expect(global.fetch).toHaveBeenCalledTimes(2);
|
|
432
|
+
expect(global.fetch.mock.calls[0][0]).toBe('https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash-preview:streamGenerateContent?alt=sse');
|
|
433
|
+
expect(global.fetch.mock.calls[1][0]).toBe('https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse');
|
|
434
|
+
expect(spawnGemini).not.toHaveBeenCalled();
|
|
435
|
+
expect(sent.find((entry) => entry.type === 'gemini-error')).toBeUndefined();
|
|
436
|
+
expect(sent).toContainEqual(expect.objectContaining({
|
|
437
|
+
type: 'gemini-status',
|
|
438
|
+
sessionId: 'gemini-direct-model-fallback',
|
|
439
|
+
data: expect.objectContaining({
|
|
440
|
+
status: 'Model busy, retrying with gemini-2.5-flash...',
|
|
441
|
+
}),
|
|
442
|
+
}));
|
|
443
|
+
expect(sent).toContainEqual(expect.objectContaining({
|
|
444
|
+
type: 'gemini-response',
|
|
445
|
+
sessionId: 'gemini-direct-model-fallback',
|
|
446
|
+
data: {
|
|
447
|
+
type: 'content_block_delta',
|
|
448
|
+
index: 0,
|
|
449
|
+
delta: { type: 'text_delta', text: 'Recovered on fallback model.' },
|
|
450
|
+
},
|
|
451
|
+
}));
|
|
452
|
+
expect(sent.at(-1)).toEqual({
|
|
453
|
+
type: 'gemini-complete',
|
|
454
|
+
sessionId: 'gemini-direct-model-fallback',
|
|
455
|
+
exitCode: 0,
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('replays oauth tool turns back to Code Assist with synthetic thought signatures and function responses', async () => {
|
|
460
|
+
getGeminiAuthHeaders.mockResolvedValue({
|
|
461
|
+
headers: { Authorization: 'Bearer tool-loop-token' },
|
|
462
|
+
authMethod: 'oauth',
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
global.fetch = vi.fn()
|
|
466
|
+
.mockResolvedValueOnce({
|
|
467
|
+
ok: true,
|
|
468
|
+
json: async () => ({
|
|
469
|
+
cloudaicompanionProject: 'test-project',
|
|
470
|
+
currentTier: { id: 'standard-tier' },
|
|
471
|
+
}),
|
|
472
|
+
})
|
|
473
|
+
.mockResolvedValueOnce({
|
|
474
|
+
ok: true,
|
|
475
|
+
body: makeSseResponse([
|
|
476
|
+
'data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"list_directory","args":{"path":"server/__tests__"}}}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5,"totalTokenCount":15}}}',
|
|
477
|
+
'',
|
|
478
|
+
]).body,
|
|
479
|
+
})
|
|
480
|
+
.mockResolvedValueOnce({
|
|
481
|
+
ok: true,
|
|
482
|
+
body: makeSseResponse([
|
|
483
|
+
'data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"Tests directory summarized."}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":30,"candidatesTokenCount":5,"totalTokenCount":35}}}',
|
|
484
|
+
'',
|
|
485
|
+
]).body,
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
const { SYNTHETIC_THOUGHT_SIGNATURE, queryGeminiApi } = await geminiApiModulePromise;
|
|
489
|
+
const { sent, writer } = createTestWriter();
|
|
490
|
+
|
|
491
|
+
await queryGeminiApi('Summarize the Gemini tests.', {
|
|
492
|
+
cwd: process.cwd(),
|
|
493
|
+
projectPath: process.cwd(),
|
|
494
|
+
model: 'gemini-2.5-flash-lite',
|
|
495
|
+
sessionId: 'gemini-oauth-tool-loop',
|
|
496
|
+
permissionMode: 'bypassPermissions',
|
|
497
|
+
}, writer);
|
|
498
|
+
|
|
499
|
+
expect(global.fetch).toHaveBeenCalledTimes(3);
|
|
500
|
+
|
|
501
|
+
const secondTurnBody = JSON.parse(global.fetch.mock.calls[2][1].body);
|
|
502
|
+
expect(secondTurnBody).toEqual(expect.objectContaining({
|
|
503
|
+
model: 'gemini-2.5-flash-lite',
|
|
504
|
+
project: 'test-project',
|
|
505
|
+
request: expect.objectContaining({
|
|
506
|
+
session_id: 'gemini-oauth-tool-loop',
|
|
507
|
+
contents: expect.arrayContaining([
|
|
508
|
+
expect.objectContaining({
|
|
509
|
+
role: 'model',
|
|
510
|
+
parts: [{
|
|
511
|
+
functionCall: {
|
|
512
|
+
name: 'list_directory',
|
|
513
|
+
args: { path: 'server/__tests__' },
|
|
514
|
+
},
|
|
515
|
+
thoughtSignature: SYNTHETIC_THOUGHT_SIGNATURE,
|
|
516
|
+
}],
|
|
517
|
+
}),
|
|
518
|
+
expect.objectContaining({
|
|
519
|
+
role: 'user',
|
|
520
|
+
parts: [{
|
|
521
|
+
functionResponse: {
|
|
522
|
+
name: 'list_directory',
|
|
523
|
+
id: expect.any(String),
|
|
524
|
+
response: {
|
|
525
|
+
result: expect.any(String),
|
|
526
|
+
},
|
|
527
|
+
},
|
|
528
|
+
}],
|
|
529
|
+
}),
|
|
530
|
+
]),
|
|
531
|
+
}),
|
|
532
|
+
}));
|
|
533
|
+
|
|
534
|
+
expect(sent).toContainEqual(expect.objectContaining({
|
|
535
|
+
type: 'gemini-response',
|
|
536
|
+
sessionId: 'gemini-oauth-tool-loop',
|
|
537
|
+
data: {
|
|
538
|
+
role: 'assistant',
|
|
539
|
+
content: [{
|
|
540
|
+
type: 'tool_use',
|
|
541
|
+
id: expect.any(String),
|
|
542
|
+
name: 'list_directory',
|
|
543
|
+
input: { path: 'server/__tests__' },
|
|
544
|
+
}],
|
|
545
|
+
},
|
|
546
|
+
}));
|
|
547
|
+
|
|
548
|
+
expect(sent).toContainEqual(expect.objectContaining({
|
|
549
|
+
type: 'gemini-response',
|
|
550
|
+
sessionId: 'gemini-oauth-tool-loop',
|
|
551
|
+
data: {
|
|
552
|
+
role: 'user',
|
|
553
|
+
content: [{
|
|
554
|
+
type: 'tool_result',
|
|
555
|
+
tool_use_id: expect.any(String),
|
|
556
|
+
content: expect.any(String),
|
|
557
|
+
is_error: false,
|
|
558
|
+
}],
|
|
559
|
+
},
|
|
560
|
+
}));
|
|
561
|
+
|
|
562
|
+
expect(sent.at(-1)).toEqual({
|
|
563
|
+
type: 'gemini-complete',
|
|
564
|
+
sessionId: 'gemini-oauth-tool-loop',
|
|
565
|
+
exitCode: 0,
|
|
566
|
+
});
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it('bridges the direct-api session into Gemini CLI storage and falls back to CLI after retry exhaustion', async () => {
|
|
570
|
+
process.env.GEMINI_API_MAX_ATTEMPTS = '2';
|
|
571
|
+
const sessionId = 'gemini-cli-fallback-bridge';
|
|
572
|
+
|
|
573
|
+
await removeGeminiCliConversation(sessionId);
|
|
574
|
+
|
|
575
|
+
getGeminiAuthHeaders.mockResolvedValue({
|
|
576
|
+
headers: { Authorization: 'Bearer exhausted-token' },
|
|
577
|
+
authMethod: 'oauth',
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
global.fetch = vi.fn()
|
|
581
|
+
.mockResolvedValueOnce({
|
|
582
|
+
ok: true,
|
|
583
|
+
json: async () => ({
|
|
584
|
+
cloudaicompanionProject: 'test-project',
|
|
585
|
+
currentTier: { id: 'standard-tier' },
|
|
586
|
+
}),
|
|
587
|
+
})
|
|
588
|
+
.mockResolvedValueOnce({
|
|
589
|
+
ok: true,
|
|
590
|
+
body: makeSseResponse([
|
|
591
|
+
'data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"list_directory","args":{"path":"server"}}}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5,"totalTokenCount":15}}}',
|
|
592
|
+
'',
|
|
593
|
+
]).body,
|
|
594
|
+
})
|
|
595
|
+
.mockResolvedValueOnce({
|
|
596
|
+
ok: false,
|
|
597
|
+
status: 429,
|
|
598
|
+
headers: new Headers(),
|
|
599
|
+
text: async () => 'RESOURCE_EXHAUSTED: No capacity available for model gemini-2.5-flash-lite',
|
|
600
|
+
})
|
|
601
|
+
.mockResolvedValueOnce({
|
|
602
|
+
ok: false,
|
|
603
|
+
status: 429,
|
|
604
|
+
headers: new Headers(),
|
|
605
|
+
text: async () => 'RESOURCE_EXHAUSTED: No capacity available for model gemini-2.5-flash-lite',
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
const { queryGeminiApi } = await geminiApiModulePromise;
|
|
609
|
+
const { sent, writer } = createTestWriter();
|
|
610
|
+
|
|
611
|
+
await queryGeminiApi('Continue inspecting the server folder.', {
|
|
612
|
+
cwd: process.cwd(),
|
|
613
|
+
projectPath: process.cwd(),
|
|
614
|
+
model: 'gemini-2.5-flash-lite',
|
|
615
|
+
sessionId,
|
|
616
|
+
permissionMode: 'bypassPermissions',
|
|
617
|
+
}, writer);
|
|
618
|
+
|
|
619
|
+
expect(global.fetch).toHaveBeenCalledTimes(4);
|
|
620
|
+
expect(sent.find((entry) => entry.type === 'gemini-error')).toBeUndefined();
|
|
621
|
+
expect(spawnGemini).toHaveBeenCalledTimes(1);
|
|
622
|
+
expect(spawnGemini).toHaveBeenCalledWith(
|
|
623
|
+
expect.stringContaining('Continue the current task from the existing session context.'),
|
|
624
|
+
expect.objectContaining({
|
|
625
|
+
cwd: process.cwd(),
|
|
626
|
+
projectPath: process.cwd(),
|
|
627
|
+
model: 'gemini-2.5-flash-lite',
|
|
628
|
+
sessionId,
|
|
629
|
+
}),
|
|
630
|
+
writer,
|
|
631
|
+
);
|
|
632
|
+
|
|
633
|
+
const bridgedConversation = await readGeminiCliConversation(sessionId);
|
|
634
|
+
expect(bridgedConversation).toEqual(expect.objectContaining({
|
|
635
|
+
sessionId,
|
|
636
|
+
kind: 'main',
|
|
637
|
+
messages: expect.arrayContaining([
|
|
638
|
+
expect.objectContaining({
|
|
639
|
+
type: 'user',
|
|
640
|
+
content: 'Continue inspecting the server folder.',
|
|
641
|
+
}),
|
|
642
|
+
expect.objectContaining({
|
|
643
|
+
type: 'gemini',
|
|
644
|
+
toolCalls: [
|
|
645
|
+
expect.objectContaining({
|
|
646
|
+
name: 'list_directory',
|
|
647
|
+
args: { path: 'server' },
|
|
648
|
+
result: expect.any(String),
|
|
649
|
+
}),
|
|
650
|
+
],
|
|
651
|
+
}),
|
|
652
|
+
]),
|
|
653
|
+
}));
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
it('emits a final gemini-error for non-retryable API failures without invoking CLI fallback', async () => {
|
|
657
|
+
getGeminiAuthHeaders.mockResolvedValue({
|
|
658
|
+
headers: { Authorization: 'Bearer bad-request-token' },
|
|
659
|
+
authMethod: 'oauth',
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
global.fetch = vi.fn()
|
|
663
|
+
.mockResolvedValueOnce({
|
|
664
|
+
ok: true,
|
|
665
|
+
json: async () => ({
|
|
666
|
+
cloudaicompanionProject: 'test-project',
|
|
667
|
+
currentTier: { id: 'standard-tier' },
|
|
668
|
+
}),
|
|
669
|
+
})
|
|
670
|
+
.mockResolvedValueOnce({
|
|
671
|
+
ok: false,
|
|
672
|
+
status: 400,
|
|
673
|
+
headers: new Headers(),
|
|
674
|
+
text: async () => JSON.stringify({ error: { message: 'Bad request payload' } }),
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
const { queryGeminiApi } = await geminiApiModulePromise;
|
|
678
|
+
const { sent, writer } = createTestWriter();
|
|
679
|
+
|
|
680
|
+
await queryGeminiApi('This request is malformed.', {
|
|
681
|
+
cwd: '/tmp/project',
|
|
682
|
+
projectPath: '/tmp/project',
|
|
683
|
+
model: 'gemini-2.5-flash-lite',
|
|
684
|
+
sessionId: 'gemini-non-retryable-failure',
|
|
685
|
+
permissionMode: 'bypassPermissions',
|
|
686
|
+
}, writer);
|
|
687
|
+
|
|
688
|
+
expect(global.fetch).toHaveBeenCalledTimes(2);
|
|
689
|
+
expect(spawnGemini).not.toHaveBeenCalled();
|
|
690
|
+
expect(sent.filter((entry) => entry.type === 'gemini-error')).toEqual([
|
|
691
|
+
expect.objectContaining({
|
|
692
|
+
type: 'gemini-error',
|
|
693
|
+
sessionId: 'gemini-non-retryable-failure',
|
|
694
|
+
isRetryable: false,
|
|
695
|
+
error: 'Bad request payload',
|
|
696
|
+
}),
|
|
697
|
+
]);
|
|
698
|
+
});
|
|
699
|
+
});
|