返回 DeepSeek-Reasonix
ask_test.go
根目录 / internal / agent / ask_test.go
1 package agent
2
3 import (
4 "context"
5 "crypto/sha256"
6 "fmt"
7 "strings"
8 "testing"
9
10 "reasonix/internal/event"
11 "reasonix/internal/provider"
12 )
13
14 type recordingAsker struct {
15 questions []event.AskQuestion
16 }
17
18 func (r *recordingAsker) Ask(_ context.Context, questions []event.AskQuestion) ([]event.AskAnswer, error) {
19 r.questions = questions
20 return []event.AskAnswer{{QuestionID: "q1", Selected: []string{"Keep going"}}}, nil
21 }
22
23 func TestAskToolRejectsBlankOptionLabels(t *testing.T) {
24 _, err := NewAskTool().Execute(context.Background(), []byte(`{
25 "questions":[{
26 "header":"Direction",
27 "question":"Which path?",
28 "options":[
29 {"label":"Keep going"},
30 {"label":" ","description":"blank labels render as empty picker rows"}
31 ]
32 }]
33 }`))
34 if err == nil {
35 t.Fatal("expected blank option label to be rejected")
36 }
37 if !strings.Contains(err.Error(), "option 2") || !strings.Contains(err.Error(), "label") {
38 t.Fatalf("error = %v, want it to identify the blank option label", err)
39 }
40 }
41
42 func TestAskToolRejectsDuplicateOptionLabelsAfterTrimming(t *testing.T) {
43 _, err := NewAskTool().Execute(context.Background(), []byte(`{
44 "questions":[{
45 "header":"Release",
46 "question":"What should happen next?",
47 "options":[
48 {"label":"Deploy"},
49 {"label":" Deploy ","description":"same label after trimming"}
50 ]
51 }]
52 }`))
53 if err == nil {
54 t.Fatal("expected duplicate trimmed option label to be rejected")
55 }
56 if !strings.Contains(err.Error(), "option 2") || !strings.Contains(err.Error(), "duplicate") || !strings.Contains(err.Error(), "Deploy") {
57 t.Fatalf("error = %v, want it to identify the duplicate option label", err)
58 }
59 }
60
61 func TestAskToolRejectsExactDuplicateOptionLabels(t *testing.T) {
62 _, err := NewAskTool().Execute(context.Background(), []byte(`{
63 "questions":[{
64 "header":"Release",
65 "question":"What should happen next?",
66 "options":[
67 {"label":"Deploy"},
68 {"label":"Deploy"}
69 ]
70 }]
71 }`))
72 if err == nil {
73 t.Fatal("expected duplicate option label to be rejected")
74 }
75 if !strings.Contains(err.Error(), "option 2") || !strings.Contains(err.Error(), "duplicate") || !strings.Contains(err.Error(), "Deploy") {
76 t.Fatalf("error = %v, want it to identify the duplicate option label", err)
77 }
78 }
79
80 func TestAskToolTrimsPromptAndOptionsBeforePrompting(t *testing.T) {
81 asker := &recordingAsker{}
82 ctx := withCallContext(context.Background(), "call_1", event.Discard, asker, false)
83 out, err := NewAskTool().Execute(ctx, []byte(`{
84 "questions":[{
85 "header":" Direction ",
86 "question":" Which path? ",
87 "options":[
88 {"label":" Keep going ","description":" normal path "},
89 {"label":" Stop "}
90 ]
91 }]
92 }`))
93 if err != nil {
94 t.Fatalf("Execute: %v", err)
95 }
96 if !strings.Contains(out, "Direction: Keep going") {
97 t.Fatalf("answer summary = %q, want trimmed header and answer", out)
98 }
99 if len(asker.questions) != 1 {
100 t.Fatalf("questions = %+v, want one", asker.questions)
101 }
102 q := asker.questions[0]
103 if q.Header != "Direction" || q.Prompt != "Which path?" {
104 t.Fatalf("prompt text not trimmed: %+v", q)
105 }
106 if q.Options[0].Label != "Keep going" || q.Options[0].Description != "normal path" {
107 t.Fatalf("option text not trimmed: %+v", q.Options[0])
108 }
109 }
110
111 type fixedAsker struct{ answers []event.AskAnswer }
112
113 func (f fixedAsker) Ask(_ context.Context, _ []event.AskQuestion) ([]event.AskAnswer, error) {
114 return f.answers, nil
115 }
116
117 func TestAskToolProviderContractStable(t *testing.T) {
118 tool := NewAskTool()
119 contract := tool.Description() + "\n" + string(provider.CanonicalizeSchema(tool.Schema()))
120 got := fmt.Sprintf("%x", sha256.Sum256([]byte(contract)))
121 const want = "f4c6efe84da2e964b3f8566b1f0921ca88812ae3ecfba46185d0439ae4f4c2a5"
122 if got != want {
123 t.Fatalf("ask provider contract hash = %s, want %s; tool description or canonical schema changed", got, want)
124 }
125 }
126
127 func TestAskToolDismissTellsModelToStopNotProceed(t *testing.T) {
128 ctx := withCallContext(context.Background(), "call_1", event.Discard, fixedAsker{answers: nil}, false)
129 out, err := NewAskTool().Execute(ctx, []byte(`{
130 "questions":[{
131 "header":"Config",
132 "question":"Configure a statusline script?",
133 "options":[{"label":"Yes"},{"label":"No"}]
134 }]
135 }`))
136 if err != nil {
137 t.Fatalf("Execute: %v", err)
138 }
139 if strings.Contains(out, "(no answer)") {
140 t.Fatalf("dismiss result still uses the (no answer) wording the model reads as proceed: %q", out)
141 }
142 if !strings.Contains(out, "Do not") || !strings.Contains(out, "wait for the user") {
143 t.Fatalf("dismiss result should tell the model to stop and wait, got %q", out)
144 }
145 }
146
147 func TestAskToolPartialAnswerMarksUnansweredQuestions(t *testing.T) {
148 ctx := withCallContext(context.Background(), "call_1", event.Discard,
149 fixedAsker{answers: []event.AskAnswer{{QuestionID: "q1", Selected: []string{"Deploy"}}}}, false)
150 out, err := NewAskTool().Execute(ctx, []byte(`{
151 "questions":[
152 {"header":"Release","question":"What next?","options":[{"label":"Deploy"},{"label":"Hold"}]},
153 {"header":"Notify","question":"Tell the team?","options":[{"label":"Yes"},{"label":"No"}]}
154 ]
155 }`))
156 if err != nil {
157 t.Fatalf("Execute: %v", err)
158 }
159 if !strings.Contains(out, "Release: Deploy") {
160 t.Fatalf("answered question should be reported, got %q", out)
161 }
162 if !strings.Contains(out, "Notify:") || !strings.Contains(out, "don't assume a choice") {
163 t.Fatalf("unanswered question should be marked, got %q", out)
164 }
165 }
166
167 func TestAskToolHeadlessFallbackIsExplicitModelAssumption(t *testing.T) {
168 out, err := NewAskTool().Execute(context.Background(), []byte(`{
169 "questions":[{
170 "header":"Direction",
171 "question":"Which path?",
172 "options":[
173 {"label":"Keep going"},
174 {"label":"Stop"}
175 ]
176 }]
177 }`))
178 if err != nil {
179 t.Fatalf("Execute: %v", err)
180 }
181 for _, want := range []string{"No interactive user answered", "model-assumption fallback", "not a user answer"} {
182 if !strings.Contains(out, want) {
183 t.Fatalf("headless fallback = %q, want it to contain %q", out, want)
184 }
185 }
186 if strings.Contains(out, "The user answered") {
187 t.Fatalf("headless fallback must not be formatted as a user answer: %q", out)
188 }
189 }
190
190 lines GO