consolidate checkout status handling and improve git detection
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user