返回 DeepSeek-Reasonix
dispatch_extension_test.go
根目录 / internal / acp / dispatch_extension_test.go
1 package acp
2
3 import (
4 "context"
5 "encoding/json"
6 "strings"
7 "testing"
8
9 "reasonix/internal/control"
10 "reasonix/internal/event"
11 "reasonix/internal/extension/protocol"
12 "reasonix/internal/extension/uihub"
13 )
14
15 func extStatusEvent(severity string) event.Event {
16 return event.Event{
17 Kind: event.ExtensionStatus,
18 Extension: &event.ExtensionSurfacePayload{
19 PluginID: "alpha", SurfaceID: "s1", Kind: event.ExtensionSurfaceStatus,
20 Status: &event.ExtensionStatusView{Label: "building", Detail: "3 of 9", Severity: severity},
21 },
22 }
23 }
24
25 func extCardEvent() event.Event {
26 return event.Event{
27 Kind: event.ExtensionSurface,
28 Extension: &event.ExtensionSurfacePayload{
29 PluginID: "alpha", SurfaceID: "c1", Kind: event.ExtensionSurfaceCard,
30 Card: &event.ExtensionCardView{
31 Title: "CI status",
32 Text: "all green",
33 Fields: []event.ExtensionKeyValue{{Key: "branch", Value: "main"}},
34 },
35 },
36 }
37 }
38
39 // chunkText extracts the text of an agent_message_chunk update map.
40 func chunkText(t *testing.T, u map[string]any) string {
41 t.Helper()
42 if u["sessionUpdate"] != "agent_message_chunk" {
43 t.Fatalf("sessionUpdate = %v, want agent_message_chunk", u["sessionUpdate"])
44 }
45 content, _ := u["content"].(map[string]any)
46 text, _ := content["text"].(string)
47 return text
48 }
49
50 func TestUpdateSinkExtensionUnsupportedClientGetsTextOnly(t *testing.T) {
51 fn := &fakeNotifier{}
52 sink := newUpdateSink(fn, "sess-1") // extensionSurface unbound → unsupported
53
54 sink.Emit(extCardEvent())
55 if len(fn.notifs) != 1 {
56 t.Fatalf("emitted %d notifications, want 1 (text fallback only)", len(fn.notifs))
57 }
58 text := chunkText(t, fn.updateMap(t, 0))
59 for _, want := range []string{"CI status", "all green", "branch: main"} {
60 if !strings.Contains(text, want) {
61 t.Errorf("card fallback missing %q: %q", want, text)
62 }
63 }
64 if strings.Contains(text, "[warning]") {
65 t.Errorf("severity-less card must not carry the warning prefix: %q", text)
66 }
67 }
68
69 func TestUpdateSinkExtensionStatusAndSeverityPrefixes(t *testing.T) {
70 fn := &fakeNotifier{}
71 sink := newUpdateSink(fn, "sess-1")
72
73 sink.Emit(extStatusEvent("info"))
74 sink.Emit(extStatusEvent("warn"))
75 sink.Emit(extStatusEvent("error"))
76
77 if len(fn.notifs) != 3 {
78 t.Fatalf("emitted %d notifications, want 3", len(fn.notifs))
79 }
80 info := chunkText(t, fn.updateMap(t, 0))
81 if !strings.Contains(info, "[alpha] building: 3 of 9") || strings.Contains(info, "[warning]") {
82 t.Errorf("info status = %q", info)
83 }
84 for _, i := range []int{1, 2} {
85 if text := chunkText(t, fn.updateMap(t, i)); !strings.Contains(text, "[warning] [alpha] building") {
86 t.Errorf("notif %d = %q, want [warning] prefix", i, text)
87 }
88 }
89 }
90
91 func TestUpdateSinkExtensionNotification(t *testing.T) {
92 fn := &fakeNotifier{}
93 sink := newUpdateSink(fn, "sess-1")
94 sink.Emit(event.Event{
95 Kind: event.ExtensionSurface,
96 Extension: &event.ExtensionSurfacePayload{
97 PluginID: "alpha", SurfaceID: "n1", Kind: event.ExtensionSurfaceNotification,
98 Notification: &event.ExtensionNotificationView{Title: "Deploy done", Body: "v2 live", Severity: "warn"},
99 },
100 })
101 text := chunkText(t, fn.updateMap(t, 0))
102 if !strings.Contains(text, "[warning] Deploy done") || !strings.Contains(text, "v2 live") {
103 t.Fatalf("notification fallback = %q", text)
104 }
105 }
106
107 func TestUpdateSinkExtensionSupportedClientGetsMetaAndText(t *testing.T) {
108 fn := &fakeNotifier{}
109 sink := newUpdateSink(fn, "sess-1")
110 sink.bindExtensionSurface(true)
111
112 sink.Emit(extCardEvent())
113 if len(fn.notifs) != 2 {
114 t.Fatalf("emitted %d notifications, want 2 (vendor _meta + text fallback)", len(fn.notifs))
115 }
116
117 u := fn.updateMap(t, 0)
118 if u["sessionUpdate"] != extensionSurfaceUpdateKind {
119 t.Fatalf("structured update sessionUpdate = %v, want %q", u["sessionUpdate"], extensionSurfaceUpdateKind)
120 }
121 meta, _ := u["_meta"].(map[string]any)
122 vendor, _ := meta["reasonix.io"].(map[string]any)
123 surface, _ := vendor["extensionSurface"].(map[string]any)
124 if surface == nil {
125 t.Fatalf("structured update missing _meta.reasonix.io.extensionSurface: %v", u)
126 }
127 if surface["kind"] != "card" || surface["pluginId"] != "alpha" || surface["surfaceId"] != "c1" {
128 t.Errorf("surface DTO = %v", surface)
129 }
130 card, _ := surface["card"].(map[string]any)
131 if card["title"] != "CI status" || card["text"] != "all green" {
132 t.Errorf("card DTO = %v", card)
133 }
134
135 // Belt and suspenders: the text fallback still rides behind it.
136 text := chunkText(t, fn.updateMap(t, 1))
137 if !strings.Contains(text, "CI status") {
138 t.Errorf("text fallback = %q", text)
139 }
140 }
141
142 func TestUpdateSinkExtensionFormFlattensToAnnouncement(t *testing.T) {
143 // Published form surfaces flatten to title + message; the blocking prompt
144 // side never reaches this sink — it rides AskRequest →
145 // session/request_permission (covered by
146 // TestUpdateSinkAskRequestUsesPermissionChoices).
147 fn := &fakeNotifier{}
148 sink := newUpdateSink(fn, "sess-1")
149 sink.Emit(event.Event{
150 Kind: event.ExtensionSurface,
151 Extension: &event.ExtensionSurfacePayload{
152 PluginID: "alpha", SurfaceID: "f1", Kind: event.ExtensionSurfaceForm,
153 Form: &event.ExtensionFormView{Title: "Setup", Message: "pick options"},
154 },
155 })
156 if len(fn.notifs) != 1 {
157 t.Fatalf("emitted %d notifications, want 1", len(fn.notifs))
158 }
159 text := chunkText(t, fn.updateMap(t, 0))
160 if !strings.Contains(text, "Setup") || !strings.Contains(text, "pick options") {
161 t.Fatalf("form fallback = %q", text)
162 }
163 }
164
165 func TestUpdateSinkExtensionNilPayloadDropped(t *testing.T) {
166 fn := &fakeNotifier{}
167 sink := newUpdateSink(fn, "sess-1")
168 sink.bindExtensionSurface(true)
169 sink.Emit(event.Event{Kind: event.ExtensionSurface})
170 sink.Emit(event.Event{Kind: event.ExtensionStatus})
171 if len(fn.notifs) != 0 {
172 t.Fatalf("nil payloads emitted %d notifications, want 0", len(fn.notifs))
173 }
174 }
175
176 func TestInitializeAdvertisesExtensionSurface(t *testing.T) {
177 svc := &service{}
178 result, err := svc.initialize(context.Background(), nil)
179 if err != nil {
180 t.Fatalf("initialize: %v", err)
181 }
182 ir, ok := result.(InitializeResult)
183 if !ok {
184 t.Fatalf("initialize returned %T", result)
185 }
186 vendor, ok := ir.AgentCapabilities.Meta["reasonix.io"].(ReasonixExtensionCapabilities)
187 if !ok {
188 t.Fatalf("_meta[reasonix.io] = %T", ir.AgentCapabilities.Meta["reasonix.io"])
189 }
190 if vendor.ExtensionSurface == nil || !vendor.ExtensionSurface.Supported ||
191 vendor.ExtensionSurface.SchemaVersion != reasonixExtensionSurfaceSchemaVersion {
192 t.Fatalf("extensionSurface capability = %+v", vendor.ExtensionSurface)
193 }
194
195 // The wire shape keeps the vendor namespace and camelCase keys.
196 raw, err := json.Marshal(ir)
197 if err != nil {
198 t.Fatalf("marshal: %v", err)
199 }
200 var decoded struct {
201 AgentCapabilities struct {
202 Meta map[string]struct {
203 ExtensionSurface *struct {
204 Supported bool `json:"supported"`
205 SchemaVersion int `json:"schemaVersion"`
206 } `json:"extensionSurface"`
207 } `json:"_meta"`
208 } `json:"agentCapabilities"`
209 }
210 if err := json.Unmarshal(raw, &decoded); err != nil {
211 t.Fatalf("unmarshal: %v", err)
212 }
213 got := decoded.AgentCapabilities.Meta["reasonix.io"].ExtensionSurface
214 if got == nil || !got.Supported || got.SchemaVersion != reasonixExtensionSurfaceSchemaVersion {
215 t.Fatalf("wire extensionSurface = %+v", got)
216 }
217 }
218
219 func TestClientExtensionSurfaceSupportedParsing(t *testing.T) {
220 tests := []struct {
221 name string
222 meta map[string]any
223 want bool
224 }{
225 {"absent", nil, false},
226 {"vendor block absent", map[string]any{"other": true}, false},
227 {"capability absent", map[string]any{"reasonix.io": map[string]any{}}, false},
228 {"supported", map[string]any{"reasonix.io": map[string]any{
229 "extensionSurface": map[string]any{"supported": true, "schemaVersion": 1},
230 }}, true},
231 {"explicit false", map[string]any{"reasonix.io": map[string]any{
232 "extensionSurface": map[string]any{"supported": false},
233 }}, false},
234 {"malformed vendor", map[string]any{"reasonix.io": "nope"}, false},
235 {"malformed capability", map[string]any{"reasonix.io": map[string]any{
236 "extensionSurface": "nope",
237 }}, false},
238 {"malformed flag", map[string]any{"reasonix.io": map[string]any{
239 "extensionSurface": map[string]any{"supported": "yes"},
240 }}, false},
241 }
242 for _, tt := range tests {
243 if got := clientExtensionSurfaceSupported(ClientCapabilities{Meta: tt.meta}); got != tt.want {
244 t.Errorf("%s: got %v, want %v", tt.name, got, tt.want)
245 }
246 }
247 }
248
249 func TestInitializeRecordsClientExtensionSurfaceSupport(t *testing.T) {
250 svc := &service{}
251 if svc.extensionSurfaceSupported() {
252 t.Fatal("supported before initialize")
253 }
254 params := InitializeParams{
255 ProtocolVersion: 1,
256 ClientCapabilities: ClientCapabilities{Meta: map[string]any{
257 "reasonix.io": map[string]any{
258 "extensionSurface": map[string]any{"supported": true, "schemaVersion": 1},
259 },
260 }},
261 }
262 raw, err := json.Marshal(params)
263 if err != nil {
264 t.Fatalf("marshal params: %v", err)
265 }
266 if _, err := svc.initialize(context.Background(), raw); err != nil {
267 t.Fatalf("initialize: %v", err)
268 }
269 if !svc.extensionSurfaceSupported() {
270 t.Fatal("client support not recorded")
271 }
272 }
273
274 // extActionController builds a real controller with one registered extension
275 // action backed by a fake sidecar client — the same wiring boot installs.
276 type extActionClient struct {
277 result protocol.UIActionResult
278 got *protocol.UIActionParams
279 }
280
281 func (f *extActionClient) UIAction(_ context.Context, p protocol.UIActionParams) (protocol.UIActionResult, error) {
282 f.got = &p
283 return f.result, nil
284 }
285
286 func (f *extActionClient) UISubmit(_ context.Context, p protocol.UISubmitParams) (protocol.UISubmitResult, error) {
287 return protocol.UISubmitResult{Accepted: true}, nil
288 }
289
290 func newExtActionController(t *testing.T, client *extActionClient) acpController {
291 t.Helper()
292 ctrl := control.New(control.Options{Sink: event.Discard})
293 hub := uihub.New(uihub.Options{
294 SessionID: "sess-1", Generation: 1,
295 Resolve: func(string) uihub.ActionClient { return client },
296 })
297 if err := hub.RegisterActions("alpha", []protocol.UIActionDecl{{ActionID: "act1", Label: "Act one"}}); err != nil {
298 t.Fatalf("RegisterActions: %v", err)
299 }
300 ctrl.SetExtensionUI(hub)
301 return ctrl
302 }
303
304 func TestAvailableCommandsIncludeExtensionActions(t *testing.T) {
305 ctrl := newExtActionController(t, &extActionClient{})
306 cmds := availableCommandsFor(ctrl)
307 var found *AvailableCommand
308 for i := range cmds {
309 if cmds[i].Name == "alpha:act1" {
310 found = &cmds[i]
311 }
312 }
313 if found == nil {
314 t.Fatalf("extension action missing from available commands: %+v", cmds)
315 }
316 if found.Description != "Act one" {
317 t.Errorf("description = %q, want the action label", found.Description)
318 }
319 }
320
321 func TestResolveSlashPromptFallsThroughToExtensionAction(t *testing.T) {
322 client := &extActionClient{result: protocol.UIActionResult{Accepted: true, Message: "rerun scheduled"}}
323 sess := &acpSession{id: "sess-1", ctrl: newExtActionController(t, client)}
324 svc := &service{}
325
326 got := svc.resolveSlashPrompt(context.Background(), sess, "/alpha:act1 k=v extra")
327 if got != "rerun scheduled" {
328 t.Fatalf("resolveSlashPrompt = %q, want the action result", got)
329 }
330 if client.got == nil || client.got.ActionID != "act1" ||
331 client.got.Args["k"] != "v" || client.got.Args["arg1"] != "extra" {
332 t.Fatalf("action params = %+v", client.got)
333 }
334
335 // Undeclared actions and non-action lines pass through untouched.
336 if got := svc.resolveSlashPrompt(context.Background(), sess, "/alpha:other"); got != "/alpha:other" {
337 t.Fatalf("undeclared action rewrote to %q", got)
338 }
339 if got := svc.resolveSlashPrompt(context.Background(), sess, "/plain"); got != "/plain" {
340 t.Fatalf("plain slash rewrote to %q", got)
341 }
342
343 // A failed invocation leaves the line untouched rather than prompting the
344 // model with an error string.
345 failing := &acpSession{id: "sess-1", ctrl: newExtActionController(t, &extActionClient{
346 result: protocol.UIActionResult{Accepted: false, Message: "nope"},
347 })}
348 if got := svc.resolveSlashPrompt(context.Background(), failing, "/alpha:act1"); got != "/alpha:act1" {
349 t.Fatalf("failed action rewrote to %q", got)
350 }
351 }
352
352 lines GO