@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.
- package/ARCHITECTURE.md +68 -49
- package/package.json +5 -4
- package/src/__tests__/answer-codec.test.ts +102 -0
- package/src/__tests__/channel-handler.test.ts +16 -99
- package/src/__tests__/component-keymap.test.ts +2 -30
- package/src/__tests__/component.test.ts +59 -170
- package/src/__tests__/e2e.test.ts +6 -53
- package/src/__tests__/fixtures.ts +0 -15
- package/src/__tests__/index.test.ts +24 -92
- package/src/__tests__/question-view.test.ts +1 -30
- package/src/__tests__/submit-view.test.ts +13 -19
- package/src/__tests__/types.test.ts +0 -1
- package/src/__tests__/validate.test.ts +4 -10
- package/src/__tests__/w2-draft-hint.test.ts +1 -21
- package/src/__tests__/w3-regression.test.ts +3 -31
- package/src/answer-codec.ts +33 -0
- package/src/channel-handler.ts +18 -49
- package/src/component.ts +23 -47
- package/src/index.ts +34 -35
- package/src/question-view.ts +11 -43
- package/src/submit-view.ts +23 -12
- package/src/types.ts +12 -9
- package/src/validate.ts +2 -2
- package/src/__tests__/answer-format.test.ts +0 -275
- package/src/answer-format.ts +0 -127
|
@@ -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?"]).
|
|
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?"]).
|
|
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) →
|
|
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?"]).
|
|
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?"]).
|
|
627
|
+
expect(result.details.answers["Which tools?"]).toEqual({ selected: ["A", "C"], other: null });
|
|
628
628
|
});
|
|
629
629
|
|
|
630
|
-
|
|
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
|
-
//
|
|
645
|
-
expect(result.details.answers["Which DB?"]).
|
|
646
|
+
// 结构化:selected + other 分离
|
|
647
|
+
expect(result.details.answers["Which DB?"]).toEqual({ selected: ["Postgres"], other: "Custom DB" });
|
|
646
648
|
});
|
|
647
649
|
|
|
648
|
-
it("R-
|
|
650
|
+
it("R-3b: other-only answer (no main key) → AnswerValue {selected:[], other}", async () => {
|
|
649
651
|
const tool = getTool();
|
|
650
|
-
|
|
651
|
-
|
|
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?": "
|
|
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
|
-
|
|
659
|
+
validSingle,
|
|
691
660
|
undefined,
|
|
692
661
|
undefined,
|
|
693
662
|
makeCtx({ mode: "rpc", selectResult: protoAnswers }),
|
|
694
663
|
);
|
|
695
|
-
|
|
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?"]).
|
|
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
|
|
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?"]).
|
|
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?"]).
|
|
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
|
|
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)).
|
|
85
|
+
expect(getAnswerText(q1, s)).toEqual({ selected: ["Postgres"], other: null });
|
|
86
86
|
});
|
|
87
87
|
|
|
88
|
-
it("S-6: multi-select returns labels
|
|
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)).
|
|
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)).
|
|
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)).
|
|
119
|
+
expect(getAnswerText(multiQ, s)).toEqual({ selected: ["A"], other: null });
|
|
120
120
|
});
|
|
121
121
|
|
|
122
|
-
it("S-8: Other free-text
|
|
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)).
|
|
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-
|
|
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
|
-
|
|
156
|
+
freeTextValue: "nice",
|
|
163
157
|
});
|
|
164
|
-
expect(getAnswerText(multiQ, s)).
|
|
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?"]).
|
|
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"]).
|
|
183
|
+
expect(result.answers["Q1"]).toEqual({ selected: ["A"], other: null });
|
|
190
184
|
expect(result.answers["Q2"]).toBeUndefined();
|
|
191
185
|
});
|
|
192
186
|
});
|
|
@@ -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
|
|
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"]).
|
|
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/
|
|
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?"]).
|
|
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"]).
|
|
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
|
+
}
|
package/src/channel-handler.ts
CHANGED
|
@@ -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
|
|
13
|
-
//
|
|
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 {
|
|
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 =
|
|
72
|
-
* (
|
|
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 = 选中项
|
|
77
|
-
* - 多选:value = JSON.stringify(选中项
|
|
75
|
+
* - 单选:value = 选中项 label string
|
|
76
|
+
* - 多选:value = JSON.stringify(选中项 label 数组)
|
|
78
77
|
* - Other 自由文本:单独 key `${header}__other`
|
|
79
|
-
* - comment:单独 key `${header}__comment`
|
|
80
78
|
*
|
|
81
|
-
*
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
}
|