@zhushanwen/pi-ask-user 7.2.3 → 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 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: 10 files in `src/`, ~2085 lines total.
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 │ ← Tool factory + execute (6-step flow) + renderCall/renderResult
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 an `"ask_user"` channel handler 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).
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 (`"ask_user"`). The permission footer-provider handshake above uses its own separate slot — same pattern, different slot, not a registrant here.
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/README.md CHANGED
@@ -57,7 +57,7 @@ If you recommend an option, prefix its label with `(Recommended)` and list it fi
57
57
  | `questions` | 1-4 entries | schema (`minItems`/`maxItems`) |
58
58
  | `options` | 2-4 entries | schema |
59
59
  | `question` | ≤1000 chars, no control chars (incl. `\n`), unique within the call | schema description + `validate.ts` |
60
- | `header` | ≤12 chars; required when `questions.length > 1` | `validate.ts` (length + non-empty) |
60
+ | `header` | ≤12 chars (always); non-empty and unique when `questions.length > 1` | `validate.ts` (length; non-empty + uniqueness in multi-question mode) |
61
61
  | `options[].label` | non-empty, unique within the question | `validate.ts` |
62
62
 
63
63
  Validation failures make the tool `throw` with a message that names the violation and tells you how to fix it — correct the parameters and retry. (Pi converts a thrown error into an `isError: true` tool result; business outcomes like answers and cancellation are returned normally.)
@@ -80,11 +80,12 @@ A question with no answer reports as `(no answer)`.
80
80
  | No interactive UI (headless) | `throw`, tool **disabled for the session** | Proceed with a defensible decision stated in text, or wait for the user — **do not retry** |
81
81
  | Agent aborted (goal cancelled / context compacted) | `cancelled: true` | The text identifies it as an agent abort, not a user cancel. Do not assume an answer; do not retry ask_user — propagate the abort, or wait for new instructions if the decision is still required. |
82
82
  | User cancels (Esc → confirm, or Cancel button) | `cancelled: true` | Wait for new instructions, or re-ask with refined options if the decision is still required |
83
- | Unexpected error during interaction | `throw` (Pi shows it as `isError: true`) | Retry once with corrected parameters, or proceed with a defensible decision |
83
+ | Unexpected error during interaction (TUI) | `throw` (Pi shows it as `isError: true`) | Tool stays enabled — retry with corrected parameters, or proceed with a defensible decision |
84
+ | Unexpected error during interaction (RPC/GUI) | `throw` + tool **disabled for the session** | Same as the headless row — do not retry |
84
85
 
85
86
  Business outcomes (answers / cancellation) are returned as normal results; only validation failures and unexpected exceptions `throw` — Pi marks a thrown error `isError: true` with empty `details`.
86
87
 
87
- The headless branch physically removes the tool from the session (`setActiveTools`) — this is deliberate, so a function-calling loop cannot keep retrying `ask_user` in a non-interactive context.
88
+ The headless and RPC-failure branches physically remove the tool from the session (`setActiveTools`) — this is deliberate, so a function-calling loop cannot keep retrying `ask_user` in a non-interactive context. The TUI branch does not disable: an interaction failure there is usually transient, so the tool stays enabled for a retry.
88
89
 
89
90
  ## Features
90
91
 
