@zhushanwen/pi-ask-user 0.0.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +171 -0
- package/README.md +135 -9
- package/package.json +6 -2
- package/src/__tests__/answer-format.test.ts +107 -0
- package/src/__tests__/component-keymap.test.ts +522 -0
- package/src/__tests__/component.test.ts +50 -12
- package/src/__tests__/e2e-harness.ts +1 -0
- package/src/__tests__/editor-ops.test.ts +179 -0
- package/src/__tests__/fixtures.ts +56 -2
- package/src/__tests__/index.test.ts +329 -25
- package/src/__tests__/question-view.test.ts +71 -53
- package/src/__tests__/sdk-contract.test.ts +111 -0
- package/src/__tests__/validate.test.ts +19 -4
- package/src/__tests__/w2-draft-hint.test.ts +157 -0
- package/src/__tests__/w3-regression.test.ts +128 -0
- package/src/answer-format.ts +51 -0
- package/src/component.ts +132 -129
- package/src/editor-ops.ts +75 -0
- package/src/index.ts +198 -72
- package/src/question-view.ts +81 -65
- package/src/submit-view.ts +19 -11
- package/src/types.ts +24 -2
- package/src/validate.ts +20 -1
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// src/__tests__/editor-ops.test.ts
|
|
2
|
+
//
|
|
3
|
+
// editor-ops.ts 独立单元测试。
|
|
4
|
+
// 覆盖审查 S5 发现的 surrogate pair 边界盲区:
|
|
5
|
+
// - moveCursorLeft/Right 在 emoji(surrogate pair)边界正确按 code point 移动
|
|
6
|
+
// - deleteCharBeforeCursor 删整个 emoji code point
|
|
7
|
+
//
|
|
8
|
+
// 之前仅靠 component.test.ts 黑盒间接覆盖,无法直接锁定 surrogate 行为。
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it } from "vitest";
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
deleteCharBeforeCursor,
|
|
14
|
+
handleEditorPaste,
|
|
15
|
+
insertAtCursor,
|
|
16
|
+
moveCursorEnd,
|
|
17
|
+
moveCursorHome,
|
|
18
|
+
moveCursorLeft,
|
|
19
|
+
moveCursorRight,
|
|
20
|
+
} from "../editor-ops.js";
|
|
21
|
+
import { createQuestionState, type QuestionState } from "../types.js";
|
|
22
|
+
|
|
23
|
+
function stateWith(text: string, cursor = text.length): QuestionState {
|
|
24
|
+
const s = createQuestionState();
|
|
25
|
+
s.draftText = text;
|
|
26
|
+
s.cursorIndex = cursor;
|
|
27
|
+
return s;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("moveCursorLeft", () => {
|
|
31
|
+
it("moves left by 1 for ASCII", () => {
|
|
32
|
+
const s = stateWith("abc", 3);
|
|
33
|
+
moveCursorLeft(s);
|
|
34
|
+
expect(s.cursorIndex).toBe(2);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("moves left by 1 code point (2 UTF-16 units) for emoji", () => {
|
|
38
|
+
// "a😀b" → length=4 (a=1, 😀=2, b=1),光标在末尾(4)
|
|
39
|
+
const s = stateWith("a😀b", 4);
|
|
40
|
+
moveCursorLeft(s); // 从 4 → 跳过 b 到 3
|
|
41
|
+
expect(s.cursorIndex).toBe(3);
|
|
42
|
+
moveCursorLeft(s); // 从 3 → 跳过 emoji 到 1(不是 2)
|
|
43
|
+
expect(s.cursorIndex).toBe(1);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("does not go below 0", () => {
|
|
47
|
+
const s = stateWith("abc", 0);
|
|
48
|
+
moveCursorLeft(s);
|
|
49
|
+
expect(s.cursorIndex).toBe(0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("stops at 0 after emoji", () => {
|
|
53
|
+
const s = stateWith("😀", 2);
|
|
54
|
+
moveCursorLeft(s);
|
|
55
|
+
expect(s.cursorIndex).toBe(0);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("moveCursorRight", () => {
|
|
60
|
+
it("moves right by 1 for ASCII", () => {
|
|
61
|
+
const s = stateWith("abc", 0);
|
|
62
|
+
moveCursorRight(s);
|
|
63
|
+
expect(s.cursorIndex).toBe(1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("moves right by 1 code point (2 UTF-16 units) for emoji", () => {
|
|
67
|
+
// "a😀b" → 光标在 0,右移应到 1(a),再到 3(跳过 emoji 的 2 个 unit)
|
|
68
|
+
const s = stateWith("a😀b", 0);
|
|
69
|
+
moveCursorRight(s); // 0 → 1
|
|
70
|
+
expect(s.cursorIndex).toBe(1);
|
|
71
|
+
moveCursorRight(s); // 1 → 3(跳过 😀 的 2 个 unit)
|
|
72
|
+
expect(s.cursorIndex).toBe(3);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("does not exceed draftText.length", () => {
|
|
76
|
+
const s = stateWith("ab", 2);
|
|
77
|
+
moveCursorRight(s);
|
|
78
|
+
expect(s.cursorIndex).toBe(2);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("stops at end after emoji", () => {
|
|
82
|
+
const s = stateWith("x😀", 1);
|
|
83
|
+
moveCursorRight(s);
|
|
84
|
+
expect(s.cursorIndex).toBe(3); // 跳到末尾
|
|
85
|
+
moveCursorRight(s); // 不超出
|
|
86
|
+
expect(s.cursorIndex).toBe(3);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("deleteCharBeforeCursor", () => {
|
|
91
|
+
it("deletes single ASCII char", () => {
|
|
92
|
+
const s = stateWith("abc", 2);
|
|
93
|
+
const changed = deleteCharBeforeCursor(s);
|
|
94
|
+
expect(changed).toBe(true);
|
|
95
|
+
expect(s.draftText).toBe("ac");
|
|
96
|
+
expect(s.cursorIndex).toBe(1);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("deletes entire emoji code point (2 UTF-16 units)", () => {
|
|
100
|
+
// "a😀b" 光标在 4(末尾),删除应删掉 b → "a😀"
|
|
101
|
+
const s = stateWith("a😀b", 4);
|
|
102
|
+
deleteCharBeforeCursor(s);
|
|
103
|
+
expect(s.draftText).toBe("a😀");
|
|
104
|
+
expect(s.cursorIndex).toBe(3);
|
|
105
|
+
// 再删一次应删掉整个 emoji(2 units),不是只删 1
|
|
106
|
+
deleteCharBeforeCursor(s);
|
|
107
|
+
expect(s.draftText).toBe("a");
|
|
108
|
+
expect(s.cursorIndex).toBe(1);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("returns false at cursor 0 (nothing to delete)", () => {
|
|
112
|
+
const s = stateWith("abc", 0);
|
|
113
|
+
const changed = deleteCharBeforeCursor(s);
|
|
114
|
+
expect(changed).toBe(false);
|
|
115
|
+
expect(s.draftText).toBe("abc");
|
|
116
|
+
expect(s.cursorIndex).toBe(0);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("deletes emoji at start of text", () => {
|
|
120
|
+
const s = stateWith("😀hello", 2);
|
|
121
|
+
deleteCharBeforeCursor(s);
|
|
122
|
+
expect(s.draftText).toBe("hello");
|
|
123
|
+
expect(s.cursorIndex).toBe(0);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("insertAtCursor", () => {
|
|
128
|
+
it("inserts text at cursor position", () => {
|
|
129
|
+
const s = stateWith("abc", 1);
|
|
130
|
+
insertAtCursor(s, "X");
|
|
131
|
+
expect(s.draftText).toBe("aXbc");
|
|
132
|
+
expect(s.cursorIndex).toBe(2);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("moveCursorHome / moveCursorEnd", () => {
|
|
137
|
+
it("home sets cursor to 0", () => {
|
|
138
|
+
const s = stateWith("abc", 2);
|
|
139
|
+
moveCursorHome(s);
|
|
140
|
+
expect(s.cursorIndex).toBe(0);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("end sets cursor to draftText.length", () => {
|
|
144
|
+
const s = stateWith("a😀b", 0);
|
|
145
|
+
moveCursorEnd(s);
|
|
146
|
+
expect(s.cursorIndex).toBe(4);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("handleEditorPaste", () => {
|
|
151
|
+
it("inserts printable text", () => {
|
|
152
|
+
const s = stateWith("ab", 1);
|
|
153
|
+
const changed = handleEditorPaste(s, "XY");
|
|
154
|
+
expect(changed).toBe(true);
|
|
155
|
+
expect(s.draftText).toBe("aXYb");
|
|
156
|
+
expect(s.cursorIndex).toBe(3);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("inserts emoji correctly (surrogate pair via Array.from)", () => {
|
|
160
|
+
const s = stateWith("", 0);
|
|
161
|
+
handleEditorPaste(s, "😀");
|
|
162
|
+
expect(s.draftText).toBe("😀");
|
|
163
|
+
expect(s.cursorIndex).toBe(2);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("rejects unknown escape sequences (non-bracketed)", () => {
|
|
167
|
+
const s = stateWith("ab", 1);
|
|
168
|
+
const changed = handleEditorPaste(s, "\x1b[6n");
|
|
169
|
+
expect(changed).toBe(false);
|
|
170
|
+
expect(s.draftText).toBe("ab");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("strips bracketed paste markers", () => {
|
|
174
|
+
const s = stateWith("", 0);
|
|
175
|
+
const changed = handleEditorPaste(s, "\x1b[200~hello\x1b[201~");
|
|
176
|
+
expect(changed).toBe(true);
|
|
177
|
+
expect(s.draftText).toBe("hello");
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// src/__tests__/fixtures.ts
|
|
2
2
|
// Shared test fixtures — stub theme, mock TUI, sample questions, key sequences.
|
|
3
|
-
import
|
|
3
|
+
import { AskUserComponent } from "../component";
|
|
4
|
+
import type { Question, Result, ThemeLike } from "../types";
|
|
4
5
|
|
|
5
6
|
// ── Stub theme (passthrough — no ANSI codes, plain text) ──
|
|
6
7
|
export const stubTheme: ThemeLike = {
|
|
@@ -14,7 +15,6 @@ export const mockTui = { requestRender: (): void => {} };
|
|
|
14
15
|
|
|
15
16
|
// ── Key sequences (real terminal escape codes that matchesKey recognizes) ──
|
|
16
17
|
export const ENTER = "\r";
|
|
17
|
-
export const SPACE = " ";
|
|
18
18
|
export const ESC = "\x1b";
|
|
19
19
|
export const UP = "\x1b[A";
|
|
20
20
|
export const DOWN = "\x1b[B";
|
|
@@ -22,6 +22,14 @@ export const RIGHT = "\x1b[C";
|
|
|
22
22
|
export const LEFT = "\x1b[D";
|
|
23
23
|
export const TAB = "\t";
|
|
24
24
|
export const BKSP = "\x7f";
|
|
25
|
+
export const BACKSPACE = "\x7f";
|
|
26
|
+
export const HOME = "\x1b[H";
|
|
27
|
+
export const END = "\x1b[F";
|
|
28
|
+
export const INSERT = "\x1b[2~";
|
|
29
|
+
export const PGUP = "\x1b[5~";
|
|
30
|
+
export const PGDN = "\x1b[6~";
|
|
31
|
+
export const F1 = "\x1bOP";
|
|
32
|
+
export const DELETE = "\x1b[3~";
|
|
25
33
|
|
|
26
34
|
// ── Sample questions ──
|
|
27
35
|
export const singleQ: Question = {
|
|
@@ -66,3 +74,49 @@ export const multiQWithComment: Question[] = [
|
|
|
66
74
|
{ question: "Q1", header: "First", allowComment: true, options: [{ label: "A" }, { label: "B" }] },
|
|
67
75
|
{ question: "Q2", header: "Second", options: [{ label: "X" }, { label: "Y" }] },
|
|
68
76
|
];
|
|
77
|
+
|
|
78
|
+
// ── Aliases (PAGE_UP/PAGE_DOWN for W1 tests, PGUP/PGDN for W3 tests) ──
|
|
79
|
+
export const PAGE_UP = "\x1b[5~";
|
|
80
|
+
export const PAGE_DOWN = "\x1b[6~";
|
|
81
|
+
|
|
82
|
+
// ── Modifier key sequences (ctrl/alt/shift/super + arrow/special) ──
|
|
83
|
+
// CSI u encoding (Kitty/modifyOtherKeys mode 2): ESC [ <code> ; <mod> ~
|
|
84
|
+
// mod = 1 + bitmask: shift=1, alt=2, ctrl=4, super=8
|
|
85
|
+
export const CTRL_UP = "\x1b[1;5A";
|
|
86
|
+
export const CTRL_DOWN = "\x1b[1;5B";
|
|
87
|
+
export const CTRL_LEFT = "\x1b[1;5D";
|
|
88
|
+
export const CTRL_RIGHT = "\x1b[1;5C";
|
|
89
|
+
export const ALT_UP = "\x1b[1;3A";
|
|
90
|
+
export const ALT_DOWN = "\x1b[1;3B";
|
|
91
|
+
export const ALT_LEFT = "\x1b[1;3D";
|
|
92
|
+
export const ALT_RIGHT = "\x1b[1;3C";
|
|
93
|
+
export const SHIFT_UP = "\x1b[1;2A";
|
|
94
|
+
export const SHIFT_DOWN = "\x1b[1;2B";
|
|
95
|
+
export const SHIFT_LEFT = "\x1b[1;2D";
|
|
96
|
+
export const SHIFT_RIGHT = "\x1b[1;2C";
|
|
97
|
+
export const SUPER_UP = "\x1b[1;9A";
|
|
98
|
+
export const SUPER_DOWN = "\x1b[1;9B";
|
|
99
|
+
export const SUPER_LEFT = "\x1b[1;9D";
|
|
100
|
+
export const SUPER_RIGHT = "\x1b[1;9C";
|
|
101
|
+
export const CTRL_SHIFT_UP = "\x1b[1;6A";
|
|
102
|
+
export const CTRL_SHIFT_DOWN = "\x1b[1;6B";
|
|
103
|
+
|
|
104
|
+
// ── Unknown control sequences (terminal spontaneous, parseKey returns undefined) ──
|
|
105
|
+
export const OSC_BEL = "\x1b]11;rgb:aa/bb/cc\x07"; // OSC, BEL 终止
|
|
106
|
+
export const OSC_ST = "\x1b]11;rgb:aa/bb/cc\x1b\\"; // OSC, ST (ESC\\) 终止
|
|
107
|
+
export const DA1 = "\x1b[?6c"; // DA1 响应
|
|
108
|
+
export const DA2 = "\x1b[>0c"; // DA2 响应
|
|
109
|
+
export const DCS = "\x1bP>|tmux 3.4\x1b\\"; // DCS XTVersion 响应
|
|
110
|
+
export const APC = "\x1b_Gi=31\x1b\\"; // APC Kitty graphics 响应
|
|
111
|
+
export const UNKNOWN_CSI = "\x1b[99~"; // 未知 CSI
|
|
112
|
+
export const UNKNOWN_SS3 = "\x1bOZ"; // 未知 SS3
|
|
113
|
+
|
|
114
|
+
// ── Component helpers ──
|
|
115
|
+
|
|
116
|
+
/** 创建默认单问题组件,返回 { c, result } */
|
|
117
|
+
export function make(questions?: Question[]): { c: AskUserComponent; result: { val: Result | null | undefined } } {
|
|
118
|
+
const qs = questions ?? [{ question: "Pick one", options: [{ label: "A" }, { label: "B" }] }];
|
|
119
|
+
const result = { val: undefined as Result | null | undefined };
|
|
120
|
+
const c = new AskUserComponent(qs, mockTui, stubTheme, (r: Result | null) => { result.val = r; });
|
|
121
|
+
return { c, result };
|
|
122
|
+
}
|
|
@@ -6,6 +6,8 @@ import factory from "../index";
|
|
|
6
6
|
import { mockTui, stubTheme } from "./fixtures";
|
|
7
7
|
|
|
8
8
|
// ── Types for the registered tool ───────────────────────
|
|
9
|
+
type TestMode = "tui" | "rpc" | "json" | "print";
|
|
10
|
+
|
|
9
11
|
interface RegisteredTool {
|
|
10
12
|
name: string;
|
|
11
13
|
label: string;
|
|
@@ -16,6 +18,7 @@ interface RegisteredTool {
|
|
|
16
18
|
signal: AbortSignal | undefined,
|
|
17
19
|
onUpdate: unknown,
|
|
18
20
|
ctx: {
|
|
21
|
+
mode: TestMode;
|
|
19
22
|
hasUI: boolean;
|
|
20
23
|
signal?: AbortSignal;
|
|
21
24
|
ui: {
|
|
@@ -23,6 +26,11 @@ interface RegisteredTool {
|
|
|
23
26
|
factory: (...args: unknown[]) => unknown,
|
|
24
27
|
options?: { overlay?: boolean },
|
|
25
28
|
): Promise<T>;
|
|
29
|
+
select?: (
|
|
30
|
+
title: string,
|
|
31
|
+
options: string[],
|
|
32
|
+
opts?: { signal?: AbortSignal },
|
|
33
|
+
) => Promise<string | undefined>;
|
|
26
34
|
};
|
|
27
35
|
},
|
|
28
36
|
) => Promise<Record<string, unknown>>;
|
|
@@ -57,16 +65,40 @@ const getTool = (overrides: Partial<MockPi> = {}): RegisteredTool => {
|
|
|
57
65
|
return pi.tool;
|
|
58
66
|
};
|
|
59
67
|
|
|
68
|
+
// 真 headless ctx:mode='print'(无 dialog 能力,hasUI=false),ui 上无 select。
|
|
69
|
+
// isGuiCapable(ctx)=false(mode≠'rpc')→ 不走 RPC 分支 → custom 也不可用 → catch 走禁用。
|
|
70
|
+
const makeHeadlessCtx = () => ({
|
|
71
|
+
mode: "print" as const,
|
|
72
|
+
hasUI: false,
|
|
73
|
+
signal: undefined as AbortSignal | undefined,
|
|
74
|
+
ui: {},
|
|
75
|
+
});
|
|
76
|
+
|
|
60
77
|
// ── Mock ctx builder ────────────────────────────────────
|
|
78
|
+
// mode 区分三场景:'tui'(默认,走 custom)/ 'rpc'(走 select)/ 'print'(headless)。
|
|
79
|
+
// Pi 的 hasUI:TUI 和 RPC 都为 true(dialog-capable),print/json 为 false。
|
|
80
|
+
// RPC 模式才挂 select(与真实 Pi 一致:TUI 模式的 ctx.ui 不一定有 select)。
|
|
61
81
|
const makeCtx = (
|
|
62
82
|
overrides: Partial<{
|
|
63
|
-
|
|
83
|
+
mode: TestMode;
|
|
64
84
|
customResult: unknown;
|
|
65
85
|
customThrows: Error | null;
|
|
86
|
+
selectResult: string | undefined;
|
|
87
|
+
selectThrows: Error | null;
|
|
66
88
|
}> = {},
|
|
67
89
|
) => {
|
|
68
|
-
const {
|
|
90
|
+
const {
|
|
91
|
+
mode = "tui",
|
|
92
|
+
customResult = null,
|
|
93
|
+
customThrows = null,
|
|
94
|
+
selectResult = undefined,
|
|
95
|
+
selectThrows = null,
|
|
96
|
+
} = overrides;
|
|
97
|
+
const hasUI = mode === "tui" || mode === "rpc";
|
|
98
|
+
// RPC 模式才挂 select(与真实 Pi 一致:TUI 模式的 ctx.ui 不一定有 select)
|
|
99
|
+
const hasSelect = mode === "rpc";
|
|
69
100
|
return {
|
|
101
|
+
mode,
|
|
70
102
|
hasUI,
|
|
71
103
|
signal: undefined as AbortSignal | undefined,
|
|
72
104
|
ui: {
|
|
@@ -74,6 +106,18 @@ const makeCtx = (
|
|
|
74
106
|
if (customThrows) throw customThrows;
|
|
75
107
|
return customResult as T;
|
|
76
108
|
},
|
|
109
|
+
...(hasSelect
|
|
110
|
+
? {
|
|
111
|
+
select: async (
|
|
112
|
+
_title: string,
|
|
113
|
+
_options: string[],
|
|
114
|
+
_opts?: { signal?: AbortSignal },
|
|
115
|
+
): Promise<string | undefined> => {
|
|
116
|
+
if (selectThrows) throw selectThrows;
|
|
117
|
+
return selectResult;
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
: {}),
|
|
77
121
|
},
|
|
78
122
|
};
|
|
79
123
|
};
|
|
@@ -156,45 +200,32 @@ describe("execute — validation (FR-2 / AC-8 / AC-13)", () => {
|
|
|
156
200
|
});
|
|
157
201
|
|
|
158
202
|
// ── I-5 ~ I-7: Headless(FR-8 / AC-7)──────────────────
|
|
203
|
+
// 真 headless:hasUI=false 且 ui 上无 select(print 模式),askUserInteract 抛错 → 禁用工具。
|
|
159
204
|
describe("execute — headless (FR-8 / AC-7)", () => {
|
|
160
|
-
it("I-5:
|
|
205
|
+
it("I-5: headless (no select) → isError with disabled message", async () => {
|
|
161
206
|
const tool = getTool();
|
|
162
|
-
const result = await tool.execute("id", validSingle, undefined, undefined,
|
|
207
|
+
const result = await tool.execute("id", validSingle, undefined, undefined, makeHeadlessCtx());
|
|
163
208
|
expect(result.isError).toBe(true);
|
|
164
|
-
expect(result.content[0].text).toContain("
|
|
209
|
+
expect(result.content[0].text).toContain("disabled");
|
|
165
210
|
});
|
|
166
211
|
|
|
167
|
-
it("I-6:
|
|
168
|
-
const tool = getTool();
|
|
169
|
-
await tool.execute("id", validSingle, undefined, undefined, makeCtx({ hasUI: false }));
|
|
170
|
-
// The mock setActiveTools stores into activeTools; getAllTools returns ask_user + other_tool.
|
|
171
|
-
// We verify by checking the pi mock captured a filtered list.
|
|
172
|
-
// Re-run with a pi that records the call.
|
|
212
|
+
it("I-6: headless disables ask_user tool via setActiveTools", async () => {
|
|
173
213
|
let captured: string[] | null = null;
|
|
174
|
-
const pi = {
|
|
175
|
-
registerTool() {},
|
|
176
|
-
getAllTools: () => [{ name: "ask_user" }, { name: "other" }],
|
|
177
|
-
setActiveTools: (names: string[]) => {
|
|
178
|
-
captured = names;
|
|
179
|
-
},
|
|
180
|
-
};
|
|
181
|
-
factory(pi as never);
|
|
182
|
-
// Re-extract tool — factory already registered, but registerTool is no-op above.
|
|
183
|
-
// Use the getTool approach with override instead:
|
|
184
214
|
const tool2 = getTool({
|
|
185
215
|
getAllTools: () => [{ name: "ask_user" }, { name: "other" }],
|
|
186
216
|
setActiveTools: (names: string[]) => {
|
|
187
217
|
captured = names;
|
|
188
218
|
},
|
|
189
219
|
});
|
|
190
|
-
await tool2.execute("id", validSingle, undefined, undefined,
|
|
220
|
+
await tool2.execute("id", validSingle, undefined, undefined, makeHeadlessCtx());
|
|
191
221
|
expect(captured).not.toContain("ask_user");
|
|
192
222
|
expect(captured).toContain("other");
|
|
193
223
|
});
|
|
194
224
|
|
|
195
|
-
it("I-7:
|
|
225
|
+
it("I-7: headless details.cancelled = true", async () => {
|
|
196
226
|
const tool = getTool();
|
|
197
|
-
const result = await tool.execute("id", validSingle, undefined, undefined,
|
|
227
|
+
const result = await tool.execute("id", validSingle, undefined, undefined, makeHeadlessCtx());
|
|
228
|
+
// headless 走 step 2 提前返回:cancelled Result(禁用工具,不进交互分支)
|
|
198
229
|
expect(result.details.cancelled).toBe(true);
|
|
199
230
|
});
|
|
200
231
|
});
|
|
@@ -208,7 +239,9 @@ describe("execute — signal abort (FR-10 / AC-14)", () => {
|
|
|
208
239
|
const ctx = makeCtx();
|
|
209
240
|
ctx.signal = controller.signal;
|
|
210
241
|
const result = await tool.execute("id", validSingle, controller.signal, undefined, ctx);
|
|
211
|
-
|
|
242
|
+
// step3 是 agent abort(goal 取消/compact/session 切换),文案应明确告知 abort,
|
|
243
|
+
// 区别于 step5 的用户取消(A5)
|
|
244
|
+
expect(result.content[0].text).toContain("abort");
|
|
212
245
|
expect(result.details.cancelled).toBe(true);
|
|
213
246
|
});
|
|
214
247
|
|
|
@@ -219,6 +252,7 @@ describe("execute — signal abort (FR-10 / AC-14)", () => {
|
|
|
219
252
|
// 此前 mock 直接返回 customResult、从不调用 factory,该 abort 监听器是 dead path。
|
|
220
253
|
// 现在中断后监听器调用 done(null),custom 解析为 null → cancelled。
|
|
221
254
|
const ctx = {
|
|
255
|
+
mode: "tui" as const,
|
|
222
256
|
hasUI: true,
|
|
223
257
|
signal: controller.signal,
|
|
224
258
|
ui: {
|
|
@@ -477,3 +511,273 @@ describe("factory registration (FR-1)", () => {
|
|
|
477
511
|
expect(typeof tool.renderResult).toBe("function");
|
|
478
512
|
});
|
|
479
513
|
});
|
|
514
|
+
|
|
515
|
+
// ── FR-3: inline 渲染(不传 overlay)───────────────────────
|
|
516
|
+
describe("execute — inline render (FR-3)", () => {
|
|
517
|
+
it("I-FR3: ui.custom called WITHOUT overlay options (inline, not modal)", async () => {
|
|
518
|
+
const tool = getTool();
|
|
519
|
+
let customArgCount = -1;
|
|
520
|
+
const ctx = {
|
|
521
|
+
mode: "tui" as const,
|
|
522
|
+
hasUI: true,
|
|
523
|
+
signal: undefined as AbortSignal | undefined,
|
|
524
|
+
ui: {
|
|
525
|
+
custom: async (...args: unknown[]): Promise<null> => {
|
|
526
|
+
customArgCount = args.length;
|
|
527
|
+
return null; // cancelled — simplest resolve
|
|
528
|
+
},
|
|
529
|
+
},
|
|
530
|
+
};
|
|
531
|
+
await tool.execute("id", validSingle, undefined, undefined, ctx);
|
|
532
|
+
// FR-3: execute 调用 ui.custom 只传 factory(1 个参数),不传 overlay options
|
|
533
|
+
expect(customArgCount).toBe(1);
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
// ── RPC 模式(xyz-agent GUI 富交互协议)──────────────────
|
|
538
|
+
// hasUI=false + ui.select 存在 → 走 askUserInteract(select 通道 + ASK_USER_MARKER)。
|
|
539
|
+
// select 的返回值是前端 JSON.stringify 的 AskUserAnswers,index.ts 做格式转换。
|
|
540
|
+
describe("execute — RPC mode (askUserInteract via select channel)", () => {
|
|
541
|
+
it("R-1: single-select answer → converted to Result.answers (key=question)", async () => {
|
|
542
|
+
const tool = getTool();
|
|
543
|
+
// 协议 answers:key=header(单问题无 header → question 全文),value=选中 label
|
|
544
|
+
const protoAnswers = JSON.stringify({ "Which DB?": "Postgres" });
|
|
545
|
+
const result = await tool.execute(
|
|
546
|
+
"id",
|
|
547
|
+
validSingle,
|
|
548
|
+
undefined,
|
|
549
|
+
undefined,
|
|
550
|
+
makeCtx({ mode: "rpc", selectResult: protoAnswers }),
|
|
551
|
+
);
|
|
552
|
+
expect(result.details.cancelled).toBe(false);
|
|
553
|
+
expect(result.details.answers["Which DB?"]).toBe("Postgres");
|
|
554
|
+
expect(result.content[0].text).toContain("Postgres");
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it("R-2: multi-select answer (JSON array) → comma-joined labels", async () => {
|
|
558
|
+
const tool = getTool();
|
|
559
|
+
const multi = {
|
|
560
|
+
questions: [
|
|
561
|
+
{
|
|
562
|
+
question: "Which tools?",
|
|
563
|
+
header: "Tools",
|
|
564
|
+
options: [{ label: "A" }, { label: "B" }, { label: "C" }],
|
|
565
|
+
multiSelect: true,
|
|
566
|
+
},
|
|
567
|
+
],
|
|
568
|
+
};
|
|
569
|
+
// 协议多选:value = JSON.stringify(["A","C"])
|
|
570
|
+
const protoAnswers = JSON.stringify({ Tools: JSON.stringify(["A", "C"]) });
|
|
571
|
+
const result = await tool.execute(
|
|
572
|
+
"id",
|
|
573
|
+
multi,
|
|
574
|
+
undefined,
|
|
575
|
+
undefined,
|
|
576
|
+
makeCtx({ mode: "rpc", selectResult: protoAnswers }),
|
|
577
|
+
);
|
|
578
|
+
expect(result.details.answers["Which tools?"]).toBe("A, C");
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it("R-2b: multi-select 乱序回传 → 按 options 定义顺序排序(S#3)", async () => {
|
|
582
|
+
const tool = getTool();
|
|
583
|
+
const multi = {
|
|
584
|
+
questions: [
|
|
585
|
+
{
|
|
586
|
+
question: "Which tools?",
|
|
587
|
+
header: "Tools",
|
|
588
|
+
options: [{ label: "A" }, { label: "B" }, { label: "C" }],
|
|
589
|
+
multiSelect: true,
|
|
590
|
+
},
|
|
591
|
+
],
|
|
592
|
+
};
|
|
593
|
+
// 前端回传顺序 ["C", "A"] —— 应按 options 索引重排为 "A, C"
|
|
594
|
+
const protoAnswers = JSON.stringify({ Tools: JSON.stringify(["C", "A"]) });
|
|
595
|
+
const result = await tool.execute(
|
|
596
|
+
"id",
|
|
597
|
+
multi,
|
|
598
|
+
undefined,
|
|
599
|
+
undefined,
|
|
600
|
+
makeCtx({ mode: "rpc", selectResult: protoAnswers }),
|
|
601
|
+
);
|
|
602
|
+
expect(result.details.answers["Which tools?"]).toBe("A, C");
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it("R-3: Other free text → appended to answer parts", async () => {
|
|
606
|
+
const tool = getTool();
|
|
607
|
+
// 单选 Postgres + Other "Custom DB"
|
|
608
|
+
const protoAnswers = JSON.stringify({
|
|
609
|
+
"Which DB?": "Postgres",
|
|
610
|
+
"Which DB?__other": "Custom DB",
|
|
611
|
+
});
|
|
612
|
+
const result = await tool.execute(
|
|
613
|
+
"id",
|
|
614
|
+
validSingle,
|
|
615
|
+
undefined,
|
|
616
|
+
undefined,
|
|
617
|
+
makeCtx({ mode: "rpc", selectResult: protoAnswers }),
|
|
618
|
+
);
|
|
619
|
+
// TUI 语义:parts = [selected, other].join(", ")
|
|
620
|
+
expect(result.details.answers["Which DB?"]).toBe("Postgres, Custom DB");
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
it("R-4: comment → inlined with ' — ' separator", async () => {
|
|
624
|
+
const tool = getTool();
|
|
625
|
+
const withComment = {
|
|
626
|
+
questions: [
|
|
627
|
+
{
|
|
628
|
+
question: "Which DB?",
|
|
629
|
+
options: [{ label: "Postgres" }, { label: "SQLite" }],
|
|
630
|
+
allowComment: true,
|
|
631
|
+
},
|
|
632
|
+
],
|
|
633
|
+
};
|
|
634
|
+
const protoAnswers = JSON.stringify({
|
|
635
|
+
"Which DB?": "Postgres",
|
|
636
|
+
"Which DB?__comment": "prod constraint",
|
|
637
|
+
});
|
|
638
|
+
const result = await tool.execute(
|
|
639
|
+
"id",
|
|
640
|
+
withComment,
|
|
641
|
+
undefined,
|
|
642
|
+
undefined,
|
|
643
|
+
makeCtx({ mode: "rpc", selectResult: protoAnswers }),
|
|
644
|
+
);
|
|
645
|
+
expect(result.details.answers["Which DB?"]).toBe("Postgres — prod constraint");
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it("R-5: user cancel (select returns undefined) → cancelled details", async () => {
|
|
649
|
+
const tool = getTool();
|
|
650
|
+
const result = await tool.execute(
|
|
651
|
+
"id",
|
|
652
|
+
validSingle,
|
|
653
|
+
undefined,
|
|
654
|
+
undefined,
|
|
655
|
+
makeCtx({ mode: "rpc", selectResult: undefined }),
|
|
656
|
+
);
|
|
657
|
+
expect(result.content[0].text).toContain("User cancelled");
|
|
658
|
+
expect(result.details.cancelled).toBe(true);
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it("R-6: select throws → isError + disabled (not retriable)", async () => {
|
|
662
|
+
const tool = getTool();
|
|
663
|
+
const result = await tool.execute(
|
|
664
|
+
"id",
|
|
665
|
+
validSingle,
|
|
666
|
+
undefined,
|
|
667
|
+
undefined,
|
|
668
|
+
makeCtx({ mode: "rpc", selectThrows: new Error("channel broken") }),
|
|
669
|
+
);
|
|
670
|
+
expect(result.isError).toBe(true);
|
|
671
|
+
expect(result.content[0].text).toContain("disabled");
|
|
672
|
+
expect(result.details.error).toBe("channel broken");
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
it("R-7: header used as answers key when provided", async () => {
|
|
676
|
+
const tool = getTool();
|
|
677
|
+
const multiQ = {
|
|
678
|
+
questions: [
|
|
679
|
+
{
|
|
680
|
+
question: "Which database?",
|
|
681
|
+
header: "DB",
|
|
682
|
+
options: [{ label: "Postgres" }, { label: "MySQL" }],
|
|
683
|
+
},
|
|
684
|
+
],
|
|
685
|
+
};
|
|
686
|
+
// 协议 answers key = header("DB"),但 Result.answers key = question 全文
|
|
687
|
+
const protoAnswers = JSON.stringify({ DB: "Postgres" });
|
|
688
|
+
const result = await tool.execute(
|
|
689
|
+
"id",
|
|
690
|
+
multiQ,
|
|
691
|
+
undefined,
|
|
692
|
+
undefined,
|
|
693
|
+
makeCtx({ mode: "rpc", selectResult: protoAnswers }),
|
|
694
|
+
);
|
|
695
|
+
// 转换后 key 必须是 question 全文(与 TUI 版 buildResult 一致)
|
|
696
|
+
expect(result.details.answers["Which database?"]).toBe("Postgres");
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
it("R-8: multi-question mixed (single-select + multi-select + Other + comment)", async () => {
|
|
700
|
+
const tool = getTool();
|
|
701
|
+
const mixed = {
|
|
702
|
+
questions: [
|
|
703
|
+
{
|
|
704
|
+
question: "Which database?",
|
|
705
|
+
header: "DB",
|
|
706
|
+
options: [{ label: "Postgres" }, { label: "MySQL" }],
|
|
707
|
+
},
|
|
708
|
+
{
|
|
709
|
+
question: "Which tools?",
|
|
710
|
+
header: "Tools",
|
|
711
|
+
options: [{ label: "A" }, { label: "B" }, { label: "C" }],
|
|
712
|
+
multiSelect: true,
|
|
713
|
+
},
|
|
714
|
+
{
|
|
715
|
+
question: "Which region?",
|
|
716
|
+
header: "Region",
|
|
717
|
+
options: [{ label: "US" }, { label: "EU" }],
|
|
718
|
+
allowComment: true,
|
|
719
|
+
},
|
|
720
|
+
],
|
|
721
|
+
};
|
|
722
|
+
// Q1: single-select Postgres
|
|
723
|
+
// Q2: multi-select [C, A] (乱序 → 应重排为 A, C) + Other "Custom"
|
|
724
|
+
// Q3: 无选中 (parts.length === 0 → skip, 不出现在 answers 中)
|
|
725
|
+
const protoAnswers = JSON.stringify({
|
|
726
|
+
DB: "Postgres",
|
|
727
|
+
Tools: JSON.stringify(["C", "A"]),
|
|
728
|
+
"Tools__other": "Custom",
|
|
729
|
+
// Region 无选中 → protoAnswersToResult 的 `if (parts.length === 0) continue` 跳过
|
|
730
|
+
});
|
|
731
|
+
const result = await tool.execute(
|
|
732
|
+
"id",
|
|
733
|
+
mixed,
|
|
734
|
+
undefined,
|
|
735
|
+
undefined,
|
|
736
|
+
makeCtx({ mode: "rpc", selectResult: protoAnswers }),
|
|
737
|
+
);
|
|
738
|
+
|
|
739
|
+
expect(result.details.cancelled).toBe(false);
|
|
740
|
+
// Q1: single-select
|
|
741
|
+
expect(result.details.answers["Which database?"]).toBe("Postgres");
|
|
742
|
+
// Q2: multi-select 重排 + Other
|
|
743
|
+
expect(result.details.answers["Which tools?"]).toBe("A, C, Custom");
|
|
744
|
+
// Q3: 无选中 → 跳过(不在 answers map 中)
|
|
745
|
+
expect(result.details.answers["Which region?"]).toBeUndefined();
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
it("R-9: multi-question with comment on one question", async () => {
|
|
749
|
+
const tool = getTool();
|
|
750
|
+
const multiQ = {
|
|
751
|
+
questions: [
|
|
752
|
+
{
|
|
753
|
+
question: "Which DB?",
|
|
754
|
+
header: "DB",
|
|
755
|
+
options: [{ label: "Postgres" }],
|
|
756
|
+
},
|
|
757
|
+
{
|
|
758
|
+
question: "Why?",
|
|
759
|
+
header: "Reason",
|
|
760
|
+
options: [{ label: "Performance" }],
|
|
761
|
+
allowComment: true,
|
|
762
|
+
},
|
|
763
|
+
],
|
|
764
|
+
};
|
|
765
|
+
const protoAnswers = JSON.stringify({
|
|
766
|
+
DB: "Postgres",
|
|
767
|
+
Reason: "Performance",
|
|
768
|
+
"Reason__comment": "benchmarked",
|
|
769
|
+
});
|
|
770
|
+
const result = await tool.execute(
|
|
771
|
+
"id",
|
|
772
|
+
multiQ,
|
|
773
|
+
undefined,
|
|
774
|
+
undefined,
|
|
775
|
+
makeCtx({ mode: "rpc", selectResult: protoAnswers }),
|
|
776
|
+
);
|
|
777
|
+
|
|
778
|
+
// Q1: 无 comment
|
|
779
|
+
expect(result.details.answers["Which DB?"]).toBe("Postgres");
|
|
780
|
+
// Q2: 有 comment → 内联
|
|
781
|
+
expect(result.details.answers["Why?"]).toBe("Performance — benchmarked");
|
|
782
|
+
});
|
|
783
|
+
});
|