Skip to content

Commit a2e2a86

Browse files
committed
Add e2e test for session persistence
1 parent 4fc31a7 commit a2e2a86

3 files changed

Lines changed: 106 additions & 28 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import {afterEach, expect, it} from "vitest";
2+
import {AgentMode} from "../../../AgentMode";
3+
import {
4+
createAuthenticatedFixture,
5+
describeE2E,
6+
OTHER_TEST_MODEL_ID,
7+
type SpawnedAgentFixture,
8+
} from "./acp-e2e-test-utils";
9+
10+
describeE2E("E2E session persistence tests", () => {
11+
let beforeRestartFixture: SpawnedAgentFixture | null = null;
12+
let afterRestartFixture: SpawnedAgentFixture | null = null;
13+
14+
afterEach(async () => {
15+
await afterRestartFixture?.dispose();
16+
await beforeRestartFixture?.dispose();
17+
afterRestartFixture = null;
18+
beforeRestartFixture = null;
19+
});
20+
21+
it("persists a session across ACP process restart", async () => {
22+
beforeRestartFixture = await createAuthenticatedFixture();
23+
const sessionId = (await beforeRestartFixture.createSession()).sessionId;
24+
25+
await beforeRestartFixture.connection.unstable_setSessionModel({sessionId, modelId: OTHER_TEST_MODEL_ID.toString()});
26+
const memorizedToken = "token-for-tests-123";
27+
await beforeRestartFixture.expectPromptText(
28+
sessionId,
29+
`Remember this token - "${memorizedToken}". Reply with exactly before-restart-ok and nothing else.`,
30+
(text) => expect(text.toLowerCase()).toContain("before-restart-ok"),
31+
);
32+
33+
afterRestartFixture = await beforeRestartFixture.restart();
34+
35+
const loadSessionResponse = await afterRestartFixture.connection.loadSession({
36+
sessionId,
37+
cwd: afterRestartFixture.workspaceDir,
38+
mcpServers: [],
39+
});
40+
expect(loadSessionResponse.models?.currentModelId).toBe(OTHER_TEST_MODEL_ID.toString());
41+
42+
await afterRestartFixture.expectPromptText(
43+
sessionId,
44+
"What token did I ask you to remember earlier? Reply with just the token and nothing else.",
45+
(text) => expect(text.toLowerCase()).toContain(memorizedToken),
46+
);
47+
});
48+
});

src/__tests__/CodexACPAgent/e2e/acp-e2e-test-utils.ts

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -103,24 +103,22 @@ async function createSpawnedFixture(
103103
authenticate: Authenticator,
104104
extraEnv?: NodeJS.ProcessEnv,
105105
): Promise<SpawnedAgentFixture> {
106-
const fixture = createSpawnedAgentFixture(extraEnv);
107-
const connection = fixture.connection;
108-
109-
const initializeResponse = await connection.initialize({
110-
protocolVersion: acp.PROTOCOL_VERSION,
111-
clientCapabilities: buildClientCapabilities(),
112-
clientInfo: {
113-
name: "vitest",
114-
version: "1.0.0",
115-
},
116-
});
106+
return await createSpawnedAgentFixture(async (connection) => {
107+
const initializeResponse = await connection.initialize({
108+
protocolVersion: acp.PROTOCOL_VERSION,
109+
clientCapabilities: buildClientCapabilities(),
110+
clientInfo: {
111+
name: "vitest",
112+
version: "1.0.0",
113+
},
114+
});
117115

118-
if (initializeResponse.protocolVersion !== acp.PROTOCOL_VERSION) {
119-
throw new Error(`Unexpected protocol version: ${initializeResponse.protocolVersion}`);
120-
}
116+
if (initializeResponse.protocolVersion !== acp.PROTOCOL_VERSION) {
117+
throw new Error(`Unexpected protocol version: ${initializeResponse.protocolVersion}`);
118+
}
121119

122-
await authenticate(connection, initializeResponse.authMethods ?? []);
123-
return fixture;
120+
await authenticate(connection, initializeResponse.authMethods ?? []);
121+
}, extraEnv);
124122
}
125123

