@zhushanwen/pi-ask-user 1.0.0 → 1.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-ask-user",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Inline adaptive ask_user tool for Pi — single/multi-question structured input with split-pane preview, inline editor, and optional comments.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -204,6 +204,24 @@ describe("execute — validation (FR-2 / AC-8 / AC-13)", () => {
204
204
  );
205
205
  expect(result.details.cancelled).toBe(true);
206
206
  });
207
+
208
+ it("I-4b: string options (schema-relaxed) → execute → validateInput catches → isError + Correct hint", async () => {
209
+ // 端到端证明:schema 放宽(Union([OptionSchema, string]))后 string options
210
+ // 能穿过 TypeCompiler.Check、抵达 execute → validateInput 友好拦截。
211
+ // test-coverage reviewer 点名的“execute wiring 未测”缺口。
212
+ const tool = getTool();
213
+ const result = await tool.execute(
214
+ "id",
215
+ { questions: [{ question: "Q", options: ["A", "B"] }] },
216
+ undefined,
217
+ undefined,
218
+ makeCtx(),
219
+ );
220
+ expect(result.isError).toBe(true);
221
+ expect(result.details.cancelled).toBe(true);
222
+ expect(result.content[0].text).toContain("not strings");
223
+ expect(result.content[0].text).toContain("Correct");
224
+ });
207
225
  });
208
226
 
209
227
  // ── I-5 ~ I-7: Headless(FR-8 / AC-7)──────────────────
