Skip to content
Merged
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
1 change: 1 addition & 0 deletions docs/air-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ The adapter sends these keys only to AIR. "AIR" in the gate column means that th
| `phase` | `agent_message_chunk._meta.jetbrains.air` | Codex message phase string | AIR |
| `kind` | session mode `_meta.jetbrains.air` and `mode` config option value `_meta.jetbrains.air` | `standard`, `auto_review`, or `full_access` | AIR |
| `commandAction` | available command `_meta.jetbrains.air` | command action object | AIR |
| `customAnswer` | `request_user_input` note field of an elicitation schema, `_meta.jetbrains.air` | `true` | AIR |

## JetBrains shared keys

Expand Down
1 change: 1 addition & 0 deletions src/AirExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export const AIR_RAW_INPUT_RENDERING_KEY = "rawInputRendering";
export const AIR_PLAN_CONTENT_DELTA_KEY = "planContentDelta";
export const AIR_CONTENT_DELTA_KEY = "contentDelta";
export const AIR_SUBAGENT_KEY = "subagent";
export const AIR_CUSTOM_ANSWER_KEY = "customAnswer";
export const AIR_EXTENSION_VERSION = 1;

/** Merge one AIR payload into metadata while preserving other object namespaces. */
Expand Down
39 changes: 29 additions & 10 deletions src/CodexElicitationHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type {PermissionPromptContext} from "./permissions/lifecycle";
import {AcpToolCallRenderer} from "./tool-calls/AcpToolCallRenderer";
import {ElicitationReporter} from "./tool-calls/reporters/ElicitationReporter";
import {isRecord, normalizeJsonObject, normalizeJsonValue, recordOrNull} from "./permissions/json";
import {AIR_CUSTOM_ANSWER_KEY, isAirClient, withAirMeta} from "./AirExtension";
type AcpBackedMcpElicitationParams = Extract<
McpServerElicitationRequestParams,
{ mode: "form" } | { mode: "url" }
Expand Down Expand Up @@ -123,6 +124,17 @@ function userInputNoteFieldId(questionId: string, questionIds: ReadonlySet<strin
return fieldId;
}

/** The text that AIR sends in a choice field when the user types an own answer instead of choosing an option. */
function typedChoiceAnswer(
value: acp.ElicitationContentValue | undefined,
question: ToolRequestUserInputParams["questions"][number],
): string | undefined {
if (typeof value !== "string" || value === USER_INPUT_OTHER_OPTION) {
return undefined;
}
return question.options?.some(option => option.label === value) ? undefined : value;
}

