From 96b078c02d10b0aca13fbc17f448451070fbd393 Mon Sep 17 00:00:00 2001 From: Allan Thraen Date: Mon, 10 Aug 2026 16:42:11 +0200 Subject: [PATCH] fix(claude): honor CLAUDE_CONFIG_DIR when resolving the resume session id Restored Claude sessions failed with "No conversation found with session ID: ", leaving a bare shell prompt behind (the pwsh wrapper runs with -NoExit, so claude's exit is visible rather than closing the pane). ClaudeSessionService hardcoded ~/.claude as Claude's data directory. Claude Code honors CLAUDE_CONFIG_DIR, and when that is set its conversations live under $CLAUDE_CONFIG_DIR/projects/ instead. A machine that has ever run without the env var keeps a stale ~/.claude/projects/ tree, so GetLastSessionId returned the newest id from the *wrong* store - a real, valid-looking UUID that current Claude has no record of. Reproduced on the reporting machine: CLAUDE_CONFIG_DIR is set at User scope, so CodeShellManager.exe inherits it. For one project folder the stale ~/.claude tree resolved to 6270d0e9-... (last written two months earlier) - exactly the id in the error - while the live store resolved to 8eaf0a22-..., modified that day. ResolveClaudeHome(configDir, userProfile) now picks CLAUDE_CONFIG_DIR when set and falls back to ~/.claude otherwise. GetLastSessionId gains an internal overload taking the resolved home so the lookup is testable without touching process environment or the real user profile; the public signature is unchanged. Note this deliberately does NOT fall back to the legacy directory when the resolved one has no session: returning null starts a fresh conversation, which is correct, whereas falling back reintroduces the bogus-id failure. Does not address issue #75 (the same-folder resume race) - that is a separate root cause in the same code path. Verified: 9 new unit tests (env resolution, stale-vs-live store selection, the real on-disk layout where a / subdirectory sits next to .jsonl); 215/215 tests pass; app builds. Co-Authored-By: Claude Opus 5 (1M context) --- .../Services/ClaudeSessionService.cs | 25 ++- .../ClaudeSessionServiceTests.cs | 145 ++++++++++++++++++ 2 files changed, 166 insertions(+), 4 deletions(-) create mode 100644 tests/CodeShellManager.Tests/ClaudeSessionServiceTests.cs 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); + } +}