From bb1a335e3a9358400981933efe986401fa50c0ac Mon Sep 17 00:00:00 2001 From: Amolith Date: Sat, 3 Jan 2026 20:42:19 -0700 Subject: [PATCH] feat(acp): implement LoadSession Allows ACP clients to load and resume previous sessions by ID. - Advertise loadSession capability in Initialize - Replay message history via SessionUpdate notifications Assisted-by: Claude Opus 4.5 via Crush --- internal/acp/agent.go | 114 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 113 insertions(+), 1 deletion(-) diff --git a/internal/acp/agent.go b/internal/acp/agent.go index d9b9df10e1235ec1b63d21c8e000fd002ec75e61..b0b3c6e13530401e4b6ad1f4617bf802d22ce5f4 100644 --- a/internal/acp/agent.go +++ b/internal/acp/agent.go @@ -6,6 +6,7 @@ import ( "github.com/charmbracelet/crush/internal/app" "github.com/charmbracelet/crush/internal/csync" + "github.com/charmbracelet/crush/internal/message" "github.com/coder/acp-go-sdk" ) @@ -19,6 +20,7 @@ type Agent struct { // Compile-time interface checks. var ( _ acp.Agent = (*Agent)(nil) + _ acp.AgentLoader = (*Agent)(nil) _ acp.AgentExperimental = (*Agent)(nil) ) @@ -41,7 +43,7 @@ func (a *Agent) Initialize(ctx context.Context, params acp.InitializeRequest) (a return acp.InitializeResponse{ ProtocolVersion: acp.ProtocolVersionNumber, AgentCapabilities: acp.AgentCapabilities{ - LoadSession: false, + LoadSession: true, McpCapabilities: acp.McpCapabilities{ Http: false, Sse: false, @@ -82,6 +84,37 @@ func (a *Agent) NewSession(ctx context.Context, params acp.NewSessionRequest) (a }, nil } +// LoadSession loads an existing session to resume a previous conversation. +func (a *Agent) LoadSession(ctx context.Context, params acp.LoadSessionRequest) (acp.LoadSessionResponse, error) { + sessionID := string(params.SessionId) + slog.Info("ACP LoadSession", "session_id", sessionID) + + // Verify the session exists. + session, err := a.app.Sessions.Get(ctx, sessionID) + if err != nil { + return acp.LoadSessionResponse{}, err + } + + // Create and start the event sink for future updates. + sink := NewSink(context.Background(), a.conn, session.ID) + sink.Start(a.app.Messages, a.app.Permissions, a.app.Sessions) + a.sinks.Set(session.ID, sink) + + // Load and replay historical messages to the client. + messages, err := a.app.Messages.List(ctx, sessionID) + if err != nil { + return acp.LoadSessionResponse{}, err + } + + for _, msg := range messages { + if err := a.replayMessage(ctx, sessionID, msg); err != nil { + slog.Error("Failed to replay message", "message_id", msg.ID, "error", err) + } + } + + return acp.LoadSessionResponse{}, nil +} + // SetSessionMode handles mode switching (stub - Crush doesn't have modes yet). func (a *Agent) SetSessionMode(ctx context.Context, params acp.SetSessionModeRequest) (acp.SetSessionModeResponse, error) { slog.Debug("ACP SetSessionMode", "mode_id", params.ModeId) @@ -128,3 +161,82 @@ func (a *Agent) Cancel(ctx context.Context, params acp.CancelNotification) error a.app.AgentCoordinator.Cancel(string(params.SessionId)) return nil } + +// replayMessage sends a historical message to the client via session updates. +func (a *Agent) replayMessage(ctx context.Context, sessionID string, msg message.Message) error { + for _, part := range msg.Parts { + update := a.translateHistoryPart(msg.Role, part) + if update == nil { + continue + } + + if err := a.conn.SessionUpdate(ctx, acp.SessionNotification{ + SessionId: acp.SessionId(sessionID), + Update: *update, + }); err != nil { + return err + } + } + return nil +} + +// translateHistoryPart converts a message part to an ACP session update for +// history replay. Unlike streaming updates, this sends full content rather +// than deltas. +func (a *Agent) translateHistoryPart(role message.MessageRole, part message.ContentPart) *acp.SessionUpdate { + switch p := part.(type) { + case message.TextContent: + if p.Text == "" { + return nil + } + var update acp.SessionUpdate + if role == message.User { + update = acp.UpdateUserMessageText(p.Text) + } else { + update = acp.UpdateAgentMessageText(p.Text) + } + return &update + + case message.ReasoningContent: + if p.Thinking == "" { + return nil + } + update := acp.UpdateAgentThoughtText(p.Thinking) + return &update + + case message.ToolCall: + // For history replay, send the tool call as completed with full input. + opts := []acp.ToolCallStartOpt{ + acp.WithStartStatus(acp.ToolCallStatusCompleted), + acp.WithStartKind(toolKind(p.Name)), + } + if input := parseToolInput(p.Input); input != nil { + if input.Path != "" { + opts = append(opts, acp.WithStartLocations([]acp.ToolCallLocation{{Path: input.Path}})) + } + opts = append(opts, acp.WithStartRawInput(input.Raw)) + } + title := p.Name + if input := parseToolInput(p.Input); input != nil && input.Title != "" { + title = input.Title + } + update := acp.StartToolCall(acp.ToolCallId(p.ID), title, opts...) + return &update + + case message.ToolResult: + status := acp.ToolCallStatusCompleted + if p.IsError { + status = acp.ToolCallStatusFailed + } + content := []acp.ToolCallContent{acp.ToolContent(acp.TextBlock(p.Content))} + update := acp.UpdateToolCall( + acp.ToolCallId(p.ToolCallID), + acp.WithUpdateStatus(status), + acp.WithUpdateContent(content), + ) + return &update + + default: + return nil + } +}