package plugin import ( "context" "encoding/json" "fmt" "os" "strings" "sync" "testing" "time" "reasonix/internal/tool" ) // helperSpec returns a Spec that re-invokes this test binary as a minimal MCP // stdio server (see TestHelperProcess in plugin_test.go). Reused across every // lazy_test case so the helper-process contract — "echo: " responder with // tools/list exposing echo and zed — stays the single source of truth. func helperSpec() Spec { return Spec{ Name: "mock", Command: os.Args[0], Args: []string{"-test.run=TestHelperProcess", "--"}, Env: map[string]string{"GO_WANT_HELPER_PROCESS": "1"}, } } // writeMockCache primes the on-disk cache for spec with the two tools the // helper subprocess exposes (echo, zed). We mirror the real schemas so a // cache-hit lazyTool surfaces the same Schema() bytes that a freshly handshaked // remoteTool would — the test for "model sees real schema before any Execute" // depends on this equivalence. func writeMockCache(t *testing.T, spec Spec) { t.Helper() cs := CachedSchema{ SpecHash: SpecFingerprint(spec), Capabilities: map[string]bool{"prompts": false, "resources": false}, Tools: []CachedTool{ { Name: "echo", Description: "Echo back the message.", Schema: json.RawMessage(`{"type":"object","properties":{"msg":{"type":"string"}},"required":["z","msg"]}`), }, { Name: "zed", Description: "Sorted after echo.", Schema: json.RawMessage(`{"type":"object"}`), }, }, } if err := SaveCachedSchema(spec.Name, cs); err != nil { t.Fatalf("SaveCachedSchema: %v", err) } } // waitForServer polls host.ServerNames() until name appears or timeout // elapses. The lazy path spawns via a goroutine, so tests need a bounded poll // rather than a fixed sleep — five seconds covers a slow CI subprocess fork // while still aborting clearly on a real hang. func waitForServer(t *testing.T, host *Host, name string, timeout time.Duration) { t.Helper() deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { for _, n := range host.ServerNames() { if n == name { return } } time.Sleep(10 * time.Millisecond) } t.Fatalf("server %q never appeared in host.ServerNames() within %v (got %v)", name, timeout, host.ServerNames()) } func waitForCachedSchema(t *testing.T, spec Spec, timeout time.Duration) *CachedSchema { t.Helper() deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { if cs, ok := LoadCachedSchema(spec.Name, SpecFingerprint(spec)); ok { return cs } time.Sleep(10 * time.Millisecond) } t.Fatalf("cached schema for %q never appeared within %v", spec.Name, timeout) return nil } // TestLazyCacheHitSyncSpawn drives the cache-hit branch end-to-end: cache is // pre-populated, the model can see real schemas before any spawn, and the // first Execute synchronously handshakes, swaps the placeholder for the real // *remoteTool, and forwards through in one turn. This is the "warm start" // payoff — lazy plugins should be indistinguishable from eager once they have // a cache. func TestLazyCacheHitSyncSpawn(t *testing.T) { redirectCache(t) spec := helperSpec() writeMockCache(t, spec) cs, ok := LoadCachedSchema(spec.Name, SpecFingerprint(spec)) if !ok { t.Fatal("LoadCachedSchema: miss right after save (sanity)") } host := NewHost() defer host.Close() reg := tool.NewRegistry() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() tools := LazyToolset(spec, cs, host, reg, ctx, false) if len(tools) != 2 { t.Fatalf("LazyToolset returned %d tools, want 2 (echo + zed)", len(tools)) } for _, lt := range tools { reg.Add(lt) } // Before any Execute: registry exposes real cached schemas (not the empty // {"type":"object"} stub). The model relies on this to call the tool with // real args on the very first turn. echoBefore, ok := reg.Get("mcp__mock__echo") if !ok { t.Fatal("registry missing mcp__mock__echo after LazyToolset") } if _, isLazy := echoBefore.(*lazyTool); !isLazy { t.Fatalf("pre-Execute echo should be a *lazyTool, got %T", echoBefore) } gotSchema := string(echoBefore.Schema()) if !strings.Contains(gotSchema, `"msg"`) || !strings.Contains(gotSchema, `"required"`) { t.Fatalf("cached schema not surfaced through lazyTool.Schema(): %s", gotSchema) } // First Execute: cache-hit path runs the handshake synchronously and // forwards to the real tool — the user sees "echo: hi" in this same turn. out, err := echoBefore.Execute(ctx, json.RawMessage(`{"msg":"hi"}`)) if err != nil { t.Fatalf("Execute: %v", err) } if out != "echo: hi" { t.Fatalf("Execute result = %q, want %q", out, "echo: hi") } // The spawn actually happened — host now lists the mock server. names := host.ServerNames() if len(names) != 1 || names[0] != "mock" { t.Fatalf("host.ServerNames() = %v, want [mock]", names) } // After Execute, the registry entry must STILL be the placeholder: cache-hit // placeholders are pinned for the whole session so the request's tools // array stays byte-identical even when the live handshake differs from the // cache (see trySwap). Execution keeps forwarding to the real tool through // the shared spawn state. echoAfter, _ := reg.Get("mcp__mock__echo") if _, isLazy := echoAfter.(*lazyTool); !isLazy { t.Fatalf("post-Execute echo should remain the pinned *lazyTool, got %T", echoAfter) } if got := string(echoAfter.Schema()); got != gotSchema { t.Fatalf("registry schema bytes changed across the handshake:\nbefore: %s\nafter: %s", gotSchema, got) } // Second call goes straight through the ready state to the real tool. out2, err := echoAfter.Execute(ctx, json.RawMessage(`{"msg":"again"}`)) if err != nil { t.Fatalf("second Execute: %v", err) } if out2 != "echo: again" { t.Fatalf("second Execute result = %q, want %q", out2, "echo: again") } } func TestLazyCacheHitReusesExistingSharedHostClient(t *testing.T) { redirectCache(t) spec := helperSpec() writeMockCache(t, spec) cs, ok := LoadCachedSchema(spec.Name, SpecFingerprint(spec)) if !ok { t.Fatal("LoadCachedSchema: miss right after save (sanity)") } host := NewHost() defer host.Close() reg := tool.NewRegistry() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if _, err := host.Add(ctx, spec); err != nil { t.Fatalf("preconnect shared host: %v", err) } tools := LazyToolset(spec, cs, host, reg, ctx, false) for _, lt := range tools { reg.Add(lt) } echoBefore, ok := reg.Get("mcp__mock__echo") if !ok { t.Fatal("registry missing mcp__mock__echo after LazyToolset") } out, err := echoBefore.Execute(ctx, json.RawMessage(`{"msg":"hi"}`)) if err != nil { t.Fatalf("Execute against existing shared host client: %v", err) } if out != "echo: hi" { t.Fatalf("Execute result = %q, want %q", out, "echo: hi") } if got := host.ServerNames(); len(got) != 1 || got[0] != "mock" { t.Fatalf("shared host should still have exactly one mock server, got %v", got) } } func TestAddWithLifecycleCoalescesConcurrentSameServer(t *testing.T) { spec := helperSpec() spec.Env["GO_WANT_HELPER_INIT_MS"] = "200" host := NewHost() defer host.Close() lifeCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() start := make(chan struct{}) errs := make([]error, 2) toolCounts := make([]int, 2) var wg sync.WaitGroup for i := range errs { wg.Add(1) go func(i int) { defer wg.Done() <-start callCtx, cancelCall := context.WithTimeout(lifeCtx, 5*time.Second) defer cancelCall() tools, err := host.AddWithLifecycle(lifeCtx, callCtx, spec) errs[i] = err toolCounts[i] = len(tools) }(i) } close(start) wg.Wait() for i, err := range errs { if err != nil { t.Fatalf("AddWithLifecycle call %d failed: %v (all errors: %v)", i, err, errs) } if toolCounts[i] != 2 { t.Fatalf("AddWithLifecycle call %d returned %d tools, want 2", i, toolCounts[i]) } } if got := host.ServerNames(); len(got) != 1 || got[0] != "mock" { t.Fatalf("host should contain exactly one connected server, got %v", got) } } func TestLazyCacheHitStartupTimeoutCanRetry(t *testing.T) { redirectCache(t) spec := helperSpec() spec.Env["GO_WANT_HELPER_INIT_MS"] = fmt.Sprint(int(defaultStartTimeout/time.Millisecond) + 200) writeMockCache(t, spec) cs, ok := LoadCachedSchema(spec.Name, SpecFingerprint(spec)) if !ok { t.Fatal("LoadCachedSchema: miss right after save (sanity)") } host := NewHost() defer host.Close() reg := tool.NewRegistry() ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() tools := LazyToolset(spec, cs, host, reg, ctx, false) for _, lt := range tools { reg.Add(lt) } echo, ok := reg.Get("mcp__mock__echo") if !ok { t.Fatal("registry missing mcp__mock__echo after LazyToolset") } lazyEcho, ok := echo.(*lazyTool) if !ok { t.Fatalf("pre-Execute echo should be a *lazyTool, got %T", echo) } if _, err := echo.Execute(ctx, json.RawMessage(`{"msg":"slow"}`)); err == nil || !strings.Contains(err.Error(), "startup timed out") { t.Fatalf("first Execute error = %v, want startup timed out", err) } lazyEcho.shared.spec.Env["GO_WANT_HELPER_INIT_MS"] = "0" out, err := echo.Execute(ctx, json.RawMessage(`{"msg":"retry"}`)) if err != nil { t.Fatalf("second Execute after timeout should retry: %v", err) } if out != "echo: retry" { t.Fatalf("Execute result = %q, want %q", out, "echo: retry") } } func TestLazyToolsetAppliesSpecReadOnlyOverrideToCachedTools(t *testing.T) { redirectCache(t) spec := helperSpec() spec.ReadOnlyToolNames = map[string]bool{"echo": true} writeMockCache(t, spec) cs, ok := LoadCachedSchema(spec.Name, SpecFingerprint(spec)) if !ok { t.Fatal("LoadCachedSchema: miss right after save (sanity)") } host := NewHost() defer host.Close() reg := tool.NewRegistry() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() tools := LazyToolset(spec, cs, host, reg, ctx, false) byName := map[string]tool.Tool{} for _, tl := range tools { byName[tl.Name()] = tl } echo := byName["mcp__mock__echo"] if echo == nil { t.Fatalf("mcp__mock__echo missing from %v", byName) } if !echo.ReadOnly() { t.Fatal("lazy cached echo should use the spec read-only override") } zed := byName["mcp__mock__zed"] if zed == nil { t.Fatalf("mcp__mock__zed missing from %v", byName) } if zed.ReadOnly() { t.Fatal("lazy cached zed should keep cached non-read-only status") } } // TestLazyCacheMissAsyncSpawn drives the cache-miss branch: with no cache, a // single "connect" placeholder shows up; first Execute returns a retry hint and // kicks the spawn async; once that spawn finishes, the registry swaps to the // real tools under their real names, and the connect stub is dropped. This is // the "model warm-up" contract — the model must not see stale schemas, so we // refuse to forward the first call and instead ask for one more turn. func TestLazyCacheMissAsyncSpawn(t *testing.T) { redirectCache(t) spec := helperSpec() host := NewHost() defer host.Close() reg := tool.NewRegistry() ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() tools := LazyToolset(spec, nil, host, reg, ctx, false) if len(tools) != 1 { t.Fatalf("cache-miss LazyToolset must return 1 connect stub, got %d", len(tools)) } for _, lt := range tools { reg.Add(lt) } connect, ok := reg.Get("mcp__mock__connect") if !ok { t.Fatalf("registry missing mcp__mock__connect; names=%v", reg.Names()) } // First Execute must NOT forward — schema is unknown, so the model would // be feeding garbage. It returns a retry hint and triggers spawn async. _, err := connect.Execute(ctx, json.RawMessage(`{}`)) if err == nil { t.Fatal("first Execute on cache-miss placeholder should error with a retry hint") } msg := err.Error() if !strings.Contains(msg, "initializing") && !strings.Contains(msg, "next turn") { t.Fatalf("first-Execute error %q should mention 'initializing' or 'next turn'", msg) } // Wait for the async spawn to complete (host.Add happens on the run() // goroutine kicked by Execute). The goroutine swaps the registry itself, so // the next model request sees the real schemas without another placeholder // Execute call. waitForServer(t, host, "mock", 5*time.Second) if _, found := reg.Get("mcp__mock__connect"); found { t.Errorf("connect stub should be removed after swap, names=%v", reg.Names()) } if _, found := reg.Get("mcp__mock__echo"); !found { t.Errorf("real mcp__mock__echo missing after swap, names=%v", reg.Names()) } if _, found := reg.Get("mcp__mock__zed"); !found { t.Errorf("real mcp__mock__zed missing after swap, names=%v", reg.Names()) } } func TestLazySwapDoesNotRaceRegistrySchemas(t *testing.T) { redirectCache(t) spec := helperSpec() spec.Env["GO_WANT_HELPER_INIT_MS"] = "50" writeMockCache(t, spec) cs, _ := LoadCachedSchema(spec.Name, SpecFingerprint(spec)) host := NewHost() defer host.Close() reg := tool.NewRegistry() ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() tools := LazyToolset(spec, cs, host, reg, ctx, false) for _, lt := range tools { reg.Add(lt) } echo, _ := reg.Get("mcp__mock__echo") if echo == nil { t.Fatal("missing mcp__mock__echo placeholder") } done := make(chan struct{}) var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() for { select { case <-done: return default: _ = reg.Schemas() } } }() out, err := echo.Execute(ctx, json.RawMessage(`{"msg":"race"}`)) close(done) wg.Wait() if err != nil { t.Fatalf("Execute: %v", err) } if out != "echo: race" { t.Fatalf("Execute result = %q, want %q", out, "echo: race") } } // TestLazyBackgroundKick covers the background-tier path: kick=true plus a // cache hit means the spawn races boot, finishes before the model calls, and // the first Execute hits the "already-ready, swap on the way through" branch. // The model never sees a placeholder schema-wise either, since the cache // fed Schema() before kick even started. func TestLazyBackgroundKick(t *testing.T) { redirectCache(t) spec := helperSpec() writeMockCache(t, spec) cs, _ := LoadCachedSchema(spec.Name, SpecFingerprint(spec)) host := NewHost() defer host.Close() reg := tool.NewRegistry() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() tools := LazyToolset(spec, cs, host, reg, ctx, true) // kick=true if len(tools) != 2 { t.Fatalf("LazyToolset(kick=true) returned %d tools, want 2", len(tools)) } for _, lt := range tools { reg.Add(lt) } // Wait for the background spawn to complete — proof that kick fired off // the handshake without us calling Execute. waitForServer(t, host, "mock", 5*time.Second) // Now Execute: the state is already spawnReady, so this should swap + // forward in one shot without a second Add call. The result must still be // correct. echo, _ := reg.Get("mcp__mock__echo") out, err := echo.Execute(ctx, json.RawMessage(`{"msg":"bg"}`)) if err != nil { t.Fatalf("Execute after background ready: %v", err) } if out != "echo: bg" { t.Fatalf("Execute result = %q, want %q", out, "echo: bg") } // One spawn, not two — kick + Execute must collapse onto the same run. if names := host.ServerNames(); len(names) != 1 { t.Fatalf("host.ServerNames() = %v, want exactly one 'mock'", names) } } func TestLazyBackgroundCacheMissPersistsSchema(t *testing.T) { redirectCache(t) spec := helperSpec() host := NewHost() defer host.Close() reg := tool.NewRegistry() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() tools := LazyToolset(spec, nil, host, reg, ctx, true) // cache miss + background kick for _, lt := range tools { reg.Add(lt) } waitForServer(t, host, "mock", 5*time.Second) cs := waitForCachedSchema(t, spec, 5*time.Second) if len(cs.Tools) != 2 { t.Fatalf("cached schema has %d tools, want 2", len(cs.Tools)) } got := map[string]bool{} for _, ct := range cs.Tools { got[ct.Name] = true } if !got["echo"] || !got["zed"] { t.Fatalf("cached tools = %v, want echo and zed", got) } } func TestLazyBackgroundCloseCancelsInFlightKick(t *testing.T) { redirectCache(t) spec := helperSpec() spec.Name = "slow" spec.Env["GO_WANT_HELPER_INIT_MS"] = "5000" writeMockCache(t, spec) cs, _ := LoadCachedSchema(spec.Name, SpecFingerprint(spec)) host := NewHost() reg := tool.NewRegistry() tools := LazyToolset(spec, cs, host, reg, context.Background(), true) for _, lt := range tools { reg.Add(lt) } done := make(chan struct{}) go func() { host.Close() close(done) }() select { case <-done: case <-time.After(2 * time.Second): t.Fatal("Host.Close did not cancel the in-flight background lazy spawn") } if names := host.ServerNames(); len(names) != 0 { t.Fatalf("closed host retained connected servers: %v", names) } } // TestLazyConcurrentExecuteOnlyOneSpawn pins the de-duplication contract: 10 // goroutines racing through Execute on the same lazyTool may only trigger ONE // spawn (and therefore one connected mock server on the host). The state // machine's mu+state gate is what makes this true; this test would catch a // regression where someone moved the state transition outside the lock or // swapped to a TOCTOU check. // // Note: by design (see lazy.go), only the winner of the race forwards // synchronously; the losers observe spawnInFlight and return a "retry next // turn" hint rather than blocking. We assert that contract too: at least one // goroutine got "echo: r", and the racers that didn't win got the // initializing hint — never a spurious error and never a stale or partial // result. After all goroutines complete, a fresh Execute hits spawnReady and // forwards normally. func TestLazyConcurrentExecuteOnlyOneSpawn(t *testing.T) { redirectCache(t) spec := helperSpec() writeMockCache(t, spec) cs, _ := LoadCachedSchema(spec.Name, SpecFingerprint(spec)) host := NewHost() defer host.Close() reg := tool.NewRegistry() ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() tools := LazyToolset(spec, cs, host, reg, ctx, false) for _, lt := range tools { reg.Add(lt) } echo, _ := reg.Get("mcp__mock__echo") const goroutines = 10 var wg sync.WaitGroup results := make([]string, goroutines) errs := make([]error, goroutines) wg.Add(goroutines) for i := 0; i < goroutines; i++ { go func(i int) { defer wg.Done() out, err := echo.Execute(ctx, json.RawMessage(fmt.Sprintf(`{"msg":"r%d"}`, i))) results[i], errs[i] = out, err }(i) } wg.Wait() // Every result must be either the real "echo: rN" output or the explicit // initializing hint — nothing else. At least one goroutine (the racing // winner) must succeed, otherwise the state machine deadlocked the win. winners := 0 for i, err := range errs { want := fmt.Sprintf("echo: r%d", i) switch { case err == nil && results[i] == want: winners++ case err != nil && strings.Contains(err.Error(), "initializing"): // expected loser default: t.Errorf("goroutine %d: result=%q err=%v — must be either %q or an 'initializing' hint", i, results[i], err, want) } } if winners == 0 { t.Fatal("no goroutine succeeded — at least the race winner must forward through") } // Exactly one Client landed on the host: the mu+state gate kept the 9 // losers off the spawn path. This is the headline invariant of the lazy // design — racing the first call must not fork-bomb the subprocess. mockCount := 0 for _, n := range host.ServerNames() { if n == "mock" { mockCount++ } } if mockCount != 1 { t.Fatalf("expected 1 'mock' server after concurrent Execute, got %d (names=%v)", mockCount, host.ServerNames()) } // A follow-up Execute (now in spawnReady) goes through cleanly: the // "retry on next turn" hint was honest, not a permanent error. out, err := echo.Execute(ctx, json.RawMessage(`{"msg":"after"}`)) if err != nil { t.Fatalf("post-race Execute: %v", err) } if out != "echo: after" { t.Fatalf("post-race Execute = %q, want %q", out, "echo: after") } } // TestLazyHandshakeFailureSurfaced covers the spawnFailed sticky branch: a // bogus command can't start, the first Execute returns an error that mentions // "failed to start", and a second Execute returns the SAME error (the state // machine doesn't retry — we don't want to fork a doomed subprocess every // turn until the user fixes config). func TestLazyHandshakeFailureSurfaced(t *testing.T) { redirectCache(t) // Bogus command: process exec will fail outright. spec := Spec{Name: "missing", Command: "reasonix-nonexistent-binary-for-lazy-test"} // Hand-craft a cache so the cache-HIT branch runs (synchronous spawn, // failure surfaces directly to the first caller rather than via a retry // hint). The SpecHash must match — otherwise LoadCachedSchema would miss // and we'd be exercising the async path. cs := &CachedSchema{ SpecHash: SpecFingerprint(spec), Capabilities: map[string]bool{}, Tools: []CachedTool{{ Name: "doit", Description: "noop", Schema: json.RawMessage(`{"type":"object"}`), }}, } host := NewHost() defer host.Close() reg := tool.NewRegistry() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() tools := LazyToolset(spec, cs, host, reg, ctx, false) if len(tools) != 1 { t.Fatalf("LazyToolset returned %d tools, want 1 (doit)", len(tools)) } for _, lt := range tools { reg.Add(lt) } doit, _ := reg.Get("mcp__missing__doit") _, err1 := doit.Execute(ctx, json.RawMessage(`{}`)) if err1 == nil { t.Fatal("Execute on a bogus command should error") } if !strings.Contains(err1.Error(), "failed to start") { t.Fatalf("error %q should mention 'failed to start'", err1.Error()) } // Second call: same error, no retry. spawnFailed is sticky on purpose — // the operator must fix config and restart, not have us fork-bomb on // every turn. _, err2 := doit.Execute(ctx, json.RawMessage(`{}`)) if err2 == nil { t.Fatal("second Execute after spawnFailed should still error") } if !strings.Contains(err2.Error(), "failed to start") { t.Fatalf("second error %q should still mention 'failed to start' (state machine must stay in spawnFailed)", err2.Error()) } } // TestLazyToolsetCacheHitSchemaVisible is the model-facing visibility test: // immediately after LazyToolset returns and BEFORE any Execute, lazyTool.Schema() // must equal the canonicalized cached schema. The whole point of the cache is // that the model sees real schemas at turn-start; if Schema() returned the // "{}" stub here, the model would call with empty args and the cache-hit // path would never get a useful first call. func TestLazyToolsetCacheHitSchemaVisible(t *testing.T) { redirectCache(t) spec := helperSpec() rawSchema := json.RawMessage(`{"properties":{"msg":{"type":"string"}},"type":"object","required":["msg"]}`) cs := &CachedSchema{ SpecHash: SpecFingerprint(spec), Capabilities: map[string]bool{}, Tools: []CachedTool{{ Name: "echo", Description: "Echo back.", Schema: rawSchema, }}, } host := NewHost() defer host.Close() reg := tool.NewRegistry() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() tools := LazyToolset(spec, cs, host, reg, ctx, false) if len(tools) != 1 { t.Fatalf("LazyToolset returned %d tools, want 1", len(tools)) } got := string(tools[0].Schema()) want := string(canonicalizeSchema(rawSchema)) if got != want { t.Fatalf("lazyTool.Schema() = %s,\nwant canonicalized cached schema = %s", got, want) } // And we never spawned: Schema() must be free, otherwise the cache // optimisation is moot. if names := host.ServerNames(); len(names) != 0 { t.Fatalf("Schema() must not spawn; host.ServerNames() = %v", names) } } // registrySchemaBytes marshals the registry's full tool schemas — the exact // surface that feeds the provider request's tools array. func registrySchemaBytes(t *testing.T, reg *tool.Registry) string { t.Helper() b, err := json.Marshal(reg.Schemas()) if err != nil { t.Fatalf("marshal schemas: %v", err) } return string(b) } // TestLazyCacheHitPinsToolBytesAcrossDivergentHandshake is the session // byte-stability guard: the cached snapshot deliberately DIFFERS from what the // live handshake will report (stale description/schema, and it omits one tool // the live server exposes). After the background spawn completes, the // registry's schema bytes must be identical to what the model saw at boot — // the divergence surfaces in the refreshed disk cache (next session), never // mid-session in the tools array. func TestLazyCacheHitPinsToolBytesAcrossDivergentHandshake(t *testing.T) { redirectCache(t) spec := helperSpec() stale := CachedSchema{ SpecHash: SpecFingerprint(spec), Capabilities: map[string]bool{}, Tools: []CachedTool{{ Name: "echo", Description: "STALE description from a previous session.", Schema: json.RawMessage(`{"type":"object","properties":{"msg":{"type":"string"}}}`), // live handshake also exposes "zed" — absent here on purpose. }}, } if err := SaveCachedSchema(spec.Name, stale); err != nil { t.Fatalf("SaveCachedSchema: %v", err) } cs, ok := LoadCachedSchema(spec.Name, SpecFingerprint(spec)) if !ok { t.Fatal("LoadCachedSchema miss after save") } host := NewHost() defer host.Close() reg := tool.NewRegistry() ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() for _, lt := range LazyToolset(spec, cs, host, reg, ctx, true) { reg.Add(lt) } bootBytes := registrySchemaBytes(t, reg) // Let the background handshake finish and give trySwap every chance to run. waitForServer(t, host, "mock", 5*time.Second) echo, _ := reg.Get("mcp__mock__echo") if out, err := echo.Execute(ctx, json.RawMessage(`{"msg":"pin"}`)); err != nil || out != "echo: pin" { t.Fatalf("Execute after ready = %q, %v", out, err) } if got := registrySchemaBytes(t, reg); got != bootBytes { t.Fatalf("tools array bytes changed mid-session after a divergent handshake:\nboot: %s\nnow: %s", bootBytes, got) } if _, found := reg.Get("mcp__mock__zed"); found { t.Fatal("live-only tool joined the registry mid-session; it must wait for the next session") } // The refreshed cache carries the live truth for the NEXT session. The // stale cache this test wrote is itself loadable, so poll until the // refresh actually lands (the background save races Execute's return on // slow machines) rather than accepting the first loadable snapshot. deadline := time.Now().Add(5 * time.Second) for { refreshed, ok := LoadCachedSchema(spec.Name, SpecFingerprint(spec)) if ok { names := map[string]bool{} for _, ct := range refreshed.Tools { names[ct.Name] = true } if names["echo"] && names["zed"] { break } if time.Now().After(deadline) { t.Fatalf("refreshed cache tools = %v, want live set {echo, zed}", refreshed.Tools) } } else if time.Now().After(deadline) { t.Fatal("cached schema never became loadable") } time.Sleep(10 * time.Millisecond) } } // TestLazyEmptyCachedToolsFallsBackToConnectStub: a snapshot with zero tools // presents nothing the model could call, so it must take the cache-miss stub // path instead of letting live tools join the registry mid-session unnamed. func TestLazyEmptyCachedToolsFallsBackToConnectStub(t *testing.T) { redirectCache(t) spec := helperSpec() cs := &CachedSchema{SpecHash: SpecFingerprint(spec), Tools: nil} host := NewHost() defer host.Close() reg := tool.NewRegistry() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() tools := LazyToolset(spec, cs, host, reg, ctx, false) if len(tools) != 1 || tools[0].Name() != "mcp__mock__connect" { t.Fatalf("empty-cache toolset = %v, want single connect stub", tools) } } // TestAddWithLifecycleSurvivesHandshakeCtxCancel proves the on-demand proxy // pattern: connect with a short handshake budget, cancel it immediately after // connect, and the stdio child must stay alive (its lifetime is lifeCtx) so // the tool call that triggered the connect can still execute. func TestAddWithLifecycleSurvivesHandshakeCtxCancel(t *testing.T) { spec := helperSpec() host := NewHost() defer host.Close() lifeCtx, cancelLife := context.WithCancel(context.Background()) defer cancelLife() handshakeCtx, cancelHandshake := context.WithTimeout(context.Background(), 5*time.Second) tools, err := host.AddWithLifecycle(lifeCtx, handshakeCtx, spec) cancelHandshake() // the proxy's deferred cancel fires right after connect if err != nil { t.Fatalf("AddWithLifecycle: %v", err) } var echo tool.Tool for _, tl := range tools { if strings.HasSuffix(tl.Name(), "__echo") { echo = tl } } if echo == nil { t.Fatalf("no echo tool in %d tools", len(tools)) } out, err := echo.Execute(context.Background(), json.RawMessage(`{"msg":"hi"}`)) if err != nil { t.Fatalf("Execute after handshake ctx cancel: %v — the child died with the handshake context", err) } if out != "echo: hi" { t.Fatalf("Execute result = %q, want %q", out, "echo: hi") } }