1package server
2
3import (
4 "encoding/json"
5 "fmt"
6 "log/slog"
7
8 "git.secluded.site/crush/internal/agent/notify"
9 "git.secluded.site/crush/internal/agent/tools/mcp"
10 "git.secluded.site/crush/internal/app"
11 "git.secluded.site/crush/internal/history"
12 "git.secluded.site/crush/internal/message"
13 "git.secluded.site/crush/internal/permission"
14 "git.secluded.site/crush/internal/proto"
15 "git.secluded.site/crush/internal/pubsub"
16 "git.secluded.site/crush/internal/session"
17 "git.secluded.site/crush/internal/skills"
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[skills.Event]:
96 return envelope(pubsub.PayloadTypeSkillsEvent, pubsub.Event[proto.SkillsEvent]{
97 Type: e.Type,
98 Payload: skillsEventToProto(e.Payload),
99 })
100 default:
101 slog.Warn("Unrecognized event type for SSE wrapping", "type", fmt.Sprintf("%T", ev))
102 return nil
103 }
104}
105
106// envelope marshals the inner event and wraps it in a pubsub.Payload.
107func envelope(payloadType pubsub.PayloadType, inner any) *pubsub.Payload {
108 raw, err := json.Marshal(inner)
109 if err != nil {
110 slog.Error("Failed to marshal event payload", "error", err)
111 return nil
112 }
113 return &pubsub.Payload{
114 Type: payloadType,
115 Payload: raw,
116 }
117}
118
119func mcpEventTypeToProto(t mcp.EventType) proto.MCPEventType {
120 switch t {
121 case mcp.EventStateChanged:
122 return proto.MCPEventStateChanged
123 case mcp.EventToolsListChanged:
124 return proto.MCPEventToolsListChanged
125 case mcp.EventPromptsListChanged:
126 return proto.MCPEventPromptsListChanged
127 case mcp.EventResourcesListChanged:
128 return proto.MCPEventResourcesListChanged
129 default:
130 return proto.MCPEventStateChanged
131 }
132}
133
134func sessionToProto(s session.Session) proto.Session {
135 return proto.Session{
136 ID: s.ID,
137 ParentSessionID: s.ParentSessionID,
138 Title: s.Title,
139 SummaryMessageID: s.SummaryMessageID,
140 MessageCount: s.MessageCount,
141 PromptTokens: s.PromptTokens,
142 CompletionTokens: s.CompletionTokens,
143 Cost: s.Cost,
144 Todos: todosToProto(s.Todos),
145 CreatedAt: s.CreatedAt,
146 UpdatedAt: s.UpdatedAt,
147 }
148}
149
150func todosToProto(todos []session.Todo) []proto.Todo {
151 if len(todos) == 0 {
152 return nil
153 }
154 out := make([]proto.Todo, len(todos))
155 for i, t := range todos {
156 out[i] = proto.Todo{
157 Content: t.Content,
158 Status: string(t.Status),
159 ActiveForm: t.ActiveForm,
160 }
161 }
162 return out
163}
164
165func fileToProto(f history.File) proto.File {
166 return proto.File{
167 ID: f.ID,
168 SessionID: f.SessionID,
169 Path: f.Path,
170 Content: f.Content,
171 Version: f.Version,
172 CreatedAt: f.CreatedAt,
173 UpdatedAt: f.UpdatedAt,
174 }
175}
176
177func messageToProto(m message.Message) proto.Message {
178 msg := proto.Message{
179 ID: m.ID,
180 SessionID: m.SessionID,
181 Role: proto.MessageRole(m.Role),
182 Model: m.Model,
183 Provider: m.Provider,
184 CreatedAt: m.CreatedAt,
185 UpdatedAt: m.UpdatedAt,
186 }
187
188 for _, p := range m.Parts {
189 switch v := p.(type) {
190 case message.TextContent:
191 msg.Parts = append(msg.Parts, proto.TextContent{Text: v.Text})
192 case message.ReasoningContent:
193 msg.Parts = append(msg.Parts, proto.ReasoningContent{
194 Thinking: v.Thinking,
195 Signature: v.Signature,
196 StartedAt: v.StartedAt,
197 FinishedAt: v.FinishedAt,
198 })
199 case message.ToolCall:
200 msg.Parts = append(msg.Parts, proto.ToolCall{
201 ID: v.ID,
202 Name: v.Name,
203 Input: v.Input,
204 Finished: v.Finished,
205 })
206 case message.ToolResult:
207 msg.Parts = append(msg.Parts, proto.ToolResult{
208 ToolCallID: v.ToolCallID,
209 Name: v.Name,
210 Content: v.Content,
211 Data: v.Data,
212 MIMEType: v.MIMEType,
213 Metadata: v.Metadata,
214 IsError: v.IsError,
215 })
216 case message.Finish:
217 msg.Parts = append(msg.Parts, proto.Finish{
218 Reason: proto.FinishReason(v.Reason),
219 Time: v.Time,
220 Message: v.Message,
221 Details: v.Details,
222 })
223 case message.ImageURLContent:
224 msg.Parts = append(msg.Parts, proto.ImageURLContent{URL: v.URL, Detail: v.Detail})
225 case message.BinaryContent:
226 msg.Parts = append(msg.Parts, proto.BinaryContent{Path: v.Path, MIMEType: v.MIMEType, Data: v.Data})
227 }
228 }
229
230 return msg
231}
232
233// skillsEventToProto converts a skills.Event into its wire form. Errors
234// are flattened to strings because error does not round-trip over JSON.
235func skillsEventToProto(e skills.Event) proto.SkillsEvent {
236 if len(e.States) == 0 {
237 return proto.SkillsEvent{}
238 }
239 out := proto.SkillsEvent{States: make([]proto.SkillState, len(e.States))}
240 for i, s := range e.States {
241 entry := proto.SkillState{
242 Name: s.Name,
243 Path: s.Path,
244 State: proto.SkillDiscoveryState(s.State),
245 }
246 if s.Err != nil {
247 entry.Error = s.Err.Error()
248 }
249 out.States[i] = entry
250 }
251 return out
252}
253
254func messagesToProto(msgs []message.Message) []proto.Message {
255 out := make([]proto.Message, len(msgs))
256 for i, m := range msgs {
257 out[i] = messageToProto(m)
258 }
259 return out
260}