返回 DeepSeek-Reasonix
ui_test.go
根目录 / sdk / go / ui_test.go
1 package extension
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "testing"
8 )
9
10 // uiCall runs fn inside an interceptor ctx (which carries the host
11 // connection) against a fake host, returning the fake host for frame
12 // assertions.
13 func uiCall(t *testing.T, fn func(ctx context.Context) error) (*fakeHost, error) {
14 t.Helper()
15 var callErr error
16 interceptors := map[string]InterceptorFunc{
17 "tool.before": func(ctx context.Context, _ string, _ json.RawMessage) (*InterceptResult, error) {
18 callErr = fn(ctx)
19 return Continue(), nil
20 },
21 }
22 host, _ := startFakeHost(t, basicHandler(), Options{Interceptors: interceptors})
23 host.onRequest(MethodHostUIPublish, func(json.RawMessage) (any, *hostError) {
24 return UIPublishResult{Accepted: true}, nil
25 })
26 host.onRequest(MethodHostUIRequest, func(json.RawMessage) (any, *hostError) {
27 return UIRequestResult{Cancelled: false, Values: map[string]any{"value": true}}, nil
28 })
29 host.handshake(t)
30 host.request(MethodExtensionIntercept, InterceptParams{
31 Event: EventToolBefore, Seq: 1, Payload: json.RawMessage(`{}`),
32 })
33 return host, callErr
34 }
35
36 // lastRawParams decodes the most recent host request params of one method.
37 func lastRawParams(t *testing.T, host *fakeHost, method string) json.RawMessage {
38 t.Helper()
39 return host.lastRawParams(t, method)
40 }
41
42 // TestHostUIPublishStatusGolden pins the exact wire field names of a status
43 // publish against the canonical schema.
44 func TestHostUIPublishStatusGolden(t *testing.T) {
45 progress := 0.5
46 host, err := uiCall(t, func(ctx context.Context) error {
47 ui := HostUI{}
48 return ui.PublishStatus(ctx, "sess-1", 7, "status-1", UIStatusPayload{
49 Label: "Indexing", Detail: "3/6", Severity: UISeverityWarn, Progress: &progress,
50 })
51 })
52 if err != nil {
53 t.Fatalf("PublishStatus: %v", err)
54 }
55 raw := lastRawParams(t, host, MethodHostUIPublish)
56 var golden map[string]any
57 if err := json.Unmarshal(raw, &golden); err != nil {
58 t.Fatalf("params not an object: %v", err)
59 }
60 assertJSONFields(t, golden, map[string]any{
61 "surfaceId": "status-1",
62 "sessionId": "sess-1",
63 "generation": float64(7),
64 "kind": "status",
65 })
66 payload, ok := golden["payload"].(map[string]any)
67 if !ok {
68 t.Fatalf("payload = %v", golden["payload"])
69 }
70 assertJSONFields(t, payload, map[string]any{
71 "label": "Indexing", "detail": "3/6", "severity": "warn", "progress": 0.5,
72 })
73 }
74
75 // TestHostUIPublishFormGolden pins the form surface shape.
76 func TestHostUIPublishFormGolden(t *testing.T) {
77 host, err := uiCall(t, func(ctx context.Context) error {
78 ui := HostUI{}
79 return ui.PublishForm(ctx, "sess-1", 7, "form-1", UIFormPayload{
80 Title: "Configure",
81 Message: "Pick values",
82 Fields: []UIFormField{
83 {Key: "name", Label: "Name", Kind: UIFieldInput, Default: "reasonix", Required: true},
84 {Key: "level", Label: "Level", Kind: UIFieldSelect, Options: []string{"low", "high"}},
85 },
86 })
87 })
88 if err != nil {
89 t.Fatalf("PublishForm: %v", err)
90 }
91 raw := lastRawParams(t, host, MethodHostUIPublish)
92 var doc struct {
93 Kind string `json:"kind"`
94 Payload struct {
95 Title string `json:"title"`
96 Message string `json:"message"`
97 Fields []struct {
98 Key string `json:"key"`
99 Label string `json:"label"`
100 Kind string `json:"kind"`
101 Options []string `json:"options,omitempty"`
102 Default any `json:"default,omitempty"`
103 Required bool `json:"required,omitempty"`
104 } `json:"fields"`
105 } `json:"payload"`
106 }
107 if err := json.Unmarshal(raw, &doc); err != nil {
108 t.Fatalf("decode: %v", err)
109 }
110 if doc.Kind != "form" || doc.Payload.Title != "Configure" || len(doc.Payload.Fields) != 2 {
111 t.Fatalf("form doc = %+v", doc)
112 }
113 name := doc.Payload.Fields[0]
114 if name.Key != "name" || name.Kind != "input" || name.Default != "reasonix" || !name.Required {
115 t.Fatalf("field 0 = %+v", name)
116 }
117 level := doc.Payload.Fields[1]
118 if level.Kind != "select" || len(level.Options) != 2 || level.Options[1] != "high" {
119 t.Fatalf("field 1 = %+v", level)
120 }
121 }
122
123 // TestHostUIRequestConfirmGolden pins the confirm prompt shape and answer
124 // mapping.
125 func TestHostUIRequestConfirmGolden(t *testing.T) {
126 var answer bool
127 host, err := uiCall(t, func(ctx context.Context) error {
128 ui := HostUI{}
129 var callErr error
130 answer, callErr = ui.RequestConfirm(ctx, "sess-1", 7, "confirm-1", "Delete everything?")
131 return callErr
132 })
133 if err != nil {
134 t.Fatalf("RequestConfirm: %v", err)
135 }
136 if !answer {
137 t.Fatal("confirm answer = false, want true from the scripted host")
138 }
139 raw := lastRawParams(t, host, MethodHostUIRequest)
140 var doc struct {
141 SurfaceID string `json:"surfaceId"`
142 SessionID string `json:"sessionId"`
143 Generation uint64 `json:"generation"`
144 Kind string `json:"kind"`
145 Payload struct {
146 Message string `json:"message"`
147 Fields []struct {
148 Key string `json:"key"`
149 Label string `json:"label"`
150 Kind string `json:"kind"`
151 } `json:"fields"`
152 } `json:"payload"`
153 }
154 if err := json.Unmarshal(raw, &doc); err != nil {
155 t.Fatalf("decode: %v", err)
156 }
157 if doc.Kind != "confirm" || doc.SurfaceID != "confirm-1" || doc.Generation != 7 {
158 t.Fatalf("request doc = %+v", doc)
159 }
160 if len(doc.Payload.Fields) != 1 || doc.Payload.Fields[0].Key != "value" || doc.Payload.Fields[0].Kind != "confirm" {
161 t.Fatalf("confirm fields = %+v", doc.Payload.Fields)
162 }
163 }
164
165 // TestHostUIRequestCancelled maps dismissal to ErrUICancelled.
166 func TestHostUIRequestCancelled(t *testing.T) {
167 var callErr error
168 interceptors := map[string]InterceptorFunc{
169 "tool.before": func(ctx context.Context, _ string, _ json.RawMessage) (*InterceptResult, error) {
170 ui := HostUI{}
171 _, callErr = ui.RequestConfirm(ctx, "sess-1", 7, "c", "sure?")
172 return Continue(), nil
173 },
174 }
175 host, _ := startFakeHost(t, basicHandler(), Options{Interceptors: interceptors})
176 host.onRequest(MethodHostUIRequest, func(json.RawMessage) (any, *hostError) {
177 return UIRequestResult{Cancelled: true}, nil
178 })
179 host.handshake(t)
180 host.request(MethodExtensionIntercept, InterceptParams{
181 Event: EventToolBefore, Seq: 1, Payload: json.RawMessage(`{}`),
182 })
183 if !errors.Is(callErr, ErrUICancelled) {
184 t.Fatalf("callErr = %v, want ErrUICancelled", callErr)
185 }
186 }
187
188 // TestHostUIRequestMultiSelect decodes a multi-answer from the wire's []any.
189 func TestHostUIRequestMultiSelect(t *testing.T) {
190 var picked []string
191 var callErr error
192 interceptors := map[string]InterceptorFunc{
193 "tool.before": func(ctx context.Context, _ string, _ json.RawMessage) (*InterceptResult, error) {
194 ui := HostUI{}
195 picked, callErr = ui.RequestMultiSelect(ctx, "sess-1", 7, "ms", MultiSelectPrompt{
196 Message: "Pick", Options: []string{"a", "b", "c"},
197 })
198 return Continue(), nil
199 },
200 }
201 host, _ := startFakeHost(t, basicHandler(), Options{Interceptors: interceptors})
202 host.onRequest(MethodHostUIRequest, func(json.RawMessage) (any, *hostError) {
203 return UIRequestResult{Cancelled: false, Values: map[string]any{"value": []any{"a", "c"}}}, nil
204 })
205 host.handshake(t)
206 host.request(MethodExtensionIntercept, InterceptParams{
207 Event: EventToolBefore, Seq: 1, Payload: json.RawMessage(`{}`),
208 })
209 if callErr != nil {
210 t.Fatalf("RequestMultiSelect: %v", callErr)
211 }
212 if len(picked) != 2 || picked[0] != "a" || picked[1] != "c" {
213 t.Fatalf("picked = %v", picked)
214 }
215 }
216
217 // TestHostUIValidation rejects invalid payloads before they hit the wire.
218 func TestHostUIValidation(t *testing.T) {
219 ui := HostUI{}
220 ctx := context.Background()
221 cases := []error{
222 ui.PublishStatus(ctx, "s", 1, "x", UIStatusPayload{}),
223 ui.PublishStatus(ctx, "s", 1, "x", UIStatusPayload{Label: "l", Severity: "fatal"}),
224 ui.PublishNotification(ctx, "s", 1, "x", UINotificationPayload{}),
225 ui.PublishForm(ctx, "s", 1, "x", UIFormPayload{}),
226 ui.PublishForm(ctx, "s", 1, "x", UIFormPayload{Fields: []UIFormField{{Key: "k", Kind: "textarea"}}}),
227 ui.PublishCard(ctx, "s", 1, "x", UICardPayload{Fields: []UIKeyValue{{Value: "v"}}}),
228 }
229 for i, err := range cases {
230 if err == nil {
231 t.Fatalf("case %d: expected a validation error", i)
232 }
233 if errors.Is(err, ErrNoConnection) {
234 t.Fatalf("case %d: validation did not run before the connection check", i)
235 }
236 }
237 if _, err := ui.RequestSelect(ctx, "s", 1, "x", SelectPrompt{}); err == nil {
238 t.Fatal("select without options: expected a validation error")
239 }
240 }
241
242 // assertJSONFields checks want's key/value pairs against got.
243 func assertJSONFields(t *testing.T, got map[string]any, want map[string]any) {
244 t.Helper()
245 for key, value := range want {
246 if got[key] != value {
247 t.Fatalf("field %q = %v, want %v (doc %v)", key, got[key], value, got)
248 }
249 }
250 }
251
251 lines GO