@koreanhole/opencodex 2.96.0 → 2.98.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { spawn as nodeSpawn } from "node:child_process";
2
- import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
2
+ import { link, lstat, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises";
3
3
  import { homedir, tmpdir } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
5
  import * as acp from "@agentclientprotocol/sdk";
@@ -8,6 +8,7 @@ import { commandInvocation } from "../../../lib/win-exec";
8
8
  import { baseScopedEnv } from "../env";
9
9
  import type { CliAgentStreamState, CliAgentTurnDeps } from "../types";
10
10
  import { mapToolIntent, ToolIntentError } from "../tool-intent";
11
+ import { recordGeminiInitialize, recordGeminiUsage } from "./runtime";
11
12
  import {
12
13
  AcpClient,
13
14
  AcpError,
@@ -196,6 +197,7 @@ export function buildGeminiAcpEnv(sourceEnv: NodeJS.ProcessEnv = process.env): R
196
197
 
197
198
  export interface GeminiAcpWorkspace {
198
199
  cwd: string;
200
+ geminiHome: string;
199
201
  policyPath: string;
200
202
  settingsPath: string;
201
203
  catalogPath: string;
@@ -203,24 +205,63 @@ export interface GeminiAcpWorkspace {
203
205
  cleanup: () => Promise<void>;
204
206
  }
205
207
 
208
+ export interface GeminiReasoningSelection {
209
+ modelId: string;
210
+ thinkingConfig: {
211
+ thinkingLevel?: string;
212
+ thinkingBudget?: number;
213
+ includeThoughts?: boolean;
214
+ };
215
+ }
216
+
217
+ function geminiSystemSettings(reasoning?: GeminiReasoningSelection): string {
218
+ const settings = JSON.parse(GEMINI_SYSTEM_SETTINGS) as Record<string, unknown>;
219
+ if (reasoning) {
220
+ settings.modelConfigs = {
221
+ customOverrides: [{
222
+ match: { model: reasoning.modelId },
223
+ modelConfig: {
224
+ generateContentConfig: {
225
+ thinkingConfig: reasoning.thinkingConfig,
226
+ },
227
+ },
228
+ }],
229
+ };
230
+ }
231
+ return `${JSON.stringify(settings, null, 2)}\n`;
232
+ }
233
+
206
234
  export async function stageGeminiAcpWorkspace(
207
235
  catalog: readonly Record<string, unknown>[] = [],
236
+ reasoning?: GeminiReasoningSelection,
237
+ options: {
238
+ root?: string;
239
+ sourceEnv?: NodeJS.ProcessEnv;
240
+ } = {},
208
241
  ): Promise<GeminiAcpWorkspace> {
209
- const cwd = await mkdtemp(join(tmpdir(), "ocx-gemini-acp-"));
242
+ const cwd = options.root ?? await mkdtemp(join(tmpdir(), "ocx-gemini-acp-"));
243
+ await mkdir(cwd, { recursive: true, mode: 0o700 });
244
+ const geminiHome = join(cwd, "gemini-home");
210
245
  const policyPath = join(cwd, "policy.toml");
211
246
  const settingsPath = join(cwd, "system-settings.json");
212
247
  const catalogPath = join(cwd, "tool-catalog.json");
213
248
  const capturePath = join(cwd, "tool-intents.ndjson");
249
+ const geminiConfigDir = join(geminiHome, ".gemini");
250
+ const exclusive = options.root === undefined;
251
+ const writeOptions = { encoding: "utf8" as const, mode: 0o600, ...(exclusive ? { flag: "wx" as const } : {}) };
214
252
  await Promise.all([
215
- writeFile(policyPath, GEMINI_POLICY, { encoding: "utf8", mode: 0o600, flag: "wx" }),
216
- writeFile(settingsPath, GEMINI_SYSTEM_SETTINGS, { encoding: "utf8", mode: 0o600, flag: "wx" }),
217
- writeFile(catalogPath, JSON.stringify(catalog), { encoding: "utf8", mode: 0o600, flag: "wx" }),
253
+ mkdir(geminiConfigDir, { recursive: true, mode: 0o700 }),
254
+ writeFile(policyPath, GEMINI_POLICY, writeOptions),
255
+ writeFile(settingsPath, geminiSystemSettings(reasoning), writeOptions),
256
+ writeFile(catalogPath, JSON.stringify(catalog), writeOptions),
218
257
  ...(catalog.length > 0
219
- ? [writeFile(capturePath, "", { encoding: "utf8", mode: 0o600, flag: "wx" })]
258
+ ? [writeFile(capturePath, "", writeOptions)]
220
259
  : []),
221
260
  ]);
261
+ await linkGeminiAuth(geminiConfigDir, options.sourceEnv ?? process.env);
222
262
  return {
223
263
  cwd,
264
+ geminiHome,
224
265
  policyPath,
225
266
  settingsPath,
226
267
  catalogPath,
@@ -229,6 +270,30 @@ export async function stageGeminiAcpWorkspace(
229
270
  };
230
271
  }
231
272
 
273
+ async function linkGeminiAuth(targetDir: string, sourceEnv: NodeJS.ProcessEnv): Promise<void> {
274
+ const realHome = nonEmpty(sourceEnv.HOME) ?? nonEmpty(sourceEnv.USERPROFILE) ?? homedir();
275
+ const realGemini = join(realHome, ".gemini");
276
+ const names = ["oauth_creds.json", "google_accounts.json", ".env", "settings.json", "trustedFolders.json"];
277
+ await Promise.all(names.map(async name => {
278
+ const target = join(realGemini, name);
279
+ try {
280
+ await lstat(target);
281
+ } catch {
282
+ return;
283
+ }
284
+ const path = join(targetDir, name);
285
+ try {
286
+ await symlink(resolve(target), path);
287
+ } catch {
288
+ try {
289
+ await link(resolve(target), path);
290
+ } catch {
291
+ // Best-effort; environment-provided auth remains the primary contract.
292
+ }
293
+ }
294
+ }));
295
+ }
296
+
232
297
  export function geminiAcpArgs(policyPath: string): string[] {
233
298
  return [
234
299
  "--acp",
@@ -247,16 +312,25 @@ export function geminiAcpArgs(policyPath: string): string[] {
247
312
  export interface GeminiAcpTurnInput {
248
313
  binary: string;
249
314
  env: Record<string, string>;
315
+ runtimeEnv?: NodeJS.ProcessEnv;
250
316
  cwd: string;
251
317
  args: readonly string[];
252
318
  modelId: string;
319
+ fingerprint?: string;
253
320
  prompt: string;
321
+ deltaPrompt?: string;
322
+ resumeSessionId?: string;
323
+ sessionKeySource?: string;
324
+ hadPreviousResponseId?: boolean;
325
+ images?: readonly { mimeType: string; data: string }[];
254
326
  toolNames: ReadonlyMap<string, string>;
255
327
  mcpServers: readonly Record<string, unknown>[];
256
328
  capturePath?: string;
257
329
  maxToolCalls: number;
258
330
  requireToolCall: boolean;
259
331
  incoming: IncomingMeta;
332
+ onVendorSession?: (sessionId: string) => void;
333
+ onResumeFailed?: () => void;
260
334
  deps?: GeminiAcpDeps;
261
335
  }
262
336
 
@@ -353,6 +427,7 @@ export async function runGeminiAcpTurn(
353
427
  emit: (event: AdapterEvent) => void,
354
428
  ): Promise<void> {
355
429
  const deps = input.deps ?? {};
430
+ const fingerprint = input.fingerprint ?? "unknown";
356
431
  const state = createGeminiStreamState();
357
432
  let client: AcpClient | undefined;
358
433
  let session: GeminiAcpSessionInfo | undefined;
@@ -364,6 +439,10 @@ export async function runGeminiAcpTurn(
364
439
  let captureResolve: ((records: unknown[]) => void) | undefined;
365
440
  let captureReject: ((error: unknown) => void) | undefined;
366
441
  let resolveAbort: (() => void) | undefined;
442
+ let loadingSession = false;
443
+ let resumed = false;
444
+ let sentPromptChars = input.prompt.length;
445
+ const seenToolCalls = new Set<string>();
367
446
 
368
447
  const deliver = (event: AdapterEvent): void => {
369
448
  if (event.type === "done" || event.type === "error" || event.type === "incomplete") {
@@ -380,8 +459,39 @@ export async function runGeminiAcpTurn(
380
459
  })
381
460
  : new Promise<never>(() => undefined);
382
461
 
462
+ const captureTool = (toolCall: Record<string, unknown>): void => {
463
+ const id = nonEmpty(toolCall.toolCallId);
464
+ if (id && seenToolCalls.has(id)) return;
465
+ if (id) seenToolCalls.add(id);
466
+ const name = nonEmpty(toolCall.name);
467
+ if (!name) return;
468
+ try {
469
+ const events = mapToolIntent("Gemini", name, id, toolCall.rawInput, state, {
470
+ toolNames: input.toolNames,
471
+ maxToolCalls: input.maxToolCalls,
472
+ });
473
+ for (const event of events) deliver(event);
474
+ if (pollTimer) {
475
+ clearInterval(pollTimer);
476
+ pollTimer = undefined;
477
+ }
478
+ if (session) void client?.cancel(session.sessionId).catch(() => undefined);
479
+ } catch (error) {
480
+ deliver({
481
+ type: "error",
482
+ message: safeAcpMessage(error, "Gemini returned an invalid tool call."),
483
+ status: 502,
484
+ errorType: "upstream_error",
485
+ code: "cli_protocol_error",
486
+ retryable: false,
487
+ });
488
+ if (session) void client?.cancel(session.sessionId).catch(() => undefined);
489
+ }
490
+ };
491
+
383
492
  const hooks: GeminiAcpHooks = {
384
493
  onSessionUpdate: notification => {
494
+ if (loadingSession) return;
385
495
  const update = notification.update;
386
496
  if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text" && update.content.text) {
387
497
  state.sawText = true;
@@ -389,9 +499,24 @@ export async function runGeminiAcpTurn(
389
499
  } else if (update.sessionUpdate === "agent_thought_chunk" && update.content.type === "text" && update.content.text) {
390
500
  state.sawThinking = true;
391
501
  deliver({ type: "thinking_delta", thinking: update.content.text });
502
+ } else if (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") {
503
+ captureTool(update as unknown as Record<string, unknown>);
504
+ } else if (update.sessionUpdate === "usage_update") {
505
+ if (typeof update.used === "number" && typeof update.size === "number") {
506
+ void recordGeminiUsage(
507
+ fingerprint,
508
+ input.modelId,
509
+ update.used,
510
+ update.size,
511
+ sentPromptChars,
512
+ input.runtimeEnv ?? process.env,
513
+ );
514
+ }
392
515
  }
393
516
  },
394
- onPermission: () => {
517
+ onPermission: params => {
518
+ const toolCall = asRecord(params.toolCall);
519
+ if (toolCall) captureTool(toolCall);
395
520
  if (session) void client?.cancel(session.sessionId).catch(() => undefined);
396
521
  },
397
522
  };
@@ -412,8 +537,34 @@ export async function runGeminiAcpTurn(
412
537
  label: "Gemini ACP",
413
538
  fallbackAllowed: false,
414
539
  }, deps);
415
- session = await client.newSession({ cwd: input.cwd, mcpServers: input.mcpServers });
540
+ if (client.initializeResult) {
541
+ await recordGeminiInitialize(
542
+ fingerprint,
543
+ client.initializeResult,
544
+ input.runtimeEnv ?? process.env,
545
+ );
546
+ }
547
+ loadingSession = Boolean(input.resumeSessionId && client.initializeResult?.capabilities.loadSession);
548
+ if (loadingSession) {
549
+ try {
550
+ session = await client.loadSession({
551
+ sessionId: input.resumeSessionId!,
552
+ cwd: input.cwd,
553
+ mcpServers: input.mcpServers,
554
+ });
555
+ resumed = true;
556
+ } catch {
557
+ input.onResumeFailed?.();
558
+ session = undefined;
559
+ } finally {
560
+ loadingSession = false;
561
+ }
562
+ }
563
+ if (!session) session = await client.newSession({ cwd: input.cwd, mcpServers: input.mcpServers });
416
564
  await applyGeminiModel(client, session, input.modelId);
565
+ input.onVendorSession?.(session.sessionId);
566
+ const promptText = resumed && input.deltaPrompt ? input.deltaPrompt : input.prompt;
567
+ sentPromptChars = promptText.length;
417
568
 
418
569
  if (input.incoming.abortSignal?.aborted) {
419
570
  deliver({
@@ -456,7 +607,15 @@ export async function runGeminiAcpTurn(
456
607
  }, CAPTURE_POLL_MS);
457
608
  }
458
609
 
459
- const promptOutcome = client.prompt(session.sessionId, input.prompt, input.incoming.abortSignal)
610
+ const promptContent: acp.ContentBlock[] = [
611
+ { type: "text", text: promptText },
612
+ ...(input.images ?? []).map(image => ({
613
+ type: "image" as const,
614
+ data: image.data,
615
+ mimeType: image.mimeType,
616
+ })),
617
+ ];
618
+ const promptOutcome = client.prompt(session.sessionId, promptContent, input.incoming.abortSignal)
460
619
  .then(response => ({ kind: "prompt" as const, response }))
461
620
  .catch(error => ({ kind: "error" as const, error }));
462
621
  const captureOutcome = capturePromise
@@ -1,12 +1,17 @@
1
1
  import { fileURLToPath } from "node:url";
2
2
  import type { AdapterEvent, IncomingMeta, OcxParsedRequest } from "../../../types";
3
3
  import { buildCodingAgentToolCatalog, CodingAgentToolCatalogError } from "../../tool-catalog";
4
- import { buildConversationInput, buildSystemPrompt } from "../protocol";
4
+ import { buildConversationDelta, buildConversationInput, buildSystemPrompt } from "../protocol";
5
5
  import { whichFromPath, type WhichFn } from "../../coding-agent/profile";
6
6
  import { isStandaloneBinary } from "../../../lib/standalone";
7
7
  import type { CliAgentTurnDeps } from "../types";
8
8
  import { ToolIntentError } from "../tool-intent";
9
- import { resolveGeminiBinary } from "./binary";
9
+ import { geminiBinaryFingerprintSync, resolveGeminiBinary } from "./binary";
10
+ import {
11
+ discoverGeminiModelMetadata,
12
+ reasoningLevelForEffort,
13
+ type GeminiMetadataResult,
14
+ } from "./model-metadata";
10
15
  import {
11
16
  GeminiAcpError,
12
17
  buildGeminiAcpEnv,
@@ -17,6 +22,18 @@ import {
17
22
  type GeminiAcpDeps,
18
23
  type GeminiAcpTurnInput,
19
24
  } from "./acp";
25
+ import { getGeminiRuntime } from "./runtime";
26
+ import {
27
+ deleteGeminiSession,
28
+ findGeminiSessionByKeyHash,
29
+ findGeminiSessionByResponseId,
30
+ geminiSessionWorkspace,
31
+ hashConversationKey,
32
+ hashMessages,
33
+ reclaimGeminiSessions,
34
+ saveGeminiSession,
35
+ type GeminiSessionRecord,
36
+ } from "./session-store";
20
37
 
21
38
  export const GEMINI_MCP_SERVER_NAME = "opencodex";
22
39
  const GEMINI_MCP_SERVER_PATH = fileURLToPath(new URL("../mcp-server.ts", import.meta.url));
@@ -35,6 +52,7 @@ export interface GeminiTurnDeps extends CliAgentTurnDeps {
35
52
  env?: NodeJS.ProcessEnv;
36
53
  probeAcp?: typeof probeGeminiAcpSupport;
37
54
  stageAcp?: typeof stageGeminiAcpWorkspace;
55
+ metadata?: typeof discoverGeminiModelMetadata;
38
56
  runAcp?: (input: GeminiAcpTurnInput, emit: (event: AdapterEvent) => void, deps?: GeminiAcpDeps) => Promise<void>;
39
57
  acpDeps?: GeminiAcpDeps;
40
58
  }
@@ -42,12 +60,43 @@ export interface GeminiTurnDeps extends CliAgentTurnDeps {
42
60
  function hasUnsupportedMedia(parsed: OcxParsedRequest): boolean {
43
61
  return parsed.context.messages.some(message =>
44
62
  Array.isArray(message.content)
45
- && message.content.some(part => part.type === "image" || part.type === "video" || part.type === "audio"),
63
+ && message.content.some(part => part.type === "video" || part.type === "audio"),
46
64
  );
47
65
  }
48
66
 
49
- function conversationText(parsed: OcxParsedRequest, vendorNameByWire: ReadonlyMap<string, string>): string {
50
- const first = buildConversationInput(parsed, { vendorNameByWire })[0];
67
+ interface CollectedImage {
68
+ mimeType: string;
69
+ data: string;
70
+ }
71
+
72
+ function collectImages(parsed: OcxParsedRequest, env: NodeJS.ProcessEnv): { images: CollectedImage[]; error?: string } {
73
+ const images: CollectedImage[] = [];
74
+ const maxBytes = Number(env.OPENCODEX_MAX_IMAGE_BYTES ?? "") || 4 * 1024 * 1024;
75
+ const allowed = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
76
+ for (const message of parsed.context.messages) {
77
+ if (!Array.isArray(message.content)) continue;
78
+ for (const part of message.content) {
79
+ if (part.type !== "image") continue;
80
+ const match = part.imageUrl?.match(/^data:([^;,]+);base64,([A-Za-z0-9+/=\s]+)$/);
81
+ if (!match || !allowed.has(match[1]!) || !match[1]!.startsWith("image/")) {
82
+ return { images: [], error: "Image input must be a supported base64 data URL." };
83
+ }
84
+ const data = match[2]!.replaceAll(/\s+/g, "");
85
+ if (Math.floor(data.length * 3 / 4) > maxBytes) {
86
+ return { images: [], error: "Image input exceeds the configured byte limit." };
87
+ }
88
+ images.push({ mimeType: match[1]!, data });
89
+ }
90
+ }
91
+ return { images };
92
+ }
93
+
94
+ function conversationText(
95
+ parsed: OcxParsedRequest,
96
+ vendorNameByWire: ReadonlyMap<string, string>,
97
+ maxChars: number,
98
+ ): string {
99
+ const first = buildConversationInput(parsed, { vendorNameByWire, maxChars })[0];
51
100
  if (!first) return "";
52
101
  try {
53
102
  const frame = JSON.parse(first) as { message?: { content?: Array<{ type?: string; text?: string }> } };
@@ -64,16 +113,26 @@ export function buildGeminiPrompt(
64
113
  parsed: OcxParsedRequest,
65
114
  vendorNameByWire: ReadonlyMap<string, string>,
66
115
  fixedSystem = GEMINI_SYSTEM_PROMPT,
116
+ maxChars?: number,
67
117
  ): string {
68
118
  return [
69
119
  "SYSTEM AND DEVELOPER INSTRUCTIONS:",
70
120
  fixedSystem,
71
121
  buildSystemPrompt(parsed),
72
122
  "CONVERSATION REPLAY:",
73
- conversationText(parsed, vendorNameByWire),
123
+ conversationText(parsed, vendorNameByWire, maxChars ?? Number.POSITIVE_INFINITY),
74
124
  ].filter(Boolean).join("\n\n");
75
125
  }
76
126
 
127
+ function buildGeminiDeltaPrompt(
128
+ parsed: OcxParsedRequest,
129
+ startIndex: number,
130
+ vendorNameByWire: ReadonlyMap<string, string>,
131
+ ): string | undefined {
132
+ const text = buildConversationDelta(parsed, startIndex, { vendorNameByWire });
133
+ return text.trim() ? text : undefined;
134
+ }
135
+
77
136
  function toolNameMap(catalog: ReturnType<typeof buildCodingAgentToolCatalog>): Map<string, string> {
78
137
  const names = new Map<string, string>();
79
138
  for (const [vendorName, wireName] of catalog.emittedNameMap) {
@@ -127,6 +186,31 @@ function failure(error: unknown): Extract<AdapterEvent, { type: "error" }> {
127
186
  };
128
187
  }
129
188
 
189
+ async function resolveSession(
190
+ parsed: OcxParsedRequest,
191
+ incoming: IncomingMeta,
192
+ env: NodeJS.ProcessEnv,
193
+ ): Promise<{ keyHash?: string; prior?: GeminiSessionRecord; source?: string }> {
194
+ if (parsed.previousResponseId) {
195
+ const prior = await findGeminiSessionByResponseId(parsed.previousResponseId, env);
196
+ if (prior) return { keyHash: prior.keyHash, prior, source: "previous_response_id" };
197
+ }
198
+ const cacheKey = parsed.promptCacheKey ?? incoming.headers.get("x-opencodex-conversation") ?? undefined;
199
+ if (cacheKey) {
200
+ const keyHash = hashConversationKey(`key:${cacheKey}`);
201
+ return {
202
+ keyHash,
203
+ prior: await findGeminiSessionByKeyHash(keyHash, env),
204
+ source: parsed.promptCacheKey ? "prompt_cache_key" : "header",
205
+ };
206
+ }
207
+ // Codex may send only previous_response_id on later turns. Seed the first
208
+ // session under the response id so the next turn has a stable mapping.
209
+ return parsed.responseId
210
+ ? { keyHash: hashConversationKey(`response:${parsed.responseId}`), source: "response_id" }
211
+ : {};
212
+ }
213
+
130
214
  export async function runGeminiTurn(
131
215
  parsed: OcxParsedRequest,
132
216
  incoming: IncomingMeta,
@@ -138,11 +222,6 @@ export async function runGeminiTurn(
138
222
  emit({ type: "error", message: "Gemini CLI requires a model selector.", status: 400, errorType: "invalid_request_error", code: "model_not_found", retryable: false });
139
223
  return;
140
224
  }
141
- if (hasUnsupportedMedia(parsed)) {
142
- emit({ type: "error", message: "Gemini CLI provider is text-only; image, video, and audio input are not enabled.", status: 400, errorType: "invalid_request_error", code: "unsupported_input_modality", retryable: false });
143
- return;
144
- }
145
-
146
225
  let catalog: ReturnType<typeof buildCodingAgentToolCatalog>;
147
226
  try {
148
227
  catalog = buildCodingAgentToolCatalog(parsed, GEMINI_MCP_SERVER_NAME);
@@ -153,12 +232,51 @@ export async function runGeminiTurn(
153
232
  }
154
233
 
155
234
  const sourceEnv = deps.env ?? process.env;
235
+ await reclaimGeminiSessions(sourceEnv).catch(() => undefined);
156
236
  const binary = resolveGeminiBinary(deps.which ?? whichFromPath, sourceEnv);
157
237
  if (!binary) {
158
238
  emit({ type: "error", message: "Gemini CLI not found on PATH. Install it and verify `gemini --help`, or set GEMINI_BIN_PATH.", status: 500, errorType: "upstream_error", code: "cli_not_found", retryable: false });
159
239
  return;
160
240
  }
161
241
 
242
+ const fingerprint = geminiBinaryFingerprintSync(binary);
243
+ let metadata: GeminiMetadataResult | undefined;
244
+ let reasoning;
245
+ try {
246
+ metadata = await (deps.metadata ?? discoverGeminiModelMetadata)({ binary, env: sourceEnv });
247
+ const capability = metadata.capabilities[requestedModel];
248
+ reasoning = reasoningLevelForEffort(capability, parsed.options.reasoning);
249
+ if (capability?.thinkingSupported && parsed.options.reasoning && !reasoning) {
250
+ emit({
251
+ type: "error",
252
+ message: `Gemini model "${requestedModel}" does not support reasoning effort "${parsed.options.reasoning}". Advertised levels: ${capability.reasoningLevels.map(level => level.effort).join(", ") || "none"}.`,
253
+ status: 400,
254
+ errorType: "invalid_request_error",
255
+ code: "reasoning_effort_unsupported",
256
+ retryable: false,
257
+ });
258
+ return;
259
+ }
260
+ } catch {
261
+ metadata = undefined;
262
+ reasoning = undefined;
263
+ }
264
+
265
+ const runtime = await getGeminiRuntime(fingerprint, sourceEnv);
266
+ const media = collectImages(parsed, sourceEnv);
267
+ if (media.error) {
268
+ emit({ type: "error", message: media.error, status: 400, errorType: "invalid_request_error", code: "unsupported_input_modality", retryable: false });
269
+ return;
270
+ }
271
+ if (hasUnsupportedMedia(parsed)) {
272
+ emit({ type: "error", message: "Gemini CLI provider does not support video or audio input.", status: 400, errorType: "invalid_request_error", code: "unsupported_input_modality", retryable: false });
273
+ return;
274
+ }
275
+ if (media.images.length > 0 && !runtime.prompt.image) {
276
+ emit({ type: "error", message: "The installed Gemini CLI does not advertise image input for this model.", status: 400, errorType: "invalid_request_error", code: "unsupported_input_modality", retryable: false });
277
+ return;
278
+ }
279
+
162
280
  const acpDeps: GeminiAcpDeps = {
163
281
  ...deps.acpDeps,
164
282
  env: sourceEnv,
@@ -183,9 +301,58 @@ export async function runGeminiTurn(
183
301
 
184
302
  const vendorNames = toolNameMap(catalog);
185
303
  const vendorNameByWire = new Map([...catalog.emittedNameMap.values()].map(wireName => [wireName, wireName]));
186
- const workspace = await (deps.stageAcp ?? stageGeminiAcpWorkspace)(catalog.tools);
304
+ const observed = runtime.models[requestedModel];
305
+ const contextWindow = observed?.contextSize
306
+ ?? metadata?.tokenLimits[requestedModel]
307
+ ?? metadata?.defaultTokenLimit;
308
+ const threshold = runtime.compressionThreshold ?? metadata?.compressionThreshold;
309
+ const charsPerToken = observed?.charsPerToken;
310
+ const maxChars = contextWindow !== undefined && threshold !== undefined && charsPerToken !== undefined
311
+ ? Math.max(1, Math.floor(contextWindow * threshold * charsPerToken))
312
+ : undefined;
313
+ const system = systemPromptWithToolNames(catalog);
314
+ const fullPrompt = buildGeminiPrompt(parsed, vendorNameByWire, system, maxChars);
315
+ const sessionMessages = parsed.context.messages.filter(message => message.role !== "developer");
316
+ const resolved = await resolveSession(parsed, incoming, sourceEnv);
317
+ const sessionKey = resolved.prior?.keyHash ?? resolved.keyHash;
318
+ let prior = resolved.prior;
319
+ let reuse = false;
320
+ let deltaPrompt: string | undefined;
321
+ if (prior && prior.fingerprint === fingerprint && prior.modelId === requestedModel) {
322
+ const prefix = sessionMessages.slice(0, prior.historyCount);
323
+ if (prefix.length === prior.historyCount && hashMessages(prefix) === prior.historyHash) {
324
+ let deltaStart = prior.historyCount;
325
+ while (deltaStart < sessionMessages.length && sessionMessages[deltaStart]!.role === "assistant") deltaStart++;
326
+ if (deltaStart < sessionMessages.length) {
327
+ deltaPrompt = buildGeminiDeltaPrompt(parsed, deltaStart, vendorNameByWire);
328
+ reuse = Boolean(deltaPrompt);
329
+ }
330
+ } else if (parsed.previousResponseId && sessionMessages.length > 0) {
331
+ // A previous_response_id client sends only the new turn, not the replay.
332
+ deltaPrompt = buildGeminiDeltaPrompt(parsed, 0, vendorNameByWire);
333
+ reuse = Boolean(deltaPrompt);
334
+ }
335
+ }
336
+ if (prior && !reuse) {
337
+ await deleteGeminiSession(prior.keyHash, sourceEnv).catch(() => undefined);
338
+ prior = undefined;
339
+ }
340
+ const workspace = await (deps.stageAcp ?? stageGeminiAcpWorkspace)(
341
+ catalog.tools,
342
+ reasoning ? { modelId: requestedModel, thinkingConfig: reasoning.thinkingConfig } : undefined,
343
+ {
344
+ ...(reuse && prior ? { root: prior.workspace } : sessionKey ? { root: geminiSessionWorkspace(sessionKey, sourceEnv) } : {}),
345
+ sourceEnv,
346
+ },
347
+ );
348
+ let vendorSessionId: string | undefined;
349
+ let terminal: AdapterEvent | undefined;
187
350
  try {
188
- const turnEnv = { ...acpEnv, GEMINI_CLI_SYSTEM_SETTINGS_PATH: workspace.settingsPath };
351
+ const turnEnv = {
352
+ ...acpEnv,
353
+ GEMINI_CLI_HOME: workspace.geminiHome,
354
+ GEMINI_CLI_SYSTEM_SETTINGS_PATH: workspace.settingsPath,
355
+ };
189
356
  const mcpServers = workspace.capturePath && catalog.emittedNameMap.size > 0
190
357
  ? [{
191
358
  type: "stdio",
@@ -200,21 +367,48 @@ export async function runGeminiTurn(
200
367
  await (deps.runAcp ?? runGeminiAcpTurn)({
201
368
  binary,
202
369
  env: turnEnv,
370
+ runtimeEnv: sourceEnv,
203
371
  cwd: workspace.cwd,
204
372
  args: geminiAcpArgs(workspace.policyPath),
205
373
  modelId: requestedModel,
206
- prompt: buildGeminiPrompt(parsed, vendorNameByWire, systemPromptWithToolNames(catalog)),
374
+ fingerprint,
375
+ prompt: fullPrompt,
376
+ ...(reuse && prior && deltaPrompt ? { deltaPrompt, resumeSessionId: prior.vendorSessionId } : {}),
377
+ ...(resolved.source ? { sessionKeySource: resolved.source } : {}),
378
+ hadPreviousResponseId: Boolean(parsed.previousResponseId),
379
+ ...(media.images.length > 0 ? { images: media.images } : {}),
207
380
  toolNames: vendorNames,
208
381
  mcpServers,
209
382
  ...(workspace.capturePath ? { capturePath: workspace.capturePath } : {}),
210
383
  maxToolCalls: 16,
211
384
  requireToolCall: catalog.requireToolCall,
212
385
  incoming,
386
+ onVendorSession: sessionId => { vendorSessionId = sessionId; },
213
387
  deps: acpDeps,
214
- }, emit, acpDeps);
388
+ }, event => {
389
+ if ((event.type === "done" || event.type === "error" || event.type === "incomplete") && !terminal) terminal = event;
390
+ emit(event);
391
+ }, acpDeps);
392
+ const reusable = terminal?.type === "done" && terminal.stopReason !== "tool_use";
393
+ if (sessionKey && vendorSessionId && reusable) {
394
+ await saveGeminiSession({
395
+ keyHash: sessionKey,
396
+ vendorSessionId,
397
+ fingerprint,
398
+ modelId: requestedModel,
399
+ workspace: workspace.cwd,
400
+ historyCount: sessionMessages.length,
401
+ historyHash: hashMessages(sessionMessages),
402
+ lastUsed: Date.now(),
403
+ turnCount: (resolved.prior?.turnCount ?? 0) + 1,
404
+ }, parsed.responseId, sourceEnv);
405
+ } else {
406
+ if (sessionKey) await deleteGeminiSession(sessionKey, sourceEnv).catch(() => undefined);
407
+ await workspace.cleanup();
408
+ }
215
409
  } catch (error) {
216
- emit(failure(error));
217
- } finally {
410
+ if (!terminal) emit(failure(error));
411
+ if (sessionKey) await deleteGeminiSession(sessionKey, sourceEnv).catch(() => undefined);
218
412
  await workspace.cleanup();
219
413
  }
220
414
  }