Update files
This commit is contained in:
@@ -223,6 +223,7 @@ export type AgentPermissionRequest = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
input?: AgentMetadata;
|
||||
detail?: ToolCallDetail;
|
||||
suggestions?: AgentPermissionUpdate[];
|
||||
metadata?: AgentMetadata;
|
||||
};
|
||||
|
||||
@@ -368,26 +368,35 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
|
||||
);
|
||||
|
||||
test(
|
||||
"shows the command inside pending tool calls",
|
||||
"shows the command inside permission requests",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const config = buildConfig(cwd, { maxThinkingTokens: 2048 });
|
||||
const session = await client.createSession(config);
|
||||
const filePath = path.join(cwd, "permission.txt");
|
||||
writeFileSync(filePath, "ok", "utf8");
|
||||
|
||||
let pendingCommand: string | null = null;
|
||||
const events = session.stream("Run the exact command `pwd` via Bash and stop.");
|
||||
let requestedCommand: string | null = null;
|
||||
const events = session.stream(
|
||||
"Run the exact command `rm -f permission.txt` via Bash and stop."
|
||||
);
|
||||
|
||||
try {
|
||||
for await (const event of events) {
|
||||
await autoApprove(session, event);
|
||||
if (
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "tool_call" &&
|
||||
event.item.name.toLowerCase().includes("bash") &&
|
||||
event.item.status === "pending"
|
||||
event.type === "permission_requested" &&
|
||||
event.request.kind === "tool" &&
|
||||
event.request.name.toLowerCase().includes("bash")
|
||||
) {
|
||||
pendingCommand = extractToolCommand(event.item.detail);
|
||||
requestedCommand = extractToolCommand(
|
||||
event.request.detail ?? {
|
||||
type: "unknown",
|
||||
input: event.request.input ?? null,
|
||||
output: null,
|
||||
}
|
||||
);
|
||||
await session.respondToPermission(event.request.id, { behavior: "allow" });
|
||||
}
|
||||
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
||||
break;
|
||||
@@ -398,8 +407,8 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
expect(pendingCommand).toBeTruthy();
|
||||
expect(pendingCommand?.toLowerCase()).toContain("pwd");
|
||||
expect(requestedCommand).toBeTruthy();
|
||||
expect(requestedCommand?.toLowerCase()).toContain("permission.txt");
|
||||
},
|
||||
150_000
|
||||
);
|
||||
@@ -501,6 +510,14 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
|
||||
for await (const event of session.stream(prompt)) {
|
||||
if (event.type === "permission_requested" && !captured) {
|
||||
captured = event.request;
|
||||
const requestedCommand = extractToolCommand(
|
||||
captured.detail ?? {
|
||||
type: "unknown",
|
||||
input: captured.input ?? null,
|
||||
output: null,
|
||||
}
|
||||
);
|
||||
expect((requestedCommand ?? "").toLowerCase()).toContain("permission.txt");
|
||||
expect(session.getPendingPermissions().length).toBeGreaterThan(0);
|
||||
await session.respondToPermission(captured.id, { behavior: "allow" });
|
||||
}
|
||||
@@ -597,6 +614,14 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
|
||||
item.status === "completed"
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
timeline.some(
|
||||
(item) =>
|
||||
item.type === "tool_call" &&
|
||||
isPermissionCommandToolCall(item) &&
|
||||
item.status === "failed"
|
||||
)
|
||||
).toBe(true);
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
} finally {
|
||||
await cleanup();
|
||||
@@ -668,6 +693,14 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
|
||||
item.status === "completed"
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
timeline.some(
|
||||
(item) =>
|
||||
item.type === "tool_call" &&
|
||||
isPermissionCommandToolCall(item) &&
|
||||
item.status === "failed"
|
||||
)
|
||||
).toBe(true);
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
} finally {
|
||||
await cleanup();
|
||||
@@ -909,6 +942,79 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
|
||||
180_000
|
||||
);
|
||||
|
||||
test(
|
||||
"handles AskUserQuestion approval flow",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const config = buildConfig(cwd, { maxThinkingTokens: 2048 });
|
||||
const session = await client.createSession(config);
|
||||
|
||||
const prompt = [
|
||||
"You must call the AskUserQuestion tool exactly once and wait for the user's answer.",
|
||||
"Create one question with header 'color', prompt 'Choose a color', and options Blue and Red.",
|
||||
"Set multiSelect to false.",
|
||||
"After receiving the answer, reply with exactly QUESTION_FLOW_DONE.",
|
||||
"Do not use any other tools.",
|
||||
].join(" ");
|
||||
|
||||
let capturedQuestion: AgentPermissionRequest | null = null;
|
||||
let sawResolvedAllow = false;
|
||||
let sawDone = false;
|
||||
|
||||
for await (const event of session.stream(prompt)) {
|
||||
if (
|
||||
event.type === "permission_requested" &&
|
||||
event.request.kind === "question" &&
|
||||
!capturedQuestion
|
||||
) {
|
||||
capturedQuestion = event.request;
|
||||
const baseInput =
|
||||
typeof capturedQuestion.input === "object" && capturedQuestion.input !== null
|
||||
? (capturedQuestion.input as Record<string, unknown>)
|
||||
: {};
|
||||
await session.respondToPermission(capturedQuestion.id, {
|
||||
behavior: "allow",
|
||||
updatedInput: {
|
||||
...baseInput,
|
||||
answers: { color: "Blue" },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "permission_resolved" &&
|
||||
capturedQuestion &&
|
||||
event.requestId === capturedQuestion.id &&
|
||||
event.resolution.behavior === "allow"
|
||||
) {
|
||||
sawResolvedAllow = true;
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "assistant_message" &&
|
||||
event.item.text.includes("QUESTION_FLOW_DONE")
|
||||
) {
|
||||
sawDone = true;
|
||||
}
|
||||
|
||||
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(capturedQuestion).not.toBeNull();
|
||||
expect(sawResolvedAllow).toBe(true);
|
||||
expect(session.getPendingPermissions()).toHaveLength(0);
|
||||
expect(sawDone).toBe(true);
|
||||
|
||||
await session.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
},
|
||||
180_000
|
||||
);
|
||||
|
||||
test(
|
||||
"hydrates persisted tool call results into the UI stream",
|
||||
async () => {
|
||||
|
||||
@@ -783,6 +783,20 @@ class ClaudeAgentSession implements AgentSession {
|
||||
};
|
||||
pending.resolve(result);
|
||||
} else {
|
||||
if (pending.request.kind === "tool") {
|
||||
this.pushToolCall(
|
||||
mapClaudeFailedToolCall({
|
||||
name: pending.request.name,
|
||||
callId:
|
||||
(typeof pending.request.metadata?.toolUseId === "string"
|
||||
? pending.request.metadata.toolUseId
|
||||
: null) ?? pending.request.id,
|
||||
input: pending.request.input ?? null,
|
||||
output: null,
|
||||
error: { message: response.message ?? "Permission denied" },
|
||||
})
|
||||
);
|
||||
}
|
||||
const result: PermissionResult = {
|
||||
behavior: "deny",
|
||||
message: response.message ?? "Permission request denied",
|
||||
@@ -1361,6 +1375,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
options
|
||||
): Promise<PermissionResult> => {
|
||||
const requestId = `permission-${randomUUID()}`;
|
||||
const kind = resolvePermissionKind(toolName, input);
|
||||
const metadata: AgentMetadata = {};
|
||||
if (options.toolUseID) {
|
||||
metadata.toolUseId = options.toolUseID;
|
||||
@@ -1368,13 +1383,23 @@ class ClaudeAgentSession implements AgentSession {
|
||||
if (toolName === "ExitPlanMode" && typeof input.plan === "string") {
|
||||
metadata.planText = input.plan;
|
||||
}
|
||||
const detail =
|
||||
kind === "tool"
|
||||
? mapClaudeRunningToolCall({
|
||||
name: toolName,
|
||||
callId: options.toolUseID ?? requestId,
|
||||
input,
|
||||
output: null,
|
||||
}).detail
|
||||
: undefined;
|
||||
|
||||
const request: AgentPermissionRequest = {
|
||||
id: requestId,
|
||||
provider: "claude",
|
||||
name: toolName,
|
||||
kind: resolvePermissionKind(toolName, input),
|
||||
kind,
|
||||
input,
|
||||
detail,
|
||||
suggestions: options.suggestions?.map((suggestion) => ({ ...suggestion })),
|
||||
metadata: Object.keys(metadata).length ? metadata : undefined,
|
||||
};
|
||||
|
||||
@@ -203,6 +203,43 @@ describe("Codex app-server provider (integration)", () => {
|
||||
30000
|
||||
);
|
||||
|
||||
test.runIf(isCodexInstalled())(
|
||||
"listModels honors configured Codex model + reasoning defaults",
|
||||
async () => {
|
||||
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 models = await client.listModels();
|
||||
const configuredModel = models.find((model) => model.id === "gpt-5.3-codex");
|
||||
expect(configuredModel).toBeDefined();
|
||||
expect(configuredModel?.isDefault).toBe(true);
|
||||
expect(configuredModel?.defaultThinkingOptionId).toBe("xhigh");
|
||||
expect(configuredModel?.thinkingOptions?.some((option) => option.id === "xhigh")).toBe(
|
||||
true
|
||||
);
|
||||
} finally {
|
||||
if (prevCodexHome === undefined) {
|
||||
delete process.env.CODEX_HOME;
|
||||
} else {
|
||||
process.env.CODEX_HOME = prevCodexHome;
|
||||
}
|
||||
rmSync(codexHome, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
30000
|
||||
);
|
||||
|
||||
test.runIf(isCodexInstalled())("accepts image prompt blocks without request validation errors", async () => {
|
||||
const cleanup = useTempCodexSessionDir();
|
||||
const cwd = tmpCwd("codex-image-prompt-");
|
||||
@@ -473,6 +510,10 @@ describe("Codex app-server provider (integration)", () => {
|
||||
if (event.type === "permission_requested" && event.request.name === "CodexBash") {
|
||||
sawPermission = true;
|
||||
captured = event.request;
|
||||
expect(captured.detail?.type).toBe("shell");
|
||||
if (captured.detail?.type === "shell") {
|
||||
expect(captured.detail.command).toContain("printf");
|
||||
}
|
||||
await session.respondToPermission(event.request.id, { behavior: "allow" });
|
||||
}
|
||||
if (
|
||||
@@ -515,6 +556,87 @@ describe("Codex app-server provider (integration)", () => {
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
test.runIf(isCodexInstalled())("command approval deny emits failed tool call and skips execution", async () => {
|
||||
const cleanup = useTempCodexSessionDir();
|
||||
const cwd = tmpCwd("codex-cmd-deny-");
|
||||
const filePath = path.join(cwd, "permission-deny.txt");
|
||||
writeFileSync(filePath, "ok", "utf8");
|
||||
|
||||
try {
|
||||
const client = new CodexAppServerAgentClient(logger);
|
||||
const session = await client.createSession({
|
||||
provider: "codex",
|
||||
cwd,
|
||||
modeId: "auto",
|
||||
approvalPolicy: "on-request",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
});
|
||||
|
||||
let sawPermission = false;
|
||||
let captured: AgentPermissionRequest | null = null;
|
||||
let sawPermissionResolvedDeny = false;
|
||||
const timelineItems: AgentTimelineItem[] = [];
|
||||
|
||||
const events = session.stream(
|
||||
[
|
||||
"You must use your shell tool to run the exact command",
|
||||
"`rm -f permission-deny.txt`.",
|
||||
"If approval is denied, reply DENIED and stop.",
|
||||
].join(" ")
|
||||
);
|
||||
|
||||
let failure: string | null = null;
|
||||
for await (const event of events) {
|
||||
if (event.type === "permission_requested" && event.request.name === "CodexBash") {
|
||||
sawPermission = true;
|
||||
captured = event.request;
|
||||
await session.respondToPermission(event.request.id, {
|
||||
behavior: "deny",
|
||||
message: "Denied by test",
|
||||
});
|
||||
}
|
||||
if (
|
||||
event.type === "permission_resolved" &&
|
||||
captured &&
|
||||
event.requestId === captured.id &&
|
||||
event.resolution.behavior === "deny"
|
||||
) {
|
||||
sawPermissionResolvedDeny = true;
|
||||
}
|
||||
if (event.type === "timeline" && event.item.type === "tool_call") {
|
||||
timelineItems.push(event.item);
|
||||
}
|
||||
if (event.type === "turn_failed") {
|
||||
failure = event.error;
|
||||
break;
|
||||
}
|
||||
if (event.type === "turn_completed") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await session.close();
|
||||
|
||||
if (failure) {
|
||||
throw new Error(failure);
|
||||
}
|
||||
|
||||
expect(sawPermission).toBe(true);
|
||||
expect(sawPermissionResolvedDeny).toBe(true);
|
||||
expect(
|
||||
timelineItems.some(
|
||||
(item) =>
|
||||
item.status === "failed" && hasShellCommand(item, "permission-deny.txt")
|
||||
)
|
||||
).toBe(true);
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
} finally {
|
||||
cleanup();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
test.runIf(isCodexInstalled())(
|
||||
"streams responses and maps shell + file change tool calls into timeline items",
|
||||
async () => {
|
||||
@@ -651,6 +773,225 @@ describe("Codex app-server provider (integration)", () => {
|
||||
120000
|
||||
);
|
||||
|
||||
test.runIf(isCodexInstalled())(
|
||||
"emits expandable canonical detail for apply_patch tool calls",
|
||||
async () => {
|
||||
const cleanup = useTempCodexSessionDir();
|
||||
const cwd = tmpCwd("codex-patch-detail-");
|
||||
const patchFile = path.join(cwd, "expandable-patch.txt");
|
||||
|
||||
try {
|
||||
const client = new CodexAppServerAgentClient(logger);
|
||||
const session = await client.createSession({
|
||||
provider: "codex",
|
||||
cwd,
|
||||
modeId: "full-access",
|
||||
approvalPolicy: "on-request",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
});
|
||||
const persistenceHandle = session.describePersistence();
|
||||
|
||||
const timelineItems: AgentTimelineItem[] = [];
|
||||
let failure: string | null = null;
|
||||
const patch = [
|
||||
"*** Begin Patch",
|
||||
"*** Add File: expandable-patch.txt",
|
||||
"+expandable",
|
||||
"*** 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")
|
||||
);
|
||||
|
||||
for await (const event of events) {
|
||||
if (event.type === "permission_requested") {
|
||||
await session.respondToPermission(event.request.id, { behavior: "allow" });
|
||||
}
|
||||
if (event.type === "timeline") {
|
||||
timelineItems.push(event.item);
|
||||
}
|
||||
if (event.type === "turn_failed") {
|
||||
failure = event.error;
|
||||
break;
|
||||
}
|
||||
if (event.type === "turn_completed") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await session.close();
|
||||
|
||||
if (failure) {
|
||||
throw new Error(failure);
|
||||
}
|
||||
|
||||
const patchCalls = timelineItems.filter(
|
||||
(item): item is Extract<AgentTimelineItem, { type: "tool_call" }> =>
|
||||
item.type === "tool_call" &&
|
||||
item.name.trim().replace(/[.\s-]+/g, "_").toLowerCase().endsWith("apply_patch")
|
||||
);
|
||||
if (patchCalls.length === 0) {
|
||||
const fileExists = existsSync(patchFile);
|
||||
const fileContent = fileExists ? readFileSync(patchFile, "utf8") : null;
|
||||
const toolCalls = timelineItems
|
||||
.filter((item): item is Extract<AgentTimelineItem, { type: "tool_call" }> => item.type === "tool_call")
|
||||
.map((item) => ({
|
||||
name: item.name,
|
||||
status: item.status,
|
||||
detail: item.detail,
|
||||
error: item.error,
|
||||
}));
|
||||
let historyToolCalls: Array<{
|
||||
name: string;
|
||||
status: string;
|
||||
detail: unknown;
|
||||
error: unknown;
|
||||
}> = [];
|
||||
if (persistenceHandle) {
|
||||
const resumed = await client.resumeSession(persistenceHandle);
|
||||
for await (const event of resumed.streamHistory()) {
|
||||
if (event.type === "timeline" && event.item.type === "tool_call") {
|
||||
historyToolCalls.push({
|
||||
name: event.item.name,
|
||||
status: event.item.status,
|
||||
detail: event.item.detail,
|
||||
error: event.item.error,
|
||||
});
|
||||
}
|
||||
}
|
||||
await resumed.close();
|
||||
}
|
||||
throw new Error(
|
||||
`No apply_patch call observed. fileExists=${fileExists} fileContent=${JSON.stringify(fileContent)} liveToolCalls=${JSON.stringify(toolCalls)} historyToolCalls=${JSON.stringify(historyToolCalls)}`
|
||||
);
|
||||
}
|
||||
const completedPatchCall = patchCalls.find((item) => item.status === "completed");
|
||||
expect(completedPatchCall).toBeDefined();
|
||||
if (!completedPatchCall) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Patch tool calls must be renderable as expandable details in the UI.
|
||||
expect(completedPatchCall.detail.type).toBe("edit");
|
||||
if (completedPatchCall.detail.type === "edit") {
|
||||
const renderablePayload =
|
||||
completedPatchCall.detail.unifiedDiff ?? completedPatchCall.detail.newString;
|
||||
expect(typeof renderablePayload).toBe("string");
|
||||
expect(renderablePayload).toContain("expandable");
|
||||
expect(renderablePayload).not.toContain("*** Begin Patch");
|
||||
}
|
||||
|
||||
const patchText =
|
||||
(await waitForFileToContainText(patchFile, "expandable")) ??
|
||||
readFileSync(patchFile, "utf8");
|
||||
expect(patchText.trim()).toBe("expandable");
|
||||
} finally {
|
||||
cleanup();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
120000
|
||||
);
|
||||
|
||||
test.runIf(isCodexInstalled())(
|
||||
"avoids duplicate assistant timeline rows when mirrored item lifecycle notifications are emitted",
|
||||
async () => {
|
||||
const cleanup = useTempCodexSessionDir();
|
||||
const cwd = tmpCwd("codex-mirrored-item-lifecycle-");
|
||||
|
||||
try {
|
||||
const client = new CodexAppServerAgentClient(logger);
|
||||
const session = await client.createSession({
|
||||
provider: "codex",
|
||||
cwd,
|
||||
modeId: "full-access",
|
||||
approvalPolicy: "never",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
});
|
||||
|
||||
const lifecycleChannelsByItemId = new Map<string, { item: boolean; codexEvent: boolean }>();
|
||||
const rawClient = (session as any).client as
|
||||
| {
|
||||
notificationHandler?: (method: string, params: unknown) => void;
|
||||
setNotificationHandler?: (handler: (method: string, params: unknown) => void) => void;
|
||||
}
|
||||
| null;
|
||||
const originalHandler = rawClient?.notificationHandler;
|
||||
rawClient?.setNotificationHandler?.((method: string, params: unknown) => {
|
||||
if (method === "item/completed" || method === "codex/event/item_completed") {
|
||||
const record =
|
||||
params && typeof params === "object" && "msg" in (params as Record<string, unknown>)
|
||||
? ((params as { msg?: { item?: { id?: unknown; type?: unknown } } }).msg?.item ?? null)
|
||||
: ((params as { item?: { id?: unknown; type?: unknown } })?.item ?? null);
|
||||
const itemId = typeof record?.id === "string" ? record.id : null;
|
||||
const normalizedType =
|
||||
typeof record?.type === "string"
|
||||
? record.type.replace(/[._-]/g, "").toLowerCase()
|
||||
: "";
|
||||
if (itemId && normalizedType === "agentmessage") {
|
||||
const existing = lifecycleChannelsByItemId.get(itemId) ?? {
|
||||
item: false,
|
||||
codexEvent: false,
|
||||
};
|
||||
if (method === "item/completed") {
|
||||
existing.item = true;
|
||||
} else {
|
||||
existing.codexEvent = true;
|
||||
}
|
||||
lifecycleChannelsByItemId.set(itemId, existing);
|
||||
}
|
||||
}
|
||||
originalHandler?.(method, params);
|
||||
});
|
||||
|
||||
const assistantMessages: string[] = [];
|
||||
let failure: string | null = null;
|
||||
for await (const event of session.stream("Reply with exactly: DUPLICATE_CHECK_DONE")) {
|
||||
if (event.type === "timeline" && event.item.type === "assistant_message") {
|
||||
assistantMessages.push(event.item.text);
|
||||
}
|
||||
if (event.type === "turn_failed") {
|
||||
failure = event.error;
|
||||
break;
|
||||
}
|
||||
if (event.type === "turn_completed") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await session.close();
|
||||
if (failure) {
|
||||
throw new Error(failure);
|
||||
}
|
||||
|
||||
const normalizedMessages = assistantMessages.map((text) => text.trim()).filter(Boolean);
|
||||
expect(
|
||||
normalizedMessages.some((text) => text.toLowerCase().includes("duplicate_check_done"))
|
||||
).toBe(true);
|
||||
const adjacentDuplicates = normalizedMessages.filter(
|
||||
(text, index) => index > 0 && normalizedMessages[index - 1] === text
|
||||
);
|
||||
expect(adjacentDuplicates.length).toBe(0);
|
||||
const sawMirroredLifecycleForAgentMessage = Array.from(
|
||||
lifecycleChannelsByItemId.values()
|
||||
).some((entry) => entry.item && entry.codexEvent);
|
||||
expect(sawMirroredLifecycleForAgentMessage).toBe(true);
|
||||
} finally {
|
||||
cleanup();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
120000
|
||||
);
|
||||
|
||||
test.runIf(isCodexInstalled())(
|
||||
"interrupts long-running commands and emits a canceled turn",
|
||||
async () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
AgentSlashCommand,
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
ToolCallTimelineItem,
|
||||
AgentUsage,
|
||||
ListModelsOptions,
|
||||
ListPersistedAgentsOptions,
|
||||
@@ -34,7 +35,10 @@ import path from "node:path";
|
||||
import readline from "node:readline";
|
||||
import { z } from "zod";
|
||||
import { loadCodexPersistedTimeline } from "./codex-rollout-timeline.js";
|
||||
import { mapCodexToolCallFromThreadItem } from "./codex/tool-call-mapper.js";
|
||||
import {
|
||||
mapCodexRolloutToolCall,
|
||||
mapCodexToolCallFromThreadItem,
|
||||
} from "./codex/tool-call-mapper.js";
|
||||
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
@@ -114,6 +118,32 @@ function normalizeCodexThinkingOptionId(
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeCodexModelId(modelId: string | null | undefined): string | undefined {
|
||||
if (typeof modelId !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = modelId.trim();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
type CodexConfiguredDefaults = {
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
};
|
||||
|
||||
function mergeCodexConfiguredDefaults(
|
||||
primary: CodexConfiguredDefaults,
|
||||
fallback: CodexConfiguredDefaults
|
||||
): CodexConfiguredDefaults {
|
||||
return {
|
||||
model: primary.model ?? fallback.model,
|
||||
thinkingOptionId: primary.thinkingOptionId ?? fallback.thinkingOptionId,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCodexBinary(): string {
|
||||
try {
|
||||
const codexPath = execSync("which codex", { encoding: "utf8" }).trim();
|
||||
@@ -672,6 +702,230 @@ function planStepsToTodoItems(steps: Array<{ step: string; status: string }>): {
|
||||
}));
|
||||
}
|
||||
|
||||
type CodexPatchFileChange = {
|
||||
path: string;
|
||||
kind?: string;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
function normalizeCodexThreadItemType(rawType: string | undefined): string | undefined {
|
||||
if (!rawType) {
|
||||
return rawType;
|
||||
}
|
||||
switch (rawType) {
|
||||
case "UserMessage":
|
||||
return "userMessage";
|
||||
case "AgentMessage":
|
||||
return "agentMessage";
|
||||
case "Reasoning":
|
||||
return "reasoning";
|
||||
case "Plan":
|
||||
return "plan";
|
||||
case "CommandExecution":
|
||||
return "commandExecution";
|
||||
case "FileChange":
|
||||
return "fileChange";
|
||||
case "McpToolCall":
|
||||
return "mcpToolCall";
|
||||
case "WebSearch":
|
||||
return "webSearch";
|
||||
default:
|
||||
return rawType;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCodexCommandValue(
|
||||
value: unknown
|
||||
): string | string[] | null {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.length) {
|
||||
return null;
|
||||
}
|
||||
const wrapperMatch = trimmed.match(
|
||||
/^(?:\/bin\/)?(?:zsh|bash|sh)\s+-(?:lc|c)\s+([\s\S]+)$/
|
||||
);
|
||||
if (!wrapperMatch) {
|
||||
return trimmed;
|
||||
}
|
||||
const candidate = wrapperMatch[1]?.trim() ?? "";
|
||||
if (!candidate.length) {
|
||||
return trimmed;
|
||||
}
|
||||
if (
|
||||
(candidate.startsWith('"') && candidate.endsWith('"')) ||
|
||||
(candidate.startsWith("'") && candidate.endsWith("'"))
|
||||
) {
|
||||
return candidate.slice(1, -1);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const parts = value
|
||||
.filter((entry): entry is string => typeof entry === "string")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
if (parts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (parts.length >= 3 && (parts[1] === "-lc" || parts[1] === "-c")) {
|
||||
return parts[2] ?? parts;
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function parseCodexPatchChanges(changes: unknown): CodexPatchFileChange[] {
|
||||
if (!changes || typeof changes !== "object") {
|
||||
return [];
|
||||
}
|
||||
return Object.entries(changes as Record<string, unknown>)
|
||||
.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,
|
||||
};
|
||||
})
|
||||
.filter((entry): entry is CodexPatchFileChange => entry !== null);
|
||||
}
|
||||
|
||||
function codexPatchTextFields(
|
||||
text: string | null | undefined
|
||||
): { patch?: string; content?: string } {
|
||||
if (typeof text !== "string") {
|
||||
return {};
|
||||
}
|
||||
const normalized = text.trimStart();
|
||||
const looksLikeUnifiedDiff =
|
||||
normalized.startsWith("diff --git") ||
|
||||
normalized.startsWith("@@") ||
|
||||
normalized.startsWith("--- ") ||
|
||||
normalized.startsWith("+++ ");
|
||||
return looksLikeUnifiedDiff ? { patch: text } : { content: text };
|
||||
}
|
||||
|
||||
function toRunningToolCall(item: ToolCallTimelineItem): ToolCallTimelineItem {
|
||||
return {
|
||||
...item,
|
||||
status: "running",
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapCodexExecNotificationToToolCall(params: {
|
||||
callId?: string | null;
|
||||
command: unknown;
|
||||
cwd?: string | null;
|
||||
output?: string | null;
|
||||
exitCode?: number | null;
|
||||
success?: boolean | null;
|
||||
stderr?: string | null;
|
||||
running: boolean;
|
||||
}): ToolCallTimelineItem | null {
|
||||
const command = normalizeCodexCommandValue(params.command);
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
const isFailure =
|
||||
params.running
|
||||
? false
|
||||
: params.success === false ||
|
||||
(typeof params.exitCode === "number" && params.exitCode !== 0);
|
||||
const output =
|
||||
params.running
|
||||
? null
|
||||
: {
|
||||
command,
|
||||
...(params.output !== null && params.output !== undefined
|
||||
? { output: params.output }
|
||||
: {}),
|
||||
...(params.exitCode !== null && params.exitCode !== undefined
|
||||
? { exitCode: params.exitCode }
|
||||
: {}),
|
||||
};
|
||||
const mapped = mapCodexRolloutToolCall({
|
||||
callId: params.callId ?? null,
|
||||
name: "shell",
|
||||
input: {
|
||||
command,
|
||||
...(params.cwd ? { cwd: params.cwd } : {}),
|
||||
},
|
||||
output,
|
||||
error: isFailure
|
||||
? { message: params.stderr?.trim() || "Command failed" }
|
||||
: null,
|
||||
cwd: params.cwd ?? null,
|
||||
});
|
||||
return params.running ? toRunningToolCall(mapped) : mapped;
|
||||
}
|
||||
|
||||
function mapCodexPatchNotificationToToolCall(params: {
|
||||
callId?: string | null;
|
||||
changes: unknown;
|
||||
cwd?: string | null;
|
||||
stdout?: string | null;
|
||||
stderr?: string | null;
|
||||
success?: boolean | null;
|
||||
latestUnifiedDiff?: string | null;
|
||||
running: boolean;
|
||||
}): ToolCallTimelineItem {
|
||||
const files = parseCodexPatchChanges(params.changes);
|
||||
const firstPath = files[0]?.path;
|
||||
const firstContent = files
|
||||
.map((file) => file.content?.trim())
|
||||
.find((value): value is string => typeof value === "string" && value.length > 0);
|
||||
const patchText = params.latestUnifiedDiff?.trim() || firstContent;
|
||||
const patchFields = codexPatchTextFields(patchText);
|
||||
const mapped = mapCodexRolloutToolCall({
|
||||
callId: params.callId ?? null,
|
||||
name: "apply_patch",
|
||||
input: firstPath
|
||||
? {
|
||||
path: firstPath,
|
||||
...patchFields,
|
||||
files: files.map((file) => ({ path: file.path, kind: file.kind })),
|
||||
}
|
||||
: {
|
||||
changes: params.changes ?? null,
|
||||
...patchFields,
|
||||
},
|
||||
output: params.running
|
||||
? null
|
||||
: {
|
||||
...(files.length > 0
|
||||
? {
|
||||
files: files.map((file) => ({
|
||||
path: file.path,
|
||||
...(file.kind ? { kind: file.kind } : {}),
|
||||
...patchFields,
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
...(params.stdout ? { stdout: params.stdout } : {}),
|
||||
...(params.stderr ? { stderr: params.stderr } : {}),
|
||||
...(params.success !== null && params.success !== undefined
|
||||
? { success: params.success }
|
||||
: {}),
|
||||
},
|
||||
error:
|
||||
params.running || params.success !== false
|
||||
? null
|
||||
: { message: params.stderr?.trim() || "Patch apply failed" },
|
||||
cwd: params.cwd ?? null,
|
||||
});
|
||||
return params.running ? toRunningToolCall(mapped) : mapped;
|
||||
}
|
||||
|
||||
function threadItemToTimeline(
|
||||
item: any,
|
||||
options?: { includeUserMessage?: boolean; cwd?: string | null }
|
||||
@@ -679,25 +933,37 @@ function threadItemToTimeline(
|
||||
if (!item || typeof item !== "object") return null;
|
||||
const includeUserMessage = options?.includeUserMessage ?? true;
|
||||
const cwd = options?.cwd ?? null;
|
||||
switch (item.type) {
|
||||
const normalizedType = normalizeCodexThreadItemType(
|
||||
typeof item.type === "string" ? item.type : undefined
|
||||
);
|
||||
const normalizedItem =
|
||||
normalizedType && normalizedType !== item.type
|
||||
? ({ ...item, type: normalizedType } as typeof item)
|
||||
: item;
|
||||
|
||||
switch (normalizedType) {
|
||||
case "userMessage": {
|
||||
if (!includeUserMessage) {
|
||||
return null;
|
||||
}
|
||||
const text = extractUserText(item.content) ?? "";
|
||||
const text = extractUserText(normalizedItem.content) ?? "";
|
||||
return { type: "user_message", text };
|
||||
}
|
||||
case "agentMessage": {
|
||||
return { type: "assistant_message", text: item.text ?? "" };
|
||||
return { type: "assistant_message", text: normalizedItem.text ?? "" };
|
||||
}
|
||||
case "plan": {
|
||||
const text = item.text ?? "";
|
||||
const text = normalizedItem.text ?? "";
|
||||
const items = parsePlanTextToTodoItems(text);
|
||||
return { type: "todo", items };
|
||||
}
|
||||
case "reasoning": {
|
||||
const summary = Array.isArray(item.summary) ? item.summary.join("\n") : "";
|
||||
const content = Array.isArray(item.content) ? item.content.join("\n") : "";
|
||||
const summary = Array.isArray(normalizedItem.summary)
|
||||
? normalizedItem.summary.join("\n")
|
||||
: "";
|
||||
const content = Array.isArray(normalizedItem.content)
|
||||
? normalizedItem.content.join("\n")
|
||||
: "";
|
||||
const text = summary || content;
|
||||
return text ? { type: "reasoning", text } : null;
|
||||
}
|
||||
@@ -705,7 +971,7 @@ function threadItemToTimeline(
|
||||
case "fileChange":
|
||||
case "mcpToolCall":
|
||||
case "webSearch":
|
||||
return mapCodexToolCallFromThreadItem(item, { cwd });
|
||||
return mapCodexToolCallFromThreadItem(normalizedItem, { cwd });
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -772,6 +1038,7 @@ const TurnCompletedNotificationSchema = z.object({
|
||||
message: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.nullable()
|
||||
.optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
@@ -827,6 +1094,83 @@ const CodexEventTaskCompleteNotificationSchema = z.object({
|
||||
.passthrough(),
|
||||
}).passthrough();
|
||||
|
||||
const CodexEventItemLifecycleNotificationSchema = z.object({
|
||||
msg: z
|
||||
.object({
|
||||
type: z.enum(["item_started", "item_completed"]),
|
||||
item: z
|
||||
.object({
|
||||
id: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
})
|
||||
.passthrough(),
|
||||
}).passthrough();
|
||||
|
||||
const CodexEventExecCommandBeginNotificationSchema = z.object({
|
||||
msg: z
|
||||
.object({
|
||||
type: z.literal("exec_command_begin"),
|
||||
call_id: z.string().optional(),
|
||||
command: z.unknown().optional(),
|
||||
cwd: z.string().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
}).passthrough();
|
||||
|
||||
const CodexEventExecCommandEndNotificationSchema = z.object({
|
||||
msg: z
|
||||
.object({
|
||||
type: z.literal("exec_command_end"),
|
||||
call_id: z.string().optional(),
|
||||
command: z.unknown().optional(),
|
||||
cwd: z.string().optional(),
|
||||
stdout: z.string().optional(),
|
||||
stderr: z.string().optional(),
|
||||
aggregated_output: z.string().optional(),
|
||||
aggregatedOutput: z.string().optional(),
|
||||
formatted_output: z.string().optional(),
|
||||
exit_code: z.number().nullable().optional(),
|
||||
exitCode: z.number().nullable().optional(),
|
||||
success: z.boolean().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
}).passthrough();
|
||||
|
||||
const CodexEventPatchApplyBeginNotificationSchema = z.object({
|
||||
msg: z
|
||||
.object({
|
||||
type: z.literal("patch_apply_begin"),
|
||||
call_id: z.string().optional(),
|
||||
changes: z.unknown().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
}).passthrough();
|
||||
|
||||
const CodexEventPatchApplyEndNotificationSchema = z.object({
|
||||
msg: z
|
||||
.object({
|
||||
type: z.literal("patch_apply_end"),
|
||||
call_id: z.string().optional(),
|
||||
changes: z.unknown().optional(),
|
||||
stdout: z.string().optional(),
|
||||
stderr: z.string().optional(),
|
||||
success: z.boolean().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
}).passthrough();
|
||||
|
||||
const CodexEventTurnDiffNotificationSchema = z.object({
|
||||
msg: z
|
||||
.object({
|
||||
type: z.literal("turn_diff"),
|
||||
unified_diff: z.string().optional(),
|
||||
diff: z.string().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
}).passthrough();
|
||||
|
||||
type ParsedCodexNotification =
|
||||
| { kind: "thread_started"; threadId: string }
|
||||
| { kind: "turn_started"; turnId: string }
|
||||
@@ -836,8 +1180,45 @@ type ParsedCodexNotification =
|
||||
| { kind: "token_usage_updated"; tokenUsage: unknown }
|
||||
| { kind: "agent_message_delta"; itemId: string; delta: string }
|
||||
| { kind: "reasoning_delta"; itemId: string; delta: string }
|
||||
| { kind: "item_completed"; item: { id?: string; type?: string; [key: string]: unknown } }
|
||||
| { kind: "item_started"; item: { id?: string; type?: string; [key: string]: unknown } }
|
||||
| {
|
||||
kind: "item_completed";
|
||||
source: "item" | "codex_event";
|
||||
item: { id?: string; type?: string; [key: string]: unknown };
|
||||
}
|
||||
| {
|
||||
kind: "item_started";
|
||||
source: "item" | "codex_event";
|
||||
item: { id?: string; type?: string; [key: string]: unknown };
|
||||
}
|
||||
| {
|
||||
kind: "exec_command_started";
|
||||
callId: string | null;
|
||||
command: unknown;
|
||||
cwd: string | null;
|
||||
}
|
||||
| {
|
||||
kind: "exec_command_completed";
|
||||
callId: string | null;
|
||||
command: unknown;
|
||||
cwd: string | null;
|
||||
output: string | null;
|
||||
exitCode: number | null;
|
||||
success: boolean | null;
|
||||
stderr: string | null;
|
||||
}
|
||||
| {
|
||||
kind: "patch_apply_started";
|
||||
callId: string | null;
|
||||
changes: unknown;
|
||||
}
|
||||
| {
|
||||
kind: "patch_apply_completed";
|
||||
callId: string | null;
|
||||
changes: unknown;
|
||||
stdout: string | null;
|
||||
stderr: string | null;
|
||||
success: boolean | null;
|
||||
}
|
||||
| { kind: "invalid_payload"; method: string; params: unknown }
|
||||
| { kind: "unknown_method"; method: string; params: unknown };
|
||||
|
||||
@@ -915,17 +1296,121 @@ const CodexNotificationSchema = z.union([
|
||||
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
|
||||
),
|
||||
z.object({ method: z.literal("item/completed"), params: ItemLifecycleNotificationSchema }).transform(
|
||||
({ params }): ParsedCodexNotification => ({ kind: "item_completed", item: params.item })
|
||||
({ params }): ParsedCodexNotification => ({ kind: "item_completed", source: "item", item: params.item })
|
||||
),
|
||||
z.object({ method: z.literal("item/completed"), params: z.unknown() }).transform(
|
||||
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
|
||||
),
|
||||
z.object({ method: z.literal("item/started"), params: ItemLifecycleNotificationSchema }).transform(
|
||||
({ params }): ParsedCodexNotification => ({ kind: "item_started", item: params.item })
|
||||
({ params }): ParsedCodexNotification => ({ kind: "item_started", source: "item", item: params.item })
|
||||
),
|
||||
z.object({ method: z.literal("item/started"), params: z.unknown() }).transform(
|
||||
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
|
||||
),
|
||||
z.object({
|
||||
method: z.literal("codex/event/item_started"),
|
||||
params: CodexEventItemLifecycleNotificationSchema,
|
||||
}).transform(
|
||||
({ params }): ParsedCodexNotification => ({
|
||||
kind: "item_started",
|
||||
source: "codex_event",
|
||||
item: params.msg.item,
|
||||
})
|
||||
),
|
||||
z.object({ method: z.literal("codex/event/item_started"), params: z.unknown() }).transform(
|
||||
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
|
||||
),
|
||||
z.object({
|
||||
method: z.literal("codex/event/item_completed"),
|
||||
params: CodexEventItemLifecycleNotificationSchema,
|
||||
}).transform(
|
||||
({ params }): ParsedCodexNotification => ({
|
||||
kind: "item_completed",
|
||||
source: "codex_event",
|
||||
item: params.msg.item,
|
||||
})
|
||||
),
|
||||
z.object({ method: z.literal("codex/event/item_completed"), params: z.unknown() }).transform(
|
||||
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
|
||||
),
|
||||
z.object({
|
||||
method: z.literal("codex/event/exec_command_begin"),
|
||||
params: CodexEventExecCommandBeginNotificationSchema,
|
||||
}).transform(
|
||||
({ params }): ParsedCodexNotification => ({
|
||||
kind: "exec_command_started",
|
||||
callId: params.msg.call_id ?? null,
|
||||
command: params.msg.command ?? null,
|
||||
cwd: params.msg.cwd ?? null,
|
||||
})
|
||||
),
|
||||
z.object({ method: z.literal("codex/event/exec_command_begin"), params: z.unknown() }).transform(
|
||||
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
|
||||
),
|
||||
z.object({
|
||||
method: z.literal("codex/event/exec_command_end"),
|
||||
params: CodexEventExecCommandEndNotificationSchema,
|
||||
}).transform(
|
||||
({ params }): ParsedCodexNotification => ({
|
||||
kind: "exec_command_completed",
|
||||
callId: params.msg.call_id ?? null,
|
||||
command: params.msg.command ?? null,
|
||||
cwd: params.msg.cwd ?? null,
|
||||
output:
|
||||
params.msg.aggregated_output ??
|
||||
params.msg.aggregatedOutput ??
|
||||
params.msg.formatted_output ??
|
||||
params.msg.stdout ??
|
||||
null,
|
||||
exitCode: params.msg.exit_code ?? params.msg.exitCode ?? null,
|
||||
success: params.msg.success ?? null,
|
||||
stderr: params.msg.stderr ?? null,
|
||||
})
|
||||
),
|
||||
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/patch_apply_begin"),
|
||||
params: CodexEventPatchApplyBeginNotificationSchema,
|
||||
}).transform(
|
||||
({ params }): ParsedCodexNotification => ({
|
||||
kind: "patch_apply_started",
|
||||
callId: params.msg.call_id ?? null,
|
||||
changes: params.msg.changes ?? null,
|
||||
})
|
||||
),
|
||||
z.object({ method: z.literal("codex/event/patch_apply_begin"), params: z.unknown() }).transform(
|
||||
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
|
||||
),
|
||||
z.object({
|
||||
method: z.literal("codex/event/patch_apply_end"),
|
||||
params: CodexEventPatchApplyEndNotificationSchema,
|
||||
}).transform(
|
||||
({ params }): ParsedCodexNotification => ({
|
||||
kind: "patch_apply_completed",
|
||||
callId: params.msg.call_id ?? null,
|
||||
changes: params.msg.changes ?? null,
|
||||
stdout: params.msg.stdout ?? null,
|
||||
stderr: params.msg.stderr ?? null,
|
||||
success: params.msg.success ?? null,
|
||||
})
|
||||
),
|
||||
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("codex/event/turn_diff"),
|
||||
params: CodexEventTurnDiffNotificationSchema,
|
||||
}).transform(
|
||||
({ params }): ParsedCodexNotification => ({
|
||||
kind: "diff_updated",
|
||||
diff: params.msg.unified_diff ?? params.msg.diff ?? "",
|
||||
})
|
||||
),
|
||||
z.object({ method: z.literal("codex/event/turn_diff"), params: z.unknown() }).transform(
|
||||
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
|
||||
),
|
||||
z.object({
|
||||
method: z.literal("codex/event/turn_aborted"),
|
||||
params: CodexEventTurnAbortedNotificationSchema,
|
||||
@@ -968,6 +1453,53 @@ async function writeImageAttachment(mimeType: string, data: string): Promise<str
|
||||
return filePath;
|
||||
}
|
||||
|
||||
async function readCodexConfiguredDefaults(
|
||||
client: CodexAppServerClient,
|
||||
logger: Logger
|
||||
): Promise<CodexConfiguredDefaults> {
|
||||
let savedConfigDefaults: CodexConfiguredDefaults = {};
|
||||
try {
|
||||
const response = (await client.request("getUserSavedConfig", {})) as {
|
||||
config?: {
|
||||
model?: string | null;
|
||||
modelReasoningEffort?: string | null;
|
||||
};
|
||||
};
|
||||
savedConfigDefaults = {
|
||||
model: normalizeCodexModelId(response?.config?.model),
|
||||
thinkingOptionId: normalizeCodexThinkingOptionId(
|
||||
response?.config?.modelReasoningEffort ?? null
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.debug({ error }, "Failed to read Codex saved config defaults");
|
||||
}
|
||||
|
||||
if (savedConfigDefaults.model && savedConfigDefaults.thinkingOptionId) {
|
||||
return savedConfigDefaults;
|
||||
}
|
||||
|
||||
let configReadDefaults: CodexConfiguredDefaults = {};
|
||||
try {
|
||||
const response = (await client.request("config/read", {})) as {
|
||||
config?: {
|
||||
model?: string | null;
|
||||
model_reasoning_effort?: string | null;
|
||||
};
|
||||
};
|
||||
configReadDefaults = {
|
||||
model: normalizeCodexModelId(response?.config?.model),
|
||||
thinkingOptionId: normalizeCodexThinkingOptionId(
|
||||
response?.config?.model_reasoning_effort ?? null
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.debug({ error }, "Failed to read Codex config defaults");
|
||||
}
|
||||
|
||||
return mergeCodexConfiguredDefaults(savedConfigDefaults, configReadDefaults);
|
||||
}
|
||||
|
||||
export async function codexAppServerTurnInputFromPrompt(
|
||||
prompt: AgentPromptInput,
|
||||
logger: Logger
|
||||
@@ -1033,8 +1565,11 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
private resolvedPermissionRequests = new Set<string>();
|
||||
private pendingAgentMessages = new Map<string, string>();
|
||||
private pendingReasoning = 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 latestTurnUnifiedDiff: string | null = null;
|
||||
private latestUsage: AgentUsage | undefined;
|
||||
private connected = false;
|
||||
private collaborationModes: Array<{
|
||||
@@ -1443,10 +1978,41 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
if (!pending) {
|
||||
throw new Error(`No pending Codex app-server permission request with id '${requestId}'`);
|
||||
}
|
||||
const pendingRequest = this.pendingPermissions.get(requestId) ?? null;
|
||||
this.pendingPermissionHandlers.delete(requestId);
|
||||
this.pendingPermissions.delete(requestId);
|
||||
this.resolvedPermissionRequests.add(requestId);
|
||||
|
||||
if (response.behavior === "deny" && pendingRequest?.kind === "tool") {
|
||||
const fallbackName =
|
||||
pendingRequest.name === "CodexBash"
|
||||
? "shell"
|
||||
: pendingRequest.name === "CodexFileChange"
|
||||
? "apply_patch"
|
||||
: pendingRequest.name;
|
||||
this.emitEvent({
|
||||
type: "timeline",
|
||||
provider: CODEX_PROVIDER,
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: requestId,
|
||||
name: fallbackName,
|
||||
status: "failed",
|
||||
error: { message: response.message ?? "Permission denied" },
|
||||
detail:
|
||||
pendingRequest.detail ?? {
|
||||
type: "unknown",
|
||||
input: pendingRequest.input ?? null,
|
||||
output: null,
|
||||
},
|
||||
metadata: {
|
||||
permissionRequestId: requestId,
|
||||
denied: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this.emitEvent({
|
||||
type: "permission_resolved",
|
||||
provider: CODEX_PROVIDER,
|
||||
@@ -1607,6 +2173,10 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
|
||||
// Resolve model - if not specified, query available models and pick default
|
||||
let model = this.config.model;
|
||||
if (!model) {
|
||||
const configuredDefaults = await readCodexConfiguredDefaults(this.client, this.logger);
|
||||
model = configuredDefaults.model;
|
||||
}
|
||||
if (!model) {
|
||||
const modelResponse = (await this.client.request("model/list", {})) as CodexModelListResponse;
|
||||
const models = modelResponse?.data ?? [];
|
||||
@@ -1616,6 +2186,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
model = defaultModel.id;
|
||||
}
|
||||
this.config.model = model;
|
||||
|
||||
const preset = MODE_PRESETS[this.currentMode] ?? MODE_PRESETS[DEFAULT_CODEX_MODE_ID];
|
||||
const approvalPolicy = this.config.approvalPolicy ?? preset.approvalPolicy;
|
||||
@@ -1681,6 +2252,9 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
|
||||
if (parsed.kind === "turn_started") {
|
||||
this.currentTurnId = parsed.turnId;
|
||||
this.latestTurnUnifiedDiff = null;
|
||||
this.emittedItemStartedIds.clear();
|
||||
this.emittedItemCompletedIds.clear();
|
||||
this.emitEvent({ type: "turn_started", provider: CODEX_PROVIDER });
|
||||
return;
|
||||
}
|
||||
@@ -1697,6 +2271,9 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
} else {
|
||||
this.emitEvent({ type: "turn_completed", provider: CODEX_PROVIDER, usage: this.latestUsage });
|
||||
}
|
||||
this.latestTurnUnifiedDiff = null;
|
||||
this.emittedItemStartedIds.clear();
|
||||
this.emittedItemCompletedIds.clear();
|
||||
this.eventQueue?.end();
|
||||
return;
|
||||
}
|
||||
@@ -1717,13 +2294,12 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
if (parsed.kind === "diff_updated") {
|
||||
if (parsed.diff.trim().length > 0) {
|
||||
// NOTE: Codex app-server emits frequent `turn/diff/updated` notifications
|
||||
// containing a full accumulated unified diff for the *entire turn*.
|
||||
// This is not a concrete file-change tool call; it is progress telemetry.
|
||||
// We intentionally do NOT store it in the agent timeline to avoid
|
||||
// snapshot bloat and relay/WebSocket size limits.
|
||||
}
|
||||
const trimmedDiff = parsed.diff.trim();
|
||||
this.latestTurnUnifiedDiff = trimmedDiff.length > 0 ? trimmedDiff : null;
|
||||
// NOTE: Codex app-server emits frequent `turn/diff/updated` notifications
|
||||
// containing a full accumulated unified diff for the *entire turn*.
|
||||
// This is not a concrete file-change tool call; it is progress telemetry.
|
||||
// We intentionally do NOT store every diff update in the timeline.
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1745,13 +2321,79 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.kind === "exec_command_started") {
|
||||
const timelineItem = mapCodexExecNotificationToToolCall({
|
||||
callId: parsed.callId,
|
||||
command: parsed.command,
|
||||
cwd: parsed.cwd ?? this.config.cwd ?? null,
|
||||
running: true,
|
||||
});
|
||||
if (timelineItem) {
|
||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.kind === "exec_command_completed") {
|
||||
const timelineItem = mapCodexExecNotificationToToolCall({
|
||||
callId: parsed.callId,
|
||||
command: parsed.command,
|
||||
cwd: parsed.cwd ?? this.config.cwd ?? null,
|
||||
output: parsed.output,
|
||||
exitCode: parsed.exitCode,
|
||||
success: parsed.success,
|
||||
stderr: parsed.stderr,
|
||||
running: false,
|
||||
});
|
||||
if (timelineItem) {
|
||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.kind === "patch_apply_started") {
|
||||
const timelineItem = mapCodexPatchNotificationToToolCall({
|
||||
callId: parsed.callId,
|
||||
changes: parsed.changes,
|
||||
cwd: this.config.cwd ?? null,
|
||||
latestUnifiedDiff: this.latestTurnUnifiedDiff,
|
||||
running: true,
|
||||
});
|
||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.kind === "patch_apply_completed") {
|
||||
const timelineItem = mapCodexPatchNotificationToToolCall({
|
||||
callId: parsed.callId,
|
||||
changes: parsed.changes,
|
||||
cwd: this.config.cwd ?? null,
|
||||
stdout: parsed.stdout,
|
||||
stderr: parsed.stderr,
|
||||
success: parsed.success,
|
||||
latestUnifiedDiff: this.latestTurnUnifiedDiff,
|
||||
running: false,
|
||||
});
|
||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.kind === "item_completed") {
|
||||
// Codex emits mirrored lifecycle notifications via both `codex/event/item_*`
|
||||
// and canonical `item/*`. We render only the canonical channel to avoid
|
||||
// duplicated assistant/reasoning rows.
|
||||
if (parsed.source === "codex_event") {
|
||||
return;
|
||||
}
|
||||
const timelineItem = threadItemToTimeline(parsed.item, {
|
||||
includeUserMessage: false,
|
||||
cwd: this.config.cwd ?? null,
|
||||
});
|
||||
if (timelineItem) {
|
||||
const itemId = parsed.item.id;
|
||||
if (itemId && this.emittedItemCompletedIds.has(itemId)) {
|
||||
return;
|
||||
}
|
||||
if (timelineItem.type === "assistant_message" && itemId) {
|
||||
const buffered = this.pendingAgentMessages.get(itemId);
|
||||
if (buffered && buffered.length > 0) {
|
||||
@@ -1765,17 +2407,31 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
||||
if (itemId) {
|
||||
this.emittedItemCompletedIds.add(itemId);
|
||||
this.emittedItemStartedIds.delete(itemId);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.kind === "item_started") {
|
||||
if (parsed.source === "codex_event") {
|
||||
return;
|
||||
}
|
||||
const timelineItem = threadItemToTimeline(parsed.item, {
|
||||
includeUserMessage: false,
|
||||
cwd: this.config.cwd ?? null,
|
||||
});
|
||||
if (timelineItem && timelineItem.type === "tool_call") {
|
||||
const itemId = parsed.item.id;
|
||||
if (itemId && this.emittedItemStartedIds.has(itemId)) {
|
||||
return;
|
||||
}
|
||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
||||
if (itemId) {
|
||||
this.emittedItemStartedIds.add(itemId);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1817,6 +2473,12 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
cwd?: string | null;
|
||||
reason?: string | null;
|
||||
};
|
||||
const commandPreview = mapCodexExecNotificationToToolCall({
|
||||
callId: parsed.itemId,
|
||||
command: parsed.command,
|
||||
cwd: parsed.cwd ?? this.config.cwd ?? null,
|
||||
running: true,
|
||||
});
|
||||
const requestId = `permission-${parsed.itemId}`;
|
||||
const title = parsed.command ? `Run command: ${parsed.command}` : "Run command";
|
||||
const request: AgentPermissionRequest = {
|
||||
@@ -1830,6 +2492,15 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
command: parsed.command ?? undefined,
|
||||
cwd: parsed.cwd ?? undefined,
|
||||
},
|
||||
detail:
|
||||
commandPreview?.detail ?? {
|
||||
type: "unknown",
|
||||
input: {
|
||||
command: parsed.command ?? null,
|
||||
cwd: parsed.cwd ?? null,
|
||||
},
|
||||
output: null,
|
||||
},
|
||||
metadata: {
|
||||
itemId: parsed.itemId,
|
||||
threadId: parsed.threadId,
|
||||
@@ -1853,6 +2524,13 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
kind: "tool",
|
||||
title: "Apply file changes",
|
||||
description: parsed.reason ?? undefined,
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input: {
|
||||
reason: parsed.reason ?? null,
|
||||
},
|
||||
output: null,
|
||||
},
|
||||
metadata: {
|
||||
itemId: parsed.itemId,
|
||||
threadId: parsed.threadId,
|
||||
@@ -1876,6 +2554,13 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
kind: "tool",
|
||||
title: "Tool action requires approval",
|
||||
description: undefined,
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input: {
|
||||
questions: Array.isArray(parsed.questions) ? parsed.questions : [],
|
||||
},
|
||||
output: null,
|
||||
},
|
||||
metadata: {
|
||||
itemId: parsed.itemId,
|
||||
threadId: parsed.threadId,
|
||||
@@ -2020,12 +2705,21 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
|
||||
const response = (await client.request("model/list", {})) as { data?: Array<any> };
|
||||
const models = Array.isArray(response?.data) ? response.data : [];
|
||||
const configuredDefaults = await readCodexConfiguredDefaults(client, this.logger);
|
||||
const configuredDefaultModelId = configuredDefaults.model;
|
||||
const configuredDefaultThinkingOptionId = configuredDefaults.thinkingOptionId;
|
||||
const hasConfiguredDefaultModel =
|
||||
typeof configuredDefaultModelId === "string"
|
||||
? models.some((model) => model?.id === configuredDefaultModelId)
|
||||
: false;
|
||||
return models.map((model) => {
|
||||
const defaultReasoningEffort = normalizeCodexThinkingOptionId(
|
||||
typeof model.defaultReasoningEffort === "string"
|
||||
? model.defaultReasoningEffort
|
||||
: null
|
||||
);
|
||||
const resolvedDefaultReasoningEffort =
|
||||
configuredDefaultThinkingOptionId ?? defaultReasoningEffort;
|
||||
|
||||
const thinkingById = new Map<string, { id: string; label: string; description?: string }>();
|
||||
if (Array.isArray(model.supportedReasoningEfforts)) {
|
||||
@@ -2042,27 +2736,35 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
if (defaultReasoningEffort && !thinkingById.has(defaultReasoningEffort)) {
|
||||
thinkingById.set(defaultReasoningEffort, {
|
||||
id: defaultReasoningEffort,
|
||||
label: defaultReasoningEffort,
|
||||
description: "Model default reasoning effort",
|
||||
if (resolvedDefaultReasoningEffort && !thinkingById.has(resolvedDefaultReasoningEffort)) {
|
||||
thinkingById.set(resolvedDefaultReasoningEffort, {
|
||||
id: resolvedDefaultReasoningEffort,
|
||||
label: resolvedDefaultReasoningEffort,
|
||||
description:
|
||||
configuredDefaultThinkingOptionId === resolvedDefaultReasoningEffort
|
||||
? "Configured default reasoning effort"
|
||||
: "Model default reasoning effort",
|
||||
});
|
||||
}
|
||||
|
||||
const thinkingOptions = Array.from(thinkingById.values()).map((option) => ({
|
||||
...option,
|
||||
isDefault: option.id === defaultReasoningEffort,
|
||||
isDefault: option.id === resolvedDefaultReasoningEffort,
|
||||
}));
|
||||
const defaultThinkingOptionId =
|
||||
defaultReasoningEffort ?? thinkingOptions.find((option) => option.isDefault)?.id ?? thinkingOptions[0]?.id;
|
||||
resolvedDefaultReasoningEffort ??
|
||||
thinkingOptions.find((option) => option.isDefault)?.id ??
|
||||
thinkingOptions[0]?.id;
|
||||
const isDefaultModel = hasConfiguredDefaultModel
|
||||
? model.id === configuredDefaultModelId
|
||||
: model.isDefault;
|
||||
|
||||
return {
|
||||
provider: CODEX_PROVIDER,
|
||||
id: model.id,
|
||||
label: model.displayName,
|
||||
description: model.description,
|
||||
isDefault: model.isDefault,
|
||||
isDefault: isDefaultModel,
|
||||
thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined,
|
||||
defaultThinkingOptionId,
|
||||
metadata: {
|
||||
|
||||
@@ -40,7 +40,7 @@ describe("codex rollout parsing", () => {
|
||||
type: "tool_call",
|
||||
name: "Bash",
|
||||
callId: "call_MhTWDF2mpM4dhbNmHNt6ikDF",
|
||||
input: { command: "task show cc4ea7d1" },
|
||||
detail: { type: "shell", command: "task show cc4ea7d1" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,8 +78,11 @@ describe("codex rollout parsing", () => {
|
||||
type: "tool_call",
|
||||
name: "Bash",
|
||||
callId: "call_abc123",
|
||||
input: { command: "echo hello" },
|
||||
output: expect.stringContaining("hello"),
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "echo hello",
|
||||
output: "hello",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -143,7 +146,7 @@ describe("codex rollout parsing", () => {
|
||||
type: "tool_call",
|
||||
name: "Bash",
|
||||
callId: "call_shell123",
|
||||
input: { command: "ls -la" },
|
||||
detail: { type: "shell", command: "ls -la" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -249,11 +252,61 @@ describe("codex rollout parsing", () => {
|
||||
type: "tool_call",
|
||||
name: "Bash",
|
||||
callId: "call_legacy_1",
|
||||
input: { command: "echo hello" },
|
||||
output: "hello",
|
||||
detail: { type: "shell", command: "echo hello", output: "hello" },
|
||||
});
|
||||
});
|
||||
|
||||
test("parses custom_tool_call apply_patch with input/output into editable tool detail", async () => {
|
||||
const rolloutPath = join(tmpDir, "rollout.jsonl");
|
||||
const patch = [
|
||||
"*** Begin Patch",
|
||||
"*** Add File: src/new-file.ts",
|
||||
"+export const value = 1;",
|
||||
"*** End Patch",
|
||||
].join("\n");
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
timestamp: "2026-02-09T10:00:00.000Z",
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "custom_tool_call",
|
||||
name: "apply_patch",
|
||||
call_id: "call_patch_custom_1",
|
||||
input: patch,
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: "2026-02-09T10:00:01.000Z",
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "custom_tool_call_output",
|
||||
call_id: "call_patch_custom_1",
|
||||
output: '{"output":"Success. Updated the following files:\\nA src/new-file.ts\\n","metadata":{"exit_code":0}}',
|
||||
},
|
||||
}),
|
||||
];
|
||||
writeFileSync(rolloutPath, lines.join("\n") + "\n");
|
||||
|
||||
const timeline = await parseRolloutFile(rolloutPath);
|
||||
const toolCalls = timeline.filter((i) => i.type === "tool_call");
|
||||
expect(toolCalls.length).toBe(1);
|
||||
|
||||
const patchCall = toolCalls[0];
|
||||
expect(patchCall).toMatchObject({
|
||||
type: "tool_call",
|
||||
name: "apply_patch",
|
||||
callId: "call_patch_custom_1",
|
||||
status: "completed",
|
||||
});
|
||||
expect(patchCall.detail.type).toBe("edit");
|
||||
if (patchCall.detail.type === "edit") {
|
||||
expect(patchCall.detail.filePath).toBe("src/new-file.ts");
|
||||
expect(patchCall.detail.unifiedDiff).toContain("diff --git");
|
||||
expect(patchCall.detail.unifiedDiff).toContain("+export const value = 1;");
|
||||
expect(patchCall.detail.unifiedDiff).not.toContain("*** Begin Patch");
|
||||
}
|
||||
});
|
||||
|
||||
test("parses legacy event_msg shape using msg", async () => {
|
||||
const rolloutPath = join(tmpDir, "rollout.jsonl");
|
||||
const lines = [
|
||||
@@ -289,6 +342,70 @@ describe("codex rollout parsing", () => {
|
||||
expect(timeline).toContainEqual({ type: "assistant_message", text: "done" });
|
||||
expect(timeline).toContainEqual({ type: "user_message", text: "question" });
|
||||
});
|
||||
|
||||
test("deduplicates mirrored response_item and event_msg text records", async () => {
|
||||
const rolloutPath = join(tmpDir, "rollout.jsonl");
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
timestamp: "2026-01-22T07:08:54.000Z",
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "question" }],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: "2026-01-22T07:08:54.001Z",
|
||||
type: "event_msg",
|
||||
payload: {
|
||||
type: "user_message",
|
||||
message: "question",
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: "2026-01-22T07:08:54.010Z",
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "reasoning",
|
||||
content: [{ type: "reasoning_text", text: "thinking" }],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: "2026-01-22T07:08:54.011Z",
|
||||
type: "event_msg",
|
||||
payload: {
|
||||
type: "agent_reasoning",
|
||||
text: "thinking",
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: "2026-01-22T07:08:54.020Z",
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "answer" }],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: "2026-01-22T07:08:54.021Z",
|
||||
type: "event_msg",
|
||||
payload: {
|
||||
type: "agent_message",
|
||||
message: "answer",
|
||||
},
|
||||
}),
|
||||
];
|
||||
writeFileSync(rolloutPath, lines.join("\n") + "\n");
|
||||
|
||||
const timeline = await parseRolloutFile(rolloutPath);
|
||||
expect(timeline).toEqual([
|
||||
{ type: "user_message", text: "question" },
|
||||
{ type: "reasoning", text: "thinking" },
|
||||
{ type: "assistant_message", text: "answer" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("complex conversation", () => {
|
||||
@@ -362,8 +479,11 @@ describe("codex rollout parsing", () => {
|
||||
expect(timeline[2]).toMatchObject({
|
||||
type: "tool_call",
|
||||
name: "Bash",
|
||||
input: { command: "npm test" },
|
||||
output: expect.stringContaining("All tests passed!"),
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "npm test",
|
||||
output: "All tests passed!",
|
||||
},
|
||||
});
|
||||
expect(timeline[3]).toMatchObject({
|
||||
type: "assistant_message",
|
||||
|
||||
@@ -95,21 +95,34 @@ const RolloutResponseFunctionCallPayloadSchema = z.object({
|
||||
type: z.literal("function_call"),
|
||||
name: z.string().optional(),
|
||||
call_id: z.string().optional(),
|
||||
arguments: z.string().optional(),
|
||||
arguments: z.unknown().optional(),
|
||||
});
|
||||
|
||||
const RolloutResponseCustomToolCallPayloadSchema = z.object({
|
||||
type: z.literal("custom_tool_call"),
|
||||
name: z.string().optional(),
|
||||
call_id: z.string().optional(),
|
||||
arguments: z.string().optional(),
|
||||
});
|
||||
const RolloutResponseCustomToolCallPayloadSchema = z
|
||||
.object({
|
||||
type: z.literal("custom_tool_call"),
|
||||
name: z.string().optional(),
|
||||
call_id: z.string().optional(),
|
||||
arguments: z.unknown().optional(),
|
||||
input: z.unknown().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const RolloutResponseFunctionCallOutputPayloadSchema = z.object({
|
||||
type: z.literal("function_call_output"),
|
||||
call_id: z.string().optional(),
|
||||
output: z.string().optional(),
|
||||
});
|
||||
const RolloutResponseFunctionCallOutputPayloadSchema = z
|
||||
.object({
|
||||
type: z.literal("function_call_output"),
|
||||
call_id: z.string().optional(),
|
||||
output: z.unknown().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const RolloutResponseCustomToolCallOutputPayloadSchema = z
|
||||
.object({
|
||||
type: z.literal("custom_tool_call_output"),
|
||||
call_id: z.string().optional(),
|
||||
output: z.unknown().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const RolloutEventAgentReasoningPayloadSchema = z.object({
|
||||
type: z.literal("agent_reasoning"),
|
||||
@@ -154,7 +167,7 @@ type RolloutResponseReasoningPayload = z.infer<
|
||||
type ParsedRolloutRecord =
|
||||
| { kind: "timeline"; item: AgentTimelineItem }
|
||||
| { kind: "call"; name: string; callId?: string; input?: unknown }
|
||||
| { kind: "output"; callId: string; output: string }
|
||||
| { kind: "output"; callId: string; output: unknown }
|
||||
| { kind: "ignore" };
|
||||
|
||||
const RolloutMessageContentSchema = z
|
||||
@@ -252,6 +265,22 @@ function parseJsonLikeString(value: string): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonLikeValue(value: unknown): unknown {
|
||||
if (typeof value === "string") {
|
||||
return parseJsonLikeString(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readOutputPayloadValue(payload: Record<string, unknown>): unknown {
|
||||
if (payload.output !== undefined) {
|
||||
return parseJsonLikeValue(payload.output);
|
||||
}
|
||||
|
||||
const { type: _type, call_id: _callId, ...rest } = payload;
|
||||
return Object.keys(rest).length > 0 ? rest : undefined;
|
||||
}
|
||||
|
||||
const FunctionCallInputNormalizationSchema = z
|
||||
.union([
|
||||
z.object({ cmd: z.string() }).transform((input) => ({ name: "Bash", input: { command: input.cmd } })),
|
||||
@@ -284,7 +313,8 @@ const RolloutResponseRecordSchema = z
|
||||
])
|
||||
.transform((payload): ParsedRolloutRecord => {
|
||||
const rawName = payload.name ?? "unknown";
|
||||
const parsedArguments = payload.arguments ? parseJsonLikeString(payload.arguments) : undefined;
|
||||
const rawInput = payload.arguments ?? ("input" in payload ? payload.input : undefined);
|
||||
const parsedArguments = parseJsonLikeValue(rawInput);
|
||||
const normalized =
|
||||
rawName === "exec_command" || rawName === "shell"
|
||||
? FunctionCallInputNormalizationSchema.parse(parsedArguments)
|
||||
@@ -299,12 +329,20 @@ const RolloutResponseRecordSchema = z
|
||||
input: normalized.input,
|
||||
};
|
||||
}),
|
||||
RolloutResponseFunctionCallOutputPayloadSchema.transform(
|
||||
(payload): ParsedRolloutRecord =>
|
||||
payload.call_id && payload.output
|
||||
? { kind: "output", callId: payload.call_id, output: payload.output }
|
||||
: { kind: "ignore" }
|
||||
),
|
||||
z
|
||||
.union([
|
||||
RolloutResponseFunctionCallOutputPayloadSchema,
|
||||
RolloutResponseCustomToolCallOutputPayloadSchema,
|
||||
])
|
||||
.transform((payload): ParsedRolloutRecord => {
|
||||
if (!payload.call_id) {
|
||||
return { kind: "ignore" };
|
||||
}
|
||||
const output = readOutputPayloadValue(payload);
|
||||
return output !== undefined
|
||||
? { kind: "output", callId: payload.call_id, output }
|
||||
: { kind: "ignore" };
|
||||
}),
|
||||
z.unknown().transform((): ParsedRolloutRecord => ({ kind: "ignore" })),
|
||||
]);
|
||||
|
||||
@@ -391,6 +429,37 @@ function parseJsonRolloutTimeline(
|
||||
return timeline;
|
||||
}
|
||||
|
||||
function timelineTextFingerprint(item: AgentTimelineItem): string | null {
|
||||
switch (item.type) {
|
||||
case "user_message":
|
||||
case "assistant_message":
|
||||
case "reasoning":
|
||||
return `${item.type}\u0000${item.text}`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function dedupeMirroredTextTimelineItems(
|
||||
timeline: AgentTimelineItem[]
|
||||
): AgentTimelineItem[] {
|
||||
const deduped: AgentTimelineItem[] = [];
|
||||
for (const item of timeline) {
|
||||
const prev = deduped[deduped.length - 1];
|
||||
if (!prev) {
|
||||
deduped.push(item);
|
||||
continue;
|
||||
}
|
||||
const currentFingerprint = timelineTextFingerprint(item);
|
||||
const previousFingerprint = timelineTextFingerprint(prev);
|
||||
if (currentFingerprint && previousFingerprint && currentFingerprint === previousFingerprint) {
|
||||
continue;
|
||||
}
|
||||
deduped.push(item);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
export async function parseRolloutFile(
|
||||
filePath: string
|
||||
): Promise<AgentTimelineItem[]> {
|
||||
@@ -403,7 +472,7 @@ export async function parseRolloutFile(
|
||||
const parsed = JSON.parse(trimmed);
|
||||
const jsonTimeline = parseJsonRolloutTimeline(parsed);
|
||||
if (jsonTimeline) {
|
||||
return jsonTimeline;
|
||||
return dedupeMirroredTextTimelineItems(jsonTimeline);
|
||||
}
|
||||
} catch {
|
||||
// Fall back to JSONL parsing.
|
||||
@@ -428,9 +497,9 @@ export async function parseRolloutFile(
|
||||
|
||||
const outputsByCallId = parsedRecords
|
||||
.filter((record): record is Extract<ParsedRolloutRecord, { kind: "output" }> => record.kind === "output")
|
||||
.reduce((map, record) => map.set(record.callId, record.output), new Map<string, string>());
|
||||
.reduce((map, record) => map.set(record.callId, record.output), new Map<string, unknown>());
|
||||
|
||||
return parsedRecords.flatMap((record): AgentTimelineItem[] =>
|
||||
const timeline = parsedRecords.flatMap((record): AgentTimelineItem[] =>
|
||||
record.kind === "timeline"
|
||||
? [record.item]
|
||||
: record.kind === "call"
|
||||
@@ -444,6 +513,7 @@ export async function parseRolloutFile(
|
||||
]
|
||||
: []
|
||||
);
|
||||
return dedupeMirroredTextTimelineItems(timeline);
|
||||
}
|
||||
|
||||
export type CodexPersistedTimelineOptions = {
|
||||
|
||||
@@ -27,6 +27,63 @@ describe("codex tool-call mapper", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("unwraps shell wrapper arrays for commandExecution", () => {
|
||||
const item = mapCodexToolCallFromThreadItem({
|
||||
type: "commandExecution",
|
||||
id: "codex-call-wrapper-array",
|
||||
status: "running",
|
||||
command: ["/bin/zsh", "-lc", "echo hello"],
|
||||
cwd: "/tmp/repo",
|
||||
});
|
||||
|
||||
expect(item?.detail).toEqual({
|
||||
type: "shell",
|
||||
command: "echo hello",
|
||||
cwd: "/tmp/repo",
|
||||
});
|
||||
});
|
||||
|
||||
it("unwraps shell wrapper strings for commandExecution", () => {
|
||||
const item = mapCodexToolCallFromThreadItem({
|
||||
type: "commandExecution",
|
||||
id: "codex-call-wrapper-string",
|
||||
status: "running",
|
||||
command: '/bin/zsh -lc "echo hello"',
|
||||
cwd: "/tmp/repo",
|
||||
});
|
||||
|
||||
expect(item?.detail).toEqual({
|
||||
type: "shell",
|
||||
command: "echo hello",
|
||||
cwd: "/tmp/repo",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps only command output body when commandExecution output is wrapped in shell envelope", () => {
|
||||
const item = mapCodexToolCallFromThreadItem({
|
||||
type: "commandExecution",
|
||||
id: "codex-call-envelope-output",
|
||||
status: "completed",
|
||||
command: "echo hello",
|
||||
cwd: "/tmp/repo",
|
||||
aggregatedOutput:
|
||||
"Chunk ID: e87d40\nWall time: 0.0521 seconds\nProcess exited with code 0\nOriginal token count: 192\nOutput:\n214 export type AgentPermissionRequestKind = \"tool\";",
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
expect(item?.detail?.type).toBe("shell");
|
||||
if (item?.detail?.type === "shell") {
|
||||
expect(item.detail.output).toBe(
|
||||
"214 export type AgentPermissionRequestKind = \"tool\";"
|
||||
);
|
||||
expect(item.detail.output).not.toContain("Chunk ID:");
|
||||
expect(item.detail.output).not.toContain("Wall time:");
|
||||
expect(item.detail.output).not.toContain("Process exited with code");
|
||||
expect(item.detail.output).not.toContain("Original token count:");
|
||||
expect(item.detail.output).not.toContain("Output:");
|
||||
}
|
||||
});
|
||||
|
||||
it("maps running known tool variants with detail for early summaries", () => {
|
||||
const readItem = mapCodexToolCallFromThreadItem(
|
||||
{
|
||||
@@ -159,6 +216,26 @@ describe("codex tool-call mapper", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("maps fileChange content fallback into editable text when unified diff is absent", () => {
|
||||
const item = mapCodexToolCallFromThreadItem(
|
||||
{
|
||||
type: "fileChange",
|
||||
id: "codex-content-1",
|
||||
status: "completed",
|
||||
changes: [{ path: "/tmp/repo/src/content-only.ts", kind: "modify", content: "line one\nline two\n" }],
|
||||
},
|
||||
{ cwd: "/tmp/repo" }
|
||||
);
|
||||
|
||||
expect(item).toBeTruthy();
|
||||
expect(item?.detail?.type).toBe("edit");
|
||||
if (item?.detail?.type === "edit") {
|
||||
expect(item.detail.filePath).toBe("src/content-only.ts");
|
||||
expect(item.detail.newString).toContain("line one");
|
||||
expect(item.detail.unifiedDiff).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("maps write/edit/search known variants with distinct detail types", () => {
|
||||
const writeItem = mapCodexToolCallFromThreadItem(
|
||||
{
|
||||
@@ -240,4 +317,98 @@ describe("codex tool-call mapper", () => {
|
||||
});
|
||||
expect(item.callId).toBe("codex-call-4");
|
||||
});
|
||||
|
||||
it("maps apply_patch rollout calls with raw patch input into edit detail", () => {
|
||||
const patch = [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: /tmp/repo/src/index.ts",
|
||||
"@@",
|
||||
"-old",
|
||||
"+new",
|
||||
"*** End Patch",
|
||||
].join("\n");
|
||||
const item = mapCodexRolloutToolCall({
|
||||
callId: "codex-call-apply",
|
||||
name: "apply_patch",
|
||||
input: patch,
|
||||
output: '{"output":"Success. Updated the following files:\\nM src/index.ts\\n"}',
|
||||
cwd: "/tmp/repo",
|
||||
});
|
||||
|
||||
expect(item.status).toBe("completed");
|
||||
expect(item.error).toBeNull();
|
||||
expect(item.detail.type).toBe("edit");
|
||||
if (item.detail.type === "edit") {
|
||||
expect(item.detail.filePath).toBe("src/index.ts");
|
||||
expect(item.detail.unifiedDiff).toContain("diff --git");
|
||||
expect(item.detail.unifiedDiff).toContain("@@");
|
||||
expect(item.detail.unifiedDiff).toContain("-old");
|
||||
expect(item.detail.unifiedDiff).toContain("+new");
|
||||
expect(item.detail.unifiedDiff).not.toContain("*** Begin Patch");
|
||||
expect(item.detail.newString).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("maps apply_patch object content payloads into unified diff detail", () => {
|
||||
const patch = [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: /tmp/repo/src/object.ts",
|
||||
"@@",
|
||||
"-before",
|
||||
"+after",
|
||||
"*** End Patch",
|
||||
].join("\n");
|
||||
|
||||
const item = mapCodexRolloutToolCall({
|
||||
callId: "codex-call-apply-object",
|
||||
name: "apply_patch",
|
||||
input: {
|
||||
path: "/tmp/repo/src/object.ts",
|
||||
content: patch,
|
||||
},
|
||||
output: null,
|
||||
cwd: "/tmp/repo",
|
||||
});
|
||||
|
||||
expect(item.detail.type).toBe("edit");
|
||||
if (item.detail.type === "edit") {
|
||||
expect(item.detail.filePath).toBe("src/object.ts");
|
||||
expect(item.detail.unifiedDiff).toContain("diff --git");
|
||||
expect(item.detail.unifiedDiff).toContain("@@");
|
||||
expect(item.detail.unifiedDiff).toContain("-before");
|
||||
expect(item.detail.unifiedDiff).toContain("+after");
|
||||
expect(item.detail.unifiedDiff).not.toContain("*** Begin Patch");
|
||||
expect(item.detail.newString).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("maps fileChange content that contains codex patch envelopes as unified diffs", () => {
|
||||
const patch = [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: /tmp/repo/src/from-file-change.ts",
|
||||
"@@",
|
||||
"-alpha",
|
||||
"+beta",
|
||||
"*** End Patch",
|
||||
].join("\n");
|
||||
|
||||
const item = mapCodexToolCallFromThreadItem(
|
||||
{
|
||||
type: "fileChange",
|
||||
id: "codex-file-change-patch-content",
|
||||
status: "completed",
|
||||
changes: [{ path: "/tmp/repo/src/from-file-change.ts", kind: "modify", content: patch }],
|
||||
},
|
||||
{ cwd: "/tmp/repo" }
|
||||
);
|
||||
|
||||
expect(item?.detail?.type).toBe("edit");
|
||||
if (item?.detail?.type === "edit") {
|
||||
expect(item.detail.filePath).toBe("src/from-file-change.ts");
|
||||
expect(item.detail.unifiedDiff).toContain("-alpha");
|
||||
expect(item.detail.unifiedDiff).toContain("+beta");
|
||||
expect(item.detail.unifiedDiff).not.toContain("*** Begin Patch");
|
||||
expect(item.detail.newString).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ToolCallDetail, ToolCallTimelineItem } from "../../agent-sdk-types
|
||||
import { CommandValueSchema } from "../tool-call-detail-primitives.js";
|
||||
import {
|
||||
coerceToolCallId,
|
||||
commandFromValue,
|
||||
extractCodexShellOutput,
|
||||
truncateDiffText,
|
||||
} from "../tool-call-mapper-utils.js";
|
||||
import {
|
||||
@@ -59,6 +59,7 @@ const CodexFileChangeItemSchema = z
|
||||
path: z.string().optional(),
|
||||
kind: z.string().optional(),
|
||||
diff: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
)
|
||||
@@ -108,6 +109,213 @@ function coerceCallId(raw: string | null | undefined, name: string, input: unkno
|
||||
});
|
||||
}
|
||||
|
||||
function maybeUnwrapShellWrapperCommand(command: string): string {
|
||||
const trimmed = command.trim();
|
||||
const wrapperMatch = trimmed.match(
|
||||
/^(?:\/bin\/)?(?:zsh|bash|sh)\s+-(?:lc|c)\s+([\s\S]+)$/
|
||||
);
|
||||
if (!wrapperMatch) {
|
||||
return trimmed;
|
||||
}
|
||||
const candidate = wrapperMatch[1]?.trim() ?? "";
|
||||
if (!candidate) {
|
||||
return trimmed;
|
||||
}
|
||||
if (
|
||||
(candidate.startsWith('"') && candidate.endsWith('"')) ||
|
||||
(candidate.startsWith("'") && candidate.endsWith("'"))
|
||||
) {
|
||||
return candidate.slice(1, -1);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function normalizeCommandExecutionCommand(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
const normalized = maybeUnwrapShellWrapperCommand(value);
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const parts = value
|
||||
.filter((entry): entry is string => typeof entry === "string")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
if (parts.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (parts.length >= 3 && (parts[1] === "-lc" || parts[1] === "-c")) {
|
||||
const unwrapped = parts[2]?.trim();
|
||||
return unwrapped && unwrapped.length > 0 ? unwrapped : undefined;
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function looksLikeUnifiedDiff(text: string): boolean {
|
||||
const normalized = text.trimStart();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
normalized.startsWith("diff --git") ||
|
||||
normalized.startsWith("@@") ||
|
||||
normalized.startsWith("--- ") ||
|
||||
normalized.startsWith("+++ ")
|
||||
);
|
||||
}
|
||||
|
||||
type CodexApplyPatchDirective = {
|
||||
kind: "add" | "update" | "delete";
|
||||
path: string;
|
||||
};
|
||||
|
||||
function parseCodexApplyPatchDirective(line: string): CodexApplyPatchDirective | null {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith("*** Add File:")) {
|
||||
return { kind: "add", path: trimmed.replace("*** Add File:", "").trim() };
|
||||
}
|
||||
if (trimmed.startsWith("*** Update File:")) {
|
||||
return { kind: "update", path: trimmed.replace("*** Update File:", "").trim() };
|
||||
}
|
||||
if (trimmed.startsWith("*** Delete File:")) {
|
||||
return { kind: "delete", path: trimmed.replace("*** Delete File:", "").trim() };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function looksLikeCodexApplyPatch(text: string): boolean {
|
||||
const normalized = text.trimStart();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (normalized.startsWith("*** Begin Patch")) {
|
||||
return true;
|
||||
}
|
||||
return text.split(/\r?\n/).some((line) => parseCodexApplyPatchDirective(line) !== null);
|
||||
}
|
||||
|
||||
function normalizeDiffHeaderPath(rawPath: string): string {
|
||||
return rawPath.trim().replace(/^["']+|["']+$/g, "");
|
||||
}
|
||||
|
||||
function codexApplyPatchToUnifiedDiff(text: string): string {
|
||||
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
||||
const output: string[] = [];
|
||||
let sawDiffBody = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const directive = parseCodexApplyPatchDirective(line);
|
||||
if (directive) {
|
||||
const path = normalizeDiffHeaderPath(directive.path);
|
||||
if (path.length > 0) {
|
||||
if (output.length > 0 && output[output.length - 1] !== "") {
|
||||
output.push("");
|
||||
}
|
||||
const left = directive.kind === "add" ? "/dev/null" : `a/${path}`;
|
||||
const right = directive.kind === "delete" ? "/dev/null" : `b/${path}`;
|
||||
output.push(`diff --git a/${path} b/${path}`);
|
||||
output.push(`--- ${left}`);
|
||||
output.push(`+++ ${right}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const trimmed = line.trim();
|
||||
if (
|
||||
trimmed === "*** Begin Patch" ||
|
||||
trimmed === "*** End Patch" ||
|
||||
trimmed === "*** End of File" ||
|
||||
trimmed.startsWith("*** Move to:")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith("@@")) {
|
||||
output.push(line);
|
||||
sawDiffBody = true;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) {
|
||||
output.push(line);
|
||||
sawDiffBody = true;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("\\ No newline at end of file")) {
|
||||
output.push(line);
|
||||
sawDiffBody = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sawDiffBody) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const normalized = output.join("\n").trim();
|
||||
return normalized.length > 0 ? normalized : text;
|
||||
}
|
||||
|
||||
function classifyDiffLikeText(
|
||||
text: string
|
||||
): { isDiff: true; text: string } | { isDiff: false; text: string } {
|
||||
if (looksLikeUnifiedDiff(text)) {
|
||||
return { isDiff: true, text };
|
||||
}
|
||||
if (looksLikeCodexApplyPatch(text)) {
|
||||
return { isDiff: true, text: codexApplyPatchToUnifiedDiff(text) };
|
||||
}
|
||||
return { isDiff: false, text };
|
||||
}
|
||||
|
||||
function asEditTextFields(
|
||||
text: string | undefined
|
||||
): { unifiedDiff?: string; newString?: string } {
|
||||
if (typeof text !== "string" || text.length === 0) {
|
||||
return {};
|
||||
}
|
||||
const classified = classifyDiffLikeText(text);
|
||||
if (classified.isDiff) {
|
||||
return { unifiedDiff: truncateDiffText(classified.text) };
|
||||
}
|
||||
return { newString: text };
|
||||
}
|
||||
|
||||
function asEditFileOutputFields(
|
||||
text: string | undefined
|
||||
): { patch?: string; content?: string } {
|
||||
if (typeof text !== "string" || text.length === 0) {
|
||||
return {};
|
||||
}
|
||||
const classified = classifyDiffLikeText(text);
|
||||
if (classified.isDiff) {
|
||||
return { patch: truncateDiffText(classified.text) };
|
||||
}
|
||||
return { content: text };
|
||||
}
|
||||
|
||||
function asPatchOrContentFields(text: string | undefined): { patch?: string; content?: string } {
|
||||
if (typeof text !== "string" || text.length === 0) {
|
||||
return {};
|
||||
}
|
||||
const classified = classifyDiffLikeText(text);
|
||||
if (classified.isDiff) {
|
||||
return { patch: truncateDiffText(classified.text) };
|
||||
}
|
||||
return { content: text };
|
||||
}
|
||||
|
||||
function removePatchLikeFields(input: Record<string, unknown>): Record<string, unknown> {
|
||||
const {
|
||||
patch: _patch,
|
||||
diff: _diff,
|
||||
unified_diff: _unifiedDiffSnake,
|
||||
unifiedDiff: _unifiedDiffCamel,
|
||||
...rest
|
||||
} = input;
|
||||
return rest;
|
||||
}
|
||||
|
||||
function resolveStatus(
|
||||
rawStatus: string | undefined,
|
||||
error: unknown,
|
||||
@@ -189,20 +397,121 @@ function toNullableObject(value: Record<string, unknown>): Record<string, unknow
|
||||
return Object.keys(value).length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function extractPatchPrimaryFilePath(patch: string): string | undefined {
|
||||
for (const line of patch.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith("*** Add File:")) {
|
||||
return trimmed.replace("*** Add File:", "").trim();
|
||||
}
|
||||
if (trimmed.startsWith("*** Update File:")) {
|
||||
return trimmed.replace("*** Update File:", "").trim();
|
||||
}
|
||||
if (trimmed.startsWith("*** Delete File:")) {
|
||||
return trimmed.replace("*** Delete File:", "").trim();
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeApplyPatchInput(input: unknown): unknown {
|
||||
if (typeof input === "string") {
|
||||
const filePath = extractPatchPrimaryFilePath(input);
|
||||
const textFields = asPatchOrContentFields(input);
|
||||
return filePath ? { path: filePath, ...textFields } : textFields;
|
||||
}
|
||||
|
||||
if (!isRecord(input)) {
|
||||
return input;
|
||||
}
|
||||
|
||||
const existingPath =
|
||||
(typeof input.path === "string" && input.path.trim().length > 0 && input.path.trim()) ||
|
||||
(typeof input.file_path === "string" &&
|
||||
input.file_path.trim().length > 0 &&
|
||||
input.file_path.trim()) ||
|
||||
(typeof input.filePath === "string" &&
|
||||
input.filePath.trim().length > 0 &&
|
||||
input.filePath.trim());
|
||||
const patchText =
|
||||
(typeof input.patch === "string" && input.patch) ||
|
||||
(typeof input.diff === "string" && input.diff) ||
|
||||
(typeof input.unified_diff === "string" && input.unified_diff) ||
|
||||
(typeof input.unifiedDiff === "string" && input.unifiedDiff) ||
|
||||
undefined;
|
||||
const contentText = typeof input.content === "string" ? input.content : undefined;
|
||||
const inferredPatchFromContent = !patchText && typeof contentText === "string" ? contentText : undefined;
|
||||
const patchOrContentText = patchText ?? inferredPatchFromContent;
|
||||
|
||||
if (existingPath && !patchOrContentText) {
|
||||
return input;
|
||||
}
|
||||
|
||||
if (!patchOrContentText) {
|
||||
return input;
|
||||
}
|
||||
|
||||
const base = removePatchLikeFields(input);
|
||||
if (inferredPatchFromContent) {
|
||||
delete (base as { content?: unknown }).content;
|
||||
}
|
||||
const filePath = existingPath || extractPatchPrimaryFilePath(patchOrContentText);
|
||||
const textFields = asPatchOrContentFields(patchOrContentText);
|
||||
return filePath ? { ...base, path: filePath, ...textFields } : { ...base, ...textFields };
|
||||
}
|
||||
|
||||
function deriveApplyPatchDetailFromInput(
|
||||
input: unknown,
|
||||
cwd: string | null | undefined
|
||||
): ToolCallDetail | null {
|
||||
if (!isRecord(input)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pathValue =
|
||||
(typeof input.path === "string" && input.path.trim()) ||
|
||||
(typeof input.file_path === "string" && input.file_path.trim()) ||
|
||||
(typeof input.filePath === "string" && input.filePath.trim()) ||
|
||||
"";
|
||||
if (!pathValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedPath = normalizeCodexFilePath(pathValue, cwd) ?? pathValue;
|
||||
const diffText =
|
||||
(typeof input.patch === "string" && input.patch) ||
|
||||
(typeof input.diff === "string" && input.diff) ||
|
||||
(typeof input.unified_diff === "string" && input.unified_diff) ||
|
||||
(typeof input.unifiedDiff === "string" && input.unifiedDiff) ||
|
||||
(typeof input.content === "string" && input.content) ||
|
||||
undefined;
|
||||
|
||||
const textFields = asEditTextFields(diffText);
|
||||
return {
|
||||
type: "edit",
|
||||
filePath: normalizedPath,
|
||||
...textFields,
|
||||
};
|
||||
}
|
||||
|
||||
function mapCommandExecutionItem(
|
||||
item: z.infer<typeof CodexCommandExecutionItemSchema>
|
||||
): ToolCallTimelineItem {
|
||||
const command = item.command ? commandFromValue(item.command) : undefined;
|
||||
const command = normalizeCommandExecutionCommand(item.command);
|
||||
const parsedOutput = extractCodexShellOutput(item.aggregatedOutput);
|
||||
const input = toNullableObject({
|
||||
...(command !== undefined ? { command } : {}),
|
||||
...(item.cwd !== undefined ? { cwd: item.cwd } : {}),
|
||||
});
|
||||
|
||||
const output =
|
||||
item.aggregatedOutput !== undefined || item.exitCode !== undefined
|
||||
parsedOutput !== undefined || item.exitCode !== undefined
|
||||
? {
|
||||
...(command !== undefined ? { command } : {}),
|
||||
...(item.aggregatedOutput !== undefined ? { output: item.aggregatedOutput } : {}),
|
||||
...(parsedOutput !== undefined ? { output: parsedOutput } : {}),
|
||||
...(item.exitCode !== undefined ? { exitCode: item.exitCode } : {}),
|
||||
}
|
||||
: null;
|
||||
@@ -212,7 +521,7 @@ function mapCommandExecutionItem(
|
||||
type: "shell" as const,
|
||||
command,
|
||||
...(item.cwd ? { cwd: item.cwd } : {}),
|
||||
...(item.aggregatedOutput ? { output: item.aggregatedOutput } : {}),
|
||||
...(parsedOutput ? { output: parsedOutput } : {}),
|
||||
...(item.exitCode !== undefined ? { exitCode: item.exitCode } : {}),
|
||||
}
|
||||
: {
|
||||
@@ -251,7 +560,7 @@ function mapFileChangeItem(
|
||||
return {
|
||||
path: pathValue,
|
||||
kind: change.kind,
|
||||
diff: change.diff,
|
||||
diff: change.diff ?? change.content,
|
||||
};
|
||||
})
|
||||
.filter((change) => change.path !== undefined);
|
||||
@@ -273,18 +582,19 @@ function mapFileChangeItem(
|
||||
files: files.map((file) => ({
|
||||
path: file.path,
|
||||
...(file.kind !== undefined ? { kind: file.kind } : {}),
|
||||
...(file.diff !== undefined ? { patch: truncateDiffText(file.diff) } : {}),
|
||||
...asEditFileOutputFields(file.diff),
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
const firstFile = files[0];
|
||||
const firstTextFields = asEditTextFields(firstFile?.diff);
|
||||
const detail = firstFile?.path
|
||||
? {
|
||||
type: "edit" as const,
|
||||
filePath: firstFile.path,
|
||||
...(firstFile.diff !== undefined ? { unifiedDiff: truncateDiffText(firstFile.diff) } : {}),
|
||||
...firstTextFields,
|
||||
}
|
||||
: {
|
||||
type: "unknown" as const,
|
||||
@@ -400,19 +710,31 @@ export function mapCodexRolloutToolCall(params: {
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
error?: unknown;
|
||||
cwd?: string | null;
|
||||
}): ToolCallTimelineItem {
|
||||
const parsed = CodexRolloutToolCallParamsSchema.parse(params);
|
||||
const input = parsed.input ?? null;
|
||||
const rawInput = parsed.input ?? null;
|
||||
const normalizedName = parsed.name.trim().toLowerCase();
|
||||
const input =
|
||||
normalizedName === "apply_patch" || normalizedName === "apply_diff"
|
||||
? normalizeApplyPatchInput(rawInput)
|
||||
: rawInput;
|
||||
const output = parsed.output ?? null;
|
||||
const error = parsed.error ?? null;
|
||||
const status = resolveStatus("completed", error, output);
|
||||
const callId = coerceCallId(parsed.callId, parsed.name, input);
|
||||
const detail = deriveCodexToolDetail({
|
||||
let detail = deriveCodexToolDetail({
|
||||
name: parsed.name,
|
||||
input,
|
||||
output,
|
||||
cwd: null,
|
||||
cwd: params.cwd ?? null,
|
||||
});
|
||||
if (detail.type === "unknown" && (normalizedName === "apply_patch" || normalizedName === "apply_diff")) {
|
||||
const fallbackDetail = deriveApplyPatchDetailFromInput(input, params.cwd ?? null);
|
||||
if (fallbackDetail) {
|
||||
detail = fallbackDetail;
|
||||
}
|
||||
}
|
||||
|
||||
return buildToolCall({
|
||||
callId,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import type { ToolCallDetail } from "../agent-sdk-types.js";
|
||||
import {
|
||||
commandFromValue,
|
||||
extractCodexShellOutput,
|
||||
flattenReadContent as flattenToolReadContent,
|
||||
nonEmptyString,
|
||||
truncateDiffText,
|
||||
@@ -83,12 +84,11 @@ const ToolShellOutputObjectSchema = z
|
||||
export const ToolShellOutputSchema = z.union([
|
||||
z.string().transform((value) => ({
|
||||
command: undefined,
|
||||
output: nonEmptyString(value),
|
||||
output: extractCodexShellOutput(value),
|
||||
exitCode: undefined,
|
||||
})),
|
||||
ToolShellOutputObjectSchema.transform((value) => ({
|
||||
command: nonEmptyString(value.command) ?? nonEmptyString(value.result?.command),
|
||||
output:
|
||||
ToolShellOutputObjectSchema.transform((value) => {
|
||||
const rawOutput =
|
||||
nonEmptyString(value.output) ??
|
||||
nonEmptyString(value.text) ??
|
||||
nonEmptyString(value.content) ??
|
||||
@@ -102,14 +102,19 @@ export const ToolShellOutputSchema = z.union([
|
||||
nonEmptyString(value.structured_content?.content) ??
|
||||
nonEmptyString(value.result?.output) ??
|
||||
nonEmptyString(value.result?.text) ??
|
||||
nonEmptyString(value.result?.content),
|
||||
exitCode:
|
||||
value.exitCode ??
|
||||
value.exit_code ??
|
||||
value.metadata?.exitCode ??
|
||||
value.metadata?.exit_code ??
|
||||
undefined,
|
||||
})),
|
||||
nonEmptyString(value.result?.content);
|
||||
|
||||
return {
|
||||
command: nonEmptyString(value.command) ?? nonEmptyString(value.result?.command),
|
||||
output: extractCodexShellOutput(rawOutput),
|
||||
exitCode:
|
||||
value.exitCode ??
|
||||
value.exit_code ??
|
||||
value.metadata?.exitCode ??
|
||||
value.metadata?.exit_code ??
|
||||
undefined,
|
||||
};
|
||||
}),
|
||||
]);
|
||||
|
||||
export const ToolPathInputSchema = z.union([
|
||||
|
||||
@@ -21,6 +21,70 @@ export function commandFromValue(value: unknown): string | undefined {
|
||||
return tokens.length > 0 ? tokens.join(" ") : undefined;
|
||||
}
|
||||
|
||||
const CODEX_SHELL_ENVELOPE_HEADER_LINES = new Set([
|
||||
"chunk id:",
|
||||
"wall time:",
|
||||
"process exited with code",
|
||||
"original token count:",
|
||||
]);
|
||||
|
||||
function isCodexShellEnvelopeHeaderLine(line: string): boolean {
|
||||
const normalized = line.trim().toLowerCase();
|
||||
for (const prefix of CODEX_SHELL_ENVELOPE_HEADER_LINES) {
|
||||
if (normalized.startsWith(prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function looksLikeCodexShellEnvelope(lines: string[]): boolean {
|
||||
if (lines.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const first = lines[0]?.trim().toLowerCase() ?? "";
|
||||
if (!first.startsWith("chunk id:")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const headerWindow = lines.slice(0, 8).map((line) => line.trim().toLowerCase());
|
||||
const hasWallTime = headerWindow.some((line) => line.startsWith("wall time:"));
|
||||
const hasExitCode = headerWindow.some((line) => line.startsWith("process exited with code"));
|
||||
return hasWallTime && hasExitCode;
|
||||
}
|
||||
|
||||
export function extractCodexShellOutput(value: string | undefined): string | undefined {
|
||||
const text = nonEmptyString(value);
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = text.replace(/\r\n/g, "\n");
|
||||
const lines = normalized.split("\n");
|
||||
if (!looksLikeCodexShellEnvelope(lines)) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const outputLineIndex = lines.findIndex((line) => line.trim() === "Output:");
|
||||
if (outputLineIndex >= 0) {
|
||||
return nonEmptyString(lines.slice(outputLineIndex + 1).join("\n"));
|
||||
}
|
||||
|
||||
let firstBodyLineIndex = -1;
|
||||
for (let index = 1; index < lines.length; index += 1) {
|
||||
const line = lines[index] ?? "";
|
||||
if (!isCodexShellEnvelopeHeaderLine(line)) {
|
||||
firstBodyLineIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (firstBodyLineIndex === -1) {
|
||||
return undefined;
|
||||
}
|
||||
return nonEmptyString(lines.slice(firstBodyLineIndex).join("\n"));
|
||||
}
|
||||
|
||||
export function flattenReadContent<Chunk extends ReadChunkLike>(
|
||||
value: string | Chunk | Chunk[] | undefined
|
||||
): string | undefined {
|
||||
|
||||
@@ -770,6 +770,110 @@ describe("daemon client E2E", () => {
|
||||
90_000
|
||||
);
|
||||
|
||||
speechTest(
|
||||
"voice mode flushes buffered audio after inactivity when isLast is missing",
|
||||
async () => {
|
||||
const voiceCwd = tmpCwd();
|
||||
const voiceAgent = await ctx.client.createAgent({
|
||||
config: {
|
||||
...getFullAccessConfig("codex"),
|
||||
cwd: voiceCwd,
|
||||
},
|
||||
});
|
||||
await ctx.client.setVoiceMode(true, voiceAgent.id);
|
||||
|
||||
const transcription = waitForSignal(40_000, (resolve) => {
|
||||
const unsubscribe = ctx.client.on("transcription_result", (message) => {
|
||||
if (message.type !== "transcription_result") {
|
||||
return;
|
||||
}
|
||||
resolve(message.payload);
|
||||
});
|
||||
return unsubscribe;
|
||||
});
|
||||
|
||||
const errorSignal = waitForSignal(40_000, (resolve) => {
|
||||
const unsubscribeStatus = ctx.client.on("status", (message) => {
|
||||
if (message.type !== "status") {
|
||||
return;
|
||||
}
|
||||
if (message.payload.status !== "error") {
|
||||
return;
|
||||
}
|
||||
resolve(`status:error ${message.payload.message}`);
|
||||
});
|
||||
|
||||
const unsubscribeLog = ctx.client.on("activity_log", (message) => {
|
||||
if (message.type !== "activity_log") {
|
||||
return;
|
||||
}
|
||||
if (message.payload.type !== "error") {
|
||||
return;
|
||||
}
|
||||
resolve(`activity_log:error ${message.payload.content}`);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeStatus();
|
||||
unsubscribeLog();
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
const fixturePath = path.resolve(
|
||||
process.cwd(),
|
||||
"..",
|
||||
"app",
|
||||
"e2e",
|
||||
"fixtures",
|
||||
"recording.wav"
|
||||
);
|
||||
const wav = await import("node:fs/promises").then((fs) => fs.readFile(fixturePath));
|
||||
const { sampleRate, pcm16 } = parsePcm16MonoWav(wav);
|
||||
expect(sampleRate).toBe(16000);
|
||||
|
||||
const format = "audio/pcm;rate=16000;bits=16";
|
||||
const chunkBytes = 3200; // 100ms @ 16kHz mono PCM16
|
||||
const maxChunksWithoutLast = 25;
|
||||
|
||||
let sentChunks = 0;
|
||||
for (
|
||||
let offset = 0;
|
||||
offset < pcm16.length && sentChunks < maxChunksWithoutLast;
|
||||
offset += chunkBytes
|
||||
) {
|
||||
const chunk = pcm16.subarray(offset, Math.min(pcm16.length, offset + chunkBytes));
|
||||
await ctx.client.sendVoiceAudioChunk(chunk.toString("base64"), format, false);
|
||||
sentChunks += 1;
|
||||
}
|
||||
|
||||
const outcome = await Promise.race([
|
||||
transcription.then((payload) => ({ kind: "ok" as const, payload })),
|
||||
errorSignal.then((error) => ({ kind: "error" as const, error })),
|
||||
]);
|
||||
|
||||
if (outcome.kind === "error") {
|
||||
throw new Error(outcome.error);
|
||||
}
|
||||
|
||||
expect(typeof outcome.payload.text).toBe("string");
|
||||
if (outcome.payload.byteLength !== undefined) {
|
||||
expect(outcome.payload.byteLength).toBeGreaterThan(0);
|
||||
}
|
||||
if (outcome.payload.text.trim().length > 0) {
|
||||
expect(outcome.payload.text.trim().length).toBeGreaterThan(1);
|
||||
} else {
|
||||
expect(outcome.payload.isLowConfidence).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
await Promise.allSettled([transcription, errorSignal]);
|
||||
await ctx.client.setVoiceMode(false);
|
||||
rmSync(voiceCwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
90_000
|
||||
);
|
||||
|
||||
speechTest(
|
||||
"streams dictation PCM and returns final transcript",
|
||||
async () => {
|
||||
|
||||
@@ -31,6 +31,7 @@ import { maybePersistTtsDebugAudio } from "./agent/tts-debug.js";
|
||||
import { isPaseoDictationDebugEnabled } from "./agent/recordings-debug.js";
|
||||
import {
|
||||
DictationStreamManager,
|
||||
type DictationStreamOutboundMessage,
|
||||
} from "./dictation/dictation-stream-manager.js";
|
||||
import {
|
||||
buildConfigOverrides,
|
||||
@@ -241,6 +242,8 @@ const MIN_STREAMING_SEGMENT_DURATION_MS = 1000;
|
||||
const MIN_STREAMING_SEGMENT_BYTES = Math.round(
|
||||
PCM_BYTES_PER_MS * MIN_STREAMING_SEGMENT_DURATION_MS
|
||||
);
|
||||
const VOICE_MODE_INACTIVITY_FLUSH_MS = 4500;
|
||||
const VOICE_INTERNAL_DICTATION_ID_PREFIX = "__voice_turn__:";
|
||||
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/;
|
||||
const AgentIdSchema = z.string().uuid();
|
||||
const VOICE_MCP_SERVER_NAME = "paseo_voice";
|
||||
@@ -257,6 +260,18 @@ interface AudioBufferState {
|
||||
totalPCMBytes: number;
|
||||
}
|
||||
|
||||
type VoiceTranscriptionResultPayload = {
|
||||
text: string;
|
||||
requestId: string;
|
||||
language?: string;
|
||||
duration?: number;
|
||||
avgLogprob?: number;
|
||||
isLowConfidence?: boolean;
|
||||
byteLength?: number;
|
||||
format?: string;
|
||||
debugRecordingPath?: string;
|
||||
};
|
||||
|
||||
export type SessionOptions = {
|
||||
clientId: string;
|
||||
onMessage: (msg: SessionOutboundMessage) => void;
|
||||
@@ -384,11 +399,25 @@ export class Session {
|
||||
private speechInProgress = false;
|
||||
|
||||
private readonly dictationStreamManager: DictationStreamManager;
|
||||
private readonly voiceStreamManager: DictationStreamManager;
|
||||
|
||||
// Audio buffering for interruption handling
|
||||
private pendingAudioSegments: Array<{ audio: Buffer; format: string }> = [];
|
||||
private bufferTimeout: NodeJS.Timeout | null = null;
|
||||
private voiceModeInactivityTimeout: NodeJS.Timeout | null = null;
|
||||
private audioBuffer: AudioBufferState | null = null;
|
||||
private activeVoiceDictationId: string | null = null;
|
||||
private activeVoiceDictationFormat: string | null = null;
|
||||
private activeVoiceDictationNextSeq = 0;
|
||||
private activeVoiceDictationStartPromise: Promise<void> | null = null;
|
||||
private activeVoiceDictationFinalizePromise: Promise<void> | null = null;
|
||||
private activeVoiceDictationResultPromise:
|
||||
| Promise<{ text: string; debugRecordingPath?: string }>
|
||||
| null = null;
|
||||
private activeVoiceDictationResolve:
|
||||
| ((value: { text: string; debugRecordingPath?: string }) => void)
|
||||
| null = null;
|
||||
private activeVoiceDictationReject: ((error: Error) => void) | null = null;
|
||||
|
||||
// Optional TTS debug capture (persisted per utterance)
|
||||
private readonly ttsDebugStreams = new Map<
|
||||
@@ -511,10 +540,17 @@ export class Session {
|
||||
this.dictationStreamManager = new DictationStreamManager({
|
||||
logger: this.sessionLogger,
|
||||
sessionId: this.sessionId,
|
||||
emit: (msg) => this.emit(msg as unknown as SessionOutboundMessage),
|
||||
emit: (msg) => this.handleDictationManagerMessage(msg),
|
||||
stt: dictation?.stt ?? null,
|
||||
finalTimeoutMs: dictation?.finalTimeoutMs,
|
||||
});
|
||||
this.voiceStreamManager = new DictationStreamManager({
|
||||
logger: this.sessionLogger.child({ stream: "voice-internal" }),
|
||||
sessionId: this.sessionId,
|
||||
emit: (msg) => this.handleDictationManagerMessage(msg),
|
||||
stt: stt,
|
||||
finalTimeoutMs: dictation?.finalTimeoutMs,
|
||||
});
|
||||
|
||||
// Initialize agent MCP client asynchronously
|
||||
void this.initializeAgentMcp();
|
||||
@@ -1573,6 +1609,9 @@ export class Session {
|
||||
private async disableVoiceModeForActiveAgent(
|
||||
restoreAgentConfig: boolean
|
||||
): Promise<void> {
|
||||
this.clearVoiceModeInactivityTimeout();
|
||||
this.cancelActiveVoiceDictationStream("voice mode disabled");
|
||||
|
||||
const agentId = this.voiceModeAgentId;
|
||||
if (!agentId) {
|
||||
this.voiceModeBaseConfig = null;
|
||||
@@ -1607,6 +1646,234 @@ export class Session {
|
||||
this.voiceModeAgentId = null;
|
||||
}
|
||||
|
||||
private isInternalVoiceDictationId(dictationId: string): boolean {
|
||||
return dictationId.startsWith(VOICE_INTERNAL_DICTATION_ID_PREFIX);
|
||||
}
|
||||
|
||||
private handleDictationManagerMessage(msg: DictationStreamOutboundMessage): void {
|
||||
if (msg.type === "activity_log") {
|
||||
const metadata = msg.payload.metadata as { dictationId?: unknown } | undefined;
|
||||
const dictationId =
|
||||
metadata && typeof metadata.dictationId === "string" ? metadata.dictationId : null;
|
||||
if (dictationId && this.isInternalVoiceDictationId(dictationId)) {
|
||||
return;
|
||||
}
|
||||
this.emit(msg as unknown as SessionOutboundMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const payloadWithDictationId = msg.payload as { dictationId?: unknown };
|
||||
const dictationId =
|
||||
payloadWithDictationId && typeof payloadWithDictationId.dictationId === "string"
|
||||
? payloadWithDictationId.dictationId
|
||||
: null;
|
||||
|
||||
if (!dictationId || !this.isInternalVoiceDictationId(dictationId)) {
|
||||
this.emit(msg as unknown as SessionOutboundMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "dictation_stream_final") {
|
||||
if (dictationId !== this.activeVoiceDictationId || !this.activeVoiceDictationResolve) {
|
||||
return;
|
||||
}
|
||||
this.activeVoiceDictationResolve({
|
||||
text: msg.payload.text,
|
||||
...(msg.payload.debugRecordingPath
|
||||
? { debugRecordingPath: msg.payload.debugRecordingPath }
|
||||
: {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "dictation_stream_error") {
|
||||
if (dictationId !== this.activeVoiceDictationId || !this.activeVoiceDictationReject) {
|
||||
return;
|
||||
}
|
||||
this.activeVoiceDictationReject(new Error(msg.payload.error));
|
||||
return;
|
||||
}
|
||||
|
||||
// Ack/partial messages for internal voice dictation are consumed server-side.
|
||||
}
|
||||
|
||||
private resetActiveVoiceDictationState(): void {
|
||||
this.activeVoiceDictationId = null;
|
||||
this.activeVoiceDictationFormat = null;
|
||||
this.activeVoiceDictationNextSeq = 0;
|
||||
this.activeVoiceDictationStartPromise = null;
|
||||
this.activeVoiceDictationFinalizePromise = null;
|
||||
this.activeVoiceDictationResultPromise = null;
|
||||
this.activeVoiceDictationResolve = null;
|
||||
this.activeVoiceDictationReject = null;
|
||||
}
|
||||
|
||||
private cancelActiveVoiceDictationStream(reason: string): void {
|
||||
const dictationId = this.activeVoiceDictationId;
|
||||
if (!dictationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.sessionLogger.debug({ dictationId, reason }, "Cancelling active internal voice dictation stream");
|
||||
if (this.activeVoiceDictationReject) {
|
||||
this.activeVoiceDictationReject(new Error(`Voice dictation cancelled: ${reason}`));
|
||||
}
|
||||
this.voiceStreamManager.handleCancel(dictationId);
|
||||
this.resetActiveVoiceDictationState();
|
||||
}
|
||||
|
||||
private async ensureActiveVoiceDictationStream(format: string): Promise<void> {
|
||||
if (this.activeVoiceDictationId && this.activeVoiceDictationFormat === format) {
|
||||
if (this.activeVoiceDictationStartPromise) {
|
||||
await this.activeVoiceDictationStartPromise;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.activeVoiceDictationId) {
|
||||
await this.finalizeActiveVoiceDictationStream("voice format changed");
|
||||
}
|
||||
|
||||
const dictationId = `${VOICE_INTERNAL_DICTATION_ID_PREFIX}${uuidv4()}`;
|
||||
let resolve:
|
||||
| ((value: { text: string; debugRecordingPath?: string }) => void)
|
||||
| null = null;
|
||||
let reject: ((error: Error) => void) | null = null;
|
||||
const resultPromise = new Promise<{ text: string; debugRecordingPath?: string }>(
|
||||
(resolveFn, rejectFn) => {
|
||||
resolve = resolveFn;
|
||||
reject = rejectFn;
|
||||
}
|
||||
);
|
||||
// Prevent process-level unhandled rejection warnings when cancellation races are resolved later.
|
||||
void resultPromise.catch(() => undefined);
|
||||
|
||||
this.activeVoiceDictationId = dictationId;
|
||||
this.activeVoiceDictationFormat = format;
|
||||
this.activeVoiceDictationNextSeq = 0;
|
||||
this.activeVoiceDictationFinalizePromise = null;
|
||||
this.activeVoiceDictationResultPromise = resultPromise;
|
||||
this.activeVoiceDictationResolve = resolve;
|
||||
this.activeVoiceDictationReject = reject;
|
||||
this.setPhase("transcribing");
|
||||
this.emit({
|
||||
type: "activity_log",
|
||||
payload: {
|
||||
id: uuidv4(),
|
||||
timestamp: new Date(),
|
||||
type: "system",
|
||||
content: "Transcribing audio...",
|
||||
},
|
||||
});
|
||||
|
||||
const startPromise = this.voiceStreamManager.handleStart(dictationId, format);
|
||||
this.activeVoiceDictationStartPromise = startPromise;
|
||||
try {
|
||||
await startPromise;
|
||||
} catch (error) {
|
||||
this.resetActiveVoiceDictationState();
|
||||
throw error;
|
||||
} finally {
|
||||
if (this.activeVoiceDictationId === dictationId) {
|
||||
this.activeVoiceDictationStartPromise = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async appendToActiveVoiceDictationStream(
|
||||
audioBase64: string,
|
||||
format: string
|
||||
): Promise<void> {
|
||||
if (this.activeVoiceDictationFinalizePromise) {
|
||||
await this.activeVoiceDictationFinalizePromise.catch(() => undefined);
|
||||
}
|
||||
await this.ensureActiveVoiceDictationStream(format);
|
||||
const dictationId = this.activeVoiceDictationId;
|
||||
if (!dictationId) {
|
||||
throw new Error("Voice dictation stream did not initialize");
|
||||
}
|
||||
|
||||
const seq = this.activeVoiceDictationNextSeq;
|
||||
this.activeVoiceDictationNextSeq += 1;
|
||||
await this.voiceStreamManager.handleChunk({
|
||||
dictationId,
|
||||
seq,
|
||||
audioBase64,
|
||||
format,
|
||||
});
|
||||
}
|
||||
|
||||
private async finalizeActiveVoiceDictationStream(reason: string): Promise<void> {
|
||||
const dictationId = this.activeVoiceDictationId;
|
||||
if (!dictationId) {
|
||||
return;
|
||||
}
|
||||
this.clearVoiceModeInactivityTimeout();
|
||||
if (this.activeVoiceDictationStartPromise) {
|
||||
await this.activeVoiceDictationStartPromise;
|
||||
}
|
||||
|
||||
if (this.activeVoiceDictationFinalizePromise) {
|
||||
await this.activeVoiceDictationFinalizePromise;
|
||||
return;
|
||||
}
|
||||
|
||||
const finalSeq = this.activeVoiceDictationNextSeq - 1;
|
||||
const resultPromise = this.activeVoiceDictationResultPromise;
|
||||
if (!resultPromise) {
|
||||
this.resetActiveVoiceDictationState();
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeVoiceDictationFinalizePromise = (async () => {
|
||||
this.sessionLogger.debug(
|
||||
{ dictationId, finalSeq, reason },
|
||||
"Finalizing internal voice dictation stream"
|
||||
);
|
||||
await this.voiceStreamManager.handleFinish(dictationId, finalSeq);
|
||||
const result = await resultPromise;
|
||||
this.resetActiveVoiceDictationState();
|
||||
const requestId = uuidv4();
|
||||
const transcriptText = result.text.trim();
|
||||
this.sessionLogger.info(
|
||||
{
|
||||
requestId,
|
||||
isVoiceMode: this.isVoiceMode,
|
||||
transcriptLength: transcriptText.length,
|
||||
transcript: transcriptText,
|
||||
},
|
||||
"Transcription result"
|
||||
);
|
||||
await this.handleTranscriptionResultPayload({
|
||||
text: result.text,
|
||||
requestId,
|
||||
...(result.debugRecordingPath
|
||||
? { debugRecordingPath: result.debugRecordingPath, format: "audio/wav" }
|
||||
: {}),
|
||||
});
|
||||
})();
|
||||
|
||||
try {
|
||||
await this.activeVoiceDictationFinalizePromise;
|
||||
} catch (error) {
|
||||
this.resetActiveVoiceDictationState();
|
||||
this.setPhase("idle");
|
||||
this.clearSpeechInProgress("transcription error");
|
||||
this.emit({
|
||||
type: "activity_log",
|
||||
payload: {
|
||||
id: uuidv4(),
|
||||
timestamp: new Date(),
|
||||
type: "error",
|
||||
content: `Transcription error: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle text message to agent (with optional image attachments)
|
||||
*/
|
||||
@@ -4553,8 +4820,23 @@ export class Session {
|
||||
|
||||
await this.handleVoiceSpeechStart();
|
||||
|
||||
const chunkBuffer = Buffer.from(msg.audio, "base64");
|
||||
const chunkFormat = msg.format || "audio/wav";
|
||||
|
||||
if (this.isVoiceMode) {
|
||||
await this.appendToActiveVoiceDictationStream(msg.audio, chunkFormat);
|
||||
if (!msg.isLast) {
|
||||
this.setVoiceModeInactivityTimeout();
|
||||
this.sessionLogger.debug("Voice mode: streaming chunk, waiting for speech end");
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearVoiceModeInactivityTimeout();
|
||||
this.sessionLogger.debug("Voice mode: speech ended, finalizing streaming transcription");
|
||||
await this.finalizeActiveVoiceDictationStream("speech ended");
|
||||
return;
|
||||
}
|
||||
|
||||
const chunkBuffer = Buffer.from(msg.audio, "base64");
|
||||
const isPCMChunk = chunkFormat.toLowerCase().includes("pcm");
|
||||
|
||||
if (!this.audioBuffer) {
|
||||
@@ -4592,16 +4874,6 @@ export class Session {
|
||||
this.audioBuffer.totalPCMBytes += chunkBuffer.length;
|
||||
}
|
||||
|
||||
// In voice mode, only process audio when the user has finished speaking (isLast = true)
|
||||
// This prevents partial transcriptions from being sent to the LLM
|
||||
if (this.isVoiceMode) {
|
||||
if (!msg.isLast) {
|
||||
this.sessionLogger.debug("Voice mode: buffering audio, waiting for speech end");
|
||||
return;
|
||||
}
|
||||
this.sessionLogger.debug("Voice mode: speech ended, processing complete audio");
|
||||
}
|
||||
|
||||
// In non-voice mode, use streaming threshold to process chunks
|
||||
const reachedStreamingThreshold =
|
||||
!this.isVoiceMode &&
|
||||
@@ -4747,85 +5019,17 @@ export class Session {
|
||||
"Transcription result"
|
||||
);
|
||||
|
||||
// Emit transcription result
|
||||
this.emit({
|
||||
type: "transcription_result",
|
||||
payload: {
|
||||
text: result.text,
|
||||
language: result.language,
|
||||
duration: result.duration,
|
||||
requestId,
|
||||
avgLogprob: result.avgLogprob,
|
||||
isLowConfidence: result.isLowConfidence,
|
||||
byteLength: result.byteLength,
|
||||
format: result.format,
|
||||
debugRecordingPath: result.debugRecordingPath,
|
||||
},
|
||||
await this.handleTranscriptionResultPayload({
|
||||
text: result.text,
|
||||
language: result.language,
|
||||
duration: result.duration,
|
||||
requestId,
|
||||
avgLogprob: result.avgLogprob,
|
||||
isLowConfidence: result.isLowConfidence,
|
||||
byteLength: result.byteLength,
|
||||
format: result.format,
|
||||
debugRecordingPath: result.debugRecordingPath,
|
||||
});
|
||||
|
||||
if (!transcriptText) {
|
||||
this.sessionLogger.debug("Empty transcription (false positive), not aborting");
|
||||
this.setPhase("idle");
|
||||
this.clearSpeechInProgress("empty transcription");
|
||||
return;
|
||||
}
|
||||
|
||||
// Has content - abort any in-progress stream now
|
||||
this.createAbortController();
|
||||
|
||||
// Wait for aborted stream to finish cleanup (save partial response)
|
||||
if (this.currentStreamPromise) {
|
||||
this.sessionLogger.debug("Waiting for aborted stream to finish cleanup");
|
||||
await this.currentStreamPromise;
|
||||
}
|
||||
|
||||
if (result.debugRecordingPath) {
|
||||
this.emit({
|
||||
type: "activity_log",
|
||||
payload: {
|
||||
id: uuidv4(),
|
||||
timestamp: new Date(),
|
||||
type: "system",
|
||||
content: `Saved input audio: ${result.debugRecordingPath}`,
|
||||
metadata: {
|
||||
recordingPath: result.debugRecordingPath,
|
||||
format: result.format,
|
||||
requestId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Emit activity log
|
||||
this.emit({
|
||||
type: "activity_log",
|
||||
payload: {
|
||||
id: uuidv4(),
|
||||
timestamp: new Date(),
|
||||
type: "transcript",
|
||||
content: result.text,
|
||||
metadata: {
|
||||
language: result.language,
|
||||
duration: result.duration,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.clearSpeechInProgress("transcription complete");
|
||||
if (!this.isVoiceMode) {
|
||||
this.sessionLogger.debug(
|
||||
{ requestId },
|
||||
"Skipping voice agent processing because voice mode is disabled"
|
||||
);
|
||||
this.setPhase("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
// Set phase to LLM and process (TTS enabled in voice mode for voice agents)
|
||||
this.setPhase("llm");
|
||||
this.currentStreamPromise = this.processVoiceTurn(result.text);
|
||||
await this.currentStreamPromise;
|
||||
this.setPhase("idle");
|
||||
} catch (error: any) {
|
||||
this.setPhase("idle");
|
||||
this.clearSpeechInProgress("transcription error");
|
||||
@@ -4842,6 +5046,89 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleTranscriptionResultPayload(
|
||||
result: VoiceTranscriptionResultPayload
|
||||
): Promise<void> {
|
||||
const transcriptText = result.text.trim();
|
||||
|
||||
this.emit({
|
||||
type: "transcription_result",
|
||||
payload: {
|
||||
text: result.text,
|
||||
...(result.language ? { language: result.language } : {}),
|
||||
...(result.duration !== undefined ? { duration: result.duration } : {}),
|
||||
requestId: result.requestId,
|
||||
...(result.avgLogprob !== undefined ? { avgLogprob: result.avgLogprob } : {}),
|
||||
...(result.isLowConfidence !== undefined ? { isLowConfidence: result.isLowConfidence } : {}),
|
||||
...(result.byteLength !== undefined ? { byteLength: result.byteLength } : {}),
|
||||
...(result.format ? { format: result.format } : {}),
|
||||
...(result.debugRecordingPath ? { debugRecordingPath: result.debugRecordingPath } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!transcriptText) {
|
||||
this.sessionLogger.debug("Empty transcription (false positive), not aborting");
|
||||
this.setPhase("idle");
|
||||
this.clearSpeechInProgress("empty transcription");
|
||||
return;
|
||||
}
|
||||
|
||||
// Has content - abort any in-progress stream now
|
||||
this.createAbortController();
|
||||
|
||||
// Wait for aborted stream to finish cleanup (save partial response)
|
||||
if (this.currentStreamPromise) {
|
||||
this.sessionLogger.debug("Waiting for aborted stream to finish cleanup");
|
||||
await this.currentStreamPromise;
|
||||
}
|
||||
|
||||
if (result.debugRecordingPath) {
|
||||
this.emit({
|
||||
type: "activity_log",
|
||||
payload: {
|
||||
id: uuidv4(),
|
||||
timestamp: new Date(),
|
||||
type: "system",
|
||||
content: `Saved input audio: ${result.debugRecordingPath}`,
|
||||
metadata: {
|
||||
recordingPath: result.debugRecordingPath,
|
||||
...(result.format ? { format: result.format } : {}),
|
||||
requestId: result.requestId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this.emit({
|
||||
type: "activity_log",
|
||||
payload: {
|
||||
id: uuidv4(),
|
||||
timestamp: new Date(),
|
||||
type: "transcript",
|
||||
content: result.text,
|
||||
metadata: {
|
||||
...(result.language ? { language: result.language } : {}),
|
||||
...(result.duration !== undefined ? { duration: result.duration } : {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.clearSpeechInProgress("transcription complete");
|
||||
if (!this.isVoiceMode) {
|
||||
this.sessionLogger.debug(
|
||||
{ requestId: result.requestId },
|
||||
"Skipping voice agent processing because voice mode is disabled"
|
||||
);
|
||||
this.setPhase("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
this.setPhase("llm");
|
||||
this.currentStreamPromise = this.processVoiceTurn(result.text);
|
||||
await this.currentStreamPromise;
|
||||
this.setPhase("idle");
|
||||
}
|
||||
|
||||
private registerVoiceBridgeForAgent(agentId: string): void {
|
||||
this.registerVoiceSpeakHandler?.(agentId, async ({ text, signal }) => {
|
||||
this.sessionLogger.info(
|
||||
@@ -5061,6 +5348,8 @@ export class Session {
|
||||
this.audioBuffer = null;
|
||||
}
|
||||
|
||||
this.cancelActiveVoiceDictationStream("new speech turn started");
|
||||
this.clearVoiceModeInactivityTimeout();
|
||||
this.clearBufferTimeout();
|
||||
|
||||
this.abortController.abort();
|
||||
@@ -5126,6 +5415,43 @@ export class Session {
|
||||
}, 10000); // 10 second timeout
|
||||
}
|
||||
|
||||
private setVoiceModeInactivityTimeout(): void {
|
||||
if (!this.isVoiceMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearVoiceModeInactivityTimeout();
|
||||
this.voiceModeInactivityTimeout = setTimeout(() => {
|
||||
this.voiceModeInactivityTimeout = null;
|
||||
if (!this.isVoiceMode || !this.activeVoiceDictationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.sessionLogger.warn(
|
||||
{
|
||||
timeoutMs: VOICE_MODE_INACTIVITY_FLUSH_MS,
|
||||
dictationId: this.activeVoiceDictationId,
|
||||
nextSeq: this.activeVoiceDictationNextSeq,
|
||||
},
|
||||
"Voice mode inactivity timeout reached without isLast; finalizing active voice dictation stream"
|
||||
);
|
||||
|
||||
void this.finalizeActiveVoiceDictationStream("inactivity timeout").catch((error) => {
|
||||
this.sessionLogger.error(
|
||||
{ err: error },
|
||||
"Failed to finalize voice dictation stream after inactivity timeout"
|
||||
);
|
||||
});
|
||||
}, VOICE_MODE_INACTIVITY_FLUSH_MS);
|
||||
}
|
||||
|
||||
private clearVoiceModeInactivityTimeout(): void {
|
||||
if (this.voiceModeInactivityTimeout) {
|
||||
clearTimeout(this.voiceModeInactivityTimeout);
|
||||
this.voiceModeInactivityTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear buffer timeout
|
||||
*/
|
||||
@@ -5206,15 +5532,18 @@ export class Session {
|
||||
this.abortController.abort();
|
||||
|
||||
// Clear timeouts
|
||||
this.clearVoiceModeInactivityTimeout();
|
||||
this.clearBufferTimeout();
|
||||
|
||||
// Clear buffers
|
||||
this.cancelActiveVoiceDictationStream("session cleanup");
|
||||
this.pendingAudioSegments = [];
|
||||
this.audioBuffer = null;
|
||||
|
||||
// Cleanup managers
|
||||
this.ttsManager.cleanup();
|
||||
this.sttManager.cleanup();
|
||||
this.voiceStreamManager.cleanupAll();
|
||||
this.dictationStreamManager.cleanupAll();
|
||||
|
||||
// Close MCP clients
|
||||
|
||||
@@ -3,6 +3,14 @@ import { tmpdir } from "os";
|
||||
import path from "path";
|
||||
import { seedClaudeAuth } from "./claude-auth.js";
|
||||
|
||||
function isIgnorableCleanupError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
return code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM";
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up an isolated Claude config directory for testing.
|
||||
* Creates a temp directory with:
|
||||
@@ -39,6 +47,12 @@ export function useTempClaudeConfigDir(): () => void {
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = previousConfigDir;
|
||||
}
|
||||
rmSync(configDir, { recursive: true, force: true });
|
||||
try {
|
||||
rmSync(configDir, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
if (!isIgnorableCleanupError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user