From 076080db489b22dfdc640305554f0e0b7fe41216 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 27 Dec 2025 07:55:54 +0000 Subject: [PATCH] Fix external session import and add Claude persistence test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- packages/server/playwright.config.ts | 30 ----- .../server/agent/providers/codex-mcp-agent.ts | 81 +++++++++++- packages/server/src/server/daemon.e2e.test.ts | 122 +++++++++++++++++- .../tests/e2e/create-agent-modal.spec.ts | 13 -- 4 files changed, 193 insertions(+), 53 deletions(-) delete mode 100644 packages/server/playwright.config.ts delete mode 100644 packages/server/tests/e2e/create-agent-modal.spec.ts diff --git a/packages/server/playwright.config.ts b/packages/server/playwright.config.ts deleted file mode 100644 index 5da77e844..000000000 --- a/packages/server/playwright.config.ts +++ /dev/null @@ -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, - }, -}); diff --git a/packages/server/src/server/agent/providers/codex-mcp-agent.ts b/packages/server/src/server/agent/providers/codex-mcp-agent.ts index 3215908da..998e4473e 100644 --- a/packages/server/src/server/agent/providers/codex-mcp-agent.ts +++ b/packages/server/src/server/agent/providers/codex-mcp-agent.ts @@ -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; + 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("")) 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 `\nThis is a continuation of a previous session. Here is the conversation history:\n\n${formatted}\n`; + } catch { + return null; + } +} + class Pushable implements AsyncIterable { private queue: T[] = []; private resolvers: ((value: IteratorResult) => 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 | 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: {} } } diff --git a/packages/server/src/server/daemon.e2e.test.ts b/packages/server/src/server/daemon.e2e.test.ts index 92a91d5d7..3afdadc14 100644 --- a/packages/server/src/server/daemon.e2e.test.ts +++ b/packages/server/src/server/daemon.e2e.test.ts @@ -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 ` - // 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", diff --git a/packages/server/tests/e2e/create-agent-modal.spec.ts b/packages/server/tests/e2e/create-agent-modal.spec.ts deleted file mode 100644 index b8f0aee38..000000000 --- a/packages/server/tests/e2e/create-agent-modal.spec.ts +++ /dev/null @@ -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(); -});