96c68a0af9
* fix(backend): return mcp message_task once dispatched, not after target turn ends The MCP message_task_kandev tool was synchronously waiting for the target session's entire turn to complete before returning, even though its description advertises a fast dispatch status. For idle target sessions, the call would hang for the full duration of the agent's response (with no upper bound — waitForPromptDone has no timeout). Threads a dispatchOnly flag from the MCP handler down through PromptTask → executor.Prompt → PromptAgent → SendPrompt. When set, SendPrompt returns once agentctl.Prompt accepts the prompt instead of blocking on the agent's complete event. Stale completion signals from prior dispatch-only prompts are drained at the start of SendPrompt so they don't poison the next waiting call. * fix: address PR review feedback - Replace time.Sleep with channel-based wait for WS connection in new SendPrompt tests, per CLAUDE.md (channel sync over time.Sleep for non-subprocess tests). - Drop redundant Warn that logged the remapped sentinel after the original error was already logged by the preceding Warn. --------- Co-authored-by: Kandev Agent <agent@kandev.dev>
698 lines
20 KiB
Go
698 lines
20 KiB
Go
package lifecycle
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/coder/acp-go-sdk"
|
|
"github.com/gorilla/websocket"
|
|
"github.com/kandev/kandev/internal/agent/agents"
|
|
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 {
|
|
continue
|
|
}
|
|
|
|
m.mu.Lock()
|
|
m.actionLog = append(m.actionLog, msg.Action)
|
|
m.mu.Unlock()
|
|
|
|
resp := m.buildResponse(msg)
|
|
if resp == nil {
|
|
continue
|
|
}
|
|
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
|
|
}
|
|
|
|
// buildResponse returns the handler or default response for a request message.
|
|
func (m *mockAgentServer) buildResponse(msg ws.Message) *ws.Message {
|
|
if m.handler != nil {
|
|
return m.handler(msg)
|
|
}
|
|
return m.defaultHandler(msg)
|
|
}
|
|
|
|
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 := &testAgent{
|
|
id: "test-agent",
|
|
enabled: true,
|
|
runtimeConfig: &agents.RuntimeConfig{
|
|
Cmd: agents.NewCommand("test-agent"),
|
|
Protocol: agent.ProtocolACP,
|
|
SessionConfig: agents.SessionConfig{
|
|
NativeSessionResume: false,
|
|
},
|
|
ResourceLimits: agents.ResourceLimits{MemoryMB: 512, CPUCores: 0.5, Timeout: time.Hour},
|
|
},
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
|
|
err := sm.InitializeAndPrompt(ctx, execution, agentConfig, "", nil, 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 := &testAgent{
|
|
id: "test-agent",
|
|
enabled: true,
|
|
runtimeConfig: &agents.RuntimeConfig{
|
|
Cmd: agents.NewCommand("test-agent"),
|
|
Protocol: agent.ProtocolACP,
|
|
ResourceLimits: agents.ResourceLimits{MemoryMB: 512, CPUCores: 0.5, Timeout: time.Hour},
|
|
},
|
|
}
|
|
|
|
// 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, 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 := &testAgent{
|
|
id: "test-agent",
|
|
enabled: true,
|
|
runtimeConfig: &agents.RuntimeConfig{
|
|
Cmd: agents.NewCommand("test-agent"),
|
|
Protocol: agent.ProtocolACP,
|
|
SessionConfig: agents.SessionConfig{
|
|
NativeSessionResume: false,
|
|
},
|
|
ResourceLimits: agents.ResourceLimits{MemoryMB: 512, CPUCores: 0.5, Timeout: time.Hour},
|
|
},
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
|
|
err := sm.InitializeAndPrompt(ctx, execution, agentConfig, "Build a feature", nil, 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 := &testAgent{
|
|
id: "test-agent",
|
|
enabled: true,
|
|
runtimeConfig: &agents.RuntimeConfig{
|
|
Cmd: agents.NewCommand("test-agent"),
|
|
Protocol: agent.ProtocolACP,
|
|
ResourceLimits: agents.ResourceLimits{MemoryMB: 512, CPUCores: 0.5, Timeout: time.Hour},
|
|
},
|
|
}
|
|
|
|
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, 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 := &testAgent{
|
|
id: "test-agent",
|
|
enabled: true,
|
|
runtimeConfig: &agents.RuntimeConfig{
|
|
Cmd: agents.NewCommand("test-agent"),
|
|
Protocol: agent.ProtocolACP,
|
|
SessionConfig: agents.SessionConfig{
|
|
NativeSessionResume: false,
|
|
},
|
|
ResourceLimits: agents.ResourceLimits{MemoryMB: 512, CPUCores: 0.5, Timeout: time.Hour},
|
|
},
|
|
}
|
|
|
|
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 TestIsMethodNotFoundErr(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "matching JSON-RPC method not found code",
|
|
err: fmt.Errorf("wrapped: %w", &acp.RequestError{Code: -32601, Message: "Method not found"}),
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "non-matching JSON-RPC code",
|
|
err: fmt.Errorf("wrapped: %w", &acp.RequestError{Code: -32600, Message: "Invalid Request"}),
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "non-RequestError error",
|
|
err: fmt.Errorf("some other error"),
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "nil error",
|
|
err: nil,
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "direct RequestError with method not found code",
|
|
err: &acp.RequestError{Code: -32601, Message: "Method not found"},
|
|
expected: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := isMethodNotFoundErr(tt.err)
|
|
if got != tt.expected {
|
|
t.Errorf("isMethodNotFoundErr(%v) = %v, want %v", tt.err, got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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 := &testAgent{
|
|
id: "test-agent",
|
|
enabled: true,
|
|
runtimeConfig: &agents.RuntimeConfig{
|
|
Cmd: agents.NewCommand("test-agent"),
|
|
Protocol: agent.ProtocolACP,
|
|
SessionConfig: agents.SessionConfig{
|
|
NativeSessionResume: true,
|
|
},
|
|
ResourceLimits: agents.ResourceLimits{MemoryMB: 512, CPUCores: 0.5, Timeout: time.Hour},
|
|
},
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
// waitForWSConnected blocks until the mock's agent stream WebSocket has accepted
|
|
// a connection. Avoids racing the goroutine that StreamUpdates spawns to maintain
|
|
// the WS, per CLAUDE.md preference for channel-based sync over time.Sleep.
|
|
func waitForWSConnected(t *testing.T, mock *mockAgentServer) {
|
|
t.Helper()
|
|
select {
|
|
case <-mock.wsConnected:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("agent stream did not connect within 2s")
|
|
}
|
|
}
|
|
|
|
// TestSendPrompt_DispatchOnlyReturnsWithoutWaiting verifies that dispatch-only
|
|
// mode returns immediately after agentctl.Prompt succeeds, without blocking on
|
|
// the agent's complete event. This is what message_task_kandev relies on so the
|
|
// MCP tool call doesn't hang for the duration of the target's turn.
|
|
func TestSendPrompt_DispatchOnlyReturnsWithoutWaiting(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()
|
|
|
|
// Stream must be connected before SendPrompt — agentctl.Prompt sends through it.
|
|
ctx := context.Background()
|
|
if err := client.StreamUpdates(ctx, func(event agentctl.AgentEvent) {}, nil, nil); err != nil {
|
|
t.Fatalf("failed to connect stream: %v", err)
|
|
}
|
|
waitForWSConnected(t, mock)
|
|
|
|
execution := &AgentExecution{
|
|
ID: "test-exec",
|
|
TaskID: "test-task",
|
|
SessionID: "test-session",
|
|
WorkspacePath: "/workspace",
|
|
agentctl: client,
|
|
// Buffered chan but nothing will ever publish to it during this test —
|
|
// dispatch-only must not wait on it.
|
|
promptDoneCh: make(chan PromptCompletionSignal, 1),
|
|
}
|
|
|
|
done := make(chan struct {
|
|
result *PromptResult
|
|
err error
|
|
}, 1)
|
|
go func() {
|
|
result, err := sm.SendPrompt(ctx, execution, "hello", false, nil, true)
|
|
done <- struct {
|
|
result *PromptResult
|
|
err error
|
|
}{result, err}
|
|
}()
|
|
|
|
select {
|
|
case got := <-done:
|
|
if got.err != nil {
|
|
t.Fatalf("SendPrompt(dispatchOnly) returned error: %v", got.err)
|
|
}
|
|
if got.result == nil || got.result.StopReason != PromptStopReasonDispatched {
|
|
t.Fatalf("expected StopReason=%q, got %+v", PromptStopReasonDispatched, got.result)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("SendPrompt(dispatchOnly=true) blocked waiting for completion signal")
|
|
}
|
|
}
|
|
|
|
// TestSendPrompt_DrainsStaleSignalFromPriorDispatchOnly verifies that a leftover
|
|
// completion signal from a previous dispatch-only prompt does not cause the next
|
|
// (waiting) SendPrompt to return immediately with a stale result.
|
|
func TestSendPrompt_DrainsStaleSignalFromPriorDispatchOnly(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()
|
|
if err := client.StreamUpdates(ctx, func(event agentctl.AgentEvent) {}, nil, nil); err != nil {
|
|
t.Fatalf("failed to connect stream: %v", err)
|
|
}
|
|
waitForWSConnected(t, mock)
|
|
|
|
execution := &AgentExecution{
|
|
ID: "test-exec",
|
|
TaskID: "test-task",
|
|
SessionID: "test-session",
|
|
WorkspacePath: "/workspace",
|
|
agentctl: client,
|
|
promptDoneCh: make(chan PromptCompletionSignal, 1),
|
|
}
|
|
|
|
// Pre-load a stale signal as if a prior dispatch-only prompt's completion
|
|
// landed in the buffer after SendPrompt returned.
|
|
execution.promptDoneCh <- PromptCompletionSignal{StopReason: "stale"}
|
|
|
|
// Run a normal SendPrompt (waits for completion). It must drain the stale
|
|
// signal and block until a real signal arrives or ctx times out.
|
|
timeoutCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
|
|
defer cancel()
|
|
|
|
_, err := sm.SendPrompt(timeoutCtx, execution, "hello", false, nil, false)
|
|
if err == nil {
|
|
t.Fatal("expected SendPrompt to block until ctx timeout, but it returned immediately (stale signal not drained)")
|
|
}
|
|
if !strings.Contains(err.Error(), context.DeadlineExceeded.Error()) {
|
|
t.Fatalf("expected deadline exceeded error, got: %v", err)
|
|
}
|
|
}
|