@zhushanwen/pi-ask-user 6.0.0 → 7.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/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
package/ARCHITECTURE.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Internals reference for maintainers. For the usage contract (what the tool does, when an agent should call it), see [README.md](./README.md). This document covers how the code is structured, the state machine, the defensive execute flow, and where each design invariant is enforced — so a change does not silently break an invariant.
|
|
4
4
|
|
|
5
|
-
Source:
|
|
5
|
+
Source: 10 files in `src/`, ~1970 lines total.
|
|
6
6
|
|
|
7
7
|
## File dependency graph
|
|
8
8
|
|
|
@@ -10,33 +10,59 @@ Source: 6 files in `src/`, ~1320 lines total.
|
|
|
10
10
|
┌─────────────┐
|
|
11
11
|
│ types.ts │ ← shared leaf; imports only typebox
|
|
12
12
|
│ Schema + │ holds QuestionState / ThemeLike /
|
|
13
|
-
│ shared │
|
|
14
|
-
│ state types │ component.ts) to break
|
|
15
|
-
└──────▲──────┘
|
|
13
|
+
│ shared │ AnswerValue / createQuestionState
|
|
14
|
+
│ state types │ here (NOT in component.ts) to break
|
|
15
|
+
└──────▲──────┘ the cycle
|
|
16
16
|
│ imported by all
|
|
17
|
-
|
|
18
|
-
│ │
|
|
19
|
-
┌──────┴──────┐ ┌──────┴──────┐
|
|
20
|
-
│ validate.ts │ │question-view│ │
|
|
21
|
-
│ pure check │ │ pure render │ │
|
|
22
|
-
└──────▲──────┘ └──────▲──────┘
|
|
23
|
-
│ │
|
|
24
|
-
│ ┌──────┴──────────────────┘
|
|
25
|
-
│ │
|
|
26
|
-
┌──────┴───────────┴──┐
|
|
27
|
-
│ component.ts │
|
|
28
|
-
│
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
17
|
+
┌──────────────────┼─────────────────────┬─────────────────┐
|
|
18
|
+
│ │ │ │
|
|
19
|
+
┌──────┴──────┐ ┌──────┴──────┐ ┌─────────┴──────┐ ┌───────┴────────┐
|
|
20
|
+
│ validate.ts │ │question-view│ │ submit-view │ │ answer-codec │
|
|
21
|
+
│ pure check │ │ pure render │ │ pure render │ │ AnswerValue → │
|
|
22
|
+
└──────▲──────┘ └──────▲──────┘ │ + buildResult │ │ proto entries │
|
|
23
|
+
│ │ └──────▲─────────┘ └───────▲────────┘
|
|
24
|
+
│ ┌──────┴──────────────────┘ │
|
|
25
|
+
│ │ │
|
|
26
|
+
┌──────┴───────────┴──┐ ┌────────────────────┐ │
|
|
27
|
+
│ component.ts │◀───│ editor-ops.ts │ │
|
|
28
|
+
│ state machine + │ │ editor pure ops │ │
|
|
29
|
+
│ input routing + │ │ (insert/delete/ │ │
|
|
30
|
+
│ race guards │ │ cursor/paste) │ │
|
|
31
|
+
└──────────▲──────────┘ └────────────────────┘ │
|
|
32
|
+
│ │
|
|
33
|
+
│ ┌────────────────────┐ │
|
|
34
|
+
├────────────────▶│ channel-handler.ts │◀──────────────┘
|
|
35
|
+
│ (AskUserComp.) │ subagent passthru │
|
|
36
|
+
│ └─────────▲──────────┘
|
|
37
|
+
│ │
|
|
38
|
+
│ ┌─────────┴──────────┐
|
|
39
|
+
│ │channel-registry- │
|
|
40
|
+
│ │register.ts │
|
|
41
|
+
│ │globalThis Symbol │
|
|
42
|
+
│ │handshake │
|
|
43
|
+
│ └────────────────────┘
|
|
44
|
+
┌──────┴───────────┐
|
|
45
|
+
│ index.ts │ ← Tool factory + execute (6-step flow) + renderCall/renderResult
|
|
46
|
+
└──────────────────┘ imports component + validate + types + submit-view +
|
|
47
|
+
channel-handler + channel-registry-register
|
|
34
48
|
```
|
|
35
49
|
|
|
36
50
|
**No cycles.** All imports flow one direction; `types.ts` is the single leaf depended on by everyone.
|
|
37
51
|
|
|
38
52
|
**Why `QuestionState` / `ThemeLike` live in `types.ts`, not `component.ts`** (see the comment in `types.ts`): `question-view.ts` and `submit-view.ts` are pure render functions that read/write `QuestionState` and need the `ThemeLike` interface. If those types lived in `component.ts`, the render views would import `component.ts`, and `component.ts` imports the render views — a cycle. Sinking the shared types to the dependency-free leaf keeps every arrow monotone. **Do not move these types back** without reintroducing the cycle.
|
|
39
53
|
|
|
54
|
+
## Answer model & protocol boundary (D1)
|
|
55
|
+
|
|
56
|
+
`AnswerValue` (`types.ts`) is the single structured answer model — `{ selected: string[]; other: string | null }`. The old internal/proto double model is gone: proto `AskUserOption.value` equaled `label` (both were the same string), so the proto layer consumes the same single model (`Result.answers: Record<string, AnswerValue>`, key = question text).
|
|
57
|
+
|
|
58
|
+
Serialization happens **once, at the protocol boundary**: `encodeAnswer(value, { key, multiSelect })` in `answer-codec.ts` converts an `AnswerValue` into proto answers entries, byte-aligned with `@xyz-agent/extension-protocol` helpers' decode contract (`getAskUserAnswer` / `getAskUserOther`):
|
|
59
|
+
|
|
60
|
+
- 单选:`answers[key] = selected[0]`
|
|
61
|
+
- 多选:`answers[key] = JSON.stringify(selected)`
|
|
62
|
+
- Other:`answers[`${key}__other`] = other`(仅在 other 非空时写入)
|
|
63
|
+
|
|
64
|
+
Encoding is **one-way** — the old `parseAnswerParts` text reverse-parsing was deleted with the double model; there is no decode counterpart inside the extension. `channel-handler.ts` reads `AnswerValue` directly and calls `encodeAnswer` when forwarding to subagents.
|
|
65
|
+
|
|
40
66
|
## `execute` defensive flow (6 steps)
|
|
41
67
|
|
|
42
68
|
`execute` in `src/index.ts` runs six ordered checks. Order is not arbitrary — each early step is cheaper than the next and some have side effects that must precede the rest.
|
|
@@ -54,33 +80,27 @@ Source: 6 files in `src/`, ~1320 lines total.
|
|
|
54
80
|
|
|
55
81
|
## `QuestionState` machine
|
|
56
82
|
|
|
57
|
-
Each question has a `QuestionState` (`types.ts`). Its `mode` field is a
|
|
83
|
+
Each question has a `QuestionState` (`types.ts`). Its `mode` field is a two-state machine:
|
|
58
84
|
|
|
59
85
|
```
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
▼
|
|
63
|
-
┌─────────────┐ Enter (
|
|
64
|
-
│ options │
|
|
65
|
-
│ (default) │
|
|
66
|
-
|
|
67
|
-
│ │
|
|
68
|
-
Enter │ │ Esc (
|
|
69
|
-
(
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
│ │
|
|
76
|
-
▼ │ Esc (discard)
|
|
77
|
-
Enter (empty → clear freeTextValue)
|
|
78
|
-
│
|
|
79
|
-
▼
|
|
80
|
-
options
|
|
86
|
+
Enter (on Other row)
|
|
87
|
+
┌────────────────────────────────────────┐
|
|
88
|
+
▼ │
|
|
89
|
+
┌─────────────┐ Enter (text → save + ┌──────────────┐
|
|
90
|
+
│ options │ afterConfirm + advance) ────▶ │ freeform │
|
|
91
|
+
│ (default) │ ◀───────────────────────────────│ (editor) │
|
|
92
|
+
└─────┬───▲───┘ Esc (save draft → back) / └──────┬───▲───┘
|
|
93
|
+
│ │ Enter empty (clear value → back) │ │
|
|
94
|
+
Enter │ │ Esc (back to previous tab; │ │
|
|
95
|
+
(normal on first tab → confirm-cancel overlay) │ │
|
|
96
|
+
option) │ │
|
|
97
|
+
▼ │
|
|
98
|
+
afterConfirm → advance (next tab / Submit tab) │
|
|
99
|
+
│
|
|
100
|
+
└─────────────────────────────────────────────────┘
|
|
81
101
|
```
|
|
82
102
|
|
|
83
|
-
Transitions live in `component.ts`: `options → freeform` (Enter on Other), `freeform → options` (Enter saves
|
|
103
|
+
Transitions live in `component.ts`: `options → freeform` (Enter on the Other row — the last option — prefills `draftText` from `freeTextValue ?? freeDraft`), `freeform → options` (Enter saves trimmed text into `freeTextValue` and calls `afterConfirm`; Enter on empty text clears `freeTextValue`; Esc saves the draft into `freeDraft` and returns to the list, restoring the saved options cursor). There is **no comment mode** — the freeform editor is the only text input; `QuestionMode = "options" | "freeform"`.
|
|
84
104
|
|
|
85
105
|
### `confirmed` invariant
|
|
86
106
|
|
|
@@ -95,14 +115,14 @@ Four assignment sites maintain it (`component.ts`):
|
|
|
95
115
|
|------|------|----------------------|
|
|
96
116
|
| `afterConfirm()` | `true` | Safe: caller has already set `selectedIndex` / `selectedIndices` / `freeTextValue`. |
|
|
97
117
|
| `autoConfirmIfAnswered()` | `true` | Safe: guarded by `if (hasAnswer)` — never sets `true` without an answer. |
|
|
98
|
-
| `toggleIndex()` when multi-select empties | `false` | Necessary: un-checking the last option must drop `confirmed` to preserve the contrapositive. |
|
|
99
|
-
| `
|
|
118
|
+
| `toggleIndex()` when multi-select empties | `false` | Necessary: un-checking the last option (with no free text) must drop `confirmed` to preserve the contrapositive. |
|
|
119
|
+
| `handleEditorEnter` freeform empty-Enter | `false` | Necessary: clearing `freeTextValue` with no other answer must drop `confirmed`. |
|
|
100
120
|
|
|
101
121
|
If you add a new path that changes the answer set, audit both directions of this invariant.
|
|
102
122
|
|
|
103
123
|
### `autoConfirmIfAnswered` trigger
|
|
104
124
|
|
|
105
|
-
Called only from `gotoTab()` — when the user navigates between tabs via Tab/Shift+Tab without pressing Enter. It promotes an implicitly-answered tab (toggled but not confirmed) to `confirmed`.
|
|
125
|
+
Called only from `gotoTab()` — when the user navigates between tabs via Tab/Shift+Tab without pressing Enter. It promotes an implicitly-answered tab (toggled but not confirmed) to `confirmed`. A Tab navigation intent should not force a confirm prompt — only the Enter path confirms via `afterConfirm`.
|
|
106
126
|
|
|
107
127
|
## Race guards
|
|
108
128
|
|
|
@@ -151,13 +171,12 @@ Design spec: `.xyz-harness/2026-06-15-ask-user/spec.md` (FR = functional require
|
|
|
151
171
|
| FR-2 (param schema/validation) | `types.ts` schema + `validate.ts` |
|
|
152
172
|
| FR-3 (inline render, no overlay) | `execute` → `ctx.ui.custom` without `options` |
|
|
153
173
|
| FR-4 (question view) | `question-view.ts` `renderQuestionView` |
|
|
154
|
-
| FR-6 (input handling) | `component.ts` `handleInput` / `handleEditorInput` |
|
|
174
|
+
| FR-6 (input handling) | `component.ts` `handleInput` / `handleOptionsInput` / `handleEditorInput` |
|
|
155
175
|
| FR-8 (headless disable) | `execute` step 2 |
|
|
156
176
|
| FR-9 (custom render) | `renderCall` / `renderResult` |
|
|
157
177
|
| FR-10 (signal abort) | `execute` step 3 + step 4 abort listener |
|
|
158
178
|
| FR-12 (re-entry guard) | `_resolved` field + `cancel()` shared by abort listener |
|
|
159
179
|
| FR-13 (error catch-all) | `execute` step 4 try/catch |
|
|
160
|
-
| AC-17 (Esc in comment skips, keeps value) | `handleEditorInput` comment-mode Esc branch |
|
|
161
180
|
|
|
162
181
|
When you change one of these behaviors, update both the code comment (which cites the FR/AC) and this table.
|
|
163
182
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-ask-user",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "7.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,19 +26,20 @@
|
|
|
26
26
|
"ARCHITECTURE.md"
|
|
27
27
|
],
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@xyz-agent/extension-protocol": "^0.
|
|
29
|
+
"@xyz-agent/extension-protocol": "^0.4.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@earendil-works/pi-tui": "*",
|
|
33
|
-
"typebox": "*",
|
|
34
33
|
"@types/node": "^24.0.0",
|
|
34
|
+
"fast-check": "^4.9.0",
|
|
35
|
+
"typebox": "*",
|
|
35
36
|
"vitest": "^4.1.8"
|
|
36
37
|
},
|
|
37
38
|
"peerDependencies": {
|
|
38
39
|
"@earendil-works/pi-coding-agent": "*",
|
|
39
40
|
"@earendil-works/pi-tui": "*",
|
|
40
41
|
"typebox": "*",
|
|
41
|
-
"@zhushanwen/pi-subagent-workflow": "
|
|
42
|
+
"@zhushanwen/pi-subagent-workflow": "7.0.1"
|
|
42
43
|
},
|
|
43
44
|
"peerDependenciesMeta": {
|
|
44
45
|
"@earendil-works/pi-tui": {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// src/__tests__/answer-codec.test.ts
|
|
2
|
+
// encodeAnswer 单向序列化 round-trip 测试(TC-09)。
|
|
3
|
+
// 反向验证用 @xyz-agent/extension-protocol 的解码 helper(getAskUserAnswer / getAskUserOther)
|
|
4
|
+
// ——该 helper 是 proto answers 格式的唯一解码 SSOT,encode 输出必须与其字节级对齐。
|
|
5
|
+
// m1 增量(TC-01):property-based describe 作为 5 个确定性用例的随机化超集补充。
|
|
6
|
+
import { getAskUserAnswer, getAskUserOther } from "@xyz-agent/extension-protocol";
|
|
7
|
+
import fc from "fast-check";
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
|
|
10
|
+
import { encodeAnswer } from "../answer-codec";
|
|
11
|
+
|
|
12
|
+
describe("encodeAnswer", () => {
|
|
13
|
+
it("single-select: 主 key 写选中 label,other=null 不写 __other;getAskUserAnswer 解码得 string", () => {
|
|
14
|
+
const answers = encodeAnswer({ selected: ["A"], other: null }, { key: "db", multiSelect: false });
|
|
15
|
+
expect(answers).toEqual({ db: "A" });
|
|
16
|
+
expect(getAskUserAnswer(answers, { question: "db", options: [{ label: "A" }] })).toBe("A");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("multi-select: 主 key 写 JSON.stringify(selected);getAskUserAnswer 解码得 string[]", () => {
|
|
20
|
+
const answers = encodeAnswer({ selected: ["A", "B"], other: null }, { key: "lang", multiSelect: true });
|
|
21
|
+
expect(answers).toEqual({ lang: '["A","B"]' });
|
|
22
|
+
expect(getAskUserAnswer(answers, { question: "lang", multiSelect: true })).toEqual(["A", "B"]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("selected 空 + other 有值: 只写 ${key}__other 不写主 key;getAskUserOther 解码得文本", () => {
|
|
26
|
+
const answers = encodeAnswer({ selected: [], other: "foo" }, { key: "db", multiSelect: false });
|
|
27
|
+
expect(answers).toEqual({ db__other: "foo" });
|
|
28
|
+
expect(getAskUserOther(answers, { question: "db" })).toBe("foo");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("selected 空 + other null/空串: 返回 {}(未答,调用方不写入)", () => {
|
|
32
|
+
expect(encodeAnswer({ selected: [], other: null }, { key: "db", multiSelect: false })).toEqual({});
|
|
33
|
+
expect(encodeAnswer({ selected: [], other: "" }, { key: "db", multiSelect: true })).toEqual({});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("selected 与 other 同时有值: 主 key + __other 都写;两个 helper 各自解码", () => {
|
|
37
|
+
const answers = encodeAnswer(
|
|
38
|
+
{ selected: ["A"], other: "custom" },
|
|
39
|
+
{ key: "db", multiSelect: false },
|
|
40
|
+
);
|
|
41
|
+
expect(answers).toEqual({ db: "A", db__other: "custom" });
|
|
42
|
+
expect(getAskUserAnswer(answers, { question: "db", options: [{ label: "A" }] })).toBe("A");
|
|
43
|
+
expect(getAskUserOther(answers, { question: "db" })).toBe("custom");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ── m1 property-based round-trip(TC-01)─────────────────
|
|
48
|
+
// fast-check 随机生成合法 AnswerValue 组合 → encodeAnswer → 用协议解码 helper 反向验证
|
|
49
|
+
// 信息保持。六分支断言对应 encodeAnswer 序列化契约(C1)与解码契约(C2):
|
|
50
|
+
// (1) selected 非空 + multiSelect → 解码 deepEqual selected
|
|
51
|
+
// (2) selected 非空 + 单选 → 解码 === selected[0]
|
|
52
|
+
// (3) selected 空 → 主 key 不存在
|
|
53
|
+
// (4) other 非 null 非空串 → getAskUserOther 精确还原;否则 undefined
|
|
54
|
+
// (5) selected 空 + other null/空串 → 返回 {}(未答,调用方不写入)
|
|
55
|
+
// (6) 输出对象无多余键(主 key + __other 至多 2 个)
|
|
56
|
+
// key 生成器过滤 Object.prototype 属性名(toString/constructor/__proto__ 等):
|
|
57
|
+
// 这些名字读 answers[key] 会落到原型链(拿到 Function/原型对象而非 undefined),
|
|
58
|
+
// 是 plain-object answers 的既有协议局限(编码侧赋值本身安全),m1 只测不改 src/。
|
|
59
|
+
describe("encodeAnswer — property-based round-trip", () => {
|
|
60
|
+
it("任意 AnswerValue 组合序列化后经解码 helper 不丢信息", () => {
|
|
61
|
+
fc.assert(
|
|
62
|
+
fc.property(
|
|
63
|
+
fc.string().filter((k) => Object.prototype[k] === undefined),
|
|
64
|
+
fc.boolean(),
|
|
65
|
+
fc.array(fc.string(), { maxLength: 5 }),
|
|
66
|
+
fc.option(fc.string(), { nil: null }),
|
|
67
|
+
(key, multiSelect, selected, other) => {
|
|
68
|
+
const answers = encodeAnswer({ selected, other }, { key, multiSelect });
|
|
69
|
+
|
|
70
|
+
// (6) 输出对象无多余键
|
|
71
|
+
expect(Object.keys(answers).length).toBeLessThanOrEqual(2);
|
|
72
|
+
|
|
73
|
+
if (selected.length > 0) {
|
|
74
|
+
// (1)(2) 主 key 写入:多选 JSON.stringify(selected) / 单选 selected[0]
|
|
75
|
+
const decoded = getAskUserAnswer(answers, { question: key, multiSelect });
|
|
76
|
+
if (multiSelect) {
|
|
77
|
+
expect(decoded).toEqual(selected);
|
|
78
|
+
} else {
|
|
79
|
+
expect(decoded).toBe(selected[0]);
|
|
80
|
+
}
|
|
81
|
+
} else {
|
|
82
|
+
// (3) selected 空 → 主 key 不产生
|
|
83
|
+
expect(answers[key]).toBeUndefined();
|
|
84
|
+
expect(getAskUserAnswer(answers, { question: key, multiSelect })).toBeUndefined();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// (4) other:非 null 非空串才写 __other,解码精确还原
|
|
88
|
+
if (other !== null && other !== "") {
|
|
89
|
+
expect(getAskUserOther(answers, { question: key })).toBe(other);
|
|
90
|
+
} else {
|
|
91
|
+
expect(getAskUserOther(answers, { question: key })).toBeUndefined();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// (5) 全空 → 返回 {}(未答,调用方不写入)
|
|
95
|
+
if (selected.length === 0 && (other === null || other === "")) {
|
|
96
|
+
expect(answers).toEqual({});
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
),
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// - RPC 路径(ctx.mode === 'rpc'):转发器——handler 内部调 askUserInteract(select 通道),
|
|
7
7
|
// 把 proto answers JSON.stringify 成 {value} 返回,子进程 JSON.parse(value) 正确 decode。
|
|
8
8
|
// - TUI 路径(ctx.mode === 'tui'):handler 走 ctx.ui.custom(mock 成返回预设 Result),
|
|
9
|
-
//
|
|
9
|
+
// 验证内部结构化 Result.answers(AnswerValue)→ proto AskUserAnswers 重新编码
|
|
10
|
+
// (single/multi/Other 三种答案形态,经 encodeAnswer 序列化)。
|
|
10
11
|
// - 取消(askUserInteract/custom 返回 null 或 cancelled)→ {cancelled: true}
|
|
11
12
|
// - 输入校验(channelPayload 缺失/无 questions)→ {cancelled: true}
|
|
12
13
|
import type { AskUserQuestion } from "@xyz-agent/extension-protocol";
|
|
@@ -61,24 +62,14 @@ function makeCtx(opts: MockCtxOpts): {
|
|
|
61
62
|
// ── 样例 proto questions(handler 收到的格式) ──────────
|
|
62
63
|
const singleProto: AskUserQuestion = {
|
|
63
64
|
question: "Which DB?",
|
|
64
|
-
options: [{ label: "Postgres"
|
|
65
|
+
options: [{ label: "Postgres" }, { label: "SQLite" }],
|
|
65
66
|
};
|
|
66
67
|
|
|
67
68
|
const multiProto: AskUserQuestion = {
|
|
68
69
|
question: "Which tools?",
|
|
69
70
|
header: "Tools",
|
|
70
71
|
multiSelect: true,
|
|
71
|
-
options: [
|
|
72
|
-
{ label: "A", value: "A" },
|
|
73
|
-
{ label: "B", value: "B" },
|
|
74
|
-
{ label: "C", value: "C" },
|
|
75
|
-
],
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
const commentProto: AskUserQuestion = {
|
|
79
|
-
question: "Which DB?",
|
|
80
|
-
allowComment: true,
|
|
81
|
-
options: [{ label: "Postgres", value: "Postgres" }, { label: "SQLite", value: "SQLite" }],
|
|
72
|
+
options: [{ label: "A" }, { label: "B" }, { label: "C" }],
|
|
82
73
|
};
|
|
83
74
|
|
|
84
75
|
// ── Tests ───────────────────────────────────────────────
|
|
@@ -103,16 +94,15 @@ describe("createAskUserChannelHandler", () => {
|
|
|
103
94
|
expect(resp).toEqual({ value: JSON.stringify({ Tools: JSON.stringify(["A", "C"]) }) });
|
|
104
95
|
});
|
|
105
96
|
|
|
106
|
-
it("RPC: Other
|
|
97
|
+
it("RPC: Other proto answers → 透传", async () => {
|
|
107
98
|
const protoAnswers = {
|
|
108
99
|
"Which DB?": "Postgres",
|
|
109
100
|
"Which DB?__other": "Custom DB",
|
|
110
|
-
"Which DB?__comment": "prod constraint",
|
|
111
101
|
};
|
|
112
102
|
const handler = createAskUserChannelHandler(
|
|
113
103
|
makeCtx({ mode: "rpc", selectResult: JSON.stringify(protoAnswers) }) as never,
|
|
114
104
|
);
|
|
115
|
-
const resp = await handler({ channelPayload: { questions: [
|
|
105
|
+
const resp = await handler({ channelPayload: { questions: [singleProto] } });
|
|
116
106
|
expect(resp).toEqual({ value: JSON.stringify(protoAnswers) });
|
|
117
107
|
});
|
|
118
108
|
|
|
@@ -124,11 +114,11 @@ describe("createAskUserChannelHandler", () => {
|
|
|
124
114
|
expect(resp).toEqual({ cancelled: true });
|
|
125
115
|
});
|
|
126
116
|
|
|
127
|
-
it("TUI: internal Result single-select → 重新编码为 proto answers", async () => {
|
|
128
|
-
// 内部 Result.answers:key = question 全文,value =
|
|
117
|
+
it("TUI: internal Result single-select AnswerValue → 重新编码为 proto answers", async () => {
|
|
118
|
+
// 内部 Result.answers:key = question 全文,value = 结构化 AnswerValue
|
|
129
119
|
const internalResult: Result = {
|
|
130
120
|
questions: [],
|
|
131
|
-
answers: { "Which DB?": "Postgres" },
|
|
121
|
+
answers: { "Which DB?": { selected: ["Postgres"], other: null } },
|
|
132
122
|
cancelled: false,
|
|
133
123
|
};
|
|
134
124
|
const handler = createAskUserChannelHandler(
|
|
@@ -142,7 +132,7 @@ describe("createAskUserChannelHandler", () => {
|
|
|
142
132
|
it("TUI: multi-select internal Result → proto JSON array value", async () => {
|
|
143
133
|
const internalResult: Result = {
|
|
144
134
|
questions: [],
|
|
145
|
-
answers: { "Which tools?": "A, C" },
|
|
135
|
+
answers: { "Which tools?": { selected: ["A", "C"], other: null } },
|
|
146
136
|
cancelled: false,
|
|
147
137
|
};
|
|
148
138
|
const handler = createAskUserChannelHandler(
|
|
@@ -153,59 +143,11 @@ describe("createAskUserChannelHandler", () => {
|
|
|
153
143
|
expect(resp).toEqual({ value: JSON.stringify({ Tools: JSON.stringify(["A", "C"]) }) });
|
|
154
144
|
});
|
|
155
145
|
|
|
156
|
-
it("TUI: value≠label single-select → encodeTuiResultToProto 回查 proto option value(PR #85 #8 回归守护)", async () => {
|
|
157
|
-
// value≠label 是 #8 修复的核心场景:TUI 渲染用 label,但 proto 期望回传 option.value。
|
|
158
|
-
// 若 #8 修复回归(直接 push label),此测试会失败:返回 "显示名A" 而非 "val_a"。
|
|
159
|
-
const valueNeqLabelProto: AskUserQuestion = {
|
|
160
|
-
question: "选哪个?",
|
|
161
|
-
options: [
|
|
162
|
-
{ label: "显示名A", value: "val_a" },
|
|
163
|
-
{ label: "显示名B", value: "val_b" },
|
|
164
|
-
],
|
|
165
|
-
};
|
|
166
|
-
// 内部 Result.answers:用户在 TUI 选了"显示名A"(label)
|
|
167
|
-
const internalResult: Result = {
|
|
168
|
-
questions: [],
|
|
169
|
-
answers: { "选哪个?": "显示名A" },
|
|
170
|
-
cancelled: false,
|
|
171
|
-
};
|
|
172
|
-
const handler = createAskUserChannelHandler(
|
|
173
|
-
makeCtx({ mode: "tui", customResult: internalResult }) as never,
|
|
174
|
-
);
|
|
175
|
-
const resp = await handler({ channelPayload: { questions: [valueNeqLabelProto] } });
|
|
176
|
-
// 期望:proto answers 回查 value,返回 "val_a"(不是 label "显示名A")
|
|
177
|
-
expect(resp).toEqual({ value: JSON.stringify({ "选哪个?": "val_a" }) });
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
it("TUI: value≠label multi-select → proto JSON 数组元素回查 value(PR #85 #8 回归守护)", async () => {
|
|
181
|
-
// 多选路径同样依赖 #8 修复:selected.push(opt?.value ?? t),多选会 JSON.stringify 数组。
|
|
182
|
-
const valueNeqLabelMultiProto: AskUserQuestion = {
|
|
183
|
-
question: "选哪些?",
|
|
184
|
-
header: "Opts",
|
|
185
|
-
multiSelect: true,
|
|
186
|
-
options: [
|
|
187
|
-
{ label: "显示名A", value: "val_a" },
|
|
188
|
-
{ label: "显示名B", value: "val_b" },
|
|
189
|
-
],
|
|
190
|
-
};
|
|
191
|
-
const internalResult: Result = {
|
|
192
|
-
questions: [],
|
|
193
|
-
answers: { "选哪些?": "显示名A, 显示名B" },
|
|
194
|
-
cancelled: false,
|
|
195
|
-
};
|
|
196
|
-
const handler = createAskUserChannelHandler(
|
|
197
|
-
makeCtx({ mode: "tui", customResult: internalResult }) as never,
|
|
198
|
-
);
|
|
199
|
-
const resp = await handler({ channelPayload: { questions: [valueNeqLabelMultiProto] } });
|
|
200
|
-
// 期望:多选 JSON 数组,每个元素回查 value(["val_a","val_b"],不是 label)
|
|
201
|
-
expect(resp).toEqual({ value: JSON.stringify({ Opts: JSON.stringify(["val_a", "val_b"]) }) });
|
|
202
|
-
});
|
|
203
|
-
|
|
204
146
|
it("TUI: Other free text → ${key}__other", async () => {
|
|
205
|
-
// 内部 Result:selected label + Other
|
|
147
|
+
// 内部 Result:selected label + Other 文本分离存储(AnswerValue.other)
|
|
206
148
|
const internalResult: Result = {
|
|
207
149
|
questions: [],
|
|
208
|
-
answers: { "Which DB?": "Postgres, Custom DB" },
|
|
150
|
+
answers: { "Which DB?": { selected: ["Postgres"], other: "Custom DB" } },
|
|
209
151
|
cancelled: false,
|
|
210
152
|
};
|
|
211
153
|
const handler = createAskUserChannelHandler(
|
|
@@ -217,43 +159,18 @@ describe("createAskUserChannelHandler", () => {
|
|
|
217
159
|
});
|
|
218
160
|
});
|
|
219
161
|
|
|
220
|
-
it("TUI:
|
|
221
|
-
const internalResult: Result = {
|
|
222
|
-
questions: [],
|
|
223
|
-
answers: { "Which DB?": "Postgres — prod constraint" },
|
|
224
|
-
cancelled: false,
|
|
225
|
-
};
|
|
226
|
-
const handler = createAskUserChannelHandler(
|
|
227
|
-
makeCtx({ mode: "tui", customResult: internalResult }) as never,
|
|
228
|
-
);
|
|
229
|
-
const resp = await handler({ channelPayload: { questions: [commentProto] } });
|
|
230
|
-
expect(resp).toEqual({
|
|
231
|
-
value: JSON.stringify({ "Which DB?": "Postgres", "Which DB?__comment": "prod constraint" }),
|
|
232
|
-
});
|
|
233
|
-
});
|
|
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
|
-
};
|
|
162
|
+
it("TUI: Other-only answer(selected 空)→ 只写 ${key}__other 不写主 key", async () => {
|
|
243
163
|
const internalResult: Result = {
|
|
244
164
|
questions: [],
|
|
245
|
-
answers: { "Which DB?":
|
|
165
|
+
answers: { "Which DB?": { selected: [], other: "custom text" } },
|
|
246
166
|
cancelled: false,
|
|
247
167
|
};
|
|
248
168
|
const handler = createAskUserChannelHandler(
|
|
249
169
|
makeCtx({ mode: "tui", customResult: internalResult }) as never,
|
|
250
170
|
);
|
|
251
|
-
const resp = await handler({ channelPayload: { questions: [
|
|
171
|
+
const resp = await handler({ channelPayload: { questions: [singleProto] } });
|
|
252
172
|
expect(resp).toEqual({
|
|
253
|
-
value: JSON.stringify({
|
|
254
|
-
"Which DB?": "Postgres — prod",
|
|
255
|
-
"Which DB?__comment": "constraint",
|
|
256
|
-
}),
|
|
173
|
+
value: JSON.stringify({ "Which DB?__other": "custom text" }),
|
|
257
174
|
});
|
|
258
175
|
});
|
|
259
176
|
|
|
@@ -30,7 +30,6 @@ import {
|
|
|
30
30
|
INSERT,
|
|
31
31
|
LEFT,
|
|
32
32
|
make,
|
|
33
|
-
multiQWithComment,
|
|
34
33
|
OSC_BEL,
|
|
35
34
|
OSC_ST,
|
|
36
35
|
PAGE_DOWN,
|
|
@@ -51,14 +50,6 @@ import {
|
|
|
51
50
|
UP,
|
|
52
51
|
} from "./fixtures";
|
|
53
52
|
|
|
54
|
-
/** Helper: 打开 comment 编辑器并返回 component(多问题避免单问题 auto-submit) */
|
|
55
|
-
function openComment(): AskUserComponent {
|
|
56
|
-
const { c } = make(multiQWithComment);
|
|
57
|
-
// Q1 (allowComment): select A → enters comment mode
|
|
58
|
-
c.handleInput(ENTER);
|
|
59
|
-
return c;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
53
|
/** Helper: 打开 freeform 编辑器并返回 component(不渲染,避免缓存) */
|
|
63
54
|
function openFreeform(q: Question[]): AskUserComponent {
|
|
64
55
|
const { c } = make(q);
|
|
@@ -264,25 +255,6 @@ describe("AskUserComponent — key leak fix (C-ARROW / C-KEYMAP)", () => {
|
|
|
264
255
|
expect(editorLine).not.toContain("abc");
|
|
265
256
|
});
|
|
266
257
|
|
|
267
|
-
// ── C-KEYMAP-COMMENT-UP: arrow key no-op in comment editor too ──
|
|
268
|
-
it("C-KEYMAP-COMMENT-UP: arrow keys are no-op in comment editor", () => {
|
|
269
|
-
const c = openComment();
|
|
270
|
-
c.handleInput("a");
|
|
271
|
-
c.handleInput(UP);
|
|
272
|
-
c.handleInput(RIGHT);
|
|
273
|
-
c.handleInput("b");
|
|
274
|
-
// comment editor renders text and cursor on separate lines;
|
|
275
|
-
// check the text line (before cursor) contains "ab"
|
|
276
|
-
const lines = c.render(60);
|
|
277
|
-
const textLine = lines.find((l) => l.includes("ab"));
|
|
278
|
-
expect(textLine).toBeDefined();
|
|
279
|
-
expect(textLine).toContain("ab");
|
|
280
|
-
// Ensure no leaked bracket characters from arrow escape sequences
|
|
281
|
-
const allText = lines.join("\n");
|
|
282
|
-
expect(allText).not.toMatch(/\[A/); // no leaked UP sequence
|
|
283
|
-
expect(allText).not.toMatch(/\[C/); // no leaked RIGHT sequence
|
|
284
|
-
});
|
|
285
|
-
|
|
286
258
|
// ── C-KEYMAP-MOD: modifier key combinations matrix (18 cases) ──
|
|
287
259
|
const modifierCases: Array<{ name: string; seq: string }> = [
|
|
288
260
|
{ name: "ctrl+up", seq: CTRL_UP },
|
|
@@ -429,8 +401,8 @@ describe("AskUserComponent — unknown control sequence leak fix (C-CSI)", () =>
|
|
|
429
401
|
expect(editorLine).not.toContain("\x1b[A"); expect(editorLine).not.toContain("\x1b[B"); expect(editorLine).not.toContain("\x1b[C"); expect(editorLine).not.toContain("\x1b[D");
|
|
430
402
|
});
|
|
431
403
|
|
|
432
|
-
it("C-CSI-10: unknown CSI does not leak in
|
|
433
|
-
const c =
|
|
404
|
+
it("C-CSI-10: unknown CSI does not leak in editor", () => {
|
|
405
|
+
const c = openFreeform([singleQ]);
|
|
434
406
|
c.handleInput(UNKNOWN_CSI);
|
|
435
407
|
c.handleInput("ab");
|
|
436
408
|
const lines = c.render(60);
|