From 29d561725cc6865405309e22e5e235ade40c8793 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 28 Dec 2025 23:59:09 +0700 Subject: [PATCH] Fix Codex import list --- .../server/agent/providers/codex-mcp-agent.ts | 300 +++++++++++++++++- packages/server/src/server/daemon.e2e.test.ts | 68 ++++ plan.md | 11 +- 3 files changed, 377 insertions(+), 2 deletions(-) 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 5c54a1e2c..976664090 100644 --- a/packages/server/src/server/agent/providers/codex-mcp-agent.ts +++ b/packages/server/src/server/agent/providers/codex-mcp-agent.ts @@ -25,6 +25,8 @@ import type { AgentTimelineItem, AgentUsage, AgentRuntimeInfo, + ListPersistedAgentsOptions, + PersistedAgentDescriptor, } from "../agent-sdk-types.js"; type CodexMcpAgentConfig = AgentSessionConfig & { provider: "codex" }; @@ -4127,6 +4129,66 @@ export class CodexMcpAgentClient implements AgentClient { await session.connect(); return session; } + + async listPersistedAgents( + options?: ListPersistedAgentsOptions + ): Promise { + const root = resolveCodexSessionRoot(); + if (!root) { + return []; + } + const limit = options?.limit ?? 20; + const candidates = await collectCodexRolloutFiles(root, limit * 3); + const descriptors: PersistedAgentDescriptor[] = []; + const seen = new Set(); + + for (const candidate of candidates) { + const meta = await readCodexSessionMeta(candidate.path); + const sessionId = + meta?.sessionId ?? deriveCodexSessionIdFromPath(candidate.path); + if (!sessionId || seen.has(sessionId)) { + continue; + } + const cwd = meta?.cwd ?? process.cwd(); + const title = + typeof meta?.title === "string" && meta.title.trim().length > 0 + ? meta.title + : null; + const lastActivityAt = resolveCodexSessionTimestamp(meta, candidate.mtime); + const metadata: Record = { + provider: CODEX_PROVIDER, + cwd, + ...(title ? { title } : {}), + conversationId: sessionId, + ...(root ? { codexSessionDir: root } : {}), + codexRolloutPath: candidate.path, + }; + const timeline = await loadCodexPersistedTimeline(sessionId, { + sessionRoot: root, + rolloutPath: candidate.path, + }); + descriptors.push({ + provider: CODEX_PROVIDER, + sessionId, + cwd, + title, + lastActivityAt, + persistence: { + provider: CODEX_PROVIDER, + sessionId, + nativeHandle: sessionId, + metadata, + }, + timeline, + }); + seen.add(sessionId); + if (descriptors.length >= limit) { + break; + } + } + + return descriptors; + } } // ============================================================================ @@ -4135,6 +4197,20 @@ export class CodexMcpAgentClient implements AgentClient { const MAX_ROLLOUT_SEARCH_DEPTH = 4; const PERSISTED_TIMELINE_LIMIT = 100; +const CODEX_ROLLOUT_PREFIX = "rollout-"; +const CODEX_ROLLOUT_EXTENSIONS = [".jsonl", ".json"]; + +type CodexRolloutCandidate = { + path: string; + mtime: Date; +}; + +type CodexSessionMeta = { + sessionId?: string; + cwd?: string; + title?: string | null; + timestamp?: string; +}; function resolveCodexSessionRoot(): string | null { if (process.env.CODEX_SESSION_DIR) { @@ -4286,11 +4362,67 @@ function extractReasoningText(payload: RolloutResponsePayload): string { return ""; } +function parseJsonRolloutTimeline( + parsed: unknown +): AgentTimelineItem[] | null { + if (!parsed || typeof parsed !== "object") { + return null; + } + const items = (parsed as { items?: unknown }).items; + if (!Array.isArray(items)) { + return null; + } + const timeline: AgentTimelineItem[] = []; + for (const entry of items) { + if (!entry || typeof entry !== "object") { + continue; + } + const record = entry as Record; + const type = record.type; + if (type === "message") { + const role = record.role; + const text = extractMessageText(record.content); + if (!text || typeof role !== "string") { + continue; + } + if (role === "assistant") { + timeline.push({ type: "assistant_message", text }); + } else if (role === "user") { + if (!isSyntheticRolloutUserMessage(text)) { + timeline.push({ type: "user_message", text }); + } + } + continue; + } + if (type === "reasoning") { + const text = extractReasoningText(record as RolloutResponsePayload); + if (text) { + timeline.push({ type: "reasoning", text }); + } + continue; + } + } + return timeline; +} + async function parseRolloutFile( filePath: string ): Promise { const content = await fs.readFile(filePath, "utf8"); - const lines = content + const trimmed = content.trim(); + if (!trimmed) { + return []; + } + try { + const parsed = JSON.parse(trimmed); + const jsonTimeline = parseJsonRolloutTimeline(parsed); + if (jsonTimeline) { + return jsonTimeline; + } + } catch { + // Fall back to JSONL parsing. + } + const lines = trimmed .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); @@ -4429,3 +4561,169 @@ function readCodexMetadataString( const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } + +function resolveCodexSessionTimestamp( + meta: CodexSessionMeta | null, + fallback: Date +): Date { + if (meta?.timestamp) { + const parsed = new Date(meta.timestamp); + if (!Number.isNaN(parsed.getTime())) { + return parsed; + } + } + return fallback; +} + +function deriveCodexSessionIdFromPath(filePath: string): string | null { + const base = path.basename(filePath); + if (!base.startsWith(CODEX_ROLLOUT_PREFIX)) { + return null; + } + const ext = CODEX_ROLLOUT_EXTENSIONS.find((suffix) => base.endsWith(suffix)); + const withoutExt = ext ? base.slice(0, -ext.length) : base; + const remainder = withoutExt.slice(CODEX_ROLLOUT_PREFIX.length).trim(); + if (!remainder) { + return null; + } + const uuidMatch = remainder.match( + /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i + ); + return uuidMatch ? uuidMatch[0] : remainder; +} + +async function collectCodexRolloutFiles( + root: string, + limit: number +): Promise { + const candidates: CodexRolloutCandidate[] = []; + const stack: Array<{ dir: string; depth: number }> = [{ dir: root, depth: 0 }]; + + while (stack.length > 0) { + const { dir, depth } = stack.pop()!; + let entries: Dirent[]; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (depth < MAX_ROLLOUT_SEARCH_DEPTH) { + stack.push({ dir: entryPath, depth: depth + 1 }); + } + continue; + } + if (!entry.isFile()) { + continue; + } + if ( + !entry.name.startsWith(CODEX_ROLLOUT_PREFIX) || + !CODEX_ROLLOUT_EXTENSIONS.some((suffix) => entry.name.endsWith(suffix)) + ) { + continue; + } + try { + const stats = await fs.stat(entryPath); + if (!stats.isFile()) { + continue; + } + candidates.push({ path: entryPath, mtime: stats.mtime }); + } catch { + continue; + } + } + } + + candidates.sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); + return candidates.slice(0, limit); +} + +function parseCodexSessionMetaPayload( + payload: Record +): CodexSessionMeta { + const sessionId = + typeof payload.id === "string" + ? payload.id + : typeof payload.sessionId === "string" + ? payload.sessionId + : typeof payload.threadId === "string" + ? payload.threadId + : undefined; + const cwd = typeof payload.cwd === "string" ? payload.cwd : undefined; + const title = typeof payload.title === "string" ? payload.title : null; + const timestamp = + typeof payload.timestamp === "string" ? payload.timestamp : undefined; + return { sessionId, cwd, title, timestamp }; +} + +function parseCodexSessionMetaFromJson( + data: unknown +): CodexSessionMeta | null { + if (!data || typeof data !== "object") { + return null; + } + const session = (data as { session?: unknown }).session; + if (!session || typeof session !== "object") { + return null; + } + return parseCodexSessionMetaPayload(session as Record); +} + +function parseCodexSessionMetaFromJsonLine( + line: string +): CodexSessionMeta | null { + if (!line) { + return null; + } + try { + const parsed = JSON.parse(line); + if (!parsed || typeof parsed !== "object") { + return null; + } + const type = (parsed as { type?: unknown }).type; + if (type !== "session_meta") { + return null; + } + const payload = (parsed as { payload?: unknown }).payload; + if (!payload || typeof payload !== "object") { + return null; + } + return parseCodexSessionMetaPayload(payload as Record); + } catch { + return null; + } +} + +async function readCodexSessionMeta( + filePath: string +): Promise { + let content: string; + try { + content = await fs.readFile(filePath, "utf8"); + } catch { + return null; + } + const trimmed = content.trim(); + if (!trimmed) { + return null; + } + try { + const parsed = JSON.parse(trimmed); + const meta = parseCodexSessionMetaFromJson(parsed); + if (meta) { + return meta; + } + } catch { + // JSONL fallback + } + const lines = trimmed.split(/\r?\n/); + for (const line of lines) { + const meta = parseCodexSessionMetaFromJsonLine(line); + if (meta) { + return meta; + } + } + return null; +} diff --git a/packages/server/src/server/daemon.e2e.test.ts b/packages/server/src/server/daemon.e2e.test.ts index 34f4fd484..e8098bf2d 100644 --- a/packages/server/src/server/daemon.e2e.test.ts +++ b/packages/server/src/server/daemon.e2e.test.ts @@ -2594,6 +2594,74 @@ describe("daemon E2E", () => { }); }); + describe("Codex persisted agent import", () => { + test("lists Codex sessions from rollout files", async () => { + const previousCodexSessionDir = process.env.CODEX_SESSION_DIR; + const codexSessionDir = mkdtempSync(path.join(tmpdir(), "codex-session-")); + process.env.CODEX_SESSION_DIR = codexSessionDir; + + const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const cwd = "/tmp/codex-import-test"; + const now = new Date().toISOString(); + const rolloutPath = path.join(codexSessionDir, `rollout-${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ + timestamp: now, + type: "session_meta", + payload: { id: sessionId, timestamp: now, cwd }, + }), + JSON.stringify({ + timestamp: now, + type: "response_item", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Import this Codex session" }], + }, + }), + JSON.stringify({ + timestamp: now, + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Codex import ready" }], + }, + }), + ]; + writeFileSync(rolloutPath, `${lines.join("\n")}\n`, "utf8"); + + try { + const persisted = await ctx.client.listPersistedAgents(); + const codexEntry = persisted.find( + (item) => item.provider === "codex" && item.sessionId === sessionId + ); + + expect(codexEntry).toBeTruthy(); + expect(codexEntry?.cwd).toBe(cwd); + + const timelineTexts = (codexEntry?.timeline ?? []) + .map((item) => { + if (item.type === "user_message" || item.type === "assistant_message") { + return item.text; + } + return null; + }) + .filter((text): text is string => typeof text === "string"); + + expect(timelineTexts).toContain("Import this Codex session"); + expect(timelineTexts).toContain("Codex import ready"); + } finally { + if (previousCodexSessionDir === undefined) { + delete process.env.CODEX_SESSION_DIR; + } else { + process.env.CODEX_SESSION_DIR = previousCodexSessionDir; + } + rmSync(codexSessionDir, { recursive: true, force: true }); + } + }); + }); + describe("Claude session persistence", () => { test( "persists and resumes Claude agent with conversation history (remembers number)", diff --git a/plan.md b/plan.md index 8bf76b95c..489efbcb5 100644 --- a/plan.md +++ b/plan.md @@ -51,11 +51,12 @@ Improvements to the new agent screen in the app. - Add daemon-level E2E test to verify filtering. - **Done (2025-12-28 23:48)**: WHAT: `packages/server/src/server/agent/providers/claude-agent.ts:1876-1887` skips sidechain history entries when parsing persisted Claude sessions; `packages/server/src/server/daemon.e2e.test.ts:2532-2594` adds a daemon E2E that seeds Warmup history and asserts it is not imported. RESULT: Claude import list ignores internal Warmup messages and surfaces real user history. EVIDENCE: Not run (not requested). -- [ ] **Iteration 4**: Fix Codex import screen showing nothing. +- [x] **Iteration 4**: Fix Codex import screen showing nothing. - Codex import screen currently displays no content. - Investigate and fix the issue. - Add daemon-level E2E test to verify import works correctly. + - **Done (2025-12-28 23:58)**: WHAT: `packages/server/src/server/agent/providers/codex-mcp-agent.ts:4133-4476` adds Codex persisted session listing from rollout files plus JSON/JSONL timeline parsing and session metadata extraction; `packages/server/src/server/daemon.e2e.test.ts:2597-2662` adds Codex persisted agent import E2E coverage. RESULT: Codex import list now populates from on-disk rollouts with timeline previews and is covered by a daemon E2E. EVIDENCE: Not run (not requested). - [ ] **Iteration 5**: Fix gallery 15s timeout causing full-screen error crash. @@ -82,6 +83,14 @@ Improvements to the new agent screen in the app. - The host label is shown on the git diff screen but it's not relevant there. - Remove it from the UI. +- [ ] **Checkpoint**: Run typecheck and review code quality. + + - Run `npm run typecheck` and fix any errors. + - Review all code changes made in this plan (Iterations 1-8). + - Look for brittle TypeScript: `as` casts, `any` types, weakly typed code. + - Ensure proper type inference and type safety. + - Add fix tasks for any issues found. + - [ ] **Checkpoint**: Review daemon test coverage for fixes in this plan. - Review all fixes made in this plan (Iterations 3-8).