@@ -0,0 +1,249 @@
1+package skills
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestManager_NoGlobalMirrorByDefault(t *testing.T) {
15+ // Not parallel - touches package-level cache.
16+ prev := GetLatestStates()
17+ t.Cleanup(func() { SetLatestStates(prev) })
18+
19+ SetLatestStates(nil)
20+
21+ mgrA := NewManager(nil, nil, []*SkillState{{Name: "a", State: StateNormal}})
22+ mgrB := NewManager(nil, nil, []*SkillState{{Name: "b", State: StateNormal}})
23+
24+ mgrA.PublishStates(mgrA.States())
25+ mgrB.PublishStates(mgrB.States())
26+
27+ // Without WithGlobalMirror, the package-level cache must not be
28+ // touched by manager construction or PublishStates calls.
29+ require.Nil(t, GetLatestStates(), "package global must remain untouched")
30+ require.Equal(t, "a", mgrA.States()[0].Name)
31+ require.Equal(t, "b", mgrB.States()[0].Name)
32+}
33+
34+func TestManager_GlobalMirror(t *testing.T) {
35+ // Not parallel - touches package-level cache.
36+ prev := GetLatestStates()
37+ t.Cleanup(func() { SetLatestStates(prev) })
38+
39+ SetLatestStates(nil)
40+
41+ mgr := NewManager(nil, nil, []*SkillState{{Name: "x", State: StateNormal}}, WithGlobalMirror())
42+
43+ got := GetLatestStates()
44+ require.Len(t, got, 1)
45+ require.Equal(t, "x", got[0].Name)
46+
47+ // PublishStates with mirror enabled forwards to the global cache.
48+ mgr.SetLatestStates([]*SkillState{{Name: "y", State: StateNormal}})
49+ got = GetLatestStates()
50+ require.Len(t, got, 1)
51+ require.Equal(t, "y", got[0].Name)
52+}
53+
54+func TestManager_PublishStatesUpdatesCache(t *testing.T) {
55+ // Not parallel - exercises WithGlobalMirror, which touches the
56+ // package-level cache.
57+ prev := GetLatestStates()
58+ t.Cleanup(func() { SetLatestStates(prev) })
59+
60+ SetLatestStates(nil)
61+
62+ mgr := NewManager(nil, nil, []*SkillState{{Name: "old"}}, WithGlobalMirror())
63+ t.Cleanup(mgr.Shutdown)
64+
65+ // PublishStates must update every observable snapshot, not just the
66+ // SSE subscribers: Manager.States() (used by workspaceToProto on
67+ // the backend) and skills.GetLatestStates() (read by the TUI on the
68+ // client process and in local mode) must reflect the new value.
69+ mgr.PublishStates([]*SkillState{{Name: "new"}})
70+
71+ got := mgr.States()
72+ require.Len(t, got, 1)
73+ require.Equal(t, "new", got[0].Name)
74+
75+ cached := GetLatestStates()
76+ require.Len(t, cached, 1)
77+ require.Equal(t, "new", cached[0].Name)
78+}
79+
80+func TestManager_SubscribeReceivesPublishedStates(t *testing.T) {
81+ t.Parallel()
82+
83+ mgr := NewManager(nil, nil, nil)
84+ t.Cleanup(mgr.Shutdown)
85+
86+ ctx, cancel := context.WithCancel(context.Background())
87+ t.Cleanup(cancel)
88+ ch := mgr.SubscribeEvents(ctx)
89+
90+ want := []*SkillState{{Name: "k", State: StateNormal}}
91+ go mgr.PublishStates(want)
92+
93+ select {
94+ case ev := <-ch:
95+ require.Equal(t, "k", ev.Payload.States[0].Name)
96+ case <-time.After(2 * time.Second):
97+ t.Fatal("timed out waiting for manager event")
98+ }
99+}
100+
101+func TestManager_ConcurrentWorkspacesAreIsolated(t *testing.T) {
102+ t.Parallel()
103+
104+ // Two managers without WithGlobalMirror should not see each other's
105+ // events; this models the multi-workspace backend.
106+ mgrA := NewManager(nil, nil, nil)
107+ mgrB := NewManager(nil, nil, nil)
108+ t.Cleanup(mgrA.Shutdown)
109+ t.Cleanup(mgrB.Shutdown)
110+
111+ ctx, cancel := context.WithCancel(context.Background())
112+ t.Cleanup(cancel)
113+ chA := mgrA.SubscribeEvents(ctx)
114+ chB := mgrB.SubscribeEvents(ctx)
115+
116+ go mgrA.PublishStates([]*SkillState{{Name: "from-a"}})
117+
118+ select {
119+ case ev := <-chA:
120+ require.Equal(t, "from-a", ev.Payload.States[0].Name)
121+ case <-time.After(2 * time.Second):
122+ t.Fatal("workspace A never received its own event")
123+ }
124+
125+ select {
126+ case ev := <-chB:
127+ t.Fatalf("workspace B received workspace A's event: %v", ev)
128+ case <-time.After(100 * time.Millisecond):
129+ // Expected — B's stream is isolated.
130+ }
131+}
132+
133+func TestDiscoverFromConfig(t *testing.T) {
134+ t.Parallel()
135+
136+ tmp := t.TempDir()
137+ skillDir := filepath.Join(tmp, "custom-skill")
138+ require.NoError(t, os.MkdirAll(skillDir, 0o755))
139+ require.NoError(t, os.WriteFile(
140+ filepath.Join(skillDir, SkillFileName),
141+ []byte("---\nname: custom-skill\ndescription: A custom skill for tests.\n---\nDo a thing.\n"),
142+ 0o644,
143+ ))
144+
145+ allSkills, activeSkills, states := DiscoverFromConfig(DiscoveryConfig{
146+ SkillsPaths: []string{tmp},
147+ DisabledSkills: nil,
148+ })
149+
150+ // Builtins plus our one custom skill.
151+ require.NotEmpty(t, allSkills)
152+ require.NotEmpty(t, activeSkills)
153+ require.GreaterOrEqual(t, len(allSkills), 2)
154+ require.GreaterOrEqual(t, len(activeSkills), 2)
155+
156+ // The custom skill is present with full Instructions populated, so
157+ // the coordinator can render system prompts without re-walking the
158+ // filesystem.
159+ var custom *Skill
160+ for _, s := range allSkills {
161+ if s.Name == "custom-skill" {
162+ custom = s
163+ break
164+ }
165+ }
166+ require.NotNil(t, custom)
167+ require.NotEmpty(t, custom.Instructions, "DiscoverFromConfig must return Skill.Instructions")
168+
169+ // State snapshot includes the custom skill too.
170+ foundCustom := false
171+ for _, s := range states {
172+ if s.Name == "custom-skill" {
173+ foundCustom = true
174+ require.Equal(t, StateNormal, s.State)
175+ }
176+ }
177+ require.True(t, foundCustom, "states slice should include the custom skill")
178+}
179+
180+func TestDiscoverFromConfig_DisabledFiltered(t *testing.T) {
181+ t.Parallel()
182+
183+ tmp := t.TempDir()
184+ skillDir := filepath.Join(tmp, "off-skill")
185+ require.NoError(t, os.MkdirAll(skillDir, 0o755))
186+ require.NoError(t, os.WriteFile(
187+ filepath.Join(skillDir, SkillFileName),
188+ []byte("---\nname: off-skill\ndescription: Should be filtered.\n---\nx\n"),
189+ 0o644,
190+ ))
191+
192+ allSkills, activeSkills, states := DiscoverFromConfig(DiscoveryConfig{
193+ SkillsPaths: []string{tmp},
194+ DisabledSkills: []string{"off-skill"},
195+ })
196+
197+ // All discovered: yes; active: no.
198+ hasInAll := false
199+ for _, s := range allSkills {
200+ if s.Name == "off-skill" {
201+ hasInAll = true
202+ }
203+ }
204+ require.True(t, hasInAll, "DisabledSkills must not be removed from allSkills")
205+
206+ for _, s := range activeSkills {
207+ require.NotEqual(t, "off-skill", s.Name, "DisabledSkills must be removed from activeSkills")
208+ }
209+
210+ // State snapshot still carries discovered entries (UI re-applies filter).
211+ hasInStates := false
212+ for _, s := range states {
213+ if s.Name == "off-skill" {
214+ hasInStates = true
215+ }
216+ }
217+ require.True(t, hasInStates)
218+}
219+
220+func TestDiscoverFromConfig_Resolver(t *testing.T) {
221+ t.Parallel()
222+
223+ tmp := t.TempDir()
224+ skillDir := filepath.Join(tmp, "envvar-skill")
225+ require.NoError(t, os.MkdirAll(skillDir, 0o755))
226+ require.NoError(t, os.WriteFile(
227+ filepath.Join(skillDir, SkillFileName),
228+ []byte("---\nname: envvar-skill\ndescription: Env-resolved.\n---\nx\n"),
229+ 0o644,
230+ ))
231+
232+ allSkills, _, _ := DiscoverFromConfig(DiscoveryConfig{
233+ SkillsPaths: []string{"$CUSTOM_SKILLS_DIR"},
234+ Resolver: func(s string) (string, error) {
235+ if s == "$CUSTOM_SKILLS_DIR" {
236+ return tmp, nil
237+ }
238+ return s, errors.New("unknown")
239+ },
240+ })
241+
242+ found := false
243+ for _, s := range allSkills {
244+ if s.Name == "envvar-skill" {
245+ found = true
246+ }
247+ }
248+ require.True(t, found, "DiscoverFromConfig must expand $VAR via Resolver")
249+}