1package workspace
2
3import (
4 "encoding/json"
5 "io"
6 "net/http"
7 "net/http/httptest"
8 "net/url"
9 "testing"
10
11 "github.com/charmbracelet/crush/internal/client"
12 "github.com/charmbracelet/crush/internal/message"
13 "github.com/charmbracelet/crush/internal/permission"
14 "github.com/charmbracelet/crush/internal/proto"
15 "github.com/charmbracelet/crush/internal/pubsub"
16 "github.com/charmbracelet/crush/internal/skills"
17 "github.com/stretchr/testify/require"
18)
19
20// TestProtoToMessageToolResult ensures that ToolResult metadata,
21// data, and MIME type survive the conversion from proto on the
22// client. Without these fields the TUI cannot render rich tool
23// output (e.g. syntax-highlighted code from view, diffs from edit,
24// images, etc.) and falls back to the raw LLM-facing string.
25func TestProtoToMessageToolResult(t *testing.T) {
26 t.Parallel()
27
28 src := proto.Message{
29 ID: "m1",
30 Role: proto.Tool,
31 Parts: []proto.ContentPart{
32 proto.ToolResult{
33 ToolCallID: "call-1",
34 Name: "view",
35 Content: "<file>\n 1| hi\n</file>",
36 Data: "base64data",
37 MIMEType: "image/png",
38 Metadata: `{"file_path":"/tmp/x","content":"hi"}`,
39 IsError: false,
40 },
41 },
42 }
43
44 got := protoToMessage(src)
45 require.Len(t, got.Parts, 1)
46 tr, ok := got.Parts[0].(message.ToolResult)
47 require.True(t, ok, "expected message.ToolResult, got %T", got.Parts[0])
48 require.Equal(t, "call-1", tr.ToolCallID)
49 require.Equal(t, "view", tr.Name)
50 require.Equal(t, "<file>\n 1| hi\n</file>", tr.Content)
51 require.Equal(t, "base64data", tr.Data)
52 require.Equal(t, "image/png", tr.MIMEType)
53 require.Equal(t, `{"file_path":"/tmp/x","content":"hi"}`, tr.Metadata)
54 require.False(t, tr.IsError)
55}
56
57// TestClientWorkspace_PermissionGrantMapping verifies that
58// PermissionGrant on the ClientWorkspace serializes a one-time grant
59// (proto.PermissionAllow) and PermissionGrantPersistent serializes a
60// persistent grant (proto.PermissionAllowForSession). A swap between
61// these two would silently flip "allow once" into "remember for the
62// session", and vice versa, so we pin the wire mapping here.
63func TestClientWorkspace_PermissionGrantMapping(t *testing.T) {
64 t.Parallel()
65
66 cases := []struct {
67 name string
68 call func(*ClientWorkspace, permission.PermissionRequest)
69 want proto.PermissionAction
70 }{
71 {
72 name: "Grant -> PermissionAllow",
73 call: func(w *ClientWorkspace, p permission.PermissionRequest) {
74 w.PermissionGrant(p)
75 },
76 want: proto.PermissionAllow,
77 },
78 {
79 name: "GrantPersistent -> PermissionAllowForSession",
80 call: func(w *ClientWorkspace, p permission.PermissionRequest) {
81 w.PermissionGrantPersistent(p)
82 },
83 want: proto.PermissionAllowForSession,
84 },
85 {
86 name: "Deny -> PermissionDeny",
87 call: func(w *ClientWorkspace, p permission.PermissionRequest) {
88 w.PermissionDeny(p)
89 },
90 want: proto.PermissionDeny,
91 },
92 }
93
94 for _, tc := range cases {
95 t.Run(tc.name, func(t *testing.T) {
96 t.Parallel()
97
98 var got proto.PermissionGrant
99 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
100 require.Equal(t, http.MethodPost, r.Method)
101 require.Equal(t, "/v1/workspaces/ws-1/permissions/grant", r.URL.Path)
102 body, err := io.ReadAll(r.Body)
103 require.NoError(t, err)
104 require.NoError(t, json.Unmarshal(body, &got))
105 require.NoError(t, json.NewEncoder(w).Encode(proto.PermissionGrantResponse{Resolved: true}))
106 }))
107 defer srv.Close()
108
109 u, err := url.Parse(srv.URL)
110 require.NoError(t, err)
111 c, err := client.NewClient(t.TempDir(), "tcp", u.Host)
112 require.NoError(t, err)
113
114 ws := NewClientWorkspace(c, proto.Workspace{ID: "ws-1"})
115
116 perm := permission.PermissionRequest{
117 ID: "req-1",
118 SessionID: "sess-1",
119 ToolCallID: "tc-1",
120 ToolName: "tool",
121 Description: "do thing",
122 Action: "act",
123 Path: "/tmp/p",
124 }
125 tc.call(ws, perm)
126
127 require.Equal(t, tc.want, got.Action)
128 require.Equal(t, "req-1", got.Permission.ID)
129 require.Equal(t, "sess-1", got.Permission.SessionID)
130 require.Equal(t, "tc-1", got.Permission.ToolCallID)
131 require.Equal(t, "tool", got.Permission.ToolName)
132 require.Equal(t, "act", got.Permission.Action)
133 require.Equal(t, "/tmp/p", got.Permission.Path)
134 })
135 }
136}
137
138// TestProtoToSkillStates verifies that the wire representation of skill
139// discovery states reconstructs identical values on the client,
140// including synthetic errors derived from Error strings.
141func TestProtoToSkillStates(t *testing.T) {
142 t.Parallel()
143
144 in := []proto.SkillState{
145 {Name: "ok", Path: "/p/ok", State: proto.SkillStateNormal},
146 {Name: "broken", Path: "/p/broken", State: proto.SkillStateError, Error: "bad frontmatter"},
147 }
148
149 got := protoToSkillStates(in)
150 require.Len(t, got, 2)
151 require.Equal(t, "ok", got[0].Name)
152 require.Equal(t, skills.StateNormal, got[0].State)
153 require.NoError(t, got[0].Err)
154 require.Equal(t, "broken", got[1].Name)
155 require.Equal(t, skills.StateError, got[1].State)
156 require.EqualError(t, got[1].Err, "bad frontmatter")
157}
158
159// TestTranslateEvent_Skills verifies that an incoming proto.SkillsEvent
160// is converted into pubsub.Event[skills.Event] and that the
161// client-process skill cache is updated as a side effect, so callers
162// reading skills.GetLatestStates see fresh data after each delta.
163func TestTranslateEvent_Skills(t *testing.T) {
164 // Not parallel - touches the package-level skills cache via the
165 // manager constructed with WithGlobalMirror.
166 prev := skills.GetLatestStates()
167 t.Cleanup(func() { skills.SetLatestStates(prev) })
168
169 skills.SetLatestStates(nil)
170
171 w := NewClientWorkspace(nil, proto.Workspace{})
172 ev := pubsub.Event[proto.SkillsEvent]{
173 Type: pubsub.UpdatedEvent,
174 Payload: proto.SkillsEvent{
175 States: []proto.SkillState{
176 {Name: "from-server", Path: "/p", State: proto.SkillStateNormal},
177 },
178 },
179 }
180
181 out := w.translateEvent(ev)
182 got, ok := out.(pubsub.Event[skills.Event])
183 require.True(t, ok, "expected pubsub.Event[skills.Event], got %T", out)
184 require.Len(t, got.Payload.States, 1)
185 require.Equal(t, "from-server", got.Payload.States[0].Name)
186
187 // Manager (with WithGlobalMirror) propagated to the package cache.
188 cached := skills.GetLatestStates()
189 require.Len(t, cached, 1)
190 require.Equal(t, "from-server", cached[0].Name)
191}
192
193// TestNewClientWorkspace_SeedsSkillsCache verifies that the snapshot in
194// proto.Workspace.Skills populates the package-level cache the TUI
195// reads at construction time, eliminating the race between TUI startup
196// and the first SSE event.
197func TestNewClientWorkspace_SeedsSkillsCache(t *testing.T) {
198 // Not parallel - touches the package-level skills cache.
199 prev := skills.GetLatestStates()
200 t.Cleanup(func() { skills.SetLatestStates(prev) })
201
202 skills.SetLatestStates(nil)
203
204 _ = NewClientWorkspace(nil, proto.Workspace{
205 Skills: []proto.SkillState{
206 {Name: "seeded", Path: "/p", State: proto.SkillStateNormal},
207 },
208 })
209
210 got := skills.GetLatestStates()
211 require.Len(t, got, 1)
212 require.Equal(t, "seeded", got[0].Name)
213}