mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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 { 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: {} } }
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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