返回 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 calls int
17 }
18
19 func (r *recordingAsker) Ask(_ context.Context, questions []event.AskQuestion) ([]event.AskAnswer, error) {
20 r.calls++
21 r.questions = questions
22 return []event.AskAnswer{{QuestionID: "q1", Selected: []string{"Keep going"}}}, nil
23 }
24
25 func TestAskToolReusesAcceptedDecisionAndRejectsSpoofedID(t *testing.T) {
26 turn := &turnRuntime{}
27 asker := &recordingAsker{}
28 ctx := withTurnState(withCallContext(context.Background(), "call", event.Discard, asker, false), turn)
29 args := []byte(`{"decision_id":"dec-original","questions":[{"header":"Direction","question":"Which path?","options":[{"label":"Keep going"},{"label":"Stop"}]}]}`)
30 if _, err := NewAskTool().Execute(ctx, args); err != nil {
31 t.Fatalf("first ask: %v", err)
32 }
33 if asker.calls != 1 {
34 t.Fatalf("asker calls = %d", asker.calls)
35 }
36 second, err := NewAskTool().Execute(ctx, []byte(`{"decision_id":"dec-original","questions":[{"header":"Direction","question":"Which path?","options":[{"label":"Keep going"},{"label":"Stop"}]}]}`))
37 if err != nil || !strings.Contains(second, "dec-original") || asker.calls != 1 {
38 t.Fatalf("repeat clarification = %q err=%v calls=%d", second, err, asker.calls)
39 }
40 rephrased, err := NewAskTool().Execute(ctx, []byte(`{"questions":[{"header":"Direction","question":"Please choose the direction now.","options":[{"label":"Keep going"},{"label":"Stop"}]}]}`))
41 if err != nil || !strings.Contains(rephrased, "same ambiguity") || !strings.Contains(rephrased, "dec-original") || asker.calls != 1 {
42 t.Fatalf("rephrased clarification = %q err=%v calls=%d", rephrased, err, asker.calls)
43 }
44 if _, err := NewAskTool().Execute(ctx, []byte(`{"questions":[{"header":"Environment","question":"Which deployment target?","options":[{"label":"Staging"},{"label":"Production"}]}]}`)); err != nil {
45 t.Fatalf("unrelated clarification should remain available: %v", err)
46 }
47 if asker.calls != 2 {
48 t.Fatalf("unrelated clarification calls = %d, want 2", asker.calls)
49 }
50 _, err = NewAskTool().Execute(ctx, []byte(`{"decision_id":"dec-spoofed","new_evidence":"changed","questions":[{"header":"Again","question":"Ask again?","options":[{"label":"Yes"},{"label":"No"}]}]}`))
51 if err == nil || !strings.Contains(err.Error(), "unknown decision_id") || asker.calls != 2 {
52 t.Fatalf("spoofed decision: err=%v calls=%d", err, asker.calls)
53 }
54 if _, err := NewAskTool().Execute(ctx, []byte(`{"decision_id":"dec-original","new_evidence":"new failing test","questions":[{"header":"Again","question":"Reconsider?","options":[{"label":"Keep going"},{"label":"Stop"}]}]}`)); err != nil {
55 t.Fatalf("evidence-backed reopen: %v", err)
56 }
57 if asker.calls != 3 {
58 t.Fatalf("reopened asker calls = %d, want 3", asker.calls)
59 }
60 }
61
62 func TestAskToolRejectsBlankOptionLabels(t *testing.T) {
63 _, err := NewAskTool().Execute(context.Background(), []byte(`{
64 "questions":[{
65 "header":"Direction",
66 "question":"Which path?",
67 "options":[
68 {"label":"Keep going"},
69 {"label":" ","description":"blank labels render as empty picker rows"}
70 ]
71 }]
72 }`))
73 if err == nil {
74 t.Fatal("expected blank option label to be rejected")
75 }
76 if !strings.Contains(err.Error(), "option 2") || !strings.Contains(err.Error(), "label") {
77 t.Fatalf("error = %v, want it to identify the blank option label", err)
78 }
79 }
80
81 func TestAskToolRejectsDuplicateOptionLabelsAfterTrimming(t *testing.T) {
82 _, err := NewAskTool().Execute(context.Background(), []byte(`{
83 "questions":[{
84 "header":"Release",
85 "question":"What should happen next?",
86 "options":[
87 {"label":"Deploy"},
88 {"label":" Deploy ","description":"same label after trimming"}
89 ]
90 }]
91 }`))
92 if err == nil {
93 t.Fatal("expected duplicate trimmed option label to be rejected")
94 }
95 if !strings.Contains(err.Error(), "option 2") || !strings.Contains(err.Error(), "duplicate") || !strings.Contains(err.Error(), "Deploy") {
96 t.Fatalf("error = %v, want it to identify the duplicate option label", err)
97 }
98 }
99
100 func TestAskToolRejectsExactDuplicateOptionLabels(t *testing.T) {
101 _, err := NewAskTool().Execute(context.Background(), []byte(`{
102 "questions":[{
103 "header":"Release",
104 "question":"What should happen next?",
105 "options":[
106 {"label":"Deploy"},
107 {"label":"Deploy"}
108 ]
109 }]
110 }`))
111 if err == nil {
112 t.Fatal("expected duplicate option label to be rejected")
113 }
114 if !strings.Contains(err.Error(), "option 2") || !strings.Contains(err.Error(), "duplicate") || !strings.Contains(err.Error(), "Deploy") {
115 t.Fatalf("error = %v, want it to identify the duplicate option label", err)
116 }
117 }
118
119 func TestAskToolTrimsPromptAndOptionsBeforePrompting(t *testing.T) {
120 asker := &recordingAsker{}
121 ctx := withCallContext(context.Background(), "call_1", event.Discard, asker, false)
122 out, err := NewAskTool().Execute(ctx, []byte(`{
123 "questions":[{
124 "header":" Direction ",
125 "question":" Which path? ",
126 "options":[
127 {"label":" Keep going ","description":" normal path "},
128 {"label":" Stop "}
129 ]
130 }]
131 }`))
132 if err != nil {
133 t.Fatalf("Execute: %v", err)
134 }
135 if !strings.Contains(out, "Direction: Keep going") {
136 t.Fatalf("answer summary = %q, want trimmed header and answer", out)
137 }
138 if len(asker.questions) != 1 {
139 t.Fatalf("questions = %+v, want one", asker.questions)
140 }
141 q := asker.questions[0]
142 if q.Header != "Direction" || q.Prompt != "Which path?" {
143 t.Fatalf("prompt text not trimmed: %+v", q)
144 }
145 if q.Options[0].Label != "Keep going" || q.Options[0].Description != "normal path" {
146 t.Fatalf("option text not trimmed: %+v", q.Options[0])
147 }
148 }
149
150 type fixedAsker struct{ answers []event.AskAnswer }
151
152 func (f fixedAsker) Ask(_ context.Context, _ []event.AskQuestion) ([]event.AskAnswer, error) {
153 return f.answers, nil
154 }
155
156 func TestAskToolProviderContractStable(t *testing.T) {
157 tool := NewAskTool()
158 contract := tool.Description() + "\n" + string(provider.CanonicalizeSchema(tool.Schema()))
159 got := fmt.Sprintf("%x", sha256.Sum256([]byte(contract)))
160 const want = "0c33e37d7e6e95a300e7e77dfc38e36445c83b377e302ddfe1b1743fc3f271df"
161 if got != want {
162 t.Fatalf("ask provider contract hash = %s, want %s; tool description or canonical schema changed", got, want)
163 }
164 }
165
166 func TestAskToolDismissTellsModelToStopNotProceed(t *testing.T) {
167 ctx := withCallContext(context.Background(), "call_1", event.Discard, fixedAsker{answers: nil}, false)
168 out, err := NewAskTool().Execute(ctx, []byte(`{
169 "questions":[{
170 "header":"Config",
171 "question":"Configure a statusline script?",
172 "options":[{"label":"Yes"},{"label":"No"}]
173 }]
174 }`))
175 if err != nil {
176 t.Fatalf("Execute: %v", err)
177 }
178 if strings.Contains(out, "(no answer)") {
179 t.Fatalf("dismiss result still uses the (no answer) wording the model reads as proceed: %q", out)
180 }
181 if !strings.Contains(out, "Do not") || !strings.Contains(out, "wait for the user") {
182 t.Fatalf("dismiss result should tell the model to stop and wait, got %q", out)
183 }
184 }
185
186 func TestAskToolPartialAnswerMarksUnansweredQuestions(t *testing.T) {
187 ctx := withCallContext(context.Background(), "call_1", event.Discard,
188 fixedAsker{answers: []event.AskAnswer{{QuestionID: "q1", Selected: []string{"Deploy"}}}}, false)
189 out, err := NewAskTool().Execute(ctx, []byte(`{
190 "questions":[
191 {"header":"Release","question":"What next?","options":[{"label":"Deploy"},{"label":"Hold"}]},
192 {"header":"Notify","question":"Tell the team?","options":[{"label":"Yes"},{"label":"No"}]}
193 ]
194 }`))
195 if err != nil {
196 t.Fatalf("Execute: %v", err)
197 }
198 if !strings.Contains(out, "Release: Deploy") {
199 t.Fatalf("answered question should be reported, got %q", out)
200 }
201 if !strings.Contains(out, "Notify:") || !strings.Contains(out, "don't assume a choice") {
202 t.Fatalf("unanswered question should be marked, got %q", out)
203 }
204 }
205
206 func TestAskToolHeadlessFallbackIsExplicitModelAssumption(t *testing.T) {
207 out, err := NewAskTool().Execute(context.Background(), []byte(`{
208 "questions":[{
209 "header":"Direction",
210 "question":"Which path?",
211 "options":[
212 {"label":"Keep going"},
213 {"label":"Stop"}
214 ]
215 }]
216 }`))
217 if err != nil {
218 t.Fatalf("Execute: %v", err)
219 }
220 for _, want := range []string{"No interactive user answered", "model-assumption fallback", "not a user answer"} {
221 if !strings.Contains(out, want) {
222 t.Fatalf("headless fallback = %q, want it to contain %q", out, want)
223 }
224 }
225 if strings.Contains(out, "The user answered") {
226 t.Fatalf("headless fallback must not be formatted as a user answer: %q", out)
227 }
228 }
229
229 lines GO