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