diff --git a/docs/air-extensions.md b/docs/air-extensions.md index c724e256..d9fe7d35 100644 --- a/docs/air-extensions.md +++ b/docs/air-extensions.md @@ -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 diff --git a/src/AirExtension.ts b/src/AirExtension.ts index 4914e3a0..fa1224e7 100644 --- a/src/AirExtension.ts +++ b/src/AirExtension.ts @@ -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. */ diff --git a/src/CodexElicitationHandler.ts b/src/CodexElicitationHandler.ts index 5b05a0e1..605ccf99 100644 --- a/src/CodexElicitationHandler.ts +++ b/src/CodexElicitationHandler.ts @@ -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" } @@ -123,6 +124,17 @@ function userInputNoteFieldId(questionId: string, questionIds: ReadonlySet option.label === value) ? undefined : value; +} + function userInputResponseValue( content: Record, fieldId: string @@ -394,6 +406,7 @@ export class CodexElicitationHandler implements ElicitationHandler { const properties: Record = {}; 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 ?? []; @@ -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.", @@ -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, }; } } @@ -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]; diff --git a/src/__tests__/CodexACPAgent/data/elicitation-user-input-air-custom-answer.json b/src/__tests__/CodexACPAgent/data/elicitation-user-input-air-custom-answer.json new file mode 100644 index 00000000..9afc3b57 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/elicitation-user-input-air-custom-answer.json @@ -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 + } + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/elicitation-events.test.ts b/src/__tests__/CodexACPAgent/elicitation-events.test.ts index 7ba6637f..b356978e 100644 --- a/src/__tests__/CodexACPAgent/elicitation-events.test.ts +++ b/src/__tests__/CodexACPAgent/elicitation-events.test.ts @@ -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) => {