client_workspace.go

  1package workspace
  2
  3import (
  4	"context"
  5	"fmt"
  6	"log/slog"
  7	"strings"
  8	"sync"
  9	"time"
 10
 11	tea "charm.land/bubbletea/v2"
 12	"github.com/charmbracelet/crush/internal/agent/notify"
 13	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
 14	"github.com/charmbracelet/crush/internal/client"
 15	"github.com/charmbracelet/crush/internal/config"
 16	"github.com/charmbracelet/crush/internal/history"
 17	"github.com/charmbracelet/crush/internal/log"
 18	"github.com/charmbracelet/crush/internal/lsp"
 19	"github.com/charmbracelet/crush/internal/message"
 20	"github.com/charmbracelet/crush/internal/oauth"
 21	"github.com/charmbracelet/crush/internal/permission"
 22	"github.com/charmbracelet/crush/internal/proto"
 23	"github.com/charmbracelet/crush/internal/pubsub"
 24	"github.com/charmbracelet/crush/internal/session"
 25)
 26
 27// ClientWorkspace implements the Workspace interface by delegating all
 28// operations to a remote server via the client SDK. It caches the
 29// proto.Workspace returned at creation time and refreshes it after
 30// config-mutating operations.
 31type ClientWorkspace struct {
 32	client *client.Client
 33
 34	mu sync.RWMutex
 35	ws proto.Workspace
 36}
 37
 38// NewClientWorkspace creates a new ClientWorkspace that proxies all
 39// operations through the given client SDK. The ws parameter is the
 40// proto.Workspace snapshot returned by the server at creation time.
 41func NewClientWorkspace(c *client.Client, ws proto.Workspace) *ClientWorkspace {
 42	if ws.Config != nil {
 43		ws.Config.SetupAgents()
 44	}
 45	return &ClientWorkspace{
 46		client: c,
 47		ws:     ws,
 48	}
 49}
 50
 51// refreshWorkspace re-fetches the workspace from the server, updating
 52// the cached snapshot. Called after config-mutating operations.
 53func (w *ClientWorkspace) refreshWorkspace() {
 54	updated, err := w.client.GetWorkspace(context.Background(), w.ws.ID)
 55	if err != nil {
 56		slog.Error("Failed to refresh workspace", "error", err)
 57		return
 58	}
 59	if updated.Config != nil {
 60		updated.Config.SetupAgents()
 61	}
 62	w.mu.Lock()
 63	w.ws = *updated
 64	w.mu.Unlock()
 65}
 66
 67// cached returns a snapshot of the cached workspace.
 68func (w *ClientWorkspace) cached() proto.Workspace {
 69	w.mu.RLock()
 70	defer w.mu.RUnlock()
 71	return w.ws
 72}
 73
 74// workspaceID returns the cached workspace ID.
 75func (w *ClientWorkspace) workspaceID() string {
 76	return w.cached().ID
 77}
 78
 79// -- Sessions --
 80
 81func (w *ClientWorkspace) CreateSession(ctx context.Context, title string) (session.Session, error) {
 82	sess, err := w.client.CreateSession(ctx, w.workspaceID(), title)
 83	if err != nil {
 84		return session.Session{}, err
 85	}
 86	return protoToSession(*sess), nil
 87}
 88
 89func (w *ClientWorkspace) GetSession(ctx context.Context, sessionID string) (session.Session, error) {
 90	sess, err := w.client.GetSession(ctx, w.workspaceID(), sessionID)
 91	if err != nil {
 92		return session.Session{}, err
 93	}
 94	return protoToSession(*sess), nil
 95}
 96
 97func (w *ClientWorkspace) ListSessions(ctx context.Context) ([]session.Session, error) {
 98	protoSessions, err := w.client.ListSessions(ctx, w.workspaceID())
 99	if err != nil {
100		return nil, err
101	}
102	sessions := make([]session.Session, len(protoSessions))
103	for i, s := range protoSessions {
104		sessions[i] = protoToSession(s)
105	}
106	return sessions, nil
107}
108
109func (w *ClientWorkspace) SaveSession(ctx context.Context, sess session.Session) (session.Session, error) {
110	saved, err := w.client.SaveSession(ctx, w.workspaceID(), sessionToProto(sess))
111	if err != nil {
112		return session.Session{}, err
113	}
114	return protoToSession(*saved), nil
115}
116
117func (w *ClientWorkspace) DeleteSession(ctx context.Context, sessionID string) error {
118	return w.client.DeleteSession(ctx, w.workspaceID(), sessionID)
119}
120
121func (w *ClientWorkspace) CreateAgentToolSessionID(messageID, toolCallID string) string {
122	return fmt.Sprintf("%s$$%s", messageID, toolCallID)
123}
124
125func (w *ClientWorkspace) ParseAgentToolSessionID(sessionID string) (string, string, bool) {
126	parts := strings.Split(sessionID, "$$")
127	if len(parts) != 2 {
128		return "", "", false
129	}
130	return parts[0], parts[1], true
131}
132
133// -- Messages --
134
135func (w *ClientWorkspace) ListMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
136	msgs, err := w.client.ListMessages(ctx, w.workspaceID(), sessionID)
137	if err != nil {
138		return nil, err
139	}
140	return protoToMessages(msgs), nil
141}
142
143func (w *ClientWorkspace) ListUserMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
144	msgs, err := w.client.ListUserMessages(ctx, w.workspaceID(), sessionID)
145	if err != nil {
146		return nil, err
147	}
148	return protoToMessages(msgs), nil
149}
150
151func (w *ClientWorkspace) ListAllUserMessages(ctx context.Context) ([]message.Message, error) {
152	msgs, err := w.client.ListAllUserMessages(ctx, w.workspaceID())
153	if err != nil {
154		return nil, err
155	}
156	return protoToMessages(msgs), nil
157}
158
159// -- Agent --
160
161func (w *ClientWorkspace) AgentRun(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) error {
162	return w.client.SendMessage(ctx, w.workspaceID(), sessionID, prompt, attachments...)
163}
164
165func (w *ClientWorkspace) AgentCancel(sessionID string) {
166	_ = w.client.CancelAgentSession(context.Background(), w.workspaceID(), sessionID)
167}
168
169func (w *ClientWorkspace) AgentIsBusy() bool {
170	info, err := w.client.GetAgentInfo(context.Background(), w.workspaceID())
171	if err != nil {
172		return false
173	}
174	return info.IsBusy
175}
176
177func (w *ClientWorkspace) AgentIsSessionBusy(sessionID string) bool {
178	info, err := w.client.GetAgentSessionInfo(context.Background(), w.workspaceID(), sessionID)
179	if err != nil {
180		return false
181	}
182	return info.IsBusy
183}
184
185func (w *ClientWorkspace) AgentModel() AgentModel {
186	info, err := w.client.GetAgentInfo(context.Background(), w.workspaceID())
187	if err != nil {
188		return AgentModel{}
189	}
190	return AgentModel{
191		CatwalkCfg: info.Model,
192		ModelCfg:   info.ModelCfg,
193	}
194}
195
196func (w *ClientWorkspace) AgentIsReady() bool {
197	info, err := w.client.GetAgentInfo(context.Background(), w.workspaceID())
198	if err != nil {
199		return false
200	}
201	return info.IsReady
202}
203
204func (w *ClientWorkspace) AgentQueuedPrompts(sessionID string) int {
205	count, err := w.client.GetAgentSessionQueuedPrompts(context.Background(), w.workspaceID(), sessionID)
206	if err != nil {
207		return 0
208	}
209	return count
210}
211
212func (w *ClientWorkspace) AgentQueuedPromptsList(sessionID string) []string {
213	prompts, err := w.client.GetAgentSessionQueuedPromptsList(context.Background(), w.workspaceID(), sessionID)
214	if err != nil {
215		return nil
216	}
217	return prompts
218}
219
220func (w *ClientWorkspace) AgentClearQueue(sessionID string) {
221	_ = w.client.ClearAgentSessionQueuedPrompts(context.Background(), w.workspaceID(), sessionID)
222}
223
224func (w *ClientWorkspace) AgentSummarize(ctx context.Context, sessionID string) error {
225	return w.client.AgentSummarizeSession(ctx, w.workspaceID(), sessionID)
226}
227
228func (w *ClientWorkspace) UpdateAgentModel(ctx context.Context) error {
229	return w.client.UpdateAgent(ctx, w.workspaceID())
230}
231
232func (w *ClientWorkspace) InitCoderAgent(ctx context.Context) error {
233	return w.client.InitiateAgentProcessing(ctx, w.workspaceID())
234}
235
236func (w *ClientWorkspace) GetDefaultSmallModel(providerID string) config.SelectedModel {
237	model, err := w.client.GetDefaultSmallModel(context.Background(), w.workspaceID(), providerID)
238	if err != nil {
239		return config.SelectedModel{}
240	}
241	return *model
242}
243
244// -- Permissions --
245
246func (w *ClientWorkspace) PermissionGrant(perm permission.PermissionRequest) {
247	_ = w.client.GrantPermission(context.Background(), w.workspaceID(), proto.PermissionGrant{
248		Permission: proto.PermissionRequest{
249			ID:          perm.ID,
250			SessionID:   perm.SessionID,
251			ToolCallID:  perm.ToolCallID,
252			ToolName:    perm.ToolName,
253			Description: perm.Description,
254			Action:      perm.Action,
255			Path:        perm.Path,
256			Params:      perm.Params,
257		},
258		Action: proto.PermissionAllowForSession,
259	})
260}
261
262func (w *ClientWorkspace) PermissionGrantPersistent(perm permission.PermissionRequest) {
263	_ = w.client.GrantPermission(context.Background(), w.workspaceID(), proto.PermissionGrant{
264		Permission: proto.PermissionRequest{
265			ID:          perm.ID,
266			SessionID:   perm.SessionID,
267			ToolCallID:  perm.ToolCallID,
268			ToolName:    perm.ToolName,
269			Description: perm.Description,
270			Action:      perm.Action,
271			Path:        perm.Path,
272			Params:      perm.Params,
273		},
274		Action: proto.PermissionAllow,
275	})
276}
277
278func (w *ClientWorkspace) PermissionDeny(perm permission.PermissionRequest) {
279	_ = w.client.GrantPermission(context.Background(), w.workspaceID(), proto.PermissionGrant{
280		Permission: proto.PermissionRequest{
281			ID:          perm.ID,
282			SessionID:   perm.SessionID,
283			ToolCallID:  perm.ToolCallID,
284			ToolName:    perm.ToolName,
285			Description: perm.Description,
286			Action:      perm.Action,
287			Path:        perm.Path,
288			Params:      perm.Params,
289		},
290		Action: proto.PermissionDeny,
291	})
292}
293
294func (w *ClientWorkspace) PermissionSkipRequests() bool {
295	skip, err := w.client.GetPermissionsSkipRequests(context.Background(), w.workspaceID())
296	if err != nil {
297		return false
298	}
299	return skip
300}
301
302func (w *ClientWorkspace) PermissionSetSkipRequests(skip bool) {
303	_ = w.client.SetPermissionsSkipRequests(context.Background(), w.workspaceID(), skip)
304}
305
306// -- FileTracker --
307
308func (w *ClientWorkspace) FileTrackerRecordRead(ctx context.Context, sessionID, path string) {
309	_ = w.client.FileTrackerRecordRead(ctx, w.workspaceID(), sessionID, path)
310}
311
312func (w *ClientWorkspace) FileTrackerLastReadTime(ctx context.Context, sessionID, path string) time.Time {
313	t, err := w.client.FileTrackerLastReadTime(ctx, w.workspaceID(), sessionID, path)
314	if err != nil {
315		return time.Time{}
316	}
317	return t
318}
319
320func (w *ClientWorkspace) FileTrackerListReadFiles(ctx context.Context, sessionID string) ([]string, error) {
321	return w.client.FileTrackerListReadFiles(ctx, w.workspaceID(), sessionID)
322}
323
324// -- History --
325
326func (w *ClientWorkspace) ListSessionHistory(ctx context.Context, sessionID string) ([]history.File, error) {
327	files, err := w.client.ListSessionHistoryFiles(ctx, w.workspaceID(), sessionID)
328	if err != nil {
329		return nil, err
330	}
331	return protoToFiles(files), nil
332}
333
334// -- LSP --
335
336func (w *ClientWorkspace) LSPStart(ctx context.Context, path string) {
337	_ = w.client.LSPStart(ctx, w.workspaceID(), path)
338}
339
340func (w *ClientWorkspace) LSPStopAll(ctx context.Context) {
341	_ = w.client.LSPStopAll(ctx, w.workspaceID())
342}
343
344func (w *ClientWorkspace) LSPGetStates() map[string]LSPClientInfo {
345	states, err := w.client.GetLSPs(context.Background(), w.workspaceID())
346	if err != nil {
347		return nil
348	}
349	result := make(map[string]LSPClientInfo, len(states))
350	for k, v := range states {
351		result[k] = LSPClientInfo{
352			Name:            v.Name,
353			State:           v.State,
354			Error:           v.Error,
355			DiagnosticCount: v.DiagnosticCount,
356			ConnectedAt:     v.ConnectedAt,
357		}
358	}
359	return result
360}
361
362func (w *ClientWorkspace) LSPGetClient(_ string) (*lsp.Client, bool) {
363	return nil, false
364}
365
366// -- Config (read-only) --
367
368func (w *ClientWorkspace) Config() *config.Config {
369	return w.cached().Config
370}
371
372func (w *ClientWorkspace) WorkingDir() string {
373	return w.cached().Path
374}
375
376func (w *ClientWorkspace) Resolver() config.VariableResolver {
377	return config.IdentityResolver()
378}
379
380// -- Config mutations --
381
382func (w *ClientWorkspace) UpdatePreferredModel(scope config.Scope, modelType config.SelectedModelType, model config.SelectedModel) error {
383	err := w.client.UpdatePreferredModel(context.Background(), w.workspaceID(), scope, modelType, model)
384	if err == nil {
385		w.refreshWorkspace()
386	}
387	return err
388}
389
390func (w *ClientWorkspace) SetCompactMode(scope config.Scope, enabled bool) error {
391	err := w.client.SetCompactMode(context.Background(), w.workspaceID(), scope, enabled)
392	if err == nil {
393		w.refreshWorkspace()
394	}
395	return err
396}
397
398func (w *ClientWorkspace) SetProviderAPIKey(scope config.Scope, providerID string, apiKey any) error {
399	err := w.client.SetProviderAPIKey(context.Background(), w.workspaceID(), scope, providerID, apiKey)
400	if err == nil {
401		w.refreshWorkspace()
402	}
403	return err
404}
405
406func (w *ClientWorkspace) SetConfigField(scope config.Scope, key string, value any) error {
407	err := w.client.SetConfigField(context.Background(), w.workspaceID(), scope, key, value)
408	if err == nil {
409		w.refreshWorkspace()
410	}
411	return err
412}
413
414func (w *ClientWorkspace) RemoveConfigField(scope config.Scope, key string) error {
415	err := w.client.RemoveConfigField(context.Background(), w.workspaceID(), scope, key)
416	if err == nil {
417		w.refreshWorkspace()
418	}
419	return err
420}
421
422func (w *ClientWorkspace) ImportCopilot() (*oauth.Token, bool) {
423	token, ok, err := w.client.ImportCopilot(context.Background(), w.workspaceID())
424	if err != nil {
425		return nil, false
426	}
427	if ok {
428		w.refreshWorkspace()
429	}
430	return token, ok
431}
432
433func (w *ClientWorkspace) RefreshOAuthToken(ctx context.Context, scope config.Scope, providerID string) error {
434	err := w.client.RefreshOAuthToken(ctx, w.workspaceID(), scope, providerID)
435	if err == nil {
436		w.refreshWorkspace()
437	}
438	return err
439}
440
441// -- Project lifecycle --
442
443func (w *ClientWorkspace) ProjectNeedsInitialization() (bool, error) {
444	return w.client.ProjectNeedsInitialization(context.Background(), w.workspaceID())
445}
446
447func (w *ClientWorkspace) MarkProjectInitialized() error {
448	return w.client.MarkProjectInitialized(context.Background(), w.workspaceID())
449}
450
451func (w *ClientWorkspace) InitializePrompt() (string, error) {
452	return w.client.GetInitializePrompt(context.Background(), w.workspaceID())
453}
454
455// -- MCP operations --
456
457func (w *ClientWorkspace) MCPGetStates() map[string]mcp.ClientInfo {
458	states, err := w.client.MCPGetStates(context.Background(), w.workspaceID())
459	if err != nil {
460		return nil
461	}
462	result := make(map[string]mcp.ClientInfo, len(states))
463	for k, v := range states {
464		result[k] = mcp.ClientInfo{
465			Name:  v.Name,
466			State: mcp.State(v.State),
467			Error: v.Error,
468			Counts: mcp.Counts{
469				Tools:     v.ToolCount,
470				Prompts:   v.PromptCount,
471				Resources: v.ResourceCount,
472			},
473			ConnectedAt: v.ConnectedAt,
474		}
475	}
476	return result
477}
478
479func (w *ClientWorkspace) MCPRefreshPrompts(ctx context.Context, name string) {
480	_ = w.client.MCPRefreshPrompts(ctx, w.workspaceID(), name)
481}
482
483func (w *ClientWorkspace) MCPRefreshResources(ctx context.Context, name string) {
484	_ = w.client.MCPRefreshResources(ctx, w.workspaceID(), name)
485}
486
487func (w *ClientWorkspace) RefreshMCPTools(ctx context.Context, name string) {
488	_ = w.client.RefreshMCPTools(ctx, w.workspaceID(), name)
489}
490
491func (w *ClientWorkspace) ReadMCPResource(ctx context.Context, name, uri string) ([]MCPResourceContents, error) {
492	contents, err := w.client.ReadMCPResource(ctx, w.workspaceID(), name, uri)
493	if err != nil {
494		return nil, err
495	}
496	result := make([]MCPResourceContents, len(contents))
497	for i, c := range contents {
498		result[i] = MCPResourceContents{
499			URI:      c.URI,
500			MIMEType: c.MIMEType,
501			Text:     c.Text,
502			Blob:     c.Blob,
503		}
504	}
505	return result, nil
506}
507
508func (w *ClientWorkspace) GetMCPPrompt(clientID, promptID string, args map[string]string) (string, error) {
509	return w.client.GetMCPPrompt(context.Background(), w.workspaceID(), clientID, promptID, args)
510}
511
512func (w *ClientWorkspace) EnableDockerMCP(ctx context.Context) error {
513	return w.client.EnableDockerMCP(ctx, w.workspaceID())
514}
515
516func (w *ClientWorkspace) DisableDockerMCP() error {
517	return w.client.DisableDockerMCP(context.Background(), w.workspaceID())
518}
519
520// -- Lifecycle --
521
522func (w *ClientWorkspace) Subscribe(program *tea.Program) {
523	defer log.RecoverPanic("ClientWorkspace.Subscribe", func() {
524		slog.Info("TUI subscription panic: attempting graceful shutdown")
525		program.Quit()
526	})
527
528	evc, err := w.client.SubscribeEvents(context.Background(), w.workspaceID())
529	if err != nil {
530		slog.Error("Failed to subscribe to events", "error", err)
531		return
532	}
533
534	for ev := range evc {
535		translated := translateEvent(ev)
536		if translated != nil {
537			program.Send(translated)
538		}
539	}
540}
541
542func (w *ClientWorkspace) Shutdown() {
543	_ = w.client.DeleteWorkspace(context.Background(), w.workspaceID())
544}
545
546// translateEvent converts proto-typed SSE events into the domain types
547// that the TUI's Update() method expects.
548func translateEvent(ev any) tea.Msg {
549	switch e := ev.(type) {
550	case pubsub.Event[proto.LSPEvent]:
551		return pubsub.Event[LSPEvent]{
552			Type: e.Type,
553			Payload: LSPEvent{
554				Type:            LSPEventType(e.Payload.Type),
555				Name:            e.Payload.Name,
556				State:           e.Payload.State,
557				Error:           e.Payload.Error,
558				DiagnosticCount: e.Payload.DiagnosticCount,
559			},
560		}
561	case pubsub.Event[proto.MCPEvent]:
562		return pubsub.Event[mcp.Event]{
563			Type: e.Type,
564			Payload: mcp.Event{
565				Type:  protoToMCPEventType(e.Payload.Type),
566				Name:  e.Payload.Name,
567				State: mcp.State(e.Payload.State),
568				Error: e.Payload.Error,
569				Counts: mcp.Counts{
570					Tools:     e.Payload.ToolCount,
571					Prompts:   e.Payload.PromptCount,
572					Resources: e.Payload.ResourceCount,
573				},
574			},
575		}
576	case pubsub.Event[proto.PermissionRequest]:
577		return pubsub.Event[permission.PermissionRequest]{
578			Type: e.Type,
579			Payload: permission.PermissionRequest{
580				ID:          e.Payload.ID,
581				SessionID:   e.Payload.SessionID,
582				ToolCallID:  e.Payload.ToolCallID,
583				ToolName:    e.Payload.ToolName,
584				Description: e.Payload.Description,
585				Action:      e.Payload.Action,
586				Path:        e.Payload.Path,
587				Params:      e.Payload.Params,
588			},
589		}
590	case pubsub.Event[proto.PermissionNotification]:
591		return pubsub.Event[permission.PermissionNotification]{
592			Type: e.Type,
593			Payload: permission.PermissionNotification{
594				ToolCallID: e.Payload.ToolCallID,
595				Granted:    e.Payload.Granted,
596				Denied:     e.Payload.Denied,
597			},
598		}
599	case pubsub.Event[proto.Message]:
600		return pubsub.Event[message.Message]{
601			Type:    e.Type,
602			Payload: protoToMessage(e.Payload),
603		}
604	case pubsub.Event[proto.Session]:
605		return pubsub.Event[session.Session]{
606			Type:    e.Type,
607			Payload: protoToSession(e.Payload),
608		}
609	case pubsub.Event[proto.File]:
610		return pubsub.Event[history.File]{
611			Type:    e.Type,
612			Payload: protoToFile(e.Payload),
613		}
614	case pubsub.Event[proto.AgentEvent]:
615		return pubsub.Event[notify.Notification]{
616			Type: e.Type,
617			Payload: notify.Notification{
618				SessionID:    e.Payload.SessionID,
619				SessionTitle: e.Payload.SessionTitle,
620				Type:         notify.Type(e.Payload.Type),
621			},
622		}
623	default:
624		slog.Warn("Unknown event type in translateEvent", "type", fmt.Sprintf("%T", ev))
625		return nil
626	}
627}
628
629func protoToMCPEventType(t proto.MCPEventType) mcp.EventType {
630	switch t {
631	case proto.MCPEventStateChanged:
632		return mcp.EventStateChanged
633	case proto.MCPEventToolsListChanged:
634		return mcp.EventToolsListChanged
635	case proto.MCPEventPromptsListChanged:
636		return mcp.EventPromptsListChanged
637	case proto.MCPEventResourcesListChanged:
638		return mcp.EventResourcesListChanged
639	default:
640		return mcp.EventStateChanged
641	}
642}
643
644func protoToSession(s proto.Session) session.Session {
645	return session.Session{
646		ID:               s.ID,
647		ParentSessionID:  s.ParentSessionID,
648		Title:            s.Title,
649		SummaryMessageID: s.SummaryMessageID,
650		MessageCount:     s.MessageCount,
651		PromptTokens:     s.PromptTokens,
652		CompletionTokens: s.CompletionTokens,
653		Cost:             s.Cost,
654		CreatedAt:        s.CreatedAt,
655		UpdatedAt:        s.UpdatedAt,
656	}
657}
658
659func protoToFile(f proto.File) history.File {
660	return history.File{
661		ID:        f.ID,
662		SessionID: f.SessionID,
663		Path:      f.Path,
664		Content:   f.Content,
665		Version:   f.Version,
666		CreatedAt: f.CreatedAt,
667		UpdatedAt: f.UpdatedAt,
668	}
669}
670
671func protoToMessage(m proto.Message) message.Message {
672	msg := message.Message{
673		ID:        m.ID,
674		SessionID: m.SessionID,
675		Role:      message.MessageRole(m.Role),
676		Model:     m.Model,
677		Provider:  m.Provider,
678		CreatedAt: m.CreatedAt,
679		UpdatedAt: m.UpdatedAt,
680	}
681
682	for _, p := range m.Parts {
683		switch v := p.(type) {
684		case proto.TextContent:
685			msg.Parts = append(msg.Parts, message.TextContent{Text: v.Text})
686		case proto.ReasoningContent:
687			msg.Parts = append(msg.Parts, message.ReasoningContent{
688				Thinking:   v.Thinking,
689				Signature:  v.Signature,
690				StartedAt:  v.StartedAt,
691				FinishedAt: v.FinishedAt,
692			})
693		case proto.ToolCall:
694			msg.Parts = append(msg.Parts, message.ToolCall{
695				ID:       v.ID,
696				Name:     v.Name,
697				Input:    v.Input,
698				Finished: v.Finished,
699			})
700		case proto.ToolResult:
701			msg.Parts = append(msg.Parts, message.ToolResult{
702				ToolCallID: v.ToolCallID,
703				Name:       v.Name,
704				Content:    v.Content,
705				IsError:    v.IsError,
706			})
707		case proto.Finish:
708			msg.Parts = append(msg.Parts, message.Finish{
709				Reason:  message.FinishReason(v.Reason),
710				Time:    v.Time,
711				Message: v.Message,
712				Details: v.Details,
713			})
714		case proto.ImageURLContent:
715			msg.Parts = append(msg.Parts, message.ImageURLContent{URL: v.URL, Detail: v.Detail})
716		case proto.BinaryContent:
717			msg.Parts = append(msg.Parts, message.BinaryContent{Path: v.Path, MIMEType: v.MIMEType, Data: v.Data})
718		}
719	}
720
721	return msg
722}
723
724func protoToMessages(msgs []proto.Message) []message.Message {
725	out := make([]message.Message, len(msgs))
726	for i, m := range msgs {
727		out[i] = protoToMessage(m)
728	}
729	return out
730}
731
732func protoToFiles(files []proto.File) []history.File {
733	out := make([]history.File, len(files))
734	for i, f := range files {
735		out[i] = protoToFile(f)
736	}
737	return out
738}
739
740func sessionToProto(s session.Session) proto.Session {
741	return proto.Session{
742		ID:               s.ID,
743		ParentSessionID:  s.ParentSessionID,
744		Title:            s.Title,
745		SummaryMessageID: s.SummaryMessageID,
746		MessageCount:     s.MessageCount,
747		PromptTokens:     s.PromptTokens,
748		CompletionTokens: s.CompletionTokens,
749		Cost:             s.Cost,
750		CreatedAt:        s.CreatedAt,
751		UpdatedAt:        s.UpdatedAt,
752	}
753}