@@ -0,0 +1,95 @@
1
+ // src/__tests__/prompt-quality.test.ts
2
+ //
3
+ // 提示词质量回归:ask_user tool 的 description 与 validate.ts 文案必须能让弱模型
4
+ // 首次调用就用对参数形状,用错了也能拿到带 Correct 正例的纠正。
5
+ //
6
+ // 背景(系统性债务):
7
+ // - description 缺 JSON 正例:弱模型最高频错误是把 options 当字符串数组传
8
+ // ("options":["A","B"] 而非 [{"label","description"}])。
9
+ // - 条件必填(header)用 Type.Optional 表达,弱模型批量时漏 header。
10
+ // - schema 层 ajv 干报错先于 validate.ts 友好文案——故 InputSchema 故意放宽 options
11
+ // 元素到 string,让误用能抵达 validateInput 的带正例纠正。
12
+ //
13
+ // 本测试用源码断言(读 .ts 文件文本)锁定这些约束,防止后续重构把正例/反例/调用信号
14
+ // 删掉或弱化。读源码而非 import,避免 mock 链(index.ts 依赖 pi-tui/ExtensionAPI 等)。
15
+
16
+ import { readFileSync } from "node:fs";
17
+ import { dirname,join } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ import { describe, expect, it } from "vitest";
21
+
22
+ const __dirname = dirname(fileURLToPath(import.meta.url));
23
+
24
+ const INDEX_SRC = readFileSync(join(__dirname, "../index.ts"), "utf-8");
25
+ const VALIDATE_SRC = readFileSync(join(__dirname, "../validate.ts"), "utf-8");
26
+
27
+ /** 提取 description: `...` 模板字符串的原始内容(模板内无反引号,匹配到闭合 `,)。 */
28
+ function extractDescription(src: string): string {
29
+ const m = src.match(/description:\s*`([\s\S]*?)`,/);
30
+ if (!m) throw new Error("description template literal not found in index.ts");
31
+ return m[1];
32
+ }
33
+
34
+ const DESCRIPTION = extractDescription(INDEX_SRC);
35
+
36
+ describe("ask_user description — 参数形状正例(B. 补正例)", () => {
37
+ it("含单问题 JSON 正例,options 为 {label, description} 对象数组", () => {
38
+ // 弱模型最高频错误是把 options 当字符串数组传;正例必须显式给出对象形态。
39
+ expect(DESCRIPTION).toContain('"options":[{"label"');
40
+ expect(DESCRIPTION).toContain('"question"');
41
+ });
42
+
43
+ it("含批量 JSON 正例,每个 question 带 header", () => {
44
+ // 批量模式下 header 是条件必填,正例必须展示 header 字段。
45
+ expect(DESCRIPTION).toContain('"header"');
46
+ // 批量正例含多个 question(questions 数组里至少两个 header)
47
+ const headerMatches = DESCRIPTION.match(/"header"/g) || [];
48
+ expect(headerMatches.length).toBeGreaterThanOrEqual(2);
49
+ });
50
+
51
+ it("正例里的 label 可带 (Recommended) 前缀(调用信号未被破坏)", () => {
52
+ expect(DESCRIPTION).toContain("(Recommended)");
53
+ });
54
+ });
55
+
56
+ describe("ask_user description — 参数结构反例(B. 补反例)", () => {
57
+ it("含 ≥2 条参数结构反例(string array / flatten / Other 等)", () => {
58
+ // 锁定关键反例措辞,防止后续精简掉。
59
+ const antiPatterns = [
60
+ "string array", // options 不能当字符串数组传
61
+ "Flatten", // 不能把字段铺到顶层(匹配 Flattening/Flatten)
62
+ "Other", // 不能手动加 Other
63
+ ];
64
+ const hits = antiPatterns.filter((p) => DESCRIPTION.includes(p));
65
+ expect(hits.length).toBeGreaterThanOrEqual(2);
66
+ });
67
+ });
68
+
69
+ describe("ask_user description — 调用信号保留(不要破坏亮点)", () => {
70
+ it("保留 'Use ONLY when ALL hold' 调用门槛", () => {
71
+ // 这是 ask_user 写得比 subagent 原版好的调用信号引导,必须保留。
72
+ expect(DESCRIPTION).toContain("Use ONLY when");
73
+ });
74
+
75
+ it("保留 'Do NOT use' 边界段", () => {
76
+ expect(DESCRIPTION).toContain("Do NOT use");
77
+ });
78
+ });
79
+
80
+ describe("validate.ts — runtime 友好纠错(A. 带正例)", () => {
81
+ it("含 options 字符串元素检测('objects, not strings')", () => {
82
+ // schema 层已放宽让 string options 进到这里;validate 必须友好拦截。
83
+ expect(VALIDATE_SRC).toContain("objects, not strings");
84
+ });
85
+
86
+ it("options 字符串错误带 Correct 正例", () => {
87
+ // 友好文案必须给出最小可用形状,让弱模型直接抄。
88
+ expect(VALIDATE_SRC).toContain('Correct: "options":[{"label"');
89
+ });
90
+
91
+ it("header 缺失错误带 Correct 正例", () => {
92
+ // 多问题模式漏 header 是弱模型批量时的常见错误,纠正文案要带正例。
93
+ expect(VALIDATE_SRC).toContain("Correct: {\"header\"");
94
+ });
95
+ });
@@ -1,7 +1,8 @@
1
1
  // src/__tests__/validate.test.ts
2
+ import { Value } from "@sinclair/typebox/value";
2
3
  import { describe, expect, it } from "vitest";
3
4
 
4
- import { HEADER_MAX_CHARS, type Question } from "../types";
5
+ import { HEADER_MAX_CHARS, InputSchema, type Question } from "../types";
5
6
  import { validateInput } from "../validate";
6
7
 
