| 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, ¬ice); 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 |