From 14b0db55b06e4dc4e936dccef9a0f3bcab26f9e0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 3 Feb 2026 14:50:15 +0700 Subject: [PATCH] consolidate checkout status handling and improve git detection --- packages/app/src/components/git-diff-pane.tsx | 6 +- .../app/src/components/grouped-agent-list.tsx | 6 +- .../hooks/use-checkout-status-query.test.ts | 8 +- .../src/screens/agent/agent-ready-screen.tsx | 90 +++++------------ .../src/screens/agent/draft-agent-screen.tsx | 98 ++++++++----------- packages/app/src/utils/agent-display-info.ts | 3 + .../app/src/utils/tool-call-parsers.test.ts | 59 +++++++++++ packages/app/src/utils/tool-call-parsers.ts | 40 ++++++-- .../providers/codex-app-server-agent.test.ts | 52 +++++++++- .../agent/providers/codex-app-server-agent.ts | 96 ++++++++++++++++-- .../src/server/daemon-e2e/checkout-debug.ts | 91 ++++++++++------- packages/server/src/shared/messages.ts | 8 -- 12 files changed, 369 insertions(+), 188 deletions(-) diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 03d0574d9..fe1109cfb 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -791,7 +791,11 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { (isDiffError && diffError instanceof Error ? diffError.message : null); const prErrorMessage = prPayloadError?.message ?? null; 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 aheadCount = gitStatus?.aheadBehind?.ahead ?? 0; const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0; diff --git a/packages/app/src/components/grouped-agent-list.tsx b/packages/app/src/components/grouped-agent-list.tsx index 6d1f32fa3..2001f30f5 100644 --- a/packages/app/src/components/grouped-agent-list.tsx +++ b/packages/app/src/components/grouped-agent-list.tsx @@ -440,7 +440,11 @@ export function GroupedAgentList({ }); const checkout = checkoutQuery.data ?? null; const activeBranchLabel = checkout?.isGit - ? (checkout.currentBranch ?? checkout.baseRef ?? "git") + ? ((checkout.currentBranch && checkout.currentBranch !== "HEAD" + ? checkout.currentBranch + : null) ?? + checkout.baseRef ?? + "git") : null; const canArchive = !isRunning && !agent.requiresAttention; diff --git a/packages/app/src/hooks/use-checkout-status-query.test.ts b/packages/app/src/hooks/use-checkout-status-query.test.ts index d55615c48..403c49499 100644 --- a/packages/app/src/hooks/use-checkout-status-query.test.ts +++ b/packages/app/src/hooks/use-checkout-status-query.test.ts @@ -39,7 +39,7 @@ describe("useCheckoutStatusQuery", () => { describe("nextCheckoutStatusRefetchDecision", () => { 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({ 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( - nextCheckoutStatusRefetchDecision("daemon-1:agent-1", "daemon-1:agent-2") + nextCheckoutStatusRefetchDecision("daemon-1:/path/a", "daemon-1:/path/b") ).toEqual({ - nextSeenKey: "daemon-1:agent-2", + nextSeenKey: "daemon-1:/path/b", shouldRefetch: true, }); }); diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx index 221350fd2..08d34b5d0 100644 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -12,7 +12,6 @@ import { useRouter } from "expo-router"; import { useFocusEffect } from "@react-navigation/native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; -import { useQuery } from "@tanstack/react-query"; import ReanimatedAnimated, { useAnimatedStyle, useSharedValue, @@ -72,9 +71,6 @@ import { const DROPDOWN_WIDTH = 220; const EMPTY_STREAM_ITEMS: StreamItem[] = []; - -type BranchStatus = "idle" | "loading" | "ready" | "error"; - export function AgentReadyScreen({ serverId, agentId, @@ -372,43 +368,6 @@ function AgentScreenContent({ const agentModel = extractAgentModel(agent); 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 const checkoutStatusQuery = useCheckoutStatusQuery({ serverId, @@ -648,7 +607,7 @@ function AgentScreenContent({ { if (open && agent?.cwd) { - refetchRepoInfo().catch(() => {}); + checkoutStatusQuery.refresh().catch(() => {}); } }} > @@ -679,31 +638,30 @@ function AgentScreenContent({ - - Branch - - {branchStatus === "loading" ? ( - <> - - Fetching… - - ) : ( - - {branchDisplayValue} - - )} + {checkout?.isGit && checkout.currentBranch && checkout.currentBranch !== "HEAD" ? ( + + Branch + + {checkoutStatusQuery.isFetching ? ( + <> + + Fetching… + + ) : ( + + {checkout.currentBranch} + + )} + - + ) : null} diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index 8d58ebc55..5c3c928d2 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -27,6 +27,10 @@ import { import { FileDropZone } from "@/components/file-drop-zone"; import { useQuery } from "@tanstack/react-query"; 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 { useDaemonRegistry } from "@/contexts/daemon-registry-context"; import { formatConnectionStatus } from "@/utils/daemons"; @@ -309,82 +313,62 @@ export function DraftAgentScreen({ "Repository details will load automatically once the selected host is back online." : null; - type RepoInfoState = { - cwd: string; - repoRoot: string; - currentBranch: string | null; - isDirty: boolean; - }; - const repoInfoQuery = useQuery({ - queryKey: ["checkoutStatus", selectedServerId, trimmedWorkingDir], + const checkoutStatusQuery = useQuery({ + queryKey: checkoutStatusQueryKey(selectedServerId ?? "", trimmedWorkingDir), queryFn: async () => { const client = sessionClient; if (!client) { throw new Error("Daemon client unavailable"); } - const payload = 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, - }; + return await client.getCheckoutStatus(trimmedWorkingDir); }, enabled: + Boolean(selectedServerId) && Boolean(trimmedWorkingDir) && !repoAvailabilityError && Boolean(sessionClient) && isConnected, retry: false, + staleTime: CHECKOUT_STATUS_STALE_TIME, + refetchOnMount: "always", }); - const repoInfo = repoInfoQuery.data ?? null; - const refetchRepoInfo = repoInfoQuery.refetch; - const repoRequestError = repoInfoQuery.error as Error | null; - const repoRequestStatus: "idle" | "loading" | "success" | "error" = - !shouldInspectRepo || repoAvailabilityError - ? "idle" - : repoInfoQuery.isPending || repoInfoQuery.isFetching - ? "loading" - : repoInfoQuery.isError - ? "error" - : repoInfoQuery.isSuccess - ? "success" - : "idle"; + + const checkout = checkoutStatusQuery.data ?? null; + const refetchCheckoutStatus = checkoutStatusQuery.refetch; + const checkoutQueryError = + checkoutStatusQuery.error instanceof Error ? checkoutStatusQuery.error.message : null; + const checkoutPayloadError = checkout?.error ? checkout.error.message : null; + const isNonGitDirectory = - repoRequestStatus === "error" && - /not in a git repository|not a git repository/i.test(repoRequestError?.message ?? ""); + Boolean(trimmedWorkingDir) && + checkoutStatusQuery.isSuccess && + checkout?.isGit === false && + checkout?.error == null; + const isDirectoryNotExists = - repoRequestStatus === "error" && - /does not exist|no such file or directory|ENOENT/i.test(repoRequestError?.message ?? ""); + checkoutStatusQuery.isError && + /does not exist|no such file or directory|ENOENT/i.test(checkoutQueryError ?? ""); + const repoInfoStatus: "idle" | "loading" | "ready" | "error" = !shouldInspectRepo ? "idle" : repoAvailabilityError ? "error" - : repoRequestStatus === "loading" + : checkoutStatusQuery.isPending || checkoutStatusQuery.isFetching ? "loading" - : repoRequestStatus === "error" - ? isNonGitDirectory - ? "idle" - : "error" - : repoRequestStatus === "success" + : checkoutStatusQuery.isError || Boolean(checkoutPayloadError) + ? "error" + : checkout?.isGit ? "ready" : "idle"; + const repoInfoError = - repoAvailabilityError ?? (isNonGitDirectory ? null : repoRequestError?.message ?? null); - const gitHelperText = isNonGitDirectory - ? "No git repository detected. Git options are disabled for this directory." - : null; + repoAvailabilityError ?? + (checkoutStatusQuery.isError ? checkoutQueryError : null) ?? + checkoutPayloadError; const isCreateWorktree = worktreeMode === "create"; const isAttachWorktree = worktreeMode === "attach"; - const worktreeListRoot = repoInfo?.repoRoot ?? trimmedWorkingDir; + const worktreeListRoot = checkout?.repoRoot ?? trimmedWorkingDir; const worktreeListQuery = useQuery({ queryKey: ["paseoWorktreeList", selectedServerId, worktreeListRoot], queryFn: async () => { @@ -446,10 +430,10 @@ export function DraftAgentScreen({ setSelectedWorktreePath(""); } if (mode !== "none") { - refetchRepoInfo(); + refetchCheckoutStatus(); } }, - [worktreeSlug, refetchRepoInfo] + [worktreeSlug, refetchCheckoutStatus] ); const validateWorktreeName = useCallback( @@ -567,11 +551,14 @@ export function DraftAgentScreen({ if (baseBranch) { return; } - const current = repoInfo?.currentBranch?.trim(); + const current = checkout?.isGit ? checkout.currentBranch?.trim() : null; + if (!current || current === "HEAD") { + return; + } if (current) { setBaseBranch(current); } - }, [isCreateWorktree, isNonGitDirectory, baseBranch, repoInfo?.currentBranch]); + }, [isCreateWorktree, isNonGitDirectory, baseBranch, checkout]); useEffect(() => { if (isNonGitDirectory && worktreeMode !== "none") { @@ -787,7 +774,6 @@ export function DraftAgentScreen({ baseBranch, worktreeSlug, selectedWorktreePath, - repoInfo?.currentBranch, gitBlockingError, baseBranchError, isDirectoryNotExists, @@ -891,7 +877,7 @@ export function DraftAgentScreen({ worktreeMode={worktreeMode} onWorktreeModeChange={handleWorktreeModeChange} worktreeSlug={worktreeSlug} - currentBranch={repoInfo?.currentBranch ?? null} + currentBranch={checkout?.isGit ? checkout.currentBranch ?? null : null} baseBranch={baseBranch} onBaseBranchChange={handleBaseBranchChange} status={repoInfoStatus} diff --git a/packages/app/src/utils/agent-display-info.ts b/packages/app/src/utils/agent-display-info.ts index 8fb4351b0..cedc7fafe 100644 --- a/packages/app/src/utils/agent-display-info.ts +++ b/packages/app/src/utils/agent-display-info.ts @@ -15,6 +15,9 @@ export function deriveBranchLabel( if (!currentBranch) { return null; } + if (currentBranch === "HEAD") { + return null; + } if (baseRef && currentBranch === baseRef) { return null; } diff --git a/packages/app/src/utils/tool-call-parsers.test.ts b/packages/app/src/utils/tool-call-parsers.test.ts index 57619fd27..459ac430f 100644 --- a/packages/app/src/utils/tool-call-parsers.test.ts +++ b/packages/app/src/utils/tool-call-parsers.test.ts @@ -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)", () => { const input = { files: [ diff --git a/packages/app/src/utils/tool-call-parsers.ts b/packages/app/src/utils/tool-call-parsers.ts index 05146598b..e8ff6b46f 100644 --- a/packages/app/src/utils/tool-call-parsers.ts +++ b/packages/app/src/utils/tool-call-parsers.ts @@ -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; + 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({ files: z.array(z.object({ path: z.string(), - kind: z.string().optional(), + kind: ApplyPatchFileKindSchema, })).min(1), }).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({ files: z.array(z.object({ path: z.string(), patch: z.string().optional(), - kind: z.string().optional(), + kind: ApplyPatchFileKindSchema, })).optional(), message: z.string().optional(), success: z.boolean().optional(), @@ -1068,13 +1090,19 @@ const ApplyPatchToolCallSchema = z }) .transform((data): { type: "edit"; filePath: string; oldString: string; newString: string; unifiedDiff?: string } => { 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 const resultParsed = ApplyPatchResultSchema.safeParse(data.result); let unifiedDiff: string | undefined; if (resultParsed.success && resultParsed.data.files) { - const resultFile = resultParsed.data.files.find(f => f.path === filePath) ?? resultParsed.data.files[0]; + const matchPaths = new Set([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; } diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 8b0eed924..1b47c4edb 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -4,7 +4,10 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync, existsSync import os from "node:os"; 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 type { AgentPermissionRequest, @@ -14,6 +17,8 @@ import type { const CODEX_TEST_MODEL = "gpt-5.1-codex-mini"; const CODEX_TEST_REASONING_EFFORT = "low"; +const ONE_BY_ONE_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X1r0AAAAASUVORK5CYII="; function isCodexInstalled(): boolean { try { @@ -64,12 +69,57 @@ function hasApplyPatchFile(item: AgentTimelineItem, fileName: string): boolean { describe("Codex app-server provider (integration)", () => { 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 () => { const client = new CodexAppServerAgentClient(logger); const models = await client.listModels(); expect(models.some((model) => model.id.includes("gpt-5.1-codex"))).toBe(true); }, 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 () => { const cleanup = useTempCodexSessionDir(); const cwd = tmpCwd("codex-runtime-"); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 0467a4906..ba8483fbd 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -27,6 +27,7 @@ import type { Logger } from "pino"; import { execSync, spawn } from "node:child_process"; import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import { Dirent } from "node:fs"; import os from "node:os"; @@ -36,6 +37,7 @@ import readline from "node:readline"; const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000; const CODEX_PROVIDER = "codex" as const; +const CODEX_IMAGE_ATTACHMENT_DIR = "paseo-attachments"; const CODEX_APP_SERVER_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: true, @@ -790,6 +792,87 @@ function toSandboxPolicy(type: string, networkAccess?: boolean): Record { + 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 { + if (typeof prompt === "string") { + return [{ type: "text", text: prompt }]; + } + + const blocks = prompt as Array; + 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 { readonly provider = CODEX_PROVIDER; readonly capabilities = CODEX_APP_SERVER_CAPABILITIES; @@ -818,7 +901,6 @@ class CodexAppServerAgentSession implements AgentSession { private pendingReasoning = new Map(); private latestUsage: AgentUsage | undefined; private connected = false; - private paseoInstructionsInjected = false; private collaborationModes: Array<{ name: string; mode?: string | null; @@ -845,7 +927,6 @@ class CodexAppServerAgentSession implements AgentSession { if (this.resumeHandle?.sessionId) { this.currentThreadId = this.resumeHandle.sessionId; this.historyPending = true; - this.paseoInstructionsInjected = true; } } @@ -1057,7 +1138,7 @@ class CodexAppServerAgentSession implements AgentSession { } else { 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 approvalPolicy = this.config.approvalPolicy ?? preset.approvalPolicy; const sandboxPolicyType = this.config.sandboxMode ?? preset.sandbox; @@ -1364,17 +1445,12 @@ class CodexAppServerAgentSession implements AgentSession { this.currentThreadId = threadId; } - private buildUserInput(prompt: AgentPromptInput): unknown[] { + private async buildUserInput(prompt: AgentPromptInput): Promise { if (typeof prompt === "string") { - this.paseoInstructionsInjected = true; return [{ type: "text", text: prompt }]; } const blocks = prompt as AgentPromptContentBlock[]; - if (this.paseoInstructionsInjected) { - return blocks; - } - this.paseoInstructionsInjected = true; - return blocks; + return await codexAppServerTurnInputFromPrompt(blocks, this.logger); } private emitEvent(event: AgentStreamEvent): void { diff --git a/packages/server/src/server/daemon-e2e/checkout-debug.ts b/packages/server/src/server/daemon-e2e/checkout-debug.ts index 5da2045e7..60cb3d9e6 100644 --- a/packages/server/src/server/daemon-e2e/checkout-debug.ts +++ b/packages/server/src/server/daemon-e2e/checkout-debug.ts @@ -3,7 +3,7 @@ * Ad-hoc script to debug checkout_status_request timeouts. * * 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: * PASEO_PORT=7777 npx tsx packages/server/src/server/daemon-e2e/checkout-debug.ts @@ -42,11 +42,13 @@ async function testMultiAgentSequence() { 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 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 @@ -63,7 +65,7 @@ async function testMultiAgentSequence() { const agentsList = await client.fetchAgents(); agents.length = 0; 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) { @@ -80,53 +82,72 @@ async function testMultiAgentSequence() { } // Pick first two agents (or use command line args) - const agent1Id = process.argv[2] ?? agents[0]?.id; - const agent2Id = process.argv[3] ?? agents[1]?.id ?? agents[0]?.id; + const arg1 = process.argv[2]; + 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) { - console.log("No agents available to test"); + const cwd1 = arg1 && !agent1 ? arg1 : agent1?.cwd; + const cwd2 = arg2 && !agent2 ? arg2 : agent2?.cwd; + + if (!cwd1) { + console.log("No checkout cwd available to test"); 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(); try { - const status1 = await client.getCheckoutStatus(agent1Id); - console.log(`✓ Agent1 completed in ${Date.now() - start1}ms - branch: ${status1.currentBranch}`); + const status1 = await client.getCheckoutStatus(cwd1); + console.log( + `✓ Cwd1 completed in ${Date.now() - start1}ms - branch: ${status1.currentBranch}` + ); } 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)}...) ===`); - const start2 = Date.now(); - try { - const status2 = await client.getCheckoutStatus(agent2Id); - console.log(`✓ Agent2 completed in ${Date.now() - start2}ms - branch: ${status2.currentBranch}`); - } catch (err) { - console.log(`✗ Agent2 failed after ${Date.now() - start2}ms:`, err); + if (cwd2) { + console.log(`\n=== Test 2: Request checkout for cwd2 (${cwd2}) ===`); + const start2 = Date.now(); + try { + const status2 = await client.getCheckoutStatus(cwd2); + console.log( + `✓ 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(); try { - const status3 = await client.getCheckoutStatus(agent1Id); - console.log(`✓ Agent1 (retry) completed in ${Date.now() - start3}ms - branch: ${status3.currentBranch}`); + const status3 = await client.getCheckoutStatus(cwd1); + console.log( + `✓ Cwd1 (retry) completed in ${Date.now() - start3}ms - branch: ${status3.currentBranch}` + ); } 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 ===`); - const start4 = Date.now(); - try { - const [p1, p2] = await Promise.all([ - client.getCheckoutStatus(agent1Id), - client.getCheckoutStatus(agent2Id), - ]); - console.log(`✓ Parallel completed in ${Date.now() - start4}ms`); - console.log(` Agent1 branch: ${p1.currentBranch}`); - console.log(` Agent2 branch: ${p2.currentBranch}`); - } catch (err) { - console.log(`✗ Parallel failed after ${Date.now() - start4}ms:`, err); + if (cwd2) { + console.log(`\n=== Test 4: Request both cwds in parallel ===`); + const start4 = Date.now(); + try { + const [p1, p2] = await Promise.all([ + client.getCheckoutStatus(cwd1), + client.getCheckoutStatus(cwd2), + ]); + console.log(`✓ Parallel completed in ${Date.now() - start4}ms`); + console.log(` Cwd1 branch: ${p1.currentBranch}`); + console.log(` Cwd2 branch: ${p2.currentBranch}`); + } catch (err) { + console.log(`✗ Parallel failed after ${Date.now() - start4}ms:`, err); + } } } catch (error) { diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 728c67eda..2f7221c00 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -478,14 +478,6 @@ export const ListProviderModelsRequestMessageSchema = z.object({ 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({ type: z.literal("resume_agent_request"), handle: AgentPersistenceHandleSchema,