@zhushanwen/pi-ask-user 5.0.0-dev.1 → 5.0.1
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/package.json +3 -3
- package/src/__tests__/answer-format.test.ts +171 -3
- package/src/__tests__/channel-handler.test.ts +25 -0
- package/src/__tests__/index.test.ts +26 -0
- package/src/answer-format.ts +89 -13
- package/src/channel-handler.ts +17 -24
- package/src/index.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-ask-user",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.1",
|
|
4
4
|
"description": "Inline adaptive ask_user tool for Pi — single/multi-question structured input with split-pane preview, inline editor and optional comments.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"ARCHITECTURE.md"
|
|
27
27
|
],
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@xyz-agent/extension-protocol": "^0.3.1
|
|
29
|
+
"@xyz-agent/extension-protocol": "^0.3.1"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@earendil-works/pi-tui": "*",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"@earendil-works/pi-coding-agent": "*",
|
|
39
39
|
"@earendil-works/pi-tui": "*",
|
|
40
40
|
"typebox": "*",
|
|
41
|
-
"@zhushanwen/pi-subagent-workflow": "5.0.
|
|
41
|
+
"@zhushanwen/pi-subagent-workflow": "5.0.1"
|
|
42
42
|
},
|
|
43
43
|
"peerDependenciesMeta": {
|
|
44
44
|
"@earendil-works/pi-tui": {
|
|
@@ -16,9 +16,10 @@ describe("formatAnswer", () => {
|
|
|
16
16
|
expect(formatAnswer([])).toBeNull();
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
-
it("
|
|
20
|
-
// parts
|
|
21
|
-
|
|
19
|
+
it("comment-only: empty parts + comment → separator-prefixed comment (MF-1)", () => {
|
|
20
|
+
// parts 空但有 comment = allowComment 的「无选项适用但想说明原因」场景,
|
|
21
|
+
// 不能返回 null(会把评论一起丢弃);以 ANSWER_COMMENT_SEPARATOR 开头保持往返可解析
|
|
22
|
+
expect(formatAnswer([], "some comment")).toBe(`${ANSWER_COMMENT_SEPARATOR}some comment`);
|
|
22
23
|
});
|
|
23
24
|
|
|
24
25
|
it("joins single part without separator", () => {
|
|
@@ -104,4 +105,171 @@ describe("parseAnswerParts", () => {
|
|
|
104
105
|
expect(result.selected).toEqual([]);
|
|
105
106
|
expect(result.comment).toBe("just a comment");
|
|
106
107
|
});
|
|
108
|
+
|
|
109
|
+
// MF-2 回归:label 自身含 ANSWER_COMMENT_SEPARATOR(LLM 生成 label 常见形态)时,
|
|
110
|
+
// 首个分隔符切分会把 label 拦腰截断 → token 无法精确匹配 → 选中值静默丢失
|
|
111
|
+
describe("MF-2: label contains ANSWER_COMMENT_SEPARATOR", () => {
|
|
112
|
+
const labels = ["Postgres — prod", "SQLite"];
|
|
113
|
+
|
|
114
|
+
it("whole answer exactly matches separator-containing label → selected, no comment", () => {
|
|
115
|
+
const result = parseAnswerParts("Postgres — prod", labels);
|
|
116
|
+
expect(result.selected).toEqual(["Postgres — prod"]);
|
|
117
|
+
expect(result.comment).toBeUndefined();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("selection + comment → later separator becomes split point", () => {
|
|
121
|
+
const result = parseAnswerParts("Postgres — prod — fast", labels);
|
|
122
|
+
expect(result.selected).toEqual(["Postgres — prod"]);
|
|
123
|
+
expect(result.comment).toBe("fast");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("two selections where first label contains separator", () => {
|
|
127
|
+
const result = parseAnswerParts("Postgres — prod, SQLite — my note", labels);
|
|
128
|
+
expect(result.selected).toEqual(["Postgres — prod", "SQLite"]);
|
|
129
|
+
expect(result.comment).toBe("my note");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("comment containing separator keeps full remainder", () => {
|
|
133
|
+
const result = parseAnswerParts("Postgres — prod — fast — very fast", labels);
|
|
134
|
+
expect(result.selected).toEqual(["Postgres — prod"]);
|
|
135
|
+
expect(result.comment).toBe("fast — very fast");
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("returns non-matching body tokens as otherTokens (Other free text)", () => {
|
|
140
|
+
const result = parseAnswerParts("custom text", ["yes", "no"]);
|
|
141
|
+
expect(result.selected).toEqual([]);
|
|
142
|
+
expect(result.otherTokens).toEqual(["custom text"]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("otherTokens excludes matched labels and comment", () => {
|
|
146
|
+
const result = parseAnswerParts("A, custom text — note", ["A", "B"]);
|
|
147
|
+
expect(result.selected).toEqual(["A"]);
|
|
148
|
+
expect(result.otherTokens).toEqual(["custom text"]);
|
|
149
|
+
expect(result.comment).toBe("note");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// MF-6 回归:两个 label 都含分隔符时("Postgres — prod, MySQL"),分隔符扫描会把
|
|
153
|
+
// 首个分隔符当切分点 → body "Postgres" 无匹配 → 剩余 "prod, MySQL" 被误判为 comment,
|
|
154
|
+
// 两个选中项全部静默丢失。纯多选分支(所有逗号 token 均为 label)在扫描前短路。
|
|
155
|
+
describe("MF-6: pure multi-select where every label contains separator", () => {
|
|
156
|
+
const labels = ["Postgres — prod", "MySQL"];
|
|
157
|
+
|
|
158
|
+
it("all comma tokens are labels → selected all, no comment", () => {
|
|
159
|
+
const result = parseAnswerParts("Postgres — prod, MySQL", labels);
|
|
160
|
+
expect(result.selected).toEqual(["Postgres — prod", "MySQL"]);
|
|
161
|
+
expect(result.comment).toBeUndefined();
|
|
162
|
+
expect(result.otherTokens).toEqual([]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("comment form not broken: non-label token 'MySQL — note' skips the branch", () => {
|
|
166
|
+
// "MySQL — note" 非 label → 不进纯多选分支;分隔符扫描取「body 整串可解析为
|
|
167
|
+
// 选中项」的最靠后切分("Postgres — prod, MySQL" 两个 token 均 label)→
|
|
168
|
+
// 选中两项 + comment "note"(与 MF-2 多选 + comment 语义一致)
|
|
169
|
+
const result = parseAnswerParts("Postgres — prod, MySQL — note", labels);
|
|
170
|
+
expect(result.selected).toEqual(["Postgres — prod", "MySQL"]);
|
|
171
|
+
expect(result.comment).toBe("note");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("three selections all containing separators", () => {
|
|
175
|
+
const labels3 = ["Postgres — prod", "MySQL — prod", "SQLite — prod"];
|
|
176
|
+
const result = parseAnswerParts("Postgres — prod, MySQL — prod, SQLite — prod", labels3);
|
|
177
|
+
expect(result.selected).toEqual(["Postgres — prod", "MySQL — prod", "SQLite — prod"]);
|
|
178
|
+
expect(result.comment).toBeUndefined();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// MF-7 回归:重叠前缀 label(短 label 是长 label 的前缀)时,分隔符扫描取「首个 body
|
|
183
|
+
// 含 label token」的切分会停在短 label 处,把长 label 的剩余部分误判为 comment。
|
|
184
|
+
// 修复:优先「body 整串精确命中 label」的切分点,多个命中取最靠后者(最长匹配)。
|
|
185
|
+
describe("MF-7: overlapping prefix labels prefer whole-body label split", () => {
|
|
186
|
+
const labels = ["Postgres", "Postgres — prod"];
|
|
187
|
+
|
|
188
|
+
it("longest whole-body label wins → 'Postgres — prod' + comment 'x'", () => {
|
|
189
|
+
const result = parseAnswerParts("Postgres — prod — x", labels);
|
|
190
|
+
expect(result.selected).toEqual(["Postgres — prod"]);
|
|
191
|
+
expect(result.comment).toBe("x");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("short label + comment containing long-label prefix (inherent ambiguity, documented tradeoff)", () => {
|
|
195
|
+
// 长 label 不在候选时(labels 只有 "Postgres"),同一文本解析为
|
|
196
|
+
// 选短 label + comment "prod — x"——文本层面唯一可解析的归属
|
|
197
|
+
const result = parseAnswerParts("Postgres — prod — x", ["Postgres"]);
|
|
198
|
+
expect(result.selected).toEqual(["Postgres"]);
|
|
199
|
+
expect(result.comment).toBe("prod — x");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("multi-select + comment with prefix labels → split after all labels", () => {
|
|
203
|
+
// "Postgres — prod, SQLite" 两个 token 均 label → 切分点取最后分隔符,
|
|
204
|
+
// body 完整保留两项选中,comment "note"(首个整串命中 "Postgres" 不截断)
|
|
205
|
+
const labels3 = ["Postgres", "Postgres — prod", "SQLite"];
|
|
206
|
+
const result = parseAnswerParts("Postgres — prod, SQLite — note", labels3);
|
|
207
|
+
expect(result.selected).toEqual(["Postgres — prod", "SQLite"]);
|
|
208
|
+
expect(result.comment).toBe("note");
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// MF-2(R3 回归):label 含分隔符 + Other 自由文本 + comment 的混合形态。
|
|
213
|
+
// R3 的 all-label 单谓词扫描落空 → 回退首分隔符 → 选中项静默丢失;第二级
|
|
214
|
+
// 「body 含 ≥1 label token」首个命中落在真正分隔选中/Other 与 comment 的分隔符上。
|
|
215
|
+
describe("MF-2: label with separator + Other free text + comment", () => {
|
|
216
|
+
const labels = ["Postgres — prod"];
|
|
217
|
+
|
|
218
|
+
it("mixed body (label + Other) + comment → label selected, Other kept, comment at later separator", () => {
|
|
219
|
+
const result = parseAnswerParts("Postgres — prod, custom text — note", labels);
|
|
220
|
+
expect(result.selected).toEqual(["Postgres — prod"]);
|
|
221
|
+
expect(result.otherTokens).toEqual(["custom text"]);
|
|
222
|
+
expect(result.comment).toBe("note");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("comment containing separator keeps full remainder (no over-split at second separator)", () => {
|
|
226
|
+
// "A, B — note — x":第一级 all-label body 在第一个分隔符命中,短路第二级
|
|
227
|
+
// ——若走「≥1 label token 取最后」会在第二个分隔符过切(comment 错位)
|
|
228
|
+
const result = parseAnswerParts("A, B — note — x", ["A", "B"]);
|
|
229
|
+
expect(result.selected).toEqual(["A", "B"]);
|
|
230
|
+
expect(result.comment).toBe("note — x");
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// MF-3(pre-existing):label 含分隔符 + Other 自由文本、无 comment 形态。
|
|
235
|
+
// 两级扫描均无命中(唯一候选 body "Postgres" 不含 label token)→ 整串含 label
|
|
236
|
+
// token 时不切分,整串作 body(选中 + Other)——首分隔符切分会把 label 拦腰截断
|
|
237
|
+
// 产生空选中。S-17 冻结边界:整串无任何 label token 时仍走首分隔符切分。
|
|
238
|
+
describe("MF-3: label with separator + Other free text, no comment", () => {
|
|
239
|
+
const labels = ["Postgres — prod"];
|
|
240
|
+
|
|
241
|
+
it("no comment variant → whole string as body, label selected + Other kept", () => {
|
|
242
|
+
const result = parseAnswerParts("Postgres — prod, custom text", labels);
|
|
243
|
+
expect(result.selected).toEqual(["Postgres — prod"]);
|
|
244
|
+
expect(result.otherTokens).toEqual(["custom text"]);
|
|
245
|
+
expect(result.comment).toBeUndefined();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("S-17 freeze intact: whole string without any label token still first-separator split", () => {
|
|
249
|
+
// "custom — text" 整串无 label token → 不切分回退不触发,保持首分隔符切分
|
|
250
|
+
const result = parseAnswerParts("custom — text", labels);
|
|
251
|
+
expect(result.selected).toEqual([]);
|
|
252
|
+
expect(result.otherTokens).toEqual(["custom"]);
|
|
253
|
+
expect(result.comment).toBe("text");
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// S-17 行为固化:Other 自由文本自身含 " — "(如 "custom — text")与
|
|
258
|
+
// 「选中/Other + comment」形态在纯解析层面固有歧义,保持首个分隔符切分行为
|
|
259
|
+
// (body "custom" + comment "text"),不做解析层区分(见 answer-format.ts 注释)。
|
|
260
|
+
describe("S-17: Other free text containing separator (behavior freeze)", () => {
|
|
261
|
+
it("separator inside Other text → first-separator split, documented ambiguity", () => {
|
|
262
|
+
const result = parseAnswerParts("custom — text", ["yes", "no"]);
|
|
263
|
+
expect(result.selected).toEqual([]);
|
|
264
|
+
expect(result.otherTokens).toEqual(["custom"]);
|
|
265
|
+
expect(result.comment).toBe("text");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("Other text with separator + matched label before it", () => {
|
|
269
|
+
const result = parseAnswerParts("yes, custom — text", ["yes", "no"]);
|
|
270
|
+
expect(result.selected).toEqual(["yes"]);
|
|
271
|
+
expect(result.otherTokens).toEqual(["custom"]);
|
|
272
|
+
expect(result.comment).toBe("text");
|
|
273
|
+
});
|
|
274
|
+
});
|
|
107
275
|
});
|
|
@@ -232,6 +232,31 @@ describe("createAskUserChannelHandler", () => {
|
|
|
232
232
|
});
|
|
233
233
|
});
|
|
234
234
|
|
|
235
|
+
it("TUI: label containing ' — ' keeps full selection (MF-2)", async () => {
|
|
236
|
+
// label 含 ANSWER_COMMENT_SEPARATOR("Postgres — prod")时,首分隔符切分
|
|
237
|
+
// 会拦腰截断 label → 无法精确匹配 → 选中值静默丢失;修复后选中保留、comment 完整
|
|
238
|
+
const sepLabelProto: AskUserQuestion = {
|
|
239
|
+
question: "Which DB?",
|
|
240
|
+
allowComment: true,
|
|
241
|
+
options: [{ label: "Postgres — prod", value: "Postgres — prod" }, { label: "SQLite", value: "SQLite" }],
|
|
242
|
+
};
|
|
243
|
+
const internalResult: Result = {
|
|
244
|
+
questions: [],
|
|
245
|
+
answers: { "Which DB?": "Postgres — prod — constraint" },
|
|
246
|
+
cancelled: false,
|
|
247
|
+
};
|
|
248
|
+
const handler = createAskUserChannelHandler(
|
|
249
|
+
makeCtx({ mode: "tui", customResult: internalResult }) as never,
|
|
250
|
+
);
|
|
251
|
+
const resp = await handler({ channelPayload: { questions: [sepLabelProto] } });
|
|
252
|
+
expect(resp).toEqual({
|
|
253
|
+
value: JSON.stringify({
|
|
254
|
+
"Which DB?": "Postgres — prod",
|
|
255
|
+
"Which DB?__comment": "constraint",
|
|
256
|
+
}),
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
235
260
|
it("TUI: user cancel (custom returns null) → {cancelled: true}", async () => {
|
|
236
261
|
const handler = createAskUserChannelHandler(
|
|
237
262
|
makeCtx({ mode: "tui", customResult: null }) as never,
|
|
@@ -670,6 +670,32 @@ describe("execute — RPC mode (askUserInteract via select channel)", () => {
|
|
|
670
670
|
expect(result.details.answers["Which DB?"]).toBe("Postgres — prod constraint");
|
|
671
671
|
});
|
|
672
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": "生产环境都不能用,需评估新方案",
|
|
687
|
+
});
|
|
688
|
+
const result = await tool.execute(
|
|
689
|
+
"id",
|
|
690
|
+
withComment,
|
|
691
|
+
undefined,
|
|
692
|
+
undefined,
|
|
693
|
+
makeCtx({ mode: "rpc", selectResult: protoAnswers }),
|
|
694
|
+
);
|
|
695
|
+
// formatAnswer 空 parts 不再返回 null——comment-only 以 " — comment" 形式保留
|
|
696
|
+
expect(result.details.answers["Which DB?"]).toBe(" — 生产环境都不能用,需评估新方案");
|
|
697
|
+
});
|
|
698
|
+
|
|
673
699
|
it("R-5: user cancel (select returns undefined) → cancelled details", async () => {
|
|
674
700
|
const tool = getTool();
|
|
675
701
|
const result = await tool.execute(
|
package/src/answer-format.ts
CHANGED
|
@@ -7,11 +7,13 @@ import { ANSWER_COMMENT_SEPARATOR } from "./types";
|
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* 把答案各部分拼装为最终文本格式:"part1, part2 — comment"。
|
|
10
|
-
* - parts 为空 →
|
|
10
|
+
* - parts 为空 + comment 有值 → comment-only 答案(allowComment 的「无选项适用但想说明原因」
|
|
11
|
+
* 场景):以 ANSWER_COMMENT_SEPARATOR 开头,保持 parseAnswerParts 往返可解析
|
|
12
|
+
* - parts 与 comment 都为空 → 返回 null(未答)
|
|
11
13
|
* - comment 有值 → 追加 ANSWER_COMMENT_SEPARATOR + comment
|
|
12
14
|
*/
|
|
13
15
|
export function formatAnswer(parts: string[], comment?: string | null): string | null {
|
|
14
|
-
if (parts.length === 0) return null;
|
|
16
|
+
if (parts.length === 0) return comment ? `${ANSWER_COMMENT_SEPARATOR}${comment}` : null;
|
|
15
17
|
const base = parts.join(", ");
|
|
16
18
|
return comment ? `${base}${ANSWER_COMMENT_SEPARATOR}${comment}` : base;
|
|
17
19
|
}
|
|
@@ -27,25 +29,99 @@ export function formatAnswer(parts: string[], comment?: string | null): string |
|
|
|
27
29
|
export function parseAnswerParts(
|
|
28
30
|
answer: string,
|
|
29
31
|
labels: string[],
|
|
30
|
-
): { selected: string[]; comment?: string } {
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
): { selected: string[]; comment?: string; otherTokens: string[] } {
|
|
33
|
+
const labelSet = new Set(labels);
|
|
34
|
+
const trimmed = answer.trim();
|
|
35
|
+
|
|
36
|
+
// 整串精确命中单个 label → 单选该 label、无 comment。label 自身可能含
|
|
37
|
+
// ANSWER_COMMENT_SEPARATOR(如 "Postgres — prod"),首个分隔符切分会把它拦腰截断。
|
|
38
|
+
if (labelSet.has(trimmed)) return { selected: [trimmed], comment: undefined, otherTokens: [] };
|
|
39
|
+
|
|
40
|
+
// MF-6:整串逗号切分的每个 token 都是 label → 纯多选、无 comment。
|
|
41
|
+
// 必须置于分隔符扫描之前——"Postgres — prod, MySQL"(两个 label 都含分隔符形态)
|
|
42
|
+
// 若进扫描,首个分隔符切在 "Postgres — prod" 中间,剩余 "prod, MySQL" 被误判为
|
|
43
|
+
// comment,两个选中项全部静默丢失。仅当全部 token 均为 label 时触发:
|
|
44
|
+
// "Postgres — prod, MySQL — note" 中 "MySQL — note" 非 label → 不进此分支,
|
|
45
|
+
// 由分隔符扫描解析为选中 "Postgres — prod" + "MySQL" + comment "note"
|
|
46
|
+
// (含 comment 的多选形态不破坏)。
|
|
47
|
+
const allTokens = trimmed.split(/[,,]/).map((t) => t.trim()).filter(Boolean);
|
|
48
|
+
if (allTokens.length > 0 && allTokens.every((t) => labelSet.has(t))) {
|
|
49
|
+
return { selected: allTokens, comment: undefined, otherTokens: [] };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// body/comment 切分点:两级扫描 + 不切分回退,语义见各段注释。
|
|
53
|
+
// 第一级(MF-7 语义):取「body 整串可解析为选中项」的最靠后分隔符 = 最长选中 body,
|
|
54
|
+
// 其后的全部剩余文本为 comment。"可解析为选中项" = 整串精确命中单个 label(含逗号
|
|
55
|
+
// label 如 "A, B"),或全部逗号 token 均为 label(多选 body,如 "Postgres — prod, MySQL")。
|
|
56
|
+
// 第二级(MF-2 回归修复):第一级无命中时回退「body 含 ≥1 个 label token」的首个分隔符——
|
|
57
|
+
// "Postgres — prod, custom text — note"(label 含分隔符 + Other 自由文本 + comment)
|
|
58
|
+
// 无任何 all-label body,单谓词扫描落空 → 回退首分隔符把 label 拦腰截断,选中项静默丢失;
|
|
59
|
+
// ≥1 谓词在真正分隔选中/Other 与 comment 的第二个分隔符命中。
|
|
60
|
+
// 不切分回退(MF-3):两级都无命中但整串含 label token("Postgres — prod, custom text"
|
|
61
|
+
// 无 comment 形态)→ 整串作 body(选中 + Other),不切分——首分隔符切分把 label 拦腰
|
|
62
|
+
// 截断产生空选中。整串无任何 label token(S-17:"custom — text" Other 自由文本自身含
|
|
63
|
+
// 分隔符)→ 保持首个分隔符切分(body "custom" + comment "text"),不做解析层区分,
|
|
64
|
+
// 由调用方(TUI encode)按 label 命中情况归属。
|
|
65
|
+
// MF-7 固有歧义取舍(第一级):重叠前缀 label 如 ["Postgres", "Postgres — prod"] 时,
|
|
66
|
+
// "Postgres — prod — x" 取最靠后的整串命中即最长匹配(选中长 label + comment "x");
|
|
67
|
+
// 若用户意图确实是「选短 label + comment 含长 label 前缀」会被误判——文本本身无法区分,
|
|
68
|
+
// 精确 label body 优先是更优启发而非完备解;长 label 不在候选时("Postgres — prod" 非
|
|
69
|
+
// label)该意图解析正确。
|
|
70
|
+
// 注意:分隔符扫描用原始 answer(不 trim)——comment-only 答案以 " — comment" 开头,
|
|
71
|
+
// trim 会吃掉前导空格导致 indexOf 失配。
|
|
72
|
+
let body = trimmed;
|
|
33
73
|
let comment: string | undefined;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
74
|
+
let splitIdx = answer.indexOf(ANSWER_COMMENT_SEPARATOR);
|
|
75
|
+
// 第一级:取「body 整串可解析为选中项」的最靠后分隔符
|
|
76
|
+
let bestSplit = -1;
|
|
77
|
+
let scan = splitIdx;
|
|
78
|
+
while (scan >= 0) {
|
|
79
|
+
const candidateBody = answer.slice(0, scan).trim();
|
|
80
|
+
if (labelSet.has(candidateBody)) {
|
|
81
|
+
bestSplit = scan;
|
|
82
|
+
} else {
|
|
83
|
+
const candidateTokens = candidateBody.split(/[,,]/).map((t) => t.trim()).filter(Boolean);
|
|
84
|
+
if (candidateTokens.length > 0 && candidateTokens.every((t) => labelSet.has(t))) {
|
|
85
|
+
bestSplit = scan;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
scan = answer.indexOf(ANSWER_COMMENT_SEPARATOR, scan + ANSWER_COMMENT_SEPARATOR.length);
|
|
89
|
+
}
|
|
90
|
+
if (bestSplit < 0) {
|
|
91
|
+
// 第二级:取「body 含 ≥1 个 label token」的首个分隔符(R2 的 some() 谓词回归,
|
|
92
|
+
// 仅在第一级无命中时运行——"A, B — note — x" 形态第一级已命中,不会过切)
|
|
93
|
+
scan = splitIdx;
|
|
94
|
+
while (scan >= 0) {
|
|
95
|
+
const candidateTokens = answer.slice(0, scan).trim().split(/[,,]/).map((t) => t.trim()).filter(Boolean);
|
|
96
|
+
if (candidateTokens.some((t) => labelSet.has(t))) {
|
|
97
|
+
bestSplit = scan;
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
scan = answer.indexOf(ANSWER_COMMENT_SEPARATOR, scan + ANSWER_COMMENT_SEPARATOR.length);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// MF-3 不切分回退:整串含 label token 时以整串为 body(选中 + Other),不切分。
|
|
104
|
+
// splitIdx >= 0 前置(无分隔符时本就整串为 body,无需走此分支)。
|
|
105
|
+
const noSplit = bestSplit < 0 && splitIdx >= 0 && allTokens.some((t) => labelSet.has(t));
|
|
106
|
+
if (!noSplit && splitIdx >= 0) {
|
|
107
|
+
if (bestSplit >= 0) {
|
|
108
|
+
splitIdx = bestSplit;
|
|
109
|
+
}
|
|
110
|
+
body = answer.slice(0, splitIdx).trim();
|
|
111
|
+
comment = answer.slice(splitIdx + ANSWER_COMMENT_SEPARATOR.length).trim() || undefined;
|
|
38
112
|
}
|
|
39
113
|
|
|
40
|
-
// body 形如 "label1, label2" → 精确匹配候选 label
|
|
41
|
-
|
|
114
|
+
// body 形如 "label1, label2" → 精确匹配候选 label;剩余 tokens 不匹配任何 label
|
|
115
|
+
// → Other 自由文本(otherTokens 返回给调用方,如 channel-handler 提取 __other)
|
|
42
116
|
const tokens = body.split(/[,,]/).map((t) => t.trim()).filter(Boolean);
|
|
43
117
|
const selected: string[] = [];
|
|
44
|
-
|
|
118
|
+
const otherTokens: string[] = [];
|
|
45
119
|
for (const token of tokens) {
|
|
46
120
|
if (labelSet.has(token)) {
|
|
47
121
|
selected.push(token);
|
|
122
|
+
} else {
|
|
123
|
+
otherTokens.push(token);
|
|
48
124
|
}
|
|
49
125
|
}
|
|
50
|
-
return { selected, comment };
|
|
126
|
+
return { selected, comment, otherTokens };
|
|
51
127
|
}
|
package/src/channel-handler.ts
CHANGED
|
@@ -23,7 +23,8 @@ import {
|
|
|
23
23
|
} from "@xyz-agent/extension-protocol";
|
|
24
24
|
|
|
25
25
|
import { AskUserComponent } from "./component";
|
|
26
|
-
import {
|
|
26
|
+
import { parseAnswerParts } from "./answer-format";
|
|
27
|
+
import { type Option, type Question, type Result, type ThemeLike } from "./types";
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* channel handler 签名——与 subagent-workflow 的 UiChannelRegistry.ChannelHandler 一致
|
|
@@ -97,29 +98,21 @@ function encodeTuiResultToProto(
|
|
|
97
98
|
if (o.value !== undefined) knownLabels.add(o.value);
|
|
98
99
|
}
|
|
99
100
|
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
// TUI/RPC 两条路径产出分裂。value 缺失时 fallback label(保持 ask-user 自身
|
|
116
|
-
// toProtoQuestions 的 value=label 语义,以及历史行为)。
|
|
117
|
-
const opt = pq.options?.find(o => o.label === t || o.value === t);
|
|
118
|
-
selected.push(opt?.value ?? t);
|
|
119
|
-
} else {
|
|
120
|
-
otherTokens.push(t);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
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
|
+
});
|
|
123
116
|
const otherText = otherTokens.join(", ") || undefined;
|
|
124
117
|
|
|
125
118
|
// 主 key:单选 = 首个选中 value;多选 = JSON 数组(即便为空也写入,与 RPC 契约一致)
|
package/src/index.ts
CHANGED
|
@@ -135,7 +135,7 @@ function toProtoQuestions(questions: Question[]): AskUserQuestion[] {
|
|
|
135
135
|
/**
|
|
136
136
|
* 把协议包 AskUserAnswers 转换为 ask-user 内部 Result.answers。
|
|
137
137
|
*
|
|
138
|
-
* 协议格式:key=header/question, 单选=string, 多选=JSON数组, Other=__other, comment=__comment(
|
|
138
|
+
* 协议格式:key=header/question, 单选=string, 多选=JSON数组, Other=__other, comment=__comment(5.0.0-dev.1 restore)
|
|
139
139
|
* ask-user 格式:key=question 全文, value=逗号分隔 label + Other, comment 内联(` — `)
|
|
140
140
|
*
|
|
141
141
|
* 拼装逻辑复用 formatAnswer(与 TUI 版 getAnswerText 共享同一格式函数),
|