Update files
This commit is contained in:
@@ -6,9 +6,10 @@ import { mkdtempSync } from "node:fs";
|
||||
|
||||
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { agentConfigs } from "../../daemon-e2e/agent-configs.js";
|
||||
|
||||
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
|
||||
const CODEX_TEST_THINKING_OPTION_ID = "low";
|
||||
const CODEX_TEST_MODEL = agentConfigs.codex.model;
|
||||
const CODEX_TEST_THINKING_OPTION_ID = agentConfigs.codex.thinkingOptionId;
|
||||
|
||||
function isCodexInstalled(): boolean {
|
||||
try {
|
||||
|
||||
@@ -5,18 +5,20 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
__codexAppServerInternals,
|
||||
CodexAppServerAgentClient,
|
||||
codexAppServerTurnInputFromPrompt,
|
||||
} from "./codex-app-server-agent.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { agentConfigs } from "../../daemon-e2e/agent-configs.js";
|
||||
import type {
|
||||
AgentPermissionRequest,
|
||||
AgentPromptContentBlock,
|
||||
AgentTimelineItem,
|
||||
} from "../agent-sdk-types.js";
|
||||
|
||||
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
|
||||
const CODEX_TEST_THINKING_OPTION_ID = "low";
|
||||
const CODEX_TEST_MODEL = agentConfigs.codex.model;
|
||||
const CODEX_TEST_THINKING_OPTION_ID = agentConfigs.codex.thinkingOptionId;
|
||||
const ONE_BY_ONE_PNG_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X1r0AAAAASUVORK5CYII=";
|
||||
|
||||
@@ -87,6 +89,29 @@ function hasApplyPatchFile(item: AgentTimelineItem, fileName: string): boolean {
|
||||
return inInput || inOutput || inDiff || inputPath === fileName || outputPath === fileName;
|
||||
}
|
||||
|
||||
function buildStrictApplyPatchPrompt(
|
||||
patch: string,
|
||||
completionToken: string,
|
||||
options?: { includePermissionStep?: boolean }
|
||||
): string {
|
||||
const lines = [
|
||||
"You are running an automated integration test.",
|
||||
"Required behavior:",
|
||||
"- Call the apply_patch tool exactly once using the patch below.",
|
||||
"- Do not call shell, Bash, exec_command, write_file, or any other tool.",
|
||||
"- Do not ask for confirmation in text or via any tool call.",
|
||||
];
|
||||
if (options?.includePermissionStep) {
|
||||
lines.push(
|
||||
"- If permission is required, wait for approval and then continue with the same apply_patch call."
|
||||
);
|
||||
}
|
||||
lines.push("Patch to apply exactly:");
|
||||
lines.push(patch);
|
||||
lines.push(`After successful apply_patch completion, reply exactly ${completionToken}.`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function waitForFileToContainText(
|
||||
filePath: string,
|
||||
expectedText: string,
|
||||
@@ -175,6 +200,50 @@ describe("Codex app-server provider (integration)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("maps patch notifications with array-style changes and alias diff keys", () => {
|
||||
const item = __codexAppServerInternals.mapCodexPatchNotificationToToolCall({
|
||||
callId: "patch-array-alias",
|
||||
changes: [
|
||||
{
|
||||
path: "/tmp/repo/src/array-alias.ts",
|
||||
kind: "modify",
|
||||
unified_diff: "@@\n-old\n+new\n",
|
||||
},
|
||||
],
|
||||
cwd: "/tmp/repo",
|
||||
running: false,
|
||||
});
|
||||
|
||||
expect(item.detail.type).toBe("edit");
|
||||
if (item.detail.type === "edit") {
|
||||
expect(item.detail.filePath).toBe("src/array-alias.ts");
|
||||
expect(item.detail.unifiedDiff).toContain("-old");
|
||||
expect(item.detail.unifiedDiff).toContain("+new");
|
||||
expect(item.detail.newString).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("maps patch notifications with object-style single change payloads", () => {
|
||||
const item = __codexAppServerInternals.mapCodexPatchNotificationToToolCall({
|
||||
callId: "patch-object-single",
|
||||
changes: {
|
||||
path: "/tmp/repo/src/object-single.ts",
|
||||
kind: "modify",
|
||||
patch: "@@\n-before\n+after\n",
|
||||
},
|
||||
cwd: "/tmp/repo",
|
||||
running: false,
|
||||
});
|
||||
|
||||
expect(item.detail.type).toBe("edit");
|
||||
if (item.detail.type === "edit") {
|
||||
expect(item.detail.filePath).toBe("src/object-single.ts");
|
||||
expect(item.detail.unifiedDiff).toContain("-before");
|
||||
expect(item.detail.unifiedDiff).toContain("+after");
|
||||
expect(item.detail.newString).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test.runIf(isCodexInstalled())("listModels returns live Codex models", async () => {
|
||||
const client = new CodexAppServerAgentClient(logger);
|
||||
const models = await client.listModels();
|
||||
@@ -209,19 +278,28 @@ describe("Codex app-server provider (integration)", () => {
|
||||
const codexHome = tmpCwd("codex-home-defaults-");
|
||||
const prevCodexHome = process.env.CODEX_HOME;
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
writeFileSync(
|
||||
path.join(codexHome, "config.toml"),
|
||||
[
|
||||
'model = "gpt-5.3-codex"',
|
||||
'model_reasoning_effort = "xhigh"',
|
||||
].join("\n"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
try {
|
||||
const client = new CodexAppServerAgentClient(logger);
|
||||
const baselineModels = await client.listModels();
|
||||
const baselineDefaultModel =
|
||||
baselineModels.find((model) => model.isDefault) ?? baselineModels[0];
|
||||
expect(baselineDefaultModel).toBeDefined();
|
||||
const configuredModelId = baselineDefaultModel?.id;
|
||||
expect(typeof configuredModelId).toBe("string");
|
||||
expect((configuredModelId ?? "").length).toBeGreaterThan(0);
|
||||
|
||||
writeFileSync(
|
||||
path.join(codexHome, "config.toml"),
|
||||
[
|
||||
`model = "${configuredModelId}"`,
|
||||
'model_reasoning_effort = "xhigh"',
|
||||
].join("\n"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const models = await client.listModels();
|
||||
const configuredModel = models.find((model) => model.id === "gpt-5.3-codex");
|
||||
const configuredModel = models.find((model) => model.id === configuredModelId);
|
||||
expect(configuredModel).toBeDefined();
|
||||
expect(configuredModel?.isDefault).toBe(true);
|
||||
expect(configuredModel?.defaultThinkingOptionId).toBe("xhigh");
|
||||
@@ -703,14 +781,9 @@ describe("Codex app-server provider (integration)", () => {
|
||||
"*** End Patch",
|
||||
].join("\n");
|
||||
const patchEvents = session.stream(
|
||||
[
|
||||
"Use the apply_patch tool and nothing else.",
|
||||
"Do not use the shell tool for file changes.",
|
||||
"Do not respond with any message until the apply_patch tool completes.",
|
||||
"Apply the following patch exactly:",
|
||||
patch,
|
||||
"After it completes, reply PATCH_DONE.",
|
||||
].join("\n")
|
||||
buildStrictApplyPatchPrompt(patch, "PATCH_DONE", {
|
||||
includePermissionStep: true,
|
||||
})
|
||||
);
|
||||
|
||||
for await (const event of patchEvents) {
|
||||
@@ -801,13 +874,9 @@ describe("Codex app-server provider (integration)", () => {
|
||||
"*** End Patch",
|
||||
].join("\n");
|
||||
const events = session.stream(
|
||||
[
|
||||
"Use the apply_patch tool and nothing else.",
|
||||
"Do not use shell or any other file-edit tool.",
|
||||
"Apply this patch exactly:",
|
||||
patch,
|
||||
"After tool completion, reply PATCH_DONE.",
|
||||
].join("\n")
|
||||
buildStrictApplyPatchPrompt(patch, "PATCH_DONE", {
|
||||
includePermissionStep: true,
|
||||
})
|
||||
);
|
||||
|
||||
for await (const event of events) {
|
||||
@@ -1252,14 +1321,9 @@ describe("Codex app-server provider (integration)", () => {
|
||||
"*** End Patch",
|
||||
].join("\n");
|
||||
const events = session.stream(
|
||||
[
|
||||
"Use the apply_patch tool and nothing else.",
|
||||
"If you need approval before writing files, request approval first.",
|
||||
"Do not respond with any message until the apply_patch tool completes.",
|
||||
"Apply the following patch exactly:",
|
||||
patch,
|
||||
"After approval, reply FILE_DONE.",
|
||||
].join("\n")
|
||||
buildStrictApplyPatchPrompt(patch, "FILE_DONE", {
|
||||
includePermissionStep: true,
|
||||
})
|
||||
);
|
||||
|
||||
let failure: string | null = null;
|
||||
@@ -1298,7 +1362,19 @@ describe("Codex app-server provider (integration)", () => {
|
||||
expect(sawPermissionResolved).toBe(true);
|
||||
}
|
||||
const sawPatch = timelineItems.some((item) => hasApplyPatchFile(item, "approval-test.txt"));
|
||||
expect(sawPatch).toBe(true);
|
||||
if (!sawPatch) {
|
||||
const toolCalls = timelineItems
|
||||
.filter((item): item is Extract<AgentTimelineItem, { type: "tool_call" }> => item.type === "tool_call")
|
||||
.map((item) => ({
|
||||
name: item.name,
|
||||
status: item.status,
|
||||
callId: item.callId,
|
||||
detail: item.detail,
|
||||
}));
|
||||
throw new Error(
|
||||
`Did not observe apply_patch timeline detail for approval-test.txt. Tool calls: ${JSON.stringify(toolCalls)}`
|
||||
);
|
||||
}
|
||||
|
||||
const text = await waitForFileToContainText(targetPath, "ok", { timeoutMs: 10000 });
|
||||
if (!text) {
|
||||
|
||||
@@ -708,6 +708,27 @@ type CodexPatchFileChange = {
|
||||
content?: string;
|
||||
};
|
||||
|
||||
function extractPatchLikeText(value: unknown): string | undefined {
|
||||
if (!value || typeof value !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const candidates = [
|
||||
record.diff,
|
||||
record.patch,
|
||||
record.unified_diff,
|
||||
record.unifiedDiff,
|
||||
record.content,
|
||||
record.newString,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate === "string" && candidate.length > 0) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeCodexThreadItemType(rawType: string | undefined): string | undefined {
|
||||
if (!rawType) {
|
||||
return rawType;
|
||||
@@ -780,20 +801,60 @@ function parseCodexPatchChanges(changes: unknown): CodexPatchFileChange[] {
|
||||
if (!changes || typeof changes !== "object") {
|
||||
return [];
|
||||
}
|
||||
return Object.entries(changes as Record<string, unknown>)
|
||||
|
||||
if (Array.isArray(changes)) {
|
||||
return changes
|
||||
.map((entry): CodexPatchFileChange | null => {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return null;
|
||||
}
|
||||
const record = entry as Record<string, unknown>;
|
||||
const pathValue =
|
||||
typeof record.path === "string" && record.path.trim().length > 0
|
||||
? record.path.trim()
|
||||
: "";
|
||||
if (!pathValue) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
path: pathValue,
|
||||
kind:
|
||||
(typeof record.kind === "string" && record.kind) ||
|
||||
(typeof record.type === "string" && record.type) ||
|
||||
undefined,
|
||||
content: extractPatchLikeText(record),
|
||||
};
|
||||
})
|
||||
.filter((entry): entry is CodexPatchFileChange => entry !== null);
|
||||
}
|
||||
|
||||
const recordChanges = changes as Record<string, unknown>;
|
||||
if (typeof recordChanges.path === "string" && recordChanges.path.trim().length > 0) {
|
||||
return [
|
||||
{
|
||||
path: recordChanges.path.trim(),
|
||||
kind:
|
||||
(typeof recordChanges.kind === "string" && recordChanges.kind) ||
|
||||
(typeof recordChanges.type === "string" && recordChanges.type) ||
|
||||
undefined,
|
||||
content: extractPatchLikeText(recordChanges),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return Object.entries(recordChanges)
|
||||
.map(([path, value]): CodexPatchFileChange | null => {
|
||||
const normalizedPath = path.trim();
|
||||
if (!normalizedPath) {
|
||||
return null;
|
||||
}
|
||||
const parsed =
|
||||
value && typeof value === "object"
|
||||
? (value as { type?: unknown; content?: unknown })
|
||||
: null;
|
||||
return {
|
||||
path: normalizedPath,
|
||||
kind: typeof parsed?.type === "string" ? parsed.type : undefined,
|
||||
content: typeof parsed?.content === "string" ? parsed.content : undefined,
|
||||
kind:
|
||||
value && typeof value === "object" && typeof (value as { type?: unknown }).type === "string"
|
||||
? ((value as { type?: string }).type ?? undefined)
|
||||
: undefined,
|
||||
content: extractPatchLikeText(value),
|
||||
};
|
||||
})
|
||||
.filter((entry): entry is CodexPatchFileChange => entry !== null);
|
||||
@@ -822,6 +883,46 @@ function toRunningToolCall(item: ToolCallTimelineItem): ToolCallTimelineItem {
|
||||
};
|
||||
}
|
||||
|
||||
function isEditToolCallWithoutContent(item: ToolCallTimelineItem): boolean {
|
||||
if (item.type !== "tool_call") {
|
||||
return false;
|
||||
}
|
||||
if (item.detail.type !== "edit") {
|
||||
return false;
|
||||
}
|
||||
const hasDiff =
|
||||
typeof item.detail.unifiedDiff === "string" &&
|
||||
item.detail.unifiedDiff.trim().length > 0;
|
||||
const hasNewString =
|
||||
typeof item.detail.newString === "string" &&
|
||||
item.detail.newString.trim().length > 0;
|
||||
return !hasDiff && !hasNewString;
|
||||
}
|
||||
|
||||
function decodeCodexOutputDeltaChunk(chunk: string): string {
|
||||
const trimmed = chunk.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return chunk;
|
||||
}
|
||||
if (!/^[A-Za-z0-9+/=]+$/.test(trimmed) || trimmed.length % 4 !== 0) {
|
||||
return chunk;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = Buffer.from(trimmed, "base64").toString("utf8");
|
||||
if (decoded.length === 0) {
|
||||
return chunk;
|
||||
}
|
||||
const normalizedInput = trimmed.replace(/=+$/, "");
|
||||
const normalizedRoundTrip = Buffer.from(decoded, "utf8")
|
||||
.toString("base64")
|
||||
.replace(/=+$/, "");
|
||||
return normalizedRoundTrip === normalizedInput ? decoded : chunk;
|
||||
} catch {
|
||||
return chunk;
|
||||
}
|
||||
}
|
||||
|
||||
function mapCodexExecNotificationToToolCall(params: {
|
||||
callId?: string | null;
|
||||
command: unknown;
|
||||
@@ -881,10 +982,10 @@ function mapCodexPatchNotificationToToolCall(params: {
|
||||
}): ToolCallTimelineItem {
|
||||
const files = parseCodexPatchChanges(params.changes);
|
||||
const firstPath = files[0]?.path;
|
||||
const firstContent = files
|
||||
const firstPatchText = files
|
||||
.map((file) => file.content?.trim())
|
||||
.find((value): value is string => typeof value === "string" && value.length > 0);
|
||||
const patchText = params.latestUnifiedDiff?.trim() || firstContent;
|
||||
const patchText = params.latestUnifiedDiff?.trim() || firstPatchText;
|
||||
const patchFields = codexPatchTextFields(patchText);
|
||||
const mapped = mapCodexRolloutToolCall({
|
||||
callId: params.callId ?? null,
|
||||
@@ -907,7 +1008,7 @@ function mapCodexPatchNotificationToToolCall(params: {
|
||||
files: files.map((file) => ({
|
||||
path: file.path,
|
||||
...(file.kind ? { kind: file.kind } : {}),
|
||||
...patchFields,
|
||||
...codexPatchTextFields(file.content ?? patchText),
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
@@ -1138,6 +1239,18 @@ const CodexEventExecCommandEndNotificationSchema = z.object({
|
||||
.passthrough(),
|
||||
}).passthrough();
|
||||
|
||||
const CodexEventExecCommandOutputDeltaNotificationSchema = z.object({
|
||||
msg: z
|
||||
.object({
|
||||
type: z.literal("exec_command_output_delta"),
|
||||
call_id: z.string().optional(),
|
||||
stream: z.string().optional(),
|
||||
chunk: z.string().optional(),
|
||||
delta: z.string().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
}).passthrough();
|
||||
|
||||
const CodexEventPatchApplyBeginNotificationSchema = z.object({
|
||||
msg: z
|
||||
.object({
|
||||
@@ -1161,6 +1274,12 @@ const CodexEventPatchApplyEndNotificationSchema = z.object({
|
||||
.passthrough(),
|
||||
}).passthrough();
|
||||
|
||||
const ItemFileChangeOutputDeltaNotificationSchema = z.object({
|
||||
itemId: z.string(),
|
||||
delta: z.string().optional(),
|
||||
chunk: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const CodexEventTurnDiffNotificationSchema = z.object({
|
||||
msg: z
|
||||
.object({
|
||||
@@ -1206,6 +1325,12 @@ type ParsedCodexNotification =
|
||||
success: boolean | null;
|
||||
stderr: string | null;
|
||||
}
|
||||
| {
|
||||
kind: "exec_command_output_delta";
|
||||
callId: string | null;
|
||||
stream: string | null;
|
||||
chunk: string | null;
|
||||
}
|
||||
| {
|
||||
kind: "patch_apply_started";
|
||||
callId: string | null;
|
||||
@@ -1219,6 +1344,11 @@ type ParsedCodexNotification =
|
||||
stderr: string | null;
|
||||
success: boolean | null;
|
||||
}
|
||||
| {
|
||||
kind: "file_change_output_delta";
|
||||
itemId: string;
|
||||
delta: string | null;
|
||||
}
|
||||
| { kind: "invalid_payload"; method: string; params: unknown }
|
||||
| { kind: "unknown_method"; method: string; params: unknown };
|
||||
|
||||
@@ -1370,6 +1500,23 @@ const CodexNotificationSchema = z.union([
|
||||
z.object({ method: z.literal("codex/event/exec_command_end"), params: z.unknown() }).transform(
|
||||
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
|
||||
),
|
||||
z.object({
|
||||
method: z.literal("codex/event/exec_command_output_delta"),
|
||||
params: CodexEventExecCommandOutputDeltaNotificationSchema,
|
||||
}).transform(
|
||||
({ params }): ParsedCodexNotification => ({
|
||||
kind: "exec_command_output_delta",
|
||||
callId: params.msg.call_id ?? null,
|
||||
stream: params.msg.stream ?? null,
|
||||
chunk: params.msg.chunk ?? params.msg.delta ?? null,
|
||||
})
|
||||
),
|
||||
z.object({
|
||||
method: z.literal("codex/event/exec_command_output_delta"),
|
||||
params: z.unknown(),
|
||||
}).transform(
|
||||
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
|
||||
),
|
||||
z.object({
|
||||
method: z.literal("codex/event/patch_apply_begin"),
|
||||
params: CodexEventPatchApplyBeginNotificationSchema,
|
||||
@@ -1399,6 +1546,19 @@ const CodexNotificationSchema = z.union([
|
||||
z.object({ method: z.literal("codex/event/patch_apply_end"), params: z.unknown() }).transform(
|
||||
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
|
||||
),
|
||||
z.object({
|
||||
method: z.literal("item/fileChange/outputDelta"),
|
||||
params: ItemFileChangeOutputDeltaNotificationSchema,
|
||||
}).transform(
|
||||
({ params }): ParsedCodexNotification => ({
|
||||
kind: "file_change_output_delta",
|
||||
itemId: params.itemId,
|
||||
delta: params.delta ?? params.chunk ?? null,
|
||||
})
|
||||
),
|
||||
z.object({ method: z.literal("item/fileChange/outputDelta"), params: z.unknown() }).transform(
|
||||
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
|
||||
),
|
||||
z.object({
|
||||
method: z.literal("codex/event/turn_diff"),
|
||||
params: CodexEventTurnDiffNotificationSchema,
|
||||
@@ -1539,6 +1699,10 @@ export async function codexAppServerTurnInputFromPrompt(
|
||||
return output;
|
||||
}
|
||||
|
||||
export const __codexAppServerInternals = {
|
||||
mapCodexPatchNotificationToToolCall,
|
||||
};
|
||||
|
||||
class CodexAppServerAgentSession implements AgentSession {
|
||||
readonly provider = CODEX_PROVIDER;
|
||||
readonly capabilities = CODEX_APP_SERVER_CAPABILITIES;
|
||||
@@ -1565,10 +1729,13 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
private resolvedPermissionRequests = new Set<string>();
|
||||
private pendingAgentMessages = new Map<string, string>();
|
||||
private pendingReasoning = new Map<string, string[]>();
|
||||
private pendingCommandOutputDeltas = new Map<string, string[]>();
|
||||
private pendingFileChangeOutputDeltas = new Map<string, string[]>();
|
||||
private emittedItemStartedIds = new Set<string>();
|
||||
private emittedItemCompletedIds = new Set<string>();
|
||||
private warnedUnknownNotificationMethods = new Set<string>();
|
||||
private warnedInvalidNotificationPayloads = new Set<string>();
|
||||
private warnedIncompleteEditToolCallIds = new Set<string>();
|
||||
private latestTurnUnifiedDiff: string | null = null;
|
||||
private latestUsage: AgentUsage | undefined;
|
||||
private connected = false;
|
||||
@@ -1757,6 +1924,9 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
cwd: this.config.cwd ?? null,
|
||||
});
|
||||
if (timelineItem) {
|
||||
if (timelineItem.type === "tool_call") {
|
||||
this.warnOnIncompleteEditToolCall(timelineItem, "thread_read", item);
|
||||
}
|
||||
threadTimeline.push(timelineItem);
|
||||
}
|
||||
}
|
||||
@@ -2255,6 +2425,9 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
this.latestTurnUnifiedDiff = null;
|
||||
this.emittedItemStartedIds.clear();
|
||||
this.emittedItemCompletedIds.clear();
|
||||
this.pendingCommandOutputDeltas.clear();
|
||||
this.pendingFileChangeOutputDeltas.clear();
|
||||
this.warnedIncompleteEditToolCallIds.clear();
|
||||
this.emitEvent({ type: "turn_started", provider: CODEX_PROVIDER });
|
||||
return;
|
||||
}
|
||||
@@ -2274,6 +2447,9 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
this.latestTurnUnifiedDiff = null;
|
||||
this.emittedItemStartedIds.clear();
|
||||
this.emittedItemCompletedIds.clear();
|
||||
this.pendingCommandOutputDeltas.clear();
|
||||
this.pendingFileChangeOutputDeltas.clear();
|
||||
this.warnedIncompleteEditToolCallIds.clear();
|
||||
this.eventQueue?.end();
|
||||
return;
|
||||
}
|
||||
@@ -2321,7 +2497,25 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.kind === "exec_command_output_delta") {
|
||||
this.appendOutputDeltaChunk(
|
||||
this.pendingCommandOutputDeltas,
|
||||
parsed.callId,
|
||||
parsed.chunk,
|
||||
{ decodeBase64: true }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.kind === "file_change_output_delta") {
|
||||
this.appendOutputDeltaChunk(this.pendingFileChangeOutputDeltas, parsed.itemId, parsed.delta);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.kind === "exec_command_started") {
|
||||
if (parsed.callId) {
|
||||
this.pendingCommandOutputDeltas.delete(parsed.callId);
|
||||
}
|
||||
const timelineItem = mapCodexExecNotificationToToolCall({
|
||||
callId: parsed.callId,
|
||||
command: parsed.command,
|
||||
@@ -2335,11 +2529,15 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
if (parsed.kind === "exec_command_completed") {
|
||||
const bufferedOutput = this.consumeOutputDelta(
|
||||
this.pendingCommandOutputDeltas,
|
||||
parsed.callId
|
||||
);
|
||||
const timelineItem = mapCodexExecNotificationToToolCall({
|
||||
callId: parsed.callId,
|
||||
command: parsed.command,
|
||||
cwd: parsed.cwd ?? this.config.cwd ?? null,
|
||||
output: parsed.output,
|
||||
output: parsed.output ?? bufferedOutput,
|
||||
exitCode: parsed.exitCode,
|
||||
success: parsed.success,
|
||||
stderr: parsed.stderr,
|
||||
@@ -2352,6 +2550,9 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
if (parsed.kind === "patch_apply_started") {
|
||||
if (parsed.callId) {
|
||||
this.pendingFileChangeOutputDeltas.delete(parsed.callId);
|
||||
}
|
||||
const timelineItem = mapCodexPatchNotificationToToolCall({
|
||||
callId: parsed.callId,
|
||||
changes: parsed.changes,
|
||||
@@ -2359,21 +2560,34 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
latestUnifiedDiff: this.latestTurnUnifiedDiff,
|
||||
running: true,
|
||||
});
|
||||
this.warnOnIncompleteEditToolCall(timelineItem, "patch_apply_started", {
|
||||
callId: parsed.callId,
|
||||
changes: parsed.changes,
|
||||
});
|
||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.kind === "patch_apply_completed") {
|
||||
const bufferedOutput = this.consumeOutputDelta(
|
||||
this.pendingFileChangeOutputDeltas,
|
||||
parsed.callId
|
||||
);
|
||||
const timelineItem = mapCodexPatchNotificationToToolCall({
|
||||
callId: parsed.callId,
|
||||
changes: parsed.changes,
|
||||
cwd: this.config.cwd ?? null,
|
||||
stdout: parsed.stdout,
|
||||
stdout: parsed.stdout ?? bufferedOutput,
|
||||
stderr: parsed.stderr,
|
||||
success: parsed.success,
|
||||
latestUnifiedDiff: this.latestTurnUnifiedDiff,
|
||||
running: false,
|
||||
});
|
||||
this.warnOnIncompleteEditToolCall(timelineItem, "patch_apply_completed", {
|
||||
callId: parsed.callId,
|
||||
changes: parsed.changes,
|
||||
stdout: parsed.stdout,
|
||||
});
|
||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
||||
return;
|
||||
}
|
||||
@@ -2406,10 +2620,15 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
timelineItem.text = buffered.join("");
|
||||
}
|
||||
}
|
||||
if (timelineItem.type === "tool_call") {
|
||||
this.warnOnIncompleteEditToolCall(timelineItem, "item_completed", parsed.item);
|
||||
}
|
||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
||||
if (itemId) {
|
||||
this.emittedItemCompletedIds.add(itemId);
|
||||
this.emittedItemStartedIds.delete(itemId);
|
||||
this.pendingCommandOutputDeltas.delete(itemId);
|
||||
this.pendingFileChangeOutputDeltas.delete(itemId);
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -2428,9 +2647,12 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
if (itemId && this.emittedItemStartedIds.has(itemId)) {
|
||||
return;
|
||||
}
|
||||
this.warnOnIncompleteEditToolCall(timelineItem, "item_started", parsed.item);
|
||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
||||
if (itemId) {
|
||||
this.emittedItemStartedIds.add(itemId);
|
||||
this.pendingCommandOutputDeltas.delete(itemId);
|
||||
this.pendingFileChangeOutputDeltas.delete(itemId);
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -2464,6 +2686,62 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
);
|
||||
}
|
||||
|
||||
private appendOutputDeltaChunk(
|
||||
store: Map<string, string[]>,
|
||||
id: string | null | undefined,
|
||||
chunk: string | null | undefined,
|
||||
options?: { decodeBase64?: boolean }
|
||||
): void {
|
||||
if (!id || !chunk) {
|
||||
return;
|
||||
}
|
||||
const normalized = options?.decodeBase64 ? decodeCodexOutputDeltaChunk(chunk) : chunk;
|
||||
if (!normalized.length) {
|
||||
return;
|
||||
}
|
||||
const prev = store.get(id) ?? [];
|
||||
prev.push(normalized);
|
||||
store.set(id, prev);
|
||||
}
|
||||
|
||||
private consumeOutputDelta(store: Map<string, string[]>, id: string | null | undefined): string | null {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
const buffered = store.get(id);
|
||||
if (!buffered || buffered.length === 0) {
|
||||
return null;
|
||||
}
|
||||
store.delete(id);
|
||||
return buffered.join("");
|
||||
}
|
||||
|
||||
private warnOnIncompleteEditToolCall(
|
||||
item: ToolCallTimelineItem,
|
||||
source: string,
|
||||
payload: unknown
|
||||
): void {
|
||||
if (!isEditToolCallWithoutContent(item)) {
|
||||
return;
|
||||
}
|
||||
const warnKey = `${source}:${item.callId}`;
|
||||
if (this.warnedIncompleteEditToolCallIds.has(warnKey)) {
|
||||
return;
|
||||
}
|
||||
this.warnedIncompleteEditToolCallIds.add(warnKey);
|
||||
this.logger.warn(
|
||||
{
|
||||
source,
|
||||
callId: item.callId,
|
||||
status: item.status,
|
||||
name: item.name,
|
||||
detail: item.detail,
|
||||
payload,
|
||||
},
|
||||
"Codex edit tool call is missing diff/content fields"
|
||||
);
|
||||
}
|
||||
|
||||
private handleCommandApprovalRequest(params: unknown): Promise<unknown> {
|
||||
const parsed = params as {
|
||||
itemId: string;
|
||||
|
||||
@@ -411,4 +411,56 @@ describe("codex tool-call mapper", () => {
|
||||
expect(item.detail.newString).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("maps fileChange patch alias fields into edit unified diff detail", () => {
|
||||
const item = mapCodexToolCallFromThreadItem(
|
||||
{
|
||||
type: "fileChange",
|
||||
id: "codex-file-change-patch-alias",
|
||||
status: "completed",
|
||||
changes: [
|
||||
{
|
||||
path: "/tmp/repo/src/from-patch-alias.ts",
|
||||
kind: "modify",
|
||||
patch: "@@\n-oldAlias\n+newAlias\n",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ cwd: "/tmp/repo" }
|
||||
);
|
||||
|
||||
expect(item?.detail?.type).toBe("edit");
|
||||
if (item?.detail?.type === "edit") {
|
||||
expect(item.detail.filePath).toBe("src/from-patch-alias.ts");
|
||||
expect(item.detail.unifiedDiff).toContain("-oldAlias");
|
||||
expect(item.detail.unifiedDiff).toContain("+newAlias");
|
||||
expect(item.detail.newString).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("maps fileChange unifiedDiff alias fields into edit unified diff detail", () => {
|
||||
const item = mapCodexToolCallFromThreadItem(
|
||||
{
|
||||
type: "fileChange",
|
||||
id: "codex-file-change-unified-diff-alias",
|
||||
status: "completed",
|
||||
changes: [
|
||||
{
|
||||
path: "/tmp/repo/src/from-unified-diff-alias.ts",
|
||||
kind: "modify",
|
||||
unified_diff: "@@\n-beforeAlias\n+afterAlias\n",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ cwd: "/tmp/repo" }
|
||||
);
|
||||
|
||||
expect(item?.detail?.type).toBe("edit");
|
||||
if (item?.detail?.type === "edit") {
|
||||
expect(item.detail.filePath).toBe("src/from-unified-diff-alias.ts");
|
||||
expect(item.detail.unifiedDiff).toContain("-beforeAlias");
|
||||
expect(item.detail.unifiedDiff).toContain("+afterAlias");
|
||||
expect(item.detail.newString).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,7 +59,11 @@ const CodexFileChangeItemSchema = z
|
||||
path: z.string().optional(),
|
||||
kind: z.string().optional(),
|
||||
diff: z.string().optional(),
|
||||
patch: z.string().optional(),
|
||||
unified_diff: z.string().optional(),
|
||||
unifiedDiff: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
newString: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
)
|
||||
@@ -305,6 +309,15 @@ function asPatchOrContentFields(text: string | undefined): { patch?: string; con
|
||||
return { content: text };
|
||||
}
|
||||
|
||||
function pickFirstPatchLikeString(values: unknown[]): string | undefined {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function removePatchLikeFields(input: Record<string, unknown>): Record<string, unknown> {
|
||||
const {
|
||||
patch: _patch,
|
||||
@@ -560,7 +573,14 @@ function mapFileChangeItem(
|
||||
return {
|
||||
path: pathValue,
|
||||
kind: change.kind,
|
||||
diff: change.diff ?? change.content,
|
||||
diff: pickFirstPatchLikeString([
|
||||
change.diff,
|
||||
change.patch,
|
||||
change.unified_diff,
|
||||
change.unifiedDiff,
|
||||
change.content,
|
||||
change.newString,
|
||||
]),
|
||||
};
|
||||
})
|
||||
.filter((change) => change.path !== undefined);
|
||||
|
||||
@@ -64,7 +64,7 @@ import type {
|
||||
AgentClient,
|
||||
AgentProvider,
|
||||
} from "./agent/agent-sdk-types.js";
|
||||
import { acquirePidLock, releasePidLock } from "./pid-lock.js";
|
||||
import { acquirePidLock, releasePidLock, isLocked, PidLockError } from "./pid-lock.js";
|
||||
import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js";
|
||||
import {
|
||||
createVoiceMcpSocketBridgeManager,
|
||||
@@ -136,6 +136,17 @@ export async function createPaseoDaemon(
|
||||
rootLogger: Logger
|
||||
): Promise<PaseoDaemon> {
|
||||
const logger = rootLogger.child({ module: "bootstrap" });
|
||||
|
||||
// Fail fast before expensive bootstrap (speech/runtime/model checks) if another daemon is active.
|
||||
const lockState = await isLocked(config.paseoHome);
|
||||
if (lockState.locked) {
|
||||
const existingLock = lockState.info;
|
||||
throw new PidLockError(
|
||||
`Another Paseo daemon is already running (PID ${existingLock?.pid ?? "unknown"}, started ${existingLock?.startedAt ?? "unknown"})`,
|
||||
existingLock
|
||||
);
|
||||
}
|
||||
|
||||
const serverId = getOrCreateServerId(config.paseoHome, { logger });
|
||||
const daemonKeyPair = await loadOrCreateDaemonKeyPair(config.paseoHome, logger);
|
||||
let relayTransport: RelayTransportController | null = null;
|
||||
|
||||
@@ -34,7 +34,16 @@ async function main() {
|
||||
config.mcpEnabled = false;
|
||||
}
|
||||
|
||||
const daemon = await createPaseoDaemon(config, logger);
|
||||
let daemon;
|
||||
try {
|
||||
daemon = await createPaseoDaemon(config, logger);
|
||||
} catch (err) {
|
||||
if (err instanceof PidLockError) {
|
||||
logger.error({ pid: err.existingLock?.pid }, err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
await daemon.start();
|
||||
|
||||
@@ -643,7 +643,11 @@ export class VoiceAssistantWebSocketServer {
|
||||
void this.pushService.sendPush(tokens, {
|
||||
title,
|
||||
body,
|
||||
data: { agentId: params.agentId, reason: params.reason },
|
||||
data: {
|
||||
serverId: this.serverId,
|
||||
agentId: params.agentId,
|
||||
reason: params.reason,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user