Skip to content

Commit 4a575e9

Browse files
committed
feat(gemini): add resume session tracking
Implemented automatic Gemini session tracking so follow-up runs and explicit resumeThread() calls pass the CLI --resume flag. The adapter captures session ids by parsing stream events and --list-sessions output, stores the resume token on the thread state, and ensures spawnGeminiProcess forwards it. Added integration coverage in examples/src/gemini-resume.test.ts for both repeated runs on the same thread and explicit resumeThread() usage (skipping when the CLI is unavailable). Tests: npx tsx --test "examples/src/gemini-resume.test.ts"
1 parent 71400e8 commit 4a575e9

File tree

2 files changed

+242
-13
lines changed

2 files changed

+242
-13
lines changed

examples/src/gemini-resume.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* @fileoverview Exercises Gemini session resume workflows to ensure the adapter
3+
* forwards the CLI --resume flag for repeated runs and explicit resumeThread calls.
4+
*/
5+
6+
import { test, type TestContext } from 'node:test';
7+
import assert from 'node:assert/strict';
8+
import { mkdir } from 'node:fs/promises';
9+
import process from 'node:process';
10+
import { createCoder } from '@headless-coder-sdk/core/factory';
11+
import { CODER_NAME as GEMINI_CODER_NAME } from '@headless-coder-sdk/gemini-adapter';
12+
import { ensureAdaptersRegistered } from './register-adapters';
13+
14+
const WORKSPACE = process.env.GEMINI_RESUME_WORKSPACE ?? '/tmp/headless-coder-sdk/test_gemini_resume';
15+
16+
ensureAdaptersRegistered();
17+
18+
async function prepareWorkspace(dir: string): Promise<void> {
19+
await mkdir(dir, { recursive: true });
20+
}
21+
22+
function isGeminiMissing(error: unknown): boolean {
23+
const message = error instanceof Error ? error.message : String(error ?? '');
24+
return /ENOENT|not found|command failed.*gemini/i.test(message);
25+
}
26+
27+
async function registerThreadCleanup(t: TestContext, closeFn: (() => Promise<void> | void) | undefined): Promise<void> {
28+
if (!closeFn) return;
29+
const registerCleanup = (t as { cleanup?: (fn: () => Promise<void> | void) => void }).cleanup;
30+
if (typeof registerCleanup === 'function') {
31+
registerCleanup(closeFn);
32+
} else {
33+
t.signal.addEventListener('abort', () => {
34+
void closeFn();
35+
});
36+
}
37+
}
38+
39+
test('gemini reuses the same session within a thread', async t => {
40+
await prepareWorkspace(WORKSPACE);
41+
const coder = createCoder(GEMINI_CODER_NAME, {
42+
workingDirectory: WORKSPACE,
43+
includeDirectories: [WORKSPACE],
44+
yolo: true,
45+
});
46+
const thread = await coder.startThread();
47+
await registerThreadCleanup(t, coder.close?.bind(coder, thread));
48+
49+
try {
50+
const first = await thread.run('List two numbered steps for debugging flaky tests.');
51+
if (!first.threadId) {
52+
throw new Error('Gemini resume support should return a session identifier.');
53+
}
54+
const followUp = await thread.run('Add one final note emphasizing deterministic tooling.');
55+
assert.equal(
56+
followUp.threadId,
57+
first.threadId,
58+
'Subsequent runs within the same thread must reuse the Gemini session id.',
59+
);
60+
assert.ok(followUp.text, 'Gemini follow-up response should contain assistant text.');
61+
} catch (error) {
62+
if (isGeminiMissing(error)) {
63+
t.skip('Skipping Gemini resume test because the gemini CLI is not available.');
64+
return;
65+
}
66+
throw error;
67+
}
68+
});
69+
70+
test('gemini resumeThread continues an earlier session id', async t => {
71+
await prepareWorkspace(WORKSPACE);
72+
const coder = createCoder(GEMINI_CODER_NAME, {
73+
workingDirectory: WORKSPACE,
74+
includeDirectories: [WORKSPACE],
75+
yolo: true,
76+
});
77+
const baseThread = await coder.startThread();
78+
await registerThreadCleanup(t, coder.close?.bind(coder, baseThread));
79+
80+
let firstRunThreadId: string | undefined;
81+
try {
82+
const initial = await baseThread.run('Provide a short two-step incident response checklist.');
83+
firstRunThreadId = initial.threadId;
84+
if (!firstRunThreadId) {
85+
throw new Error('Gemini resume support should provide a session id after the first run.');
86+
}
87+
} catch (error) {
88+
if (isGeminiMissing(error)) {
89+
t.skip('Skipping Gemini resume test because the gemini CLI is not available.');
90+
return;
91+
}
92+
throw error;
93+
}
94+
95+
const resumed = await coder.resumeThread(firstRunThreadId);
96+
await registerThreadCleanup(t, coder.close?.bind(coder, resumed));
97+
98+
try {
99+
const followUp = await resumed.run('Extend the checklist with one preventative follow-up action.');
100+
assert.equal(
101+
followUp.threadId,
102+
firstRunThreadId,
103+
'Resumed Gemini runs should maintain the original session id.',
104+
);
105+
assert.match(followUp.text ?? '', /follow/i, 'Gemini follow-up response should mention the additive action.');
106+
} catch (error) {
107+
if (isGeminiMissing(error)) {
108+
t.skip('Skipping Gemini resume test because the gemini CLI is not available.');
109+
return;
110+
}
111+
throw error;
112+
}
113+
});

