@zhushanwen/pi-ask-user 7.0.9 → 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 +4 -7
- package/src/__tests__/index.test.ts +89 -118
- package/src/channel-handler.ts +4 -1
- package/src/index.ts +20 -22
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-ask-user",
|
|
3
|
-
"version": "7.0.
|
|
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": "
|
|
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": "7.4.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 →
|
|
144
|
+
it("I-1: duplicate question → throw", async () => {
|
|
144
145
|
const tool = getTool();
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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-
|
|
162
|
+
it("I-2: duplicate option label → throw", async () => {
|
|
179
163
|
const tool = getTool();
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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-
|
|
179
|
+
it("I-3: multi-question missing header → throw", async () => {
|
|
197
180
|
const tool = getTool();
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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 →
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
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) →
|
|
218
|
+
it("I-5: headless (no select) → throw with disabled message", async () => {
|
|
231
219
|
const tool = getTool();
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
|
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 →
|
|
300
|
-
const tool = getTool();
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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 →
|
|
652
|
+
it("R-6: select throws → throw + disabled message (not retriable; W4)", async () => {
|
|
681
653
|
const tool = getTool();
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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 () => {
|
package/src/channel-handler.ts
CHANGED
|
@@ -122,7 +122,10 @@ async function runTuiProtoInteraction(
|
|
|
122
122
|
return comp;
|
|
123
123
|
},
|
|
124
124
|
);
|
|
125
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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)
|