Fix Codex import list
This commit is contained in:
@@ -25,6 +25,8 @@ import type {
|
|||||||
AgentTimelineItem,
|
AgentTimelineItem,
|
||||||
AgentUsage,
|
AgentUsage,
|
||||||
AgentRuntimeInfo,
|
AgentRuntimeInfo,
|
||||||
|
ListPersistedAgentsOptions,
|
||||||
|
PersistedAgentDescriptor,
|
||||||
} from "../agent-sdk-types.js";
|
} from "../agent-sdk-types.js";
|
||||||
|
|
||||||
type CodexMcpAgentConfig = AgentSessionConfig & { provider: "codex" };
|
type CodexMcpAgentConfig = AgentSessionConfig & { provider: "codex" };
|
||||||
@@ -4127,6 +4129,66 @@ export class CodexMcpAgentClient implements AgentClient {
|
|||||||
await session.connect();
|
await session.connect();
|
||||||
return session;
|
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 MAX_ROLLOUT_SEARCH_DEPTH = 4;
|
||||||
const PERSISTED_TIMELINE_LIMIT = 100;
|
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 {
|
function resolveCodexSessionRoot(): string | null {
|
||||||
if (process.env.CODEX_SESSION_DIR) {
|
if (process.env.CODEX_SESSION_DIR) {
|
||||||
@@ -4286,11 +4362,67 @@ function extractReasoningText(payload: RolloutResponsePayload): string {
|
|||||||
return "";
|
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(
|
async function parseRolloutFile(
|
||||||
filePath: string
|
filePath: string
|
||||||
): Promise<AgentTimelineItem[]> {
|
): Promise<AgentTimelineItem[]> {
|
||||||
const content = await fs.readFile(filePath, "utf8");
|
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/)
|
.split(/\r?\n/)
|
||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
@@ -4429,3 +4561,169 @@ function readCodexMetadataString(
|
|||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
return trimmed.length > 0 ? trimmed : null;
|
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", () => {
|
describe("Claude session persistence", () => {
|
||||||
test(
|
test(
|
||||||
"persists and resumes Claude agent with conversation history (remembers number)",
|
"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.
|
- 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).
|
- **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.
|
- Codex import screen currently displays no content.
|
||||||
- Investigate and fix the issue.
|
- Investigate and fix the issue.
|
||||||
- Add daemon-level E2E test to verify import works correctly.
|
- 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.
|
- [ ] **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.
|
- The host label is shown on the git diff screen but it's not relevant there.
|
||||||
- Remove it from the UI.
|
- 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.
|
- [ ] **Checkpoint**: Review daemon test coverage for fixes in this plan.
|
||||||
|
|
||||||
- Review all fixes made in this plan (Iterations 3-8).
|
- Review all fixes made in this plan (Iterations 3-8).
|
||||||
|
|||||||
Reference in New Issue
Block a user