diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 714d0678..d42199cb 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -51,6 +51,7 @@ import packageJson from "../package.json"; import type {AuthenticationStatusResponse} from "./AcpExtensions"; import {createCodexCollaborationMode} from "./CollaborationModeConfig"; import type {ModeKind} from "./app-server/ModeKind"; +import {arePathBasenamesEqual, arePathsEqual, isAbsolutePathLike} from "./PathUtils"; /** * Well-known provider id for the client-configurable custom LLM gateway. @@ -862,11 +863,10 @@ export class CodexAcpClient { const requestedCwd = request.cwd?.trim() ?? null; const filterByCwd = (thread: Thread): boolean => { if (!requestedCwd) return true; - if (path.isAbsolute(requestedCwd)) { - return thread.cwd === requestedCwd; + if (isAbsolutePathLike(requestedCwd)) { + return arePathsEqual(thread.cwd, requestedCwd); } - const requestedBase = path.basename(requestedCwd); - return path.basename(thread.cwd) === requestedBase; + return arePathBasenamesEqual(thread.cwd, requestedCwd); }; const preferredProvider = this.getModelProvider(); @@ -894,7 +894,7 @@ export class CodexAcpClient { const filtered = listResponse.data .filter(filterByCwd) .map(mapThreadToSession); - if (filtered.length > 0 || path.isAbsolute(requestedCwd)) { + if (filtered.length > 0 || isAbsolutePathLike(requestedCwd)) { sessions = filtered; } else { logger.log("Ignoring non-absolute cwd filter for session/list", {cwd: requestedCwd}); diff --git a/src/PathUtils.ts b/src/PathUtils.ts new file mode 100644 index 00000000..7c96559a --- /dev/null +++ b/src/PathUtils.ts @@ -0,0 +1,60 @@ +import path from "node:path"; + +export function isAbsolutePathLike(value: string): boolean { + const trimmed = value.trim(); + return path.isAbsolute(trimmed) || isWindowsAbsolutePath(trimmed); +} + +export function arePathsEqual(left: string, right: string): boolean { + return normalizePathForComparison(left) === normalizePathForComparison(right); +} + +export function arePathBasenamesEqual(left: string, right: string): boolean { + const leftBase = path.posix.basename(normalizePathForComparison(left)); + const rightBase = path.posix.basename(normalizePathForComparison(right)); + if (shouldComparePathCaseInsensitive(left) || shouldComparePathCaseInsensitive(right)) { + return leftBase.toLowerCase() === rightBase.toLowerCase(); + } + return leftBase === rightBase; +} + +export function normalizePathForComparison(value: string): string { + const trimmed = value.trim(); + if (trimmed.length === 0) { + return ""; + } + + if (isWindowsAbsolutePath(trimmed)) { + const normalized = path.win32.normalize(trimmed).replace(/\\/g, "/"); + return trimTrailingPathSeparators(normalized).toLowerCase(); + } + + const pathForComparison = path.isAbsolute(trimmed) + ? trimmed + : trimmed.replace(/\\/g, "/"); + const normalized = path.posix.normalize(pathForComparison); + return trimTrailingPathSeparators(normalized); +} + +function isWindowsAbsolutePath(value: string): boolean { + const portableValue = value.replace(/\\/g, "/"); + return /^[A-Za-z]:\//.test(portableValue) || /^\/\/[^/]+\/[^/]+/.test(portableValue); +} + +function shouldComparePathCaseInsensitive(value: string): boolean { + return isWindowsAbsolutePath(value) || /^[A-Za-z]:/.test(value) || value.includes("\\"); +} + +function trimTrailingPathSeparators(value: string): string { + let trimmed = value; + while (trimmed.endsWith("/") && !isRootPath(trimmed)) { + trimmed = trimmed.slice(0, -1); + } + return trimmed; +} + +function isRootPath(value: string): boolean { + return value === "/" + || /^[A-Za-z]:\/$/.test(value) + || /^\/\/[^/]+\/[^/]+\/$/.test(value); +} diff --git a/src/__tests__/CodexACPAgent/list-sessions.test.ts b/src/__tests__/CodexACPAgent/list-sessions.test.ts index bf9f68c4..a8fbfa1d 100644 --- a/src/__tests__/CodexACPAgent/list-sessions.test.ts +++ b/src/__tests__/CodexACPAgent/list-sessions.test.ts @@ -93,6 +93,72 @@ describe("CodexACPAgent - list sessions", () => { ); }); + it("normalizes Windows cwd filters before comparing absolute paths", async () => { + const fixture = createCodexMockTestFixture(); + const codexAcpAgent = fixture.getCodexAcpAgent(); + const codexAcpClient = fixture.getCodexAcpClient(); + const codexAppServerClient = fixture.getCodexAppServerClient(); + + codexAcpClient.authRequired = vi.fn().mockResolvedValue(false); + + const matchingThread: Thread = { + id: "sess-win", + sessionId: "sess-win", + parentThreadId: null, + threadSource: null, + forkedFromId: null, + preview: "Windows session", + ephemeral: false, + modelProvider: "openai", + createdAt: 100, + updatedAt: 200, + recencyAt: null, + status: { type: "idle" }, + path: null, + cwd: "D:\\workspace\\sample-project\\", + cliVersion: "0.0.0", + section: null, + sectionEnteredAt: null, + source: "cli", + agentNickname: null, + agentRole: null, + gitInfo: null, + name: null, + turns: [], + }; + const otherThread: Thread = { + ...matchingThread, + id: "sess-other", + sessionId: "sess-other", + preview: "Other session", + cwd: "D:\\workspace\\other-project", + }; + + codexAppServerClient.threadList = vi.fn().mockResolvedValue({ + data: [matchingThread, otherThread], + nextCursor: null, + }); + + const response = await codexAcpAgent.listSessions({ + cwd: "d:/workspace/sample-project", + cursor: null, + }); + + expect(response.sessions).toEqual([{ + sessionId: "sess-win", + cwd: "D:\\workspace\\sample-project\\", + title: "Windows session", + updatedAt: "1970-01-01T00:03:20.000Z", + }]); + + const basenameResponse = await codexAcpAgent.listSessions({ + cwd: "sample-project", + cursor: null, + }); + + expect(basenameResponse.sessions.map(session => session.sessionId)).toEqual(["sess-win"]); + }); + it("should prefer the explicit thread name as the session title", async () => { const fixture = createCodexMockTestFixture(); const codexAcpAgent = fixture.getCodexAcpAgent(); diff --git a/src/__tests__/PathUtils.test.ts b/src/__tests__/PathUtils.test.ts new file mode 100644 index 00000000..78ff173d --- /dev/null +++ b/src/__tests__/PathUtils.test.ts @@ -0,0 +1,46 @@ +import {describe, expect, it} from "vitest"; +import { + arePathBasenamesEqual, + arePathsEqual, + isAbsolutePathLike, + normalizePathForComparison, +} from "../PathUtils"; + +describe("PathUtils", () => { + it("normalizes Windows paths for comparison", () => { + expect(arePathsEqual( + "D:\\workspace\\sample-project\\", + "d:/workspace/sample-project", + )).toBe(true); + expect(normalizePathForComparison("D:\\workspace\\sample-project\\")) + .toBe("d:/workspace/sample-project"); + }); + + it("keeps POSIX paths case-sensitive", () => { + expect(arePathsEqual("/repo/project", "/repo/project/")).toBe(true); + expect(arePathsEqual("/repo/project", "/repo/Project")).toBe(false); + }); + + it("detects Windows absolute paths on any host platform", () => { + expect(isAbsolutePathLike("D:/workspace/sample-project")).toBe(true); + expect(isAbsolutePathLike("D:\\workspace\\sample-project")).toBe(true); + expect(isAbsolutePathLike("\\\\Server\\Share\\Project")).toBe(true); + expect(isAbsolutePathLike("sample-project")).toBe(false); + }); + + it("compares Windows basenames case-insensitively", () => { + expect(arePathBasenamesEqual( + "D:\\workspace\\sample-project\\", + "SAMPLE-PROJECT", + )).toBe(true); + expect(arePathBasenamesEqual( + "D:\\workspace\\sample-project\\", + "other-project", + )).toBe(false); + }); + + it("preserves UNC share roots while trimming nested trailing separators", () => { + expect(normalizePathForComparison("\\\\Server\\Share\\")).toBe("//server/share/"); + expect(normalizePathForComparison("\\\\Server\\Share\\Project\\")).toBe("//server/share/project"); + }); +});