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
159// attachedClients returns the number of clients currently viewing
160// sessionID in ws. Hold-only clients (streams == 0) do not contribute.
161// A nil workspace is treated as zero so handlers can pass GetWorkspace's
162// result through without an extra guard.
163func attachedClients(ws *backend.Workspace, sessionID string) int {
164 if ws == nil {
165 return 0
166 }
167 return ws.AttachedClientsForSession(sessionID)
168}
169
170func todosToProto(todos []session.Todo) []proto.Todo {
171 if len(todos) == 0 {
172 return nil
173 }
174 out := make([]proto.Todo, len(todos))
175 for i, t := range todos {
176 out[i] = proto.Todo{
177 Content: t.Content,
178 Status: string(t.Status),
179 ActiveForm: t.ActiveForm,
180 }
181 }
182 return out
183}
184
185func fileToProto(f history.File) proto.File {
186 return proto.File{
187 ID: f.ID,
188 SessionID: f.SessionID,
189 Path: f.Path,
190 Content: f.Content,
191 Version: f.Version,
192 CreatedAt: f.CreatedAt,
193 UpdatedAt: f.UpdatedAt,
194 }
195}
196
197func messageToProto(m message.Message) proto.Message {
198 msg := proto.Message{
199 ID: m.ID,
200 SessionID: m.SessionID,
201 Role: proto.MessageRole(m.Role),
202 Model: m.Model,
203 Provider: m.Provider,
204 CreatedAt: m.CreatedAt,
205 UpdatedAt: m.UpdatedAt,
206 }
207
208 for _, p := range m.Parts {
209 switch v := p.(type) {
210 case message.TextContent:
211 msg.Parts = append(msg.Parts, proto.TextContent{Text: v.Text})
212 case message.ReasoningContent:
213 msg.Parts = append(msg.Parts, proto.ReasoningContent{
214 Thinking: v.Thinking,
215 Signature: v.Signature,
216 StartedAt: v.StartedAt,
217 FinishedAt: v.FinishedAt,
218 })
219 case message.ToolCall:
220 msg.Parts = append(msg.Parts, proto.ToolCall{
221 ID: v.ID,
222 Name: v.Name,
223 Input: v.Input,
224 Finished: v.Finished,
225 })
226 case message.ToolResult:
227 msg.Parts = append(msg.Parts, proto.ToolResult{
228 ToolCallID: v.ToolCallID,
229 Name: v.Name,
230 Content: v.Content,
231 Data: v.Data,
232 MIMEType: v.MIMEType,
233 Metadata: v.Metadata,
234 IsError: v.IsError,
235 })
236 case message.Finish:
237 msg.Parts = append(msg.Parts, proto.Finish{
238 Reason: proto.FinishReason(v.Reason),
239 Time: v.Time,
240 Message: v.Message,
241 Details: v.Details,
242 })
243 case message.ImageURLContent:
244 msg.Parts = append(msg.Parts, proto.ImageURLContent{URL: v.URL, Detail: v.Detail})
245 case message.BinaryContent:
246 msg.Parts = append(msg.Parts, proto.BinaryContent{Path: v.Path, MIMEType: v.MIMEType, Data: v.Data})
247 }
248 }
249
250 return msg
251}
252
253func messagesToProto(msgs []message.Message) []proto.Message {
254 out := make([]proto.Message, len(msgs))
255 for i, m := range msgs {
256 out[i] = messageToProto(m)
257 }
258 return out
259}