Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions src/CodeShellManager/Services/ClaudeSessionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,37 @@ public static bool IsClaudeCommand(string command) =>
command.Equals("claude", StringComparison.OrdinalIgnoreCase) ||
command.StartsWith("claude ", StringComparison.OrdinalIgnoreCase);

/// <summary>
/// Resolves the directory Claude Code keeps its data in. CLAUDE_CONFIG_DIR wins when
/// set; otherwise the default ~/.claude.
///
/// This matters because a machine that has ever run without the env var keeps a stale
/// ~/.claude/projects/ tree. Reading that one yields a session id from the wrong store
/// and `claude --resume &lt;id&gt;` fails with "No conversation found with session ID".
/// </summary>
internal static string ResolveClaudeHome(string? configDir, string userProfile) =>
string.IsNullOrWhiteSpace(configDir)
? Path.Combine(userProfile, ".claude")
: configDir;

/// <summary>
/// Finds the most recently modified session ID for the given working folder.
/// Returns null if no session exists (new project or claude not yet run there).
/// </summary>
public static string? GetLastSessionId(string workingFolder)
public static string? GetLastSessionId(string workingFolder) =>
GetLastSessionId(workingFolder, ResolveClaudeHome(
Environment.GetEnvironmentVariable("CLAUDE_CONFIG_DIR"),
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)));

