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