packages/gemini-adapter/src/index.ts

Lines changed: 129 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* @fileoverview Gemini CLI adapter integrating with the HeadlessCoder contract.
33
*/
44

5-
import { spawn, ChildProcess } from 'node:child_process';
5+
import { spawn, spawnSync, ChildProcess } from 'node:child_process';
66
import * as readline from 'node:readline';
77
import { once } from 'node:events';
88
import {
@@ -56,6 +56,7 @@ const DONE = Symbol('gemini-stream-done');
5656

5757
interface GeminiThreadState {
5858
id?: string;
59+
resumeToken?: string;
5960
opts: StartOpts;
6061
currentRun?: ActiveRun | null;
6162
}
@@ -121,6 +122,109 @@ function extractJsonPayload(text: string | undefined): unknown | undefined {
121122
}
122123
}
123124

125+
function captureGeminiSessionMetadata(state: GeminiThreadState, handle: ThreadHandle | undefined, payload: any): void {
126+
const sessionId = extractSessionId(payload);
127+
if (sessionId) {
128+
state.id = sessionId;
129+
state.resumeToken = sessionId;
130+
if (handle) {
131+
handle.id = sessionId;
132+
}
133+
return;
134+
}
135+
if (state.id) return;
136+
const resumeIndex = extractSessionIndex(payload);
137+
if (resumeIndex) {
138+
state.resumeToken = resumeIndex;
139+
}
140+
}
141+
142+
function extractSessionId(payload: any): string | undefined {
143+
const candidate =
144+
payload?.session_id ??
145+
payload?.sessionId ??
146+
payload?.session?.id ??
147+
payload?.session?.session_id ??
148+
payload?.metadata?.session_id;
149+
return typeof candidate === 'string' && candidate.length > 0 ? candidate : undefined;
150+
}
151+
152+
function extractSessionIndex(payload: any): string | undefined {
153+
const candidate =
154+
payload?.session_index ??
155+
payload?.sessionIndex ??
156+
payload?.index ??
157+
payload?.session?.index ??
158+
payload?.session?.session_index ??
159+
payload?.metadata?.session_index;
160+
if (typeof candidate === 'number' && Number.isFinite(candidate)) {
161+
return String(candidate);
162+
}
163+
if (typeof candidate === 'string' && candidate.trim()) {
164+
return candidate.trim();
165+
}
166+
return undefined;
167+
}
168+
169+
function resolveResumeTarget(state: GeminiThreadState): string | undefined {
170+
if (state.resumeToken) return state.resumeToken;
171+
if (state.id) return state.id;
172+
const candidate = state.opts?.resume;
173+
return typeof candidate === 'string' && candidate.length > 0 ? candidate : undefined;
174+
}
175+
176+
interface GeminiSessionEntry {
177+
index: number;
178+
id?: string;
179+
}
180+
181+
function updateSessionMetadataFromList(state: GeminiThreadState, handle: ThreadHandle | undefined): void {
182+
const entries = listGeminiSessions(state.opts ?? {});
183+
if (!entries.length) return;
184+
const latest = entries[entries.length - 1];
185+
if (latest.id) {
186+
state.id = latest.id;
187+
state.resumeToken = latest.id;
188+
if (handle) {
189+
handle.id = latest.id;
190+
}
191+
return;
192+
}
193+
if (!state.id && Number.isFinite(latest.index)) {
194+
state.resumeToken = String(latest.index);
195+
}
196+
}
197+
198+
function listGeminiSessions(opts: StartOpts): GeminiSessionEntry[] {
199+
const args = ['--list-sessions'];
200+
const result = spawnSync(geminiPath(opts.geminiBinaryPath), args, {
201+
cwd: opts.workingDirectory ?? process.cwd(),
202+
env: process.env,
203+
encoding: 'utf8',
204+
stdio: ['ignore', 'pipe', 'pipe'],
205+
});
206+
if (result.error || typeof result.status === 'number' && result.status !== 0) {
207+
return [];
208+
}
209+
const output = (result.stdout && result.stdout.trim().length ? result.stdout : result.stderr) ?? '';
210+
const parsed = parseGeminiSessionList(output);
211+
return parsed;
212+
}
213+
214+
function parseGeminiSessionList(output: string): GeminiSessionEntry[] {
215+
const entries: GeminiSessionEntry[] = [];
216+
const lineRegex = /^\s*(\d+)\.\s.*\[(.+?)\]\s*$/;
217+
for (const line of output.split(/\r?\n/)) {
218+
const match = lineRegex.exec(line);
219+
if (!match) continue;
220+
const index = Number.parseInt(match[1], 10);
221+
if (Number.isNaN(index)) continue;
222+
const id = match[2]?.trim();
223+
entries.push({ index, id: id || undefined });
224+
}
225+
return entries;
226+
}
227+
124228
/**
125229
* Adapter that proxies Gemini CLI headless invocations.
126230
*
@@ -147,7 +251,11 @@ export class GeminiAdapter implements HeadlessCoder {
147251
*/
148252
async startThread(opts?: StartOpts): Promise<ThreadHandle> {
149253
const options = { ...this.defaultOpts, ...opts };
150-
const state: GeminiThreadState = { opts: options };
254+
const state: GeminiThreadState = {
255+
opts: options,
256+
id: typeof options.resume === 'string' ? options.resume : undefined,
257+
resumeToken: typeof options.resume === 'string' ? options.resume : undefined,
258+
};
151259
return this.createThreadHandle(state);
152260
}
153261

