mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-08-15 15:40:38 +02:00
feat(ui): 显示 Agent 任务进度列表 (#251)
This commit is contained in:
@@ -443,6 +443,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
||||
conversationHandler := handler.NewConversationHandler(db, log.Logger)
|
||||
conversationHandler.SetAudit(auditSvc)
|
||||
conversationHandler.SetTaskStopper(agentHandler)
|
||||
conversationHandler.SetTaskStateProvider(agentHandler)
|
||||
auditHandler := handler.NewAuditHandler(db, auditSvc, log.Logger)
|
||||
robotHandler := handler.NewRobotHandler(cfg, db, agentHandler, log.Logger)
|
||||
robotHandler.SetAudit(auditSvc)
|
||||
@@ -1029,6 +1030,7 @@ func setupRoutes(
|
||||
protected.POST("/conversations", conversationHandler.CreateConversation)
|
||||
protected.GET("/conversations", conversationHandler.ListConversations)
|
||||
protected.GET("/conversations/:id", conversationHandler.GetConversation)
|
||||
protected.GET("/conversations/:id/plan-tasks", conversationHandler.GetConversationPlanTasks)
|
||||
protected.GET("/messages/:id/process-details", conversationHandler.GetMessageProcessDetails)
|
||||
protected.GET("/process-details/:id", conversationHandler.GetProcessDetail)
|
||||
protected.PUT("/conversations/:id", conversationHandler.UpdateConversation)
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ConversationPlanTask mirrors the public fields persisted by Eino plantask.
|
||||
// Keeping the transport model here avoids coupling the HTTP layer to Eino's
|
||||
// private task type.
|
||||
type ConversationPlanTask struct {
|
||||
ID string `json:"id"`
|
||||
Subject string `json:"subject"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Blocks []string `json:"blocks,omitempty"`
|
||||
BlockedBy []string `json:"blockedBy,omitempty"`
|
||||
ActiveForm string `json:"activeForm,omitempty"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
}
|
||||
|
||||
// ListConversationPlanTasks returns the live Eino task board for one
|
||||
// conversation. A missing task directory is the normal state for short or
|
||||
// legacy conversations and therefore returns an empty list.
|
||||
func (db *DB) ListConversationPlanTasks(conversationID string) ([]ConversationPlanTask, error) {
|
||||
return db.ListConversationPlanTasksSince(conversationID, time.Time{})
|
||||
}
|
||||
|
||||
// ListConversationPlanTasksSince limits the board to files written during the
|
||||
// current agent run. The Eino backend intentionally keeps older task files for
|
||||
// model continuity, but the conversation UI must not surface those files before
|
||||
// the new run has called TaskCreate.
|
||||
func (db *DB) ListConversationPlanTasksSince(conversationID string, since time.Time) ([]ConversationPlanTask, error) {
|
||||
if db == nil {
|
||||
return []ConversationPlanTask{}, nil
|
||||
}
|
||||
conversationID = strings.TrimSpace(conversationID)
|
||||
if conversationID == "" {
|
||||
return nil, fmt.Errorf("conversation id is required")
|
||||
}
|
||||
base := strings.TrimSpace(db.einoPlantaskBaseDir)
|
||||
if base == "" {
|
||||
return []ConversationPlanTask{}, nil
|
||||
}
|
||||
|
||||
dir := filepath.Join(base, sanitizeConversationPathSegment(conversationID))
|
||||
entries, err := os.ReadDir(dir)
|
||||
if os.IsNotExist(err) {
|
||||
return []ConversationPlanTask{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read conversation plan tasks: %w", err)
|
||||
}
|
||||
|
||||
type numberedTask struct {
|
||||
number int
|
||||
task ConversationPlanTask
|
||||
}
|
||||
numbered := make([]numberedTask, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
|
||||
continue
|
||||
}
|
||||
idText := strings.TrimSuffix(entry.Name(), ".json")
|
||||
number, parseErr := strconv.Atoi(idText)
|
||||
if parseErr != nil || number < 1 {
|
||||
continue
|
||||
}
|
||||
if !since.IsZero() {
|
||||
info, infoErr := entry.Info()
|
||||
if infoErr != nil {
|
||||
continue
|
||||
}
|
||||
if info.ModTime().Before(since) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
content, readErr := os.ReadFile(filepath.Join(dir, entry.Name()))
|
||||
if readErr != nil {
|
||||
if db.logger != nil {
|
||||
db.logger.Debug("读取 Eino 任务文件失败",
|
||||
zap.String("conversationId", conversationID),
|
||||
zap.String("file", entry.Name()),
|
||||
zap.Error(readErr))
|
||||
}
|
||||
continue
|
||||
}
|
||||
var task ConversationPlanTask
|
||||
if decodeErr := json.Unmarshal(content, &task); decodeErr != nil {
|
||||
// TaskUpdate writes files concurrently with this read. A partial read
|
||||
// is transient, so skip it and let the next poll recover.
|
||||
if db.logger != nil {
|
||||
db.logger.Debug("解析 Eino 任务文件失败",
|
||||
zap.String("conversationId", conversationID),
|
||||
zap.String("file", entry.Name()),
|
||||
zap.Error(decodeErr))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(task.ID) == "" {
|
||||
task.ID = idText
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(task.Status), "deleted") {
|
||||
continue
|
||||
}
|
||||
numbered = append(numbered, numberedTask{number: number, task: task})
|
||||
}
|
||||
|
||||
sort.SliceStable(numbered, func(i, j int) bool {
|
||||
return numbered[i].number < numbered[j].number
|
||||
})
|
||||
tasks := make([]ConversationPlanTask, 0, len(numbered))
|
||||
for _, item := range numbered {
|
||||
tasks = append(tasks, item.task)
|
||||
}
|
||||
return tasks, nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestListConversationPlanTasksSortedAndToleratesMissingDirectory(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
db, err := NewDB(filepath.Join(tmp, "plantask.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
base := filepath.Join(tmp, "skills", ".eino", "plantask")
|
||||
db.SetEinoConversationDirs(base, "", "", "")
|
||||
missing, err := db.ListConversationPlanTasks("missing")
|
||||
if err != nil || len(missing) != 0 {
|
||||
t.Fatalf("missing task board = %#v, err=%v", missing, err)
|
||||
}
|
||||
|
||||
dir := filepath.Join(base, "conversation-1")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
files := map[string]string{
|
||||
"10.json": `{"id":"10","subject":"最后检查","status":"pending"}`,
|
||||
"2.json": `{"id":"2","subject":"实现接口","status":"in_progress","activeForm":"正在实现接口"}`,
|
||||
"1.json": `{"id":"1","subject":"梳理需求","status":"completed"}`,
|
||||
"bad.json": `{`,
|
||||
}
|
||||
for name, content := range files {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(%s): %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, ".highwatermark"), []byte("10"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(highwatermark): %v", err)
|
||||
}
|
||||
|
||||
tasks, err := db.ListConversationPlanTasks("conversation-1")
|
||||
if err != nil {
|
||||
t.Fatalf("ListConversationPlanTasks: %v", err)
|
||||
}
|
||||
if len(tasks) != 3 {
|
||||
t.Fatalf("tasks = %#v, want 3", tasks)
|
||||
}
|
||||
if tasks[0].ID != "1" || tasks[1].ID != "2" || tasks[2].ID != "10" {
|
||||
t.Fatalf("task order = %q, %q, %q", tasks[0].ID, tasks[1].ID, tasks[2].ID)
|
||||
}
|
||||
if tasks[1].ActiveForm != "正在实现接口" {
|
||||
t.Fatalf("activeForm = %q", tasks[1].ActiveForm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListConversationPlanTasksSinceHidesPreviousRunUntilTaskCreate(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
db, err := NewDB(filepath.Join(tmp, "plantask-current-run.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
base := filepath.Join(tmp, "plantask")
|
||||
db.SetEinoConversationDirs(base, "", "", "")
|
||||
dir := filepath.Join(base, "conversation-current-run")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
oldPath := filepath.Join(dir, "1.json")
|
||||
if err := os.WriteFile(oldPath, []byte(`{"id":"1","subject":"上一轮任务","status":"in_progress"}`), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(old): %v", err)
|
||||
}
|
||||
runStartedAt := time.Now().Add(-time.Second)
|
||||
oldTime := runStartedAt.Add(-time.Minute)
|
||||
if err := os.Chtimes(oldPath, oldTime, oldTime); err != nil {
|
||||
t.Fatalf("Chtimes(old): %v", err)
|
||||
}
|
||||
|
||||
tasks, err := db.ListConversationPlanTasksSince("conversation-current-run", runStartedAt)
|
||||
if err != nil {
|
||||
t.Fatalf("ListConversationPlanTasksSince(before TaskCreate): %v", err)
|
||||
}
|
||||
if len(tasks) != 0 {
|
||||
t.Fatalf("stale tasks shown before current TaskCreate: %#v", tasks)
|
||||
}
|
||||
|
||||
newPath := filepath.Join(dir, "2.json")
|
||||
if err := os.WriteFile(newPath, []byte(`{"id":"2","subject":"本轮任务","status":"pending"}`), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(new): %v", err)
|
||||
}
|
||||
tasks, err = db.ListConversationPlanTasksSince("conversation-current-run", runStartedAt)
|
||||
if err != nil {
|
||||
t.Fatalf("ListConversationPlanTasksSince(after TaskCreate): %v", err)
|
||||
}
|
||||
if len(tasks) != 1 || tasks[0].ID != "2" {
|
||||
t.Fatalf("current tasks = %#v, want task 2 only", tasks)
|
||||
}
|
||||
}
|
||||
@@ -230,6 +230,20 @@ func (h *AgentHandler) CancelRunningTaskForConversation(conversationID string) {
|
||||
}
|
||||
}
|
||||
|
||||
// ConversationTaskRuntimeState exposes the authoritative live state and start
|
||||
// time used to scope persisted TaskCreate files to the current run. A task
|
||||
// already entering cancellation must stop driving progress UI immediately.
|
||||
func (h *AgentHandler) ConversationTaskRuntimeState(conversationID string) (bool, time.Time) {
|
||||
if h == nil || h.tasks == nil || strings.TrimSpace(conversationID) == "" {
|
||||
return false, time.Time{}
|
||||
}
|
||||
task := h.tasks.GetTaskSnapshot(strings.TrimSpace(conversationID))
|
||||
if task == nil || !strings.EqualFold(strings.TrimSpace(task.Status), "running") {
|
||||
return false, time.Time{}
|
||||
}
|
||||
return true, task.StartedAt
|
||||
}
|
||||
|
||||
func (h *AgentHandler) cancelRunningMCPToolsForConversation(conversationID string) {
|
||||
if h == nil || h.agent == nil {
|
||||
return
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/audit"
|
||||
"cyberstrike-ai/internal/database"
|
||||
@@ -18,12 +19,20 @@ type ConversationTaskStopper interface {
|
||||
CancelRunningTaskForConversation(conversationID string)
|
||||
}
|
||||
|
||||
// ConversationTaskStateProvider reports whether the in-memory agent task for
|
||||
// a conversation is still genuinely running. Plan files may survive a service
|
||||
// restart or cancellation, so their status alone is not authoritative.
|
||||
type ConversationTaskStateProvider interface {
|
||||
ConversationTaskRuntimeState(conversationID string) (running bool, startedAt time.Time)
|
||||
}
|
||||
|
||||
// ConversationHandler 对话处理器
|
||||
type ConversationHandler struct {
|
||||
db *database.DB
|
||||
logger *zap.Logger
|
||||
audit *audit.Service
|
||||
taskStopper ConversationTaskStopper
|
||||
taskState ConversationTaskStateProvider
|
||||
}
|
||||
|
||||
// SetAudit wires platform audit logging.
|
||||
@@ -36,6 +45,12 @@ func (h *ConversationHandler) SetTaskStopper(stopper ConversationTaskStopper) {
|
||||
h.taskStopper = stopper
|
||||
}
|
||||
|
||||
// SetTaskStateProvider wires the live agent task registry used by supplemental
|
||||
// conversation UI such as the agent-maintained plan list.
|
||||
func (h *ConversationHandler) SetTaskStateProvider(provider ConversationTaskStateProvider) {
|
||||
h.taskState = provider
|
||||
}
|
||||
|
||||
// NewConversationHandler 创建新的对话处理器
|
||||
func NewConversationHandler(db *database.DB, logger *zap.Logger) *ConversationHandler {
|
||||
return &ConversationHandler{
|
||||
@@ -206,6 +221,70 @@ func (h *ConversationHandler) GetConversation(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, conv)
|
||||
}
|
||||
|
||||
// GetConversationPlanTasks returns the task list maintained by the agent's
|
||||
// TaskCreate/TaskUpdate tools for this conversation.
|
||||
func (h *ConversationHandler) GetConversationPlanTasks(c *gin.Context) {
|
||||
id := strings.TrimSpace(c.Param("id"))
|
||||
session, ok := security.CurrentSession(c)
|
||||
if !ok || !h.db.UserCanAccessResource(session.UserID, session.Scope, "conversation", id) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该对话"})
|
||||
return
|
||||
}
|
||||
if _, err := h.db.GetConversationLite(id); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "对话不存在"})
|
||||
return
|
||||
}
|
||||
running := false
|
||||
startedAt := time.Time{}
|
||||
if h.taskState != nil {
|
||||
running, startedAt = h.taskState.ConversationTaskRuntimeState(id)
|
||||
}
|
||||
if !running {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"tasks": []database.ConversationPlanTask{}, "total": 0,
|
||||
"completed": 0, "activeStep": 0, "running": false,
|
||||
})
|
||||
return
|
||||
}
|
||||
tasks, err := h.db.ListConversationPlanTasksSince(id, startedAt)
|
||||
if err != nil {
|
||||
h.logger.Error("获取对话任务列表失败", zap.String("conversationId", id), zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取任务列表失败"})
|
||||
return
|
||||
}
|
||||
|
||||
completed := 0
|
||||
activeStep := 0
|
||||
for i, task := range tasks {
|
||||
status := strings.ToLower(strings.TrimSpace(task.Status))
|
||||
if status == "completed" {
|
||||
completed++
|
||||
}
|
||||
if activeStep == 0 && status == "in_progress" {
|
||||
activeStep = i + 1
|
||||
}
|
||||
}
|
||||
if activeStep == 0 {
|
||||
for i, task := range tasks {
|
||||
if strings.ToLower(strings.TrimSpace(task.Status)) != "completed" {
|
||||
activeStep = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if activeStep == 0 && len(tasks) > 0 {
|
||||
activeStep = len(tasks)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"tasks": tasks,
|
||||
"total": len(tasks),
|
||||
"completed": completed,
|
||||
"activeStep": activeStep,
|
||||
"running": true,
|
||||
})
|
||||
}
|
||||
|
||||
const (
|
||||
defaultProcessDetailsPageLimit = 50
|
||||
maxProcessDetailsPageLimit = 500
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/security"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type staticConversationTaskState struct {
|
||||
running bool
|
||||
startedAt time.Time
|
||||
}
|
||||
|
||||
func (s staticConversationTaskState) ConversationTaskRuntimeState(string) (bool, time.Time) {
|
||||
return s.running, s.startedAt
|
||||
}
|
||||
|
||||
func TestGetConversationPlanTasksRequiresAccessAndReportsProgress(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
tmp := t.TempDir()
|
||||
db, err := database.NewDB(filepath.Join(tmp, "conversation-plantask.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
conversation, err := db.CreateConversation("plan", database.ConversationCreateMeta{})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateConversation: %v", err)
|
||||
}
|
||||
user, err := db.CreateRBACUser("plan-user", "Plan User", "hash", true, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRBACUser: %v", err)
|
||||
}
|
||||
base := filepath.Join(tmp, "plantask")
|
||||
db.SetEinoConversationDirs(base, "", "", "")
|
||||
dir := filepath.Join(base, conversation.ID)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
for name, content := range map[string]string{
|
||||
"1.json": `{"id":"1","subject":"完成项","status":"completed"}`,
|
||||
"2.json": `{"id":"2","subject":"当前项","status":"in_progress"}`,
|
||||
"3.json": `{"id":"3","subject":"等待项","status":"pending"}`,
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
handler := NewConversationHandler(db, zap.NewNop())
|
||||
handler.SetTaskStateProvider(staticConversationTaskState{running: true})
|
||||
request := func() *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/conversations/"+conversation.ID+"/plan-tasks", nil)
|
||||
c.Params = gin.Params{{Key: "id", Value: conversation.ID}}
|
||||
c.Set(security.ContextSessionKey, security.Session{
|
||||
UserID: user.ID,
|
||||
Scope: database.RBACScopeAssigned,
|
||||
})
|
||||
handler.GetConversationPlanTasks(c)
|
||||
return w
|
||||
}
|
||||
|
||||
w := request()
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("unassigned status = %d, want %d", w.Code, http.StatusForbidden)
|
||||
}
|
||||
if err := db.AssignResourceToUser(user.ID, "conversation", conversation.ID); err != nil {
|
||||
t.Fatalf("AssignResourceToUser: %v", err)
|
||||
}
|
||||
w = request()
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("assigned status = %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var response struct {
|
||||
Total int `json:"total"`
|
||||
Completed int `json:"completed"`
|
||||
ActiveStep int `json:"activeStep"`
|
||||
Tasks []database.ConversationPlanTask `json:"tasks"`
|
||||
Running bool `json:"running"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if response.Total != 3 || response.Completed != 1 || response.ActiveStep != 2 || !response.Running {
|
||||
t.Fatalf("progress = %#v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConversationPlanTasksReportsStoppedLiveTask(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
tmp := t.TempDir()
|
||||
db, err := database.NewDB(filepath.Join(tmp, "conversation-plantask-stopped.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
conversation, err := db.CreateConversation("stopped plan", database.ConversationCreateMeta{})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateConversation: %v", err)
|
||||
}
|
||||
user, err := db.CreateRBACUser("stopped-plan-user", "Stopped Plan User", "hash", true, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRBACUser: %v", err)
|
||||
}
|
||||
if err := db.AssignResourceToUser(user.ID, "conversation", conversation.ID); err != nil {
|
||||
t.Fatalf("AssignResourceToUser: %v", err)
|
||||
}
|
||||
base := filepath.Join(tmp, "plantask")
|
||||
db.SetEinoConversationDirs(base, "", "", "")
|
||||
dir := filepath.Join(base, conversation.ID)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "1.json"), []byte(`{"id":"1","subject":"残留项","status":"in_progress"}`), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
handler := NewConversationHandler(db, zap.NewNop())
|
||||
handler.SetTaskStateProvider(staticConversationTaskState{running: false})
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/conversations/"+conversation.ID+"/plan-tasks", nil)
|
||||
c.Params = gin.Params{{Key: "id", Value: conversation.ID}}
|
||||
c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned})
|
||||
handler.GetConversationPlanTasks(c)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var response struct {
|
||||
Running bool `json:"running"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if response.Running || response.Total != 0 {
|
||||
t.Fatalf("response = %#v", response)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user