yao/openapi/tests/agent/robot_interact_test.go
Max 3fee0e3fc3 Enhance execution listing and interaction with pagination and streaming support
- Refactor execution listing to support pagination with `Page` and `PageSize` options, replacing previous `Limit` and `Offset` parameters.
- Introduce `ExcludeStatuses` in execution queries to filter out specific execution statuses.
- Implement streaming interaction methods in the manager, allowing real-time responses from the host agent during interactions.
- Update API endpoints to accommodate new query parameters and enhance interaction capabilities with streaming support.
- Modify tests to ensure coverage for new pagination and streaming functionalities.
2026-02-27 16:38:30 +08:00

351 lines
12 KiB
Go

package openapi_test
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
robotapi "github.com/yaoapp/yao/agent/robot/api"
"github.com/yaoapp/yao/openapi"
"github.com/yaoapp/yao/openapi/tests/testutils"
)
func TestInteractRobot(t *testing.T) {
if testing.Short() {
t.Skip("Skipping interact tests in short mode (requires AI/manager)")
}
serverURL := testutils.Prepare(t)
defer testutils.Clean()
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
client := testutils.RegisterTestClient(t, "Interact Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
err := robotapi.Start()
require.NoError(t, err, "Manager must start for Interact tests")
defer robotapi.Stop()
robotID := fmt.Sprintf("test_interact_%d", time.Now().UnixNano())
createRobotForInteract(t, serverURL, baseURL, tokenInfo.AccessToken, robotID, "Interact Test Robot")
defer deleteRobotForInteract(t, serverURL, baseURL, tokenInfo.AccessToken, robotID)
t.Run("InteractSync_FullFlow", func(t *testing.T) {
interactData := map[string]interface{}{
"message": "Please write a short greeting email for our Monday standup.",
}
body, _ := json.Marshal(interactData)
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/interact", bytes.NewBuffer(body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.NotNil(t, resp)
defer resp.Body.Close()
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
require.NoError(t, err)
t.Logf("Sync interact: status_code=%d, response=%+v", resp.StatusCode, response)
if resp.StatusCode == http.StatusOK {
data, _ := response["data"].(map[string]interface{})
if data != nil {
assert.NotEmpty(t, data["execution_id"], "should have execution_id")
assert.NotEmpty(t, data["reply"], "Host Agent should provide a reply")
assert.NotEmpty(t, data["status"], "should have a status")
validStatuses := []string{"confirmed", "waiting_for_more", "adjusted", "acknowledged"}
status, _ := data["status"].(string)
assert.Contains(t, validStatuses, status,
"status should reflect Host Agent action outcome, got: %s", status)
t.Logf("Sync result: exec_id=%v, status=%v, reply=%v, wait_for_more=%v",
data["execution_id"], data["status"], data["reply"], data["wait_for_more"])
}
} else {
t.Logf("Sync interact returned %d: %v (may indicate Manager routing issue)", resp.StatusCode, response)
}
})
t.Run("InteractSSE_FullFlow", func(t *testing.T) {
interactData := map[string]interface{}{
"message": "Draft a brief thank-you note for the design team.",
"stream": true,
}
body, _ := json.Marshal(interactData)
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/interact", bytes.NewBuffer(body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.NotNil(t, resp)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
var errResp map[string]interface{}
json.NewDecoder(resp.Body).Decode(&errResp)
t.Fatalf("SSE interact failed: status=%d, error=%v", resp.StatusCode, errResp)
}
contentType := resp.Header.Get("Content-Type")
assert.Contains(t, contentType, "text/event-stream")
messages := parseCUISSEMessages(t, resp)
require.NotEmpty(t, messages, "should receive CUI message events")
var textMessages []map[string]interface{}
var interactDone map[string]interface{}
for _, msg := range messages {
msgType, _ := msg["type"].(string)
if msgType == "text" {
textMessages = append(textMessages, msg)
}
if msgType == "event" {
props, _ := msg["props"].(map[string]interface{})
if props != nil {
if evt, _ := props["event"].(string); evt == "interact_done" {
interactDone = props
}
}
}
}
t.Logf("SSE: %d total messages, %d text messages, interact_done=%v",
len(messages), len(textMessages), interactDone != nil)
assert.NotNil(t, interactDone, "should have an interact_done event")
if interactDone != nil {
doneData, _ := interactDone["data"].(map[string]interface{})
if doneData != nil {
if status, ok := doneData["status"].(string); ok {
validStatuses := []string{"confirmed", "waiting_for_more", "adjusted", "acknowledged", "error"}
assert.Contains(t, validStatuses, status,
"final status should be a valid outcome")
}
if execID, ok := doneData["execution_id"].(string); ok {
assert.NotEmpty(t, execID, "done event should carry execution_id")
}
t.Logf("SSE done data: %+v", doneData)
}
}
})
t.Run("InteractSSE_MultiTurn", func(t *testing.T) {
// Turn 1: vague request
body1, _ := json.Marshal(map[string]interface{}{
"message": "Do something with emails.",
"stream": true,
})
req1, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/interact", bytes.NewBuffer(body1))
req1.Header.Set("Content-Type", "application/json")
req1.Header.Set("Accept", "text/event-stream")
req1.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp1, err := http.DefaultClient.Do(req1)
require.NoError(t, err)
defer resp1.Body.Close()
if resp1.StatusCode != http.StatusOK {
var errResp map[string]interface{}
json.NewDecoder(resp1.Body).Decode(&errResp)
t.Fatalf("Turn 1 SSE failed: status=%d, error=%v", resp1.StatusCode, errResp)
}
turn1Messages := parseCUISSEMessages(t, resp1)
require.NotEmpty(t, turn1Messages)
turn1Done := findInteractDone(turn1Messages)
require.NotNil(t, turn1Done, "Turn 1 should have interact_done event")
doneData1, _ := turn1Done["data"].(map[string]interface{})
require.NotNil(t, doneData1)
execID, _ := doneData1["execution_id"].(string)
t.Logf("Turn 1: exec_id=%s, status=%v, wait_for_more=%v",
execID, doneData1["status"], doneData1["wait_for_more"])
assert.NotEmpty(t, execID, "Turn 1 should create an execution")
// Turn 2: clarify with same execution_id
body2, _ := json.Marshal(map[string]interface{}{
"execution_id": execID,
"message": "Please write a congratulations email for the team hitting Q4 targets. Yes, proceed.",
"stream": true,
})
req2, _ := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/interact", bytes.NewBuffer(body2))
req2.Header.Set("Content-Type", "application/json")
req2.Header.Set("Accept", "text/event-stream")
req2.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp2, err := http.DefaultClient.Do(req2)
require.NoError(t, err)
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusOK {
var errResp map[string]interface{}
json.NewDecoder(resp2.Body).Decode(&errResp)
t.Fatalf("Turn 2 SSE failed: status=%d, error=%v", resp2.StatusCode, errResp)
}
turn2Messages := parseCUISSEMessages(t, resp2)
require.NotEmpty(t, turn2Messages)
turn2Done := findInteractDone(turn2Messages)
require.NotNil(t, turn2Done, "Turn 2 should have interact_done event")
doneData2, _ := turn2Done["data"].(map[string]interface{})
require.NotNil(t, doneData2)
execID2, _ := doneData2["execution_id"].(string)
t.Logf("Turn 2: exec_id=%s, status=%v, wait_for_more=%v",
execID2, doneData2["status"], doneData2["wait_for_more"])
assert.Equal(t, execID, execID2, "Turn 2 should reference same execution")
})
t.Run("InteractMissingMessage", func(t *testing.T) {
body, _ := json.Marshal(map[string]interface{}{})
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/interact", bytes.NewBuffer(body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("InteractNotFound", func(t *testing.T) {
body, _ := json.Marshal(map[string]interface{}{"message": "test"})
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/non_existent_robot/interact", bytes.NewBuffer(body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
})
t.Run("InteractUnauthorized", func(t *testing.T) {
body, _ := json.Marshal(map[string]interface{}{"message": "test"})
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/interact", bytes.NewBuffer(body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
}
// parseCUISSEMessages parses the SSE stream using CUI Message protocol format:
// each line is "data: {json}\n\n" where the JSON is a message.Message object.
func parseCUISSEMessages(t *testing.T, resp *http.Response) []map[string]interface{} {
scanner := bufio.NewScanner(resp.Body)
var messages []map[string]interface{}
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "data: ") {
data := strings.TrimPrefix(line, "data: ")
var parsed map[string]interface{}
if err := json.Unmarshal([]byte(data), &parsed); err == nil {
messages = append(messages, parsed)
}
}
}
return messages
}
// findInteractDone finds the interact_done event from CUI messages.
func findInteractDone(messages []map[string]interface{}) map[string]interface{} {
for _, msg := range messages {
msgType, _ := msg["type"].(string)
if msgType == "event" {
props, _ := msg["props"].(map[string]interface{})
if props != nil {
if evt, _ := props["event"].(string); evt == "interact_done" {
return props
}
}
}
}
return nil
}
func createRobotForInteract(t *testing.T, serverURL, baseURL, token, robotID, displayName string) {
createData := map[string]interface{}{
"member_id": robotID,
"team_id": "test_team_001",
"display_name": displayName,
"robot_config": map[string]interface{}{
"identity": map[string]interface{}{
"role": "Email Assistant",
"duties": []string{"Write and manage emails"},
},
"quota": map[string]interface{}{
"max": 5,
"queue": 20,
},
"triggers": map[string]interface{}{
"intervene": map[string]interface{}{"enabled": true},
},
"resources": map[string]interface{}{
"phases": map[string]interface{}{
"host": "robot.host",
},
},
},
}
body, _ := json.Marshal(createData)
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
var errBody map[string]interface{}
json.NewDecoder(resp.Body).Decode(&errBody)
t.Logf("Create robot response: %d %v", resp.StatusCode, errBody)
}
}
func deleteRobotForInteract(t *testing.T, serverURL, baseURL, token, robotID string) {
req, _ := http.NewRequest("DELETE", serverURL+baseURL+"/agent/robots/"+robotID, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, _ := http.DefaultClient.Do(req)
if resp != nil {
resp.Body.Close()
}
}