@zhushanwen/pi-ask-user 0.1.0 → 1.0.0
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 +9 -2
- package/src/__tests__/answer-format.test.ts +107 -0
- package/src/__tests__/channel-handler.test.ts +286 -0
- package/src/__tests__/channel-registry-register.test.ts +180 -0
- package/src/__tests__/component-keymap.test.ts +0 -31
- package/src/__tests__/e2e-harness.ts +6 -0
- package/src/__tests__/e2e.test.ts +2 -2
- package/src/__tests__/editor-ops.test.ts +179 -0
- package/src/__tests__/index.test.ts +312 -24
- package/src/__tests__/question-view.test.ts +50 -35
- package/src/__tests__/sdk-contract.test.ts +1 -1
- package/src/__tests__/validate.test.ts +5 -3
- package/src/__tests__/w2-draft-hint.test.ts +0 -89
- package/src/__tests__/w3-regression.test.ts +5 -187
- package/src/answer-format.ts +51 -0
- package/src/channel-handler.ts +215 -0
- package/src/channel-registry-register.ts +108 -0
- package/src/component.ts +42 -101
- package/src/editor-ops.ts +75 -0
- package/src/index.ts +211 -69
- package/src/question-view.ts +32 -47
- package/src/submit-view.ts +2 -4
- package/src/validate.ts +11 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-ask-user",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.0.0",
|
|
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",
|
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
"README.md",
|
|
26
26
|
"ARCHITECTURE.md"
|
|
27
27
|
],
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@xyz-agent/extension-protocol": "^0.2.0"
|
|
30
|
+
},
|
|
28
31
|
"devDependencies": {
|
|
29
32
|
"@earendil-works/pi-tui": "*",
|
|
30
33
|
"@sinclair/typebox": "*",
|
|
@@ -34,11 +37,15 @@
|
|
|
34
37
|
"peerDependencies": {
|
|
35
38
|
"@mariozechner/pi-coding-agent": "*",
|
|
36
39
|
"@mariozechner/pi-tui": "*",
|
|
37
|
-
"@sinclair/typebox": "*"
|
|
40
|
+
"@sinclair/typebox": "*",
|
|
41
|
+
"@zhushanwen/pi-subagent-workflow": "*"
|
|
38
42
|
},
|
|
39
43
|
"peerDependenciesMeta": {
|
|
40
44
|
"@mariozechner/pi-tui": {
|
|
41
45
|
"optional": true
|
|
46
|
+
},
|
|
47
|
+
"@zhushanwen/pi-subagent-workflow": {
|
|
48
|
+
"optional": true
|
|
42
49
|
}
|
|
43
50
|
},
|
|
44
51
|
"scripts": {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// src/__tests__/answer-format.test.ts
|
|
2
|
+
//
|
|
3
|
+
// answer-format.ts 独立单元测试。
|
|
4
|
+
// 覆盖审查 S5 发现的覆盖盲区:
|
|
5
|
+
// - parseAnswerParts 子串误匹配("A" 不应命中 "AB")
|
|
6
|
+
// - formatAnswer 空 parts → null
|
|
7
|
+
// - comment 分隔符边界
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
|
|
11
|
+
import { formatAnswer, parseAnswerParts } from "../answer-format.js";
|
|
12
|
+
import { ANSWER_COMMENT_SEPARATOR } from "../types.js";
|
|
13
|
+
|
|
14
|
+
describe("formatAnswer", () => {
|
|
15
|
+
it("returns null for empty parts (unanswered)", () => {
|
|
16
|
+
expect(formatAnswer([])).toBeNull();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("returns null for empty parts even with comment", () => {
|
|
20
|
+
// parts 空 = 没有选中选项,即使有 comment 也不应产出有效答案行
|
|
21
|
+
expect(formatAnswer([], "some comment")).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("joins single part without separator", () => {
|
|
25
|
+
expect(formatAnswer(["yes"])).toBe("yes");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("joins multiple parts with ', '", () => {
|
|
29
|
+
expect(formatAnswer(["A", "B", "C"])).toBe("A, B, C");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("appends comment with ANSWER_COMMENT_SEPARATOR", () => {
|
|
33
|
+
const result = formatAnswer(["A", "B"], "my comment");
|
|
34
|
+
expect(result).toBe(`A, B${ANSWER_COMMENT_SEPARATOR}my comment`);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("handles null comment (no separator appended)", () => {
|
|
38
|
+
expect(formatAnswer(["A"], null)).toBe("A");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("parseAnswerParts", () => {
|
|
43
|
+
it("extracts selected labels by exact match", () => {
|
|
44
|
+
const labels = ["yes", "no", "maybe"];
|
|
45
|
+
const result = parseAnswerParts("yes, no", labels);
|
|
46
|
+
expect(result.selected).toEqual(["yes", "no"]);
|
|
47
|
+
expect(result.comment).toBeUndefined();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// S5 核心:防子串误匹配——"A" 不应命中 label "AB"
|
|
51
|
+
it("does NOT match substring labels (A vs AB)", () => {
|
|
52
|
+
const labels = ["A", "AB", "ABC"];
|
|
53
|
+
// 答案 "A, AB" 应精确匹配两个 label,而非 "A" 匹配三次
|
|
54
|
+
const result = parseAnswerParts("A, AB", labels);
|
|
55
|
+
expect(result.selected).toEqual(["A", "AB"]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("does NOT match 'A' when only 'AB' is in answer", () => {
|
|
59
|
+
const labels = ["A", "AB"];
|
|
60
|
+
// 答案 "AB" 只应命中 "AB",不应命中 "A"
|
|
61
|
+
const result = parseAnswerParts("AB", labels);
|
|
62
|
+
expect(result.selected).toEqual(["AB"]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("preserves order of appearance in answer (not label order)", () => {
|
|
66
|
+
const labels = ["A", "B", "C"];
|
|
67
|
+
// 用户选择顺序可能与 options 定义顺序不同
|
|
68
|
+
const result = parseAnswerParts("C, A", labels);
|
|
69
|
+
expect(result.selected).toEqual(["C", "A"]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("extracts comment after ANSWER_COMMENT_SEPARATOR", () => {
|
|
73
|
+
const labels = ["yes"];
|
|
74
|
+
const answer = `yes${ANSWER_COMMENT_SEPARATOR}because reasons`;
|
|
75
|
+
const result = parseAnswerParts(answer, labels);
|
|
76
|
+
expect(result.selected).toEqual(["yes"]);
|
|
77
|
+
expect(result.comment).toBe("because reasons");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("handles full-width comma (,) as separator", () => {
|
|
81
|
+
const labels = ["A", "B"];
|
|
82
|
+
const result = parseAnswerParts("A,B", labels);
|
|
83
|
+
expect(result.selected).toEqual(["A", "B"]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("returns non-matching tokens as neither selected nor comment (Other free text)", () => {
|
|
87
|
+
const labels = ["yes", "no"];
|
|
88
|
+
// "custom text" 不匹配任何 label → 是 Other 自由文本
|
|
89
|
+
const result = parseAnswerParts("custom text", labels);
|
|
90
|
+
expect(result.selected).toEqual([]);
|
|
91
|
+
expect(result.comment).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("handles empty answer string", () => {
|
|
95
|
+
const result = parseAnswerParts("", ["A", "B"]);
|
|
96
|
+
expect(result.selected).toEqual([]);
|
|
97
|
+
expect(result.comment).toBeUndefined();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("handles answer with only comment (no selected labels)", () => {
|
|
101
|
+
const labels = ["A"];
|
|
102
|
+
const answer = `${ANSWER_COMMENT_SEPARATOR}just a comment`;
|
|
103
|
+
const result = parseAnswerParts(answer, labels);
|
|
104
|
+
expect(result.selected).toEqual([]);
|
|
105
|
+
expect(result.comment).toBe("just a comment");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// src/__tests__/channel-handler.test.ts
|
|
2
|
+
//
|
|
3
|
+
// Tests createAskUserChannelHandler:把 subagent 子进程的 ask_user 请求透传到主进程 UI。
|
|
4
|
+
//
|
|
5
|
+
// 覆盖:
|
|
6
|
+
// - RPC 路径(ctx.mode === 'rpc'):转发器——handler 内部调 askUserInteract(select 通道),
|
|
7
|
+
// 把 proto answers JSON.stringify 成 {value} 返回,子进程 JSON.parse(value) 正确 decode。
|
|
8
|
+
// - TUI 路径(ctx.mode === 'tui'):handler 走 ctx.ui.custom(mock 成返回预设 Result),
|
|
9
|
+
// 验证内部 Result → proto AskUserAnswers 重新编码(single/multi/Other/comment 四种答案形态)。
|
|
10
|
+
// - 取消(askUserInteract/custom 返回 null 或 cancelled)→ {cancelled: true}
|
|
11
|
+
// - 输入校验(channelPayload 缺失/无 questions)→ {cancelled: true}
|
|
12
|
+
import type { AskUserQuestion } from "@xyz-agent/extension-protocol";
|
|
13
|
+
import { describe, expect, it } from "vitest";
|
|
14
|
+
|
|
15
|
+
import { createAskUserChannelHandler } from "../channel-handler";
|
|
16
|
+
import type { Result } from "../types";
|
|
17
|
+
|
|
18
|
+
// ── Mock ctx ───────────────────────────────────────────
|
|
19
|
+
// RPC:ctx.ui.select 模拟前端回传的 proto answers(JSON.stringify(AskUserAnswers))。
|
|
20
|
+
// TUI:ctx.ui.custom 模拟 AskUserComponent 产出的内部 Result。
|
|
21
|
+
type CtxMode = "tui" | "rpc";
|
|
22
|
+
|
|
23
|
+
interface MockCtxOpts {
|
|
24
|
+
mode: CtxMode;
|
|
25
|
+
/** RPC:select 返回的 value(JSON.stringify 后的 proto answers);undefined = 取消 */
|
|
26
|
+
selectResult?: string | undefined;
|
|
27
|
+
/** TUI:custom 返回的内部 Result;null = 用户取消 */
|
|
28
|
+
customResult?: Result | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeCtx(opts: MockCtxOpts): {
|
|
32
|
+
mode: CtxMode;
|
|
33
|
+
hasUI: boolean;
|
|
34
|
+
ui: {
|
|
35
|
+
select?: (title: string, options: string[], o?: { signal?: AbortSignal }) => Promise<string | undefined>;
|
|
36
|
+
custom: <T = void>(factory: unknown) => Promise<T>;
|
|
37
|
+
};
|
|
38
|
+
} {
|
|
39
|
+
const { mode, selectResult, customResult } = opts;
|
|
40
|
+
const hasUI = true;
|
|
41
|
+
if (mode === "rpc") {
|
|
42
|
+
return {
|
|
43
|
+
mode,
|
|
44
|
+
hasUI,
|
|
45
|
+
ui: {
|
|
46
|
+
select: async (): Promise<string | undefined> => selectResult,
|
|
47
|
+
custom: async <T = void>(): Promise<T> => undefined as T,
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
// TUI
|
|
52
|
+
return {
|
|
53
|
+
mode,
|
|
54
|
+
hasUI,
|
|
55
|
+
ui: {
|
|
56
|
+
custom: async <T = void>(): Promise<T> => customResult as T,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── 样例 proto questions(handler 收到的格式) ──────────
|
|
62
|
+
const singleProto: AskUserQuestion = {
|
|
63
|
+
question: "Which DB?",
|
|
64
|
+
options: [{ label: "Postgres", value: "Postgres" }, { label: "SQLite", value: "SQLite" }],
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const multiProto: AskUserQuestion = {
|
|
68
|
+
question: "Which tools?",
|
|
69
|
+
header: "Tools",
|
|
70
|
+
multiSelect: true,
|
|
71
|
+
options: [
|
|
72
|
+
{ label: "A", value: "A" },
|
|
73
|
+
{ label: "B", value: "B" },
|
|
74
|
+
{ label: "C", value: "C" },
|
|
75
|
+
],
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const commentProto: AskUserQuestion = {
|
|
79
|
+
question: "Which DB?",
|
|
80
|
+
allowComment: true,
|
|
81
|
+
options: [{ label: "Postgres", value: "Postgres" }, { label: "SQLite", value: "SQLite" }],
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// ── Tests ───────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
describe("createAskUserChannelHandler", () => {
|
|
87
|
+
it("RPC: single-select proto answers → {value: JSON.stringify(answers)}", async () => {
|
|
88
|
+
// 前端回传 proto answers:{[key]: value},key = question 全文(无 header)
|
|
89
|
+
const protoAnswers = { "Which DB?": "Postgres" };
|
|
90
|
+
const handler = createAskUserChannelHandler(
|
|
91
|
+
makeCtx({ mode: "rpc", selectResult: JSON.stringify(protoAnswers) }) as never,
|
|
92
|
+
);
|
|
93
|
+
const resp = await handler({ channelPayload: { questions: [singleProto] } });
|
|
94
|
+
expect(resp).toEqual({ value: JSON.stringify({ "Which DB?": "Postgres" }) });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("RPC: multi-select proto answers (JSON array value) → 透传", async () => {
|
|
98
|
+
const protoAnswers = { Tools: JSON.stringify(["A", "C"]) };
|
|
99
|
+
const handler = createAskUserChannelHandler(
|
|
100
|
+
makeCtx({ mode: "rpc", selectResult: JSON.stringify(protoAnswers) }) as never,
|
|
101
|
+
);
|
|
102
|
+
const resp = await handler({ channelPayload: { questions: [multiProto] } });
|
|
103
|
+
expect(resp).toEqual({ value: JSON.stringify({ Tools: JSON.stringify(["A", "C"]) }) });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("RPC: Other + comment proto answers → 透传", async () => {
|
|
107
|
+
const protoAnswers = {
|
|
108
|
+
"Which DB?": "Postgres",
|
|
109
|
+
"Which DB?__other": "Custom DB",
|
|
110
|
+
"Which DB?__comment": "prod constraint",
|
|
111
|
+
};
|
|
112
|
+
const handler = createAskUserChannelHandler(
|
|
113
|
+
makeCtx({ mode: "rpc", selectResult: JSON.stringify(protoAnswers) }) as never,
|
|
114
|
+
);
|
|
115
|
+
const resp = await handler({ channelPayload: { questions: [commentProto] } });
|
|
116
|
+
expect(resp).toEqual({ value: JSON.stringify(protoAnswers) });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("RPC: user cancel (select undefined) → {cancelled: true}", async () => {
|
|
120
|
+
const handler = createAskUserChannelHandler(
|
|
121
|
+
makeCtx({ mode: "rpc", selectResult: undefined }) as never,
|
|
122
|
+
);
|
|
123
|
+
const resp = await handler({ channelPayload: { questions: [singleProto] } });
|
|
124
|
+
expect(resp).toEqual({ cancelled: true });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("TUI: internal Result single-select → 重新编码为 proto answers", async () => {
|
|
128
|
+
// 内部 Result.answers:key = question 全文,value = 选中 label
|
|
129
|
+
const internalResult: Result = {
|
|
130
|
+
questions: [],
|
|
131
|
+
answers: { "Which DB?": "Postgres" },
|
|
132
|
+
cancelled: false,
|
|
133
|
+
};
|
|
134
|
+
const handler = createAskUserChannelHandler(
|
|
135
|
+
makeCtx({ mode: "tui", customResult: internalResult }) as never,
|
|
136
|
+
);
|
|
137
|
+
const resp = await handler({ channelPayload: { questions: [singleProto] } });
|
|
138
|
+
// 期望:proto answers { "Which DB?": "Postgres" }
|
|
139
|
+
expect(resp).toEqual({ value: JSON.stringify({ "Which DB?": "Postgres" }) });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("TUI: multi-select internal Result → proto JSON array value", async () => {
|
|
143
|
+
const internalResult: Result = {
|
|
144
|
+
questions: [],
|
|
145
|
+
answers: { "Which tools?": "A, C" },
|
|
146
|
+
cancelled: false,
|
|
147
|
+
};
|
|
148
|
+
const handler = createAskUserChannelHandler(
|
|
149
|
+
makeCtx({ mode: "tui", customResult: internalResult }) as never,
|
|
150
|
+
);
|
|
151
|
+
const resp = await handler({ channelPayload: { questions: [multiProto] } });
|
|
152
|
+
// 期望:key=header "Tools",value = JSON.stringify(["A","C"])
|
|
153
|
+
expect(resp).toEqual({ value: JSON.stringify({ Tools: JSON.stringify(["A", "C"]) }) });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("TUI: value≠label single-select → encodeTuiResultToProto 回查 proto option value(PR #85 #8 回归守护)", async () => {
|
|
157
|
+
// value≠label 是 #8 修复的核心场景:TUI 渲染用 label,但 proto 期望回传 option.value。
|
|
158
|
+
// 若 #8 修复回归(直接 push label),此测试会失败:返回 "显示名A" 而非 "val_a"。
|
|
159
|
+
const valueNeqLabelProto: AskUserQuestion = {
|
|
160
|
+
question: "选哪个?",
|
|
161
|
+
options: [
|
|
162
|
+
{ label: "显示名A", value: "val_a" },
|
|
163
|
+
{ label: "显示名B", value: "val_b" },
|
|
164
|
+
],
|
|
165
|
+
};
|
|
166
|
+
// 内部 Result.answers:用户在 TUI 选了"显示名A"(label)
|
|
167
|
+
const internalResult: Result = {
|
|
168
|
+
questions: [],
|
|
169
|
+
answers: { "选哪个?": "显示名A" },
|
|
170
|
+
cancelled: false,
|
|
171
|
+
};
|
|
172
|
+
const handler = createAskUserChannelHandler(
|
|
173
|
+
makeCtx({ mode: "tui", customResult: internalResult }) as never,
|
|
174
|
+
);
|
|
175
|
+
const resp = await handler({ channelPayload: { questions: [valueNeqLabelProto] } });
|
|
176
|
+
// 期望:proto answers 回查 value,返回 "val_a"(不是 label "显示名A")
|
|
177
|
+
expect(resp).toEqual({ value: JSON.stringify({ "选哪个?": "val_a" }) });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("TUI: value≠label multi-select → proto JSON 数组元素回查 value(PR #85 #8 回归守护)", async () => {
|
|
181
|
+
// 多选路径同样依赖 #8 修复:selected.push(opt?.value ?? t),多选会 JSON.stringify 数组。
|
|
182
|
+
const valueNeqLabelMultiProto: AskUserQuestion = {
|
|
183
|
+
question: "选哪些?",
|
|
184
|
+
header: "Opts",
|
|
185
|
+
multiSelect: true,
|
|
186
|
+
options: [
|
|
187
|
+
{ label: "显示名A", value: "val_a" },
|
|
188
|
+
{ label: "显示名B", value: "val_b" },
|
|
189
|
+
],
|
|
190
|
+
};
|
|
191
|
+
const internalResult: Result = {
|
|
192
|
+
questions: [],
|
|
193
|
+
answers: { "选哪些?": "显示名A, 显示名B" },
|
|
194
|
+
cancelled: false,
|
|
195
|
+
};
|
|
196
|
+
const handler = createAskUserChannelHandler(
|
|
197
|
+
makeCtx({ mode: "tui", customResult: internalResult }) as never,
|
|
198
|
+
);
|
|
199
|
+
const resp = await handler({ channelPayload: { questions: [valueNeqLabelMultiProto] } });
|
|
200
|
+
// 期望:多选 JSON 数组,每个元素回查 value(["val_a","val_b"],不是 label)
|
|
201
|
+
expect(resp).toEqual({ value: JSON.stringify({ Opts: JSON.stringify(["val_a", "val_b"]) }) });
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("TUI: Other free text → ${key}__other", async () => {
|
|
205
|
+
// 内部 Result:selected label + Other 文本逗号拼接(与 getAnswerText 语义一致)
|
|
206
|
+
const internalResult: Result = {
|
|
207
|
+
questions: [],
|
|
208
|
+
answers: { "Which DB?": "Postgres, Custom DB" },
|
|
209
|
+
cancelled: false,
|
|
210
|
+
};
|
|
211
|
+
const handler = createAskUserChannelHandler(
|
|
212
|
+
makeCtx({ mode: "tui", customResult: internalResult }) as never,
|
|
213
|
+
);
|
|
214
|
+
const resp = await handler({ channelPayload: { questions: [singleProto] } });
|
|
215
|
+
expect(resp).toEqual({
|
|
216
|
+
value: JSON.stringify({ "Which DB?": "Postgres", "Which DB?__other": "Custom DB" }),
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("TUI: comment → ${key}__comment", async () => {
|
|
221
|
+
const internalResult: Result = {
|
|
222
|
+
questions: [],
|
|
223
|
+
answers: { "Which DB?": "Postgres — prod constraint" },
|
|
224
|
+
cancelled: false,
|
|
225
|
+
};
|
|
226
|
+
const handler = createAskUserChannelHandler(
|
|
227
|
+
makeCtx({ mode: "tui", customResult: internalResult }) as never,
|
|
228
|
+
);
|
|
229
|
+
const resp = await handler({ channelPayload: { questions: [commentProto] } });
|
|
230
|
+
expect(resp).toEqual({
|
|
231
|
+
value: JSON.stringify({ "Which DB?": "Postgres", "Which DB?__comment": "prod constraint" }),
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("TUI: user cancel (custom returns null) → {cancelled: true}", async () => {
|
|
236
|
+
const handler = createAskUserChannelHandler(
|
|
237
|
+
makeCtx({ mode: "tui", customResult: null }) as never,
|
|
238
|
+
);
|
|
239
|
+
const resp = await handler({ channelPayload: { questions: [singleProto] } });
|
|
240
|
+
expect(resp).toEqual({ cancelled: true });
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("TUI: custom returns cancelled Result → {cancelled: true}", async () => {
|
|
244
|
+
const handler = createAskUserChannelHandler(
|
|
245
|
+
makeCtx({
|
|
246
|
+
mode: "tui",
|
|
247
|
+
customResult: { questions: [], answers: {}, cancelled: true },
|
|
248
|
+
}) as never,
|
|
249
|
+
);
|
|
250
|
+
const resp = await handler({ channelPayload: { questions: [singleProto] } });
|
|
251
|
+
expect(resp).toEqual({ cancelled: true });
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("input: channelPayload missing → {cancelled: true}", async () => {
|
|
255
|
+
const handler = createAskUserChannelHandler(makeCtx({ mode: "rpc" }) as never);
|
|
256
|
+
const resp = await handler({});
|
|
257
|
+
expect(resp).toEqual({ cancelled: true });
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("input: questions empty array → {cancelled: true}", async () => {
|
|
261
|
+
const handler = createAskUserChannelHandler(makeCtx({ mode: "rpc" }) as never);
|
|
262
|
+
const resp = await handler({ channelPayload: { questions: [] } });
|
|
263
|
+
expect(resp).toEqual({ cancelled: true });
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("input: questions not an array → {cancelled: true}", async () => {
|
|
267
|
+
const handler = createAskUserChannelHandler(makeCtx({ mode: "rpc" }) as never);
|
|
268
|
+
const resp = await handler({ channelPayload: { questions: "not-array" } });
|
|
269
|
+
expect(resp).toEqual({ cancelled: true });
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("input: req is null/undefined → {cancelled: true}", async () => {
|
|
273
|
+
const handler = createAskUserChannelHandler(makeCtx({ mode: "rpc" }) as never);
|
|
274
|
+
expect(await handler(null)).toEqual({ cancelled: true });
|
|
275
|
+
expect(await handler(undefined)).toEqual({ cancelled: true });
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("RPC: allowCancel passed through to askUserInteract (default true)", async () => {
|
|
279
|
+
// allowCancel 默认 true:验证不抛错(select mock 返回 undefined=取消)
|
|
280
|
+
const handler = createAskUserChannelHandler(
|
|
281
|
+
makeCtx({ mode: "rpc", selectResult: undefined }) as never,
|
|
282
|
+
);
|
|
283
|
+
const resp = await handler({ channelPayload: { questions: [singleProto] } });
|
|
284
|
+
expect(resp).toEqual({ cancelled: true });
|
|
285
|
+
});
|
|
286
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// src/__tests__/channel-registry-register.test.ts
|
|
2
|
+
//
|
|
3
|
+
// Tests registerAskUserChannelHandler:ask-user 侧的 globalThis Symbol 握手注册纯函数。
|
|
4
|
+
//
|
|
5
|
+
// 覆盖(PR #85 #M4 + #M5):
|
|
6
|
+
// - 空 globalThis → 建 slot(仅 pending),handler 入 pending;**slot.registry === undefined**
|
|
7
|
+
// (M4 核心断言:ask-user 永不创建 registry 实例)
|
|
8
|
+
// - slot 存在但 registry 未就绪 → push pending(registry 仍 undefined)
|
|
9
|
+
// - slot 存在且 registry 就绪 → 直接调 registry.register("ask_user", handler),pending 不增长
|
|
10
|
+
// - 重复调用:registry 就绪时 register 多次(同名覆盖幂等);未就绪时 pending.length 增长
|
|
11
|
+
// - version !== 1 → warn + 重建为新 slot,旧 pending 丢弃
|
|
12
|
+
//
|
|
13
|
+
// 隔离:每个用例前 Reflect.deleteProperty(globalThis, CHANNEL_HANDSHAKE_KEY)。
|
|
14
|
+
import { beforeEach,describe, expect, it, vi } from "vitest";
|
|
15
|
+
|
|
16
|
+
import type { ChannelHandler } from "../channel-handler";
|
|
17
|
+
import {
|
|
18
|
+
CHANNEL_HANDSHAKE_KEY,
|
|
19
|
+
type ChannelRegistryHandshake,
|
|
20
|
+
registerAskUserChannelHandler,
|
|
21
|
+
} from "../channel-registry-register";
|
|
22
|
+
|
|
23
|
+
// 拿到当前 slot(cast any 安全:测试控制 slot 写入,结构已知)
|
|
24
|
+
function readSlot(): ChannelRegistryHandshake | undefined {
|
|
25
|
+
return Reflect.get(globalThis, CHANNEL_HANDSHAKE_KEY) as
|
|
26
|
+
| ChannelRegistryHandshake
|
|
27
|
+
| undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 塞一个指定 version 的 slot(手动构造,绕过 registerAskUserChannelHandler)。 */
|
|
31
|
+
function writeSlot(slot: ChannelRegistryHandshake): void {
|
|
32
|
+
Reflect.set(globalThis, CHANNEL_HANDSHAKE_KEY, slot);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 用 spy 构造 mock registry:register 是 vi.fn,pending flush 时可断言调用。 */
|
|
36
|
+
function makeMockRegistry(): {
|
|
37
|
+
register: ReturnType<typeof vi.fn>;
|
|
38
|
+
resolve: ReturnType<typeof vi.fn>;
|
|
39
|
+
list: ReturnType<typeof vi.fn>;
|
|
40
|
+
} {
|
|
41
|
+
return {
|
|
42
|
+
register: vi.fn(),
|
|
43
|
+
resolve: vi.fn().mockReturnValue(undefined),
|
|
44
|
+
list: vi.fn().mockReturnValue([]),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 占位 handler——测试只关心调用计数和参数,handler 实体不重要
|
|
49
|
+
const noopHandler: ChannelHandler = async () => undefined;
|
|
50
|
+
|
|
51
|
+
describe("registerAskUserChannelHandler", () => {
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
Reflect.deleteProperty(globalThis, CHANNEL_HANDSHAKE_KEY);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("空 globalThis → 建 slot,handler 入 pending,slot.registry === undefined(M4 核心)", () => {
|
|
57
|
+
expect(readSlot()).toBeUndefined();
|
|
58
|
+
|
|
59
|
+
registerAskUserChannelHandler(noopHandler);
|
|
60
|
+
|
|
61
|
+
const slot = readSlot();
|
|
62
|
+
expect(slot).toBeDefined();
|
|
63
|
+
expect(slot!.version).toBe(1);
|
|
64
|
+
// M4 核心断言:ask-user 永不创建 registry 实例
|
|
65
|
+
expect(slot!.registry).toBeUndefined();
|
|
66
|
+
expect(slot!.pending).toHaveLength(1);
|
|
67
|
+
expect(slot!.pending[0]).toEqual({ channel: "ask_user", handler: noopHandler });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("slot 存在但 registry 未就绪 → push pending(registry 仍 undefined)", () => {
|
|
71
|
+
// 预置 slot:version=1,pending=[],registry 缺失(模拟 subagent-workflow 尚未 session_start)
|
|
72
|
+
const preSlot: ChannelRegistryHandshake = { version: 1, pending: [] };
|
|
73
|
+
writeSlot(preSlot);
|
|
74
|
+
|
|
75
|
+
registerAskUserChannelHandler(noopHandler);
|
|
76
|
+
|
|
77
|
+
const slot = readSlot();
|
|
78
|
+
expect(slot).toBe(preSlot); // 同一对象,未重建
|
|
79
|
+
expect(slot!.registry).toBeUndefined();
|
|
80
|
+
expect(slot!.pending).toHaveLength(1);
|
|
81
|
+
expect(slot!.pending[0]).toEqual({ channel: "ask_user", handler: noopHandler });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("slot 存在且 registry 就绪 → 调 registry.register,pending 不增长", () => {
|
|
85
|
+
const mockRegistry = makeMockRegistry();
|
|
86
|
+
const preSlot: ChannelRegistryHandshake = {
|
|
87
|
+
version: 1,
|
|
88
|
+
registry: mockRegistry as unknown as ChannelRegistryHandshake["registry"],
|
|
89
|
+
pending: [],
|
|
90
|
+
};
|
|
91
|
+
writeSlot(preSlot);
|
|
92
|
+
|
|
93
|
+
registerAskUserChannelHandler(noopHandler);
|
|
94
|
+
|
|
95
|
+
expect(mockRegistry.register).toHaveBeenCalledTimes(1);
|
|
96
|
+
expect(mockRegistry.register).toHaveBeenCalledWith("ask_user", noopHandler);
|
|
97
|
+
// pending 不增长(直接 register,不进队列)
|
|
98
|
+
expect(preSlot.pending).toHaveLength(0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("registry 就绪时重复 register → register 被调多次(同名覆盖幂等,pending 始终 0)", () => {
|
|
102
|
+
const mockRegistry = makeMockRegistry();
|
|
103
|
+
const preSlot: ChannelRegistryHandshake = {
|
|
104
|
+
version: 1,
|
|
105
|
+
registry: mockRegistry as unknown as ChannelRegistryHandshake["registry"],
|
|
106
|
+
pending: [],
|
|
107
|
+
};
|
|
108
|
+
writeSlot(preSlot);
|
|
109
|
+
|
|
110
|
+
const h1: ChannelHandler = async () => "a";
|
|
111
|
+
const h2: ChannelHandler = async () => "b";
|
|
112
|
+
registerAskUserChannelHandler(h1);
|
|
113
|
+
registerAskUserChannelHandler(h2);
|
|
114
|
+
|
|
115
|
+
expect(mockRegistry.register).toHaveBeenCalledTimes(2);
|
|
116
|
+
expect(mockRegistry.register).toHaveBeenNthCalledWith(1, "ask_user", h1);
|
|
117
|
+
expect(mockRegistry.register).toHaveBeenNthCalledWith(2, "ask_user", h2);
|
|
118
|
+
expect(preSlot.pending).toHaveLength(0); // 幂等:不进 pending
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("registry 未就绪时多次 register → pending.length 增长(顺序保留)", () => {
|
|
122
|
+
// 第 1 次:空 globalThis → 建 slot + pending[0]
|
|
123
|
+
const h1: ChannelHandler = async () => "a";
|
|
124
|
+
registerAskUserChannelHandler(h1);
|
|
125
|
+
// 第 2 次:slot 已存在、registry 仍 undefined → pending[1]
|
|
126
|
+
const h2: ChannelHandler = async () => "b";
|
|
127
|
+
registerAskUserChannelHandler(h2);
|
|
128
|
+
|
|
129
|
+
const slot = readSlot();
|
|
130
|
+
expect(slot!.registry).toBeUndefined();
|
|
131
|
+
expect(slot!.pending).toHaveLength(2);
|
|
132
|
+
expect(slot!.pending[0]).toEqual({ channel: "ask_user", handler: h1 });
|
|
133
|
+
expect(slot!.pending[1]).toEqual({ channel: "ask_user", handler: h2 });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("version !== 1 → warn + 重建为新 slot,旧 pending 丢弃", () => {
|
|
137
|
+
// 塞一个 version=2 的旧 slot(模拟未来协议升级 / 脏数据)
|
|
138
|
+
const spyWarn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
139
|
+
const legacyPending = [{ channel: "stale", handler: noopHandler }];
|
|
140
|
+
const legacySlot = {
|
|
141
|
+
version: 2 as const,
|
|
142
|
+
registry: makeMockRegistry() as unknown as ChannelRegistryHandshake["registry"],
|
|
143
|
+
pending: legacyPending,
|
|
144
|
+
};
|
|
145
|
+
writeSlot(legacySlot as unknown as ChannelRegistryHandshake);
|
|
146
|
+
|
|
147
|
+
registerAskUserChannelHandler(noopHandler);
|
|
148
|
+
|
|
149
|
+
const slot = readSlot();
|
|
150
|
+
// 旧 slot 被替换(version 退回 1,pending 重置为仅含本次注册)
|
|
151
|
+
expect(slot).not.toBe(legacySlot);
|
|
152
|
+
expect(slot!.version).toBe(1);
|
|
153
|
+
expect(slot!.pending).toEqual([{ channel: "ask_user", handler: noopHandler }]);
|
|
154
|
+
// warn 被调用(包含 version mismatch 提示)
|
|
155
|
+
expect(spyWarn).toHaveBeenCalledTimes(1);
|
|
156
|
+
expect(spyWarn.mock.calls[0]![0]).toContain("version mismatch");
|
|
157
|
+
|
|
158
|
+
spyWarn.mockRestore();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("registry 就绪但 slot 已预置 pending → 仍走 register 路径(pending 不被本函数消费)", () => {
|
|
162
|
+
// 边界:subagent-workflow flush 后理论上 pending 应为空,但若 flush 漏了,
|
|
163
|
+
// 新 handler 来时仍应直接 register(不消费遗留 pending——那是 subagent-workflow 的职责)。
|
|
164
|
+
const mockRegistry = makeMockRegistry();
|
|
165
|
+
const preExistingPending = [{ channel: "ask_user", handler: noopHandler }];
|
|
166
|
+
const preSlot: ChannelRegistryHandshake = {
|
|
167
|
+
version: 1,
|
|
168
|
+
registry: mockRegistry as unknown as ChannelRegistryHandshake["registry"],
|
|
169
|
+
pending: preExistingPending,
|
|
170
|
+
};
|
|
171
|
+
writeSlot(preSlot);
|
|
172
|
+
|
|
173
|
+
registerAskUserChannelHandler(noopHandler);
|
|
174
|
+
|
|
175
|
+
expect(mockRegistry.register).toHaveBeenCalledTimes(1);
|
|
176
|
+
// 遗留 pending 未被消费(长度不变)
|
|
177
|
+
expect(preSlot.pending).toHaveLength(1);
|
|
178
|
+
expect(preSlot.pending).toBe(preExistingPending);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -473,37 +473,6 @@ describe("AskUserComponent — unknown control sequence leak fix (C-CSI)", () =>
|
|
|
473
473
|
expect(editorLine).toContain("hello");
|
|
474
474
|
expect(editorLine).not.toContain("[200~");
|
|
475
475
|
});
|
|
476
|
-
|
|
477
|
-
it("C-CSI-R5: arrow keys still no-op", () => {
|
|
478
|
-
const c = openFreeform([singleQ]);
|
|
479
|
-
c.handleInput(RIGHT);
|
|
480
|
-
c.handleInput(RIGHT);
|
|
481
|
-
c.handleInput(RIGHT);
|
|
482
|
-
c.handleInput("a");
|
|
483
|
-
c.handleInput("b");
|
|
484
|
-
const lines = c.render(60);
|
|
485
|
-
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
486
|
-
expect(editorLine).toContain("ab");
|
|
487
|
-
expect(editorLine).not.toContain("\x1b[A"); expect(editorLine).not.toContain("\x1b[B"); expect(editorLine).not.toContain("\x1b[C"); expect(editorLine).not.toContain("\x1b[D");
|
|
488
|
-
});
|
|
489
|
-
|
|
490
|
-
it("C-CSI-R6: backspace still works", () => {
|
|
491
|
-
const c = openFreeform([singleQ]);
|
|
492
|
-
c.handleInput("abc");
|
|
493
|
-
c.handleInput(BKSP);
|
|
494
|
-
const lines = c.render(60);
|
|
495
|
-
const editorLine = lines.find((l) => l.includes("\x1b[7m"));
|
|
496
|
-
expect(editorLine).toContain("ab");
|
|
497
|
-
expect(editorLine).not.toContain("abc");
|
|
498
|
-
});
|
|
499
|
-
|
|
500
|
-
it("C-CSI-R7: Esc still exits editor", () => {
|
|
501
|
-
const c = openFreeform([singleQ]);
|
|
502
|
-
c.handleInput("abc");
|
|
503
|
-
c.handleInput(ESC);
|
|
504
|
-
const lines = c.render(60);
|
|
505
|
-
expect(lines.some((l) => l.includes("\x1b[7m"))).toBe(false);
|
|
506
|
-
});
|
|
507
476
|
});
|
|
508
477
|
|
|
509
478
|
// 🐛 = U+1F41B,UTF-16 surrogate pair(占 2 个 code unit,index 1=高代理 / 2=低代理)。
|
|
@@ -16,6 +16,8 @@ interface PiShape {
|
|
|
16
16
|
registerTool(t: unknown): void;
|
|
17
17
|
getAllTools(): { name: string }[];
|
|
18
18
|
setActiveTools(names: string[]): void;
|
|
19
|
+
// session_start handler(factory 注册透传 channel 用);测试不覆盖透传,空实现。
|
|
20
|
+
on(event: string, handler: (...args: unknown[]) => unknown): void;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
export interface E2EApi {
|
|
@@ -48,6 +50,9 @@ export function makeE2E(questions: Question[], opts: E2EOptions = {}): E2EApi {
|
|
|
48
50
|
setActiveTools(names) {
|
|
49
51
|
this.activeTools = names;
|
|
50
52
|
},
|
|
53
|
+
on() {
|
|
54
|
+
// no-op:session_start handler(透传 channel 注册),测试不覆盖
|
|
55
|
+
},
|
|
51
56
|
};
|
|
52
57
|
|
|
53
58
|
factory(pi as never);
|
|
@@ -72,6 +77,7 @@ export function makeE2E(questions: Question[], opts: E2EOptions = {}): E2EApi {
|
|
|
72
77
|
controller.signal,
|
|
73
78
|
undefined,
|
|
74
79
|
{
|
|
80
|
+
mode: hasUI ? "tui" : "print",
|
|
75
81
|
hasUI,
|
|
76
82
|
signal: controller.signal,
|
|
77
83
|
ui: {
|
|
@@ -75,9 +75,9 @@ describe("E2E-3: single question + allowComment — Enter in comment skips", ()
|
|
|
75
75
|
expect(details.cancelled).toBe(false);
|
|
76
76
|
// 不含 " — " 分隔符
|
|
77
77
|
expect(details.answers["Which DB?"]).toBe("Postgres");
|
|
78
|
-
|
|
79
|
-
});
|
|
78
|
+
expect(details.answers["Which DB?"]).not.toContain("—");
|
|
80
79
|
});
|
|
80
|
+
});
|
|
81
81
|
|
|
82
82
|
// ── E2E-4: 多问题提交 — 逐题选择后 Submit tab 提交(S-11)──────
|
|
83
83
|
describe("E2E-4: multi-question submit — answer each then Submit tab", () => {
|