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/stretchr/testify/require"
 16)
 17
 18// TestProtoToMessageToolResult ensures that ToolResult metadata,
 19// data, and MIME type survive the conversion from proto on the
 20// client. Without these fields the TUI cannot render rich tool
 21// output (e.g. syntax-highlighted code from view, diffs from edit,
 22// images, etc.) and falls back to the raw LLM-facing string.
 23func TestProtoToMessageToolResult(t *testing.T) {
 24	t.Parallel()
 25
 26	src := proto.Message{
 27		ID:   "m1",
 28		Role: proto.Tool,
 29		Parts: []proto.ContentPart{
 30			proto.ToolResult{
 31				ToolCallID: "call-1",
 32				Name:       "view",
 33				Content:    "<file>\n  1| hi\n</file>",
 34				Data:       "base64data",
 35				MIMEType:   "image/png",
 36				Metadata:   `{"file_path":"/tmp/x","content":"hi"}`,
 37				IsError:    false,
 38			},
 39		},
 40	}
 41
 42	got := protoToMessage(src)
 43	require.Len(t, got.Parts, 1)
 44	tr, ok := got.Parts[0].(message.ToolResult)
 45	require.True(t, ok, "expected message.ToolResult, got %T", got.Parts[0])
 46	require.Equal(t, "call-1", tr.ToolCallID)
 47	require.Equal(t, "view", tr.Name)
 48	require.Equal(t, "<file>\n  1| hi\n</file>", tr.Content)
 49	require.Equal(t, "base64data", tr.Data)
 50	require.Equal(t, "image/png", tr.MIMEType)
 51	require.Equal(t, `{"file_path":"/tmp/x","content":"hi"}`, tr.Metadata)
 52	require.False(t, tr.IsError)
 53}
 54
 55// TestClientWorkspace_PermissionGrantMapping verifies that
 56// PermissionGrant on the ClientWorkspace serializes a one-time grant
 57// (proto.PermissionAllow) and PermissionGrantPersistent serializes a
 58// persistent grant (proto.PermissionAllowForSession). A swap between
 59// these two would silently flip "allow once" into "remember for the
 60// session", and vice versa, so we pin the wire mapping here.
 61func TestClientWorkspace_PermissionGrantMapping(t *testing.T) {
 62	t.Parallel()
 63
 64	cases := []struct {
 65		name string
 66		call func(*ClientWorkspace, permission.PermissionRequest)
 67		want proto.PermissionAction
 68	}{
 69		{
 70			name: "Grant -> PermissionAllow",
 71			call: func(w *ClientWorkspace, p permission.PermissionRequest) {
 72				w.PermissionGrant(p)
 73			},
 74			want: proto.PermissionAllow,
 75		},
 76		{
 77			name: "GrantPersistent -> PermissionAllowForSession",
 78			call: func(w *ClientWorkspace, p permission.PermissionRequest) {
 79				w.PermissionGrantPersistent(p)
 80			},
 81			want: proto.PermissionAllowForSession,
 82		},
 83		{
 84			name: "Deny -> PermissionDeny",
 85			call: func(w *ClientWorkspace, p permission.PermissionRequest) {
 86				w.PermissionDeny(p)
 87			},
 88			want: proto.PermissionDeny,
 89		},
 90	}
 91
 92	for _, tc := range cases {
 93		t.Run(tc.name, func(t *testing.T) {
 94			t.Parallel()
 95
 96			var got proto.PermissionGrant
 97			srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 98				require.Equal(t, http.MethodPost, r.Method)
 99				require.Equal(t, "/v1/workspaces/ws-1/permissions/grant", r.URL.Path)
100				body, err := io.ReadAll(r.Body)
101				require.NoError(t, err)
102				require.NoError(t, json.Unmarshal(body, &got))
103				require.NoError(t, json.NewEncoder(w).Encode(proto.PermissionGrantResponse{Resolved: true}))
104			}))
105			defer srv.Close()
106
107			u, err := url.Parse(srv.URL)
108			require.NoError(t, err)
109			c, err := client.NewClient(t.TempDir(), "tcp", u.Host)
110			require.NoError(t, err)
111
112			ws := NewClientWorkspace(c, proto.Workspace{ID: "ws-1"})
113
114			perm := permission.PermissionRequest{
115				ID:          "req-1",
116				SessionID:   "sess-1",
117				ToolCallID:  "tc-1",
118				ToolName:    "tool",
119				Description: "do thing",
120				Action:      "act",
121				Path:        "/tmp/p",
122			}
123			tc.call(ws, perm)
124
125			require.Equal(t, tc.want, got.Action)
126			require.Equal(t, "req-1", got.Permission.ID)
127			require.Equal(t, "sess-1", got.Permission.SessionID)
128			require.Equal(t, "tc-1", got.Permission.ToolCallID)
129			require.Equal(t, "tool", got.Permission.ToolName)
130			require.Equal(t, "act", got.Permission.Action)
131			require.Equal(t, "/tmp/p", got.Permission.Path)
132		})
133	}
134}