Enhance Execution Management with UI Display Fields and Localization Support

- Added `Name` and `CurrentTaskName` fields to the `Execution` struct for improved UI display during execution phases.
- Implemented localization support for UI messages, allowing dynamic updates based on the execution context and user locale.
- Updated the executor to manage these fields at various phases, ensuring accurate representation of execution status.
- Enhanced OpenAPI documentation to reflect the new fields and their usage in execution responses.
- Added unit tests to validate the functionality of UI fields and localization handling.
This commit is contained in:
Max 2026-01-24 10:10:48 +08:00
parent c63c68fd7b
commit d590bd7557
24 changed files with 3252 additions and 337 deletions

View file

@ -698,17 +698,18 @@ Save to KB:
```go
type Config struct {
Triggers *Triggers `json:"triggers,omitempty"`
Clock *Clock `json:"clock,omitempty"`
Identity *Identity `json:"identity"`
Quota *Quota `json:"quota"`
KB *KB `json:"kb,omitempty"` // shared KB (same as assistant)
DB *DB `json:"db,omitempty"` // shared DB (same as assistant)
Learn *Learn `json:"learn,omitempty"` // learning for private KB
Resources *Resources `json:"resources"`
Delivery *DeliveryPreferences `json:"delivery,omitempty"`
Events []Event `json:"events,omitempty"`
Executor *Executor `json:"executor,omitempty"` // executor mode settings
Triggers *Triggers `json:"triggers,omitempty"`
Clock *Clock `json:"clock,omitempty"`
Identity *Identity `json:"identity"`
Quota *Quota `json:"quota"`
KB *KB `json:"kb,omitempty"` // shared KB (same as assistant)
DB *DB `json:"db,omitempty"` // shared DB (same as assistant)
Learn *Learn `json:"learn,omitempty"` // learning for private KB
Resources *Resources `json:"resources"`
Delivery *DeliveryPreferences `json:"delivery,omitempty"`
Events []Event `json:"events,omitempty"`
Executor *Executor `json:"executor,omitempty"` // executor mode settings
DefaultLocale string `json:"default_locale,omitempty"` // default language for clock/event triggers ("en-US", "zh-CN")
}
```

View file

