@zhushanwen/pi-ask-user 7.0.10 → 7.0.11

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": "7.0.10",
3
+ "version": "7.0.11",
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",
@@ -26,11 +26,12 @@
26
26
  "ARCHITECTURE.md"
27
27
  ],
28
28
  "dependencies": {
29
- "@xyz-agent/extension-protocol": "^0.4.0"
29
+ "@xyz-agent/extension-protocol": "0.5.0"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@earendil-works/pi-tui": "*",
33
33
  "@types/node": "^24.0.0",
34
+ "@vitest/coverage-v8": "^4.1.9",
34
35
  "fast-check": "^4.9.0",
35
36
  "typebox": "*",
36
37
  "vitest": "^4.1.8"
@@ -38,15 +39,11 @@
38
39
  "peerDependencies": {
39
40
  "@earendil-works/pi-coding-agent": "*",
40
41
  "@earendil-works/pi-tui": "*",
41
- "typebox": "*",
42
- "@zhushanwen/pi-subagent-workflow": "8.0.0"
42
+ "typebox": "*"
43
43
  },
44
44
  "peerDependenciesMeta": {
45
45
  "@earendil-works/pi-tui": {
46
46
  "optional": true
47
- },
48
- "@zhushanwen/pi-subagent-workflow": {
49
- "optional": true
50
47
  }
51
48
  },
52
49
  "scripts": {
@@ -139,102 +139,90 @@ const validSingle = {
139
139
  };
140
140
 
141
141
  // ── I-1 ~ I-4: 参数校验(AC-8/13)──────────────────────
142
+ // W4:错误路径 throw(pi 只对 execute throw 置 isError:true,返回值 isError 被丢弃)
142
143
  describe("execute — validation (FR-2 / AC-8 / AC-13)", () => {
143
- it("I-1: duplicate question → isError", async () => {
144
+ it("I-1: duplicate question → throw", async () => {
144
145
  const tool = getTool();
145
- const result = await tool.execute(
146
- "id",
147
- {
148
- questions: [
149
- { question: "Same", options: [{ label: "A" }, { label: "B" }] },
150
- { question: "Same", options: [{ label: "C" }, { label: "D" }] },
151
- ],
152
- },
153
- undefined,
154
- undefined,
155
- makeCtx(),
156
- );
157
- expect(result.isError).toBe(true);
158
- expect(result.content[0].text).toContain("Duplicate");
159
- });
160
-
161
- it("I-2: duplicate option label → isError", async () => {
162
- const tool = getTool();
163
- const result = await tool.execute(
164
- "id",
165
- {
166
- questions: [
167
- { question: "Q", options: [{ label: "A" }, { label: "A" }] },
168
- ],
169
- },
170
- undefined,
171
- undefined,
172
- makeCtx(),
173
- );
174
- expect(result.isError).toBe(true);
175
- expect(result.content[0].text).toContain("Duplicate option");
146
+ await expect(
147
+ tool.execute(
148
+ "id",
149
+ {
150
+ questions: [
151
+ { question: "Same", options: [{ label: "A" }, { label: "B" }] },
152
+ { question: "Same", options: [{ label: "C" }, { label: "D" }] },
153
+ ],
154
+ },
155
+ undefined,
156
+ undefined,
157
+ makeCtx(),
158
+ ),
159
+ ).rejects.toThrow("Duplicate");
176
160
  });
177
161
 
178
- it("I-3: multi-question missing headerisError", async () => {
162
+ it("I-2: duplicate option labelthrow", async () => {
179
163
  const tool = getTool();
180
- const result = await tool.execute(
181
- "id",
182
- {
183
- questions: [
184
- { question: "Q1", header: "H1", options: [{ label: "A" }, { label: "B" }] },
185
- { question: "Q2", options: [{ label: "C" }, { label: "D" }] },
186
- ],
187
- },
188
- undefined,
189
- undefined,
190
- makeCtx(),
191
- );
192
- expect(result.isError).toBe(true);
193
- expect(result.content[0].text).toContain("header");
164
+ await expect(
165
+ tool.execute(
166
+ "id",
167
+ {
168
+ questions: [
169
+ { question: "Q", options: [{ label: "A" }, { label: "A" }] },
170
+ ],
171
+ },
172
+ undefined,
173
+ undefined,
174
+ makeCtx(),
175
+ ),
176
+ ).rejects.toThrow("Duplicate option");
194
177
  });
195
178
 
196
- it("I-4: validation error details.cancelled = true", async () => {
179
+ it("I-3: multi-question missing header throw", async () => {
197
180
  const tool = getTool();
198
- const result = await tool.execute(
199
- "id",
200
- { questions: [{ question: "Q", options: [{ label: "A" }, { label: "A" }] }] },
201
- undefined,
202
- undefined,
203
- makeCtx(),
204
- );
205
- expect(result.details.cancelled).toBe(true);
181
+ await expect(
182
+ tool.execute(
183
+ "id",
184
+ {
185
+ questions: [
186
+ { question: "Q1", header: "H1", options: [{ label: "A" }, { label: "B" }] },
187
+ { question: "Q2", options: [{ label: "C" }, { label: "D" }] },
188
+ ],
189
+ },
190
+ undefined,
191
+ undefined,
192
+ makeCtx(),
193
+ ),
194
+ ).rejects.toThrow("header");
206
195
  });
207
196
 
208
- it("I-4b: string options (schema-relaxed) → execute → validateInput catches → isError + Correct hint", async () => {
197
+ it("I-4b: string options (schema-relaxed) → execute → validateInput catches → throw + Correct hint", async () => {
209
198
  // 端到端证明:schema 放宽(Union([OptionSchema, string]))后 string options
210
- // 能穿过 TypeCompiler.Check、抵达 execute → validateInput 友好拦截。
199
+ // 能穿过 TypeCompiler.Check、抵达 execute → validateInput 友好拦截(W4 后以 throw
200
+ // 表达,pi catch 后文案进 toolResult content)。
211
201
  // test-coverage reviewer 点名的“execute wiring 未测”缺口。
212
202
  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");
203
+ await expect(
204
+ tool.execute(
205
+ "id",
206
+ { questions: [{ question: "Q", options: ["A", "B"] }] },
207
+ undefined,
208
+ undefined,
209
+ makeCtx(),
210
+ ),
211
+ ).rejects.toThrow(/not strings[\s\S]*Correct/);
224
212
  });
225
213
  });
226
214
 
227
215
  // ── I-5 ~ I-7: Headless(FR-8 / AC-7)──────────────────
228
- // 真 headless:hasUI=false 且 ui 上无 select(print 模式),askUserInteract 抛错 禁用工具。
216
+ // 真 headless:hasUI=false 且 ui 上无 select(print 模式)→ 禁用工具 + throw(W4)。
229
217
  describe("execute — headless (FR-8 / AC-7)", () => {
230
- it("I-5: headless (no select) → isError with disabled message", async () => {
218
+ it("I-5: headless (no select) → throw with disabled message", async () => {
231
219
  const tool = getTool();
232
- const result = await tool.execute("id", validSingle, undefined, undefined, makeHeadlessCtx());
233
- expect(result.isError).toBe(true);
234
- expect(result.content[0].text).toContain("disabled");
220
+ await expect(
221
+ tool.execute("id", validSingle, undefined, undefined, makeHeadlessCtx()),
222
+ ).rejects.toThrow(/disabled/);
235
223
  });
236
224
 
237
- it("I-6: headless disables ask_user tool via setActiveTools", async () => {
225
+ it("I-6: headless disables ask_user tool via setActiveTools (收尾先于 throw)", async () => {
238
226
  let captured: string[] | null = null;
239
227
  const tool2 = getTool({
240
228
  getAllTools: () => [{ name: "ask_user" }, { name: "other" }],
@@ -242,17 +230,12 @@ describe("execute — headless (FR-8 / AC-7)", () => {
242
230
  captured = names;
243
231
  },
244
232
  });
245
- await tool2.execute("id", validSingle, undefined, undefined, makeHeadlessCtx());
233
+ await expect(
234
+ tool2.execute("id", validSingle, undefined, undefined, makeHeadlessCtx()),
235
+ ).rejects.toThrow();
246
236
  expect(captured).not.toContain("ask_user");
247
237
  expect(captured).toContain("other");
248
238
  });
249
-
250
- it("I-7: headless details.cancelled = true", async () => {
251
- const tool = getTool();
252
- const result = await tool.execute("id", validSingle, undefined, undefined, makeHeadlessCtx());
253
- // headless 走 step 2 提前返回:cancelled Result(禁用工具,不进交互分支)
254
- expect(result.details.cancelled).toBe(true);
255
- });
256
239
  });
257
240
 
258
241
  // ── I-8 ~ I-9: Signal abort(FR-10 / AC-14)────────────
@@ -295,31 +278,20 @@ describe("execute — signal abort (FR-10 / AC-14)", () => {
295
278
  });
296
279
 
297
280
  // ── I-10 ~ I-11: 错误兜底(FR-13 / AC-15)─────────────
281
+ // W4:错误路径 throw(原 return {isError, details.error} 的 details 在 pi catch 后
282
+ // 不再产出——pi createErrorToolResult 只保留 content)
298
283
  describe("execute — error fallback (FR-13 / AC-15)", () => {
299
- it("I-10: ui.custom throws → isError with 'ask_user failed'", async () => {
300
- const tool = getTool();
301
- const result = await tool.execute(
302
- "id",
303
- validSingle,
304
- undefined,
305
- undefined,
306
- makeCtx({ customThrows: new Error("boom") }),
307
- );
308
- expect(result.isError).toBe(true);
309
- expect(result.content[0].text).toContain("ask_user failed");
310
- expect(result.content[0].text).toContain("boom");
311
- });
312
-
313
- it("I-11: error details contains error message", async () => {
314
- const tool = getTool();
315
- const result = await tool.execute(
316
- "id",
317
- validSingle,
318
- undefined,
319
- undefined,
320
- makeCtx({ customThrows: new Error("crash") }),
321
- );
322
- expect(result.details.error).toBe("crash");
284
+ it("I-10: ui.custom throws → throw with 'ask_user failed'", async () => {
285
+ const tool = getTool();
286
+ await expect(
287
+ tool.execute(
288
+ "id",
289
+ validSingle,
290
+ undefined,
291
+ undefined,
292
+ makeCtx({ customThrows: new Error("boom") }),
293
+ ),
294
+ ).rejects.toThrow(/ask_user failed[\s\S]*boom/);
323
295
  });
324
296
  });
325
297
 
@@ -677,18 +649,17 @@ describe("execute — RPC mode (askUserInteract via select channel)", () => {
677
649
  expect(result.details.cancelled).toBe(true);
678
650
  });
679
651
 
680
- it("R-6: select throws → isError + disabled (not retriable)", async () => {
652
+ it("R-6: select throws → throw + disabled message (not retriable; W4)", async () => {
681
653
  const tool = getTool();
682
- const result = await tool.execute(
683
- "id",
684
- validSingle,
685
- undefined,
686
- undefined,
687
- makeCtx({ mode: "rpc", selectThrows: new Error("channel broken") }),
688
- );
689
- expect(result.isError).toBe(true);
690
- expect(result.content[0].text).toContain("disabled");
691
- expect(result.details.error).toBe("channel broken");
654
+ await expect(
655
+ tool.execute(
656
+ "id",
657
+ validSingle,
658
+ undefined,
659
+ undefined,
660
+ makeCtx({ mode: "rpc", selectThrows: new Error("channel broken") }),
661
+ ),
662
+ ).rejects.toThrow(/channel broken[\s\S]*disabled/);
692
663
  });
693
664
 
694
665
  it("R-7: header used as answers key when provided", async () => {
@@ -122,7 +122,10 @@ async function runTuiProtoInteraction(
122
122
  return comp;
123
123
  },
124
124
  );
125
- if (result === null || result.cancelled) return null;
125
+ // json/print 模式 ctx.ui noOpUIContext,custom 返回 undefined(TUI 返回 Result | null)。
126
+ // 显式 undefined 守卫:裸 result.cancelled 对 undefined 会抛 TypeError(W4 修复前
127
+ // 靠 dialog-queue 兜底为 {cancelled:true},现源头短路,语义等价且不再依赖兜底)。
128
+ if (result === null || result === undefined || result.cancelled) return null;
126
129
  return encodeTuiResultToProto(protoQuestions, result);
127
130
  }
128
131
 
package/src/index.ts CHANGED
@@ -17,7 +17,6 @@ import { answerValueText } from "./submit-view";
17
17
  import {
18
18
  type AnswerValue,
19
19
  type AskUserDetails,
20
- type ErrorDetails,
21
20
  HEADER_MAX_CHARS,
22
21
  InputSchema,
23
22
  type Option,
@@ -28,10 +27,13 @@ import {
28
27
  import { validateInput } from "./validate";
29
28
 
30
29
  /**
31
- * execute 的返回形状:复用 SDK AgentToolResult<AskUserDetails>,叠加运行时使用的 isError 标记。
32
- * SDK 的 AgentToolResult 类型未声明 isError(运行时通过它标记错误),这里显式补齐。
30
+ * execute 的返回形状:复用 SDK AgentToolResult<AskUserDetails>。
31
+ * SDK 的 AgentToolResult 类型未声明 isError(pi-agent-core types.d.ts:316-334)——
32
+ * 因为 pi 契约里返回值不携带错误标记:execute 只有 throw 才被置 isError:true
33
+ * (agent-loop.js:453-483),返回值里的 isError 字段会被丢弃(W4 修复前曾误用)。
34
+ * 取消(用户取消 / abort)不是错误,正常返回 cancelled result。
33
35
  */
34
- type ExecuteResult = AgentToolResult<AskUserDetails> & { isError?: boolean };
36
+ type ExecuteResult = AgentToolResult<AskUserDetails>;
35
37
 
36
38
  /** 禁用 ask_user 工具(headless / RPC 通道不可用时调用,防 LLM 反复重试)。 */
37
39
  function disableAskUser(pi: ExtensionAPI): void {
@@ -43,11 +45,10 @@ function disableAskUser(pi: ExtensionAPI): void {
43
45
  );
44
46
  }
45
47
 
46
- /** 构造 cancelled 结果(用户取消 / abort / 校验失败共用)。 */
47
- function cancelledResult(questions: Question[], text: string, isError = false): ExecuteResult {
48
+ /** 构造 cancelled 结果(用户取消 / abort 共用;错误路径已改为 throw,不走此函数)。 */
49
+ function cancelledResult(questions: Question[], text: string): ExecuteResult {
48
50
  return {
49
51
  content: [{ type: "text" as const, text }],
50
- isError: isError || undefined,
51
52
  details: { questions, answers: {}, cancelled: true } satisfies Result,
52
53
  };
53
54
  }
@@ -268,16 +269,17 @@ Don't:
268
269
  // validateInput 会先拦截 string options 再跑其余校验。通过后 questions 已是干净 Question[]。
269
270
  const validationError = validateInput(questions);
270
271
  if (validationError) {
271
- return cancelledResult(questions, `Error: ${validationError}`, true);
272
+ // 错误路径 throw(W4):pi 只对 execute throw 置 isError:true
273
+ // return {isError:true} 会被 agent-loop 丢弃(错误轮被标成功)。
274
+ throw new Error(`Error: ${validationError}`);
272
275
  }
273
276
 
274
- // 2. Headless 检查(spec FR-8):mode 非 tui 非 rpc = 无交互通道
277
+ // 2. Headless 检查(spec FR-8):mode 非 tui 非 rpc = 无交互通道。
278
+ // 先 disableAskUser 收尾(防 LLM 反复重试),再 throw——pi catch 后文案原样进 toolResult。
275
279
  if (ctx.mode !== "tui" && ctx.mode !== "rpc") {
276
280
  disableAskUser(pi);
277
- return cancelledResult(
278
- questions,
281
+ throw new Error(
279
282
  "Error: ask_user requires an interactive session. The tool has been disabled for this session. Do not retry — proceed without user input (make a defensible decision and state it) or wait for the user to reconnect.",
280
- true,
281
283
  );
282
284
  }
283
285
 
@@ -303,18 +305,14 @@ Don't:
303
305
  } catch (err) {
304
306
  // RPC 通道不可用(真 headless / select 缺失)→ 禁用工具(spec FR-8)。
305
307
  // TUI 分支不禁用——custom 抛错通常是组件临时故障,允许 LLM 重试。
308
+ // 禁用收尾先行,再 throw(W4):pi catch 后文案原样成为 toolResult content。
306
309
  const message = err instanceof Error ? err.message : String(err);
307
310
  if (useRpc) disableAskUser(pi);
308
- return {
309
- content: [{
310
- type: "text" as const,
311
- text: useRpc
312
- ? `ask_user failed: ${message}. The tool has been disabled for this session. Do not retry — proceed without user input (make a defensible decision and state it) or wait for the user to reconnect.`
313
- : `ask_user failed: ${message}. Treat as cancelled — do not assume an answer; retry the call with corrected parameters, or proceed with a defensible decision if the user cannot be reached.`,
314
- }],
315
- isError: true,
316
- details: { error: message } satisfies ErrorDetails,
317
- };
311
+ throw new Error(
312
+ useRpc
313
+ ? `ask_user failed: ${message}. The tool has been disabled for this session. Do not retry — proceed without user input (make a defensible decision and state it) or wait for the user to reconnect.`
314
+ : `ask_user failed: ${message}. Treat as cancelled — do not assume an answer; retry the call with corrected parameters, or proceed with a defensible decision if the user cannot be reached.`,
315
+ );
318
316
  }
319
317
 
320
318
  // 5. 取消(null / cancelled)