refactor(agent-providers): add toObjectRecord helper and fix type-aware lint errors in codex and claude agents (#699)

This commit is contained in:
Mohamed Boudra
2026-05-05 19:57:35 +07:00
parent b2c88e4312
commit d359bffed5
2 changed files with 163 additions and 124 deletions
@@ -142,6 +142,13 @@ export function normalizeClaudeAskUserQuestionUpdatedInput(
};
}
function toObjectRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
return value as Record<string, unknown>;
}
type TurnState = "idle" | "foreground" | "autonomous";
interface EventIdentifiers {
@@ -389,11 +396,11 @@ function summarizeClaudeOptionsForLog(options: ClaudeOptions): ClaudeOptionsLogS
if (typeof systemPromptRaw === "string") {
return { mode: "string" as const, preset: null };
}
const prompt = systemPromptRaw as Record<string, unknown>;
const promptType = typeof prompt.type === "string" ? prompt.type : "custom";
const prompt = toObjectRecord(systemPromptRaw);
const promptType = typeof prompt?.type === "string" ? prompt.type : "custom";
return {
mode: promptType === "preset" ? ("preset" as const) : ("custom" as const),
preset: typeof prompt.preset === "string" && prompt.preset.length > 0 ? prompt.preset : null,
preset: typeof prompt?.preset === "string" && prompt.preset.length > 0 ? prompt.preset : null,
};
})();
const mcpServerNames = options.mcpServers ? Object.keys(options.mcpServers).sort() : [];
@@ -464,7 +471,11 @@ function normalizeForDeterministicString(value: unknown, seen: WeakSet<object>):
return "[circular]";
}
seen.add(objectValue);
const record = value as Record<string, unknown>;
const record = toObjectRecord(value);
if (!record) {
seen.delete(objectValue);
return "[invalid]";
}
const normalized: Record<string, unknown> = {};
for (const key of Object.keys(record).sort()) {
normalized[key] = normalizeForDeterministicString(record[key], seen);
@@ -472,7 +483,10 @@ function normalizeForDeterministicString(value: unknown, seen: WeakSet<object>):
seen.delete(objectValue);
return normalized;
}
return String(value);
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return "[unsupported]";
}
function deterministicStringify(value: unknown): string {
@@ -486,11 +500,13 @@ function deterministicStringify(value: unknown): string {
}
return JSON.stringify(normalized);
} catch {
try {
return String(value);
} catch {
return "[unserializable]";
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return "[unserializable]";
}
}
@@ -902,7 +918,7 @@ class TimelineAssembler {
runId: string | null,
messageIdHint: string | null,
): AgentTimelineItem[] {
const event = message.event as unknown as Record<string, unknown>;
const event = toObjectRecord(message.event) ?? {};
const eventType = readTrimmedString(event.type);
const streamEventMessageId = this.readMessageIdFromStreamEvent(event) ?? messageIdHint;
@@ -541,6 +541,10 @@ function toCodexMcpConfig(config: McpServerConfig): CodexMcpServerConfig {
url: config.url,
http_headers: config.headers,
};
default: {
const _exhaustive = config as { type: never };
throw new Error(`Unsupported MCP config type: ${String(_exhaustive.type)}`);
}
}
}
interface JsonRpcRequest {
@@ -562,13 +566,36 @@ interface JsonRpcNotification {
type JsonRpcMessage = JsonRpcRequest | JsonRpcResponse | JsonRpcNotification;
function isJsonRpcResponse(msg: JsonRpcMessage): msg is JsonRpcResponse {
const m = msg as unknown as Record<string, unknown>;
if (typeof m.id !== "number") return false;
return m.result !== undefined || !!m.error;
}
function isJsonRpcRequest(msg: JsonRpcMessage): msg is JsonRpcRequest {
const m = msg as unknown as Record<string, unknown>;
return typeof m.id === "number" && typeof m.method === "string";
}
function isJsonRpcNotification(msg: JsonRpcMessage): msg is JsonRpcNotification {
const m = msg as unknown as Record<string, unknown>;
return typeof m.method === "string" && typeof m.id !== "number";
}
function toObjectRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
return value as Record<string, unknown>;
}
interface PendingRequest {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
}
type RequestHandler = (params: unknown) => Promise<unknown> | unknown;
type RequestHandler = (params: unknown) => unknown;
type NotificationHandler = (method: string, params: unknown) => void;
@@ -592,10 +619,6 @@ interface CodexModelListResponse {
data?: CodexModel[];
}
interface CodexThreadStartResponse {
thread?: { id?: string };
}
class CodexAppServerClient {
private readonly rl: readline.Interface;
private readonly pending = new Map<number, PendingRequest>();
@@ -717,32 +740,31 @@ class CodexAppServerClient {
private async handleLine(line: string): Promise<void> {
if (!line.trim()) return;
let msg: JsonRpcMessage;
try {
msg = JSON.parse(line);
} catch (error) {
this.logger.warn({ error, line }, "Failed to parse Codex app-server JSON");
const raw = JSON.parse(line);
const msg = toObjectRecord(raw) as unknown as JsonRpcMessage;
if (!msg) {
this.logger.warn({ line }, "Parsed JSON is not an object");
return;
}
if (typeof (msg as JsonRpcResponse).id === "number") {
const id = (msg as JsonRpcResponse).id;
if ((msg as JsonRpcResponse).result !== undefined || (msg as JsonRpcResponse).error) {
if (isJsonRpcResponse(msg)) {
const id = msg.id;
if (msg.result !== undefined || msg.error) {
const pending = this.pending.get(id);
if (!pending) return;
clearTimeout(pending.timer);
this.pending.delete(id);
if ((msg as JsonRpcResponse).error) {
pending.reject(new Error((msg as JsonRpcResponse).error?.message ?? "Unknown error"));
if (msg.error) {
pending.reject(new Error(msg.error.message ?? "Unknown error"));
} else {
pending.resolve((msg as JsonRpcResponse).result);
pending.resolve(msg.result);
}
return;
}
// Server-initiated request
if (typeof (msg as JsonRpcRequest).method === "string") {
const request = msg as JsonRpcRequest;
if (isJsonRpcRequest(msg)) {
const request = msg;
const handler = this.requestHandlers.get(request.method);
try {
const result = handler ? await handler(request.params) : {};
@@ -757,38 +779,26 @@ class CodexAppServerClient {
}
}
if (typeof (msg as JsonRpcNotification).method === "string") {
const notification = msg as JsonRpcNotification;
this.notificationHandler?.(notification.method, notification.params);
if (isJsonRpcNotification(msg)) {
this.notificationHandler?.(msg.method, msg.params);
}
}
}
function toAgentUsage(tokenUsage: unknown): AgentUsage | undefined {
if (!tokenUsage || typeof tokenUsage !== "object") return undefined;
const usage = tokenUsage as {
model_context_window?: number;
modelContextWindow?: number;
last?: {
inputTokens?: number;
cachedInputTokens?: number;
outputTokens?: number;
total_tokens?: number;
totalTokens?: number;
};
};
const usage = toObjectRecord(tokenUsage);
if (!usage) return undefined;
const last = toObjectRecord(usage.last);
const contextWindowMaxTokens = firstPositiveFiniteNumber(
usage.model_context_window,
usage.modelContextWindow,
);
const contextWindowUsedTokens = firstPositiveFiniteNumber(
usage.last?.total_tokens,
usage.last?.totalTokens,
);
const contextWindowUsedTokens = firstPositiveFiniteNumber(last?.total_tokens, last?.totalTokens);
return {
inputTokens: usage.last?.inputTokens,
cachedInputTokens: usage.last?.cachedInputTokens,
outputTokens: usage.last?.outputTokens,
inputTokens: typeof last?.inputTokens === "number" ? last.inputTokens : undefined,
cachedInputTokens:
typeof last?.cachedInputTokens === "number" ? last.cachedInputTokens : undefined,
outputTokens: typeof last?.outputTokens === "number" ? last.outputTokens : undefined,
...(contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {}),
...(contextWindowUsedTokens !== undefined ? { contextWindowUsedTokens } : {}),
};
@@ -798,11 +808,12 @@ function extractUserText(content: unknown): string | null {
if (!Array.isArray(content)) return null;
const parts: string[] = [];
for (const item of content) {
if (item && typeof item === "object") {
const obj = item as { type?: string; text?: string };
if (obj.type === "text" && typeof obj.text === "string") {
parts.push(obj.text);
}
const record = toObjectRecord(item);
if (!record) {
continue;
}
if (record.type === "text" && typeof record.text === "string") {
parts.push(record.text);
}
}
return parts.length > 0 ? parts.join("\n") : null;
@@ -919,10 +930,10 @@ function normalizeCodexQuestionPrompts(raw: unknown): CodexQuestionPrompt[] {
}
const questions: CodexQuestionPrompt[] = [];
for (const item of raw) {
if (!item || typeof item !== "object") {
const record = toObjectRecord(item);
if (!record) {
continue;
}
const record = item as Record<string, unknown>;
const id = nonEmptyString(record.id);
const header = nonEmptyString(record.header);
const question = nonEmptyString(record.question);
@@ -931,10 +942,10 @@ function normalizeCodexQuestionPrompts(raw: unknown): CodexQuestionPrompt[] {
}
const options = Array.isArray(record.options)
? record.options.flatMap((option): CodexQuestionOption[] => {
if (!option || typeof option !== "object") {
const optionRecord = toObjectRecord(option);
if (!optionRecord) {
return [];
}
const optionRecord = option as Record<string, unknown>;
const label = nonEmptyString(optionRecord.label);
if (!label) {
return [];
@@ -1045,13 +1056,9 @@ function mapCodexQuestionResponseByHeader(params: {
if (params.response.behavior !== "allow") {
return null;
}
const answersRecord =
params.response.updatedInput && typeof params.response.updatedInput === "object"
? ((params.response.updatedInput as Record<string, unknown>).answers as
| Record<string, unknown>
| undefined)
: undefined;
if (!answersRecord || typeof answersRecord !== "object") {
const updatedInputRecord = toObjectRecord(params.response.updatedInput);
const answersRecord = toObjectRecord(updatedInputRecord?.answers);
if (!answersRecord) {
return null;
}
@@ -1086,10 +1093,10 @@ interface CodexPatchFileChange {
}
function extractPatchLikeText(value: unknown): string | undefined {
if (!value || typeof value !== "object") {
const record = toObjectRecord(value);
if (!record) {
return undefined;
}
const record = value as Record<string, unknown>;
const candidates = [
record.diff,
record.patch,
@@ -1194,10 +1201,10 @@ function parseCodexPatchChanges(changes: unknown): CodexPatchFileChange[] {
if (Array.isArray(changes)) {
return changes
.map((entry): CodexPatchFileChange | null => {
if (!entry || typeof entry !== "object") {
const record = toObjectRecord(entry);
if (!record) {
return null;
}
const record = entry as Record<string, unknown>;
const pathValue = resolvePathFromRecord(record);
if (!pathValue) {
return null;
@@ -1214,7 +1221,10 @@ function parseCodexPatchChanges(changes: unknown): CodexPatchFileChange[] {
.filter((entry): entry is CodexPatchFileChange => entry !== null);
}
const recordChanges = changes as Record<string, unknown>;
const recordChanges = toObjectRecord(changes);
if (!recordChanges) {
return [];
}
const directPathValue = resolvePathFromRecord(recordChanges);
if (directPathValue) {
return [
@@ -1472,8 +1482,8 @@ function threadItemToTimeline(
item: unknown,
options?: { includeUserMessage?: boolean; cwd?: string | null },
): AgentTimelineItem | null {
if (!item || typeof item !== "object") return null;
const itemRecord = item as Record<string, unknown>;
const itemRecord = toObjectRecord(item);
if (!itemRecord) return null;
const includeUserMessage = options?.includeUserMessage ?? true;
const cwd = options?.cwd ?? null;
const normalizedType = normalizeCodexThreadItemType(
@@ -2397,17 +2407,14 @@ async function readCodexConfiguredDefaults(
): Promise<CodexConfiguredDefaults> {
let savedConfigDefaults: CodexConfiguredDefaults = {};
try {
const response = (await client.request("getUserSavedConfig", {})) as {
config?: {
model?: string | null;
modelReasoningEffort?: string | null;
};
};
const response = toObjectRecord(await client.request("getUserSavedConfig", {}));
const config = toObjectRecord(response?.config);
const modelValue = typeof config?.model === "string" ? config.model : undefined;
const thinkingOptionValue =
typeof config?.modelReasoningEffort === "string" ? config.modelReasoningEffort : null;
savedConfigDefaults = {
model: normalizeCodexModelId(response?.config?.model),
thinkingOptionId: normalizeCodexThinkingOptionId(
response?.config?.modelReasoningEffort ?? null,
),
model: normalizeCodexModelId(modelValue),
thinkingOptionId: normalizeCodexThinkingOptionId(thinkingOptionValue),
};
} catch (error) {
logger.debug({ error }, "Failed to read Codex saved config defaults");
@@ -2419,17 +2426,14 @@ async function readCodexConfiguredDefaults(
let configReadDefaults: CodexConfiguredDefaults = {};
try {
const response = (await client.request("config/read", {})) as {
config?: {
model?: string | null;
model_reasoning_effort?: string | null;
};
};
const response = toObjectRecord(await client.request("config/read", {}));
const config = toObjectRecord(response?.config);
const modelValue = typeof config?.model === "string" ? config.model : undefined;
const thinkingOptionValue =
typeof config?.model_reasoning_effort === "string" ? config.model_reasoning_effort : null;
configReadDefaults = {
model: normalizeCodexModelId(response?.config?.model),
thinkingOptionId: normalizeCodexThinkingOptionId(
response?.config?.model_reasoning_effort ?? null,
),
model: normalizeCodexModelId(modelValue),
thinkingOptionId: normalizeCodexThinkingOptionId(thinkingOptionValue),
};
} catch (error) {
logger.debug({ error }, "Failed to read Codex config defaults");
@@ -2678,19 +2682,22 @@ class CodexAppServerAgentSession implements AgentSession {
private async loadCollaborationModes(): Promise<void> {
if (!this.client) return;
try {
const response = (await this.client.request("collaborationMode/list", {})) as {
data?: Array<Record<string, unknown>>;
};
const response = toObjectRecord(await this.client.request("collaborationMode/list", {}));
const data = Array.isArray(response?.data) ? response.data : [];
this.collaborationModes = data.map((entry) => ({
name: typeof entry.name === "string" ? entry.name : "",
mode: typeof entry.mode === "string" ? entry.mode : null,
model: typeof entry.model === "string" ? entry.model : null,
reasoning_effort:
typeof entry.reasoning_effort === "string" ? entry.reasoning_effort : null,
developer_instructions:
typeof entry.developer_instructions === "string" ? entry.developer_instructions : null,
}));
this.collaborationModes = data.map((entry) => {
const record = toObjectRecord(entry);
return {
name: typeof record?.name === "string" ? record.name : "",
mode: typeof record?.mode === "string" ? record.mode : null,
model: typeof record?.model === "string" ? record.model : null,
reasoning_effort:
typeof record?.reasoning_effort === "string" ? record.reasoning_effort : null,
developer_instructions:
typeof record?.developer_instructions === "string"
? record.developer_instructions
: null,
};
});
} catch (error) {
this.logger.trace({ error }, "Failed to load collaboration modes");
this.collaborationModes = [];
@@ -2701,21 +2708,24 @@ class CodexAppServerAgentSession implements AgentSession {
private async loadSkills(): Promise<void> {
if (!this.client) return;
try {
const response = (await this.client.request("skills/list", {
cwd: [this.config.cwd],
})) as { data?: Array<Record<string, unknown>> };
const response = toObjectRecord(
await this.client.request("skills/list", {
cwd: [this.config.cwd],
}),
);
const entries = Array.isArray(response?.data) ? response.data : [];
const skills: Array<{ name: string; description: string; path: string }> = [];
for (const entry of entries) {
const list = Array.isArray(entry.skills)
? (entry.skills as Array<Record<string, unknown>>)
: [];
const entryRecord = toObjectRecord(entry);
const list = Array.isArray(entryRecord?.skills) ? entryRecord.skills : [];
for (const skill of list) {
if (typeof skill?.name !== "string" || typeof skill?.path !== "string") continue;
const skillRecord = toObjectRecord(skill);
if (typeof skillRecord?.name !== "string" || typeof skillRecord?.path !== "string")
continue;
skills.push({
name: skill.name,
description: resolveSkillDescription(skill),
path: skill.path,
name: skillRecord.name,
description: resolveSkillDescription(skillRecord),
path: skillRecord.path,
});
}
}
@@ -2896,7 +2906,7 @@ class CodexAppServerAgentSession implements AgentSession {
private async ensureThreadLoaded(): Promise<void> {
if (!this.client || !this.currentThreadId) return;
try {
const loaded = (await this.client.request("thread/loaded/list", {})) as { data?: string[] };
const loaded = toObjectRecord(await this.client.request("thread/loaded/list", {}));
const ids = Array.isArray(loaded?.data) ? loaded.data : [];
if (ids.includes(this.currentThreadId)) {
return;
@@ -3439,8 +3449,21 @@ class CodexAppServerAgentSession implements AgentSession {
}
if (!model || !thinkingOptionId) {
const modelResponse = (await this.client.request("model/list", {})) as CodexModelListResponse;
const models = modelResponse?.data ?? [];
const modelResponse = toObjectRecord(await this.client.request("model/list", {}));
const modelData = Array.isArray(modelResponse?.data) ? modelResponse.data : [];
const models = modelData
.map((m) => {
const record = toObjectRecord(m);
return {
id: typeof record?.id === "string" ? record.id : "",
isDefault: !!record?.isDefault,
defaultReasoningEffort:
typeof record?.defaultReasoningEffort === "string"
? record.defaultReasoningEffort
: undefined,
};
})
.filter((m) => m.id);
const defaultModel = models.find((m) => m.isDefault) ?? models[0];
if (!defaultModel) {
throw new Error("No models available from Codex app-server");
@@ -3473,7 +3496,7 @@ class CodexAppServerAgentSession implements AgentSession {
const approvalPolicy = this.config.approvalPolicy ?? preset.approvalPolicy;
const sandbox = this.config.sandboxMode ?? preset.sandbox;
const innerConfig = this.buildCodexInnerConfig();
const response = (await this.client.request("thread/start", {
const rawResponse = await this.client.request("thread/start", {
model,
cwd: this.config.cwd ?? null,
approvalPolicy,
@@ -3483,8 +3506,10 @@ class CodexAppServerAgentSession implements AgentSession {
: {}),
...(innerConfig ? { config: innerConfig } : {}),
...(this.ephemeral ? { ephemeral: true } : {}),
})) as CodexThreadStartResponse;
const threadId = response?.thread?.id;
});
const response = toObjectRecord(rawResponse);
const threadRecord = toObjectRecord(response?.thread);
const threadId = typeof threadRecord?.id === "string" ? threadRecord.id : undefined;
if (!threadId) {
throw new Error("Codex app-server did not return thread id");
}
@@ -4512,9 +4537,7 @@ export class CodexAppServerAgentClient implements AgentClient {
client.notify("initialized", {});
const limit = options?.limit ?? 20;
const response = (await client.request("thread/list", { limit })) as {
data?: Array<Record<string, unknown>>;
};
const response = toObjectRecord(await client.request("thread/list", { limit }));
const threads = Array.isArray(response?.data) ? response.data : [];
const descriptors: PersistedAgentDescriptor[] = await Promise.all(
threads.slice(0, limit).map(async (thread) => {