fix: retain cancellation labels after approval interruptions

This commit is contained in:
Ed1s0nZ committed 2026-10-06 03:11:11 +08:00
1 parent 054a77cdab
commit 9090e75a67
5 files changed
+105 -2

No files matched your search

+5
View File
@@ -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 分支的进度控制按钮也按终态显示,不再再次写“已完成”。真实分支执行验证取消、失败和成功标题、控制文案与遗留工具状态;保留已收到工具终态的结果。
+1 -1
View File
@@ -62,7 +62,7 @@
| 57 | — | 审计日志搜索无结果后,点击“清空”不会清除关键词或恢复列表,无反应 | 当前重置会清空关键词并请求首个无筛选页面,回归未复现 |
| 58 | — | 任务失败,也显示已完成 | 已修复队列结果标签:全部失败显示失败,混合结果显示失败数;3 项回归通过 |
| 59 | — | 批量任务在“用户并发配额已满”时进入等待调度后,即使后来停止容器、腾出配额,队列仍停在“执行中 | 当前仓库未实现,不扩展能力 |
| 60 | — | 资产扫描任务在后续工具调用的 HITL 审批阶段已被系统中断,审批卡片明确显示“任务已中断、审批 | 待核对 |
| 60 | — | 资产扫描任务在后续工具调用的 HITL 审批阶段已被系统中断,审批卡片明确显示“任务已中断、审批 | 已修复取消终态及进度收尾;真实审批等待→取消→历史/done 集成验证通过 |
| 61 | — | 问题:事实正文里的 discovered_on: target/xxx 只会当普通文本保存,不会 | 已按建议明确关系字段说明 |
| 62 | — | 无效工作流仍显示“启用”: 即使工作流校验不通过、无法试运行,列表和详情仍显示为“启用”,容易让 | 已标示已存储/当前草稿的无效工作流,API 与列表回归通过 |
| 63 | — | 编辑辅助文件时,描述修改未保存 | 已修复并有针对性验证 |
@@ -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")
}
}
+3 -1
View File
@@ -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);
}
// 检查时间线中是否有错误项
@@ -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');
}
});