diff --git a/agent/assistant/chat.go b/agent/assistant/chat.go index 63e4d8c6..64d96517 100644 --- a/agent/assistant/chat.go +++ b/agent/assistant/chat.go @@ -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(), } diff --git a/agent/assistant/chat_test.go b/agent/assistant/chat_test.go index 4f491757..839a3b75 100644 --- a/agent/assistant/chat_test.go +++ b/agent/assistant/chat_test.go @@ -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 diff --git a/agent/robot/executor/standard/executor.go b/agent/robot/executor/standard/executor.go index 7df0f801..8cceb5c0 100644 --- a/agent/robot/executor/standard/executor.go +++ b/agent/robot/executor/standard/executor.go @@ -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 diff --git a/agent/robot/executor/standard/executor_test.go b/agent/robot/executor/standard/executor_test.go index 4d2bcc22..d65ab90e 100644 --- a/agent/robot/executor/standard/executor_test.go +++ b/agent/robot/executor/standard/executor_test.go @@ -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 // ============================================================================ diff --git a/agent/robot/executor/types/helpers.go b/agent/robot/executor/types/helpers.go index b05a03de..1f2d13eb 100644 --- a/agent/robot/executor/types/helpers.go +++ b/agent/robot/executor/types/helpers.go @@ -30,6 +30,7 @@ func BuildTriggerInput(trigger robottypes.TriggerType, data interface{}) *robott input.EventType = req.EventType input.Data = req.Data } + } return input diff --git a/agent/robot/process.go b/agent/robot/process.go new file mode 100644 index 00000000..4e631bd4 --- /dev/null +++ b/agent/robot/process.go @@ -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 "" +} diff --git a/agent/robot/process_test.go b/agent/robot/process_test.go new file mode 100644 index 00000000..55a82152 --- /dev/null +++ b/agent/robot/process_test.go @@ -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 diff --git a/agent/store/types/types.go b/agent/store/types/types.go index dc6277ae..cfd6afce 100644 --- a/agent/store/types/types.go +++ b/agent/store/types/types.go @@ -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 diff --git a/agent/store/xun/chat.go b/agent/store/xun/chat.go index 33749b0d..0f27fb7a 100644 --- a/agent/store/xun/chat.go +++ b/agent/store/xun/chat.go @@ -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 { diff --git a/openapi/agent/robot/completions.go b/openapi/agent/robot/completions.go new file mode 100644 index 00000000..acca675c --- /dev/null +++ b/openapi/agent/robot/completions.go @@ -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, + }) +} diff --git a/openapi/agent/robot/execute.go b/openapi/agent/robot/execute.go new file mode 100644 index 00000000..5a4efaa8 --- /dev/null +++ b/openapi/agent/robot/execute.go @@ -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, + }) +} diff --git a/openapi/agent/robot/robot.go b/openapi/agent/robot/robot.go index da50d7b6..e9b54b4e 100644 --- a/openapi/agent/robot/robot.go +++ b/openapi/agent/robot/robot.go @@ -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 diff --git a/openapi/chat/session.go b/openapi/chat/session.go index e80d29c6..af78dcdc 100644 --- a/openapi/chat/session.go +++ b/openapi/chat/session.go @@ -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 != "" { diff --git a/openapi/tests/agent/robot_host_test.go b/openapi/tests/agent/robot_host_test.go new file mode 100644 index 00000000..0445f15b --- /dev/null +++ b/openapi/tests/agent/robot_host_test.go @@ -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() + } +} diff --git a/openapi/well-known.go b/openapi/well-known.go index a78e0d0c..dd38fe82 100644 --- a/openapi/well-known.go +++ b/openapi/well-known.go @@ -1,6 +1,9 @@ package openapi import ( + "os" + "strings" + "github.com/gin-gonic/gin" "github.com/yaoapp/yao/share" ) @@ -35,6 +38,11 @@ type YaoMetadata struct { OpenAPI string `json:"openapi"` // OpenAPI base URL (e.g., "/v1") 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 string `json:"dashboard,omitempty"` // Admin dashboard root path Optional map[string]interface{} `json:"optional,omitempty"` // Optional settings @@ -57,6 +65,7 @@ func (openapi *OpenAPI) yaoMetadata(c *gin.Context) { Description: share.App.Description, OpenAPI: openapi.Config.BaseURL, IssuerURL: openapi.Config.OAuth.IssuerURL, + ServerURL: resolveServerURL(openapi.Config.OAuth.IssuerURL), Dashboard: "/" + dashboard, Optional: share.App.Optional, } @@ -69,6 +78,25 @@ func (openapi *OpenAPI) yaoMetadata(c *gin.Context) { 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 func (openapi *OpenAPI) oauthServerMetadata(c *gin.Context) {}