Fix Codex import list
This commit is contained in:
@@ -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<PersistedAgentDescriptor[]> {
|
||||
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<string>();
|
||||
|
||||
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<string, unknown> = {
|
||||
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<string, unknown>;
|
||||
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<AgentTimelineItem[]> {
|
||||
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<CodexRolloutCandidate[]> {
|
||||
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<string, unknown>
|
||||
): 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<string, unknown>);
|
||||
}
|
||||
|
||||
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<string, unknown>);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readCodexSessionMeta(
|
||||
filePath: string
|
||||
): Promise<CodexSessionMeta | null> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user