mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-08-14 07:00:23 +02:00
* feat(chat): add project-based conversation sidebar * feat(chat): refine Codex-style conversation UI * feat(chat): add Codex-style conversation workflow * feat(ui): 优化对话框与项目侧边栏交互 * fix(chat): 修复暗色输入框圆角填色 * fix(chat): 恢复输入区分层错位布局 * fix(hitl): isolate reviewer state per conversation * feat(hitl): 增加双入口审批与倒计时进度 * fix(hitl): 汇总项目审批并隔离对话状态 * fix(ui): 修复审批状态与无项目新任务 * fix(ui): 优化审批状态与对话切换性能 * fix(ui): 修复中断任务审批仍计时 * fix(ui): 修复多对话并发切换卡顿 * fix(hitl): 主动同步审批并关闭中断状态 * fix(ui): 固定项目审批汇总为绿色 * feat(ui): 同步系统模型与推理强度 * feat(ui): 完善项目侧栏预览与新建入口 * fix(ui): 防止无项目文件夹误展开 * fix(ui): 防止长历史对话滚动误触审批 * fix(ui): 修复对话操作并补充项目置顶 * fix(ui): 移除对话分组并调整项目置顶排序 * feat(ui): 优化迭代导航与审批交互 * fix(hitl): 将 write_file 加入内置免审批工具 * fix(chat): 支持回车发送与 Shift 回车换行 * fix(chat): 优化对话刷新与 Codex 风格交互 * fix(ui): 显示对话具体更新时间 * fix(ui): 优化对话刷新与项目加载 * fix(ui): 修复 Agent 审查文字裁切 * fix(chat): 修复刷新续流与多标签页同步 * fix(chat): 修复滚动跟随与中断任务终态 * fix(ui): 修复流式滚动跳动与工具状态恢复 * fix(ui): 修复刷新后流式输出停止粘底
237 lines
9.2 KiB
Go
237 lines
9.2 KiB
Go
package handler
|
|
|
|
import (
|
|
"database/sql"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"cyberstrike-ai/internal/database"
|
|
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
func TestEnsureSchemaCancelsPendingInterruptsAfterRestart(t *testing.T) {
|
|
db, err := database.NewDB(filepath.Join(t.TempDir(), "hitl-restart.db"), zap.NewNop())
|
|
if err != nil {
|
|
t.Fatalf("open db: %v", err)
|
|
}
|
|
defer func() { _ = db.Close() }()
|
|
manager := NewHITLManager(db, zap.NewNop())
|
|
if err := manager.EnsureSchema(); err != nil {
|
|
t.Fatalf("ensure schema: %v", err)
|
|
}
|
|
conversation, err := db.CreateConversation("restart interrupted", database.ConversationCreateMeta{})
|
|
if err != nil {
|
|
t.Fatalf("create conversation: %v", err)
|
|
}
|
|
message, err := db.AddMessage(conversation.ID, "assistant", "处理中...", nil)
|
|
if err != nil {
|
|
t.Fatalf("create assistant placeholder: %v", err)
|
|
}
|
|
if _, err := db.Exec(`INSERT INTO hitl_interrupts
|
|
(id, conversation_id, message_id, mode, tool_name, tool_call_id, payload, status, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP)`,
|
|
"restart-pending", conversation.ID, message.ID, "approval", "browser", "tool-call-1", `{}`); err != nil {
|
|
t.Fatalf("insert pending interrupt: %v", err)
|
|
}
|
|
|
|
if err := manager.EnsureSchema(); err != nil {
|
|
t.Fatalf("reconcile restart: %v", err)
|
|
}
|
|
|
|
var status, decision, comment, decidedBy string
|
|
var decidedAt sql.NullTime
|
|
if err := db.QueryRow(`SELECT status, decision, decision_comment, decided_by, decided_at
|
|
FROM hitl_interrupts WHERE id = ?`, "restart-pending").
|
|
Scan(&status, &decision, &comment, &decidedBy, &decidedAt); err != nil {
|
|
t.Fatalf("query reconciled interrupt: %v", err)
|
|
}
|
|
if status != "cancelled" || decision != "reject" || comment != "process restarted" {
|
|
t.Fatalf("unexpected restart decision: status=%q decision=%q comment=%q", status, decision, comment)
|
|
}
|
|
if decidedBy != "system" {
|
|
t.Fatalf("decided_by=%q, want system", decidedBy)
|
|
}
|
|
if !decidedAt.Valid {
|
|
t.Fatal("decided_at should be set after restart reconciliation")
|
|
}
|
|
|
|
var content string
|
|
var updatedAt sql.NullTime
|
|
if err := db.QueryRow(`SELECT content, updated_at FROM messages WHERE id = ?`, message.ID).
|
|
Scan(&content, &updatedAt); err != nil {
|
|
t.Fatalf("query reconciled assistant message: %v", err)
|
|
}
|
|
if content != "任务因服务重启已中断,审批已取消。" {
|
|
t.Fatalf("assistant content=%q, want restart interruption notice", content)
|
|
}
|
|
if !updatedAt.Valid {
|
|
t.Fatal("assistant updated_at should be set to the interruption time")
|
|
}
|
|
var eventType, eventMessage string
|
|
if err := db.QueryRow(`SELECT event_type, message FROM process_details WHERE message_id = ?`, message.ID).
|
|
Scan(&eventType, &eventMessage); err != nil {
|
|
t.Fatalf("query restart cancellation process detail: %v", err)
|
|
}
|
|
if eventType != "cancelled" || eventMessage != content {
|
|
t.Fatalf("unexpected terminal detail: type=%q message=%q", eventType, eventMessage)
|
|
}
|
|
}
|
|
|
|
func TestEnsureSchemaFinalizesOnlyHistoricalPlaceholdersWithTerminalEvidence(t *testing.T) {
|
|
db, err := database.NewDB(filepath.Join(t.TempDir(), "hitl-history.db"), zap.NewNop())
|
|
if err != nil {
|
|
t.Fatalf("open db: %v", err)
|
|
}
|
|
defer func() { _ = db.Close() }()
|
|
manager := NewHITLManager(db, zap.NewNop())
|
|
if err := manager.EnsureSchema(); err != nil {
|
|
t.Fatalf("ensure schema: %v", err)
|
|
}
|
|
|
|
supersededConversation, err := db.CreateConversation("superseded placeholder", database.ConversationCreateMeta{})
|
|
if err != nil {
|
|
t.Fatalf("create superseded conversation: %v", err)
|
|
}
|
|
superseded, err := db.AddMessage(supersededConversation.ID, "assistant", "处理中...", nil)
|
|
if err != nil {
|
|
t.Fatalf("create superseded placeholder: %v", err)
|
|
}
|
|
if _, err := db.AddMessage(supersededConversation.ID, "user", "继续", nil); err != nil {
|
|
t.Fatalf("create later message: %v", err)
|
|
}
|
|
|
|
timeoutConversation, err := db.CreateConversation("timeout placeholder", database.ConversationCreateMeta{})
|
|
if err != nil {
|
|
t.Fatalf("create timeout conversation: %v", err)
|
|
}
|
|
timedOut, err := db.AddMessage(timeoutConversation.ID, "assistant", "处理中...", nil)
|
|
if err != nil {
|
|
t.Fatalf("create timeout placeholder: %v", err)
|
|
}
|
|
if _, err := db.Exec(`INSERT INTO hitl_interrupts
|
|
(id, conversation_id, message_id, mode, tool_name, status, decision, decision_comment, created_at, decided_at)
|
|
VALUES (?, ?, ?, 'approval', 'browser', 'timeout', 'reject', 'HITL timeout auto-reject for safety', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
|
|
"timeout-interrupt", timeoutConversation.ID, timedOut.ID); err != nil {
|
|
t.Fatalf("insert timeout interrupt: %v", err)
|
|
}
|
|
|
|
rejectedConversation, err := db.CreateConversation("rejected placeholder", database.ConversationCreateMeta{})
|
|
if err != nil {
|
|
t.Fatalf("create rejected conversation: %v", err)
|
|
}
|
|
rejected, err := db.AddMessage(rejectedConversation.ID, "assistant", "处理中...", nil)
|
|
if err != nil {
|
|
t.Fatalf("create rejected placeholder: %v", err)
|
|
}
|
|
if _, err := db.Exec(`INSERT INTO hitl_interrupts
|
|
(id, conversation_id, message_id, mode, tool_name, status, decision, decision_comment, created_at, decided_at)
|
|
VALUES (?, ?, ?, 'approval', 'exec', 'decided', 'reject', 'user rejected', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
|
|
"rejected-interrupt", rejectedConversation.ID, rejected.ID); err != nil {
|
|
t.Fatalf("insert rejected interrupt: %v", err)
|
|
}
|
|
|
|
activeConversation, err := db.CreateConversation("potentially active placeholder", database.ConversationCreateMeta{})
|
|
if err != nil {
|
|
t.Fatalf("create active conversation: %v", err)
|
|
}
|
|
potentiallyActive, err := db.AddMessage(activeConversation.ID, "assistant", "处理中...", nil)
|
|
if err != nil {
|
|
t.Fatalf("create potentially active placeholder: %v", err)
|
|
}
|
|
|
|
if err := manager.EnsureSchema(); err != nil {
|
|
t.Fatalf("reconcile historical placeholders: %v", err)
|
|
}
|
|
|
|
assertTerminal := func(messageID, wantContent, wantEvent string) {
|
|
t.Helper()
|
|
var content, eventType string
|
|
if err := db.QueryRow(`SELECT content FROM messages WHERE id = ?`, messageID).Scan(&content); err != nil {
|
|
t.Fatalf("query message %s: %v", messageID, err)
|
|
}
|
|
if content != wantContent {
|
|
t.Fatalf("message %s content=%q, want %q", messageID, content, wantContent)
|
|
}
|
|
if err := db.QueryRow(`SELECT event_type FROM process_details WHERE message_id = ?
|
|
AND event_type IN ('cancelled', 'timeout', 'error')`, messageID).Scan(&eventType); err != nil {
|
|
t.Fatalf("query terminal detail %s: %v", messageID, err)
|
|
}
|
|
if eventType != wantEvent {
|
|
t.Fatalf("message %s event=%q, want %q", messageID, eventType, wantEvent)
|
|
}
|
|
}
|
|
assertTerminal(superseded.ID, "任务因服务重启已中断。", "cancelled")
|
|
assertTerminal(timedOut.ID, "任务等待审批超时,已自动拒绝。", "timeout")
|
|
assertTerminal(rejected.ID, "任务审批已拒绝,执行已停止。", "cancelled")
|
|
|
|
var activeContent string
|
|
if err := db.QueryRow(`SELECT content FROM messages WHERE id = ?`, potentiallyActive.ID).Scan(&activeContent); err != nil {
|
|
t.Fatalf("query potentially active message: %v", err)
|
|
}
|
|
if activeContent != "处理中..." {
|
|
t.Fatalf("potentially active message was rewritten to %q", activeContent)
|
|
}
|
|
var terminalCount int
|
|
if err := db.QueryRow(`SELECT COUNT(*) FROM process_details WHERE message_id = ?
|
|
AND event_type IN ('cancelled', 'timeout', 'error')`, potentiallyActive.ID).Scan(&terminalCount); err != nil {
|
|
t.Fatalf("count active terminal details: %v", err)
|
|
}
|
|
if terminalCount != 0 {
|
|
t.Fatalf("potentially active message got %d terminal details", terminalCount)
|
|
}
|
|
}
|
|
|
|
func TestAuditAgentInterruptIsNotHumanPendingWork(t *testing.T) {
|
|
db, err := database.NewDB(filepath.Join(t.TempDir(), "hitl-reviewer.db"), zap.NewNop())
|
|
if err != nil {
|
|
t.Fatalf("open db: %v", err)
|
|
}
|
|
defer func() { _ = db.Close() }()
|
|
manager := NewHITLManager(db, zap.NewNop())
|
|
if err := manager.EnsureSchema(); err != nil {
|
|
t.Fatalf("ensure schema: %v", err)
|
|
}
|
|
|
|
audit, err := manager.CreatePendingInterrupt("conversation-audit", "message-audit", "review_edit", "exec", "call-audit", `{}`, "audit_agent")
|
|
if err != nil {
|
|
t.Fatalf("create audit interrupt: %v", err)
|
|
}
|
|
human, err := manager.CreatePendingInterrupt("conversation-human", "message-human", "approval", "exec", "call-human", `{}`, "human")
|
|
if err != nil {
|
|
t.Fatalf("create human interrupt: %v", err)
|
|
}
|
|
|
|
manager.mu.RLock()
|
|
_, auditWaitsForHuman := manager.pending[audit.InterruptID]
|
|
_, humanWaitsForHuman := manager.pending[human.InterruptID]
|
|
manager.mu.RUnlock()
|
|
if auditWaitsForHuman {
|
|
t.Fatal("audit-agent interrupt must not enter the human pending queue")
|
|
}
|
|
if !humanWaitsForHuman {
|
|
t.Fatal("human interrupt should enter the human pending queue")
|
|
}
|
|
|
|
query, args := (&AgentHandler{}).buildHitlListQuery(false)
|
|
if len(args) != 0 {
|
|
t.Fatalf("unexpected pending query args: %v", args)
|
|
}
|
|
if !strings.Contains(query, "COALESCE(reviewer,'human') = 'human'") {
|
|
t.Fatalf("pending query must filter out audit-agent work: %s", query)
|
|
}
|
|
rows, err := db.Query(query)
|
|
if err != nil {
|
|
t.Fatalf("query human pending interrupts: %v", err)
|
|
}
|
|
defer rows.Close()
|
|
items, err := (&AgentHandler{}).scanHitlInterruptRows(rows)
|
|
if err != nil {
|
|
t.Fatalf("scan human pending interrupts: %v", err)
|
|
}
|
|
if len(items) != 1 || items[0]["id"] != human.InterruptID || items[0]["reviewer"] != "human" {
|
|
t.Fatalf("unexpected human pending result: %#v", items)
|
|
}
|
|
}
|