@@ -162,8 +270,8 @@ export class GeminiAdapter implements HeadlessCoder {
162270
* Thread handle referencing Gemini state.
163271
*/
164272
async resumeThread(threadId: string, opts?: StartOpts): Promise<ThreadHandle> {
165-
const options = { ...this.defaultOpts, ...opts };
166-
const state: GeminiThreadState = { opts: options, id: threadId };
273+
const options = { ...this.defaultOpts, ...opts, resume: threadId };
274+
const state: GeminiThreadState = { opts: options, id: threadId, resumeToken: threadId };
167275
return this.createThreadHandle(state);
168276
}
169277

@@ -196,9 +304,9 @@ export class GeminiAdapter implements HeadlessCoder {
196304
throw new Error(`gemini exited with code ${exitCode}: ${stderr}`);
197305
}
198306
const parsed = parseGeminiJson(stdout);
199-
if (parsed.session_id) {
200-
state.id = parsed.session_id;
201-
handle.id = parsed.session_id;
307+
captureGeminiSessionMetadata(state, handle, parsed);
308+
if (!state.id || !state.resumeToken) {
309+
updateSessionMetadataFromList(state, handle);
202310
}
203311
const text = parsed.response ?? parsed.text ?? stdout;
204312
const structured = opts?.outputSchema ? extractJsonPayload(text) : undefined;
@@ -261,10 +369,7 @@ export class GeminiAdapter implements HeadlessCoder {
261369
} catch {
262370
return;
263371
}
264-
if (event.session_id) {
265-
state.id = event.session_id;
266-
handle.id = event.session_id;
267-
}
372+
captureGeminiSessionMetadata(state, handle, event);
268373
for (const normalized of normalizeGeminiEvent(event)) {
269374
push(normalized);
270375
}
@@ -302,6 +407,8 @@ export class GeminiAdapter implements HeadlessCoder {
302407
}
303408
if (code !== 0) {
304409
push(new Error(`gemini exited with code ${code}`));
410+
} else if (!state.id || !state.resumeToken) {
411+
updateSessionMetadataFromList(state, handle);
305412
}
306413
push(DONE);
307414
};
@@ -370,7 +477,8 @@ export class GeminiAdapter implements HeadlessCoder {
370477
opts?: RunOpts,
371478
) {
372479
const startOpts = state.opts ?? {};
373-
const args = buildGeminiArgs(startOpts, prompt, mode);
480+
const resumeTarget = resolveResumeTarget(state);
481+
const args = buildGeminiArgs(startOpts, prompt, mode, resumeTarget);
374482
const child = spawn(geminiPath(startOpts.geminiBinaryPath), args, {
375483
cwd: startOpts.workingDirectory,
376484
env: { ...process.env, ...(opts?.extraEnv ?? {}) },
@@ -556,13 +664,21 @@ function normalizeGeminiEvent(event: any): CoderStreamEvent[] {
556664
}
557665
}
558666

559-
function buildGeminiArgs(opts: StartOpts, prompt: string, format: 'json' | 'stream-json'): string[] {
667+
function buildGeminiArgs(
668+
opts: StartOpts,
669+
prompt: string,
670+
format: 'json' | 'stream-json',
671+
resumeTarget?: string,
672+
): string[] {
560673
const args = ['--output-format', format, '--prompt', prompt];
561674
if (opts.model) args.push('--model', opts.model);
562675
if (opts.includeDirectories?.length) {
563676
args.push('--include-directories', opts.includeDirectories.join(','));
564677
}
565678
if (opts.yolo) args.push('--yolo');
679+
if (resumeTarget) {
680+
args.push('--resume', resumeTarget);
681+
}
566682
return args;
567683
}
568684

0 commit comments

Comments
 (0)