feat: migrate agent operations from HTTP to WebSocket (#218)

This commit is contained in:
João Salavisa
2026-02-13 09:05:40 +00:00
committed by GitHub
parent 813bfd1215
commit e2ba35b3bd
20 changed files with 2400 additions and 595 deletions
@@ -25,8 +25,7 @@ func TestNewManager(t *testing.T) {
if mgr == nil {
t.Fatal("expected non-nil manager")
}
if len(mgr.providers) != 0 {
} else if len(mgr.providers) != 0 {
t.Errorf("expected no providers, got %d", len(mgr.providers))
}
}
@@ -84,12 +84,13 @@ func TestNewShellHandlers(t *testing.T) {
if handlers == nil {
t.Fatal("expected non-nil handlers")
}
if handlers.lifecycleMgr != nil {
t.Error("expected nil lifecycleMgr when nil passed")
}
if handlers.logger == nil {
t.Error("expected non-nil logger")
} else {
if handlers.lifecycleMgr != nil {
t.Error("expected nil lifecycleMgr when nil passed")
}
if handlers.logger == nil {
t.Error("expected non-nil logger")
}
}
}
@@ -267,8 +268,7 @@ func TestNewShellHandlers_WithManager(t *testing.T) {
if handlers == nil {
t.Fatal("expected non-nil handlers")
}
if handlers.lifecycleMgr != mgr {
} else if handlers.lifecycleMgr != mgr {
t.Error("expected lifecycleMgr to be set to provided manager")
}
}
@@ -688,15 +688,16 @@ func TestFinalizeBootMessage_Success(t *testing.T) {
lastMsg := bootSvc.getLastUpdatedMessage()
if lastMsg == nil {
t.Fatal("expected boot message to be updated")
}
if lastMsg.Metadata["status"] != "exited" {
t.Errorf("expected status 'exited', got %v", lastMsg.Metadata["status"])
}
if lastMsg.Metadata["exit_code"] != 0 {
t.Errorf("expected exit_code 0, got %v", lastMsg.Metadata["exit_code"])
}
if lastMsg.Metadata["completed_at"] == nil {
t.Error("expected completed_at to be set")
} else {
if lastMsg.Metadata["status"] != "exited" {
t.Errorf("expected status 'exited', got %v", lastMsg.Metadata["status"])
}
if lastMsg.Metadata["exit_code"] != 0 {
t.Errorf("expected exit_code 0, got %v", lastMsg.Metadata["exit_code"])
}
if lastMsg.Metadata["completed_at"] == nil {
t.Error("expected completed_at to be set")
}
}
}
@@ -716,13 +717,14 @@ func TestFinalizeBootMessage_Failed(t *testing.T) {
lastMsg := bootSvc.getLastUpdatedMessage()
if lastMsg == nil {
t.Fatal("expected boot message to be updated")
}
if lastMsg.Metadata["status"] != "failed" {
t.Errorf("expected status 'failed', got %v", lastMsg.Metadata["status"])
}
// Failed status should NOT have exit_code
if _, ok := lastMsg.Metadata["exit_code"]; ok {
t.Error("expected no exit_code for failed status")
} else {
if lastMsg.Metadata["status"] != "failed" {
t.Errorf("expected status 'failed', got %v", lastMsg.Metadata["status"])
}
// Failed status should NOT have exit_code
if _, ok := lastMsg.Metadata["exit_code"]; ok {
t.Error("expected no exit_code for failed status")
}
}
}
@@ -100,8 +100,7 @@ func TestNewStoreProfileResolver(t *testing.T) {
if resolver == nil {
t.Fatal("expected non-nil resolver")
}
if resolver.store != mockRepo {
} else if resolver.store != mockRepo {
t.Error("expected resolver to use the provided store")
}
}
@@ -136,27 +135,28 @@ func TestStoreProfileResolver_ResolveProfile_Success(t *testing.T) {
}
if info == nil {
t.Fatal("expected non-nil profile info")
}
if info.ProfileID != "profile-123" {
t.Errorf("expected ProfileID 'profile-123', got '%s'", info.ProfileID)
}
if info.ProfileName != "My Profile" {
t.Errorf("expected ProfileName 'My Profile', got '%s'", info.ProfileName)
}
if info.AgentID != "agent-456" {
t.Errorf("expected AgentID 'agent-456', got '%s'", info.AgentID)
}
if info.AgentName != "claude" {
t.Errorf("expected AgentName 'claude', got '%s'", info.AgentName)
}
if info.Model != "claude-3.5-sonnet" {
t.Errorf("expected Model 'claude-3.5-sonnet', got '%s'", info.Model)
}
if info.AutoApprove != true {
t.Error("expected AutoApprove to be true")
}
if info.DangerouslySkipPermissions != false {
t.Error("expected DangerouslySkipPermissions to be false")
} else {
if info.ProfileID != "profile-123" {
t.Errorf("expected ProfileID 'profile-123', got '%s'", info.ProfileID)
}
if info.ProfileName != "My Profile" {
t.Errorf("expected ProfileName 'My Profile', got '%s'", info.ProfileName)
}
if info.AgentID != "agent-456" {
t.Errorf("expected AgentID 'agent-456', got '%s'", info.AgentID)
}
if info.AgentName != "claude" {
t.Errorf("expected AgentName 'claude', got '%s'", info.AgentName)
}
if info.Model != "claude-3.5-sonnet" {
t.Errorf("expected Model 'claude-3.5-sonnet', got '%s'", info.Model)
}
if info.AutoApprove != true {
t.Error("expected AutoApprove to be true")
}
if info.DangerouslySkipPermissions != false {
t.Error("expected DangerouslySkipPermissions to be false")
}
}
}
@@ -270,9 +270,8 @@ func TestStoreProfileResolver_ResolveProfile_FallbackToRegistryDefaultModel(t *t
}
if info == nil {
t.Fatal("expected non-nil profile info")
}
// Should have fallback to registry's default model
if info.Model != "claude-sonnet-4-20250514" {
} else if info.Model != "claude-sonnet-4-20250514" {
// Should have fallback to registry's default model
t.Errorf("expected Model 'claude-sonnet-4-20250514' (from registry), got '%s'", info.Model)
}
}
@@ -306,9 +305,8 @@ func TestStoreProfileResolver_ResolveProfile_EmptyModelNoRegistry(t *testing.T)
}
if info == nil {
t.Fatal("expected non-nil profile info")
}
// Model should remain empty since no registry fallback available
if info.Model != "" {
} else if info.Model != "" {
// Model should remain empty since no registry fallback available
t.Errorf("expected empty Model when no registry fallback, got '%s'", info.Model)
}
}
@@ -47,12 +47,13 @@ func TestNewRuntimeRegistry(t *testing.T) {
if registry == nil {
t.Fatal("expected non-nil registry")
}
if registry.runtimes == nil {
t.Error("expected runtimes map to be initialized")
}
if len(registry.runtimes) != 0 {
t.Errorf("expected empty runtimes map, got %d entries", len(registry.runtimes))
} else {
if registry.runtimes == nil {
t.Error("expected runtimes map to be initialized")
}
if len(registry.runtimes) != 0 {
t.Errorf("expected empty runtimes map, got %d entries", len(registry.runtimes))
}
}
}
@@ -266,6 +266,20 @@ func (sm *SessionManager) InitializeAndPrompt(
zap.String("existing_acp_session_id", execution.ACPSessionID),
zap.Bool("native_session_resume", agentConfig.SessionConfig.NativeSessionResume))
// Connect WebSocket streams FIRST — agent operations now go over the stream
if sm.streamManager != nil {
updatesReady := make(chan struct{})
sm.streamManager.ConnectAll(execution, updatesReady)
// Wait for the updates stream to connect — required for agent operations
select {
case <-updatesReady:
sm.logger.Debug("updates stream ready")
case <-time.After(10 * time.Second):
return fmt.Errorf("timeout waiting for agent stream to connect")
}
}
// Use InitializeSession for configuration-driven session initialization
result, err := sm.InitializeSession(
ctx,
@@ -295,20 +309,6 @@ func (sm *SessionManager) InitializeAndPrompt(
sm.eventPublisher.PublishACPSessionCreated(execution, result.SessionID)
}
// Set up WebSocket streams
if sm.streamManager != nil {
updatesReady := make(chan struct{})
sm.streamManager.ConnectAll(execution, updatesReady)
// Wait for the updates stream to connect before sending prompt
select {
case <-updatesReady:
sm.logger.Debug("updates stream ready")
case <-time.After(5 * time.Second):
sm.logger.Warn("timeout waiting for updates stream to connect, proceeding anyway")
}
}
// Send the task prompt if provided - run asynchronously so orchestrator.start returns quickly.
// The agent will process the prompt and emit events via the WebSocket stream.
//
@@ -0,0 +1,503 @@
package lifecycle
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/kandev/kandev/internal/agent/registry"
agentctl "github.com/kandev/kandev/internal/agentctl/client"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/pkg/agent"
ws "github.com/kandev/kandev/pkg/websocket"
)
func newSessionTestLogger() *logger.Logger {
log, _ := logger.NewLogger(logger.LoggingConfig{
Level: "error",
Format: "json",
})
return log
}
// mockAgentServer creates a test WebSocket server simulating agentctl.
// It responds to agent stream requests and tracks which actions were called and in what order.
type mockAgentServer struct {
server *httptest.Server
mu sync.Mutex
actionLog []string // ordered log of actions received
upgrader websocket.Upgrader
handler func(msg ws.Message) *ws.Message
wsConnected chan struct{} // closed when WS stream connects
}
func newMockAgentServer(t *testing.T) *mockAgentServer {
t.Helper()
m := &mockAgentServer{
wsConnected: make(chan struct{}),
upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
},
}
mux := http.NewServeMux()
// Agent stream WebSocket endpoint
mux.HandleFunc("/api/v1/agent/stream", func(w http.ResponseWriter, r *http.Request) {
conn, err := m.upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer func() { _ = conn.Close() }()
// Signal that WS is connected
select {
case <-m.wsConnected:
default:
close(m.wsConnected)
}
for {
_, message, err := conn.ReadMessage()
if err != nil {
return
}
var msg ws.Message
if err := json.Unmarshal(message, &msg); err != nil {
continue
}
if msg.Type == ws.MessageTypeRequest {
m.mu.Lock()
m.actionLog = append(m.actionLog, msg.Action)
m.mu.Unlock()
var resp *ws.Message
if m.handler != nil {
resp = m.handler(msg)
} else {
resp = m.defaultHandler(msg)
}
if resp != nil {
data, _ := json.Marshal(resp)
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
return
}
}
}
}
})
// Workspace stream WebSocket endpoint (required by ConnectAll)
mux.HandleFunc("/api/v1/workspace/stream", func(w http.ResponseWriter, r *http.Request) {
conn, err := m.upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer func() { _ = conn.Close() }()
// Send connected message
connMsg := map[string]string{"type": "connected"}
data, _ := json.Marshal(connMsg)
_ = conn.WriteMessage(websocket.TextMessage, data)
// Keep alive
for {
if _, _, err := conn.ReadMessage(); err != nil {
return
}
}
})
m.server = httptest.NewServer(mux)
return m
}
func (m *mockAgentServer) defaultHandler(msg ws.Message) *ws.Message {
switch msg.Action {
case "agent.initialize":
resp, _ := ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
"success": true,
"agent_info": map[string]string{
"name": "test-agent",
"version": "1.0.0",
},
})
return resp
case "agent.session.new":
resp, _ := ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
"success": true,
"session_id": "test-session-123",
})
return resp
case "agent.session.load":
resp, _ := ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
"success": true,
"session_id": "loaded-session",
})
return resp
case "agent.prompt":
resp, _ := ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
"success": true,
})
return resp
default:
resp, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeUnknownAction, "unknown action", nil)
return resp
}
}
func (m *mockAgentServer) getActionLog() []string {
m.mu.Lock()
defer m.mu.Unlock()
result := make([]string, len(m.actionLog))
copy(result, m.actionLog)
return result
}
func (m *mockAgentServer) Close() {
m.server.Close()
}
// createTestClient creates an agentctl.Client pointed at the mock server.
func createTestClient(t *testing.T, serverURL string) *agentctl.Client {
t.Helper()
// Parse the test server URL (http://127.0.0.1:PORT)
url := strings.TrimPrefix(serverURL, "http://")
parts := strings.Split(url, ":")
host := parts[0]
var port int
_, _ = fmt.Sscanf(parts[1], "%d", &port)
log := newSessionTestLogger()
return agentctl.NewClient(host, port, log)
}
// --- Tests ---
func TestInitializeAndPrompt_StreamBeforeInitialize(t *testing.T) {
// This test verifies the critical ordering: stream connects BEFORE initialize is called.
mock := newMockAgentServer(t)
defer mock.Close()
log := newSessionTestLogger()
sm := NewSessionManager(log, make(chan struct{}))
// Set up real stream manager with callbacks
streamMgr := NewStreamManager(log, StreamCallbacks{
OnAgentEvent: func(execution *AgentExecution, event agentctl.AgentEvent) {},
}, nil)
sm.SetDependencies(nil, streamMgr, nil, nil)
client := createTestClient(t, mock.server.URL)
defer client.Close()
execution := &AgentExecution{
ID: "test-exec",
TaskID: "test-task",
SessionID: "test-session",
WorkspacePath: "/workspace",
agentctl: client,
promptDoneCh: make(chan PromptCompletionSignal, 1),
}
agentConfig := &registry.AgentTypeConfig{
ID: "test-agent",
Protocol: agent.ProtocolACP,
SessionConfig: registry.SessionConfig{
NativeSessionResume: false,
},
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
err := sm.InitializeAndPrompt(ctx, execution, agentConfig, "", nil, func(executionID string) error {
return nil
})
if err != nil {
t.Fatalf("InitializeAndPrompt failed: %v", err)
}
// Wait for stream to connect (it should have connected before initialize)
select {
case <-mock.wsConnected:
case <-time.After(5 * time.Second):
t.Fatal("stream never connected")
}
// Verify the ordering: initialize and session.new should have been called
// (the stream was connected first, then these went through it)
actions := mock.getActionLog()
if len(actions) < 2 {
t.Fatalf("expected at least 2 actions, got %d: %v", len(actions), actions)
}
if actions[0] != "agent.initialize" {
t.Errorf("expected first action to be 'agent.initialize', got %q", actions[0])
}
if actions[1] != "agent.session.new" {
t.Errorf("expected second action to be 'agent.session.new', got %q", actions[1])
}
// The session ID should be set
if execution.ACPSessionID != "test-session-123" {
t.Errorf("expected ACPSessionID 'test-session-123', got %q", execution.ACPSessionID)
}
}
func TestInitializeAndPrompt_StreamTimeout(t *testing.T) {
// This test verifies that InitializeAndPrompt returns an error if
// the stream fails to connect within the timeout.
log := newSessionTestLogger()
sm := NewSessionManager(log, make(chan struct{}))
// Create a stream manager that will try to connect to a server that doesn't exist
streamMgr := NewStreamManager(log, StreamCallbacks{
OnAgentEvent: func(execution *AgentExecution, event agentctl.AgentEvent) {},
}, nil)
sm.SetDependencies(nil, streamMgr, nil, nil)
// Point client at a port that doesn't exist
badClient := agentctl.NewClient("127.0.0.1", 1, log)
defer badClient.Close()
execution := &AgentExecution{
ID: "test-exec",
TaskID: "test-task",
SessionID: "test-session",
WorkspacePath: "/workspace",
agentctl: badClient,
promptDoneCh: make(chan PromptCompletionSignal, 1),
}
agentConfig := &registry.AgentTypeConfig{
ID: "test-agent",
Protocol: agent.ProtocolACP,
}
// Use short timeout so test doesn't take 10s
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
err := sm.InitializeAndPrompt(ctx, execution, agentConfig, "", nil, func(executionID string) error {
return nil
})
// Should fail because stream couldn't connect and Initialize fails
if err == nil {
t.Fatal("expected error when stream cannot connect")
}
// The error could be either timeout waiting for stream or initialize failure
// (since stream failed, initialize over WS will also fail)
}
func TestInitializeAndPrompt_WithTaskDescription(t *testing.T) {
// Test that when a task description is provided, prompt is sent after initialization.
mock := newMockAgentServer(t)
defer mock.Close()
log := newSessionTestLogger()
sm := NewSessionManager(log, make(chan struct{}))
streamMgr := NewStreamManager(log, StreamCallbacks{
OnAgentEvent: func(execution *AgentExecution, event agentctl.AgentEvent) {},
}, nil)
sm.SetDependencies(nil, streamMgr, nil, nil)
client := createTestClient(t, mock.server.URL)
defer client.Close()
execution := &AgentExecution{
ID: "test-exec",
TaskID: "test-task",
SessionID: "test-session",
WorkspacePath: "/workspace",
agentctl: client,
promptDoneCh: make(chan PromptCompletionSignal, 1),
}
agentConfig := &registry.AgentTypeConfig{
ID: "test-agent",
Protocol: agent.ProtocolACP,
SessionConfig: registry.SessionConfig{
NativeSessionResume: false,
},
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
err := sm.InitializeAndPrompt(ctx, execution, agentConfig, "Build a feature", nil, func(executionID string) error {
return nil
})
if err != nil {
t.Fatalf("InitializeAndPrompt failed: %v", err)
}
// Wait for the prompt to be sent asynchronously
time.Sleep(500 * time.Millisecond)
actions := mock.getActionLog()
// Should have: initialize, session.new, prompt
if len(actions) < 3 {
t.Fatalf("expected at least 3 actions (initialize, session.new, prompt), got %d: %v", len(actions), actions)
}
if actions[0] != "agent.initialize" {
t.Errorf("expected first action 'agent.initialize', got %q", actions[0])
}
if actions[1] != "agent.session.new" {
t.Errorf("expected second action 'agent.session.new', got %q", actions[1])
}
if actions[2] != "agent.prompt" {
t.Errorf("expected third action 'agent.prompt', got %q", actions[2])
}
}
func TestInitializeAndPrompt_NoStreamManager(t *testing.T) {
// When streamManager is nil, InitializeAndPrompt should still work
// (though in practice this means sendStreamRequest will fail).
// This tests that the nil guard for streamManager works.
log := newSessionTestLogger()
sm := NewSessionManager(log, make(chan struct{}))
// No dependencies set — streamManager is nil
// We can't actually call InitializeAndPrompt without a stream because
// the client's sendStreamRequest will fail. But we can verify the code path
// doesn't panic when streamManager is nil.
mock := newMockAgentServer(t)
defer mock.Close()
client := createTestClient(t, mock.server.URL)
defer client.Close()
execution := &AgentExecution{
ID: "test-exec",
TaskID: "test-task",
SessionID: "test-session",
WorkspacePath: "/workspace",
agentctl: client,
promptDoneCh: make(chan PromptCompletionSignal, 1),
}
agentConfig := &registry.AgentTypeConfig{
ID: "test-agent",
Protocol: agent.ProtocolACP,
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// This will fail because the stream isn't connected (sendStreamRequest returns error).
// But it should NOT panic due to nil streamManager.
err := sm.InitializeAndPrompt(ctx, execution, agentConfig, "", nil, func(executionID string) error {
return nil
})
// Expect error because Initialize call over WS will fail (stream not connected)
if err == nil {
t.Fatal("expected error when stream is not connected")
}
}
func TestInitializeSession_CreatesNewSession(t *testing.T) {
mock := newMockAgentServer(t)
defer mock.Close()
log := newSessionTestLogger()
sm := NewSessionManager(log, make(chan struct{}))
// Create client and manually connect stream
client := createTestClient(t, mock.server.URL)
defer client.Close()
ctx := context.Background()
err := client.StreamUpdates(ctx, func(event agentctl.AgentEvent) {}, nil, nil)
if err != nil {
t.Fatalf("failed to connect stream: %v", err)
}
time.Sleep(100 * time.Millisecond) // let goroutine start
agentConfig := &registry.AgentTypeConfig{
ID: "test-agent",
Protocol: agent.ProtocolACP,
SessionConfig: registry.SessionConfig{
NativeSessionResume: false,
},
}
result, err := sm.InitializeSession(ctx, client, agentConfig, "", "/workspace", nil)
if err != nil {
t.Fatalf("InitializeSession failed: %v", err)
}
if result.AgentName != "test-agent" {
t.Errorf("expected agent name 'test-agent', got %q", result.AgentName)
}
if result.SessionID != "test-session-123" {
t.Errorf("expected session ID 'test-session-123', got %q", result.SessionID)
}
}
func TestInitializeSession_LoadsExistingSession(t *testing.T) {
mock := newMockAgentServer(t)
defer mock.Close()
log := newSessionTestLogger()
sm := NewSessionManager(log, make(chan struct{}))
client := createTestClient(t, mock.server.URL)
defer client.Close()
ctx := context.Background()
err := client.StreamUpdates(ctx, func(event agentctl.AgentEvent) {}, nil, nil)
if err != nil {
t.Fatalf("failed to connect stream: %v", err)
}
time.Sleep(100 * time.Millisecond)
agentConfig := &registry.AgentTypeConfig{
ID: "test-agent",
Protocol: agent.ProtocolACP,
SessionConfig: registry.SessionConfig{
NativeSessionResume: true,
},
}
result, err := sm.InitializeSession(ctx, client, agentConfig, "existing-session", "/workspace", nil)
if err != nil {
t.Fatalf("InitializeSession failed: %v", err)
}
if result.SessionID != "existing-session" {
t.Errorf("expected session ID 'existing-session', got %q", result.SessionID)
}
// Verify that session.load was called (not session.new)
actions := mock.getActionLog()
hasLoad := false
hasNew := false
for _, a := range actions {
if a == "agent.session.load" {
hasLoad = true
}
if a == "agent.session.new" {
hasNew = true
}
}
if !hasLoad {
t.Error("expected agent.session.load to be called")
}
if hasNew {
t.Error("did not expect agent.session.new to be called when loading existing session")
}
}
@@ -41,8 +41,7 @@ func TestNewRegistry(t *testing.T) {
if reg == nil {
t.Fatal("expected non-nil registry")
}
if len(reg.agents) != 0 {
} else if len(reg.agents) != 0 {
t.Errorf("expected empty agents map, got %d", len(reg.agents))
}
}
+113 -210
View File
@@ -1,12 +1,9 @@
package client
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"sync"
"github.com/gorilla/websocket"
"github.com/kandev/kandev/internal/agentctl/types"
@@ -38,9 +35,9 @@ type InitializeResponse struct {
Error string `json:"error,omitempty"`
}
// Initialize sends the ACP initialize request
// Initialize sends the ACP initialize request via the agent WebSocket stream.
func (c *Client) Initialize(ctx context.Context, clientName, clientVersion string) (*AgentInfo, error) {
reqBody := struct {
payload := struct {
ClientName string `json:"client_name"`
ClientVersion string `json:"client_version"`
}{
@@ -48,39 +45,22 @@ func (c *Client) Initialize(ctx context.Context, clientName, clientVersion strin
ClientVersion: clientVersion,
}
body, err := json.Marshal(reqBody)
resp, err := c.sendStreamRequest(ctx, "agent.initialize", payload)
if err != nil {
return nil, err
return nil, fmt.Errorf("initialize request failed: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/agent/initialize", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer func() {
if err := resp.Body.Close(); err != nil {
c.logger.Debug("failed to close initialize response body", zap.Error(err))
if resp.Type == ws.MessageTypeError {
var errPayload ws.ErrorPayload
if err := resp.ParsePayload(&errPayload); err != nil {
return nil, fmt.Errorf("initialize failed: unable to parse error")
}
}()
respBody, err := readResponseBody(resp)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("initialize request failed with status %d: %s", resp.StatusCode, string(respBody))
return nil, fmt.Errorf("initialize failed: %s", errPayload.Message)
}
var result InitializeResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to parse initialize response (status %d, body: %s): %w", resp.StatusCode, truncateBody(respBody), err)
if err := resp.ParsePayload(&result); err != nil {
return nil, fmt.Errorf("failed to parse initialize response: %w", err)
}
if !result.Success {
return nil, fmt.Errorf("initialize failed: %s", result.Error)
@@ -95,46 +75,29 @@ type NewSessionResponse struct {
Error string `json:"error,omitempty"`
}
// NewSession creates a new ACP session
// NewSession creates a new ACP session via the agent WebSocket stream.
func (c *Client) NewSession(ctx context.Context, cwd string, mcpServers []types.McpServer) (string, error) {
reqBody := struct {
payload := struct {
Cwd string `json:"cwd"`
McpServers []types.McpServer `json:"mcp_servers,omitempty"`
}{Cwd: cwd, McpServers: mcpServers}
body, err := json.Marshal(reqBody)
resp, err := c.sendStreamRequest(ctx, "agent.session.new", payload)
if err != nil {
return "", err
return "", fmt.Errorf("new session request failed: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/agent/session/new", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer func() {
if err := resp.Body.Close(); err != nil {
c.logger.Debug("failed to close session response body", zap.Error(err))
if resp.Type == ws.MessageTypeError {
var errPayload ws.ErrorPayload
if err := resp.ParsePayload(&errPayload); err != nil {
return "", fmt.Errorf("new session failed: unable to parse error")
}
}()
respBody, err := readResponseBody(resp)
if err != nil {
return "", fmt.Errorf("failed to read response body: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("new session request failed with status %d: %s", resp.StatusCode, string(respBody))
return "", fmt.Errorf("new session failed: %s", errPayload.Message)
}
var result NewSessionResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return "", fmt.Errorf("failed to parse new session response (status %d, body: %s): %w", resp.StatusCode, truncateBody(respBody), err)
if err := resp.ParsePayload(&result); err != nil {
return "", fmt.Errorf("failed to parse new session response: %w", err)
}
if !result.Success {
return "", fmt.Errorf("new session failed: %s", result.Error)
@@ -142,48 +105,31 @@ func (c *Client) NewSession(ctx context.Context, cwd string, mcpServers []types.
return result.SessionID, nil
}
// LoadSession resumes an existing ACP session
// LoadSession resumes an existing ACP session via the agent WebSocket stream.
func (c *Client) LoadSession(ctx context.Context, sessionID string) error {
reqBody := struct {
payload := struct {
SessionID string `json:"session_id"`
}{SessionID: sessionID}
body, err := json.Marshal(reqBody)
resp, err := c.sendStreamRequest(ctx, "agent.session.load", payload)
if err != nil {
return err
return fmt.Errorf("load session request failed: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/agent/session/load", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer func() {
if err := resp.Body.Close(); err != nil {
c.logger.Debug("failed to close load session response body", zap.Error(err))
if resp.Type == ws.MessageTypeError {
var errPayload ws.ErrorPayload
if err := resp.ParsePayload(&errPayload); err != nil {
return fmt.Errorf("load session failed: unable to parse error")
}
}()
respBody, err := readResponseBody(resp)
if err != nil {
return fmt.Errorf("failed to read response body: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("load session request failed with status %d: %s", resp.StatusCode, string(respBody))
return fmt.Errorf("load session failed: %s", errPayload.Message)
}
var result struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return fmt.Errorf("failed to parse load session response (status %d, body: %s): %w", resp.StatusCode, truncateBody(respBody), err)
if err := resp.ParsePayload(&result); err != nil {
return fmt.Errorf("failed to parse load session response: %w", err)
}
if !result.Success {
return fmt.Errorf("load session failed: %s", result.Error)
@@ -191,58 +137,37 @@ func (c *Client) LoadSession(ctx context.Context, sessionID string) error {
return nil
}
// Prompt sends a fire-and-forget prompt to the agent.
// The agentctl server returns 202 immediately; completion is signaled via the WebSocket complete event.
// Prompt sends a fire-and-forget prompt to the agent via the agent WebSocket stream.
// The server returns an accepted response immediately; completion is signaled via stream events.
// Attachments (images) are passed to the agent if provided.
func (c *Client) Prompt(ctx context.Context, text string, attachments []v1.MessageAttachment) error {
reqBody := struct {
payload := struct {
Text string `json:"text"`
Attachments []v1.MessageAttachment `json:"attachments,omitempty"`
}{Text: text, Attachments: attachments}
body, err := json.Marshal(reqBody)
resp, err := c.sendStreamRequest(ctx, "agent.prompt", payload)
if err != nil {
return err
return fmt.Errorf("prompt request failed: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/agent/prompt", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer func() {
if err := resp.Body.Close(); err != nil {
c.logger.Debug("failed to close prompt response body", zap.Error(err))
if resp.Type == ws.MessageTypeError {
var errPayload ws.ErrorPayload
if err := resp.ParsePayload(&errPayload); err != nil {
return fmt.Errorf("prompt failed: unable to parse error")
}
}()
respBody, err := readResponseBody(resp)
if err != nil {
return fmt.Errorf("failed to read response body: %w", err)
}
// Accept both 200 (backwards compat) and 202 (async)
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
return fmt.Errorf("prompt request failed with status %d: %s", resp.StatusCode, string(respBody))
return fmt.Errorf("prompt failed: %s", errPayload.Message)
}
var result struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return fmt.Errorf("failed to parse prompt response (status %d, body: %s): %w", resp.StatusCode, truncateBody(respBody), err)
if err := resp.ParsePayload(&result); err != nil {
return fmt.Errorf("failed to parse prompt response: %w", err)
}
if !result.Success {
c.logger.Warn("prompt returned failure response",
zap.String("error", result.Error),
zap.Int("status_code", resp.StatusCode),
zap.String("response_body", truncateBody(respBody)))
c.logger.Warn("prompt returned failure response", zap.String("error", result.Error))
return fmt.Errorf("prompt failed: %s", result.Error)
}
return nil
@@ -275,11 +200,10 @@ func (c *Client) StreamUpdates(ctx context.Context, handler func(AgentEvent), mc
c.logger.Info("connected to updates stream", zap.String("url", wsURL))
// Use mutex for writing responses
var writeMu sync.Mutex
// writeMessage uses the shared stream write mutex for thread-safe writes
writeMessage := func(data []byte) error {
writeMu.Lock()
defer writeMu.Unlock()
c.streamWriteMu.Lock()
defer c.streamWriteMu.Unlock()
return conn.WriteMessage(websocket.TextMessage, data)
}
@@ -287,6 +211,9 @@ func (c *Client) StreamUpdates(ctx context.Context, handler func(AgentEvent), mc
go func() {
var lastErr error
defer func() {
// Clean up pending requests before signaling disconnect
c.cleanupPendingRequests()
c.mu.Lock()
c.agentStreamConn = nil
c.mu.Unlock()
@@ -312,33 +239,40 @@ func (c *Client) StreamUpdates(ctx context.Context, handler func(AgentEvent), mc
return
}
// Try to parse as ws.Message to check if it's an MCP request
// Try to parse as ws.Message to check message type
var wsMsg ws.Message
if err := json.Unmarshal(message, &wsMsg); err == nil && wsMsg.Type == ws.MessageTypeRequest {
// This is an MCP request - dispatch it
if mcpHandler != nil {
go func(msg ws.Message) {
resp, err := mcpHandler.Dispatch(ctx, &msg)
if err != nil {
c.logger.Error("MCP dispatch error", zap.Error(err))
resp, _ = ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil)
}
if resp != nil {
data, err := json.Marshal(resp)
if err != nil {
c.logger.Error("failed to marshal MCP response", zap.Error(err))
return
}
if err := writeMessage(data); err != nil {
c.logger.Debug("failed to write MCP response", zap.Error(err))
}
}
}(wsMsg)
if err := json.Unmarshal(message, &wsMsg); err == nil {
// Check if this is a response/error to a pending request
if (wsMsg.Type == ws.MessageTypeResponse || wsMsg.Type == ws.MessageTypeError) && c.resolvePendingRequest(&wsMsg) {
continue
}
// This is an MCP request - dispatch it
if wsMsg.Type == ws.MessageTypeRequest {
if mcpHandler != nil {
go func(msg ws.Message) {
resp, err := mcpHandler.Dispatch(ctx, &msg)
if err != nil {
c.logger.Error("MCP dispatch error", zap.Error(err))
resp, _ = ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil)
}
if resp != nil {
data, err := json.Marshal(resp)
if err != nil {
c.logger.Error("failed to marshal MCP response", zap.Error(err))
return
}
if err := writeMessage(data); err != nil {
c.logger.Debug("failed to write MCP response", zap.Error(err))
}
}
}(wsMsg)
}
continue
}
continue
}
// Not an MCP request, parse as agent event
// Not a ws.Message request/response, parse as agent event
var event AgentEvent
if err := json.Unmarshal(message, &event); err != nil {
c.logger.Warn("failed to parse agent event", zap.Error(err))
@@ -371,109 +305,78 @@ type CancelResponse struct {
Error string `json:"error,omitempty"`
}
// Cancel interrupts the current agent turn.
// Cancel interrupts the current agent turn via the agent WebSocket stream.
func (c *Client) Cancel(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/agent/cancel", nil)
resp, err := c.sendStreamRequest(ctx, "agent.cancel", nil)
if err != nil {
return err
return fmt.Errorf("cancel request failed: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer func() {
if err := resp.Body.Close(); err != nil {
c.logger.Debug("failed to close cancel response body", zap.Error(err))
if resp.Type == ws.MessageTypeError {
var errPayload ws.ErrorPayload
if err := resp.ParsePayload(&errPayload); err != nil {
return fmt.Errorf("cancel failed: unable to parse error")
}
}()
respBody, err := readResponseBody(resp)
if err != nil {
return fmt.Errorf("failed to read response body: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("cancel request failed with status %d: %s", resp.StatusCode, string(respBody))
return fmt.Errorf("cancel failed: %s", errPayload.Message)
}
var result CancelResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return fmt.Errorf("failed to parse cancel response (status %d, body: %s): %w", resp.StatusCode, truncateBody(respBody), err)
if err := resp.ParsePayload(&result); err != nil {
return fmt.Errorf("failed to parse cancel response: %w", err)
}
if !result.Success {
return fmt.Errorf("cancel failed: %s", result.Error)
}
return nil
}
// GetAgentStderr returns recent stderr lines from the agent process.
// GetAgentStderr returns recent stderr lines from the agent process via the agent WebSocket stream.
func (c *Client) GetAgentStderr(ctx context.Context) ([]string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/agent/stderr", nil)
resp, err := c.sendStreamRequest(ctx, "agent.stderr", nil)
if err != nil {
return nil, err
return nil, fmt.Errorf("agent stderr request failed: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("agent stderr request failed with status %d", resp.StatusCode)
if resp.Type == ws.MessageTypeError {
var errPayload ws.ErrorPayload
if err := resp.ParsePayload(&errPayload); err != nil {
return nil, fmt.Errorf("agent stderr failed: unable to parse error")
}
return nil, fmt.Errorf("agent stderr failed: %s", errPayload.Message)
}
var result struct {
Lines []string `json:"lines"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
if err := resp.ParsePayload(&result); err != nil {
return nil, fmt.Errorf("failed to parse agent stderr response: %w", err)
}
return result.Lines, nil
}
// RespondToPermission sends a response to a permission request
// RespondToPermission sends a response to a permission request via the agent WebSocket stream.
func (c *Client) RespondToPermission(ctx context.Context, pendingID, optionID string, cancelled bool) error {
reqBody := PermissionRespondRequest{
payload := PermissionRespondRequest{
PendingID: pendingID,
OptionID: optionID,
Cancelled: cancelled,
}
body, err := json.Marshal(reqBody)
resp, err := c.sendStreamRequest(ctx, "agent.permissions.respond", payload)
if err != nil {
return err
return fmt.Errorf("permission response request failed: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/agent/permissions/respond", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer func() {
if err := resp.Body.Close(); err != nil {
c.logger.Debug("failed to close permission response body", zap.Error(err))
if resp.Type == ws.MessageTypeError {
var errPayload ws.ErrorPayload
if err := resp.ParsePayload(&errPayload); err != nil {
return fmt.Errorf("permission response failed: unable to parse error")
}
}()
respBody, err := readResponseBody(resp)
if err != nil {
return fmt.Errorf("failed to read response body: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("permission response request failed with status %d: %s", resp.StatusCode, string(respBody))
return fmt.Errorf("permission response failed: %s", errPayload.Message)
}
var result PermissionRespondResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return fmt.Errorf("failed to parse permission response (status %d, body: %s): %w", resp.StatusCode, truncateBody(respBody), err)
if err := resp.ParsePayload(&result); err != nil {
return fmt.Errorf("failed to parse permission response: %w", err)
}
if !result.Success {
return fmt.Errorf("permission response failed: %s", result.Error)
@@ -0,0 +1,834 @@
package client
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/kandev/kandev/internal/common/logger"
ws "github.com/kandev/kandev/pkg/websocket"
)
func newTestLogger() *logger.Logger {
log, _ := logger.NewLogger(logger.LoggingConfig{
Level: "error",
Format: "json",
})
return log
}
// wsTestServer creates a test WebSocket server that echoes back response messages.
// The handler receives each request and should return a response message.
type wsTestServer struct {
server *httptest.Server
handler func(msg ws.Message) *ws.Message
}
func newWSTestServer(t *testing.T, handler func(msg ws.Message) *ws.Message) *wsTestServer {
t.Helper()
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
ts := &wsTestServer{handler: handler}
ts.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
t.Logf("upgrade error: %v", err)
return
}
defer func() { _ = conn.Close() }()
for {
_, message, err := conn.ReadMessage()
if err != nil {
return
}
var msg ws.Message
if err := json.Unmarshal(message, &msg); err != nil {
continue
}
if msg.Type == ws.MessageTypeRequest && ts.handler != nil {
resp := ts.handler(msg)
if resp != nil {
data, _ := json.Marshal(resp)
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
return
}
}
}
}
}))
return ts
}
func (ts *wsTestServer) Close() {
ts.server.Close()
}
// newTestClientWithStream creates a Client connected to a test WebSocket server.
// The handler processes incoming requests and returns responses.
func newTestClientWithStream(t *testing.T, handler func(msg ws.Message) *ws.Message) (*Client, *wsTestServer) {
t.Helper()
ts := newWSTestServer(t, handler)
// Parse the test server URL to get host and port
// httptest URL is like http://127.0.0.1:PORT
url := ts.server.URL
log := newTestLogger()
// Create client with the test server URL
c := &Client{
baseURL: url,
httpClient: &http.Client{Timeout: 5 * time.Second},
logger: log,
pendingRequests: make(map[string]chan *ws.Message),
}
// Connect the stream
ctx := context.Background()
err := c.StreamUpdates(ctx, func(event AgentEvent) {
// no-op handler for agent events
}, nil, nil)
if err != nil {
ts.Close()
t.Fatalf("failed to connect stream: %v", err)
}
// Give the goroutine time to start
time.Sleep(50 * time.Millisecond)
return c, ts
}
// --- Tests for infrastructure methods ---
func TestSendStreamRequest_NotConnected(t *testing.T) {
log := newTestLogger()
c := &Client{
baseURL: "http://localhost:0",
logger: log,
pendingRequests: make(map[string]chan *ws.Message),
}
ctx := context.Background()
_, err := c.sendStreamRequest(ctx, "test.action", nil)
if err == nil {
t.Fatal("expected error when stream not connected")
}
if !strings.Contains(err.Error(), "agent stream not connected") {
t.Fatalf("expected 'agent stream not connected' error, got: %v", err)
}
}
func TestSendStreamRequest_ContextCancelled(t *testing.T) {
// Server that never responds
c, ts := newTestClientWithStream(t, func(msg ws.Message) *ws.Message {
return nil // don't respond
})
defer ts.Close()
defer c.Close()
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
_, err := c.sendStreamRequest(ctx, "test.action", nil)
if err == nil {
t.Fatal("expected error on timeout")
}
if !strings.Contains(err.Error(), "context deadline exceeded") {
t.Fatalf("expected deadline exceeded error, got: %v", err)
}
}
func TestResolvePendingRequest_NoMatch(t *testing.T) {
log := newTestLogger()
c := &Client{
logger: log,
pendingRequests: make(map[string]chan *ws.Message),
}
msg := &ws.Message{ID: "nonexistent", Type: ws.MessageTypeResponse}
if c.resolvePendingRequest(msg) {
t.Fatal("expected false for unmatched request")
}
}
func TestResolvePendingRequest_EmptyID(t *testing.T) {
log := newTestLogger()
c := &Client{
logger: log,
pendingRequests: make(map[string]chan *ws.Message),
}
msg := &ws.Message{ID: "", Type: ws.MessageTypeResponse}
if c.resolvePendingRequest(msg) {
t.Fatal("expected false for empty ID")
}
}
func TestResolvePendingRequest_Match(t *testing.T) {
log := newTestLogger()
c := &Client{
logger: log,
pendingRequests: make(map[string]chan *ws.Message),
}
ch := make(chan *ws.Message, 1)
c.pendingMu.Lock()
c.pendingRequests["test-id"] = ch
c.pendingMu.Unlock()
msg := &ws.Message{ID: "test-id", Type: ws.MessageTypeResponse, Action: "test.action"}
if !c.resolvePendingRequest(msg) {
t.Fatal("expected true for matched request")
}
select {
case resp := <-ch:
if resp.ID != "test-id" {
t.Fatalf("expected response ID 'test-id', got %q", resp.ID)
}
default:
t.Fatal("expected response in channel")
}
}
func TestCleanupPendingRequests(t *testing.T) {
log := newTestLogger()
c := &Client{
logger: log,
pendingRequests: make(map[string]chan *ws.Message),
}
ch1 := make(chan *ws.Message, 1)
ch2 := make(chan *ws.Message, 1)
c.pendingMu.Lock()
c.pendingRequests["req-1"] = ch1
c.pendingRequests["req-2"] = ch2
c.pendingMu.Unlock()
c.cleanupPendingRequests()
c.pendingMu.Lock()
count := len(c.pendingRequests)
c.pendingMu.Unlock()
if count != 0 {
t.Fatalf("expected 0 pending requests after cleanup, got %d", count)
}
// Channels should be closed
_, ok := <-ch1
if ok {
t.Fatal("expected ch1 to be closed")
}
_, ok = <-ch2
if ok {
t.Fatal("expected ch2 to be closed")
}
}
func TestCleanupPendingRequests_ConcurrentSafe(t *testing.T) {
log := newTestLogger()
c := &Client{
logger: log,
pendingRequests: make(map[string]chan *ws.Message),
}
// Add many pending requests
for i := 0; i < 50; i++ {
ch := make(chan *ws.Message, 1)
c.pendingMu.Lock()
c.pendingRequests[strings.Repeat("x", i+1)] = ch
c.pendingMu.Unlock()
}
// Cleanup concurrently
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
c.cleanupPendingRequests()
}()
wg.Wait()
c.pendingMu.Lock()
count := len(c.pendingRequests)
c.pendingMu.Unlock()
if count != 0 {
t.Fatalf("expected 0 pending requests, got %d", count)
}
}
// --- Tests for the 7 migrated methods ---
func TestInitialize_Success(t *testing.T) {
c, ts := newTestClientWithStream(t, func(msg ws.Message) *ws.Message {
if msg.Action != "agent.initialize" {
t.Errorf("expected action 'agent.initialize', got %q", msg.Action)
}
resp, _ := ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
"success": true,
"agent_info": map[string]string{
"name": "test-agent",
"version": "1.0.0",
},
})
return resp
})
defer ts.Close()
defer c.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
info, err := c.Initialize(ctx, "kandev", "1.0.0")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if info == nil {
t.Fatal("expected agent info, got nil")
} else {
if info.Name != "test-agent" {
t.Errorf("expected name 'test-agent', got %q", info.Name)
}
if info.Version != "1.0.0" {
t.Errorf("expected version '1.0.0', got %q", info.Version)
}
}
}
func TestInitialize_ServerError(t *testing.T) {
c, ts := newTestClientWithStream(t, func(msg ws.Message) *ws.Message {
resp, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "agent not running", nil)
return resp
})
defer ts.Close()
defer c.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := c.Initialize(ctx, "kandev", "1.0.0")
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "agent not running") {
t.Fatalf("expected 'agent not running' error, got: %v", err)
}
}
func TestInitialize_SuccessFalse(t *testing.T) {
c, ts := newTestClientWithStream(t, func(msg ws.Message) *ws.Message {
resp, _ := ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
"success": false,
"error": "init failed internally",
})
return resp
})
defer ts.Close()
defer c.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := c.Initialize(ctx, "kandev", "1.0.0")
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "init failed internally") {
t.Fatalf("expected 'init failed internally' error, got: %v", err)
}
}
func TestNewSession_Success(t *testing.T) {
c, ts := newTestClientWithStream(t, func(msg ws.Message) *ws.Message {
if msg.Action != "agent.session.new" {
t.Errorf("expected action 'agent.session.new', got %q", msg.Action)
}
resp, _ := ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
"success": true,
"session_id": "sess-123",
})
return resp
})
defer ts.Close()
defer c.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
sessionID, err := c.NewSession(ctx, "/workspace", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if sessionID != "sess-123" {
t.Errorf("expected session ID 'sess-123', got %q", sessionID)
}
}
func TestNewSession_Error(t *testing.T) {
c, ts := newTestClientWithStream(t, func(msg ws.Message) *ws.Message {
resp, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "agent not running", nil)
return resp
})
defer ts.Close()
defer c.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := c.NewSession(ctx, "/workspace", nil)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "agent not running") {
t.Fatalf("expected 'agent not running' error, got: %v", err)
}
}
func TestLoadSession_Success(t *testing.T) {
c, ts := newTestClientWithStream(t, func(msg ws.Message) *ws.Message {
if msg.Action != "agent.session.load" {
t.Errorf("expected action 'agent.session.load', got %q", msg.Action)
}
resp, _ := ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
"success": true,
"session_id": "sess-456",
})
return resp
})
defer ts.Close()
defer c.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := c.LoadSession(ctx, "sess-456")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestLoadSession_Error(t *testing.T) {
c, ts := newTestClientWithStream(t, func(msg ws.Message) *ws.Message {
resp, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Method not found", nil)
return resp
})
defer ts.Close()
defer c.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := c.LoadSession(ctx, "sess-456")
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "Method not found") {
t.Fatalf("expected 'Method not found' error, got: %v", err)
}
}
func TestPrompt_Success(t *testing.T) {
c, ts := newTestClientWithStream(t, func(msg ws.Message) *ws.Message {
if msg.Action != "agent.prompt" {
t.Errorf("expected action 'agent.prompt', got %q", msg.Action)
}
// Verify payload
var payload struct {
Text string `json:"text"`
}
if err := msg.ParsePayload(&payload); err != nil {
t.Errorf("failed to parse payload: %v", err)
}
if payload.Text != "hello agent" {
t.Errorf("expected text 'hello agent', got %q", payload.Text)
}
resp, _ := ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
"success": true,
})
return resp
})
defer ts.Close()
defer c.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := c.Prompt(ctx, "hello agent", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestPrompt_NoActiveSession(t *testing.T) {
c, ts := newTestClientWithStream(t, func(msg ws.Message) *ws.Message {
resp, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "no active session - call new_session first", nil)
return resp
})
defer ts.Close()
defer c.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := c.Prompt(ctx, "hello agent", nil)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "no active session") {
t.Fatalf("expected 'no active session' error, got: %v", err)
}
}
func TestCancel_Success(t *testing.T) {
c, ts := newTestClientWithStream(t, func(msg ws.Message) *ws.Message {
if msg.Action != "agent.cancel" {
t.Errorf("expected action 'agent.cancel', got %q", msg.Action)
}
resp, _ := ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
"success": true,
})
return resp
})
defer ts.Close()
defer c.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := c.Cancel(ctx)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCancel_Error(t *testing.T) {
c, ts := newTestClientWithStream(t, func(msg ws.Message) *ws.Message {
resp, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "agent not running", nil)
return resp
})
defer ts.Close()
defer c.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := c.Cancel(ctx)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "agent not running") {
t.Fatalf("expected 'agent not running' error, got: %v", err)
}
}
func TestGetAgentStderr_Success(t *testing.T) {
c, ts := newTestClientWithStream(t, func(msg ws.Message) *ws.Message {
if msg.Action != "agent.stderr" {
t.Errorf("expected action 'agent.stderr', got %q", msg.Action)
}
resp, _ := ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
"lines": []string{"error line 1", "error line 2"},
})
return resp
})
defer ts.Close()
defer c.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
lines, err := c.GetAgentStderr(ctx)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(lines) != 2 {
t.Fatalf("expected 2 lines, got %d", len(lines))
}
if lines[0] != "error line 1" {
t.Errorf("expected 'error line 1', got %q", lines[0])
}
}
func TestGetAgentStderr_NotConnected(t *testing.T) {
log := newTestLogger()
c := &Client{
baseURL: "http://localhost:0",
logger: log,
pendingRequests: make(map[string]chan *ws.Message),
}
ctx := context.Background()
_, err := c.GetAgentStderr(ctx)
if err == nil {
t.Fatal("expected error when stream not connected")
}
if !strings.Contains(err.Error(), "agent stream not connected") {
t.Fatalf("expected 'agent stream not connected' error, got: %v", err)
}
}
func TestRespondToPermission_Success(t *testing.T) {
c, ts := newTestClientWithStream(t, func(msg ws.Message) *ws.Message {
if msg.Action != "agent.permissions.respond" {
t.Errorf("expected action 'agent.permissions.respond', got %q", msg.Action)
}
// Verify payload
var payload struct {
PendingID string `json:"pending_id"`
OptionID string `json:"option_id"`
Cancelled bool `json:"cancelled"`
}
if err := msg.ParsePayload(&payload); err != nil {
t.Errorf("failed to parse payload: %v", err)
}
if payload.PendingID != "perm-1" {
t.Errorf("expected pending_id 'perm-1', got %q", payload.PendingID)
}
if payload.OptionID != "allow" {
t.Errorf("expected option_id 'allow', got %q", payload.OptionID)
}
resp, _ := ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
"success": true,
})
return resp
})
defer ts.Close()
defer c.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := c.RespondToPermission(ctx, "perm-1", "allow", false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestRespondToPermission_NotFound(t *testing.T) {
c, ts := newTestClientWithStream(t, func(msg ws.Message) *ws.Message {
resp, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "permission request not found", nil)
return resp
})
defer ts.Close()
defer c.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := c.RespondToPermission(ctx, "nonexistent", "allow", false)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "permission request not found") {
t.Fatalf("expected 'permission request not found' error, got: %v", err)
}
}
// --- Tests for StreamUpdates response routing ---
func TestStreamUpdates_RoutesResponsesToPending(t *testing.T) {
// This test verifies that StreamUpdates correctly routes response messages
// to pending requests instead of treating them as agent events.
var receivedEvents []AgentEvent
var mu sync.Mutex
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer func() { _ = conn.Close() }()
for {
_, message, err := conn.ReadMessage()
if err != nil {
return
}
var msg ws.Message
if err := json.Unmarshal(message, &msg); err != nil {
continue
}
if msg.Type == ws.MessageTypeRequest {
// Send response back
resp, _ := ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
"success": true,
})
data, _ := json.Marshal(resp)
_ = conn.WriteMessage(websocket.TextMessage, data)
// Also send an agent event
event := AgentEvent{Type: "message_chunk", Text: "hello"}
eventData, _ := json.Marshal(event)
_ = conn.WriteMessage(websocket.TextMessage, eventData)
}
}
}))
defer server.Close()
log := newTestLogger()
c := &Client{
baseURL: server.URL,
httpClient: &http.Client{Timeout: 5 * time.Second},
logger: log,
pendingRequests: make(map[string]chan *ws.Message),
}
ctx := context.Background()
err := c.StreamUpdates(ctx, func(event AgentEvent) {
mu.Lock()
receivedEvents = append(receivedEvents, event)
mu.Unlock()
}, nil, nil)
if err != nil {
t.Fatalf("failed to connect: %v", err)
}
time.Sleep(50 * time.Millisecond)
// Send a request and get a response
reqCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := c.sendStreamRequest(reqCtx, "test.action", nil)
if err != nil {
t.Fatalf("sendStreamRequest failed: %v", err)
}
if resp.Type != ws.MessageTypeResponse {
t.Errorf("expected response type, got %q", resp.Type)
}
// Wait for the agent event to be processed
time.Sleep(100 * time.Millisecond)
mu.Lock()
eventCount := len(receivedEvents)
mu.Unlock()
if eventCount != 1 {
t.Errorf("expected 1 agent event, got %d", eventCount)
}
c.Close()
}
func TestStreamUpdates_DisconnectCleansPending(t *testing.T) {
// This test verifies that pending requests are cleaned up on stream disconnect.
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
// Close immediately to trigger disconnect
time.Sleep(100 * time.Millisecond)
_ = conn.Close()
}))
defer server.Close()
log := newTestLogger()
c := &Client{
baseURL: server.URL,
httpClient: &http.Client{Timeout: 5 * time.Second},
logger: log,
pendingRequests: make(map[string]chan *ws.Message),
}
disconnected := make(chan struct{})
ctx := context.Background()
err := c.StreamUpdates(ctx, func(event AgentEvent) {}, nil, func(err error) {
close(disconnected)
})
if err != nil {
t.Fatalf("failed to connect: %v", err)
}
time.Sleep(50 * time.Millisecond)
// Add a pending request
ch := make(chan *ws.Message, 1)
c.pendingMu.Lock()
c.pendingRequests["pending-test"] = ch
c.pendingMu.Unlock()
// Wait for disconnect
select {
case <-disconnected:
case <-time.After(5 * time.Second):
t.Fatal("timeout waiting for disconnect")
}
// The channel should be closed
_, ok := <-ch
if ok {
t.Fatal("expected channel to be closed after disconnect")
}
c.pendingMu.Lock()
count := len(c.pendingRequests)
c.pendingMu.Unlock()
if count != 0 {
t.Fatalf("expected 0 pending requests after disconnect, got %d", count)
}
}
// --- Concurrent request tests ---
func TestConcurrentRequests(t *testing.T) {
// Multiple concurrent requests should all resolve correctly.
c, ts := newTestClientWithStream(t, func(msg ws.Message) *ws.Message {
// Simulate some processing time
time.Sleep(10 * time.Millisecond)
resp, _ := ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
"success": true,
"action": msg.Action,
})
return resp
})
defer ts.Close()
defer c.Close()
var wg sync.WaitGroup
errors := make(chan error, 10)
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := c.sendStreamRequest(ctx, "test.concurrent", nil)
if err != nil {
errors <- err
}
}()
}
wg.Wait()
close(errors)
for err := range errors {
t.Errorf("concurrent request failed: %v", err)
}
}
+102 -2
View File
@@ -11,14 +11,16 @@ import (
"sync"
"time"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"github.com/kandev/kandev/internal/agentctl/types"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/internal/common/logger"
ws "github.com/kandev/kandev/pkg/websocket"
"go.uber.org/zap"
)
// Client communicates with agentctl via HTTP
// Client communicates with agentctl via HTTP and WebSocket
type Client struct {
baseURL string
httpClient *http.Client
@@ -28,6 +30,13 @@ type Client struct {
agentStreamConn *websocket.Conn
workspaceStreamConn *websocket.Conn
mu sync.RWMutex
// Shared write mutex for agent stream (used by StreamUpdates and sendStreamRequest)
streamWriteMu sync.Mutex
// Pending request/response tracking for agent stream
pendingRequests map[string]chan *ws.Message
pendingMu sync.Mutex
}
// StatusResponse from agentctl
@@ -49,7 +58,8 @@ func NewClient(host string, port int, log *logger.Logger) *Client {
httpClient: &http.Client{
Timeout: 60 * time.Second,
},
logger: log.WithFields(zap.String("component", "agentctl-client")),
logger: log.WithFields(zap.String("component", "agentctl-client")),
pendingRequests: make(map[string]chan *ws.Message),
}
}
@@ -503,6 +513,96 @@ func (c *Client) DeleteFile(ctx context.Context, path string) (*FileDeleteRespon
return &response, nil
}
// sendStreamRequest sends a request over the agent WebSocket stream and waits for a response.
// It creates a ws.Message with a UUID, registers a pending response channel,
// writes the message to the stream, and blocks until a response arrives or context is cancelled.
func (c *Client) sendStreamRequest(ctx context.Context, action string, payload interface{}) (*ws.Message, error) {
c.mu.RLock()
conn := c.agentStreamConn
c.mu.RUnlock()
if conn == nil {
return nil, fmt.Errorf("agent stream not connected")
}
reqID := uuid.New().String()
msg, err := ws.NewRequest(reqID, action, payload)
if err != nil {
return nil, fmt.Errorf("failed to create request message: %w", err)
}
// Register pending request
respCh := make(chan *ws.Message, 1)
c.pendingMu.Lock()
c.pendingRequests[reqID] = respCh
c.pendingMu.Unlock()
// Clean up on exit
defer func() {
c.pendingMu.Lock()
delete(c.pendingRequests, reqID)
c.pendingMu.Unlock()
}()
// Serialize write to stream
data, err := json.Marshal(msg)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
c.streamWriteMu.Lock()
writeErr := conn.WriteMessage(websocket.TextMessage, data)
c.streamWriteMu.Unlock()
if writeErr != nil {
return nil, fmt.Errorf("failed to write request to stream: %w", writeErr)
}
// Wait for response or context cancellation
select {
case resp := <-respCh:
if resp == nil {
return nil, fmt.Errorf("agent stream disconnected while waiting for response")
}
return resp, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
// resolvePendingRequest matches a response message to a pending request by ID.
// Returns true if the message was matched to a pending request.
func (c *Client) resolvePendingRequest(msg *ws.Message) bool {
if msg.ID == "" {
return false
}
c.pendingMu.Lock()
ch, ok := c.pendingRequests[msg.ID]
c.pendingMu.Unlock()
if !ok {
return false
}
// Send response to waiting caller (non-blocking since channel is buffered)
select {
case ch <- msg:
default:
}
return true
}
// cleanupPendingRequests unblocks all pending requests with nil (signaling disconnect).
func (c *Client) cleanupPendingRequests() {
c.pendingMu.Lock()
defer c.pendingMu.Unlock()
for id, ch := range c.pendingRequests {
close(ch)
delete(c.pendingRequests, id)
}
}
// Close closes all connections
func (c *Client) Close() {
c.CloseUpdatesStream()
@@ -129,18 +129,18 @@ func TestParseCodexStderrError(t *testing.T) {
if result == nil {
t.Fatal("expected non-nil result, got nil")
}
} else {
if result.HTTPError != tt.wantHTTPError {
t.Errorf("HTTPError = %q, want %q", result.HTTPError, tt.wantHTTPError)
}
if result.HTTPError != tt.wantHTTPError {
t.Errorf("HTTPError = %q, want %q", result.HTTPError, tt.wantHTTPError)
}
if tt.wantErrorType != "" && result.ErrorType != tt.wantErrorType {
t.Errorf("ErrorType = %q, want %q", result.ErrorType, tt.wantErrorType)
}
if tt.wantErrorType != "" && result.ErrorType != tt.wantErrorType {
t.Errorf("ErrorType = %q, want %q", result.ErrorType, tt.wantErrorType)
}
if !strings.Contains(result.Message, tt.wantMsgContains) {
t.Errorf("Message = %q, want to contain %q", result.Message, tt.wantMsgContains)
if !strings.Contains(result.Message, tt.wantMsgContains) {
t.Errorf("Message = %q, want to contain %q", result.Message, tt.wantMsgContains)
}
}
if tt.wantRawJSON && result.RawJSON == nil {
@@ -221,9 +221,7 @@ func TestParseCodexStderrLines(t *testing.T) {
if result == nil {
t.Fatal("expected non-nil result, got nil")
}
if !strings.Contains(result.Message, tt.wantMsgContains) {
} else if !strings.Contains(result.Message, tt.wantMsgContains) {
t.Errorf("Message = %q, want to contain %q", result.Message, tt.wantMsgContains)
}
})
@@ -237,9 +235,7 @@ func TestParseCodexStderrError_ResetsInSeconds(t *testing.T) {
result := ParseCodexStderrError(input)
if result == nil {
t.Fatal("expected non-nil result")
}
if result.ResetsInSeconds != 3600 {
} else if result.ResetsInSeconds != 3600 {
t.Errorf("ResetsInSeconds = %d, want 3600", result.ResetsInSeconds)
}
}
+249 -260
View File
@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
@@ -36,53 +35,6 @@ type InitializeResponse struct {
Error string `json:"error,omitempty"`
}
func (s *Server) handleAgentInitialize(c *gin.Context) {
var req InitializeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, InitializeResponse{
Success: false,
Error: "invalid request: " + err.Error(),
})
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second)
defer cancel()
adapter := s.procMgr.GetAdapter()
if adapter == nil {
c.JSON(http.StatusServiceUnavailable, InitializeResponse{
Success: false,
Error: "agent not running",
})
return
}
err := adapter.Initialize(ctx)
if err != nil {
s.logger.Error("initialize failed", zap.Error(err))
c.JSON(http.StatusInternalServerError, InitializeResponse{
Success: false,
Error: err.Error(),
})
return
}
// Get agent info after successful initialization
var agentInfoResp *AgentInfoResponse
if info := adapter.GetAgentInfo(); info != nil {
agentInfoResp = &AgentInfoResponse{
Name: info.Name,
Version: info.Version,
}
}
c.JSON(http.StatusOK, InitializeResponse{
Success: true,
AgentInfo: agentInfoResp,
})
}
// NewSessionRequest is a request to create a new ACP session
type NewSessionRequest struct {
Cwd string `json:"cwd"` // Working directory for the session
@@ -96,69 +48,6 @@ type NewSessionResponse struct {
Error string `json:"error,omitempty"`
}
func (s *Server) handleAgentNewSession(c *gin.Context) {
var req NewSessionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, NewSessionResponse{
Success: false,
Error: "invalid request: " + err.Error(),
})
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
defer cancel()
adapter := s.procMgr.GetAdapter()
if adapter == nil {
c.JSON(http.StatusServiceUnavailable, NewSessionResponse{
Success: false,
Error: "agent not running",
})
return
}
// If MCP server is enabled, prepend the local kandev MCP server to the list.
// This replaces the old external MCP server URL (http://localhost:9090/sse) with
// the local agentctl MCP server (http://localhost:{port}/sse).
mcpServers := req.McpServers
if s.mcpServer != nil {
localKandevMcp := types.McpServer{
Name: "kandev",
Type: "sse",
URL: fmt.Sprintf("http://localhost:%d/sse", s.cfg.Port),
}
// Filter out any existing kandev server from the list (to avoid duplicates)
filtered := make([]types.McpServer, 0, len(mcpServers)+1)
filtered = append(filtered, localKandevMcp)
for _, srv := range mcpServers {
if srv.Name != "kandev" {
filtered = append(filtered, srv)
}
}
mcpServers = filtered
s.logger.Debug("injected local kandev MCP server",
zap.String("url", localKandevMcp.URL),
zap.Int("total_servers", len(mcpServers)))
}
sessionID, err := adapter.NewSession(ctx, mcpServers)
if err != nil {
s.logger.Error("new session failed", zap.Error(err))
c.JSON(http.StatusInternalServerError, NewSessionResponse{
Success: false,
Error: err.Error(),
})
return
}
// Session ID is stored in the adapter - no need to duplicate in Manager
c.JSON(http.StatusOK, NewSessionResponse{
Success: true,
SessionID: sessionID,
})
}
// LoadSessionRequest is a request to load an existing ACP session
type LoadSessionRequest struct {
SessionID string `json:"session_id"`
@@ -171,50 +60,6 @@ type LoadSessionResponse struct {
Error string `json:"error,omitempty"`
}
func (s *Server) handleAgentLoadSession(c *gin.Context) {
var req LoadSessionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, LoadSessionResponse{
Success: false,
Error: "invalid request: " + err.Error(),
})
return
}
if req.SessionID == "" {
c.JSON(http.StatusBadRequest, LoadSessionResponse{
Success: false,
Error: "session_id is required",
})
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
defer cancel()
adapter := s.procMgr.GetAdapter()
if adapter == nil {
c.JSON(http.StatusServiceUnavailable, LoadSessionResponse{
Success: false,
Error: "agent not running",
})
return
}
if err := adapter.LoadSession(ctx, req.SessionID); err != nil {
s.logger.Error("load session failed", zap.Error(err))
c.JSON(http.StatusInternalServerError, LoadSessionResponse{
Success: false,
Error: err.Error(),
})
return
}
c.JSON(http.StatusOK, LoadSessionResponse{
Success: true,
SessionID: req.SessionID,
})
}
// PromptRequest is a request to send a prompt to the agent
type PromptRequest struct {
Text string `json:"text"` // Simple text prompt
@@ -228,54 +73,34 @@ type PromptResponse struct {
Error string `json:"error,omitempty"`
}
func (s *Server) handleAgentPrompt(c *gin.Context) {
var req PromptRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, PromptResponse{
Success: false,
Error: "invalid request: " + err.Error(),
})
return
}
// PermissionRespondRequest is a request to respond to a permission request
type PermissionRespondRequest struct {
PendingID string `json:"pending_id"`
OptionID string `json:"option_id,omitempty"`
Cancelled bool `json:"cancelled,omitempty"`
}
adapter := s.procMgr.GetAdapter()
if adapter == nil {
c.JSON(http.StatusServiceUnavailable, PromptResponse{
Success: false,
Error: "agent not running",
})
return
}
// PermissionRespondResponse is the response to a permission respond call
type PermissionRespondResponse struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
sessionID := s.procMgr.GetSessionID()
if sessionID == "" {
c.JSON(http.StatusBadRequest, PromptResponse{
Success: false,
Error: "no active session - call new_session first",
})
return
}
// AgentStderrResponse contains recent stderr lines from the agent process.
type AgentStderrResponse struct {
Lines []string `json:"lines"`
}
// Start prompt processing asynchronously.
// Completion is signaled via the WebSocket complete event, not the HTTP response.
go func() {
ctx, cancel := context.WithTimeout(context.Background(), constants.PromptTimeout)
defer cancel()
if err := adapter.Prompt(ctx, req.Text, req.Attachments); err != nil {
s.logger.Error("async prompt failed", zap.Error(err))
}
}()
s.logger.Info("prompt accepted (async)", zap.Int("attachments", len(req.Attachments)))
// Return immediately — completion comes via WebSocket complete event
c.JSON(http.StatusAccepted, PromptResponse{Success: true})
// CancelResponse is the response from a cancel request.
type CancelResponse struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// handleAgentStreamWS streams agent session notifications via WebSocket.
// This is a bidirectional stream:
// - agentctl -> backend: agent events, MCP requests
// - backend -> agentctl: MCP responses
// - backend -> agentctl: MCP responses, agent operation requests
func (s *Server) handleAgentStreamWS(c *gin.Context) {
conn, err := s.upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
@@ -308,7 +133,7 @@ func (s *Server) handleAgentStreamWS(c *gin.Context) {
// WaitGroup for cleanup
var wg sync.WaitGroup
// Read goroutine: reads MCP responses from the backend
// Read goroutine: reads MCP responses and agent operation requests from the backend
wg.Add(1)
go func() {
defer wg.Done()
@@ -324,13 +149,31 @@ func (s *Server) handleAgentStreamWS(c *gin.Context) {
return
}
// Parse as WebSocket message (MCP response)
// Parse as WebSocket message
var msg ws.Message
if err := json.Unmarshal(message, &msg); err != nil {
s.logger.Warn("failed to parse message", zap.Error(err))
continue
}
// Handle agent operation requests (type=request)
if msg.Type == ws.MessageTypeRequest {
go func(reqMsg ws.Message) {
resp := s.handleAgentStreamRequest(ctx, &reqMsg)
if resp != nil {
data, err := json.Marshal(resp)
if err != nil {
s.logger.Error("failed to marshal WS response", zap.Error(err))
return
}
if err := writeMessage(data); err != nil {
s.logger.Debug("failed to write WS response", zap.Error(err))
}
}
}(msg)
continue
}
// Forward MCP response to the backend client
if s.mcpBackendClient != nil && (msg.Type == ws.MessageTypeResponse || msg.Type == ws.MessageTypeError) {
s.mcpBackendClient.HandleResponse(&msg)
@@ -387,27 +230,213 @@ func (s *Server) handleAgentStreamWS(c *gin.Context) {
wg.Wait()
}
// PermissionRespondRequest is a request to respond to a permission request
type PermissionRespondRequest struct {
PendingID string `json:"pending_id" binding:"required"`
OptionID string `json:"option_id,omitempty"`
Cancelled bool `json:"cancelled,omitempty"`
// handleAgentStreamRequest dispatches agent operation requests received on the WebSocket stream.
func (s *Server) handleAgentStreamRequest(ctx context.Context, msg *ws.Message) *ws.Message {
switch msg.Action {
case "agent.initialize":
return s.handleWSInitialize(ctx, msg)
case "agent.session.new":
return s.handleWSNewSession(ctx, msg)
case "agent.session.load":
return s.handleWSLoadSession(ctx, msg)
case "agent.prompt":
return s.handleWSPrompt(ctx, msg)
case "agent.cancel":
return s.handleWSCancel(ctx, msg)
case "agent.permissions.respond":
return s.handleWSPermissionRespond(ctx, msg)
case "agent.stderr":
return s.handleWSStderr(ctx, msg)
default:
resp, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeUnknownAction, fmt.Sprintf("unknown action: %s", msg.Action), nil)
return resp
}
}
// PermissionRespondResponse is the response to a permission respond call
type PermissionRespondResponse struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
func (s *Server) handleWSInitialize(ctx context.Context, msg *ws.Message) *ws.Message {
var req InitializeRequest
if err := msg.ParsePayload(&req); err != nil {
resp, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid request: "+err.Error(), nil)
return resp
}
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
adapter := s.procMgr.GetAdapter()
if adapter == nil {
resp, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "agent not running", nil)
return resp
}
if err := adapter.Initialize(ctx); err != nil {
s.logger.Error("initialize failed", zap.Error(err))
resp, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil)
return resp
}
// Get agent info after successful initialization
var agentInfoResp *AgentInfoResponse
if info := adapter.GetAgentInfo(); info != nil {
agentInfoResp = &AgentInfoResponse{
Name: info.Name,
Version: info.Version,
}
}
resp, _ := ws.NewResponse(msg.ID, msg.Action, InitializeResponse{
Success: true,
AgentInfo: agentInfoResp,
})
return resp
}
func (s *Server) handlePermissionRespond(c *gin.Context) {
func (s *Server) handleWSNewSession(ctx context.Context, msg *ws.Message) *ws.Message {
var req NewSessionRequest
if err := msg.ParsePayload(&req); err != nil {
resp, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid request: "+err.Error(), nil)
return resp
}
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
adapter := s.procMgr.GetAdapter()
if adapter == nil {
resp, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "agent not running", nil)
return resp
}
// If MCP server is enabled, prepend the local kandev MCP server to the list.
mcpServers := req.McpServers
if s.mcpServer != nil {
localKandevMcp := types.McpServer{
Name: "kandev",
Type: "sse",
URL: fmt.Sprintf("http://localhost:%d/sse", s.cfg.Port),
}
filtered := make([]types.McpServer, 0, len(mcpServers)+1)
filtered = append(filtered, localKandevMcp)
for _, srv := range mcpServers {
if srv.Name != "kandev" {
filtered = append(filtered, srv)
}
}
mcpServers = filtered
s.logger.Debug("injected local kandev MCP server",
zap.String("url", localKandevMcp.URL),
zap.Int("total_servers", len(mcpServers)))
}
sessionID, err := adapter.NewSession(ctx, mcpServers)
if err != nil {
s.logger.Error("new session failed", zap.Error(err))
resp, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil)
return resp
}
resp, _ := ws.NewResponse(msg.ID, msg.Action, NewSessionResponse{
Success: true,
SessionID: sessionID,
})
return resp
}
func (s *Server) handleWSLoadSession(ctx context.Context, msg *ws.Message) *ws.Message {
var req LoadSessionRequest
if err := msg.ParsePayload(&req); err != nil {
resp, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid request: "+err.Error(), nil)
return resp
}
if req.SessionID == "" {
resp, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "session_id is required", nil)
return resp
}
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
adapter := s.procMgr.GetAdapter()
if adapter == nil {
resp, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "agent not running", nil)
return resp
}
if err := adapter.LoadSession(ctx, req.SessionID); err != nil {
s.logger.Error("load session failed", zap.Error(err))
resp, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil)
return resp
}
resp, _ := ws.NewResponse(msg.ID, msg.Action, LoadSessionResponse{
Success: true,
SessionID: req.SessionID,
})
return resp
}
func (s *Server) handleWSPrompt(ctx context.Context, msg *ws.Message) *ws.Message {
var req PromptRequest
if err := msg.ParsePayload(&req); err != nil {
resp, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid request: "+err.Error(), nil)
return resp
}
adapter := s.procMgr.GetAdapter()
if adapter == nil {
resp, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "agent not running", nil)
return resp
}
sessionID := s.procMgr.GetSessionID()
if sessionID == "" {
resp, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "no active session - call new_session first", nil)
return resp
}
// Start prompt processing asynchronously.
// Completion is signaled via the WebSocket complete event, not this response.
go func() {
promptCtx, cancel := context.WithTimeout(context.Background(), constants.PromptTimeout)
defer cancel()
if err := adapter.Prompt(promptCtx, req.Text, req.Attachments); err != nil {
s.logger.Error("async prompt failed", zap.Error(err))
}
}()
s.logger.Info("prompt accepted (async)", zap.Int("attachments", len(req.Attachments)))
// Return immediately — completion comes via WebSocket complete event
resp, _ := ws.NewResponse(msg.ID, msg.Action, PromptResponse{Success: true})
return resp
}
func (s *Server) handleWSCancel(ctx context.Context, msg *ws.Message) *ws.Message {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
adapter := s.procMgr.GetAdapter()
if adapter == nil {
resp, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "agent not running", nil)
return resp
}
if err := adapter.Cancel(ctx); err != nil {
s.logger.Error("cancel failed", zap.Error(err))
resp, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil)
return resp
}
s.logger.Info("cancel completed")
resp, _ := ws.NewResponse(msg.ID, msg.Action, CancelResponse{Success: true})
return resp
}
func (s *Server) handleWSPermissionRespond(_ context.Context, msg *ws.Message) *ws.Message {
var req PermissionRespondRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, PermissionRespondResponse{
Success: false,
Error: "invalid request: " + err.Error(),
})
return
if err := msg.ParsePayload(&req); err != nil {
resp, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid request: "+err.Error(), nil)
return resp
}
s.logger.Info("received permission response",
@@ -417,56 +446,16 @@ func (s *Server) handlePermissionRespond(c *gin.Context) {
if err := s.procMgr.RespondToPermission(req.PendingID, req.OptionID, req.Cancelled); err != nil {
s.logger.Error("failed to respond to permission", zap.Error(err))
c.JSON(http.StatusNotFound, PermissionRespondResponse{
Success: false,
Error: err.Error(),
})
return
resp, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, err.Error(), nil)
return resp
}
c.JSON(http.StatusOK, PermissionRespondResponse{
Success: true,
})
}
// AgentStderrResponse contains recent stderr lines from the agent process.
type AgentStderrResponse struct {
Lines []string `json:"lines"`
resp, _ := ws.NewResponse(msg.ID, msg.Action, PermissionRespondResponse{Success: true})
return resp
}
func (s *Server) handleAgentStderr(c *gin.Context) {
func (s *Server) handleWSStderr(_ context.Context, msg *ws.Message) *ws.Message {
lines := s.procMgr.GetRecentStderr()
c.JSON(http.StatusOK, AgentStderrResponse{Lines: lines})
}
// CancelResponse is the response from a cancel request.
type CancelResponse struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// handleAgentCancel interrupts the current agent turn.
func (s *Server) handleAgentCancel(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
defer cancel()
adapter := s.procMgr.GetAdapter()
if adapter == nil {
c.JSON(http.StatusServiceUnavailable, CancelResponse{
Success: false,
Error: "agent not running",
})
return
}
if err := adapter.Cancel(ctx); err != nil {
s.logger.Error("cancel failed", zap.Error(err))
c.JSON(http.StatusInternalServerError, CancelResponse{
Success: false,
Error: err.Error(),
})
return
}
s.logger.Info("cancel completed")
c.JSON(http.StatusOK, CancelResponse{Success: true})
resp, _ := ws.NewResponse(msg.ID, msg.Action, AgentStderrResponse{Lines: lines})
return resp
}
@@ -0,0 +1,492 @@
package api
import (
"context"
"encoding/json"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/kandev/kandev/internal/agentctl/server/config"
"github.com/kandev/kandev/internal/agentctl/server/process"
"github.com/kandev/kandev/internal/common/logger"
ws "github.com/kandev/kandev/pkg/websocket"
)
func newTestLogger() *logger.Logger {
log, _ := logger.NewLogger(logger.LoggingConfig{
Level: "error",
Format: "json",
})
return log
}
// newTestServer creates a minimal Server with a process.Manager (no adapter).
// Adapter is nil so all handlers that need it return "agent not running".
func newTestServer(t *testing.T) *Server {
t.Helper()
log := newTestLogger()
cfg := &config.InstanceConfig{
Port: 0,
WorkDir: "/tmp/test",
}
procMgr := process.NewManager(cfg, log)
return NewServer(cfg, procMgr, nil, nil, log)
}
// dialTestWS connects a WebSocket client to the test server's /api/v1/agent/stream endpoint.
func dialTestWS(t *testing.T, server *httptest.Server) *websocket.Conn {
t.Helper()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/api/v1/agent/stream"
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("failed to dial WebSocket: %v", err)
}
return conn
}
// sendWSRequest sends a ws.Message request and reads the response.
func sendWSRequest(t *testing.T, conn *websocket.Conn, action string, payload interface{}) *ws.Message {
t.Helper()
msg, err := ws.NewRequest("test-req-id", action, payload)
if err != nil {
t.Fatalf("failed to create request: %v", err)
}
data, err := json.Marshal(msg)
if err != nil {
t.Fatalf("failed to marshal request: %v", err)
}
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
t.Fatalf("failed to write request: %v", err)
}
// Read response with timeout
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
_, respData, err := conn.ReadMessage()
if err != nil {
t.Fatalf("failed to read response: %v", err)
}
var resp ws.Message
if err := json.Unmarshal(respData, &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
return &resp
}
// --- Tests for handleAgentStreamRequest dispatcher ---
func TestHandleAgentStreamRequest_UnknownAction(t *testing.T) {
s := newTestServer(t)
ctx := context.Background()
msg, _ := ws.NewRequest("req-1", "unknown.action", nil)
resp := s.handleAgentStreamRequest(ctx, msg)
if resp == nil {
t.Fatal("expected response")
} else if resp.Type != ws.MessageTypeError {
t.Errorf("expected error type, got %q", resp.Type)
}
var errPayload ws.ErrorPayload
if err := resp.ParsePayload(&errPayload); err != nil {
t.Fatalf("failed to parse error payload: %v", err)
}
if errPayload.Code != ws.ErrorCodeUnknownAction {
t.Errorf("expected UNKNOWN_ACTION code, got %q", errPayload.Code)
}
if !strings.Contains(errPayload.Message, "unknown.action") {
t.Errorf("expected error message to contain action name, got %q", errPayload.Message)
}
}
func TestHandleAgentStreamRequest_DispatchesCorrectActions(t *testing.T) {
s := newTestServer(t)
ctx := context.Background()
// All these should dispatch to their handlers (returning "agent not running" since no adapter)
actions := []string{
"agent.initialize",
"agent.session.new",
"agent.session.load",
"agent.prompt",
"agent.cancel",
"agent.permissions.respond",
"agent.stderr",
}
for _, action := range actions {
t.Run(action, func(t *testing.T) {
msg, _ := ws.NewRequest("req-"+action, action, map[string]string{})
resp := s.handleAgentStreamRequest(ctx, msg)
if resp == nil {
t.Fatal("expected response")
} else if resp.ID != "req-"+action {
t.Errorf("expected response ID 'req-%s', got %q", action, resp.ID)
}
// stderr doesn't need an adapter, so it should succeed
if action == "agent.stderr" {
if resp.Type != ws.MessageTypeResponse {
t.Errorf("expected response type for stderr, got %q", resp.Type)
}
}
})
}
}
// --- Tests for individual WS handlers with no adapter (agent not running) ---
func TestHandleWSInitialize_NoAdapter(t *testing.T) {
s := newTestServer(t)
ctx := context.Background()
msg, _ := ws.NewRequest("req-1", "agent.initialize", map[string]string{
"client_name": "test",
"client_version": "1.0.0",
})
resp := s.handleWSInitialize(ctx, msg)
if resp.Type != ws.MessageTypeError {
t.Errorf("expected error type, got %q", resp.Type)
}
var errPayload ws.ErrorPayload
if err := resp.ParsePayload(&errPayload); err != nil {
t.Fatalf("failed to parse error: %v", err)
}
if !strings.Contains(errPayload.Message, "agent not running") {
t.Errorf("expected 'agent not running', got %q", errPayload.Message)
}
}
func TestHandleWSNewSession_NoAdapter(t *testing.T) {
s := newTestServer(t)
ctx := context.Background()
msg, _ := ws.NewRequest("req-1", "agent.session.new", map[string]string{
"cwd": "/workspace",
})
resp := s.handleWSNewSession(ctx, msg)
if resp.Type != ws.MessageTypeError {
t.Errorf("expected error type, got %q", resp.Type)
}
var errPayload ws.ErrorPayload
if err := resp.ParsePayload(&errPayload); err != nil {
t.Fatalf("failed to parse error: %v", err)
}
if !strings.Contains(errPayload.Message, "agent not running") {
t.Errorf("expected 'agent not running', got %q", errPayload.Message)
}
}
func TestHandleWSLoadSession_NoAdapter(t *testing.T) {
s := newTestServer(t)
ctx := context.Background()
msg, _ := ws.NewRequest("req-1", "agent.session.load", map[string]string{
"session_id": "sess-123",
})
resp := s.handleWSLoadSession(ctx, msg)
if resp.Type != ws.MessageTypeError {
t.Errorf("expected error type, got %q", resp.Type)
}
var errPayload ws.ErrorPayload
if err := resp.ParsePayload(&errPayload); err != nil {
t.Fatalf("failed to parse error: %v", err)
}
if !strings.Contains(errPayload.Message, "agent not running") {
t.Errorf("expected 'agent not running', got %q", errPayload.Message)
}
}
func TestHandleWSLoadSession_MissingSessionID(t *testing.T) {
s := newTestServer(t)
ctx := context.Background()
msg, _ := ws.NewRequest("req-1", "agent.session.load", map[string]string{})
resp := s.handleWSLoadSession(ctx, msg)
if resp.Type != ws.MessageTypeError {
t.Errorf("expected error type, got %q", resp.Type)
}
var errPayload ws.ErrorPayload
if err := resp.ParsePayload(&errPayload); err != nil {
t.Fatalf("failed to parse error: %v", err)
}
if errPayload.Code != ws.ErrorCodeBadRequest {
t.Errorf("expected BAD_REQUEST code, got %q", errPayload.Code)
}
if !strings.Contains(errPayload.Message, "session_id is required") {
t.Errorf("expected 'session_id is required', got %q", errPayload.Message)
}
}
func TestHandleWSPrompt_NoAdapter(t *testing.T) {
s := newTestServer(t)
ctx := context.Background()
msg, _ := ws.NewRequest("req-1", "agent.prompt", map[string]string{
"text": "hello",
})
resp := s.handleWSPrompt(ctx, msg)
if resp.Type != ws.MessageTypeError {
t.Errorf("expected error type, got %q", resp.Type)
}
var errPayload ws.ErrorPayload
if err := resp.ParsePayload(&errPayload); err != nil {
t.Fatalf("failed to parse error: %v", err)
}
if !strings.Contains(errPayload.Message, "agent not running") {
t.Errorf("expected 'agent not running', got %q", errPayload.Message)
}
}
func TestHandleWSCancel_NoAdapter(t *testing.T) {
s := newTestServer(t)
ctx := context.Background()
msg, _ := ws.NewRequest("req-1", "agent.cancel", nil)
resp := s.handleWSCancel(ctx, msg)
if resp.Type != ws.MessageTypeError {
t.Errorf("expected error type, got %q", resp.Type)
}
var errPayload ws.ErrorPayload
if err := resp.ParsePayload(&errPayload); err != nil {
t.Fatalf("failed to parse error: %v", err)
}
if !strings.Contains(errPayload.Message, "agent not running") {
t.Errorf("expected 'agent not running', got %q", errPayload.Message)
}
}
func TestHandleWSStderr_Empty(t *testing.T) {
s := newTestServer(t)
ctx := context.Background()
msg, _ := ws.NewRequest("req-1", "agent.stderr", nil)
resp := s.handleWSStderr(ctx, msg)
if resp.Type != ws.MessageTypeResponse {
t.Errorf("expected response type, got %q", resp.Type)
}
var result AgentStderrResponse
if err := resp.ParsePayload(&result); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if len(result.Lines) != 0 {
t.Errorf("expected empty lines, got %d", len(result.Lines))
}
}
func TestHandleWSPermissionRespond_BadPayload(t *testing.T) {
s := newTestServer(t)
ctx := context.Background()
// pending_id is required but since it's not gin binding, ParsePayload will succeed
// with empty values. The actual error comes from RespondToPermission not finding it.
msg, _ := ws.NewRequest("req-1", "agent.permissions.respond", map[string]string{
"pending_id": "nonexistent",
"option_id": "allow",
})
resp := s.handleWSPermissionRespond(ctx, msg)
if resp.Type != ws.MessageTypeError {
t.Errorf("expected error type, got %q", resp.Type)
}
var errPayload ws.ErrorPayload
if err := resp.ParsePayload(&errPayload); err != nil {
t.Fatalf("failed to parse error: %v", err)
}
if errPayload.Code != ws.ErrorCodeNotFound {
t.Errorf("expected NOT_FOUND code, got %q", errPayload.Code)
}
}
func TestHandleWSInitialize_BadPayload(t *testing.T) {
s := newTestServer(t)
ctx := context.Background()
// Send invalid payload (non-JSON raw message)
msg := &ws.Message{
ID: "req-1",
Type: ws.MessageTypeRequest,
Action: "agent.initialize",
Payload: json.RawMessage(`invalid json`),
}
resp := s.handleWSInitialize(ctx, msg)
if resp.Type != ws.MessageTypeError {
t.Errorf("expected error type, got %q", resp.Type)
}
var errPayload ws.ErrorPayload
if err := resp.ParsePayload(&errPayload); err != nil {
t.Fatalf("failed to parse error: %v", err)
}
if errPayload.Code != ws.ErrorCodeBadRequest {
t.Errorf("expected BAD_REQUEST code, got %q", errPayload.Code)
}
}
// --- WebSocket integration tests ---
func TestAgentStreamWS_RequestResponseFlow(t *testing.T) {
s := newTestServer(t)
server := httptest.NewServer(s.router)
defer server.Close()
conn := dialTestWS(t, server)
defer func() { _ = conn.Close() }()
// Send an stderr request (doesn't need adapter)
resp := sendWSRequest(t, conn, "agent.stderr", nil)
if resp.Type != ws.MessageTypeResponse {
t.Errorf("expected response type, got %q", resp.Type)
}
if resp.ID != "test-req-id" {
t.Errorf("expected response ID 'test-req-id', got %q", resp.ID)
}
}
func TestAgentStreamWS_UnknownActionReturnsError(t *testing.T) {
s := newTestServer(t)
server := httptest.NewServer(s.router)
defer server.Close()
conn := dialTestWS(t, server)
defer func() { _ = conn.Close() }()
resp := sendWSRequest(t, conn, "nonexistent.action", nil)
if resp.Type != ws.MessageTypeError {
t.Errorf("expected error type, got %q", resp.Type)
}
var errPayload ws.ErrorPayload
if err := resp.ParsePayload(&errPayload); err != nil {
t.Fatalf("failed to parse error: %v", err)
}
if errPayload.Code != ws.ErrorCodeUnknownAction {
t.Errorf("expected UNKNOWN_ACTION, got %q", errPayload.Code)
}
}
func TestAgentStreamWS_AgentNotRunningError(t *testing.T) {
s := newTestServer(t)
server := httptest.NewServer(s.router)
defer server.Close()
conn := dialTestWS(t, server)
defer func() { _ = conn.Close() }()
resp := sendWSRequest(t, conn, "agent.initialize", map[string]string{
"client_name": "test",
"client_version": "1.0.0",
})
if resp.Type != ws.MessageTypeError {
t.Errorf("expected error type, got %q", resp.Type)
}
var errPayload ws.ErrorPayload
if err := resp.ParsePayload(&errPayload); err != nil {
t.Fatalf("failed to parse error: %v", err)
}
if !strings.Contains(errPayload.Message, "agent not running") {
t.Errorf("expected 'agent not running', got %q", errPayload.Message)
}
}
func TestAgentStreamWS_MultipleRequests(t *testing.T) {
s := newTestServer(t)
server := httptest.NewServer(s.router)
defer server.Close()
conn := dialTestWS(t, server)
defer func() { _ = conn.Close() }()
// Send multiple requests sequentially
actions := []string{"agent.stderr", "agent.stderr", "agent.cancel"}
for i, action := range actions {
msg, _ := ws.NewRequest("req-"+string(rune('a'+i)), action, nil)
data, _ := json.Marshal(msg)
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
t.Fatalf("failed to write request %d: %v", i, err)
}
}
// Read all responses
for i := 0; i < len(actions); i++ {
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
_, respData, err := conn.ReadMessage()
if err != nil {
t.Fatalf("failed to read response %d: %v", i, err)
}
var resp ws.Message
if err := json.Unmarshal(respData, &resp); err != nil {
t.Fatalf("failed to parse response %d: %v", i, err)
}
// All should have some response (either success or error)
if resp.Type != ws.MessageTypeResponse && resp.Type != ws.MessageTypeError {
t.Errorf("response %d: expected response or error type, got %q", i, resp.Type)
}
}
}
func TestAgentStreamWS_ResponsePreservesMessageID(t *testing.T) {
s := newTestServer(t)
server := httptest.NewServer(s.router)
defer server.Close()
conn := dialTestWS(t, server)
defer func() { _ = conn.Close() }()
requestID := "unique-req-id-12345"
msg, _ := ws.NewRequest(requestID, "agent.stderr", nil)
data, _ := json.Marshal(msg)
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
t.Fatalf("failed to write: %v", err)
}
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
_, respData, err := conn.ReadMessage()
if err != nil {
t.Fatalf("failed to read: %v", err)
}
var resp ws.Message
if err := json.Unmarshal(respData, &resp); err != nil {
t.Fatalf("failed to parse: %v", err)
}
if resp.ID != requestID {
t.Errorf("expected response ID %q, got %q", requestID, resp.ID)
}
if resp.Action != "agent.stderr" {
t.Errorf("expected action 'agent.stderr', got %q", resp.Action)
}
}
func TestAgentStreamWS_MalformedMessage(t *testing.T) {
s := newTestServer(t)
server := httptest.NewServer(s.router)
defer server.Close()
conn := dialTestWS(t, server)
defer func() { _ = conn.Close() }()
// Send malformed JSON - should not crash the server
if err := conn.WriteMessage(websocket.TextMessage, []byte(`not json`)); err != nil {
t.Fatalf("failed to write: %v", err)
}
// Server should still be alive - send a valid request
time.Sleep(50 * time.Millisecond)
resp := sendWSRequest(t, conn, "agent.stderr", nil)
if resp.Type != ws.MessageTypeResponse {
t.Errorf("expected response after malformed message, got %q", resp.Type)
}
}
@@ -74,15 +74,9 @@ func (s *Server) setupRoutes() {
api.POST("/start", s.handleStart)
api.POST("/stop", s.handleStop)
// Agent session/prompt methods (works with all adapters: ACP, Codex, ClaudeCode, OpenCode)
api.POST("/agent/initialize", s.handleAgentInitialize)
api.POST("/agent/session/new", s.handleAgentNewSession)
api.POST("/agent/session/load", s.handleAgentLoadSession)
api.POST("/agent/prompt", s.handleAgentPrompt)
api.POST("/agent/cancel", s.handleAgentCancel)
// Agent stream: bidirectional WebSocket for agent events, MCP, and agent operations
// (initialize, session/new, session/load, prompt, cancel, stderr, permissions/respond)
api.GET("/agent/stream", s.handleAgentStreamWS)
api.GET("/agent/stderr", s.handleAgentStderr)
api.POST("/agent/permissions/respond", s.handlePermissionRespond)
// Unified workspace stream (git status, files, shell)
api.GET("/workspace/stream", s.handleWorkspaceStreamWS)
@@ -779,15 +779,16 @@ func TestInteractiveRunner_RegisterScriptShell(t *testing.T) {
}
if scriptShell == nil {
t.Fatal("script shell not found in list")
}
if scriptShell.Label != "npm start" {
t.Errorf("script shell Label = %q, want 'npm start'", scriptShell.Label)
}
if scriptShell.InitialCommand != "npm run start" {
t.Errorf("script shell InitialCommand = %q, want 'npm run start'", scriptShell.InitialCommand)
}
if !scriptShell.Closable {
t.Error("script shell should be closable")
} else {
if scriptShell.Label != "npm start" {
t.Errorf("script shell Label = %q, want 'npm start'", scriptShell.Label)
}
if scriptShell.InitialCommand != "npm run start" {
t.Errorf("script shell InitialCommand = %q, want 'npm run start'", scriptShell.InitialCommand)
}
if !scriptShell.Closable {
t.Error("script shell should be closable")
}
}
if scriptShell.ProcessID != "" {
t.Errorf("script shell should have empty ProcessID before WebSocket connect, got %q", scriptShell.ProcessID)
@@ -79,8 +79,7 @@ func TestDequeue(t *testing.T) {
if dequeued == nil {
t.Fatal("Dequeue returned nil")
}
if dequeued.TaskID != task.ID {
} else if dequeued.TaskID != task.ID {
t.Errorf("expected TaskID = %s, got %s", task.ID, dequeued.TaskID)
}
if q.Len() != 0 {
@@ -138,8 +138,7 @@ func TestNewWatcher(t *testing.T) {
w := NewWatcher(eventBus, handlers, log)
if w == nil {
t.Fatal("NewWatcher returned nil")
}
if w.running {
} else if w.running {
t.Error("watcher should not be running initially")
}
}
@@ -18,8 +18,7 @@ func TestNewRecreator(t *testing.T) {
recreator := NewRecreator(mgr)
if recreator == nil {
t.Fatal("expected non-nil recreator")
}
if recreator.manager != mgr {
} else if recreator.manager != mgr {
t.Error("recreator manager not set correctly")
}
}
+4 -7
View File
@@ -43,13 +43,10 @@ func TestCLIMessage_GetResultData(t *testing.T) {
if got != nil {
t.Errorf("GetResultData() = %v, want nil", got)
}
} else {
if got == nil {
t.Fatalf("GetResultData() = nil, want non-nil")
}
if got.Text != tt.wantText {
t.Errorf("GetResultData().Text = %q, want %q", got.Text, tt.wantText)
}
} else if got == nil {
t.Fatalf("GetResultData() = nil, want non-nil")
} else if got.Text != tt.wantText {
t.Errorf("GetResultData().Text = %q, want %q", got.Text, tt.wantText)
}
})
}