From 82c988452f4687f8272b6481967c13d2ffd3f9f2 Mon Sep 17 00:00:00 2001 From: Daniil Shushakov <4shushakov@gmail.com> Date: Wed, 22 Jul 2026 14:03:16 +0300 Subject: [PATCH 1/5] fix: filter session list before pagination --- src/CodexAcpClient.ts | 1 + .../CodexACPAgent/list-sessions.test.ts | 71 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 0c7af2078..b475b061f 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -806,6 +806,7 @@ export class CodexAcpClient { cursor: request.cursor ?? null, modelProviders: modelProviders, sourceKinds: sourceKinds, + cwd: requestedCwd, }); const mapThreadToSession = (thread: Thread) => ({ diff --git a/src/__tests__/CodexACPAgent/list-sessions.test.ts b/src/__tests__/CodexACPAgent/list-sessions.test.ts index 845ee0fd7..4a629adb1 100644 --- a/src/__tests__/CodexACPAgent/list-sessions.test.ts +++ b/src/__tests__/CodexACPAgent/list-sessions.test.ts @@ -4,6 +4,51 @@ import { createCodexMockTestFixture } from "../acp-test-utils"; import type { Thread } from "../../app-server/v2"; describe("CodexACPAgent - list sessions", () => { + it.each([ + { + serverCwdBehavior: "string-only", + acceptsRequestedCwd: (cwd: string | string[] | null | undefined) => cwd === "/repo/project", + }, + { + serverCwdBehavior: "string-or-array", + acceptsRequestedCwd: (cwd: string | string[] | null | undefined) => { + const cwds = Array.isArray(cwd) ? cwd : cwd ? [cwd] : []; + return cwds.includes("/repo/project"); + }, + }, + ])("forwards cwd before $serverCwdBehavior thread pagination", async ({acceptsRequestedCwd}) => { + const fixture = createCodexMockTestFixture(); + const codexAcpAgent = fixture.getCodexAcpAgent(); + const codexAcpClient = fixture.getCodexAcpClient(); + const codexAppServerClient = fixture.getCodexAppServerClient(); + + codexAcpClient.authRequired = vi.fn().mockResolvedValue(false); + + const matchingThread = createThread("sess-project", "/repo/project"); + const unrelatedThread = createThread("sess-other", "/repo/other"); + codexAppServerClient.threadList = vi.fn().mockImplementation(async (params) => { + if (acceptsRequestedCwd(params.cwd)) { + return {data: [matchingThread], nextCursor: null}; + } + return {data: [unrelatedThread], nextCursor: "unrelated-global-cursor"}; + }); + + const response = await codexAcpAgent.listSessions({ + cwd: "/repo/project", + cursor: null, + }); + + expect(response).toEqual({ + sessions: [{ + sessionId: "sess-project", + cwd: "/repo/project", + title: "Session sess-project", + updatedAt: "1970-01-01T00:03:20.000Z", + }], + nextCursor: null, + }); + }); + it("should list sessions filtered by cwd", async () => { const fixture = createCodexMockTestFixture(); const codexAcpAgent = fixture.getCodexAcpAgent(); @@ -215,3 +260,29 @@ describe("CodexACPAgent - list sessions", () => { expect(response.sessions[0]?.additionalDirectories).toEqual(["/repo/extra"]); }); }); + +function createThread(id: string, cwd: string): Thread { + return { + id, + sessionId: id, + parentThreadId: null, + threadSource: null, + forkedFromId: null, + preview: `Session ${id}`, + ephemeral: false, + modelProvider: "openai", + createdAt: 100, + updatedAt: 200, + recencyAt: null, + status: {type: "idle"}, + path: null, + cwd, + cliVersion: "0.0.0", + source: "cli", + agentNickname: null, + agentRole: null, + gitInfo: null, + name: null, + turns: [], + }; +} From 002b0e455036a99dfa7437cf0f398c92c0168315 Mon Sep 17 00:00:00 2001 From: Daniil Shushakov <4shushakov@gmail.com> Date: Wed, 22 Jul 2026 14:12:53 +0300 Subject: [PATCH 2/5] fix: match CLI session ordering --- src/CodexAcpClient.ts | 2 + .../CodexACPAgent/list-sessions.test.ts | 43 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index b475b061f..bfe197694 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -807,6 +807,8 @@ export class CodexAcpClient { modelProviders: modelProviders, sourceKinds: sourceKinds, cwd: requestedCwd, + sortKey: "updated_at", + sortDirection: "desc", }); const mapThreadToSession = (thread: Thread) => ({ diff --git a/src/__tests__/CodexACPAgent/list-sessions.test.ts b/src/__tests__/CodexACPAgent/list-sessions.test.ts index 4a629adb1..2ae9286e8 100644 --- a/src/__tests__/CodexACPAgent/list-sessions.test.ts +++ b/src/__tests__/CodexACPAgent/list-sessions.test.ts @@ -49,6 +49,49 @@ describe("CodexACPAgent - list sessions", () => { }); }); + it("forwards cwd, cursor, and CLI ordering across pagination", async () => { + const fixture = createCodexMockTestFixture(); + const codexAcpAgent = fixture.getCodexAcpAgent(); + const codexAcpClient = fixture.getCodexAcpClient(); + const codexAppServerClient = fixture.getCodexAppServerClient(); + + codexAcpClient.authRequired = vi.fn().mockResolvedValue(false); + codexAppServerClient.threadList = vi.fn() + .mockResolvedValueOnce({ + data: [createThread("sess-newer", "/repo/project")], + nextCursor: "project-page-2", + }) + .mockResolvedValueOnce({ + data: [createThread("sess-older", "/repo/project")], + nextCursor: null, + }); + + const firstPage = await codexAcpAgent.listSessions({ + cwd: "/repo/project", + cursor: null, + }); + await codexAcpAgent.listSessions({ + cwd: "/repo/project", + cursor: firstPage.nextCursor ?? null, + }); + + expect(codexAppServerClient.threadList).toHaveBeenNthCalledWith(1, expect.objectContaining({ + cwd: "/repo/project", + cursor: null, + sortKey: "updated_at", + sortDirection: "desc", + })); + expect(codexAppServerClient.threadList).toHaveBeenNthCalledWith(2, expect.objectContaining({ + cwd: "/repo/project", + cursor: "project-page-2", + sortKey: "updated_at", + sortDirection: "desc", + })); + for (const [params] of vi.mocked(codexAppServerClient.threadList).mock.calls) { + expect(params).not.toHaveProperty("limit"); + } + }); + it("should list sessions filtered by cwd", async () => { const fixture = createCodexMockTestFixture(); const codexAcpAgent = fixture.getCodexAcpAgent(); From 48bee78a73285ad84732e15dac3d45bfe3b9cd3e Mon Sep 17 00:00:00 2001 From: Daniil Shushakov <4shushakov@gmail.com> Date: Wed, 22 Jul 2026 14:14:39 +0300 Subject: [PATCH 3/5] refactor: rely on App Server session filtering --- src/CodexAcpClient.ts | 22 +------------------ .../CodexACPAgent/list-sessions.test.ts | 6 ++--- 2 files changed, 4 insertions(+), 24 deletions(-) diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index bfe197694..37f2c9fa2 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -791,14 +791,6 @@ export class CodexAcpClient { "unknown", ]; const requestedCwd = request.cwd?.trim() ?? null; - const filterByCwd = (thread: Thread): boolean => { - if (!requestedCwd) return true; - if (path.isAbsolute(requestedCwd)) { - return thread.cwd === requestedCwd; - } - const requestedBase = path.basename(requestedCwd); - return path.basename(thread.cwd) === requestedBase; - }; const preferredProvider = this.getModelProvider(); const modelProviders = preferredProvider ? [preferredProvider] : []; @@ -823,20 +815,8 @@ export class CodexAcpClient { logger.log("Session list diagnostics", diagnostics); } - let sessions = listResponse.data.map(mapThreadToSession); - if (requestedCwd) { - const filtered = listResponse.data - .filter(filterByCwd) - .map(mapThreadToSession); - if (filtered.length > 0 || path.isAbsolute(requestedCwd)) { - sessions = filtered; - } else { - logger.log("Ignoring non-absolute cwd filter for session/list", {cwd: requestedCwd}); - } - } - return { - sessions, + sessions: listResponse.data.map(mapThreadToSession), nextCursor: listResponse.nextCursor ?? null, }; } diff --git a/src/__tests__/CodexACPAgent/list-sessions.test.ts b/src/__tests__/CodexACPAgent/list-sessions.test.ts index 2ae9286e8..e7203100a 100644 --- a/src/__tests__/CodexACPAgent/list-sessions.test.ts +++ b/src/__tests__/CodexACPAgent/list-sessions.test.ts @@ -147,10 +147,10 @@ describe("CodexACPAgent - list sessions", () => { turns: [], }; - codexAppServerClient.threadList = vi.fn().mockResolvedValue({ - data: [threadA, threadB], + codexAppServerClient.threadList = vi.fn().mockImplementation(async (params) => ({ + data: [threadA, threadB].filter(thread => thread.cwd === params.cwd), nextCursor: "next-cursor", - }); + })); codexAppServerClient.threadLoadedList = vi.fn().mockResolvedValue({ data: [], nextCursor: null, From 4f1cbe3aaf49fae3608315b25d80dc71774868af Mon Sep 17 00:00:00 2001 From: Daniil Shushakov <4shushakov@gmail.com> Date: Wed, 22 Jul 2026 14:29:51 +0300 Subject: [PATCH 4/5] fix: skip diagnostics for filtered sessions --- src/CodexAcpClient.ts | 2 +- .../CodexACPAgent/list-sessions.test.ts | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 37f2c9fa2..f73552197 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -810,7 +810,7 @@ export class CodexAcpClient { updatedAt: new Date(thread.updatedAt * 1000).toISOString(), }); - if (listResponse.data.length === 0) { + if (!requestedCwd && listResponse.data.length === 0) { const diagnostics = await this.runSessionListDiagnostics(); logger.log("Session list diagnostics", diagnostics); } diff --git a/src/__tests__/CodexACPAgent/list-sessions.test.ts b/src/__tests__/CodexACPAgent/list-sessions.test.ts index e7203100a..63afd7510 100644 --- a/src/__tests__/CodexACPAgent/list-sessions.test.ts +++ b/src/__tests__/CodexACPAgent/list-sessions.test.ts @@ -92,6 +92,27 @@ describe("CodexACPAgent - list sessions", () => { } }); + it("returns an empty filtered page without global diagnostics", async () => { + const fixture = createCodexMockTestFixture(); + const codexAcpAgent = fixture.getCodexAcpAgent(); + const codexAcpClient = fixture.getCodexAcpClient(); + const codexAppServerClient = fixture.getCodexAppServerClient(); + + codexAcpClient.authRequired = vi.fn().mockResolvedValue(false); + codexAppServerClient.threadList = vi.fn().mockResolvedValue({ + data: [], + nextCursor: null, + }); + + const response = await codexAcpAgent.listSessions({ + cwd: "/project/new-worktree", + cursor: null, + }); + + expect(response).toEqual({sessions: [], nextCursor: null}); + expect(codexAppServerClient.threadList).toHaveBeenCalledOnce(); + }); + it("should list sessions filtered by cwd", async () => { const fixture = createCodexMockTestFixture(); const codexAcpAgent = fixture.getCodexAcpAgent(); From 69401de020ea9d998b2e07910bcae1d55268d0a3 Mon Sep 17 00:00:00 2001 From: Daniil Shushakov <4shushakov@gmail.com> Date: Tue, 18 Aug 2026 16:41:07 +0300 Subject: [PATCH 5/5] fix: retain cwd path normalization --- src/CodexAcpClient.ts | 23 ++++++- src/PathUtils.ts | 60 +++++++++++++++++ .../CodexACPAgent/list-sessions.test.ts | 66 +++++++++++++++++++ src/__tests__/PathUtils.test.ts | 46 +++++++++++++ 4 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 src/PathUtils.ts create mode 100644 src/__tests__/PathUtils.test.ts diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index c2a1dedb1..f65ed1c47 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"; import { AGENT_FILE_CHANGE_REPORT_DEVELOPER_INSTRUCTIONS, AGENT_FILE_CHANGE_REPORT_OUTPUT_SCHEMA, @@ -1053,6 +1054,13 @@ export class CodexAcpClient { "unknown", ]; const requestedCwd = request.cwd?.trim() ?? null; + const filterByCwd = (thread: Thread): boolean => { + if (!requestedCwd) return true; + if (isAbsolutePathLike(requestedCwd)) { + return arePathsEqual(thread.cwd, requestedCwd); + } + return arePathBasenamesEqual(thread.cwd, requestedCwd); + }; const preferredProvider = this.getModelProvider(); const modelProviders = preferredProvider ? [preferredProvider] : []; @@ -1077,8 +1085,21 @@ export class CodexAcpClient { logger.log("Session list diagnostics", diagnostics); } + // App Server owns pagination; normalize the returned page for ACP path compatibility. + let sessions = listResponse.data.map(mapThreadToSession); + if (requestedCwd) { + const filtered = listResponse.data + .filter(filterByCwd) + .map(mapThreadToSession); + if (filtered.length > 0 || isAbsolutePathLike(requestedCwd)) { + sessions = filtered; + } else { + logger.log("Ignoring non-absolute cwd filter for session/list", {cwd: requestedCwd}); + } + } + return { - sessions: listResponse.data.map(mapThreadToSession), + sessions, nextCursor: listResponse.nextCursor ?? null, }; } diff --git a/src/PathUtils.ts b/src/PathUtils.ts new file mode 100644 index 000000000..7c96559a4 --- /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 985614126..dc296b611 100644 --- a/src/__tests__/CodexACPAgent/list-sessions.test.ts +++ b/src/__tests__/CodexACPAgent/list-sessions.test.ts @@ -202,6 +202,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 000000000..78ff173db --- /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"); + }); +});