diff --git a/src/CodeShellManager/Services/ClaudeSessionService.cs b/src/CodeShellManager/Services/ClaudeSessionService.cs index dd7864e..62fb851 100644 --- a/src/CodeShellManager/Services/ClaudeSessionService.cs +++ b/src/CodeShellManager/Services/ClaudeSessionService.cs @@ -18,20 +18,37 @@ public static bool IsClaudeCommand(string command) => command.Equals("claude", StringComparison.OrdinalIgnoreCase) || command.StartsWith("claude ", StringComparison.OrdinalIgnoreCase); + /// + /// 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 <id>` fails with "No conversation found with session ID". + /// + internal static string ResolveClaudeHome(string? configDir, string userProfile) => + string.IsNullOrWhiteSpace(configDir) + ? Path.Combine(userProfile, ".claude") + : configDir; + /// /// 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). /// - 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))); + + /// Claude's data directory — see . + 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; diff --git a/tests/CodeShellManager.Tests/ClaudeSessionServiceTests.cs b/tests/CodeShellManager.Tests/ClaudeSessionServiceTests.cs new file mode 100644 index 0000000..561c6bc --- /dev/null +++ b/tests/CodeShellManager.Tests/ClaudeSessionServiceTests.cs @@ -0,0 +1,145 @@ +using CodeShellManager.Services; +using Xunit; + +namespace CodeShellManager.Tests; + +/// +/// 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 <id>` 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. +/// +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 { } + } + + /// Creates <claudeHome>/projects/<dirName> and returns it. + 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 / directory (subagent transcripts) next to the + // .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); + } +}