@zhushanwen/pi-ask-user 6.0.1 → 7.0.2

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.
@@ -329,7 +329,7 @@ describe("execute — result handling (FR-7)", () => {
329
329
  const tool = getTool();
330
330
  const fakeResult = {
331
331
  questions: [{ question: "Which DB?", options: [{ label: "Postgres" }] }],
332
- answers: { "Which DB?": "Postgres" },
332
+ answers: { "Which DB?": { selected: ["Postgres"], other: null } },
333
333
  cancelled: false,
334
334
  };
335
335
  const result = await tool.execute(
@@ -347,7 +347,7 @@ describe("execute — result handling (FR-7)", () => {
347
347
  const tool = getTool();
348
348
  const fakeResult = {
349
349
  questions: [{ question: "Which DB?", options: [{ label: "Postgres" }] }],
350
- answers: { "Which DB?": "Postgres" },
350
+ answers: { "Which DB?": { selected: ["Postgres"], other: null } },
351
351
  cancelled: false,
352
352
  };
353
353
  const result = await tool.execute(
@@ -358,7 +358,7 @@ describe("execute — result handling (FR-7)", () => {
358
358
  makeCtx({ customResult: fakeResult }),
359
359
  );
360
360
  expect(result.details.questions).toEqual(fakeResult.questions);
361
- expect(result.details.answers["Which DB?"]).toBe("Postgres");
361
+ expect(result.details.answers["Which DB?"]).toEqual({ selected: ["Postgres"], other: null });
362
362
  });
363
363
 
364
364
  it("I-14: cancelled (null) → 'User cancelled'", async () => {
@@ -442,7 +442,7 @@ describe("renderCall / renderResult (FR-9)", () => {
442
442
  {
443
443
  details: {
444
444
  questions: [{ question: "Q", header: "H", options: [{ label: "A" }] }],
445
- answers: { Q: "A" },
445
+ answers: { Q: { selected: ["A"], other: null } },
446
446
  cancelled: false,
447
447
  },
448
448
  },
@@ -506,7 +506,7 @@ describe("renderCall / renderResult (FR-9)", () => {
506
506
  options: [{ label: "Postgres" }, { label: "SQLite" }],
507
507
  },
508
508
  ],
509
- answers: { "Which DB?": "Postgres" },
509
+ answers: { "Which DB?": { selected: ["Postgres"], other: null } },
510
510
  cancelled: false,
511
511
  },
512
512
  },
@@ -575,11 +575,11 @@ describe("execute — RPC mode (askUserInteract via select channel)", () => {
575
575
  makeCtx({ mode: "rpc", selectResult: protoAnswers }),
576
576
  );
577
577
  expect(result.details.cancelled).toBe(false);
578
- expect(result.details.answers["Which DB?"]).toBe("Postgres");
578
+ expect(result.details.answers["Which DB?"]).toEqual({ selected: ["Postgres"], other: null });
579
579
  expect(result.content[0].text).toContain("Postgres");
580
580
  });
581
581
 
582
- it("R-2: multi-select answer (JSON array) → comma-joined labels", async () => {
582
+ it("R-2: multi-select answer (JSON array) → AnswerValue.selected", async () => {
583
583
  const tool = getTool();
584
584
  const multi = {
585
585
  questions: [
@@ -600,7 +600,7 @@ describe("execute — RPC mode (askUserInteract via select channel)", () => {
600
600
  undefined,
601
601
  makeCtx({ mode: "rpc", selectResult: protoAnswers }),
602
602
  );
603
- expect(result.details.answers["Which tools?"]).toBe("A, C");
603
+ expect(result.details.answers["Which tools?"]).toEqual({ selected: ["A", "C"], other: null });
604
604
  });
605
605
 
606
606
  it("R-2b: multi-select 乱序回传 → 按 options 定义顺序排序(S#3)", async () => {
@@ -624,10 +624,12 @@ describe("execute — RPC mode (askUserInteract via select channel)", () => {
624
624
  undefined,
625
625
  makeCtx({ mode: "rpc", selectResult: protoAnswers }),
626
626
  );
627
- expect(result.details.answers["Which tools?"]).toBe("A, C");
627
+ expect(result.details.answers["Which tools?"]).toEqual({ selected: ["A", "C"], other: null });
628
628
  });
629
629
 
630
- it("R-3: Other free text → appended to answer parts", async () => {
630
+ // R-3 用例名(TC-03):旧措辞(parseAnswerParts 时代的拼接语义)残留,
631
+ // 断言已结构化({selected, other} 分离),更名为实际契约:AnswerValue.other 独立字段。
632
+ it("R-3: Other free text → AnswerValue.other 独立字段", async () => {
631
633
  const tool = getTool();
632
634
  // 单选 Postgres + Other "Custom DB"
633
635
  const protoAnswers = JSON.stringify({
@@ -641,59 +643,25 @@ describe("execute — RPC mode (askUserInteract via select channel)", () => {
641
643
  undefined,
642
644
  makeCtx({ mode: "rpc", selectResult: protoAnswers }),
643
645
  );
644
- // TUI 语义:parts = [selected, other].join(", ")
645
- expect(result.details.answers["Which DB?"]).toBe("Postgres, Custom DB");
646
+ // 结构化:selected + other 分离
647
+ expect(result.details.answers["Which DB?"]).toEqual({ selected: ["Postgres"], other: "Custom DB" });
646
648
  });
647
649
 
648
- it("R-4: comment → inlined with ' — ' separator", async () => {
650
+ it("R-3b: other-only answer (no main key) → AnswerValue {selected:[], other}", async () => {
649
651
  const tool = getTool();
650
- const withComment = {
651
- questions: [
652
- {
653
- question: "Which DB?",
654
- options: [{ label: "Postgres" }, { label: "SQLite" }],
655
- allowComment: true,
656
- },
657
- ],
658
- };
652
+ // 修复后前端无选项自由文本问题只写 `${key}__other`(与 encodeAnswer 契约一致),
653
+ // 不写主 key——解码必须得 {selected:[], other},不得产生 selected 双持有。
659
654
  const protoAnswers = JSON.stringify({
660
- "Which DB?": "Postgres",
661
- "Which DB?__comment": "prod constraint",
662
- });
663
- const result = await tool.execute(
664
- "id",
665
- withComment,
666
- undefined,
667
- undefined,
668
- makeCtx({ mode: "rpc", selectResult: protoAnswers }),
669
- );
670
- expect(result.details.answers["Which DB?"]).toBe("Postgres — prod constraint");
671
- });
672
-
673
- it("R-4b: comment-only answer → 保留(MF-1 回归:无选中 + comment 不静默丢失)", async () => {
674
- const tool = getTool();
675
- const withComment = {
676
- questions: [
677
- {
678
- question: "Which DB?",
679
- options: [{ label: "Postgres" }, { label: "SQLite" }],
680
- allowComment: true,
681
- },
682
- ],
683
- };
684
- // GUI 只填评论提交(无选中):answers 只有 __comment key、无主 key
685
- const protoAnswers = JSON.stringify({
686
- "Which DB?__comment": "生产环境都不能用,需评估新方案",
655
+ "Which DB?__other": "Custom DB",
687
656
  });
688
657
  const result = await tool.execute(
689
658
  "id",
690
- withComment,
659
+ validSingle,
691
660
  undefined,
692
661
  undefined,
693
662
  makeCtx({ mode: "rpc", selectResult: protoAnswers }),
694
663
  );
695
- // formatAnswer 空 parts 不再返回 null——comment-only 以 " — comment" 形式保留
696
- expect(result.details.answers["Which DB?"]).toBe(" — 生产环境都不能用,需评估新方案");
664
+ expect(result.details.answers["Which DB?"]).toEqual({ selected: [], other: "Custom DB" });
697
665
  });
698
666
 
699
667
  it("R-5: user cancel (select returns undefined) → cancelled details", async () => {
@@ -744,10 +712,10 @@ describe("execute — RPC mode (askUserInteract via select channel)", () => {
744
712
  makeCtx({ mode: "rpc", selectResult: protoAnswers }),
745
713
  );
746
714
  // 转换后 key 必须是 question 全文(与 TUI 版 buildResult 一致)
747
- expect(result.details.answers["Which database?"]).toBe("Postgres");
715
+ expect(result.details.answers["Which database?"]).toEqual({ selected: ["Postgres"], other: null });
748
716
  });
749
717
 
750
- it("R-8: multi-question mixed (single-select + multi-select + Other + comment)", async () => {
718
+ it("R-8: multi-question mixed (single-select + multi-select + Other)", async () => {
751
719
  const tool = getTool();
752
720
  const mixed = {
753
721
  questions: [
@@ -766,7 +734,6 @@ describe("execute — RPC mode (askUserInteract via select channel)", () => {
766
734
  question: "Which region?",
767
735
  header: "Region",
768
736
  options: [{ label: "US" }, { label: "EU" }],
769
- allowComment: true,
770
737
  },
771
738
  ],
772
739
  };
@@ -789,46 +756,11 @@ describe("execute — RPC mode (askUserInteract via select channel)", () => {
789
756
 
790
757
  expect(result.details.cancelled).toBe(false);
791
758
  // Q1: single-select
792
- expect(result.details.answers["Which database?"]).toBe("Postgres");
759
+ expect(result.details.answers["Which database?"]).toEqual({ selected: ["Postgres"], other: null });
793
760
  // Q2: multi-select 重排 + Other
794
- expect(result.details.answers["Which tools?"]).toBe("A, C, Custom");
761
+ expect(result.details.answers["Which tools?"]).toEqual({ selected: ["A", "C"], other: "Custom" });
795
762
  // Q3: 无选中 → 跳过(不在 answers map 中)
796
763
  expect(result.details.answers["Which region?"]).toBeUndefined();
797
764
  });
798
765
 
799
- it("R-9: multi-question with comment on one question", async () => {
800
- const tool = getTool();
801
- const multiQ = {
802
- questions: [
803
- {
804
- question: "Which DB?",
805
- header: "DB",
806
- options: [{ label: "Postgres" }],
807
- },
808
- {
809
- question: "Why?",
810
- header: "Reason",
811
- options: [{ label: "Performance" }],
812
- allowComment: true,
813
- },
814
- ],
815
- };
816
- const protoAnswers = JSON.stringify({
817
- DB: "Postgres",
818
- Reason: "Performance",
819
- "Reason__comment": "benchmarked",
820
- });
821
- const result = await tool.execute(
822
- "id",
823
- multiQ,
824
- undefined,
825
- undefined,
826
- makeCtx({ mode: "rpc", selectResult: protoAnswers }),
827
- );
828
-
829
- // Q1: 无 comment
830
- expect(result.details.answers["Which DB?"]).toBe("Postgres");
831
- // Q2: 有 comment → 内联
832
- expect(result.details.answers["Why?"]).toBe("Performance — benchmarked");
833
- });
834
766
  });
@@ -252,7 +252,7 @@ describe("renderQuestionView — Other editor mode", () => {
252
252
  });
253
253
 
254
254
  it("Q-28-WIDE: freeform 模式在宽终端下用全宽渲染(不被分屏左列压窄)", () => {
255
- // 回归:freeform/comment 模式忽略分屏,编辑器用全 width。
255
+ // 回归:freeform 模式忽略分屏,编辑器用全 width。
256
256
  // 修复前:宽终端走 split.left(≈40),Other 输入被压在左半屏换行频繁。
257
257
  const width = 100;
258
258
  // getSplitPaneWidths(100) 非 null(宽终端会进分屏分支),但 freeform 应绕过它
@@ -335,35 +335,6 @@ describe("renderQuestionView — Other editor mode", () => {
335
335
  });
336
336
  });
337
337
 
338
- // ── Q-18 ~ Q-19: 评论模式 ───────────────────────────────
339
- describe("renderQuestionView — comment mode", () => {
340
- it("Q-18: comment mode renders editor with note text", () => {
341
- const lines = rv(
342
- singleQ,
343
- makeState({ mode: "comment", selectedIndex: 0 }),
344
- stubTheme,
345
- 60,
346
- true,
347
- "my note",
348
- );
349
- const t = text(lines);
350
- expect(t.toLowerCase()).toContain("comment");
351
- expect(t).toContain("my note");
352
- });
353
-
354
- it("Q-19: comment prompt includes (optional)", () => {
355
- const lines = rv(
356
- singleQ,
357
- makeState({ mode: "comment", selectedIndex: 0 }),
358
- stubTheme,
359
- 60,
360
- true,
361
- "",
362
- );
363
- expect(text(lines)).toContain("(optional)");
364
- });
365
- });
366
-
367
338
  // ── Q-20 ~ Q-23: 帮助行 ─────────────────────────────────
368
339
  describe("renderQuestionView — help line", () => {
369
340
  it("Q-20: single-select help shows 'Enter select'", () => {
@@ -80,19 +80,19 @@ describe("renderSubmitView", () => {
80
80
 
81
81
  // ── S-5 ~ S-10: getAnswerText ────────────────────────────
82
82
  describe("getAnswerText", () => {
83
- it("S-5: single-select returns label", () => {
83
+ it("S-5: single-select returns AnswerValue with label", () => {
84
84
  const s = makeState({ confirmed: true, selectedIndex: 0 });
85
- expect(getAnswerText(q1, s)).toBe("Postgres");
85
+ expect(getAnswerText(q1, s)).toEqual({ selected: ["Postgres"], other: null });
86
86
  });
87
87
 
88
- it("S-6: multi-select returns labels joined in index order", () => {
88
+ it("S-6: multi-select returns labels sorted in index order", () => {
89
89
  const multiQ: Question = {
90
90
  question: "Features",
91
91
  multiSelect: true,
92
92
  options: [{ label: "A" }, { label: "B" }, { label: "C" }],
93
93
  };
94
94
  const s = makeState({ confirmed: true, selectedIndices: new Set([0, 1]) });
95
- expect(getAnswerText(multiQ, s)).toBe("A, B");
95
+ expect(getAnswerText(multiQ, s)).toEqual({ selected: ["A", "B"], other: null });
96
96
  });
97
97
 
98
98
  it("S-7: multi-select out-of-order toggle still sorts by index", () => {
@@ -103,7 +103,7 @@ describe("getAnswerText", () => {
103
103
  };
104
104
  // toggle order: 1, then 0, then 2 → Set may iterate 1,0,2 but output should be A, B, C
105
105
  const s = makeState({ confirmed: true, selectedIndices: new Set([1, 0, 2]) });
106
- expect(getAnswerText(multiQ, s)).toBe("A, B, C");
106
+ expect(getAnswerText(multiQ, s)).toEqual({ selected: ["A", "B", "C"], other: null });
107
107
  });
108
108
 
109
109
  it("S-7b: multi-select out-of-range indices are filtered out (defensive branch)", () => {
@@ -116,17 +116,12 @@ describe("getAnswerText", () => {
116
116
  };
117
117
  // index 5 越界(只有 2 个选项),应被过滤;只保留 A
118
118
  const s = makeState({ confirmed: true, selectedIndices: new Set([0, 5]) });
119
- expect(getAnswerText(multiQ, s)).toBe("A");
119
+ expect(getAnswerText(multiQ, s)).toEqual({ selected: ["A"], other: null });
120
120
  });
121
121
 
122
- it("S-8: Other free-text appended", () => {
122
+ it("S-8: Other free-text goes to other field", () => {
123
123
  const s = makeState({ confirmed: true, selectedIndex: null, freeTextValue: "custom" });
124
- expect(getAnswerText(q1, s)).toBe("custom");
125
- });
126
-
127
- it("S-9: comment appended with separator", () => {
128
- const s = makeState({ confirmed: true, selectedIndex: 0, commentValue: "fast" });
129
- expect(getAnswerText(q1, s)).toBe("Postgres — fast");
124
+ expect(getAnswerText(q1, s)).toEqual({ selected: [], other: "custom" });
130
125
  });
131
126
 
132
127
  it("S-10: unconfirmed returns null", () => {
@@ -149,19 +144,18 @@ describe("getAnswerText", () => {
149
144
  expect(getAnswerText(multiQ, sEmptyMulti)).toBeNull();
150
145
  });
151
146
 
152
- it("S-9b: multi-select + comment combined", () => {
147
+ it("S-9: selected + other combined in one AnswerValue", () => {
153
148
  const multiQ: Question = {
154
149
  question: "Features",
155
150
  multiSelect: true,
156
- allowComment: true,
157
151
  options: [{ label: "A" }, { label: "B" }],
158
152
  };
159
153
  const s = makeState({
160
154
  confirmed: true,
161
155
  selectedIndices: new Set([0, 1]),
162
- commentValue: "nice",
156
+ freeTextValue: "nice",
163
157
  });
164
- expect(getAnswerText(multiQ, s)).toBe("A, B — nice");
158
+ expect(getAnswerText(multiQ, s)).toEqual({ selected: ["A", "B"], other: "nice" });
165
159
  });
166
160
  });
167
161
 
@@ -172,7 +166,7 @@ describe("buildResult", () => {
172
166
  const states = [makeState({ confirmed: true, selectedIndex: 1 })];
173
167
  const result = buildResult(questions, states);
174
168
  expect(result.cancelled).toBe(false);
175
- expect(result.answers["Which DB?"]).toBe("SQLite");
169
+ expect(result.answers["Which DB?"]).toEqual({ selected: ["SQLite"], other: null });
176
170
  expect(result.questions).toBe(questions);
177
171
  });
178
172
 
@@ -186,7 +180,7 @@ describe("buildResult", () => {
186
180
  makeState({ confirmed: false }),
187
181
  ];
188
182
  const result = buildResult(questions, states);
189
- expect(result.answers["Q1"]).toBe("A");
183
+ expect(result.answers["Q1"]).toEqual({ selected: ["A"], other: null });
190
184
  expect(result.answers["Q2"]).toBeUndefined();
191
185
  });
192
186
  });
@@ -40,7 +40,6 @@ describe("types", () => {
40
40
  expect(s.selectedIndices).toBeInstanceOf(Set);
41
41
  expect(s.confirmed).toBe(false);
42
42
  expect(s.freeTextValue).toBeNull();
43
- expect(s.commentValue).toBeNull();
44
43
  expect(s.mode).toBe("options");
45
44
  });
46
45
 
@@ -82,7 +82,7 @@ describe("validateInput", () => {
82
82
  });
83
83
 
84
84
  // V-10/S3: 多 question 时 header 必须唯一——重复 header 会导致 askUserKey 碰撞
85
- // (协议 helper 用 header 作 answers 读取 key,后一个覆盖前一个的 Other/comment)
85
+ // (协议 helper 用 header 作 answers 读取 key,后一个覆盖前一个的 Other)
86
86
  it("rejects duplicate headers across different questions", () => {
87
87
  const result = validateInput([
88
88
  q({ question: "Q1", header: "Same" }),
@@ -172,6 +172,9 @@ describe("validateInput", () => {
172
172
  // 目标(任务核心):弱模型误用 "options":["A","B"] 不能被 schema 层干报错拦死,
173
173
  // 必须能进 validateInput 拿到带 Correct 正例的友好文案。Value.Check 是 Pi 运行时
174
174
  // TypeCompiler.Check 的等价校验(同一引擎),这里直接断言 schema 行为。
175
+ // 原 3 用例缩减为 2(TC-03):"string options then caught by validateInput..." 与
176
+ // V-17 逐字重复(同 options:["Postgres","SQLite"]、同 3 断言),已删除;
177
+ // 保留的 2 用例是分层增量——V-17 只测 validateInput 层,此 describe 测 schema→validate 链路。
175
178
  describe("schema-vs-validate integration (options 字符串下沉)", () => {
176
179
  it("string options PASS the schema layer (reach execute, not raw ajv error)", () => {
177
180
  const malformed = {
@@ -180,15 +183,6 @@ describe("schema-vs-validate integration (options 字符串下沉)", () => {
180
183
  expect(Value.Check(InputSchema, malformed)).toBe(true);
181
184
  });
182
185
 
183
- it("string options then caught by validateInput with a friendly Correct example", () => {
184
- const result = validateInput([
185
- { question: "Which DB?", options: ["Postgres", "SQLite"] },
186
- ]);
187
- expect(result).not.toBeNull();
188
- expect(result).toContain("objects, not strings");
189
- expect(result).toContain('Correct: "options":[{"label"');
190
- });
191
-
192
186
  it("well-formed object options still pass schema AND validateInput", () => {
193
187
  const wellFormed = {
194
188
  questions: [
@@ -13,7 +13,6 @@ import {
13
13
  multiQ,
14
14
  RIGHT,
15
15
  singleQ,
16
- singleQWithComment,
17
16
  stubTheme,
18
17
  UP,
19
18
  } from "./fixtures";
@@ -78,15 +77,6 @@ describe("W2 — draftText migration", () => {
78
77
  expect(editorLine1).toContain("aaa");
79
78
  expect(editorLine1).not.toContain("ccc");
80
79
  });
81
-
82
- it("C-BC4C: comment flow submits comment with answer", () => {
83
- const { c, result } = make([singleQWithComment]);
84
- c.handleInput(ENTER); // select A → comment mode
85
- c.handleInput("my note");
86
- c.handleInput(ENTER); // submit
87
- expect(result.val).toBeDefined();
88
- expect(result.val!.answers["Which DB? (with comment)"]).toBe("Postgres — my note");
89
- });
90
80
  });
91
81
 
92
82
  // ── hint line: append-only UX hint ──
@@ -102,16 +92,6 @@ describe("W2 — hint line", () => {
102
92
  expect(hintLine).toContain("Enter submit");
103
93
  expect(hintLine).toContain("Esc back");
104
94
  });
105
-
106
- it("C-HINT-2: comment editor hint contains all expected hints", () => {
107
- const { c } = make([singleQWithComment]);
108
- c.handleInput(ENTER);
109
- const lines = c.render(80);
110
- const hintLine = lines.find((l: string) => l.includes("move") && l.includes("Backspace deletes"));
111
- expect(hintLine).toBeDefined();
112
- expect(hintLine).toContain("Enter submit");
113
- expect(hintLine).toContain("Esc back");
114
- });
115
95
  });
116
96
 
117
97
  // ── freeDraft 隔离:丢弃的 freeform 草稿不污染答案、不触发 auto-confirm ──
@@ -150,7 +130,7 @@ describe("C-FREEDRAFT: discarded draft isolation", () => {
150
130
  // Submit
151
131
  c.handleInput(ENTER);
152
132
  expect(result.val).toBeDefined();
153
- expect(result.val!.answers["Q1"]).toBe("A");
133
+ expect(result.val!.answers["Q1"]).toEqual({ selected: ["A"], other: null });
154
134
  // 丢弃的草稿 "abc" 不应出现在任何答案中
155
135
  expect(JSON.stringify(result.val!.answers)).not.toContain("abc");
156
136
  });
@@ -1,5 +1,5 @@
1
1
  // src/__tests__/w3-regression.test.ts
2
- // W3: Forward regression — freeform/comment/bksp edge cases.
2
+ // W3: Forward regression — freeform/bksp edge cases.
3
3
  // No-op keymap coverage moved to component-keymap.test.ts (deduplicated).
4
4
  import { describe, expect, it } from "vitest";
5
5
 
@@ -14,7 +14,6 @@ import {
14
14
  LEFT,
15
15
  mockTui,
16
16
  multiQ,
17
- multiQWithComment,
18
17
  RIGHT,
19
18
  singleQ,
20
19
  stubTheme,
@@ -47,7 +46,7 @@ describe("W3 — freeform Enter clears selectedIndex (C-BC4B)", () => {
47
46
  c.handleInput(ENTER); // confirm freeform → afterConfirm → submit
48
47
  expect(result.val).toBeDefined();
49
48
  // The answer should be the freeform text
50
- expect(result.val!.answers["Which DB?"]).toBe("custom answer");
49
+ expect(result.val!.answers["Which DB?"]).toEqual({ selected: [], other: "custom answer" });
51
50
  });
52
51
 
53
52
  it("C-BC4B-SELECT: freeform on same question clears selectedIndex", () => {
@@ -68,34 +67,7 @@ describe("W3 — freeform Enter clears selectedIndex (C-BC4B)", () => {
68
67
  c.handleInput(ENTER);
69
68
  expect(result.val).toBeDefined();
70
69
  // Q1 answer should be the freeform text only
71
- expect(result.val!.answers["Q1"]).toBe("override");
72
- });
73
- });
74
-
75
- // ── C-BC4C: comment edge cases ──
76
- // C-BC4C-REEDIT (initial comment submit) removed — duplicate of w2-draft-hint C-BC4C.
77
- describe("W3 — comment re-edit (C-BC4C-CLEAR)", () => {
78
- it("C-BC4C-CLEAR: Esc in comment mode skips comment, keeps existing commentValue", () => {
79
- const { c, result } = make(multiQWithComment);
80
- // Q1: select A → comment mode
81
- c.handleInput(ENTER);
82
- c.handleInput("my note");
83
- c.handleInput(ENTER); // submit comment → advance to Q2
84
- // Q2: select X
85
- c.handleInput(ENTER);
86
- c.handleInput(ENTER); // confirm Q2 → advance to submit tab
87
- // Go back to Q1
88
- c.handleInput(LEFT);
89
- // Q1 is already confirmed, re-select A to trigger comment again
90
- c.handleInput(ENTER); // re-select A → afterConfirm → comment mode
91
- c.handleInput(ESC); // skip comment
92
- // Submit
93
- c.handleInput(RIGHT);
94
- c.handleInput(RIGHT); // navigate to submit tab
95
- c.handleInput(ENTER);
96
- expect(result.val).toBeDefined();
97
- // Comment should still be "my note" (Esc preserved commentValue)
98
- expect(result.val!.answers["Q1"]).toBe("A — my note");
70
+ expect(result.val!.answers["Q1"]).toEqual({ selected: [], other: "override" });
99
71
  });
100
72
  });
101
73
 
@@ -0,0 +1,33 @@
1
+ // src/answer-codec.ts
2
+ // AnswerValue → proto answers 条目的单向序列化(协议边界 SSOT)。
3
+ // 与 @xyz-agent/extension-protocol helpers.ts 的解码契约字节级对齐:
4
+ // - 单选:answers[key] = selected[0]
5
+ // - 多选:answers[key] = JSON.stringify(selected)
6
+ // - Other:answers[`${key}__other`] = other
7
+ // helpers.ts 的 getAskUserAnswer / getAskUserOther 是输出格式的唯一解码 SSOT。
8
+ // 单向 encode,无 parse 对偶(文本反解析已随双模型文本往返删除)。
9
+ import type { AnswerValue } from "./types";
10
+
11
+ /**
12
+ * 把结构化 AnswerValue 序列化为 proto answers 条目。
13
+ *
14
+ * @param value 结构化答案(selected = option label 数组,other = Other 自由文本)
15
+ * @param opts.key 协议 key(header ?? question 全文)
16
+ * @param opts.multiSelect 多选时主 key 写 JSON 数组,单选写首个 label
17
+ * @returns proto answers 条目;selected 空 && other 空/空串 → {}(未答,调用方不写入)
18
+ */
19
+ export function encodeAnswer(
20
+ value: AnswerValue,
21
+ opts: { key: string; multiSelect: boolean },
22
+ ): Record<string, string> {
23
+ const out: Record<string, string> = {};
24
+ if (value.selected.length > 0) {
25
+ out[opts.key] = opts.multiSelect
26
+ ? JSON.stringify(value.selected)
27
+ : value.selected[0]!;
28
+ }
29
+ if (value.other !== null && value.other !== "") {
30
+ out[`${opts.key}__other`] = value.other;
31
+ }
32
+ return out;
33
+ }
@@ -9,8 +9,8 @@
9
9
  // 不循环)。返回 {value: JSON.stringify(answers)} 让子进程 JSON.parse(value) decode。
10
10
  // - TUI:走 ctx.ui.custom + AskUserComponent。三步:(1) protoQuestions → 内部 Question[],
11
11
  // (2) ctx.ui.custom 渲染拿内部 Result,(3) 内部 Result.answers(key=question 全文,
12
- // value="label1, label2 — comment")→ 重新编码为 proto AskUserAnswers(key=header/question,
13
- // 单选=value,多选=JSON 数组,Other→__other,comment→__comment),让子进程 decode 一致。
12
+ // value=结构化 AnswerValue)→ 用 encodeAnswer 重新编码为 proto AskUserAnswers
13
+ // (key=header/question,单选=string,多选=JSON 数组,Other→__other),让子进程 decode 一致。
14
14
  //
15
15
  // handler 收到的 req.channelPayload = {questions: AskUserQuestion[], allowCancel}(proto 格式,
16
16
  // 由子进程 askUserInteract 编码、subagent-workflow parseChannel 解析 options[0] JSON 得到)。
@@ -23,8 +23,8 @@ import {
23
23
  } from "@xyz-agent/extension-protocol";
24
24
 
25
25
  import { AskUserComponent } from "./component";
26
- import { parseAnswerParts } from "./answer-format";
27
- import { type Option, type Question, type Result, type ThemeLike } from "./types";
26
+ import { encodeAnswer } from "./answer-codec";
27
+ import { type AnswerValue, type Option, type Question, type Result, type ThemeLike } from "./types";
28
28
 
29
29
  /**
30
30
  * channel handler 签名——与 subagent-workflow 的 UiChannelRegistry.ChannelHandler 一致
@@ -60,7 +60,6 @@ function protoToInternalQuestions(protoQuestions: AskUserQuestion[]): Question[]
60
60
  ...(pq.context !== undefined ? { context: pq.context } : {}),
61
61
  options: opts,
62
62
  ...(pq.multiSelect !== undefined ? { multiSelect: pq.multiSelect } : {}),
63
- ...(pq.allowComment !== undefined ? { allowComment: pq.allowComment } : {}),
64
63
  };
65
64
  });
66
65
  }
@@ -68,18 +67,16 @@ function protoToInternalQuestions(protoQuestions: AskUserQuestion[]): Question[]
68
67
  /**
69
68
  * 把 TUI 路径产出的内部 Result.answers 重新编码为 proto AskUserAnswers。
70
69
  *
71
- * 内部 Result.answers:key = question 全文,value = "label1, label2 — comment"
72
- * (Other 自由文本与 selected 标签逗号拼接,comment 用 ANSWER_COMMENT_SEPARATOR 分隔)。
70
+ * 内部 Result.answers:key = question 全文,value = 结构化 AnswerValue
71
+ * (selected = option label 数组,other = Other 自由文本)。
73
72
  *
74
73
  * proto AskUserAnswers 契约(@xyz-agent/extension-protocol):
75
74
  * - key = question.header ?? question 全文
76
- * - 单选:value = 选中项 value string
77
- * - 多选:value = JSON.stringify(选中项 value 数组)
75
+ * - 单选:value = 选中项 label string
76
+ * - 多选:value = JSON.stringify(选中项 label 数组)
78
77
  * - Other 自由文本:单独 key `${header}__other`
79
- * - comment:单独 key `${header}__comment`
80
78
  *
81
- * 解码(无信息丢失):用 protoQuestion.options 的 label 集合精确匹配 selected;
82
- * 不匹配的 token = Other 自由文本;comment 由 ANSWER_COMMENT_SEPARATOR 切出。
79
+ * 序列化走 encodeAnswer(answer-codec.ts 是唯一 encode 实现,与协议包解码 helper 对齐)。
83
80
  */
84
81
  function encodeTuiResultToProto(
85
82
  protoQuestions: AskUserQuestion[],
@@ -87,43 +84,15 @@ function encodeTuiResultToProto(
87
84
  ): AskUserAnswers {
88
85
  const answers: AskUserAnswers = {};
89
86
  for (const pq of protoQuestions) {
90
- const key = pq.header ?? pq.question;
91
- const internalText = result.answers[pq.question];
92
- if (internalText === undefined) continue; // 该问题未答(protoAnswersToResult 也跳过未答)
93
-
94
- // 已知选项 label 集合(protoQuestion.options 的 label/value 都作候选——value 缺失时用 label)
95
- const knownLabels = new Set<string>();
96
- for (const o of pq.options ?? []) {
97
- knownLabels.add(o.label);
98
- if (o.value !== undefined) knownLabels.add(o.value);
99
- }
100
-
101
- // body/comment 切分 + 选中/Other 识别:复用 parseAnswerParts(answer-format.ts 是
102
- // 唯一权威切分实现——label 含 ANSWER_COMMENT_SEPARATOR 时首分隔符切分会把 label
103
- // 拦腰截断导致选中丢失,两处各自实现必然漂移;复用保证 TUI/RPC 解码行为一致,
104
- // comment 自身含分隔符也正确往返)。knownLabels 传 label+value 并集(value 缺失时
105
- // 用 label)。
106
- const { selected: matchedLabels, comment, otherTokens } = parseAnswerParts(internalText, [...knownLabels]);
107
-
108
- // matched label 回查 proto option 的 value(PR #85 #8):TUI 渲染用 label,但 RPC 路径
109
- // (askUserInteract)回传的是 option.value。value≠label 时若直接 push label,
110
- // TUI/RPC 两条路径产出分裂。value 缺失时 fallback label(保持 ask-user 自身
111
- // toProtoQuestions 的 value=label 语义,以及历史行为)。
112
- const selected = matchedLabels.map((t: string) => {
113
- const opt = pq.options?.find(o => o.label === t || o.value === t);
114
- return opt?.value ?? t;
115
- });
116
- const otherText = otherTokens.join(", ") || undefined;
117
-
118
- // 主 key:单选 = 首个选中 value;多选 = JSON 数组(即便为空也写入,与 RPC 契约一致)
119
- if (pq.multiSelect) {
120
- answers[key] = JSON.stringify(selected);
121
- } else if (selected.length > 0) {
122
- answers[key] = selected[0]!;
123
- }
124
-
125
- if (otherText) answers[`${key}__other`] = otherText;
126
- if (comment) answers[`${key}__comment`] = comment;
87
+ const av: AnswerValue | undefined = result.answers[pq.question];
88
+ if (av === undefined) continue; // 该问题未答(buildResult 跳过未答)
89
+ Object.assign(
90
+ answers,
91
+ encodeAnswer(av, {
92
+ key: pq.header ?? pq.question,
93
+ multiSelect: pq.multiSelect === true,
94
+ }),
95
+ );
127
96
  }
128
97
  return answers;
129
98
  }