Add chat metadata persistence and enhance executor goals injection tests
- Implement TestEnsureChatMetadata to verify that metadata, including robot_id, is correctly persisted in chat records. - Update EnsureChat method to store metadata from the context when creating chat records. - Introduce TestExecutorGoalsInjection to validate that pre-confirmed goals are injected into executions from TriggerInput.Data. - Enhance executor logic to handle goal injection and persistence, ensuring accurate execution titles. - Modify chat filtering to support chat_id_prefix for improved chat retrieval based on robot identifiers.
This commit is contained in:
parent
5fa0e15a1d
commit
4f9238ac95
14 changed files with 1283 additions and 5 deletions
|
|
@ -349,6 +349,7 @@ func (ast *Assistant) EnsureChat(ctx *agentcontext.Context) error {
|
|||
Status: "active",
|
||||
Share: "private",
|
||||
Sort: 0,
|
||||
Metadata: ctx.Metadata,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -815,6 +815,117 @@ func TestEnsureChat(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestEnsureChatMetadata verifies that ctx.Metadata is persisted to the chat record.
|
||||
// This is required for Host Agent: robot_id is passed in metadata so that
|
||||
// ListChats with chat_id_prefix=robot_{id}_ can filter by robot.
|
||||
func TestEnsureChatMetadata(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ast, err := assistant.Get("mohe")
|
||||
require.NoError(t, err)
|
||||
|
||||
chatStore := assistant.GetChatStore()
|
||||
if chatStore == nil {
|
||||
t.Skip("Chat store not configured, skipping metadata tests")
|
||||
}
|
||||
|
||||
t.Run("MetadataPersisted", func(t *testing.T) {
|
||||
chatID := fmt.Sprintf("robot_test_meta_%s", uuid.New().String()[:8])
|
||||
ctx := agentcontext.New(context.Background(), &oauthtypes.AuthorizedInfo{
|
||||
UserID: "test_user_meta",
|
||||
TeamID: "test_team_meta",
|
||||
}, chatID)
|
||||
ctx.Metadata = map[string]interface{}{
|
||||
"robot_id": "robot_member_001",
|
||||
}
|
||||
|
||||
err := ast.EnsureChat(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
chat, err := chatStore.GetChat(chatID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, chat)
|
||||
require.NotNil(t, chat.Metadata, "Metadata should be persisted")
|
||||
assert.Equal(t, "robot_member_001", chat.Metadata["robot_id"],
|
||||
"robot_id should be stored in chat metadata")
|
||||
|
||||
// Cleanup
|
||||
chatStore.DeleteChat(chatID)
|
||||
t.Logf("✓ Chat metadata persisted: robot_id=%v", chat.Metadata["robot_id"])
|
||||
})
|
||||
|
||||
t.Run("MetadataPersistedWithRobotChatIDPrefix", func(t *testing.T) {
|
||||
// Simulate robot host chat_id format: robot_{member_id}_{timestamp}
|
||||
memberID := "120004485525"
|
||||
chatID := fmt.Sprintf("robot_%s_%d", memberID, time.Now().UnixMilli())
|
||||
ctx := agentcontext.New(context.Background(), &oauthtypes.AuthorizedInfo{
|
||||
UserID: "test_user_robot",
|
||||
TeamID: "test_team_robot",
|
||||
}, chatID)
|
||||
ctx.Metadata = map[string]interface{}{
|
||||
"robot_id": memberID,
|
||||
}
|
||||
|
||||
err := ast.EnsureChat(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
chat, err := chatStore.GetChat(chatID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, chat)
|
||||
require.NotNil(t, chat.Metadata)
|
||||
assert.Equal(t, memberID, chat.Metadata["robot_id"])
|
||||
|
||||
// Cleanup
|
||||
chatStore.DeleteChat(chatID)
|
||||
t.Logf("✓ Robot-prefix chat persisted with metadata: chat_id=%s", chatID)
|
||||
})
|
||||
|
||||
t.Run("NilMetadataHandled", func(t *testing.T) {
|
||||
chatID := fmt.Sprintf("test_meta_nil_%s", uuid.New().String()[:8])
|
||||
ctx := agentcontext.New(context.Background(), nil, chatID)
|
||||
ctx.Metadata = nil
|
||||
|
||||
err := ast.EnsureChat(ctx)
|
||||
assert.NoError(t, err)
|
||||
|
||||
chat, err := chatStore.GetChat(chatID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, chat)
|
||||
// Metadata nil is acceptable
|
||||
t.Logf("✓ Nil metadata handled gracefully")
|
||||
|
||||
// Cleanup
|
||||
chatStore.DeleteChat(chatID)
|
||||
})
|
||||
|
||||
t.Run("MetadataMultipleFields", func(t *testing.T) {
|
||||
chatID := fmt.Sprintf("test_meta_multi_%s", uuid.New().String()[:8])
|
||||
ctx := agentcontext.New(context.Background(), &oauthtypes.AuthorizedInfo{
|
||||
UserID: "test_user_multi",
|
||||
TeamID: "test_team_multi",
|
||||
}, chatID)
|
||||
ctx.Metadata = map[string]interface{}{
|
||||
"robot_id": "robot_multi_001",
|
||||
"source": "mission_control",
|
||||
}
|
||||
|
||||
err := ast.EnsureChat(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
chat, err := chatStore.GetChat(chatID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, chat)
|
||||
require.NotNil(t, chat.Metadata)
|
||||
assert.Equal(t, "robot_multi_001", chat.Metadata["robot_id"])
|
||||
assert.Equal(t, "mission_control", chat.Metadata["source"])
|
||||
|
||||
// Cleanup
|
||||
chatStore.DeleteChat(chatID)
|
||||
t.Logf("✓ Multiple metadata fields persisted correctly")
|
||||
})
|
||||
}
|
||||
|
||||
func TestConvertBufferedTypes(t *testing.T) {
|
||||
t.Run("ConvertBufferedMessages", func(t *testing.T) {
|
||||
// Create buffered messages
|
||||
|
|
|
|||
|
|
@ -102,6 +102,14 @@ func (e *Executor) ExecuteWithControl(ctx *robottypes.Context, robot *robottypes
|
|||
}
|
||||
}
|
||||
|
||||
// If goals are pre-confirmed (passed via Input.Data["goals"]), inject them directly.
|
||||
// RunGoals will skip LLM call when exec.Goals is already populated (§18.2).
|
||||
if exec.Goals == nil && input != nil && input.Data != nil {
|
||||
if goalsStr, ok := input.Data["goals"].(string); ok && goalsStr != "" {
|
||||
exec.Goals = &robottypes.Goals{Content: goalsStr}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize UI display fields (with i18n support)
|
||||
exec.Name, exec.CurrentTaskName = e.initUIFields(trigger, input, robot)
|
||||
|
||||
|
|
@ -120,6 +128,21 @@ func (e *Executor) ExecuteWithControl(ctx *robottypes.Context, robot *robottypes
|
|||
"error": err,
|
||||
}).Warn("Failed to persist execution record: %v", err)
|
||||
}
|
||||
|
||||
// If goals were pre-injected, persist them and update the execution title
|
||||
if exec.Goals != nil && exec.Goals.Content != "" {
|
||||
if err := e.store.UpdatePhase(ctx.Context, exec.ID, robottypes.PhaseGoals, exec.Goals); err != nil {
|
||||
log.With(log.F{
|
||||
"execution_id": exec.ID,
|
||||
"member_id": exec.MemberID,
|
||||
"error": err,
|
||||
}).Warn("Failed to persist pre-confirmed goals: %v", err)
|
||||
}
|
||||
if goalName := extractGoalName(exec.Goals); goalName != "" {
|
||||
e.updateUIFields(ctx, exec, goalName, "")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Acquire execution slot
|
||||
|
|
|
|||
|
|
@ -117,6 +117,132 @@ func TestExecutorPersistence(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Goals Injection Tests (Host Agent confirmed goals)
|
||||
// ============================================================================
|
||||
|
||||
// TestExecutorGoalsInjection verifies that when TriggerHuman is used with
|
||||
// pre-confirmed goals (from Host Agent via /v1/agent/robots/:id/execute),
|
||||
// the goals are injected directly into exec.Goals before RunGoals runs,
|
||||
// and are persisted (title updated) so the task list shows the correct title.
|
||||
func TestExecutorGoalsInjection(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
t.Run("goals_injected_from_trigger_input_data", func(t *testing.T) {
|
||||
ctx := robottypes.NewContext(context.Background(), &oauthTypes.AuthorizedInfo{
|
||||
UserID: "user_goals_inject_001",
|
||||
TeamID: "team_goals_inject_001",
|
||||
})
|
||||
|
||||
robot := createPersistenceTestRobot("member_goals_inject_001", "team_goals_inject_001")
|
||||
|
||||
e := standard.NewWithConfig(types.Config{
|
||||
SkipPersistence: false,
|
||||
})
|
||||
|
||||
// Simulate Host Agent confirmed goals passed via TriggerInput.Data
|
||||
triggerInput := &robottypes.TriggerInput{
|
||||
Data: map[string]interface{}{
|
||||
"goals": "Create a mecha image with sci-fi style",
|
||||
"chat_id": "robot_member_goals_inject_001_1234567890",
|
||||
},
|
||||
}
|
||||
|
||||
exec, err := e.Execute(ctx, robot, robottypes.TriggerHuman, triggerInput)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, exec)
|
||||
|
||||
// Goals should be injected from TriggerInput.Data
|
||||
require.NotNil(t, exec.Goals, "Goals should be injected from TriggerInput.Data")
|
||||
assert.Equal(t, "Create a mecha image with sci-fi style", exec.Goals.Content,
|
||||
"Goals content should match the pre-confirmed goals")
|
||||
|
||||
// Verify goals were persisted to the store
|
||||
s := store.NewExecutionStore()
|
||||
record, err := s.Get(context.Background(), exec.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, record)
|
||||
|
||||
// Execution name should reflect the goals (not "Preparing...")
|
||||
assert.NotEmpty(t, exec.Name, "Execution name should be set from goals")
|
||||
assert.NotEqual(t, "Preparing...", exec.Name, "Name should not be the default placeholder")
|
||||
|
||||
// Cleanup
|
||||
_ = s.Delete(context.Background(), exec.ID)
|
||||
t.Logf("✓ Goals injected from TriggerInput.Data: goals=%q, name=%q",
|
||||
exec.Goals.Content, exec.Name)
|
||||
})
|
||||
|
||||
t.Run("empty_goals_falls_through_to_goals_agent", func(t *testing.T) {
|
||||
// When TriggerInput.Data["goals"] is an empty string, the executor
|
||||
// does NOT inject pre-confirmed goals and falls through to RunGoals.
|
||||
// RunGoals will call the Goals Agent (LLM), which may succeed or fail
|
||||
// depending on the environment. We only verify that the executor returns
|
||||
// without a panic and that no pre-confirmed goals were force-injected.
|
||||
//
|
||||
// This test requires a running AI environment; skip in short mode.
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping: requires LLM for RunGoals fallback")
|
||||
}
|
||||
|
||||
ctx := robottypes.NewContext(context.Background(), &oauthTypes.AuthorizedInfo{
|
||||
UserID: "user_goals_empty_002",
|
||||
TeamID: "team_goals_empty_002",
|
||||
})
|
||||
|
||||
robot := createPersistenceTestRobot("member_goals_empty_002", "team_goals_empty_002")
|
||||
|
||||
e := standard.NewWithConfig(types.Config{
|
||||
SkipPersistence: true,
|
||||
})
|
||||
|
||||
triggerInput := &robottypes.TriggerInput{
|
||||
Data: map[string]interface{}{
|
||||
"goals": "", // empty — should not be injected as pre-confirmed
|
||||
},
|
||||
}
|
||||
|
||||
exec, err := e.Execute(ctx, robot, robottypes.TriggerHuman, triggerInput)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, exec)
|
||||
|
||||
// If Goals was set, it came from the Goals Agent, NOT from the empty string injection.
|
||||
// Either nil (agent skipped) or non-nil (agent ran) is acceptable.
|
||||
if exec.Goals != nil {
|
||||
assert.NotEmpty(t, exec.Goals.Content,
|
||||
"If Goals Agent ran, content should be non-empty")
|
||||
}
|
||||
t.Logf("✓ Empty goals falls through to Goals Agent (goals=%v)", exec.Goals != nil)
|
||||
})
|
||||
|
||||
t.Run("no_trigger_input_uses_normal_flow", func(t *testing.T) {
|
||||
ctx := robottypes.NewContext(context.Background(), &oauthTypes.AuthorizedInfo{
|
||||
UserID: "user_goals_normal_001",
|
||||
TeamID: "team_goals_normal_001",
|
||||
})
|
||||
|
||||
robot := createPersistenceTestRobot("member_goals_normal_001", "team_goals_normal_001")
|
||||
|
||||
e := standard.NewWithConfig(types.Config{
|
||||
SkipPersistence: true,
|
||||
})
|
||||
|
||||
// No TriggerInput — simulate plain string fallback (old API usage)
|
||||
exec, err := e.Execute(ctx, robot, robottypes.TriggerHuman, "simulate_failure")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, exec)
|
||||
|
||||
// Goals nil is expected — RunGoals would normally call the LLM
|
||||
assert.Nil(t, exec.Goals, "Without pre-confirmed goals, Goals should remain nil")
|
||||
t.Logf("✓ Normal flow (no pre-confirmed goals) proceeds without injection")
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ func BuildTriggerInput(trigger robottypes.TriggerType, data interface{}) *robott
|
|||
input.EventType = req.EventType
|
||||
input.Data = req.Data
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return input
|
||||
|
|
|
|||
161
agent/robot/process.go
Normal file
161
agent/robot/process.go
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
package robot
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/yaoapp/gou/process"
|
||||
"github.com/yaoapp/kun/exception"
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
"github.com/yaoapp/yao/agent/robot/api"
|
||||
"github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
func init() {
|
||||
process.RegisterGroup("robot", map[string]process.Handler{
|
||||
"get": processGet,
|
||||
"list": processList,
|
||||
"status": processStatus,
|
||||
"executions": processExecutions,
|
||||
"execution": processExecution,
|
||||
"updateChatTitle": processUpdateChatTitle,
|
||||
})
|
||||
}
|
||||
|
||||
// processGet handles robot.Get(memberID).
|
||||
// args[0]: memberID string
|
||||
func processGet(p *process.Process) interface{} {
|
||||
p.ValidateArgNums(1)
|
||||
memberID := p.ArgsString(0)
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
result, err := api.GetRobotResponse(ctx, memberID)
|
||||
if err != nil {
|
||||
exception.New(err.Error(), 500).Throw()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// processList handles robot.List(filter?).
|
||||
// args[0]: optional filter map with page, pagesize, status, search (keywords)
|
||||
func processList(p *process.Process) interface{} {
|
||||
p.ValidateArgNums(0)
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
filter := &api.ListQuery{}
|
||||
if p.NumOfArgs() > 0 {
|
||||
raw := p.ArgsMap(0)
|
||||
if v, ok := raw["page"]; ok {
|
||||
filter.Page = toInt(v)
|
||||
}
|
||||
if v, ok := raw["pagesize"]; ok {
|
||||
filter.PageSize = toInt(v)
|
||||
}
|
||||
if v, ok := raw["status"]; ok {
|
||||
filter.Status = types.RobotStatus(toString(v))
|
||||
}
|
||||
if v, ok := raw["search"]; ok {
|
||||
filter.Keywords = toString(v)
|
||||
}
|
||||
if v, ok := raw["team_id"]; ok {
|
||||
filter.TeamID = toString(v)
|
||||
}
|
||||
}
|
||||
result, err := api.ListRobots(ctx, filter)
|
||||
if err != nil {
|
||||
exception.New(err.Error(), 500).Throw()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// processStatus handles robot.Status(memberID).
|
||||
// args[0]: memberID string
|
||||
func processStatus(p *process.Process) interface{} {
|
||||
p.ValidateArgNums(1)
|
||||
memberID := p.ArgsString(0)
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
result, err := api.GetRobotStatus(ctx, memberID)
|
||||
if err != nil {
|
||||
exception.New(err.Error(), 500).Throw()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// processExecutions handles robot.Executions(memberID, filter?).
|
||||
// args[0]: memberID string; args[1]: optional filter map
|
||||
func processExecutions(p *process.Process) interface{} {
|
||||
p.ValidateArgNums(1)
|
||||
memberID := p.ArgsString(0)
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
filter := &api.ExecutionQuery{}
|
||||
if p.NumOfArgs() > 1 {
|
||||
raw := p.ArgsMap(1)
|
||||
if v, ok := raw["page"]; ok {
|
||||
filter.Page = toInt(v)
|
||||
}
|
||||
if v, ok := raw["pagesize"]; ok {
|
||||
filter.PageSize = toInt(v)
|
||||
}
|
||||
if v, ok := raw["status"]; ok {
|
||||
filter.Status = types.ExecStatus(toString(v))
|
||||
}
|
||||
if v, ok := raw["trigger"]; ok {
|
||||
filter.Trigger = types.TriggerType(toString(v))
|
||||
}
|
||||
}
|
||||
result, err := api.ListExecutions(ctx, memberID, filter)
|
||||
if err != nil {
|
||||
exception.New(err.Error(), 500).Throw()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// processExecution handles robot.Execution(memberID, executionID).
|
||||
// args[0]: memberID string; args[1]: executionID string
|
||||
func processExecution(p *process.Process) interface{} {
|
||||
p.ValidateArgNums(2)
|
||||
memberID := p.ArgsString(0)
|
||||
executionID := p.ArgsString(1)
|
||||
ctx := types.NewContext(context.Background(), nil)
|
||||
result, err := api.GetExecutionStatus(ctx, executionID)
|
||||
if err != nil {
|
||||
exception.New(err.Error(), 500).Throw()
|
||||
}
|
||||
_ = memberID // reserved for future permission scoping
|
||||
return result
|
||||
}
|
||||
|
||||
// processUpdateChatTitle handles robot.UpdateChatTitle(chatID, title).
|
||||
// args[0]: chatID string; args[1]: title string
|
||||
func processUpdateChatTitle(p *process.Process) interface{} {
|
||||
p.ValidateArgNums(2)
|
||||
chatID := p.ArgsString(0)
|
||||
title := p.ArgsString(1)
|
||||
|
||||
chatStore := assistant.GetChatStore()
|
||||
if chatStore == nil {
|
||||
exception.New("chat store not available", 500).Throw()
|
||||
}
|
||||
|
||||
if err := chatStore.UpdateChat(chatID, map[string]interface{}{"title": title}); err != nil {
|
||||
exception.New(err.Error(), 500).Throw()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func toInt(v interface{}) int {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return n
|
||||
case int64:
|
||||
return int(n)
|
||||
case float64:
|
||||
return int(n)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func toString(v interface{}) string {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
301
agent/robot/process_test.go
Normal file
301
agent/robot/process_test.go
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
package robot_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/gou/process"
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
storetypes "github.com/yaoapp/yao/agent/store/types"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
|
||||
// Register robot process handlers via init()
|
||||
_ "github.com/yaoapp/yao/agent/robot"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// robot.UpdateChatTitle
|
||||
// ============================================================================
|
||||
|
||||
func TestProcessUpdateChatTitle(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
chatStore := assistant.GetChatStore()
|
||||
if chatStore == nil {
|
||||
t.Skip("Chat store not configured, skipping UpdateChatTitle tests")
|
||||
}
|
||||
|
||||
t.Run("UpdatesTitle", func(t *testing.T) {
|
||||
chatID := fmt.Sprintf("robot_test_proc_%s", uuid.New().String()[:8])
|
||||
|
||||
// Create a chat record first
|
||||
err := chatStore.CreateChat(&storetypes.Chat{
|
||||
ChatID: chatID,
|
||||
AssistantID: "robot.host",
|
||||
Status: "active",
|
||||
Share: "private",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer chatStore.DeleteChat(chatID)
|
||||
|
||||
title := "Create a mecha image, sci-fi style"
|
||||
p := process.New("robot.UpdateChatTitle", chatID, title)
|
||||
_, err = p.Exec()
|
||||
require.NoError(t, err)
|
||||
|
||||
chat, err := chatStore.GetChat(chatID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, title, chat.Title, "Title should be updated to the confirmed goals")
|
||||
t.Logf("✓ robot.UpdateChatTitle: chat_id=%s, title=%q", chatID, chat.Title)
|
||||
})
|
||||
|
||||
t.Run("UpdatesLongGoalsTitle", func(t *testing.T) {
|
||||
chatID := fmt.Sprintf("robot_test_long_%s", uuid.New().String()[:8])
|
||||
|
||||
err := chatStore.CreateChat(&storetypes.Chat{
|
||||
ChatID: chatID,
|
||||
AssistantID: "robot.host",
|
||||
Status: "active",
|
||||
Share: "private",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer chatStore.DeleteChat(chatID)
|
||||
|
||||
// Long goals string — title field should accommodate it
|
||||
title := "请帮我制作一张充满未来感的机甲图片,风格参考《攻壳机动队》,以赛博朋克城市为背景,色调偏冷,蓝紫配色"
|
||||
p := process.New("robot.UpdateChatTitle", chatID, title)
|
||||
_, err = p.Exec()
|
||||
require.NoError(t, err)
|
||||
|
||||
chat, err := chatStore.GetChat(chatID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, title, chat.Title)
|
||||
t.Logf("✓ Long goals title persisted: %d chars", len(title))
|
||||
})
|
||||
|
||||
t.Run("ErrorOnNonExistentChat", func(t *testing.T) {
|
||||
p := process.New("robot.UpdateChatTitle", "non_existent_chat_id", "some title")
|
||||
_, err := p.Exec()
|
||||
assert.Error(t, err, "Should error when chat does not exist")
|
||||
t.Logf("✓ Non-existent chat correctly returns error")
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// robot.Get
|
||||
// ============================================================================
|
||||
|
||||
func TestProcessGet(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
t.Run("ErrorOnNotFound", func(t *testing.T) {
|
||||
p := process.New("robot.Get", "non_existent_robot_member_id")
|
||||
_, err := p.Exec()
|
||||
assert.Error(t, err, "Should error for non-existent robot")
|
||||
t.Logf("✓ robot.Get returns error for non-existent robot")
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// robot.List
|
||||
// ============================================================================
|
||||
|
||||
func TestProcessList(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
t.Run("ReturnsListWithNoFilter", func(t *testing.T) {
|
||||
p := process.New("robot.List")
|
||||
result, err := p.Exec()
|
||||
require.NoError(t, err)
|
||||
// Result is a paginated list — just assert it's not nil
|
||||
assert.NotNil(t, result)
|
||||
t.Logf("✓ robot.List returned: %T", result)
|
||||
})
|
||||
|
||||
t.Run("ReturnsListWithPageFilter", func(t *testing.T) {
|
||||
p := process.New("robot.List", map[string]interface{}{
|
||||
"page": 1,
|
||||
"pagesize": 5,
|
||||
})
|
||||
result, err := p.Exec()
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
t.Logf("✓ robot.List with page filter returned: %T", result)
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// robot.Status
|
||||
// ============================================================================
|
||||
|
||||
func TestProcessStatus(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
t.Run("ErrorOnNotFound", func(t *testing.T) {
|
||||
p := process.New("robot.Status", "non_existent_robot_member_id")
|
||||
_, err := p.Exec()
|
||||
assert.Error(t, err, "Should error for non-existent robot")
|
||||
t.Logf("✓ robot.Status returns error for non-existent robot")
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// robot.Executions
|
||||
// ============================================================================
|
||||
|
||||
func TestProcessExecutions(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
t.Run("ReturnsEmptyForUnknownRobot", func(t *testing.T) {
|
||||
memberID := fmt.Sprintf("proc_exec_test_%d", time.Now().UnixNano())
|
||||
p := process.New("robot.Executions", memberID)
|
||||
result, err := p.Exec()
|
||||
// May error or return empty — both acceptable
|
||||
if err == nil {
|
||||
assert.NotNil(t, result)
|
||||
}
|
||||
t.Logf("✓ robot.Executions handled for unknown robot")
|
||||
})
|
||||
|
||||
t.Run("AcceptsFilterMap", func(t *testing.T) {
|
||||
memberID := fmt.Sprintf("proc_exec_filter_%d", time.Now().UnixNano())
|
||||
p := process.New("robot.Executions", memberID, map[string]interface{}{
|
||||
"page": 1,
|
||||
"pagesize": 10,
|
||||
"status": "completed",
|
||||
})
|
||||
result, err := p.Exec()
|
||||
if err == nil {
|
||||
assert.NotNil(t, result)
|
||||
}
|
||||
t.Logf("✓ robot.Executions with filter handled")
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// robot.Execution
|
||||
// ============================================================================
|
||||
|
||||
func TestProcessExecution(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
t.Run("ErrorOnNonExistentExecution", func(t *testing.T) {
|
||||
p := process.New("robot.Execution", "some_member_id", "non_existent_exec_id")
|
||||
_, err := p.Exec()
|
||||
assert.Error(t, err, "Should error for non-existent execution")
|
||||
t.Logf("✓ robot.Execution returns error for non-existent execution")
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Argument Validation
|
||||
// ============================================================================
|
||||
|
||||
func TestProcessArgumentValidation(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
t.Run("UpdateChatTitle_RequiresTwoArgs", func(t *testing.T) {
|
||||
// Missing title argument
|
||||
p := process.New("robot.UpdateChatTitle", "some_chat_id")
|
||||
_, err := p.Exec()
|
||||
assert.Error(t, err, "Should require 2 arguments")
|
||||
})
|
||||
|
||||
t.Run("Get_RequiresOneArg", func(t *testing.T) {
|
||||
p := process.New("robot.Get")
|
||||
_, err := p.Exec()
|
||||
assert.Error(t, err, "Should require 1 argument")
|
||||
})
|
||||
|
||||
t.Run("Status_RequiresOneArg", func(t *testing.T) {
|
||||
p := process.New("robot.Status")
|
||||
_, err := p.Exec()
|
||||
assert.Error(t, err, "Should require 1 argument")
|
||||
})
|
||||
|
||||
t.Run("Execution_RequiresTwoArgs", func(t *testing.T) {
|
||||
p := process.New("robot.Execution", "only_one_arg")
|
||||
_, err := p.Exec()
|
||||
assert.Error(t, err, "Should require 2 arguments")
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Integration: UpdateChatTitle flow (simulate Host Agent Next Hook)
|
||||
// ============================================================================
|
||||
|
||||
func TestProcessUpdateChatTitleIntegration(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
chatStore := assistant.GetChatStore()
|
||||
if chatStore == nil {
|
||||
t.Skip("Chat store not configured")
|
||||
}
|
||||
|
||||
t.Run("SimulatesHostAgentNextHook", func(t *testing.T) {
|
||||
// Simulate the full flow:
|
||||
// 1. ChatDrawer creates a chat with robot_id in metadata
|
||||
// 2. Host Agent Next Hook calls robot.UpdateChatTitle with confirmed goals
|
||||
// 3. History dropdown shows the goals as the chat title
|
||||
|
||||
memberID := "120004485525"
|
||||
chatID := fmt.Sprintf("robot_%s_%d", memberID, time.Now().UnixMilli())
|
||||
confirmedGoals := "制作一张机甲图片,风格和设计由AI自主决定"
|
||||
|
||||
// Step 1: Create chat (simulating AssignTaskDrawer)
|
||||
err := chatStore.CreateChat(&storetypes.Chat{
|
||||
ChatID: chatID,
|
||||
AssistantID: "yao.robot-host",
|
||||
Status: "active",
|
||||
Share: "private",
|
||||
Metadata: map[string]interface{}{
|
||||
"robot_id": memberID,
|
||||
},
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer chatStore.DeleteChat(chatID)
|
||||
|
||||
// Step 2: Next Hook calls robot.UpdateChatTitle
|
||||
p := process.New("robot.UpdateChatTitle", chatID, confirmedGoals)
|
||||
_, err = p.Exec()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Step 3: Verify title is set (history dropdown will display this)
|
||||
chat, err := chatStore.GetChat(chatID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, confirmedGoals, chat.Title,
|
||||
"History dropdown should show confirmed goals as title")
|
||||
require.NotNil(t, chat.Metadata)
|
||||
assert.Equal(t, memberID, chat.Metadata["robot_id"],
|
||||
"Metadata robot_id should be preserved after title update")
|
||||
|
||||
t.Logf("✓ Full Host Agent flow: chat_id=%s, title=%q, robot_id=%v",
|
||||
chatID, chat.Title, chat.Metadata["robot_id"])
|
||||
})
|
||||
}
|
||||
|
||||
// ensure context is used (avoid unused import)
|
||||
var _ = context.Background
|
||||
|
|
@ -50,11 +50,12 @@ type Chat struct {
|
|||
|
||||
// ChatFilter for listing chats
|
||||
type ChatFilter struct {
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
TeamID string `json:"team_id,omitempty"`
|
||||
AssistantID string `json:"assistant_id,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Keywords string `json:"keywords,omitempty"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
TeamID string `json:"team_id,omitempty"`
|
||||
AssistantID string `json:"assistant_id,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Keywords string `json:"keywords,omitempty"`
|
||||
ChatIDPrefix string `json:"chat_id_prefix,omitempty"`
|
||||
|
||||
// Time range filter
|
||||
StartTime *time.Time `json:"start_time,omitempty"` // Filter chats after this time
|
||||
|
|
|
|||
|
|
@ -262,6 +262,9 @@ func (store *Xun) ListChats(filter types.ChatFilter) (*types.ChatList, error) {
|
|||
if filter.Keywords != "" {
|
||||
qb.Where("title", "like", fmt.Sprintf("%%%s%%", filter.Keywords))
|
||||
}
|
||||
if filter.ChatIDPrefix != "" {
|
||||
qb.Where("chat_id", "like", filter.ChatIDPrefix+"%")
|
||||
}
|
||||
|
||||
// Apply time range filter
|
||||
if filter.StartTime != nil {
|
||||
|
|
|
|||
124
openapi/agent/robot/completions.go
Normal file
124
openapi/agent/robot/completions.go
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
package robot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
robotstore "github.com/yaoapp/yao/agent/robot/store"
|
||||
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
||||
"github.com/yaoapp/yao/openapi/chat"
|
||||
"github.com/yaoapp/yao/openapi/response"
|
||||
)
|
||||
|
||||
// resolveHostAssistantID resolves the host assistant ID from a robot member ID.
|
||||
// It fetches the RobotRecord, parses its config, and returns the PhaseHost agent ID.
|
||||
func resolveHostAssistantID(ctx context.Context, memberID string) (string, *robotstore.RobotRecord, error) {
|
||||
store := robotstore.NewRobotStore()
|
||||
record, err := store.Get(ctx, memberID)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to get robot: %w", err)
|
||||
}
|
||||
if record == nil {
|
||||
return "", nil, fmt.Errorf("robot not found: %s", memberID)
|
||||
}
|
||||
|
||||
config, err := robottypes.ParseConfig(record.RobotConfig)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to parse robot config: %w", err)
|
||||
}
|
||||
|
||||
var hostID string
|
||||
if config != nil && config.Resources != nil {
|
||||
hostID = config.Resources.GetPhaseAgent(robottypes.PhaseHost)
|
||||
} else {
|
||||
hostID = "__yao." + string(robottypes.PhaseHost)
|
||||
}
|
||||
|
||||
return hostID, record, nil
|
||||
}
|
||||
|
||||
// injectAssistantID sets the assistant_id query parameter on the gin request,
|
||||
// so that downstream GetCompletionRequest can pick it up.
|
||||
func injectAssistantID(c *gin.Context, assistantID string) {
|
||||
q := c.Request.URL.Query()
|
||||
q.Set("assistant_id", assistantID)
|
||||
c.Request.URL.RawQuery = q.Encode()
|
||||
}
|
||||
|
||||
// RobotCompletions handles POST /v1/agent/robots/:id/completions
|
||||
// Mirror API that resolves the robot's host assistant and delegates to standard chat completions.
|
||||
func RobotCompletions(c *gin.Context) {
|
||||
robotID := c.Param("id")
|
||||
if robotID == "" {
|
||||
response.RespondWithError(c, response.StatusBadRequest, &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "robot id is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
hostID, _, err := resolveHostAssistantID(c.Request.Context(), robotID)
|
||||
if err != nil {
|
||||
response.RespondWithError(c, response.StatusInternalServerError, &response.ErrorResponse{
|
||||
Code: response.ErrServerError.Code,
|
||||
ErrorDescription: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
injectAssistantID(c, hostID)
|
||||
chat.GinCreateCompletions(c)
|
||||
}
|
||||
|
||||
// RobotAppendMessages handles POST /v1/agent/robots/:id/completions/:context_id/append
|
||||
// Mirror API that resolves the robot's host assistant and delegates to standard append.
|
||||
func RobotAppendMessages(c *gin.Context) {
|
||||
robotID := c.Param("id")
|
||||
if robotID == "" {
|
||||
response.RespondWithError(c, response.StatusBadRequest, &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "robot id is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
hostID, _, err := resolveHostAssistantID(c.Request.Context(), robotID)
|
||||
if err != nil {
|
||||
response.RespondWithError(c, response.StatusInternalServerError, &response.ErrorResponse{
|
||||
Code: response.ErrServerError.Code,
|
||||
ErrorDescription: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
injectAssistantID(c, hostID)
|
||||
chat.GinAppendMessages(c)
|
||||
}
|
||||
|
||||
// RobotHostID handles GET /v1/agent/robots/:id/host
|
||||
// Returns the host assistant ID for a robot (used by frontend to know which assistant to chat with).
|
||||
func RobotHostID(c *gin.Context) {
|
||||
robotID := c.Param("id")
|
||||
if robotID == "" {
|
||||
response.RespondWithError(c, response.StatusBadRequest, &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "robot id is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
hostID, _, err := resolveHostAssistantID(c.Request.Context(), robotID)
|
||||
if err != nil {
|
||||
response.RespondWithError(c, response.StatusInternalServerError, &response.ErrorResponse{
|
||||
Code: response.ErrServerError.Code,
|
||||
ErrorDescription: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
response.RespondWithSuccess(c, response.StatusOK, gin.H{
|
||||
"assistant_id": hostID,
|
||||
"robot_id": robotID,
|
||||
})
|
||||
}
|
||||
98
openapi/agent/robot/execute.go
Normal file
98
openapi/agent/robot/execute.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
package robot
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
robotapi "github.com/yaoapp/yao/agent/robot/api"
|
||||
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
||||
"github.com/yaoapp/yao/openapi/oauth/authorized"
|
||||
"github.com/yaoapp/yao/openapi/response"
|
||||
)
|
||||
|
||||
// ExecuteRequest is the request body for POST /v1/agent/robots/:id/execute
|
||||
type ExecuteRequest struct {
|
||||
Goals string `json:"goals" binding:"required"`
|
||||
Context map[string]interface{} `json:"context,omitempty"`
|
||||
ChatID string `json:"chat_id,omitempty"`
|
||||
}
|
||||
|
||||
// ExecuteRobot handles POST /v1/agent/robots/:id/execute
|
||||
// Directly triggers robot execution with confirmed goals, bypassing Host Agent conversation.
|
||||
// Called by CUI after the Host Agent's NEXT HOOK sends a robot.execute Action.
|
||||
func ExecuteRobot(c *gin.Context) {
|
||||
authInfo := authorized.GetInfo(c)
|
||||
if authInfo == nil || (authInfo.Subject == "" && authInfo.UserID == "") {
|
||||
response.RespondWithError(c, response.StatusUnauthorized, &response.ErrorResponse{
|
||||
Code: response.ErrInvalidToken.Code,
|
||||
ErrorDescription: "Authentication required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
robotID := c.Param("id")
|
||||
if robotID == "" {
|
||||
response.RespondWithError(c, response.StatusBadRequest, &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "robot id is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req ExecuteRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.RespondWithError(c, response.StatusBadRequest, &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "Invalid request body: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := robottypes.NewContext(c.Request.Context(), authInfo)
|
||||
|
||||
// Build TriggerInput with confirmed goals from Host Agent.
|
||||
// Passing goals via Data["goals"] allows RunGoals to skip the Goals Agent
|
||||
// and use the pre-confirmed goals directly.
|
||||
data := map[string]interface{}{
|
||||
"goals": req.Goals,
|
||||
}
|
||||
if req.Context != nil {
|
||||
data["context"] = req.Context
|
||||
}
|
||||
if req.ChatID != "" {
|
||||
data["chat_id"] = req.ChatID
|
||||
}
|
||||
triggerInput := &robottypes.TriggerInput{
|
||||
Data: data,
|
||||
}
|
||||
|
||||
result, err := robotapi.TriggerManual(ctx, robotID, robottypes.TriggerHuman, triggerInput)
|
||||
if err != nil {
|
||||
if errors.Is(err, robottypes.ErrRobotNotFound) {
|
||||
response.RespondWithError(c, response.StatusNotFound, &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "Robot not found: " + robotID,
|
||||
})
|
||||
return
|
||||
}
|
||||
response.RespondWithError(c, response.StatusInternalServerError, &response.ErrorResponse{
|
||||
Code: response.ErrServerError.Code,
|
||||
ErrorDescription: "Failed to execute: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !result.Accepted {
|
||||
response.RespondWithError(c, response.StatusBadRequest, &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: result.Message,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
response.RespondWithSuccess(c, response.StatusOK, gin.H{
|
||||
"execution_id": result.ExecutionID,
|
||||
"status": "started",
|
||||
"message": result.Message,
|
||||
})
|
||||
}
|
||||
|
|
@ -3,6 +3,8 @@ package robot
|
|||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
|
||||
_ "github.com/yaoapp/yao/agent/robot" // register robot.* process handlers
|
||||
)
|
||||
|
||||
// Attach attaches the robot API handlers to the router with OAuth protection
|
||||
|
|
@ -42,6 +44,14 @@ func Attach(group *gin.RouterGroup, oauth types.OAuth) {
|
|||
group.POST("/:id/trigger", TriggerRobot) // POST /robots/:id/trigger - Trigger robot execution
|
||||
group.POST("/:id/intervene", InterveneRobot) // POST /robots/:id/intervene - Human intervention
|
||||
|
||||
// Host Agent Chat (mirror of standard Chat Completion API)
|
||||
group.GET("/:id/host", RobotHostID) // GET /robots/:id/host - Get host assistant ID
|
||||
group.POST("/:id/completions", RobotCompletions) // POST /robots/:id/completions - Chat with host agent
|
||||
group.POST("/:id/completions/:context_id/append", RobotAppendMessages) // POST /robots/:id/completions/:context_id/append - Append messages
|
||||
|
||||
// Execute - Direct execution trigger (called by CUI after Host confirms goals)
|
||||
group.POST("/:id/execute", ExecuteRobot) // POST /robots/:id/execute - Execute with confirmed goals
|
||||
|
||||
// V2: Unified Interact API (suspend-resume, human-in-the-loop)
|
||||
group.POST("/:id/interact", InteractRobot) // POST /robots/:id/interact - Unified interaction
|
||||
group.POST("/:id/executions/:exec_id/tasks/:task_id/reply", ReplyToTask) // POST /robots/:id/executions/:exec_id/tasks/:task_id/reply - Reply to waiting task
|
||||
|
|
|
|||
|
|
@ -456,6 +456,7 @@ func buildChatFilter(c *gin.Context, authInfo *oauthtypes.AuthorizedInfo) storet
|
|||
filter.AssistantID = strings.TrimSpace(c.Query("assistant_id"))
|
||||
filter.Status = strings.TrimSpace(c.Query("status"))
|
||||
filter.Keywords = strings.TrimSpace(c.Query("keywords"))
|
||||
filter.ChatIDPrefix = strings.TrimSpace(c.Query("chat_id_prefix"))
|
||||
|
||||
// Time range filter
|
||||
if startTimeStr := c.Query("start_time"); startTimeStr != "" {
|
||||
|
|
|
|||
317
openapi/tests/agent/robot_host_test.go
Normal file
317
openapi/tests/agent/robot_host_test.go
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
package openapi_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/openapi"
|
||||
"github.com/yaoapp/yao/openapi/tests/testutils"
|
||||
)
|
||||
|
||||
// TestRobotHostID tests GET /v1/agent/robots/:id/host
|
||||
// Returns the host assistant ID for a given robot.
|
||||
func TestRobotHostID(t *testing.T) {
|
||||
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, "Host ID 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")
|
||||
|
||||
// Create a test robot with a minimal config
|
||||
robotID := fmt.Sprintf("test_host_id_%d", time.Now().UnixNano())
|
||||
createTestRobotForHost(t, serverURL, baseURL, tokenInfo.AccessToken, robotID, "Host ID Test Robot")
|
||||
defer deleteTestRobotForHost(t, serverURL, baseURL, tokenInfo.AccessToken, robotID)
|
||||
|
||||
t.Run("GetHostIDSuccess", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID+"/host", nil)
|
||||
require.NoError(t, err)
|
||||
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.StatusOK, resp.StatusCode)
|
||||
|
||||
var response map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, response, "assistant_id", "Response should contain assistant_id")
|
||||
assert.Contains(t, response, "robot_id", "Response should contain robot_id")
|
||||
assert.Equal(t, robotID, response["robot_id"])
|
||||
// assistant_id is either configured or falls back to "__yao.host"
|
||||
assistantID, ok := response["assistant_id"].(string)
|
||||
assert.True(t, ok, "assistant_id should be a string")
|
||||
assert.NotEmpty(t, assistantID, "assistant_id should not be empty")
|
||||
t.Logf("✓ Host ID: robot=%s, assistant=%s", robotID, assistantID)
|
||||
})
|
||||
|
||||
t.Run("GetHostIDNotFound", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/non_existent_robot/host", nil)
|
||||
require.NoError(t, err)
|
||||
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.StatusInternalServerError, resp.StatusCode)
|
||||
t.Logf("✓ Non-existent robot returns error for /host")
|
||||
})
|
||||
|
||||
t.Run("GetHostIDUnauthorized", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID+"/host", nil)
|
||||
require.NoError(t, err)
|
||||
// No Authorization header
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
t.Logf("✓ Unauthorized request rejected")
|
||||
})
|
||||
}
|
||||
|
||||
// TestRobotExecute tests POST /v1/agent/robots/:id/execute
|
||||
// Called by CUI after Host Agent confirms goals via robot.execute Action.
|
||||
func TestRobotExecute(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping execute tests in short mode (requires 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, "Execute 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")
|
||||
|
||||
robotID := fmt.Sprintf("test_execute_%d", time.Now().UnixNano())
|
||||
createTestRobotForHost(t, serverURL, baseURL, tokenInfo.AccessToken, robotID, "Execute Test Robot")
|
||||
defer deleteTestRobotForHost(t, serverURL, baseURL, tokenInfo.AccessToken, robotID)
|
||||
|
||||
t.Run("ExecuteWithGoals", func(t *testing.T) {
|
||||
execData := map[string]interface{}{
|
||||
"goals": "Create a mecha image with sci-fi style",
|
||||
"chat_id": fmt.Sprintf("robot_%s_%d", robotID, time.Now().UnixMilli()),
|
||||
"context": map[string]interface{}{
|
||||
"style": "sci-fi",
|
||||
"subject": "mecha",
|
||||
},
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(execData)
|
||||
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/execute", 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()
|
||||
|
||||
// Either 200 (accepted) or 400 (trigger disabled / manager not running)
|
||||
// Both are valid — we test the API contract
|
||||
var response map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
assert.Contains(t, response, "execution_id", "Should return execution_id")
|
||||
assert.Equal(t, "started", response["status"])
|
||||
t.Logf("✓ Execute accepted: execution_id=%v", response["execution_id"])
|
||||
} else {
|
||||
// Manager not running or trigger disabled is acceptable in test env
|
||||
t.Logf("Execute returned %d: %v (manager may not be running)", resp.StatusCode, response)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ExecuteMissingGoals", func(t *testing.T) {
|
||||
// goals is required — omitting it should fail with 400
|
||||
execData := map[string]interface{}{
|
||||
"chat_id": fmt.Sprintf("robot_%s_%d", robotID, time.Now().UnixMilli()),
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(execData)
|
||||
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/execute", 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.Logf("✓ Missing goals returns 400")
|
||||
})
|
||||
|
||||
t.Run("ExecuteNotFound", func(t *testing.T) {
|
||||
execData := map[string]interface{}{
|
||||
"goals": "Some goal",
|
||||
}
|
||||
body, _ := json.Marshal(execData)
|
||||
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/non_existent_robot_xyz/execute", 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()
|
||||
|
||||
// 404 (robot not found), 400 (trigger disabled) or 500 (manager not running) — all acceptable
|
||||
assert.True(t,
|
||||
resp.StatusCode == http.StatusNotFound ||
|
||||
resp.StatusCode == http.StatusBadRequest ||
|
||||
resp.StatusCode == http.StatusInternalServerError,
|
||||
"Non-existent robot should return 4xx or 500, got %d", resp.StatusCode)
|
||||
t.Logf("✓ Non-existent robot execute returns %d", resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("ExecuteUnauthorized", func(t *testing.T) {
|
||||
execData := map[string]interface{}{
|
||||
"goals": "Some goal",
|
||||
}
|
||||
body, _ := json.Marshal(execData)
|
||||
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/execute", bytes.NewBuffer(body))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// No Authorization header
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
t.Logf("✓ Unauthorized execute returns 401")
|
||||
})
|
||||
}
|
||||
|
||||
// TestRobotCompletionsMirrorAPI tests POST /v1/agent/robots/:id/completions
|
||||
// Mirror API that resolves host assistant and delegates to standard chat completions.
|
||||
func TestRobotCompletionsMirrorAPI(t *testing.T) {
|
||||
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, "Completions Mirror 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")
|
||||
|
||||
robotID := fmt.Sprintf("test_completions_%d", time.Now().UnixNano())
|
||||
createTestRobotForHost(t, serverURL, baseURL, tokenInfo.AccessToken, robotID, "Completions Mirror Test Robot")
|
||||
defer deleteTestRobotForHost(t, serverURL, baseURL, tokenInfo.AccessToken, robotID)
|
||||
|
||||
t.Run("RejectsEmptyBody", func(t *testing.T) {
|
||||
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/completions",
|
||||
bytes.NewBuffer([]byte("{}")))
|
||||
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()
|
||||
|
||||
// Should be handled by the chat completion handler
|
||||
// 4xx or 5xx is acceptable — we're verifying the route resolves
|
||||
assert.True(t, resp.StatusCode >= 400, "Empty completions request should fail, got %d", resp.StatusCode)
|
||||
t.Logf("✓ Empty completions body handled: %d", resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("RejectsNonExistentRobot", func(t *testing.T) {
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"chat_id": "test_chat_id",
|
||||
"assistant_id": "some_assistant",
|
||||
"messages": []map[string]interface{}{
|
||||
{"role": "user", "content": "hello"},
|
||||
},
|
||||
})
|
||||
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/non_existent_robot/completions",
|
||||
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.True(t, resp.StatusCode >= 400, "Non-existent robot should return error, got %d", resp.StatusCode)
|
||||
t.Logf("✓ Non-existent robot completions handled: %d", resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("UnauthorizedReturns401", func(t *testing.T) {
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"chat_id": "test_chat",
|
||||
})
|
||||
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/completions",
|
||||
bytes.NewBuffer(body))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// No Authorization header
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
t.Logf("✓ Unauthorized completions returns 401")
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
func createTestRobotForHost(t *testing.T, serverURL, baseURL, token, robotID, name string) {
|
||||
t.Helper()
|
||||
createData := map[string]interface{}{
|
||||
"member_id": robotID,
|
||||
"team_id": "test_team_host_001",
|
||||
"display_name": name,
|
||||
"bio": "A test robot for host API testing",
|
||||
}
|
||||
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)
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func deleteTestRobotForHost(t *testing.T, serverURL, baseURL, token, robotID string) {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest("DELETE", serverURL+baseURL+"/agent/robots/"+robotID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue