mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Update files
This commit is contained in:
@@ -1,2 +0,0 @@
|
||||
Dummy edit created by Codex.
|
||||
You asked for any edit, so this file is a simple placeholder.
|
||||
@@ -38,6 +38,11 @@ import { useTrafficLightPadding } from "@/utils/tauri-window";
|
||||
import { CommandCenter } from "@/components/command-center";
|
||||
import { useGlobalKeyboardNav } from "@/hooks/use-global-keyboard-nav";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
import {
|
||||
WEB_NOTIFICATION_CLICK_EVENT,
|
||||
type WebNotificationClickDetail,
|
||||
} from "@/utils/os-notifications";
|
||||
import { buildNotificationRoute } from "@/utils/notification-routing";
|
||||
|
||||
polyfillCrypto();
|
||||
|
||||
@@ -47,7 +52,24 @@ function PushNotificationRouter() {
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "web") {
|
||||
return;
|
||||
const target = globalThis as unknown as EventTarget;
|
||||
const openFromWebClick = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<WebNotificationClickDetail>;
|
||||
const route = buildNotificationRoute(customEvent.detail?.data);
|
||||
event.preventDefault();
|
||||
router.push(route as any);
|
||||
};
|
||||
|
||||
target.addEventListener(
|
||||
WEB_NOTIFICATION_CLICK_EVENT,
|
||||
openFromWebClick as EventListener
|
||||
);
|
||||
return () => {
|
||||
target.removeEventListener(
|
||||
WEB_NOTIFICATION_CLICK_EVENT,
|
||||
openFromWebClick as EventListener
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
Notifications.setNotificationHandler({
|
||||
@@ -71,14 +93,7 @@ function PushNotificationRouter() {
|
||||
const data = response.notification.request.content.data as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const agentId = typeof data?.agentId === "string" ? data.agentId : null;
|
||||
|
||||
if (agentId) {
|
||||
// Legacy route resolves agent -> host once sessions reconnect.
|
||||
router.push(`/agent/${agentId}` as any);
|
||||
} else {
|
||||
router.push("/agents" as any);
|
||||
}
|
||||
router.push(buildNotificationRoute(data) as any);
|
||||
};
|
||||
|
||||
const subscription =
|
||||
|
||||
56
packages/app/src/utils/notification-routing.test.ts
Normal file
56
packages/app/src/utils/notification-routing.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildNotificationRoute,
|
||||
resolveNotificationTarget,
|
||||
} from "./notification-routing";
|
||||
|
||||
describe("resolveNotificationTarget", () => {
|
||||
it("extracts non-empty server and agent ids", () => {
|
||||
expect(
|
||||
resolveNotificationTarget({
|
||||
serverId: " server-123 ",
|
||||
agentId: " agent-456 ",
|
||||
})
|
||||
).toEqual({
|
||||
serverId: "server-123",
|
||||
agentId: "agent-456",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for missing/empty ids", () => {
|
||||
expect(resolveNotificationTarget({ serverId: "", agentId: " " })).toEqual({
|
||||
serverId: null,
|
||||
agentId: null,
|
||||
});
|
||||
expect(resolveNotificationTarget(undefined)).toEqual({
|
||||
serverId: null,
|
||||
agentId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildNotificationRoute", () => {
|
||||
it("routes directly to server-scoped agent path when both ids are present", () => {
|
||||
expect(buildNotificationRoute({ serverId: "srv-1", agentId: "agent-1" })).toBe(
|
||||
"/agent/srv-1/agent-1"
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to legacy agent route when serverId is absent", () => {
|
||||
expect(buildNotificationRoute({ agentId: "agent-legacy" })).toBe("/agent/agent-legacy");
|
||||
});
|
||||
|
||||
it("falls back to agents list when no agent id is present", () => {
|
||||
expect(buildNotificationRoute({ serverId: "srv-only" })).toBe("/agents");
|
||||
});
|
||||
|
||||
it("encodes path segments", () => {
|
||||
expect(
|
||||
buildNotificationRoute({
|
||||
serverId: "srv/with/slash",
|
||||
agentId: "agent with space",
|
||||
})
|
||||
).toBe("/agent/srv%2Fwith%2Fslash/agent%20with%20space");
|
||||
});
|
||||
});
|
||||
34
packages/app/src/utils/notification-routing.ts
Normal file
34
packages/app/src/utils/notification-routing.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
type NotificationData = Record<string, unknown> | null | undefined;
|
||||
|
||||
function readNonEmptyString(
|
||||
data: NotificationData,
|
||||
key: string
|
||||
): string | null {
|
||||
const value = data?.[key];
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function resolveNotificationTarget(data: NotificationData): {
|
||||
serverId: string | null;
|
||||
agentId: string | null;
|
||||
} {
|
||||
return {
|
||||
serverId: readNonEmptyString(data, "serverId"),
|
||||
agentId: readNonEmptyString(data, "agentId"),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildNotificationRoute(data: NotificationData): string {
|
||||
const { serverId, agentId } = resolveNotificationTarget(data);
|
||||
if (serverId && agentId) {
|
||||
return `/agent/${encodeURIComponent(serverId)}/${encodeURIComponent(agentId)}`;
|
||||
}
|
||||
if (agentId) {
|
||||
return `/agent/${encodeURIComponent(agentId)}`;
|
||||
}
|
||||
return "/agents";
|
||||
}
|
||||
166
packages/app/src/utils/os-notifications.test.ts
Normal file
166
packages/app/src/utils/os-notifications.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type MockNotificationOptions = {
|
||||
body?: string;
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type MockNotificationInstance = {
|
||||
title: string;
|
||||
options?: MockNotificationOptions;
|
||||
onclick: ((event: Event) => void) | null;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
type GlobalSnapshot = {
|
||||
Notification: unknown;
|
||||
CustomEvent: unknown;
|
||||
dispatchEvent: unknown;
|
||||
focus: unknown;
|
||||
location: unknown;
|
||||
};
|
||||
|
||||
const originalGlobals: GlobalSnapshot = {
|
||||
Notification: (globalThis as { Notification?: unknown }).Notification,
|
||||
CustomEvent: (globalThis as { CustomEvent?: unknown }).CustomEvent,
|
||||
dispatchEvent: (globalThis as { dispatchEvent?: unknown }).dispatchEvent,
|
||||
focus: (globalThis as { focus?: unknown }).focus,
|
||||
location: (globalThis as { location?: unknown }).location,
|
||||
};
|
||||
|
||||
async function loadModuleForPlatform(platform: "web" | "ios" | "android") {
|
||||
vi.resetModules();
|
||||
vi.doMock("react-native", () => ({ Platform: { OS: platform } }));
|
||||
return import("./os-notifications");
|
||||
}
|
||||
|
||||
function restoreGlobals(): void {
|
||||
(globalThis as { Notification?: unknown }).Notification = originalGlobals.Notification;
|
||||
(globalThis as { CustomEvent?: unknown }).CustomEvent = originalGlobals.CustomEvent;
|
||||
(globalThis as { dispatchEvent?: unknown }).dispatchEvent = originalGlobals.dispatchEvent;
|
||||
(globalThis as { focus?: unknown }).focus = originalGlobals.focus;
|
||||
(globalThis as { location?: unknown }).location = originalGlobals.location;
|
||||
}
|
||||
|
||||
describe("sendOsNotification", () => {
|
||||
beforeEach(() => {
|
||||
class MockCustomEvent<T = unknown> {
|
||||
type: string;
|
||||
detail: T;
|
||||
cancelable: boolean;
|
||||
defaultPrevented = false;
|
||||
|
||||
constructor(type: string, init?: { detail?: T; cancelable?: boolean }) {
|
||||
this.type = type;
|
||||
this.detail = (init?.detail ?? null) as T;
|
||||
this.cancelable = init?.cancelable ?? false;
|
||||
}
|
||||
|
||||
preventDefault(): void {
|
||||
if (this.cancelable) {
|
||||
this.defaultPrevented = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(globalThis as { CustomEvent?: unknown }).CustomEvent = MockCustomEvent;
|
||||
(globalThis as { focus?: unknown }).focus = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.doUnmock("react-native");
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
restoreGlobals();
|
||||
});
|
||||
|
||||
it("dispatches a click event that the app can handle", async () => {
|
||||
const created: MockNotificationInstance[] = [];
|
||||
|
||||
class MockNotification implements MockNotificationInstance {
|
||||
static permission = "granted";
|
||||
static requestPermission = vi.fn(async () => "granted");
|
||||
onclick: ((event: Event) => void) | null = null;
|
||||
close = vi.fn();
|
||||
|
||||
constructor(public title: string, public options?: MockNotificationOptions) {
|
||||
created.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
const dispatchEvent = vi.fn((event: unknown) => {
|
||||
void event;
|
||||
return false;
|
||||
});
|
||||
const assign = vi.fn();
|
||||
|
||||
(globalThis as { Notification?: unknown }).Notification = MockNotification;
|
||||
(globalThis as { dispatchEvent?: unknown }).dispatchEvent = dispatchEvent;
|
||||
(globalThis as { location?: unknown }).location = { assign };
|
||||
|
||||
const { sendOsNotification, WEB_NOTIFICATION_CLICK_EVENT } =
|
||||
await loadModuleForPlatform("web");
|
||||
|
||||
const sent = await sendOsNotification({
|
||||
title: "Agent finished",
|
||||
body: "Done",
|
||||
data: { serverId: "srv-1", agentId: "agent-1" },
|
||||
});
|
||||
|
||||
expect(sent).toBe(true);
|
||||
expect(created).toHaveLength(1);
|
||||
|
||||
const clicked = created[0];
|
||||
expect(clicked.onclick).toBeTypeOf("function");
|
||||
clicked.onclick?.({} as Event);
|
||||
|
||||
expect(dispatchEvent).toHaveBeenCalledTimes(1);
|
||||
const event = dispatchEvent.mock.calls[0]?.[0] as unknown as {
|
||||
type?: string;
|
||||
detail?: { data?: Record<string, unknown> };
|
||||
};
|
||||
expect(event?.type).toBe(WEB_NOTIFICATION_CLICK_EVENT);
|
||||
expect(event?.detail).toEqual({
|
||||
data: { serverId: "srv-1", agentId: "agent-1" },
|
||||
});
|
||||
expect(assign).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to route navigation when no listener handles the click", async () => {
|
||||
const created: MockNotificationInstance[] = [];
|
||||
|
||||
class MockNotification implements MockNotificationInstance {
|
||||
static permission = "granted";
|
||||
static requestPermission = vi.fn(async () => "granted");
|
||||
onclick: ((event: Event) => void) | null = null;
|
||||
close = vi.fn();
|
||||
|
||||
constructor(public title: string, public options?: MockNotificationOptions) {
|
||||
created.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
const dispatchEvent = vi.fn((event: unknown) => {
|
||||
void event;
|
||||
return true;
|
||||
});
|
||||
const assign = vi.fn();
|
||||
|
||||
(globalThis as { Notification?: unknown }).Notification = MockNotification;
|
||||
(globalThis as { dispatchEvent?: unknown }).dispatchEvent = dispatchEvent;
|
||||
(globalThis as { location?: unknown }).location = { assign };
|
||||
|
||||
const { sendOsNotification } = await loadModuleForPlatform("web");
|
||||
|
||||
await sendOsNotification({
|
||||
title: "Agent finished",
|
||||
data: { serverId: "srv with space", agentId: "agent/1" },
|
||||
});
|
||||
|
||||
const clicked = created[0];
|
||||
expect(clicked.onclick).toBeTypeOf("function");
|
||||
clicked.onclick?.({} as Event);
|
||||
|
||||
expect(assign).toHaveBeenCalledWith("/agent/srv%20with%20space/agent%2F1");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Platform } from "react-native";
|
||||
import { buildNotificationRoute } from "./notification-routing";
|
||||
|
||||
type OsNotificationPayload = {
|
||||
title: string;
|
||||
@@ -6,6 +7,18 @@ type OsNotificationPayload = {
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type WebNotificationClickDetail = {
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type WebNotificationInstance = {
|
||||
onclick?: ((event: Event) => void) | null;
|
||||
addEventListener?: (type: string, listener: (event: Event) => void) => void;
|
||||
close?: () => void;
|
||||
};
|
||||
|
||||
export const WEB_NOTIFICATION_CLICK_EVENT = "paseo:web-notification-click";
|
||||
|
||||
let permissionRequest: Promise<boolean> | null = null;
|
||||
|
||||
function getWebNotificationConstructor(): {
|
||||
@@ -41,6 +54,71 @@ async function ensureNotificationPermission(): Promise<boolean> {
|
||||
return result;
|
||||
}
|
||||
|
||||
function dispatchWebNotificationClick(detail: WebNotificationClickDetail): boolean {
|
||||
const dispatch = (globalThis as { dispatchEvent?: (event: Event) => boolean }).dispatchEvent;
|
||||
const CustomEventConstructor = (globalThis as { CustomEvent?: typeof CustomEvent })
|
||||
.CustomEvent;
|
||||
|
||||
if (typeof dispatch !== "function" || !CustomEventConstructor) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const event = new CustomEventConstructor<WebNotificationClickDetail>(
|
||||
WEB_NOTIFICATION_CLICK_EVENT,
|
||||
{
|
||||
detail,
|
||||
cancelable: true,
|
||||
}
|
||||
);
|
||||
return dispatch(event) === false;
|
||||
}
|
||||
|
||||
function fallbackNavigateToNotificationTarget(
|
||||
data: Record<string, unknown> | undefined
|
||||
): void {
|
||||
const route = buildNotificationRoute(data);
|
||||
const location = (globalThis as { location?: { assign?: (url: string) => void; href?: string } })
|
||||
.location;
|
||||
if (!location) {
|
||||
return;
|
||||
}
|
||||
if (typeof location.assign === "function") {
|
||||
location.assign(route);
|
||||
return;
|
||||
}
|
||||
if (typeof location.href === "string") {
|
||||
location.href = route;
|
||||
}
|
||||
}
|
||||
|
||||
function attachWebClickHandler(
|
||||
notification: WebNotificationInstance,
|
||||
data: Record<string, unknown> | undefined
|
||||
): void {
|
||||
const onClick = () => {
|
||||
const focus = (globalThis as { focus?: () => void }).focus;
|
||||
if (typeof focus === "function") {
|
||||
focus();
|
||||
}
|
||||
|
||||
const handledByApp = dispatchWebNotificationClick({ data });
|
||||
if (!handledByApp) {
|
||||
fallbackNavigateToNotificationTarget(data);
|
||||
}
|
||||
|
||||
if (typeof notification.close === "function") {
|
||||
notification.close();
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof notification.addEventListener === "function") {
|
||||
notification.addEventListener("click", onClick);
|
||||
return;
|
||||
}
|
||||
|
||||
notification.onclick = onClick;
|
||||
}
|
||||
|
||||
export async function sendOsNotification(
|
||||
payload: OsNotificationPayload
|
||||
): Promise<boolean> {
|
||||
@@ -57,9 +135,10 @@ export async function sendOsNotification(
|
||||
if (!granted) {
|
||||
return false;
|
||||
}
|
||||
new NotificationConstructor(payload.title, {
|
||||
const notification = new NotificationConstructor(payload.title, {
|
||||
body: payload.body,
|
||||
data: payload.data,
|
||||
});
|
||||
}) as WebNotificationInstance;
|
||||
attachWebClickHandler(notification, payload.data);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Command } from 'commander'
|
||||
import chalk from 'chalk'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { closeSync, openSync, readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import {
|
||||
createPaseoDaemon,
|
||||
loadConfig,
|
||||
@@ -11,16 +14,34 @@ import type { CliConfigOverrides } from '@getpaseo/server'
|
||||
|
||||
interface StartOptions {
|
||||
port?: string
|
||||
listen?: string
|
||||
home?: string
|
||||
foreground?: boolean
|
||||
noRelay?: boolean
|
||||
noMcp?: boolean
|
||||
relay?: boolean
|
||||
mcp?: boolean
|
||||
allowedHosts?: string
|
||||
}
|
||||
|
||||
interface DetachedStartupReady {
|
||||
exitedEarly: false
|
||||
}
|
||||
|
||||
interface DetachedStartupExited {
|
||||
exitedEarly: true
|
||||
code: number | null
|
||||
signal: NodeJS.Signals | null
|
||||
error?: Error
|
||||
}
|
||||
|
||||
type DetachedStartupResult = DetachedStartupReady | DetachedStartupExited
|
||||
|
||||
const DETACHED_STARTUP_GRACE_MS = 1200
|
||||
const DAEMON_LOG_FILENAME = 'daemon.log'
|
||||
|
||||
export function startCommand(): Command {
|
||||
return new Command('start')
|
||||
.description('Start the Paseo daemon')
|
||||
.option('--listen <listen>', 'Listen target (host:port, port, or unix socket path)')
|
||||
.option('--port <port>', 'Port to listen on (default: 6767)')
|
||||
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
|
||||
.option('--foreground', 'Run in foreground (don\'t daemonize)')
|
||||
@@ -35,7 +56,133 @@ export function startCommand(): Command {
|
||||
})
|
||||
}
|
||||
|
||||
function buildForegroundArgs(options: StartOptions): string[] {
|
||||
const args = ['daemon', 'start', '--foreground']
|
||||
|
||||
if (options.listen) {
|
||||
args.push('--listen', options.listen)
|
||||
} else if (options.port) {
|
||||
args.push('--port', options.port)
|
||||
}
|
||||
|
||||
if (options.home) {
|
||||
args.push('--home', options.home)
|
||||
}
|
||||
|
||||
if (options.relay === false) {
|
||||
args.push('--no-relay')
|
||||
}
|
||||
|
||||
if (options.mcp === false) {
|
||||
args.push('--no-mcp')
|
||||
}
|
||||
|
||||
if (options.allowedHosts) {
|
||||
args.push('--allowed-hosts', options.allowedHosts)
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
function tailFile(filePath: string, lines = 30): string | null {
|
||||
try {
|
||||
const content = readFileSync(filePath, 'utf-8')
|
||||
return content.split('\n').filter(Boolean).slice(-lines).join('\n')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function runDetachedStart(options: StartOptions): Promise<void> {
|
||||
const childEnv: NodeJS.ProcessEnv = { ...process.env }
|
||||
if (options.home) {
|
||||
childEnv.PASEO_HOME = options.home
|
||||
}
|
||||
|
||||
const paseoHome = resolvePaseoHome(childEnv)
|
||||
const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME)
|
||||
|
||||
const cliEntry = process.argv[1]
|
||||
if (!cliEntry) {
|
||||
throw new Error('Unable to determine CLI entrypoint for detached daemon start')
|
||||
}
|
||||
|
||||
const logFd = openSync(logPath, 'a')
|
||||
|
||||
try {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[...process.execArgv, cliEntry, ...buildForegroundArgs(options)],
|
||||
{
|
||||
detached: true,
|
||||
env: childEnv,
|
||||
stdio: ['ignore', logFd, logFd],
|
||||
}
|
||||
)
|
||||
|
||||
child.unref()
|
||||
|
||||
const startup = await new Promise<DetachedStartupResult>((resolve) => {
|
||||
let settled = false
|
||||
|
||||
const finish = (value: DetachedStartupResult) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve(value)
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => finish({ exitedEarly: false }), DETACHED_STARTUP_GRACE_MS)
|
||||
|
||||
child.once('error', (error) => {
|
||||
clearTimeout(timer)
|
||||
finish({ exitedEarly: true, code: null, signal: null, error })
|
||||
})
|
||||
|
||||
child.once('exit', (code, signal) => {
|
||||
clearTimeout(timer)
|
||||
finish({ exitedEarly: true, code, signal })
|
||||
})
|
||||
})
|
||||
|
||||
if (startup.exitedEarly) {
|
||||
const reason = startup.error
|
||||
? startup.error.message
|
||||
: `exit code ${startup.code ?? 'unknown'}${startup.signal ? ` (${startup.signal})` : ''}`
|
||||
const recentLogs = tailFile(logPath)
|
||||
throw new Error(
|
||||
[
|
||||
`Daemon failed to start in background (${reason}).`,
|
||||
recentLogs ? `Recent daemon logs:\n${recentLogs}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
)
|
||||
}
|
||||
|
||||
console.log(chalk.green(`Daemon starting in background (PID ${child.pid ?? 'unknown'}).`))
|
||||
console.log(chalk.dim(`Logs: ${logPath}`))
|
||||
} finally {
|
||||
closeSync(logFd)
|
||||
}
|
||||
}
|
||||
|
||||
async function runStart(options: StartOptions): Promise<void> {
|
||||
if (options.listen && options.port) {
|
||||
console.error(chalk.red('Cannot use --listen and --port together'))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!options.foreground) {
|
||||
try {
|
||||
await runDetachedStart(options)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.error(chalk.red(message))
|
||||
process.exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Set environment variables based on CLI options
|
||||
if (options.home) {
|
||||
process.env.PASEO_HOME = options.home
|
||||
@@ -46,11 +193,13 @@ async function runStart(options: StartOptions): Promise<void> {
|
||||
let config: ReturnType<typeof loadConfig>
|
||||
const cliOverrides: CliConfigOverrides = {}
|
||||
|
||||
if (options.port) {
|
||||
if (options.listen) {
|
||||
cliOverrides.listen = options.listen
|
||||
} else if (options.port) {
|
||||
cliOverrides.listen = `127.0.0.1:${options.port}`
|
||||
}
|
||||
|
||||
if (options.noRelay) {
|
||||
if (options.relay === false) {
|
||||
cliOverrides.relayEnabled = false
|
||||
}
|
||||
|
||||
@@ -62,7 +211,7 @@ async function runStart(options: StartOptions): Promise<void> {
|
||||
: raw.split(',').map(h => h.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
if (options.noMcp) {
|
||||
if (options.mcp === false) {
|
||||
cliOverrides.mcpEnabled = false
|
||||
}
|
||||
|
||||
@@ -77,14 +226,15 @@ async function runStart(options: StartOptions): Promise<void> {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// For now, only foreground mode is supported
|
||||
// TODO: Implement daemonization in a future phase
|
||||
if (!options.foreground) {
|
||||
console.log(chalk.yellow('Note: Background daemon mode not yet implemented. Running in foreground.'))
|
||||
let daemon: Awaited<ReturnType<typeof createPaseoDaemon>>
|
||||
try {
|
||||
daemon = await createPaseoDaemon(config, logger)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.error(chalk.red(`Failed to initialize daemon: ${message}`))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const daemon = await createPaseoDaemon(config, logger)
|
||||
|
||||
// Handle graceful shutdown
|
||||
let shuttingDown = false
|
||||
const handleShutdown = async (signal: string) => {
|
||||
|
||||
@@ -6,9 +6,10 @@ import { mkdtempSync } from "node:fs";
|
||||
|
||||
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { agentConfigs } from "../../daemon-e2e/agent-configs.js";
|
||||
|
||||
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
|
||||
const CODEX_TEST_THINKING_OPTION_ID = "low";
|
||||
const CODEX_TEST_MODEL = agentConfigs.codex.model;
|
||||
const CODEX_TEST_THINKING_OPTION_ID = agentConfigs.codex.thinkingOptionId;
|
||||
|
||||
function isCodexInstalled(): boolean {
|
||||
try {
|
||||
|
||||
@@ -5,18 +5,20 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
__codexAppServerInternals,
|
||||
CodexAppServerAgentClient,
|
||||
codexAppServerTurnInputFromPrompt,
|
||||
} from "./codex-app-server-agent.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { agentConfigs } from "../../daemon-e2e/agent-configs.js";
|
||||
import type {
|
||||
AgentPermissionRequest,
|
||||
AgentPromptContentBlock,
|
||||
AgentTimelineItem,
|
||||
} from "../agent-sdk-types.js";
|
||||
|
||||
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
|
||||
const CODEX_TEST_THINKING_OPTION_ID = "low";
|
||||
const CODEX_TEST_MODEL = agentConfigs.codex.model;
|
||||
const CODEX_TEST_THINKING_OPTION_ID = agentConfigs.codex.thinkingOptionId;
|
||||
const ONE_BY_ONE_PNG_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X1r0AAAAASUVORK5CYII=";
|
||||
|
||||
@@ -87,6 +89,29 @@ function hasApplyPatchFile(item: AgentTimelineItem, fileName: string): boolean {
|
||||
return inInput || inOutput || inDiff || inputPath === fileName || outputPath === fileName;
|
||||
}
|
||||
|
||||
function buildStrictApplyPatchPrompt(
|
||||
patch: string,
|
||||
completionToken: string,
|
||||
options?: { includePermissionStep?: boolean }
|
||||
): string {
|
||||
const lines = [
|
||||
"You are running an automated integration test.",
|
||||
"Required behavior:",
|
||||
"- Call the apply_patch tool exactly once using the patch below.",
|
||||
"- Do not call shell, Bash, exec_command, write_file, or any other tool.",
|
||||
"- Do not ask for confirmation in text or via any tool call.",
|
||||
];
|
||||
if (options?.includePermissionStep) {
|
||||
lines.push(
|
||||
"- If permission is required, wait for approval and then continue with the same apply_patch call."
|
||||
);
|
||||
}
|
||||
lines.push("Patch to apply exactly:");
|
||||
lines.push(patch);
|
||||
lines.push(`After successful apply_patch completion, reply exactly ${completionToken}.`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function waitForFileToContainText(
|
||||
filePath: string,
|
||||
expectedText: string,
|
||||
@@ -175,6 +200,50 @@ describe("Codex app-server provider (integration)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("maps patch notifications with array-style changes and alias diff keys", () => {
|
||||
const item = __codexAppServerInternals.mapCodexPatchNotificationToToolCall({
|
||||
callId: "patch-array-alias",
|
||||
changes: [
|
||||
{
|
||||
path: "/tmp/repo/src/array-alias.ts",
|
||||
kind: "modify",
|
||||
unified_diff: "@@\n-old\n+new\n",
|
||||
},
|
||||
],
|
||||
cwd: "/tmp/repo",
|
||||
running: false,
|
||||
});
|
||||
|
||||
expect(item.detail.type).toBe("edit");
|
||||
if (item.detail.type === "edit") {
|
||||
expect(item.detail.filePath).toBe("src/array-alias.ts");
|
||||
expect(item.detail.unifiedDiff).toContain("-old");
|
||||
expect(item.detail.unifiedDiff).toContain("+new");
|
||||
expect(item.detail.newString).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("maps patch notifications with object-style single change payloads", () => {
|
||||
const item = __codexAppServerInternals.mapCodexPatchNotificationToToolCall({
|
||||
callId: "patch-object-single",
|
||||
changes: {
|
||||
path: "/tmp/repo/src/object-single.ts",
|
||||
kind: "modify",
|
||||
patch: "@@\n-before\n+after\n",
|
||||
},
|
||||
cwd: "/tmp/repo",
|
||||
running: false,
|
||||
});
|
||||
|
||||
expect(item.detail.type).toBe("edit");
|
||||
if (item.detail.type === "edit") {
|
||||
expect(item.detail.filePath).toBe("src/object-single.ts");
|
||||
expect(item.detail.unifiedDiff).toContain("-before");
|
||||
expect(item.detail.unifiedDiff).toContain("+after");
|
||||
expect(item.detail.newString).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test.runIf(isCodexInstalled())("listModels returns live Codex models", async () => {
|
||||
const client = new CodexAppServerAgentClient(logger);
|
||||
const models = await client.listModels();
|
||||
@@ -209,19 +278,28 @@ describe("Codex app-server provider (integration)", () => {
|
||||
const codexHome = tmpCwd("codex-home-defaults-");
|
||||
const prevCodexHome = process.env.CODEX_HOME;
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
writeFileSync(
|
||||
path.join(codexHome, "config.toml"),
|
||||
[
|
||||
'model = "gpt-5.3-codex"',
|
||||
'model_reasoning_effort = "xhigh"',
|
||||
].join("\n"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
try {
|
||||
const client = new CodexAppServerAgentClient(logger);
|
||||
const baselineModels = await client.listModels();
|
||||
const baselineDefaultModel =
|
||||
baselineModels.find((model) => model.isDefault) ?? baselineModels[0];
|
||||
expect(baselineDefaultModel).toBeDefined();
|
||||
const configuredModelId = baselineDefaultModel?.id;
|
||||
expect(typeof configuredModelId).toBe("string");
|
||||
expect((configuredModelId ?? "").length).toBeGreaterThan(0);
|
||||
|
||||
writeFileSync(
|
||||
path.join(codexHome, "config.toml"),
|
||||
[
|
||||
`model = "${configuredModelId}"`,
|
||||
'model_reasoning_effort = "xhigh"',
|
||||
].join("\n"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const models = await client.listModels();
|
||||
const configuredModel = models.find((model) => model.id === "gpt-5.3-codex");
|
||||
const configuredModel = models.find((model) => model.id === configuredModelId);
|
||||
expect(configuredModel).toBeDefined();
|
||||
expect(configuredModel?.isDefault).toBe(true);
|
||||
expect(configuredModel?.defaultThinkingOptionId).toBe("xhigh");
|
||||
@@ -703,14 +781,9 @@ describe("Codex app-server provider (integration)", () => {
|
||||
"*** End Patch",
|
||||
].join("\n");
|
||||
const patchEvents = session.stream(
|
||||
[
|
||||
"Use the apply_patch tool and nothing else.",
|
||||
"Do not use the shell tool for file changes.",
|
||||
"Do not respond with any message until the apply_patch tool completes.",
|
||||
"Apply the following patch exactly:",
|
||||
patch,
|
||||
"After it completes, reply PATCH_DONE.",
|
||||
].join("\n")
|
||||
buildStrictApplyPatchPrompt(patch, "PATCH_DONE", {
|
||||
includePermissionStep: true,
|
||||
})
|
||||
);
|
||||
|
||||
for await (const event of patchEvents) {
|
||||
@@ -801,13 +874,9 @@ describe("Codex app-server provider (integration)", () => {
|
||||
"*** End Patch",
|
||||
].join("\n");
|
||||
const events = session.stream(
|
||||
[
|
||||
"Use the apply_patch tool and nothing else.",
|
||||
"Do not use shell or any other file-edit tool.",
|
||||
"Apply this patch exactly:",
|
||||
patch,
|
||||
"After tool completion, reply PATCH_DONE.",
|
||||
].join("\n")
|
||||
buildStrictApplyPatchPrompt(patch, "PATCH_DONE", {
|
||||
includePermissionStep: true,
|
||||
})
|
||||
);
|
||||
|
||||
for await (const event of events) {
|
||||
@@ -1252,14 +1321,9 @@ describe("Codex app-server provider (integration)", () => {
|
||||
"*** End Patch",
|
||||
].join("\n");
|
||||
const events = session.stream(
|
||||
[
|
||||
"Use the apply_patch tool and nothing else.",
|
||||
"If you need approval before writing files, request approval first.",
|
||||
"Do not respond with any message until the apply_patch tool completes.",
|
||||
"Apply the following patch exactly:",
|
||||
patch,
|
||||
"After approval, reply FILE_DONE.",
|
||||
].join("\n")
|
||||
buildStrictApplyPatchPrompt(patch, "FILE_DONE", {
|
||||
includePermissionStep: true,
|
||||
})
|
||||
);
|
||||
|
||||
let failure: string | null = null;
|
||||
@@ -1298,7 +1362,19 @@ describe("Codex app-server provider (integration)", () => {
|
||||
expect(sawPermissionResolved).toBe(true);
|
||||
}
|
||||
const sawPatch = timelineItems.some((item) => hasApplyPatchFile(item, "approval-test.txt"));
|
||||
expect(sawPatch).toBe(true);
|
||||
if (!sawPatch) {
|
||||
const toolCalls = timelineItems
|
||||
.filter((item): item is Extract<AgentTimelineItem, { type: "tool_call" }> => item.type === "tool_call")
|
||||
.map((item) => ({
|
||||
name: item.name,
|
||||
status: item.status,
|
||||
callId: item.callId,
|
||||
detail: item.detail,
|
||||
}));
|
||||
throw new Error(
|
||||
`Did not observe apply_patch timeline detail for approval-test.txt. Tool calls: ${JSON.stringify(toolCalls)}`
|
||||
);
|
||||
}
|
||||
|
||||
const text = await waitForFileToContainText(targetPath, "ok", { timeoutMs: 10000 });
|
||||
if (!text) {
|
||||
|
||||
@@ -708,6 +708,27 @@ type CodexPatchFileChange = {
|
||||
content?: string;
|
||||
};
|
||||
|
||||
function extractPatchLikeText(value: unknown): string | undefined {
|
||||
if (!value || typeof value !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const candidates = [
|
||||
record.diff,
|
||||
record.patch,
|
||||
record.unified_diff,
|
||||
record.unifiedDiff,
|
||||
record.content,
|
||||
record.newString,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate === "string" && candidate.length > 0) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeCodexThreadItemType(rawType: string | undefined): string | undefined {
|
||||
if (!rawType) {
|
||||
return rawType;
|
||||
@@ -780,20 +801,60 @@ function parseCodexPatchChanges(changes: unknown): CodexPatchFileChange[] {
|
||||
if (!changes || typeof changes !== "object") {
|
||||
return [];
|
||||
}
|
||||
return Object.entries(changes as Record<string, unknown>)
|
||||
|
||||
if (Array.isArray(changes)) {
|
||||
return changes
|
||||
.map((entry): CodexPatchFileChange | null => {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return null;
|
||||
}
|
||||
const record = entry as Record<string, unknown>;
|
||||
const pathValue =
|
||||
typeof record.path === "string" && record.path.trim().length > 0
|
||||
? record.path.trim()
|
||||
: "";
|
||||
if (!pathValue) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
path: pathValue,
|
||||
kind:
|
||||
(typeof record.kind === "string" && record.kind) ||
|
||||
(typeof record.type === "string" && record.type) ||
|
||||
undefined,
|
||||
content: extractPatchLikeText(record),
|
||||
};
|
||||
})
|
||||
.filter((entry): entry is CodexPatchFileChange => entry !== null);
|
||||
}
|
||||
|
||||
const recordChanges = changes as Record<string, unknown>;
|
||||
if (typeof recordChanges.path === "string" && recordChanges.path.trim().length > 0) {
|
||||
return [
|
||||
{
|
||||
path: recordChanges.path.trim(),
|
||||
kind:
|
||||
(typeof recordChanges.kind === "string" && recordChanges.kind) ||
|
||||
(typeof recordChanges.type === "string" && recordChanges.type) ||
|
||||
undefined,
|
||||
content: extractPatchLikeText(recordChanges),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return Object.entries(recordChanges)
|
||||
.map(([path, value]): CodexPatchFileChange | null => {
|
||||
const normalizedPath = path.trim();
|
||||
if (!normalizedPath) {
|
||||
return null;
|
||||
}
|
||||
const parsed =
|
||||
value && typeof value === "object"
|
||||
? (value as { type?: unknown; content?: unknown })
|
||||
: null;
|
||||
return {
|
||||
path: normalizedPath,
|
||||
kind: typeof parsed?.type === "string" ? parsed.type : undefined,
|
||||
content: typeof parsed?.content === "string" ? parsed.content : undefined,
|
||||
kind:
|
||||
value && typeof value === "object" && typeof (value as { type?: unknown }).type === "string"
|
||||
? ((value as { type?: string }).type ?? undefined)
|
||||
: undefined,
|
||||
content: extractPatchLikeText(value),
|
||||
};
|
||||
})
|
||||
.filter((entry): entry is CodexPatchFileChange => entry !== null);
|
||||
@@ -822,6 +883,46 @@ function toRunningToolCall(item: ToolCallTimelineItem): ToolCallTimelineItem {
|
||||
};
|
||||
}
|
||||
|
||||
function isEditToolCallWithoutContent(item: ToolCallTimelineItem): boolean {
|
||||
if (item.type !== "tool_call") {
|
||||
return false;
|
||||
}
|
||||
if (item.detail.type !== "edit") {
|
||||
return false;
|
||||
}
|
||||
const hasDiff =
|
||||
typeof item.detail.unifiedDiff === "string" &&
|
||||
item.detail.unifiedDiff.trim().length > 0;
|
||||
const hasNewString =
|
||||
typeof item.detail.newString === "string" &&
|
||||
item.detail.newString.trim().length > 0;
|
||||
return !hasDiff && !hasNewString;
|
||||
}
|
||||
|
||||
function decodeCodexOutputDeltaChunk(chunk: string): string {
|
||||
const trimmed = chunk.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return chunk;
|
||||
}
|
||||
if (!/^[A-Za-z0-9+/=]+$/.test(trimmed) || trimmed.length % 4 !== 0) {
|
||||
return chunk;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = Buffer.from(trimmed, "base64").toString("utf8");
|
||||
if (decoded.length === 0) {
|
||||
return chunk;
|
||||
}
|
||||
const normalizedInput = trimmed.replace(/=+$/, "");
|
||||
const normalizedRoundTrip = Buffer.from(decoded, "utf8")
|
||||
.toString("base64")
|
||||
.replace(/=+$/, "");
|
||||
return normalizedRoundTrip === normalizedInput ? decoded : chunk;
|
||||
} catch {
|
||||
return chunk;
|
||||
}
|
||||
}
|
||||
|
||||
function mapCodexExecNotificationToToolCall(params: {
|
||||
callId?: string | null;
|
||||
command: unknown;
|
||||
@@ -881,10 +982,10 @@ function mapCodexPatchNotificationToToolCall(params: {
|
||||
}): ToolCallTimelineItem {
|
||||
const files = parseCodexPatchChanges(params.changes);
|
||||
const firstPath = files[0]?.path;
|
||||
const firstContent = files
|
||||
const firstPatchText = files
|
||||
.map((file) => file.content?.trim())
|
||||
.find((value): value is string => typeof value === "string" && value.length > 0);
|
||||
const patchText = params.latestUnifiedDiff?.trim() || firstContent;
|
||||
const patchText = params.latestUnifiedDiff?.trim() || firstPatchText;
|
||||
const patchFields = codexPatchTextFields(patchText);
|
||||
const mapped = mapCodexRolloutToolCall({
|
||||
callId: params.callId ?? null,
|
||||
@@ -907,7 +1008,7 @@ function mapCodexPatchNotificationToToolCall(params: {
|
||||
files: files.map((file) => ({
|
||||
path: file.path,
|
||||
...(file.kind ? { kind: file.kind } : {}),
|
||||
...patchFields,
|
||||
...codexPatchTextFields(file.content ?? patchText),
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
@@ -1138,6 +1239,18 @@ const CodexEventExecCommandEndNotificationSchema = z.object({
|
||||
.passthrough(),
|
||||
}).passthrough();
|
||||
|
||||
const CodexEventExecCommandOutputDeltaNotificationSchema = z.object({
|
||||
msg: z
|
||||
.object({
|
||||
type: z.literal("exec_command_output_delta"),
|
||||
call_id: z.string().optional(),
|
||||
stream: z.string().optional(),
|
||||
chunk: z.string().optional(),
|
||||
delta: z.string().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
}).passthrough();
|
||||
|
||||
const CodexEventPatchApplyBeginNotificationSchema = z.object({
|
||||
msg: z
|
||||
.object({
|
||||
@@ -1161,6 +1274,12 @@ const CodexEventPatchApplyEndNotificationSchema = z.object({
|
||||
.passthrough(),
|
||||
}).passthrough();
|
||||
|
||||
const ItemFileChangeOutputDeltaNotificationSchema = z.object({
|
||||
itemId: z.string(),
|
||||
delta: z.string().optional(),
|
||||
chunk: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const CodexEventTurnDiffNotificationSchema = z.object({
|
||||
msg: z
|
||||
.object({
|
||||
@@ -1206,6 +1325,12 @@ type ParsedCodexNotification =
|
||||
success: boolean | null;
|
||||
stderr: string | null;
|
||||
}
|
||||
| {
|
||||
kind: "exec_command_output_delta";
|
||||
callId: string | null;
|
||||
stream: string | null;
|
||||
chunk: string | null;
|
||||
}
|
||||
| {
|
||||
kind: "patch_apply_started";
|
||||
callId: string | null;
|
||||
@@ -1219,6 +1344,11 @@ type ParsedCodexNotification =
|
||||
stderr: string | null;
|
||||
success: boolean | null;
|
||||
}
|
||||
| {
|
||||
kind: "file_change_output_delta";
|
||||
itemId: string;
|
||||
delta: string | null;
|
||||
}
|
||||
| { kind: "invalid_payload"; method: string; params: unknown }
|
||||
| { kind: "unknown_method"; method: string; params: unknown };
|
||||
|
||||
@@ -1370,6 +1500,23 @@ const CodexNotificationSchema = z.union([
|
||||
z.object({ method: z.literal("codex/event/exec_command_end"), params: z.unknown() }).transform(
|
||||
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
|
||||
),
|
||||
z.object({
|
||||
method: z.literal("codex/event/exec_command_output_delta"),
|
||||
params: CodexEventExecCommandOutputDeltaNotificationSchema,
|
||||
}).transform(
|
||||
({ params }): ParsedCodexNotification => ({
|
||||
kind: "exec_command_output_delta",
|
||||
callId: params.msg.call_id ?? null,
|
||||
stream: params.msg.stream ?? null,
|
||||
chunk: params.msg.chunk ?? params.msg.delta ?? null,
|
||||
})
|
||||
),
|
||||
z.object({
|
||||
method: z.literal("codex/event/exec_command_output_delta"),
|
||||
params: z.unknown(),
|
||||
}).transform(
|
||||
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
|
||||
),
|
||||
z.object({
|
||||
method: z.literal("codex/event/patch_apply_begin"),
|
||||
params: CodexEventPatchApplyBeginNotificationSchema,
|
||||
@@ -1399,6 +1546,19 @@ const CodexNotificationSchema = z.union([
|
||||
z.object({ method: z.literal("codex/event/patch_apply_end"), params: z.unknown() }).transform(
|
||||
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
|
||||
),
|
||||
z.object({
|
||||
method: z.literal("item/fileChange/outputDelta"),
|
||||
params: ItemFileChangeOutputDeltaNotificationSchema,
|
||||
}).transform(
|
||||
({ params }): ParsedCodexNotification => ({
|
||||
kind: "file_change_output_delta",
|
||||
itemId: params.itemId,
|
||||
delta: params.delta ?? params.chunk ?? null,
|
||||
})
|
||||
),
|
||||
z.object({ method: z.literal("item/fileChange/outputDelta"), params: z.unknown() }).transform(
|
||||
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
|
||||
),
|
||||
z.object({
|
||||
method: z.literal("codex/event/turn_diff"),
|
||||
params: CodexEventTurnDiffNotificationSchema,
|
||||
@@ -1539,6 +1699,10 @@ export async function codexAppServerTurnInputFromPrompt(
|
||||
return output;
|
||||
}
|
||||
|
||||
export const __codexAppServerInternals = {
|
||||
mapCodexPatchNotificationToToolCall,
|
||||
};
|
||||
|
||||
class CodexAppServerAgentSession implements AgentSession {
|
||||
readonly provider = CODEX_PROVIDER;
|
||||
readonly capabilities = CODEX_APP_SERVER_CAPABILITIES;
|
||||
@@ -1565,10 +1729,13 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
private resolvedPermissionRequests = new Set<string>();
|
||||
private pendingAgentMessages = new Map<string, string>();
|
||||
private pendingReasoning = new Map<string, string[]>();
|
||||
private pendingCommandOutputDeltas = new Map<string, string[]>();
|
||||
private pendingFileChangeOutputDeltas = new Map<string, string[]>();
|
||||
private emittedItemStartedIds = new Set<string>();
|
||||
private emittedItemCompletedIds = new Set<string>();
|
||||
private warnedUnknownNotificationMethods = new Set<string>();
|
||||
private warnedInvalidNotificationPayloads = new Set<string>();
|
||||
private warnedIncompleteEditToolCallIds = new Set<string>();
|
||||
private latestTurnUnifiedDiff: string | null = null;
|
||||
private latestUsage: AgentUsage | undefined;
|
||||
private connected = false;
|
||||
@@ -1757,6 +1924,9 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
cwd: this.config.cwd ?? null,
|
||||
});
|
||||
if (timelineItem) {
|
||||
if (timelineItem.type === "tool_call") {
|
||||
this.warnOnIncompleteEditToolCall(timelineItem, "thread_read", item);
|
||||
}
|
||||
threadTimeline.push(timelineItem);
|
||||
}
|
||||
}
|
||||
@@ -2255,6 +2425,9 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
this.latestTurnUnifiedDiff = null;
|
||||
this.emittedItemStartedIds.clear();
|
||||
this.emittedItemCompletedIds.clear();
|
||||
this.pendingCommandOutputDeltas.clear();
|
||||
this.pendingFileChangeOutputDeltas.clear();
|
||||
this.warnedIncompleteEditToolCallIds.clear();
|
||||
this.emitEvent({ type: "turn_started", provider: CODEX_PROVIDER });
|
||||
return;
|
||||
}
|
||||
@@ -2274,6 +2447,9 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
this.latestTurnUnifiedDiff = null;
|
||||
this.emittedItemStartedIds.clear();
|
||||
this.emittedItemCompletedIds.clear();
|
||||
this.pendingCommandOutputDeltas.clear();
|
||||
this.pendingFileChangeOutputDeltas.clear();
|
||||
this.warnedIncompleteEditToolCallIds.clear();
|
||||
this.eventQueue?.end();
|
||||
return;
|
||||
}
|
||||
@@ -2321,7 +2497,25 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.kind === "exec_command_output_delta") {
|
||||
this.appendOutputDeltaChunk(
|
||||
this.pendingCommandOutputDeltas,
|
||||
parsed.callId,
|
||||
parsed.chunk,
|
||||
{ decodeBase64: true }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.kind === "file_change_output_delta") {
|
||||
this.appendOutputDeltaChunk(this.pendingFileChangeOutputDeltas, parsed.itemId, parsed.delta);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.kind === "exec_command_started") {
|
||||
if (parsed.callId) {
|
||||
this.pendingCommandOutputDeltas.delete(parsed.callId);
|
||||
}
|
||||
const timelineItem = mapCodexExecNotificationToToolCall({
|
||||
callId: parsed.callId,
|
||||
command: parsed.command,
|
||||
@@ -2335,11 +2529,15 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
if (parsed.kind === "exec_command_completed") {
|
||||
const bufferedOutput = this.consumeOutputDelta(
|
||||
this.pendingCommandOutputDeltas,
|
||||
parsed.callId
|
||||
);
|
||||
const timelineItem = mapCodexExecNotificationToToolCall({
|
||||
callId: parsed.callId,
|
||||
command: parsed.command,
|
||||
cwd: parsed.cwd ?? this.config.cwd ?? null,
|
||||
output: parsed.output,
|
||||
output: parsed.output ?? bufferedOutput,
|
||||
exitCode: parsed.exitCode,
|
||||
success: parsed.success,
|
||||
stderr: parsed.stderr,
|
||||
@@ -2352,6 +2550,9 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
if (parsed.kind === "patch_apply_started") {
|
||||
if (parsed.callId) {
|
||||
this.pendingFileChangeOutputDeltas.delete(parsed.callId);
|
||||
}
|
||||
const timelineItem = mapCodexPatchNotificationToToolCall({
|
||||
callId: parsed.callId,
|
||||
changes: parsed.changes,
|
||||
@@ -2359,21 +2560,34 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
latestUnifiedDiff: this.latestTurnUnifiedDiff,
|
||||
running: true,
|
||||
});
|
||||
this.warnOnIncompleteEditToolCall(timelineItem, "patch_apply_started", {
|
||||
callId: parsed.callId,
|
||||
changes: parsed.changes,
|
||||
});
|
||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.kind === "patch_apply_completed") {
|
||||
const bufferedOutput = this.consumeOutputDelta(
|
||||
this.pendingFileChangeOutputDeltas,
|
||||
parsed.callId
|
||||
);
|
||||
const timelineItem = mapCodexPatchNotificationToToolCall({
|
||||
callId: parsed.callId,
|
||||
changes: parsed.changes,
|
||||
cwd: this.config.cwd ?? null,
|
||||
stdout: parsed.stdout,
|
||||
stdout: parsed.stdout ?? bufferedOutput,
|
||||
stderr: parsed.stderr,
|
||||
success: parsed.success,
|
||||
latestUnifiedDiff: this.latestTurnUnifiedDiff,
|
||||
running: false,
|
||||
});
|
||||
this.warnOnIncompleteEditToolCall(timelineItem, "patch_apply_completed", {
|
||||
callId: parsed.callId,
|
||||
changes: parsed.changes,
|
||||
stdout: parsed.stdout,
|
||||
});
|
||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
||||
return;
|
||||
}
|
||||
@@ -2406,10 +2620,15 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
timelineItem.text = buffered.join("");
|
||||
}
|
||||
}
|
||||
if (timelineItem.type === "tool_call") {
|
||||
this.warnOnIncompleteEditToolCall(timelineItem, "item_completed", parsed.item);
|
||||
}
|
||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
||||
if (itemId) {
|
||||
this.emittedItemCompletedIds.add(itemId);
|
||||
this.emittedItemStartedIds.delete(itemId);
|
||||
this.pendingCommandOutputDeltas.delete(itemId);
|
||||
this.pendingFileChangeOutputDeltas.delete(itemId);
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -2428,9 +2647,12 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
if (itemId && this.emittedItemStartedIds.has(itemId)) {
|
||||
return;
|
||||
}
|
||||
this.warnOnIncompleteEditToolCall(timelineItem, "item_started", parsed.item);
|
||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
||||
if (itemId) {
|
||||
this.emittedItemStartedIds.add(itemId);
|
||||
this.pendingCommandOutputDeltas.delete(itemId);
|
||||
this.pendingFileChangeOutputDeltas.delete(itemId);
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -2464,6 +2686,62 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
);
|
||||
}
|
||||
|
||||
private appendOutputDeltaChunk(
|
||||
store: Map<string, string[]>,
|
||||
id: string | null | undefined,
|
||||
chunk: string | null | undefined,
|
||||
options?: { decodeBase64?: boolean }
|
||||
): void {
|
||||
if (!id || !chunk) {
|
||||
return;
|
||||
}
|
||||
const normalized = options?.decodeBase64 ? decodeCodexOutputDeltaChunk(chunk) : chunk;
|
||||
if (!normalized.length) {
|
||||
return;
|
||||
}
|
||||
const prev = store.get(id) ?? [];
|
||||
prev.push(normalized);
|
||||
store.set(id, prev);
|
||||
}
|
||||
|
||||
private consumeOutputDelta(store: Map<string, string[]>, id: string | null | undefined): string | null {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
const buffered = store.get(id);
|
||||
if (!buffered || buffered.length === 0) {
|
||||
return null;
|
||||
}
|
||||
store.delete(id);
|
||||
return buffered.join("");
|
||||
}
|
||||
|
||||
private warnOnIncompleteEditToolCall(
|
||||
item: ToolCallTimelineItem,
|
||||
source: string,
|
||||
payload: unknown
|
||||
): void {
|
||||
if (!isEditToolCallWithoutContent(item)) {
|
||||
return;
|
||||
}
|
||||
const warnKey = `${source}:${item.callId}`;
|
||||
if (this.warnedIncompleteEditToolCallIds.has(warnKey)) {
|
||||
return;
|
||||
}
|
||||
this.warnedIncompleteEditToolCallIds.add(warnKey);
|
||||
this.logger.warn(
|
||||
{
|
||||
source,
|
||||
callId: item.callId,
|
||||
status: item.status,
|
||||
name: item.name,
|
||||
detail: item.detail,
|
||||
payload,
|
||||
},
|
||||
"Codex edit tool call is missing diff/content fields"
|
||||
);
|
||||
}
|
||||
|
||||
private handleCommandApprovalRequest(params: unknown): Promise<unknown> {
|
||||
const parsed = params as {
|
||||
itemId: string;
|
||||
|
||||
@@ -411,4 +411,56 @@ describe("codex tool-call mapper", () => {
|
||||
expect(item.detail.newString).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("maps fileChange patch alias fields into edit unified diff detail", () => {
|
||||
const item = mapCodexToolCallFromThreadItem(
|
||||
{
|
||||
type: "fileChange",
|
||||
id: "codex-file-change-patch-alias",
|
||||
status: "completed",
|
||||
changes: [
|
||||
{
|
||||
path: "/tmp/repo/src/from-patch-alias.ts",
|
||||
kind: "modify",
|
||||
patch: "@@\n-oldAlias\n+newAlias\n",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ cwd: "/tmp/repo" }
|
||||
);
|
||||
|
||||
expect(item?.detail?.type).toBe("edit");
|
||||
if (item?.detail?.type === "edit") {
|
||||
expect(item.detail.filePath).toBe("src/from-patch-alias.ts");
|
||||
expect(item.detail.unifiedDiff).toContain("-oldAlias");
|
||||
expect(item.detail.unifiedDiff).toContain("+newAlias");
|
||||
expect(item.detail.newString).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("maps fileChange unifiedDiff alias fields into edit unified diff detail", () => {
|
||||
const item = mapCodexToolCallFromThreadItem(
|
||||
{
|
||||
type: "fileChange",
|
||||
id: "codex-file-change-unified-diff-alias",
|
||||
status: "completed",
|
||||
changes: [
|
||||
{
|
||||
path: "/tmp/repo/src/from-unified-diff-alias.ts",
|
||||
kind: "modify",
|
||||
unified_diff: "@@\n-beforeAlias\n+afterAlias\n",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ cwd: "/tmp/repo" }
|
||||
);
|
||||
|
||||
expect(item?.detail?.type).toBe("edit");
|
||||
if (item?.detail?.type === "edit") {
|
||||
expect(item.detail.filePath).toBe("src/from-unified-diff-alias.ts");
|
||||
expect(item.detail.unifiedDiff).toContain("-beforeAlias");
|
||||
expect(item.detail.unifiedDiff).toContain("+afterAlias");
|
||||
expect(item.detail.newString).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,7 +59,11 @@ const CodexFileChangeItemSchema = z
|
||||
path: z.string().optional(),
|
||||
kind: z.string().optional(),
|
||||
diff: z.string().optional(),
|
||||
patch: z.string().optional(),
|
||||
unified_diff: z.string().optional(),
|
||||
unifiedDiff: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
newString: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
)
|
||||
@@ -305,6 +309,15 @@ function asPatchOrContentFields(text: string | undefined): { patch?: string; con
|
||||
return { content: text };
|
||||
}
|
||||
|
||||
function pickFirstPatchLikeString(values: unknown[]): string | undefined {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function removePatchLikeFields(input: Record<string, unknown>): Record<string, unknown> {
|
||||
const {
|
||||
patch: _patch,
|
||||
@@ -560,7 +573,14 @@ function mapFileChangeItem(
|
||||
return {
|
||||
path: pathValue,
|
||||
kind: change.kind,
|
||||
diff: change.diff ?? change.content,
|
||||
diff: pickFirstPatchLikeString([
|
||||
change.diff,
|
||||
change.patch,
|
||||
change.unified_diff,
|
||||
change.unifiedDiff,
|
||||
change.content,
|
||||
change.newString,
|
||||
]),
|
||||
};
|
||||
})
|
||||
.filter((change) => change.path !== undefined);
|
||||
|
||||
@@ -64,7 +64,7 @@ import type {
|
||||
AgentClient,
|
||||
AgentProvider,
|
||||
} from "./agent/agent-sdk-types.js";
|
||||
import { acquirePidLock, releasePidLock } from "./pid-lock.js";
|
||||
import { acquirePidLock, releasePidLock, isLocked, PidLockError } from "./pid-lock.js";
|
||||
import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js";
|
||||
import {
|
||||
createVoiceMcpSocketBridgeManager,
|
||||
@@ -136,6 +136,17 @@ export async function createPaseoDaemon(
|
||||
rootLogger: Logger
|
||||
): Promise<PaseoDaemon> {
|
||||
const logger = rootLogger.child({ module: "bootstrap" });
|
||||
|
||||
// Fail fast before expensive bootstrap (speech/runtime/model checks) if another daemon is active.
|
||||
const lockState = await isLocked(config.paseoHome);
|
||||
if (lockState.locked) {
|
||||
const existingLock = lockState.info;
|
||||
throw new PidLockError(
|
||||
`Another Paseo daemon is already running (PID ${existingLock?.pid ?? "unknown"}, started ${existingLock?.startedAt ?? "unknown"})`,
|
||||
existingLock
|
||||
);
|
||||
}
|
||||
|
||||
const serverId = getOrCreateServerId(config.paseoHome, { logger });
|
||||
const daemonKeyPair = await loadOrCreateDaemonKeyPair(config.paseoHome, logger);
|
||||
let relayTransport: RelayTransportController | null = null;
|
||||
|
||||
@@ -34,7 +34,16 @@ async function main() {
|
||||
config.mcpEnabled = false;
|
||||
}
|
||||
|
||||
const daemon = await createPaseoDaemon(config, logger);
|
||||
let daemon;
|
||||
try {
|
||||
daemon = await createPaseoDaemon(config, logger);
|
||||
} catch (err) {
|
||||
if (err instanceof PidLockError) {
|
||||
logger.error({ pid: err.existingLock?.pid }, err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
await daemon.start();
|
||||
|
||||
@@ -643,7 +643,11 @@ export class VoiceAssistantWebSocketServer {
|
||||
void this.pushService.sendPush(tokens, {
|
||||
title,
|
||||
body,
|
||||
data: { agentId: params.agentId, reason: params.reason },
|
||||
data: {
|
||||
serverId: this.serverId,
|
||||
agentId: params.agentId,
|
||||
reason: params.reason,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user