Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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});
Expand Down
60 changes: 60 additions & 0 deletions src/PathUtils.ts
Original file line number Diff line number Diff line change
@@ -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);
}
66 changes: 66 additions & 0 deletions src/__tests__/CodexACPAgent/list-sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
46 changes: 46 additions & 0 deletions src/__tests__/PathUtils.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});