1package workspace
2
3import (
4 "testing"
5
6 "github.com/charmbracelet/crush/internal/message"
7 "github.com/charmbracelet/crush/internal/proto"
8 "github.com/charmbracelet/crush/internal/pubsub"
9 "github.com/charmbracelet/crush/internal/skills"
10 "github.com/stretchr/testify/require"
11)
12
13// TestProtoToMessageToolResult ensures that ToolResult metadata,
14// data, and MIME type survive the conversion from proto on the
15// client. Without these fields the TUI cannot render rich tool
16// output (e.g. syntax-highlighted code from view, diffs from edit,
17// images, etc.) and falls back to the raw LLM-facing string.
18func TestProtoToMessageToolResult(t *testing.T) {
19 t.Parallel()
20
21 src := proto.Message{
22 ID: "m1",
23 Role: proto.Tool,
24 Parts: []proto.ContentPart{
25 proto.ToolResult{
26 ToolCallID: "call-1",
27 Name: "view",
28 Content: "<file>\n 1| hi\n</file>",
29 Data: "base64data",
30 MIMEType: "image/png",
31 Metadata: `{"file_path":"/tmp/x","content":"hi"}`,
32 IsError: false,
33 },
34 },
35 }
36
37 got := protoToMessage(src)
38 require.Len(t, got.Parts, 1)
39 tr, ok := got.Parts[0].(message.ToolResult)
40 require.True(t, ok, "expected message.ToolResult, got %T", got.Parts[0])
41 require.Equal(t, "call-1", tr.ToolCallID)
42 require.Equal(t, "view", tr.Name)
43 require.Equal(t, "<file>\n 1| hi\n</file>", tr.Content)
44 require.Equal(t, "base64data", tr.Data)
45 require.Equal(t, "image/png", tr.MIMEType)
46 require.Equal(t, `{"file_path":"/tmp/x","content":"hi"}`, tr.Metadata)
47 require.False(t, tr.IsError)
48}
49
50// TestProtoToSkillStates verifies that the wire representation of skill
51// discovery states reconstructs identical values on the client,
52// including synthetic errors derived from Error strings.
53func TestProtoToSkillStates(t *testing.T) {
54 t.Parallel()
55
56 in := []proto.SkillState{
57 {Name: "ok", Path: "/p/ok", State: proto.SkillStateNormal},
58 {Name: "broken", Path: "/p/broken", State: proto.SkillStateError, Error: "bad frontmatter"},
59 }
60
61 got := protoToSkillStates(in)
62 require.Len(t, got, 2)
63 require.Equal(t, "ok", got[0].Name)
64 require.Equal(t, skills.StateNormal, got[0].State)
65 require.NoError(t, got[0].Err)
66 require.Equal(t, "broken", got[1].Name)
67 require.Equal(t, skills.StateError, got[1].State)
68 require.EqualError(t, got[1].Err, "bad frontmatter")
69}
70
71// TestTranslateEvent_Skills verifies that an incoming proto.SkillsEvent
72// is converted into pubsub.Event[skills.Event] and that the
73// client-process skill cache is updated as a side effect, so callers
74// reading skills.GetLatestStates see fresh data after each delta.
75func TestTranslateEvent_Skills(t *testing.T) {
76 // Not parallel - touches the package-level skills cache via the
77 // manager constructed with WithGlobalMirror.
78 prev := skills.GetLatestStates()
79 t.Cleanup(func() { skills.SetLatestStates(prev) })
80
81 skills.SetLatestStates(nil)
82
83 w := NewClientWorkspace(nil, proto.Workspace{})
84 ev := pubsub.Event[proto.SkillsEvent]{
85 Type: pubsub.UpdatedEvent,
86 Payload: proto.SkillsEvent{
87 States: []proto.SkillState{
88 {Name: "from-server", Path: "/p", State: proto.SkillStateNormal},
89 },
90 },
91 }
92
93 out := w.translateEvent(ev)
94 got, ok := out.(pubsub.Event[skills.Event])
95 require.True(t, ok, "expected pubsub.Event[skills.Event], got %T", out)
96 require.Len(t, got.Payload.States, 1)
97 require.Equal(t, "from-server", got.Payload.States[0].Name)
98
99 // Manager (with WithGlobalMirror) propagated to the package cache.
100 cached := skills.GetLatestStates()
101 require.Len(t, cached, 1)
102 require.Equal(t, "from-server", cached[0].Name)
103}
104
105// TestNewClientWorkspace_SeedsSkillsCache verifies that the snapshot in
106// proto.Workspace.Skills populates the package-level cache the TUI
107// reads at construction time, eliminating the race between TUI startup
108// and the first SSE event.
109func TestNewClientWorkspace_SeedsSkillsCache(t *testing.T) {
110 // Not parallel - touches the package-level skills cache.
111 prev := skills.GetLatestStates()
112 t.Cleanup(func() { skills.SetLatestStates(prev) })
113
114 skills.SetLatestStates(nil)
115
116 _ = NewClientWorkspace(nil, proto.Workspace{
117 Skills: []proto.SkillState{
118 {Name: "seeded", Path: "/p", State: proto.SkillStateNormal},
119 },
120 })
121
122 got := skills.GetLatestStates()
123 require.Len(t, got, 1)
124 require.Equal(t, "seeded", got[0].Name)
125}