返回 DeepSeek-Reasonix
conformance_test.go
根目录 / internal / extension / conformance / conformance_test.go
1 // Package conformance runs the Host↔SDK bidirectional conformance suite: the
2 // SDK's reference example (sdk/go/examples/fullsidecar) is built once per
3 // test run and driven against the real host sidecar client
4 // (internal/extension/sidecar) over its stdin/stdout, plus a raw-frame driver
5 // for the transport-level cases (unknown method, oversized frame, bounded
6 // shutdown exit status). The suite is hermetic: temp dirs, no network, no
7 // real providers.
8 package conformance
9
10 import (
11 "bytes"
12 "context"
13 "encoding/json"
14 "errors"
15 "fmt"
16 "os"
17 "os/exec"
18 "path/filepath"
19 "runtime"
20 "strings"
21 "sync"
22 "testing"
23 "time"
24
25 "reasonix/internal/extension/protocol"
26 "reasonix/internal/extension/rpcwire"
27 "reasonix/internal/extension/sidecar"
28 "reasonix/internal/pluginpkg"
29 )
30
31 // examplePath is the built fullsidecar binary, shared by every test.
32 var examplePath string
33
34 // TestMain builds the SDK example once for the whole run. The suite skips
35 // cleanly when no go toolchain is available (minimal test environments); a
36 // present toolchain that cannot build the example is a real failure.
37 func TestMain(m *testing.M) {
38 if _, err := exec.LookPath("go"); err != nil {
39 fmt.Fprintln(os.Stderr, "conformance: go toolchain unavailable; skipping suite")
40 os.Exit(0)
41 }
42 _, thisFile, _, ok := runtime.Caller(0)
43 if !ok {
44 fmt.Fprintln(os.Stderr, "conformance: cannot locate source root")
45 os.Exit(1)
46 }
47 sdkDir := filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "sdk", "go")
48 dir, err := os.MkdirTemp("", "fullsidecar-conformance-")
49 if err != nil {
50 fmt.Fprintln(os.Stderr, "conformance: MkdirTemp:", err)
51 os.Exit(1)
52 }
53 defer os.RemoveAll(dir)
54 binary := filepath.Join(dir, "fullsidecar")
55 if runtime.GOOS == "windows" {
56 binary += ".exe"
57 }
58 build := exec.Command("go", "build", "-C", sdkDir, "-o", binary, "./examples/fullsidecar")
59 if out, err := build.CombinedOutput(); err != nil {
60 fmt.Fprintf(os.Stderr, "conformance: build example: %v\n%s", err, out)
61 os.Exit(1)
62 }
63 examplePath = binary
64 os.Exit(m.Run())
65 }
66
67 // ---------------------------------------------------------------------------
68 // Host client fixture
69 // ---------------------------------------------------------------------------
70
71 const (
72 testPluginID = "conformance-ext"
73 testProvider = "plugin/conformance-ext/fake/echo"
74 )
75
76 // startExample launches the example under the real host sidecar client with a
77 // manifest that declares everything the example contributes. mutate tunes the
78 // runtime spec (env, under-declared manifests); opts tunes ClientOptions.
79 func startExample(t *testing.T, mutate func(rt *pluginpkg.RuntimeSpec), opts func(*sidecar.ClientOptions)) *sidecar.Client {
80 t.Helper()
81 rt := &pluginpkg.RuntimeSpec{
82 Command: examplePath,
83 Intercepts: []string{"input.receive", "tool.before", "system_prompt.build", "session.start"},
84 Replaces: []string{"system_prompt"},
85 Capabilities: []string{"providers", "ui"},
86 }
87 if mutate != nil {
88 mutate(rt)
89 }
90 root := t.TempDir()
91 pkg := pluginpkg.Package{
92 Root: root,
93 ManifestKind: "reasonix",
94 Manifest: pluginpkg.Manifest{Name: testPluginID, Version: "1.0.0", Runtime: rt},
95 }
96 installed := pluginpkg.InstalledPlugin{Name: testPluginID, Version: "1.0.0", Enabled: true, Root: root}
97 clientOpts := sidecar.ClientOptions{
98 Package: pkg,
99 Installed: installed,
100 Session: protocol.SessionContext{SessionID: "sess-conf", WorkspaceRoot: "/ws", Generation: 1},
101 }
102 if opts != nil {
103 opts(&clientOpts)
104 }
105 client, err := sidecar.StartClient(context.Background(), clientOpts)
106 if err != nil {
107 t.Fatalf("StartClient: %v", err)
108 }
109 t.Cleanup(func() { _ = client.Close() })
110 return client
111 }
112
113 // intercept is a small shortcut for the common blocking-intercept call.
114 func intercept(t *testing.T, client *sidecar.Client, event protocol.InterceptEvent, payload string) protocol.InterceptResult {
115 t.Helper()
116 result, err := client.Intercept(context.Background(), event, json.RawMessage(payload), 10*time.Second)
117 if err != nil {
118 t.Fatalf("Intercept(%s): %v", event, err)
119 }
120 return result
121 }
122
123 // decodeReplacement strict-decodes an intercept replacement into out.
124 func decodeReplacement(t *testing.T, result protocol.InterceptResult, out any) {
125 t.Helper()
126 if result.Decision != protocol.DecisionReplace {
127 t.Fatalf("decision = %q (reason %q), want replace", result.Decision, result.Reason)
128 }
129 if len(result.Replacement) == 0 {
130 t.Fatal("replace decision carries no replacement")
131 }
132 decoder := json.NewDecoder(bytes.NewReader(result.Replacement))
133 decoder.DisallowUnknownFields()
134 if err := decoder.Decode(out); err != nil {
135 t.Fatalf("replacement does not decode: %v", err)
136 }
137 }
138
139 // ---------------------------------------------------------------------------
140 // Stub UI handler and stream router
141 // ---------------------------------------------------------------------------
142
143 // stubUI records host/ui/publish calls and answers host/ui/request through a
144 // programmable function (default: the user cancelled).
145 type stubUI struct {
146 mu sync.Mutex
147 published []protocol.UIPublishParams
148 requestFn func(p protocol.UIRequestParams) (protocol.UIRequestResult, error)
149 }
150
151 func (s *stubUI) Publish(_ context.Context, p protocol.UIPublishParams) (protocol.UIPublishResult, error) {
152 s.mu.Lock()
153 defer s.mu.Unlock()
154 s.published = append(s.published, p)
155 return protocol.UIPublishResult{Accepted: true}, nil
156 }
157
158 func (s *stubUI) Request(_ context.Context, p protocol.UIRequestParams) (protocol.UIRequestResult, error) {
159 s.mu.Lock()
160 fn := s.requestFn
161 s.mu.Unlock()
162 if fn != nil {
163 return fn(p)
164 }
165 return protocol.UIRequestResult{Cancelled: true}, nil
166 }
167
168 // publishedOfKind returns the recorded publishes of one surface kind.
169 func (s *stubUI) publishedOfKind(kind protocol.UISurfaceKind) []protocol.UIPublishParams {
170 s.mu.Lock()
171 defer s.mu.Unlock()
172 var out []protocol.UIPublishParams
173 for _, p := range s.published {
174 if p.Kind == kind {
175 out = append(out, p)
176 }
177 }
178 return out
179 }
180
181 func (s *stubUI) publishedCount() int {
182 s.mu.Lock()
183 defer s.mu.Unlock()
184 return len(s.published)
185 }
186
187 // stubStreams records routed provider stream notifications.
188 type stubStreams struct {
189 mu sync.Mutex
190 chunks []protocol.StreamChunkParams
191 ends []protocol.StreamEndParams
192 }
193
194 func (s *stubStreams) RouteStreamChunk(p protocol.StreamChunkParams) {
195 s.mu.Lock()
196 defer s.mu.Unlock()
197 s.chunks = append(s.chunks, p)
198 }
199
200 func (s *stubStreams) RouteStreamEnd(p protocol.StreamEndParams) {
201 s.mu.Lock()
202 defer s.mu.Unlock()
203 s.ends = append(s.ends, p)
204 }
205
206 func (s *stubStreams) snapshot() (chunks []protocol.StreamChunkParams, ends []protocol.StreamEndParams) {
207 s.mu.Lock()
208 defer s.mu.Unlock()
209 return append([]protocol.StreamChunkParams(nil), s.chunks...), append([]protocol.StreamEndParams(nil), s.ends...)
210 }
211
212 // waitFor polls cond until it holds or the deadline expires.
213 func waitFor(t *testing.T, what string, timeout time.Duration, cond func() bool) {
214 t.Helper()
215 deadline := time.Now().Add(timeout)
216 for time.Now().Before(deadline) {
217 if cond() {
218 return
219 }
220 time.Sleep(10 * time.Millisecond)
221 }
222 t.Fatalf("timed out waiting for %s", what)
223 }
224
225 // protocolReason extracts the frozen protocol error reason from err, whether
226 // it travels as a local *protocol.ProtocolError or as a wire-shaped
227 // *rpcwire.RPCError.
228 func protocolReason(t *testing.T, err error) protocol.ErrorReason {
229 t.Helper()
230 var protocolErr *protocol.ProtocolError
231 if errors.As(err, &protocolErr) {
232 return protocolErr.Reason
233 }
234 var rpcErr *rpcwire.RPCError
235 if errors.As(err, &rpcErr) {
236 var data protocol.ProtocolErrorData
237 raw, _ := json.Marshal(rpcErr.Data)
238 if json.Unmarshal(raw, &data) == nil && data.Reason != "" {
239 return data.Reason
240 }
241 }
242 t.Fatalf("error %v carries no protocol reason", err)
243 return ""
244 }
245
246 // ---------------------------------------------------------------------------
247 // Tests: initialize handshake
248 // ---------------------------------------------------------------------------
249
250 // TestHandshakeAccepted proves the host accepts the example's full
251 // declaration: subscriptions, the system_prompt strategy slot, the namespaced
252 // provider, and the demo UI action.
253 func TestHandshakeAccepted(t *testing.T) {
254 client := startExample(t, nil, nil)
255 h := client.Handshake()
256 if h.Name != testPluginID || h.Version != "1.0.0" {
257 t.Fatalf("identity = %q/%q", h.Name, h.Version)
258 }
259 wantSubs := map[string]bool{"input.receive": true, "tool.before": true, "system_prompt.build": true, "session.start": true}
260 if len(h.Subscriptions) != len(wantSubs) {
261 t.Fatalf("subscriptions = %v", h.Subscriptions)
262 }
263 for _, sub := range h.Subscriptions {
264 if !wantSubs[sub] {
265 t.Fatalf("unexpected subscription %q in %v", sub, h.Subscriptions)
266 }
267 }
268 if len(h.Replaces) != 1 || h.Replaces[0] != "system_prompt" {
269 t.Fatalf("replaces = %v", h.Replaces)
270 }
271 if len(h.Providers) != 1 || h.Providers[0].Ref != testProvider {
272 t.Fatalf("providers = %+v", h.Providers)
273 }
274 if len(h.UIActions) != 1 || h.UIActions[0].ActionID != "demo" {
275 t.Fatalf("uiActions = %+v", h.UIActions)
276 }
277 }
278
279 // TestHandshakeUnderDeclaredRejected proves the manifest contract: an
280 // extension activating a capability its manifest did not declare is refused
281 // with capability_not_declared.
282 func TestHandshakeUnderDeclaredRejected(t *testing.T) {
283 rt := &pluginpkg.RuntimeSpec{
284 Command: examplePath,
285 Intercepts: []string{"input.receive", "tool.before", "system_prompt.build", "session.start"},
286 Replaces: []string{"system_prompt"},
287 Capabilities: []string{"ui"}, // no "providers": the example still declares one
288 }
289 root := t.TempDir()
290 pkg := pluginpkg.Package{
291 Root: root,
292 ManifestKind: "reasonix",
293 Manifest: pluginpkg.Manifest{Name: testPluginID, Version: "1.0.0", Runtime: rt},
294 }
295 installed := pluginpkg.InstalledPlugin{Name: testPluginID, Version: "1.0.0", Enabled: true, Root: root}
296 _, err := sidecar.StartClient(context.Background(), sidecar.ClientOptions{
297 Package: pkg,
298 Installed: installed,
299 Session: protocol.SessionContext{SessionID: "sess-conf", WorkspaceRoot: "/ws", Generation: 1},
300 })
301 if err == nil {
302 t.Fatal("StartClient succeeded with an under-declared manifest")
303 }
304 if reason := protocolReason(t, err); reason != protocol.ErrCapabilityNotDeclared {
305 t.Fatalf("reason = %q, want %q (err %v)", reason, protocol.ErrCapabilityNotDeclared, err)
306 }
307 }
308
309 // ---------------------------------------------------------------------------
310 // Tests: intercepts and strategy
311 // ---------------------------------------------------------------------------
312
313 // TestInputReceiveRewrite drives the "/fs " trigger: the example replaces the
314 // input; ordinary input continues untouched.
315 func TestInputReceiveRewrite(t *testing.T) {
316 client := startExample(t, nil, nil)
317
318 result := intercept(t, client, protocol.EventInputReceive, `{"text":"/fs hello world"}`)
319 var replaced struct {
320 Text string `json:"text"`
321 }
322 decodeReplacement(t, result, &replaced)
323 if replaced.Text != "hello world [rewritten by fullsidecar]" {
324 t.Fatalf("rewritten text = %q", replaced.Text)
325 }
326
327 result = intercept(t, client, protocol.EventInputReceive, `{"text":"plain input"}`)
328 if result.Decision != protocol.DecisionContinue {
329 t.Fatalf("decision for plain input = %q, want continue", result.Decision)
330 }
331 }
332
333 // TestToolBeforeBlockAndRewrite covers the tool interception: the denied tool
334 // is blocked, the rewritten tool's arguments gain the sandbox flag, and
335 // unrelated tools continue.
336 func TestToolBeforeBlockAndRewrite(t *testing.T) {
337 client := startExample(t, nil, nil)
338
339 blocked := intercept(t, client, protocol.EventToolBefore, `{"name":"dangerous_exec","arguments":"{}"}`)
340 if blocked.Decision != protocol.DecisionBlock {
341 t.Fatalf("decision = %q, want block", blocked.Decision)
342 }
343 if !strings.Contains(blocked.Reason, "dangerous_exec") {
344 t.Fatalf("block reason = %q", blocked.Reason)
345 }
346
347 rewritten := intercept(t, client, protocol.EventToolBefore, `{"name":"read","arguments":"{\"path\":\"/etc/hosts\"}"}`)
348 var replacement struct {
349 Name string `json:"name"`
350 Arguments string `json:"arguments"`
351 }
352 decodeReplacement(t, rewritten, &replacement)
353 if replacement.Name != "read" {
354 t.Fatalf("replacement name = %q", replacement.Name)
355 }
356 var args map[string]any
357 if err := json.Unmarshal([]byte(replacement.Arguments), &args); err != nil {
358 t.Fatalf("rewritten arguments are not a JSON object: %v", err)
359 }
360 if args["sandbox"] != true || args["path"] != "/etc/hosts" {
361 t.Fatalf("rewritten arguments = %v", args)
362 }
363
364 passthrough := intercept(t, client, protocol.EventToolBefore, `{"name":"write","arguments":"{}"}`)
365 if passthrough.Decision != protocol.DecisionContinue {
366 t.Fatalf("decision for unrelated tool = %q, want continue", passthrough.Decision)
367 }
368 }
369
370 // TestSystemPromptStrategy proves the strategy-slot replacement lands: the
371 // example owns system_prompt.build and wraps the base prompt.
372 func TestSystemPromptStrategy(t *testing.T) {
373 client := startExample(t, nil, nil)
374 result := intercept(t, client, protocol.EventSystemPromptBuild, `{"prompt":"BASE PROMPT","workspaceRoot":"/ws"}`)
375 var replacement struct {
376 Prompt string `json:"prompt"`
377 WorkspaceRoot string `json:"workspaceRoot"`
378 }
379 decodeReplacement(t, result, &replacement)
380 if !strings.Contains(replacement.Prompt, "fullsidecar demo strategy") || !strings.Contains(replacement.Prompt, "BASE PROMPT") {
381 t.Fatalf("replacement prompt = %q", replacement.Prompt)
382 }
383 if replacement.WorkspaceRoot != "/ws" {
384 t.Fatalf("workspaceRoot = %q", replacement.WorkspaceRoot)
385 }
386 }
387
388 // ---------------------------------------------------------------------------
389 // Tests: provider broker
390 // ---------------------------------------------------------------------------
391
392 // TestProviderCatalog fetches the extension's provider catalog through the
393 // host client.
394 func TestProviderCatalog(t *testing.T) {
395 client := startExample(t, nil, nil)
396 providers, err := client.ProviderCatalog(context.Background())
397 if err != nil {
398 t.Fatalf("ProviderCatalog: %v", err)
399 }
400 if len(providers) != 1 {
401 t.Fatalf("catalog = %+v", providers)
402 }
403 desc := providers[0]
404 if desc.Ref != testProvider || desc.Model != "echo" || !desc.Tools || !desc.Reasoning {
405 t.Fatalf("descriptor = %+v", desc)
406 }
407 }
408
409 // TestProviderStream opens one stream and asserts the scripted completion
410 // arrives in order with contiguous seqs, a tool call, usage, and a clean end.
411 func TestProviderStream(t *testing.T) {
412 streams := &stubStreams{}
413 client := startExample(t, nil, func(o *sidecar.ClientOptions) { o.Streams = streams })
414
415 opened, err := client.ProviderStreamOpen(context.Background(), protocol.StreamOpenParams{
416 StreamID: "s-full",
417 ProviderRef: testProvider,
418 Request: protocol.ProviderRequest{Messages: []protocol.ProviderMessage{}, Tools: []protocol.ProviderToolSchema{}},
419 })
420 if err != nil {
421 t.Fatalf("ProviderStreamOpen: %v", err)
422 }
423 if !opened.Accepted {
424 t.Fatal("stream open was not accepted")
425 }
426 waitFor(t, "stream end", 10*time.Second, func() bool {
427 _, ends := streams.snapshot()
428 return len(ends) == 1
429 })
430 chunks, ends := streams.snapshot()
431 if len(chunks) != 5 {
432 t.Fatalf("received %d chunks, want 5: %+v", len(chunks), chunks)
433 }
434 for i, chunk := range chunks {
435 if chunk.StreamID != "s-full" || chunk.Seq != int64(i+1) {
436 t.Fatalf("chunk %d = stream %q seq %d, want s-full/%d", i, chunk.StreamID, chunk.Seq, i+1)
437 }
438 }
439 if chunks[0].Chunk.Type != protocol.ChunkText || chunks[0].Chunk.Text != "fake-hello " {
440 t.Fatalf("chunk 1 = %+v", chunks[0].Chunk)
441 }
442 if chunks[1].Chunk.Type != protocol.ChunkText || chunks[1].Chunk.Text != "fake-world" {
443 t.Fatalf("chunk 2 = %+v", chunks[1].Chunk)
444 }
445 call := chunks[2].Chunk
446 if call.Type != protocol.ChunkToolCall || call.ToolCall == nil || call.ToolCall.Name != "lookup" || call.ToolCall.ID != "call-1" {
447 t.Fatalf("tool call chunk = %+v", call)
448 }
449 usage := chunks[3].Chunk
450 if usage.Type != protocol.ChunkUsage || usage.Usage == nil || usage.Usage.TotalTokens != 12 || usage.Usage.PromptTokens != 5 {
451 t.Fatalf("usage chunk = %+v", usage)
452 }
453 if chunks[4].Chunk.Type != protocol.ChunkDone {
454 t.Fatalf("final chunk type = %q, want done", chunks[4].Chunk.Type)
455 }
456 end := ends[0]
457 if end.StreamID != "s-full" || end.LastSeq != 5 || end.Error != "" || end.Interrupted {
458 t.Fatalf("stream end = %+v", end)
459 }
460 }
461
462 // TestProviderStreamCancel cancels mid-stream: the cancel is honored, the
463 // stream ends interrupted at the last delivered seq, and no chunk travels
464 // after the cancel.
465 func TestProviderStreamCancel(t *testing.T) {
466 streams := &stubStreams{}
467 client := startExample(t, func(rt *pluginpkg.RuntimeSpec) {
468 rt.Env = map[string]string{"FULLSIDECAR_STREAM_INTERVAL_MS": "150"}
469 }, func(o *sidecar.ClientOptions) { o.Streams = streams })
470
471 if _, err := client.ProviderStreamOpen(context.Background(), protocol.StreamOpenParams{
472 StreamID: "s-cancel",
473 ProviderRef: testProvider,
474 Request: protocol.ProviderRequest{Messages: []protocol.ProviderMessage{}, Tools: []protocol.ProviderToolSchema{}},
475 }); err != nil {
476 t.Fatalf("ProviderStreamOpen: %v", err)
477 }
478 waitFor(t, "first chunk", 10*time.Second, func() bool {
479 chunks, _ := streams.snapshot()
480 return len(chunks) >= 1
481 })
482 client.ProviderStreamCancel("s-cancel")
483 waitFor(t, "stream end", 10*time.Second, func() bool {
484 _, ends := streams.snapshot()
485 return len(ends) == 1
486 })
487 chunks, ends := streams.snapshot()
488 end := ends[0]
489 if !end.Interrupted {
490 t.Fatalf("stream end = %+v, want interrupted", end)
491 }
492 if end.LastSeq != 1 {
493 t.Fatalf("end.lastSeq = %d, want 1", end.LastSeq)
494 }
495 for _, chunk := range chunks {
496 if chunk.Seq > end.LastSeq {
497 t.Fatalf("chunk seq %d traveled after the cancel (end %+v)", chunk.Seq, end)
498 }
499 }
500 }
501
502 // ---------------------------------------------------------------------------
503 // Tests: content refs
504 // ---------------------------------------------------------------------------
505
506 // TestContentRefRehydration sends an intercept payload above the 64 KiB
507 // externalization threshold: the host moves it into a content ref, and the
508 // SDK pages it back transparently — the extension must see (and rewrite) the
509 // full payload.
510 func TestContentRefRehydration(t *testing.T) {
511 client := startExample(t, nil, nil)
512 big := strings.Repeat("x", 100<<10)
513 payload, err := json.Marshal(map[string]string{"text": "/fs " + big})
514 if err != nil {
515 t.Fatal(err)
516 }
517 if len(payload) <= protocol.ExternalizeFieldBytes {
518 t.Fatalf("payload is %d bytes, want above the %d threshold", len(payload), protocol.ExternalizeFieldBytes)
519 }
520 result, err := client.Intercept(context.Background(), protocol.EventInputReceive, payload, 15*time.Second)
521 if err != nil {
522 t.Fatalf("Intercept: %v", err)
523 }
524 var replaced struct {
525 Text string `json:"text"`
526 }
527 decodeReplacement(t, result, &replaced)
528 if replaced.Text != big+" [rewritten by fullsidecar]" {
529 t.Fatalf("rehydrated text is %d bytes, want %d (full payload reassembled)", len(replaced.Text), len(big)+len(" [rewritten by fullsidecar]"))
530 }
531 }
532
533 // ---------------------------------------------------------------------------
534 // Tests: UI
535 // ---------------------------------------------------------------------------
536
537 // TestSessionStartPublishes drives one session.start observation: the example
538 // publishes its status line and demo card through host/ui/publish.
539 func TestSessionStartPublishes(t *testing.T) {
540 ui := &stubUI{}
541 client := startExample(t, nil, func(o *sidecar.ClientOptions) { o.UI = ui })
542
543 if err := client.NotifyEvent(protocol.EventSessionStart, json.RawMessage(`{"sessionPath":"/s/1","phase":"start"}`)); err != nil {
544 t.Fatalf("NotifyEvent: %v", err)
545 }
546 waitFor(t, "status and card publish", 10*time.Second, func() bool {
547 return ui.publishedCount() >= 2
548 })
549
550 statuses := ui.publishedOfKind(protocol.UISurfaceStatus)
551 if len(statuses) != 1 {
552 t.Fatalf("status publishes = %+v", statuses)
553 }
554 var status protocol.UIStatusPayload
555 if err := json.Unmarshal(statuses[0].Payload, &status); err != nil {
556 t.Fatalf("status payload: %v", err)
557 }
558 if statuses[0].SurfaceID != "fullsidecar-status" || status.Label != "fullsidecar online" {
559 t.Fatalf("status surface = %q %+v", statuses[0].SurfaceID, status)
560 }
561
562 cards := ui.publishedOfKind(protocol.UISurfaceCard)
563 if len(cards) != 1 {
564 t.Fatalf("card publishes = %+v", cards)
565 }
566 var card protocol.UICardPayload
567 if err := json.Unmarshal(cards[0].Payload, &card); err != nil {
568 t.Fatalf("card payload: %v", err)
569 }
570 if cards[0].SurfaceID != "fullsidecar-card" || len(card.Actions) != 1 || card.Actions[0].ActionID != "demo" {
571 t.Fatalf("card surface = %q %+v", cards[0].SurfaceID, card)
572 }
573 }
574
575 // TestUIActionRoundTrip invokes the demo action: the example issues a
576 // blocking form request (answered by the stub UI handler) and publishes the
577 // greeting notification built from the answers.
578 func TestUIActionRoundTrip(t *testing.T) {
579 ui := &stubUI{}
580 var requested protocol.UIRequestParams
581 ui.requestFn = func(p protocol.UIRequestParams) (protocol.UIRequestResult, error) {
582 requested = p
583 return protocol.UIRequestResult{Values: map[string]any{"name": "Ada", "loud": true}}, nil
584 }
585 client := startExample(t, nil, func(o *sidecar.ClientOptions) { o.UI = ui })
586
587 result, err := client.UIAction(context.Background(), protocol.UIActionParams{
588 ActionID: "demo", SessionID: "sess-conf", Generation: 1,
589 })
590 if err != nil {
591 t.Fatalf("UIAction: %v", err)
592 }
593 if !result.Accepted {
594 t.Fatalf("action rejected: %+v", result)
595 }
596 if requested.SurfaceID != "fullsidecar-demo-form" || requested.SessionID != "sess-conf" || requested.Kind != protocol.UIRequestInput {
597 t.Fatalf("ui request = %+v", requested)
598 }
599 notifications := ui.publishedOfKind(protocol.UISurfaceNotification)
600 if len(notifications) != 1 {
601 t.Fatalf("notification publishes = %+v", notifications)
602 }
603 var notice protocol.UINotificationPayload
604 if err := json.Unmarshal(notifications[0].Payload, &notice); err != nil {
605 t.Fatalf("notification payload: %v", err)
606 }
607 if notice.Title != "HELLO, ADA!" {
608 t.Fatalf("greeting = %q", notice.Title)
609 }
610 }
611
612 // TestUISubmitRoundTrip delivers a form submission; the example acknowledges
613 // it with a status update.
614 func TestUISubmitRoundTrip(t *testing.T) {
615 ui := &stubUI{}
616 client := startExample(t, nil, func(o *sidecar.ClientOptions) { o.UI = ui })
617
618 result, err := client.UISubmit(context.Background(), protocol.UISubmitParams{
619 SurfaceID: "fullsidecar-demo-form", SessionID: "sess-conf", Generation: 1,
620 Values: map[string]any{"name": "Ada"},
621 })
622 if err != nil {
623 t.Fatalf("UISubmit: %v", err)
624 }
625 if !result.Accepted {
626 t.Fatalf("submit rejected: %+v", result)
627 }
628 waitFor(t, "submit status publish", 10*time.Second, func() bool {
629 return len(ui.publishedOfKind(protocol.UISurfaceStatus)) == 1
630 })
631 statuses := ui.publishedOfKind(protocol.UISurfaceStatus)
632 var status protocol.UIStatusPayload
633 if err := json.Unmarshal(statuses[0].Payload, &status); err != nil {
634 t.Fatalf("status payload: %v", err)
635 }
636 if !strings.Contains(status.Label, "fullsidecar-demo-form") {
637 t.Fatalf("submit status label = %q", status.Label)
638 }
639 }
640
641 // ---------------------------------------------------------------------------
642 // Tests: timeout and crash
643 // ---------------------------------------------------------------------------
644
645 // TestInterceptTimeout stalls the example past the intercept budget; the host
646 // must surface the frozen intercept_timeout reason.
647 func TestInterceptTimeout(t *testing.T) {
648 client := startExample(t, func(rt *pluginpkg.RuntimeSpec) {
649 rt.Env = map[string]string{"FULLSIDECAR_STALL_ON_INPUT": "stall-me"}
650 }, nil)
651 started := time.Now()
652 _, err := client.Intercept(context.Background(), protocol.EventInputReceive, json.RawMessage(`{"text":"stall-me"}`), 500*time.Millisecond)
653 if err == nil {
654 t.Fatal("Intercept succeeded against a stalling extension")
655 }
656 if reason := protocolReason(t, err); reason != protocol.ErrInterceptTimeout {
657 t.Fatalf("reason = %q, want %q (err %v)", reason, protocol.ErrInterceptTimeout, err)
658 }
659 if elapsed := time.Since(started); elapsed > 5*time.Second {
660 t.Fatalf("timeout surfaced after %s, not bounded by the 500ms budget", elapsed)
661 }
662 }
663
664 // TestCrashMidIntercept kills the extension process while an intercept is in
665 // flight: the pending call errors and every later call fails fast with the
666 // crashed-sidecar reason.
667 func TestCrashMidIntercept(t *testing.T) {
668 client := startExample(t, func(rt *pluginpkg.RuntimeSpec) {
669 rt.Env = map[string]string{"FULLSIDECAR_CRASH_ON_INPUT": "boom"}
670 }, nil)
671
672 _, err := client.Intercept(context.Background(), protocol.EventInputReceive, json.RawMessage(`{"text":"boom"}`), 10*time.Second)
673 if err == nil {
674 t.Fatal("Intercept succeeded though the extension exited mid-intercept")
675 }
676 waitFor(t, "crash detection", 10*time.Second, client.Crashed)
677
678 started := time.Now()
679 _, err = client.Intercept(context.Background(), protocol.EventInputReceive, json.RawMessage(`{"text":"ok"}`), 10*time.Second)
680 if err == nil {
681 t.Fatal("Intercept on a crashed sidecar succeeded")
682 }
683 if reason := protocolReason(t, err); reason != protocol.ErrProviderInterrupted {
684 t.Fatalf("reason = %q, want %q (err %v)", reason, protocol.ErrProviderInterrupted, err)
685 }
686 if elapsed := time.Since(started); elapsed > 2*time.Second {
687 t.Fatalf("call on a crashed sidecar took %s, not fail-fast", elapsed)
688 }
689 }
690
690 lines GO