@ -938,16 +938,17 @@ import "time"
// Config - robot_config in __yao.member
type Config struct {
Triggers *Triggers `json:"triggers,omitempty"`
Clock *Clock `json:"clock,omitempty"`
Identity *Identity `json:"identity"`
Quota *Quota `json:"quota,omitempty"`
KB *KB `json:"kb,omitempty"` // shared knowledge base (same as assistant)
DB *DB `json:"db,omitempty"` // shared database (same as assistant)
Learn *Learn `json:"learn,omitempty"` // learning config for private KB
Resources *Resources `json:"resources,omitempty"`
Delivery *DeliveryPreferences `json:"delivery,omitempty"` // see section 6.2
Events []Event `json:"events,omitempty"`
Triggers *Triggers `json:"triggers,omitempty"`
Clock *Clock `json:"clock,omitempty"`
Identity *Identity `json:"identity"`
Quota *Quota `json:"quota,omitempty"`
KB *KB `json:"kb,omitempty"` // shared knowledge base (same as assistant)
DB *DB `json:"db,omitempty"` // shared database (same as assistant)
Learn *Learn `json:"learn,omitempty"` // learning config for private KB
Resources *Resources `json:"resources,omitempty"`
Delivery *DeliveryPreferences `json:"delivery,omitempty"` // see section 6.2
Events []Event `json:"events,omitempty"`
DefaultLocale string `json:"default_locale,omitempty"` // Default language for clock/event triggers (e.g., "en-US", "zh-CN")
}
// Validate validates the config
@ -1238,6 +1239,10 @@ type Execution struct {
Phase Phase `json:"phase"`
Error string `json:"error,omitempty"`
// UI display fields (updated by executor at each phase)
// These provide human-readable status for frontend display
Name string `json:"name,omitempty"` // Execution title (updated when goals complete)
CurrentTaskName string `json:"current_task_name,omitempty"` // Current task description (updated during run phase)
// Trigger input (stored for traceability)
Input *TriggerInput `json:"input,omitempty"` // original trigger input
@ -1263,6 +1268,7 @@ type TriggerInput struct {
Action InterventionAction `json:"action,omitempty"` // task.add, goal.adjust, etc.
Messages []context.Message `json:"messages,omitempty"` // user's input (text, images, files)
UserID string `json:"user_id,omitempty"` // who triggered
Locale string `json:"locale,omitempty"` // language for UI display (e.g., "en-US", "zh-CN")
// For event trigger
Source EventSource `json:"source,omitempty"` // webhook | database

View file

@ -64,6 +64,9 @@ type TriggerRequest struct {
// Executor mode (optional, overrides robot config)
ExecutorMode types.ExecutorMode `json:"executor_mode,omitempty"`
// i18n support
Locale string `json:"locale,omitempty"` // Locale for UI messages (e.g., "en", "zh")
}
// InsertPosition - where to insert task in queue

View file

@ -54,6 +54,7 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
}
// Create execution (Job system removed, using ExecutionStore only)
input := types.BuildTriggerInput(trigger, data)
exec := &robottypes.Execution{
ID: utils.NewID(),
MemberID: robot.MemberID,
@ -62,9 +63,12 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
StartTime: time.Now(),
Status: robottypes.ExecPending,
Phase: robottypes.AllPhases[startPhaseIndex],
Input: types.BuildTriggerInput(trigger, data),
Input: input,
}
// Initialize UI display fields (with i18n support)
exec.Name, exec.CurrentTaskName = e.initUIFields(trigger, input, robot)
// Set robot reference for phase methods
exec.SetRobot(robot)
@ -138,12 +142,24 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
return exec, nil
}
// Determine locale for UI messages
locale := getEffectiveLocale(robot, exec.Input)
// Execute phases
phases := robottypes.AllPhases[startPhaseIndex:]
for _, phase := range phases {
if err := e.runPhase(ctx, exec, phase, data); err != nil {
exec.Status = robottypes.ExecFailed
exec.Error = err.Error()
// Update UI field for failure with i18n
failedPrefix := getLocalizedMessage(locale, "failed_prefix")
failureMsg := failedPrefix + err.Error()
if len(failureMsg) > 100 {
failureMsg = failureMsg[:100] + "..."
}
e.updateUIFields(ctx, exec, "", failureMsg)
log.With(log.F{
"execution_id": exec.ID,
"member_id": exec.MemberID,
@ -163,6 +179,9 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
now := time.Now()
exec.EndTime = &now
// Update UI field for completion with i18n
e.updateUIFields(ctx, exec, "", getLocalizedMessage(locale, "completed"))
duration := now.Sub(exec.StartTime)
log.With(log.F{
"execution_id": exec.ID,
@ -301,5 +320,154 @@ func (e *Executor) simulateStreamDelay() {
time.Sleep(DefaultStreamDelay)
}
// initUIFields initializes UI display fields based on trigger type with i18n support
// Returns (name, currentTaskName)
func (e *Executor) initUIFields(trigger robottypes.TriggerType, input *robottypes.TriggerInput, robot *robottypes.Robot) (string, string) {
// Determine locale for UI messages
locale := getEffectiveLocale(robot, input)
// Get localized default messages
name := getLocalizedMessage(locale, "preparing")
currentTaskName := getLocalizedMessage(locale, "starting")
switch trigger {
case robottypes.TriggerHuman:
// For human trigger, extract name from first message
if input != nil && len(input.Messages) > 0 {
if content, ok := input.Messages[0].GetContentAsString(); ok && content != "" {
// Use first 100 chars of message as name
name = content
if len(name) > 100 {
name = name[:100] + "..."
}
}
}
case robottypes.TriggerClock:
name = getLocalizedMessage(locale, "scheduled_execution")
case robottypes.TriggerEvent:
if input != nil && input.EventType != "" {
name = getLocalizedMessage(locale, "event_prefix") + input.EventType
} else {
name = getLocalizedMessage(locale, "event_triggered")
}
}
return name, currentTaskName
}
// getEffectiveLocale determines the locale for UI display
// Priority: input.Locale > robot.Config.DefaultLocale > "en"
func getEffectiveLocale(robot *robottypes.Robot, input *robottypes.TriggerInput) string {
// 1. Human trigger with explicit locale
if input != nil && input.Locale != "" {
return input.Locale
}
// 2. Robot configured default
if robot != nil && robot.Config != nil {
return robot.Config.GetDefaultLocale()
}
// 3. System default
return "en"
}
// i18n message maps for UI display fields
// Use simple locale codes (en, zh) as keys
var uiMessages = map[string]map[string]string{
"en": {
"preparing": "Preparing...",
"starting": "Starting...",
"scheduled_execution": "Scheduled execution",
"event_prefix": "Event: ",
"event_triggered": "Event triggered",
"analyzing_context": "Analyzing context...",
"planning_goals": "Planning goals...",
"breaking_down_tasks": "Breaking down tasks...",
"completed": "Completed",
"failed_prefix": "Failed: ",
"task_prefix": "Task",
},
"zh": {
"preparing": "准备中...",
"starting": "启动中...",
"scheduled_execution": "定时执行",
"event_prefix": "事件: ",
"event_triggered": "事件触发",
"analyzing_context": "分析上下文...",
"planning_goals": "规划目标...",
"breaking_down_tasks": "分解任务...",
"completed": "已完成",
"failed_prefix": "失败: ",
"task_prefix": "任务",
},
}
// getLocalizedMessage returns a localized message for the given key
func getLocalizedMessage(locale string, key string) string {
if messages, ok := uiMessages[locale]; ok {
if msg, ok := messages[key]; ok {
return msg
}
}
// Fallback to English
if messages, ok := uiMessages["en"]; ok {
if msg, ok := messages[key]; ok {
return msg
}
}
return key // Return key as fallback
}
// updateUIFields updates UI display fields and persists to database
func (e *Executor) updateUIFields(ctx *robottypes.Context, exec *robottypes.Execution, name string, currentTaskName string) {
// Update in-memory execution
if name != "" {
exec.Name = name
}
if currentTaskName != "" {
exec.CurrentTaskName = currentTaskName
}
// Persist to database
if !e.config.SkipPersistence && e.store != nil {
if err := e.store.UpdateUIFields(ctx.Context, exec.ID, name, currentTaskName); err != nil {
log.With(log.F{
"execution_id": exec.ID,
"error": err,
}).Warn("Failed to update UI fields: %v", err)
}
}
}
// extractGoalName extracts the execution name from goals output
func extractGoalName(goals *robottypes.Goals) string {
if goals == nil || goals.Content == "" {
return ""
}
// Extract first line or first sentence as the goal name
content := goals.Content
// Find first newline
if idx := indexAny(content, "\n\r"); idx > 0 {
content = content[:idx]
}
// Limit length
if len(content) > 150 {
content = content[:150] + "..."
}
return content
}
// indexAny returns the index of the first occurrence of any char in chars
func indexAny(s string, chars string) int {
for i, c := range s {
for _, ch := range chars {
if c == ch {
return i
}
}
}
return -1
}
// Verify Executor implements types.Executor
var _ types.Executor = (*Executor)(nil)

View file

@ -23,6 +23,10 @@ func (e *Executor) RunGoals(ctx *robottypes.Context, exec *robottypes.Execution,
return fmt.Errorf("robot not found in execution")
}
// Update UI field with i18n
locale := getEffectiveLocale(robot, exec.Input)
e.updateUIFields(ctx, exec, "", getLocalizedMessage(locale, "planning_goals"))
// Get agent ID for goals phase
agentID := "__yao.goals" // default
if robot.Config != nil && robot.Config.Resources != nil {
@ -113,6 +117,11 @@ func (e *Executor) RunGoals(ctx *robottypes.Context, exec *robottypes.Execution,
return fmt.Errorf("goals agent (%s) returned empty content", agentID)
}
// Update Name from goals content (extract first line as execution title)
if goalName := extractGoalName(exec.Goals); goalName != "" {
e.updateUIFields(ctx, exec, goalName, "")
}
return nil
}

View file

@ -23,6 +23,10 @@ func (e *Executor) RunInspiration(ctx *robottypes.Context, exec *robottypes.Exec
return fmt.Errorf("robot not found in execution")
}
// Update UI field with i18n
locale := getEffectiveLocale(robot, exec.Input)
e.updateUIFields(ctx, exec, "", getLocalizedMessage(locale, "analyzing_context"))
// Build clock context from trigger input or current time
var clock *robottypes.ClockContext
if exec.Input != nil && exec.Input.Clock != nil {

View file

@ -64,6 +64,9 @@ func (e *Executor) RunExecution(ctx *robottypes.Context, exec *robottypes.Execut
config = DefaultRunConfig()
}
// Determine locale for UI messages
locale := getEffectiveLocale(robot, exec.Input)
// Initialize results slice
exec.Results = make([]robottypes.TaskResult, 0, len(exec.Tasks))
@ -81,6 +84,10 @@ func (e *Executor) RunExecution(ctx *robottypes.Context, exec *robottypes.Execut
Progress: fmt.Sprintf("%d/%d tasks", i+1, len(exec.Tasks)),
}
// Update UI field with current task description (i18n)
taskName := formatTaskProgressName(task, i, len(exec.Tasks), locale)
e.updateUIFields(ctx, exec, "", taskName)
// Mark task as running
task.Status = robottypes.TaskRunning
now := time.Now()
@ -122,3 +129,23 @@ func (e *Executor) RunExecution(ctx *robottypes.Context, exec *robottypes.Execut
return nil
}
// formatTaskProgressName formats a progress name for the current task (used for UI with i18n)
func formatTaskProgressName(task *robottypes.Task, index int, total int, locale string) string {
taskPrefix := getLocalizedMessage(locale, "task_prefix")
prefix := fmt.Sprintf("%s %d/%d: ", taskPrefix, index+1, total)
// Try to get description from first message
if len(task.Messages) > 0 {
if content, ok := task.Messages[0].GetContentAsString(); ok && content != "" {
// Truncate if too long
if len(content) > 80 {
content = content[:80] + "..."
}
return prefix + content
}
}
// Fallback to executor info
return prefix + string(task.ExecutorType) + ":" + task.ExecutorID
}

View file

@ -23,6 +23,10 @@ func (e *Executor) RunTasks(ctx *robottypes.Context, exec *robottypes.Execution,
return fmt.Errorf("robot not found in execution")
}
// Update UI field with i18n
locale := getEffectiveLocale(robot, exec.Input)
e.updateUIFields(ctx, exec, "", getLocalizedMessage(locale, "breaking_down_tasks"))
// Validate: Goals must exist (from P1)
if exec.Goals == nil || exec.Goals.Content == "" {
return fmt.Errorf("goals not available for task planning")

View file

@ -0,0 +1,356 @@
package standard
import (
"testing"
"github.com/stretchr/testify/assert"
agentcontext "github.com/yaoapp/yao/agent/context"
robottypes "github.com/yaoapp/yao/agent/robot/types"
)
// ============================================================================
// getEffectiveLocale Tests
// ============================================================================
func TestGetEffectiveLocale(t *testing.T) {
t.Run("returns_input_locale_when_provided", func(t *testing.T) {
robot := &robottypes.Robot{
Config: &robottypes.Config{
DefaultLocale: "en",
},
}
input := &robottypes.TriggerInput{
Locale: "zh",
}
locale := getEffectiveLocale(robot, input)
assert.Equal(t, "zh", locale)
})
t.Run("returns_robot_default_locale_when_input_locale_empty", func(t *testing.T) {
robot := &robottypes.Robot{
Config: &robottypes.Config{
DefaultLocale: "zh",
},
}
input := &robottypes.TriggerInput{
Locale: "",
}
locale := getEffectiveLocale(robot, input)
assert.Equal(t, "zh", locale)
})
t.Run("returns_system_default_when_no_locale_configured", func(t *testing.T) {
robot := &robottypes.Robot{
Config: &robottypes.Config{},
}
input := &robottypes.TriggerInput{}
locale := getEffectiveLocale(robot, input)
assert.Equal(t, "en", locale)
})
t.Run("returns_system_default_when_robot_config_nil", func(t *testing.T) {
robot := &robottypes.Robot{}
input := &robottypes.TriggerInput{}
locale := getEffectiveLocale(robot, input)
assert.Equal(t, "en", locale)
})
t.Run("returns_system_default_when_robot_nil", func(t *testing.T) {
input := &robottypes.TriggerInput{}
locale := getEffectiveLocale(nil, input)
assert.Equal(t, "en", locale)
})
t.Run("returns_system_default_when_input_nil", func(t *testing.T) {
robot := &robottypes.Robot{}
locale := getEffectiveLocale(robot, nil)
assert.Equal(t, "en", locale)
})
}
// ============================================================================
// getLocalizedMessage Tests
// ============================================================================
func TestGetLocalizedMessage(t *testing.T) {
t.Run("returns_english_message_for_en_locale", func(t *testing.T) {
msg := getLocalizedMessage("en", "preparing")
assert.Equal(t, "Preparing...", msg)
})
t.Run("returns_chinese_message_for_zh_locale", func(t *testing.T) {
msg := getLocalizedMessage("zh", "preparing")
assert.Equal(t, "准备中...", msg)
})
t.Run("returns_english_fallback_for_unknown_locale", func(t *testing.T) {
msg := getLocalizedMessage("fr", "preparing")
assert.Equal(t, "Preparing...", msg)
})
t.Run("returns_key_for_unknown_message", func(t *testing.T) {
msg := getLocalizedMessage("en", "unknown_key")
assert.Equal(t, "unknown_key", msg)
})
t.Run("all_english_messages_exist", func(t *testing.T) {
keys := []string{
"preparing", "starting", "scheduled_execution",
"event_prefix", "event_triggered", "analyzing_context",
"planning_goals", "breaking_down_tasks", "completed",
"failed_prefix", "task_prefix",
}
for _, key := range keys {
msg := getLocalizedMessage("en", key)
assert.NotEqual(t, key, msg, "English message should exist for key: %s", key)
}
})
t.Run("all_chinese_messages_exist", func(t *testing.T) {
keys := []string{
"preparing", "starting", "scheduled_execution",
"event_prefix", "event_triggered", "analyzing_context",
"planning_goals", "breaking_down_tasks", "completed",
"failed_prefix", "task_prefix",
}
for _, key := range keys {
msg := getLocalizedMessage("zh", key)
assert.NotEqual(t, key, msg, "Chinese message should exist for key: %s", key)
}
})
}
// ============================================================================
// initUIFields Tests
// ============================================================================
func TestInitUIFields(t *testing.T) {
executor := New()
t.Run("human_trigger_extracts_name_from_message", func(t *testing.T) {
robot := &robottypes.Robot{
Config: &robottypes.Config{DefaultLocale: "en"},
}
input := &robottypes.TriggerInput{
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Please analyze the sales data"},
},
}
name, currentTaskName := executor.initUIFields(robottypes.TriggerHuman, input, robot)
assert.Equal(t, "Please analyze the sales data", name)
assert.Equal(t, "Starting...", currentTaskName)
})
t.Run("human_trigger_truncates_long_message", func(t *testing.T) {
robot := &robottypes.Robot{
Config: &robottypes.Config{DefaultLocale: "en"},
}
longMessage := "This is a very long message that exceeds one hundred characters and should be truncated with an ellipsis at the end"
input := &robottypes.TriggerInput{
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: longMessage},
},
}
name, _ := executor.initUIFields(robottypes.TriggerHuman, input, robot)
assert.LessOrEqual(t, len(name), 103) // 100 chars + "..."
assert.True(t, len(name) > 100 || name == longMessage[:100]+"...")
})
t.Run("clock_trigger_uses_scheduled_execution", func(t *testing.T) {
robot := &robottypes.Robot{
Config: &robottypes.Config{DefaultLocale: "en"},
}
input := &robottypes.TriggerInput{}
name, currentTaskName := executor.initUIFields(robottypes.TriggerClock, input, robot)
assert.Equal(t, "Scheduled execution", name)
assert.Equal(t, "Starting...", currentTaskName)
})
t.Run("clock_trigger_chinese_locale", func(t *testing.T) {
robot := &robottypes.Robot{
Config: &robottypes.Config{DefaultLocale: "zh"},
}
input := &robottypes.TriggerInput{}
name, currentTaskName := executor.initUIFields(robottypes.TriggerClock, input, robot)
assert.Equal(t, "定时执行", name)
assert.Equal(t, "启动中...", currentTaskName)
})
t.Run("event_trigger_with_event_type", func(t *testing.T) {
robot := &robottypes.Robot{
Config: &robottypes.Config{DefaultLocale: "en"},
}
input := &robottypes.TriggerInput{
EventType: "lead.created",
}
name, currentTaskName := executor.initUIFields(robottypes.TriggerEvent, input, robot)
assert.Equal(t, "Event: lead.created", name)
assert.Equal(t, "Starting...", currentTaskName)
})
t.Run("event_trigger_without_event_type", func(t *testing.T) {
robot := &robottypes.Robot{
Config: &robottypes.Config{DefaultLocale: "en"},
}
input := &robottypes.TriggerInput{}
name, _ := executor.initUIFields(robottypes.TriggerEvent, input, robot)
assert.Equal(t, "Event triggered", name)
})
t.Run("event_trigger_chinese_locale", func(t *testing.T) {
robot := &robottypes.Robot{
Config: &robottypes.Config{DefaultLocale: "zh"},
}
input := &robottypes.TriggerInput{
EventType: "order.placed",
}
name, _ := executor.initUIFields(robottypes.TriggerEvent, input, robot)
assert.Equal(t, "事件: order.placed", name)
})
t.Run("input_locale_overrides_robot_default", func(t *testing.T) {
robot := &robottypes.Robot{
Config: &robottypes.Config{DefaultLocale: "en"},
}
input := &robottypes.TriggerInput{
Locale: "zh",
}
name, currentTaskName := executor.initUIFields(robottypes.TriggerClock, input, robot)
assert.Equal(t, "定时执行", name)
assert.Equal(t, "启动中...", currentTaskName)
})
}
// ============================================================================
// extractGoalName Tests
// ============================================================================
func TestExtractGoalName(t *testing.T) {
t.Run("extracts_first_line_from_content", func(t *testing.T) {
goals := &robottypes.Goals{
Content: "Generate monthly sales report\nAnalyze trends\nSend to stakeholders",
}
name := extractGoalName(goals)
assert.Equal(t, "Generate monthly sales report", name)
})
t.Run("returns_empty_for_nil_goals", func(t *testing.T) {
name := extractGoalName(nil)
assert.Equal(t, "", name)
})
t.Run("returns_empty_for_empty_content", func(t *testing.T) {
goals := &robottypes.Goals{
Content: "",
}
name := extractGoalName(goals)
assert.Equal(t, "", name)
})
t.Run("truncates_long_first_line", func(t *testing.T) {
longLine := "This is an extremely long goal description that exceeds one hundred and fifty characters and should be truncated with an ellipsis at the end to keep the display manageable"
goals := &robottypes.Goals{
Content: longLine,
}
name := extractGoalName(goals)
assert.LessOrEqual(t, len(name), 153) // 150 chars + "..."
})
t.Run("handles_single_line_content", func(t *testing.T) {
goals := &robottypes.Goals{
Content: "Single line goal",
}
name := extractGoalName(goals)
assert.Equal(t, "Single line goal", name)
})
t.Run("handles_carriage_return", func(t *testing.T) {
goals := &robottypes.Goals{
Content: "First goal\r\nSecond goal",
}
name := extractGoalName(goals)
assert.Equal(t, "First goal", name)
})
}
// ============================================================================
// formatTaskProgressName Tests
// ============================================================================
func TestFormatTaskProgressName(t *testing.T) {
t.Run("formats_with_task_description", func(t *testing.T) {
task := &robottypes.Task{
ID: "task-001",
ExecutorType: robottypes.ExecutorAssistant,
ExecutorID: "analyst",
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Analyze sales data"},
},
}
name := formatTaskProgressName(task, 0, 3, "en")
assert.Equal(t, "Task 1/3: Analyze sales data", name)
})
t.Run("formats_with_chinese_locale", func(t *testing.T) {
task := &robottypes.Task{
ID: "task-001",
ExecutorType: robottypes.ExecutorAssistant,
ExecutorID: "analyst",
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "分析销售数据"},
},
}
name := formatTaskProgressName(task, 1, 5, "zh")
assert.Equal(t, "任务 2/5: 分析销售数据", name)
})
t.Run("truncates_long_description", func(t *testing.T) {
longDesc := "This is a very long task description that should be truncated because it exceeds 80 characters which is the maximum length allowed"
task := &robottypes.Task{
ID: "task-001",
ExecutorType: robottypes.ExecutorAssistant,
ExecutorID: "analyst",
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: longDesc},
},
}
name := formatTaskProgressName(task, 0, 1, "en")
// Should be "Task 1/1: " (11 chars) + truncated content (83 chars max with "...")
assert.Contains(t, name, "...")
assert.LessOrEqual(t, len(name), 100)
})
t.Run("fallback_to_executor_info_when_no_messages", func(t *testing.T) {
task := &robottypes.Task{
ID: "task-001",
ExecutorType: robottypes.ExecutorMCP,
ExecutorID: "calculator",
Messages: []agentcontext.Message{},
}
name := formatTaskProgressName(task, 2, 4, "en")
assert.Equal(t, "Task 3/4: mcp:calculator", name)
})
}

View file

