mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 13:11:30 -10:00
* feat(tui): suggested answers with a recommended default for ask_user When the agent asks a clarifying question mid-task, it can now offer 2-4 suggested answers and mark one recommended; the interactive TUI renders a selector (arrow keys, lime cursor, "(recommended)" tag) with the cursor resting on the recommended option, plus a trailing "✎ Type my own answer…" entry that drops into the existing free-text composer. Whichever the user picks — a suggested option's text or their typed answer — flows through the same answer channel the agent loop already waits on. Optional and backward-compatible: a question with no options renders the plain free-text prompt exactly as before. Headless `zero exec` is unaffected (it never wires OnAskUser; the non-interactive best-assumption fallback stands). - contract: agent.AskUserQuestion / tools.AskUserQuestion gain Recommended; the tool schema advertises optional options + recommended; parser keeps recommended only when it resolves to one of the options (canonical text). - system prompt: tells the model it MAY supply options + a recommended one. - TUI: pendingAskUserPrompt gains cursor/typing; ask_user_prompt.go holds the selector logic mirroring permission_prompt.go; renderFocusedAskUserPrompt renders the picker or the free-text prompt. Tests: selector defaults to recommended and returns its text; "type my own" falls back to free-text and returns the typed text; no-options stays plain free-text. make build / go vet / go test ./... -race / make lint green. * fix(tui): ask_user — single input, Esc steps back from "type my own" Two UX fixes from interactive testing: - No double display: the focused ask card no longer echoes the in-progress free-text answer (it was shown both inside the card AND in the composer text box). The composer is now the single input; the card just shows the question and a hint. renderFocusedAskUserPrompt no longer takes the input string. - Esc from "type my own" returns to the selector for that question instead of cancelling the whole questionnaire (escapeAskUser); a question with no options, or the selector itself, still cancels on Esc as before. Tests: type-my-own asserts the card does not echo the typed text and the hint flips to "type your own answer"; new test covers Esc returning to the selector (no cancel, no answer delivered) then selecting a suggested option. * fix(tui): ask_user review fixes — multi-select, selector typing, dead code Address the three confirmed findings from the code review: - multi-select no longer silently narrowed to one option: a single-pick selector cannot represent MultiSelect, so a multi-select question now renders as free-text with its options surfaced as suggestions; the user types one or more and the answer is returned verbatim (syncQuestionState; escapeAskUser and the renderer updated to match). - typing in selector mode no longer discarded: a printable keystroke while the selector is showing flips into free-text and captures the key (model.go key capture), instead of accumulating in a hidden input that Enter then overrode with the highlighted option. - removed dead submitAskUserAnswer (the Enter handler now routes through confirmAskUser; the orphan had no callers). Tests: multi-select falls back to free-text and returns the typed answer; a printable key in the selector switches to free-text and is captured. Full gate green (build / vet / go test ./... -race / make lint). * fix(tui): address PR review — element-wise option compare, clearer Esc copy - ask_user_test: compare option slices with slices.Equal instead of strings.Join, so the parser test can't be fooled by ["A,B"] vs ["A","B"] or nil vs [""]. - ask_user prompt: the Esc hint said "skip", which reads like "skip this question" on a multi-question prompt even though Esc ends the rest of the questionnaire (remaining answered by best-assumption). Reworded to "skip the rest" in the selector and free-text hints; "type my own" keeps "go back".
228 lines
7.5 KiB
Go
228 lines
7.5 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestParseAskUserRecommended(t *testing.T) {
|
|
// recommended is kept only when it resolves to one of the options (canonical
|
|
// option text), case-insensitively; otherwise it is dropped so Recommended stays
|
|
// either empty or a member of Options.
|
|
cases := []struct {
|
|
name string
|
|
options []any
|
|
recommended any
|
|
wantOptions []string
|
|
wantRec string
|
|
}{
|
|
{"exact match", []any{"Postgres", "SQLite"}, "SQLite", []string{"Postgres", "SQLite"}, "SQLite"},
|
|
{"case-insensitive maps to canonical", []any{"Postgres", "SQLite"}, "sqlite", []string{"Postgres", "SQLite"}, "SQLite"},
|
|
{"non-member dropped", []any{"Postgres", "SQLite"}, "Mongo", []string{"Postgres", "SQLite"}, ""},
|
|
{"no options drops recommended", nil, "SQLite", nil, ""},
|
|
{"non-string recommended ignored", []any{"A", "B"}, 3, []string{"A", "B"}, ""},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
question := map[string]any{"question": "Which one?"}
|
|
if c.options != nil {
|
|
question["options"] = c.options
|
|
}
|
|
if c.recommended != nil {
|
|
question["recommended"] = c.recommended
|
|
}
|
|
parsed, err := ParseAskUserQuestions(map[string]any{"questions": []any{question}})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(parsed) != 1 {
|
|
t.Fatalf("expected one question, got %d", len(parsed))
|
|
}
|
|
if !slices.Equal(parsed[0].Options, c.wantOptions) {
|
|
t.Fatalf("options = %#v, want %#v", parsed[0].Options, c.wantOptions)
|
|
}
|
|
if parsed[0].Recommended != c.wantRec {
|
|
t.Fatalf("recommended = %q, want %q", parsed[0].Recommended, c.wantRec)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAskUserToolAdvertisesRecommended(t *testing.T) {
|
|
schema := NewAskUserTool().Parameters()
|
|
if _, ok := schema.Properties["questions"].Items.Properties["recommended"]; !ok {
|
|
t.Fatal("expected question item to document a recommended field")
|
|
}
|
|
}
|
|
|
|
func TestAskUserToolSafetyIsReadOnly(t *testing.T) {
|
|
safety := NewAskUserTool().Safety()
|
|
if safety.Permission != PermissionAllow {
|
|
t.Fatalf("expected ask_user to be permission=allow, got %q", safety.Permission)
|
|
}
|
|
if safety.SideEffect != SideEffectRead {
|
|
t.Fatalf("expected ask_user side effect read, got %q", safety.SideEffect)
|
|
}
|
|
}
|
|
|
|
func TestAskUserToolAdvertisesQuestionSchema(t *testing.T) {
|
|
schema := NewAskUserTool().Parameters()
|
|
questions, ok := schema.Properties["questions"]
|
|
if !ok {
|
|
t.Fatal("expected ask_user to advertise a questions property")
|
|
}
|
|
if questions.Type != "array" || questions.Items == nil {
|
|
t.Fatalf("expected questions to be an array with item schema, got %#v", questions)
|
|
}
|
|
if questions.Items.Type != "object" {
|
|
t.Fatalf("expected question items to be objects, got %q", questions.Items.Type)
|
|
}
|
|
if _, ok := questions.Items.Properties["question"]; !ok {
|
|
t.Fatal("expected question item to document a question field")
|
|
}
|
|
if _, ok := questions.Items.Properties["options"]; !ok {
|
|
t.Fatal("expected question item to document an options field")
|
|
}
|
|
if _, ok := questions.Items.Properties["multiSelect"]; !ok {
|
|
t.Fatal("expected question item to document a multiSelect field")
|
|
}
|
|
requiredQuestion := false
|
|
for _, name := range questions.Items.Required {
|
|
if name == "question" {
|
|
requiredQuestion = true
|
|
}
|
|
}
|
|
if !requiredQuestion {
|
|
t.Fatalf("expected question field to be required, got %v", questions.Items.Required)
|
|
}
|
|
requiredQuestions := false
|
|
for _, name := range schema.Required {
|
|
if name == "questions" {
|
|
requiredQuestions = true
|
|
}
|
|
}
|
|
if !requiredQuestions {
|
|
t.Fatalf("expected questions to be required, got %v", schema.Required)
|
|
}
|
|
}
|
|
|
|
func TestAskUserToolAdvertisesNonEmptyQuestionSchema(t *testing.T) {
|
|
data, err := json.Marshal(NewAskUserTool().Parameters())
|
|
if err != nil {
|
|
t.Fatalf("marshal schema: %v", err)
|
|
}
|
|
schema := string(data)
|
|
for _, want := range []string{`"minItems":1`, `"minLength":1`} {
|
|
if !strings.Contains(schema, want) {
|
|
t.Fatalf("ask_user schema missing %s: %s", want, schema)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAskUserToolRunFallsBackWhenNonInteractive(t *testing.T) {
|
|
// The tool's own Run() is only reached when nothing intercepted the call,
|
|
// i.e. there is no interactive user. It must degrade gracefully, never block.
|
|
result := NewAskUserTool().Run(context.Background(), map[string]any{
|
|
"questions": []any{
|
|
map[string]any{"question": "Which framework?"},
|
|
},
|
|
})
|
|
if result.Status != StatusOK {
|
|
t.Fatalf("expected ok status from graceful fallback, got %s: %s", result.Status, result.Output)
|
|
}
|
|
if !strings.Contains(strings.ToLower(result.Output), "no interactive user") {
|
|
t.Fatalf("expected no-interactive-user message, got %q", result.Output)
|
|
}
|
|
if !strings.Contains(strings.ToLower(result.Output), "assumption") {
|
|
t.Fatalf("expected guidance to proceed with assumptions, got %q", result.Output)
|
|
}
|
|
}
|
|
|
|
func TestAskUserToolRunRejectsMissingQuestions(t *testing.T) {
|
|
result := NewAskUserTool().Run(context.Background(), map[string]any{
|
|
"questions": []any{},
|
|
})
|
|
if result.Status != StatusError {
|
|
t.Fatalf("expected error status when no questions provided, got %s", result.Status)
|
|
}
|
|
if !strings.Contains(strings.ToLower(result.Output), "at least one question") {
|
|
t.Fatalf("unexpected output: %q", result.Output)
|
|
}
|
|
}
|
|
|
|
func TestCoreReadOnlyToolsIncludeAskUser(t *testing.T) {
|
|
found := false
|
|
for _, tool := range CoreReadOnlyTools(t.TempDir()) {
|
|
if tool.Name() == "ask_user" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("expected ask_user in core read-only tools")
|
|
}
|
|
}
|
|
|
|
func TestParseAskUserQuestionsLenientOptions(t *testing.T) {
|
|
// minimax-style: options as array of objects with a label field.
|
|
qs, err := ParseAskUserQuestions(map[string]any{
|
|
"questions": []any{
|
|
map[string]any{"question": "Pick a style", "options": []any{
|
|
map[string]any{"label": "Modern"},
|
|
map[string]any{"value": "Classic"},
|
|
"Minimal",
|
|
}},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("objects/strings options must not error: %v", err)
|
|
}
|
|
if got := qs[0].Options; len(got) != 3 || got[0] != "Modern" || got[1] != "Classic" || got[2] != "Minimal" {
|
|
t.Fatalf("coerced options = %v, want [Modern Classic Minimal]", got)
|
|
}
|
|
|
|
// options as a single newline-delimited string.
|
|
qs, err = ParseAskUserQuestions(map[string]any{
|
|
"questions": []any{map[string]any{"question": "q", "options": "A\nB"}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("string options must not error: %v", err)
|
|
}
|
|
if got := qs[0].Options; len(got) != 2 || got[0] != "A" || got[1] != "B" {
|
|
t.Fatalf("string-split options = %v, want [A B]", got)
|
|
}
|
|
|
|
// no options at all = valid free-text question.
|
|
if _, err := ParseAskUserQuestions(map[string]any{
|
|
"questions": []any{map[string]any{"question": "free text?"}},
|
|
}); err != nil {
|
|
t.Fatalf("missing options must be allowed: %v", err)
|
|
}
|
|
|
|
// multiSelect is a UI hint: an uncoercible value must NOT fail the call; it
|
|
// defaults to false (best-effort, like options).
|
|
qs, err = ParseAskUserQuestions(map[string]any{
|
|
"questions": []any{map[string]any{"question": "q", "multiSelect": "maybe"}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("uncoercible multiSelect must not error: %v", err)
|
|
}
|
|
if qs[0].MultiSelect {
|
|
t.Fatalf("uncoercible multiSelect should default to false")
|
|
}
|
|
}
|
|
|
|
func TestParseAskUserQuestionsStringItem(t *testing.T) {
|
|
// minimax-style: a question item that is a bare string, not an object.
|
|
qs, err := ParseAskUserQuestions(map[string]any{"questions": []any{"What is your name?"}})
|
|
if err != nil {
|
|
t.Fatalf("string question item must not error: %v", err)
|
|
}
|
|
if len(qs) != 1 || qs[0].Question != "What is your name?" {
|
|
t.Fatalf("bare-string question = %+v", qs)
|
|
}
|
|
}
|