diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 0c7af207..f7355219 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] : []; @@ -806,6 +798,9 @@ export class CodexAcpClient { cursor: request.cursor ?? null, modelProviders: modelProviders, sourceKinds: sourceKinds, + cwd: requestedCwd, + sortKey: "updated_at", + sortDirection: "desc", }); const mapThreadToSession = (thread: Thread) => ({ @@ -815,25 +810,13 @@ 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); } - 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 845ee0fd..63afd751 100644 --- a/src/__tests__/CodexACPAgent/list-sessions.test.ts +++ b/src/__tests__/CodexACPAgent/list-sessions.test.ts @@ -4,6 +4,115 @@ 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("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("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(); @@ -59,10 +168,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, @@ -215,3 +324,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: [], + }; +}