fix: send the legacy custom answer key to AIR (#1210)

Released AIR versions, such as 262.air/release-09, read the custom
answer companion only from the root key
_meta._askUserQuestionCustomAnswer. The adapter sent only
_meta.jetbrains.air.customAnswer, so these versions showed the companion
as a separate question.

For an AIR client the companion now carries both keys with the same
value. Other clients get no key, as before. Remove the root key when the
ACP registry no longer serves AIR versions that read only that key.

IJAI-2892
This commit is contained in:
Kirill Alekhnovich
2026-10-01 13:25:01 +02:00
committed by GitHub
parent f53b0d7f40
commit af0b43de94
6 changed files with 40 additions and 14 deletions

View File

@@ -1204,6 +1204,10 @@ For AIR, the companion field carries `_meta.jetbrains.air.customAnswer`:
`questionId` names the select field of the same question.
A client can render the companion as the free-text choice of that question.
For AIR, the companion field also carries the same value under the root key `_meta._askUserQuestionCustomAnswer`.
Released AIR versions read only that key.
A client that is not AIR gets neither key.
## Session fork point
AIR can fork a session at one agent message.
@@ -1255,12 +1259,11 @@ The adapter sends the key only to an AIR client.
These keys moved into the AIR namespace. The adapter sends the new key only to AIR, and the old key to no client.
| Old key | New key |
| --------------------------------------------------------- | ---------------------------------------------- |
| `initialize._meta.goal`, `session_info_update._meta.goal` | `_meta.jetbrains.air.goal`, same shape |
| mode `_meta.kind`, config option value `_meta.kind` | `_meta.jetbrains.air.kind` |
| elicitation property `_meta._askUserQuestionCustomAnswer` | `_meta.jetbrains.air.customAnswer`, same value |
| tool call `_meta.contextCompaction` | `_meta.jetbrains.air.contextCompaction` |
| Old key | New key |
| --------------------------------------------------------- | --------------------------------------- |
| `initialize._meta.goal`, `session_info_update._meta.goal` | `_meta.jetbrains.air.goal`, same shape |
| mode `_meta.kind`, config option value `_meta.kind` | `_meta.jetbrains.air.kind` |
| tool call `_meta.contextCompaction` | `_meta.jetbrains.air.contextCompaction` |
The adapter does not send `_meta.claudeCode.title`, `claudeCode.subagent`, `claudeCode.skill`, or `claudeCode.skillPath` to any client.
AIR reads `_meta.jetbrains.air.commandTitle`, `subagent`, and `skill` instead.

View File

@@ -20,6 +20,12 @@ export const AIR_GOAL_KEY = "goal";
export const AIR_KIND_KEY = "kind";
export const AIR_PERMISSION_KEY = "permission";
export const AIR_CUSTOM_ANSWER_KEY = "customAnswer";
/**
* The root `_meta` spelling of {@link AIR_CUSTOM_ANSWER_KEY} that released
* AIR versions read. Remove it when the ACP registry no longer serves AIR
* versions that read only this spelling.
*/
export const LEGACY_AIR_CUSTOM_ANSWER_KEY = "_askUserQuestionCustomAnswer";
const JETBRAINS_META_KEY = "jetbrains";
const AIR_META_KEY = "air";

View File

@@ -12,7 +12,11 @@ import type {
AskUserQuestionInput,
AskUserQuestionOutput,
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
import { AIR_CUSTOM_ANSWER_KEY, withAirMeta } from "./air-extension.js";
import {
AIR_CUSTOM_ANSWER_KEY,
LEGACY_AIR_CUSTOM_ANSWER_KEY,
withAirMeta,
} from "./air-extension.js";
/**
* Bridges between the Claude Agent SDK's elicitation/dialog callbacks and ACP's
@@ -204,6 +208,7 @@ export function askUserQuestionsToCreateRequest(
? { type: "array", title, description, items: { anyOf: options } }
: { type: "string", title, description, oneOf: options };
const customAnswer = { questionId: questionFieldKey(index), isCustomAnswer: true };
properties[questionCustomFieldKey(index)] = {
type: "string",
title: "Other",
@@ -211,13 +216,15 @@ export function askUserQuestionsToCreateRequest(
? "Type your own answer to add to your selection above (optional)."
: "Type your own answer, or add a note to the option you chose above (optional).",
// Marks the field as the custom answer companion of a select question,
// under `_meta.jetbrains.air.customAnswer`. Only AIR gets the marker.
// under `_meta.jetbrains.air.customAnswer` and under the root key that
// released AIR versions read. Only AIR gets the marker.
...(airClient
? {
_meta: withAirMeta(undefined, AIR_CUSTOM_ANSWER_KEY, {
questionId: questionFieldKey(index),
isCustomAnswer: true,
}),
_meta: withAirMeta(
{ [LEGACY_AIR_CUSTOM_ANSWER_KEY]: customAnswer },
AIR_CUSTOM_ANSWER_KEY,
customAnswer,
),
}
: {}),
};

View File

@@ -50,6 +50,7 @@ import {
compareWithBaseline,
} from "./acp-scenarios/compare.js";
import { EXTENSION_SESSION_UPDATES, validateRecorded } from "./acp-scenarios/schema.js";
import { LEGACY_AIR_CUSTOM_ANSWER_KEY } from "../air-extension.js";
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
const actual = await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>();
@@ -661,6 +662,7 @@ describe.skipIf(baselineDir)("ACP scenarios", () => {
(record) => record.kind === "createElicitation",
)!.payload as Record<string, any>;
expect(elicitation.requestedSchema.properties.question_0_custom._meta).toEqual({
_askUserQuestionCustomAnswer: { questionId: "question_0", isCustomAnswer: true },
jetbrains: {
air: { version: 1, customAnswer: { questionId: "question_0", isCustomAnswer: true } },
},
@@ -689,7 +691,13 @@ describe.skipIf(baselineDir)("ACP scenarios", () => {
for (const scenario of SCENARIOS) {
for (const { at, meta } of metaObjects(air(scenario.name))) {
const duplicates = [
...Object.keys(meta).filter((k) => AIR_ONLY_META_KEYS.has(k) && k !== "jetbrains"),
// Released AIR versions read only the legacy custom answer key, so AIR gets both.
...Object.keys(meta).filter(
(k) =>
AIR_ONLY_META_KEYS.has(k) &&
k !== "jetbrains" &&
k !== LEGACY_AIR_CUSTOM_ANSWER_KEY,
),
...Object.keys((meta.claudeCode as Record<string, unknown> | undefined) ?? {}).filter(
(k) => AIR_ONLY_CLAUDE_CODE_KEYS.has(k),
),

View File

@@ -3,7 +3,7 @@
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"AskUserQuestion"}},"content":[],"kind":"other","name":"AskUserQuestion","sessionUpdate":"tool_call","status":"pending","title":"Asking for your input","toolCallId":"toolu_ask"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawInput":{"questions":[{"header":"Database","multiSelect":false,"options":[{"description":"Relational","label":"Postgres","preview":"CREATE TABLE t ();"},{"description":"Key-value","label":"Redis"}],"question":"Which database?"}]},"sessionUpdate":"tool_call_update","toolCallId":"toolu_ask"}}}
{"kind":"createElicitation","payload":{"message":"Which database?","mode":"form","requestedSchema":{"properties":{"question_0":{"oneOf":[{"_meta":{"_claude/askUserQuestionOption":{"preview":"CREATE TABLE t ();"}},"const":"Postgres","description":"Relational","title":"Postgres"},{"const":"Redis","description":"Key-value","title":"Redis"}],"title":"Database","type":"string"},"question_0_custom":{"_meta":{"jetbrains":{"air":{"customAnswer":{"isCustomAnswer":true,"questionId":"question_0"},"version":1}}},"description":"Type your own answer, or add a note to the option you chose above (optional).","title":"Other","type":"string"}},"type":"object"},"sessionId":"<session>","toolCallId":"toolu_ask"}}
{"kind":"createElicitation","payload":{"message":"Which database?","mode":"form","requestedSchema":{"properties":{"question_0":{"oneOf":[{"_meta":{"_claude/askUserQuestionOption":{"preview":"CREATE TABLE t ();"}},"const":"Postgres","description":"Relational","title":"Postgres"},{"const":"Redis","description":"Key-value","title":"Redis"}],"title":"Database","type":"string"},"question_0_custom":{"_meta":{"_askUserQuestionCustomAnswer":{"isCustomAnswer":true,"questionId":"question_0"},"jetbrains":{"air":{"customAnswer":{"isCustomAnswer":true,"questionId":"question_0"},"version":1}}},"description":"Type your own answer, or add a note to the option you chose above (optional).","title":"Other","type":"string"}},"type":"object"},"sessionId":"<session>","toolCallId":"toolu_ask"}}
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"User has answered your questions: \"Which database?\"=\"Postgres\".","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_ask"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}

View File

@@ -265,6 +265,7 @@ describe("askUserQuestionsToCreateRequest", () => {
type: "string",
title: "Other",
_meta: {
_askUserQuestionCustomAnswer: { questionId: "question_0", isCustomAnswer: true },
jetbrains: {
air: { customAnswer: { questionId: "question_0", isCustomAnswer: true } },
},
@@ -274,6 +275,7 @@ describe("askUserQuestionsToCreateRequest", () => {
type: "string",
title: "Other",
_meta: {
_askUserQuestionCustomAnswer: { questionId: "question_1", isCustomAnswer: true },
jetbrains: {
air: { customAnswer: { questionId: "question_1", isCustomAnswer: true } },
},