7
8
  const q = (overrides: Partial<Question> = {}): Question => ({
@@ -136,4 +137,67 @@ describe("validateInput", () => {
136
137
  it("accepts header at exactly HEADER_MAX_CHARS (12)", () => {
137
138
  expect(validateInput([q({ header: "123456789012" })])).toBeNull();
138
139
  });
140
+
141
+ // V-17: options 元素是 string(弱模型最高频误用 "options":["A","B"])→ 友好错误带 Correct 正例。
142
+ // schema 层已放宽让 string options 能进到这里(见 types.ts InputSchema),故可直接传 string[]。
143
+ it("rejects string option elements with a Correct example (weak-model misuse)", () => {
144
+ const result = validateInput([
145
+ { question: "Which DB?", options: ["Postgres", "SQLite"] },
146
+ ]);
147
+ expect(result).not.toBeNull();
148
+ expect(result).toContain("objects, not strings");
149
+ expect(result).toContain('Correct: "options":[{"label"');
150
+ });
151
+
152
+ // V-18: 混合 [string, object] → 仍友好拦截第一个 string 元素
153
+ it("rejects mixed string/object options", () => {
154
+ const result = validateInput([
155
+ { question: "Q", options: ["A", { label: "B" }] },
156
+ ]);
157
+ expect(result).toContain("objects, not strings");
158
+ });
159
+
160
+ // V-19: header 缺失错误带 Correct 正例(A. runtime 友好纠错)
161
+ it("header-missing error includes a Correct example", () => {
162
+ const result = validateInput([
163
+ q({ question: "Q1", header: "H1" }),
164
+ q({ question: "Q2" }), // no header
165
+ ]);
166
+ expect(result).toContain("Correct:");
167
+ expect(result).toContain('"header"');
168
+ });
169
+ });
170
+
171
+ // ── options 字符串「下沉」机制集成证明 ──────────────────
172
+ // 目标(任务核心):弱模型误用 "options":["A","B"] 不能被 schema 层干报错拦死,
173
+ // 必须能进 validateInput 拿到带 Correct 正例的友好文案。Value.Check 是 Pi 运行时
174
+ // TypeCompiler.Check 的等价校验(同一引擎),这里直接断言 schema 行为。
175
+ describe("schema-vs-validate integration (options 字符串下沉)", () => {
176
+ it("string options PASS the schema layer (reach execute, not raw ajv error)", () => {
177
+ const malformed = {
178
+ questions: [{ question: "Which DB?", options: ["Postgres", "SQLite"] }],
179
+ };
180
+ expect(Value.Check(InputSchema, malformed)).toBe(true);
181
+ });
182
+
183
+ it("string options then caught by validateInput with a friendly Correct example", () => {
184
+ const result = validateInput([
185
+ { question: "Which DB?", options: ["Postgres", "SQLite"] },
186
+ ]);
187
+ expect(result).not.toBeNull();
188
+ expect(result).toContain("objects, not strings");
189
+ expect(result).toContain('Correct: "options":[{"label"');
190
+ });
191
+
192
+ it("well-formed object options still pass schema AND validateInput", () => {
193
+ const wellFormed = {
194
+ questions: [
195
+ { question: "Which DB?", options: [{ label: "A" }, { label: "B" }] },
196
+ ],
197
+ };
198
+ expect(Value.Check(InputSchema, wellFormed)).toBe(true);
199
+ expect(
200
+ validateInput([{ question: "Which DB?", options: [{ label: "A" }, { label: "B" }] }]),
201
+ ).toBeNull();
202
+ });
139
203
  });
package/src/index.ts CHANGED
@@ -233,7 +233,18 @@ export default function (pi: ExtensionAPI): void {
233
233
 
234
234
  Do NOT use this tool to outsource judgment you should make — if you can form a defensible recommendation from the codebase, proceed and state your choice. Do NOT use for trivia answerable by reading code/docs, or for simple confirmations ("I'll delete X") where plain text suffices. You cannot use this tool to collect free-form requirements, long-form feedback, or multi-paragraph input — it returns short selections only.
235
235
 
236
- If you recommend an option, prefix its label with "(Recommended)" and list it first. For structured multi-option decisions, prefer this tool over plain-text questions; for everything else, reply in plain text.`,
236
+ If you recommend an option, prefix its label with "(Recommended)" and list it first. For structured multi-option decisions, prefer this tool over plain-text questions; for everything else, reply in plain text.
237
+
238
+ Examples:
239
+ {"questions":[{"question":"Which DB?","context":"Need ACID + JSON columns.","options":[{"label":"(Recommended) Postgres","description":"Mature, strong consistency."},{"label":"SQLite","description":"Zero-ops, embedded."}]}]}
240
+
241
+ {"questions":[{"header":"DB","question":"Which database?","options":[{"label":"Postgres","description":"..."},{"label":"SQLite","description":"..."}]},{"header":"Region","question":"Which region?","options":[{"label":"us-east-1","description":"..."},{"label":"eu-west-1","description":"..."}]}]}
242
+
243
+ Don't:
244
+ - Passing options as a string array ("options":["A","B"]) — each option must be {"label","description"}.
245
+ - Forgetting header in multi-question mode (questions.length > 1).
246
+ - Flattening question/header/options to the top level — wrap them in questions:[...].
247
+ - Including an "Other" option — it is added automatically.`,
237
248
  promptSnippet:
238
249
  "Ask the user structured clarifying questions with options — only when you cannot resolve the ambiguity yourself",
239
250
  promptGuidelines: [
@@ -253,9 +264,11 @@ If you recommend an option, prefix its label with "(Recommended)" and list it fi
253
264
  _onUpdate: AgentToolUpdateCallback<AskUserDetails> | undefined,
254
265
  ctx: ExtensionContext,
255
266
  ): Promise<ExecuteResult> {
256
- const questions = params.questions;
267
+ const questions = params.questions as Question[];
257
268
 
258
- // 1. 参数校验(spec FR-2
269
+ // 1. 参数校验(spec FR-2)。validateInput 接收宽松 InputQuestion[]:
270
+ // options 可能含 string 误用(schema 已故意放宽以抵达这里的友好文案),
271
+ // validateInput 会先拦截 string options 再跑其余校验。通过后 questions 已是干净 Question[]。
259
272
  const validationError = validateInput(questions);
260
273
  if (validationError) {
261
274
  return cancelledResult(questions, `Error: ${validationError}`, true);
@@ -326,7 +339,8 @@ If you recommend an option, prefix its label with "(Recommended)" and list it fi
326
339
  },
327
340
 
328
341
  renderCall(args: Static<typeof InputSchema>, theme: ThemeLike) {
329
- const questions: Question[] = args.questions ?? [];
342
+ // args 来自 LLM 原始入参(options 可能是 string),只读 header/question 不碰 options。
343
+ const questions = (args.questions ?? []) as Question[];
330
344
  const topics = questions.map((q) => q.header ?? truncateToWidth(q.question, HEADER_MAX_CHARS)).join(", ");
331
345
  return new TruncatedText(
332
346
  theme.fg("toolTitle", theme.bold("ask_user ")) + theme.fg("muted", topics),
package/src/types.ts CHANGED
@@ -52,17 +52,47 @@ export const QuestionSchema = Type.Object({
52
52
  ),
53
53
  });
54
54
 
55
+ /**
56
+ * LLM-facing input schema (宽松版 options 元素)。
57
+ *
58
+ * options 元素故意放宽为 `OptionSchema | string`:弱模型最高频误用是把 options
59
+ * 当字符串数组传(`"options":["A","B"]`)。严格 schema 会让 Pi 运行时的 typebox
60
+ * TypeCompiler.Check 直接拦截(干报错 "must be object"),根本进不了 validateInput
61
+ * 的友好文案。这里放宽让 string 元素通过 schema 层、抵达 validateInput,由它返回带
62
+ * Correct 正例的纠正错误(runtime 友好纠错)。
63
+ *
64
+ * 字段描述复用 QuestionSchema.properties(只覆盖 options 数组元素类型),避免描述
65
+ * 双份维护。Static 派生的 InputQuestion.options 是 `(Option | string)[]`,让
66
+ * validateInput 里的 `typeof opt === "string"` 检查在 TS 层 sound(而非死分支)。
67
+ * 通过 validateInput 后,运行时已保证无 string,index.ts 以 `as Question[]` 收窄使用。
68
+ */
69
+ const inputOptionElement = Type.Union([OptionSchema, Type.String()]);
70
+
55
71
  export const InputSchema = Type.Object({
56
- questions: Type.Array(QuestionSchema, {
57
- minItems: 1,
58
- maxItems: 4,
59
- description: "1-4 questions, each a single decision. Batch only related decisions that the user should resolve together; otherwise ask the most important one alone.",
60
- }),
72
+ questions: Type.Array(
73
+ Type.Object({
74
+ ...QuestionSchema.properties,
75
+ options: Type.Array(inputOptionElement, {
76
+ minItems: 2,
77
+ maxItems: 4,
78
+ description:
79
+ "2-4 mutually exclusive options. Each must be a {label, description} OBJECT, never a bare string; do NOT include an 'Other' option — it is added automatically.",
80
+ }),
81
+ }),
82
+ {
83
+ minItems: 1,
84
+ maxItems: 4,
85
+ description: "1-4 questions, each a single decision. Batch only related decisions that the user should resolve together; otherwise ask the most important one alone.",
86
+ },
87
+ ),
61
88
  });
62
89
 
63
90
  // ── 派生类型 ─────────────────────────────────────────
64
91
  export type Option = Static<typeof OptionSchema>;
92
+ /** 内部使用的严格 question 形状(options 为干净 Option[])。validateInput 通过后使用。 */
65
93
  export type Question = Static<typeof QuestionSchema>;
94
+ /** LLM 入参 question 形状:options 可能含 string 误用,validateInput 负责友好拦截。 */
95
+ export type InputQuestion = Static<typeof InputSchema>["questions"][number];
66
96
 
67
97
  // ── Result schema(details,renderResult 数据源) ─────
68
98
  export const ResultSchema = Type.Object({
package/src/validate.ts CHANGED
@@ -1,20 +1,29 @@
1
1
  // src/validate.ts
2
- import { HEADER_MAX_CHARS, type Question, QUESTION_MAX_CHARS } from "./types";
2
+ import { HEADER_MAX_CHARS, type InputQuestion, QUESTION_MAX_CHARS } from "./types";
3
3
 
4
4
  /** 控制字符(含 \n \r \t 等):question 文本禁止包含,避免 answers key 含不可见字符(spec FR-2) */
5
5
  const CONTROL_CHAR_RE = /[\x00-\x1f\x7f]/;
6
6
 
7
+ /** 错误消息里 question/header 文本预览的截断长度(避免长文本撑爆错误消息)。 */
8
+ const ERROR_PREVIEW_CHARS = 20;
9
+
7
10
  /**
8
11
  * 校验输入参数。通过返回 null,失败返回错误消息字符串。
12
+ *
13
+ * 入参类型是宽松的 InputQuestion[](options 元素可能是 string 误用)——见 types.ts
14
+ * InputSchema 注释。Pi 运行时只放宽到让 string options 能进到这里被友好拦截;这里先
15
+ * 预检 string options,再跑原有结构/语义校验。
16
+ *
9
17
  * 校验项(spec FR-2):
18
+ * - options 元素必须是 {label, description} 对象,不能是 string(弱模型高频误用)
10
19
  * - question 文本长度上限与无控制字符(保证 answers key 有界、可预测)
11
20
  * - question 文本在数组内唯一
12
21
  * - 同问题内 option label 唯一
13
22
  * - 多问题(questions.length > 1)时每个 question 必须有非空 header
14
23
  *
15
- * 错误消息面向 LLM:除描述违规外,附带一句修复指引(如何改)。
24
+ * 错误消息面向 LLM:除描述违规外,附带一句修复指引(如何改),对结构误用附 Correct 正例。
16
25
  */
17
- export function validateInput(questions: Question[]): string | null {
26
+ export function validateInput(questions: InputQuestion[]): string | null {
18
27
  const seenQuestions = new Set<string>();
19
28
 
20
29
  for (const q of questions) {
@@ -22,11 +31,11 @@ export function validateInput(questions: Question[]): string | null {
22
31
 
23
32
  // 1a. question 文本长度上限(key 有界)
24
33
  if (qt.length > QUESTION_MAX_CHARS) {
25
- return `Question text exceeds ${QUESTION_MAX_CHARS} chars: "${qt.slice(0, 20)}...". Shorten it to a single concise decision; move extra context into the context field.`;
34
+ return `Question text exceeds ${QUESTION_MAX_CHARS} chars: "${qt.slice(0, ERROR_PREVIEW_CHARS)}...". Shorten it to a single concise decision; move extra context into the context field.`;
26
35
  }
27
36
  // 1b. question 文本无控制字符(key 可预测,不影响下游渲染/解析)
28
37
  if (CONTROL_CHAR_RE.test(qt)) {
29
- return `Question text must not contain control characters (incl. newlines): "${qt.slice(0, 20)}...". Use plain single-line text; split multi-part questions into separate entries.`;
38
+ return `Question text must not contain control characters (incl. newlines): "${qt.slice(0, ERROR_PREVIEW_CHARS)}...". Use plain single-line text; split multi-part questions into separate entries.`;
30
39
  }
31
40
 
32
41
  // 1c. question 文本唯一
@@ -35,14 +44,20 @@ export function validateInput(questions: Question[]): string | null {
35
44
  }
36
45
  seenQuestions.add(qt);
37
46
 
38
- // 2. option label 唯一且非空(空 label 会污染 details.answers 的值)
47
+ // 2. option 元素必须是 {label, description} 对象,不能是 string。
48
+ // 弱模型最高频误用:"options":["A","B"]。schema 层已放宽让 string 进来,这里友好拦截
49
+ // (InputQuestion.options 是 (Option | string)[],typeof 收窄后 opt 为 Option)。
39
50
  const seenLabels = new Set<string>();
40
51
  for (const opt of q.options) {
52
+ if (typeof opt === "string") {
53
+ return `Options for question "${qt}" must be an array of {label, description} objects, not strings. Correct: "options":[{"label":"A","description":"..."},{"label":"B","description":"..."}]`;
54
+ }
55
+ // opt 已收窄为 Option
41
56
  if (opt.label.trim() === "") {
42
- return `Option label must not be empty in question "${q.question}". Give every option a distinct, descriptive label.`;
57
+ return `Option label must not be empty in question "${qt}". Give every option a distinct, descriptive label.`;
43
58
  }
44
59
  if (seenLabels.has(opt.label)) {
45
- return `Duplicate option label "${opt.label}" in question "${q.question}". Options must be mutually exclusive — reword one so each label maps to a distinct choice.`;
60
+ return `Duplicate option label "${opt.label}" in question "${qt}". Options must be mutually exclusive — reword one so each label maps to a distinct choice.`;
46
61
  }
47
62
  seenLabels.add(opt.label);
48
63
  }
@@ -52,7 +67,7 @@ export function validateInput(questions: Question[]): string | null {
52
67
  if (questions.length > 1) {
53
68
  for (const q of questions) {
54
69
  if (!q.header || q.header.trim() === "") {
55
- return `Question "${q.question}" requires a non-empty header in multi-question mode (it labels the tab). Provide a header of <=12 chars.`;
70
+ return `Question "${q.question}" requires a non-empty header in multi-question mode (it labels the tab). Provide a header of <=12 chars. Correct: {"header":"DB","question":"...","options":[{"label":"...","description":"..."}]}`;
56
71
  }
57
72
  }
58
73
 
@@ -72,7 +87,7 @@ export function validateInput(questions: Question[]): string | null {
72
87
  // 这里提前拒绝,让 LLM 拿到可修复错误而非残缺 UI(兑现 schema description 的 ≤12 契约)。
73
88
  for (const q of questions) {
74
89
  if (q.header !== undefined && q.header.length > HEADER_MAX_CHARS) {
75
- return `Header exceeds ${HEADER_MAX_CHARS} chars: "${q.header.slice(0, 20)}..." in question "${q.question}". Shorten it; longer headers are truncated in the tab bar.`;
90
+ return `Header exceeds ${HEADER_MAX_CHARS} chars: "${q.header.slice(0, ERROR_PREVIEW_CHARS)}..." in question "${q.question}". Shorten it; longer headers are truncated in the tab bar.`;
76
91
  }
77
92
  }
78
93