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:
@@ -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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { randomUUID } from "node:crypto";
|
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 os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
@@ -2383,14 +2383,21 @@ function buildCodexMcpConfig(
|
|||||||
// Build the config payload with MCP servers
|
// Build the config payload with MCP servers
|
||||||
const innerConfig: CodexConfigPayload = {};
|
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) {
|
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) {
|
if (experimentalResume) {
|
||||||
innerConfig.experimental_resume = experimentalResume;
|
const history = parseRolloutHistory(experimentalResume);
|
||||||
|
if (history) {
|
||||||
|
developerInstructions = history;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build MCP servers configuration
|
// Build MCP servers configuration
|
||||||
@@ -2438,6 +2445,7 @@ function buildCodexMcpConfig(
|
|||||||
sandbox: string;
|
sandbox: string;
|
||||||
config?: CodexConfigPayload;
|
config?: CodexConfigPayload;
|
||||||
model?: string;
|
model?: string;
|
||||||
|
"developer-instructions"?: string;
|
||||||
} = {
|
} = {
|
||||||
prompt,
|
prompt,
|
||||||
cwd: config.cwd,
|
cwd: config.cwd,
|
||||||
@@ -2453,6 +2461,12 @@ function buildCodexMcpConfig(
|
|||||||
if (typeof config.model === "string" && config.model.length > 0) {
|
if (typeof config.model === "string" && config.model.length > 0) {
|
||||||
configPayload.model = config.model;
|
configPayload.model = config.model;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add developer instructions for session resume context
|
||||||
|
if (developerInstructions) {
|
||||||
|
configPayload["developer-instructions"] = developerInstructions;
|
||||||
|
}
|
||||||
|
|
||||||
return configPayload;
|
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> {
|
class Pushable<T> implements AsyncIterable<T> {
|
||||||
private queue: T[] = [];
|
private queue: T[] = [];
|
||||||
private resolvers: ((value: IteratorResult<T>) => void)[] = [];
|
private resolvers: ((value: IteratorResult<T>) => void)[] = [];
|
||||||
@@ -2622,6 +2687,12 @@ class CodexMcpAgentSession implements AgentSession {
|
|||||||
this.historyPending = true;
|
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(
|
this.client = new Client(
|
||||||
{ name: "voice-dev-codex", version: "1.0.0" },
|
{ name: "voice-dev-codex", version: "1.0.0" },
|
||||||
{ capabilities: { elicitation: {} } }
|
{ capabilities: { elicitation: {} } }
|
||||||
|
|||||||
@@ -2273,11 +2273,7 @@ describe("daemon E2E", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("external Codex session import", () => {
|
describe("external Codex session import", () => {
|
||||||
// TODO: Codex MCP's experimental_resume feature doesn't properly load
|
test(
|
||||||
// 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(
|
|
||||||
"imports external codex exec session and preserves conversation context",
|
"imports external codex exec session and preserves conversation context",
|
||||||
async () => {
|
async () => {
|
||||||
const cwd = tmpCwd();
|
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", () => {
|
describe("Claude agent overlapping stream() calls race condition", () => {
|
||||||
test(
|
test(
|
||||||
"interrupting message should produce coherent text without garbling from race condition",
|
"interrupting message should produce coherent text without garbling from race condition",
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user