- 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.
351 lines
12 KiB
Go
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()
|
|
}
|
|
}
|