126124
export function requireLiveApiKey(): string {

src/__tests__/CodexACPAgent/e2e/spawned-agent-fixture.ts

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export interface SpawnedAgentFixture {
2222
readonly connection: acp.ClientSideConnection;
2323
readonly workspaceDir: string;
2424
createSession(): Promise<acp.NewSessionResponse>;
25+
restart(): Promise<SpawnedAgentFixture>;
2526
writeSkill(skill: TestSkill): void;
2627
setPermissionResponder(responder: PermissionResponder): void;
2728
expectPromptText(
@@ -38,16 +39,13 @@ export interface SpawnedAgentFixture {
3839
dispose(): Promise<void>;
3940
}
4041

41-
export function createSpawnedAgentFixture(
42-
extraEnv?: NodeJS.ProcessEnv,
43-
): SpawnedAgentFixture {
44-
const paths = RuntimePaths.createTemporary();
45-
writeCodexHomeConfig(paths.codexHome, {
46-
model: DEFAULT_TEST_MODEL_ID.model,
47-
model_reasoning_effort: DEFAULT_TEST_MODEL_ID.effort,
48-
web_search: "disabled",
49-
});
42+
type ConnectionInitializer = (connection: acp.ClientSideConnection) => Promise<void>;
5043

44+
export async function createSpawnedAgentFixture(
45+
initializeConnection: ConnectionInitializer,
46+
extraEnv?: NodeJS.ProcessEnv,
47+
paths = RuntimePaths.createTemporary(),
48+
): Promise<SpawnedAgentFixture> {
5149
const agentProcess = spawn("npm", ["run", "--silent", "start"], {
5250
cwd: process.cwd(),
5351
env: {
@@ -59,7 +57,15 @@ export function createSpawnedAgentFixture(
5957
stdio: ["pipe", "pipe", "pipe"],
6058
});
6159

62-
return new SpawnedAgentFixtureImpl(new RecordingClient(), agentProcess, paths);
60+
const fixture = new SpawnedAgentFixtureImpl(
61+
new RecordingClient(),
62+
agentProcess,
63+
paths,
64+
initializeConnection,
65+
extraEnv,
66+
);
67+
await initializeConnection(fixture.connection);
68+
return fixture;
6369
}
6470

6571
class RuntimePaths {
@@ -79,6 +85,11 @@ class RuntimePaths {
7985
for (const dir of [paths.codexHome, paths.workspaceDir, paths.appServerLogsDir]) {
8086
fs.mkdirSync(dir, {recursive: true});
8187
}
88+
writeCodexHomeConfig(paths.codexHome, {
89+
model: DEFAULT_TEST_MODEL_ID.model,
90+
model_reasoning_effort: DEFAULT_TEST_MODEL_ID.effort,
91+
web_search: "disabled",
92+
});
8293
return paths;
8394
}
8495
}
@@ -128,11 +139,14 @@ class RecordingClient implements acp.Client {
128139

129140
class SpawnedAgentFixtureImpl implements SpawnedAgentFixture {
130141
readonly connection: acp.ClientSideConnection;
142+
private disposed = false;
131143

132144
constructor(
133145
private readonly client: RecordingClient,
134146
private readonly agentProcess: ChildProcessWithoutNullStreams,
135147
private readonly paths: RuntimePaths,
148+
private readonly initializeConnection: ConnectionInitializer,
149+
private readonly extraEnv?: NodeJS.ProcessEnv,
136150
) {
137151
const output = Readable.toWeb(agentProcess.stdout) as ReadableStream<Uint8Array>;
138152
this.connection = new acp.ClientSideConnection(
@@ -152,6 +166,11 @@ class SpawnedAgentFixtureImpl implements SpawnedAgentFixture {
152166
});
153167
}
154168

169+
async restart(): Promise<SpawnedAgentFixture> {
170+
await this.stopProcess(false);
171+
return await createSpawnedAgentFixture(this.initializeConnection, this.extraEnv, this.paths);
172+
}
173+
155174
writeSkill(skill: TestSkill): void {
156175
const skillDirectory = path.join(this.paths.codexHome, "skills", skill.name);
157176
fs.mkdirSync(skillDirectory, {recursive: true});
@@ -211,6 +230,15 @@ class SpawnedAgentFixtureImpl implements SpawnedAgentFixture {
211230
}
212231

213232
async dispose(): Promise<void> {
233+
if (this.disposed) {
234+
return;
235+
}
236+
this.disposed = true;
237+
await this.stopProcess(true);
238+
removeDirectoryWithRetry(this.paths.rootDir);
239+
}
240+
241+
private async stopProcess(printLogs: boolean): Promise<void> {
214242
if (!this.agentProcess.stdin.destroyed && !this.agentProcess.stdin.writableEnded) {
215243
this.agentProcess.stdin.end();
216244
}
@@ -221,12 +249,16 @@ class SpawnedAgentFixtureImpl implements SpawnedAgentFixture {
221249
await waitForProcessExit(this.agentProcess, 4_000);
222250
}
223251

224-
printLogDirectory(this.paths.appServerLogsDir);
225-
removeDirectoryWithRetry(this.paths.rootDir);
252+
if (printLogs) {
253+
printLogDirectory(this.paths.appServerLogsDir);
254+
}
226255
}
227256
}
228257

229258
function printLogDirectory(logDirectory: string): void {
259+
if (!fs.existsSync(logDirectory)) {
260+
return;
261+
}
230262
fs.readdirSync(logDirectory, {withFileTypes: true})
231263
.filter((entry) => entry.isFile())
232264
.forEach((entry) => {

0 commit comments

Comments
 (0)