Files
zero/internal/tools/ask_user_test.go
KRATOS 1d1fa7c514 feat(tui): ask_user suggested answers with a recommended default (#326)
* 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".
2026-06-27 14:12:41 +05:30

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)
}
}