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
This commit is contained in:
@@ -39,4 +39,13 @@ describe("providers snapshot query scope", () => {
|
|||||||
providers: ["codex"],
|
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"),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||||
|
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||||
|
|
||||||
export const PROVIDERS_SNAPSHOT_QUERY_ROOT = "providersSnapshot";
|
export const PROVIDERS_SNAPSHOT_QUERY_ROOT = "providersSnapshot";
|
||||||
|
|
||||||
export function normalizeProvidersSnapshotCwd(cwd?: string | null): string | null {
|
export function normalizeProvidersSnapshotCwd(cwd?: string | null): string | null {
|
||||||
const trimmed = cwd?.trim();
|
return normalizeWorkspacePath(cwd);
|
||||||
return trimmed ? trimmed : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function providersSnapshotQueryRoot(serverId: string | null) {
|
export function providersSnapshotQueryRoot(serverId: string | null) {
|
||||||
|
|||||||
@@ -250,6 +250,27 @@ describe("applyProvidersSnapshotUpdate", () => {
|
|||||||
providersSnapshot([]),
|
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", () => {
|
describe("selectorOpenRefetchDecision", () => {
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
|
|||||||
import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js";
|
import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js";
|
||||||
import { realClaudeRewindSdk, revertClaudeConversation, revertClaudeFiles } from "./rewind.js";
|
import { realClaudeRewindSdk, revertClaudeConversation, revertClaudeFiles } from "./rewind.js";
|
||||||
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
|
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
|
||||||
|
import { claudeProjectDirSync } from "./project-dir.js";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getAgentStreamEventTurnId,
|
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 {
|
interface ClaudeOptionsLogSummary {
|
||||||
cwd: string | null;
|
cwd: string | null;
|
||||||
permissionMode: 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.
|
// Fall back to the configured cwd when the path has already disappeared.
|
||||||
}
|
}
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
const sanitized = sanitizeClaudeProjectPath(candidate);
|
const historyPath = path.join(
|
||||||
const historyPath = path.join(configDir, "projects", sanitized, `${sessionId}.jsonl`);
|
claudeProjectDirSync(candidate, { configDir }),
|
||||||
|
`${sessionId}.jsonl`,
|
||||||
|
);
|
||||||
if (fs.existsSync(historyPath)) {
|
if (fs.existsSync(historyPath)) {
|
||||||
return historyPath;
|
return historyPath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const sanitized = sanitizeClaudeProjectPath(cwd);
|
return path.join(claudeProjectDirSync(cwd, { configDir }), `${sessionId}.jsonl`);
|
||||||
return path.join(configDir, "projects", sanitized, `${sessionId}.jsonl`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private convertHistoryEntry(entry: ClaudeHistoryEntry): AgentTimelineItem[] {
|
private convertHistoryEntry(entry: ClaudeHistoryEntry): AgentTimelineItem[] {
|
||||||
|
|||||||
+3
-7
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|||||||
|
|
||||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||||
import { ClaudeAgentClient } from "./agent.js";
|
import { ClaudeAgentClient } from "./agent.js";
|
||||||
|
import { claudeProjectDirSync } from "./project-dir.js";
|
||||||
import { streamSession } from "../test-utils/session-stream-adapter.js";
|
import { streamSession } from "../test-utils/session-stream-adapter.js";
|
||||||
import type { AgentPersistenceHandle, AgentStreamEvent } from "../../agent-sdk-types.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_USER_MARKER = "HISTORY_ONLY_USER_MARKER";
|
||||||
const HISTORY_ASSISTANT_MARKER = "HISTORY_ONLY_ASSISTANT_MARKER";
|
const HISTORY_ASSISTANT_MARKER = "HISTORY_ONLY_ASSISTANT_MARKER";
|
||||||
|
|
||||||
function sanitizeClaudeProjectPath(cwd: string): string {
|
|
||||||
return cwd.replace(/[\\/._:]/g, "-");
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSdkQueryMock() {
|
function buildSdkQueryMock() {
|
||||||
const events = [
|
const events = [
|
||||||
{
|
{
|
||||||
@@ -100,12 +97,11 @@ describe("ClaudeAgentSession history replay regression", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
tempRoot = mkdtempSync(path.join(os.tmpdir(), "claude-history-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");
|
configDir = path.join(tempRoot, "claude-config");
|
||||||
mkdirSync(cwd, { recursive: true });
|
mkdirSync(cwd, { recursive: true });
|
||||||
|
|
||||||
const sanitized = sanitizeClaudeProjectPath(cwd);
|
const historyDir = claudeProjectDirSync(cwd, { configDir });
|
||||||
const historyDir = path.join(configDir, "projects", sanitized);
|
|
||||||
mkdirSync(historyDir, { recursive: true });
|
mkdirSync(historyDir, { recursive: true });
|
||||||
const historyPath = path.join(historyDir, "history-session.jsonl");
|
const historyPath = path.join(historyDir, "history-session.jsonl");
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { join } from "node:path";
|
|||||||
import { getSessionInfo } from "@anthropic-ai/claude-agent-sdk";
|
import { getSessionInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
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
|
// Parity oracle: the Claude SDK's getSessionInfo({ dir }) canonicalizes the
|
||||||
// given dir with the SDK's own encoder and looks for `<sessionId>.jsonl` under
|
// given dir with the SDK's own encoder and looks for `<sessionId>.jsonl` under
|
||||||
@@ -101,6 +101,12 @@ describe("claudeProjectDir parity with Claude Agent SDK", () => {
|
|||||||
expect(info?.sessionId).toBe(sessionId);
|
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<string> {
|
async function ensureDir(path: string): Promise<string> {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { realpathSync } from "node:fs";
|
||||||
import { realpath } from "node:fs/promises";
|
import { realpath } from "node:fs/promises";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
@@ -23,6 +24,12 @@ export async function claudeProjectDir(
|
|||||||
return join(projectsRoot, encode(canonical));
|
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<string> {
|
async function canonicalize(input: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
return (await realpath(input)).normalize("NFC");
|
return (await realpath(input)).normalize("NFC");
|
||||||
@@ -31,6 +38,14 @@ async function canonicalize(input: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canonicalizeSync(input: string): string {
|
||||||
|
try {
|
||||||
|
return realpathSync.native(input).normalize("NFC");
|
||||||
|
} catch {
|
||||||
|
return input.normalize("NFC");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function encode(input: string): string {
|
function encode(input: string): string {
|
||||||
const replaced = input.replace(/[^a-zA-Z0-9]/g, "-");
|
const replaced = input.replace(/[^a-zA-Z0-9]/g, "-");
|
||||||
if (replaced.length <= PROJECT_DIR_LENGTH_CAP) {
|
if (replaced.length <= PROJECT_DIR_LENGTH_CAP) {
|
||||||
|
|||||||
Reference in New Issue
Block a user