diff --git a/internal/acp/client.go b/internal/acp/client.go index 61f13789..d09f76ae 100644 --- a/internal/acp/client.go +++ b/internal/acp/client.go @@ -218,6 +218,61 @@ func (c *Client) NewSession(ctx context.Context, cwd string) (string, error) { return out.SessionID, nil } +// LoadSessionResult contains the response from loading a persisted session. +type LoadSessionResult struct { + SessionID string `json:"sessionId"` + Model string `json:"model,omitempty"` + MessageCount int `json:"messageCount"` + Status string `json:"status"` +} + +// SessionSummary represents a session entry returned by ListSessions. +type SessionSummary struct { + ID string `json:"id"` + Preview string `json:"preview,omitempty"` + CWD string `json:"cwd,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +// LoadSession opens an existing persisted session on the ACP server. +func (c *Client) LoadSession(ctx context.Context, sessionID string) (*LoadSessionResult, error) { + params := map[string]any{ + "sessionId": sessionID, + } + res, err := c.call(ctx, "session/load", params) + if err != nil { + return nil, err + } + if res.Error != nil { + return nil, fmt.Errorf("rpc error (%d): %s", res.Error.Code, res.Error.Message) + } + + var out LoadSessionResult + if err := json.Unmarshal(res.Result, &out); err != nil { + return nil, fmt.Errorf("unmarshal session/load result: %w", err) + } + return &out, nil +} + +// ListSessions queries the ACP server for available sessions. +func (c *Client) ListSessions(ctx context.Context) ([]SessionSummary, error) { + res, err := c.call(ctx, "session/list", map[string]any{}) + if err != nil { + return nil, err + } + if res.Error != nil { + return nil, fmt.Errorf("rpc error (%d): %s", res.Error.Code, res.Error.Message) + } + + var out struct { + Sessions []SessionSummary `json:"sessions"` + } + if err := json.Unmarshal(res.Result, &out); err != nil { + return nil, fmt.Errorf("unmarshal session/list result: %w", err) + } + return out.Sessions, nil +} + // Prompt submits a prompt to an active ACP session and awaits the response. func (c *Client) Prompt(ctx context.Context, sessionID, prompt string) (*PromptResult, error) { params := map[string]any{ diff --git a/internal/acp/server.go b/internal/acp/server.go index db5bfa65..7b1b60be 100644 --- a/internal/acp/server.go +++ b/internal/acp/server.go @@ -20,6 +20,7 @@ import ( "time" "github.com/GrayCodeAI/hawk/internal/engine" + "github.com/GrayCodeAI/hawk/internal/session" ) // ProtocolVersion is the ACP protocol version this server implements. @@ -160,7 +161,8 @@ func (s *Server) handle(ctx context.Context, msg rpcMessage) { s.reply(msg.ID, map[string]any{ "protocolVersion": ProtocolVersion, "agentCapabilities": map[string]any{ - "loadSession": false, + "loadSession": true, + "listSessions": true, "promptCapabilities": map[string]any{ "image": false, "audio": false, @@ -178,6 +180,10 @@ func (s *Server) handle(ctx context.Context, msg rpcMessage) { }) case "session/new": s.handleSessionNew(msg) + case "session/load": + s.handleSessionLoad(msg) + case "session/list": + s.handleSessionList(msg) case "session/setMode": s.handleSetMode(msg) case "session/setIsolation": @@ -316,6 +322,90 @@ func (s *Server) handleSessionNew(msg rpcMessage) { }) } +type loadSessionParams struct { + SessionID string `json:"sessionId"` +} + +func (s *Server) handleSessionLoad(msg rpcMessage) { + var p loadSessionParams + if err := json.Unmarshal(msg.Params, &p); err != nil || p.SessionID == "" { + s.writeError(msg.ID, errCodeInvalidParams, "invalid or missing sessionId") + return + } + + // 1. Load persisted session + persisted, err := session.Load(p.SessionID) + if err != nil { + s.writeError(msg.ID, errCodeInvalidParams, fmt.Sprintf("session %q not found: %v", p.SessionID, err)) + return + } + + // 2. Build new engine session + sess, err := s.factory() + if err != nil { + s.writeError(msg.ID, errCodeInternal, "failed to construct session: "+err.Error()) + return + } + + // 3. Populate messages + for _, m := range persisted.Messages { + sess.Persistence().AddMessage(m.Role, m.Content) + } + + // 4. Register in active sessions + s.mu.Lock() + if len(s.sessions) >= maxACPSessions { + s.evictOldestLocked() + } + s.sessions[p.SessionID] = &acpSession{sess: sess} + s.order = append(s.order, p.SessionID) + s.mu.Unlock() + + // Route tool-permission prompts to the client for this session. + sess.SetPermissionFn(s.permissionFnFor(p.SessionID)) + _ = sess.SetWorkMode(engine.WorkModeAct) + + s.reply(msg.ID, map[string]any{ + "sessionId": p.SessionID, + "model": persisted.Model, + "modes": map[string]any{ + "availableModes": []string{"plan", "act", "review"}, + "currentModeId": string(sess.WorkMode()), + }, + "messageCount": len(persisted.Messages), + "status": "ready", + }) +} + +func (s *Server) handleSessionList(msg rpcMessage) { + list, err := session.List() + if err != nil { + s.writeError(msg.ID, errCodeInternal, "failed to list sessions: "+err.Error()) + return + } + + type sessionSummary struct { + ID string `json:"id"` + Preview string `json:"preview,omitempty"` + CWD string `json:"cwd,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` + } + + summaries := make([]sessionSummary, 0, len(list)) + for _, e := range list { + summaries = append(summaries, sessionSummary{ + ID: e.ID, + Preview: e.Preview, + CWD: e.CWD, + UpdatedAt: e.UpdatedAt.Format(time.RFC3339), + }) + } + + s.reply(msg.ID, map[string]any{ + "sessions": summaries, + }) +} + // evictOldestLocked removes the oldest session to keep memory bounded; the // caller must hold s.mu. Any in-flight prompt is cancelled first. func (s *Server) evictOldestLocked() { diff --git a/internal/acp/server_test.go b/internal/acp/server_test.go index 39dfac23..17865094 100644 --- a/internal/acp/server_test.go +++ b/internal/acp/server_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/GrayCodeAI/hawk/internal/engine" + "github.com/GrayCodeAI/hawk/internal/session" "github.com/GrayCodeAI/hawk/internal/tool" ) @@ -131,6 +132,102 @@ func TestACP_ParseError(t *testing.T) { } } +func TestACP_SessionLoad(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("HAWK_SESSIONS_DIR", tempDir) + + // Create and persist a session + sessID := "acp-load-test-1" + prior := &session.Session{ + ID: sessID, + Model: "mock-model", + Name: "Test Session 1", + Messages: []session.Message{ + {Role: "user", Content: "Hello from prior session"}, + {Role: "assistant", Content: "Hello! How can I help you?"}, + }, + } + if err := session.Save(prior); err != nil { + t.Fatalf("session.Save failed: %v", err) + } + + lines := []string{ + `{"jsonrpc":"2.0","id":1,"method":"session/load","params":{"sessionId":"` + sessID + `"}}`, + } + msgs := runServer(t, testFactory, lines) + + if len(msgs) != 1 { + t.Fatalf("expected 1 response, got %d", len(msgs)) + } + if msgs[0].Error != nil { + t.Fatalf("unexpected rpc error: %+v", msgs[0].Error) + } + + var r struct { + SessionID string `json:"sessionId"` + Model string `json:"model"` + MessageCount int `json:"messageCount"` + Status string `json:"status"` + } + if err := json.Unmarshal(msgs[0].Result, &r); err != nil { + t.Fatalf("failed to unmarshal load result: %v", err) + } + if r.SessionID != sessID { + t.Errorf("got sessionId %q, want %q", r.SessionID, sessID) + } + if r.MessageCount != 2 { + t.Errorf("got messageCount %d, want 2", r.MessageCount) + } + if r.Status != "ready" { + t.Errorf("got status %q, want ready", r.Status) + } +} + +func TestACP_SessionList(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("HAWK_SESSIONS_DIR", tempDir) + + prior := &session.Session{ + ID: "acp-list-test-1", + Model: "mock-model", + Name: "List Test Session", + Messages: []session.Message{ + {Role: "user", Content: "First prompt"}, + }, + } + if err := session.Save(prior); err != nil { + t.Fatalf("session.Save failed: %v", err) + } + + lines := []string{ + `{"jsonrpc":"2.0","id":1,"method":"session/list","params":{}}`, + } + msgs := runServer(t, testFactory, lines) + + if len(msgs) != 1 { + t.Fatalf("expected 1 response, got %d", len(msgs)) + } + if msgs[0].Error != nil { + t.Fatalf("unexpected rpc error: %+v", msgs[0].Error) + } + + var r struct { + Sessions []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"sessions"` + } + if err := json.Unmarshal(msgs[0].Result, &r); err != nil { + t.Fatalf("failed to unmarshal list result: %v", err) + } + if len(r.Sessions) == 0 { + t.Fatalf("expected at least 1 session in list") + } + if r.Sessions[0].ID != "acp-list-test-1" { + t.Errorf("got session ID %q, want acp-list-test-1", r.Sessions[0].ID) + } +} + func hasID(m rpcMessage, id int) bool { if len(m.ID) == 0 { return false