@@ -2,11 +2,14 @@ import { describe, expect, test } from "vitest";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { once } from "node:events";
|
||||
|
||||
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { agentConfigs } from "../../daemon-e2e/agent-configs.js";
|
||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
|
||||
const CODEX_TEST_MODEL = agentConfigs.codex.model;
|
||||
const CODEX_TEST_THINKING_OPTION_ID = agentConfigs.codex.thinkingOptionId;
|
||||
@@ -20,6 +23,192 @@ function isCodexInstalled(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function sse(events: unknown[]): string {
|
||||
return events
|
||||
.map((event) => {
|
||||
const type =
|
||||
typeof event === "object" &&
|
||||
event !== null &&
|
||||
"type" in event &&
|
||||
typeof (event as { type?: unknown }).type === "string"
|
||||
? (event as { type: string }).type
|
||||
: "message";
|
||||
return `event: ${type}\ndata: ${JSON.stringify(event)}\n\n`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function responseCreated(id: string): Record<string, unknown> {
|
||||
return {
|
||||
type: "response.created",
|
||||
response: { id },
|
||||
};
|
||||
}
|
||||
|
||||
function responseCompleted(id: string): Record<string, unknown> {
|
||||
return {
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id,
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
input_tokens_details: null,
|
||||
output_tokens: 0,
|
||||
output_tokens_details: null,
|
||||
total_tokens: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function functionCallEvent(callId: string, name: string, argumentsJson: string): Record<string, unknown> {
|
||||
return {
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
call_id: callId,
|
||||
name,
|
||||
arguments: argumentsJson,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assistantMessageEvent(id: string, text: string): Record<string, unknown> {
|
||||
return {
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
id,
|
||||
content: [{ type: "output_text", text }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function requestUserInputSse(callId: string): string {
|
||||
return sse([
|
||||
responseCreated("resp-1"),
|
||||
functionCallEvent(
|
||||
callId,
|
||||
"request_user_input",
|
||||
JSON.stringify({
|
||||
questions: [
|
||||
{
|
||||
id: "confirm_path",
|
||||
header: "Confirm",
|
||||
question: "Proceed with the plan?",
|
||||
options: [
|
||||
{
|
||||
label: "Yes (Recommended)",
|
||||
description: "Continue the current plan.",
|
||||
},
|
||||
{
|
||||
label: "No",
|
||||
description: "Stop and revisit the approach.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
responseCompleted("resp-1"),
|
||||
]);
|
||||
}
|
||||
|
||||
function assistantMessageSse(text: string): string {
|
||||
return sse([responseCreated("resp-2"), assistantMessageEvent("msg-1", text), responseCompleted("resp-2")]);
|
||||
}
|
||||
|
||||
async function startMockResponsesServer(sequence: string[]): Promise<{
|
||||
url: string;
|
||||
close: () => Promise<void>;
|
||||
requestBodies: string[];
|
||||
}> {
|
||||
const requestBodies: string[] = [];
|
||||
let index = 0;
|
||||
const server = createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
req.on("end", () => {
|
||||
requestBodies.push(Buffer.concat(chunks).toString("utf8"));
|
||||
if (req.method !== "POST" || req.url !== "/v1/responses") {
|
||||
res.statusCode = 404;
|
||||
res.end("not found");
|
||||
return;
|
||||
}
|
||||
const body = sequence[index] ?? assistantMessageSse("done");
|
||||
index += 1;
|
||||
res.statusCode = 200;
|
||||
res.setHeader("content-type", "text/event-stream");
|
||||
res.setHeader("cache-control", "no-cache");
|
||||
res.end(body);
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Expected TCP address for mock responses server");
|
||||
}
|
||||
return {
|
||||
url: `http://127.0.0.1:${address.port}`,
|
||||
requestBodies,
|
||||
close: () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function writeMockCodexConfig(codexHome: string, serverUrl: string): void {
|
||||
writeFileSync(
|
||||
path.join(codexHome, "config.toml"),
|
||||
`
|
||||
model = "mock-model"
|
||||
approval_policy = "untrusted"
|
||||
sandbox_mode = "read-only"
|
||||
|
||||
model_provider = "mock_provider"
|
||||
|
||||
[model_providers.mock_provider]
|
||||
name = "Mock provider for test"
|
||||
base_url = "${serverUrl}/v1"
|
||||
wire_api = "responses"
|
||||
request_max_retries = 0
|
||||
stream_max_retries = 0
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
function waitForEvent<TEvent extends AgentStreamEvent>(params: {
|
||||
session: {
|
||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
|
||||
};
|
||||
predicate: (event: AgentStreamEvent) => event is TEvent;
|
||||
timeoutMs: number;
|
||||
label: string;
|
||||
}): Promise<TEvent> {
|
||||
return new Promise<TEvent>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
unsubscribe();
|
||||
reject(new Error(`Timed out waiting for ${params.label}`));
|
||||
}, params.timeoutMs);
|
||||
|
||||
const unsubscribe = params.session.subscribe((event) => {
|
||||
if (!params.predicate(event)) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
resolve(event);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("Codex app-server provider (e2e)", () => {
|
||||
test.runIf(isCodexInstalled())(
|
||||
"lists models and runs a simple prompt",
|
||||
@@ -43,4 +232,171 @@ describe("Codex app-server provider (e2e)", () => {
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
test.runIf(isCodexInstalled())(
|
||||
"surfaces request_user_input from the app-server as question permissions and timeline tool calls",
|
||||
async () => {
|
||||
const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-app-server-question-cwd-"));
|
||||
const codexHome = mkdtempSync(path.join(os.tmpdir(), "codex-app-server-question-home-"));
|
||||
const mockServer = await startMockResponsesServer([
|
||||
requestUserInputSse("call1"),
|
||||
assistantMessageSse("done"),
|
||||
]);
|
||||
|
||||
try {
|
||||
writeMockCodexConfig(codexHome, mockServer.url);
|
||||
|
||||
const client = new CodexAppServerAgentClient(createTestLogger());
|
||||
const session = await client.createSession(
|
||||
{
|
||||
provider: "codex",
|
||||
cwd,
|
||||
modeId: "auto",
|
||||
model: "mock-model",
|
||||
thinkingOptionId: "medium",
|
||||
},
|
||||
{
|
||||
env: {
|
||||
CODEX_HOME: codexHome,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
await session.setFeature?.("plan_mode", true);
|
||||
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => {
|
||||
events.push(event);
|
||||
});
|
||||
|
||||
const questionRequested = waitForEvent({
|
||||
session,
|
||||
timeoutMs: 15_000,
|
||||
label: "question permission request",
|
||||
predicate: (
|
||||
event,
|
||||
): event is Extract<
|
||||
AgentStreamEvent,
|
||||
{ type: "permission_requested" }
|
||||
> =>
|
||||
event.type === "permission_requested" &&
|
||||
event.request.provider === "codex" &&
|
||||
event.request.kind === "question" &&
|
||||
event.request.name === "request_user_input",
|
||||
});
|
||||
|
||||
const turnFinished = waitForEvent({
|
||||
session,
|
||||
timeoutMs: 15_000,
|
||||
label: "turn completion",
|
||||
predicate: (
|
||||
event,
|
||||
): event is Extract<
|
||||
AgentStreamEvent,
|
||||
{ type: "turn_completed" | "turn_failed" | "turn_canceled" }
|
||||
> =>
|
||||
event.type === "turn_completed" ||
|
||||
event.type === "turn_failed" ||
|
||||
event.type === "turn_canceled",
|
||||
});
|
||||
|
||||
await session.startTurn("ask something");
|
||||
|
||||
const permissionEvent = await questionRequested;
|
||||
expect(permissionEvent.request.input).toEqual({
|
||||
questions: [
|
||||
{
|
||||
id: "confirm_path",
|
||||
header: "Confirm",
|
||||
question: "Proceed with the plan?",
|
||||
isOther: true,
|
||||
options: [
|
||||
{
|
||||
label: "Yes (Recommended)",
|
||||
description: "Continue the current plan.",
|
||||
},
|
||||
{
|
||||
label: "No",
|
||||
description: "Stop and revisit the approach.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const runningQuestionCall = events.find(
|
||||
(event) =>
|
||||
event.type === "timeline" &&
|
||||
event.provider === "codex" &&
|
||||
event.item.type === "tool_call" &&
|
||||
event.item.name === "request_user_input" &&
|
||||
event.item.status === "running",
|
||||
);
|
||||
expect(runningQuestionCall).toBeDefined();
|
||||
|
||||
await session.respondToPermission(permissionEvent.request.id, {
|
||||
behavior: "allow",
|
||||
updatedInput: {
|
||||
answers: {
|
||||
Confirm: "Yes (Recommended)",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const terminalEvent = await turnFinished;
|
||||
expect(terminalEvent.type).toBe("turn_completed");
|
||||
|
||||
const completedQuestionCall = events.find(
|
||||
(event) =>
|
||||
event.type === "timeline" &&
|
||||
event.provider === "codex" &&
|
||||
event.item.type === "tool_call" &&
|
||||
event.item.name === "request_user_input" &&
|
||||
event.item.status === "completed",
|
||||
);
|
||||
expect(completedQuestionCall).toBeDefined();
|
||||
if (
|
||||
!completedQuestionCall ||
|
||||
completedQuestionCall.type !== "timeline" ||
|
||||
completedQuestionCall.item.type !== "tool_call"
|
||||
) {
|
||||
throw new Error("Expected completed request_user_input tool call");
|
||||
}
|
||||
expect(completedQuestionCall.item.metadata).toMatchObject({
|
||||
answers: {
|
||||
confirm_path: ["Yes (Recommended)"],
|
||||
},
|
||||
});
|
||||
expect(
|
||||
mockServer.requestBodies.some(
|
||||
(body) =>
|
||||
body.includes('"type":"function_call_output"') &&
|
||||
body.includes('"call_id":"call1"'),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const finalAssistantMessage = [...events]
|
||||
.reverse()
|
||||
.find((event) => event.type === "timeline" && event.item.type === "assistant_message");
|
||||
expect(finalAssistantMessage).toBeDefined();
|
||||
if (
|
||||
!finalAssistantMessage ||
|
||||
finalAssistantMessage.type !== "timeline" ||
|
||||
finalAssistantMessage.item.type !== "assistant_message"
|
||||
) {
|
||||
throw new Error("Expected final assistant message");
|
||||
}
|
||||
expect(finalAssistantMessage.item.text.trim()).toBe("done");
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
} finally {
|
||||
await mockServer.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
rmSync(codexHome, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { existsSync, rmSync } from "node:fs";
|
||||
|
||||
import type { AgentLaunchContext } from "../agent-sdk-types.js";
|
||||
import type { AgentLaunchContext, AgentSession, AgentSessionConfig, AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
import {
|
||||
__codexAppServerInternals,
|
||||
codexAppServerTurnInputFromPrompt,
|
||||
@@ -10,6 +10,32 @@ import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
|
||||
const ONE_BY_ONE_PNG_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X1r0AAAAASUVORK5CYII=";
|
||||
const CODEX_PROVIDER = "codex";
|
||||
|
||||
function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSessionConfig {
|
||||
return {
|
||||
provider: CODEX_PROVIDER,
|
||||
cwd: "/tmp/codex-question-test",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createSession(configOverrides: Partial<AgentSessionConfig> = {}) {
|
||||
const session = new __codexAppServerInternals.CodexAppServerAgentSession(
|
||||
createConfig(configOverrides),
|
||||
null,
|
||||
createTestLogger(),
|
||||
() => {
|
||||
throw new Error("Test session cannot spawn Codex app-server");
|
||||
},
|
||||
) as unknown as AgentSession & { [key: string]: unknown };
|
||||
session.connected = true;
|
||||
session.currentThreadId = "test-thread";
|
||||
session.activeForegroundTurnId = "test-turn";
|
||||
return session;
|
||||
}
|
||||
|
||||
describe("Codex app-server provider", () => {
|
||||
const logger = createTestLogger();
|
||||
@@ -138,4 +164,185 @@ describe("Codex app-server provider", () => {
|
||||
expect(env.PASEO_AGENT_ID).toBe(launchContext.env?.PASEO_AGENT_ID);
|
||||
expect(env.PASEO_TEST_FLAG).toBe(launchContext.env?.PASEO_TEST_FLAG);
|
||||
});
|
||||
|
||||
test("projects request_user_input into a question permission and running timeline tool call", () => {
|
||||
const session = createSession();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
void (session as any).handleToolApprovalRequest({
|
||||
itemId: "call-question-1",
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
questions: [
|
||||
{
|
||||
id: "favorite_drink",
|
||||
header: "Drink",
|
||||
question: "Which drink do you want?",
|
||||
options: [
|
||||
{ label: "Coffee", description: "Default" },
|
||||
{ label: "Tea" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
turnId: "test-turn",
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "call-question-1",
|
||||
name: "request_user_input",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
text: "Drink: Which drink do you want?\nOptions: Coffee, Tea",
|
||||
icon: "brain",
|
||||
},
|
||||
metadata: {
|
||||
questions: [
|
||||
{
|
||||
id: "favorite_drink",
|
||||
header: "Drink",
|
||||
question: "Which drink do you want?",
|
||||
options: [
|
||||
{ label: "Coffee", description: "Default" },
|
||||
{ label: "Tea" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "permission_requested",
|
||||
provider: "codex",
|
||||
turnId: "test-turn",
|
||||
request: {
|
||||
id: "permission-call-question-1",
|
||||
provider: "codex",
|
||||
name: "request_user_input",
|
||||
kind: "question",
|
||||
title: "Question",
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
text: "Drink: Which drink do you want?\nOptions: Coffee, Tea",
|
||||
icon: "brain",
|
||||
},
|
||||
input: {
|
||||
questions: [
|
||||
{
|
||||
id: "favorite_drink",
|
||||
header: "Drink",
|
||||
question: "Which drink do you want?",
|
||||
options: [
|
||||
{ label: "Coffee", description: "Default" },
|
||||
{ label: "Tea" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
metadata: {
|
||||
itemId: "call-question-1",
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
questions: [
|
||||
{
|
||||
id: "favorite_drink",
|
||||
header: "Drink",
|
||||
question: "Which drink do you want?",
|
||||
options: [
|
||||
{ label: "Coffee", description: "Default" },
|
||||
{ label: "Tea" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("maps question responses from headers back to question ids and completes the tool call", async () => {
|
||||
const session = createSession();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
const pendingResponse = (session as any).handleToolApprovalRequest({
|
||||
itemId: "call-question-2",
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
questions: [
|
||||
{
|
||||
id: "favorite_drink",
|
||||
header: "Drink",
|
||||
question: "Which drink do you want?",
|
||||
options: [{ label: "Coffee" }, { label: "Tea" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await session.respondToPermission("permission-call-question-2", {
|
||||
behavior: "allow",
|
||||
updatedInput: {
|
||||
answers: {
|
||||
Drink: "Tea",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(pendingResponse).resolves.toEqual({
|
||||
answers: {
|
||||
favorite_drink: { answers: ["Tea"] },
|
||||
},
|
||||
});
|
||||
expect(events.at(-2)).toEqual({
|
||||
type: "permission_resolved",
|
||||
provider: "codex",
|
||||
turnId: "test-turn",
|
||||
requestId: "permission-call-question-2",
|
||||
resolution: {
|
||||
behavior: "allow",
|
||||
updatedInput: {
|
||||
answers: {
|
||||
Drink: "Tea",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(events.at(-1)).toEqual({
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
turnId: "test-turn",
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "call-question-2",
|
||||
name: "request_user_input",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
text: "Drink: Which drink do you want?\nOptions: Coffee, Tea\n\nAnswers:\n\nfavorite_drink: Tea",
|
||||
icon: "brain",
|
||||
},
|
||||
metadata: {
|
||||
questions: [
|
||||
{
|
||||
id: "favorite_drink",
|
||||
header: "Drink",
|
||||
question: "Which drink do you want?",
|
||||
options: [{ label: "Coffee" }, { label: "Tea" }],
|
||||
},
|
||||
],
|
||||
answers: {
|
||||
favorite_drink: ["Tea"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -573,6 +573,17 @@ class CodexAppServerClient {
|
||||
this.child.stdin.write(`${JSON.stringify(payload)}\n`);
|
||||
}
|
||||
|
||||
private writeJsonRpcResponse(response: JsonRpcResponse): void {
|
||||
if (this.disposed || this.child.stdin.destroyed || !this.child.stdin.writable) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.child.stdin.write(`${JSON.stringify(response)}\n`);
|
||||
} catch (error) {
|
||||
this.logger.debug({ error }, "Failed to write Codex app-server JSON-RPC response");
|
||||
}
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
@@ -617,14 +628,12 @@ class CodexAppServerClient {
|
||||
const handler = this.requestHandlers.get(request.method);
|
||||
try {
|
||||
const result = handler ? await handler(request.params) : {};
|
||||
const response: JsonRpcResponse = { id: request.id, result };
|
||||
this.child.stdin.write(`${JSON.stringify(response)}\n`);
|
||||
this.writeJsonRpcResponse({ id: request.id, result });
|
||||
} catch (error) {
|
||||
const response: JsonRpcResponse = {
|
||||
this.writeJsonRpcResponse({
|
||||
id: request.id,
|
||||
error: { message: error instanceof Error ? error.message : String(error) },
|
||||
};
|
||||
this.child.stdin.write(`${JSON.stringify(response)}\n`);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -723,6 +732,187 @@ function mapCodexPlanToToolCall(params: { callId: string; text: string }): ToolC
|
||||
};
|
||||
}
|
||||
|
||||
type CodexQuestionOption = {
|
||||
label: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
type CodexQuestionPrompt = {
|
||||
id: string;
|
||||
header: string;
|
||||
question: string;
|
||||
options: CodexQuestionOption[];
|
||||
multiSelect?: boolean;
|
||||
isOther?: boolean;
|
||||
isSecret?: boolean;
|
||||
};
|
||||
|
||||
function normalizeCodexQuestionPrompts(raw: unknown): CodexQuestionPrompt[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
const questions: CodexQuestionPrompt[] = [];
|
||||
for (const item of raw) {
|
||||
if (!item || typeof item !== "object") {
|
||||
continue;
|
||||
}
|
||||
const record = item as Record<string, unknown>;
|
||||
const id = nonEmptyString(record.id);
|
||||
const header = nonEmptyString(record.header);
|
||||
const question = nonEmptyString(record.question);
|
||||
if (!id || !header || !question) {
|
||||
continue;
|
||||
}
|
||||
const options = Array.isArray(record.options)
|
||||
? record.options.flatMap((option): CodexQuestionOption[] => {
|
||||
if (!option || typeof option !== "object") {
|
||||
return [];
|
||||
}
|
||||
const optionRecord = option as Record<string, unknown>;
|
||||
const label = nonEmptyString(optionRecord.label);
|
||||
if (!label) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
label,
|
||||
...(typeof optionRecord.description === "string" &&
|
||||
optionRecord.description.trim().length > 0
|
||||
? { description: optionRecord.description }
|
||||
: {}),
|
||||
},
|
||||
];
|
||||
})
|
||||
: [];
|
||||
questions.push({
|
||||
id,
|
||||
header,
|
||||
question,
|
||||
options,
|
||||
...(record.multiSelect === true ? { multiSelect: true } : {}),
|
||||
...(record.isOther === true ? { isOther: true } : {}),
|
||||
...(record.isSecret === true ? { isSecret: true } : {}),
|
||||
});
|
||||
}
|
||||
return questions;
|
||||
}
|
||||
|
||||
function formatCodexQuestionPrompts(questions: CodexQuestionPrompt[]): string {
|
||||
return questions
|
||||
.map((question) => {
|
||||
const lines = [`${question.header}: ${question.question}`];
|
||||
if (question.options.length > 0) {
|
||||
lines.push(`Options: ${question.options.map((option) => option.label).join(", ")}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
})
|
||||
.join("\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function mapCodexQuestionRequestToToolCall(params: {
|
||||
callId: string;
|
||||
questions: CodexQuestionPrompt[];
|
||||
status: ToolCallTimelineItem["status"];
|
||||
answers?: Record<string, string[]>;
|
||||
error?: unknown;
|
||||
}): ToolCallTimelineItem {
|
||||
const formattedQuestions = formatCodexQuestionPrompts(params.questions);
|
||||
const formattedAnswers =
|
||||
params.answers && Object.keys(params.answers).length > 0
|
||||
? Object.entries(params.answers)
|
||||
.map(([id, values]) => `${id}: ${values.join(", ")}`)
|
||||
.join("\n")
|
||||
: null;
|
||||
const detailText =
|
||||
params.status === "completed" && formattedAnswers
|
||||
? [formattedQuestions, "Answers:", formattedAnswers].filter(Boolean).join("\n\n")
|
||||
: formattedQuestions;
|
||||
|
||||
const base = {
|
||||
type: "tool_call" as const,
|
||||
callId: params.callId,
|
||||
name: "request_user_input",
|
||||
detail: {
|
||||
type: "plain_text" as const,
|
||||
text: detailText,
|
||||
icon: "brain" as const,
|
||||
},
|
||||
metadata: {
|
||||
questions: params.questions,
|
||||
...(params.answers ? { answers: params.answers } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
if (params.status === "failed") {
|
||||
return {
|
||||
...base,
|
||||
status: "failed",
|
||||
error: params.error ?? { message: "Question dismissed" },
|
||||
};
|
||||
}
|
||||
if (params.status === "canceled") {
|
||||
return {
|
||||
...base,
|
||||
status: "canceled",
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
if (params.status === "running") {
|
||||
return {
|
||||
...base,
|
||||
status: "running",
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
status: "completed",
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapCodexQuestionResponseByHeader(params: {
|
||||
questions: CodexQuestionPrompt[];
|
||||
response: AgentPermissionResponse;
|
||||
}): Record<string, { answers: string[] }> | null {
|
||||
if (params.response.behavior !== "allow") {
|
||||
return null;
|
||||
}
|
||||
const answersRecord =
|
||||
params.response.updatedInput && typeof params.response.updatedInput === "object"
|
||||
? ((params.response.updatedInput as Record<string, unknown>).answers as
|
||||
| Record<string, unknown>
|
||||
| undefined)
|
||||
: undefined;
|
||||
if (!answersRecord || typeof answersRecord !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const answers: Record<string, { answers: string[] }> = {};
|
||||
for (const question of params.questions) {
|
||||
const rawAnswer = answersRecord[question.header];
|
||||
if (typeof rawAnswer !== "string") {
|
||||
continue;
|
||||
}
|
||||
const normalizedAnswer = rawAnswer.trim();
|
||||
if (!normalizedAnswer) {
|
||||
continue;
|
||||
}
|
||||
const values = question.multiSelect
|
||||
? normalizedAnswer
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0)
|
||||
: [normalizedAnswer];
|
||||
if (values.length > 0) {
|
||||
answers[question.id] = { answers: values };
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(answers).length > 0 ? answers : null;
|
||||
}
|
||||
|
||||
type CodexPatchFileChange = {
|
||||
path: string;
|
||||
kind?: string;
|
||||
@@ -2072,8 +2262,8 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
string,
|
||||
{
|
||||
resolve: (value: unknown) => void;
|
||||
kind: "command" | "file" | "tool";
|
||||
questions?: Array<{ id: string; options?: Array<{ label?: string; value?: string }> }>;
|
||||
kind: "command" | "file" | "question";
|
||||
questions?: CodexQuestionPrompt[];
|
||||
}
|
||||
>();
|
||||
private resolvedPermissionRequests = new Set<string>();
|
||||
@@ -2284,6 +2474,10 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
this.client.setRequestHandler("item/fileChange/requestApproval", (params) =>
|
||||
this.handleFileChangeApprovalRequest(params),
|
||||
);
|
||||
this.client.setRequestHandler("item/tool/requestUserInput", (params) =>
|
||||
this.handleToolApprovalRequest(params),
|
||||
);
|
||||
// Keep the legacy method name for older Codex builds.
|
||||
this.client.setRequestHandler("tool/requestUserInput", (params) =>
|
||||
this.handleToolApprovalRequest(params),
|
||||
);
|
||||
@@ -2745,26 +2939,53 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
|
||||
// tool/requestUserInput
|
||||
const answers: Record<string, { answers: string[] }> = {};
|
||||
const questions = pending.questions ?? [];
|
||||
const decision =
|
||||
response.behavior === "allow" ? "accept" : response.interrupt ? "cancel" : "decline";
|
||||
for (const question of questions) {
|
||||
let picked = decision;
|
||||
const options = question.options ?? [];
|
||||
if (options.length > 0) {
|
||||
const byLabel = options.find((opt) => (opt.label ?? "").toLowerCase().includes(decision));
|
||||
const byValue = options.find((opt) => (opt.value ?? "").toLowerCase().includes(decision));
|
||||
const option = byLabel ?? byValue ?? options[0]!;
|
||||
picked = option.value ?? option.label ?? decision;
|
||||
}
|
||||
answers[question.id] = { answers: [picked] };
|
||||
const itemId =
|
||||
typeof pendingRequest?.metadata?.itemId === "string" ? pendingRequest.metadata.itemId : requestId;
|
||||
if (response.behavior === "allow") {
|
||||
const mappedAnswers = mapCodexQuestionResponseByHeader({
|
||||
questions,
|
||||
response,
|
||||
});
|
||||
const answers =
|
||||
mappedAnswers ??
|
||||
Object.fromEntries(
|
||||
questions
|
||||
.map((question) => {
|
||||
const fallback = question.options[0]?.label?.trim();
|
||||
return fallback
|
||||
? [question.id, { answers: [fallback] }]
|
||||
: null;
|
||||
})
|
||||
.filter((entry): entry is [string, { answers: string[] }] => entry !== null),
|
||||
);
|
||||
this.emitEvent({
|
||||
type: "timeline",
|
||||
provider: CODEX_PROVIDER,
|
||||
item: mapCodexQuestionRequestToToolCall({
|
||||
callId: itemId,
|
||||
questions,
|
||||
status: "completed",
|
||||
answers: Object.fromEntries(
|
||||
Object.entries(answers).map(([id, value]) => [id, value.answers]),
|
||||
),
|
||||
}),
|
||||
});
|
||||
pending.resolve({ answers });
|
||||
return;
|
||||
}
|
||||
if (questions.length === 0) {
|
||||
answers["default"] = { answers: [decision] };
|
||||
}
|
||||
pending.resolve({ answers });
|
||||
|
||||
this.emitEvent({
|
||||
type: "timeline",
|
||||
provider: CODEX_PROVIDER,
|
||||
item: mapCodexQuestionRequestToToolCall({
|
||||
callId: itemId,
|
||||
questions,
|
||||
status: response.interrupt ? "canceled" : "failed",
|
||||
error: { message: response.message ?? "Question dismissed" },
|
||||
}),
|
||||
});
|
||||
pending.resolve({ answers: {} });
|
||||
}
|
||||
|
||||
describePersistence(): {
|
||||
@@ -3463,34 +3684,43 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
private handleToolApprovalRequest(params: unknown): Promise<unknown> {
|
||||
const parsed = params as { itemId: string; threadId: string; turnId: string; questions: any[] };
|
||||
const requestId = `permission-${parsed.itemId}`;
|
||||
const questions = normalizeCodexQuestionPrompts(parsed.questions);
|
||||
const request: AgentPermissionRequest = {
|
||||
id: requestId,
|
||||
provider: CODEX_PROVIDER,
|
||||
name: "CodexTool",
|
||||
kind: "tool",
|
||||
title: "Tool action requires approval",
|
||||
name: "request_user_input",
|
||||
kind: "question",
|
||||
title: "Question",
|
||||
description: undefined,
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input: {
|
||||
questions: Array.isArray(parsed.questions) ? parsed.questions : [],
|
||||
},
|
||||
output: null,
|
||||
type: "plain_text",
|
||||
text: formatCodexQuestionPrompts(questions),
|
||||
icon: "brain",
|
||||
},
|
||||
input: { questions },
|
||||
metadata: {
|
||||
itemId: parsed.itemId,
|
||||
threadId: parsed.threadId,
|
||||
turnId: parsed.turnId,
|
||||
questions: parsed.questions,
|
||||
questions,
|
||||
},
|
||||
};
|
||||
this.pendingPermissions.set(requestId, request);
|
||||
this.emitEvent({
|
||||
type: "timeline",
|
||||
provider: CODEX_PROVIDER,
|
||||
item: mapCodexQuestionRequestToToolCall({
|
||||
callId: parsed.itemId,
|
||||
questions,
|
||||
status: "running",
|
||||
}),
|
||||
});
|
||||
this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request });
|
||||
return new Promise((resolve) => {
|
||||
this.pendingPermissionHandlers.set(requestId, {
|
||||
resolve,
|
||||
kind: "tool",
|
||||
questions: Array.isArray(parsed.questions) ? parsed.questions : [],
|
||||
kind: "question",
|
||||
questions,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -3723,8 +3953,11 @@ export const __codexAppServerInternals = {
|
||||
buildCodexAppServerEnv,
|
||||
codexModelSupportsFastMode,
|
||||
CodexAppServerAgentSession,
|
||||
formatCodexQuestionPrompts,
|
||||
mapCodexQuestionRequestToToolCall,
|
||||
mapCodexPatchNotificationToToolCall,
|
||||
planStepsToMarkdown,
|
||||
mapCodexPlanToToolCall,
|
||||
normalizeCodexQuestionPrompts,
|
||||
threadItemToTimeline,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user