feat(codebuddy): refactor elicitation card with schema validation and i18n

- Extract pure domain logic into codebuddyElicitationForm (parse, validate, build)
- Add requestInstanceId to decouple SDK instance reuse from elicitationId
- Component-level validation with accessible error ids (aria-describedby)
- Add notInteger validation code for fractional numeric input on integer fields
- Add SDK contract type test for elicitation handler signatures
- Add 11 validation message keys across en/ru/zh-CN/zh-TW i18n locales
- 14 tests covering domain, infrastructure, components, and SDK contract
This commit is contained in:
lengyuqu committed 2026-07-28 00:44:22 +08:00
1 parent d8f1c6cd15
commit a4b0926ded
14 files changed
+1052 -169

No files matched your search

+12
View File
@@ -232,6 +232,18 @@ export const enAiMessages: Messages = {
'ai.codebuddy.elicitation.no': 'No',
'ai.codebuddy.elicitation.decline': 'Decline',
'ai.codebuddy.elicitation.accept': 'Continue',
'ai.codebuddy.elicitation.validation.required': '{field} is required.',
'ai.codebuddy.elicitation.validation.invalidType': '{field} has an invalid value.',
'ai.codebuddy.elicitation.validation.integer': '{field} must be an integer.',
'ai.codebuddy.elicitation.validation.notInteger': '{field} must be a whole number.',
'ai.codebuddy.elicitation.validation.minimum': '{field} must be at least {limit}.',
'ai.codebuddy.elicitation.validation.maximum': '{field} must be at most {limit}.',
'ai.codebuddy.elicitation.validation.minLength': '{field} must contain at least {limit} characters.',
'ai.codebuddy.elicitation.validation.maxLength': '{field} must contain at most {limit} characters.',
'ai.codebuddy.elicitation.validation.minItems': 'Select at least {limit} options for {field}.',
'ai.codebuddy.elicitation.validation.maxItems': 'Select at most {limit} options for {field}.',
'ai.codebuddy.elicitation.validation.format': '{field} must match the {format} format.',
'ai.codebuddy.elicitation.validation.option': 'Select a valid option for {field}.',
// AI OpenCode
'ai.opencode.title': 'OpenCode',
+12
View File
@@ -197,6 +197,18 @@ export const ruAiMessages: Messages = {
'ai.codebuddy.elicitation.no': 'Нет',
'ai.codebuddy.elicitation.decline': 'Отклонить',
'ai.codebuddy.elicitation.accept': 'Продолжить',
'ai.codebuddy.elicitation.validation.required': 'Поле «{field}» обязательно.',
'ai.codebuddy.elicitation.validation.invalidType': 'Поле «{field}» содержит недопустимое значение.',
'ai.codebuddy.elicitation.validation.integer': 'Поле «{field}» должно быть целым числом.',
'ai.codebuddy.elicitation.validation.notInteger': 'Поле «{field}» должно быть целым числом.',
'ai.codebuddy.elicitation.validation.minimum': 'Значение «{field}» должно быть не меньше {limit}.',
'ai.codebuddy.elicitation.validation.maximum': 'Значение «{field}» должно быть не больше {limit}.',
'ai.codebuddy.elicitation.validation.minLength': 'Поле «{field}» должно содержать не менее {limit} символов.',
'ai.codebuddy.elicitation.validation.maxLength': 'Поле «{field}» должно содержать не более {limit} символов.',
'ai.codebuddy.elicitation.validation.minItems': 'Выберите не менее {limit} вариантов для «{field}».',
'ai.codebuddy.elicitation.validation.maxItems': 'Выберите не более {limit} вариантов для «{field}».',
'ai.codebuddy.elicitation.validation.format': 'Поле «{field}» должно соответствовать формату {format}.',
'ai.codebuddy.elicitation.validation.option': 'Выберите допустимый вариант для «{field}».',
// AI Default Agent
'ai.defaultAgent': 'Агент по умолчанию',
+12
View File
@@ -232,6 +232,18 @@ export const zhCNAiMessages: Messages = {
'ai.codebuddy.elicitation.no': '否',
'ai.codebuddy.elicitation.decline': '拒绝',
'ai.codebuddy.elicitation.accept': '继续',
'ai.codebuddy.elicitation.validation.required': '{field} 为必填项。',
'ai.codebuddy.elicitation.validation.invalidType': '{field} 的值无效。',
'ai.codebuddy.elicitation.validation.integer': '{field} 必须是整数。',
'ai.codebuddy.elicitation.validation.notInteger': '{field} 必须是整数。',
'ai.codebuddy.elicitation.validation.minimum': '{field} 不能小于 {limit}。',
'ai.codebuddy.elicitation.validation.maximum': '{field} 不能大于 {limit}。',
'ai.codebuddy.elicitation.validation.minLength': '{field} 至少需要 {limit} 个字符。',
'ai.codebuddy.elicitation.validation.maxLength': '{field} 最多允许 {limit} 个字符。',
'ai.codebuddy.elicitation.validation.minItems': '{field} 至少选择 {limit} 项。',
'ai.codebuddy.elicitation.validation.maxItems': '{field} 最多选择 {limit} 项。',
'ai.codebuddy.elicitation.validation.format': '{field} 必须符合 {format} 格式。',
'ai.codebuddy.elicitation.validation.option': '请为 {field} 选择有效选项。',
// AI OpenCode
'ai.opencode.title': 'OpenCode',
+12
View File
@@ -232,6 +232,18 @@ export const zhTWAiMessages: Messages = {
'ai.codebuddy.elicitation.no': '否',
'ai.codebuddy.elicitation.decline': '拒絕',
'ai.codebuddy.elicitation.accept': '繼續',
'ai.codebuddy.elicitation.validation.required': '{field} 為必填項。',
'ai.codebuddy.elicitation.validation.invalidType': '{field} 的值無效。',
'ai.codebuddy.elicitation.validation.integer': '{field} 必須是整數。',
'ai.codebuddy.elicitation.validation.notInteger': '{field} 必須是整數。',
'ai.codebuddy.elicitation.validation.minimum': '{field} 不能小於 {limit}。',
'ai.codebuddy.elicitation.validation.maximum': '{field} 不能大於 {limit}。',
'ai.codebuddy.elicitation.validation.minLength': '{field} 至少需要 {limit} 個字元。',
'ai.codebuddy.elicitation.validation.maxLength': '{field} 最多允許 {limit} 個字元。',
'ai.codebuddy.elicitation.validation.minItems': '{field} 至少選擇 {limit} 項。',
'ai.codebuddy.elicitation.validation.maxItems': '{field} 最多選擇 {limit} 項。',
'ai.codebuddy.elicitation.validation.format': '{field} 必須符合 {format} 格式。',
'ai.codebuddy.elicitation.validation.option': '請為 {field} 選擇有效選項。',
// AI OpenCode
'ai.opencode.title': 'OpenCode',
+1 -1
View File
@@ -770,7 +770,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
.filter((elicitation) => !activeSessionId || elicitation.chatSessionId === activeSessionId)
.map((elicitation) => (
<CodebuddyElicitationCard
key={elicitation.elicitationId}
key={`${elicitation.elicitationId}:${elicitation.requestInstanceId ?? 0}`}
elicitation={elicitation}
onRespond={(action, content) =>
handleCodebuddyElicitation(elicitation.elicitationId, action, content)}
@@ -2,9 +2,13 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { I18nProvider } from '../../application/i18n/I18nProvider';
import { CodebuddyElicitationCard } from './CodebuddyElicitationCard';
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
test('CodebuddyElicitationCard renders MCP form fields and response actions', () => {
const markup = renderToStaticMarkup(
<I18nProvider locale="en">
@@ -21,6 +25,7 @@ test('CodebuddyElicitationCard renders MCP form fields and response actions', ()
type: 'string',
title: 'Environment',
enum: ['staging', 'production'],
default: 'staging',
},
dryRun: {
type: 'boolean',
@@ -43,4 +48,182 @@ test('CodebuddyElicitationCard renders MCP form fields and response actions', ()
assert.match(markup, /Dry run/);
assert.match(markup, /Decline/);
assert.match(markup, /Continue/);
assert.doesNotMatch(markup, /role="alert"/);
});
test('CodebuddyElicitationCard renders constraint errors and blocks submission', () => {
const markup = renderToStaticMarkup(
<I18nProvider locale="en">
<CodebuddyElicitationCard
elicitation={{
elicitationId: 'el-invalid',
chatSessionId: 'chat-1',
request: {
message: 'Choose valid settings',
requestedSchema: {
type: 'object',
properties: {
retries: {
type: 'integer',
title: 'Retries',
minimum: 10,
default: 1,
},
regions: {
type: 'array',
title: 'Regions',
minItems: 2,
default: ['us-east'],
items: {
enum: ['us-east', 'eu-west'],
},
},
},
required: ['retries', 'regions'],
},
},
}}
onRespond={async () => {}}
/>
</I18nProvider>,
);
assert.match(markup, /Retries must be at least 10\./);
assert.match(markup, /Select at least 2 options for Regions\./);
assert.match(markup, /aria-invalid="true"/);
assert.match(markup, /<button[^>]*disabled=""[^>]*>Continue<\/button>/);
});
test('CodebuddyElicitationCard uses unique error ids across concurrent cards', () => {
const makeCard = (elicitationId: string) => (
<CodebuddyElicitationCard
elicitation={{
elicitationId,
chatSessionId: 'chat-1',
request: {
requestedSchema: {
type: 'object',
properties: {
retries: {
type: 'integer',
minimum: 2,
default: 1,
},
},
},
},
}}
onRespond={async () => {}}
/>
);
const markup = renderToStaticMarkup(
<I18nProvider locale="en">
{makeCard('el-1')}
{makeCard('el-2')}
</I18nProvider>,
);
const ids = Array.from(markup.matchAll(/id="([^"]+-field-0-error)"/g), (match) => match[1]);
const describedByIds = Array.from(
markup.matchAll(/aria-describedby="([^"]+-field-0-error)"/g),
(match) => match[1],
);
assert.equal(ids.length, 2);
assert.equal(new Set(ids).size, 2);
assert.deepEqual(describedByIds, ids);
});
test('CodebuddyElicitationCard shows "must be whole number" for decimal integer input', () => {
const markup = renderToStaticMarkup(
<I18nProvider locale="en">
<CodebuddyElicitationCard
elicitation={{
elicitationId: 'el-integer',
chatSessionId: 'chat-1',
request: {
message: 'Pick an integer',
requestedSchema: {
type: 'object',
properties: {
retries: {
type: 'integer',
title: 'Retries',
default: 3.5,
},
},
required: ['retries'],
},
},
}}
onRespond={async () => {}}
/>
</I18nProvider>,
);
assert.match(markup, /Retries must be a whole number\./);
assert.match(markup, /aria-invalid="true"/);
});
test('CodebuddyElicitationCard can clear optional constrained values back to omitted', async () => {
const responses: Array<{
action: string;
content?: Record<string, unknown>;
}> = [];
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<CodebuddyElicitationCard
elicitation={{
elicitationId: 'el-optional',
chatSessionId: 'chat-1',
request: {
requestedSchema: {
type: 'object',
properties: {
contact: {
type: 'string',
format: 'email',
},
regions: {
type: 'array',
minItems: 1,
items: { enum: ['us-east'] },
},
},
},
},
}}
onRespond={async (action, content) => {
responses.push({ action, content });
}}
/>,
);
});
const emailInput = renderer!.root.findByProps({ type: 'email' });
const regionInput = renderer!.root.findByProps({ type: 'checkbox' });
await act(async () => {
emailInput.props.onChange({ target: { value: 'cat@example.com' } });
regionInput.props.onChange({ target: { checked: true } });
});
await act(async () => {
emailInput.props.onChange({ target: { value: '' } });
regionInput.props.onChange({ target: { checked: false } });
});
const continueButton = renderer!.root
.findAllByType('button')
.find((button) => button.children.join('') === 'ai.codebuddy.elicitation.accept');
assert.ok(continueButton);
assert.equal(continueButton.props.disabled, false);
await act(async () => {
continueButton.props.onClick();
await Promise.resolve();
});
assert.deepEqual(responses, [{ action: 'accept', content: {} }]);
await act(async () => {
renderer!.unmount();
});
});
+188 -165
View File
@@ -1,80 +1,40 @@
import React, { useMemo, useState } from 'react';
import React, { useCallback, useId, useMemo, useState } from 'react';
import { MessageCircleQuestion } from 'lucide-react';
import { useI18n } from '../../application/i18n/I18nProvider';
import {
buildCodebuddyElicitationContent,
initialCodebuddyElicitationValues,
parseCodebuddyElicitationFields,
selectedCodebuddyOptionKey,
toggleCodebuddyArrayOption,
validateCodebuddyElicitationValues,
type CodebuddyElicitationField,
type CodebuddyElicitationValidationIssue,
} from '../../domain/codebuddyElicitationForm';
import type {
CodebuddyElicitation,
CodebuddyElicitationAction,
} from '../../infrastructure/ai/shared/codebuddyElicitations';
import { Button } from '../ui/button';
interface ElicitationField {
id: string;
title: string;
description: string;
type: string;
required: boolean;
defaultValue?: unknown;
options: Array<{ value: string; label: string }>;
minimum?: number;
maximum?: number;
function validationMessage(
validationIssue: CodebuddyElicitationValidationIssue,
t: ReturnType<typeof useI18n>['t'],
): string {
const values = {
field: validationIssue.fieldTitle,
limit: validationIssue.limit,
format: validationIssue.format,
};
return t(`ai.codebuddy.elicitation.validation.${validationIssue.code}`, values);
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function fieldOptions(schema: Record<string, unknown>): Array<{ value: string; label: string }> {
if (Array.isArray(schema.enum)) {
const labels = Array.isArray(schema.enumNames) ? schema.enumNames : [];
return schema.enum.map((value, index) => ({
value: String(value),
label: String(labels[index] ?? value),
}));
}
const variants = Array.isArray(schema.oneOf)
? schema.oneOf
: Array.isArray(schema.anyOf)
? schema.anyOf
: [];
return variants.flatMap((variant) => {
const option = asRecord(variant);
return option.const == null
? []
: [{ value: String(option.const), label: String(option.title ?? option.const) }];
});
}
function parseFields(elicitation: CodebuddyElicitation): ElicitationField[] {
const schema = asRecord(elicitation.request.requestedSchema);
const properties = asRecord(schema.properties);
const required = new Set(
Array.isArray(schema.required) ? schema.required.map((value) => String(value)) : [],
);
return Object.entries(properties).map(([id, rawField]) => {
const field = asRecord(rawField);
const type = String(field.type || 'string');
return {
id,
title: String(field.title || id),
description: String(field.description || ''),
type,
required: required.has(id),
defaultValue: field.default,
options: fieldOptions(type === 'array' ? asRecord(field.items) : field),
minimum: typeof field.minimum === 'number' ? field.minimum : undefined,
maximum: typeof field.maximum === 'number' ? field.maximum : undefined,
};
});
}
function initialValues(fields: ElicitationField[]): Record<string, unknown> {
return Object.fromEntries(
fields
.filter((field) => field.defaultValue !== undefined)
.map((field) => [field.id, field.defaultValue]),
);
function inputType(field: CodebuddyElicitationField): React.HTMLInputTypeAttribute {
if (field.type === 'number' || field.type === 'integer') return 'number';
if (field.format === 'email') return 'email';
if (field.format === 'uri') return 'url';
if (field.format === 'date') return 'date';
return 'text';
}
export const CodebuddyElicitationCard: React.FC<{
@@ -85,18 +45,31 @@ export const CodebuddyElicitationCard: React.FC<{
) => Promise<void>;
}> = ({ elicitation, onRespond }) => {
const { t } = useI18n();
const fields = useMemo(() => parseFields(elicitation), [elicitation]);
const [values, setValues] = useState<Record<string, unknown>>(() => initialValues(fields));
const formId = useId();
const fields = useMemo(
() => parseCodebuddyElicitationFields(elicitation.request.requestedSchema),
[elicitation.request.requestedSchema],
);
const [values, setValues] = useState<Record<string, unknown>>(
() => initialCodebuddyElicitationValues(fields),
);
const [touchedFields, setTouchedFields] = useState<Set<string>>(() => new Set());
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const validationIssues = validateCodebuddyElicitationValues(fields, values);
const validationByField = new Map(
validationIssues.map((validationIssue) => [validationIssue.fieldId, validationIssue]),
);
const complete = validationIssues.length === 0;
const complete = fields.every((field) => {
if (!field.required) return true;
const value = values[field.id];
if (field.type === 'boolean') return typeof value === 'boolean';
if (field.type === 'array') return Array.isArray(value) && value.length > 0;
return value !== undefined && String(value).trim().length > 0;
});
const markFieldTouched = useCallback((fieldId: string) => {
setTouchedFields((current) => {
if (current.has(fieldId)) return current;
const next = new Set(current);
next.add(fieldId);
return next;
});
}, []);
const respond = async (
action: CodebuddyElicitationAction,
@@ -125,92 +98,141 @@ export const CodebuddyElicitationCard: React.FC<{
</div>
</div>
{fields.map((field) => (
<div key={field.id} className="block space-y-1.5">
<span className="text-xs font-medium">
{field.title}{field.required ? ' *' : ''}
</span>
{field.description ? (
<span className="block text-[11px] text-muted-foreground">{field.description}</span>
) : null}
{field.type === 'array' && field.options.length > 0 ? (
<div className="space-y-1.5">
{field.options.map((option) => {
const selected = Array.isArray(values[field.id])
? values[field.id] as unknown[]
: [];
return (
<label
key={option.value}
className="flex items-center gap-2 rounded-md border border-border/50 px-2.5 py-2 text-xs"
>
<input
type="checkbox"
checked={selected.map(String).includes(option.value)}
disabled={submitting}
onChange={(event) => {
const next = event.target.checked
? [...selected, option.value]
: selected.filter((value) => String(value) !== option.value);
setValues((current) => ({ ...current, [field.id]: next }));
}}
/>
<span>{option.label}</span>
</label>
);
})}
</div>
) : field.options.length > 0 ? (
<select
value={String(values[field.id] ?? '')}
disabled={submitting}
onChange={(event) => setValues((current) => ({
...current,
[field.id]: event.target.value,
}))}
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="">{t('ai.codebuddy.elicitation.select')}</option>
{field.options.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
) : field.type === 'boolean' ? (
<select
value={values[field.id] === undefined ? '' : String(values[field.id])}
disabled={submitting}
onChange={(event) => {
const value = event.target.value === ''
? undefined
: event.target.value === 'true';
setValues((current) => ({ ...current, [field.id]: value }));
}}
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="">{t('ai.codebuddy.elicitation.select')}</option>
<option value="true">{t('ai.codebuddy.elicitation.yes')}</option>
<option value="false">{t('ai.codebuddy.elicitation.no')}</option>
</select>
) : (
<input
type={field.type === 'number' || field.type === 'integer' ? 'number' : 'text'}
min={field.minimum}
max={field.maximum}
step={field.type === 'integer' ? 1 : undefined}
value={String(values[field.id] ?? '')}
disabled={submitting}
onChange={(event) => {
const raw = event.target.value;
const value = field.type === 'number' || field.type === 'integer'
? (raw === '' ? undefined : Number(raw))
: raw;
setValues((current) => ({ ...current, [field.id]: value }));
}}
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
)}
</div>
))}
{fields.map((field, fieldIndex) => {
const validationIssue = validationByField.get(field.id);
const visibleValidationIssue = validationIssue
&& (values[field.id] !== undefined || touchedFields.has(field.id))
? validationIssue
: undefined;
const errorId = visibleValidationIssue
? `${formId}-field-${fieldIndex}-error`
: undefined;
return (
<div key={field.id} className="block space-y-1.5">
<span className="text-xs font-medium">
{field.title}{field.required ? ' *' : ''}
</span>
{field.description ? (
<span className="block text-[11px] text-muted-foreground">{field.description}</span>
) : null}
{field.type === 'array' && field.options.length > 0 ? (
<div
className="space-y-1.5"
role="group"
aria-invalid={Boolean(visibleValidationIssue)}
aria-describedby={errorId}
>
{field.options.map((option) => {
const selected = Array.isArray(values[field.id])
? values[field.id] as unknown[]
: [];
const checked = selected.some((value) => Object.is(value, option.value));
const maximumReached = field.maxItems !== undefined
&& selected.length >= field.maxItems;
return (
<label
key={option.key}
className="flex items-center gap-2 rounded-md border border-border/50 px-2.5 py-2 text-xs"
>
<input
type="checkbox"
checked={checked}
disabled={submitting || (!checked && maximumReached)}
onChange={(event) => {
markFieldTouched(field.id);
setValues((current) => {
const next = toggleCodebuddyArrayOption(
current[field.id],
option.value,
event.target.checked,
);
return {
...current,
[field.id]: next.length === 0 ? undefined : next,
};
});
}}
/>
<span>{option.label}</span>
</label>
);
})}
</div>
) : field.options.length > 0 ? (
<select
value={selectedCodebuddyOptionKey(field.options, values[field.id])}
disabled={submitting}
aria-invalid={Boolean(visibleValidationIssue)}
aria-describedby={errorId}
onChange={(event) => {
markFieldTouched(field.id);
const option = field.options.find(
(candidate) => candidate.key === event.target.value,
);
setValues((current) => ({
...current,
[field.id]: option?.value,
}));
}}
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="">{t('ai.codebuddy.elicitation.select')}</option>
{field.options.map((option) => (
<option key={option.key} value={option.key}>{option.label}</option>
))}
</select>
) : field.type === 'boolean' ? (
<select
value={values[field.id] === undefined ? '' : String(values[field.id])}
disabled={submitting}
aria-invalid={Boolean(visibleValidationIssue)}
aria-describedby={errorId}
onChange={(event) => {
markFieldTouched(field.id);
const value = event.target.value === ''
? undefined
: event.target.value === 'true';
setValues((current) => ({ ...current, [field.id]: value }));
}}
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="">{t('ai.codebuddy.elicitation.select')}</option>
<option value="true">{t('ai.codebuddy.elicitation.yes')}</option>
<option value="false">{t('ai.codebuddy.elicitation.no')}</option>
</select>
) : (
<input
type={inputType(field)}
min={field.minimum}
max={field.maximum}
minLength={field.type === 'string' ? field.minLength : undefined}
maxLength={field.type === 'string' ? field.maxLength : undefined}
step={field.type === 'integer' ? 1 : undefined}
value={String(values[field.id] ?? '')}
disabled={submitting}
aria-invalid={Boolean(visibleValidationIssue)}
aria-describedby={errorId}
onChange={(event) => {
markFieldTouched(field.id);
const raw = event.target.value;
const value = raw === ''
? undefined
: field.type === 'number' || field.type === 'integer'
? Number(raw)
: raw;
setValues((current) => ({ ...current, [field.id]: value }));
}}
className="h-8 w-full rounded-md border border-input bg-background px-2.5 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
)}
{visibleValidationIssue ? (
<p id={errorId} role="alert" className="text-[11px] text-destructive">
{validationMessage(visibleValidationIssue, t)}
</p>
) : null}
</div>
);
})}
{error ? <p className="text-xs text-destructive">{error}</p> : null}
@@ -234,9 +256,10 @@ export const CodebuddyElicitationCard: React.FC<{
<Button
size="sm"
disabled={submitting || !complete}
onClick={() => void respond('accept', Object.fromEntries(
Object.entries(values).filter(([, value]) => value !== undefined),
))}
onClick={() => void respond(
'accept',
buildCodebuddyElicitationContent(values, fields),
)}
>
{t('ai.codebuddy.elicitation.accept')}
</Button>
+233
View File
@@ -0,0 +1,233 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildCodebuddyElicitationContent,
initialCodebuddyElicitationValues,
parseCodebuddyElicitationFields,
selectedCodebuddyOptionKey,
toggleCodebuddyArrayOption,
validateCodebuddyElicitationValues,
} from './codebuddyElicitationForm';
test('CodeBuddy elicitation options preserve typed enum and const values', () => {
const fields = parseCodebuddyElicitationFields({
type: 'object',
properties: {
retries: {
type: 'integer',
enum: [1, 2],
default: 1,
},
enabled: {
type: 'boolean',
oneOf: [
{ const: true, title: 'Enabled' },
{ const: false, title: 'Disabled' },
],
},
mixed: {
type: 'array',
items: {
enum: [1, '1', false],
},
},
},
});
assert.equal(fields[0].options[1].value, 2);
assert.equal(fields[1].options[1].value, false);
assert.equal(selectedCodebuddyOptionKey(fields[0].options, 1), 'enum:0');
assert.deepEqual(initialCodebuddyElicitationValues(fields), { retries: 1 });
let mixed: unknown[] = [];
mixed = toggleCodebuddyArrayOption(mixed, 1, true);
mixed = toggleCodebuddyArrayOption(mixed, '1', true);
mixed = toggleCodebuddyArrayOption(mixed, false, true);
mixed = toggleCodebuddyArrayOption(mixed, '1', true);
assert.deepEqual(mixed, [1, '1', false]);
assert.deepEqual(buildCodebuddyElicitationContent({
retries: 2,
enabled: false,
mixed,
omitted: undefined,
}), {
retries: 2,
enabled: false,
mixed: [1, '1', false],
});
});
test('CodeBuddy elicitation content excludes stale fields from a replaced schema', () => {
const fields = parseCodebuddyElicitationFields({
type: 'object',
properties: {
current: { type: 'string' },
},
});
assert.deepEqual(buildCodebuddyElicitationContent({
stale: 'must not leak',
current: 'kept',
omitted: undefined,
}, fields), {
current: 'kept',
});
});
test('CodeBuddy elicitation validation enforces numeric and array constraints', () => {
const fields = parseCodebuddyElicitationFields({
type: 'object',
properties: {
retries: {
type: 'integer',
title: 'Retries',
minimum: 10,
maximum: 20,
},
choices: {
type: 'array',
title: 'Choices',
minItems: 2,
maxItems: 2,
items: { enum: ['a', 'b', 'c'] },
},
},
required: ['retries', 'choices'],
});
assert.deepEqual(
validateCodebuddyElicitationValues(fields, {
retries: 1,
choices: ['a'],
}).map(({ fieldId, code, limit }) => ({ fieldId, code, limit })),
[
{ fieldId: 'retries', code: 'minimum', limit: 10 },
{ fieldId: 'choices', code: 'minItems', limit: 2 },
],
);
assert.deepEqual(validateCodebuddyElicitationValues(fields, {
retries: 10.5,
choices: ['a', 'b', 'c'],
}).map(({ fieldId, code, limit }) => ({ fieldId, code, limit })), [
{ fieldId: 'retries', code: 'notInteger', limit: undefined },
{ fieldId: 'choices', code: 'maxItems', limit: 2 },
]);
assert.deepEqual(validateCodebuddyElicitationValues(fields, {
retries: 12,
choices: ['a', 'b'],
}), []);
});
test('CodeBuddy elicitation validation reports notInteger for fractional numeric input', () => {
const fields = parseCodebuddyElicitationFields({
type: 'object',
properties: {
retries: {
type: 'integer',
title: 'Retries',
minimum: 2,
maximum: 5,
},
},
required: ['retries'],
});
const issues = validateCodebuddyElicitationValues(fields, { retries: 3.5 });
assert.equal(issues.length, 1);
assert.equal(issues[0].code, 'notInteger');
assert.equal(issues[0].fieldId, 'retries');
assert.equal(issues[0].fieldTitle, 'Retries');
});
test('CodeBuddy elicitation validation enforces string lengths, formats, and options', () => {
const fields = parseCodebuddyElicitationFields({
type: 'object',
properties: {
name: {
type: 'string',
minLength: 2,
maxLength: 3,
},
email: {
type: 'string',
format: 'email',
},
date: {
type: 'string',
format: 'date',
},
dateTime: {
type: 'string',
format: 'date-time',
},
environment: {
type: 'string',
enum: ['staging', 'production'],
},
optional: {
type: 'string',
default: null,
},
},
required: ['name', 'email', 'date', 'dateTime', 'environment'],
});
assert.deepEqual(initialCodebuddyElicitationValues(fields), {});
assert.deepEqual(
validateCodebuddyElicitationValues(fields, {
name: 'a',
email: 'not-an-email',
date: '2026-02-30',
dateTime: '2026-02-30T12:00:00Z',
environment: 'unknown',
}).map(({ fieldId, code }) => ({ fieldId, code })),
[
{ fieldId: 'name', code: 'minLength' },
{ fieldId: 'email', code: 'format' },
{ fieldId: 'date', code: 'format' },
{ fieldId: 'dateTime', code: 'format' },
{ fieldId: 'environment', code: 'option' },
],
);
assert.deepEqual(validateCodebuddyElicitationValues(fields, {
name: '猫猫',
email: 'cat@example.com',
date: '2026-07-27',
dateTime: '2026-07-27T15:30:00+08:00',
environment: 'staging',
}), []);
});
test('CodeBuddy elicitation validation follows standard email and date-time boundaries', () => {
const fields = parseCodebuddyElicitationFields({
type: 'object',
properties: {
email: {
type: 'string',
format: 'email',
},
dateTime: {
type: 'string',
format: 'date-time',
},
},
required: ['email', 'dateTime'],
});
assert.deepEqual(validateCodebuddyElicitationValues(fields, {
email: 'cat+alerts@example.com',
dateTime: '2026-07-27t15:30:00z',
}), []);
assert.deepEqual(validateCodebuddyElicitationValues(fields, {
email: 'cat@example.com',
dateTime: '1990-12-31T23:59:60Z',
}), []);
assert.deepEqual(
validateCodebuddyElicitationValues(fields, {
email: 'cat@example..com',
dateTime: '2026-07-27T15:30:00Z',
}).map(({ fieldId, code }) => ({ fieldId, code })),
[{ fieldId: 'email', code: 'format' }],
);
});
+309
View File
@@ -0,0 +1,309 @@
export interface CodebuddyElicitationOption {
key: string;
value: unknown;
label: string;
}
export interface CodebuddyElicitationField {
id: string;
title: string;
description: string;
type: string;
required: boolean;
defaultValue?: unknown;
options: CodebuddyElicitationOption[];
minimum?: number;
maximum?: number;
minLength?: number;
maxLength?: number;
minItems?: number;
maxItems?: number;
format?: string;
}
export type CodebuddyElicitationValidationCode =
| 'required'
| 'invalidType'
| 'integer'
| 'notInteger'
| 'minimum'
| 'maximum'
| 'minLength'
| 'maxLength'
| 'minItems'
| 'maxItems'
| 'format'
| 'option';
export interface CodebuddyElicitationValidationIssue {
fieldId: string;
fieldTitle: string;
code: CodebuddyElicitationValidationCode;
limit?: number;
format?: string;
}
const EMAIL_PATTERN =
/^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i;
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const DATE_TIME_PATTERN =
/^(\d{4}-\d{2}-\d{2})[Tt ](\d{2}):(\d{2}):(\d{2}(?:\.\d+)?)(?:[Zz]|([+-])(\d{2})(?::?(\d{2}))?)$/;
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function optionalNumber(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
function fieldOptions(schema: Record<string, unknown>): CodebuddyElicitationOption[] {
if (Array.isArray(schema.enum)) {
const labels = Array.isArray(schema.enumNames) ? schema.enumNames : [];
return schema.enum.map((value, index) => ({
key: `enum:${index}`,
value,
label: String(labels[index] ?? value),
}));
}
const variants = Array.isArray(schema.oneOf)
? schema.oneOf
: Array.isArray(schema.anyOf)
? schema.anyOf
: [];
return variants.flatMap((variant, index) => {
const option = asRecord(variant);
return !Object.prototype.hasOwnProperty.call(option, 'const')
? []
: [{
key: `variant:${index}`,
value: option.const,
label: String(option.title ?? option.const),
}];
});
}
export function parseCodebuddyElicitationFields(
requestedSchema: unknown,
): CodebuddyElicitationField[] {
const schema = asRecord(requestedSchema);
const properties = asRecord(schema.properties);
const required = new Set(
Array.isArray(schema.required) ? schema.required.map((value) => String(value)) : [],
);
return Object.entries(properties).map(([id, rawField]) => {
const field = asRecord(rawField);
const type = String(field.type || 'string');
return {
id,
title: String(field.title || id),
description: String(field.description || ''),
type,
required: required.has(id),
defaultValue: field.default === null ? undefined : field.default,
options: fieldOptions(type === 'array' ? asRecord(field.items) : field),
minimum: optionalNumber(field.minimum),
maximum: optionalNumber(field.maximum),
minLength: optionalNumber(field.minLength),
maxLength: optionalNumber(field.maxLength),
minItems: optionalNumber(field.minItems),
maxItems: optionalNumber(field.maxItems),
format: typeof field.format === 'string' ? field.format : undefined,
};
});
}
export function initialCodebuddyElicitationValues(
fields: CodebuddyElicitationField[],
): Record<string, unknown> {
return Object.fromEntries(
fields
.filter((field) => field.defaultValue !== undefined)
.map((field) => [field.id, field.defaultValue]),
);
}
export function selectedCodebuddyOptionKey(
options: CodebuddyElicitationOption[],
value: unknown,
): string {
return options.find((option) => Object.is(option.value, value))?.key || '';
}
export function toggleCodebuddyArrayOption(
currentValue: unknown,
optionValue: unknown,
checked: boolean,
): unknown[] {
const selected = Array.isArray(currentValue) ? currentValue : [];
if (checked) {
return selected.some((value) => Object.is(value, optionValue))
? selected
: [...selected, optionValue];
}
return selected.filter((value) => !Object.is(value, optionValue));
}
export function buildCodebuddyElicitationContent(
values: Record<string, unknown>,
fields?: CodebuddyElicitationField[],
): Record<string, unknown> {
const allowedFieldIds = fields
? new Set(fields.map((field) => field.id))
: null;
return Object.fromEntries(
Object.entries(values).filter(
([fieldId, value]) => value !== undefined
&& (!allowedFieldIds || allowedFieldIds.has(fieldId)),
),
);
}
function hasOptionValue(
options: CodebuddyElicitationOption[],
value: unknown,
): boolean {
return options.some((option) => Object.is(option.value, value));
}
function isValidDate(value: string): boolean {
if (!DATE_PATTERN.test(value)) return false;
const date = new Date(`${value}T00:00:00.000Z`);
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
}
function isValidDateTime(value: string): boolean {
const match = DATE_TIME_PATTERN.exec(value);
if (!match || !isValidDate(match[1])) return false;
const hour = Number(match[2]);
const minute = Number(match[3]);
const second = Number(match[4]);
const offsetSign = match[5] === '-' ? -1 : 1;
const offsetHour = match[6] === undefined ? 0 : Number(match[6]);
const offsetMinute = match[7] === undefined ? 0 : Number(match[7]);
if (offsetHour > 23 || offsetMinute > 59) return false;
if (hour <= 23 && minute <= 59 && second < 60) return true;
const utcMinute = minute - offsetMinute * offsetSign;
const utcHour = hour - offsetHour * offsetSign - (utcMinute < 0 ? 1 : 0);
return (utcHour === 23 || utcHour === -1)
&& (utcMinute === 59 || utcMinute === -1)
&& second < 61;
}
function matchesFormat(value: string, format: string | undefined): boolean {
if (!format) return true;
if (format === 'email') {
return EMAIL_PATTERN.test(value);
}
if (format === 'uri') {
try {
return Boolean(new URL(value).protocol);
} catch {
return false;
}
}
if (format === 'date') {
return isValidDate(value);
}
if (format === 'date-time') {
return isValidDateTime(value);
}
return true;
}
function issue(
field: CodebuddyElicitationField,
code: CodebuddyElicitationValidationCode,
details: Pick<CodebuddyElicitationValidationIssue, 'limit' | 'format'> = {},
): CodebuddyElicitationValidationIssue {
return {
fieldId: field.id,
fieldTitle: field.title,
code,
...details,
};
}
function validateField(
field: CodebuddyElicitationField,
value: unknown,
): CodebuddyElicitationValidationIssue | null {
if (value === undefined) {
return field.required ? issue(field, 'required') : null;
}
if (field.type === 'boolean') {
if (typeof value !== 'boolean') return issue(field, 'invalidType');
if (field.options.length > 0 && !hasOptionValue(field.options, value)) {
return issue(field, 'option');
}
return null;
}
if (field.type === 'array') {
if (!Array.isArray(value)) return issue(field, 'invalidType');
if (field.required && value.length === 0) return issue(field, 'required');
if (field.minItems !== undefined && value.length < field.minItems) {
return issue(field, 'minItems', { limit: field.minItems });
}
if (field.maxItems !== undefined && value.length > field.maxItems) {
return issue(field, 'maxItems', { limit: field.maxItems });
}
if (
field.options.length > 0 &&
value.some((selected) => !hasOptionValue(field.options, selected))
) {
return issue(field, 'option');
}
return null;
}
if (field.type === 'number' || field.type === 'integer') {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return issue(field, 'invalidType');
}
if (field.type === 'integer' && !Number.isInteger(value)) {
return issue(field, 'notInteger');
}
if (field.minimum !== undefined && value < field.minimum) {
return issue(field, 'minimum', { limit: field.minimum });
}
if (field.maximum !== undefined && value > field.maximum) {
return issue(field, 'maximum', { limit: field.maximum });
}
if (field.options.length > 0 && !hasOptionValue(field.options, value)) {
return issue(field, 'option');
}
return null;
}
if (typeof value !== 'string') return issue(field, 'invalidType');
if (field.required && value.trim().length === 0) return issue(field, 'required');
const length = [...value].length;
if (field.minLength !== undefined && length < field.minLength) {
return issue(field, 'minLength', { limit: field.minLength });
}
if (field.maxLength !== undefined && length > field.maxLength) {
return issue(field, 'maxLength', { limit: field.maxLength });
}
if (!matchesFormat(value, field.format)) {
return issue(field, 'format', { format: field.format });
}
if (field.options.length > 0 && !hasOptionValue(field.options, value)) {
return issue(field, 'option');
}
return null;
}
export function validateCodebuddyElicitationValues(
fields: CodebuddyElicitationField[],
values: Record<string, unknown>,
): CodebuddyElicitationValidationIssue[] {
return fields.flatMap((field) => {
const validationIssue = validateField(field, values[field.id]);
return validationIssue ? [validationIssue] : [];
});
}
@@ -0,0 +1,43 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type {
ElicitationCreateRequest,
ElicitationHandler,
Options,
SessionOptions,
} from '@tencent-ai/agent-sdk';
test('CodeBuddy SDK Options and SessionOptions support elicitation handlers', async () => {
const calls: string[] = [];
const handler: ElicitationHandler = {
async create(_request, _options) {
calls.push('create');
return { action: 'accept', content: { confirmed: true } };
},
complete() {
calls.push('complete');
},
};
const queryOptions = { elicitation: handler } satisfies Options;
const sessionOptions = { elicitation: handler } satisfies SessionOptions;
const request: ElicitationCreateRequest = {
sessionId: 'contract-session',
mode: 'form',
message: 'Confirm?',
requestedSchema: {
type: 'object',
properties: { confirmed: { type: 'boolean' } },
},
};
assert.equal(queryOptions.elicitation, handler);
assert.equal(sessionOptions.elicitation, handler);
assert.deepEqual(
await sessionOptions.elicitation.create(request, {
signal: new AbortController().signal,
}),
{ action: 'accept', content: { confirmed: true } },
);
await sessionOptions.elicitation.complete?.({ elicitationId: 'contract-elicit' });
assert.deepEqual(calls, ['create', 'complete']);
});
@@ -86,3 +86,27 @@ test('CodeBuddy elicitation gate replays, responds, completes, and clears by cha
value: previousWindow,
});
});
test('CodeBuddy elicitation registration assigns a new instance to reused protocol ids', () => {
const received: CodebuddyElicitation[] = [];
const unsubscribe = onCodebuddyElicitation((elicitation) => received.push(elicitation));
registerCodebuddyElicitation({
elicitationId: 'reused-id',
chatSessionId: 'chat-1',
request: { message: 'First request' },
});
registerCodebuddyElicitation({
elicitationId: 'reused-id',
chatSessionId: 'chat-1',
request: { message: 'Replacement request' },
});
assert.equal(received.length, 2);
assert.equal(typeof received[0].requestInstanceId, 'number');
assert.equal(typeof received[1].requestInstanceId, 'number');
assert.notEqual(received[0].requestInstanceId, received[1].requestInstanceId);
completeCodebuddyElicitation({ elicitationId: 'reused-id' });
unsubscribe();
});
@@ -13,6 +13,7 @@ export interface CodebuddyElicitation {
elicitationId: string;
chatSessionId: string;
request: CodebuddyElicitationRequest;
requestInstanceId?: number;
}
type ElicitationListener = (elicitation: CodebuddyElicitation) => void;
@@ -21,6 +22,7 @@ type ClearedListener = (elicitationIds: string[]) => void;
const pendingElicitations = new Map<string, CodebuddyElicitation>();
const listeners = new Set<ElicitationListener>();
const clearedListeners = new Set<ClearedListener>();
let nextRequestInstanceId = 0;
function notifyCleared(elicitationIds: string[]): void {
if (elicitationIds.length === 0) return;
@@ -31,9 +33,13 @@ function notifyCleared(elicitationIds: string[]): void {
export function registerCodebuddyElicitation(elicitation: CodebuddyElicitation): void {
if (!elicitation.elicitationId) return;
pendingElicitations.set(elicitation.elicitationId, elicitation);
const registeredElicitation = {
...elicitation,
requestInstanceId: ++nextRequestInstanceId,
};
pendingElicitations.set(elicitation.elicitationId, registeredElicitation);
for (const listener of listeners) {
try { listener(elicitation); } catch { /* ignore listener failures */ }
try { listener(registeredElicitation); } catch { /* ignore listener failures */ }
}
}
+2 -1
View File
@@ -47,11 +47,12 @@
"generate:capability-tools": "node scripts/generate-capability-tools.cjs",
"generate:codex-app-server-schema": "node scripts/generate-codex-app-server-schema.cjs",
"check:codex-app-server-schema": "node scripts/generate-codex-app-server-schema.cjs --check",
"check:codebuddy-sdk-contract": "tsc -p tsconfig.codebuddy-sdk-contract.json",
"generate:plugin-contract": "node scripts/generate-plugin-contract.mjs",
"check:plugin-contract": "node scripts/generate-plugin-contract.mjs --check",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"pretest": "npm run build:plugin-runtime-deps",
"pretest": "npm run build:plugin-runtime-deps && npm run check:codebuddy-sdk-contract",
"test": "node --test --import tsx electron/*.test.cjs electron/scripts/*.test.cjs electron/terminalWorker/*.test.cjs electron/capabilities/*.test.cjs electron/capabilities/*/*.test.cjs electron/capabilities/*/*/*.test.cjs electron/cli/*.test.cjs electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs electron/bridges/aiBridge/codexAppServer/*.test.cjs electron/bridges/aiBridge/sdk/*.test.cjs scripts/*.test.cjs application/*.test.ts application/app/*.test.ts application/i18n/locales/*.test.ts application/state/*.test.ts application/state/*/*.test.ts components/*.test.ts components/*.test.tsx components/editor/*.test.ts components/editor/*.test.tsx components/notes/*.test.tsx components/port-forwarding/*.test.ts components/scripts/*.test.tsx components/systemManager/*.test.ts components/terminalLayer/*.test.ts components/settings/*.test.tsx components/settings/tabs/*.test.ts components/settings/tabs/ai/*.test.ts components/settings/tabs/ai/*.test.tsx components/ai/*.test.ts components/ai/*.test.tsx components/ai/toolArtifacts/*.test.ts components/ai/toolArtifacts/*.test.tsx components/ai-elements/*.test.tsx components/sftp/*.test.ts components/terminal/*.test.ts components/terminal/*.test.tsx components/terminal/runtime/*.test.ts components/vault/*.test.ts components/vault/*.test.tsx domain/*.test.ts domain/*/*.test.ts infrastructure/ai/*.test.ts infrastructure/ai/*/*.test.ts infrastructure/ai/sdk/*.test.ts infrastructure/config/*.test.ts infrastructure/persistence/*.test.ts infrastructure/services/*.test.ts infrastructure/services/*/*.test.ts lib/*.test.ts packages/*/src/*.test.ts",
"test:plugin-contract": "node --test --import tsx packages/*/src/*.test.ts",
"pretest:plugin-runtime": "npm run build:plugin-runtime-deps",
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"allowJs": false,
"allowImportingTsExtensions": false,
"isolatedModules": false,
"noEmit": true
},
"files": [
"infrastructure/ai/sdk/codebuddySdkContract.test.ts"
],
"include": []
}