Update files

This commit is contained in:
Mohamed Boudra
2026-02-10 11:43:07 +07:00
parent 7001928f4f
commit ade4fb2e45
15 changed files with 1026 additions and 77 deletions

View File

@@ -1,2 +0,0 @@
Dummy edit created by Codex.
You asked for any edit, so this file is a simple placeholder.

View File

@@ -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 =

View 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");
});
});

View 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";
}

View 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");
});
});

View File

@@ -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;
}

View File

@@ -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) => {

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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();
}
});
});

View File

@@ -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);

View File

@@ -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;

View File

@@ -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();

View File

@@ -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,
},
});
}
}