Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 113 additions & 2 deletions apps/server/src/agents/adapters/codex-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,14 @@ import {
type UserInputResponsePayload,
type UserInputRequestId,
} from "@farfield/protocol";
import { readFile, stat } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import { z } from "zod";
import { logger } from "../../logger.js";
import type {
AgentAdapter,
AgentArchiveThreadInput,
AgentCapabilities,
AgentCreateThreadInput,
AgentCreateThreadResult,
Expand Down Expand Up @@ -135,6 +139,19 @@ const APP_SERVER_THREAD_REFRESH_DEBOUNCE_MS = 120;
const IPC_THREAD_REFRESH_DEBOUNCE_MS = 1_500;
const THREAD_REFRESH_RETRY_DELAY_MS = 600;
const CONNECTION_CHECK_MIN_INTERVAL_MS = 2_000;
const SESSION_INDEX_PATH = join(homedir(), ".codex", "session_index.jsonl");

const SessionIndexEntrySchema = z
.object({
id: z.string().min(1),
thread_name: z.string(),
})
.passthrough();

interface SessionIndexCache {
mtimeMs: number;
titlesByThreadId: Map<string, string>;
}

type ThreadActionRoute =
| {
Expand Down Expand Up @@ -200,6 +217,7 @@ export class CodexAgentAdapter implements AgentAdapter {
>();
private readonly streamPatchSyncDisabledThreadIds = new Set<string>();
private readonly threadTitleById = new Map<string, string | null>();
private sessionIndexCache: SessionIndexCache | null = null;
private readonly canonicalThreadStateErrorById = new Map<
string,
AgentThreadLiveState["liveStateError"]
Expand Down Expand Up @@ -484,8 +502,14 @@ export class CodexAgentAdapter implements AgentAdapter {
),
);

const indexedTitles = await this.readSessionIndexTitles();
const data = result.data.map((thread) => {
const title = this.resolveThreadTitle(thread.id, thread.title);
const directTitle = getThreadListItemTitle(thread);
const title = this.resolveThreadTitle(
thread.id,
directTitle,
indexedTitles.get(thread.id),
);
const snapshot = this.canonicalThreadStateById.get(thread.id);
const isGenerating = snapshot
? isThreadStateGenerating(snapshot)
Expand Down Expand Up @@ -558,7 +582,7 @@ export class CodexAgentAdapter implements AgentAdapter {
ephemeral: input.ephemeral ?? false,
}),
);
this.setThreadTitle(result.thread.id, result.thread.title);
this.setThreadTitle(result.thread.id, getThreadListItemTitle(result.thread));

return {
threadId: result.thread.id,
Expand Down Expand Up @@ -799,6 +823,22 @@ export class CodexAgentAdapter implements AgentAdapter {
await this.runThreadOperationWithResumeRetry(input.threadId, interruptTurn);
}

public async archiveThread(input: AgentArchiveThreadInput): Promise<void> {
this.ensureCodexAvailable();
await this.runAppServerCall(() =>
this.appClient.archiveThread({ threadId: input.threadId }),
);
this.notifyThreadStateChanged(input.threadId);
}

public async unarchiveThread(input: AgentArchiveThreadInput): Promise<void> {
this.ensureCodexAvailable();
await this.runAppServerCall(() =>
this.appClient.unarchiveThread({ threadId: input.threadId }),
);
this.notifyThreadStateChanged(input.threadId);
}

public async listModels(limit: number) {
this.ensureCodexAvailable();
return this.runAppServerCall(() => this.appClient.listModels(limit));
Expand Down Expand Up @@ -2764,11 +2804,16 @@ export class CodexAgentAdapter implements AgentAdapter {
private resolveThreadTitle(
threadId: string,
directTitle: string | null | undefined,
indexedTitle: string | undefined,
): string | null | undefined {
if (directTitle !== undefined) {
return directTitle;
}

if (indexedTitle !== undefined) {
return indexedTitle;
}

if (this.threadTitleById.has(threadId)) {
return this.threadTitleById.get(threadId);
}
Expand All @@ -2781,6 +2826,53 @@ export class CodexAgentAdapter implements AgentAdapter {
return snapshot.title;
}

private async readSessionIndexTitles(): Promise<Map<string, string>> {
let stats: Awaited<ReturnType<typeof stat>>;
try {
stats = await stat(SESSION_INDEX_PATH);
} catch {
return new Map();
}

if (
this.sessionIndexCache &&
this.sessionIndexCache.mtimeMs === stats.mtimeMs
) {
return this.sessionIndexCache.titlesByThreadId;
}

const titlesByThreadId = new Map<string, string>();
try {
const content = await readFile(SESSION_INDEX_PATH, "utf8");
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
const parsed = SessionIndexEntrySchema.safeParse(JSON.parse(trimmed));
if (!parsed.success) {
continue;
}
const title = parsed.data.thread_name.trim();
if (!title) {
continue;
}
titlesByThreadId.set(parsed.data.id, title);
}
Comment on lines +2847 to +2861

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Invalid JSON in a single line aborts processing of remaining entries.

If JSON.parse(trimmed) throws on a malformed line, the exception propagates to the outer catch block, discarding all subsequent valid entries. Wrap the parse in a per-line try-catch to skip only the invalid line.

🐛 Proposed fix
       for (const line of content.split("\n")) {
         const trimmed = line.trim();
         if (!trimmed) {
           continue;
         }
-        const parsed = SessionIndexEntrySchema.safeParse(JSON.parse(trimmed));
-        if (!parsed.success) {
+        let parsed;
+        try {
+          parsed = SessionIndexEntrySchema.safeParse(JSON.parse(trimmed));
+        } catch {
+          continue;
+        }
+        if (!parsed.success) {
           continue;
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
const parsed = SessionIndexEntrySchema.safeParse(JSON.parse(trimmed));
if (!parsed.success) {
continue;
}
const title = parsed.data.thread_name.trim();
if (!title) {
continue;
}
titlesByThreadId.set(parsed.data.id, title);
}
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
let parsed;
try {
parsed = SessionIndexEntrySchema.safeParse(JSON.parse(trimmed));
} catch {
continue;
}
if (!parsed.success) {
continue;
}
const title = parsed.data.thread_name.trim();
if (!title) {
continue;
}
titlesByThreadId.set(parsed.data.id, title);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/agents/adapters/codex-agent.ts` around lines 2847 - 2861, The
JSON.parse call within the loop that processes content.split("\n") can throw an
exception when encountering malformed JSON on a line, which propagates to the
outer catch block and discards all subsequent valid entries. Wrap the
JSON.parse(trimmed) call in a per-line try-catch block so that when parsing
fails, only that specific line is skipped with a continue statement, allowing
the loop to proceed with processing remaining lines. This should be done around
the JSON.parse invocation that feeds into SessionIndexEntrySchema.safeParse.

} catch (error) {
logger.debug(
{ error: toErrorMessage(error) },
"codex-session-index-title-read-failed",
);
}

this.sessionIndexCache = {
mtimeMs: stats.mtimeMs,
titlesByThreadId,
};
return titlesByThreadId;
}

private setThreadTitle(
threadId: string,
title: string | null | undefined,
Expand Down Expand Up @@ -2815,6 +2907,25 @@ function toErrorMessage(error: Error | string | unknown): string {
return String(error);
}

function getThreadListItemTitle(thread: unknown): string | null | undefined {
if (!thread || typeof thread !== "object") {
return undefined;
}

const record = thread as Record<string, unknown>;
const title = record["title"];
if (title === null || typeof title === "string") {
return title;
}

const name = record["name"];
if (name === null || typeof name === "string") {
return name;
}

return undefined;
}
Comment on lines +2910 to +2927

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Uses unknown type and runtime introspection instead of Zod schema.

As per coding guidelines, unknown is forbidden and type introspection must use Zod. Define a schema for the thread list item shape and use .safeParse() instead of manual type checks.

♻️ Proposed refactor using Zod
+const ThreadListItemTitleSchema = z.object({
+  title: z.union([z.string(), z.null()]).optional(),
+  name: z.union([z.string(), z.null()]).optional(),
+}).passthrough();
+
-function getThreadListItemTitle(thread: unknown): string | null | undefined {
-  if (!thread || typeof thread !== "object") {
-    return undefined;
-  }
-
-  const record = thread as Record<string, unknown>;
-  const title = record["title"];
-  if (title === null || typeof title === "string") {
-    return title;
-  }
-
-  const name = record["name"];
-  if (name === null || typeof name === "string") {
-    return name;
-  }
-
-  return undefined;
-}
+function getThreadListItemTitle(thread: z.input<typeof ThreadListItemTitleSchema>): string | null | undefined {
+  const parsed = ThreadListItemTitleSchema.safeParse(thread);
+  if (!parsed.success) {
+    return undefined;
+  }
+
+  const { title, name } = parsed.data;
+  if (title !== undefined) {
+    return title;
+  }
+  if (name !== undefined) {
+    return name;
+  }
+  return undefined;
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function getThreadListItemTitle(thread: unknown): string | null | undefined {
if (!thread || typeof thread !== "object") {
return undefined;
}
const record = thread as Record<string, unknown>;
const title = record["title"];
if (title === null || typeof title === "string") {
return title;
}
const name = record["name"];
if (name === null || typeof name === "string") {
return name;
}
return undefined;
}
const ThreadListItemTitleSchema = z.object({
title: z.union([z.string(), z.null()]).optional(),
name: z.union([z.string(), z.null()]).optional(),
}).passthrough();
function getThreadListItemTitle(thread: z.input<typeof ThreadListItemTitleSchema>): string | null | undefined {
const parsed = ThreadListItemTitleSchema.safeParse(thread);
if (!parsed.success) {
return undefined;
}
const { title, name } = parsed.data;
if (title !== undefined) {
return title;
}
if (name !== undefined) {
return name;
}
return undefined;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/agents/adapters/codex-agent.ts` around lines 2910 - 2927, The
getThreadListItemTitle function uses unknown type and manual runtime type
introspection with typeof checks instead of Zod validation. Define a Zod schema
that describes the expected thread object shape with optional title and name
string properties, then replace the manual type checking logic with Zod's
safeParse method. When safeParse succeeds, extract the title or name from the
validated data object, and return undefined if validation fails. This ensures
type safety and consistency with the codebase's validation patterns.

Source: Coding guidelines


const INVALID_REQUEST_ERROR_CODE = -32600;

export function isInvalidRequestAppServerRpcError(
Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/agents/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ export interface AgentInterruptInput {
ownerClientId?: string;
}

export interface AgentArchiveThreadInput {
threadId: string;
}

export interface AgentThreadLiveState {
ownerClientId: string | null;
conversationState: AppServerReadThreadResponse["thread"] | null;
Expand Down Expand Up @@ -157,6 +161,8 @@ export interface AgentAdapter {
submitUserInput?(
input: AgentSubmitUserInputInput,
): Promise<{ ownerClientId: string; requestId: UserInputRequestId }>;
archiveThread?(input: AgentArchiveThreadInput): Promise<void>;
unarchiveThread?(input: AgentArchiveThreadInput): Promise<void>;
readLiveState?(threadId: string): Promise<AgentThreadLiveState>;
readStreamEvents?(
threadId: string,
Expand Down
Loading