@@ -92,7 +93,7 @@ The headless branch physically removes the tool from the session (`setActiveTool
92
93
  - **Split-pane preview** (≥84 cols): option list left, selected option detail right. The right pane is **plain-text** option detail (label + description), not a Markdown renderer.
93
94
  - **Inline free-text editor**: select "Other" → Enter → type a custom answer. Multi-line aware, soft-wrapped. No comment mode — this editor is the only free-form input.
94
95
  - **Multi-select**: `multiSelect: true` → toggle checkboxes with Space, Enter to confirm.
95
- - **Esc confirm-to-cancel**: Esc on the first question opens a confirm overlay (a second Esc cancels; any other key stays).
96
+ - **Esc confirm-to-cancel**: in options mode, Esc on the first question opens a confirm overlay (a second Esc cancels; any other key stays). Elsewhere Esc means back, not cancel: on later question tabs it steps back one tab, on the Submit tab it returns to the last question, and in the Other editor it saves the draft and returns to the option list.
96
97
  - **Headless-safe**: disables the tool and throws when no UI is available.
97
98
 
98
99
  ## File structure
@@ -104,6 +105,7 @@ extensions/universal/ask-user/
104
105
  ├── README.md # this file — usage contract for LLM callers + overview
105
106
  ├── ARCHITECTURE.md # internals: dependency graph, state machine, defensive flow
106
107
  ├── vitest.config.ts
108
+ ├── tsconfig.json # typecheck config (not in the npm files allowlist)
107
109
  └── src/
108
110
  ├── index.ts # Tool factory: registerTool + execute (6-step defensive flow) + renderCall/renderResult
109
111
  ├── types.ts # Input schema, Result schema, shared state types (QuestionState/ThemeLike) — dependency leaf
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-ask-user",
3
- "version": "7.2.3",
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.12.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 内部调 askUserInteract(select 通道),
7
- // 把 proto answers JSON.stringify 成 {value} 返回,子进程 JSON.parse(value) 正确 decode。
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 AskUserAnswers 重新编码
10
- // (single/multi/Other 三种答案形态,经 encodeAnswer 序列化)。
11
- // - 取消(askUserInteract/custom 返回 null 或 cancelled)→ {cancelled: true}
12
- // - 输入校验(channelPayload 缺失/无 questions)→ {cancelled: true}
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(AskUserAnswers))。
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> => selectResult,
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
- // ── 样例 proto questions(handler 收到的格式) ──────────
63
- const singleProto: AskUserQuestion = {
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 multiProto: AskUserQuestion = {
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: single-select proto answers → {value: JSON.stringify(answers)}", async () => {
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({ channelPayload: { questions: [singleProto] } });
127
+ const resp = await handler(req);
85
128
  expect(resp).toEqual({ value: JSON.stringify({ "Which DB?": "Postgres" }) });
86
129
  });
87
130
 
88
- it("RPC: multi-select proto answers (JSON array value) → 透传", async () => {
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({ channelPayload: { questions: [multiProto] } });
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: Other proto answers → 透传", async () => {
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({ channelPayload: { questions: [singleProto] } });
148
+ const resp = await handler(req);
106
149
  expect(resp).toEqual({ value: JSON.stringify(protoAnswers) });
107
150
  });
108
151
 
109
- it("RPC: user cancel (select undefined) → {cancelled: true}", async () => {
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({ channelPayload: { questions: [singleProto] } });
156
+ const resp = await handler(req);
114
157
  expect(resp).toEqual({ cancelled: true });
115
158
  });
116
159
 
117
- it("TUI: internal Result single-select AnswerValue → 重新编码为 proto answers", async () => {
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({ channelPayload: { questions: [singleProto] } });
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: multi-select internal Result → proto JSON array value", async () => {
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({ channelPayload: { questions: [multiProto] } });
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: Other free text → ${key}__other", async () => {
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({ channelPayload: { questions: [singleProto] } });
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: Other-only answer(selected 空)→ 只写 ${key}__other 不写主 key", async () => {
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: [singleProto] } });
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: user cancel (custom returns null) → {cancelled: true}", async () => {
243
+ it("TUI custom returns cancelled Result → {cancelled: true}", async () => {
178
244
  const handler = createAskUserChannelHandler(
179
- makeCtx({ mode: "tui", customResult: null }) as never,
245
+ makeCtx({
246
+ mode: "tui",
247
+ customResult: { questions: [], answers: {}, cancelled: true },
248
+ }) as never,
180
249
  );
181
- const resp = await handler({ channelPayload: { questions: [singleProto] } });
250
+ const resp = await handler({ channelPayload: { questions: [legacySingle] } });
182
251
  expect(resp).toEqual({ cancelled: true });
183
252
  });
184
253
 
185
- it("TUI: custom returns cancelled Result → {cancelled: true}", async () => {
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
- mode: "tui",
189
- customResult: { questions: [], answers: {}, cancelled: true },
190
- }) as never,
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({ channelPayload: { questions: [singleProto] } });
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
- it("input: channelPayload missing → {cancelled: true}", async () => {
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("input: questions empty array → {cancelled: true}", async () => {
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("input: questions not an array → {cancelled: true}", async () => {
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("input: req is null/undefined → {cancelled: true}", async () => {
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 askUserInteract (default true)", async () => {
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: [singleProto] } });
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 入 pending;**slot.registry === undefined**
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 就绪 → 直接调 registry.register("ask_user", handler),pending 不增长
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
- expect(slot!.pending).toHaveLength(1);
77
- expect(slot!.pending[0]).toEqual({ channel: "ask_user", handler: noopHandler });
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).toHaveLength(1);
91
- expect(slot!.pending[0]).toEqual({ channel: "ask_user", handler: noopHandler });
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 就绪 → 调 registry.register,pending 不增长", () => {
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(1);
106
- expect(mockRegistry.register).toHaveBeenCalledWith("ask_user", noopHandler);
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(2);
133
+ expect(mockRegistry.register).toHaveBeenCalledTimes(4);
126
134
  expect(mockRegistry.register).toHaveBeenNthCalledWith(1, "ask_user", h1);
127
- expect(mockRegistry.register).toHaveBeenNthCalledWith(2, "ask_user", h2);
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[0]
142
+ // 第 1 次:空 globalThis → 建 slot + 双名 pending
133
143
  const h1: ChannelHandler = async () => "a";
134
144
  registerAskUserChannelHandler(h1);
135
- // 第 2 次:slot 已存在、registry 仍 undefined → pending[1]
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).toHaveLength(2);
142
- expect(slot!.pending[0]).toEqual({ channel: "ask_user", handler: h1 });
143
- expect(slot!.pending[1]).toEqual({ channel: "ask_user", handler: h2 });
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([{ channel: "ask_user", handler: noopHandler }]);
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(1);
199
+ expect(mockRegistry.register).toHaveBeenCalledTimes(2);
184
200
  // 遗留 pending 未被消费(长度不变)
185
201
  expect(preSlot.pending).toHaveLength(1);
186
202
  expect(preSlot.pending).toBe(preExistingPending);