@ryan_nookpi/pi-extension-ask-user-question 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/coerce.test.ts +193 -0
- package/coerce.ts +121 -0
- package/index.test.ts +54 -0
- package/index.ts +13 -7
- package/package.json +1 -1
- package/schema.test.ts +20 -0
- package/schema.ts +20 -6
package/README.md
CHANGED
|
@@ -88,3 +88,21 @@ pi install npm:@ryan_nookpi/pi-extension-ask-user-question
|
|
|
88
88
|
## 반환 형태
|
|
89
89
|
|
|
90
90
|
응답은 각 질문의 `id` 기준으로 정리되어 반환됩니다. 취소 시에는 `cancelled: true`가 내려오고, 완료 시에는 질문별 답변 목록이 포함됩니다.
|
|
91
|
+
|
|
92
|
+
## 허용되는 느슨한 입력 형태
|
|
93
|
+
|
|
94
|
+
위 스키마가 권장 형식이지만, 다음과 같은 변형 입력도 자동으로 정규화해서 처리합니다.
|
|
95
|
+
|
|
96
|
+
- `questions`를 JSON 문자열로 직렬화해 전달: `{ "questions": "[ ... ]" }`
|
|
97
|
+
- 질문 프롬프트를 `prompt` 대신 `question` 필드로 전달
|
|
98
|
+
- `options`를 `{ value, label }` 객체가 아닌 문자열 배열로 전달 (동일 값으로 승격)
|
|
99
|
+
- `options`를 JSON 문자열로 직렬화해 전달
|
|
100
|
+
- `id` 생략 시 순서대로 `q1`, `q2`, ...로 자동 부여
|
|
101
|
+
|
|
102
|
+
예:
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"questions": "[{\"type\": \"radio\", \"question\": \"extension 이름\", \"options\": [\"claude-code-use\", \"pi-claude-code-use\"], \"allowOther\": true}]"
|
|
107
|
+
}
|
|
108
|
+
```
|
package/coerce.test.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { coerceAskUserQuestionParams } from "./coerce.ts";
|
|
4
|
+
|
|
5
|
+
describe("coerceAskUserQuestionParams", () => {
|
|
6
|
+
it("구조화된 파라미터를 그대로 보존한다", () => {
|
|
7
|
+
const input = {
|
|
8
|
+
title: "Title",
|
|
9
|
+
description: "Desc",
|
|
10
|
+
questions: [
|
|
11
|
+
{
|
|
12
|
+
id: "env",
|
|
13
|
+
type: "radio",
|
|
14
|
+
prompt: "어느 환경?",
|
|
15
|
+
options: [
|
|
16
|
+
{ value: "staging", label: "스테이징" },
|
|
17
|
+
{ value: "prod", label: "프로덕션", description: "실서비스" },
|
|
18
|
+
],
|
|
19
|
+
allowOther: false,
|
|
20
|
+
required: true,
|
|
21
|
+
default: "staging",
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const result = coerceAskUserQuestionParams(input);
|
|
27
|
+
expect(result).toEqual(input);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("JSON 문자열로 전달된 questions를 파싱한다", () => {
|
|
31
|
+
const payload = {
|
|
32
|
+
questions: JSON.stringify([
|
|
33
|
+
{
|
|
34
|
+
type: "radio",
|
|
35
|
+
question: "파일 배치 구조",
|
|
36
|
+
options: ["directory", "single file", "single file + flat"],
|
|
37
|
+
allowOther: true,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
type: "checkbox",
|
|
41
|
+
question: "포함 여부",
|
|
42
|
+
options: ["LICENSE", "author attribution"],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
type: "text",
|
|
46
|
+
question: "추가 메모",
|
|
47
|
+
placeholder: "선택 사항",
|
|
48
|
+
required: false,
|
|
49
|
+
},
|
|
50
|
+
]),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const result = coerceAskUserQuestionParams(payload);
|
|
54
|
+
|
|
55
|
+
expect(result.questions).toHaveLength(3);
|
|
56
|
+
expect(result.questions[0]).toEqual({
|
|
57
|
+
id: "q1",
|
|
58
|
+
type: "radio",
|
|
59
|
+
prompt: "파일 배치 구조",
|
|
60
|
+
options: [
|
|
61
|
+
{ value: "directory", label: "directory" },
|
|
62
|
+
{ value: "single file", label: "single file" },
|
|
63
|
+
{ value: "single file + flat", label: "single file + flat" },
|
|
64
|
+
],
|
|
65
|
+
allowOther: true,
|
|
66
|
+
});
|
|
67
|
+
expect(result.questions[1].id).toBe("q2");
|
|
68
|
+
expect(result.questions[1].type).toBe("checkbox");
|
|
69
|
+
expect(result.questions[1].options).toEqual([
|
|
70
|
+
{ value: "LICENSE", label: "LICENSE" },
|
|
71
|
+
{ value: "author attribution", label: "author attribution" },
|
|
72
|
+
]);
|
|
73
|
+
expect(result.questions[2]).toEqual({
|
|
74
|
+
id: "q3",
|
|
75
|
+
type: "text",
|
|
76
|
+
prompt: "추가 메모",
|
|
77
|
+
placeholder: "선택 사항",
|
|
78
|
+
required: false,
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("question 별칭과 options JSON 문자열을 지원한다", () => {
|
|
83
|
+
const result = coerceAskUserQuestionParams({
|
|
84
|
+
questions: [
|
|
85
|
+
{
|
|
86
|
+
type: "radio",
|
|
87
|
+
question: "extension 이름",
|
|
88
|
+
options: JSON.stringify(["claude-code-use", "pi-claude-code-use"]),
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(result.questions[0].prompt).toBe("extension 이름");
|
|
94
|
+
expect(result.questions[0].options).toEqual([
|
|
95
|
+
{ value: "claude-code-use", label: "claude-code-use" },
|
|
96
|
+
{ value: "pi-claude-code-use", label: "pi-claude-code-use" },
|
|
97
|
+
]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("잘못된 입력을 조용히 걸러낸다", () => {
|
|
101
|
+
const result = coerceAskUserQuestionParams({
|
|
102
|
+
questions: [
|
|
103
|
+
null,
|
|
104
|
+
{ type: "radio" }, // prompt 없음 -> 제외
|
|
105
|
+
{ type: "unknown", prompt: "?" }, // 잘못된 type -> 제외
|
|
106
|
+
{ type: "text", prompt: "OK" },
|
|
107
|
+
"not-an-object",
|
|
108
|
+
],
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(result.questions).toHaveLength(1);
|
|
112
|
+
expect(result.questions[0]).toMatchObject({ id: "q4", type: "text", prompt: "OK" });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("questions 자체가 누락되거나 비-객체 입력이면 빈 배열을 돌려준다", () => {
|
|
116
|
+
expect(coerceAskUserQuestionParams(null).questions).toEqual([]);
|
|
117
|
+
expect(coerceAskUserQuestionParams(undefined).questions).toEqual([]);
|
|
118
|
+
expect(coerceAskUserQuestionParams({ questions: "not-json" }).questions).toEqual([]);
|
|
119
|
+
expect(coerceAskUserQuestionParams({ questions: '{"not":"array"}' }).questions).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("checkbox default가 배열이면 문자열만 남겨 보존한다", () => {
|
|
123
|
+
const result = coerceAskUserQuestionParams({
|
|
124
|
+
questions: [
|
|
125
|
+
{
|
|
126
|
+
type: "checkbox",
|
|
127
|
+
prompt: "포함할 항목",
|
|
128
|
+
options: ["a", "b", "c"],
|
|
129
|
+
default: ["a", 42, "b", null],
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
});
|
|
133
|
+
expect(result.questions[0].default).toEqual(["a", "b"]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("default 배열에서 유효 문자열이 없으면 기본값을 비워 둔다", () => {
|
|
137
|
+
const result = coerceAskUserQuestionParams({
|
|
138
|
+
questions: [
|
|
139
|
+
{
|
|
140
|
+
type: "checkbox",
|
|
141
|
+
prompt: "포함할 항목",
|
|
142
|
+
options: ["a", "b"],
|
|
143
|
+
default: [1, 2, null],
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
});
|
|
147
|
+
expect(result.questions[0].default).toBeUndefined();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("질문 수준의 label, empty/non-string 옵션, JSON 되는 빈 문자열 등 세부 변형을 사장 없이 무시한다", () => {
|
|
151
|
+
const result = coerceAskUserQuestionParams({
|
|
152
|
+
questions: [
|
|
153
|
+
{
|
|
154
|
+
id: "labelled",
|
|
155
|
+
type: "radio",
|
|
156
|
+
prompt: "완전한 질문",
|
|
157
|
+
label: "Short label",
|
|
158
|
+
options: [
|
|
159
|
+
" ", // empty string option -> dropped
|
|
160
|
+
42, // non-string, non-object option -> dropped
|
|
161
|
+
{ label: "Label only" }, // label -> promoted to value
|
|
162
|
+
{ value: "raw-value" }, // value -> promoted to label
|
|
163
|
+
{ description: "no label/value" }, // dropped
|
|
164
|
+
{ value: "v", label: "L", description: "with description" },
|
|
165
|
+
],
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
expect(result.questions[0].label).toBe("Short label");
|
|
171
|
+
expect(result.questions[0].options).toEqual([
|
|
172
|
+
{ value: "Label only", label: "Label only" },
|
|
173
|
+
{ value: "raw-value", label: "raw-value" },
|
|
174
|
+
{ value: "v", label: "L", description: "with description" },
|
|
175
|
+
]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("빈 문자열 questions 필드는 빈 배열로 간주한다", () => {
|
|
179
|
+
expect(coerceAskUserQuestionParams({ questions: " " }).questions).toEqual([]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("공백만 있는 prompt/label 문자열은 없는 것으로 간주한다", () => {
|
|
183
|
+
const result = coerceAskUserQuestionParams({
|
|
184
|
+
questions: [
|
|
185
|
+
{ type: "radio", prompt: " " }, // whitespace prompt -> dropped
|
|
186
|
+
{ type: "text", prompt: "valid", label: " " }, // whitespace label -> ignored
|
|
187
|
+
],
|
|
188
|
+
});
|
|
189
|
+
expect(result.questions).toHaveLength(1);
|
|
190
|
+
expect(result.questions[0]).toMatchObject({ type: "text", prompt: "valid" });
|
|
191
|
+
expect(result.questions[0].label).toBeUndefined();
|
|
192
|
+
});
|
|
193
|
+
});
|
package/coerce.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { AskUserQuestionParamsInput, Question, QuestionOption } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 느슨한 입력을 정규화된 파라미터로 변환한다.
|
|
5
|
+
*
|
|
6
|
+
* 허용하는 변형:
|
|
7
|
+
* - `questions`가 JSON 문자열이면 파싱한다.
|
|
8
|
+
* - 질문의 `question` 필드를 `prompt` 별칭으로 받는다.
|
|
9
|
+
* - `options`가 문자열 배열이면 `{ value, label }` 형태로 승격한다.
|
|
10
|
+
* - `options`가 JSON 문자열이면 파싱한다.
|
|
11
|
+
* - `id`가 없으면 `q1`, `q2` 형태로 자동 생성한다.
|
|
12
|
+
*/
|
|
13
|
+
export function coerceAskUserQuestionParams(raw: unknown): AskUserQuestionParamsInput {
|
|
14
|
+
if (raw == null || typeof raw !== "object") {
|
|
15
|
+
return { questions: [] };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const record = raw as Record<string, unknown>;
|
|
19
|
+
const title = typeof record.title === "string" ? record.title : undefined;
|
|
20
|
+
const description = typeof record.description === "string" ? record.description : undefined;
|
|
21
|
+
const questions = coerceQuestionList(record.questions);
|
|
22
|
+
|
|
23
|
+
return { title, description, questions };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function coerceQuestionList(value: unknown): Question[] {
|
|
27
|
+
const list = parseMaybeJsonArray(value);
|
|
28
|
+
if (!list) return [];
|
|
29
|
+
|
|
30
|
+
return list
|
|
31
|
+
.map((item, index) => coerceQuestion(item, index))
|
|
32
|
+
.filter((question): question is Question => question != null);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function coerceQuestion(value: unknown, index: number): Question | null {
|
|
36
|
+
if (value == null || typeof value !== "object") return null;
|
|
37
|
+
const record = value as Record<string, unknown>;
|
|
38
|
+
|
|
39
|
+
const type = coerceType(record.type);
|
|
40
|
+
if (!type) return null;
|
|
41
|
+
|
|
42
|
+
const prompt = coerceString(record.prompt) ?? coerceString(record.question);
|
|
43
|
+
if (!prompt) return null;
|
|
44
|
+
|
|
45
|
+
const id = coerceString(record.id) ?? `q${index + 1}`;
|
|
46
|
+
const question: Question = { id, type, prompt };
|
|
47
|
+
|
|
48
|
+
const label = coerceString(record.label);
|
|
49
|
+
if (label) question.label = label;
|
|
50
|
+
|
|
51
|
+
const options = coerceOptions(record.options);
|
|
52
|
+
if (options.length > 0) question.options = options;
|
|
53
|
+
|
|
54
|
+
if (typeof record.allowOther === "boolean") question.allowOther = record.allowOther;
|
|
55
|
+
if (typeof record.required === "boolean") question.required = record.required;
|
|
56
|
+
|
|
57
|
+
const placeholder = coerceString(record.placeholder);
|
|
58
|
+
if (placeholder) question.placeholder = placeholder;
|
|
59
|
+
|
|
60
|
+
const defaultValue = coerceDefault(record.default);
|
|
61
|
+
if (defaultValue !== undefined) question.default = defaultValue;
|
|
62
|
+
|
|
63
|
+
return question;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function coerceOptions(value: unknown): QuestionOption[] {
|
|
67
|
+
const list = parseMaybeJsonArray(value);
|
|
68
|
+
if (!list) return [];
|
|
69
|
+
|
|
70
|
+
return list.map((item) => coerceOption(item)).filter((option): option is QuestionOption => option != null);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function coerceOption(value: unknown): QuestionOption | null {
|
|
74
|
+
if (typeof value === "string") {
|
|
75
|
+
const trimmed = value.trim();
|
|
76
|
+
if (!trimmed) return null;
|
|
77
|
+
return { value: trimmed, label: trimmed };
|
|
78
|
+
}
|
|
79
|
+
if (value == null || typeof value !== "object") return null;
|
|
80
|
+
const record = value as Record<string, unknown>;
|
|
81
|
+
const rawValue = coerceString(record.value) ?? coerceString(record.label);
|
|
82
|
+
const rawLabel = coerceString(record.label) ?? coerceString(record.value);
|
|
83
|
+
if (!rawValue || !rawLabel) return null;
|
|
84
|
+
const option: QuestionOption = { value: rawValue, label: rawLabel };
|
|
85
|
+
const description = coerceString(record.description);
|
|
86
|
+
if (description) option.description = description;
|
|
87
|
+
return option;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function coerceType(value: unknown): "radio" | "checkbox" | "text" | null {
|
|
91
|
+
if (value === "radio" || value === "checkbox" || value === "text") return value;
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function coerceString(value: unknown): string | undefined {
|
|
96
|
+
if (typeof value !== "string") return undefined;
|
|
97
|
+
const trimmed = value.trim();
|
|
98
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function coerceDefault(value: unknown): string | string[] | undefined {
|
|
102
|
+
if (typeof value === "string") return value;
|
|
103
|
+
if (Array.isArray(value)) {
|
|
104
|
+
const strings = value.filter((item): item is string => typeof item === "string");
|
|
105
|
+
return strings.length > 0 ? strings : undefined;
|
|
106
|
+
}
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function parseMaybeJsonArray(value: unknown): unknown[] | null {
|
|
111
|
+
if (Array.isArray(value)) return value;
|
|
112
|
+
if (typeof value !== "string") return null;
|
|
113
|
+
const trimmed = value.trim();
|
|
114
|
+
if (!trimmed) return null;
|
|
115
|
+
try {
|
|
116
|
+
const parsed = JSON.parse(trimmed);
|
|
117
|
+
return Array.isArray(parsed) ? parsed : null;
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
package/index.test.ts
CHANGED
|
@@ -42,11 +42,65 @@ describe("ask-user-question extension", () => {
|
|
|
42
42
|
hasUI: true,
|
|
43
43
|
ui: { custom: vi.fn() },
|
|
44
44
|
} as unknown as ExtensionContext);
|
|
45
|
+
const invalidQuestions = await tool.execute(
|
|
46
|
+
"call-2b",
|
|
47
|
+
{ title: "Title", questions: "not-json" },
|
|
48
|
+
undefined,
|
|
49
|
+
undefined,
|
|
50
|
+
{ hasUI: true, ui: { custom: vi.fn() } } as unknown as ExtensionContext,
|
|
51
|
+
);
|
|
45
52
|
|
|
46
53
|
expect(noUi).toMatchObject({
|
|
47
54
|
content: [{ type: "text", text: "오류: UI를 사용할 수 없습니다. 현재 비대화형 모드에서 실행 중입니다." }],
|
|
48
55
|
});
|
|
49
56
|
expect(noQuestions).toMatchObject({ content: [{ type: "text", text: "오류: 질문이 제공되지 않았습니다." }] });
|
|
57
|
+
expect(invalidQuestions).toMatchObject({ content: [{ type: "text", text: "오류: 질문이 제공되지 않았습니다." }] });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("JSON 문자열로 직렬화된 questions도 폼 실행 경로로 진입한다", async () => {
|
|
61
|
+
const apiMock = createExtensionApiMock();
|
|
62
|
+
askUserQuestionExtension(apiMock.api);
|
|
63
|
+
const tool = apiMock.getTool("ask_user_question");
|
|
64
|
+
if (!tool.execute) throw new Error("execute is missing");
|
|
65
|
+
|
|
66
|
+
const customMock = vi.fn(async () => ({
|
|
67
|
+
title: undefined,
|
|
68
|
+
questions: normalizeQuestions([
|
|
69
|
+
{
|
|
70
|
+
id: "q1",
|
|
71
|
+
type: "radio",
|
|
72
|
+
prompt: "extension 이름",
|
|
73
|
+
options: [
|
|
74
|
+
{ value: "claude-code-use", label: "claude-code-use" },
|
|
75
|
+
{ value: "pi-claude-code-use", label: "pi-claude-code-use" },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
]),
|
|
79
|
+
answers: [{ id: "q1", type: "radio" as const, value: "claude-code-use", wasCustom: false }],
|
|
80
|
+
cancelled: false,
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
const serializedResult = await tool.execute(
|
|
84
|
+
"call-json",
|
|
85
|
+
{
|
|
86
|
+
questions: JSON.stringify([
|
|
87
|
+
{
|
|
88
|
+
type: "radio",
|
|
89
|
+
question: "extension 이름",
|
|
90
|
+
options: ["claude-code-use", "pi-claude-code-use"],
|
|
91
|
+
allowOther: true,
|
|
92
|
+
},
|
|
93
|
+
]),
|
|
94
|
+
},
|
|
95
|
+
undefined,
|
|
96
|
+
undefined,
|
|
97
|
+
{ hasUI: true, ui: { custom: customMock } } as unknown as ExtensionContext,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
expect(customMock).toHaveBeenCalledTimes(1);
|
|
101
|
+
expect(serializedResult).toMatchObject({
|
|
102
|
+
content: [{ type: "text", text: "Q1: claude-code-use" }],
|
|
103
|
+
});
|
|
50
104
|
});
|
|
51
105
|
|
|
52
106
|
it("returns cancelled and successful execution results", async () => {
|
package/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
2
|
|
|
3
|
+
import { coerceAskUserQuestionParams } from "./coerce.ts";
|
|
3
4
|
import { runAskUserQuestionForm } from "./form-ui.ts";
|
|
4
5
|
import {
|
|
5
6
|
buildRenderCallText,
|
|
@@ -44,7 +45,14 @@ function buildSuccessResponse(result: FormResult) {
|
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
export type { AskUserQuestionParamsInput, FormResult, Question };
|
|
47
|
-
export {
|
|
48
|
+
export {
|
|
49
|
+
AskUserQuestionParams,
|
|
50
|
+
buildRenderCallText,
|
|
51
|
+
buildRenderResultText,
|
|
52
|
+
coerceAskUserQuestionParams,
|
|
53
|
+
errorResult,
|
|
54
|
+
normalizeQuestions,
|
|
55
|
+
};
|
|
48
56
|
|
|
49
57
|
export default function askUserQuestion(pi: ExtensionAPI) {
|
|
50
58
|
pi.registerTool({
|
|
@@ -59,12 +67,12 @@ export default function askUserQuestion(pi: ExtensionAPI) {
|
|
|
59
67
|
return errorResult("오류: UI를 사용할 수 없습니다. 현재 비대화형 모드에서 실행 중입니다.");
|
|
60
68
|
}
|
|
61
69
|
|
|
62
|
-
const params = rawParams
|
|
70
|
+
const params = coerceAskUserQuestionParams(rawParams);
|
|
63
71
|
if (!params.questions.length) {
|
|
64
72
|
return errorResult("오류: 질문이 제공되지 않았습니다.");
|
|
65
73
|
}
|
|
66
74
|
|
|
67
|
-
const questions = normalizeQuestions(params.questions
|
|
75
|
+
const questions = normalizeQuestions(params.questions);
|
|
68
76
|
const result = await runAskUserQuestionForm(
|
|
69
77
|
ctx,
|
|
70
78
|
{ title: params.title, description: params.description },
|
|
@@ -73,10 +81,8 @@ export default function askUserQuestion(pi: ExtensionAPI) {
|
|
|
73
81
|
return result.cancelled ? buildCancelledResponse(result) : buildSuccessResponse(result);
|
|
74
82
|
},
|
|
75
83
|
renderCall(args, theme) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
theme,
|
|
79
|
-
);
|
|
84
|
+
const coerced = coerceAskUserQuestionParams(args);
|
|
85
|
+
return renderCall({ questions: coerced.questions, title: coerced.title }, theme);
|
|
80
86
|
},
|
|
81
87
|
renderResult(result, _options, theme) {
|
|
82
88
|
return renderResult(result as { content?: Array<{ type: string; text?: string }>; details?: FormResult }, theme);
|
package/package.json
CHANGED
package/schema.test.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Value } from "@sinclair/typebox/value";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { AskUserQuestionParams } from "./schema.ts";
|
|
5
|
+
|
|
6
|
+
describe("ask-user-question/schema", () => {
|
|
7
|
+
it("질문 배열 JSON 문자열과 느슨한 질문 필드를 허용한다", () => {
|
|
8
|
+
const payload = {
|
|
9
|
+
questions: JSON.stringify([
|
|
10
|
+
{
|
|
11
|
+
question: "오늘 기분이 어떠세요?",
|
|
12
|
+
type: "radio",
|
|
13
|
+
options: ["좋음", "보통", "나쁨"],
|
|
14
|
+
},
|
|
15
|
+
]),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
expect(Value.Check(AskUserQuestionParams, payload)).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
});
|
package/schema.ts
CHANGED
|
@@ -7,14 +7,25 @@ export const OptionSchema = Type.Object({
|
|
|
7
7
|
description: Type.Optional(Type.String({ description: "옵션 아래에 표시할 보조 설명" })),
|
|
8
8
|
});
|
|
9
9
|
|
|
10
|
+
export const LooseOptionSchema = Type.Union([
|
|
11
|
+
OptionSchema,
|
|
12
|
+
Type.String({ description: "문자열 선택지. value/label이 같은 값으로 처리됨" }),
|
|
13
|
+
]);
|
|
14
|
+
|
|
10
15
|
export const QuestionSchema = Type.Object({
|
|
11
|
-
id: Type.String({ description: "질문 고유
|
|
16
|
+
id: Type.Optional(Type.String({ description: "질문 고유 식별자. 생략 시 q1, q2...로 자동 생성" })),
|
|
12
17
|
type: StringEnum(["radio", "checkbox", "text"] as const, {
|
|
13
18
|
description: "질문 유형: radio(단일 선택), checkbox(복수 선택), text(자유 입력)",
|
|
14
19
|
}),
|
|
15
|
-
prompt: Type.String({ description: "사용자에게 표시할 질문 문구" }),
|
|
20
|
+
prompt: Type.Optional(Type.String({ description: "사용자에게 표시할 질문 문구" })),
|
|
21
|
+
question: Type.Optional(Type.String({ description: "prompt 별칭" })),
|
|
16
22
|
label: Type.Optional(Type.String({ description: "탭 바에 표시할 짧은 라벨(기본값: Q1, Q2...)" })),
|
|
17
|
-
options: Type.Optional(
|
|
23
|
+
options: Type.Optional(
|
|
24
|
+
Type.Union([
|
|
25
|
+
Type.Array(LooseOptionSchema, { description: "radio/checkbox용 선택지 목록" }),
|
|
26
|
+
Type.String({ description: "선택지 배열을 직렬화한 JSON 문자열" }),
|
|
27
|
+
]),
|
|
28
|
+
),
|
|
18
29
|
allowOther: Type.Optional(
|
|
19
30
|
Type.Boolean({ description: "'기타...' 직접 입력 옵션 추가 여부 (radio/checkbox 기본값: true)" }),
|
|
20
31
|
),
|
|
@@ -30,7 +41,10 @@ export const QuestionSchema = Type.Object({
|
|
|
30
41
|
export const AskUserQuestionParams = Type.Object({
|
|
31
42
|
title: Type.Optional(Type.String({ description: "폼 상단에 표시할 제목" })),
|
|
32
43
|
description: Type.Optional(Type.String({ description: "폼 상단에 표시할 안내 문구" })),
|
|
33
|
-
questions: Type.
|
|
34
|
-
|
|
35
|
-
|
|
44
|
+
questions: Type.Union([
|
|
45
|
+
Type.Array(QuestionSchema, {
|
|
46
|
+
description: "질문 목록. radio는 단일 선택, checkbox는 복수 선택, text는 자유 입력",
|
|
47
|
+
}),
|
|
48
|
+
Type.String({ description: "질문 배열을 직렬화한 JSON 문자열" }),
|
|
49
|
+
]),
|
|
36
50
|
});
|