events_test.go

  1package server
  2
  3import (
  4	"encoding/json"
  5	"errors"
  6	"testing"
  7
  8	"github.com/charmbracelet/crush/internal/agent/notify"
  9	"github.com/charmbracelet/crush/internal/message"
 10	"github.com/charmbracelet/crush/internal/proto"
 11	"github.com/charmbracelet/crush/internal/pubsub"
 12	"github.com/charmbracelet/crush/internal/skills"
 13	"github.com/stretchr/testify/require"
 14)
 15
 16// TestMessageToProtoToolResult ensures that ToolResult metadata,
 17// data, and MIME type survive the conversion to proto. Without these
 18// fields the TUI cannot render rich tool output (e.g. syntax-
 19// highlighted code from view, diffs from edit, images, etc.) and
 20// falls back to the raw LLM-facing string.
 21func TestMessageToProtoToolResult(t *testing.T) {
 22	t.Parallel()
 23
 24	src := message.Message{
 25		ID:   "m1",
 26		Role: message.Tool,
 27		Parts: []message.ContentPart{
 28			message.ToolResult{
 29				ToolCallID: "call-1",
 30				Name:       "view",
 31				Content:    "<file>\n  1| hi\n</file>",
 32				Data:       "base64data",
 33				MIMEType:   "image/png",
 34				Metadata:   `{"file_path":"/tmp/x","content":"hi"}`,
 35				IsError:    false,
 36			},
 37		},
 38	}
 39
 40	got := messageToProto(src)
 41	require.Len(t, got.Parts, 1)
 42	tr, ok := got.Parts[0].(proto.ToolResult)
 43	require.True(t, ok, "expected proto.ToolResult, got %T", got.Parts[0])
 44	require.Equal(t, "call-1", tr.ToolCallID)
 45	require.Equal(t, "view", tr.Name)
 46	require.Equal(t, "<file>\n  1| hi\n</file>", tr.Content)
 47	require.Equal(t, "base64data", tr.Data)
 48	require.Equal(t, "image/png", tr.MIMEType)
 49	require.Equal(t, `{"file_path":"/tmp/x","content":"hi"}`, tr.Metadata)
 50	require.False(t, tr.IsError)
 51}
 52
 53// TestSkillsEventToProto_RoundTrip verifies that a pubsub.Event[skills.Event]
 54// can be wrapped, marshaled, and unmarshaled back through the SSE
 55// envelope without losing state values or error messages.
 56func TestSkillsEventToProto_RoundTrip(t *testing.T) {
 57	t.Parallel()
 58
 59	src := pubsub.Event[skills.Event]{
 60		Type: pubsub.UpdatedEvent,
 61		Payload: skills.Event{
 62			States: []*skills.SkillState{
 63				{Name: "ok", Path: "/p/ok", State: skills.StateNormal},
 64				{Name: "broken", Path: "/p/broken", State: skills.StateError, Err: errors.New("bad frontmatter")},
 65			},
 66		},
 67	}
 68
 69	env := wrapEvent(src)
 70	require.NotNil(t, env)
 71	require.Equal(t, pubsub.PayloadTypeSkillsEvent, env.Type)
 72
 73	var decoded pubsub.Event[proto.SkillsEvent]
 74	require.NoError(t, json.Unmarshal(env.Payload, &decoded))
 75	require.Equal(t, pubsub.UpdatedEvent, decoded.Type)
 76	require.Len(t, decoded.Payload.States, 2)
 77
 78	require.Equal(t, "ok", decoded.Payload.States[0].Name)
 79	require.Equal(t, "/p/ok", decoded.Payload.States[0].Path)
 80	require.Equal(t, proto.SkillStateNormal, decoded.Payload.States[0].State)
 81	require.Empty(t, decoded.Payload.States[0].Error)
 82
 83	require.Equal(t, "broken", decoded.Payload.States[1].Name)
 84	require.Equal(t, proto.SkillStateError, decoded.Payload.States[1].State)
 85	require.Equal(t, "bad frontmatter", decoded.Payload.States[1].Error)
 86}
 87
 88// TestRunCompleteToProto_RoundTrip verifies that the authoritative
 89// per-run completion event survives the SSE envelope conversion with
 90// all reconciliation fields intact. SessionID, MessageID, and Text
 91// are what non-interactive clients (e.g. `crush run`) rely on to
 92// terminate the run loop and guarantee final text on stdout when
 93// message events arrive out of order.
 94func TestRunCompleteToProto_RoundTrip(t *testing.T) {
 95	t.Parallel()
 96
 97	src := pubsub.Event[notify.RunComplete]{
 98		Type: pubsub.UpdatedEvent,
 99		Payload: notify.RunComplete{
100			SessionID: "S",
101			RunID:     "run-42",
102			MessageID: "M",
103			Text:      "VERDICT: APPROVED",
104			Error:     "",
105			Cancelled: false,
106		},
107	}
108
109	env := wrapEvent(src)
110	require.NotNil(t, env)
111	require.Equal(t, pubsub.PayloadTypeRunComplete, env.Type)
112
113	var decoded pubsub.Event[proto.RunComplete]
114	require.NoError(t, json.Unmarshal(env.Payload, &decoded))
115	require.Equal(t, pubsub.UpdatedEvent, decoded.Type)
116	require.Equal(t, "S", decoded.Payload.SessionID)
117	require.Equal(t, "run-42", decoded.Payload.RunID,
118		"RunID must survive the SSE envelope so clients can correlate "+
119			"this event with the SendMessage call that produced it")
120	require.Equal(t, "M", decoded.Payload.MessageID)
121	require.Equal(t, "VERDICT: APPROVED", decoded.Payload.Text)
122	require.Empty(t, decoded.Payload.Error)
123	require.False(t, decoded.Payload.Cancelled)
124}
125
126// TestRunCompleteToProto_Error verifies that error- and cancel-shaped
127// RunComplete events round-trip cleanly so clients can distinguish
128// "agent failed" (returns non-zero from `crush run`) from "agent
129// cancelled by user" (clean exit).
130func TestRunCompleteToProto_Error(t *testing.T) {
131	t.Parallel()
132
133	src := pubsub.Event[notify.RunComplete]{
134		Type: pubsub.UpdatedEvent,
135		Payload: notify.RunComplete{
136			SessionID: "S",
137			MessageID: "M",
138			Text:      "partial",
139			Error:     "context canceled",
140			Cancelled: true,
141		},
142	}
143
144	env := wrapEvent(src)
145	require.NotNil(t, env)
146	var decoded pubsub.Event[proto.RunComplete]
147	require.NoError(t, json.Unmarshal(env.Payload, &decoded))
148	require.Equal(t, "context canceled", decoded.Payload.Error)
149	require.True(t, decoded.Payload.Cancelled)
150}