events.go

  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}