Add Description Field to Task Struct and Enhance Execution Management

- Introduced a new `Description` field in the `Task` struct for a human-readable task description, improving UI clarity.
- Updated the `ParseTask` function to save the description from input data and convert it to a message if no explicit messages are provided.
- Enhanced the `Executor` to update UI fields with localized messages during task execution phases, ensuring better user feedback.
- Implemented a new method in the `ExecutionStore` to persist task status updates, allowing real-time UI updates.
- Added unit tests to validate the new task description handling and UI updates during execution phases.
This commit is contained in:
Max 2026-01-24 12:16:02 +08:00
parent de9e2c589b
commit 28d5730289
15 changed files with 651 additions and 221 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

View file

@ -1314,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) {
@ -95,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
}
@ -109,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

@ -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(),
}
}
@ -94,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)
@ -127,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
@ -211,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)
}
@ -381,6 +417,9 @@ var uiMessages = map[string]map[string]string{
"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",
@ -401,6 +440,9 @@ var uiMessages = map[string]map[string]string{
"analyzing_context": "分析上下文...",
"planning_goals": "规划目标...",
"breaking_down_tasks": "分解任务...",
"generating_delivery": "生成交付内容...",
"sending_delivery": "正在发送...",
"learning_from_exec": "学习执行经验...",
"completed": "已完成",
"failed_prefix": "失败于",
"task_prefix": "任务",
@ -451,6 +493,30 @@ func (e *Executor) updateUIFields(ctx *robottypes.Context, exec *robottypes.Exec
}
}
// 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 == "" {

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

@ -93,6 +93,9 @@ func (e *Executor) RunExecution(ctx *robottypes.Context, exec *robottypes.Execut
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)
@ -114,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)
}
}
@ -135,7 +143,16 @@ func formatTaskProgressName(task *robottypes.Task, index int, total int, locale
taskPrefix := getLocalizedMessage(locale, "task_prefix")
prefix := fmt.Sprintf("%s %d/%d: ", taskPrefix, index+1, total)
// Try to get description from first message
// 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

View file

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

@ -103,8 +103,9 @@ func TestGetLocalizedMessage(t *testing.T) {
keys := []string{
"preparing", "starting", "scheduled_execution",
"event_prefix", "event_triggered", "analyzing_context",
"planning_goals", "breaking_down_tasks", "completed",
"failed_prefix", "task_prefix",
"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",
@ -119,8 +120,9 @@ func TestGetLocalizedMessage(t *testing.T) {
keys := []string{
"preparing", "starting", "scheduled_execution",
"event_prefix", "event_triggered", "analyzing_context",
"planning_goals", "breaking_down_tasks", "completed",
"failed_prefix", "task_prefix",
"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",
@ -312,9 +314,26 @@ func TestExtractGoalName(t *testing.T) {
// ============================================================================
func TestFormatTaskProgressName(t *testing.T) {
t.Run("formats_with_task_description", func(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{
@ -340,14 +359,31 @@ func TestFormatTaskProgressName(t *testing.T) {
assert.Equal(t, "任务 2/5: 分析销售数据", name)
})
t.Run("truncates_long_description", func(t *testing.T) {
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: longDesc},
{Role: agentcontext.RoleUser, Content: longContent},
},
}

View file

@ -317,6 +317,34 @@ 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 {

View file

@ -604,6 +604,161 @@ func TestExecutionStoreUpdateUIFields(t *testing.T) {
})
}
// 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() {

View file

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

@ -45,6 +45,21 @@
| `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)
@ -53,6 +68,10 @@
- [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
@ -606,44 +625,52 @@ var uiMessages = map[string]map[string]string{
- [x] Store - add `UpdateUIFields()` method
- [x] Unit tests for UI fields and i18n
**Frontend Cleanup (Pending):**
**Frontend Cleanup (Completed):**
- [x] Components already use `exec.id` (no changes needed)
- [ ] Remove `job_id` field from `types.ts`
- [ ] Remove `job_id` from `mock/data.ts`
- [ ] Use `name`/`current_task_name` directly from API response
- [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`)
#### 5.2 SDK Types (`types.ts`)
- [ ] `ExecutionFilter` interface
- [ ] `Execution` interface (align with backend `ExecutionResponse`)
- [ ] `ExecutionListResponse` interface
- [ ] `ExecutionControlResponse` interface
- [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`)
#### 5.3 SDK Methods (`robots.ts`)
- [ ] `ListExecutions(robotId, filter)`
- [ ] `GetExecution(robotId, execId)`
- [ ] `PauseExecution(robotId, execId)`
- [ ] `ResumeExecution(robotId, execId)`
- [ ] `CancelExecution(robotId, execId)`
- [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
#### 5.4 Page Integration
- [ ] ActiveTab: Replace mock with `ListExecutions()` API
- [ ] Filter: `status=running|pending`
- [ ] Polling: 1-minute interval (60000ms) - will switch to SSE in Phase 6
- [ ] HistoryTab: Replace mock with `ListExecutions()` API
- [ ] Filter: `status` filter, `keyword` search
- [ ] Pagination: page/pagesize
- [ ] Polling: 1-minute interval for list refresh
- [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~~