From c3515d74b90736b3947cd8f50028635b2671b50e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 5 Jun 2026 02:29:34 +0800 Subject: [PATCH] Fix Windows workspace provider loading (#1329) * test: cover Windows provider snapshot cwd updates * fix: normalize Windows provider snapshot paths * fix: preserve Claude history path candidates * fix: match Claude project paths on Windows --- .../hooks/providers-snapshot-query.test.ts | 9 ++++++++ .../app/src/hooks/providers-snapshot-query.ts | 4 ++-- .../src/hooks/use-providers-snapshot.test.ts | 21 +++++++++++++++++++ .../server/agent/providers/claude/agent.ts | 14 ++++++------- .../agent.voice-history-regression.test.ts | 10 +++------ .../providers/claude/project-dir.test.ts | 8 ++++++- .../agent/providers/claude/project-dir.ts | 15 +++++++++++++ 7 files changed, 63 insertions(+), 18 deletions(-) diff --git a/packages/app/src/hooks/providers-snapshot-query.test.ts b/packages/app/src/hooks/providers-snapshot-query.test.ts index d3cceb0dc..d577fa459 100644 --- a/packages/app/src/hooks/providers-snapshot-query.test.ts +++ b/packages/app/src/hooks/providers-snapshot-query.test.ts @@ -39,4 +39,13 @@ describe("providers snapshot query scope", () => { providers: ["codex"], }); }); + + it("uses one query scope for Windows cwd values with either separator", () => { + expect(normalizeProvidersSnapshotCwd("C:\\Users\\Ezekiel Bulver\\project")).toBe( + "C:/Users/Ezekiel Bulver/project", + ); + expect(providersSnapshotQueryKey("server-1", "C:\\Users\\Ezekiel Bulver\\project")).toEqual( + providersSnapshotQueryKey("server-1", "C:/Users/Ezekiel Bulver/project"), + ); + }); }); diff --git a/packages/app/src/hooks/providers-snapshot-query.ts b/packages/app/src/hooks/providers-snapshot-query.ts index cb58a823e..66d240150 100644 --- a/packages/app/src/hooks/providers-snapshot-query.ts +++ b/packages/app/src/hooks/providers-snapshot-query.ts @@ -1,10 +1,10 @@ import type { AgentProvider } from "@getpaseo/protocol/agent-types"; +import { normalizeWorkspacePath } from "@/utils/workspace-identity"; export const PROVIDERS_SNAPSHOT_QUERY_ROOT = "providersSnapshot"; export function normalizeProvidersSnapshotCwd(cwd?: string | null): string | null { - const trimmed = cwd?.trim(); - return trimmed ? trimmed : null; + return normalizeWorkspacePath(cwd); } export function providersSnapshotQueryRoot(serverId: string | null) { diff --git a/packages/app/src/hooks/use-providers-snapshot.test.ts b/packages/app/src/hooks/use-providers-snapshot.test.ts index b57000732..d3653b487 100644 --- a/packages/app/src/hooks/use-providers-snapshot.test.ts +++ b/packages/app/src/hooks/use-providers-snapshot.test.ts @@ -250,6 +250,27 @@ describe("applyProvidersSnapshotUpdate", () => { providersSnapshot([]), ); }); + + it("applies Windows daemon updates to app-normalized workspace paths", () => { + const workspaceCwd = "C:/Users/Ezekiel Bulver/project"; + const daemonCwd = "C:\\Users\\Ezekiel Bulver\\project"; + queryClient.setQueryData( + providersSnapshotQueryKey(serverId, workspaceCwd), + providersSnapshot([codexEntry("loading")]), + ); + + applyProvidersSnapshotUpdate({ + serverId, + queryClient, + message: updateMessage([codexEntry("ready", [readyCodexModel])], daemonCwd), + }); + + expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId, workspaceCwd))).toEqual({ + entries: [codexEntry("ready", [readyCodexModel])], + generatedAt: "2026-01-01T00:00:01.000Z", + requestId: "providers_snapshot_update", + }); + }); }); describe("selectorOpenRefetchDecision", () => { diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index d35aa1bd1..b6473c699 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -45,6 +45,7 @@ import { renderPromptAttachmentAsText } from "../../prompt-attachments.js"; import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js"; import { realClaudeRewindSdk, revertClaudeConversation, revertClaudeFiles } from "./rewind.js"; import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js"; +import { claudeProjectDirSync } from "./project-dir.js"; import { getAgentStreamEventTurnId, @@ -339,10 +340,6 @@ function isClaudeThinkingEffort(value: string | null | undefined): value is Clau ); } -function sanitizeClaudeProjectPath(cwd: string): string { - return cwd.replace(/[\\/._:]/g, "-"); -} - interface ClaudeOptionsLogSummary { cwd: string | null; permissionMode: string | null; @@ -3985,14 +3982,15 @@ class ClaudeAgentSession implements AgentSession { // Fall back to the configured cwd when the path has already disappeared. } for (const candidate of candidates) { - const sanitized = sanitizeClaudeProjectPath(candidate); - const historyPath = path.join(configDir, "projects", sanitized, `${sessionId}.jsonl`); + const historyPath = path.join( + claudeProjectDirSync(candidate, { configDir }), + `${sessionId}.jsonl`, + ); if (fs.existsSync(historyPath)) { return historyPath; } } - const sanitized = sanitizeClaudeProjectPath(cwd); - return path.join(configDir, "projects", sanitized, `${sessionId}.jsonl`); + return path.join(claudeProjectDirSync(cwd, { configDir }), `${sessionId}.jsonl`); } private convertHistoryEntry(entry: ClaudeHistoryEntry): AgentTimelineItem[] { diff --git a/packages/server/src/server/agent/providers/claude/agent.voice-history-regression.test.ts b/packages/server/src/server/agent/providers/claude/agent.voice-history-regression.test.ts index 449e52dfe..919c4256c 100644 --- a/packages/server/src/server/agent/providers/claude/agent.voice-history-regression.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.voice-history-regression.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { createTestLogger } from "../../../../test-utils/test-logger.js"; import { ClaudeAgentClient } from "./agent.js"; +import { claudeProjectDirSync } from "./project-dir.js"; import { streamSession } from "../test-utils/session-stream-adapter.js"; import type { AgentPersistenceHandle, AgentStreamEvent } from "../../agent-sdk-types.js"; @@ -15,10 +16,6 @@ const LIVE_REPLY_MARKER = "LIVE_ONLY_REPLY_MARKER"; const HISTORY_USER_MARKER = "HISTORY_ONLY_USER_MARKER"; const HISTORY_ASSISTANT_MARKER = "HISTORY_ONLY_ASSISTANT_MARKER"; -function sanitizeClaudeProjectPath(cwd: string): string { - return cwd.replace(/[\\/._:]/g, "-"); -} - function buildSdkQueryMock() { const events = [ { @@ -100,12 +97,11 @@ describe("ClaudeAgentSession history replay regression", () => { }); tempRoot = mkdtempSync(path.join(os.tmpdir(), "claude-history-regression-")); - cwd = path.join(tempRoot, "repo"); + cwd = path.join(tempRoot, "Michael Depies", "repo"); configDir = path.join(tempRoot, "claude-config"); mkdirSync(cwd, { recursive: true }); - const sanitized = sanitizeClaudeProjectPath(cwd); - const historyDir = path.join(configDir, "projects", sanitized); + const historyDir = claudeProjectDirSync(cwd, { configDir }); mkdirSync(historyDir, { recursive: true }); const historyPath = path.join(historyDir, "history-session.jsonl"); writeFileSync( diff --git a/packages/server/src/server/agent/providers/claude/project-dir.test.ts b/packages/server/src/server/agent/providers/claude/project-dir.test.ts index e90e13aad..c93ab8b2a 100644 --- a/packages/server/src/server/agent/providers/claude/project-dir.test.ts +++ b/packages/server/src/server/agent/providers/claude/project-dir.test.ts @@ -7,7 +7,7 @@ import { join } from "node:path"; import { getSessionInfo } from "@anthropic-ai/claude-agent-sdk"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; -import { claudeProjectDir } from "./project-dir.js"; +import { claudeProjectDir, claudeProjectDirSync } from "./project-dir.js"; // Parity oracle: the Claude SDK's getSessionInfo({ dir }) canonicalizes the // given dir with the SDK's own encoder and looks for `.jsonl` under @@ -101,6 +101,12 @@ describe("claudeProjectDir parity with Claude Agent SDK", () => { expect(info?.sessionId).toBe(sessionId); }); } + + test("sync helper uses the same directory as the SDK-parity helper", async () => { + const cwd = await ensureDir(join(workspaceRoot, "sync path with spaces")); + + await expect(claudeProjectDir(cwd)).resolves.toBe(claudeProjectDirSync(cwd)); + }); }); async function ensureDir(path: string): Promise { diff --git a/packages/server/src/server/agent/providers/claude/project-dir.ts b/packages/server/src/server/agent/providers/claude/project-dir.ts index d8cc95e64..09af1935a 100644 --- a/packages/server/src/server/agent/providers/claude/project-dir.ts +++ b/packages/server/src/server/agent/providers/claude/project-dir.ts @@ -1,3 +1,4 @@ +import { realpathSync } from "node:fs"; import { realpath } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -23,6 +24,12 @@ export async function claudeProjectDir( return join(projectsRoot, encode(canonical)); } +export function claudeProjectDirSync(cwd: string, options?: ClaudeProjectDirOptions): string { + const canonical = canonicalizeSync(cwd); + const projectsRoot = join(resolveConfigDir(options), "projects"); + return join(projectsRoot, encode(canonical)); +} + async function canonicalize(input: string): Promise { try { return (await realpath(input)).normalize("NFC"); @@ -31,6 +38,14 @@ async function canonicalize(input: string): Promise { } } +function canonicalizeSync(input: string): string { + try { + return realpathSync.native(input).normalize("NFC"); + } catch { + return input.normalize("NFC"); + } +} + function encode(input: string): string { const replaced = input.replace(/[^a-zA-Z0-9]/g, "-"); if (replaced.length <= PROJECT_DIR_LENGTH_CAP) {