Merge pull request #1479 from trheyi/main
Add chat metadata persistence and enhance executor goals injection tests
This commit is contained in:
commit
b5bf9770b3
15 changed files with 1311 additions and 5 deletions
|
|
@ -349,6 +349,7 @@ func (ast *Assistant) EnsureChat(ctx *agentcontext.Context) error {
|
||||||
Status: "active",
|
Status: "active",
|
||||||
Share: "private",
|
Share: "private",
|
||||||
Sort: 0,
|
Sort: 0,
|
||||||
|
Metadata: ctx.Metadata,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
UpdatedAt: 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) {
|
func TestConvertBufferedTypes(t *testing.T) {
|
||||||
t.Run("ConvertBufferedMessages", func(t *testing.T) {
|
t.Run("ConvertBufferedMessages", func(t *testing.T) {
|
||||||
// Create buffered messages
|
// 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)
|
// Initialize UI display fields (with i18n support)
|
||||||
exec.Name, exec.CurrentTaskName = e.initUIFields(trigger, input, robot)
|
exec.Name, exec.CurrentTaskName = e.initUIFields(trigger, input, robot)
|
||||||
|
|
||||||
|
|
@ -120,6 +128,21 @@ func (e *Executor) ExecuteWithControl(ctx *robottypes.Context, robot *robottypes
|
||||||
"error": err,
|
"error": err,
|
||||||
}).Warn("Failed to persist execution record: %v", 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
|
// 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
|
// Helper Functions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ func BuildTriggerInput(trigger robottypes.TriggerType, data interface{}) *robott
|
||||||
input.EventType = req.EventType
|
input.EventType = req.EventType
|
||||||
input.Data = req.Data
|
input.Data = req.Data
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return input
|
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
|
// ChatFilter for listing chats
|
||||||
type ChatFilter struct {
|
type ChatFilter struct {
|
||||||
UserID string `json:"user_id,omitempty"`
|
UserID string `json:"user_id,omitempty"`
|
||||||
TeamID string `json:"team_id,omitempty"`
|
TeamID string `json:"team_id,omitempty"`
|
||||||
AssistantID string `json:"assistant_id,omitempty"`
|
AssistantID string `json:"assistant_id,omitempty"`
|
||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
Keywords string `json:"keywords,omitempty"`
|
Keywords string `json:"keywords,omitempty"`
|
||||||
|
ChatIDPrefix string `json:"chat_id_prefix,omitempty"`
|
||||||
|
|
||||||
// Time range filter
|
// Time range filter
|
||||||
StartTime *time.Time `json:"start_time,omitempty"` // Filter chats after this time
|
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 != "" {
|
if filter.Keywords != "" {
|
||||||
qb.Where("title", "like", fmt.Sprintf("%%%s%%", 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
|
// Apply time range filter
|
||||||
if filter.StartTime != nil {
|
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 (
|
import (
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
"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
|
// 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/trigger", TriggerRobot) // POST /robots/:id/trigger - Trigger robot execution
|
||||||
group.POST("/:id/intervene", InterveneRobot) // POST /robots/:id/intervene - Human intervention
|
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)
|
// V2: Unified Interact API (suspend-resume, human-in-the-loop)
|
||||||
group.POST("/:id/interact", InteractRobot) // POST /robots/:id/interact - Unified interaction
|
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
|
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.AssistantID = strings.TrimSpace(c.Query("assistant_id"))
|
||||||
filter.Status = strings.TrimSpace(c.Query("status"))
|
filter.Status = strings.TrimSpace(c.Query("status"))
|
||||||
filter.Keywords = strings.TrimSpace(c.Query("keywords"))
|
filter.Keywords = strings.TrimSpace(c.Query("keywords"))
|
||||||
|
filter.ChatIDPrefix = strings.TrimSpace(c.Query("chat_id_prefix"))
|
||||||
|
|
||||||
// Time range filter
|
// Time range filter
|
||||||
if startTimeStr := c.Query("start_time"); startTimeStr != "" {
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
package openapi
|
package openapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/yaoapp/yao/share"
|
"github.com/yaoapp/yao/share"
|
||||||
)
|
)
|
||||||
|
|
@ -35,6 +38,11 @@ type YaoMetadata struct {
|
||||||
OpenAPI string `json:"openapi"` // OpenAPI base URL (e.g., "/v1")
|
OpenAPI string `json:"openapi"` // OpenAPI base URL (e.g., "/v1")
|
||||||
IssuerURL string `json:"issuer_url,omitempty"` // OAuth issuer URL
|
IssuerURL string `json:"issuer_url,omitempty"` // OAuth issuer URL
|
||||||
|
|
||||||
|
// Public server URL — the externally accessible base URL for this instance.
|
||||||
|
// Clients, proxies, and integrations should use this to construct API endpoints.
|
||||||
|
// Set via env YAO_SERVER_URL; falls back to issuer_url (stripped of path), then empty.
|
||||||
|
ServerURL string `json:"server_url,omitempty"`
|
||||||
|
|
||||||
// Dashboard configuration
|
// Dashboard configuration
|
||||||
Dashboard string `json:"dashboard,omitempty"` // Admin dashboard root path
|
Dashboard string `json:"dashboard,omitempty"` // Admin dashboard root path
|
||||||
Optional map[string]interface{} `json:"optional,omitempty"` // Optional settings
|
Optional map[string]interface{} `json:"optional,omitempty"` // Optional settings
|
||||||
|
|
@ -57,6 +65,7 @@ func (openapi *OpenAPI) yaoMetadata(c *gin.Context) {
|
||||||
Description: share.App.Description,
|
Description: share.App.Description,
|
||||||
OpenAPI: openapi.Config.BaseURL,
|
OpenAPI: openapi.Config.BaseURL,
|
||||||
IssuerURL: openapi.Config.OAuth.IssuerURL,
|
IssuerURL: openapi.Config.OAuth.IssuerURL,
|
||||||
|
ServerURL: resolveServerURL(openapi.Config.OAuth.IssuerURL),
|
||||||
Dashboard: "/" + dashboard,
|
Dashboard: "/" + dashboard,
|
||||||
Optional: share.App.Optional,
|
Optional: share.App.Optional,
|
||||||
}
|
}
|
||||||
|
|
@ -69,6 +78,25 @@ func (openapi *OpenAPI) yaoMetadata(c *gin.Context) {
|
||||||
c.JSON(200, metadata)
|
c.JSON(200, metadata)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveServerURL returns the public server URL for this instance.
|
||||||
|
// Priority: YAO_SERVER_URL env > issuer_url origin > empty string.
|
||||||
|
func resolveServerURL(issuerURL string) string {
|
||||||
|
if v := strings.TrimRight(os.Getenv("YAO_SERVER_URL"), "/"); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
// Strip path from issuer_url to get just the origin (scheme + host + port)
|
||||||
|
if issuerURL != "" {
|
||||||
|
if idx := strings.Index(issuerURL, "://"); idx != -1 {
|
||||||
|
rest := issuerURL[idx+3:]
|
||||||
|
if slash := strings.Index(rest, "/"); slash != -1 {
|
||||||
|
return issuerURL[:idx+3] + rest[:slash]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return issuerURL
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// oauthServerMetadata returns authorization server metadata - RFC 8414
|
// oauthServerMetadata returns authorization server metadata - RFC 8414
|
||||||
func (openapi *OpenAPI) oauthServerMetadata(c *gin.Context) {}
|
func (openapi *OpenAPI) oauthServerMetadata(c *gin.Context) {}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue