Skip to content

Commit bd45271

Browse files
committed
Add Gemini session visibility regression test
1 parent 451677c commit bd45271

File tree

4 files changed

+273
-0
lines changed

4 files changed

+273
-0
lines changed

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ If no new rule is detected -> do not update the file.
133133
- CI/release full-solution runs must exclude auth-required tests using `-- --treenode-filter "/*/*/*/*[RequiresGeminiAuth!=true]"` so pipelines remain non-auth and deterministic.
134134
- Cross-platform non-auth smoke must run `gemini` from local installation in CI and verify unauthenticated behavior explicitly (for example `gemini login status` in isolated profile returns "Not logged in"), proving binary discovery + process launch on each platform.
135135
- Real Gemini integration tests must rely on existing local Gemini CLI login/session only; do not read or require `OPENAI_API_KEY` in test setup.
136+
- For local authenticated Gemini verification, prefer the cheapest available Gemini model that works in the current account/environment to keep testing costs down.
136137
- Adapter regression coverage is critical: when CLI contract changes affect streaming/events/options, keep the `Extensions.AI` and `Extensions.AgentFramework` tests green in the same pass.
137138
- Do not use nullable `TryGetSettings()` + early `return` skip patterns in real integration tests; resolve required settings directly and fail fast with actionable errors when missing.
138139
- Do not bypass integration tests on Windows with unconditional early returns; keep tests cross-platform for supported Gemini CLI environments.
@@ -206,6 +207,7 @@ If no new rule is detected -> do not update the file.
206207

207208
- Read `AGENTS.md` and relevant docs before editing code.
208209
- Keep API behavior aligned with actual Gemini CLI contracts first; TypeScript SDK mapping may be used only as an optional historical reference, not as a blocker for C# SDK design.
210+
- For `ManagedCode.GeminiSharpSDK`, do not import Codex-specific behavior, docs, or assumptions into implementation or tests unless the user explicitly asks for cross-SDK comparison; keep Gemini fixes grounded in real Gemini CLI behavior.
209211
- Maintain GitHub workflow health (`.github/workflows`).
210212

211213
**Ask first:**

GeminiSharpSDK.Tests/Integration/RealGeminiIntegrationTests.cs

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
using System.Diagnostics;
2+
using System.Text.Json;
13
using ManagedCode.GeminiSharpSDK.Client;
4+
using ManagedCode.GeminiSharpSDK.Internal;
25
using ManagedCode.GeminiSharpSDK.Models;
36
using ManagedCode.GeminiSharpSDK.Tests.Shared;
47

@@ -7,6 +10,26 @@ namespace ManagedCode.GeminiSharpSDK.Tests.Integration;
710
[Property("RequiresGeminiAuth", "true")]
811
public class RealGeminiIntegrationTests
912
{
13+
private const string SolutionFileName = "ManagedCode.GeminiSharpSDK.slnx";
14+
private const string TestsDirectoryName = "tests";
15+
private const string SandboxDirectoryName = ".sandbox";
16+
private const string SandboxPrefix = "RealGeminiIntegrationTests-SessionVisibility-";
17+
private const string GitExecutableName = "git";
18+
private const string GitInitArgument = "init";
19+
private const string QuietArgument = "-q";
20+
private const string ListSessionsFlag = "--list-sessions";
21+
private const string GeminiDirectoryName = ".gemini";
22+
private const string ProjectsFileName = "projects.json";
23+
private const string ProjectsPropertyName = "projects";
24+
private const string TmpDirectoryName = "tmp";
25+
private const string ChatsDirectoryName = "chats";
26+
private const string SessionIdPropertyName = "sessionId";
27+
private const string SessionFileSearchPattern = "session-*.json";
28+
private const string ProjectSessionVisiblePrompt = "Reply with short plain text: ok.";
29+
private static readonly TimeSpan SessionVisibilityTimeout = TimeSpan.FromSeconds(10);
30+
private static readonly TimeSpan CliCommandTimeout = TimeSpan.FromSeconds(30);
31+
private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(200);
32+
1033
[Test]
1134
public async Task RunAsync_WithRealGeminiCli_ReturnsStructuredOutput()
1235
{
@@ -90,11 +113,255 @@ public async Task RunAsync_WithRealGeminiCli_SecondTurnKeepsThreadId()
90113
await Assert.That(thread.Id).IsEqualTo(firstThreadId);
91114
}
92115

116+
[Test]
117+
public async Task RunAsync_WithFreshWorkingDirectory_PersistsSessionVisibleToGeminiCli()
118+
{
119+
var settings = RealGeminiTestSupport.GetRequiredSettings();
120+
var sandboxDirectory = await CreateGitSandboxDirectoryAsync();
121+
122+
try
123+
{
124+
using var client = RealGeminiTestSupport.CreateClient();
125+
var thread = client.StartThread(new ThreadOptions
126+
{
127+
Model = settings.Model,
128+
WorkingDirectory = sandboxDirectory,
129+
Ephemeral = false,
130+
});
131+
using var cancellation = new CancellationTokenSource(TimeSpan.FromMinutes(2));
132+
133+
var result = await thread.RunAsync(
134+
ProjectSessionVisiblePrompt,
135+
new TurnOptions { CancellationToken = cancellation.Token });
136+
137+
await Assert.That(result.Usage).IsNotNull();
138+
await Assert.That(thread.Id).IsNotNull();
139+
140+
var persistedSessionPath = await FindPersistedSessionPathAsync(
141+
sandboxDirectory,
142+
thread.Id!,
143+
SessionVisibilityTimeout);
144+
145+
await Assert.That(persistedSessionPath).IsNotNull();
146+
147+
var listSessionsResult = await RunGeminiAsync(
148+
sandboxDirectory,
149+
CliCommandTimeout,
150+
ListSessionsFlag);
151+
152+
await Assert.That(listSessionsResult.ExitCode).IsEqualTo(0);
153+
await Assert.That(string.Concat(listSessionsResult.StandardOutput, listSessionsResult.StandardError))
154+
.Contains(thread.Id!);
155+
}
156+
finally
157+
{
158+
if (Directory.Exists(sandboxDirectory))
159+
{
160+
Directory.Delete(sandboxDirectory, recursive: true);
161+
}
162+
}
163+
}
164+
93165
private static GeminiThread StartRealIntegrationThread(GeminiClient client, string model)
94166
{
95167
return client.StartThread(new ThreadOptions
96168
{
97169
Model = model,
98170
});
99171
}
172+
173+
private static async Task<string> CreateGitSandboxDirectoryAsync()
174+
{
175+
var repositoryRoot = ResolveRepositoryRootPath();
176+
var sandboxDirectory = Path.Combine(
177+
repositoryRoot,
178+
TestsDirectoryName,
179+
SandboxDirectoryName,
180+
$"{SandboxPrefix}{Guid.NewGuid():N}");
181+
182+
Directory.CreateDirectory(sandboxDirectory);
183+
var gitInitResult = await RunCommand(
184+
GitExecutableName,
185+
sandboxDirectory,
186+
CliCommandTimeout,
187+
GitInitArgument,
188+
QuietArgument);
189+
190+
if (gitInitResult.ExitCode != 0)
191+
{
192+
throw new InvalidOperationException(
193+
$"Failed to initialize git sandbox: {gitInitResult.StandardError}");
194+
}
195+
196+
return sandboxDirectory;
197+
}
198+
199+
private static string ResolveRepositoryRootPath()
200+
{
201+
var current = new DirectoryInfo(AppContext.BaseDirectory);
202+
while (current is not null)
203+
{
204+
if (File.Exists(Path.Combine(current.FullName, SolutionFileName)))
205+
{
206+
return current.FullName;
207+
}
208+
209+
current = current.Parent;
210+
}
211+
212+
throw new InvalidOperationException("Could not locate repository root from test execution directory.");
213+
}
214+
215+
private static async Task<string?> FindPersistedSessionPathAsync(
216+
string workingDirectory,
217+
string threadId,
218+
TimeSpan timeout)
219+
{
220+
var deadline = DateTime.UtcNow + timeout;
221+
while (DateTime.UtcNow <= deadline)
222+
{
223+
var persistedSessionPath = TryFindPersistedSessionPath(workingDirectory, threadId);
224+
if (persistedSessionPath is not null)
225+
{
226+
return persistedSessionPath;
227+
}
228+
229+
await Task.Delay(PollInterval);
230+
}
231+
232+
return null;
233+
}
234+
235+
private static string? TryFindPersistedSessionPath(string workingDirectory, string threadId)
236+
{
237+
var projectKey = TryResolveProjectKey(workingDirectory);
238+
if (string.IsNullOrWhiteSpace(projectKey))
239+
{
240+
return null;
241+
}
242+
243+
var geminiHome = Path.Combine(
244+
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
245+
GeminiDirectoryName);
246+
var chatsDirectory = Path.Combine(geminiHome, TmpDirectoryName, projectKey, ChatsDirectoryName);
247+
if (!Directory.Exists(chatsDirectory))
248+
{
249+
return null;
250+
}
251+
252+
foreach (var sessionFile in Directory.EnumerateFiles(chatsDirectory, SessionFileSearchPattern, SearchOption.TopDirectoryOnly))
253+
{
254+
if (SessionFileMatchesThreadId(sessionFile, threadId))
255+
{
256+
return sessionFile;
257+
}
258+
}
259+
260+
return null;
261+
}
262+
263+
private static string? TryResolveProjectKey(string workingDirectory)
264+
{
265+
var projectsPath = Path.Combine(
266+
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
267+
GeminiDirectoryName,
268+
ProjectsFileName);
269+
if (!File.Exists(projectsPath))
270+
{
271+
return null;
272+
}
273+
274+
using var stream = File.OpenRead(projectsPath);
275+
using var document = JsonDocument.Parse(stream);
276+
if (!document.RootElement.TryGetProperty(ProjectsPropertyName, out var projectsElement)
277+
|| projectsElement.ValueKind != JsonValueKind.Object)
278+
{
279+
return null;
280+
}
281+
282+
var normalizedWorkingDirectory = Path.GetFullPath(workingDirectory);
283+
foreach (var project in projectsElement.EnumerateObject())
284+
{
285+
if (!string.Equals(Path.GetFullPath(project.Name), normalizedWorkingDirectory, StringComparison.Ordinal))
286+
{
287+
continue;
288+
}
289+
290+
return project.Value.ValueKind == JsonValueKind.String
291+
? project.Value.GetString()
292+
: null;
293+
}
294+
295+
return null;
296+
}
297+
298+
private static bool SessionFileMatchesThreadId(string sessionFile, string threadId)
299+
{
300+
using var stream = File.OpenRead(sessionFile);
301+
using var document = JsonDocument.Parse(stream);
302+
return document.RootElement.TryGetProperty(SessionIdPropertyName, out var sessionIdElement)
303+
&& sessionIdElement.ValueKind == JsonValueKind.String
304+
&& string.Equals(sessionIdElement.GetString(), threadId, StringComparison.Ordinal);
305+
}
306+
307+
private static Task<GeminiCommandResult> RunGeminiAsync(
308+
string workingDirectory,
309+
TimeSpan timeout,
310+
params string[] arguments)
311+
{
312+
var executablePath = GeminiCliLocator.FindGeminiPath(null);
313+
return RunCommand(executablePath, workingDirectory, timeout, arguments);
314+
}
315+
316+
private static async Task<GeminiCommandResult> RunCommand(
317+
string executablePath,
318+
string workingDirectory,
319+
TimeSpan timeout,
320+
params string[] arguments)
321+
{
322+
using var cancellation = new CancellationTokenSource(timeout);
323+
var startInfo = new ProcessStartInfo(executablePath)
324+
{
325+
RedirectStandardOutput = true,
326+
RedirectStandardError = true,
327+
UseShellExecute = false,
328+
CreateNoWindow = true,
329+
WorkingDirectory = workingDirectory,
330+
};
331+
332+
foreach (var argument in arguments)
333+
{
334+
if (string.IsNullOrWhiteSpace(argument))
335+
{
336+
continue;
337+
}
338+
339+
startInfo.ArgumentList.Add(argument);
340+
}
341+
342+
using var process = new Process { StartInfo = startInfo };
343+
try
344+
{
345+
if (!process.Start())
346+
{
347+
throw new InvalidOperationException($"Failed to start command '{executablePath}'.");
348+
}
349+
}
350+
catch (Exception exception)
351+
{
352+
throw new InvalidOperationException($"Failed to start command '{executablePath}'.", exception);
353+
}
354+
355+
var standardOutputTask = process.StandardOutput.ReadToEndAsync(cancellation.Token);
356+
var standardErrorTask = process.StandardError.ReadToEndAsync(cancellation.Token);
357+
358+
await process.WaitForExitAsync(cancellation.Token);
359+
360+
return new GeminiCommandResult(
361+
process.ExitCode,
362+
await standardOutputTask,
363+
await standardErrorTask);
364+
}
365+
366+
private sealed record GeminiCommandResult(int ExitCode, string StandardOutput, string StandardError);
100367
}

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ var thread = client.StartThread(new ThreadOptions
8181

8282
`ThreadOptions` maps to the current headless Gemini CLI surface (`--prompt`, `--output-format stream-json`, `--resume`, `--approval-mode`, `--include-directories`, and sandbox toggle). Unsupported legacy flags fail fast.
8383

84+
Fresh SDK-started runs persist per Gemini project/working directory and are visible via `gemini --list-sessions` for that same project.
85+
8486
```csharp
8587
var thread = client.StartThread(new ThreadOptions
8688
{

docs/Features/thread-run-flow.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ Provide deterministic thread-based execution over Gemini CLI so C# consumers can
4545
- `LocalImageInput` accepts image path, `FileInfo`, or `Stream`; stream inputs are materialized to temp files and referenced in the prompt as local `@path` inputs.
4646
- Gemini executable resolution is deterministic: prefer npm-vendored native binary, then PATH lookup; on Windows PATH lookup checks `gemini.exe`, `gemini.cmd`, `gemini.bat`, then `gemini`.
4747
- Thread options map only the current supported headless Gemini CLI flags (`model`, `resume`, `approval-mode`, `include-directories`, sandbox toggle), plus raw `AdditionalCliArguments` passthrough for forward-compatible flags.
48+
- A fresh SDK-started Gemini run with a dedicated working directory persists a resumable session file and is visible through `gemini --list-sessions` for that same project.
4849
- Unsupported legacy headless flags fail fast with actionable `NotSupportedException`.
4950
- Cleanup failures are never silently swallowed; process/schema/image cleanup issues are logged through `ILogger`.
5051

@@ -109,6 +110,7 @@ flowchart LR
109110
- Protocol parsing: [ThreadEventParserTests.cs](../../GeminiSharpSDK.Tests/Unit/ThreadEventParserTests.cs)
110111
- CLI argument mapping: [GeminiExecTests.cs](../../GeminiSharpSDK.Tests/Unit/GeminiExecTests.cs)
111112
- Client lifecycle: [GeminiClientTests.cs](../../GeminiSharpSDK.Tests/Unit/GeminiClientTests.cs)
113+
- Real session persistence visibility: [RealGeminiIntegrationTests.cs](../../GeminiSharpSDK.Tests/Integration/RealGeminiIntegrationTests.cs)
112114

113115
---
114116

0 commit comments

Comments
 (0)