consolidate checkout status handling and improve git detection

This commit is contained in:
Mohamed Boudra
2026-02-03 14:50:15 +07:00
parent ad632cb3bd
commit 14b0db55b0
12 changed files with 369 additions and 188 deletions
@@ -791,7 +791,11 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
(isDiffError && diffError instanceof Error ? diffError.message : null); (isDiffError && diffError instanceof Error ? diffError.message : null);
const prErrorMessage = prPayloadError?.message ?? null; const prErrorMessage = prPayloadError?.message ?? null;
const branchLabel = const branchLabel =
gitStatus?.currentBranch ?? (notGit ? "Not a git repository" : "Unknown"); gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD"
? gitStatus.currentBranch
: notGit
? "Not a git repository"
: "Unknown";
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading; const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0; const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0;
const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0; const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0;
@@ -440,7 +440,11 @@ export function GroupedAgentList({
}); });
const checkout = checkoutQuery.data ?? null; const checkout = checkoutQuery.data ?? null;
const activeBranchLabel = checkout?.isGit const activeBranchLabel = checkout?.isGit
? (checkout.currentBranch ?? checkout.baseRef ?? "git") ? ((checkout.currentBranch && checkout.currentBranch !== "HEAD"
? checkout.currentBranch
: null) ??
checkout.baseRef ??
"git")
: null; : null;
const canArchive = !isRunning && !agent.requiresAttention; const canArchive = !isRunning && !agent.requiresAttention;
@@ -39,7 +39,7 @@ describe("useCheckoutStatusQuery", () => {
describe("nextCheckoutStatusRefetchDecision", () => { describe("nextCheckoutStatusRefetchDecision", () => {
it("refetches only once per key until reset", () => { it("refetches only once per key until reset", () => {
const key = "daemon-1:agent-1"; const key = "daemon-1:/path/to/project";
expect(nextCheckoutStatusRefetchDecision(null, key)).toEqual({ expect(nextCheckoutStatusRefetchDecision(null, key)).toEqual({
nextSeenKey: key, nextSeenKey: key,
@@ -62,11 +62,11 @@ describe("useCheckoutStatusQuery", () => {
}); });
}); });
it("refetches again when agent changes while active", () => { it("refetches again when cwd changes while active", () => {
expect( expect(
nextCheckoutStatusRefetchDecision("daemon-1:agent-1", "daemon-1:agent-2") nextCheckoutStatusRefetchDecision("daemon-1:/path/a", "daemon-1:/path/b")
).toEqual({ ).toEqual({
nextSeenKey: "daemon-1:agent-2", nextSeenKey: "daemon-1:/path/b",
shouldRefetch: true, shouldRefetch: true,
}); });
}); });
@@ -12,7 +12,6 @@ import { useRouter } from "expo-router";
import { useFocusEffect } from "@react-navigation/native"; import { useFocusEffect } from "@react-navigation/native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import { useQuery } from "@tanstack/react-query";
import ReanimatedAnimated, { import ReanimatedAnimated, {
useAnimatedStyle, useAnimatedStyle,
useSharedValue, useSharedValue,
@@ -72,9 +71,6 @@ import {
const DROPDOWN_WIDTH = 220; const DROPDOWN_WIDTH = 220;
const EMPTY_STREAM_ITEMS: StreamItem[] = []; const EMPTY_STREAM_ITEMS: StreamItem[] = [];
type BranchStatus = "idle" | "loading" | "ready" | "error";
export function AgentReadyScreen({ export function AgentReadyScreen({
serverId, serverId,
agentId, agentId,
@@ -372,43 +368,6 @@ function AgentScreenContent({
const agentModel = extractAgentModel(agent); const agentModel = extractAgentModel(agent);
const modelDisplayValue = agentModel ?? "Unknown"; const modelDisplayValue = agentModel ?? "Unknown";
const repoInfoQuery = useQuery({
queryKey: ["checkoutStatus", serverId, agent?.cwd ?? ""],
queryFn: async () => {
if (!client) {
throw new Error("Daemon client unavailable");
}
const payload = await client.getCheckoutStatus(agent?.cwd ?? ".");
if (payload.error) {
throw new Error(payload.error.message);
}
return {
cwd: payload.cwd,
currentBranch: payload.currentBranch ?? null,
};
},
enabled: Boolean(client && isConnected && agent?.cwd),
retry: false,
});
const { refetch: refetchRepoInfo } = repoInfoQuery;
const branchStatus: BranchStatus = !agent?.cwd
? "idle"
: repoInfoQuery.isPending || repoInfoQuery.isFetching
? "loading"
: repoInfoQuery.isError
? "error"
: repoInfoQuery.isSuccess
? "ready"
: "idle";
const branchLabel = repoInfoQuery.data?.currentBranch ?? null;
const branchError = repoInfoQuery.error instanceof Error
? repoInfoQuery.error.message
: null;
const branchDisplayValue =
branchStatus === "error"
? branchError ?? "Unavailable"
: branchLabel ?? "Unknown";
// Checkout status for header subtitle // Checkout status for header subtitle
const checkoutStatusQuery = useCheckoutStatusQuery({ const checkoutStatusQuery = useCheckoutStatusQuery({
serverId, serverId,
@@ -648,7 +607,7 @@ function AgentScreenContent({
<DropdownMenu <DropdownMenu
onOpenChange={(open) => { onOpenChange={(open) => {
if (open && agent?.cwd) { if (open && agent?.cwd) {
refetchRepoInfo().catch(() => {}); checkoutStatusQuery.refresh().catch(() => {});
} }
}} }}
> >
@@ -679,31 +638,30 @@ function AgentScreenContent({
</Text> </Text>
</View> </View>
<View style={styles.menuMetaRow}> {checkout?.isGit && checkout.currentBranch && checkout.currentBranch !== "HEAD" ? (
<Text style={styles.menuMetaLabel}>Branch</Text> <View style={styles.menuMetaRow}>
<View style={styles.menuMetaValueRow}> <Text style={styles.menuMetaLabel}>Branch</Text>
{branchStatus === "loading" ? ( <View style={styles.menuMetaValueRow}>
<> {checkoutStatusQuery.isFetching ? (
<ActivityIndicator <>
size="small" <ActivityIndicator
color={theme.colors.foregroundMuted} size="small"
/> color={theme.colors.foregroundMuted}
<Text style={styles.menuMetaPendingText}>Fetching…</Text> />
</> <Text style={styles.menuMetaPendingText}>Fetching…</Text>
) : ( </>
<Text ) : (
style={[ <Text
styles.menuMetaValue, style={styles.menuMetaValue}
branchStatus === "error" ? styles.menuMetaValueError : null, numberOfLines={1}
]} ellipsizeMode="middle"
numberOfLines={1} >
ellipsizeMode="middle" {checkout.currentBranch}
> </Text>
{branchDisplayValue} )}
</Text> </View>
)}
</View> </View>
</View> ) : null}
</View> </View>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@@ -27,6 +27,10 @@ import {
import { FileDropZone } from "@/components/file-drop-zone"; import { FileDropZone } from "@/components/file-drop-zone";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAgentFormState, type CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; import { useAgentFormState, type CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
import {
CHECKOUT_STATUS_STALE_TIME,
checkoutStatusQueryKey,
} from "@/hooks/use-checkout-status-query";
import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { formatConnectionStatus } from "@/utils/daemons"; import { formatConnectionStatus } from "@/utils/daemons";
@@ -309,82 +313,62 @@ export function DraftAgentScreen({
"Repository details will load automatically once the selected host is back online." "Repository details will load automatically once the selected host is back online."
: null; : null;
type RepoInfoState = { const checkoutStatusQuery = useQuery({
cwd: string; queryKey: checkoutStatusQueryKey(selectedServerId ?? "", trimmedWorkingDir),
repoRoot: string;
currentBranch: string | null;
isDirty: boolean;
};
const repoInfoQuery = useQuery({
queryKey: ["checkoutStatus", selectedServerId, trimmedWorkingDir],
queryFn: async () => { queryFn: async () => {
const client = sessionClient; const client = sessionClient;
if (!client) { if (!client) {
throw new Error("Daemon client unavailable"); throw new Error("Daemon client unavailable");
} }
const payload = await client.getCheckoutStatus(trimmedWorkingDir || "."); return await client.getCheckoutStatus(trimmedWorkingDir);
if (payload.error) {
throw new Error(payload.error.message);
}
if (!payload.isGit) {
throw new Error("Not a git repository");
}
// After the isGit check, TypeScript knows we have a git repo
return {
cwd: payload.cwd,
repoRoot: payload.repoRoot,
currentBranch: payload.currentBranch,
isDirty: payload.isDirty,
};
}, },
enabled: enabled:
Boolean(selectedServerId) &&
Boolean(trimmedWorkingDir) && Boolean(trimmedWorkingDir) &&
!repoAvailabilityError && !repoAvailabilityError &&
Boolean(sessionClient) && Boolean(sessionClient) &&
isConnected, isConnected,
retry: false, retry: false,
staleTime: CHECKOUT_STATUS_STALE_TIME,
refetchOnMount: "always",
}); });
const repoInfo = repoInfoQuery.data ?? null;
const refetchRepoInfo = repoInfoQuery.refetch; const checkout = checkoutStatusQuery.data ?? null;
const repoRequestError = repoInfoQuery.error as Error | null; const refetchCheckoutStatus = checkoutStatusQuery.refetch;
const repoRequestStatus: "idle" | "loading" | "success" | "error" = const checkoutQueryError =
!shouldInspectRepo || repoAvailabilityError checkoutStatusQuery.error instanceof Error ? checkoutStatusQuery.error.message : null;
? "idle" const checkoutPayloadError = checkout?.error ? checkout.error.message : null;
: repoInfoQuery.isPending || repoInfoQuery.isFetching
? "loading"
: repoInfoQuery.isError
? "error"
: repoInfoQuery.isSuccess
? "success"
: "idle";
const isNonGitDirectory = const isNonGitDirectory =
repoRequestStatus === "error" && Boolean(trimmedWorkingDir) &&
/not in a git repository|not a git repository/i.test(repoRequestError?.message ?? ""); checkoutStatusQuery.isSuccess &&
checkout?.isGit === false &&
checkout?.error == null;
const isDirectoryNotExists = const isDirectoryNotExists =
repoRequestStatus === "error" && checkoutStatusQuery.isError &&
/does not exist|no such file or directory|ENOENT/i.test(repoRequestError?.message ?? ""); /does not exist|no such file or directory|ENOENT/i.test(checkoutQueryError ?? "");
const repoInfoStatus: "idle" | "loading" | "ready" | "error" = !shouldInspectRepo const repoInfoStatus: "idle" | "loading" | "ready" | "error" = !shouldInspectRepo
? "idle" ? "idle"
: repoAvailabilityError : repoAvailabilityError
? "error" ? "error"
: repoRequestStatus === "loading" : checkoutStatusQuery.isPending || checkoutStatusQuery.isFetching
? "loading" ? "loading"
: repoRequestStatus === "error" : checkoutStatusQuery.isError || Boolean(checkoutPayloadError)
? isNonGitDirectory ? "error"
? "idle" : checkout?.isGit
: "error"
: repoRequestStatus === "success"
? "ready" ? "ready"
: "idle"; : "idle";
const repoInfoError = const repoInfoError =
repoAvailabilityError ?? (isNonGitDirectory ? null : repoRequestError?.message ?? null); repoAvailabilityError ??
const gitHelperText = isNonGitDirectory (checkoutStatusQuery.isError ? checkoutQueryError : null) ??
? "No git repository detected. Git options are disabled for this directory." checkoutPayloadError;
: null;
const isCreateWorktree = worktreeMode === "create"; const isCreateWorktree = worktreeMode === "create";
const isAttachWorktree = worktreeMode === "attach"; const isAttachWorktree = worktreeMode === "attach";
const worktreeListRoot = repoInfo?.repoRoot ?? trimmedWorkingDir; const worktreeListRoot = checkout?.repoRoot ?? trimmedWorkingDir;
const worktreeListQuery = useQuery({ const worktreeListQuery = useQuery({
queryKey: ["paseoWorktreeList", selectedServerId, worktreeListRoot], queryKey: ["paseoWorktreeList", selectedServerId, worktreeListRoot],
queryFn: async () => { queryFn: async () => {
@@ -446,10 +430,10 @@ export function DraftAgentScreen({
setSelectedWorktreePath(""); setSelectedWorktreePath("");
} }
if (mode !== "none") { if (mode !== "none") {
refetchRepoInfo(); refetchCheckoutStatus();
} }
}, },
[worktreeSlug, refetchRepoInfo] [worktreeSlug, refetchCheckoutStatus]
); );
const validateWorktreeName = useCallback( const validateWorktreeName = useCallback(
@@ -567,11 +551,14 @@ export function DraftAgentScreen({
if (baseBranch) { if (baseBranch) {
return; return;
} }
const current = repoInfo?.currentBranch?.trim(); const current = checkout?.isGit ? checkout.currentBranch?.trim() : null;
if (!current || current === "HEAD") {
return;
}
if (current) { if (current) {
setBaseBranch(current); setBaseBranch(current);
} }
}, [isCreateWorktree, isNonGitDirectory, baseBranch, repoInfo?.currentBranch]); }, [isCreateWorktree, isNonGitDirectory, baseBranch, checkout]);
useEffect(() => { useEffect(() => {
if (isNonGitDirectory && worktreeMode !== "none") { if (isNonGitDirectory && worktreeMode !== "none") {
@@ -787,7 +774,6 @@ export function DraftAgentScreen({
baseBranch, baseBranch,
worktreeSlug, worktreeSlug,
selectedWorktreePath, selectedWorktreePath,
repoInfo?.currentBranch,
gitBlockingError, gitBlockingError,
baseBranchError, baseBranchError,
isDirectoryNotExists, isDirectoryNotExists,
@@ -891,7 +877,7 @@ export function DraftAgentScreen({
worktreeMode={worktreeMode} worktreeMode={worktreeMode}
onWorktreeModeChange={handleWorktreeModeChange} onWorktreeModeChange={handleWorktreeModeChange}
worktreeSlug={worktreeSlug} worktreeSlug={worktreeSlug}
currentBranch={repoInfo?.currentBranch ?? null} currentBranch={checkout?.isGit ? checkout.currentBranch ?? null : null}
baseBranch={baseBranch} baseBranch={baseBranch}
onBaseBranchChange={handleBaseBranchChange} onBaseBranchChange={handleBaseBranchChange}
status={repoInfoStatus} status={repoInfoStatus}
@@ -15,6 +15,9 @@ export function deriveBranchLabel(
if (!currentBranch) { if (!currentBranch) {
return null; return null;
} }
if (currentBranch === "HEAD") {
return null;
}
if (baseRef && currentBranch === baseRef) { if (baseRef && currentBranch === baseRef) {
return null; return null;
} }
@@ -219,6 +219,65 @@ describe("parseToolCallDisplay - apply_patch (Codex)", () => {
} }
}); });
test("parses apply_patch with kind object (type/move_path) into edit type", () => {
const input = {
files: [
{
path: "/Users/moboudra/-paseo/worktrees/paseo/naive-zebra/packages/server/src/server/daemon-keypair.ts",
kind: { type: "update", move_path: null },
},
],
};
const result = {
files: [
{
path: "/Users/moboudra/-paseo/worktrees/paseo/naive-zebra/packages/server/src/server/daemon-keypair.ts",
patch: "@@ -1,1 +1,1 @@\n-foo\n+bar",
kind: { type: "update", move_path: null },
},
],
success: true,
};
const display = parseToolCallDisplay("apply_patch", input, result);
expect(display.type).toBe("edit");
expect(display.toolName).toBe("Edit");
if (display.type === "edit") {
expect(display.filePath).toBe(
"/Users/moboudra/-paseo/worktrees/paseo/naive-zebra/packages/server/src/server/daemon-keypair.ts"
);
expect(display.unifiedDiff).toBe("@@ -1,1 +1,1 @@\n-foo\n+bar");
}
});
test("prefers move_path for display but still finds patch by original path", () => {
const input = {
files: [
{
path: "/some/old-path.txt",
kind: { type: "update", move_path: "/some/new-path.txt" },
},
],
};
const result = {
files: [
{
path: "/some/old-path.txt",
patch: "@@ -1,1 +1,1 @@\n-old\n+new",
kind: { type: "update", move_path: "/some/new-path.txt" },
},
],
success: true,
};
const display = parseToolCallDisplay("apply_patch", input, result);
expect(display.type).toBe("edit");
if (display.type === "edit") {
expect(display.filePath).toBe("/some/new-path.txt");
expect(display.unifiedDiff).toBe("@@ -1,1 +1,1 @@\n-old\n+new");
}
});
test("parses pending apply_patch (no result yet)", () => { test("parses pending apply_patch (no result yet)", () => {
const input = { const input = {
files: [ files: [
+34 -6
View File
@@ -1041,20 +1041,42 @@ const EditToolCallSchema = z
}; };
}); });
// Codex apply_patch input: { files: [{ path: string, kind: string }] } const ApplyPatchFileKindSchema = z
.union([
z.string(),
z
.object({
type: z.string().optional(),
move_path: z.string().nullable().optional(),
movePath: z.string().nullable().optional(),
})
.passthrough(),
])
.optional();
function getApplyPatchMovePath(kind: unknown): string | undefined {
if (!kind || typeof kind !== "object") return undefined;
const record = kind as Record<string, unknown>;
const movePath = typeof record.movePath === "string" ? record.movePath : undefined;
const movePathSnake =
typeof record.move_path === "string" ? record.move_path : undefined;
return movePath ?? movePathSnake ?? undefined;
}
// Codex apply_patch input: { files: [{ path: string, kind: string | object }] }
const ApplyPatchInputSchema = z.object({ const ApplyPatchInputSchema = z.object({
files: z.array(z.object({ files: z.array(z.object({
path: z.string(), path: z.string(),
kind: z.string().optional(), kind: ApplyPatchFileKindSchema,
})).min(1), })).min(1),
}).passthrough(); }).passthrough();
// Codex apply_patch result: { files: [{ path: string, patch: string, kind: string }], message: string, success: boolean } // Codex apply_patch result: { files: [{ path: string, patch: string, kind: string | object }], message: string, success: boolean }
const ApplyPatchResultSchema = z.object({ const ApplyPatchResultSchema = z.object({
files: z.array(z.object({ files: z.array(z.object({
path: z.string(), path: z.string(),
patch: z.string().optional(), patch: z.string().optional(),
kind: z.string().optional(), kind: ApplyPatchFileKindSchema,
})).optional(), })).optional(),
message: z.string().optional(), message: z.string().optional(),
success: z.boolean().optional(), success: z.boolean().optional(),
@@ -1068,13 +1090,19 @@ const ApplyPatchToolCallSchema = z
}) })
.transform((data): { type: "edit"; filePath: string; oldString: string; newString: string; unifiedDiff?: string } => { .transform((data): { type: "edit"; filePath: string; oldString: string; newString: string; unifiedDiff?: string } => {
const firstFile = data.input.files[0]; const firstFile = data.input.files[0];
const filePath = firstFile.path; const movePath = getApplyPatchMovePath(firstFile.kind);
const filePath = movePath ?? firstFile.path;
// Try to get the patch from the result // Try to get the patch from the result
const resultParsed = ApplyPatchResultSchema.safeParse(data.result); const resultParsed = ApplyPatchResultSchema.safeParse(data.result);
let unifiedDiff: string | undefined; let unifiedDiff: string | undefined;
if (resultParsed.success && resultParsed.data.files) { if (resultParsed.success && resultParsed.data.files) {
const resultFile = resultParsed.data.files.find(f => f.path === filePath) ?? resultParsed.data.files[0]; const matchPaths = new Set<string>([firstFile.path]);
if (movePath) matchPaths.add(movePath);
const resultFile =
resultParsed.data.files.find((f) => matchPaths.has(f.path)) ??
resultParsed.data.files[0];
unifiedDiff = resultFile?.patch; unifiedDiff = resultFile?.patch;
} }
@@ -4,7 +4,10 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync, existsSync
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js"; import {
CodexAppServerAgentClient,
codexAppServerTurnInputFromPrompt,
} from "./codex-app-server-agent.js";
import { createTestLogger } from "../../../test-utils/test-logger.js"; import { createTestLogger } from "../../../test-utils/test-logger.js";
import type { import type {
AgentPermissionRequest, AgentPermissionRequest,
@@ -14,6 +17,8 @@ import type {
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini"; const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low"; const CODEX_TEST_REASONING_EFFORT = "low";
const ONE_BY_ONE_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X1r0AAAAASUVORK5CYII=";
function isCodexInstalled(): boolean { function isCodexInstalled(): boolean {
try { try {
@@ -64,12 +69,57 @@ function hasApplyPatchFile(item: AgentTimelineItem, fileName: string): boolean {
describe("Codex app-server provider (integration)", () => { describe("Codex app-server provider (integration)", () => {
const logger = createTestLogger(); const logger = createTestLogger();
test("maps image prompt blocks to Codex localImage input", async () => {
const input = await codexAppServerTurnInputFromPrompt(
[
{ type: "text", text: "hello" },
{ type: "image", mimeType: "image/png", data: ONE_BY_ONE_PNG_BASE64 },
],
logger
);
const localImage = input.find((item) => (item as any)?.type === "localImage") as
| { type: "localImage"; path?: string }
| undefined;
expect(localImage?.path).toBeTypeOf("string");
if (localImage?.path) {
expect(existsSync(localImage.path)).toBe(true);
rmSync(localImage.path, { force: true });
}
});
test.runIf(isCodexInstalled())("listModels returns live Codex models", async () => { test.runIf(isCodexInstalled())("listModels returns live Codex models", async () => {
const client = new CodexAppServerAgentClient(logger); const client = new CodexAppServerAgentClient(logger);
const models = await client.listModels(); const models = await client.listModels();
expect(models.some((model) => model.id.includes("gpt-5.1-codex"))).toBe(true); expect(models.some((model) => model.id.includes("gpt-5.1-codex"))).toBe(true);
}, 30000); }, 30000);
test.runIf(isCodexInstalled())("accepts image prompt blocks without request validation errors", async () => {
const cleanup = useTempCodexSessionDir();
const cwd = tmpCwd("codex-image-prompt-");
try {
const client = new CodexAppServerAgentClient(logger);
const session = await client.createSession({
provider: "codex",
cwd,
modeId: "auto",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
});
const result = await session.run([
{ type: "text", text: "Reply with exactly: OK." },
{ type: "image", mimeType: "image/png", data: ONE_BY_ONE_PNG_BASE64 },
] satisfies AgentPromptContentBlock[]);
await session.close();
expect(result.finalText).toContain("OK");
} finally {
cleanup();
rmSync(cwd, { recursive: true, force: true });
}
}, 60000);
test.runIf(isCodexInstalled())("getRuntimeInfo reflects model + mode", async () => { test.runIf(isCodexInstalled())("getRuntimeInfo reflects model + mode", async () => {
const cleanup = useTempCodexSessionDir(); const cleanup = useTempCodexSessionDir();
const cwd = tmpCwd("codex-runtime-"); const cwd = tmpCwd("codex-runtime-");
@@ -27,6 +27,7 @@ import type { Logger } from "pino";
import { execSync, spawn } from "node:child_process"; import { execSync, spawn } from "node:child_process";
import type { ChildProcessWithoutNullStreams } from "node:child_process"; import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import { Dirent } from "node:fs"; import { Dirent } from "node:fs";
import os from "node:os"; import os from "node:os";
@@ -36,6 +37,7 @@ import readline from "node:readline";
const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000; const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
const CODEX_PROVIDER = "codex" as const; const CODEX_PROVIDER = "codex" as const;
const CODEX_IMAGE_ATTACHMENT_DIR = "paseo-attachments";
const CODEX_APP_SERVER_CAPABILITIES: AgentCapabilityFlags = { const CODEX_APP_SERVER_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true, supportsStreaming: true,
@@ -790,6 +792,87 @@ function toSandboxPolicy(type: string, networkAccess?: boolean): Record<string,
} }
} }
function getImageExtension(mimeType: string): string {
switch (mimeType) {
case "image/jpeg":
return "jpg";
case "image/png":
return "png";
case "image/webp":
return "webp";
case "image/gif":
return "gif";
case "image/bmp":
return "bmp";
case "image/tiff":
return "tiff";
default:
return "bin";
}
}
type ImageDataPayload = { mimeType: string; data: string };
function normalizeImageData(mimeType: string, data: string): ImageDataPayload {
if (data.startsWith("data:")) {
const match = data.match(/^data:([^;]+);base64,(.*)$/);
if (match) {
return { mimeType: match[1], data: match[2] };
}
}
return { mimeType, data };
}
async function writeImageAttachment(mimeType: string, data: string): Promise<string> {
const attachmentsDir = path.join(os.tmpdir(), CODEX_IMAGE_ATTACHMENT_DIR);
await fs.mkdir(attachmentsDir, { recursive: true });
const normalized = normalizeImageData(mimeType, data);
const extension = getImageExtension(normalized.mimeType);
const filename = `${randomUUID()}.${extension}`;
const filePath = path.join(attachmentsDir, filename);
await fs.writeFile(filePath, Buffer.from(normalized.data, "base64"));
return filePath;
}
export async function codexAppServerTurnInputFromPrompt(
prompt: AgentPromptInput,
logger: Logger
): Promise<unknown[]> {
if (typeof prompt === "string") {
return [{ type: "text", text: prompt }];
}
const blocks = prompt as Array<unknown>;
const output: unknown[] = [];
for (const block of blocks) {
if (!block || typeof block !== "object") {
output.push(block);
continue;
}
const record = block as { type?: unknown; mimeType?: unknown; data?: unknown };
if (
record.type === "image" &&
typeof record.mimeType === "string" &&
typeof record.data === "string"
) {
try {
const filePath = await writeImageAttachment(record.mimeType, record.data);
output.push({ type: "localImage", path: filePath });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.warn({ message }, "Failed to write Codex image attachment");
output.push({
type: "text",
text: `User attached image (failed to write temp file): ${message}`,
});
}
continue;
}
output.push(block);
}
return output;
}
class CodexAppServerAgentSession implements AgentSession { class CodexAppServerAgentSession implements AgentSession {
readonly provider = CODEX_PROVIDER; readonly provider = CODEX_PROVIDER;
readonly capabilities = CODEX_APP_SERVER_CAPABILITIES; readonly capabilities = CODEX_APP_SERVER_CAPABILITIES;
@@ -818,7 +901,6 @@ class CodexAppServerAgentSession implements AgentSession {
private pendingReasoning = new Map<string, string[]>(); private pendingReasoning = new Map<string, string[]>();
private latestUsage: AgentUsage | undefined; private latestUsage: AgentUsage | undefined;
private connected = false; private connected = false;
private paseoInstructionsInjected = false;
private collaborationModes: Array<{ private collaborationModes: Array<{
name: string; name: string;
mode?: string | null; mode?: string | null;
@@ -845,7 +927,6 @@ class CodexAppServerAgentSession implements AgentSession {
if (this.resumeHandle?.sessionId) { if (this.resumeHandle?.sessionId) {
this.currentThreadId = this.resumeHandle.sessionId; this.currentThreadId = this.resumeHandle.sessionId;
this.historyPending = true; this.historyPending = true;
this.paseoInstructionsInjected = true;
} }
} }
@@ -1057,7 +1138,7 @@ class CodexAppServerAgentSession implements AgentSession {
} else { } else {
await this.ensureThread(); await this.ensureThread();
} }
const input = this.buildUserInput(prompt); const input = await this.buildUserInput(prompt);
const preset = MODE_PRESETS[this.currentMode] ?? MODE_PRESETS[DEFAULT_CODEX_MODE_ID]; const preset = MODE_PRESETS[this.currentMode] ?? MODE_PRESETS[DEFAULT_CODEX_MODE_ID];
const approvalPolicy = this.config.approvalPolicy ?? preset.approvalPolicy; const approvalPolicy = this.config.approvalPolicy ?? preset.approvalPolicy;
const sandboxPolicyType = this.config.sandboxMode ?? preset.sandbox; const sandboxPolicyType = this.config.sandboxMode ?? preset.sandbox;
@@ -1364,17 +1445,12 @@ class CodexAppServerAgentSession implements AgentSession {
this.currentThreadId = threadId; this.currentThreadId = threadId;
} }
private buildUserInput(prompt: AgentPromptInput): unknown[] { private async buildUserInput(prompt: AgentPromptInput): Promise<unknown[]> {
if (typeof prompt === "string") { if (typeof prompt === "string") {
this.paseoInstructionsInjected = true;
return [{ type: "text", text: prompt }]; return [{ type: "text", text: prompt }];
} }
const blocks = prompt as AgentPromptContentBlock[]; const blocks = prompt as AgentPromptContentBlock[];
if (this.paseoInstructionsInjected) { return await codexAppServerTurnInputFromPrompt(blocks, this.logger);
return blocks;
}
this.paseoInstructionsInjected = true;
return blocks;
} }
private emitEvent(event: AgentStreamEvent): void { private emitEvent(event: AgentStreamEvent): void {
@@ -3,7 +3,7 @@
* Ad-hoc script to debug checkout_status_request timeouts. * Ad-hoc script to debug checkout_status_request timeouts.
* *
* Usage: * Usage:
* npx tsx packages/server/src/server/daemon-e2e/checkout-debug.ts [agentId1] [agentId2] * npx tsx packages/server/src/server/daemon-e2e/checkout-debug.ts [agentIdOrCwd1] [agentIdOrCwd2]
* *
* To test against a different daemon: * To test against a different daemon:
* PASEO_PORT=7777 npx tsx packages/server/src/server/daemon-e2e/checkout-debug.ts * PASEO_PORT=7777 npx tsx packages/server/src/server/daemon-e2e/checkout-debug.ts
@@ -42,11 +42,13 @@ async function testMultiAgentSequence() {
reconnect: { enabled: false }, reconnect: { enabled: false },
}); });
const agents: Array<{ id: string; title: string }> = []; const agents: Array<{ id: string; title: string; cwd: string }> = [];
// Also log raw messages for debugging // Also log raw messages for debugging
client.on("checkout_status_response", (msg: any) => { client.on("checkout_status_response", (msg: any) => {
console.log(`[RAW checkout_status_response] requestId=${msg.payload.requestId} agentId=${msg.payload.agentId}`); console.log(
`[RAW checkout_status_response] requestId=${msg.payload.requestId} cwd=${msg.payload.cwd}`
);
}); });
// Listen to connection state changes // Listen to connection state changes
@@ -63,7 +65,7 @@ async function testMultiAgentSequence() {
const agentsList = await client.fetchAgents(); const agentsList = await client.fetchAgents();
agents.length = 0; agents.length = 0;
for (const a of agentsList) { for (const a of agentsList) {
agents.push({ id: a.id, title: a.title ?? "(untitled)" }); agents.push({ id: a.id, title: a.title ?? "(untitled)", cwd: a.cwd });
} }
if (agents.length === 0) { if (agents.length === 0) {
@@ -80,53 +82,72 @@ async function testMultiAgentSequence() {
} }
// Pick first two agents (or use command line args) // Pick first two agents (or use command line args)
const agent1Id = process.argv[2] ?? agents[0]?.id; const arg1 = process.argv[2];
const agent2Id = process.argv[3] ?? agents[1]?.id ?? agents[0]?.id; const arg2 = process.argv[3];
const agent1 = (arg1 ? agents.find((a) => a.id === arg1) : null) ?? agents[0] ?? null;
const agent2 =
(arg2 ? agents.find((a) => a.id === arg2) : null) ??
agents[1] ??
agents[0] ??
null;
if (!agent1Id) { const cwd1 = arg1 && !agent1 ? arg1 : agent1?.cwd;
console.log("No agents available to test"); const cwd2 = arg2 && !agent2 ? arg2 : agent2?.cwd;
if (!cwd1) {
console.log("No checkout cwd available to test");
return; return;
} }
console.log(`\n=== Test 1: Request checkout for agent1 (${agent1Id.slice(0, 8)}...) ===`); console.log(`\n=== Test 1: Request checkout for cwd1 (${cwd1}) ===`);
const start1 = Date.now(); const start1 = Date.now();
try { try {
const status1 = await client.getCheckoutStatus(agent1Id); const status1 = await client.getCheckoutStatus(cwd1);
console.log(`✓ Agent1 completed in ${Date.now() - start1}ms - branch: ${status1.currentBranch}`); console.log(
`✓ Cwd1 completed in ${Date.now() - start1}ms - branch: ${status1.currentBranch}`
);
} catch (err) { } catch (err) {
console.log(`✗ Agent1 failed after ${Date.now() - start1}ms:`, err); console.log(`✗ Cwd1 failed after ${Date.now() - start1}ms:`, err);
} }
console.log(`\n=== Test 2: Request checkout for agent2 (${agent2Id.slice(0, 8)}...) ===`); if (cwd2) {
const start2 = Date.now(); console.log(`\n=== Test 2: Request checkout for cwd2 (${cwd2}) ===`);
try { const start2 = Date.now();
const status2 = await client.getCheckoutStatus(agent2Id); try {
console.log(`✓ Agent2 completed in ${Date.now() - start2}ms - branch: ${status2.currentBranch}`); const status2 = await client.getCheckoutStatus(cwd2);
} catch (err) { console.log(
console.log(`✗ Agent2 failed after ${Date.now() - start2}ms:`, err); `✓ Cwd2 completed in ${Date.now() - start2}ms - branch: ${status2.currentBranch}`
);
} catch (err) {
console.log(`✗ Cwd2 failed after ${Date.now() - start2}ms:`, err);
}
} }
console.log(`\n=== Test 3: Request checkout for agent1 again ===`); console.log(`\n=== Test 3: Request checkout for cwd1 again ===`);
const start3 = Date.now(); const start3 = Date.now();
try { try {
const status3 = await client.getCheckoutStatus(agent1Id); const status3 = await client.getCheckoutStatus(cwd1);
console.log(`✓ Agent1 (retry) completed in ${Date.now() - start3}ms - branch: ${status3.currentBranch}`); console.log(
`✓ Cwd1 (retry) completed in ${Date.now() - start3}ms - branch: ${status3.currentBranch}`
);
} catch (err) { } catch (err) {
console.log(`✗ Agent1 (retry) failed after ${Date.now() - start3}ms:`, err); console.log(`✗ Cwd1 (retry) failed after ${Date.now() - start3}ms:`, err);
} }
console.log(`\n=== Test 4: Request both agents in parallel ===`); if (cwd2) {
const start4 = Date.now(); console.log(`\n=== Test 4: Request both cwds in parallel ===`);
try { const start4 = Date.now();
const [p1, p2] = await Promise.all([ try {
client.getCheckoutStatus(agent1Id), const [p1, p2] = await Promise.all([
client.getCheckoutStatus(agent2Id), client.getCheckoutStatus(cwd1),
]); client.getCheckoutStatus(cwd2),
console.log(`✓ Parallel completed in ${Date.now() - start4}ms`); ]);
console.log(` Agent1 branch: ${p1.currentBranch}`); console.log(`✓ Parallel completed in ${Date.now() - start4}ms`);
console.log(` Agent2 branch: ${p2.currentBranch}`); console.log(` Cwd1 branch: ${p1.currentBranch}`);
} catch (err) { console.log(` Cwd2 branch: ${p2.currentBranch}`);
console.log(`✗ Parallel failed after ${Date.now() - start4}ms:`, err); } catch (err) {
console.log(`✗ Parallel failed after ${Date.now() - start4}ms:`, err);
}
} }
} catch (error) { } catch (error) {
-8
View File
@@ -478,14 +478,6 @@ export const ListProviderModelsRequestMessageSchema = z.object({
requestId: z.string(), requestId: z.string(),
}); });
// Legacy alias used by older clients; keep for compatibility
export const GitRepoInfoRequestMessageSchema = z.object({
type: z.literal("git_repo_info_request"),
cwd: z.string(),
requestId: z.string(),
});
export const ResumeAgentRequestMessageSchema = z.object({ export const ResumeAgentRequestMessageSchema = z.object({
type: z.literal("resume_agent_request"), type: z.literal("resume_agent_request"),
handle: AgentPersistenceHandleSchema, handle: AgentPersistenceHandleSchema,