- 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.
361 lines
11 KiB
Go
361 lines
11 KiB
Go
package api_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/yaoapp/gou/model"
|
|
"github.com/yaoapp/xun/capsule"
|
|
"github.com/yaoapp/yao/agent/robot/api"
|
|
"github.com/yaoapp/yao/agent/robot/executor/standard"
|
|
"github.com/yaoapp/yao/agent/robot/types"
|
|
"github.com/yaoapp/yao/agent/testutils"
|
|
)
|
|
|
|
// TestE2EInteractNewAssignment tests the full Interact flow for a new task assignment.
|
|
// With the conversational Host Agent, the first turn may return natural language
|
|
// (waiting_for_more) or an action decision depending on request clarity.
|
|
func TestE2EInteractNewAssignment(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping E2E test - requires real LLM calls")
|
|
}
|
|
|
|
testutils.Prepare(t)
|
|
defer testutils.Clean(t)
|
|
|
|
cleanupInteractRobots(t)
|
|
cleanupInteractExecutions(t)
|
|
defer cleanupInteractRobots(t)
|
|
defer cleanupInteractExecutions(t)
|
|
|
|
t.Run("assign_via_interact_creates_execution_and_gets_host_reply", func(t *testing.T) {
|
|
memberID := "robot_e2e_interact_assign"
|
|
setupInteractRobot(t, memberID, "team_e2e_interact")
|
|
|
|
err := api.Start()
|
|
require.NoError(t, err)
|
|
defer api.Stop()
|
|
|
|
ctx := types.NewContext(context.Background(), testAuth())
|
|
|
|
robot, err := api.GetRobot(ctx, memberID)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, robot)
|
|
|
|
result, err := api.Interact(ctx, memberID, &api.InteractRequest{
|
|
Source: types.InteractSourceUI,
|
|
Message: "Please write a short greeting email for our team meeting tomorrow morning.",
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
|
|
t.Logf("Interact result: status=%s, message=%s, reply=%s, exec_id=%s, wait_for_more=%v",
|
|
result.Status, result.Message, result.Reply, result.ExecutionID, result.WaitForMore)
|
|
|
|
assert.NotEmpty(t, result.ExecutionID, "should create an execution")
|
|
assert.NotEmpty(t, result.ChatID, "should have a chat session")
|
|
assert.NotEmpty(t, result.Reply, "Host Agent should provide a reply")
|
|
|
|
validStatuses := []string{"confirmed", "waiting_for_more", "adjusted", "acknowledged"}
|
|
assert.Contains(t, validStatuses, result.Status,
|
|
"status should be one of the valid Host Agent action outcomes")
|
|
|
|
if result.Status == "confirmed" {
|
|
time.Sleep(2 * time.Second)
|
|
executions, err := api.ListExecutions(ctx, memberID, &api.ExecutionQuery{Page: 1, PageSize: 5})
|
|
require.NoError(t, err)
|
|
assert.Greater(t, len(executions.Data), 0, "confirmed execution should exist in store")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestE2EInteractStream tests the streaming version end-to-end.
|
|
func TestE2EInteractStream(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping E2E test - requires real LLM calls")
|
|
}
|
|
|
|
testutils.Prepare(t)
|
|
defer testutils.Clean(t)
|
|
|
|
cleanupInteractRobots(t)
|
|
cleanupInteractExecutions(t)
|
|
defer cleanupInteractRobots(t)
|
|
defer cleanupInteractExecutions(t)
|
|
|
|
t.Run("stream_assign_returns_chunks_and_valid_result", func(t *testing.T) {
|
|
memberID := "robot_e2e_interact_stream"
|
|
setupInteractRobot(t, memberID, "team_e2e_interact")
|
|
|
|
err := api.Start()
|
|
require.NoError(t, err)
|
|
defer api.Stop()
|
|
|
|
ctx := types.NewContext(context.Background(), testAuth())
|
|
|
|
var mu sync.Mutex
|
|
var chunks []*standard.StreamChunk
|
|
|
|
streamFn := func(chunk *standard.StreamChunk) int {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
chunks = append(chunks, chunk)
|
|
return 0
|
|
}
|
|
|
|
result, err := api.InteractStream(ctx, memberID, &api.InteractRequest{
|
|
Source: types.InteractSourceUI,
|
|
Message: "Help me draft a brief status update email about completing the Q4 report.",
|
|
}, streamFn)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
|
|
mu.Lock()
|
|
chunkCount := len(chunks)
|
|
var textChunks []string
|
|
for _, c := range chunks {
|
|
if c.Type == "text" && c.Delta {
|
|
textChunks = append(textChunks, c.Content)
|
|
}
|
|
}
|
|
mu.Unlock()
|
|
|
|
combined := strings.Join(textChunks, "")
|
|
|
|
t.Logf("Stream received %d total chunks, %d text chunks, combined length: %d",
|
|
chunkCount, len(textChunks), len(combined))
|
|
t.Logf("Result: status=%s, exec_id=%s, reply_len=%d, wait_for_more=%v",
|
|
result.Status, result.ExecutionID, len(result.Reply), result.WaitForMore)
|
|
|
|
assert.Greater(t, len(textChunks), 0, "should receive streaming text chunks from Host Agent")
|
|
assert.NotEmpty(t, combined, "combined text should not be empty")
|
|
assert.NotEmpty(t, result.ExecutionID, "should create an execution")
|
|
assert.NotEmpty(t, result.Reply, "final result should contain reply")
|
|
|
|
validStatuses := []string{"confirmed", "waiting_for_more", "adjusted"}
|
|
assert.Contains(t, validStatuses, result.Status)
|
|
})
|
|
}
|
|
|
|
// TestE2EInteractMultiTurn tests a multi-turn conversation:
|
|
// Turn 1: Send vague message -> Host Agent replies conversationally (waiting_for_more)
|
|
// Turn 2: Send clear confirmation -> Host Agent returns action JSON (confirmed or other action)
|
|
func TestE2EInteractMultiTurn(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping E2E test - requires real LLM calls")
|
|
}
|
|
|
|
testutils.Prepare(t)
|
|
defer testutils.Clean(t)
|
|
|
|
cleanupInteractRobots(t)
|
|
cleanupInteractExecutions(t)
|
|
defer cleanupInteractRobots(t)
|
|
defer cleanupInteractExecutions(t)
|
|
|
|
t.Run("multi_turn_assign_conversation", func(t *testing.T) {
|
|
memberID := "robot_e2e_interact_multiturn"
|
|
setupInteractRobot(t, memberID, "team_e2e_interact")
|
|
|
|
err := api.Start()
|
|
require.NoError(t, err)
|
|
defer api.Stop()
|
|
|
|
ctx := types.NewContext(context.Background(), testAuth())
|
|
|
|
// Turn 1: Send vague message — expect conversational reply
|
|
result1, err := api.Interact(ctx, memberID, &api.InteractRequest{
|
|
Source: types.InteractSourceUI,
|
|
Message: "Do something with emails.",
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result1)
|
|
|
|
t.Logf("Turn 1: status=%s, reply=%s, exec_id=%s, wait_for_more=%v",
|
|
result1.Status, result1.Reply, result1.ExecutionID, result1.WaitForMore)
|
|
|
|
assert.NotEmpty(t, result1.ExecutionID)
|
|
assert.NotEmpty(t, result1.Reply)
|
|
|
|
// Turn 2: Clarify/confirm with the same execution_id
|
|
result2, err := api.Interact(ctx, memberID, &api.InteractRequest{
|
|
ExecutionID: result1.ExecutionID,
|
|
Source: types.InteractSourceUI,
|
|
Message: "Yes, please write a brief thank-you email to the design team for their Q4 work. Go ahead and confirm.",
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result2)
|
|
|
|
t.Logf("Turn 2: status=%s, reply=%s, exec_id=%s, wait_for_more=%v",
|
|
result2.Status, result2.Reply, result2.ExecutionID, result2.WaitForMore)
|
|
|
|
assert.NotEmpty(t, result2.Reply)
|
|
assert.Equal(t, result1.ExecutionID, result2.ExecutionID, "should be same execution")
|
|
|
|
validStatuses := []string{"confirmed", "waiting_for_more", "adjusted", "acknowledged"}
|
|
assert.Contains(t, validStatuses, result2.Status,
|
|
"second turn should produce a valid outcome")
|
|
})
|
|
}
|
|
|
|
// TestE2EInteractStreamMultiTurn tests multi-turn with streaming.
|
|
func TestE2EInteractStreamMultiTurn(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping E2E test - requires real LLM calls")
|
|
}
|
|
|
|
testutils.Prepare(t)
|
|
defer testutils.Clean(t)
|
|
|
|
cleanupInteractRobots(t)
|
|
cleanupInteractExecutions(t)
|
|
defer cleanupInteractRobots(t)
|
|
defer cleanupInteractExecutions(t)
|
|
|
|
t.Run("stream_multi_turn", func(t *testing.T) {
|
|
memberID := "robot_e2e_interact_stream_mt"
|
|
setupInteractRobot(t, memberID, "team_e2e_interact")
|
|
|
|
err := api.Start()
|
|
require.NoError(t, err)
|
|
defer api.Stop()
|
|
|
|
ctx := types.NewContext(context.Background(), testAuth())
|
|
|
|
// Turn 1
|
|
var mu1 sync.Mutex
|
|
var chunks1 []*standard.StreamChunk
|
|
result1, err := api.InteractStream(ctx, memberID, &api.InteractRequest{
|
|
Source: types.InteractSourceUI,
|
|
Message: "I need help with something.",
|
|
}, func(chunk *standard.StreamChunk) int {
|
|
mu1.Lock()
|
|
chunks1 = append(chunks1, chunk)
|
|
mu1.Unlock()
|
|
return 0
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result1)
|
|
|
|
mu1.Lock()
|
|
t.Logf("Turn 1 stream: %d chunks, status=%s, reply=%s, wait_for_more=%v",
|
|
len(chunks1), result1.Status, result1.Reply, result1.WaitForMore)
|
|
mu1.Unlock()
|
|
|
|
assert.NotEmpty(t, result1.ExecutionID)
|
|
assert.NotEmpty(t, result1.Reply)
|
|
|
|
// Turn 2: Clarify with same execution_id
|
|
var mu2 sync.Mutex
|
|
var chunks2 []*standard.StreamChunk
|
|
result2, err := api.InteractStream(ctx, memberID, &api.InteractRequest{
|
|
ExecutionID: result1.ExecutionID,
|
|
Source: types.InteractSourceUI,
|
|
Message: "Please compose a short farewell message for a colleague leaving the team. Yes, go ahead.",
|
|
}, func(chunk *standard.StreamChunk) int {
|
|
mu2.Lock()
|
|
chunks2 = append(chunks2, chunk)
|
|
mu2.Unlock()
|
|
return 0
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result2)
|
|
|
|
mu2.Lock()
|
|
t.Logf("Turn 2 stream: %d chunks, status=%s, reply=%s, wait_for_more=%v",
|
|
len(chunks2), result2.Status, result2.Reply, result2.WaitForMore)
|
|
mu2.Unlock()
|
|
|
|
assert.NotEmpty(t, result2.Reply)
|
|
assert.Equal(t, result1.ExecutionID, result2.ExecutionID)
|
|
})
|
|
}
|
|
|
|
// ==================== Helper Functions ====================
|
|
|
|
func setupInteractRobot(t *testing.T, memberID, teamID string) {
|
|
m := model.Select("__yao.member")
|
|
tableName := m.MetaData.Table.Name
|
|
qb := capsule.Query()
|
|
|
|
robotConfig := map[string]interface{}{
|
|
"identity": map[string]interface{}{
|
|
"role": "Email Assistant",
|
|
"duties": []string{"Write and manage emails"},
|
|
"rules": []string{"Always confirm before sending", "Keep emails professional"},
|
|
},
|
|
"quota": map[string]interface{}{
|
|
"max": 5,
|
|
"queue": 20,
|
|
"priority": 5,
|
|
},
|
|
"triggers": map[string]interface{}{
|
|
"intervene": map[string]interface{}{"enabled": true},
|
|
},
|
|
"resources": map[string]interface{}{
|
|
"phases": map[string]interface{}{
|
|
"inspiration": "robot.inspiration",
|
|
"goals": "robot.goals",
|
|
"tasks": "robot.tasks",
|
|
"run": "robot.validation",
|
|
"validation": "robot.validation",
|
|
"delivery": "robot.delivery",
|
|
"learning": "robot.learning",
|
|
"host": "robot.host",
|
|
},
|
|
"agents": []string{},
|
|
},
|
|
}
|
|
configJSON, _ := json.Marshal(robotConfig)
|
|
|
|
systemPrompt := `You are an email assistant for E2E testing of the Interact API.
|
|
When asked to write an email, confirm the task and generate a brief email draft.`
|
|
|
|
err := qb.Table(tableName).Insert([]map[string]interface{}{
|
|
{
|
|
"member_id": memberID,
|
|
"team_id": teamID,
|
|
"member_type": "robot",
|
|
"display_name": "E2E Interact Test Robot " + memberID,
|
|
"system_prompt": systemPrompt,
|
|
"status": "active",
|
|
"role_id": "member",
|
|
"autonomous_mode": false,
|
|
"robot_status": "idle",
|
|
"robot_config": string(configJSON),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Failed to insert interact robot %s: %v", memberID, err)
|
|
}
|
|
}
|
|
|
|
func cleanupInteractRobots(t *testing.T) {
|
|
m := model.Select("__yao.member")
|
|
if m == nil {
|
|
return
|
|
}
|
|
qb := capsule.Query()
|
|
_, err := qb.Table(m.MetaData.Table.Name).Where("member_id", "like", "robot_e2e_interact%").Delete()
|
|
if err != nil {
|
|
t.Logf("Warning: cleanup interact robots: %v", err)
|
|
}
|
|
}
|
|
|
|
func cleanupInteractExecutions(t *testing.T) {
|
|
m := model.Select("__yao.agent.execution")
|
|
if m == nil {
|
|
return
|
|
}
|
|
qb := capsule.Query()
|
|
_, err := qb.Table(m.MetaData.Table.Name).Where("member_id", "like", "robot_e2e_interact%").Delete()
|
|
if err != nil {
|
|
t.Logf("Warning: cleanup interact executions: %v", err)
|
|
}
|
|
}
|