function userInputResponseValue(
content: Record<string, acp.ElicitationContentValue>,
fieldId: string
Expand Down Expand Up @@ -394,6 +406,7 @@ export class CodexElicitationHandler implements ElicitationHandler {
const properties: Record<string, acp.ElicitationPropertySchema> = {};
const required: string[] = [];
const questionIds = new Set(params.questions.map(question => question.id));
const airClient = isAirClient(this.clientCapabilities);

for (const question of params.questions) {
const options = question.options ?? [];
Expand All @@ -420,7 +433,7 @@ export class CodexElicitationHandler implements ElicitationHandler {
title: option.label,
...(option.description ? { description: option.description } : {}),
})),
...(hasOtherAnswer && !options.some(option => option.label === USER_INPUT_OTHER_OPTION) ? [{
...(hasOtherAnswer && !airClient && !options.some(option => option.label === USER_INPUT_OTHER_OPTION) ? [{
const: USER_INPUT_OTHER_OPTION,
title: USER_INPUT_OTHER_OPTION,
description: "Provide a different answer in the note field.",
Expand All @@ -432,16 +445,17 @@ export class CodexElicitationHandler implements ElicitationHandler {
type: "string",
};
if (hasOtherAnswer) {
const noteMeta = {
codex: {
questionId: question.id,
role: "user_note",
isSecret: question.isSecret,
},
};
properties[userInputNoteFieldId(question.id, questionIds)] = {
type: "string",
title: "Additional answer or note",
_meta: {
codex: {
questionId: question.id,
role: "user_note",
isSecret: question.isSecret,
},
},
_meta: airClient ? withAirMeta(noteMeta, AIR_CUSTOM_ANSWER_KEY, true) : noteMeta,
};
}
}
Expand Down Expand Up @@ -523,13 +537,18 @@ export class CodexElicitationHandler implements ElicitationHandler {
const answers: ToolRequestUserInputResponse["answers"] = {};
const content = contentRecord(response.content);
const questionIds = new Set(params.questions.map(question => question.id));
const airClient = isAirClient(this.clientCapabilities);
for (const question of params.questions) {
const answerValues: string[] = [];
const hasOtherAnswer = question.isOther && question.options != null && question.options.length > 0;
const value = userInputResponseValue(content, question.id);
if (value !== undefined) {
const typedAnswer = airClient && hasOtherAnswer ? typedChoiceAnswer(value, question) : undefined;
if (typedAnswer !== undefined) {
answerValues.push(USER_INPUT_OTHER_OPTION, `${USER_INPUT_NOTE_PREFIX}${typedAnswer.trim()}`);
} else if (value !== undefined) {
answerValues.push(...(Array.isArray(value) ? value.map(String) : [String(value)]));
}
if (question.isOther && question.options != null && question.options.length > 0) {
if (hasOtherAnswer) {
const note = userInputResponseValue(content, userInputNoteFieldId(question.id, questionIds));
if (note !== undefined) {
const notes = Array.isArray(note) ? note : [note];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
{
"method": "createElicitation",
"args": [
{
"sessionId": "test-session-id",
"toolCallId": "request-user-input-1",
"mode": "form",
"message": "Codex needs your input to continue.",
"requestedSchema": {
"type": "object",
"properties": {
"next_step": {
"title": "What should I do next?",
"description": "Next step",
"_meta": {
"codex": {
"isOther": true,
"isSecret": false
}
},
"type": "string",
"oneOf": [
{
"const": "Run tests",
"title": "Run tests",
"description": "Run the focused test suite."
},
{
"const": "Stop",
"title": "Stop",
"description": "Stop and report current status."
}
]
},
"next_step_note": {
"type": "string",
"title": "Additional answer or note",
"_meta": {
"codex": {
"questionId": "next_step",
"role": "user_note",
"isSecret": false
},
"jetbrains": {
"air": {
"version": 1,
"customAnswer": true
}
}
}
}
},
"required": [
"next_step"
]
},
"_meta": {
"codex": {
"autoResolutionMs": null
}
}
}
]
}
44 changes: 44 additions & 0 deletions src/__tests__/CodexACPAgent/elicitation-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1001,6 +1001,50 @@ describe('Elicitation Events', () => {
await promptPromise;
});

it.each([
{ answer: 'Inspect flaky logs', expected: ['None of the above', 'user_note: Inspect flaky logs'] },
{ answer: 'Run tests', expected: ['Run tests'] },
])('should mark the note as the AIR custom answer and map $answer to Codex answers', async ({ answer, expected }) => {
const { promptPromise, completeTurn } = await setupSessionWithPendingPromptAndCapabilities({
elicitation: { form: {} },
_meta: { jetbrains: { air: { version: 1, capabilities: [] } } },
});
fixture.setElicitationResponse({
action: 'accept',
content: { next_step: answer },
});

const params: ToolRequestUserInputParams = {
threadId: sessionId,
turnId: 'turn-1',
itemId: 'request-user-input-1',
autoResolutionMs: null,
isBlocking: true,
questions: [{
id: 'next_step',
header: 'Next step',
question: 'What should I do next?',
isOther: true,
isSecret: false,
options: [
{ label: 'Run tests', description: 'Run the focused test suite.' },
{ label: 'Stop', description: 'Stop and report current status.' },
],
}],
};

const response = await fixture.sendServerRequest('item/tool/requestUserInput', params);
expect(response).toEqual({
answers: { next_step: { answers: expected } },
});
await expect(fixture.getAcpConnectionDump([])).toMatchFileSnapshot(
'data/elicitation-user-input-air-custom-answer.json',
);

completeTurn();
await promptPromise;
});

it.each(['before', 'after'] as const)(
'should preserve questions with note field IDs when they appear %s the choice',
async (order) => {
Expand Down
Loading