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}