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
4 changes: 4 additions & 0 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,10 @@ export class CodexAcpClient {
}
}

async deleteSession(sessionId: string): Promise<void> {
await this.codexClient.threadArchive({threadId: sessionId});
}

async awaitMcpServerStartup(serverNames: Array<string>, afterVersion: number): Promise<McpStartupResult> {
return await this.codexClient.awaitMcpServerStartup(serverNames, afterVersion);
}
Expand Down
36 changes: 36 additions & 0 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ export class CodexAcpServer implements acp.Agent {
resume: { },
list: { },
close: { },
delete: { },
},
mcpCapabilities: {
acp: false,
Expand Down Expand Up @@ -455,6 +456,41 @@ export class CodexAcpServer implements acp.Agent {
return {};
}

async unstable_deleteSession(params: acp.DeleteSessionRequest): Promise<acp.DeleteSessionResponse> {
logger.log("Deleting session...", {sessionId: params.sessionId});
const sessionId = params.sessionId;
const shouldCloseLocalSession = this.hasLocalSession(sessionId);

this.beginSessionCloseFence(sessionId);
try {
if (shouldCloseLocalSession) {
await this.closeSession({sessionId});
} else {
this.bumpSessionGeneration(sessionId);
}

await this.runWithProcessCheck(() => this.codexAcpClient.deleteSession(sessionId));
logger.log("Session deleted", {sessionId});
} finally {
this.endSessionCloseFence(sessionId);
}

return {};
}

private hasLocalSession(sessionId: string): boolean {
return this.sessions.has(sessionId)
|| this.pendingMcpStartupSessions.has(sessionId)
|| this.pendingTurnStarts.has(sessionId)
|| this.activePrompts.has(sessionId)
|| this.hasPendingSessionOpen(sessionId)
|| this.sessionIsClosing(sessionId);
}

private hasPendingSessionOpen(sessionId: string): boolean {
return this.sessionOpenGenerations.get(sessionId) === this.getSessionGeneration(sessionId);
}

async newSession(
params: acp.NewSessionRequest,
): Promise<acp.NewSessionResponse> {
Expand Down
6 changes: 6 additions & 0 deletions src/CodexAppServerClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import type {
SkillsExtraRootsSetParams,
SkillsListParams,
SkillsListResponse,
ThreadArchiveParams,
ThreadArchiveResponse,
ThreadLoadedListParams,
ThreadLoadedListResponse,
ThreadListParams,
Expand Down Expand Up @@ -241,6 +243,10 @@ export class CodexAppServerClient {
return await this.sendRequest({ method: "thread/read", params: params });
}

async threadArchive(params: ThreadArchiveParams): Promise<ThreadArchiveResponse> {
return await this.sendRequest({ method: "thread/archive", params: params });
}

async threadUnsubscribe(params: ThreadUnsubscribeParams): Promise<ThreadUnsubscribeResponse> {
return await this.sendRequest({ method: "thread/unsubscribe", params: params });
}
Expand Down
31 changes: 31 additions & 0 deletions src/__tests__/CodexACPAgent/data/session-delete-active-turn.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"eventType": "request",
"method": "turn/interrupt",
"params": {
"threadId": "session-id",
"turnId": "turn-id"
}
}
{
"eventType": "response"
}
{
"eventType": "request",
"method": "thread/unsubscribe",
"params": {
"threadId": "session-id"
}
}
{
"eventType": "response"
}
{
"eventType": "request",
"method": "thread/archive",
"params": {
"threadId": "session-id"
}
}
{
"eventType": "response"
}
20 changes: 20 additions & 0 deletions src/__tests__/CodexACPAgent/data/session-delete-idle.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"eventType": "request",
"method": "thread/unsubscribe",
"params": {
"threadId": "session-id"
}
}
{
"eventType": "response"
}
{
"eventType": "request",
"method": "thread/archive",
"params": {
"threadId": "session-id"
}
}
{
"eventType": "response"
}
10 changes: 10 additions & 0 deletions src/__tests__/CodexACPAgent/data/session-delete-unknown-local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"eventType": "request",
"method": "thread/archive",
"params": {
"threadId": "session-id"
}
}
{
"eventType": "response"
}
1 change: 1 addition & 0 deletions src/__tests__/CodexACPAgent/initialize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ describe('CodexACPAgent - initialize', () => {
resume: {},
list: {},
close: {},
delete: {},
},
mcpCapabilities: {
acp: false,
Expand Down
147 changes: 147 additions & 0 deletions src/__tests__/CodexACPAgent/session-delete.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import {describe, expect, it, vi} from "vitest";
import {
createCodexMockTestFixture,
createTestModel,
type CodexMockTestFixture,
} from "../acp-test-utils";
import type {CodexAcpServer} from "../../CodexAcpServer";
import type {CodexAcpClient} from "../../CodexAcpClient";

const sessionId = "session-id";

describe("ACP session delete", () => {
it("advertises session delete support", async () => {
const fixture = createCodexMockTestFixture();

const response = await fixture.getCodexAcpAgent().initialize({protocolVersion: 1});

expect(response.agentCapabilities?.sessionCapabilities?.delete).toEqual({});
});

it("archives sessions that are not active locally", async () => {
const fixture = createCodexMockTestFixture();

await expect(fixture.getCodexAcpAgent().unstable_deleteSession({sessionId})).resolves.toEqual({});

await expect(fixture.getCodexConnectionDump([])).toMatchFileSnapshot(
"data/session-delete-unknown-local.json"
);
});

it("closes local session resources before archiving", async () => {
const {fixture, codexAcpAgent} = await createSession();

await expect(codexAcpAgent.unstable_deleteSession({sessionId})).resolves.toEqual({});

await expect(fixture.getCodexConnectionDump([])).toMatchFileSnapshot(
"data/session-delete-idle.json"
);
expect(() => codexAcpAgent.getSessionState(sessionId)).toThrow(`Session ${sessionId} not found`);
});

it("does not close again when deleting a previously closed session", async () => {
const {fixture, codexAcpAgent} = await createSession();

await codexAcpAgent.closeSession({sessionId});
fixture.clearCodexConnectionDump();

await expect(codexAcpAgent.unstable_deleteSession({sessionId})).resolves.toEqual({});

await expect(fixture.getCodexConnectionDump([])).toMatchFileSnapshot(
"data/session-delete-unknown-local.json"
);
});

it("interrupts active turns before archiving", async () => {
const {fixture, codexAcpAgent} = await createSession();
codexAcpAgent.getSessionState(sessionId).currentTurnId = "turn-id";

await expect(codexAcpAgent.unstable_deleteSession({sessionId})).resolves.toEqual({});

await expect(fixture.getCodexConnectionDump([])).toMatchFileSnapshot(
"data/session-delete-active-turn.json"
);
expect(() => codexAcpAgent.getSessionState(sessionId)).toThrow(`Session ${sessionId} not found`);
});

it("rejects resume while unknown-local delete archive is in flight", async () => {
const fixture = createCodexMockTestFixture();
const codexAcpAgent = fixture.getCodexAcpAgent();
const codexAcpClient = fixture.getCodexAcpClient();
const archive = deferred<void>();
vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false);
vi.spyOn(codexAcpClient, "deleteSession").mockReturnValue(archive.promise);
const resumeSessionSpy = vi.spyOn(codexAcpClient, "resumeSession");

const deletePromise = codexAcpAgent.unstable_deleteSession({sessionId});
await vi.waitFor(() => {
expect(codexAcpClient.deleteSession).toHaveBeenCalledWith(sessionId);
});

await expect(codexAcpAgent.resumeSession({
sessionId,
cwd: "/test/cwd",
mcpServers: [],
})).rejects.toThrow("Invalid request");
expect(resumeSessionSpy).not.toHaveBeenCalled();

archive.resolve(undefined);
await expect(deletePromise).resolves.toEqual({});
});

it("rejects load after local close completes while delete archive is in flight", async () => {
const {codexAcpAgent, codexAcpClient} = await createSession();
const archive = deferred<void>();
vi.spyOn(codexAcpClient, "deleteSession").mockReturnValue(archive.promise);
const loadSessionSpy = vi.spyOn(codexAcpClient, "loadSession");

const deletePromise = codexAcpAgent.unstable_deleteSession({sessionId});
await vi.waitFor(() => {
expect(codexAcpClient.deleteSession).toHaveBeenCalledWith(sessionId);
});

await expect(codexAcpAgent.loadSession({
sessionId,
cwd: "/test/cwd",
mcpServers: [],
})).rejects.toThrow("Invalid request");
expect(loadSessionSpy).not.toHaveBeenCalled();

archive.resolve(undefined);
await expect(deletePromise).resolves.toEqual({});
});
});

async function createSession(): Promise<{
fixture: CodexMockTestFixture,
codexAcpAgent: CodexAcpServer,
codexAcpClient: CodexAcpClient,
}> {
const fixture = createCodexMockTestFixture();
const codexAcpAgent = fixture.getCodexAcpAgent();
const codexAcpClient = fixture.getCodexAcpClient();
const model = createTestModel();

vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false);
vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false});
vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({
sessionId,
currentModelId: "model-id[medium]",
models: [model],
currentServiceTier: null,
});

await codexAcpAgent.newSession({cwd: "/test/cwd", mcpServers: []});
fixture.clearCodexConnectionDump();
fixture.clearAcpConnectionDump();

return {fixture, codexAcpAgent, codexAcpClient};
}

function deferred<T>(): {promise: Promise<T>, resolve: (value: T) => void} {
let resolve: (value: T) => void = () => {};
const promise = new Promise<T>((innerResolve) => {
resolve = innerResolve;
});
return {promise, resolve};
}
Loading