@ -25,6 +25,10 @@ type ExecutionRecord struct {
Current *CurrentState `json:"current,omitempty"`
Error string `json:"error,omitempty"`
// UI display fields (updated by executor at each phase)
Name string `json:"name,omitempty"` // Execution title
CurrentTaskName string `json:"current_task_name,omitempty"` // Current task description
// Trigger input
Input *types.TriggerInput `json:"input,omitempty"`
@ -313,6 +317,41 @@ func (s *ExecutionStore) UpdateCurrent(ctx context.Context, executionID string,
return nil
}
// UpdateUIFields updates the UI display fields (name and current_task_name)
// These fields are updated by executor at each phase for frontend display
func (s *ExecutionStore) UpdateUIFields(ctx context.Context, executionID string, name string, currentTaskName string) error {
mod := model.Select(s.modelID)
if mod == nil {
return fmt.Errorf("model %s not found", s.modelID)
}
updateData := map[string]interface{}{}
if name != "" {
updateData["name"] = name
}
if currentTaskName != "" {
updateData["current_task_name"] = currentTaskName
}
if len(updateData) == 0 {
return nil // Nothing to update
}
_, err := mod.UpdateWhere(
model.QueryParam{
Wheres: []model.QueryWhere{
{Column: "execution_id", Value: executionID},
},
},
updateData,
)
if err != nil {
return fmt.Errorf("failed to update UI fields: %w", err)
}
return nil
}
// Delete removes an execution record by execution_id
func (s *ExecutionStore) Delete(ctx context.Context, executionID string) error {
mod := model.Select(s.modelID)
@ -346,6 +385,12 @@ func (s *ExecutionStore) recordToMap(record *ExecutionRecord) map[string]interfa
if record.Error != "" {
data["error"] = record.Error
}
if record.Name != "" {
data["name"] = record.Name
}
if record.CurrentTaskName != "" {
data["current_task_name"] = record.CurrentTaskName
}
if record.Current != nil {
data["current"] = record.Current
}
@ -416,6 +461,12 @@ func (s *ExecutionStore) mapToRecord(row map[string]interface{}) (*ExecutionReco
if v, ok := row["error"].(string); ok {
record.Error = v
}
if v, ok := row["name"].(string); ok {
record.Name = v
}
if v, ok := row["current_task_name"].(string); ok {
record.CurrentTaskName = v
}
// JSON fields - need to unmarshal
if v := row["current"]; v != nil {
@ -624,20 +675,22 @@ func (s *ExecutionStore) parseTime(v interface{}) *time.Time {
// FromExecution creates an ExecutionRecord from a runtime Execution
func FromExecution(exec *types.Execution) *ExecutionRecord {
record := &ExecutionRecord{
ExecutionID: exec.ID,
MemberID: exec.MemberID,
TeamID: exec.TeamID,
TriggerType: exec.TriggerType,
Status: exec.Status,
Phase: exec.Phase,
Error: exec.Error,
Input: exec.Input,
Inspiration: exec.Inspiration,
Goals: exec.Goals,
Tasks: exec.Tasks,
Results: exec.Results,
Delivery: exec.Delivery,
Learning: exec.Learning,
ExecutionID: exec.ID,
MemberID: exec.MemberID,
TeamID: exec.TeamID,
TriggerType: exec.TriggerType,
Status: exec.Status,
Phase: exec.Phase,
Error: exec.Error,
Name: exec.Name,
CurrentTaskName: exec.CurrentTaskName,
Input: exec.Input,
Inspiration: exec.Inspiration,
Goals: exec.Goals,
Tasks: exec.Tasks,
Results: exec.Results,
Delivery: exec.Delivery,
Learning: exec.Learning,
}
// Convert timestamps
@ -662,20 +715,22 @@ func FromExecution(exec *types.Execution) *ExecutionRecord {
// ToExecution converts an ExecutionRecord to a runtime Execution
func (r *ExecutionRecord) ToExecution() *types.Execution {
exec := &types.Execution{
ID: r.ExecutionID,
MemberID: r.MemberID,
TeamID: r.TeamID,
TriggerType: r.TriggerType,
Status: r.Status,
Phase: r.Phase,
Error: r.Error,
Input: r.Input,
Inspiration: r.Inspiration,
Goals: r.Goals,
Tasks: r.Tasks,
Results: r.Results,
Delivery: r.Delivery,
Learning: r.Learning,
ID: r.ExecutionID,
MemberID: r.MemberID,
TeamID: r.TeamID,
TriggerType: r.TriggerType,
Status: r.Status,
Phase: r.Phase,
Error: r.Error,
Name: r.Name,
CurrentTaskName: r.CurrentTaskName,
Input: r.Input,
Inspiration: r.Inspiration,
Goals: r.Goals,
Tasks: r.Tasks,
Results: r.Results,
Delivery: r.Delivery,
Learning: r.Learning,
}
// Convert timestamps

View file

@ -505,6 +505,105 @@ func TestExecutionStoreUpdateCurrent(t *testing.T) {
})
}
// TestExecutionStoreUpdateUIFields tests updating UI display fields
func TestExecutionStoreUpdateUIFields(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
cleanupTestExecutions(t)
defer cleanupTestExecutions(t)
s := store.NewExecutionStore()
ctx := context.Background()
// Create a base record
startTime := time.Now()
record := &store.ExecutionRecord{
ExecutionID: "exec_test_uifields_001",
MemberID: "member_uifields_001",
TeamID: "team_uifields_001",
TriggerType: types.TriggerHuman,
Status: types.ExecRunning,
Phase: types.PhaseInspiration,
StartTime: &startTime,
}
err := s.Save(ctx, record)
require.NoError(t, err)
t.Run("updates_name_only", func(t *testing.T) {
err := s.UpdateUIFields(ctx, "exec_test_uifields_001", "Analyze sales data", "")
require.NoError(t, err)
saved, err := s.Get(ctx, "exec_test_uifields_001")
require.NoError(t, err)
assert.Equal(t, "Analyze sales data", saved.Name)
assert.Equal(t, "", saved.CurrentTaskName)
})
t.Run("updates_current_task_name_only", func(t *testing.T) {
err := s.UpdateUIFields(ctx, "exec_test_uifields_001", "", "Analyzing context...")
require.NoError(t, err)
saved, err := s.Get(ctx, "exec_test_uifields_001")
require.NoError(t, err)
assert.Equal(t, "Analyze sales data", saved.Name) // Previous value retained
assert.Equal(t, "Analyzing context...", saved.CurrentTaskName)
})
t.Run("updates_both_fields", func(t *testing.T) {
err := s.UpdateUIFields(ctx, "exec_test_uifields_001", "Generate monthly report", "Task 1/3: Collect data")
require.NoError(t, err)
saved, err := s.Get(ctx, "exec_test_uifields_001")
require.NoError(t, err)
assert.Equal(t, "Generate monthly report", saved.Name)
assert.Equal(t, "Task 1/3: Collect data", saved.CurrentTaskName)
})
t.Run("does_nothing_when_both_empty", func(t *testing.T) {
// Get current values
before, err := s.Get(ctx, "exec_test_uifields_001")
require.NoError(t, err)
// Update with empty strings
err = s.UpdateUIFields(ctx, "exec_test_uifields_001", "", "")
require.NoError(t, err)
// Values should remain unchanged
after, err := s.Get(ctx, "exec_test_uifields_001")
require.NoError(t, err)
assert.Equal(t, before.Name, after.Name)
assert.Equal(t, before.CurrentTaskName, after.CurrentTaskName)
})
t.Run("handles_chinese_content", func(t *testing.T) {
err := s.UpdateUIFields(ctx, "exec_test_uifields_001", "生成月度报告", "任务 2/3: 分析数据")
require.NoError(t, err)
saved, err := s.Get(ctx, "exec_test_uifields_001")
require.NoError(t, err)
assert.Equal(t, "生成月度报告", saved.Name)
assert.Equal(t, "任务 2/3: 分析数据", saved.CurrentTaskName)
})
t.Run("handles_long_content", func(t *testing.T) {
longName := "This is a very long execution name that might come from a detailed user instruction about what they want the robot to accomplish in this particular run cycle"
longTask := "Task 1/5: Processing a complex multi-step operation with various sub-tasks that need to be completed..."
err := s.UpdateUIFields(ctx, "exec_test_uifields_001", longName, longTask)
require.NoError(t, err)
saved, err := s.Get(ctx, "exec_test_uifields_001")
require.NoError(t, err)
assert.Equal(t, longName, saved.Name)
assert.Equal(t, longTask, saved.CurrentTaskName)
})
}
// TestExecutionStoreDelete tests deleting execution records
func TestExecutionStoreDelete(t *testing.T) {
if testing.Short() {
@ -569,17 +668,19 @@ func TestExecutionRecordConversion(t *testing.T) {
now := time.Now()
endTime := now.Add(time.Hour)
exec := &types.Execution{
ID: "exec_convert_001",
MemberID: "member_convert_001",
TeamID: "team_convert_001",
TriggerType: types.TriggerHuman,
Status: types.ExecCompleted,
Phase: types.PhaseDelivery,
StartTime: now,
EndTime: &endTime,
Error: "",
Inspiration: &types.InspirationReport{Content: "Test inspiration"},
Goals: &types.Goals{Content: "Test goals"},
ID: "exec_convert_001",
MemberID: "member_convert_001",
TeamID: "team_convert_001",
TriggerType: types.TriggerHuman,
Status: types.ExecCompleted,
Phase: types.PhaseDelivery,
StartTime: now,
EndTime: &endTime,
Error: "",
Name: "Analyze sales data",
CurrentTaskName: "Task 1/3: Processing",
Inspiration: &types.InspirationReport{Content: "Test inspiration"},
Goals: &types.Goals{Content: "Test goals"},
Tasks: []types.Task{
{ID: "task_001", ExecutorType: types.ExecutorAssistant},
},
@ -608,22 +709,27 @@ func TestExecutionRecordConversion(t *testing.T) {
assert.Len(t, record.Results, 1)
assert.NotNil(t, record.Current)
assert.Equal(t, 1, record.Current.TaskIndex)
// Verify UI fields conversion
assert.Equal(t, "Analyze sales data", record.Name)
assert.Equal(t, "Task 1/3: Processing", record.CurrentTaskName)
})
t.Run("converts_to_execution", func(t *testing.T) {
now := time.Now()
endTime := now.Add(time.Hour)
record := &store.ExecutionRecord{
ExecutionID: "exec_convert_002",
MemberID: "member_convert_002",
TeamID: "team_convert_002",
TriggerType: types.TriggerClock,
Status: types.ExecRunning,
Phase: types.PhaseRun,
StartTime: &now,
EndTime: &endTime,
Inspiration: &types.InspirationReport{Content: "Test inspiration"},
Goals: &types.Goals{Content: "Test goals"},
ExecutionID: "exec_convert_002",
MemberID: "member_convert_002",
TeamID: "team_convert_002",
TriggerType: types.TriggerClock,
Status: types.ExecRunning,
Phase: types.PhaseRun,
StartTime: &now,
EndTime: &endTime,
Name: "定时执行",
CurrentTaskName: "任务 1/2: 数据分析",
Inspiration: &types.InspirationReport{Content: "Test inspiration"},
Goals: &types.Goals{Content: "Test goals"},
Tasks: []types.Task{
{ID: "task_002", ExecutorType: types.ExecutorProcess},
},
@ -650,6 +756,9 @@ func TestExecutionRecordConversion(t *testing.T) {
assert.Len(t, exec.Results, 1)
assert.NotNil(t, exec.Current)
assert.Equal(t, 0, exec.Current.TaskIndex)
// Verify UI fields conversion
assert.Equal(t, "定时执行", exec.Name)
assert.Equal(t, "任务 1/2: 数据分析", exec.CurrentTaskName)
})
}

View file

@ -7,17 +7,18 @@ import (
// Config - robot_config in __yao.member
type Config struct {
Triggers *Triggers `json:"triggers,omitempty"`
Clock *Clock `json:"clock,omitempty"`
Identity *Identity `json:"identity"`
Quota *Quota `json:"quota,omitempty"`
KB *KB `json:"kb,omitempty"` // shared knowledge base (same as assistant)
DB *DB `json:"db,omitempty"` // shared database (same as assistant)
Learn *Learn `json:"learn,omitempty"` // learning config for private KB
Resources *Resources `json:"resources,omitempty"`
Delivery *DeliveryPreferences `json:"delivery,omitempty"` // delivery preferences (see robot.go)
Events []Event `json:"events,omitempty"`
Executor *ExecutorConfig `json:"executor,omitempty"` // executor mode settings
Triggers *Triggers `json:"triggers,omitempty"`
Clock *Clock `json:"clock,omitempty"`
Identity *Identity `json:"identity"`
Quota *Quota `json:"quota,omitempty"`
KB *KB `json:"kb,omitempty"` // shared knowledge base (same as assistant)
DB *DB `json:"db,omitempty"` // shared database (same as assistant)
Learn *Learn `json:"learn,omitempty"` // learning config for private KB
Resources *Resources `json:"resources,omitempty"`
Delivery *DeliveryPreferences `json:"delivery,omitempty"` // delivery preferences (see robot.go)
Events []Event `json:"events,omitempty"`
Executor *ExecutorConfig `json:"executor,omitempty"` // executor mode settings
DefaultLocale string `json:"default_locale,omitempty"` // default language for clock/event triggers ("en", "zh")
}
// ExecutorConfig - executor settings
@ -59,6 +60,14 @@ func (c *Config) Validate() error {
return nil
}
// GetDefaultLocale returns the default locale (default: "en")
func (c *Config) GetDefaultLocale() string {
if c == nil || c.DefaultLocale == "" {
return "en"
}
return c.DefaultLocale
}
// Triggers - trigger enable/disable
type Triggers struct {
Clock *TriggerSwitch `json:"clock,omitempty"`

View file

@ -129,6 +129,11 @@ type Execution struct {
Phase Phase `json:"phase"`
Error string `json:"error,omitempty"`
// UI display fields (updated by executor at each phase)
// These provide human-readable status for frontend display
Name string `json:"name,omitempty"` // Execution title (updated when goals complete)
CurrentTaskName string `json:"current_task_name,omitempty"` // Current task description (updated during run phase)
// Trigger input (stored for traceability)
Input *TriggerInput `json:"input,omitempty"` // original trigger input
@ -163,6 +168,7 @@ type TriggerInput struct {
Action InterventionAction `json:"action,omitempty"` // task.add, goal.adjust, etc.
Messages []agentcontext.Message `json:"messages,omitempty"` // user's input (text, images, files)
UserID string `json:"user_id,omitempty"` // who triggered
Locale string `json:"locale,omitempty"` // language for UI display (e.g., "en-US", "zh-CN")
// For event trigger
Source EventSource `json:"source,omitempty"` // webhook | database

File diff suppressed because it is too large Load diff

View file

@ -253,9 +253,13 @@ type ExecutionResponse struct {
Status string `json:"status"`
Phase string `json:"phase"`
Error *string `json:"error,omitempty"`
JobID string `json:"job_id"`
Name string `json:"name,omitempty"` // Localized execution name
CurrentTaskName string `json:"current_task_name,omitempty"` // Localized current task
// UI display fields (from backend Execution)
// These are updated by executor at each phase for frontend display
Name string `json:"name,omitempty"` // Execution title
CurrentTaskName string `json:"current_task_name,omitempty"` // Current task description
// Phase outputs (for detail view)
Goals *GoalsResponse `json:"goals,omitempty"`
Tasks []TaskResponse `json:"tasks,omitempty"`
Current *CurrentState `json:"current,omitempty"`
@ -263,6 +267,17 @@ type ExecutionResponse struct {
}
```
**UI Display Fields Update Timeline:**
| Phase | `Name` | `CurrentTaskName` |
|-------|--------|-------------------|
| Created | Human: from `input.messages[0]`<br>Clock/Event: "Preparing..." | "Starting..." |
| `inspiration` | - | "Analyzing context..." |
| `goals` complete | Extracted from first goal in `goals.content` | "Planning goals..." |
| `tasks` | - | "Breaking down tasks..." |
| `run` (each task) | - | Current task description |
| Completed/Failed | - | "Completed" / "Failed: {error}" |
### 4.4 ResultResponse
```go
@ -491,13 +506,50 @@ data: {"error": "Something went wrong", "phase": "run"}
### 8.1 Locale Detection
Priority order:
1. Query parameter: `?locale=zh-CN`
2. Request body field: `locale: "zh-CN"`
3. Accept-Language header
4. Default: `en-US`
**For API requests (Human trigger):**
### 8.2 Localized Fields
Priority order:
1. Request body field: `locale: "zh-CN"` (in TriggerRequest)
2. Query parameter: `?locale=zh-CN`
3. Accept-Language header
4. Robot's `default_locale` config
5. System default: `en-US`
**For Clock/Event triggers (no user context):**
Priority order:
1. Robot's `default_locale` config (from `robot_config.default_locale`)
2. System default: `en-US`
### 8.2 Robot Default Locale
Robots can configure a default language for clock/event triggered executions:
```go
// In RobotConfig (robot_config field in __yao.member)
type Config struct {
// ... other fields ...
DefaultLocale string `json:"default_locale,omitempty"` // "en-US", "zh-CN"
}
```
**Language resolution:**
```go
func getLocale(robot *Robot, input *TriggerInput) string {
// 1. Human trigger with explicit locale
if input != nil && input.Locale != "" {
return input.Locale
}
// 2. Robot configured default
if robot.Config != nil && robot.Config.DefaultLocale != "" {
return robot.Config.DefaultLocale
}
// 3. System default
return "en-US"
}
```
### 8.3 Localized Fields
| Response Type | Localized Fields |
|---------------|------------------|

View file

@ -6,6 +6,188 @@
---
## Field Alignment Review Summary
> Last reviewed: 2026-01-23
### Robot Fields ✅ Fully Aligned
| Backend (`types.go`) | Frontend (`types.ts`) | Status |
|---------------------|----------------------|--------|
| `member_id` | `member_id` | ✅ |
| `team_id` | `team_id` | ✅ |
| `display_name` | `display_name` | ✅ |
| `bio` | `bio` / `description` | ✅ |
| `name` (← member_id) | `name` | ✅ |
| `description` (← bio) | `description` | ✅ |
| `robot_status` | `robot_status` | ✅ |
| `autonomous_mode` | `autonomous_mode` | ✅ |
| `robot_config` | `robot_config` | ✅ |
| `robot_email` | `robot_email` | ✅ |
| All other fields | Same | ✅ |
### Execution Fields ✅ Aligned
| Backend (`types.go`) | Frontend (`types.ts`) | Status |
|---------------------|----------------------|--------|
| `id` | `id` | ✅ Aligned |
| `member_id` | `member_id` | ✅ |
| `team_id` | `team_id` | ✅ |
| `trigger_type` | `trigger_type` | ✅ |
| `status` | `status` | ✅ |
| `phase` | `phase` | ✅ |
| `start_time` | `start_time` | ✅ |
| `end_time` | `end_time` | ✅ |
| `error` | `error` | ✅ |
| `input` | `input` | ✅ Optional |
| Phase outputs | Same | ✅ Detail view |
| `name` | `name` | ✅ Added |
| `current_task_name` | `current_task_name` | ✅ Added |
| - | `job_id` | 🗑️ **Dead field, to be removed** |
**Action Items:**
- [x] **Backend**: `Execution` struct - add `Name`, `CurrentTaskName` fields (see Improvement Plan below)
- [x] **Backend**: `RobotConfig` struct - add `DefaultLocale` field (see Improvement Plan below)
- [x] **Backend**: `TriggerInput` struct - add `Locale` field (see Improvement Plan below)
- [x] **Backend**: Database model `execution.mod.yao` - add `name`, `current_task_name` columns
- [x] **Backend**: Executor - update `Name`, `CurrentTaskName` at each phase
- [x] **Backend**: Store layer - add `UpdateUIFields()` method
- [x] **Backend**: Unit tests for UI fields and i18n (executor/standard/ui_fields_test.go, store/execution_test.go)
- [ ] **Frontend**: Remove `job_id` field from `types.ts`
- [ ] **Frontend**: Remove `job_id` mock data from `mock/data.ts`
- [ ] **Frontend**: Use `name` and `current_task_name` directly from API response
---
### Improvement Plan: Execution UI Display Fields ✅ Implemented
> **Problem:** Frontend needs to display "execution title" and "current task", which must be dynamically updated at different phases
> **Solution:** Backend manages these fields centrally; `Execution` struct gets new fields, executor updates them at each phase
**1. Execution struct fields (`agent/robot/types/robot.go`):** ✅
```go
type Execution struct {
// ... existing fields ...
// UI display fields (updated by executor at each phase)
Name string `json:"name,omitempty"` // Execution title
CurrentTaskName string `json:"current_task_name,omitempty"` // Current task description
}
```
**2. Update timeline:** ✅
| Phase | `Name` | `CurrentTaskName` |
|-------|--------|-------------------|
| Created | Human: extract from `input.messages[0]`<br>Clock/Event: "Preparing..." (localized) | "Starting..." (localized) |
| `inspiration` | - | "Analyzing context..." (localized) |
| `goals` complete | Extract first line from `goals.content` | "Planning goals..." (localized) |
| `tasks` | - | "Breaking down tasks..." (localized) |
| `run` (each task) | - | Current `task` description (e.g., "Task 1/3: ...") |
| Completed/Failed | - | "Completed" / "Failed: {error}" (localized) |
**3. Implementation files:**
- `agent/robot/types/robot.go` - Execution struct fields ✅
- `agent/robot/store/execution.go` - UpdateUIFields() method ✅
- `agent/robot/executor/standard/executor.go` - initUIFields(), updateUIFields(), i18n messages ✅
- `agent/robot/executor/standard/inspiration.go` - Update CurrentTaskName ✅
- `agent/robot/executor/standard/goals.go` - Update Name and CurrentTaskName ✅
- `agent/robot/executor/standard/tasks.go` - Update CurrentTaskName ✅
- `agent/robot/executor/standard/run.go` - Update CurrentTaskName for each task ✅
- `yao/models/agent/execution.mod.yao` - Database columns ✅
---
### Improvement Plan: i18n Default Locale ✅ Implemented
> **Problem:** Clock/Event triggers have no user context, unknown which language to use for generated content
> **Solution:** `RobotConfig` gets a default locale configuration field
**1. RobotConfig struct field (`agent/robot/types/config.go`):** ✅
```go
type Config struct {
// ... existing fields ...
DefaultLocale string `json:"default_locale,omitempty"` // "en" | "zh", default "en"
}
// GetDefaultLocale returns the default locale (default: "en")
func (c *Config) GetDefaultLocale() string {
if c == nil || c.DefaultLocale == "" {
return "en"
}
return c.DefaultLocale
}
```
**2. TriggerInput struct field (`agent/robot/types/robot.go`):** ✅
```go
type TriggerInput struct {
// ... existing fields ...
Locale string `json:"locale,omitempty"` // Language from human trigger
}
```
**3. Locale determination logic (`agent/robot/executor/standard/executor.go`):** ✅
```go
func getEffectiveLocale(robot *Robot, input *TriggerInput) string {
// 1. Human trigger: use locale from request
if input != nil && input.Locale != "" {
return input.Locale
}
// 2. Clock/Event trigger: use Robot config
if robot != nil && robot.Config != nil {
return robot.Config.GetDefaultLocale()
}
// 3. System default
return "en"
}
```
**4. Locale source priority:** ✅
| Trigger Type | Locale Source |
|--------------|---------------|
| Human | Request `locale` → Robot `default_locale` → "en" |
| Event | Robot `default_locale` → "en" |
| Clock | Robot `default_locale` → "en" |
**5. Localized messages (`executor.go`):** ✅
```go
var uiMessages = map[string]map[string]string{
"en": {
"preparing": "Preparing...",
"starting": "Starting...",
"scheduled_execution": "Scheduled execution",
"event_prefix": "Event: ",
"event_triggered": "Event triggered",
"analyzing_context": "Analyzing context...",
"planning_goals": "Planning goals...",
"breaking_down_tasks": "Breaking down tasks...",
"completed": "Completed",
"failed_prefix": "Failed: ",
"task_prefix": "Task",
},
"zh": {
"preparing": "准备中...",
"starting": "启动中...",
"scheduled_execution": "定时执行",
// ... more Chinese messages
},
}
```
> **Note:** User preference locale fallback deferred to future version
### Deferred Features (Phase 5/6)
| Feature | Current Status | Future Plan |
|---------|---------------|-------------|
| Trigger/Intervene UI | Backend done, frontend deferred | Phase 5 (requires SSE) |
| Real-time refresh | Polling 60s | Phase 6 (SSE streams) |
| Multi-turn chat | Not started | Phase 5 |
---
## Implementation Strategy
> **Integrate frontend immediately after each phase to validate deliverables.**
@ -42,8 +224,8 @@
└─ Locale parameter support
🟡 Medium Risk (Deferred):
Phase 5: Multi-turn Chat API
Phase 6: Real-time SSE Streams
Phase 5: Multi-turn Chat API + Trigger/Intervene UI
Phase 6: Real-time SSE Streams (replace polling)
```
---
@ -277,88 +459,207 @@
---
## 🟢 Phase 2: Execution Management ⬜ [Low Risk]
## 🟢 Phase 2: Execution Management [Backend ✅ | Frontend ⬜]
> **Backend:** Steps 1-4 ✅ Complete (including UI fields and i18n)
> **Frontend:** Step 5 ⬜ Pending
> **Deferred:** Trigger/Intervene UI → Phase 5 (requires SSE)
**Goal:** Execution listing, details, control, and trigger/intervene (single-submit mode)
**Risk:** 🟢 Low - Wraps existing API functions
**Risk:** 🟢 Low - Wraps existing `robot/api` functions
**Workflow:** 1. Implement All Endpoints → 2. Linter Check → 3. Code Review → 4. Unit Tests → 5. Frontend Integration
### 2.1 List Executions ⬜
---
- [ ] `execution.go` - GET /v1/robots/:id/executions
- [ ] Parse query params: `status`, `trigger_type`, `keyword`, `page`, `pagesize`
- [ ] Call `robot/api.GetExecutions()`
- [ ] Add derived fields: `name`, `current_task_name`
- [ ] Format response
- [ ] Test: `tests/robot/execution_list_test.go`
### Step 1: Implement All OpenAPI Endpoints ✅
### 2.2 Get Execution ⬜
> Location: `yao/openapi/agent/robot/`
> Calls: `yao/agent/robot/api/` (existing functions)
- [ ] GET /v1/robots/:id/executions/:exec_id
- [ ] Call `robot/api.GetExecution()`
- [ ] Full task details with localization
- [ ] Test: `tests/robot/execution_get_test.go`
#### 2.1.1 Types (`types.go`) ✅
### 2.3 Execution Control ⬜
- [x] `ExecutionFilter` - query params for listing
- [x] `ExecutionResponse` - single execution response
- [x] `ExecutionListResponse` - paginated list response
- [x] `ExecutionControlResponse` - pause/resume/cancel response
- [x] `TriggerRequest` - trigger execution request
- [x] `TriggerResponse` - trigger result response
- [x] `InterveneRequest` - human intervention request
- [x] `InterveneResponse` - intervention result response
- [ ] POST /v1/robots/:id/executions/:exec_id/pause
- [ ] Call `robot/api.Pause()`
- [ ] POST /v1/robots/:id/executions/:exec_id/resume
- [ ] Call `robot/api.Resume()`
- [ ] POST /v1/robots/:id/executions/:exec_id/cancel
- [ ] Call `robot/api.Stop()`
- [ ] POST /v1/robots/:id/executions/:exec_id/retry
- [ ] Re-trigger with same input
- [ ] Test: `tests/robot/execution_control_test.go`
#### 2.1.2 Execution Handlers (`execution.go`) ✅
### 2.4 Execution Types ⬜
> **Permission Note:** Execution permissions are inherited from the parent robot.
> Check robot's `__yao_team_id` and `__yao_created_by` for access control.
- [ ] Add to `types.go`:
- [ ] `ExecutionResponse` struct
- [ ] `TaskResponse` struct
- [ ] `CurrentStateResponse` struct
- [ ] `GoalsResponse` struct
- [ ] `DeliveryResultResponse` struct
- [x] `ListExecutions` - GET /v1/agent/robots/:id/executions
- Parse query: `status`, `trigger_type`, `keyword`, `page`, `pagesize`
- Call `robot/api.ListExecutions()`
- Permission: Check robot CanRead (via robot ID)
- [x] `GetExecution` - GET /v1/agent/robots/:id/executions/:exec_id
- Call `robot/api.GetExecution()`
- Permission: Check robot CanRead (via robot ID)
- [x] `PauseExecution` - POST /v1/agent/robots/:id/executions/:exec_id/pause
- Call `robot/api.PauseExecution()`
- Permission: Check robot CanWrite (via robot ID)
- [x] `ResumeExecution` - POST /v1/agent/robots/:id/executions/:exec_id/resume
- Call `robot/api.ResumeExecution()`
- Permission: Check robot CanWrite (via robot ID)
- [x] `CancelExecution` - POST /v1/agent/robots/:id/executions/:exec_id/cancel
- Call `robot/api.StopExecution()`
- Permission: Check robot CanWrite (via robot ID)
### 2.5 Trigger & Intervene (Single-Submit Mode) ⬜
#### 2.1.3 Trigger Handlers (`trigger.go`) ✅
> **Note:** This is single-submit mode. Multi-turn chat is deferred to Phase 5.
> **Permission Note:** Same as execution - check robot's permission.
- [ ] `trigger.go` - POST /v1/robots/:id/trigger
- [ ] Parse `TriggerRequest` (messages, attachments)
- [ ] Call `robot/api.Trigger()`
- [ ] Return execution ID and status
- [ ] Optional: Return SSE stream for progress
- [ ] Test: `tests/robot/trigger_test.go`
- [x] `TriggerRobot` - POST /v1/agent/robots/:id/trigger
- Parse `TriggerRequest` (messages, trigger_type)
- Call `robot/api.Trigger()`
- Return execution ID and status
- Permission: Check robot CanWrite (via robot ID)
- [x] `InterveneRobot` - POST /v1/agent/robots/:id/intervene
- Parse `InterveneRequest` (action, messages)
- Call `robot/api.Intervene()`
- Return result
- Permission: Check robot CanWrite (via robot ID)
- [ ] POST /v1/robots/:id/intervene
- [ ] Parse `InterveneRequest`
- [ ] Call `robot/api.Intervene()`
- [ ] Return result
- [ ] Test: `tests/robot/intervene_test.go`
#### 2.1.4 Route Registration (`robot.go`) ✅
### 2.6 Trigger Types ⬜
- [x] Add execution routes to `Attach()`:
- `GET /:id/executions`
- `GET /:id/executions/:exec_id`
- `POST /:id/executions/:exec_id/pause`
- `POST /:id/executions/:exec_id/resume`
- `POST /:id/executions/:exec_id/cancel`
- `POST /:id/trigger`
- `POST /:id/intervene`
- [ ] Add to `types.go`:
- [ ] `TriggerRequest` struct
- [ ] `TriggerResponse` struct
- [ ] `InterveneRequest` struct
- [ ] `InterveneResponse` struct
- [ ] `Message` struct
- [ ] `Attachment` struct
---
### 2.7 Frontend Integration ⬜
### Step 2: Linter Check ✅
> Integrate immediately after backend completion
- [x] Run `ReadLints` on all modified files
- [x] Fix any linter errors
- [x] Verify imports are correct
- [x] Build verification passed
- [ ] SDK: Add execution methods to `robot.ts`
- [ ] `listExecutions(robotId, params)`
- [ ] `getExecution(robotId, execId)`
- [ ] `pauseExecution()`, `resumeExecution()`, `cancelExecution()`
- [ ] `triggerRobot(robotId, data)`
- [ ] `intervene(robotId, data)`
- [ ] Page: Execution list/detail page integration
- [ ] Page: Assign Task (trigger execution) integration
- [ ] Verify: E2E testing
---
### Step 3: Code Review ✅
- [x] Review type definitions (`types.go`)
- `ExecutionFilter`, `ExecutionResponse`, `ExecutionListResponse`, `ExecutionControlResponse`
- `TriggerRequest`, `TriggerResponse`, `InterveneRequest`, `InterveneResponse`
- Conversion functions: `NewExecutionListResponse`, `NewExecutionResponseFromExecution`, `NewExecutionResponseBrief`
- [x] Review permission handling
- All execution/trigger handlers check robot permission first
- Read permission for listing and getting executions
- Write permission for control (pause/resume/cancel), trigger, and intervene
- Permission inherited from parent robot (check via `YaoTeamID` and `YaoCreatedBy`)
- [x] Review error handling
- Fixed: Use `errors.Is()` instead of `==` for error comparison
- Proper HTTP status codes (400, 404, 403, 500)
- Consistent error response format
- [x] Review response formats
- Brief format for list view (omits phase outputs)
- Full format for detail view (includes all fields)
- Consistent with existing robot responses
---
### Step 4: Unit Tests ✅
> Location: `yao/openapi/tests/agent/`
> Uses `testing.Short()` to skip AI/manager-dependent tests
- [x] Create `robot_execution_test.go`
- [x] `TestListExecutions` - list executions with pagination/filters
- [x] `TestGetExecution` - get execution details, not found cases
- [x] `TestExecutionControl` - pause/resume/cancel endpoints
- [x] `TestExecutionPermissions` - permission inheritance from robot
- [x] Create `robot_trigger_test.go`
- [x] `TestTriggerRobot` - trigger with messages, action, invalid body
- [x] `TestInterveneRobot` - intervene with action, missing action validation
- [x] `TestTriggerPermissions` - permission inheritance from robot
- [x] All tests use `testing.Short()` to skip AI-dependent tests
- [x] Tests compile successfully
- [x] All tests pass (with manager not started gracefully handled)
---
### Step 5: Frontend Integration ⬜
> Location: `cui/packages/cui/openapi/agent/robot/`
> **Note:** Trigger/Intervene API deferred to Phase 5 (waiting for SSE support)
> **Note:** Use 1-minute polling for execution list refresh (will switch to SSE in Phase 6)
#### 5.1 Prerequisites ✅
> **Dependency:** Backend improvement plans completed (see "Improvement Plan" sections above)
**Backend (Completed):**
- [x] `Execution` struct - add `Name`, `CurrentTaskName` fields
- [x] `RobotConfig` struct - add `DefaultLocale` field
- [x] `TriggerInput` struct - add `Locale` field
- [x] Executor - update `Name`, `CurrentTaskName` at each phase
- [x] Store - add `UpdateUIFields()` method
- [x] Unit tests for UI fields and i18n
**Frontend Cleanup (Pending):**
- [x] Components already use `exec.id` (no changes needed)
- [ ] Remove `job_id` field from `types.ts`
- [ ] Remove `job_id` from `mock/data.ts`
- [ ] Use `name`/`current_task_name` directly from API response
#### 5.2 SDK Types (`types.ts`) ⬜
- [ ] `ExecutionFilter` interface
- [ ] `Execution` interface (align with backend `ExecutionResponse`)
- [ ] `ExecutionListResponse` interface
- [ ] `ExecutionControlResponse` interface
**Deferred to Phase 5 (SSE):**
- [ ] ~~`TriggerRequest` / `TriggerResponse` interfaces~~
- [ ] ~~`InterveneRequest` / `InterveneResponse` interfaces~~
#### 5.3 SDK Methods (`robots.ts`) ⬜
- [ ] `ListExecutions(robotId, filter)`
- [ ] `GetExecution(robotId, execId)`
- [ ] `PauseExecution(robotId, execId)`
- [ ] `ResumeExecution(robotId, execId)`
- [ ] `CancelExecution(robotId, execId)`
**Deferred to Phase 5 (SSE):**
- [ ] ~~`Trigger(robotId, data)`~~
- [ ] ~~`Intervene(robotId, data)`~~
#### 5.4 Page Integration ⬜
- [ ] ActiveTab: Replace mock with `ListExecutions()` API
- [ ] Filter: `status=running|pending`
- [ ] Polling: 1-minute interval (60000ms) - will switch to SSE in Phase 6
- [ ] HistoryTab: Replace mock with `ListExecutions()` API
- [ ] Filter: `status` filter, `keyword` search
- [ ] Pagination: page/pagesize
- [ ] Polling: 1-minute interval for list refresh
**Deferred to Phase 5 (SSE):**
- [ ] ~~Assign Task Modal: Call `Trigger()` API~~
- [ ] ~~GuideExecution: Call `Intervene()` API~~
#### 5.5 Polling vs SSE Strategy
**Current (Phase 2):** Polling
- Refresh execution list every 60 seconds
- Manual refresh button for immediate update
- Acceptable latency for status display
**Future (Phase 6):** SSE Real-time
- `GET /robots/:id/executions/stream` - real-time execution updates
- `GET /robots/stream` - robot status changes
- Instant updates, no polling delay
---
@ -455,12 +756,13 @@
---
## 🟡 Phase 5: Multi-turn Chat API ⬜ [Medium Risk - Deferred]
## 🟡 Phase 5: Multi-turn Chat API + Trigger/Intervene UI ⬜ [Medium Risk - Deferred]
> **Frontend Fallback:** Single-submit mode (user input → immediate execution)
> **Risk:** 🟡 Medium - New stateful component
> **Dependency:** Requires SSE infrastructure (partially)
**Goal:** Multi-turn conversation before execution
**Goal:** Multi-turn conversation before execution + Human trigger/intervene UI
### 5.1 Backend Prerequisites ⬜
@ -486,14 +788,36 @@
- [ ] Use conversation history as execution input
- [ ] Auto-cleanup conversation after execution starts
### 5.4 Frontend Trigger/Intervene Integration (Deferred from Phase 2) ⬜
> **Note:** These features require SSE for proper UX (streaming response)
> Currently backend `/trigger` and `/intervene` endpoints exist but return immediately
> Frontend needs streaming response to show assistant's reaction before confirming
**SDK Types:**
- [ ] `TriggerRequest` / `TriggerResponse` interfaces
- [ ] `InterveneRequest` / `InterveneResponse` interfaces
- [ ] `ChatMessage` interface for multi-turn
**SDK Methods:**
- [ ] `Trigger(robotId, data)` - with SSE support
- [ ] `Intervene(robotId, data)` - with SSE support
- [ ] `Chat(robotId, data)` - multi-turn conversation SSE
**Page Integration:**
- [ ] AssignTaskDrawer: Multi-turn chat before trigger
- [ ] GuideExecutionDrawer: Multi-turn intervention
- [ ] Real-time streaming response display
---
## 🟡 Phase 6: Real-time SSE Streams ⬜ [Medium Risk - Deferred]
> **Frontend Fallback:** Polling (GET /executions every 3-5 seconds)
> **Frontend Current:** Polling every 60 seconds (1 minute)
> **Frontend Future:** SSE streams for instant updates
> **Risk:** 🟡 Medium - Requires modification of executor/manager
**Goal:** SSE streams for real-time status updates
**Goal:** SSE streams for real-time status updates, replacing polling
### 6.1 Backend Event System ⬜
@ -632,9 +956,9 @@ yao/openapi/tests/robot/
| 1. Core CRUD | 🟢 | ✅ | ✅ | Robot CRUD endpoints |
| 1-FE Frontend Integration | 🟢 | - | ✅ | SDK ✅, Page Integration ✅, UI/UX ✅ |
| 1.5 Manager Lifecycle | 🟢 | ✅ | - | Auto-start, auto-reload, graceful shutdown |
| 2. Execution | 🟢 | ⬜ | ⬜ | Execution listing, control, trigger |
| 2. Execution | 🟢 | ✅ | ⬜ | Execution listing, control, trigger (backend complete with UI fields & i18n) |
| 3. Results/Activities | 🟢 | ⬜ | ⬜ | Deliverables and activity feed |
| 4. i18n | 🟢 | ⬜ | ⬜ | Locale parameter support |
| 4. i18n | 🟢 | ✅ | ⬜ | Locale parameter support (backend executor i18n complete) |
| 5. Chat API | 🟡 | ⬜ | ⬜ | Multi-turn conversation (Deferred) |
| 6. SSE Streams | 🟡 | ⬜ | ⬜ | Real-time status updates (Deferred) |
@ -809,8 +1133,8 @@ Each phase independently deliverable:
| Phase | Backend | Frontend | Verifiable Features |
|-------|---------|----------|---------------------|
| 1 | ✅ | ✅ | Robot CRUD basic management |
| 2 | ⬜ | ⬜ | Execution list/control/trigger |
| 2 | ✅ | ⬜ | Execution list/control/trigger (backend with UI fields & i18n) |
| 3 | ⬜ | ⬜ | Results/Activities viewing |
| 4 | ⬜ | ⬜ | Multi-language support |
| 4 | ✅ | ⬜ | Multi-language support (backend executor i18n) |
| 5 | ⬜ | ⬜ | Multi-turn chat UX (optional) |
| 6 | ⬜ | ⬜ | Real-time push (optional) |

View file

@ -0,0 +1,348 @@
package robot
import (
"errors"
"github.com/gin-gonic/gin"
"github.com/yaoapp/kun/log"
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"
)
// ==================== Execution Handlers ====================
// Permission Note: Execution permissions are inherited from the parent robot.
// Check robot's __yao_team_id and __yao_created_by for access control.
// ListExecutions lists executions for a robot
// GET /v1/agent/robots/:id/executions
func ListExecutions(c *gin.Context) {
// Get authorized information
authInfo := authorized.GetInfo(c)
// Get robot ID from URL parameter
robotID := c.Param("id")
if robotID == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "robot id is required",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Create robot context
ctx := &robottypes.Context{}
// Check robot permission first (executions inherit robot permission)
robotResp, err := robotapi.GetRobotResponse(ctx, robotID)
if err != nil {
if errors.Is(err, robottypes.ErrRobotNotFound) {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Robot not found: " + robotID,
}
response.RespondWithError(c, response.StatusNotFound, errorResp)
return
}
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Failed to get robot: " + err.Error(),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Check read permission on robot
if !CanRead(c, authInfo, robotResp.YaoTeamID, robotResp.YaoCreatedBy) {
errorResp := &response.ErrorResponse{
Code: response.ErrAccessDenied.Code,
ErrorDescription: "Forbidden: No permission to access this robot's executions",
}
response.RespondWithError(c, response.StatusForbidden, errorResp)
return
}
// Parse query parameters
var filter ExecutionFilter
if err := c.ShouldBindQuery(&filter); err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Invalid query parameters: " + err.Error(),
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Apply defaults
if filter.Page <= 0 {
filter.Page = 1
}
if filter.PageSize <= 0 {
filter.PageSize = 20
}
if filter.PageSize > 100 {
filter.PageSize = 100
}
// Build API query
query := &robotapi.ExecutionQuery{
Page: filter.Page,
PageSize: filter.PageSize,
}
if filter.Status != "" {
query.Status = robottypes.ExecStatus(filter.Status)
}
if filter.TriggerType != "" {
query.Trigger = robottypes.TriggerType(filter.TriggerType)
}
// Call API layer
result, err := robotapi.ListExecutions(ctx, robotID, query)
if err != nil {
log.Error("Failed to list executions for robot %s: %v", robotID, err)
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Failed to list executions: " + err.Error(),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Convert to response (brief format for list)
data := make([]*ExecutionResponse, 0, len(result.Data))
for _, exec := range result.Data {
data = append(data, NewExecutionResponseBrief(exec))
}
resp := &ExecutionListResponse{
Data: data,
Total: result.Total,
Page: result.Page,
PageSize: result.PageSize,
}
response.RespondWithSuccess(c, response.StatusOK, resp)
}
// GetExecution gets a single execution by ID
// GET /v1/agent/robots/:id/executions/:exec_id
func GetExecution(c *gin.Context) {
// Get authorized information
authInfo := authorized.GetInfo(c)
// Get robot ID and execution ID from URL parameters
robotID := c.Param("id")
execID := c.Param("exec_id")
if robotID == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "robot id is required",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
if execID == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "execution id is required",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Create robot context
ctx := &robottypes.Context{}
// Check robot permission first
robotResp, err := robotapi.GetRobotResponse(ctx, robotID)
if err != nil {
if errors.Is(err, robottypes.ErrRobotNotFound) {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Robot not found: " + robotID,
}
response.RespondWithError(c, response.StatusNotFound, errorResp)
return
}
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Failed to get robot: " + err.Error(),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Check read permission on robot
if !CanRead(c, authInfo, robotResp.YaoTeamID, robotResp.YaoCreatedBy) {
errorResp := &response.ErrorResponse{
Code: response.ErrAccessDenied.Code,
ErrorDescription: "Forbidden: No permission to access this robot's executions",
}
response.RespondWithError(c, response.StatusForbidden, errorResp)
return
}
// Get execution
exec, err := robotapi.GetExecution(ctx, execID)
if err != nil {
log.Error("Failed to get execution %s: %v", execID, err)
if err.Error() == "execution not found: "+execID {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Execution not found: " + execID,
}
response.RespondWithError(c, response.StatusNotFound, errorResp)
return
}
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Failed to get execution: " + err.Error(),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Verify execution belongs to this robot
if exec.MemberID != robotID {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Execution does not belong to this robot",
}
response.RespondWithError(c, response.StatusNotFound, errorResp)
return
}
// Convert to response (full format for detail)
resp := NewExecutionResponseFromExecution(exec)
response.RespondWithSuccess(c, response.StatusOK, resp)
}
// PauseExecution pauses a running execution
// POST /v1/agent/robots/:id/executions/:exec_id/pause
func PauseExecution(c *gin.Context) {
handleExecutionControl(c, "pause")
}
// ResumeExecution resumes a paused execution
// POST /v1/agent/robots/:id/executions/:exec_id/resume
func ResumeExecution(c *gin.Context) {
handleExecutionControl(c, "resume")
}
// CancelExecution cancels/stops an execution
// POST /v1/agent/robots/:id/executions/:exec_id/cancel
func CancelExecution(c *gin.Context) {
handleExecutionControl(c, "cancel")
}
// handleExecutionControl handles pause/resume/cancel operations
func handleExecutionControl(c *gin.Context, action string) {
// Get authorized information
authInfo := authorized.GetInfo(c)
// Get robot ID and execution ID from URL parameters
robotID := c.Param("id")
execID := c.Param("exec_id")
if robotID == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "robot id is required",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
if execID == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "execution id is required",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Create robot context
ctx := &robottypes.Context{}
// Check robot permission first
robotResp, err := robotapi.GetRobotResponse(ctx, robotID)
if err != nil {
if errors.Is(err, robottypes.ErrRobotNotFound) {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Robot not found: " + robotID,
}
response.RespondWithError(c, response.StatusNotFound, errorResp)
return
}
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Failed to get robot: " + err.Error(),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Check write permission on robot (control operations require write)
if !CanWrite(c, authInfo, robotResp.YaoTeamID, robotResp.YaoCreatedBy) {
errorResp := &response.ErrorResponse{
Code: response.ErrAccessDenied.Code,
ErrorDescription: "Forbidden: No permission to control this robot's executions",
}
response.RespondWithError(c, response.StatusForbidden, errorResp)
return
}
// Execute the control action
var controlErr error
switch action {
case "pause":
controlErr = robotapi.PauseExecution(ctx, execID)
case "resume":
controlErr = robotapi.ResumeExecution(ctx, execID)
case "cancel":
controlErr = robotapi.StopExecution(ctx, execID)
default:
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Invalid action: " + action,
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
if controlErr != nil {
log.Error("Failed to %s execution %s: %v", action, execID, controlErr)
// Check for common errors
errMsg := controlErr.Error()
if errMsg == "execution_id is required" || errMsg == "execution not found" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Execution not found: " + execID,
}
response.RespondWithError(c, response.StatusNotFound, errorResp)
return
}
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Failed to " + action + " execution: " + controlErr.Error(),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Return success
resp := &ExecutionControlResponse{
ExecutionID: execID,
Action: action + "d", // paused, resumed, cancelled
Success: true,
Message: "Execution " + action + "d successfully",
}
response.RespondWithSuccess(c, response.StatusOK, resp)
}

View file

@ -22,4 +22,15 @@ func Attach(group *gin.RouterGroup, oauth types.OAuth) {
// Robot Status
group.GET("/:id/status", GetRobotStatus) // GET /robots/:id/status - Get robot runtime status
// Execution Management
group.GET("/:id/executions", ListExecutions) // GET /robots/:id/executions - List robot executions
group.GET("/:id/executions/:exec_id", GetExecution) // GET /robots/:id/executions/:exec_id - Get execution details
group.POST("/:id/executions/:exec_id/pause", PauseExecution) // POST /robots/:id/executions/:exec_id/pause - Pause execution
group.POST("/:id/executions/:exec_id/resume", ResumeExecution) // POST /robots/:id/executions/:exec_id/resume - Resume execution
group.POST("/:id/executions/:exec_id/cancel", CancelExecution) // POST /robots/:id/executions/:exec_id/cancel - Cancel execution
// Trigger & Intervene
group.POST("/:id/trigger", TriggerRobot) // POST /robots/:id/trigger - Trigger robot execution
group.POST("/:id/intervene", InterveneRobot) // POST /robots/:id/intervene - Human intervention
}

View file

@ -0,0 +1,271 @@
package robot
import (
"errors"
"github.com/gin-gonic/gin"
"github.com/yaoapp/kun/log"
agentcontext "github.com/yaoapp/yao/agent/context"
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"
)
// ==================== Trigger Handlers ====================
// Permission Note: Same as execution - check robot's permission.
// TriggerRobot triggers a robot execution
// POST /v1/agent/robots/:id/trigger
func TriggerRobot(c *gin.Context) {
// Get authorized information
authInfo := authorized.GetInfo(c)
// Get robot ID from URL parameter
robotID := c.Param("id")
if robotID == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "robot id is required",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Parse request body
var req TriggerRequest
if err := c.ShouldBindJSON(&req); err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Invalid request body: " + err.Error(),
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Create robot context
ctx := &robottypes.Context{}
// Check robot permission first
robotResp, err := robotapi.GetRobotResponse(ctx, robotID)
if err != nil {
if errors.Is(err, robottypes.ErrRobotNotFound) {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Robot not found: " + robotID,
}
response.RespondWithError(c, response.StatusNotFound, errorResp)
return
}
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Failed to get robot: " + err.Error(),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Check write permission on robot (trigger requires write permission)
if !CanWrite(c, authInfo, robotResp.YaoTeamID, robotResp.YaoCreatedBy) {
errorResp := &response.ErrorResponse{
Code: response.ErrAccessDenied.Code,
ErrorDescription: "Forbidden: No permission to trigger this robot",
}
response.RespondWithError(c, response.StatusForbidden, errorResp)
return
}
// Build API trigger request
apiReq := buildAPITriggerRequest(&req)
// Call API layer
result, err := robotapi.Trigger(ctx, robotID, apiReq)
if err != nil {
log.Error("Failed to trigger robot %s: %v", robotID, err)
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Failed to trigger robot: " + err.Error(),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Convert to response
resp := &TriggerResponse{
Accepted: result.Accepted,
ExecutionID: result.ExecutionID,
Queued: result.Queued,
Message: result.Message,
}
if result.Accepted {
response.RespondWithSuccess(c, response.StatusOK, resp)
} else {
// Trigger was not accepted (e.g., queue full, robot paused)
response.RespondWithSuccess(c, response.StatusOK, resp)
}
}
// InterveneRobot performs human intervention on a robot
// POST /v1/agent/robots/:id/intervene
func InterveneRobot(c *gin.Context) {
// Get authorized information
authInfo := authorized.GetInfo(c)
// Get robot ID from URL parameter
robotID := c.Param("id")
if robotID == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "robot id is required",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Parse request body
var req InterveneRequest
if err := c.ShouldBindJSON(&req); err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Invalid request body: " + err.Error(),
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Validate action
if req.Action == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "action is required",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Create robot context
ctx := &robottypes.Context{}
// Check robot permission first
robotResp, err := robotapi.GetRobotResponse(ctx, robotID)
if err != nil {
if errors.Is(err, robottypes.ErrRobotNotFound) {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Robot not found: " + robotID,
}
response.RespondWithError(c, response.StatusNotFound, errorResp)
return
}
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Failed to get robot: " + err.Error(),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Check write permission on robot (intervention requires write permission)
if !CanWrite(c, authInfo, robotResp.YaoTeamID, robotResp.YaoCreatedBy) {
errorResp := &response.ErrorResponse{
Code: response.ErrAccessDenied.Code,
ErrorDescription: "Forbidden: No permission to intervene with this robot",
}
response.RespondWithError(c, response.StatusForbidden, errorResp)
return
}
// Build API trigger request for intervention
apiReq := &robotapi.TriggerRequest{
Type: robottypes.TriggerHuman,
Action: robottypes.InterventionAction(req.Action),
PlanAt: req.PlanAt,
}
// Convert messages
if len(req.Messages) > 0 {
apiReq.Messages = convertMessagesToContext(req.Messages)
}
// Call API layer (Intervene uses TriggerHuman internally)
result, err := robotapi.Intervene(ctx, robotID, apiReq)
if err != nil {
log.Error("Failed to intervene with robot %s: %v", robotID, err)
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Failed to intervene: " + err.Error(),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Convert to response
resp := &InterveneResponse{
Accepted: result.Accepted,
ExecutionID: result.ExecutionID,
Message: result.Message,
}
response.RespondWithSuccess(c, response.StatusOK, resp)
}
// ==================== Helper Functions ====================
// buildAPITriggerRequest builds robotapi.TriggerRequest from HTTP request
func buildAPITriggerRequest(req *TriggerRequest) *robotapi.TriggerRequest {
apiReq := &robotapi.TriggerRequest{}
// Set trigger type (default to human)
switch req.TriggerType {
case "event":
apiReq.Type = robottypes.TriggerEvent
case "clock":
apiReq.Type = robottypes.TriggerClock
default:
apiReq.Type = robottypes.TriggerHuman
}
// Human intervention fields
if req.Action != "" {
apiReq.Action = robottypes.InterventionAction(req.Action)
}
if len(req.Messages) > 0 {
apiReq.Messages = convertMessagesToContext(req.Messages)
}
// Event fields
if req.Source != "" {
apiReq.Source = robottypes.EventSource(req.Source)
}
if req.EventType != "" {
apiReq.EventType = req.EventType
}
if req.Data != nil {
apiReq.Data = req.Data
}
// Executor mode
if req.ExecutorMode != "" {
apiReq.ExecutorMode = robottypes.ExecutorMode(req.ExecutorMode)
}
// i18n locale
if req.Locale != "" {
apiReq.Locale = req.Locale
}
return apiReq
}
// convertMessagesToContext converts MessageItem slice to agent context messages
func convertMessagesToContext(msgs []MessageItem) []agentcontext.Message {
result := make([]agentcontext.Message, 0, len(msgs))
for _, m := range msgs {
result = append(result, agentcontext.Message{
Role: agentcontext.MessageRole(m.Role),
Content: m.Content,
})
}
return result
}

View file

@ -4,6 +4,7 @@ import (
"time"
robotapi "github.com/yaoapp/yao/agent/robot/api"
robottypes "github.com/yaoapp/yao/agent/robot/types"
)
// ==================== Request Types ====================
@ -253,3 +254,185 @@ func NewStatusResponse(s *robotapi.RobotState) *StatusResponse {
RunningIDs: s.RunningIDs,
}
}
// ==================== Execution Types ====================
// ExecutionFilter - query params for listing executions
type ExecutionFilter struct {
Status string `form:"status"` // pending | running | paused | completed | failed | cancelled
TriggerType string `form:"trigger_type"` // clock | human | event
Keyword string `form:"keyword"` // search in execution details
Page int `form:"page"`
PageSize int `form:"pagesize"`
}
// ExecutionResponse - single execution response
type ExecutionResponse struct {
ID string `json:"id"`
MemberID string `json:"member_id"`
TeamID string `json:"team_id"`
TriggerType string `json:"trigger_type"`
Status string `json:"status"`
Phase string `json:"phase"`
StartTime time.Time `json:"start_time"`
EndTime *time.Time `json:"end_time,omitempty"`
Error string `json:"error,omitempty"`
// UI display fields (updated by executor at each phase)
Name string `json:"name,omitempty"` // Execution title
CurrentTaskName string `json:"current_task_name,omitempty"` // Current task description
// Phase outputs (optional, included in detail view)
Inspiration interface{} `json:"inspiration,omitempty"`
Goals interface{} `json:"goals,omitempty"`
Tasks interface{} `json:"tasks,omitempty"`
Current interface{} `json:"current,omitempty"`
Results interface{} `json:"results,omitempty"`
Delivery interface{} `json:"delivery,omitempty"`
// Input (optional, included in detail view)
Input interface{} `json:"input,omitempty"`
}
// ExecutionListResponse - paginated list response
type ExecutionListResponse struct {
Data []*ExecutionResponse `json:"data"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pagesize"`
}
// ExecutionControlResponse - response for pause/resume/cancel
type ExecutionControlResponse struct {
ExecutionID string `json:"execution_id"`
Action string `json:"action"` // paused | resumed | cancelled
Success bool `json:"success"`
Message string `json:"message,omitempty"`
}
// ==================== Trigger Types ====================
// TriggerRequest - HTTP request to trigger robot execution
type TriggerRequest struct {
// Trigger type: human | event | clock (defaults to human)
TriggerType string `json:"trigger_type,omitempty"`
// Human intervention fields
Action string `json:"action,omitempty"` // task.add, goal.adjust, etc.
Messages []MessageItem `json:"messages,omitempty"` // user's input
// Event fields
Source string `json:"source,omitempty"` // webhook | database
EventType string `json:"event_type,omitempty"` // lead.created, etc.
Data map[string]interface{} `json:"data,omitempty"` // event payload
// Executor mode (optional)
ExecutorMode string `json:"executor_mode,omitempty"` // standard | fast | careful
// i18n support
Locale string `json:"locale,omitempty"` // Locale for UI messages (e.g., "en", "zh")
}
// MessageItem - a single message in trigger request
type MessageItem struct {
Role string `json:"role"` // user | assistant | system
Content string `json:"content"` // message text
Name string `json:"name,omitempty"` // optional name
FileID string `json:"file_id,omitempty"` // optional attachment
}
// TriggerResponse - response after triggering
type TriggerResponse struct {
Accepted bool `json:"accepted"`
ExecutionID string `json:"execution_id,omitempty"`
Queued bool `json:"queued,omitempty"`
Message string `json:"message,omitempty"`
}
// InterveneRequest - HTTP request for human intervention
type InterveneRequest struct {
Action string `json:"action"` // task.add, goal.adjust, etc.
Messages []MessageItem `json:"messages,omitempty"` // user's input
PlanAt *time.Time `json:"plan_at,omitempty"` // schedule for later
}
// InterveneResponse - response after intervention
type InterveneResponse struct {
Accepted bool `json:"accepted"`
ExecutionID string `json:"execution_id,omitempty"`
Message string `json:"message,omitempty"`
}
// ==================== Execution Conversion Functions ====================
// NewExecutionListResponse creates an ExecutionListResponse from api.ExecutionResult
func NewExecutionListResponse(e *robotapi.ExecutionResult) *ExecutionListResponse {
if e == nil {
return nil
}
data := make([]*ExecutionResponse, 0, len(e.Data))
for _, exec := range e.Data {
data = append(data, NewExecutionResponseFromExecution(exec))
}
return &ExecutionListResponse{
Data: data,
Total: e.Total,
Page: e.Page,
PageSize: e.PageSize,
}
}
// NewExecutionResponseFromExecution converts types.Execution to ExecutionResponse
func NewExecutionResponseFromExecution(exec *robottypes.Execution) *ExecutionResponse {
if exec == nil {
return nil
}
return &ExecutionResponse{
ID: exec.ID,
MemberID: exec.MemberID,
TeamID: exec.TeamID,
TriggerType: string(exec.TriggerType),
Status: string(exec.Status),
Phase: string(exec.Phase),
StartTime: exec.StartTime,
EndTime: exec.EndTime,
Error: exec.Error,
// UI display fields
Name: exec.Name,
CurrentTaskName: exec.CurrentTaskName,
// Phase outputs - include in detail view
Inspiration: exec.Inspiration,
Goals: exec.Goals,
Tasks: exec.Tasks,
Current: exec.Current,
Results: exec.Results,
Delivery: exec.Delivery,
Input: exec.Input,
}
}
// NewExecutionResponseBrief creates a brief ExecutionResponse (for list view)
func NewExecutionResponseBrief(exec *robottypes.Execution) *ExecutionResponse {
if exec == nil {
return nil
}
return &ExecutionResponse{
ID: exec.ID,
MemberID: exec.MemberID,
TeamID: exec.TeamID,
TriggerType: string(exec.TriggerType),
Status: string(exec.Status),
Phase: string(exec.Phase),
StartTime: exec.StartTime,
EndTime: exec.EndTime,
Error: exec.Error,
// UI display fields - include in list view for display
Name: exec.Name,
CurrentTaskName: exec.CurrentTaskName,
// Omit phase outputs for list view
}
}

View file

@ -0,0 +1,416 @@
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"
)
// TestListExecutions tests the execution listing endpoint
// GET /v1/agent/robots/:id/executions
func TestListExecutions(t *testing.T) {
if testing.Short() {
t.Skip("Skipping execution tests in short mode")
}
serverURL := testutils.Prepare(t)
defer testutils.Clean()
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
// Register test client and get token
client := testutils.RegisterTestClient(t, "Execution List 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
robotID := fmt.Sprintf("test_exec_list_%d", time.Now().UnixNano())
createRobot(t, serverURL, baseURL, tokenInfo.AccessToken, robotID, "Execution List Robot")
defer deleteRobot(t, serverURL, baseURL, tokenInfo.AccessToken, robotID)
t.Run("ListExecutionsSuccess", func(t *testing.T) {
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID+"/executions", nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.NotNil(t, resp)
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)
// Verify pagination fields exist
assert.Contains(t, response, "data")
assert.Contains(t, response, "page")
assert.Contains(t, response, "pagesize")
assert.Contains(t, response, "total")
// Verify execution items structure (if any executions exist)
data, ok := response["data"].([]interface{})
if ok && len(data) > 0 {
exec := data[0].(map[string]interface{})
// Basic fields should exist
assert.Contains(t, exec, "id")
assert.Contains(t, exec, "status")
assert.Contains(t, exec, "phase")
// UI display fields (may be empty string, but field should be present in response)
// These are new fields added for frontend display
t.Logf("Execution response fields: id=%v, name=%v, current_task_name=%v",
exec["id"], exec["name"], exec["current_task_name"])
}
})
t.Run("ListExecutionsWithPagination", func(t *testing.T) {
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID+"/executions?page=1&pagesize=5", 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.Equal(t, float64(1), response["page"])
assert.Equal(t, float64(5), response["pagesize"])
})
t.Run("ListExecutionsWithStatusFilter", func(t *testing.T) {
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID+"/executions?status=completed", 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, "data")
})
t.Run("ListExecutionsWithTriggerTypeFilter", func(t *testing.T) {
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID+"/executions?trigger_type=human", 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, "data")
})
t.Run("ListExecutionsRobotNotFound", func(t *testing.T) {
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/non_existent_robot/executions", 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.StatusNotFound, resp.StatusCode)
})
t.Run("ListExecutionsUnauthorized", func(t *testing.T) {
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID+"/executions", 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)
})
}
// TestGetExecution tests the execution detail endpoint
// GET /v1/agent/robots/:id/executions/:exec_id
func TestGetExecution(t *testing.T) {
if testing.Short() {
t.Skip("Skipping execution tests in short mode")
}
serverURL := testutils.Prepare(t)
defer testutils.Clean()
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
// Register test client and get token
client := testutils.RegisterTestClient(t, "Execution Get 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
robotID := fmt.Sprintf("test_exec_get_%d", time.Now().UnixNano())
createRobot(t, serverURL, baseURL, tokenInfo.AccessToken, robotID, "Execution Get Robot")
defer deleteRobot(t, serverURL, baseURL, tokenInfo.AccessToken, robotID)
t.Run("GetExecutionNotFound", func(t *testing.T) {
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID+"/executions/non_existent_exec", 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.StatusNotFound, resp.StatusCode)
})
t.Run("GetExecutionRobotNotFound", func(t *testing.T) {
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/non_existent_robot/executions/some_exec", 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.StatusNotFound, resp.StatusCode)
})
}
// TestExecutionControl tests the execution control endpoints
// POST /v1/agent/robots/:id/executions/:exec_id/pause
// POST /v1/agent/robots/:id/executions/:exec_id/resume
// POST /v1/agent/robots/:id/executions/:exec_id/cancel
func TestExecutionControl(t *testing.T) {
if testing.Short() {
t.Skip("Skipping execution control tests in short mode")
}
serverURL := testutils.Prepare(t)
defer testutils.Clean()
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
// Register test client and get token
client := testutils.RegisterTestClient(t, "Execution Control 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
robotID := fmt.Sprintf("test_exec_ctrl_%d", time.Now().UnixNano())
createRobot(t, serverURL, baseURL, tokenInfo.AccessToken, robotID, "Execution Control Robot")
defer deleteRobot(t, serverURL, baseURL, tokenInfo.AccessToken, robotID)
t.Run("PauseExecutionNotFound", func(t *testing.T) {
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/executions/non_existent_exec/pause", 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()
// Should return error (404 or 500 depending on implementation)
assert.True(t, resp.StatusCode >= 400, "Expected error status code")
})
t.Run("ResumeExecutionNotFound", func(t *testing.T) {
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/executions/non_existent_exec/resume", 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.True(t, resp.StatusCode >= 400, "Expected error status code")
})
t.Run("CancelExecutionNotFound", func(t *testing.T) {
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/executions/non_existent_exec/cancel", 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.True(t, resp.StatusCode >= 400, "Expected error status code")
})
t.Run("PauseExecutionRobotNotFound", func(t *testing.T) {
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/non_existent_robot/executions/some_exec/pause", 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.StatusNotFound, resp.StatusCode)
})
t.Run("ControlExecutionUnauthorized", func(t *testing.T) {
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/executions/some_exec/pause", 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)
})
}
// TestExecutionPermissions tests execution permission inheritance from robot
func TestExecutionPermissions(t *testing.T) {
if testing.Short() {
t.Skip("Skipping execution permission tests in short mode")
}
serverURL := testutils.Prepare(t)
defer testutils.Clean()
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
// Register test client
client := testutils.RegisterTestClient(t, "Execution Permission Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
// Create User 1
token1 := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
user1ID := token1.UserID
// Create User 2
token2 := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
// User 1 creates a robot
robotID := fmt.Sprintf("test_exec_perm_%d", time.Now().UnixNano())
createRobotWithTeam(t, serverURL, baseURL, token1.AccessToken, robotID, "Permission Test Robot", user1ID)
defer deleteRobot(t, serverURL, baseURL, token1.AccessToken, robotID)
t.Run("OwnerCanListExecutions", func(t *testing.T) {
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID+"/executions", nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+token1.AccessToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
t.Logf("Owner (User 1) successfully listed executions for their robot")
})
t.Run("OtherUserExecutionAccess", func(t *testing.T) {
// User 2 attempts to list executions for User 1's robot
// With system:root scope this might succeed
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID+"/executions", nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+token2.AccessToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
t.Logf("User 2 execution list attempt status: %d (with system:root scope)", resp.StatusCode)
})
}
// ==================== Helper Functions ====================
func createRobot(t *testing.T, serverURL, baseURL, token, robotID, displayName string) {
createData := map[string]interface{}{
"member_id": robotID,
"team_id": "test_team_001",
"display_name": displayName,
}
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 createRobotWithTeam(t *testing.T, serverURL, baseURL, token, robotID, displayName, teamID string) {
createData := map[string]interface{}{
"member_id": robotID,
"team_id": teamID,
"display_name": displayName,
}
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 deleteRobot(t *testing.T, serverURL, baseURL, token, robotID string) {
req, _ := http.NewRequest("DELETE", serverURL+baseURL+"/agent/robots/"+robotID, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, _ := http.DefaultClient.Do(req)
if resp != nil {
resp.Body.Close()
}
}

View file

@ -214,11 +214,11 @@ func TestCreateRobot(t *testing.T) {
createdRobotIDs = append(createdRobotIDs, robotID)
})
t.Run("CreateRobotMissingRequiredFields", func(t *testing.T) {
// Missing member_id
t.Run("CreateRobotMissingDisplayName", func(t *testing.T) {
// Missing display_name (the only required field)
createData := map[string]interface{}{
"team_id": "test_team_001",
"display_name": "Test Robot",
"team_id": "test_team_001",
// display_name is missing
}
body, _ := json.Marshal(createData)
@ -235,6 +235,41 @@ func TestCreateRobot(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("CreateRobotAutoGenerateMemberID", func(t *testing.T) {
// member_id is optional - should be auto-generated
createData := map[string]interface{}{
"team_id": "test_team_001",
"display_name": "Auto ID Robot",
}
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 "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusCreated, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
require.NoError(t, err)
// Verify member_id was auto-generated
memberID, ok := response["member_id"].(string)
assert.True(t, ok, "member_id should be a string")
assert.NotEmpty(t, memberID, "member_id should be auto-generated")
assert.Len(t, memberID, 12, "auto-generated member_id should be 12 digits")
t.Logf("Auto-generated member_id: %s", memberID)
// Cleanup
createdRobotIDs = append(createdRobotIDs, memberID)
})
t.Run("CreateRobotDuplicate", func(t *testing.T) {
robotID := fmt.Sprintf("test_robot_dup_%d", time.Now().UnixNano())

View file

@ -0,0 +1,502 @@
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"
)
// TestTriggerRobot tests the robot trigger endpoint
// POST /v1/agent/robots/:id/trigger
func TestTriggerRobot(t *testing.T) {
if testing.Short() {
t.Skip("Skipping trigger tests in short mode (requires AI/manager)")
}
serverURL := testutils.Prepare(t)
defer testutils.Clean()
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
// Register test client and get token
client := testutils.RegisterTestClient(t, "Trigger 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
robotID := fmt.Sprintf("test_trigger_%d", time.Now().UnixNano())
createRobotForTrigger(t, serverURL, baseURL, tokenInfo.AccessToken, robotID, "Trigger Test Robot")
defer deleteRobotForTrigger(t, serverURL, baseURL, tokenInfo.AccessToken, robotID)
t.Run("TriggerRobotBasic", func(t *testing.T) {
triggerData := map[string]interface{}{
"trigger_type": "human",
"messages": []map[string]interface{}{
{
"role": "user",
"content": "Hello, please help me with a task",
},
},
}
body, _ := json.Marshal(triggerData)
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/trigger", 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)
require.NotNil(t, resp)
defer resp.Body.Close()
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
require.NoError(t, err)
// If manager is not started, we get a 500 error (expected in test environment)
// In production with manager running, response should contain "accepted" field
if resp.StatusCode == http.StatusInternalServerError {
// Expected when robot manager is not started
assert.Contains(t, response, "error_description")
t.Logf("Trigger response (manager not started): status=%d, error=%v", resp.StatusCode, response["error_description"])
} else {
// Manager is running - verify accepted field
assert.Contains(t, response, "accepted")
t.Logf("Trigger response: status=%d, accepted=%v", resp.StatusCode, response["accepted"])
}
})
t.Run("TriggerRobotWithAction", func(t *testing.T) {
triggerData := map[string]interface{}{
"trigger_type": "human",
"action": "task.add",
"messages": []map[string]interface{}{
{
"role": "user",
"content": "Add a new task: Review quarterly report",
},
},
}
body, _ := json.Marshal(triggerData)
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/trigger", 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()
var response map[string]interface{}
json.NewDecoder(resp.Body).Decode(&response)
// If manager is not started, we get a 500 error (expected in test environment)
if resp.StatusCode == http.StatusInternalServerError {
assert.Contains(t, response, "error_description")
t.Logf("Trigger with action response (manager not started): status=%d", resp.StatusCode)
} else {
assert.Contains(t, response, "accepted")
t.Logf("Trigger with action response: status=%d", resp.StatusCode)
}
})
t.Run("TriggerRobotWithLocale", func(t *testing.T) {
// Test the new locale parameter for i18n support
triggerData := map[string]interface{}{
"trigger_type": "human",
"locale": "zh", // Chinese locale
"messages": []map[string]interface{}{
{
"role": "user",
"content": "请帮我分析销售数据",
},
},
}
body, _ := json.Marshal(triggerData)
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/trigger", 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()
var response map[string]interface{}
json.NewDecoder(resp.Body).Decode(&response)
// If manager is not started, we get a 500 error (expected in test environment)
if resp.StatusCode == http.StatusInternalServerError {
assert.Contains(t, response, "error_description")
t.Logf("Trigger with locale response (manager not started): status=%d", resp.StatusCode)
} else {
assert.Contains(t, response, "accepted")
t.Logf("Trigger with locale response: status=%d, accepted=%v", resp.StatusCode, response["accepted"])
}
})
t.Run("TriggerRobotNotFound", func(t *testing.T) {
triggerData := map[string]interface{}{
"trigger_type": "human",
}
body, _ := json.Marshal(triggerData)
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/non_existent_robot/trigger", 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.StatusNotFound, resp.StatusCode)
})
t.Run("TriggerRobotUnauthorized", func(t *testing.T) {
triggerData := map[string]interface{}{
"trigger_type": "human",
}
body, _ := json.Marshal(triggerData)
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/trigger", 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.Run("TriggerRobotInvalidBody", func(t *testing.T) {
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/trigger", bytes.NewBuffer([]byte("invalid json")))
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)
})
}
// TestInterveneRobot tests the robot intervention endpoint
// POST /v1/agent/robots/:id/intervene
func TestInterveneRobot(t *testing.T) {
if testing.Short() {
t.Skip("Skipping intervene tests in short mode (requires AI/manager)")
}
serverURL := testutils.Prepare(t)
defer testutils.Clean()
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
// Register test client and get token
client := testutils.RegisterTestClient(t, "Intervene 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
robotID := fmt.Sprintf("test_intervene_%d", time.Now().UnixNano())
createRobotForTrigger(t, serverURL, baseURL, tokenInfo.AccessToken, robotID, "Intervene Test Robot")
defer deleteRobotForTrigger(t, serverURL, baseURL, tokenInfo.AccessToken, robotID)
t.Run("InterveneRobotBasic", func(t *testing.T) {
interveneData := map[string]interface{}{
"action": "task.add",
"messages": []map[string]interface{}{
{
"role": "user",
"content": "Please add a high priority task",
},
},
}
body, _ := json.Marshal(interveneData)
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/intervene", 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)
require.NotNil(t, resp)
defer resp.Body.Close()
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
require.NoError(t, err)
// If manager is not started, we get a 500 error (expected in test environment)
if resp.StatusCode == http.StatusInternalServerError {
assert.Contains(t, response, "error_description")
t.Logf("Intervene response (manager not started): status=%d, error=%v", resp.StatusCode, response["error_description"])
} else {
assert.Contains(t, response, "accepted")
t.Logf("Intervene response: status=%d, accepted=%v", resp.StatusCode, response["accepted"])
}
})
t.Run("InterveneRobotMissingAction", func(t *testing.T) {
interveneData := map[string]interface{}{
"messages": []map[string]interface{}{
{
"role": "user",
"content": "Some message",
},
},
}
body, _ := json.Marshal(interveneData)
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/intervene", 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()
// Action is required
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("InterveneRobotNotFound", func(t *testing.T) {
interveneData := map[string]interface{}{
"action": "task.add",
}
body, _ := json.Marshal(interveneData)
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/non_existent_robot/intervene", 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.StatusNotFound, resp.StatusCode)
})
t.Run("InterveneRobotUnauthorized", func(t *testing.T) {
interveneData := map[string]interface{}{
"action": "task.add",
}
body, _ := json.Marshal(interveneData)
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/intervene", 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.Run("InterveneRobotInvalidBody", func(t *testing.T) {
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/intervene", bytes.NewBuffer([]byte("invalid json")))
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)
})
}
// TestTriggerPermissions tests trigger permission inheritance from robot
func TestTriggerPermissions(t *testing.T) {
if testing.Short() {
t.Skip("Skipping trigger permission tests in short mode")
}
serverURL := testutils.Prepare(t)
defer testutils.Clean()
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
// Register test client
client := testutils.RegisterTestClient(t, "Trigger Permission Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
// Create User 1
token1 := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
user1ID := token1.UserID
// Create User 2
token2 := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
// User 1 creates a robot
robotID := fmt.Sprintf("test_trig_perm_%d", time.Now().UnixNano())
createRobotWithTeamForTrigger(t, serverURL, baseURL, token1.AccessToken, robotID, "Trigger Perm Robot", user1ID)
defer deleteRobotForTrigger(t, serverURL, baseURL, token1.AccessToken, robotID)
t.Run("OwnerCanTrigger", func(t *testing.T) {
triggerData := map[string]interface{}{
"trigger_type": "human",
"messages": []map[string]interface{}{
{
"role": "user",
"content": "Owner triggering robot",
},
},
}
body, _ := json.Marshal(triggerData)
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/trigger", bytes.NewBuffer(body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token1.AccessToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
// Owner should be able to trigger (may fail at manager level with 500, but not 403 permission denied)
// 500 = manager not started (acceptable), 403 = permission denied (not acceptable)
assert.NotEqual(t, http.StatusForbidden, resp.StatusCode, "Owner should have permission to trigger")
t.Logf("Owner trigger attempt status: %d", resp.StatusCode)
})
t.Run("OwnerCanIntervene", func(t *testing.T) {
interveneData := map[string]interface{}{
"action": "task.add",
"messages": []map[string]interface{}{
{
"role": "user",
"content": "Owner intervention",
},
},
}
body, _ := json.Marshal(interveneData)
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/intervene", bytes.NewBuffer(body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token1.AccessToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
// 500 = manager not started (acceptable), 403 = permission denied (not acceptable)
assert.NotEqual(t, http.StatusForbidden, resp.StatusCode, "Owner should have permission to intervene")
t.Logf("Owner intervene attempt status: %d", resp.StatusCode)
})
t.Run("OtherUserTriggerAccess", func(t *testing.T) {
// User 2 attempts to trigger User 1's robot
// With system:root scope this might succeed
triggerData := map[string]interface{}{
"trigger_type": "human",
}
body, _ := json.Marshal(triggerData)
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/trigger", bytes.NewBuffer(body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token2.AccessToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
t.Logf("User 2 trigger attempt status: %d (with system:root scope)", resp.StatusCode)
})
}
// ==================== Helper Functions ====================
func createRobotForTrigger(t *testing.T, serverURL, baseURL, token, robotID, displayName string) {
createData := map[string]interface{}{
"member_id": robotID,
"team_id": "test_team_001",
"display_name": displayName,
}
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 createRobotWithTeamForTrigger(t *testing.T, serverURL, baseURL, token, robotID, displayName, teamID string) {
createData := map[string]interface{}{
"member_id": robotID,
"team_id": teamID,
"display_name": displayName,
}
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 deleteRobotForTrigger(t *testing.T, serverURL, baseURL, token, robotID string) {
req, _ := http.NewRequest("DELETE", serverURL+baseURL+"/agent/robots/"+robotID, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, _ := http.DefaultClient.Do(req)
if resp != nil {
resp.Body.Close()
}
}

View file

@ -94,6 +94,22 @@
"comment": "Error message if execution failed",
"nullable": true
},
{
"name": "name",
"type": "string",
"label": "Name",
"comment": "Execution title for UI display (updated by executor at goals phase)",
"length": 512,
"nullable": true
},
{
"name": "current_task_name",
"type": "string",
"label": "Current Task Name",
"comment": "Current task description for UI display (updated by executor at run phase)",
"length": 512,
"nullable": true
},
{
"name": "input",
"type": "json",