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");
|
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}
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
Reference in New Issue
Block a user