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:
1 parent
d8f1c6cd15
commit
a4b0926ded
14 files changed
+1052
-169
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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': 'Агент по умолчанию',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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' }],
|
||||
);
|
||||
});
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": false,
|
||||
"allowImportingTsExtensions": false,
|
||||
"isolatedModules": false,
|
||||
"noEmit": true
|
||||
},
|
||||
"files": [
|
||||
"infrastructure/ai/sdk/codebuddySdkContract.test.ts"
|
||||
],
|
||||
"include": []
|
||||
}
|
||||
Reference in new issue
Block a user