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
@@ -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-");
@@ -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<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 {
readonly provider = CODEX_PROVIDER;
readonly capabilities = CODEX_APP_SERVER_CAPABILITIES;
@@ -818,7 +901,6 @@ class CodexAppServerAgentSession implements AgentSession {
private pendingReasoning = new Map<string, string[]>();
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<unknown[]> {
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 {
@@ -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) {
-8
View File
@@ -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,