mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-08-14 15:10:20 +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): 修复刷新后流式输出停止粘底
196 lines
6.2 KiB
Go
196 lines
6.2 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"cyberstrike-ai/internal/config"
|
|
"cyberstrike-ai/internal/database"
|
|
"cyberstrike-ai/internal/openai"
|
|
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// TestCreateProgressCallback_ConcurrentToolEvents 回归 issue #142:并行 tool 回调不得 concurrent map panic。
|
|
func TestCreateProgressCallback_ConcurrentToolEvents(t *testing.T) {
|
|
logger := zap.NewNop()
|
|
h := &AgentHandler{
|
|
logger: logger,
|
|
config: &config.Config{},
|
|
}
|
|
cb := h.createProgressCallback(context.Background(), nil, "conv-race-test", "", nil)
|
|
|
|
const workers = 64
|
|
var wg sync.WaitGroup
|
|
wg.Add(workers * 2)
|
|
for i := 0; i < workers; i++ {
|
|
i := i
|
|
go func() {
|
|
defer wg.Done()
|
|
toolCallID := fmt.Sprintf("tc-%d", i)
|
|
cb("tool_call", "calling skill", map[string]interface{}{
|
|
"toolCallId": toolCallID,
|
|
"toolName": "skill",
|
|
"argumentsObj": map[string]interface{}{"skill_name": "demo-skill"},
|
|
})
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
toolCallID := fmt.Sprintf("tc-%d", i)
|
|
cb("tool_result", "skill done", map[string]interface{}{
|
|
"toolCallId": toolCallID,
|
|
"toolName": "skill",
|
|
"success": true,
|
|
})
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
// TestCreateProgressCallback_MirrorsWebStreamEvents 页面刷新后 task-events 订阅必须
|
|
// 继续收到原 Web SSE 任务的后续事件,不能只等数据库最终结果。
|
|
func TestCreateProgressCallback_MirrorsWebStreamEvents(t *testing.T) {
|
|
bus := NewTaskEventBus()
|
|
h := &AgentHandler{logger: zap.NewNop(), config: &config.Config{}, taskEventBus: bus}
|
|
_, events := bus.Subscribe("conv-refresh-stream")
|
|
primaryCalls := 0
|
|
cb := h.createProgressCallback(
|
|
context.Background(), nil, "conv-refresh-stream", "",
|
|
func(eventType, message string, data interface{}) { primaryCalls++ },
|
|
)
|
|
|
|
cb("progress", "第 3 轮", map[string]interface{}{"iteration": 3})
|
|
if primaryCalls != 1 {
|
|
t.Fatalf("expected primary SSE callback once, got %d", primaryCalls)
|
|
}
|
|
select {
|
|
case payload := <-events:
|
|
body := string(payload)
|
|
if !strings.Contains(body, `"type":"progress"`) || !strings.Contains(body, `"conversationId":"conv-refresh-stream"`) {
|
|
t.Fatalf("unexpected mirrored event: %s", body)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("expected progress event mirrored to task event bus")
|
|
}
|
|
}
|
|
|
|
func TestCreateProgressCallback_PersistsRunningResponseBeforeDone(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
db, err := database.NewDB(filepath.Join(tmp, "test.sqlite"), zap.NewNop())
|
|
if err != nil {
|
|
t.Fatalf("NewDB: %v", err)
|
|
}
|
|
conv, err := db.CreateConversation("refresh-running", database.ConversationCreateMeta{})
|
|
if err != nil {
|
|
t.Fatalf("CreateConversation: %v", err)
|
|
}
|
|
asst, err := db.AddMessage(conv.ID, "assistant", "处理中...", nil)
|
|
if err != nil {
|
|
t.Fatalf("AddMessage: %v", err)
|
|
}
|
|
|
|
h := &AgentHandler{logger: zap.NewNop(), db: db}
|
|
cb := h.createProgressCallback(context.Background(), nil, conv.ID, asst.ID, nil)
|
|
meta := map[string]interface{}{
|
|
"streamId": "response-refresh-1",
|
|
"einoAgent": "cyberstrike-eino-single",
|
|
"orchestration": "eino_single",
|
|
}
|
|
cb("response_start", "", meta)
|
|
cb("response_delta", "刷新前已生成的第一部分", openai.WithSSEAccumulated(meta, "刷新前已生成的第一部分"))
|
|
|
|
details, err := db.GetProcessDetails(asst.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetProcessDetails: %v", err)
|
|
}
|
|
if len(details) != 1 || details[0].EventType != "planning" || details[0].Message != "刷新前已生成的第一部分" {
|
|
t.Fatalf("expected one running planning snapshot, got %+v", details)
|
|
}
|
|
|
|
longer := "刷新前已生成的第一部分" + strings.Repeat("继续迭代", 300)
|
|
cb("response_delta", "继续迭代", openai.WithSSEAccumulated(meta, longer))
|
|
details, err = db.GetProcessDetails(asst.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetProcessDetails after update: %v", err)
|
|
}
|
|
if len(details) != 1 || details[0].Message != longer {
|
|
t.Fatalf("running snapshot should update in-place, rows=%d len=%d", len(details), len(details[0].Message))
|
|
}
|
|
}
|
|
|
|
// TestCreateProgressCallback_FlushesReasoningOnDone 流式推理聚合须在 done/response 时落库,刷新后可回放。
|
|
func TestCreateProgressCallback_FlushesReasoningOnDone(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
db, err := database.NewDB(filepath.Join(tmp, "test.sqlite"), zap.NewNop())
|
|
if err != nil {
|
|
t.Fatalf("NewDB: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmp)
|
|
|
|
conv, err := db.CreateConversation("test", database.ConversationCreateMeta{})
|
|
if err != nil {
|
|
t.Fatalf("CreateConversation: %v", err)
|
|
}
|
|
asst, err := db.AddMessage(conv.ID, "assistant", "处理中...", nil)
|
|
if err != nil {
|
|
t.Fatalf("AddMessage: %v", err)
|
|
}
|
|
|
|
h := &AgentHandler{logger: zap.NewNop(), db: db}
|
|
cb := h.createProgressCallback(context.Background(), nil, conv.ID, asst.ID, nil)
|
|
|
|
streamID := "eino-reasoning-test-1"
|
|
cb("reasoning_chain_stream_start", " ", map[string]interface{}{
|
|
"streamId": streamID,
|
|
"source": "eino",
|
|
})
|
|
cb("reasoning_chain_stream_delta", "step one", openai.WithSSEAccumulated(map[string]interface{}{
|
|
"streamId": streamID,
|
|
}, "step one"))
|
|
cb("done", "", map[string]interface{}{"conversationId": conv.ID})
|
|
|
|
details, err := db.GetProcessDetails(asst.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetProcessDetails: %v", err)
|
|
}
|
|
found := false
|
|
for _, d := range details {
|
|
if d.EventType == "reasoning_chain" && d.Message == "step one" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("expected reasoning_chain persisted on done, got %+v", details)
|
|
}
|
|
}
|
|
|
|
func TestEnrichProgressEventData(t *testing.T) {
|
|
t.Run("fills ids", func(t *testing.T) {
|
|
out := enrichProgressEventData(map[string]interface{}{"source": "eino"}, "conv-1", "msg-1")
|
|
m, ok := out.(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("expected map, got %T", out)
|
|
}
|
|
if m["conversationId"] != "conv-1" || m["messageId"] != "msg-1" {
|
|
t.Fatalf("unexpected enrichment: %+v", m)
|
|
}
|
|
})
|
|
t.Run("preserves existing ids", func(t *testing.T) {
|
|
out := enrichProgressEventData(map[string]interface{}{
|
|
"conversationId": "keep-conv",
|
|
"messageId": "keep-msg",
|
|
}, "conv-1", "msg-1")
|
|
m := out.(map[string]interface{})
|
|
if m["conversationId"] != "keep-conv" || m["messageId"] != "keep-msg" {
|
|
t.Fatalf("should not overwrite existing ids: %+v", m)
|
|
}
|
|
})
|
|
}
|