From 9090e75a67eaa368593e91f6cda347572f50bb6d Mon Sep 17 00:00:00 2001 From: Ed1s0nZ Date: Tue, 6 Oct 2026 03:11:11 +0800 Subject: [PATCH] fix: retain cancellation labels after approval interruptions --- docs/bug-sheet-review.md | 5 ++ docs/bug-sheet-status.md | 2 +- .../hitl_task_cancel_integration_test.go | 86 +++++++++++++++++++ web/static/js/monitor.js | 4 +- web/static/js/progress-done-outcome.test.cjs | 10 +++ 5 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 internal/handler/hitl_task_cancel_integration_test.go diff --git a/docs/bug-sheet-review.md b/docs/bug-sheet-review.md index c585139e..99d159e1 100644 --- a/docs/bug-sheet-review.md +++ b/docs/bug-sheet-review.md @@ -119,3 +119,8 @@ - Excel 第 24 行:AgentTaskManager 原先允许取消之后的 UpdateTaskStatus/FinishTaskRun 覆盖终态。明确用户取消增加单向取消标记,晚到的 running/completed/failed 更新及 completed 收尾仍保存 cancelled;“中断并继续”不设置该标记。 - 主 SSE 收尾从实际已保存任务取 status,保留原消息字段且不修改调用方对象;前端 done 不再一律覆盖为成功标题,取消/失败/超时/清理异常使用实际结果,取消时遗留工具调用标记取消。任务 eventBus 本来已有真实 status,保持不变。 - 新增取消晚到更新、正常继续、done 实际状态与前端展示回归;完整 handler 包测试通过。Excel 第 60 行仍需审批等待中断的完整路径验证,暂不声明该历史场景全部完成。 + +## 追加:审批等待取消的闭环验证 + +- Excel 第 60 行:真实数据库中创建会话/助手消息,启用人工审批,实际 waitHITLApproval 发出 pending 后通过 AgentTaskManager 取消;验证等待返回 context.Canceled、审批记录 cancelled、任务从活跃列表移除、历史与 done 均 cancelled。测试不调用真实模型或扫描目标,因此证明审批/任务收尾路径,不能重建截图中的远端模型过程。 +- done 分支的进度控制按钮也按终态显示,不再再次写“已完成”。真实分支执行验证取消、失败和成功标题、控制文案与遗留工具状态;保留已收到工具终态的结果。 diff --git a/docs/bug-sheet-status.md b/docs/bug-sheet-status.md index 274e5731..ab23827d 100644 --- a/docs/bug-sheet-status.md +++ b/docs/bug-sheet-status.md @@ -62,7 +62,7 @@ | 57 | — | 审计日志搜索无结果后,点击“清空”不会清除关键词或恢复列表,无反应 | 当前重置会清空关键词并请求首个无筛选页面,回归未复现 | | 58 | — | 任务失败,也显示已完成 | 已修复队列结果标签:全部失败显示失败,混合结果显示失败数;3 项回归通过 | | 59 | — | 批量任务在“用户并发配额已满”时进入等待调度后,即使后来停止容器、腾出配额,队列仍停在“执行中 | 当前仓库未实现,不扩展能力 | -| 60 | — | 资产扫描任务在后续工具调用的 HITL 审批阶段已被系统中断,审批卡片明确显示“任务已中断、审批 | 待核对 | +| 60 | — | 资产扫描任务在后续工具调用的 HITL 审批阶段已被系统中断,审批卡片明确显示“任务已中断、审批 | 已修复取消终态及进度收尾;真实审批等待→取消→历史/done 集成验证通过 | | 61 | — | 问题:事实正文里的 discovered_on: target/xxx 只会当普通文本保存,不会 | 已按建议明确关系字段说明 | | 62 | — | 无效工作流仍显示“启用”: 即使工作流校验不通过、无法试运行,列表和详情仍显示为“启用”,容易让 | 已标示已存储/当前草稿的无效工作流,API 与列表回归通过 | | 63 | — | 编辑辅助文件时,描述修改未保存 | 已修复并有针对性验证 | diff --git a/internal/handler/hitl_task_cancel_integration_test.go b/internal/handler/hitl_task_cancel_integration_test.go new file mode 100644 index 00000000..136a0456 --- /dev/null +++ b/internal/handler/hitl_task_cancel_integration_test.go @@ -0,0 +1,86 @@ +package handler + +import ( + "context" + "cyberstrike-ai/internal/database" + "errors" + "go.uber.org/zap" + "path/filepath" + "testing" + "time" +) + +func TestTaskCancellationEndsPendingHumanApprovalAndTask(t *testing.T) { + db, err := database.NewDB(filepath.Join(t.TempDir(), "approval.db"), zap.NewNop()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + approvals := NewHITLManager(db, zap.NewNop()) + if err := approvals.EnsureSchema(); err != nil { + t.Fatal(err) + } + conversation, err := db.CreateConversation("test approval cancellation", database.ConversationCreateMeta{}) + if err != nil { + t.Fatal(err) + } + message, err := db.AddMessage(conversation.ID, "assistant", "test pending", nil) + if err != nil { + t.Fatal(err) + } + approvals.ActivateConversation(conversation.ID, &HITLRequest{Enabled: true, Mode: "approval", Reviewer: "human"}) + tasks := NewAgentTaskManager() + ctx, cancel := context.WithCancelCause(context.Background()) + task, err := tasks.StartTask(conversation.ID, "test", cancel) + if err != nil { + t.Fatal(err) + } + h := &AgentHandler{db: db, logger: zap.NewNop(), tasks: tasks, hitlManager: approvals} + pending := make(chan string, 1) + finished := make(chan error, 1) + go func() { + _, err := h.waitHITLApproval(ctx, cancel, conversation.ID, message.ID, "http-framework-test", "call-1", map[string]interface{}{"arguments": "test-only"}, func(kind, message string, data interface{}) { + if kind == "hitl_interrupt" { + pending <- data.(map[string]interface{})["interruptId"].(string) + } + }) + finished <- err + }() + var interruptID string + select { + case interruptID = <-pending: + case <-time.After(5 * time.Second): + t.Fatal("approval never became pending") + } + if _, err := tasks.CancelTask(conversation.ID, ErrTaskCancelled); err != nil { + t.Fatal(err) + } + select { + case err := <-finished: + if !errors.Is(err, context.Canceled) { + t.Fatalf("approval error: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("approval did not stop") + } + var approvalStatus string + if err := db.QueryRow("SELECT status FROM hitl_interrupts WHERE id=?", interruptID).Scan(&approvalStatus); err != nil { + t.Fatal(err) + } + if approvalStatus != "cancelled" { + t.Fatalf("approval status: %s", approvalStatus) + } + var doneStatus string + send := h.taskFinishingEventSender(func(kind, message string, data interface{}) { + if kind == "done" { + doneStatus = data.(map[string]interface{})["status"].(string) + } + }, conversation.ID, task.RunID, func() string { return "completed" }) + send("done", "", map[string]interface{}{}) + if doneStatus != "cancelled" || len(tasks.GetActiveTasks()) != 0 { + t.Fatalf("task still active or wrong result: %s", doneStatus) + } + if history := tasks.GetCompletedTasks(); len(history) != 1 || history[0].Status != "cancelled" { + t.Fatal("cancelled history missing") + } +} diff --git a/web/static/js/monitor.js b/web/static/js/monitor.js index a8afbb3d..8009d489 100644 --- a/web/static/js/monitor.js +++ b/web/static/js/monitor.js @@ -4206,7 +4206,9 @@ function handleStreamEvent(event, progressElement, progressId, updateProgressConversation(progressId, event.data.conversationId); } if (progressTaskState.has(progressId)) { - finalizeProgressTask(progressId, typeof window.t === 'function' ? window.t('tasks.statusCompleted') : '已完成'); + const outcome = progressDoneOutcome(event.data); + const labelKey = outcome.icon === '✅' ? 'tasks.statusCompleted' : outcome.key; + finalizeProgressTask(progressId, typeof window.t === 'function' ? window.t(labelKey) : outcome.fallback); } // 检查时间线中是否有错误项 diff --git a/web/static/js/progress-done-outcome.test.cjs b/web/static/js/progress-done-outcome.test.cjs index 225aba9a..7db1f682 100644 --- a/web/static/js/progress-done-outcome.test.cjs +++ b/web/static/js/progress-done-outcome.test.cjs @@ -6,3 +6,13 @@ test('done preserves cancelled and failed outcomes instead of showing success',( } assert.equal(c.progressDoneOutcome({status:'completed'}).icon,'✅');assert.equal(c.progressDoneOutcome({workflowStatus:'cancelled'}).icon,'⛔'); }); +test('actual done branch cancels its progress control and outstanding tools',()=>{ + const start=source.indexOf(" case 'done':"),end=source.indexOf('\n }',start); + for(const status of ['cancelled','failed','completed']){ + const title={};let label,toolStatus; + const ctx=vm.createContext({event:{type:'done',data:{status}},progressId:'p',window:{},document:{querySelector:()=>title},timeline:null,progressTaskState:new Map([['p',{}]]),stopProgressElapsedClock(){},responseStreamStateByProgressId:new Map(),mainIterationStateByProgressId:new Map(),thinkingStreamStateByProgressId:new Map(),einoAgentReplyStreamStateByProgressId:new Map(),toolResultStreamStateByKey:new Map(),loadActiveTasks(){},setTimeout(){},finalizeProgressTask:(_,value)=>{label=value},finalizeOutstandingToolCallsForProgress:(_,value)=>{toolStatus=value}}); + vm.runInContext(source.slice(source.indexOf('function progressDoneOutcome('),source.indexOf('function finalizeOutstandingToolCallsForProgress(')),ctx); + vm.runInContext('function run(){switch(event.type){'+source.slice(start,end)+'}}',ctx);ctx.run(); + assert.equal(title.textContent.startsWith('✅'),status==='completed');assert.equal(label,status==='cancelled'?'任务已取消':status==='failed'?'任务执行失败':'渗透测试完成');assert.equal(toolStatus,status==='cancelled'?'cancelled':'failed'); + } +});