client_workspace_test.go

  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}