@zhushanwen/pi-ask-user 7.2.4 → 7.3.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/ARCHITECTURE.md +17 -17
- package/package.json +2 -2
- package/src/__tests__/channel-handler.test.ts +151 -45
- package/src/__tests__/channel-registry-register.test.ts +36 -20
- package/src/__tests__/form-adapter.test.ts +156 -0
- package/src/__tests__/index.test.ts +126 -3
- package/src/channel-handler.ts +79 -49
- package/src/channel-registry-register.ts +19 -12
- package/src/form-adapter.ts +119 -0
- package/src/index.ts +30 -7
package/ARCHITECTURE.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
Internals reference for maintainers. For the usage contract (what the tool does, when an agent should call it), see [README.md](./README.md). This document covers how the code is structured, the state machine, the defensive execute flow, and where each design invariant is enforced — so a change does not silently break an invariant.
|
|
6
6
|
|
|
7
|
-
Source:
|
|
7
|
+
Source: 11 files in `src/`, ~2265 lines total.
|
|
8
8
|
|
|
9
9
|
## File dependency graph
|
|
10
10
|
|
|
@@ -32,21 +32,21 @@ Source: 10 files in `src/`, ~2085 lines total.
|
|
|
32
32
|
│ race guards │ │ cursor/paste) │ │
|
|
33
33
|
└──────────▲──────────┘ └────────────────────┘ │
|
|
34
34
|
│ │
|
|
35
|
-
│ ┌────────────────────┐
|
|
36
|
-
├────────────────▶│ channel-handler.ts
|
|
37
|
-
│ (AskUserComp.) │ subagent passthru │
|
|
38
|
-
│ └─────────▲──────────┘
|
|
39
|
-
│ │
|
|
40
|
-
│ ┌─────────┴──────────┐
|
|
41
|
-
│ │channel-registry- │
|
|
42
|
-
│ │register.ts │
|
|
43
|
-
│ │globalThis Symbol │
|
|
44
|
-
│ │handshake │
|
|
45
|
-
│ └────────────────────┘
|
|
46
|
-
┌──────┴───────────┐
|
|
47
|
-
│ index.ts
|
|
35
|
+
│ ┌────────────────────┐ ┌────────────────┐
|
|
36
|
+
├────────────────▶│ channel-handler.ts │◀─│ form-adapter │
|
|
37
|
+
│ (AskUserComp.) │ subagent passthru │ │ AskUserQuestion│
|
|
38
|
+
│ └─────────▲──────────┘ │ ↔ FormQuestion │
|
|
39
|
+
│ │ │ normalize (D3) │
|
|
40
|
+
│ ┌─────────┴──────────┐ └───────▲────────┘
|
|
41
|
+
│ │channel-registry- │ │
|
|
42
|
+
│ │register.ts │ │
|
|
43
|
+
│ │globalThis Symbol │ │
|
|
44
|
+
│ │handshake │ │
|
|
45
|
+
│ └────────────────────┘ │
|
|
46
|
+
┌──────┴───────────┐ │
|
|
47
|
+
│ index.ts │◀────────────────────────────────────┘
|
|
48
48
|
└──────────────────┘ imports component + validate + types + submit-view +
|
|
49
|
-
channel-handler + channel-registry-register
|
|
49
|
+
channel-handler + channel-registry-register + form-adapter
|
|
50
50
|
```
|
|
51
51
|
|
|
52
52
|
**No cycles.** All imports flow one direction; `types.ts` is the single leaf depended on by everyone.
|
|
@@ -67,11 +67,11 @@ Encoding is **one-way** — the old `parseAnswerParts` text reverse-parsing was
|
|
|
67
67
|
|
|
68
68
|
## Channel registry handshake (subagent passthrough)
|
|
69
69
|
|
|
70
|
-
`channel-handler.ts` + `channel-registry-register.ts` implement the subagent passthrough: the host-process ask-user extension registers
|
|
70
|
+
`channel-handler.ts` + `channel-registry-register.ts` implement the subagent passthrough: the host-process ask-user extension registers one handler under **both channel names** — `ASK_USER_CHANNELS = ["ask_user", "ui_form"]` (`channel-registry-register.ts`; D9 dual-name: new subagents send `ui_form`, legacy npm subagents still send `ask_user`) — through a versioned `globalThis[Symbol.for]` slot handshake (`CHANNEL_HANDSHAKE_KEY`). The skeleton (versioned slot + pending/flush; this side never creates the registry instance) is deliberate and must not be simplified away — the M4 incident (a simplified self-made registry hijacking the canonical slot) is the anchor, see the header comment of `channel-registry-register.ts`. A second consumer of the same handshake *pattern* exists: `extensions/universal/permission/src/footer-provider.ts` (own slot key `FOOTER_HANDSHAKE_KEY`, same versioned-slot shape).
|
|
71
71
|
|
|
72
72
|
Registry outreach facts (ext-simplify-11, D4):
|
|
73
73
|
|
|
74
|
-
- **Single registrant on this slot.** The `CHANNEL_HANDSHAKE_KEY` slot currently has exactly one registrant: ask-user
|
|
74
|
+
- **Single registrant on this slot.** The `CHANNEL_HANDSHAKE_KEY` slot currently has exactly one registrant: ask-user, registering the same handler on both `"ask_user"` and `"ui_form"` (one handler, two channel names). The permission footer-provider handshake above uses its own separate slot — same pattern, different slot, not a registrant here.
|
|
75
75
|
- **`"gui_widget"` route reserved but vacant.** The engine-sdk ui-channels parsing (`parseChannel`, re-exported via `packages/subagent-core/src/execution/ui-channels.ts`) reserves a `"gui_widget"` channel name, but no package registers a handler for it; the core-side `ui-request-handler-factory` special-cases the unregistered `"gui_widget"` request to `{ack:true}` without forwarding. A future registrant needs no ask-user change — registration is keyed by channel name and orthogonal per extension.
|
|
76
76
|
- **Known gap, accepted (version-mismatch slot overwrite).** On `slot.version !== 1`, `readSlot` discards the slot and `registerAskUserChannelHandler` rebuilds it via `ensureSlot` — if a hypothetical v2 registry ever held the slot, this would drop its reference. Ruled not-worth-fixing (ext-simplify-11 finding 6): both sides pin `HANDSHAKE_VERSION = 1`, so the mismatch path is unreachable today; fixing it would be defensive code for an imagined v2. Re-review trigger: any PR that bumps either side's handshake version.
|
|
77
77
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-ask-user",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.3.0",
|
|
4
4
|
"description": "Inline adaptive ask_user tool for Pi — single/multi-question structured input with split-pane preview and an inline free-text editor.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"ARCHITECTURE.md"
|
|
30
30
|
],
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@zhushanwen/extension-protocol": "0.
|
|
32
|
+
"@zhushanwen/extension-protocol": "0.13.0",
|
|
33
33
|
"@zhushanwen/pi-ext-guards": "0.4.1",
|
|
34
34
|
"@zhushanwen/pi-extension-logger": "0.6.1"
|
|
35
35
|
},
|
|
@@ -2,22 +2,29 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Tests createAskUserChannelHandler:把 subagent 子进程的 ask_user 请求透传到主进程 UI。
|
|
4
4
|
//
|
|
5
|
+
// D9 双 payload mock 轨:孙进程 npm 版本不可控——新孙进程发 {formQuestions: FormQuestion[]}
|
|
6
|
+
// (ui_form channel),旧孙进程发 {questions: AskUserQuestion[]}(ask_user channel)。
|
|
7
|
+
// 核心行为用例全部在两种 payload 形态下各跑一遍(payloadShapes 参数化),断言输出逐字一致。
|
|
8
|
+
//
|
|
5
9
|
// 覆盖:
|
|
6
|
-
// - RPC 路径(ctx.mode === 'rpc'):转发器——handler 内部调
|
|
7
|
-
//
|
|
10
|
+
// - RPC 路径(ctx.mode === 'rpc'):转发器——handler 内部调 uiFormInteract(select 通道 +
|
|
11
|
+
// UI_FORM_MARKER),把 proto answers JSON.stringify 成 {value} 返回,子进程 JSON.parse(value)
|
|
12
|
+
// 正确 decode(回包 isFormAnswers 校验通过)。
|
|
8
13
|
// - TUI 路径(ctx.mode === 'tui'):handler 走 ctx.ui.custom(mock 成返回预设 Result),
|
|
9
|
-
// 验证内部结构化 Result.answers(AnswerValue)→ proto
|
|
10
|
-
// (single/multi/Other
|
|
11
|
-
// - 取消(
|
|
12
|
-
// - 输入校验(channelPayload
|
|
13
|
-
import type { AskUserQuestion } from "@zhushanwen/extension-protocol";
|
|
14
|
+
// 验证内部结构化 Result.answers(AnswerValue)→ proto answers 重新编码
|
|
15
|
+
// (single/multi/Other/text 四种答案形态,经 encodeAnswer 序列化)。
|
|
16
|
+
// - 取消(uiFormInteract 不 ok / custom 返回 null 或 cancelled)→ {cancelled: true}
|
|
17
|
+
// - 输入校验(channelPayload 缺失 / 双读两键皆空 / formQuestions 全不合法)→ {cancelled: true}
|
|
18
|
+
import type { AskUserQuestion, FormQuestion } from "@zhushanwen/extension-protocol";
|
|
14
19
|
import { describe, expect, it } from "vitest";
|
|
15
20
|
|
|
16
21
|
import { createAskUserChannelHandler } from "../channel-handler";
|
|
17
22
|
import type { Result } from "../types";
|
|
18
23
|
|
|
19
24
|
// ── Mock ctx ───────────────────────────────────────────
|
|
20
|
-
// RPC:ctx.ui.select 模拟前端回传的 proto answers(JSON.stringify(
|
|
25
|
+
// RPC:ctx.ui.select 模拟前端回传的 proto answers(JSON.stringify(FormAnswers))。
|
|
26
|
+
// selectEcho = true 时 select 原样回显 options[0](模拟旧宿主不识别 UI_FORM_MARKER 的
|
|
27
|
+
// band 单选项回显——uiFormInteract echo 检测命中 → channel-error → handler 折叠 cancelled)。
|
|
21
28
|
// TUI:ctx.ui.custom 模拟 AskUserComponent 产出的内部 Result。
|
|
22
29
|
type CtxMode = "tui" | "rpc";
|
|
23
30
|
|
|
@@ -25,6 +32,8 @@ interface MockCtxOpts {
|
|
|
25
32
|
mode: CtxMode;
|
|
26
33
|
/** RPC:select 返回的 value(JSON.stringify 后的 proto answers);undefined = 取消 */
|
|
27
34
|
selectResult?: string | undefined;
|
|
35
|
+
/** RPC:select 原样回显收到的 options[0](echo 场景) */
|
|
36
|
+
selectEcho?: boolean;
|
|
28
37
|
/** TUI:custom 返回的内部 Result;null = 用户取消 */
|
|
29
38
|
customResult?: Result | null;
|
|
30
39
|
}
|
|
@@ -37,14 +46,15 @@ function makeCtx(opts: MockCtxOpts): {
|
|
|
37
46
|
custom: <T = void>(factory: unknown) => Promise<T>;
|
|
38
47
|
};
|
|
39
48
|
} {
|
|
40
|
-
const { mode, selectResult, customResult } = opts;
|
|
49
|
+
const { mode, selectResult, selectEcho, customResult } = opts;
|
|
41
50
|
const hasUI = true;
|
|
42
51
|
if (mode === "rpc") {
|
|
43
52
|
return {
|
|
44
53
|
mode,
|
|
45
54
|
hasUI,
|
|
46
55
|
ui: {
|
|
47
|
-
select: async (): Promise<string | undefined> =>
|
|
56
|
+
select: async (_title: string, options: string[]): Promise<string | undefined> =>
|
|
57
|
+
selectEcho ? options[0] : selectResult,
|
|
48
58
|
custom: async <T = void>(): Promise<T> => undefined as T,
|
|
49
59
|
},
|
|
50
60
|
};
|
|
@@ -59,42 +69,75 @@ function makeCtx(opts: MockCtxOpts): {
|
|
|
59
69
|
};
|
|
60
70
|
}
|
|
61
71
|
|
|
62
|
-
// ──
|
|
63
|
-
const
|
|
72
|
+
// ── 样例问题(legacy AskUserQuestion 与等价 FormQuestion 双形态)──
|
|
73
|
+
const legacySingle: AskUserQuestion = {
|
|
74
|
+
question: "Which DB?",
|
|
75
|
+
options: [{ label: "Postgres" }, { label: "SQLite" }],
|
|
76
|
+
};
|
|
77
|
+
const formSingle: FormQuestion = {
|
|
78
|
+
type: "choice",
|
|
64
79
|
question: "Which DB?",
|
|
65
80
|
options: [{ label: "Postgres" }, { label: "SQLite" }],
|
|
66
81
|
};
|
|
67
82
|
|
|
68
|
-
const
|
|
83
|
+
const legacyMulti: AskUserQuestion = {
|
|
69
84
|
question: "Which tools?",
|
|
70
85
|
header: "Tools",
|
|
71
86
|
multiSelect: true,
|
|
72
87
|
options: [{ label: "A" }, { label: "B" }, { label: "C" }],
|
|
73
88
|
};
|
|
89
|
+
const formMulti: FormQuestion = {
|
|
90
|
+
type: "choice",
|
|
91
|
+
question: "Which tools?",
|
|
92
|
+
header: "Tools",
|
|
93
|
+
multi: true,
|
|
94
|
+
options: [{ label: "A" }, { label: "B" }, { label: "C" }],
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** D9 双 payload 轨:同一问题集的两种 wire 形态(旧 questions / 新 formQuestions) */
|
|
98
|
+
function payloadShapes(
|
|
99
|
+
legacy: AskUserQuestion[],
|
|
100
|
+
form: FormQuestion[],
|
|
101
|
+
allowCancel?: boolean,
|
|
102
|
+
): { label: string; req: unknown }[] {
|
|
103
|
+
return [
|
|
104
|
+
{
|
|
105
|
+
label: "legacy payload {questions}",
|
|
106
|
+
req: { channelPayload: { questions: legacy, ...(allowCancel !== undefined ? { allowCancel } : {}) } },
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
label: "new payload {formQuestions}",
|
|
110
|
+
req: { channelPayload: { formQuestions: form, ...(allowCancel !== undefined ? { allowCancel } : {}) } },
|
|
111
|
+
},
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const singleShapes = payloadShapes([legacySingle], [formSingle]);
|
|
116
|
+
const multiShapes = payloadShapes([legacyMulti], [formMulti]);
|
|
74
117
|
|
|
75
118
|
// ── Tests ───────────────────────────────────────────────
|
|
76
119
|
|
|
77
|
-
describe("createAskUserChannelHandler", () => {
|
|
78
|
-
it("RPC
|
|
120
|
+
describe("createAskUserChannelHandler — D9 双 payload 轨(两种形态行为逐字一致)", () => {
|
|
121
|
+
it.each(singleShapes)("RPC single-select: $label → {value: JSON.stringify(answers)}", async ({ req }) => {
|
|
79
122
|
// 前端回传 proto answers:{[key]: value},key = question 全文(无 header)
|
|
80
123
|
const protoAnswers = { "Which DB?": "Postgres" };
|
|
81
124
|
const handler = createAskUserChannelHandler(
|
|
82
125
|
makeCtx({ mode: "rpc", selectResult: JSON.stringify(protoAnswers) }) as never,
|
|
83
126
|
);
|
|
84
|
-
const resp = await handler(
|
|
127
|
+
const resp = await handler(req);
|
|
85
128
|
expect(resp).toEqual({ value: JSON.stringify({ "Which DB?": "Postgres" }) });
|
|
86
129
|
});
|
|
87
130
|
|
|
88
|
-
it("RPC
|
|
131
|
+
it.each(multiShapes)("RPC multi-select: $label → JSON array value 透传", async ({ req }) => {
|
|
89
132
|
const protoAnswers = { Tools: JSON.stringify(["A", "C"]) };
|
|
90
133
|
const handler = createAskUserChannelHandler(
|
|
91
134
|
makeCtx({ mode: "rpc", selectResult: JSON.stringify(protoAnswers) }) as never,
|
|
92
135
|
);
|
|
93
|
-
const resp = await handler(
|
|
136
|
+
const resp = await handler(req);
|
|
94
137
|
expect(resp).toEqual({ value: JSON.stringify({ Tools: JSON.stringify(["A", "C"]) }) });
|
|
95
138
|
});
|
|
96
139
|
|
|
97
|
-
it("RPC
|
|
140
|
+
it.each(singleShapes)("RPC Other: $label → __other 键透传", async ({ req }) => {
|
|
98
141
|
const protoAnswers = {
|
|
99
142
|
"Which DB?": "Postgres",
|
|
100
143
|
"Which DB?__other": "Custom DB",
|
|
@@ -102,19 +145,32 @@ describe("createAskUserChannelHandler", () => {
|
|
|
102
145
|
const handler = createAskUserChannelHandler(
|
|
103
146
|
makeCtx({ mode: "rpc", selectResult: JSON.stringify(protoAnswers) }) as never,
|
|
104
147
|
);
|
|
105
|
-
const resp = await handler(
|
|
148
|
+
const resp = await handler(req);
|
|
106
149
|
expect(resp).toEqual({ value: JSON.stringify(protoAnswers) });
|
|
107
150
|
});
|
|
108
151
|
|
|
109
|
-
it("RPC
|
|
152
|
+
it.each(singleShapes)("RPC user cancel: $label → {cancelled: true}", async ({ req }) => {
|
|
110
153
|
const handler = createAskUserChannelHandler(
|
|
111
154
|
makeCtx({ mode: "rpc", selectResult: undefined }) as never,
|
|
112
155
|
);
|
|
113
|
-
const resp = await handler(
|
|
156
|
+
const resp = await handler(req);
|
|
114
157
|
expect(resp).toEqual({ cancelled: true });
|
|
115
158
|
});
|
|
116
159
|
|
|
117
|
-
it(
|
|
160
|
+
it.each(singleShapes)(
|
|
161
|
+
"RPC echo(旧宿主回显 payload): $label → channel-error 折叠 {cancelled: true}",
|
|
162
|
+
async ({ req }) => {
|
|
163
|
+
// uiFormInteract echo 检测命中(value === payload)→ channel-error;
|
|
164
|
+
// 间接链回包形状只有 {value}|{cancelled},throw 会被 dialog-queue 兜底成同一结果
|
|
165
|
+
const handler = createAskUserChannelHandler(
|
|
166
|
+
makeCtx({ mode: "rpc", selectEcho: true }) as never,
|
|
167
|
+
);
|
|
168
|
+
const resp = await handler(req);
|
|
169
|
+
expect(resp).toEqual({ cancelled: true });
|
|
170
|
+
},
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
it.each(singleShapes)("TUI single-select: $label → 重新编码为 proto answers", async ({ req }) => {
|
|
118
174
|
// 内部 Result.answers:key = question 全文,value = 结构化 AnswerValue
|
|
119
175
|
const internalResult: Result = {
|
|
120
176
|
questions: [],
|
|
@@ -124,12 +180,12 @@ describe("createAskUserChannelHandler", () => {
|
|
|
124
180
|
const handler = createAskUserChannelHandler(
|
|
125
181
|
makeCtx({ mode: "tui", customResult: internalResult }) as never,
|
|
126
182
|
);
|
|
127
|
-
const resp = await handler(
|
|
183
|
+
const resp = await handler(req);
|
|
128
184
|
// 期望:proto answers { "Which DB?": "Postgres" }
|
|
129
185
|
expect(resp).toEqual({ value: JSON.stringify({ "Which DB?": "Postgres" }) });
|
|
130
186
|
});
|
|
131
187
|
|
|
132
|
-
it("TUI
|
|
188
|
+
it.each(multiShapes)("TUI multi-select: $label → proto JSON array value", async ({ req }) => {
|
|
133
189
|
const internalResult: Result = {
|
|
134
190
|
questions: [],
|
|
135
191
|
answers: { "Which tools?": { selected: ["A", "C"], other: null } },
|
|
@@ -138,12 +194,12 @@ describe("createAskUserChannelHandler", () => {
|
|
|
138
194
|
const handler = createAskUserChannelHandler(
|
|
139
195
|
makeCtx({ mode: "tui", customResult: internalResult }) as never,
|
|
140
196
|
);
|
|
141
|
-
const resp = await handler(
|
|
197
|
+
const resp = await handler(req);
|
|
142
198
|
// 期望:key=header "Tools",value = JSON.stringify(["A","C"])
|
|
143
199
|
expect(resp).toEqual({ value: JSON.stringify({ Tools: JSON.stringify(["A", "C"]) }) });
|
|
144
200
|
});
|
|
145
201
|
|
|
146
|
-
it("TUI
|
|
202
|
+
it.each(singleShapes)("TUI Other free text: $label → ${key}__other", async ({ req }) => {
|
|
147
203
|
// 内部 Result:selected label + Other 文本分离存储(AnswerValue.other)
|
|
148
204
|
const internalResult: Result = {
|
|
149
205
|
questions: [],
|
|
@@ -153,13 +209,23 @@ describe("createAskUserChannelHandler", () => {
|
|
|
153
209
|
const handler = createAskUserChannelHandler(
|
|
154
210
|
makeCtx({ mode: "tui", customResult: internalResult }) as never,
|
|
155
211
|
);
|
|
156
|
-
const resp = await handler(
|
|
212
|
+
const resp = await handler(req);
|
|
157
213
|
expect(resp).toEqual({
|
|
158
214
|
value: JSON.stringify({ "Which DB?": "Postgres", "Which DB?__other": "Custom DB" }),
|
|
159
215
|
});
|
|
160
216
|
});
|
|
161
217
|
|
|
162
|
-
it("TUI
|
|
218
|
+
it.each(singleShapes)("TUI user cancel: $label → {cancelled: true}", async ({ req }) => {
|
|
219
|
+
const handler = createAskUserChannelHandler(
|
|
220
|
+
makeCtx({ mode: "tui", customResult: null }) as never,
|
|
221
|
+
);
|
|
222
|
+
const resp = await handler(req);
|
|
223
|
+
expect(resp).toEqual({ cancelled: true });
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("createAskUserChannelHandler — 形态特有用例", () => {
|
|
228
|
+
it("TUI Other-only answer(selected 空)→ 只写 ${key}__other 不写主 key", async () => {
|
|
163
229
|
const internalResult: Result = {
|
|
164
230
|
questions: [],
|
|
165
231
|
answers: { "Which DB?": { selected: [], other: "custom text" } },
|
|
@@ -168,61 +234,101 @@ describe("createAskUserChannelHandler", () => {
|
|
|
168
234
|
const handler = createAskUserChannelHandler(
|
|
169
235
|
makeCtx({ mode: "tui", customResult: internalResult }) as never,
|
|
170
236
|
);
|
|
171
|
-
const resp = await handler({ channelPayload: { questions: [
|
|
237
|
+
const resp = await handler({ channelPayload: { questions: [legacySingle] } });
|
|
172
238
|
expect(resp).toEqual({
|
|
173
239
|
value: JSON.stringify({ "Which DB?__other": "custom text" }),
|
|
174
240
|
});
|
|
175
241
|
});
|
|
176
242
|
|
|
177
|
-
it("TUI
|
|
243
|
+
it("TUI custom returns cancelled Result → {cancelled: true}", async () => {
|
|
178
244
|
const handler = createAskUserChannelHandler(
|
|
179
|
-
makeCtx({
|
|
245
|
+
makeCtx({
|
|
246
|
+
mode: "tui",
|
|
247
|
+
customResult: { questions: [], answers: {}, cancelled: true },
|
|
248
|
+
}) as never,
|
|
180
249
|
);
|
|
181
|
-
const resp = await handler({ channelPayload: { questions: [
|
|
250
|
+
const resp = await handler({ channelPayload: { questions: [legacySingle] } });
|
|
182
251
|
expect(resp).toEqual({ cancelled: true });
|
|
183
252
|
});
|
|
184
253
|
|
|
185
|
-
it("TUI
|
|
254
|
+
it("TUI text question(新形态):自由文本答案只写 ${key}__other", async () => {
|
|
255
|
+
// 旧 payload 无 options 问题 → text;组件渲染为空 options 自由输入,
|
|
256
|
+
// 答案经 AnswerValue.other 编码为 __other 键(与 FormOverlay text 题键位一致)
|
|
257
|
+
const internalResult: Result = {
|
|
258
|
+
questions: [],
|
|
259
|
+
answers: { "Describe the issue": { selected: [], other: "typed free text" } },
|
|
260
|
+
cancelled: false,
|
|
261
|
+
};
|
|
186
262
|
const handler = createAskUserChannelHandler(
|
|
187
|
-
makeCtx({
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
263
|
+
makeCtx({ mode: "tui", customResult: internalResult }) as never,
|
|
264
|
+
);
|
|
265
|
+
const resp = await handler({
|
|
266
|
+
channelPayload: { formQuestions: [{ type: "text", question: "Describe the issue" }] },
|
|
267
|
+
});
|
|
268
|
+
expect(resp).toEqual({
|
|
269
|
+
value: JSON.stringify({ "Describe the issue__other": "typed free text" }),
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("新 payload 逐项守卫:不合法项过滤,合法项照常服务", async () => {
|
|
274
|
+
const protoAnswers = { "Which DB?": "Postgres" };
|
|
275
|
+
const handler = createAskUserChannelHandler(
|
|
276
|
+
makeCtx({ mode: "rpc", selectResult: JSON.stringify(protoAnswers) }) as never,
|
|
191
277
|
);
|
|
192
|
-
const resp = await handler({
|
|
278
|
+
const resp = await handler({
|
|
279
|
+
channelPayload: {
|
|
280
|
+
formQuestions: [{ type: "choice", question: 123 }, formSingle, "garbage"],
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
expect(resp).toEqual({ value: JSON.stringify({ "Which DB?": "Postgres" }) });
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("新 payload formQuestions 空数组 → ?? 语义生效 → {cancelled: true}(不回落旧键)", async () => {
|
|
287
|
+
const handler = createAskUserChannelHandler(makeCtx({ mode: "rpc" }) as never);
|
|
288
|
+
const resp = await handler({
|
|
289
|
+
channelPayload: { formQuestions: [], questions: [legacySingle] },
|
|
290
|
+
});
|
|
193
291
|
expect(resp).toEqual({ cancelled: true });
|
|
194
292
|
});
|
|
293
|
+
});
|
|
195
294
|
|
|
196
|
-
|
|
295
|
+
describe("createAskUserChannelHandler — input 校验", () => {
|
|
296
|
+
it("channelPayload missing → {cancelled: true}", async () => {
|
|
197
297
|
const handler = createAskUserChannelHandler(makeCtx({ mode: "rpc" }) as never);
|
|
198
298
|
const resp = await handler({});
|
|
199
299
|
expect(resp).toEqual({ cancelled: true });
|
|
200
300
|
});
|
|
201
301
|
|
|
202
|
-
it("
|
|
302
|
+
it("questions empty array → {cancelled: true}", async () => {
|
|
203
303
|
const handler = createAskUserChannelHandler(makeCtx({ mode: "rpc" }) as never);
|
|
204
304
|
const resp = await handler({ channelPayload: { questions: [] } });
|
|
205
305
|
expect(resp).toEqual({ cancelled: true });
|
|
206
306
|
});
|
|
207
307
|
|
|
208
|
-
it("
|
|
308
|
+
it("questions not an array → {cancelled: true}", async () => {
|
|
209
309
|
const handler = createAskUserChannelHandler(makeCtx({ mode: "rpc" }) as never);
|
|
210
310
|
const resp = await handler({ channelPayload: { questions: "not-array" } });
|
|
211
311
|
expect(resp).toEqual({ cancelled: true });
|
|
212
312
|
});
|
|
213
313
|
|
|
214
|
-
it("
|
|
314
|
+
it("formQuestions 全不合法 → {cancelled: true}", async () => {
|
|
315
|
+
const handler = createAskUserChannelHandler(makeCtx({ mode: "rpc" }) as never);
|
|
316
|
+
const resp = await handler({ channelPayload: { formQuestions: ["nope", 42] } });
|
|
317
|
+
expect(resp).toEqual({ cancelled: true });
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("req is null/undefined → {cancelled: true}", async () => {
|
|
215
321
|
const handler = createAskUserChannelHandler(makeCtx({ mode: "rpc" }) as never);
|
|
216
322
|
expect(await handler(null)).toEqual({ cancelled: true });
|
|
217
323
|
expect(await handler(undefined)).toEqual({ cancelled: true });
|
|
218
324
|
});
|
|
219
325
|
|
|
220
|
-
it("RPC: allowCancel passed through to
|
|
326
|
+
it("RPC: allowCancel passed through to uiFormInteract (default true)", async () => {
|
|
221
327
|
// allowCancel 默认 true:验证不抛错(select mock 返回 undefined=取消)
|
|
222
328
|
const handler = createAskUserChannelHandler(
|
|
223
329
|
makeCtx({ mode: "rpc", selectResult: undefined }) as never,
|
|
224
330
|
);
|
|
225
|
-
const resp = await handler({ channelPayload: { questions: [
|
|
331
|
+
const resp = await handler({ channelPayload: { questions: [legacySingle] } });
|
|
226
332
|
expect(resp).toEqual({ cancelled: true });
|
|
227
333
|
});
|
|
228
334
|
});
|
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Tests registerAskUserChannelHandler:ask-user 侧的 globalThis Symbol 握手注册纯函数。
|
|
4
4
|
//
|
|
5
|
-
// 覆盖(PR #85 #M4 + #M5
|
|
6
|
-
// - 空 globalThis → 建 slot(仅 pending),handler
|
|
5
|
+
// 覆盖(PR #85 #M4 + #M5 + D9 双名注册):
|
|
6
|
+
// - 空 globalThis → 建 slot(仅 pending),handler 逐名入 pending;**slot.registry === undefined**
|
|
7
7
|
// (M4 核心断言:ask-user 永不创建 registry 实例)
|
|
8
8
|
// - slot 存在但 registry 未就绪 → push pending(registry 仍 undefined)
|
|
9
|
-
// - slot 存在且 registry 就绪 →
|
|
9
|
+
// - slot 存在且 registry 就绪 → 对每个 channel 名调 registry.register(name, handler),pending 不增长
|
|
10
10
|
// - 重复调用:registry 就绪时 register 多次(同名覆盖幂等);未就绪时 pending.length 增长
|
|
11
11
|
// - version !== 1 → warn + 重建为新 slot,旧 pending 丢弃
|
|
12
|
+
// - D9:同一 handler 注册两代 channel 名——"ask_user"(旧孙进程 marker 派生)+
|
|
13
|
+
// "ui_form"(新孙进程 UI_FORM_MARKER 派生),顺序 = ASK_USER_CHANNELS 声明序
|
|
12
14
|
//
|
|
13
15
|
// 隔离:每个用例前 Reflect.deleteProperty(globalThis, CHANNEL_HANDSHAKE_KEY)。
|
|
14
16
|
// Mock 共享 logger,让 logger.warn 可被 spy(源码已从 console.warn 改为 logger.warn)
|
|
@@ -73,8 +75,11 @@ describe("registerAskUserChannelHandler", () => {
|
|
|
73
75
|
expect(slot!.version).toBe(1);
|
|
74
76
|
// M4 核心断言:ask-user 永不创建 registry 实例
|
|
75
77
|
expect(slot!.registry).toBeUndefined();
|
|
76
|
-
|
|
77
|
-
expect(slot!.pending
|
|
78
|
+
// D9 双名:ask_user + ui_form 各一条 pending
|
|
79
|
+
expect(slot!.pending).toEqual([
|
|
80
|
+
{ channel: "ask_user", handler: noopHandler },
|
|
81
|
+
{ channel: "ui_form", handler: noopHandler },
|
|
82
|
+
]);
|
|
78
83
|
});
|
|
79
84
|
|
|
80
85
|
it("slot 存在但 registry 未就绪 → push pending(registry 仍 undefined)", () => {
|
|
@@ -87,11 +92,13 @@ describe("registerAskUserChannelHandler", () => {
|
|
|
87
92
|
const slot = readSlot();
|
|
88
93
|
expect(slot).toBe(preSlot); // 同一对象,未重建
|
|
89
94
|
expect(slot!.registry).toBeUndefined();
|
|
90
|
-
expect(slot!.pending).
|
|
91
|
-
|
|
95
|
+
expect(slot!.pending).toEqual([
|
|
96
|
+
{ channel: "ask_user", handler: noopHandler },
|
|
97
|
+
{ channel: "ui_form", handler: noopHandler },
|
|
98
|
+
]);
|
|
92
99
|
});
|
|
93
100
|
|
|
94
|
-
it("slot 存在且 registry 就绪 →
|
|
101
|
+
it("slot 存在且 registry 就绪 → 逐名调 registry.register,pending 不增长", () => {
|
|
95
102
|
const mockRegistry = makeMockRegistry();
|
|
96
103
|
const preSlot: ChannelRegistryHandshake = {
|
|
97
104
|
version: 1,
|
|
@@ -102,8 +109,9 @@ describe("registerAskUserChannelHandler", () => {
|
|
|
102
109
|
|
|
103
110
|
registerAskUserChannelHandler(noopHandler);
|
|
104
111
|
|
|
105
|
-
expect(mockRegistry.register).toHaveBeenCalledTimes(
|
|
106
|
-
expect(mockRegistry.register).
|
|
112
|
+
expect(mockRegistry.register).toHaveBeenCalledTimes(2);
|
|
113
|
+
expect(mockRegistry.register).toHaveBeenNthCalledWith(1, "ask_user", noopHandler);
|
|
114
|
+
expect(mockRegistry.register).toHaveBeenNthCalledWith(2, "ui_form", noopHandler);
|
|
107
115
|
// pending 不增长(直接 register,不进队列)
|
|
108
116
|
expect(preSlot.pending).toHaveLength(0);
|
|
109
117
|
});
|
|
@@ -122,25 +130,30 @@ describe("registerAskUserChannelHandler", () => {
|
|
|
122
130
|
registerAskUserChannelHandler(h1);
|
|
123
131
|
registerAskUserChannelHandler(h2);
|
|
124
132
|
|
|
125
|
-
expect(mockRegistry.register).toHaveBeenCalledTimes(
|
|
133
|
+
expect(mockRegistry.register).toHaveBeenCalledTimes(4);
|
|
126
134
|
expect(mockRegistry.register).toHaveBeenNthCalledWith(1, "ask_user", h1);
|
|
127
|
-
expect(mockRegistry.register).toHaveBeenNthCalledWith(2, "
|
|
135
|
+
expect(mockRegistry.register).toHaveBeenNthCalledWith(2, "ui_form", h1);
|
|
136
|
+
expect(mockRegistry.register).toHaveBeenNthCalledWith(3, "ask_user", h2);
|
|
137
|
+
expect(mockRegistry.register).toHaveBeenNthCalledWith(4, "ui_form", h2);
|
|
128
138
|
expect(preSlot.pending).toHaveLength(0); // 幂等:不进 pending
|
|
129
139
|
});
|
|
130
140
|
|
|
131
141
|
it("registry 未就绪时多次 register → pending.length 增长(顺序保留)", () => {
|
|
132
|
-
// 第 1 次:空 globalThis → 建 slot + pending
|
|
142
|
+
// 第 1 次:空 globalThis → 建 slot + 双名 pending
|
|
133
143
|
const h1: ChannelHandler = async () => "a";
|
|
134
144
|
registerAskUserChannelHandler(h1);
|
|
135
|
-
// 第 2 次:slot 已存在、registry 仍 undefined → pending
|
|
145
|
+
// 第 2 次:slot 已存在、registry 仍 undefined → 双名 pending 追加
|
|
136
146
|
const h2: ChannelHandler = async () => "b";
|
|
137
147
|
registerAskUserChannelHandler(h2);
|
|
138
148
|
|
|
139
149
|
const slot = readSlot();
|
|
140
150
|
expect(slot!.registry).toBeUndefined();
|
|
141
|
-
expect(slot!.pending).
|
|
142
|
-
|
|
143
|
-
|
|
151
|
+
expect(slot!.pending).toEqual([
|
|
152
|
+
{ channel: "ask_user", handler: h1 },
|
|
153
|
+
{ channel: "ui_form", handler: h1 },
|
|
154
|
+
{ channel: "ask_user", handler: h2 },
|
|
155
|
+
{ channel: "ui_form", handler: h2 },
|
|
156
|
+
]);
|
|
144
157
|
});
|
|
145
158
|
|
|
146
159
|
it("version !== 1 → warn + 重建为新 slot,旧 pending 丢弃", () => {
|
|
@@ -157,10 +170,13 @@ describe("registerAskUserChannelHandler", () => {
|
|
|
157
170
|
registerAskUserChannelHandler(noopHandler);
|
|
158
171
|
|
|
159
172
|
const slot = readSlot();
|
|
160
|
-
// 旧 slot 被替换(version 退回 1,pending
|
|
173
|
+
// 旧 slot 被替换(version 退回 1,pending 重置为仅含本次双名注册)
|
|
161
174
|
expect(slot).not.toBe(legacySlot);
|
|
162
175
|
expect(slot!.version).toBe(1);
|
|
163
|
-
expect(slot!.pending).toEqual([
|
|
176
|
+
expect(slot!.pending).toEqual([
|
|
177
|
+
{ channel: "ask_user", handler: noopHandler },
|
|
178
|
+
{ channel: "ui_form", handler: noopHandler },
|
|
179
|
+
]);
|
|
164
180
|
// warn 被调用(包含 version mismatch 提示)
|
|
165
181
|
expect(loggerMock.warn).toHaveBeenCalledTimes(1);
|
|
166
182
|
expect(loggerMock.warn.mock.calls[0]![0]).toContain("version mismatch");
|
|
@@ -180,7 +196,7 @@ describe("registerAskUserChannelHandler", () => {
|
|
|
180
196
|
|
|
181
197
|
registerAskUserChannelHandler(noopHandler);
|
|
182
198
|
|
|
183
|
-
expect(mockRegistry.register).toHaveBeenCalledTimes(
|
|
199
|
+
expect(mockRegistry.register).toHaveBeenCalledTimes(2);
|
|
184
200
|
// 遗留 pending 未被消费(长度不变)
|
|
185
201
|
expect(preSlot.pending).toHaveLength(1);
|
|
186
202
|
expect(preSlot.pending).toBe(preExistingPending);
|