refactor(server,app): lift return types and parse at boundaries (T2 typeaware production sweep) (#758)

* refactor(server,app): lift return types and parse at boundaries (T2 typeaware production sweep)

* fix(server): align acp-agent test assertions with pino call shape
This commit is contained in:
Mohamed Boudra
2026-05-05 19:58:56 +07:00
parent 53e1be4da6
commit 685a3d0ee2
8 changed files with 203 additions and 150 deletions
+21 -17
View File
@@ -32,12 +32,13 @@ export async function loadRealDaemonState(): Promise<RealDaemonState> {
if (!paseoHome) throw new Error("E2E_PASEO_HOME not set — globalSetup must run first"); if (!paseoHome) throw new Error("E2E_PASEO_HOME not set — globalSetup must run first");
const resp = await fetch(`http://127.0.0.1:${port}/api/status`); const resp = await fetch(`http://127.0.0.1:${port}/api/status`);
const data = (await resp.json()) as DaemonApiStatus; const data: DaemonApiStatus = await resp.json();
let pid: number | null = null; let pid: number | null = null;
try { try {
const raw = readFileSync(`${paseoHome}/paseo.pid`, "utf8"); const raw = readFileSync(`${paseoHome}/paseo.pid`, "utf8");
pid = (JSON.parse(raw) as PidFileContent).pid ?? null; const pidContent: PidFileContent = JSON.parse(raw);
pid = pidContent.pid ?? null;
} catch (err) { } catch (err) {
// PID file may not be present yet on a very fresh daemon start // PID file may not be present yet on a very fresh daemon start
console.warn("[desktop-updates] paseo.pid not found:", err); console.warn("[desktop-updates] paseo.pid not found:", err);
@@ -70,6 +71,12 @@ export interface ConfirmDialogCall {
title: string | undefined; title: string | undefined;
} }
declare global {
interface Window {
__capturedDialogCall: ConfirmDialogCall | undefined;
}
}
/** /**
* Injects window.paseoDesktop before app load so all Electron-gated code * Injects window.paseoDesktop before app load so all Electron-gated code
* activates. The update-check IPC is mocked at the boundary so the real * activates. The update-check IPC is mocked at the boundary so the real
@@ -151,12 +158,14 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
} }
if (command === "patch_desktop_settings") { if (command === "patch_desktop_settings") {
const patchDaemon = const daemon = args?.daemon;
args?.daemon && typeof args.daemon === "object" if (
? (args.daemon as Record<string, unknown>) daemon !== null &&
: {}; typeof daemon === "object" &&
if (typeof patchDaemon.manageBuiltInDaemon === "boolean") { "manageBuiltInDaemon" in daemon &&
manageDaemon = patchDaemon.manageBuiltInDaemon; typeof daemon.manageBuiltInDaemon === "boolean"
) {
manageDaemon = daemon.manageBuiltInDaemon;
} }
return { return {
releaseChannel: "stable", releaseChannel: "stable",
@@ -183,9 +192,9 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
}, },
dialog: { dialog: {
ask: async (message: string, options?: Record<string, unknown>) => { ask: async (message: string, options?: Record<string, unknown>) => {
(window as unknown as Record<string, unknown>).__capturedDialogCall = { window.__capturedDialogCall = {
message, message,
title: options?.title, title: typeof options?.title === "string" ? options.title : undefined,
}; };
return cfg.confirmShouldAccept ?? false; return cfg.confirmShouldAccept ?? false;
}, },
@@ -228,13 +237,8 @@ export async function interceptDaemonManagementConfirmDialog(
page: Page, page: Page,
): Promise<ConfirmDialogCall> { ): Promise<ConfirmDialogCall> {
await page.getByRole("switch", { name: "Manage built-in daemon" }).click(); await page.getByRole("switch", { name: "Manage built-in daemon" }).click();
await page.waitForFunction( await page.waitForFunction(() => !!window.__capturedDialogCall, { timeout: 5_000 });
() => !!(window as unknown as Record<string, unknown>).__capturedDialogCall, return page.evaluate(() => window.__capturedDialogCall!);
{ timeout: 5_000 },
);
return page.evaluate(
() => (window as unknown as Record<string, unknown>).__capturedDialogCall as ConfirmDialogCall,
);
} }
export async function toggleDaemonManagement( export async function toggleDaemonManagement(
@@ -20,7 +20,7 @@ const IS_WEB = platformIsWeb;
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
const EMPTY_COMBOBOX_OPTIONS: ReadonlyArray<ComboboxOption> = []; const EMPTY_COMBOBOX_OPTIONS: ComboboxOption[] = [];
function noop() {} function noop() {}
@@ -446,12 +446,11 @@ function ProviderSearchInput({
const InputComponent = isMobile ? BottomSheetTextInput : TextInput; const InputComponent = isMobile ? BottomSheetTextInput : TextInput;
useEffect(() => { useEffect(() => {
if (autoFocus && platformIsWeb && inputRef.current) { if (!autoFocus || !platformIsWeb || !inputRef.current) return () => {};
const timer = setTimeout(() => { const timer = setTimeout(() => {
inputRef.current?.focus(); inputRef.current?.focus();
}, 50); }, 50);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}
}, [autoFocus]); }, [autoFocus]);
const inputStyle = useMemo( const inputStyle = useMemo(
@@ -699,12 +698,12 @@ export function CombinedModelSelector({
useEffect(() => { useEffect(() => {
if (platformIsWeb) { if (platformIsWeb) {
return; return () => {};
} }
if (!isOpen) { if (!isOpen) {
setIsContentReady(false); setIsContentReady(false);
return; return () => {};
} }
const frame = requestAnimationFrame(() => { const frame = requestAnimationFrame(() => {
@@ -791,7 +790,7 @@ export function CombinedModelSelector({
)} )}
</Pressable> </Pressable>
<Combobox <Combobox
options={EMPTY_COMBOBOX_OPTIONS as ComboboxOption[]} options={EMPTY_COMBOBOX_OPTIONS}
value="" value=""
onSelect={noop} onSelect={noop}
open={isOpen} open={isOpen}
+17 -27
View File
@@ -865,11 +865,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}, [client, isConnected, serverId]); }, [client, isConnected, serverId]);
useEffect(() => { useEffect(() => {
if (!voiceRuntime) { const unregister = voiceRuntime?.registerSession({
return;
}
return voiceRuntime.registerSession({
serverId, serverId,
setVoiceMode: async (enabled, agentId) => { setVoiceMode: async (enabled, agentId) => {
if (!client) { if (!client) {
@@ -899,6 +895,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
setIsPlayingAudio(serverId, isPlaying); setIsPlayingAudio(serverId, isPlaying);
}, },
}); });
return () => unregister?.();
}, [client, serverId, setIsPlayingAudio, voiceRuntime]); }, [client, serverId, setIsPlayingAudio, voiceRuntime]);
useEffect(() => { useEffect(() => {
@@ -916,7 +913,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
useEffect(() => { useEffect(() => {
if (!client || !isConnected) { if (!client || !isConnected) {
return; return () => {};
} }
let cancelled = false; let cancelled = false;
@@ -1242,10 +1239,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
if (message.payload.kind === "remove") { if (message.payload.kind === "remove") {
clearWorkspaceArchivePending({ clearWorkspaceArchivePending({
serverId, serverId,
workspaceId: String(message.payload.id), workspaceId: message.payload.id,
}); });
removeWorkspaceSetup({ serverId, workspaceId: String(message.payload.id) }); removeWorkspaceSetup({ serverId, workspaceId: message.payload.id });
removeWorkspace(serverId, String(message.payload.id)); removeWorkspace(serverId, message.payload.id);
return; return;
} }
const workspace = normalizeWorkspaceDescriptor(message.payload.workspace); const workspace = normalizeWorkspaceDescriptor(message.payload.workspace);
@@ -1403,15 +1400,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
} }
if (data.type === "tool_call" && data.metadata) { if (data.type === "tool_call" && data.metadata) {
const { const toolCallId =
toolCallId, typeof data.metadata.toolCallId === "string" ? data.metadata.toolCallId : "";
toolName, const toolName = typeof data.metadata.toolName === "string" ? data.metadata.toolName : "";
arguments: args, const args = data.metadata.arguments;
} = data.metadata as {
toolCallId: string;
toolName: string;
arguments: unknown;
};
setMessages(serverId, (prev) => [ setMessages(serverId, (prev) => [
...prev, ...prev,
@@ -1428,10 +1420,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
} }
if (data.type === "tool_result" && data.metadata) { if (data.type === "tool_result" && data.metadata) {
const { toolCallId, result } = data.metadata as { const toolCallId =
toolCallId: string; typeof data.metadata.toolCallId === "string" ? data.metadata.toolCallId : "";
result: unknown; const result = data.metadata.result;
};
const applyToolResult = applyToolResultToMessages(toolCallId, result); const applyToolResult = applyToolResultToMessages(toolCallId, result);
setMessages(serverId, applyToolResult); setMessages(serverId, applyToolResult);
@@ -1439,10 +1430,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
} }
if (data.type === "error" && data.metadata && "toolCallId" in data.metadata) { if (data.type === "error" && data.metadata && "toolCallId" in data.metadata) {
const { toolCallId, error } = data.metadata as { const toolCallId =
toolCallId: string; typeof data.metadata.toolCallId === "string" ? data.metadata.toolCallId : "";
error: unknown; const error = data.metadata.error;
};
const applyToolError = applyToolErrorToMessages(toolCallId, error); const applyToolError = applyToolErrorToMessages(toolCallId, error);
setMessages(serverId, applyToolError); setMessages(serverId, applyToolError);
@@ -1800,7 +1790,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
} catch (error) { } catch (error) {
console.error("[Session] Failed to prepare images for agent creation:", error); console.error("[Session] Failed to prepare images for agent creation:", error);
} }
return client.createAgent({ await client.createAgent({
config, config,
...(trimmedPrompt ? { initialPrompt: trimmedPrompt } : {}), ...(trimmedPrompt ? { initialPrompt: trimmedPrompt } : {}),
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
+13 -7
View File
@@ -601,8 +601,8 @@ export interface WaitForFinishResult {
interface Waiter<T> { interface Waiter<T> {
predicate: (msg: SessionOutboundMessage) => T | null; predicate: (msg: SessionOutboundMessage) => T | null;
resolve: (value: T) => void; resolve(value: T): void;
reject: (error: Error) => void; reject(error: Error): void;
timeoutHandle: ReturnType<typeof setTimeout> | null; timeoutHandle: ReturnType<typeof setTimeout> | null;
} }
@@ -1160,7 +1160,7 @@ export class DaemonClient {
} }
const type = arg1; const type = arg1;
const handler = arg2 as (message: SessionOutboundMessage) => void; const handler = arg2!;
if (!this.messageHandlers.has(type)) { if (!this.messageHandlers.has(type)) {
this.messageHandlers.set(type, new Set()); this.messageHandlers.set(type, new Set());
@@ -4043,7 +4043,13 @@ export class DaemonClient {
const parsed = WSOutboundMessageSchema.safeParse(parsedJson); const parsed = WSOutboundMessageSchema.safeParse(parsedJson);
if (!parsed.success) { if (!parsed.success) {
const msgType = (parsedJson as { type?: string })?.type ?? "unknown"; const msgType =
parsedJson != null &&
typeof parsedJson === "object" &&
"type" in parsedJson &&
typeof parsedJson.type === "string"
? parsedJson.type
: "unknown";
this.logger.warn({ msgType, error: parsed.error.message }, "Message validation failed"); this.logger.warn({ msgType, error: parsed.error.message }, "Message validation failed");
return; return;
} }
@@ -4405,7 +4411,7 @@ export class DaemonClient {
timeout > 0 timeout > 0
? setTimeout(() => { ? setTimeout(() => {
if (waiter) { if (waiter) {
this.waiters.delete(waiter as Waiter<unknown>); this.waiters.delete(waiter);
} }
wrappedReject(timeoutError); wrappedReject(timeoutError);
}, timeout) }, timeout)
@@ -4417,7 +4423,7 @@ export class DaemonClient {
reject: wrappedReject, reject: wrappedReject,
timeoutHandle, timeoutHandle,
}; };
this.waiters.add(waiter as Waiter<unknown>); this.waiters.add(waiter);
}); });
const cancel = (error: Error) => { const cancel = (error: Error) => {
@@ -4426,7 +4432,7 @@ export class DaemonClient {
} }
if (waiter) { if (waiter) {
this.waiters.delete(waiter as Waiter<unknown>); this.waiters.delete(waiter);
if (waiter.timeoutHandle) { if (waiter.timeoutHandle) {
clearTimeout(waiter.timeoutHandle); clearTimeout(waiter.timeoutHandle);
} }
@@ -220,7 +220,7 @@ test("ACP setModel only uses config-option fallback when the matching select cho
await expect(session.setModel("new-provider-model")).resolves.toBeUndefined(); await expect(session.setModel("new-provider-model")).resolves.toBeUndefined();
expect(childLogger.warn).toHaveBeenCalledWith( expect(childLogger.warn).toHaveBeenCalledWith(
"new-provider-model", { value: "new-provider-model" },
expect.stringContaining("is not a valid claude-acp model config option"), expect.stringContaining("is not a valid claude-acp model config option"),
); );
expect(setSessionConfigOption).not.toHaveBeenCalled(); expect(setSessionConfigOption).not.toHaveBeenCalled();
@@ -514,11 +514,11 @@ describe("ACPAgentSession Zed parity", () => {
expect(invalid.setSessionMode).not.toHaveBeenCalled(); expect(invalid.setSessionMode).not.toHaveBeenCalled();
expect(invalid.unstableSetSessionModel).not.toHaveBeenCalled(); expect(invalid.unstableSetSessionModel).not.toHaveBeenCalled();
expect(childLogger.warn).toHaveBeenCalledWith( expect(childLogger.warn).toHaveBeenCalledWith(
expect.stringContaining("acceptEdits"), { value: expect.stringContaining("acceptEdits") },
expect.stringContaining("not valid"), expect.stringContaining("not valid"),
); );
expect(childLogger.warn).toHaveBeenCalledWith( expect(childLogger.warn).toHaveBeenCalledWith(
expect.stringContaining("opus"), { value: expect.stringContaining("opus") },
expect.stringContaining("not a valid"), expect.stringContaining("not a valid"),
); );
}); });
@@ -3,6 +3,10 @@ import { randomUUID } from "node:crypto";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { Readable, Writable } from "node:stream"; import { Readable, Writable } from "node:stream";
import type {
ReadableStream as NodeReadableStream,
WritableStream as NodeWritableStream,
} from "node:stream/web";
import { import {
ClientSideConnection, ClientSideConnection,
PROTOCOL_VERSION, PROTOCOL_VERSION,
@@ -90,6 +94,18 @@ import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provi
import { findExecutable } from "../../../utils/executable.js"; import { findExecutable } from "../../../utils/executable.js";
import { spawnProcess } from "../../../utils/spawn.js"; import { spawnProcess } from "../../../utils/spawn.js";
function assertChildWithPipes(
child: ChildProcess,
): asserts child is ChildProcessWithoutNullStreams {
if (!child.stdin || !child.stdout || !child.stderr) {
throw new Error("Child process did not expose stdio pipes");
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === "object" && !Array.isArray(value);
}
const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = { const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true, supportsStreaming: true,
supportsSessionPersistence: true, supportsSessionPersistence: true,
@@ -120,8 +136,8 @@ function summarizeMalformedACPStdoutError(error: unknown): { type: string; messa
} }
export function createLoggedNdJsonStream( export function createLoggedNdJsonStream(
output: WritableStream<Uint8Array>, output: NodeWritableStream,
input: ReadableStream<Uint8Array>, input: NodeReadableStream,
options: { logger: Logger; provider: string }, options: { logger: Logger; provider: string },
): ACPStream { ): ACPStream {
const textEncoder = new TextEncoder(); const textEncoder = new TextEncoder();
@@ -152,7 +168,7 @@ export function createLoggedNdJsonStream(
} }
try { try {
const message = JSON.parse(trimmedLine) as AnyMessage; const message: AnyMessage = JSON.parse(trimmedLine);
controller.enqueue(message); controller.enqueue(message);
} catch (error) { } catch (error) {
options.logger.warn( options.logger.warn(
@@ -629,7 +645,8 @@ export class ACPAgentClient implements AgentClient {
overlays: [launchEnv], overlays: [launchEnv],
}), }),
stdio: ["pipe", "pipe", "pipe"], stdio: ["pipe", "pipe", "pipe"],
}) as ChildProcessWithoutNullStreams; });
assertChildWithPipes(child);
const stderrChunks: string[] = []; const stderrChunks: string[] = [];
child.stderr.on("data", (chunk: Buffer | string) => { child.stderr.on("data", (chunk: Buffer | string) => {
@@ -643,13 +660,9 @@ export class ACPAgentClient implements AgentClient {
}); });
}); });
if (!child.stdin || !child.stdout) {
throw new Error(`${this.provider} ACP process did not expose stdio pipes`);
}
const stream = createLoggedNdJsonStream( const stream = createLoggedNdJsonStream(
Writable.toWeb(child.stdin) as WritableStream<Uint8Array>, Writable.toWeb(child.stdin),
Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>, Readable.toWeb(child.stdout),
{ logger: this.logger, provider: this.provider }, { logger: this.logger, provider: this.provider },
); );
const connection = new ClientSideConnection(() => this.buildProbeClient(), stream); const connection = new ClientSideConnection(() => this.buildProbeClient(), stream);
@@ -1546,7 +1559,8 @@ export class ACPAgentSession implements AgentSession, ACPClient {
overlays: [this.launchEnv], overlays: [this.launchEnv],
}), }),
stdio: ["pipe", "pipe", "pipe"], stdio: ["pipe", "pipe", "pipe"],
}) as ChildProcessWithoutNullStreams; });
assertChildWithPipes(child);
const stderrChunks: string[] = []; const stderrChunks: string[] = [];
child.stderr.on("data", (chunk: Buffer | string) => { child.stderr.on("data", (chunk: Buffer | string) => {
@@ -1568,13 +1582,9 @@ export class ACPAgentSession implements AgentSession, ACPClient {
} }
}); });
if (!child.stdin || !child.stdout) {
throw new Error(`${this.provider} ACP process did not expose stdio pipes`);
}
const stream = createLoggedNdJsonStream( const stream = createLoggedNdJsonStream(
Writable.toWeb(child.stdin) as WritableStream<Uint8Array>, Writable.toWeb(child.stdin),
Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>, Readable.toWeb(child.stdout),
{ logger: this.logger, provider: this.provider }, { logger: this.logger, provider: this.provider },
); );
const connection = new ClientSideConnection(() => this, stream); const connection = new ClientSideConnection(() => this, stream);
@@ -1630,7 +1640,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
} }
private warnInvalidSelection(value: string, message: string): void { private warnInvalidSelection(value: string, message: string): void {
(this.logger.warn as unknown as (value: string, message: string) => void)(value, message); this.logger.warn({ value }, message);
} }
private translateSessionUpdate(update: SessionUpdate): AgentStreamEvent[] { private translateSessionUpdate(update: SessionUpdate): AgentStreamEvent[] {
@@ -2410,9 +2420,7 @@ function appendTerminalOutput(entry: TerminalEntry, chunk: string): void {
} }
function readRecord(value: unknown): Record<string, unknown> | null { function readRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) return isRecord(value) ? value : null;
? (value as Record<string, unknown>)
: null;
} }
function readString(record: Record<string, unknown> | null, keys: string[]): string | undefined { function readString(record: Record<string, unknown> | null, keys: string[]): string | undefined {
@@ -2473,7 +2481,7 @@ function stringifyUnknown(value: unknown): string | undefined {
try { try {
return JSON.stringify(value); return JSON.stringify(value);
} catch { } catch {
return String(value); return typeof value === "bigint" ? String(value) : "[unserializable]";
} }
} }
@@ -30,7 +30,7 @@ import type {
import type { Logger } from "pino"; import type { Logger } from "pino";
import { homedir } from "node:os"; import { homedir } from "node:os";
import type { ChildProcessWithoutNullStreams } from "node:child_process"; import type { ChildProcess, ChildProcessWithoutNullStreams } from "node:child_process";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { Dirent } from "node:fs"; import { Dirent } from "node:fs";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
@@ -65,6 +65,18 @@ import {
import { runProviderTurn } from "./provider-runner.js"; import { runProviderTurn } from "./provider-runner.js";
import type { WorkspaceGitService } from "../../workspace-git-service.js"; import type { WorkspaceGitService } from "../../workspace-git-service.js";
function assertChildWithPipes(
child: ChildProcess,
): asserts child is ChildProcessWithoutNullStreams {
if (!child.stdin || !child.stdout || !child.stderr) {
throw new Error("Child process did not expose stdio pipes");
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === "object" && !Array.isArray(value);
}
const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000; const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
const TURN_START_TIMEOUT_MS = 90 * 1000; const TURN_START_TIMEOUT_MS = 90 * 1000;
const INTERRUPT_TIMEOUT_MS = 2_000; const INTERRUPT_TIMEOUT_MS = 2_000;
@@ -564,29 +576,24 @@ interface JsonRpcNotification {
params?: unknown; params?: unknown;
} }
type JsonRpcMessage = JsonRpcRequest | JsonRpcResponse | JsonRpcNotification; function isJsonRpcResponse(msg: unknown): msg is JsonRpcResponse {
if (!isRecord(msg)) return false;
function isJsonRpcResponse(msg: JsonRpcMessage): msg is JsonRpcResponse { if (typeof msg.id !== "number") return false;
const m = msg as unknown as Record<string, unknown>; return msg.result !== undefined || !!msg.error;
if (typeof m.id !== "number") return false;
return m.result !== undefined || !!m.error;
} }
function isJsonRpcRequest(msg: JsonRpcMessage): msg is JsonRpcRequest { function isJsonRpcRequest(msg: unknown): msg is JsonRpcRequest {
const m = msg as unknown as Record<string, unknown>; if (!isRecord(msg)) return false;
return typeof m.id === "number" && typeof m.method === "string"; return typeof msg.id === "number" && typeof msg.method === "string";
} }
function isJsonRpcNotification(msg: JsonRpcMessage): msg is JsonRpcNotification { function isJsonRpcNotification(msg: unknown): msg is JsonRpcNotification {
const m = msg as unknown as Record<string, unknown>; if (!isRecord(msg)) return false;
return typeof m.method === "string" && typeof m.id !== "number"; return typeof msg.method === "string" && typeof msg.id !== "number";
} }
function toObjectRecord(value: unknown): Record<string, unknown> | undefined { function toObjectRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) { return isRecord(value) ? value : undefined;
return undefined;
}
return value as Record<string, unknown>;
} }
interface PendingRequest { interface PendingRequest {
@@ -615,9 +622,28 @@ interface CodexModel {
supportedReasoningEfforts?: CodexReasoningEffortEntry[]; supportedReasoningEfforts?: CodexReasoningEffortEntry[];
} }
interface CodexModelListResponse { const CodexModelListResponseSchema = z.object({
data?: CodexModel[]; data: z
} .array(
z.object({
id: z.string(),
displayName: z.string().optional(),
description: z.string().optional(),
isDefault: z.boolean().optional(),
model: z.string().optional(),
defaultReasoningEffort: z.string().optional(),
supportedReasoningEfforts: z
.array(
z.object({
reasoningEffort: z.string().optional(),
description: z.string().optional(),
}),
)
.optional(),
}),
)
.optional(),
});
class CodexAppServerClient { class CodexAppServerClient {
private readonly rl: readline.Interface; private readonly rl: readline.Interface;
@@ -740,31 +766,30 @@ class CodexAppServerClient {
private async handleLine(line: string): Promise<void> { private async handleLine(line: string): Promise<void> {
if (!line.trim()) return; if (!line.trim()) return;
const raw = JSON.parse(line); const raw: unknown = JSON.parse(line);
const msg = toObjectRecord(raw) as unknown as JsonRpcMessage; if (!isRecord(raw)) {
if (!msg) {
this.logger.warn({ line }, "Parsed JSON is not an object"); this.logger.warn({ line }, "Parsed JSON is not an object");
return; return;
} }
if (isJsonRpcResponse(msg)) { if (isJsonRpcResponse(raw)) {
const id = msg.id; const id = raw.id;
if (msg.result !== undefined || msg.error) { if (raw.result !== undefined || raw.error) {
const pending = this.pending.get(id); const pending = this.pending.get(id);
if (!pending) return; if (!pending) return;
clearTimeout(pending.timer); clearTimeout(pending.timer);
this.pending.delete(id); this.pending.delete(id);
if (msg.error) { if (raw.error) {
pending.reject(new Error(msg.error.message ?? "Unknown error")); pending.reject(new Error(raw.error.message ?? "Unknown error"));
} else { } else {
pending.resolve(msg.result); pending.resolve(raw.result);
} }
return; return;
} }
// Server-initiated request // Server-initiated request
if (isJsonRpcRequest(msg)) { if (isJsonRpcRequest(raw)) {
const request = msg; const request = raw;
const handler = this.requestHandlers.get(request.method); const handler = this.requestHandlers.get(request.method);
try { try {
const result = handler ? await handler(request.params) : {}; const result = handler ? await handler(request.params) : {};
@@ -779,8 +804,8 @@ class CodexAppServerClient {
} }
} }
if (isJsonRpcNotification(msg)) { if (isJsonRpcNotification(raw)) {
this.notificationHandler?.(msg.method, msg.params); this.notificationHandler?.(raw.method, raw.params);
} }
} }
} }
@@ -4322,14 +4347,16 @@ class CodexAppServerAgentSession implements AgentSession {
} }
private handleCommandApprovalRequest(params: unknown): Promise<unknown> { private handleCommandApprovalRequest(params: unknown): Promise<unknown> {
const parsed = params as { const parsed = z
itemId: string; .object({
threadId: string; itemId: z.string(),
turnId: string; threadId: z.string(),
command?: string | null; turnId: z.string(),
cwd?: string | null; command: z.string().nullable().optional(),
reason?: string | null; cwd: z.string().nullable().optional(),
}; reason: z.string().nullable().optional(),
})
.parse(params);
const commandPreview = mapCodexExecNotificationToToolCall({ const commandPreview = mapCodexExecNotificationToToolCall({
callId: parsed.itemId, callId: parsed.itemId,
command: parsed.command, command: parsed.command,
@@ -4371,12 +4398,14 @@ class CodexAppServerAgentSession implements AgentSession {
} }
private handleFileChangeApprovalRequest(params: unknown): Promise<unknown> { private handleFileChangeApprovalRequest(params: unknown): Promise<unknown> {
const parsed = params as { const parsed = z
itemId: string; .object({
threadId: string; itemId: z.string(),
turnId: string; threadId: z.string(),
reason?: string | null; turnId: z.string(),
}; reason: z.string().nullable().optional(),
})
.parse(params);
const requestId = `permission-${parsed.itemId}`; const requestId = `permission-${parsed.itemId}`;
const request: AgentPermissionRequest = { const request: AgentPermissionRequest = {
id: requestId, id: requestId,
@@ -4406,12 +4435,14 @@ class CodexAppServerAgentSession implements AgentSession {
} }
private handleToolApprovalRequest(params: unknown): Promise<unknown> { private handleToolApprovalRequest(params: unknown): Promise<unknown> {
const parsed = params as { const parsed = z
itemId: string; .object({
threadId: string; itemId: z.string(),
turnId: string; threadId: z.string(),
questions: unknown[]; turnId: z.string(),
}; questions: z.array(z.unknown()),
})
.parse(params);
const requestId = `permission-${parsed.itemId}`; const requestId = `permission-${parsed.itemId}`;
const questions = normalizeCodexQuestionPrompts(parsed.questions); const questions = normalizeCodexQuestionPrompts(parsed.questions);
const request: AgentPermissionRequest = { const request: AgentPermissionRequest = {
@@ -4475,14 +4506,16 @@ export class CodexAppServerAgentClient implements AgentClient {
}, },
"Spawning Codex app server", "Spawning Codex app server",
); );
return spawnProcess(launchPrefix.command, [...launchPrefix.args, "app-server"], { const child = spawnProcess(launchPrefix.command, [...launchPrefix.args, "app-server"], {
detached: process.platform !== "win32", detached: process.platform !== "win32",
stdio: ["pipe", "pipe", "pipe"], stdio: ["pipe", "pipe", "pipe"],
...createProviderEnvSpec({ ...createProviderEnvSpec({
runtimeSettings: this.runtimeSettings, runtimeSettings: this.runtimeSettings,
overlays: [launchEnv], overlays: [launchEnv],
}), }),
}) as ChildProcessWithoutNullStreams; });
assertChildWithPipes(child);
return child;
} }
async createSession( async createSession(
@@ -4508,7 +4541,7 @@ export class CodexAppServerAgentClient implements AgentClient {
overrides?: Partial<AgentSessionConfig>, overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext, launchContext?: AgentLaunchContext,
): Promise<AgentSession> { ): Promise<AgentSession> {
const storedConfig = (handle.metadata ?? {}) as unknown as AgentSessionConfig; const storedConfig = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
const merged: AgentSessionConfig = { const merged: AgentSessionConfig = {
...storedConfig, ...storedConfig,
...overrides, ...overrides,
@@ -4599,8 +4632,9 @@ export class CodexAppServerAgentClient implements AgentClient {
await client.request("initialize", buildCodexAppServerInitializeParams()); await client.request("initialize", buildCodexAppServerInitializeParams());
client.notify("initialized", {}); client.notify("initialized", {});
const response = (await client.request("model/list", {})) as CodexModelListResponse; const rawResponse = await client.request("model/list", {});
const models = Array.isArray(response?.data) ? response.data : []; const parsedResponse = CodexModelListResponseSchema.safeParse(rawResponse);
const models = parsedResponse.success ? (parsedResponse.data.data ?? []) : [];
const configuredDefaults = await readCodexConfiguredDefaults(client, this.logger); const configuredDefaults = await readCodexConfiguredDefaults(client, this.logger);
const configuredDefaultModelId = configuredDefaults.model; const configuredDefaultModelId = configuredDefaults.model;
const configuredDefaultThinkingOptionId = configuredDefaults.thinkingOptionId; const configuredDefaultThinkingOptionId = configuredDefaults.thinkingOptionId;
+18 -6
View File
@@ -44,6 +44,18 @@ interface PullRequestStatusLookupTarget {
headRepositoryOwner?: string; headRepositoryOwner?: string;
} }
function getErrorStderr(error: Error): string {
return "stderr" in error && typeof error.stderr === "string" ? error.stderr : "";
}
function getErrorStdout(error: Error): string {
return "stdout" in error && typeof error.stdout === "string" ? error.stdout : "";
}
function throwBranchNotFound(branch: string | undefined): never {
throw new Error(`Branch not found: ${branch ?? "unknown"}`);
}
function createPullRequestStatusCache(ttlMs: number) { function createPullRequestStatusCache(ttlMs: number) {
return new TTLCache<string, PullRequestStatusResult>({ return new TTLCache<string, PullRequestStatusResult>({
ttl: ttlMs, ttl: ttlMs,
@@ -380,8 +392,8 @@ export async function checkoutResolvedBranch(
cwd, cwd,
}); });
return { source: "remote" }; return { source: "remote" };
case "not-found": default:
throw new Error(`Branch not found: ${input.requestedBranch ?? "unknown"}`); return throwBranchNotFound(input.requestedBranch);
} }
} }
@@ -1967,7 +1979,7 @@ async function detectAndThrowMergeToBaseConflict(
const { operationCwd, error, baseRef, currentBranch } = input; const { operationCwd, error, baseRef, currentBranch } = input;
const errorDetails = const errorDetails =
error instanceof Error error instanceof Error
? `${error.message}\n${(error as { stderr?: string }).stderr ?? ""}\n${(error as { stdout?: string }).stdout ?? ""}` ? `${error.message}\n${getErrorStderr(error)}\n${getErrorStdout(error)}`
: String(error); : String(error);
try { try {
const [unmergedOutput, lsFilesOutput, statusOutput] = await Promise.all([ const [unmergedOutput, lsFilesOutput, statusOutput] = await Promise.all([
@@ -1990,7 +2002,7 @@ async function detectAndThrowMergeToBaseConflict(
.split("\n") .split("\n")
.map((line) => line.trim()) .map((line) => line.trim())
.filter(Boolean) .filter(Boolean)
.map((line) => line.split("\t").pop() as string), .map((line) => line.split("\t").at(-1) ?? ""),
...statusConflicts, ...statusConflicts,
].filter(Boolean); ].filter(Boolean);
const conflictDetected = const conflictDetected =
@@ -2150,7 +2162,7 @@ async function detectAndThrowMergeFromBaseConflict(
const { cwd, error, baseRef, currentBranch } = input; const { cwd, error, baseRef, currentBranch } = input;
const errorDetails = const errorDetails =
error instanceof Error error instanceof Error
? `${error.message}\n${(error as { stderr?: string }).stderr ?? ""}\n${(error as { stdout?: string }).stdout ?? ""}` ? `${error.message}\n${getErrorStderr(error)}\n${getErrorStdout(error)}`
: String(error); : String(error);
try { try {
const [unmergedOutput, lsFilesOutput, statusOutput] = await Promise.all([ const [unmergedOutput, lsFilesOutput, statusOutput] = await Promise.all([
@@ -2173,7 +2185,7 @@ async function detectAndThrowMergeFromBaseConflict(
.split("\n") .split("\n")
.map((line) => line.trim()) .map((line) => line.trim())
.filter(Boolean) .filter(Boolean)
.map((line) => line.split("\t").pop() as string), .map((line) => line.split("\t").at(-1) ?? ""),
...statusConflicts, ...statusConflicts,
].filter(Boolean); ].filter(Boolean);
const conflictDetected = const conflictDetected =