Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 36 additions & 43 deletions src/CodexElicitationHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ type AcpBackedMcpElicitationParams = Extract<
{ mode: "form" } | { mode: "url" }
>;

const USER_INPUT_OTHER_FIELD_SUFFIX = "__other";
const USER_INPUT_NOTE_FIELD_SUFFIX = "_note";
const USER_INPUT_OTHER_OPTION = "None of the above";
const USER_INPUT_NOTE_PREFIX = "user_note: ";

/**
* Parses the `persist` field from the elicitation request `_meta`.
Expand Down Expand Up @@ -223,19 +225,6 @@ function elicitationResponseMeta(
return Object.keys(meta).length === 0 ? null : meta;
}

function userInputOtherFieldId(questionId: string, questionIds: Set<string>): string {
const base = `${questionId}${USER_INPUT_OTHER_FIELD_SUFFIX}`;
if (!questionIds.has(base)) {
return base;
}

let index = 1;
while (questionIds.has(`${base}${index}`)) {
index += 1;
}
return `${base}${index}`;
}

function userInputResponseValue(
content: Record<string, acp.ElicitationContentValue>,
fieldId: string
Expand Down Expand Up @@ -495,63 +484,62 @@ export class CodexElicitationHandler implements ElicitationHandler {
private buildUserInputRequest(params: ToolRequestUserInputParams): acp.CreateElicitationRequest {
const properties: Record<string, acp.ElicitationPropertySchema> = {};
const required: string[] = [];
const questionIds = new Set(params.questions.map(question => question.id));

for (const question of params.questions) {
const options = question.options ?? [];
const hasOptions = options.length > 0;
const hasOtherAnswer = question.isOther && hasOptions;
const base = {
title: question.header || question.id,
description: question.question,
title: question.question || question.header || question.id,
...(question.header ? { description: question.header } : {}),
_meta: {
codex: {
isOther: question.isOther,
isSecret: question.isSecret,
},
},
};
if (!hasOtherAnswer) {
required.push(question.id);
}
required.push(question.id);
properties[question.id] = hasOptions
? {
...base,
type: "string",
oneOf: options.map(option => ({
const: option.label,
title: option.label,
description: option.description,
})),
oneOf: [
...options.map(option => ({
const: option.label,
title: option.label,
...(option.description ? { description: option.description } : {}),
})),
...(hasOtherAnswer ? [{
const: USER_INPUT_OTHER_OPTION,
title: USER_INPUT_OTHER_OPTION,
description: "Provide a different answer in the note field.",
}] : []),
],
}
: {
...base,
type: "string",
};
if (hasOtherAnswer) {
properties[userInputOtherFieldId(question.id, questionIds)] = {
properties[`${question.id}${USER_INPUT_NOTE_FIELD_SUFFIX}`] = {
type: "string",
title: "Other",
description: "Type your own answer instead of choosing an option above.",
title: "Additional answer or note",
_meta: {
codex: {
questionId: question.id,
isOtherAnswer: true,
role: "user_note",
isSecret: question.isSecret,
},
},
};
}
}

const firstQuestion = params.questions[0];
return {
sessionId: this.sessionState.sessionId,
toolCallId: params.itemId,
mode: "form",
message: params.questions.length === 1 && firstQuestion
? firstQuestion.question
: "Input requested",
message: "Codex needs your input to continue.",
requestedSchema: {
type: "object",
properties,
Expand Down Expand Up @@ -696,19 +684,24 @@ export class CodexElicitationHandler implements ElicitationHandler {

const answers: ToolRequestUserInputResponse["answers"] = {};
const content = contentRecord(response.content);
const questionIds = new Set(params.questions.map(question => question.id));
for (const question of params.questions) {
const value = question.isOther && question.options != null && question.options.length > 0
? userInputResponseValue(content, userInputOtherFieldId(question.id, questionIds))
?? userInputResponseValue(content, question.id)
: userInputResponseValue(content, question.id);
if (value === undefined) {
const answerValues: string[] = [];
const value = userInputResponseValue(content, question.id);
if (value !== undefined) {
answerValues.push(...(Array.isArray(value) ? value.map(String) : [String(value)]));
}
if (question.isOther && question.options != null && question.options.length > 0) {
const note = userInputResponseValue(content, `${question.id}${USER_INPUT_NOTE_FIELD_SUFFIX}`);
if (note !== undefined) {
const notes = Array.isArray(note) ? note : [note];
answerValues.push(...notes.map(item => `${USER_INPUT_NOTE_PREFIX}${String(item).trim()}`));
}
}
if (answerValues.length === 0) {
continue;
}
answers[question.id] = {
answers: Array.isArray(value)
? value.map(String)
: [String(value)],
answers: answerValues,
};
}
return { answers };
Expand Down
67 changes: 45 additions & 22 deletions src/__tests__/CodexACPAgent/elicitation-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -727,47 +727,70 @@ describe('Elicitation Events', () => {
},
});

const [elicitationEvent] = fixture.getAcpConnectionEvents(['_meta']);
expect(elicitationEvent).toMatchObject({
expect(fixture.getAcpConnectionEvents([])).toEqual([{
method: 'createElicitation',
args: [{
sessionId,
toolCallId: 'request-user-input-1',
mode: 'form',
message: 'Input requested',
message: 'Codex needs your input to continue.',
requestedSchema: {
type: 'object',
required: ['notes'],
properties: {
next_step: {
type: 'string',
title: 'What should I do next?',
description: 'Next step',
oneOf: [
{ const: 'Run tests', title: 'Run tests', description: 'Run the focused test suite.' },
{ const: 'Stop', title: 'Stop', description: 'Stop and report current status.' },
{
const: 'None of the above',
title: 'None of the above',
description: 'Provide a different answer in the note field.',
},
],
_meta: {
codex: { isOther: true, isSecret: false },
},
},
next_step_note: {
type: 'string',
title: 'Additional answer or note',
_meta: {
codex: { questionId: 'next_step', role: 'user_note', isSecret: false },
},
},
notes: {
type: 'string',
title: 'Any extra instructions?',
description: 'Notes',
_meta: {
codex: { isOther: false, isSecret: false },
},
},
},
required: ['next_step', 'notes'],
},
_meta: {
codex: { autoResolutionMs: 60000 },
},
}],
});
expect(elicitationEvent!.args[0].requestedSchema.properties.next_step.oneOf).toEqual([
{ const: 'Run tests', title: 'Run tests', description: 'Run the focused test suite.' },
{ const: 'Stop', title: 'Stop', description: 'Stop and report current status.' },
]);
expect(elicitationEvent!.args[0].requestedSchema.properties.next_step__other).toMatchObject({
type: 'string',
title: 'Other',
});
expect(elicitationEvent!.args[0].requestedSchema.properties.notes).toMatchObject({
type: 'string',
title: 'Notes',
description: 'Any extra instructions?',
});
}]);

completeTurn();
await promptPromise;
});

it('should prefer free-form Other answers over fixed choices', async () => {
it('should return the Other choice and its note using Codex answer conventions', async () => {
const { promptPromise, completeTurn } = await setupSessionWithPendingPromptAndCapabilities({
elicitation: { form: {} },
});
fixture.setElicitationResponse({
action: 'accept',
content: {
next_step: 'Run tests',
next_step__other: 'Inspect flaky logs',
next_step: 'None of the above',
next_step_note: 'Inspect flaky logs',
},
});

Expand All @@ -792,7 +815,7 @@ describe('Elicitation Events', () => {
const response = await fixture.sendServerRequest('item/tool/requestUserInput', params);
expect(response).toEqual({
answers: {
next_step: { answers: ['Inspect flaky logs'] },
next_step: { answers: ['None of the above', 'user_note: Inspect flaky logs'] },
},
});

Expand Down