@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.
@@ -0,0 +1,156 @@
1
+ // src/__tests__/form-adapter.test.ts
2
+ //
3
+ // 包内归一 adapter(form-adapter.ts)的映射规则单测——设计 ui-presentation-protocol
4
+ // D2/D3:askUserInteract 退役后 AskUserQuestion↔FormQuestion 归一职责下沉本包。
5
+ // 键位规则对拍基准 = e2e/ask-user-real.spec.ts A1 协议面:无 allowComment、Other 保留
6
+ // (__other 键)、多选 JSON 序列化。
7
+ import { describe, expect, it } from "vitest";
8
+
9
+ import {
10
+ askUserToFormQuestions,
11
+ formToAskUserQuestions,
12
+ formToInternalQuestions,
13
+ internalToFormQuestions,
14
+ } from "../form-adapter";
15
+ import type { Question } from "../types";
16
+
17
+ // ── 样例 internal Question(tool 路径的源形态)──────────────
18
+ const internalSingle: Question = {
19
+ question: "Which DB?",
20
+ options: [
21
+ { label: "Postgres", description: "Mature" },
22
+ { label: "SQLite" },
23
+ ],
24
+ };
25
+ const internalMulti: Question = {
26
+ question: "Which tools?",
27
+ header: "Tools",
28
+ context: "pick many",
29
+ options: [{ label: "A" }, { label: "B" }],
30
+ multiSelect: true,
31
+ };
32
+
33
+ describe("internalToFormQuestions(wire 形态构造)", () => {
34
+ it("options 必填 → 恒 choice;multiSelect → multi 重命名;allowOther 固定 true", () => {
35
+ const [q] = internalToFormQuestions([internalSingle]);
36
+ expect(q).toEqual({
37
+ type: "choice",
38
+ question: "Which DB?",
39
+ options: [
40
+ { label: "Postgres", description: "Mature" },
41
+ { label: "SQLite" },
42
+ ],
43
+ allowOther: true,
44
+ });
45
+ const [m] = internalToFormQuestions([internalMulti]);
46
+ expect(m.type).toBe("choice");
47
+ expect(m.multi).toBe(true);
48
+ expect(m.multiSelect).toBeUndefined();
49
+ expect(m.header).toBe("Tools");
50
+ expect(m.context).toBe("pick many");
51
+ });
52
+
53
+ it("wire 形态无 allowComment / 无 comment 类字段(A1 对拍基准)", () => {
54
+ for (const q of internalToFormQuestions([internalSingle, internalMulti])) {
55
+ for (const key of Object.keys(q)) {
56
+ expect(key.includes("comment"), `字段 ${key} 不应含 comment`).toBe(false);
57
+ }
58
+ }
59
+ });
60
+ });
61
+
62
+ describe("askUserToFormQuestions(D9 旧 payload 归一)", () => {
63
+ it("有 options → choice(multiSelect → multi)", () => {
64
+ const [q] = askUserToFormQuestions([
65
+ { question: "q", header: "h", multiSelect: true, options: [{ label: "A" }] },
66
+ ]);
67
+ expect(q).toEqual({
68
+ type: "choice",
69
+ header: "h",
70
+ question: "q",
71
+ options: [{ label: "A" }],
72
+ multi: true,
73
+ });
74
+ });
75
+
76
+ it("无 options → text(协议值域完备保留)", () => {
77
+ const [q] = askUserToFormQuestions([{ question: "free text q", header: "h" }]);
78
+ expect(q).toEqual({ type: "text", header: "h", question: "free text q" });
79
+ });
80
+ });
81
+
82
+ describe("formToAskUserQuestions(解码视图)", () => {
83
+ it("choice:multi 还原为 multiSelect,保持解码 helper 契约", () => {
84
+ const [q] = formToAskUserQuestions([
85
+ { type: "choice", question: "q", options: [{ label: "A" }], multi: true },
86
+ ]);
87
+ expect(q).toEqual({ question: "q", options: [{ label: "A" }], multiSelect: true });
88
+ });
89
+
90
+ it("text:无 options 字段(答案经 __other 键解码)", () => {
91
+ const [q] = formToAskUserQuestions([{ type: "text", question: "q" }]);
92
+ expect(q).toEqual({ question: "q" });
93
+ });
94
+
95
+ it("schedule:跳过(ask-user 链路不产生)", () => {
96
+ const out = formToAskUserQuestions([
97
+ { type: "schedule", question: "when?" },
98
+ { type: "text", question: "q" },
99
+ ]);
100
+ expect(out).toEqual([{ question: "q" }]);
101
+ });
102
+
103
+ it("round trip:internal → Form → AskUser 与旧 toProtoQuestions 语义等价", () => {
104
+ const form = internalToFormQuestions([internalSingle, internalMulti]);
105
+ const decodeView = formToAskUserQuestions(form);
106
+ expect(decodeView[0]).toEqual({
107
+ question: "Which DB?",
108
+ options: [
109
+ { label: "Postgres", description: "Mature" },
110
+ { label: "SQLite" },
111
+ ],
112
+ allowOther: true,
113
+ });
114
+ expect(decodeView[1]).toEqual({
115
+ question: "Which tools?",
116
+ header: "Tools",
117
+ context: "pick many",
118
+ options: [{ label: "A" }, { label: "B" }],
119
+ multiSelect: true,
120
+ allowOther: true,
121
+ });
122
+ });
123
+ });
124
+
125
+ describe("formToInternalQuestions(TUI 组件形态)", () => {
126
+ it("choice → options 原样 + multiSelect 还原", () => {
127
+ const [q] = formToInternalQuestions([
128
+ {
129
+ type: "choice",
130
+ question: "q",
131
+ header: "h",
132
+ options: [{ label: "A", description: "d" }],
133
+ multi: true,
134
+ },
135
+ ]);
136
+ expect(q).toEqual({
137
+ question: "q",
138
+ header: "h",
139
+ options: [{ label: "A", description: "d" }],
140
+ multiSelect: true,
141
+ });
142
+ });
143
+
144
+ it("text → 空 options(组件渲染为自由输入,与旧无 options payload 现状一致)", () => {
145
+ const [q] = formToInternalQuestions([{ type: "text", question: "q" }]);
146
+ expect(q).toEqual({ question: "q", options: [] });
147
+ });
148
+
149
+ it("schedule → 跳过", () => {
150
+ const out = formToInternalQuestions([
151
+ { type: "schedule", question: "when?" },
152
+ { type: "text", question: "q" },
153
+ ]);
154
+ expect(out).toEqual([{ question: "q", options: [] }]);
155
+ });
156
+ });
@@ -523,9 +523,11 @@ describe("execute — inline render (FR-3)", () => {
523
523
  });
