feat: Enhance AI Agent and Channel Management

- Updated labels in the AI Agents dashboard for clarity, changing "流程状态" to "Playbook 状态" and "未发布流程" to "未发布 Playbook".
- Introduced AI Agent rollout percentage management in channel editing, allowing users to set and rollback rollout percentages.
- Added new API endpoints for rolling back AI Agent rollout and fetching agent run metrics.
- Implemented new UI components for displaying agent run details, including status, duration, and input/output tokens.
- Enhanced type definitions for AdminChannel and AIAgent to include rollout percentages and runtime modes.
- Updated navigation to include a section for agent runs.
- Added new translations for agent run features in both English and Chinese.
This commit is contained in:
mlogclub
2026-07-25 12:04:06 +08:00
parent 45741d4032
commit 34051a4631
101 changed files with 8377 additions and 340 deletions
@@ -12,13 +12,14 @@ import (
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/toolx"
"github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
)
func TestAIAgentServiceCreatesDefaultWorkflow(t *testing.T) {
func TestAIAgentServiceCreatesWorkflowOnlyWhenRequested(t *testing.T) {
setupAIAgentWorkflowTestDB(t)
operator := aiAgentWorkflowTestOperator()
aiConfigID := createAIAgentWorkflowTestConfig(t)
@@ -26,6 +27,7 @@ func TestAIAgentServiceCreatesDefaultWorkflow(t *testing.T) {
item, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{
Name: "workflow agent",
AIConfigID: aiConfigID,
RuntimeMode: enums.AIAgentRuntimeModeWorkflow,
ServiceMode: enums.IMConversationServiceModeAIOnly,
HandoffMode: enums.AIAgentHandoffModeWaitPool,
FallbackMode: enums.AIAgentFallbackModeNoAnswer,
@@ -33,6 +35,15 @@ func TestAIAgentServiceCreatesDefaultWorkflow(t *testing.T) {
if err != nil {
t.Fatalf("CreateAIAgent() error = %v", err)
}
if item.RuntimeMode != enums.AIAgentRuntimeModeWorkflow {
t.Fatalf("default runtime mode = %q, want %q", item.RuntimeMode, enums.AIAgentRuntimeModeWorkflow)
}
if item.MaxSteps != 6 {
t.Fatalf("default max steps = %d, want 6", item.MaxSteps)
}
if item.RolloutPercent != 100 {
t.Fatalf("workflow rollout default = %d, want 100", item.RolloutPercent)
}
workflow, err := AIWorkflowService.GetOrCreateAgentWorkflow(item.ID, operator)
if err != nil {
@@ -81,10 +92,12 @@ func TestAIAgentServiceCreatesDefaultWorkflow(t *testing.T) {
}
}
assertConditionBranchToNodeType(t, stored, "policy_route_1", workflowregistry.NodeTypeSendReply, "eq", "direct_reply")
assertConditionBranchToNodeType(t, stored, "policy_route_1", workflowregistry.NodeTypeHandoffToHuman, "eq", "handoff_to_human")
assertConditionBranchToNodeID(t, stored, "policy_route_1", "handoff_confirm_prompt_1", "eq", "handoff_to_human")
assertConditionBranchToNodeType(t, stored, "policy_route_1", workflowregistry.NodeTypePrepareTicketDraft, "eq", "prepare_ticket")
assertConditionBranchToNodeID(t, stored, "ticket_draft_route_1", "ticket_confirm_prompt_1", "is_true", nil)
assertDefaultBranchToNodeID(t, stored, "ticket_draft_route_1", "ticket_followup_reply_1")
assertConditionBranchToNodeID(t, stored, "handoff_confirm_route_1", "handoff_1", "is_true", nil)
assertDefaultBranchToNodeID(t, stored, "handoff_confirm_route_1", "handoff_cancel_reply_1")
assertConditionBranchToNodeID(t, stored, "answerability_route_1", "reply_1", "eq", "answerable")
assertDefaultBranchToNodeID(t, stored, "answerability_route_1", "fallback_reply_1")
if !workflowEdgeExists(stored, "create_ticket_1", "ticket_result_reply_1") {
@@ -93,6 +106,7 @@ func TestAIAgentServiceCreatesDefaultWorkflow(t *testing.T) {
assertConditionBranchesHavePortEdges(t, stored, "policy_route_1")
assertConditionBranchesHavePortEdges(t, stored, "ticket_draft_route_1")
assertConditionBranchesHavePortEdges(t, stored, "ticket_confirm_route_1")
assertConditionBranchesHavePortEdges(t, stored, "handoff_confirm_route_1")
assertConditionBranchesHavePortEdges(t, stored, "answerability_route_1")
assertConditionBranchOrder(t, stored, "policy_route_1", []string{
"handoff",
@@ -114,6 +128,230 @@ func TestAIAgentServiceCreatesDefaultWorkflow(t *testing.T) {
})
}
func TestAIAgentServiceDefaultsNewAutonomousAgentToSmallRollout(t *testing.T) {
setupAIAgentWorkflowTestDB(t)
item, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{
Name: "small-rollout autonomous agent", AIConfigID: createAIAgentWorkflowTestConfig(t), RuntimeMode: enums.AIAgentRuntimeModeAutonomous,
ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer,
}, aiAgentWorkflowTestOperator())
if err != nil {
t.Fatalf("CreateAIAgent: %v", err)
}
if item.RolloutPercent != defaultNewAutonomousRolloutPercent {
t.Fatalf("autonomous rollout default = %d, want %d", item.RolloutPercent, defaultNewAutonomousRolloutPercent)
}
}
func TestAIAgentServiceDefaultsToAutonomousWithoutWorkflow(t *testing.T) {
setupAIAgentWorkflowTestDB(t)
operator := aiAgentWorkflowTestOperator()
item, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{
Name: "default autonomous agent", AIConfigID: createAIAgentWorkflowTestConfig(t),
ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer,
}, operator)
if err != nil {
t.Fatalf("CreateAIAgent() error = %v", err)
}
if item.RuntimeMode != enums.AIAgentRuntimeModeAutonomous {
t.Fatalf("default runtime mode = %q, want %q", item.RuntimeMode, enums.AIAgentRuntimeModeAutonomous)
}
var workflowCount int64
if err := sqls.DB().Model(&models.AIWorkflow{}).Where("agent_id = ?", item.ID).Count(&workflowCount).Error; err != nil {
t.Fatalf("count workflows: %v", err)
}
if workflowCount != 0 {
t.Fatalf("default autonomous agent created %d workflows", workflowCount)
}
}
func TestAIAgentServiceCreatesWorkflowDraftForHybrid(t *testing.T) {
setupAIAgentWorkflowTestDB(t)
item, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{
Name: "hybrid agent", AIConfigID: createAIAgentWorkflowTestConfig(t), RuntimeMode: enums.AIAgentRuntimeModeHybrid,
ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer,
}, aiAgentWorkflowTestOperator())
if err != nil {
t.Fatalf("CreateAIAgent() error = %v", err)
}
var workflowCount int64
if err := sqls.DB().Model(&models.AIWorkflow{}).Where("agent_id = ?", item.ID).Count(&workflowCount).Error; err != nil {
t.Fatalf("count workflows: %v", err)
}
if workflowCount != 1 {
t.Fatalf("hybrid agent created %d workflows, want 1", workflowCount)
}
}
func TestAIAgentServiceNormalizesToolPolicy(t *testing.T) {
policy, err := AIAgentService.normalizeToolPolicy(`{"maxTotalCalls":2,"maxArgumentBytes":1024,"allowedRiskLevels":["READ","read","sensitive"]}`)
if err != nil {
t.Fatalf("normalizeToolPolicy: %v", err)
}
if !strings.Contains(policy, `"maxTotalCalls":2`) || !strings.Contains(policy, `"allowedRiskLevels":["read","sensitive"]`) {
t.Fatalf("unexpected normalized policy: %s", policy)
}
if _, err := AIAgentService.normalizeToolPolicy(`{"allowedRiskLevels":["admin"]}`); err == nil {
t.Fatal("expected invalid risk level error")
}
if _, err := AIAgentService.normalizeToolPolicy(`not-json`); err == nil {
t.Fatal("expected invalid JSON error")
}
}
func TestAIAgentServiceAllowsRegisteredReadDirectTool(t *testing.T) {
tools, err := AIAgentService.normalizeDirectTools([]request.AIAgentMCPToolRequest{{ToolCode: toolx.BuiltinConversationContext.Code}})
if err != nil {
t.Fatalf("normalizeDirectTools: %v", err)
}
if len(tools) != 1 || tools[0].ToolCode != toolx.BuiltinConversationContext.Code {
t.Fatalf("unexpected normalized direct tools: %#v", tools)
}
tools, err = AIAgentService.normalizeDirectTools([]request.AIAgentMCPToolRequest{{ToolCode: toolx.BuiltinKnowledgeRetrieve.Code}})
if err != nil || len(tools) != 1 || tools[0].ToolCode != toolx.BuiltinKnowledgeRetrieve.Code {
t.Fatalf("expected registered knowledge retrieve tool to be allowed, tools=%#v err=%v", tools, err)
}
tools, err = AIAgentService.normalizeDirectTools([]request.AIAgentMCPToolRequest{{ToolCode: toolx.GraphPrepareTicketDraft.Code}})
if err != nil || len(tools) != 1 || tools[0].ToolCode != toolx.GraphPrepareTicketDraft.Code {
t.Fatalf("expected registered ticket draft tool to be allowed, tools=%#v err=%v", tools, err)
}
tools, err = AIAgentService.normalizeDirectTools([]request.AIAgentMCPToolRequest{{ToolCode: toolx.GraphAnalyzeConversation.Code}})
if err != nil || len(tools) != 1 || tools[0].ToolCode != toolx.GraphAnalyzeConversation.Code {
t.Fatalf("expected registered conversation analysis tool to be allowed, tools=%#v err=%v", tools, err)
}
tools, err = AIAgentService.normalizeDirectTools([]request.AIAgentMCPToolRequest{{ToolCode: toolx.GraphTriageServiceRequest.Code}})
if err != nil || len(tools) != 1 || tools[0].ToolCode != toolx.GraphTriageServiceRequest.Code {
t.Fatalf("expected registered service triage tool to be allowed, tools=%#v err=%v", tools, err)
}
if _, err := AIAgentService.normalizeDirectTools([]request.AIAgentMCPToolRequest{{ToolCode: toolx.GraphHandoffConversation.Code}}); err == nil {
t.Fatal("expected unsupported graph direct tool to be rejected")
}
}
func TestAIAgentServiceRollsBackToOwnPublishedRevision(t *testing.T) {
setupAIAgentWorkflowTestDB(t)
db := sqls.DB()
agent := &models.AIAgent{Name: "rollback-agent", Status: enums.StatusOk, RuntimeMode: enums.AIAgentRuntimeModeAutonomous}
if err := db.Create(agent).Error; err != nil {
t.Fatalf("create agent: %v", err)
}
revision := &models.AgentRevision{AgentID: agent.ID, Revision: 1, Status: enums.StatusOk}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
if err := AIAgentService.RollbackAIAgent(agent.ID, revision.ID, aiAgentWorkflowTestOperator()); err != nil {
t.Fatalf("RollbackAIAgent: %v", err)
}
if updated := AIAgentService.Get(agent.ID); updated == nil || updated.PublishedRevisionID != revision.ID {
t.Fatalf("rollback did not bind revision: %#v", updated)
}
otherRevision := &models.AgentRevision{AgentID: agent.ID + 1, Revision: 1, Status: enums.StatusOk}
if err := db.Create(otherRevision).Error; err != nil {
t.Fatalf("create other revision: %v", err)
}
if err := AIAgentService.RollbackAIAgent(agent.ID, otherRevision.ID, aiAgentWorkflowTestOperator()); err == nil {
t.Fatal("expected cross-agent revision rollback rejection")
}
}
func TestAIAgentServiceRollsBackPreviousRolloutPercent(t *testing.T) {
setupAIAgentWorkflowTestDB(t)
agent := &models.AIAgent{
Name: "rollout-agent",
Status: enums.StatusOk,
RuntimeMode: enums.AIAgentRuntimeModeAutonomous,
RolloutPercent: 20,
PreviousRolloutPercent: 100,
}
if err := sqls.DB().Create(agent).Error; err != nil {
t.Fatalf("create agent: %v", err)
}
operator := aiAgentWorkflowTestOperator()
if err := AIAgentService.RollbackAIAgentRollout(agent.ID, operator); err != nil {
t.Fatalf("RollbackAIAgentRollout: %v", err)
}
updated := AIAgentService.Get(agent.ID)
if updated == nil || updated.RolloutPercent != 100 || updated.PreviousRolloutPercent != 20 {
t.Fatalf("unexpected rollout rollback result: %#v", updated)
}
if err := AIAgentService.RollbackAIAgentRollout(agent.ID, operator); err != nil {
t.Fatalf("second RollbackAIAgentRollout: %v", err)
}
updated = AIAgentService.Get(agent.ID)
if updated == nil || updated.RolloutPercent != 20 || updated.PreviousRolloutPercent != 100 {
t.Fatalf("unexpected rollout redo result: %#v", updated)
}
if err := sqls.DB().Model(&models.AIAgent{}).Where("id = ?", agent.ID).Update("previous_rollout_percent", 0).Error; err != nil {
t.Fatalf("clear previous rollout: %v", err)
}
if err := AIAgentService.RollbackAIAgentRollout(agent.ID, operator); err == nil {
t.Fatal("expected missing previous rollout to be rejected")
}
}
func TestAIAgentServiceUpdateUnpublishesAutonomousAgent(t *testing.T) {
setupAIAgentWorkflowTestDB(t)
operator := aiAgentWorkflowTestOperator()
agent, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{
Name: "autonomous agent", AIConfigID: createAIAgentWorkflowTestConfig(t), RuntimeMode: enums.AIAgentRuntimeModeAutonomous,
ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer,
}, operator)
if err != nil {
t.Fatalf("CreateAIAgent() error = %v", err)
}
if _, err := AIAgentService.PublishAIAgent(agent.ID, operator); err != nil {
t.Fatalf("PublishAIAgent() error = %v", err)
}
if published := AIAgentService.Get(agent.ID); published == nil || published.PublishedRevisionID <= 0 {
t.Fatalf("expected published autonomous agent, got %#v", published)
}
if err := AIAgentService.UpdateAIAgent(request.UpdateAIAgentRequest{ID: agent.ID, CreateAIAgentRequest: request.CreateAIAgentRequest{
Name: agent.Name, Description: "changed draft", AIConfigID: agent.AIConfigID, RuntimeMode: enums.AIAgentRuntimeModeAutonomous,
ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer,
}}, operator); err != nil {
t.Fatalf("UpdateAIAgent() error = %v", err)
}
if updated := AIAgentService.Get(agent.ID); updated == nil || updated.PublishedRevisionID != 0 {
t.Fatalf("expected autonomous update to clear published revision, got %#v", updated)
}
}
func TestAIAgentServiceRejectsPublishWithUnavailableModelConfig(t *testing.T) {
setupAIAgentWorkflowTestDB(t)
operator := aiAgentWorkflowTestOperator()
configID := createAIAgentWorkflowTestConfig(t)
agent, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{
Name: "unavailable model agent", AIConfigID: configID, RuntimeMode: enums.AIAgentRuntimeModeAutonomous,
ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer,
}, operator)
if err != nil {
t.Fatalf("CreateAIAgent() error = %v", err)
}
if err := sqls.DB().Model(&models.AIConfig{}).Where("id = ?", configID).Update("status", enums.StatusDisabled).Error; err != nil {
t.Fatalf("disable model config: %v", err)
}
if _, err := AIAgentService.PublishAIAgent(agent.ID, operator); err == nil {
t.Fatal("expected unavailable model config to reject publishing")
}
}
func TestAIAgentServiceRejectsPublishWithSensitiveDirectTool(t *testing.T) {
setupAIAgentWorkflowTestDB(t)
operator := aiAgentWorkflowTestOperator()
agent, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{
Name: "sensitive tool agent", AIConfigID: createAIAgentWorkflowTestConfig(t), RuntimeMode: enums.AIAgentRuntimeModeAutonomous,
ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer,
}, operator)
if err != nil {
t.Fatalf("CreateAIAgent() error = %v", err)
}
if err := sqls.DB().Model(&models.AIAgent{}).Where("id = ?", agent.ID).Update("allowed_mcp_tools", `[{"toolCode":"mcp/demo/write_order"}]`).Error; err != nil {
t.Fatalf("set direct tool: %v", err)
}
if _, err := AIAgentService.PublishAIAgent(agent.ID, operator); err == nil || !strings.Contains(err.Error(), "confirmed playbook") {
t.Fatalf("expected sensitive direct tool publish rejection, got %v", err)
}
}
func TestAIWorkflowServiceDefaultAgentWorkflowDefinitionRequiresKnowledgeRetrieveConfiguration(t *testing.T) {
definition := AIWorkflowService.DefaultAgentWorkflowDefinition()
if definition.SchemaVersion != dsl.SchemaVersion || nodeTypeByID(definition, "start_1") != workflowregistry.NodeTypeStart {
@@ -135,6 +373,13 @@ func TestAIWorkflowServiceDefaultAgentWorkflowDefinitionRequiresKnowledgeRetriev
if !workflowHasNodeType(definition, workflowregistry.NodeTypeCreateTicket) {
t.Fatalf("expected default workflow to include ticket creation node")
}
if nodeTypeByID(definition, "handoff_confirm_1") != workflowregistry.NodeTypeHumanConfirm {
t.Fatalf("expected default workflow handoff path to include human confirmation")
}
handoff := workflowNodeByID(t, definition, "handoff_1")
if nodeID, field, ok := handoff.Data.InputsValues["confirmed"].Ref(); !ok || nodeID != "handoff_confirm_1" || field != "confirmed" {
t.Fatalf("expected handoff to use confirmation result, got %#v", handoff.Data.InputsValues["confirmed"])
}
}
func TestAIWorkflowServiceDefaultAgentWorkflowTicketPromptIncludesDraftFields(t *testing.T) {
@@ -203,6 +448,19 @@ func TestAIWorkflowServicePublishAgentWorkflowBindsAgentVersion(t *testing.T) {
if storedAgent.WorkflowVersionID != version.ID {
t.Fatalf("expected agent workflow version %d, got %d", version.ID, storedAgent.WorkflowVersionID)
}
if storedAgent.PublishedRevisionID <= 0 {
t.Fatalf("expected published agent revision id, got %d", storedAgent.PublishedRevisionID)
}
var revision models.AgentRevision
if err := sqls.DB().First(&revision, storedAgent.PublishedRevisionID).Error; err != nil {
t.Fatalf("load agent revision: %v", err)
}
if revision.AgentID != agent.ID || revision.WorkflowVersionID != version.ID || revision.Revision != 1 || revision.DefinitionHash == "" {
t.Fatalf("unexpected published agent revision: %#v", revision)
}
if !strings.Contains(revision.Definition, `"modelName":"gpt-test"`) || strings.Contains(revision.Definition, "revision-test-secret") {
t.Fatalf("unexpected revision definition: %s", revision.Definition)
}
}
func setupAIAgentWorkflowTestDB(t *testing.T) {
@@ -211,7 +469,7 @@ func setupAIAgentWorkflowTestDB(t *testing.T) {
if err != nil {
t.Fatalf("open sqlite db: %v", err)
}
if err := db.AutoMigrate(&models.AIAgent{}, &models.AIConfig{}, &models.KnowledgeBase{}, &models.AIWorkflow{}, &models.AIWorkflowVersion{}); err != nil {
if err := db.AutoMigrate(&models.AIAgent{}, &models.AIConfig{}, &models.KnowledgeBase{}, &models.AIWorkflow{}, &models.AIWorkflowVersion{}, &models.AgentRevision{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
@@ -222,6 +480,7 @@ func createAIAgentWorkflowTestConfig(t *testing.T) int64 {
item := &models.AIConfig{
Name: "workflow-test-config",
Provider: enums.AIProviderOpenAI,
APIKey: "revision-test-secret",
ModelType: enums.AIModelTypeLLM,
ModelName: "gpt-test",
Status: enums.StatusOk,