@pi-in-go/pigpen-ask-user-question 0.0.0-stage → 0.1.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/CREDITS.md +17 -0
- package/LICENSE +22 -0
- package/README.md +27 -2
- package/extensions/rpiv-ask-user-question/config.go +189 -0
- package/extensions/rpiv-ask-user-question/config_test.go +142 -0
- package/extensions/rpiv-ask-user-question/copy.go +34 -0
- package/extensions/rpiv-ask-user-question/envelope_test.go +177 -0
- package/extensions/rpiv-ask-user-question/execute_test.go +119 -0
- package/extensions/rpiv-ask-user-question/export_test.go +8 -0
- package/extensions/rpiv-ask-user-question/extension.go +125 -0
- package/extensions/rpiv-ask-user-question/fakehost_test.go +548 -0
- package/extensions/rpiv-ask-user-question/go.mod +6 -0
- package/extensions/rpiv-ask-user-question/go.sum +2 -0
- package/extensions/rpiv-ask-user-question/guidance_test.go +126 -0
- package/extensions/rpiv-ask-user-question/helpers_test.go +70 -0
- package/extensions/rpiv-ask-user-question/items_test.go +38 -0
- package/extensions/rpiv-ask-user-question/jsutil.go +18 -0
- package/extensions/rpiv-ask-user-question/logic.go +185 -0
- package/extensions/rpiv-ask-user-question/normalize_test.go +64 -0
- package/extensions/rpiv-ask-user-question/normalize_tool_test.go +50 -0
- package/extensions/rpiv-ask-user-question/parallel_test.go +90 -0
- package/extensions/rpiv-ask-user-question/params.go +80 -0
- package/extensions/rpiv-ask-user-question/reconcile_test.go +117 -0
- package/extensions/rpiv-ask-user-question/rig_test.go +230 -0
- package/extensions/rpiv-ask-user-question/rpc.go +203 -0
- package/extensions/rpiv-ask-user-question/rpc_test.go +210 -0
- package/extensions/rpiv-ask-user-question/schema.go +58 -0
- package/extensions/rpiv-ask-user-question/skips_test.go +29 -0
- package/extensions/rpiv-ask-user-question/tooldef_test.go +68 -0
- package/extensions/rpiv-ask-user-question/twin_ext_test.go +28 -0
- package/extensions/rpiv-ask-user-question/twin_test.go +28 -0
- package/extensions/rpiv-ask-user-question/types.go +114 -0
- package/extensions/rpiv-ask-user-question/types_test.go +288 -0
- package/extensions/rpiv-ask-user-question/validate_test.go +71 -0
- package/package.json +37 -4
- package/port/PORT.md +102 -0
- package/port/demo/pane-answered.txt +36 -0
- package/port/demo/pane-input.txt +36 -0
- package/port/demo/pane-select.txt +36 -0
- package/port/demo/turns.json +4 -0
- package/port/gen-mutations.py +119 -0
- package/port/gen-scenarios.py +118 -0
- package/port/golden/guidance-in-request.jsonl +11 -0
- package/port/golden/line-terminators.jsonl +19 -0
- package/port/golden/mixed-questions.jsonl +21 -0
- package/port/golden/multi-select-dismissed.jsonl +19 -0
- package/port/golden/multi-select-numbers.jsonl +19 -0
- package/port/golden/multi-select-variants.jsonl +22 -0
- package/port/golden/other-input-dismissed.jsonl +20 -0
- package/port/golden/partial-then-dismissed.jsonl +20 -0
- package/port/golden/previews.jsonl +19 -0
- package/port/golden/reserved-and-duplicates.jsonl +46 -0
- package/port/golden/schema-rejections.jsonl +60 -0
- package/port/golden/single-dismissed.jsonl +19 -0
- package/port/golden/single-other-typed.jsonl +20 -0
- package/port/golden/single-select.jsonl +19 -0
- package/port/golden/two-calls.jsonl +23 -0
- package/port/golden/unlisted-choice.jsonl +19 -0
- package/port/mutations.json +524 -0
- package/port/oracle/CHANGELOG.md +489 -0
- package/port/oracle/LICENSE +21 -0
- package/port/oracle/README.md +94 -0
- package/port/oracle/UPSTREAM.md +8 -0
- package/port/oracle/ask-user-question.execute.test.ts +525 -0
- package/port/oracle/ask-user-question.guidance.test.ts +142 -0
- package/port/oracle/ask-user-question.listener.test.ts +286 -0
- package/port/oracle/ask-user-question.normalize.test.ts +147 -0
- package/port/oracle/ask-user-question.session-load.test.ts +123 -0
- package/port/oracle/ask-user-question.test.ts +703 -0
- package/port/oracle/ask-user-question.ts +412 -0
- package/port/oracle/banned-flags.test.ts +39 -0
- package/port/oracle/config.test.ts +119 -0
- package/port/oracle/config.ts +100 -0
- package/port/oracle/docs/configuration.md +121 -0
- package/port/oracle/docs/hosts.md +97 -0
- package/port/oracle/docs/keyboard.md +111 -0
- package/port/oracle/docs/localization.md +87 -0
- package/port/oracle/docs/tool-schema.md +127 -0
- package/port/oracle/events.ts +59 -0
- package/port/oracle/factory.test.ts +1103 -0
- package/port/oracle/index.ts +54 -0
- package/port/oracle/locales/de.json +31 -0
- package/port/oracle/locales/en.json +34 -0
- package/port/oracle/locales/es.json +31 -0
- package/port/oracle/locales/fr.json +31 -0
- package/port/oracle/locales/pt-BR.json +31 -0
- package/port/oracle/locales/pt.json +31 -0
- package/port/oracle/locales/ru.json +31 -0
- package/port/oracle/locales/uk.json +31 -0
- package/port/oracle/locales/zh.json +33 -0
- package/port/oracle/locales.test.ts +36 -0
- package/port/oracle/package-lock.json +42 -0
- package/port/oracle/package.json +101 -0
- package/port/oracle/reconcile.test.ts +155 -0
- package/port/oracle/reconcile.ts +47 -0
- package/port/oracle/rpc-fallback.test.ts +273 -0
- package/port/oracle/rpc-fallback.ts +166 -0
- package/port/oracle/ship-manifest.test.ts +8 -0
- package/port/oracle/state/build-questionnaire.ts +333 -0
- package/port/oracle/state/external-editor.test.ts +48 -0
- package/port/oracle/state/external-editor.ts +62 -0
- package/port/oracle/state/i18n-bridge.shim.test.ts +101 -0
- package/port/oracle/state/i18n-bridge.ts +51 -0
- package/port/oracle/state/key-router.test.ts +986 -0
- package/port/oracle/state/key-router.ts +353 -0
- package/port/oracle/state/questionnaire-session.test.ts +314 -0
- package/port/oracle/state/questionnaire-session.ts +285 -0
- package/port/oracle/state/questionnaire-state.test.ts +110 -0
- package/port/oracle/state/row-intent.test.ts +104 -0
- package/port/oracle/state/row-intent.ts +143 -0
- package/port/oracle/state/selectors/contract.ts +24 -0
- package/port/oracle/state/selectors/derivations.ts +40 -0
- package/port/oracle/state/selectors/focus.ts +17 -0
- package/port/oracle/state/selectors/projections.ts +106 -0
- package/port/oracle/state/state-reducer.test.ts +577 -0
- package/port/oracle/state/state-reducer.ts +341 -0
- package/port/oracle/state/state.ts +65 -0
- package/port/oracle/test-fixtures.ts +170 -0
- package/port/oracle/tool/format-answer.ts +29 -0
- package/port/oracle/tool/normalize-params.test.ts +77 -0
- package/port/oracle/tool/normalize-params.ts +56 -0
- package/port/oracle/tool/response-envelope.ts +58 -0
- package/port/oracle/tool/types.test.ts +298 -0
- package/port/oracle/tool/types.ts +156 -0
- package/port/oracle/tool/validate-questionnaire.ts +56 -0
- package/port/oracle/view/component-binding.ts +45 -0
- package/port/oracle/view/components/inline-input.test.ts +42 -0
- package/port/oracle/view/components/inline-input.ts +66 -0
- package/port/oracle/view/components/multi-select-view.test.ts +476 -0
- package/port/oracle/view/components/multi-select-view.ts +191 -0
- package/port/oracle/view/components/option-list-view.test.ts +97 -0
- package/port/oracle/view/components/option-list-view.ts +68 -0
- package/port/oracle/view/components/preview/markdown-content-cache.ts +77 -0
- package/port/oracle/view/components/preview/preview-block-renderer.test.ts +147 -0
- package/port/oracle/view/components/preview/preview-block-renderer.ts +118 -0
- package/port/oracle/view/components/preview/preview-box-renderer.ts +86 -0
- package/port/oracle/view/components/preview/preview-layout-decider.test.ts +245 -0
- package/port/oracle/view/components/preview/preview-layout-decider.ts +207 -0
- package/port/oracle/view/components/preview/preview-pane.test.ts +970 -0
- package/port/oracle/view/components/preview/preview-pane.ts +233 -0
- package/port/oracle/view/components/submit-picker.test.ts +70 -0
- package/port/oracle/view/components/submit-picker.ts +65 -0
- package/port/oracle/view/components/tab-bar.test.ts +130 -0
- package/port/oracle/view/components/tab-bar.ts +56 -0
- package/port/oracle/view/components/wrapping-select.test.ts +659 -0
- package/port/oracle/view/components/wrapping-select.ts +297 -0
- package/port/oracle/view/dialog-builder.test.ts +25 -0
- package/port/oracle/view/dialog-builder.ts +272 -0
- package/port/oracle/view/dialog-container.test.ts +711 -0
- package/port/oracle/view/dialog-overflow.test.ts +601 -0
- package/port/oracle/view/props-adapter.test.ts +210 -0
- package/port/oracle/view/props-adapter.ts +117 -0
- package/port/oracle/view/stateful-view.ts +24 -0
- package/port/oracle/view/tab-components.ts +16 -0
- package/port/oracle/view/tab-content-strategy.ts +316 -0
- package/port/red-run.log +183 -0
- package/port/scenarios/guidance-in-request.json +18 -0
- package/port/scenarios/line-terminators.json +48 -0
- package/port/scenarios/mixed-questions.json +87 -0
- package/port/scenarios/multi-select-dismissed.json +49 -0
- package/port/scenarios/multi-select-numbers.json +57 -0
- package/port/scenarios/multi-select-variants.json +103 -0
- package/port/scenarios/other-input-dismissed.json +55 -0
- package/port/scenarios/partial-then-dismissed.json +69 -0
- package/port/scenarios/previews.json +54 -0
- package/port/scenarios/reserved-and-duplicates.json +157 -0
- package/port/scenarios/schema-rejections.json +225 -0
- package/port/scenarios/single-dismissed.json +52 -0
- package/port/scenarios/single-other-typed.json +55 -0
- package/port/scenarios/single-select.json +52 -0
- package/port/scenarios/two-calls.json +72 -0
- package/port/scenarios/unlisted-choice.json +52 -0
- package/port/slices.json +24 -0
- package/port/upstream-tests.json +719 -0
- package/provenance.json +22 -0
package/CREDITS.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Credits
|
|
2
|
+
|
|
3
|
+
`extensions/rpiv-ask-user-question` is a Go port of **@juicesharp/rpiv-ask-user-question** 2.11.0 from **rpiv-mono**,
|
|
4
|
+
https://github.com/juicesharp/rpiv-mono, by **juicesharp** (MIT, Copyright (c) 2026 juicesharp).
|
|
5
|
+
|
|
6
|
+
- Original: `packages/rpiv-ask-user-question`
|
|
7
|
+
- Commit: `61904e69e1a50e12585bdf15f0310e633a62ba36` (release v2.11.0)
|
|
8
|
+
- The unmodified original (its tests included, images omitted) is kept at
|
|
9
|
+
[`port/oracle/`](port/oracle) as the equivalence oracle, with the license at
|
|
10
|
+
[`port/oracle/LICENSE`](port/oracle/LICENSE). `@juicesharp/rpiv-config` 2.12.0 (MIT, juicesharp), the
|
|
11
|
+
original's configuration library, is installed from npm into `port/oracle/node_modules` and is not
|
|
12
|
+
copied; the port's `config.go` is the todo port's, which was written against its `config.ts` at the pinned commit.
|
|
13
|
+
|
|
14
|
+
The Go code, the scenarios and the tests were written for this Package by Michael Kinsy. Wording, tool schema,
|
|
15
|
+
prompt guidance and behavior follow the original. Modified paths: none of the original is modified; the port is a
|
|
16
|
+
separate implementation. Not ported: the locales (E1, English only: approved by the owner), the interactive questionnaire component and the overlay
|
|
17
|
+
collapse key (see [port/PORT.md](port/PORT.md)).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 juicesharp (the original, @juicesharp/rpiv-ask-user-question)
|
|
4
|
+
Copyright (c) 2026 Michael Kinsy (the Go port)
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,28 @@
|
|
|
1
|
-
#
|
|
1
|
+
# rpiv-ask-user-question (Go port, partial)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A Go extension for PiG: the `ask_user_question` tool, which lets the model put up to four structured questions
|
|
4
|
+
(2-4 options each, single or multiple choice, an optional preview per option) to you when it would otherwise guess.
|
|
5
|
+
Every question gets a "Type something." row for a free-text answer; dismissing a dialog declines the questionnaire.
|
|
6
|
+
|
|
7
|
+
It is a port of `@juicesharp/rpiv-ask-user-question` 2.11.0 (see [CREDITS.md](CREDITS.md)), made with the
|
|
8
|
+
[extension porting Skill](../extension-port/README.md). It needs no Node runtime.
|
|
9
|
+
|
|
10
|
+
## What is ported
|
|
11
|
+
|
|
12
|
+
This is **slice A**: the tool and everything the model sees and the equivalence checks can reach.
|
|
13
|
+
|
|
14
|
+
- The tool, its JSON Schema (limits enforced by the host), its description and prompt guidance, and the config
|
|
15
|
+
overrides (`~/.config/rpiv-ask-user-question/config.json`: `guidance.description`, `guidance.promptSnippet`,
|
|
16
|
+
`guidance.promptGuidelines`).
|
|
17
|
+
- Line-terminator normalisation, the validation (reserved and duplicate labels, option counts), the answer envelope
|
|
18
|
+
and decline text, the `rpiv:ask-user:prompt` and `rpiv:ask-user:blocked` events.
|
|
19
|
+
- The sequential dialog walker (one select or input dialog per question), which is what the original runs on RPC hosts.
|
|
20
|
+
**In a terminal this port runs the same dialogs**, where the original shows its tabbed questionnaire.
|
|
21
|
+
- Removing the tool from the active set on hosts without a UI (`before_agent_start`).
|
|
22
|
+
|
|
23
|
+
**Not ported yet (slice B):** the interactive questionnaire (tabs, per-option notes, submit tab, side-by-side preview
|
|
24
|
+
pane, external editor, collapse key), the locales, and the terminal bell. See [port/PORT.md](port/PORT.md).
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
pig install ./components/ask-user-question
|
|
28
|
+
```
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
package ask_user_question
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"os"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
"strings"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// Configuration: <config home>/rpiv-ask-user-question/config.json, read fresh on every call (no caching, no /reload).
|
|
11
|
+
// upstream: config.ts, and rpiv-config config.ts (loadJsonConfigWithLegacyFallback) at the same commit.
|
|
12
|
+
const (
|
|
13
|
+
// defaultCollapseKey is the collapse and expand shortcut when collapseKey is missing or invalid.
|
|
14
|
+
defaultCollapseKey = "ctrl+]"
|
|
15
|
+
// collapseKeyOff disables the collapse shortcut entirely.
|
|
16
|
+
collapseKeyOff = "off"
|
|
17
|
+
configName = "rpiv-ask-user-question"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
type guidanceFields struct {
|
|
21
|
+
PromptSnippet string
|
|
22
|
+
PromptGuidelines []string
|
|
23
|
+
Description string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// askConfig is the file's content. The loader does not validate: each resolver decides.
|
|
27
|
+
type askConfig struct {
|
|
28
|
+
Guidance any
|
|
29
|
+
CollapseKey any
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func homeDir() string {
|
|
33
|
+
home, _ := os.UserHomeDir()
|
|
34
|
+
return home
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func defaultConfigDir() string { return filepath.Join(homeDir(), ".config") }
|
|
38
|
+
|
|
39
|
+
// resolveConfigDir is $XDG_CONFIG_HOME when it is set and absolute (a leading ~ is expanded), else ~/.config.
|
|
40
|
+
func resolveConfigDir() string {
|
|
41
|
+
xdg := jsTrim(os.Getenv("XDG_CONFIG_HOME"))
|
|
42
|
+
if xdg == "" {
|
|
43
|
+
return defaultConfigDir()
|
|
44
|
+
}
|
|
45
|
+
switch {
|
|
46
|
+
case xdg == "~":
|
|
47
|
+
xdg = homeDir()
|
|
48
|
+
case strings.HasPrefix(xdg, "~/"):
|
|
49
|
+
xdg = filepath.Join(homeDir(), xdg[2:])
|
|
50
|
+
}
|
|
51
|
+
if filepath.IsAbs(xdg) {
|
|
52
|
+
return xdg
|
|
53
|
+
}
|
|
54
|
+
return defaultConfigDir()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// loadJSONConfig reads a JSON object; a missing, unreadable, invalid or non-object file reads as empty.
|
|
58
|
+
func loadJSONConfig(path string) map[string]any {
|
|
59
|
+
data, err := os.ReadFile(path)
|
|
60
|
+
if err != nil {
|
|
61
|
+
return map[string]any{}
|
|
62
|
+
}
|
|
63
|
+
var parsed any
|
|
64
|
+
if err := json.Unmarshal(data, &parsed); err != nil {
|
|
65
|
+
return map[string]any{}
|
|
66
|
+
}
|
|
67
|
+
m, ok := parsed.(map[string]any)
|
|
68
|
+
if !ok {
|
|
69
|
+
return map[string]any{}
|
|
70
|
+
}
|
|
71
|
+
return m
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
func fileExists(path string) bool {
|
|
75
|
+
_, err := os.Stat(path)
|
|
76
|
+
return err == nil
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
func loadConfig() askConfig {
|
|
80
|
+
xdgPath := filepath.Join(resolveConfigDir(), configName, "config.json")
|
|
81
|
+
m := loadJSONConfig(filepath.Join(defaultConfigDir(), configName, "config.json"))
|
|
82
|
+
if fileExists(xdgPath) {
|
|
83
|
+
m = loadJSONConfig(xdgPath)
|
|
84
|
+
}
|
|
85
|
+
return askConfig{Guidance: m["guidance"], CollapseKey: m["collapseKey"]}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
var (
|
|
89
|
+
specialKeys = map[string]bool{
|
|
90
|
+
"escape": true, "esc": true, "enter": true, "return": true, "tab": true, "space": true, "backspace": true,
|
|
91
|
+
"delete": true, "insert": true, "clear": true, "home": true, "end": true, "pageup": true, "pagedown": true,
|
|
92
|
+
"up": true, "down": true, "left": true, "right": true,
|
|
93
|
+
"f1": true, "f2": true, "f3": true, "f4": true, "f5": true, "f6": true, "f7": true, "f8": true,
|
|
94
|
+
"f9": true, "f10": true, "f11": true, "f12": true,
|
|
95
|
+
}
|
|
96
|
+
modifierKeys = map[string]bool{"ctrl": true, "shift": true, "alt": true, "super": true}
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
const baseKeyChars = "abcdefghijklmnopqrstuvwxyz0123456789_-!@#$%^&*()|~`'\":;,./<>?[]{}=\\"
|
|
100
|
+
|
|
101
|
+
// isValidCollapseKeySpec validates a spec against pi-tui's KeyId grammar: zero or more distinct modifiers,
|
|
102
|
+
// then a base key that is one printable character or a named special key. A loose check is not enough: pi-tui
|
|
103
|
+
// takes the last `+` part as the key and ignores unknown parts, so a typo like `ctr+]` would match every bare
|
|
104
|
+
// `]` keypress. upstream: config.ts:78-93.
|
|
105
|
+
func isValidCollapseKeySpec(spec string) bool {
|
|
106
|
+
if spec == "" || strings.HasPrefix(spec, "+") || strings.HasSuffix(spec, "+") || strings.Contains(spec, "++") {
|
|
107
|
+
return false
|
|
108
|
+
}
|
|
109
|
+
parts := strings.Split(spec, "+")
|
|
110
|
+
base := parts[len(parts)-1]
|
|
111
|
+
modifiers := parts[:len(parts)-1]
|
|
112
|
+
seen := map[string]bool{}
|
|
113
|
+
for _, m := range modifiers {
|
|
114
|
+
if seen[m] || !modifierKeys[m] {
|
|
115
|
+
return false
|
|
116
|
+
}
|
|
117
|
+
seen[m] = true
|
|
118
|
+
}
|
|
119
|
+
if r := []rune(base); len(r) == 1 && r[0] < 0x10000 {
|
|
120
|
+
return strings.ContainsRune(baseKeyChars, r[0])
|
|
121
|
+
}
|
|
122
|
+
return specialKeys[base]
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// resolveCollapseKey is the collapse key: the default when the field is missing, not a string, blank or
|
|
126
|
+
// invalid; the sentinel "off"; or the lowercased validated spec.
|
|
127
|
+
func resolveCollapseKey() string {
|
|
128
|
+
raw, ok := loadConfig().CollapseKey.(string)
|
|
129
|
+
if !ok {
|
|
130
|
+
return defaultCollapseKey
|
|
131
|
+
}
|
|
132
|
+
raw = strings.ToLower(jsTrim(raw))
|
|
133
|
+
switch {
|
|
134
|
+
case raw == "":
|
|
135
|
+
return defaultCollapseKey
|
|
136
|
+
case raw == collapseKeyOff:
|
|
137
|
+
return collapseKeyOff
|
|
138
|
+
case isValidCollapseKeySpec(raw):
|
|
139
|
+
return raw
|
|
140
|
+
}
|
|
141
|
+
return defaultCollapseKey
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// validateGuidanceFields keeps the guidance fields that have the right type and are not empty.
|
|
145
|
+
// upstream: rpiv-config config.ts validateGuidanceFields.
|
|
146
|
+
func validateGuidanceFields(fields any) guidanceFields {
|
|
147
|
+
g, ok := fields.(map[string]any)
|
|
148
|
+
if !ok {
|
|
149
|
+
return guidanceFields{}
|
|
150
|
+
}
|
|
151
|
+
var out guidanceFields
|
|
152
|
+
if s, ok := g["promptSnippet"].(string); ok && s != "" {
|
|
153
|
+
out.PromptSnippet = s
|
|
154
|
+
}
|
|
155
|
+
if list, ok := g["promptGuidelines"].([]any); ok && len(list) > 0 {
|
|
156
|
+
lines := make([]string, 0, len(list))
|
|
157
|
+
for _, e := range list {
|
|
158
|
+
s, ok := e.(string)
|
|
159
|
+
if !ok || s == "" {
|
|
160
|
+
lines = nil
|
|
161
|
+
break
|
|
162
|
+
}
|
|
163
|
+
lines = append(lines, s)
|
|
164
|
+
}
|
|
165
|
+
out.PromptGuidelines = lines
|
|
166
|
+
}
|
|
167
|
+
if s, ok := g["description"].(string); ok && s != "" {
|
|
168
|
+
out.Description = s
|
|
169
|
+
}
|
|
170
|
+
return out
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// formatKeySpecForDisplay renders a key spec for a hint: `ctrl+]` as `Ctrl+]`, `pageup` as `PageUp`.
|
|
174
|
+
// upstream: config.ts:formatKeySpecForDisplay.
|
|
175
|
+
func formatKeySpecForDisplay(spec string) string {
|
|
176
|
+
compound := map[string]string{"pageup": "PageUp", "pagedown": "PageDown"}
|
|
177
|
+
parts := strings.Split(spec, "+")
|
|
178
|
+
for i, p := range parts {
|
|
179
|
+
switch {
|
|
180
|
+
case compound[p] != "":
|
|
181
|
+
parts[i] = compound[p]
|
|
182
|
+
case len([]rune(p)) <= 1:
|
|
183
|
+
parts[i] = strings.ToUpper(p)
|
|
184
|
+
default:
|
|
185
|
+
parts[i] = strings.ToUpper(p[:1]) + p[1:]
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return strings.Join(parts, "+")
|
|
189
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
package ask_user_question
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"testing"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
const fConfig = "config"
|
|
10
|
+
|
|
11
|
+
// configHome points HOME at a temp dir (and clears XDG_CONFIG_HOME) and returns a writer for
|
|
12
|
+
// ~/.config/rpiv-ask-user-question/config.json (upstream: config.test.ts).
|
|
13
|
+
func configHome(t *testing.T) (write func(contents string)) {
|
|
14
|
+
t.Helper()
|
|
15
|
+
home := t.TempDir()
|
|
16
|
+
t.Setenv("HOME", home)
|
|
17
|
+
t.Setenv("USERPROFILE", home)
|
|
18
|
+
t.Setenv("XDG_CONFIG_HOME", "")
|
|
19
|
+
path := filepath.Join(home, ".config", "rpiv-ask-user-question", "config.json")
|
|
20
|
+
return func(contents string) {
|
|
21
|
+
t.Helper()
|
|
22
|
+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
23
|
+
t.Fatal(err)
|
|
24
|
+
}
|
|
25
|
+
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
|
|
26
|
+
t.Fatal(err)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func TestFormatKeySpecForDisplay(t *testing.T) {
|
|
32
|
+
tw(t, fConfig, "capitalizes each +-part of a resolved spec", func(t *testing.T) {
|
|
33
|
+
eq(t, formatKeySpecForDisplay("ctrl+shift+t"), "Ctrl+Shift+T", "spec")
|
|
34
|
+
eq(t, formatKeySpecForDisplay("alt+o"), "Alt+O", "alt")
|
|
35
|
+
})
|
|
36
|
+
tw(t, fConfig, "handles named special keys and bare keys", func(t *testing.T) {
|
|
37
|
+
eq(t, formatKeySpecForDisplay("escape"), "Escape", "escape")
|
|
38
|
+
eq(t, formatKeySpecForDisplay("f5"), "F5", "f5")
|
|
39
|
+
eq(t, formatKeySpecForDisplay("]"), "]", "bare")
|
|
40
|
+
})
|
|
41
|
+
tw(t, fConfig, "cases compound-word named keys conventionally", func(t *testing.T) {
|
|
42
|
+
eq(t, formatKeySpecForDisplay("pageup"), "PageUp", "pageup")
|
|
43
|
+
eq(t, formatKeySpecForDisplay("ctrl+pagedown"), "Ctrl+PageDown", "pagedown")
|
|
44
|
+
})
|
|
45
|
+
tw(t, fConfig, "round-trips the default key to the historical hint casing", func(t *testing.T) {
|
|
46
|
+
eq(t, formatKeySpecForDisplay(defaultCollapseKey), "Ctrl+]", "default")
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func TestResolveCollapseKey(t *testing.T) {
|
|
51
|
+
tw(t, fConfig, "returns the default when config has no collapseKey", func(t *testing.T) {
|
|
52
|
+
w := configHome(t)
|
|
53
|
+
eq(t, resolveCollapseKey(), "ctrl+]", "no file")
|
|
54
|
+
w(`{}`)
|
|
55
|
+
eq(t, resolveCollapseKey(), "ctrl+]", "empty config")
|
|
56
|
+
})
|
|
57
|
+
tw(t, fConfig, "returns the default when collapseKey is empty or whitespace", func(t *testing.T) {
|
|
58
|
+
w := configHome(t)
|
|
59
|
+
w(`{"collapseKey":""}`)
|
|
60
|
+
eq(t, resolveCollapseKey(), defaultCollapseKey, "empty")
|
|
61
|
+
w(`{"collapseKey":" "}`)
|
|
62
|
+
eq(t, resolveCollapseKey(), defaultCollapseKey, "blank")
|
|
63
|
+
})
|
|
64
|
+
tw(t, fConfig, "normalizes the spec (trim + lowercase)", func(t *testing.T) {
|
|
65
|
+
w := configHome(t)
|
|
66
|
+
w(`{"collapseKey":" Alt+O "}`)
|
|
67
|
+
eq(t, resolveCollapseKey(), "alt+o", "key")
|
|
68
|
+
})
|
|
69
|
+
tw(t, fConfig, "returns the off sentinel unchanged (case-insensitive)", func(t *testing.T) {
|
|
70
|
+
w := configHome(t)
|
|
71
|
+
for _, v := range []string{"off", "OFF", " Off "} {
|
|
72
|
+
w(`{"collapseKey":"` + v + `"}`)
|
|
73
|
+
eq(t, resolveCollapseKey(), collapseKeyOff, v)
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
tw(t, fConfig, "falls back to the default for malformed specs", func(t *testing.T) {
|
|
77
|
+
w := configHome(t)
|
|
78
|
+
for _, v := range []string{"+", "ctrl+", "+t", "ctrl++t", "ctrl+ctrl+t", ""} {
|
|
79
|
+
w(`{"collapseKey":"` + v + `"}`)
|
|
80
|
+
eq(t, resolveCollapseKey(), defaultCollapseKey, "spec "+v)
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
tw(t, fConfig, "falls back to the default for typo'd modifiers and unknown key names", func(t *testing.T) {
|
|
84
|
+
w := configHome(t)
|
|
85
|
+
for _, v := range []string{"ctr+]", "control+t", "ctrl+foo", "ctrl+f13", "meta+t"} {
|
|
86
|
+
w(`{"collapseKey":"` + v + `"}`)
|
|
87
|
+
eq(t, resolveCollapseKey(), defaultCollapseKey, "spec "+v)
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
tw(t, fConfig, "accepts named special keys and bare base keys", func(t *testing.T) {
|
|
91
|
+
w := configHome(t)
|
|
92
|
+
for _, v := range []string{"escape", "ctrl+pageup", "f12", "alt+o", "ctrl+]", "shift+tab", "super+k"} {
|
|
93
|
+
w(`{"collapseKey":"` + v + `"}`)
|
|
94
|
+
eq(t, resolveCollapseKey(), v, "spec "+v)
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
func TestLoadConfig(t *testing.T) {
|
|
100
|
+
tw(t, fConfig, "returns an empty config when no file is present", func(t *testing.T) {
|
|
101
|
+
configHome(t)
|
|
102
|
+
eq(t, loadConfig(), askConfig{}, "config")
|
|
103
|
+
})
|
|
104
|
+
tw(t, fConfig, "reads a valid JSON config", func(t *testing.T) {
|
|
105
|
+
w := configHome(t)
|
|
106
|
+
w(`{"collapseKey":"alt+o","guidance":{"promptSnippet":"s"}}`)
|
|
107
|
+
got := loadConfig()
|
|
108
|
+
eq(t, got.CollapseKey, "alt+o", "collapseKey")
|
|
109
|
+
eq(t, got.Guidance, map[string]any{"promptSnippet": "s"}, "guidance")
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
func TestConfigRobustness(t *testing.T) {
|
|
114
|
+
w := configHome(t)
|
|
115
|
+
for _, bad := range []string{`not json`, `[]`, `"x"`, `null`, ``} {
|
|
116
|
+
w(bad)
|
|
117
|
+
eq(t, loadConfig(), askConfig{}, "content "+bad)
|
|
118
|
+
}
|
|
119
|
+
// The default location is the legacy one; an XDG location wins when its file exists.
|
|
120
|
+
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
|
121
|
+
w(`{"collapseKey":"alt+o"}`)
|
|
122
|
+
eq(t, resolveCollapseKey(), "alt+o", "legacy location when XDG has no file")
|
|
123
|
+
xdg := os.Getenv("XDG_CONFIG_HOME")
|
|
124
|
+
if err := os.MkdirAll(filepath.Join(xdg, "rpiv-ask-user-question"), 0o755); err != nil {
|
|
125
|
+
t.Fatal(err)
|
|
126
|
+
}
|
|
127
|
+
if err := os.WriteFile(filepath.Join(xdg, "rpiv-ask-user-question", "config.json"), []byte(`{"collapseKey":"off"}`), 0o644); err != nil {
|
|
128
|
+
t.Fatal(err)
|
|
129
|
+
}
|
|
130
|
+
eq(t, resolveCollapseKey(), "off", "XDG file wins")
|
|
131
|
+
t.Setenv("XDG_CONFIG_HOME", "relative/dir")
|
|
132
|
+
eq(t, resolveCollapseKey(), "alt+o", "a relative XDG_CONFIG_HOME is ignored")
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
func TestValidateGuidanceFields(t *testing.T) {
|
|
136
|
+
eq(t, validateGuidanceFields(nil), guidanceFields{}, "nil")
|
|
137
|
+
eq(t, validateGuidanceFields("x"), guidanceFields{}, "string")
|
|
138
|
+
eq(t, validateGuidanceFields(map[string]any{"promptSnippet": "s", "promptGuidelines": []any{"a", "b"}, "description": "d"}),
|
|
139
|
+
guidanceFields{PromptSnippet: "s", PromptGuidelines: []string{"a", "b"}, Description: "d"}, "valid")
|
|
140
|
+
eq(t, validateGuidanceFields(map[string]any{"promptSnippet": "", "promptGuidelines": []any{"a", ""}, "description": 5.0}), guidanceFields{}, "invalid")
|
|
141
|
+
eq(t, validateGuidanceFields(map[string]any{"promptGuidelines": []any{}}), guidanceFields{}, "empty list")
|
|
142
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
package ask_user_question
|
|
2
|
+
|
|
3
|
+
// The tool's built-in model-facing copy. upstream: ask-user-question.ts DEFAULT_PROMPT_SNIPPET,
|
|
4
|
+
// DEFAULT_PROMPT_GUIDELINES and DEFAULT_TOOL_DESCRIPTION, with the limits (4 questions, 2-4 options) filled in.
|
|
5
|
+
var (
|
|
6
|
+
defaultPromptSnippet = "Ask the user up to 4 structured questions (2-4 options each) when requirements are ambiguous"
|
|
7
|
+
|
|
8
|
+
defaultPromptGuidelines = []string{
|
|
9
|
+
"Use ask_user_question whenever the user's request is underspecified and you cannot proceed without concrete decisions — you can ask up to 4 questions per invocation.",
|
|
10
|
+
"Each question MUST have 2-4 options. Every option requires a concise label (1-5 words) and a description explaining what the choice means or its trade-offs. The user can additionally type a custom answer via the automatically appended \"Type something.\" row on every question, or press Esc to abandon the questionnaire. Do NOT author \"Other\" or \"Type something.\" labels yourself — reserved labels are rejected at runtime.",
|
|
11
|
+
"Set multiSelect: true when multiple answers are valid. Provide an options[].preview markdown string when an option benefits from richer side-by-side context (mockups, code snippets, diagrams, configs) — single-select only. The \"Type something.\" row is appended to every question; in preview mode it expands to the full pane width while typing so the custom answer is not cramped into the narrow options column. If you recommend a specific option, make that the first option and append \"(Recommended)\" to its label.",
|
|
12
|
+
"Do not stack multiple ask_user_question calls back-to-back — group all clarifying questions into one invocation.",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
defaultToolDescription = "Ask the user one or more structured questions during execution. Use when you need to:\n" +
|
|
16
|
+
"1. Gather user preferences or requirements\n" +
|
|
17
|
+
"2. Clarify ambiguous instructions\n" +
|
|
18
|
+
"3. Get decisions on implementation choices as you work\n" +
|
|
19
|
+
"4. Offer choices to the user about what direction to take\n" +
|
|
20
|
+
"\n" +
|
|
21
|
+
"Usage notes:\n" +
|
|
22
|
+
"- Users can type a custom answer via the automatically appended \"Type something.\" row on every question or press Esc to abandon the questionnaire. Do NOT author \"Other\" or \"Type something.\" labels yourself — reserved labels are rejected at runtime.\n" +
|
|
23
|
+
"- Use multiSelect: true when multiple answers are valid. The \"Type something.\" row is available on every question, including when options carry a `preview`; in preview mode it expands to the full pane width while typing so the custom answer is not cramped into the narrow options column.\n" +
|
|
24
|
+
"- If you recommend a specific option, make that the first option in the list and add \"(Recommended)\" at the end of the label.\n" +
|
|
25
|
+
"\n" +
|
|
26
|
+
"Preview feature:\n" +
|
|
27
|
+
"Use the optional `preview` field on options when presenting concrete artifacts that users need to visually compare:\n" +
|
|
28
|
+
"- ASCII mockups of UI layouts or components\n" +
|
|
29
|
+
"- Code snippets showing different implementations\n" +
|
|
30
|
+
"- Diagram variations\n" +
|
|
31
|
+
"- Configuration examples\n" +
|
|
32
|
+
"\n" +
|
|
33
|
+
"Preview content is rendered as markdown in a monospace box. Multi-line text with newlines is supported. When any option has a preview, the UI switches to a side-by-side layout with a vertical option list on the left and preview on the right. Do not use previews for simple preference questions where labels and descriptions suffice. Note: previews are only supported for single-select questions (not multiSelect)."
|
|
34
|
+
)
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
package ask_user_question
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"testing"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
const fMain = "ask-user-question"
|
|
9
|
+
|
|
10
|
+
func twoQ() questionParams {
|
|
11
|
+
return params(qn("Q1?", "A", opt("x", "d"), opt("y", "d")), qn("Q2?", "B", opt("p", "d"), opt("q", "d")))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
func TestEnvelope(t *testing.T) {
|
|
15
|
+
const prefix, suffix = "User has answered your questions:", "You can now continue with the user's answers in mind."
|
|
16
|
+
tw(t, fMain, "null result → decline envelope + empty answers + cancelled true", func(t *testing.T) {
|
|
17
|
+
out := buildQuestionnaireResponse(nil, single())
|
|
18
|
+
eq(t, out.Text, "User declined to answer questions", "text")
|
|
19
|
+
eq(t, out.Details, questionnaireResult{Cancelled: true}, "details")
|
|
20
|
+
})
|
|
21
|
+
tw(t, fMain, "cancelled result preserves partial answers in details (not in content)", func(t *testing.T) {
|
|
22
|
+
r := &questionnaireResult{Cancelled: true, Answers: []questionAnswer{answerOpt(0, "Which?", "A")}}
|
|
23
|
+
out := buildQuestionnaireResponse(r, single())
|
|
24
|
+
eq(t, out.Text, "User declined to answer questions", "text")
|
|
25
|
+
eq(t, out.Details.Answers, r.Answers, "answers")
|
|
26
|
+
})
|
|
27
|
+
tw(t, fMain, "single answered question → CC envelope wrapper with question text and answer", func(t *testing.T) {
|
|
28
|
+
r := &questionnaireResult{Answers: []questionAnswer{answerOpt(0, "Which?", "A")}}
|
|
29
|
+
eq(t, buildQuestionnaireResponse(r, single()).Text, prefix+` "Which?"="A". `+suffix, "text")
|
|
30
|
+
})
|
|
31
|
+
tw(t, fMain, "envelope uses question text not header (no Q1 prefix)", func(t *testing.T) {
|
|
32
|
+
r := &questionnaireResult{Answers: []questionAnswer{answerOpt(0, "Which?", "A")}}
|
|
33
|
+
text := buildQuestionnaireResponse(r, single()).Text
|
|
34
|
+
if contains(text, "Pick") || contains(text, "Q1") {
|
|
35
|
+
t.Fatalf("header or Q1 leaked into %q", text)
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
tw(t, fMain, "two answered questions render as separate sentences inside one envelope", func(t *testing.T) {
|
|
39
|
+
r := &questionnaireResult{Answers: []questionAnswer{answerOpt(0, "Q1?", "x"), answerOpt(1, "Q2?", "q")}}
|
|
40
|
+
eq(t, buildQuestionnaireResponse(r, twoQ()).Text, prefix+` "Q1?"="x". "Q2?"="q". `+suffix, "text")
|
|
41
|
+
})
|
|
42
|
+
tw(t, fMain, "multiSelect answer renders as comma-joined labels in <A> position", func(t *testing.T) {
|
|
43
|
+
p := params(multi(qn("Q?", "H", opt("red", "r"), opt("blue", "b"))))
|
|
44
|
+
r := &questionnaireResult{Answers: []questionAnswer{answerMulti(0, "Q?", "red", "blue")}}
|
|
45
|
+
eq(t, buildQuestionnaireResponse(r, p).Text, prefix+` "Q?"="red, blue". `+suffix, "text")
|
|
46
|
+
})
|
|
47
|
+
tw(t, fMain, "custom typed answer renders raw text (no 'User answered:' prefix)", func(t *testing.T) {
|
|
48
|
+
r := &questionnaireResult{Answers: []questionAnswer{answerCustom(0, "Which?", "my own")}}
|
|
49
|
+
eq(t, buildQuestionnaireResponse(r, single()).Text, prefix+` "Which?"="my own". `+suffix, "text")
|
|
50
|
+
})
|
|
51
|
+
tw(t, fMain, "empty custom answer renders as (no input) in <A> position", func(t *testing.T) {
|
|
52
|
+
a := questionAnswer{QuestionIndex: 0, Question: "Which?", Kind: "custom"}
|
|
53
|
+
eq(t, buildQuestionnaireResponse(&questionnaireResult{Answers: []questionAnswer{a}}, single()).Text, prefix+` "Which?"="(no input)". `+suffix, "text")
|
|
54
|
+
a.Answer = sp("")
|
|
55
|
+
eq(t, buildQuestionnaireResponse(&questionnaireResult{Answers: []questionAnswer{a}}, single()).Text, prefix+` "Which?"="(no input)". `+suffix, "empty string")
|
|
56
|
+
})
|
|
57
|
+
tw(t, fMain, "notes are echoed as 'user notes: <text>' AND preserved in details", func(t *testing.T) {
|
|
58
|
+
a := answerOpt(0, "Which?", "A")
|
|
59
|
+
a.Notes = "because"
|
|
60
|
+
out := buildQuestionnaireResponse(&questionnaireResult{Answers: []questionAnswer{a}}, single())
|
|
61
|
+
eq(t, out.Text, prefix+` "Which?"="A". user notes: because. `+suffix, "text")
|
|
62
|
+
eq(t, at(out.Details.Answers, 0).Notes, "because", "details")
|
|
63
|
+
})
|
|
64
|
+
tw(t, fMain, "multiSelect answer with notes renders 'user notes:' suffix and NO 'selected preview:' suffix (FR-7)", func(t *testing.T) {
|
|
65
|
+
a := answerMulti(0, "Q?", "red")
|
|
66
|
+
a.Notes = "n"
|
|
67
|
+
p := params(multi(qn("Q?", "H", opt("red", "r"), opt("blue", "b"))))
|
|
68
|
+
eq(t, buildQuestionnaireResponse(&questionnaireResult{Answers: []questionAnswer{a}}, p).Text, prefix+` "Q?"="red". user notes: n. `+suffix, "text")
|
|
69
|
+
})
|
|
70
|
+
tw(t, fMain, "cancelled: false with no matching answers still returns DECLINE_MESSAGE text", func(t *testing.T) {
|
|
71
|
+
out := buildQuestionnaireResponse(&questionnaireResult{}, single())
|
|
72
|
+
eq(t, out.Text, "User declined to answer questions", "text")
|
|
73
|
+
eq(t, out.Details, questionnaireResult{Cancelled: true}, "details")
|
|
74
|
+
})
|
|
75
|
+
tw(t, fMain, "success + note: exact envelope bytes with the trailing 'global note: <note>.' segment (raw multiline echo)", func(t *testing.T) {
|
|
76
|
+
r := &questionnaireResult{Answers: []questionAnswer{answerOpt(0, "Which?", "A")}, GlobalNote: "line one\nline two"}
|
|
77
|
+
eq(t, buildQuestionnaireResponse(r, single()).Text, prefix+` "Which?"="A". global note: line one`+"\n"+`line two. `+suffix, "text")
|
|
78
|
+
})
|
|
79
|
+
tw(t, fMain, "the global-note segment is anchored by the envelope suffix", func(t *testing.T) {
|
|
80
|
+
r := &questionnaireResult{Answers: []questionAnswer{answerOpt(0, "Which?", "A")}, GlobalNote: "n"}
|
|
81
|
+
text := buildQuestionnaireResponse(r, single()).Text
|
|
82
|
+
eq(t, tail(text, len(" global note: n. "+suffix)), " global note: n. "+suffix, "tail")
|
|
83
|
+
})
|
|
84
|
+
tw(t, fMain, "zero answers + note is NOT a decline — answered envelope with details by reference", func(t *testing.T) {
|
|
85
|
+
r := &questionnaireResult{GlobalNote: "just this"}
|
|
86
|
+
out := buildQuestionnaireResponse(r, single())
|
|
87
|
+
eq(t, out.Text, prefix+` global note: just this. `+suffix, "text")
|
|
88
|
+
eq(t, out.Details, *r, "details")
|
|
89
|
+
})
|
|
90
|
+
tw(t, fMain, "cancelled + note: DECLINE_MESSAGE text, answers forwarded AND globalNote preserved in details", func(t *testing.T) {
|
|
91
|
+
r := &questionnaireResult{Cancelled: true, GlobalNote: "kept", Answers: []questionAnswer{answerOpt(0, "Which?", "A")}}
|
|
92
|
+
out := buildQuestionnaireResponse(r, single())
|
|
93
|
+
eq(t, out.Text, "User declined to answer questions", "text")
|
|
94
|
+
eq(t, out.Details, questionnaireResult{Cancelled: true, GlobalNote: "kept", Answers: r.Answers}, "details")
|
|
95
|
+
})
|
|
96
|
+
tw(t, fMain, "zero answers + no note still declines (fresh details literal carries no globalNote key)", func(t *testing.T) {
|
|
97
|
+
out := buildQuestionnaireResponse(&questionnaireResult{}, single())
|
|
98
|
+
b, _ := json.Marshal(out.Details)
|
|
99
|
+
eq(t, string(b), `{"answers":[],"cancelled":true}`, "wire")
|
|
100
|
+
})
|
|
101
|
+
tw(t, fMain, "formats 2 answered questions as comma-period-separated segments inside one envelope", func(t *testing.T) {
|
|
102
|
+
r := &questionnaireResult{Answers: []questionAnswer{answerOpt(0, "Q1?", "x"), answerCustom(1, "Q2?", "free")}}
|
|
103
|
+
eq(t, buildQuestionnaireResponse(r, twoQ()).Text, prefix+` "Q1?"="x". "Q2?"="free". `+suffix, "text")
|
|
104
|
+
})
|
|
105
|
+
tw(t, fMain, "formats 3 questions with mixed answer types in single-line envelope", func(t *testing.T) {
|
|
106
|
+
p := params(qn("Q1?", "A", two()...), multi(qn("Q2?", "B", opt("p", "d"), opt("q", "d"))), qn("Q3?", "C", two()...))
|
|
107
|
+
r := &questionnaireResult{Answers: []questionAnswer{answerOpt(0, "Q1?", "A"), answerMulti(1, "Q2?", "p", "q"), answerCustom(2, "Q3?", "z")}}
|
|
108
|
+
text := buildQuestionnaireResponse(r, p).Text
|
|
109
|
+
eq(t, text, prefix+` "Q1?"="A". "Q2?"="p, q". "Q3?"="z". `+suffix, "text")
|
|
110
|
+
if contains(text, "\n") {
|
|
111
|
+
t.Fatalf("envelope spans lines: %q", text)
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
tw(t, fMain, "skips unanswered questions (omits their segment from envelope)", func(t *testing.T) {
|
|
115
|
+
r := &questionnaireResult{Answers: []questionAnswer{answerOpt(1, "Q2?", "p")}}
|
|
116
|
+
eq(t, buildQuestionnaireResponse(r, twoQ()).Text, prefix+` "Q2?"="p". `+suffix, "text")
|
|
117
|
+
})
|
|
118
|
+
tw(t, fMain, "preserves notes in details AND echoes them across multiple questions", func(t *testing.T) {
|
|
119
|
+
a, b := answerOpt(0, "Q1?", "x"), answerOpt(1, "Q2?", "p")
|
|
120
|
+
a.Notes, b.Notes = "n1", "n2"
|
|
121
|
+
out := buildQuestionnaireResponse(&questionnaireResult{Answers: []questionAnswer{a, b}}, twoQ())
|
|
122
|
+
eq(t, out.Text, prefix+` "Q1?"="x". user notes: n1. "Q2?"="p". user notes: n2. `+suffix, "text")
|
|
123
|
+
eq(t, at(out.Details.Answers, 1).Notes, "n2", "details")
|
|
124
|
+
})
|
|
125
|
+
tw(t, fMain, "echoes preview text in envelope when single-select answer matches a preview-bearing option", func(t *testing.T) {
|
|
126
|
+
a := answerOpt(0, "Which?", "A")
|
|
127
|
+
a.Preview = "+--+"
|
|
128
|
+
eq(t, buildQuestionnaireResponse(&questionnaireResult{Answers: []questionAnswer{a}}, single()).Text, prefix+` "Which?"="A". selected preview: +--+. `+suffix, "text")
|
|
129
|
+
})
|
|
130
|
+
tw(t, fMain, "omits 'selected preview:' fragment when answer has no preview", func(t *testing.T) {
|
|
131
|
+
text := buildQuestionnaireResponse(&questionnaireResult{Answers: []questionAnswer{answerOpt(0, "Which?", "A")}}, single()).Text
|
|
132
|
+
if contains(text, "selected preview") {
|
|
133
|
+
t.Fatalf("preview fragment in %q", text)
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
tw(t, fMain, "omits 'user notes:' fragment when answer has no notes", func(t *testing.T) {
|
|
137
|
+
text := buildQuestionnaireResponse(&questionnaireResult{Answers: []questionAnswer{answerOpt(0, "Which?", "A")}}, single()).Text
|
|
138
|
+
if contains(text, "user notes") {
|
|
139
|
+
t.Fatalf("notes fragment in %q", text)
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
tw(t, fMain, "envelope wraps with CC prefix and suffix sentences", func(t *testing.T) {
|
|
143
|
+
text := buildQuestionnaireResponse(&questionnaireResult{Answers: []questionAnswer{answerOpt(0, "Which?", "A")}}, single()).Text
|
|
144
|
+
eq(t, head(text, len(prefix)), prefix, "prefix")
|
|
145
|
+
eq(t, tail(text, len(suffix)), suffix, "suffix")
|
|
146
|
+
})
|
|
147
|
+
tw(t, fMain, "locks the envelope shape", func(t *testing.T) {
|
|
148
|
+
out := buildQuestionnaireResponse(&questionnaireResult{Answers: []questionAnswer{answerOpt(0, "Which?", "A")}}, single())
|
|
149
|
+
b, _ := json.Marshal(out.Details)
|
|
150
|
+
eq(t, string(b), `{"answers":[{"answer":"A","kind":"option","question":"Which?","questionIndex":0}],"cancelled":false}`, "details wire")
|
|
151
|
+
})
|
|
152
|
+
tskip(t, fMain, "passes details by reference (no clone)", "Go passes the result by value into the output; there is no reference identity to assert (the details equal the input: TestEnvelopeDetailsEqualTheInput)")
|
|
153
|
+
tw(t, fMain, "accepts error field in envelope", func(t *testing.T) {
|
|
154
|
+
out := buildToolResult("Error: x", questionnaireResult{Cancelled: true, Error: errNoUI})
|
|
155
|
+
b, _ := json.Marshal(out.Details)
|
|
156
|
+
eq(t, string(b), `{"answers":[],"cancelled":true,"error":"no_ui"}`, "wire")
|
|
157
|
+
eq(t, out.Text, "Error: x", "text")
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
func TestEnvelopeDetailsEqualTheInput(t *testing.T) {
|
|
162
|
+
r := &questionnaireResult{Answers: []questionAnswer{answerOpt(0, "Which?", "A")}}
|
|
163
|
+
eq(t, buildQuestionnaireResponse(r, single()).Details, *r, "details")
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
func TestAnswerSegmentOrdersPreviewBeforeNotes(t *testing.T) {
|
|
167
|
+
a := answerOpt(0, "Q?", "A")
|
|
168
|
+
a.Preview, a.Notes = "P", "N"
|
|
169
|
+
eq(t, buildAnswerSegment(a), `"Q?"="A". selected preview: P. user notes: N.`, "segment")
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
func TestFormatAnswerScalar(t *testing.T) {
|
|
173
|
+
eq(t, formatAnswerScalar(answerMulti(0, "Q", "a", "b")), "a, b", "multi")
|
|
174
|
+
eq(t, formatAnswerScalar(answerMulti(0, "Q")), "(no input)", "multi empty")
|
|
175
|
+
eq(t, formatAnswerScalar(questionAnswer{Kind: "option"}), "(no input)", "option without answer")
|
|
176
|
+
eq(t, formatAnswerScalar(answerOpt(0, "Q", "")), "", "option with an empty answer keeps it (?? only replaces null)")
|
|
177
|
+
}
|