Fix external session import and add Claude persistence test

- Fix experimental_resume by parsing rollout history and injecting as
  developer-instructions (experimental_resume was removed from Codex MCP)
- Add parseRolloutHistory() to extract conversation from JSONL transcripts
- Set pendingResumeFile from config.extra.codex.experimental_resume
- Add Claude session persistence test (remembers number across sessions)
- Remove Playwright tests (not used in this codebase)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Mohamed Boudra
2025-12-27 07:55:54 +00:00
parent 9d941a1509
commit 076080db48
4 changed files with 193 additions and 53 deletions

View File

@@ -1,30 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{
name: 'Mobile Chrome',
use: { ...devices['iPhone 12'] },
},
{
name: 'Desktop Chrome',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
});

View File

@@ -1,6 +1,6 @@
import { execSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { promises as fs, readdirSync, statSync, type Dirent } from "node:fs";
import { promises as fs, readdirSync, readFileSync, statSync, type Dirent } from "node:fs";
import os from "node:os";
import path from "node:path";
@@ -2383,14 +2383,21 @@ function buildCodexMcpConfig(
// Build the config payload with MCP servers
const innerConfig: CodexConfigPayload = {};
// Add extra codex config if provided
// Add extra codex config if provided (but filter out experimental_resume since it's deprecated)
if (config.extra?.codex) {
Object.assign(innerConfig, config.extra.codex);
const { experimental_resume: _, ...codexConfig } = config.extra.codex as Record<string, unknown>;
Object.assign(innerConfig, codexConfig);
}
// Add experimental_resume if we're resuming from a previous session
// Parse and inject conversation history if resuming from a previous session
// Note: experimental_resume was deprecated/removed from Codex MCP server.
// Instead, we parse the rollout file and inject history as developer instructions.
let developerInstructions: string | undefined;
if (experimentalResume) {
innerConfig.experimental_resume = experimentalResume;
const history = parseRolloutHistory(experimentalResume);
if (history) {
developerInstructions = history;
}
}
// Build MCP servers configuration
@@ -2438,6 +2445,7 @@ function buildCodexMcpConfig(
sandbox: string;
config?: CodexConfigPayload;
model?: string;
"developer-instructions"?: string;
} = {
prompt,
cwd: config.cwd,
@@ -2453,6 +2461,12 @@ function buildCodexMcpConfig(
if (typeof config.model === "string" && config.model.length > 0) {
configPayload.model = config.model;
}
// Add developer instructions for session resume context
if (developerInstructions) {
configPayload["developer-instructions"] = developerInstructions;
}
return configPayload;
}
@@ -2520,6 +2534,57 @@ function findCodexResumeFile(sessionId: string | null): string | null {
}
}
/**
* Parse a Codex rollout JSONL file and extract the conversation history.
* Returns a formatted string with the previous conversation that can be
* injected as context into a new session.
*/
function parseRolloutHistory(rolloutPath: string): string | null {
try {
const content = readFileSync(rolloutPath, "utf-8");
const lines = content.split("\n").filter((line) => line.trim());
const messages: { role: "user" | "assistant"; text: string }[] = [];
for (const line of lines) {
try {
const entry = JSON.parse(line);
// Extract user and assistant messages from response_item entries
if (entry.type === "response_item" && entry.payload?.type === "message") {
const role = entry.payload.role as "user" | "assistant";
const contentItems = entry.payload.content;
if (Array.isArray(contentItems)) {
for (const item of contentItems) {
// User messages have input_text, assistant messages have output_text
const text = item.text || item.input_text || item.output_text;
if (text && (role === "user" || role === "assistant")) {
// Skip environment context messages
if (text.includes("<environment_context>")) continue;
messages.push({ role, text });
}
}
}
}
} catch {
// Skip malformed lines
}
}
if (messages.length === 0) return null;
// Format as conversation history
const formatted = messages
.map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.text}`)
.join("\n\n");
return `<previous_conversation>\nThis is a continuation of a previous session. Here is the conversation history:\n\n${formatted}\n</previous_conversation>`;
} catch {
return null;
}
}
class Pushable<T> implements AsyncIterable<T> {
private queue: T[] = [];
private resolvers: ((value: IteratorResult<T>) => void)[] = [];
@@ -2622,6 +2687,12 @@ class CodexMcpAgentSession implements AgentSession {
this.historyPending = true;
}
// Check for external session import via extra.codex.experimental_resume
const extraCodex = config.extra?.codex as Record<string, unknown> | undefined;
if (extraCodex?.experimental_resume && typeof extraCodex.experimental_resume === "string") {
this.pendingResumeFile = extraCodex.experimental_resume;
}
this.client = new Client(
{ name: "voice-dev-codex", version: "1.0.0" },
{ capabilities: { elicitation: {} } }

View File

@@ -2273,11 +2273,7 @@ describe("daemon E2E", () => {
});
describe("external Codex session import", () => {
// TODO: Codex MCP's experimental_resume feature doesn't properly load
// conversation context. The config is passed correctly but Codex starts
// a fresh session instead of resuming. This works with `codex exec resume <sessionId>`
// but not via the MCP tool with experimental_resume file path.
test.skip(
test(
"imports external codex exec session and preserves conversation context",
async () => {
const cwd = tmpCwd();
@@ -2533,6 +2529,122 @@ describe("daemon E2E", () => {
);
});
describe("Claude session persistence", () => {
test(
"persists and resumes Claude agent with conversation history (remembers number)",
async () => {
const cwd = tmpCwd();
// Use a memorable number that we'll ask about later
const magicNumber = 69;
// === STEP 1: Create Claude agent and have it remember a number ===
console.log("[CLAUDE PERSISTENCE TEST] Creating Claude agent...");
const agent = await ctx.client.createAgent({
provider: "claude",
cwd,
title: "Claude Persistence Test",
modeId: "bypassPermissions",
});
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
expect(agent.provider).toBe("claude");
console.log("[CLAUDE PERSISTENCE TEST] Created agent:", agent.id);
// === STEP 2: Ask it to remember the number ===
console.log("[CLAUDE PERSISTENCE TEST] Asking to remember number...");
await ctx.client.sendMessage(
agent.id,
`Remember this number: ${magicNumber}. Just confirm you've remembered it and reply with a single short sentence.`
);
const afterRemember = await ctx.client.waitForAgentIdle(agent.id, 120000);
expect(afterRemember.status).toBe("idle");
expect(afterRemember.lastError).toBeUndefined();
// Verify we got a confirmation response
let queue = ctx.client.getMessageQueue();
const confirmationMessages: string[] = [];
for (const m of queue) {
if (
m.type === "agent_stream" &&
m.payload.agentId === agent.id &&
m.payload.event.type === "timeline"
) {
const item = m.payload.event.item;
if (item.type === "assistant_message" && item.text) {
confirmationMessages.push(item.text);
}
}
}
const confirmationResponse = confirmationMessages.join("");
console.log("[CLAUDE PERSISTENCE TEST] Confirmation response:", JSON.stringify(confirmationResponse));
expect(confirmationResponse.length).toBeGreaterThan(0);
// === STEP 3: Get persistence handle and delete agent ===
expect(afterRemember.persistence).toBeTruthy();
const persistence = afterRemember.persistence;
expect(persistence?.provider).toBe("claude");
expect(persistence?.sessionId).toBeTruthy();
console.log("[CLAUDE PERSISTENCE TEST] Got persistence handle:", persistence?.sessionId);
// Delete the agent
await ctx.client.deleteAgent(agent.id);
console.log("[CLAUDE PERSISTENCE TEST] Deleted agent");
// === STEP 4: Resume the agent using persistence handle ===
console.log("[CLAUDE PERSISTENCE TEST] Resuming agent...");
ctx.client.clearMessageQueue();
const resumedAgent = await ctx.client.resumeAgent(persistence!);
expect(resumedAgent.id).toBeTruthy();
expect(resumedAgent.status).toBe("idle");
expect(resumedAgent.provider).toBe("claude");
console.log("[CLAUDE PERSISTENCE TEST] Resumed agent:", resumedAgent.id);
// === STEP 5: Ask about the remembered number ===
console.log("[CLAUDE PERSISTENCE TEST] Asking about remembered number...");
ctx.client.clearMessageQueue();
await ctx.client.sendMessage(
resumedAgent.id,
"What was the number I asked you to remember earlier? Reply with just the number and nothing else."
);
const afterRecall = await ctx.client.waitForAgentIdle(resumedAgent.id, 120000);
expect(afterRecall.status).toBe("idle");
expect(afterRecall.lastError).toBeUndefined();
// === STEP 6: Verify the response contains the magic number ===
queue = ctx.client.getMessageQueue();
const recallMessages: string[] = [];
for (const m of queue) {
if (
m.type === "agent_stream" &&
m.payload.agentId === resumedAgent.id &&
m.payload.event.type === "timeline"
) {
const item = m.payload.event.item;
if (item.type === "assistant_message" && item.text) {
recallMessages.push(item.text);
}
}
}
const fullResponse = recallMessages.join("");
console.log("[CLAUDE PERSISTENCE TEST] Recall response:", JSON.stringify(fullResponse));
// CRITICAL ASSERTION: The response should contain the magic number
// This proves the Claude agent successfully preserved conversation context
expect(fullResponse).toContain(String(magicNumber));
// Cleanup
await ctx.client.deleteAgent(resumedAgent.id);
rmSync(cwd, { recursive: true, force: true });
},
300000 // 5 minute timeout for multiple Claude API calls
);
});
describe("Claude agent overlapping stream() calls race condition", () => {
test(
"interrupting message should produce coherent text without garbling from race condition",

View File

@@ -1,13 +0,0 @@
import { test, expect } from "@playwright/test";
test("opens the create agent modal", async ({ page }) => {
await page.goto("/?modal=create");
await expect(page.getByText("Create New Agent")).toBeVisible();
await expect(page.getByText("Initial Prompt")).toBeVisible();
await expect(
page.getByPlaceholder("Describe what you want the agent to do")
).toBeVisible();
await expect(page.getByText("Working Directory")).toBeVisible();
await expect(page.getByText("Create Agent")).toBeVisible();
});