Add chat metadata persistence and enhance executor goals injection tests

- Implement TestEnsureChatMetadata to verify that metadata, including robot_id, is correctly persisted in chat records.
- Update EnsureChat method to store metadata from the context when creating chat records.
- Introduce TestExecutorGoalsInjection to validate that pre-confirmed goals are injected into executions from TriggerInput.Data.
- Enhance executor logic to handle goal injection and persistence, ensuring accurate execution titles.
- Modify chat filtering to support chat_id_prefix for improved chat retrieval based on robot identifiers.
This commit is contained in:
Max 2026-02-28 13:57:45 +08:00
parent 5fa0e15a1d
commit 4f9238ac95
14 changed files with 1283 additions and 5 deletions

View file

@ -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(),
}

View file

@ -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

View file

@ -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

View file

@ -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
// ============================================================================

View file

@ -30,6 +30,7 @@ func BuildTriggerInput(trigger robottypes.TriggerType, data interface{}) *robott
input.EventType = req.EventType
input.Data = req.Data
}
}
return input

161
agent/robot/process.go Normal file
View 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
View 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

View file

@ -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

View file

@ -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 {

View 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,
})
}

View 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,
})
}

View file

@ -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

View file

@ -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 != "" {

View 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()
}
}