@koreanhole/opencodex 2.97.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;
@@ -232,22 +234,34 @@ function geminiSystemSettings(reasoning?: GeminiReasoningSelection): string {
232
234
  export async function stageGeminiAcpWorkspace(
233
235
  catalog: readonly Record<string, unknown>[] = [],
234
236
  reasoning?: GeminiReasoningSelection,
237
+ options: {
238
+ root?: string;
239
+ sourceEnv?: NodeJS.ProcessEnv;
240
+ } = {},
235
241
  ): Promise<GeminiAcpWorkspace> {
236
- 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");
237
245
  const policyPath = join(cwd, "policy.toml");
238
246
  const settingsPath = join(cwd, "system-settings.json");
239
247
  const catalogPath = join(cwd, "tool-catalog.json");
240
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 } : {}) };
241
252
  await Promise.all([
242
- writeFile(policyPath, GEMINI_POLICY, { encoding: "utf8", mode: 0o600, flag: "wx" }),
243
- writeFile(settingsPath, geminiSystemSettings(reasoning), { encoding: "utf8", mode: 0o600, flag: "wx" }),
244
- 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),
245
257
  ...(catalog.length > 0
246
- ? [writeFile(capturePath, "", { encoding: "utf8", mode: 0o600, flag: "wx" })]
258
+ ? [writeFile(capturePath, "", writeOptions)]
247
259
  : []),
248
260
  ]);
261
+ await linkGeminiAuth(geminiConfigDir, options.sourceEnv ?? process.env);
249
262
  return {
250
263
  cwd,
264
+ geminiHome,
251
265
  policyPath,
252
266
  settingsPath,
253
267
  catalogPath,
@@ -256,6 +270,30 @@ export async function stageGeminiAcpWorkspace(
256
270
  };
257
271
  }
258
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
+
259
297
  export function geminiAcpArgs(policyPath: string): string[] {
260
298
  return [
261
299
  "--acp",
@@ -274,16 +312,25 @@ export function geminiAcpArgs(policyPath: string): string[] {
274
312
  export interface GeminiAcpTurnInput {
275
313
  binary: string;
276
314
  env: Record<string, string>;
315
+ runtimeEnv?: NodeJS.ProcessEnv;
277
316
  cwd: string;
278
317
  args: readonly string[];
279
318
  modelId: string;
319
+ fingerprint?: string;
280
320
  prompt: string;
321
+ deltaPrompt?: string;
322
+ resumeSessionId?: string;
323
+ sessionKeySource?: string;
324
+ hadPreviousResponseId?: boolean;
325
+ images?: readonly { mimeType: string; data: string }[];
281
326
  toolNames: ReadonlyMap<string, string>;
282
327
  mcpServers: readonly Record<string, unknown>[];
283
328
  capturePath?: string;
284
329
  maxToolCalls: number;
285
330
  requireToolCall: boolean;
286
331
  incoming: IncomingMeta;
332
+ onVendorSession?: (sessionId: string) => void;
333
+ onResumeFailed?: () => void;
287
334
  deps?: GeminiAcpDeps;
288
335
  }
289
336
 
@@ -380,6 +427,7 @@ export async function runGeminiAcpTurn(
380
427
  emit: (event: AdapterEvent) => void,
381
428
  ): Promise<void> {
382
429
  const deps = input.deps ?? {};
430
+ const fingerprint = input.fingerprint ?? "unknown";
383
431
  const state = createGeminiStreamState();
384
432
  let client: AcpClient | undefined;
385
433
  let session: GeminiAcpSessionInfo | undefined;
@@ -391,6 +439,10 @@ export async function runGeminiAcpTurn(
391
439
  let captureResolve: ((records: unknown[]) => void) | undefined;
392
440
  let captureReject: ((error: unknown) => void) | undefined;
393
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>();
394
446
 
395
447
  const deliver = (event: AdapterEvent): void => {
396
448
  if (event.type === "done" || event.type === "error" || event.type === "incomplete") {
@@ -407,8 +459,39 @@ export async function runGeminiAcpTurn(
407
459
  })
408
460
  : new Promise<never>(() => undefined);
409
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
+
410
492
  const hooks: GeminiAcpHooks = {
411
493
  onSessionUpdate: notification => {
494
+ if (loadingSession) return;
412
495
  const update = notification.update;
413
496
  if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text" && update.content.text) {
414
497
  state.sawText = true;
@@ -416,9 +499,24 @@ export async function runGeminiAcpTurn(
416
499
  } else if (update.sessionUpdate === "agent_thought_chunk" && update.content.type === "text" && update.content.text) {
417
500
  state.sawThinking = true;
418
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
+ }
419
515
  }
420
516
  },
421
- onPermission: () => {
517
+ onPermission: params => {
518
+ const toolCall = asRecord(params.toolCall);
519
+ if (toolCall) captureTool(toolCall);
422
520
  if (session) void client?.cancel(session.sessionId).catch(() => undefined);
423
521
  },
424
522
  };
@@ -439,8 +537,34 @@ export async function runGeminiAcpTurn(
439
537
  label: "Gemini ACP",
440
538
  fallbackAllowed: false,
441
539
  }, deps);
442
- 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 });
443
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;
444
568
 
445
569
  if (input.incoming.abortSignal?.aborted) {
446
570
  deliver({
@@ -483,7 +607,15 @@ export async function runGeminiAcpTurn(
483
607
  }, CAPTURE_POLL_MS);
484
608
  }
485
609
 
486
- 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)
487
619
  .then(response => ({ kind: "prompt" as const, response }))
488
620
  .catch(error => ({ kind: "error" as const, error }));
489
621
  const captureOutcome = capturePromise
@@ -1,12 +1,12 @@
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
10
  import {
11
11
  discoverGeminiModelMetadata,
12
12
  reasoningLevelForEffort,
@@ -22,6 +22,18 @@ import {
22
22
  type GeminiAcpDeps,
23
23
  type GeminiAcpTurnInput,
24
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";
25
37
 
26
38
  export const GEMINI_MCP_SERVER_NAME = "opencodex";
27
39
  const GEMINI_MCP_SERVER_PATH = fileURLToPath(new URL("../mcp-server.ts", import.meta.url));
@@ -48,12 +60,43 @@ export interface GeminiTurnDeps extends CliAgentTurnDeps {
48
60
  function hasUnsupportedMedia(parsed: OcxParsedRequest): boolean {
49
61
  return parsed.context.messages.some(message =>
50
62
  Array.isArray(message.content)
51
- && message.content.some(part => part.type === "image" || part.type === "video" || part.type === "audio"),
63
+ && message.content.some(part => part.type === "video" || part.type === "audio"),
52
64
  );
53
65
  }
54
66
 
55
- function conversationText(parsed: OcxParsedRequest, vendorNameByWire: ReadonlyMap<string, string>): string {
56
- 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];
57
100
  if (!first) return "";
58
101
  try {
59
102
  const frame = JSON.parse(first) as { message?: { content?: Array<{ type?: string; text?: string }> } };
@@ -70,16 +113,26 @@ export function buildGeminiPrompt(
70
113
  parsed: OcxParsedRequest,
71
114
  vendorNameByWire: ReadonlyMap<string, string>,
72
115
  fixedSystem = GEMINI_SYSTEM_PROMPT,
116
+ maxChars?: number,
73
117
  ): string {
74
118
  return [
75
119
  "SYSTEM AND DEVELOPER INSTRUCTIONS:",
76
120
  fixedSystem,
77
121
  buildSystemPrompt(parsed),
78
122
  "CONVERSATION REPLAY:",
79
- conversationText(parsed, vendorNameByWire),
123
+ conversationText(parsed, vendorNameByWire, maxChars ?? Number.POSITIVE_INFINITY),
80
124
  ].filter(Boolean).join("\n\n");
81
125
  }
82
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
+
83
136
  function toolNameMap(catalog: ReturnType<typeof buildCodingAgentToolCatalog>): Map<string, string> {
84
137
  const names = new Map<string, string>();
85
138
  for (const [vendorName, wireName] of catalog.emittedNameMap) {
@@ -133,6 +186,31 @@ function failure(error: unknown): Extract<AdapterEvent, { type: "error" }> {
133
186
  };
134
187
  }
135
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
+
136
214
  export async function runGeminiTurn(
137
215
  parsed: OcxParsedRequest,
138
216
  incoming: IncomingMeta,
@@ -144,11 +222,6 @@ export async function runGeminiTurn(
144
222
  emit({ type: "error", message: "Gemini CLI requires a model selector.", status: 400, errorType: "invalid_request_error", code: "model_not_found", retryable: false });
145
223
  return;
146
224
  }
147
- if (hasUnsupportedMedia(parsed)) {
148
- 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 });
149
- return;
150
- }
151
-
152
225
  let catalog: ReturnType<typeof buildCodingAgentToolCatalog>;
153
226
  try {
154
227
  catalog = buildCodingAgentToolCatalog(parsed, GEMINI_MCP_SERVER_NAME);
@@ -159,12 +232,14 @@ export async function runGeminiTurn(
159
232
  }
160
233
 
161
234
  const sourceEnv = deps.env ?? process.env;
235
+ await reclaimGeminiSessions(sourceEnv).catch(() => undefined);
162
236
  const binary = resolveGeminiBinary(deps.which ?? whichFromPath, sourceEnv);
163
237
  if (!binary) {
164
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 });
165
239
  return;
166
240
  }
167
241
 
242
+ const fingerprint = geminiBinaryFingerprintSync(binary);
168
243
  let metadata: GeminiMetadataResult | undefined;
169
244
  let reasoning;
170
245
  try {
@@ -187,6 +262,21 @@ export async function runGeminiTurn(
187
262
  reasoning = undefined;
188
263
  }
189
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
+
190
280
  const acpDeps: GeminiAcpDeps = {
191
281
  ...deps.acpDeps,
192
282
  env: sourceEnv,
@@ -211,12 +301,58 @@ export async function runGeminiTurn(
211
301
 
212
302
  const vendorNames = toolNameMap(catalog);
213
303
  const vendorNameByWire = new Map([...catalog.emittedNameMap.values()].map(wireName => [wireName, wireName]));
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
+ }
214
340
  const workspace = await (deps.stageAcp ?? stageGeminiAcpWorkspace)(
215
341
  catalog.tools,
216
342
  reasoning ? { modelId: requestedModel, thinkingConfig: reasoning.thinkingConfig } : undefined,
343
+ {
344
+ ...(reuse && prior ? { root: prior.workspace } : sessionKey ? { root: geminiSessionWorkspace(sessionKey, sourceEnv) } : {}),
345
+ sourceEnv,
346
+ },
217
347
  );
348
+ let vendorSessionId: string | undefined;
349
+ let terminal: AdapterEvent | undefined;
218
350
  try {
219
- 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
+ };
220
356
  const mcpServers = workspace.capturePath && catalog.emittedNameMap.size > 0
221
357
  ? [{
222
358
  type: "stdio",
@@ -231,21 +367,48 @@ export async function runGeminiTurn(
231
367
  await (deps.runAcp ?? runGeminiAcpTurn)({
232
368
  binary,
233
369
  env: turnEnv,
370
+ runtimeEnv: sourceEnv,
234
371
  cwd: workspace.cwd,
235
372
  args: geminiAcpArgs(workspace.policyPath),
236
373
  modelId: requestedModel,
237
- 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 } : {}),
238
380
  toolNames: vendorNames,
239
381
  mcpServers,
240
382
  ...(workspace.capturePath ? { capturePath: workspace.capturePath } : {}),
241
383
  maxToolCalls: 16,
242
384
  requireToolCall: catalog.requireToolCall,
243
385
  incoming,
386
+ onVendorSession: sessionId => { vendorSessionId = sessionId; },
244
387
  deps: acpDeps,
245
- }, 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
+ }
246
409
  } catch (error) {
247
- emit(failure(error));
248
- } finally {
410
+ if (!terminal) emit(failure(error));
411
+ if (sessionKey) await deleteGeminiSession(sessionKey, sourceEnv).catch(() => undefined);
249
412
  await workspace.cleanup();
250
413
  }
251
414
  }
@@ -13,6 +13,11 @@ import {
13
13
  stageGeminiAcpWorkspace,
14
14
  } from "./acp";
15
15
  import { discoverGeminiModelMetadata, mergeGeminiModelDescriptors } from "./model-metadata";
16
+ import {
17
+ getGeminiRuntime,
18
+ recordGeminiCompressionThreshold,
19
+ recordGeminiInitialize,
20
+ } from "./runtime";
16
21
  import type { OcxProviderModel } from "../../../types";
17
22
 
18
23
  export type GeminiLiveModelsFailure = "not_found" | "unsupported" | "auth_required" | "backoff" | "transport";
@@ -108,8 +113,12 @@ export async function discoverGeminiLiveModels(options: GeminiLiveModelsOptions
108
113
  return staleResult(fingerprint, current)
109
114
  ?? { ok: false, reason: "unsupported", detail: "Gemini --help does not advertise --acp.", fingerprint };
110
115
  }
111
- const workspace = await stageGeminiAcpWorkspace();
112
- const sessionEnv = { ...env, GEMINI_CLI_SYSTEM_SETTINGS_PATH: workspace.settingsPath };
116
+ const workspace = await stageGeminiAcpWorkspace([], undefined, { sourceEnv });
117
+ const sessionEnv = {
118
+ ...env,
119
+ GEMINI_CLI_HOME: workspace.geminiHome,
120
+ GEMINI_CLI_SYSTEM_SETTINGS_PATH: workspace.settingsPath,
121
+ };
113
122
  let client: AcpClient | undefined;
114
123
  try {
115
124
  client = await AcpClient.connect({
@@ -120,6 +129,7 @@ export async function discoverGeminiLiveModels(options: GeminiLiveModelsOptions
120
129
  label: "Gemini ACP",
121
130
  fallbackAllowed: false,
122
131
  }, options.deps);
132
+ if (client.initializeResult) await recordGeminiInitialize(fingerprint, client.initializeResult, sourceEnv);
123
133
  const session = await client.newSession({ cwd: workspace.cwd, mcpServers: [] });
124
134
  const models = geminiModelIds(session);
125
135
  const metadata = await discoverGeminiModelMetadata({
@@ -129,7 +139,26 @@ export async function discoverGeminiLiveModels(options: GeminiLiveModelsOptions
129
139
  ...(options.now ? { now: options.now } : {}),
130
140
  ...(options.forceRefresh ? { ttlMs: 0 } : {}),
131
141
  });
132
- const modelInfo = mergeGeminiModelDescriptors(session.availableModels, metadata);
142
+ await recordGeminiCompressionThreshold(fingerprint, metadata.compressionThreshold, sourceEnv);
143
+ const runtime = await getGeminiRuntime(fingerprint, sourceEnv);
144
+ const modelInfo = mergeGeminiModelDescriptors(session.availableModels, metadata).map(model => {
145
+ const observed = runtime.models[model.id];
146
+ const contextWindow = observed?.contextSize ?? model.contextWindowTokens;
147
+ return {
148
+ ...model,
149
+ ...(contextWindow !== undefined
150
+ ? { contextWindowTokens: contextWindow, maxContextWindowTokens: contextWindow }
151
+ : {}),
152
+ ...(runtime.compressionThreshold !== undefined
153
+ ? { compressionThreshold: runtime.compressionThreshold }
154
+ : {}),
155
+ inputModalities: [
156
+ "text" as const,
157
+ ...(runtime.prompt.image ? ["image" as const] : []),
158
+ ],
159
+ loadSession: runtime.loadSession,
160
+ };
161
+ });
133
162
  const source = session.availableModels.length > 0 ? "models" : "default";
134
163
  cache.set(fingerprint, { models, modelInfo, source, fetchedAt: timestamp, ...(metadata.warning ? { metadataWarning: metadata.warning } : {}) });
135
164
  failureCache.delete(fingerprint);