Merge pull request #1427 from trheyi/main

Enhance Execution Management with UI Display Fields and Localization
This commit is contained in:
Max 2026-01-24 12:30:05 +08:00 committed by GitHub
commit ef5768139a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 3762 additions and 362 deletions

View file

@ -351,6 +351,7 @@ P2 Agent reads Goals markdown and breaks into executable tasks:
```go
type Task struct {
ID string // unique task ID
Description string // human-readable task description (for UI display)
Messages []context.Message // original input (text, images, files, audio)
GoalRef string // reference to goal (e.g., "Goal 1")
Source TaskSource // auto | human | event
@ -698,17 +699,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
@ -1308,10 +1314,11 @@ type DeliveryTarget struct {
// Task - planned task (structured, for execution)
type Task struct {
ID string `json:"id"`
Messages []context.Message `json:"messages"` // original input (text, images, files)
GoalRef string `json:"goal_ref,omitempty"` // reference to goal (e.g., "Goal 1")
Source TaskSource `json:"source"` // auto | human | event
ID string `json:"id"`
Description string `json:"description,omitempty"` // human-readable task description (for UI display)
Messages []context.Message `json:"messages"` // original input (text, images, files)
GoalRef string `json:"goal_ref,omitempty"` // reference to goal (e.g., "Goal 1")
Source TaskSource `json:"source"` // auto | human | event
// Executor
ExecutorType ExecutorType `json:"executor_type"`

View file

@ -21,6 +21,9 @@ const memberModel = "__yao.member"
// robotStore is the shared robot store instance
var robotStore = store.NewRobotStore()
// executionStore is the shared execution store instance
var executionStore = store.NewExecutionStore()
// GetRobot returns a robot by member ID
// Returns the robot from cache if available, otherwise loads from database
func GetRobot(ctx *types.Context, memberID string) (*types.Robot, error) {
@ -62,9 +65,11 @@ func ListRobots(ctx *types.Context, query *ListQuery) (*ListResult, error) {
return listRobotsFromDB(query)
}
// If only teamID specified (no other filters), use cache for faster lookup
// Note: Cache only contains autonomous_mode=true robots, so this is safe
if query.TeamID != "" && query.Status == "" && query.Keywords == "" && query.ClockMode == "" {
// If only teamID specified AND explicitly filtering for autonomous_mode=true, use cache
// Cache only contains autonomous_mode=true robots
// When autonomous_mode is not specified or false, must query database to include all robots
if query.TeamID != "" && query.Status == "" && query.Keywords == "" && query.ClockMode == "" &&
query.AutonomousMode != nil && *query.AutonomousMode == true {
robots := mgr.Cache().List(query.TeamID)
return paginateRobots(robots, query), nil
}
@ -93,7 +98,6 @@ func GetRobotStatus(ctx *types.Context, memberID string) (*RobotState, error) {
DisplayName: robot.DisplayName,
Bio: robot.Bio,
Status: robot.Status,
Running: robot.RunningCount(),
MaxRunning: 2, // default
}
@ -107,11 +111,33 @@ func GetRobotStatus(ctx *types.Context, memberID string) (*RobotState, error) {
state.MaxRunning = robot.Config.Quota.GetMax()
}
// Get running execution IDs
executions := robot.GetExecutions()
state.RunningIDs = make([]string, 0, len(executions))
for _, exec := range executions {
state.RunningIDs = append(state.RunningIDs, exec.ID)
// Get running execution IDs from ExecutionStore (more reliable than in-memory)
// This ensures we get accurate status even when robot is loaded from database
runningExecs, err := executionStore.List(context.Background(), &store.ListOptions{
MemberID: memberID,
Status: types.ExecRunning,
Limit: 100,
})
if err == nil && len(runningExecs) > 0 {
state.Running = len(runningExecs)
state.RunningIDs = make([]string, 0, len(runningExecs))
for _, exec := range runningExecs {
state.RunningIDs = append(state.RunningIDs, exec.ExecutionID)
}
// Update status based on running count
state.Status = types.RobotWorking
} else {
// No running executions from store, check in-memory
executions := robot.GetExecutions()
state.Running = len(executions)
state.RunningIDs = make([]string, 0, len(executions))
for _, exec := range executions {
state.RunningIDs = append(state.RunningIDs, exec.ID)
}
// If there are running executions in memory, update status
if state.Running > 0 {
state.Status = types.RobotWorking
}
}
// Set last run time

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

@ -30,6 +30,10 @@ func (e *Executor) RunDelivery(ctx *robottypes.Context, exec *robottypes.Executi
return fmt.Errorf("robot not found in execution")
}
// Update UI field with i18n
locale := getEffectiveLocale(robot, exec.Input)
e.updateUIFields(ctx, exec, "", getLocalizedMessage(locale, "generating_delivery"))
// Get agent ID for delivery phase
agentID := "__yao.delivery" // default
if robot.Config != nil && robot.Config.Resources != nil {
@ -108,6 +112,10 @@ func (e *Executor) routeToDeliveryCenter(ctx *robottypes.Context, exec *robottyp
return nil
}
// Update UI field to show delivery is in progress
locale := getEffectiveLocale(robot, exec.Input)
e.updateUIFields(ctx, exec, "", getLocalizedMessage(locale, "sending_delivery"))
// Create Delivery Center and execute
center := NewDeliveryCenter()
results, err := center.Deliver(ctx, exec.Delivery.Content, &robottypes.DeliveryContext{

View file

@ -20,6 +20,7 @@ import (
type Executor struct {
config types.Config
store *store.ExecutionStore
robotStore *store.RobotStore
execCount atomic.Int32
currentCount atomic.Int32
onStart func()
@ -29,15 +30,17 @@ type Executor struct {
// New creates a new standard executor
func New() *Executor {
return &Executor{
store: store.NewExecutionStore(),
store: store.NewExecutionStore(),
robotStore: store.NewRobotStore(),
}
}
// NewWithConfig creates a new standard executor with configuration
func NewWithConfig(config types.Config) *Executor {
return &Executor{
config: config,
store: store.NewExecutionStore(),
config: config,
store: store.NewExecutionStore(),
robotStore: store.NewRobotStore(),
}
}
@ -54,6 +57,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 +66,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)
@ -90,7 +97,19 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
}).Warn("Execution quota exceeded")
return nil, robottypes.ErrQuotaExceeded
}
defer robot.RemoveExecution(exec.ID)
// Defer: remove execution from robot's tracking and update robot status if no more executions
defer func() {
robot.RemoveExecution(exec.ID)
// Update robot status to idle if no more running executions
if robot.RunningCount() == 0 && !e.config.SkipPersistence && e.robotStore != nil {
if err := e.robotStore.UpdateStatus(ctx.Context, robot.MemberID, robottypes.RobotIdle); err != nil {
log.With(log.F{
"member_id": robot.MemberID,
"error": err,
}).Warn("Failed to update robot status to idle: %v", err)
}
}
}()
// Track execution count
e.execCount.Add(1)
@ -123,6 +142,16 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
}
}
// Update robot status to working (when execution starts)
if !e.config.SkipPersistence && e.robotStore != nil {
if err := e.robotStore.UpdateStatus(ctx.Context, robot.MemberID, robottypes.RobotWorking); err != nil {
log.With(log.F{
"member_id": robot.MemberID,
"error": err,
}).Warn("Failed to update robot status to working: %v", err)
}
}
// Check for simulated failure (for testing)
if dataStr, ok := data.(string); ok && dataStr == "simulate_failure" {
exec.Status = robottypes.ExecFailed
@ -138,12 +167,23 @@ 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
// Use concise phase name, NOT the full error message (error is in exec.Error)
failedPrefix := getLocalizedMessage(locale, "failed_prefix")
phaseName := getLocalizedMessage(locale, "phase_"+string(phase))
failureMsg := failedPrefix + phaseName
e.updateUIFields(ctx, exec, "", failureMsg)
log.With(log.F{
"execution_id": exec.ID,
"member_id": exec.MemberID,
@ -163,6 +203,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,
@ -193,6 +236,17 @@ func (e *Executor) runPhase(ctx *robottypes.Context, exec *robottypes.Execution,
"phase": string(phase),
}).Info("Phase started: %s", phase)
// Persist phase change immediately (so frontend sees current phase)
if !e.config.SkipPersistence && e.store != nil {
if err := e.store.UpdatePhase(ctx.Context, exec.ID, phase, nil); err != nil {
log.With(log.F{
"execution_id": exec.ID,
"phase": string(phase),
"error": err,
}).Warn("Failed to persist phase start: %v", err)
}
}
if e.config.OnPhaseStart != nil {
e.config.OnPhaseStart(phase)
}
@ -301,5 +355,198 @@ 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...",
"generating_delivery": "Generating delivery content...",
"sending_delivery": "Sending delivery...",
"learning_from_exec": "Learning from execution...",
"completed": "Completed",
"failed_prefix": "Failed at ",
"task_prefix": "Task",
// Phase names for failure messages
"phase_inspiration": "inspiration",
"phase_goals": "goals",
"phase_tasks": "tasks",
"phase_run": "execution",
"phase_delivery": "delivery",
"phase_learning": "learning",
},
"zh": {
"preparing": "准备中...",
"starting": "启动中...",
"scheduled_execution": "定时执行",
"event_prefix": "事件: ",
"event_triggered": "事件触发",
"analyzing_context": "分析上下文...",
"planning_goals": "规划目标...",
"breaking_down_tasks": "分解任务...",
"generating_delivery": "生成交付内容...",
"sending_delivery": "正在发送...",
"learning_from_exec": "学习执行经验...",
"completed": "已完成",
"failed_prefix": "失败于",
"task_prefix": "任务",
// Phase names for failure messages
"phase_inspiration": "灵感阶段",
"phase_goals": "目标阶段",
"phase_tasks": "任务阶段",
"phase_run": "执行阶段",
"phase_delivery": "交付阶段",
"phase_learning": "学习阶段",
},
}
// 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)
}
}
}
// updateTasksState persists the current tasks array with status to database
// This should be called after each task status change for real-time UI updates
func (e *Executor) updateTasksState(ctx *robottypes.Context, exec *robottypes.Execution) {
if e.config.SkipPersistence || e.store == nil {
return
}
// Convert Current to store.CurrentState
var current *store.CurrentState
if exec.Current != nil {
current = &store.CurrentState{
TaskIndex: exec.Current.TaskIndex,
Progress: exec.Current.Progress,
}
}
if err := e.store.UpdateTasks(ctx.Context, exec.ID, exec.Tasks, current); err != nil {
log.With(log.F{
"execution_id": exec.ID,
"error": err,
}).Warn("Failed to update tasks state: %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

@ -20,6 +20,13 @@ import (
//
// TODO: Implement real learning extraction
func (e *Executor) RunLearning(ctx *robottypes.Context, exec *robottypes.Execution, _ interface{}) error {
// Get robot for locale
robot := exec.GetRobot()
// Update UI field with i18n
locale := getEffectiveLocale(robot, exec.Input)
e.updateUIFields(ctx, exec, "", getLocalizedMessage(locale, "learning_from_exec"))
e.simulateStreamDelay()
exec.Learning = []robottypes.LearningEntry{

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,11 +84,18 @@ 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()
task.StartTime = &now
// Persist running state to database
e.updateTasksState(ctx, exec)
// Build task context with previous results
taskCtx := runner.BuildTaskContext(exec, i)
@ -107,12 +117,17 @@ func (e *Executor) RunExecution(ctx *robottypes.Context, exec *robottypes.Execut
// Store result
exec.Results = append(exec.Results, *result)
// Persist completed/failed state to database
e.updateTasksState(ctx, exec)
// Check if we should continue on failure
if !result.Success && !config.ContinueOnFailure {
// Mark remaining tasks as skipped
for j := i + 1; j < len(exec.Tasks); j++ {
exec.Tasks[j].Status = robottypes.TaskSkipped
}
// Persist skipped state to database
e.updateTasksState(ctx, exec)
return fmt.Errorf("task %s failed: %s", task.ID, result.Error)
}
}
@ -122,3 +137,32 @@ 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)
// Priority 1: Use Description field if available
if task.Description != "" {
desc := task.Description
if len(desc) > 80 {
desc = desc[:80] + "..."
}
return prefix + desc
}
// Priority 2: 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")
@ -152,9 +156,11 @@ func ParseTask(data map[string]interface{}, index int) (*robottypes.Task, error)
task.Messages = ParseMessages(messages)
}
// Optional: description -> convert to message if no messages
if len(task.Messages) == 0 {
if desc, ok := data["description"].(string); ok && desc != "" {
// Optional: description - save to Description field and convert to message if no messages
if desc, ok := data["description"].(string); ok && desc != "" {
task.Description = desc
// Also convert to Messages for execution if no explicit messages provided
if len(task.Messages) == 0 {
task.Messages = []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: desc},
}

View file

@ -309,7 +309,8 @@ func TestParseTasks(t *testing.T) {
// Second task
assert.Equal(t, "task-002", tasks[1].ID)
assert.Equal(t, "experts.text-writer", tasks[1].ExecutorID)
assert.Len(t, tasks[1].Messages, 1) // description converted to message
assert.Equal(t, "Generate report from analysis", tasks[1].Description) // description saved to field
assert.Len(t, tasks[1].Messages, 1) // description also converted to message
assert.Equal(t, 1, tasks[1].Order)
})
@ -329,6 +330,62 @@ func TestParseTasks(t *testing.T) {
assert.Equal(t, "task-001", tasks[0].ID)
})
t.Run("saves description to field and preserves explicit messages", func(t *testing.T) {
data := []interface{}{
map[string]interface{}{
"id": "task-001",
"executor_type": "agent",
"executor_id": "experts.summarizer",
"description": "Task description for UI",
"messages": []interface{}{
map[string]interface{}{
"role": "user",
"content": "Explicit message content",
},
},
},
}
tasks, err := standard.ParseTasks(data)
require.NoError(t, err)
require.Len(t, tasks, 1)
// Description should be saved to field
assert.Equal(t, "Task description for UI", tasks[0].Description)
// Explicit messages should be preserved (not overwritten by description)
assert.Len(t, tasks[0].Messages, 1)
content, ok := tasks[0].Messages[0].GetContentAsString()
assert.True(t, ok)
assert.Equal(t, "Explicit message content", content)
})
t.Run("converts description to message when no messages provided", func(t *testing.T) {
data := []interface{}{
map[string]interface{}{
"id": "task-001",
"executor_type": "agent",
"executor_id": "experts.summarizer",
"description": "Only description, no messages",
},
}
tasks, err := standard.ParseTasks(data)
require.NoError(t, err)
require.Len(t, tasks, 1)
// Description should be saved to field
assert.Equal(t, "Only description, no messages", tasks[0].Description)
// Description should also be converted to message for execution
assert.Len(t, tasks[0].Messages, 1)
content, ok := tasks[0].Messages[0].GetContentAsString()
assert.True(t, ok)
assert.Equal(t, "Only description, no messages", content)
})
t.Run("returns error for missing executor_type", func(t *testing.T) {
data := []interface{}{
map[string]interface{}{

View file

@ -0,0 +1,407 @@
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",
"generating_delivery", "sending_delivery", "learning_from_exec",
"completed", "failed_prefix", "task_prefix",
// Phase names for failure messages
"phase_inspiration", "phase_goals", "phase_tasks",
"phase_run", "phase_delivery", "phase_learning",
}
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",
"generating_delivery", "sending_delivery", "learning_from_exec",
"completed", "failed_prefix", "task_prefix",
// Phase names for failure messages
"phase_inspiration", "phase_goals", "phase_tasks",
"phase_run", "phase_delivery", "phase_learning",
}
for _, key := range keys {
msg := getLocalizedMessage("zh", key)
assert.NotEqual(t, key, msg, "Chinese message should exist for key: %s", key)
}
})
t.Run("failure_message_is_concise", func(t *testing.T) {
// Test that failure messages use phase names, not full error text
enFailure := getLocalizedMessage("en", "failed_prefix") + getLocalizedMessage("en", "phase_inspiration")
assert.Equal(t, "Failed at inspiration", enFailure)
zhFailure := getLocalizedMessage("zh", "failed_prefix") + getLocalizedMessage("zh", "phase_inspiration")
assert.Equal(t, "失败于灵感阶段", zhFailure)
})
}
// ============================================================================
// 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("prioritizes_description_field_over_messages", func(t *testing.T) {
task := &robottypes.Task{
ID: "task-001",
Description: "High-level task description for UI",
ExecutorType: robottypes.ExecutorAssistant,
ExecutorID: "analyst",
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Detailed message content for execution"},
},
}
name := formatTaskProgressName(task, 0, 3, "en")
// Should use Description field, NOT the message content
assert.Equal(t, "Task 1/3: High-level task description for UI", name)
})
t.Run("falls_back_to_message_when_no_description", func(t *testing.T) {
task := &robottypes.Task{
ID: "task-001",
Description: "", // Empty description
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_field", 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",
Description: longDesc,
ExecutorType: robottypes.ExecutorAssistant,
ExecutorID: "analyst",
Messages: []agentcontext.Message{},
}
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("truncates_long_message_content", func(t *testing.T) {
longContent := "This is a very long message content that should be truncated because it exceeds 80 characters which is the maximum length allowed"
task := &robottypes.Task{
ID: "task-001",
Description: "", // No description, will use message
ExecutorType: robottypes.ExecutorAssistant,
ExecutorID: "analyst",
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: longContent},
},
}
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,69 @@ func (s *ExecutionStore) UpdateCurrent(ctx context.Context, executionID string,
return nil
}
// UpdateTasks updates the tasks array with current status
// This should be called after each task completes to persist status changes
func (s *ExecutionStore) UpdateTasks(ctx context.Context, executionID string, tasks []types.Task, current *CurrentState) error {
mod := model.Select(s.modelID)
if mod == nil {
return fmt.Errorf("model %s not found", s.modelID)
}
updateData := map[string]interface{}{
"tasks": tasks,
"current": current,
}
_, err := mod.UpdateWhere(
model.QueryParam{
Wheres: []model.QueryWhere{
{Column: "execution_id", Value: executionID},
},
},
updateData,
)
if err != nil {
return fmt.Errorf("failed to update tasks: %w", err)
}
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 +413,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 +489,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 +703,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 +743,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,260 @@ 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)
})
}
// TestExecutionStoreUpdateTasks tests updating tasks array with status
func TestExecutionStoreUpdateTasks(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 with initial tasks
startTime := time.Now()
record := &store.ExecutionRecord{
ExecutionID: "exec_test_tasks_001",
MemberID: "member_tasks_001",
TeamID: "team_tasks_001",
TriggerType: types.TriggerClock,
Status: types.ExecRunning,
Phase: types.PhaseRun,
StartTime: &startTime,
Tasks: []types.Task{
{ID: "task_001", ExecutorType: types.ExecutorAssistant, Status: types.TaskPending, Order: 0},
{ID: "task_002", ExecutorType: types.ExecutorProcess, Status: types.TaskPending, Order: 1},
{ID: "task_003", ExecutorType: types.ExecutorAssistant, Status: types.TaskPending, Order: 2},
},
}
err := s.Save(ctx, record)
require.NoError(t, err)
t.Run("updates_task_status_to_running", func(t *testing.T) {
// Update first task to running
tasks := []types.Task{
{ID: "task_001", ExecutorType: types.ExecutorAssistant, Status: types.TaskRunning, Order: 0},
{ID: "task_002", ExecutorType: types.ExecutorProcess, Status: types.TaskPending, Order: 1},
{ID: "task_003", ExecutorType: types.ExecutorAssistant, Status: types.TaskPending, Order: 2},
}
current := &store.CurrentState{TaskIndex: 0, Progress: "1/3 tasks"}
err := s.UpdateTasks(ctx, "exec_test_tasks_001", tasks, current)
require.NoError(t, err)
saved, err := s.Get(ctx, "exec_test_tasks_001")
require.NoError(t, err)
require.Len(t, saved.Tasks, 3)
assert.Equal(t, types.TaskRunning, saved.Tasks[0].Status)
assert.Equal(t, types.TaskPending, saved.Tasks[1].Status)
assert.Equal(t, types.TaskPending, saved.Tasks[2].Status)
assert.NotNil(t, saved.Current)
assert.Equal(t, 0, saved.Current.TaskIndex)
})
t.Run("updates_task_status_to_completed", func(t *testing.T) {
// First task completed, second running
tasks := []types.Task{
{ID: "task_001", ExecutorType: types.ExecutorAssistant, Status: types.TaskCompleted, Order: 0},
{ID: "task_002", ExecutorType: types.ExecutorProcess, Status: types.TaskRunning, Order: 1},
{ID: "task_003", ExecutorType: types.ExecutorAssistant, Status: types.TaskPending, Order: 2},
}
current := &store.CurrentState{TaskIndex: 1, Progress: "2/3 tasks"}
err := s.UpdateTasks(ctx, "exec_test_tasks_001", tasks, current)
require.NoError(t, err)
saved, err := s.Get(ctx, "exec_test_tasks_001")
require.NoError(t, err)
assert.Equal(t, types.TaskCompleted, saved.Tasks[0].Status)
assert.Equal(t, types.TaskRunning, saved.Tasks[1].Status)
assert.Equal(t, types.TaskPending, saved.Tasks[2].Status)
assert.Equal(t, 1, saved.Current.TaskIndex)
})
t.Run("updates_task_status_to_failed_with_skipped", func(t *testing.T) {
// Second task failed, third skipped
tasks := []types.Task{
{ID: "task_001", ExecutorType: types.ExecutorAssistant, Status: types.TaskCompleted, Order: 0},
{ID: "task_002", ExecutorType: types.ExecutorProcess, Status: types.TaskFailed, Order: 1},
{ID: "task_003", ExecutorType: types.ExecutorAssistant, Status: types.TaskSkipped, Order: 2},
}
current := &store.CurrentState{TaskIndex: 1, Progress: "Failed at 2/3"}
err := s.UpdateTasks(ctx, "exec_test_tasks_001", tasks, current)
require.NoError(t, err)
saved, err := s.Get(ctx, "exec_test_tasks_001")
require.NoError(t, err)
assert.Equal(t, types.TaskCompleted, saved.Tasks[0].Status)
assert.Equal(t, types.TaskFailed, saved.Tasks[1].Status)
assert.Equal(t, types.TaskSkipped, saved.Tasks[2].Status)
})
t.Run("updates_with_nil_current", func(t *testing.T) {
// All tasks completed, no current
tasks := []types.Task{
{ID: "task_001", ExecutorType: types.ExecutorAssistant, Status: types.TaskCompleted, Order: 0},
{ID: "task_002", ExecutorType: types.ExecutorProcess, Status: types.TaskCompleted, Order: 1},
{ID: "task_003", ExecutorType: types.ExecutorAssistant, Status: types.TaskCompleted, Order: 2},
}
err := s.UpdateTasks(ctx, "exec_test_tasks_001", tasks, nil)
require.NoError(t, err)
saved, err := s.Get(ctx, "exec_test_tasks_001")
require.NoError(t, err)
assert.Equal(t, types.TaskCompleted, saved.Tasks[0].Status)
assert.Equal(t, types.TaskCompleted, saved.Tasks[1].Status)
assert.Equal(t, types.TaskCompleted, saved.Tasks[2].Status)
})
t.Run("preserves_task_description", func(t *testing.T) {
// Create a new record with descriptions
record2 := &store.ExecutionRecord{
ExecutionID: "exec_test_tasks_002",
MemberID: "member_tasks_002",
TeamID: "team_tasks_002",
TriggerType: types.TriggerHuman,
Status: types.ExecRunning,
Phase: types.PhaseRun,
StartTime: &startTime,
Tasks: []types.Task{
{ID: "task_d01", Description: "Analyze data", ExecutorType: types.ExecutorAssistant, Status: types.TaskPending, Order: 0},
{ID: "task_d02", Description: "Generate report", ExecutorType: types.ExecutorAssistant, Status: types.TaskPending, Order: 1},
},
}
err := s.Save(ctx, record2)
require.NoError(t, err)
// Update status preserving description
tasks := []types.Task{
{ID: "task_d01", Description: "Analyze data", ExecutorType: types.ExecutorAssistant, Status: types.TaskCompleted, Order: 0},
{ID: "task_d02", Description: "Generate report", ExecutorType: types.ExecutorAssistant, Status: types.TaskRunning, Order: 1},
}
err = s.UpdateTasks(ctx, "exec_test_tasks_002", tasks, &store.CurrentState{TaskIndex: 1})
require.NoError(t, err)
saved, err := s.Get(ctx, "exec_test_tasks_002")
require.NoError(t, err)
assert.Equal(t, "Analyze data", saved.Tasks[0].Description)
assert.Equal(t, "Generate report", saved.Tasks[1].Description)
assert.Equal(t, types.TaskCompleted, saved.Tasks[0].Status)
assert.Equal(t, types.TaskRunning, saved.Tasks[1].Status)
})
}
// TestExecutionStoreDelete tests deleting execution records
func TestExecutionStoreDelete(t *testing.T) {
if testing.Short() {
@ -569,17 +823,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 +864,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 +911,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
@ -210,10 +216,11 @@ type DeliveryTarget struct {
// Task - planned task (structured, for execution)
type Task struct {
ID string `json:"id"`
Messages []agentcontext.Message `json:"messages"` // original input (text, images, files)
GoalRef string `json:"goal_ref,omitempty"` // reference to goal (e.g., "Goal 1")
Source TaskSource `json:"source"` // auto | human | event
ID string `json:"id"`
Description string `json:"description,omitempty"` // human-readable task description (for UI display)
Messages []agentcontext.Message `json:"messages"` // original input (text, images, files)
GoalRef string `json:"goal_ref,omitempty"` // reference to goal (e.g., "Goal 1")
Source TaskSource `json:"source"` // auto | human | event
// Executor
ExecutorType ExecutorType `json:"executor_type"`

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,207 @@
---
## 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** |
### Task Fields ✅ Aligned
| Backend (`types.go`) | Frontend (`types.ts`) | Status |
|---------------------|----------------------|--------|
| `id` | `id` | ✅ |
| `description` | `description` | ✅ Added |
| `goal_ref` | `goal_ref` | ✅ |
| `source` | `source` | ✅ |
| `executor_type` | `executor_type` | ✅ |
| `executor_id` | `executor_id` | ✅ |
| `status` | `status` | ✅ |
| `order` | `order` | ✅ |
| `start_time` | `start_time` | ✅ |
| `end_time` | `end_time` | ✅ |
**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)
- [x] **Backend**: `Task` struct - add `Description` field for human-readable task description
- [x] **Backend**: `ParseTask()` - save description from LLM output to `Task.Description`
- [x] **Frontend**: `Task` type - add `description` field
- [x] **Frontend**: Task list display - use `description` as primary title, fallback to `executor_id`
- [ ] **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 +243,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 +478,215 @@
---
## 🟢 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 (Completed):**
- [x] Components already use `exec.id` (no changes needed)
- [x] Remove `job_id` field from `types.ts`
- [x] ~~Remove `job_id` from `mock/data.ts`~~ (mock kept for reference, not used)
- [x] Use `name`/`current_task_name` directly from API response (string, not `{en, cn}`)
#### 5.2 SDK Types (`types.ts`) ✅
- [x] `ExecutionFilter` interface
- [x] `ExecutionResponse` interface (align with backend)
- [x] `ExecutionListResponse` interface
- [x] `ExecutionControlResponse` interface
- [x] `ExecStatus`, `TriggerType`, `Phase` type aliases
**Deferred to Phase 5 (SSE):**
- [ ] ~~`TriggerRequest` / `TriggerResponse` interfaces~~
- [ ] ~~`InterveneRequest` / `InterveneResponse` interfaces~~
#### 5.3 SDK Methods (`robots.ts`) ✅
- [x] `ListExecutions(robotId, filter)`
- [x] `GetExecution(robotId, execId)`
- [x] `PauseExecution(robotId, execId)`
- [x] `ResumeExecution(robotId, execId)`
- [x] `CancelExecution(robotId, execId)`
**Deferred to Phase 5 (SSE):**
- [ ] ~~`Trigger(robotId, data)`~~
- [ ] ~~`Intervene(robotId, data)`~~
#### 5.4 Page Integration ✅
- [x] ActiveTab: Replace mock with `ListExecutions()` API
- [x] Filter: `status=running|pending`
- [x] Polling: 1-minute interval (60000ms) - will switch to SSE in Phase 6
- [x] HistoryTab: Replace mock with `ListExecutions()` API
- [x] Filter: `status` filter, `keyword` search
- [x] Pagination: page/pagesize
- [x] Polling: 1-minute interval for list refresh
- [x] Execution Detail: Call `GetExecution()` API
- [x] Display execution phases and outputs
- [x] Display `name` and `current_task_name` from API
- [x] Display `error` field for failed executions
- [x] Execution controls: Pause/Resume/Cancel buttons (call control APIs)
- [x] Auto-refresh while execution is running (5s for running)
- [x] useRobots hook extended with execution methods
**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 +783,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 +815,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 +983,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 +1160,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

@ -1,9 +1,11 @@
package robot
import (
"fmt"
"time"
robotapi "github.com/yaoapp/yao/agent/robot/api"
robottypes "github.com/yaoapp/yao/agent/robot/types"
)
// ==================== Request Types ====================
@ -253,3 +255,209 @@ 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
}
// Calculate current state for progress bar display
// If exec.Current is nil but we have tasks, calculate progress from tasks
var current interface{}
if exec.Current != nil {
current = exec.Current
} else if len(exec.Tasks) > 0 {
// Calculate completed count from tasks
completedCount := 0
for _, task := range exec.Tasks {
if task.Status == robottypes.TaskCompleted ||
task.Status == robottypes.TaskFailed ||
task.Status == robottypes.TaskSkipped {
completedCount++
}
}
// Create a synthetic current state for progress display
current = map[string]interface{}{
"task_index": len(exec.Tasks),
"progress": fmt.Sprintf("%d/%d", completedCount, len(exec.Tasks)),
}
}
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,
// Include Current for progress bar display in list view
Current: current,
// Omit other phase outputs for list view (inspiration, goals, tasks, results, delivery, input)
}
}

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",