1package server
2
3import (
4 "encoding/json"
5 "fmt"
6 "log/slog"
7
8 "github.com/charmbracelet/crush/internal/agent/notify"
9 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
10 "github.com/charmbracelet/crush/internal/app"
11 "github.com/charmbracelet/crush/internal/backend"
12 "github.com/charmbracelet/crush/internal/history"
13 "github.com/charmbracelet/crush/internal/message"
14 "github.com/charmbracelet/crush/internal/permission"
15 "github.com/charmbracelet/crush/internal/proto"
16 "github.com/charmbracelet/crush/internal/pubsub"
17 "github.com/charmbracelet/crush/internal/session"
18)
19
20// wrapEvent converts a raw tea.Msg (a pubsub.Event[T] from the app
21// event fan-in) into a pubsub.Payload envelope with the correct
22// PayloadType discriminator and a proto-typed inner payload that has
23// proper JSON tags. Returns nil if the event type is unrecognized.
24func wrapEvent(ev any) *pubsub.Payload {
25 switch e := ev.(type) {
26 case pubsub.Event[app.LSPEvent]:
27 return envelope(pubsub.PayloadTypeLSPEvent, pubsub.Event[proto.LSPEvent]{
28 Type: e.Type,
29 Payload: proto.LSPEvent{
30 Type: proto.LSPEventType(e.Payload.Type),
31 Name: e.Payload.Name,
32 State: e.Payload.State,
33 Error: e.Payload.Error,
34 DiagnosticCount: e.Payload.DiagnosticCount,
35 },
36 })
37 case pubsub.Event[mcp.Event]:
38 return envelope(pubsub.PayloadTypeMCPEvent, pubsub.Event[proto.MCPEvent]{
39 Type: e.Type,
40 Payload: proto.MCPEvent{
41 Type: mcpEventTypeToProto(e.Payload.Type),
42 Name: e.Payload.Name,
43 State: proto.MCPState(e.Payload.State),
44 Error: e.Payload.Error,
45 ToolCount: e.Payload.Counts.Tools,
46 },
47 })
48 case pubsub.Event[permission.PermissionRequest]:
49 return envelope(pubsub.PayloadTypePermissionRequest, pubsub.Event[proto.PermissionRequest]{
50 Type: e.Type,
51 Payload: proto.PermissionRequest{
52 ID: e.Payload.ID,
53 SessionID: e.Payload.SessionID,
54 ToolCallID: e.Payload.ToolCallID,
55 ToolName: e.Payload.ToolName,
56 Description: e.Payload.Description,
57 Action: e.Payload.Action,
58 Path: e.Payload.Path,
59 Params: e.Payload.Params,
60 },
61 })
62 case pubsub.Event[permission.PermissionNotification]:
63 return envelope(pubsub.PayloadTypePermissionNotification, pubsub.Event[proto.PermissionNotification]{
64 Type: e.Type,
65 Payload: proto.PermissionNotification{
66 ToolCallID: e.Payload.ToolCallID,
67 Granted: e.Payload.Granted,
68 Denied: e.Payload.Denied,
69 },
70 })
71 case pubsub.Event[message.Message]:
72 return envelope(pubsub.PayloadTypeMessage, pubsub.Event[proto.Message]{
73 Type: e.Type,
74 Payload: messageToProto(e.Payload),
75 })
76 case pubsub.Event[session.Session]:
77 return envelope(pubsub.PayloadTypeSession, pubsub.Event[proto.Session]{
78 Type: e.Type,
79 Payload: sessionToProto(e.Payload),
80 })
81 case pubsub.Event[history.File]:
82 return envelope(pubsub.PayloadTypeFile, pubsub.Event[proto.File]{
83 Type: e.Type,
84 Payload: fileToProto(e.Payload),
85 })
86 case pubsub.Event[notify.Notification]:
87 return envelope(pubsub.PayloadTypeAgentEvent, pubsub.Event[proto.AgentEvent]{
88 Type: e.Type,
89 Payload: proto.AgentEvent{
90 SessionID: e.Payload.SessionID,
91 SessionTitle: e.Payload.SessionTitle,
92 Type: proto.AgentEventType(e.Payload.Type),
93 },
94 })
95 case pubsub.Event[proto.ConfigChanged]:
96 return envelope(pubsub.PayloadTypeConfigChanged, e)
97 default:
98 slog.Warn("Unrecognized event type for SSE wrapping", "type", fmt.Sprintf("%T", ev))
99 return nil
100 }
101}
102
103// envelope marshals the inner event and wraps it in a pubsub.Payload.
104func envelope(payloadType pubsub.PayloadType, inner any) *pubsub.Payload {
105 raw, err := json.Marshal(inner)
106 if err != nil {
107 slog.Error("Failed to marshal event payload", "error", err)
108 return nil
109 }
110 return &pubsub.Payload{
111 Type: payloadType,
112 Payload: raw,
113 }
114}
115
116func mcpEventTypeToProto(t mcp.EventType) proto.MCPEventType {
117 switch t {
118 case mcp.EventStateChanged:
119 return proto.MCPEventStateChanged
120 case mcp.EventToolsListChanged:
121 return proto.MCPEventToolsListChanged
122 case mcp.EventPromptsListChanged:
123 return proto.MCPEventPromptsListChanged
124 case mcp.EventResourcesListChanged:
125 return proto.MCPEventResourcesListChanged
126 default:
127 return proto.MCPEventStateChanged
128 }
129}
130
131func sessionToProto(s session.Session) proto.Session {
132 return proto.Session{
133 ID: s.ID,
134 ParentSessionID: s.ParentSessionID,
135 Title: s.Title,
136 SummaryMessageID: s.SummaryMessageID,
137 MessageCount: s.MessageCount,
138 PromptTokens: s.PromptTokens,
139 CompletionTokens: s.CompletionTokens,
140 Cost: s.Cost,
141 Todos: todosToProto(s.Todos),
142 CreatedAt: s.CreatedAt,
143 UpdatedAt: s.UpdatedAt,
144 }
145}
146
147// isSessionBusy reports whether the given workspace has an in-flight
148// agent run for sessionID. It tolerates a nil workspace (treating it as
149// "not busy") so REST handlers can pass GetWorkspace's result through
150// unconditionally — the workspace lookup error is already surfaced by
151// the prior ListSessions/GetSession call when relevant.
152func isSessionBusy(ws *backend.Workspace, sessionID string) bool {
153 if ws == nil || ws.App == nil || ws.AgentCoordinator == nil {
154 return false
155 }
156 return ws.AgentCoordinator.IsSessionBusy(sessionID)
157}
158
159func todosToProto(todos []session.Todo) []proto.Todo {
160 if len(todos) == 0 {
161 return nil
162 }
163 out := make([]proto.Todo, len(todos))
164 for i, t := range todos {
165 out[i] = proto.Todo{
166 Content: t.Content,
167 Status: string(t.Status),
168 ActiveForm: t.ActiveForm,
169 }
170 }
171 return out
172}
173
174func fileToProto(f history.File) proto.File {
175 return proto.File{
176 ID: f.ID,
177 SessionID: f.SessionID,
178 Path: f.Path,
179 Content: f.Content,
180 Version: f.Version,
181 CreatedAt: f.CreatedAt,
182 UpdatedAt: f.UpdatedAt,
183 }
184}
185
186func messageToProto(m message.Message) proto.Message {
187 msg := proto.Message{
188 ID: m.ID,
189 SessionID: m.SessionID,
190 Role: proto.MessageRole(m.Role),
191 Model: m.Model,
192 Provider: m.Provider,
193 CreatedAt: m.CreatedAt,
194 UpdatedAt: m.UpdatedAt,
195 }
196
197 for _, p := range m.Parts {
198 switch v := p.(type) {
199 case message.TextContent:
200 msg.Parts = append(msg.Parts, proto.TextContent{Text: v.Text})
201 case message.ReasoningContent:
202 msg.Parts = append(msg.Parts, proto.ReasoningContent{
203 Thinking: v.Thinking,
204 Signature: v.Signature,
205 StartedAt: v.StartedAt,
206 FinishedAt: v.FinishedAt,
207 })
208 case message.ToolCall:
209 msg.Parts = append(msg.Parts, proto.ToolCall{
210 ID: v.ID,
211 Name: v.Name,
212 Input: v.Input,
213 Finished: v.Finished,
214 })
215 case message.ToolResult:
216 msg.Parts = append(msg.Parts, proto.ToolResult{
217 ToolCallID: v.ToolCallID,
218 Name: v.Name,
219 Content: v.Content,
220 Data: v.Data,
221 MIMEType: v.MIMEType,
222 Metadata: v.Metadata,
223 IsError: v.IsError,
224 })
225 case message.Finish:
226 msg.Parts = append(msg.Parts, proto.Finish{
227 Reason: proto.FinishReason(v.Reason),
228 Time: v.Time,
229 Message: v.Message,
230 Details: v.Details,
231 })
232 case message.ImageURLContent:
233 msg.Parts = append(msg.Parts, proto.ImageURLContent{URL: v.URL, Detail: v.Detail})
234 case message.BinaryContent:
235 msg.Parts = append(msg.Parts, proto.BinaryContent{Path: v.Path, MIMEType: v.MIMEType, Data: v.Data})
236 }
237 }
238
239 return msg
240}
241
242func messagesToProto(msgs []message.Message) []proto.Message {
243 out := make([]proto.Message, len(msgs))
244 for i, m := range msgs {
245 out[i] = messageToProto(m)
246 }
247 return out
248}