client_workspace_test.go

  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}