/// <param name="claudeHome">Claude's data directory — see <see cref="ResolveClaudeHome"/>.</param>
internal static string? GetLastSessionId(string workingFolder, string claudeHome)
{
if (string.IsNullOrWhiteSpace(workingFolder)) return null;

try
{
string projectDir = ToProjectDirName(workingFolder);
string claudeProjectsPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".claude", "projects", projectDir);
string claudeProjectsPath = Path.Combine(claudeHome, "projects", projectDir);

if (!Directory.Exists(claudeProjectsPath)) return null;

Expand Down
145 changes: 145 additions & 0 deletions tests/CodeShellManager.Tests/ClaudeSessionServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
using CodeShellManager.Services;
using Xunit;

namespace CodeShellManager.Tests;

/// <summary>
/// Claude Code stores conversations under CLAUDE_CONFIG_DIR when that env var is set,
/// falling back to ~/.claude. Reading the wrong root yields a session id from a stale
/// store, and `claude --resume &lt;id&gt;` then fails with "No conversation found with
/// session ID". Timestamps are set explicitly rather than via Task.Delay — Windows'
/// ~15.6ms timer granularity makes wall-clock ordering flaky.
/// </summary>
public class ClaudeSessionServiceTests : IDisposable
{
private readonly string _root;

public ClaudeSessionServiceTests()
{
_root = Path.Combine(Path.GetTempPath(), "csm-claude-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_root);
}

public void Dispose()
{
try { Directory.Delete(_root, recursive: true); } catch { }
}

/// <summary>Creates &lt;claudeHome&gt;/projects/&lt;dirName&gt; and returns it.</summary>
private string MakeProjectDir(string claudeHome, string dirName)
{
string p = Path.Combine(claudeHome, "projects", dirName);
Directory.CreateDirectory(p);
return p;
}

private static void WriteSession(string projectDir, string sessionId, DateTime lastWriteUtc)
{
string f = Path.Combine(projectDir, sessionId + ".jsonl");
File.WriteAllText(f, "{}\n");
File.SetLastWriteTimeUtc(f, lastWriteUtc);
}

// ── ResolveClaudeHome ────────────────────────────────────────────────────

[Fact]
public void ResolveClaudeHome_UsesConfigDir_WhenSet()
{
string result = ClaudeSessionService.ResolveClaudeHome(
@"C:\Users\someone\.claude-work", @"C:\Users\someone");

Assert.Equal(@"C:\Users\someone\.claude-work", result);
}

[Fact]
public void ResolveClaudeHome_FallsBackToDotClaude_WhenConfigDirNull()
{
string result = ClaudeSessionService.ResolveClaudeHome(null, @"C:\Users\someone");

Assert.Equal(Path.Combine(@"C:\Users\someone", ".claude"), result);
}

[Fact]
public void ResolveClaudeHome_FallsBackToDotClaude_WhenConfigDirBlank()
{
string result = ClaudeSessionService.ResolveClaudeHome(" ", @"C:\Users\someone");

Assert.Equal(Path.Combine(@"C:\Users\someone", ".claude"), result);
}

// ── GetLastSessionId ─────────────────────────────────────────────────────

[Fact]
public void GetLastSessionId_ReturnsNewestSessionByWriteTime()
{
string dir = MakeProjectDir(_root, "C--Github-Foo");
WriteSession(dir, "11111111-1111-1111-1111-111111111111", new DateTime(2026, 6, 10, 0, 0, 0, DateTimeKind.Utc));
WriteSession(dir, "22222222-2222-2222-2222-222222222222", new DateTime(2026, 8, 10, 0, 0, 0, DateTimeKind.Utc));

string? id = ClaudeSessionService.GetLastSessionId(@"C:\Github\Foo", _root);

Assert.Equal("22222222-2222-2222-2222-222222222222", id);
}

[Fact]
public void GetLastSessionId_ReadsFromTheGivenClaudeHome_NotAHardcodedOne()
{
// The regression: a stale ~/.claude alongside a live CLAUDE_CONFIG_DIR. Only the
// session in the home we were handed may be returned.
string stale = Path.Combine(_root, "stale");
string live = Path.Combine(_root, "live");
WriteSession(MakeProjectDir(stale, "C--Github-Foo"), "5ta1e000-0000-0000-0000-000000000000",
new DateTime(2026, 6, 10, 0, 0, 0, DateTimeKind.Utc));
WriteSession(MakeProjectDir(live, "C--Github-Foo"), "11ve0000-0000-0000-0000-000000000000",
new DateTime(2026, 8, 10, 0, 0, 0, DateTimeKind.Utc));

string? id = ClaudeSessionService.GetLastSessionId(@"C:\Github\Foo", live);

Assert.Equal("11ve0000-0000-0000-0000-000000000000", id);
}

[Fact]
public void GetLastSessionId_ReturnsNull_WhenProjectDirDoesNotExist()
{
string? id = ClaudeSessionService.GetLastSessionId(@"C:\Github\NeverUsed", _root);

Assert.Null(id);
}

[Fact]
public void GetLastSessionId_ReturnsNull_WhenProjectDirHasNoSessions()
{
MakeProjectDir(_root, "C--Github-Empty");

string? id = ClaudeSessionService.GetLastSessionId(@"C:\Github\Empty", _root);

Assert.Null(id);
}

[Fact]
public void GetLastSessionId_IgnoresSubdirectoriesAndNonSessionFiles()
{
// Real layout has a <session-id>/ directory (subagent transcripts) next to the
// <session-id>.jsonl, plus a memory/ dir. Neither may be mistaken for a session.
string dir = MakeProjectDir(_root, "C--Github-Foo");
WriteSession(dir, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc));
Directory.CreateDirectory(Path.Combine(dir, "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb.jsonl"));
Directory.CreateDirectory(Path.Combine(dir, "memory"));
File.WriteAllText(Path.Combine(dir, "notes.txt"), "x");

string? id = ClaudeSessionService.GetLastSessionId(@"C:\Github\Foo", _root);

Assert.Equal("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", id);
}

[Fact]
public void GetLastSessionId_MapsDriveAndSeparatorsToProjectDirName()
{
string dir = MakeProjectDir(_root, "C--Github-umage-CodeShellManager");
WriteSession(dir, "cccccccc-cccc-cccc-cccc-cccccccccccc", new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc));

string? id = ClaudeSessionService.GetLastSessionId(@"C:\Github\umage\CodeShellManager", _root);

Assert.Equal("cccccccc-cccc-cccc-cccc-cccccccccccc", id);
}
}
Loading