524
524
 
525
525
  // ── RPC 模式(taiji GUI 富交互协议)──────────────────
526
- // hasUI=false + ui.select 存在 → 走 askUserInteract(select 通道 + ASK_USER_MARKER)。
527
- // select 的返回值是前端 JSON.stringify 的 AskUserAnswers,index.ts 做格式转换。
528
- describe("execute — RPC mode (askUserInteract via select channel)", () => {
526
+ // mode='rpc' + ui.select 存在 → 走 uiFormInteract(统一表单协议:select 通道 +
527
+ // UI_FORM_MARKER,问题经 form-adapter 转 FormQuestion choice 形态)。
528
+ // select 的返回值是前端 FormOverlay JSON.stringify 的 FormAnswers(choice 部分与旧
529
+ // AskUserAnswers 逐字兼容),index.ts 做格式转换。
530
+ describe("execute — RPC mode (uiFormInteract via select channel)", () => {
529
531
  it("R-1: single-select answer → converted to Result.answers (key=question)", async () => {
530
532
  const tool = getTool();
531
533
  // 协议 answers:key=header(单问题无 header → question 全文),value=选中 label
@@ -725,4 +727,125 @@ describe("execute — RPC mode (askUserInteract via select channel)", () => {
725
727
  expect(result.details.answers["Which region?"]).toBeUndefined();
726
728
  });
727
729
 
730
+ it("R-9: select 收到 title=UI_FORM_MARKER + options[0]={formQuestions, allowCancel}(D2 adapter 形态)", async () => {
731
+ const tool = getTool();
732
+ let capturedTitle = "";
733
+ let capturedOptions: string[] = [];
734
+ const ctx = {
735
+ mode: "rpc" as const,
736
+ hasUI: true,
737
+ signal: undefined as AbortSignal | undefined,
738
+ ui: {
739
+ select: async (
740
+ title: string,
741
+ options: string[],
742
+ ): Promise<string> => {
743
+ capturedTitle = title;
744
+ capturedOptions = options;
745
+ return JSON.stringify({ "Which DB?": "Postgres" });
746
+ },
747
+ custom: async <T = void>(): Promise<T> => undefined as T,
748
+ },
749
+ };
750
+ await tool.execute("id", validSingle, undefined, undefined, ctx);
751
+
752
+ // wire 形态:统一表单协议 marker + 单元素 options
753
+ expect(capturedTitle).toBe("\x00TAIJI_UI_FORM");
754
+ expect(capturedOptions).toHaveLength(1);
755
+ const payload = JSON.parse(capturedOptions[0]!) as {
756
+ formQuestions?: Array<Record<string, unknown>>;
757
+ questions?: unknown;
758
+ allowCancel?: boolean;
759
+ };
760
+ expect(payload.questions).toBeUndefined();
761
+ expect(payload.allowCancel).toBe(true);
762
+ expect(payload.formQuestions).toEqual([
763
+ {
764
+ type: "choice",
765
+ question: "Which DB?",
766
+ options: [
767
+ { label: "Postgres", description: undefined },
768
+ { label: "SQLite", description: undefined },
769
+ ],
770
+ allowOther: true,
771
+ },
772
+ ]);
773
+ // A1 对拍基准:问题对象无 allowComment / comment 类字段
774
+ for (const key of Object.keys(payload.formQuestions![0]!)) {
775
+ expect(key.includes("comment"), `字段 ${key} 不应含 comment`).toBe(false);
776
+ }
777
+ });
778
+
779
+ it("R-9b: multiSelect → multi 重命名进 wire 形态", async () => {
780
+ const tool = getTool();
781
+ let capturedOptions: string[] = [];
782
+ const ctx = {
783
+ mode: "rpc" as const,
784
+ hasUI: true,
785
+ signal: undefined as AbortSignal | undefined,
786
+ ui: {
787
+ select: async (_title: string, options: string[]): Promise<string> => {
788
+ capturedOptions = options;
789
+ return JSON.stringify({ Tools: JSON.stringify(["A"]) });
790
+ },
791
+ custom: async <T = void>(): Promise<T> => undefined as T,
792
+ },
793
+ };
794
+ const multi = {
795
+ questions: [
796
+ {
797
+ question: "Which tools?",
798
+ header: "Tools",
799
+ options: [{ label: "A" }, { label: "B" }],
800
+ multiSelect: true,
801
+ },
802
+ ],
803
+ };
804
+ await tool.execute("id", multi, undefined, undefined, ctx);
805
+ const payload = JSON.parse(capturedOptions[0]!) as {
806
+ formQuestions?: Array<Record<string, unknown>>;
807
+ };
808
+ expect(payload.formQuestions![0]!.multi).toBe(true);
809
+ expect(payload.formQuestions![0]!.multiSelect).toBeUndefined();
810
+ });
811
+
812
+ it("R-10: select 回显 payload(echo,旧 taiji 宿主)→ channel-error throw 带升级指引 + 工具禁用", async () => {
813
+ let capturedActive: string[] | null = null;
814
+ const tool = getTool({
815
+ getAllTools: () => [{ name: "ask_user" }, { name: "other" }],
816
+ setActiveTools: (names: string[]) => {
817
+ capturedActive = names;
818
+ },
819
+ });
820
+ // 旧宿主不识别 UI_FORM_MARKER → band 单选项 = payload 自身,点选回显
821
+ const ctx = {
822
+ mode: "rpc" as const,
823
+ hasUI: true,
824
+ signal: undefined as AbortSignal | undefined,
825
+ ui: {
826
+ select: async (_title: string, options: string[]): Promise<string> => options[0]!,
827
+ custom: async <T = void>(): Promise<T> => undefined as T,
828
+ },
829
+ };
830
+ await expect(
831
+ tool.execute("id", validSingle, undefined, undefined, ctx),
832
+ ).rejects.toThrow(/ask_user failed[\s\S]*upgrade taiji/);
833
+ // channel-error 折叠 = 禁用工具(防 LLM 反复重试),与 R-6 一致
834
+ expect(capturedActive).not.toContain("ask_user");
835
+ expect(capturedActive).toContain("other");
836
+ });
837
+
838
+ it("R-11: select 返回合法 JSON 但非 FormAnswers 形状(JSON 数组)→ 折叠 cancelled(非 throw)", async () => {
839
+ const tool = getTool();
840
+ const result = await tool.execute(
841
+ "id",
842
+ validSingle,
843
+ undefined,
844
+ undefined,
845
+ makeCtx({ mode: "rpc", selectResult: JSON.stringify(["A", "B"]) }),
846
+ );
847
+ expect(result.content[0].text).toContain("User cancelled");
848
+ expect(result.details.cancelled).toBe(true);
849
+ });
850
+
728
851
  });
@@ -2,30 +2,47 @@
2
2
  //
3
3
  // ask_user channel handler:把 subagent 子进程的 ask_user 请求透传到主进程 UI 渲染。
4
4
  //
5
- // 设计(关键决策):askUserInteract(@zhushanwen/extension-protocol)只在 RPC 模式可用
5
+ // 设计(关键决策):uiFormInteract(@zhushanwen/extension-protocol)只在 RPC 模式可用
6
6
  // (内部 isGuiCapable 检查 mode==='rpc',TUI 下抛错)。所以 handler 按 ctx.mode 分流:
7
- // - RPC:转发器——调 askUserInteract(guiCtx, protoQuestions),复用 select 通道 +
8
- // ASK_USER_MARKER 契约,主进程 ctx.ui.select 经 GUI sidecar 渲染(不进 parseSpawnLine,
7
+ // - RPC:转发器——调 uiFormInteract(guiCtx, formQuestions),复用 select 通道 +
8
+ // UI_FORM_MARKER 契约,主进程 ctx.ui.select 经 GUI sidecar 渲染(不进 parseSpawnLine,
9
9
  // 不循环)。返回 {value: JSON.stringify(answers)} 让子进程 JSON.parse(value) decode。
10
- // - TUI:走 ctx.ui.custom + AskUserComponent。三步:(1) protoQuestions → 内部 Question[],
10
+ // - TUI:走 ctx.ui.custom + AskUserComponent。三步:(1) formQuestions → 内部 Question[],
11
11
  // (2) ctx.ui.custom 渲染拿内部 Result,(3) 内部 Result.answers(key=question 全文,
12
- // value=结构化 AnswerValue)→ 用 encodeAnswer 重新编码为 proto AskUserAnswers
12
+ // value=结构化 AnswerValue)→ 用 encodeAnswer 重新编码为 proto FormAnswers
13
13
  // (key=header/question,单选=string,多选=JSON 数组,Other→__other),让子进程 decode 一致。
14
14
  //
15
- // handler 收到的 req.channelPayload = {questions: AskUserQuestion[], allowCancel}(proto 格式,
16
- // 由子进程 askUserInteract 编码、packages/subagent-core 的 parseChannel(execution/ui/ui-channels.ts)
17
- // 解析 options[0] JSON 得到)。
15
+ // D9 双名双读(subagent 间接链双维兼容):subagent 孙进程跑 npm 版 ask-user,版本不可控——
16
+ // - channel 名由 marker 派生(subagent-engine-sdk ui-channels.ts normalizeChannelName:
17
+ // 剥 \x00TAIJI_ 前缀 + 小写化)——新孙进程发 ui_form、旧孙进程发 ask_user,
18
+ // channel-registry-register 双名注册两代通道,本 handler 同一实现服务两名;
19
+ // - 入口 payload 双读 `formQuestions ?? questions`:新 payload {formQuestions: FormQuestion[]}
20
+ // (逐项 isFormQuestion 守卫过滤),旧 payload {questions: AskUserQuestion[]} 经包内
21
+ // 归一 adapter 转 FormQuestion。旧读与旧名随 D7 窗口退役。
22
+ //
23
+ // handler 收到的 req.channelPayload 由 packages/subagent-core 的 parseChannel
24
+ // (execution/ui/ui-channels.ts)解析 select options[0] JSON 得到。
18
25
 
19
26
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
20
27
  import {
21
- type AskUserAnswers,
22
- askUserInteract,
23
28
  type AskUserQuestion,
29
+ type FormAnswers,
30
+ type FormQuestion,
31
+ isFormQuestion,
32
+ uiFormInteract,
24
33
  } from "@zhushanwen/extension-protocol";
34
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
25
35
 
26
36
  import { AskUserComponent } from "./component";
27
37
  import { encodeAnswer } from "./answer-codec";
28
- import { type AnswerValue, type Option, type Question, type Result, type ThemeLike } from "./types";
38
+ import {
39
+ askUserToFormQuestions,
40
+ formToAskUserQuestions,
41
+ formToInternalQuestions,
42
+ } from "./form-adapter";
43
+ import { type AnswerValue, type Result, type ThemeLike } from "./types";
44
+
45
+ const logger = getLogger("ask-user");
29
46
 
30
47
  /**
31
48
  * channel handler 签名——与 packages/subagent-core 的 UiChannelRegistry.ChannelHandler 一致
@@ -41,37 +58,44 @@ export type ChannelHandler = (req: unknown) => Promise<unknown>;
41
58
  type ChannelResponse = { value: string } | { cancelled: true };
42
59
 
43
60
  /** handler 收到的 req 形状收窄(ChannelHandler 签名是 unknown,按形状 as 收窄)。
44
- * channelPayload 由 packages/subagent-core 的 parseChannel 填充。 */
61
+ * channelPayload 由 packages/subagent-core 的 parseChannel 填充(D9 双 payload):
62
+ * 新形态 {formQuestions, allowCancel} / 旧形态 {questions, allowCancel}。 */
45
63
  interface ChannelRequest {
46
- channelPayload?: { questions?: AskUserQuestion[]; allowCancel?: boolean };
64
+ channelPayload?: {
65
+ formQuestions?: FormQuestion[];
66
+ questions?: AskUserQuestion[];
67
+ allowCancel?: boolean;
68
+ };
47
69
  }
48
70
 
49
- /** proto AskUserQuestion → 内部 Question(AskUserComponent 接受内部格式)。
50
- * proto options 可选(无 options=纯自由文本),内部 options 必填——无 options 的 protoQuestion
51
- * 这里仍映射出 options(从子进程 ask-user 调用方保证 protoQuestions 总带 options;若缺则返 [] 由调用方判)。 */
52
- function protoToInternalQuestions(protoQuestions: AskUserQuestion[]): Question[] {
53
- return protoQuestions.map((pq: AskUserQuestion): Question => {
54
- const opts: Option[] = (pq.options ?? []).map((o: { label: string; description?: string }): Option => ({
55
- label: o.label,
56
- ...(o.description !== undefined ? { description: o.description } : {}),
57
- }));
58
- return {
59
- question: pq.question,
60
- ...(pq.header !== undefined ? { header: pq.header } : {}),
61
- ...(pq.context !== undefined ? { context: pq.context } : {}),
62
- options: opts,
63
- ...(pq.multiSelect !== undefined ? { multiSelect: pq.multiSelect } : {}),
64
- };
65
- });
71
+ /**
72
+ * D9 双读:channelPayload 归一为 FormQuestion[]。
73
+ * - 新 payload formQuestions 逐项 isFormQuestion 守卫过滤(不合法项跳过——与
74
+ * event-adapter / renderer 的逐项过滤失败策略一致,D2)
75
+ * - 旧 payload questions 经包内归一 adapter 转 FormQuestion(legacy 子进程产出,
76
+ * 其自身 schema 已保证形状,不做二次守卫——与迁移前行为一致)
77
+ * 返回 undefined = 无可用问题(payload 缺失/空/全不合法),调用方折 {cancelled: true}。
78
+ */
79
+ function payloadToFormQuestions(payload: ChannelRequest["channelPayload"]): FormQuestion[] | undefined {
80
+ if (payload === undefined) return undefined;
81
+ if (Array.isArray(payload.formQuestions)) {
82
+ const filtered = payload.formQuestions.filter((q: unknown) => isFormQuestion(q));
83
+ return filtered.length > 0 ? filtered : undefined;
84
+ }
85
+ if (Array.isArray(payload.questions) && payload.questions.length > 0) {
86
+ return askUserToFormQuestions(payload.questions);
87
+ }
88
+ return undefined;
66
89
  }
67
90
 
68
91
  /**
69
- * 把 TUI 路径产出的内部 Result.answers 重新编码为 proto AskUserAnswers。
92
+ * 把 TUI 路径产出的内部 Result.answers 重新编码为 proto FormAnswers。
70
93
  *
71
94
  * 内部 Result.answers:key = question 全文,value = 结构化 AnswerValue
72
95
  * (selected = option label 数组,other = Other 自由文本)。
73
96
  *
74
- * proto AskUserAnswers 契约(@zhushanwen/extension-protocol):
97
+ * proto FormAnswers 契约(@zhushanwen/extension-protocol,choice/text 部分与旧
98
+ * AskUserAnswers 逐字兼容):
75
99
  * - key = question.header ?? question 全文
76
100
  * - 单选:value = 选中项 label string
77
101
  * - 多选:value = JSON.stringify(选中项 label 数组)
@@ -83,8 +107,8 @@ function protoToInternalQuestions(protoQuestions: AskUserQuestion[]): Question[]
83
107
  function encodeTuiResultToProto(
84
108
  protoQuestions: AskUserQuestion[],
85
109
  result: Result,
86
- ): AskUserAnswers {
87
- const answers: AskUserAnswers = {};
110
+ ): FormAnswers {
111
+ const answers: FormAnswers = {};
88
112
  for (const pq of protoQuestions) {
89
113
  const av: AnswerValue | undefined = result.answers[pq.question];
90
114
  if (av === undefined) continue; // 该问题未答(buildResult 跳过未答)
@@ -106,11 +130,11 @@ function encodeTuiResultToProto(
106
130
  * 支持禁用 Esc 后,应把 allowCancel 下传给 AskUserComponent 构造函数。当前 allowCancel=false
107
131
  * 时 TUI 与 RPC 路径仍有分裂,但 handler 层已不再吞掉 allowCancel(修复分裂的第一步)。 */
108
132
  async function runTuiProtoInteraction(
109
- protoQuestions: AskUserQuestion[],
133
+ formQuestions: FormQuestion[],
110
134
  ctx: ExtensionContext,
111
135
  allowCancel: boolean,
112
- ): Promise<AskUserAnswers | null> {
113
- const questions = protoToInternalQuestions(protoQuestions);
136
+ ): Promise<FormAnswers | null> {
137
+ const questions = formToInternalQuestions(formQuestions);
114
138
  // 预留:组件升级后此处改为 new AskUserComponent(questions, tui, theme, done, allowCancel)
115
139
  void allowCancel;
116
140
  const result = await ctx.ui.custom<Result | null>(
@@ -128,14 +152,14 @@ async function runTuiProtoInteraction(
128
152
  // 显式 undefined 守卫:裸 result.cancelled 对 undefined 会抛 TypeError(W4 修复前
129
153
  // 靠 dialog-queue 兜底为 {cancelled:true},现源头短路,语义等价且不再依赖兜底)。
130
154
  if (result === null || result === undefined || result.cancelled) return null;
131
- return encodeTuiResultToProto(protoQuestions, result);
155
+ return encodeTuiResultToProto(formToAskUserQuestions(formQuestions), result);
132
156
  }
133
157
 
134
158
  /**
135
159
  * 创建 ask_user channel handler。
136
160
  *
137
161
  * @param ctx 主进程 ExtensionContext(session_start 时注入)
138
- * @returns ChannelHandler——req.channelPayload = {questions, allowCancel}(proto 格式),
162
+ * @returns ChannelHandler——req.channelPayload = {formQuestions} 或 {questions}(D9 双读),
139
163
  * 返回 {value: JSON.stringify(answers)} 或 {cancelled: true}
140
164
  */
141
165
  export function createAskUserChannelHandler(ctx: ExtensionContext): ChannelHandler {
@@ -146,20 +170,20 @@ export function createAskUserChannelHandler(ctx: ExtensionContext): ChannelHandl
146
170
  return { cancelled: true } satisfies ChannelResponse;
147
171
  }
148
172
  const r = req as ChannelRequest;
149
- const payload = r.channelPayload;
150
- if (!payload || !Array.isArray(payload.questions) || payload.questions.length === 0) {
173
+ const formQuestions = payloadToFormQuestions(r.channelPayload);
174
+ if (formQuestions === undefined) {
151
175
  return { cancelled: true } satisfies ChannelResponse;
152
176
  }
153
- const { questions, allowCancel } = payload;
177
+ const allowCancel = r.channelPayload?.allowCancel ?? true;
154
178
 
155
- // 按 ctx.mode 分流(PR #85 #13 / #M6):rpc 走 askUserInteract(select 通道+sidecar),
179
+ // 按 ctx.mode 分流(PR #85 #13 / #M6):rpc 走 uiFormInteract(select 通道+sidecar),
156
180
  // 其余(tui/json/print/undefined)走 ctx.ui.custom+AskUserComponent。
157
181
  // 用 ctx.mode === "rpc" 二值判定(与 index.ts execute 的 useRpc 判定一致);
158
182
  // 三值分类不需要——handler 只关心「rpc 转发」vs「TUI 内部渲染」两条路径。
159
183
  const answers =
160
184
  ctx.mode === "rpc"
161
- ? await runRpcForward(questions, ctx, allowCancel ?? true)
162
- : await runTuiProtoInteraction(questions, ctx, allowCancel ?? true);
185
+ ? await runRpcForward(formQuestions, ctx, allowCancel)
186
+ : await runTuiProtoInteraction(formQuestions, ctx, allowCancel);
163
187
 
164
188
  if (answers === null) return { cancelled: true } satisfies ChannelResponse;
165
189
  return { value: JSON.stringify(answers) } satisfies ChannelResponse;
@@ -167,16 +191,22 @@ export function createAskUserChannelHandler(ctx: ExtensionContext): ChannelHandl
167
191
  }
168
192
 
169
193
  /** RPC 转发器:主进程 ctx.ui.select 经 GUI sidecar 渲染(不进 parseSpawnLine,不循环)。
170
- * 完整复用 askUserInteract 的 encode/decode 契约。 */
194
+ * 完整复用 uiFormInteract 的 encode/decode 契约(FormAnswers ≡ 旧 AskUserAnswers)。
195
+ * 间接链的通道失败(含 echo 命中的旧宿主组合)折叠 cancelled + 留痕——回包形状只有
196
+ * {value}|{cancelled} 两态,throw 会被 dialog-queue 兜底成同一结果,日志是唯一留痕面。 */
171
197
  async function runRpcForward(
172
- questions: AskUserQuestion[],
198
+ formQuestions: FormQuestion[],
173
199
  ctx: ExtensionContext,
174
200
  allowCancel: boolean,
175
- ): Promise<AskUserAnswers | null> {
201
+ ): Promise<FormAnswers | null> {
176
202
  const guiCtx = {
177
203
  mode: ctx.mode,
178
204
  hasUI: ctx.hasUI,
179
205
  ui: { select: ctx.ui.select.bind(ctx.ui) },
180
206
  };
181
- return askUserInteract(guiCtx, questions, { allowCancel });
207
+ const result = await uiFormInteract(guiCtx, formQuestions, {
208
+ allowCancel,
209
+ log: (msg, detail) => logger.warn(msg, detail),
210
+ });
211
+ return result.ok ? result.answers : null;
182
212
  }
@@ -33,8 +33,13 @@ export const CHANNEL_HANDSHAKE_KEY = Symbol.for(
33
33
  /** 握手协议版本号。读写 slot 时校验 version !== 1 视为不兼容(warn + 重建 slot)。 */
34
34
  const HANDSHAKE_VERSION = 1;
35
35
 
36
- /** channel 名称(ask-user 固定注册 "ask_user")。 */
37
- const ASK_USER_CHANNEL = "ask_user";
36
+ /** 注册的 channel 名(D9 双名注册两代通道)。
37
+ * channel 名由孙进程 select title 的 marker 经 normalizeChannelName 派生
38
+ * (subagent-engine-sdk ui-channels.ts:剥 "\x00TAIJI_" 前缀 + 小写化):
39
+ * - 旧孙进程(askUserInteract / ASK_USER_MARKER "\x00TAIJI_ASK_USER")→ "ask_user"
40
+ * - 新孙进程(uiFormInteract / UI_FORM_MARKER "\x00TAIJI_UI_FORM")→ "ui_form"
41
+ * 孙进程 npm 版本不可控,同名 handler 注册两代通道名;旧名随 D7 窗口退役。 */
42
+ const ASK_USER_CHANNELS = ["ask_user", "ui_form"] as const;
38
43
 
39
44
  /**
40
45
  * channel registry 的本地等价接口(与 packages/subagent-core 的 UiChannelRegistry 形状一致,
@@ -91,24 +96,26 @@ function ensureSlot(): ChannelRegistryHandshake {
91
96
  }
92
97
 
93
98
  /**
94
- * 注册 ask_user channel handler 到 globalThis 握手 slot。
99
+ * 注册 ask_user channel handler 到 globalThis 握手 slot(D9 双名:ask_user + ui_form)。
95
100
  *
96
101
  * 行为:
97
- * 1. slot 不存在或 version 不兼容 → 建 slot(仅 pending),handler 入 pending;
98
- * **slot.registry 保持 undefined**(M4 核心:ask-user 不建 registry)
99
- * 2. slot 存在但 registry 未就绪 → handler 入 pending
100
- * 3. slot 存在且 registry 就绪 → 直接调 registry.register("ask_user", handler)
102
+ * 1. slot 不存在或 version 不兼容 → 建 slot(仅 pending),handler 按 ASK_USER_CHANNELS
103
+ * 逐名入 pending;**slot.registry 保持 undefined**(M4 核心:ask-user 不建 registry)
104
+ * 2. slot 存在但 registry 未就绪 → handler 逐名入 pending
105
+ * 3. slot 存在且 registry 就绪 → 对每个 channel 名调 registry.register(name, handler)
101
106
  *
102
107
  * 多次调用幂等:registry 就绪时 register 同名覆盖;未就绪时 pending.length 增长
103
108
  * (packages/subagent-core flush 时一次性消费所有 pending)。
104
109
  *
105
- * @param handler ask_user channel handler(createAskUserChannelHandler 产出)
110
+ * @param handler ask_user channel handler(createAskUserChannelHandler 产出,双名共用同一实现)
106
111
  */
107
112
  export function registerAskUserChannelHandler(handler: ChannelHandler): void {
108
113
  const slot = readSlot() ?? ensureSlot();
109
- if (slot.registry !== undefined) {
110
- slot.registry.register(ASK_USER_CHANNEL, handler);
111
- return;
114
+ for (const channel of ASK_USER_CHANNELS) {
115
+ if (slot.registry !== undefined) {
116
+ slot.registry.register(channel, handler);
117
+ } else {
118
+ slot.pending.push({ channel, handler });
119
+ }
112
120
  }
113
- slot.pending.push({ channel: ASK_USER_CHANNEL, handler });
114
121
  }