feat: 支持会话级漏洞复测与运行状态展示
This commit is contained in:
1 parent
8dae851b9b
commit
82c1ed9cbb
30 files changed
+2080
-80
No files matched your search
@@ -0,0 +1,63 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConversationListRunningState(t *testing.T) {
|
||||
s, _ := newRetestServer(t)
|
||||
var ids []int64
|
||||
for _, key := range []string{"auto", "reporter", "retester"} {
|
||||
c, err := s.m.pg.CreateConversation(key, "runtime status test", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids = append(ids, c.ID)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
s.chatMu.Lock()
|
||||
clear(s.chatBusy)
|
||||
s.chatMu.Unlock()
|
||||
for _, id := range ids {
|
||||
_, _ = s.m.pg.Exec(`DELETE FROM conversations WHERE id=$1`, id)
|
||||
}
|
||||
})
|
||||
check := func(want map[int64]bool) {
|
||||
t.Helper()
|
||||
w := retestRequest(s.pgListConversations, http.MethodGet, 0, "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body)
|
||||
}
|
||||
var body struct {
|
||||
Conversations []conversationListItem `json:"conversations"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := 0
|
||||
for _, item := range body.Conversations {
|
||||
if expected, ok := want[item.ID]; ok {
|
||||
found++
|
||||
if item.Running != expected || item.Title != "runtime status test" {
|
||||
t.Fatalf("conversation %d: running=%v want=%v title=%q", item.ID, item.Running, expected, item.Title)
|
||||
}
|
||||
}
|
||||
}
|
||||
if found != len(want) {
|
||||
t.Fatalf("found %d of %d conversations", found, len(want))
|
||||
}
|
||||
}
|
||||
check(map[int64]bool{ids[0]: false, ids[1]: false, ids[2]: false})
|
||||
s.chatMu.Lock()
|
||||
s.chatBusy[s.convBusyKey(ids[1])] = true
|
||||
s.chatBusy[s.convBusyKey(ids[2])] = true
|
||||
s.chatBusy["unrelated-task"] = true
|
||||
s.chatMu.Unlock()
|
||||
check(map[int64]bool{ids[0]: false, ids[1]: true, ids[2]: true})
|
||||
s.chatMu.Lock()
|
||||
clear(s.chatBusy)
|
||||
s.chatMu.Unlock()
|
||||
check(map[int64]bool{ids[0]: false, ids[1]: false, ids[2]: false})
|
||||
}
|
||||
+68
-6
@@ -45,6 +45,11 @@ func decodeConversationRequest(w http.ResponseWriter, r *http.Request, value any
|
||||
// conversation_activities and the browser POLLS ?since=cursor for live updates
|
||||
// (no per-conversation SSE broadcaster needed).
|
||||
|
||||
type conversationListItem struct {
|
||||
*db.Conversation
|
||||
Running bool `json:"running"`
|
||||
}
|
||||
|
||||
func (s *Server) pgListConversations(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
@@ -55,7 +60,13 @@ func (s *Server) pgListConversations(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"conversations": cs})
|
||||
items := make([]conversationListItem, 0, len(cs))
|
||||
s.chatMu.Lock()
|
||||
for _, c := range cs {
|
||||
items = append(items, conversationListItem{Conversation: c, Running: s.chatBusy[s.convBusyKey(c.ID)]})
|
||||
}
|
||||
s.chatMu.Unlock()
|
||||
writeJSON(w, 200, map[string]any{"conversations": items})
|
||||
}
|
||||
|
||||
func (s *Server) pgCreateConversation(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -445,7 +456,8 @@ func (s *Server) pgSendConversationMessage(w http.ResponseWriter, r *http.Reques
|
||||
// conversation_activities. Shared by the chat HTTP handler and the P3 scheduler.
|
||||
// busyKey clears when the run ends (best-effort in-flight marker).
|
||||
func (s *Server) runConversation(c *db.Conversation, msg, busyKey string) {
|
||||
go s.runConversationSync(c, msg, busyKey)
|
||||
ctx, cancel := s.conversationRunContext(c.ID, busyKey)
|
||||
go s.runConversationTurn(ctx, cancel, c, msg, busyKey)
|
||||
}
|
||||
|
||||
// runConversationSync runs ONE agent turn on a conversation and BLOCKS until the
|
||||
@@ -453,12 +465,23 @@ func (s *Server) runConversation(c *db.Conversation, msg, busyKey string) {
|
||||
// fire-and-forget chat path; the P3 trigger queue calls it directly so it can wait
|
||||
// for completion before starting the next queued fire for the same agent.
|
||||
func (s *Server) runConversationSync(c *db.Conversation, msg, busyKey string) {
|
||||
ctx, cancel := s.conversationRunContext(c.ID, busyKey)
|
||||
s.runConversationTurn(ctx, cancel, c, msg, busyKey)
|
||||
}
|
||||
|
||||
// Register cancellation before returning 202, so an immediate stop/delete cannot
|
||||
// miss a background goroutine which has not started yet.
|
||||
func (s *Server) conversationRunContext(id int64, busyKey string) (context.Context, context.CancelCauseFunc) {
|
||||
// Per-run cancellable context so a manual stop (pgStopConversation) can abort
|
||||
// just this session. Registered under chatMu so the stop handler can find it.
|
||||
ctx, cancel := context.WithCancelCause(intercept.WithConvID(s.ctx, c.ID))
|
||||
ctx, cancel := context.WithCancelCause(intercept.WithConvID(s.ctx, id))
|
||||
s.chatMu.Lock()
|
||||
s.chatCancel[busyKey] = cancel
|
||||
s.chatMu.Unlock()
|
||||
return ctx, cancel
|
||||
}
|
||||
|
||||
func (s *Server) runConversationTurn(ctx context.Context, cancel context.CancelCauseFunc, c *db.Conversation, msg, busyKey string) {
|
||||
defer func() {
|
||||
cancel(agent.AbortChatTurnFinished)
|
||||
s.chatMu.Lock()
|
||||
@@ -466,9 +489,43 @@ func (s *Server) runConversationSync(c *db.Conversation, msg, busyKey string) {
|
||||
delete(s.chatCancel, busyKey)
|
||||
s.chatMu.Unlock()
|
||||
}()
|
||||
// Only the first turn executes a historical retest. Follow-up conversation
|
||||
// turns may explain the sealed result; the result tool refuses to overwrite it.
|
||||
finishStatus, finishReason := "failed", "复测未能启动"
|
||||
if c.AgentKey == db.FindingRetestAgentKey {
|
||||
// Read without the run cancellation so an immediate stop still seals pending.
|
||||
r, err := s.m.pg.FindingRetestForConversation(context.Background(), c.ID)
|
||||
if err != nil {
|
||||
log.Printf("[conv %d] load retest: %v", c.ID, err)
|
||||
return
|
||||
}
|
||||
if r != nil && r.Status == "pending" {
|
||||
defer func() {
|
||||
if ctx.Err() != nil {
|
||||
finishStatus, finishReason = "stopped", "复测已停止或服务已关闭"
|
||||
}
|
||||
s.finishRetest(r.ID, finishStatus, finishReason)
|
||||
}()
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
started, err := s.m.pg.StartFindingRetest(ctx, r.ID)
|
||||
if err != nil {
|
||||
finishReason = err.Error()
|
||||
return
|
||||
}
|
||||
if !started {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
// precedence: the conversation agent's own binding → this conversation's pin → global.
|
||||
ca := s.resolveChatAgent(c)
|
||||
if ca == nil {
|
||||
finishReason = s.chatUnavailableReason()
|
||||
return
|
||||
}
|
||||
pg := s.m.pg
|
||||
@@ -486,9 +543,14 @@ func (s *Server) runConversationSync(c *db.Conversation, msg, busyKey string) {
|
||||
}
|
||||
// On a manual stop ctx is cancelled; Chat already emits a clean "已手动停止"
|
||||
// step, so skip the raw-error entry — only surface genuine failures.
|
||||
if _, err := ca.Chat(ctx, c.AgentKey, sessionID, msg, maxTurns, maxDuration, webSearch, emit); err != nil && ctx.Err() == nil {
|
||||
_, _ = pg.AppendConvActivity(c.ID, db.Activity{Worker: c.AgentKey, Kind: "text", IsError: true,
|
||||
Summary: "(出错:" + err.Error() + ")", Detail: err.Error()})
|
||||
if _, err := ca.Chat(ctx, c.AgentKey, sessionID, msg, maxTurns, maxDuration, webSearch, emit); err != nil {
|
||||
finishReason = err.Error()
|
||||
if ctx.Err() == nil {
|
||||
_, _ = pg.AppendConvActivity(c.ID, db.Activity{Worker: c.AgentKey, Kind: "text", IsError: true,
|
||||
Summary: "(出错:" + err.Error() + ")", Detail: err.Error()})
|
||||
}
|
||||
} else {
|
||||
finishStatus, finishReason = "completed", ""
|
||||
}
|
||||
_ = pg.TouchConversation(c.ID)
|
||||
}
|
||||
|
||||
+1
-1
@@ -297,7 +297,7 @@ type FindingDTO struct {
|
||||
VulnClass string `json:"vulnclass"`
|
||||
Name string `json:"name,omitempty"` // 漏洞名称;为空时前端回退展示 vulnclass
|
||||
Severity string `json:"severity"` // critical | high | medium | low
|
||||
Status string `json:"status"` // pending | in_progress | confirmed | resolved | false_positive | ignored | duplicate | risk_accepted
|
||||
Status string `json:"status"` // pending | in_progress | confirmed | resolved | fixed | false_positive | ignored | duplicate | risk_accepted
|
||||
Summary string `json:"summary"`
|
||||
Evidence string `json:"evidence"`
|
||||
Report string `json:"report,omitempty"` // 详细报告(Markdown);仅详情接口返回,列表为空
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Autumn-27/artex/agent"
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
)
|
||||
|
||||
func (s *Server) listActiveFindingRetests(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
items, err := pg.ListActiveFindingRetests(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"retests": items})
|
||||
}
|
||||
|
||||
func (s *Server) listFindingRetests(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
id, ok := pathInt(r, "id")
|
||||
if !ok || id <= 0 {
|
||||
writeErr(w, 400, "bad finding id")
|
||||
return
|
||||
}
|
||||
f, err := pg.GetFinding(id)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if f == nil {
|
||||
writeErr(w, 404, "finding not found")
|
||||
return
|
||||
}
|
||||
items, err := pg.ListFindingRetests(id)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"retests": items})
|
||||
}
|
||||
|
||||
func (s *Server) startFindingRetest(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
id, ok := pathInt(r, "id")
|
||||
if !ok || id <= 0 {
|
||||
writeErr(w, 400, "bad finding id")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
if !decodeConversationRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
req.Notes = strings.TrimSpace(req.Notes)
|
||||
if utf8.RuneCountInString(req.Notes) > 4000 {
|
||||
writeErr(w, 400, "复测补充说明最多 4000 个字符")
|
||||
return
|
||||
}
|
||||
f, err := pg.GetFinding(id)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if f == nil {
|
||||
writeErr(w, 404, "finding not found")
|
||||
return
|
||||
}
|
||||
a, err := pg.GetAgentByKey(db.FindingRetestAgentKey)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if a == nil || !a.Enabled {
|
||||
writeErr(w, 409, "漏洞复测 Agent 不存在或未启用,请在 Agent 管理中配置 retester")
|
||||
return
|
||||
}
|
||||
for _, key := range []string{"get_finding_retest_context", "record_finding_retest_result"} {
|
||||
t, err := pg.GetTool(key)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if t == nil || !t.Enabled || !slices.Contains(t.Agents, a.Key) {
|
||||
writeErr(w, 409, "请为复测 Agent 启用并绑定工具:"+key)
|
||||
return
|
||||
}
|
||||
}
|
||||
if s.resolveChatAgent(&db.Conversation{AgentKey: a.Key}) == nil {
|
||||
writeErr(w, 503, s.chatUnavailableReason())
|
||||
return
|
||||
}
|
||||
if s.ctx.Err() != nil {
|
||||
writeErr(w, 503, "服务正在停止")
|
||||
return
|
||||
}
|
||||
retest, conv, created, err := pg.CreateFindingRetest(r.Context(), id, req.Notes)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
writeErr(w, 404, "finding not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if created {
|
||||
busyKey := s.convBusyKey(conv.ID)
|
||||
s.chatMu.Lock()
|
||||
s.chatBusy[busyKey] = true
|
||||
s.chatMu.Unlock()
|
||||
s.runConversation(conv, retest.InitialMessage(), busyKey)
|
||||
}
|
||||
code := http.StatusOK
|
||||
if created {
|
||||
code = http.StatusAccepted
|
||||
}
|
||||
writeJSON(w, code, map[string]any{"retest": retest, "created": created})
|
||||
}
|
||||
|
||||
// Both tools derive the finding from server-owned conversation context. Tool
|
||||
// arguments cannot redirect a result into a different finding or conversation.
|
||||
func (s *Server) findingRetestTools() []actool.CoreTool {
|
||||
return []actool.CoreTool{
|
||||
roTool("get_finding_retest_context", "读取当前复测会话关联的漏洞证据快照、复测状态、补充说明与当前任务约束。无参数,只能读取本会话。",
|
||||
objSchema(map[string]any{}), func(ctx context.Context, _ json.RawMessage) (actool.Result, error) {
|
||||
r, err := s.m.pg.FindingRetestForConversation(ctx, intercept.ConvIDFromContext(ctx))
|
||||
if err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
}
|
||||
if r == nil {
|
||||
return actool.Errorf("当前会话未关联复测记录,请从漏洞详情发起复测"), nil
|
||||
}
|
||||
var constraints []db.Constraint
|
||||
f, err := s.m.pg.GetFinding(r.FindingID)
|
||||
if err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
}
|
||||
if f != nil && f.TaskID != nil {
|
||||
if task, ok := s.m.Task(strconv.FormatInt(*f.TaskID, 10)); ok {
|
||||
constraints, err = task.Store.ListConstraints()
|
||||
if err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return jsonResult(map[string]any{"retest": r, "current_constraints": constraints})
|
||||
}),
|
||||
wrTool("record_finding_retest_result", "为当前复测会话保存唯一结论;原漏洞证据与报告保持不变。会话成功结束且结论为 fixed 时,系统自动将漏洞状态改为已修复;其他结论保留原状态。必须提供本次实际检查的证据,无法确认时写明阻塞原因。",
|
||||
objSchema(map[string]any{
|
||||
"verdict": map[string]any{"type": "string", "enum": []string{"reproduced", "fixed", "inconclusive"}},
|
||||
"summary": strParam("本次复测结论摘要"),
|
||||
"evidence": strParam("Markdown:本次实际步骤、观察、对照、结论依据;无法确认则列出已检查内容和阻塞原因"),
|
||||
}, "verdict", "summary", "evidence"), func(ctx context.Context, in json.RawMessage) (actool.Result, error) {
|
||||
var a struct {
|
||||
Verdict string `json:"verdict"`
|
||||
Summary string `json:"summary"`
|
||||
Evidence string `json:"evidence"`
|
||||
}
|
||||
if err := json.Unmarshal(in, &a); err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
}
|
||||
if err := s.m.pg.RecordFindingRetestResult(ctx, intercept.ConvIDFromContext(ctx), a.Verdict, a.Summary, a.Evidence); err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
}
|
||||
return jsonResult(map[string]any{"saved": true, "verdict": a.Verdict})
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// Seed the editable agent atomically, without an automatic discovery trigger.
|
||||
// Once seeded, user edits/deletion survive restarts; a pre-existing key is kept.
|
||||
func (s *Server) seedFindingRetester() error {
|
||||
for _, t := range s.findingRetestTools() {
|
||||
schema, _ := json.Marshal(t.InputSchema())
|
||||
bindings, _ := json.Marshal([]string{db.FindingRetestAgentKey})
|
||||
if err := s.m.pg.SeedTool(t.Name(), t.Description(), schema, bindings); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
const flag = "finding_retester_seed_v1"
|
||||
tx, err := s.m.pg.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
// Lock this migration, including concurrent server initialization.
|
||||
if _, err = tx.Exec(`SELECT pg_advisory_xact_lock(7337741010)`); err != nil {
|
||||
return err
|
||||
}
|
||||
var done string
|
||||
err = tx.QueryRow(`SELECT value FROM settings WHERE key=$1`, flag).Scan(&done)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
if done == "true" {
|
||||
return nil
|
||||
}
|
||||
var id int64
|
||||
err = tx.QueryRow(`INSERT INTO agents(key,name,description,role,builtin,enabled)
|
||||
VALUES ($1,'漏洞复测','从漏洞详情手动启动,读取原证据并保存独立复测结论。','assistant',false,true)
|
||||
ON CONFLICT (key) DO NOTHING RETURNING id`, db.FindingRetestAgentKey).Scan(&id)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
if id > 0 {
|
||||
var pid int64
|
||||
if err = tx.QueryRow(`INSERT INTO agent_prompts(agent_id,version,template_text,note,updated_by)
|
||||
VALUES ($1,1,$2,'内置默认','system') RETURNING id`, id, agent.RetesterDefaultPrompt).Scan(&pid); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec(`UPDATE agents SET current_prompt_id=$1 WHERE id=$2`, pid, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err = tx.Exec(`INSERT INTO settings(key,value) VALUES ($1,'true') ON CONFLICT(key) DO UPDATE SET value='true'`, flag); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Server) finishRetest(id int64, status, reason string) {
|
||||
if err := s.m.pg.FinishFindingRetest(id, status, reason); err != nil {
|
||||
// Surface failure to the conversation runner rather than inventing a result.
|
||||
log.Printf("[retest %d] finish: %v", id, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"iter"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/agent"
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
"github.com/Autumn-27/norma/llm"
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
)
|
||||
|
||||
type retestProvider struct {
|
||||
complete func(context.Context, llm.CompletionRequest) (llm.Message, string, llm.Usage, error)
|
||||
}
|
||||
|
||||
func (p retestProvider) Complete(ctx context.Context, req llm.CompletionRequest) (llm.Message, string, llm.Usage, error) {
|
||||
return p.complete(ctx, req)
|
||||
}
|
||||
func (p retestProvider) Stream(context.Context, llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] {
|
||||
return func(yield func(llm.StreamEvent, error) bool) {
|
||||
yield(llm.StreamEvent{}, errors.New("test expects non-streaming"))
|
||||
}
|
||||
}
|
||||
|
||||
func newRetestServer(t *testing.T) (*Server, int64) {
|
||||
t.Helper()
|
||||
dsn, _, err := db.DSN()
|
||||
if err != nil {
|
||||
t.Skip("test postgres not configured")
|
||||
}
|
||||
pg, err := db.Open(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
td := t.TempDir()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := &Server{ctx: ctx, m: &Manager{pg: pg, dir: td, tasks: map[string]*Task{}},
|
||||
chatBusy: map[string]bool{}, chatCancel: map[string]context.CancelCauseFunc{}}
|
||||
if err := s.seedFindingRetester(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fid, err := pg.AddFinding(0, 0, "retest-server", "复测测试", "high", "summary", "original proof", "worker", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldAugment, oldResolve, oldPrompt := agent.ToolAugment, agent.ToolResolve, agent.PromptOverride
|
||||
agent.ToolAugment = func(context.Context, string) ([]actool.CoreTool, agent.DeferredInfo, func()) {
|
||||
return s.findingRetestTools(), agent.DeferredInfo{}, func() {}
|
||||
}
|
||||
agent.ToolResolve = nil
|
||||
agent.PromptOverride = func(string) (string, bool) { return agent.RetesterDefaultPrompt, true }
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
waitRetestIdle(t, s)
|
||||
agent.ToolAugment, agent.ToolResolve, agent.PromptOverride = oldAugment, oldResolve, oldPrompt
|
||||
_, _ = pg.Exec(`DELETE FROM conversations WHERE id IN (SELECT conversation_id FROM finding_retests WHERE finding_id=$1)`, fid)
|
||||
_, _ = pg.DeleteFinding(fid)
|
||||
pg.Close()
|
||||
})
|
||||
return s, fid
|
||||
}
|
||||
|
||||
func setRetestProvider(s *Server, p retestProvider) {
|
||||
s.chatAgent = agent.NewChatAgent(p, "test", s.m.dir, nil, 10000)
|
||||
s.chatAgent.SetNonStreaming(func() bool { return true })
|
||||
}
|
||||
|
||||
func retestRequest(handler http.HandlerFunc, method string, id int64, body string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest(method, "/", strings.NewReader(body))
|
||||
r.SetPathValue("id", strconv.FormatInt(id, 10))
|
||||
w := httptest.NewRecorder()
|
||||
handler(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
func waitRetestIdle(t *testing.T, s *Server) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
s.chatMu.Lock()
|
||||
n := len(s.chatBusy)
|
||||
s.chatMu.Unlock()
|
||||
if n == 0 {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Error("conversation did not become idle")
|
||||
}
|
||||
|
||||
func TestRetestActiveStatusLifecycle(t *testing.T) {
|
||||
s, fid := newRetestServer(t)
|
||||
check := func(want string, conversationID int64) {
|
||||
t.Helper()
|
||||
w := retestRequest(s.listActiveFindingRetests, http.MethodGet, 0, "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Retests []db.ActiveFindingRetest `json:"retests"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var matches []db.ActiveFindingRetest
|
||||
for _, item := range body.Retests {
|
||||
if item.FindingID == fid {
|
||||
matches = append(matches, item)
|
||||
}
|
||||
}
|
||||
if want == "" {
|
||||
if len(matches) != 0 {
|
||||
t.Fatalf("terminal retest still active: %+v", matches)
|
||||
}
|
||||
} else if len(matches) != 1 || matches[0].Status != want || matches[0].ConversationID != conversationID {
|
||||
t.Fatalf("active=%+v want status=%s conversation=%d", matches, want, conversationID)
|
||||
}
|
||||
for _, field := range []string{`"snapshot"`, `"evidence"`, `"notes"`, `"summary"`} {
|
||||
if strings.Contains(w.Body.String(), field) {
|
||||
t.Fatalf("status response leaks %s", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
check("", 0)
|
||||
for _, terminal := range []string{"completed", "failed", "stopped"} {
|
||||
r, c, _, err := s.m.pg.CreateFindingRetest(t.Context(), fid, "private notes")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check("pending", c.ID)
|
||||
if _, err := s.m.pg.StartFindingRetest(t.Context(), r.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check("running", c.ID)
|
||||
if terminal == "completed" {
|
||||
if err := s.m.pg.RecordFindingRetestResult(t.Context(), c.ID, "fixed", "summary", "proof"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check("running", c.ID) // A staged verdict is still running until the turn ends.
|
||||
}
|
||||
if err := s.m.pg.FinishFindingRetest(r.ID, terminal, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check("", 0)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetestHTTPThroughConversationAndTools(t *testing.T) {
|
||||
s, fid := newRetestServer(t)
|
||||
entered, release := make(chan struct{}), make(chan struct{})
|
||||
var calls atomic.Int32
|
||||
setRetestProvider(s, retestProvider{complete: func(ctx context.Context, req llm.CompletionRequest) (llm.Message, string, llm.Usage, error) {
|
||||
n := calls.Add(1)
|
||||
if n == 1 {
|
||||
close(entered)
|
||||
select {
|
||||
case <-release:
|
||||
case <-ctx.Done():
|
||||
return llm.Message{}, "", llm.Usage{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
name, input := "get_finding_retest_context", `{}`
|
||||
if n == 2 {
|
||||
serialized, _ := json.Marshal(req.Messages)
|
||||
if !bytes.Contains(serialized, []byte("original proof")) {
|
||||
t.Error("agent did not receive source evidence")
|
||||
}
|
||||
name, input = "record_finding_retest_result", `{"verdict":"inconclusive","summary":"缺少测试登录态","evidence":"已检查原证据;当前缺少有效登录态,无法确认修复状态。"}`
|
||||
}
|
||||
if n > 2 {
|
||||
return llm.Message{Role: llm.RoleAssistant, Content: []llm.ContentBlock{llm.TextBlock("复测结论已保存")}}, "end_turn", llm.Usage{}, nil
|
||||
}
|
||||
return llm.Message{Role: llm.RoleAssistant, Content: []llm.ContentBlock{{Type: llm.BlockToolUse, ID: strconv.Itoa(int(n)), Name: name, Input: json.RawMessage(input)}}}, "tool_use", llm.Usage{}, nil
|
||||
}})
|
||||
w := retestRequest(s.startFindingRetest, "POST", fid, `{"notes":"仅验证原接口"}`)
|
||||
if w.Code != 202 {
|
||||
t.Fatalf("create: %d %s", w.Code, w.Body)
|
||||
}
|
||||
select {
|
||||
case <-entered:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("provider not called")
|
||||
}
|
||||
dup := retestRequest(s.startFindingRetest, "POST", fid, `{}`)
|
||||
if dup.Code != 200 || !strings.Contains(dup.Body.String(), `"created":false`) {
|
||||
t.Fatalf("duplicate %d %s", dup.Code, dup.Body)
|
||||
}
|
||||
close(release)
|
||||
waitRetestIdle(t, s)
|
||||
rows, err := s.m.pg.ListFindingRetests(fid)
|
||||
if err != nil || len(rows) != 1 || rows[0].Status != "completed" || rows[0].Verdict != "inconclusive" {
|
||||
t.Fatalf("history=%+v err=%v", rows, err)
|
||||
}
|
||||
if calls.Load() != 3 {
|
||||
t.Fatal("unexpected LLM calls", calls.Load())
|
||||
}
|
||||
f, _ := s.m.pg.GetFinding(fid)
|
||||
if f.Status != db.FindingPending || f.Evidence != "original proof" {
|
||||
t.Fatal("source modified")
|
||||
}
|
||||
listing := retestRequest(s.listFindingRetests, "GET", fid, "")
|
||||
if listing.Code != 200 || strings.Contains(listing.Body.String(), `"snapshot"`) {
|
||||
t.Fatalf("history %d %s", listing.Code, listing.Body)
|
||||
}
|
||||
res, err := s.findingRetestTools()[1].Call(intercept.WithConvID(t.Context(), *rows[0].ConversationID), json.RawMessage(`{"verdict":"fixed","summary":"bad overwrite","evidence":"bad"}`), nil)
|
||||
if err != nil || !res.IsError {
|
||||
t.Fatal("sealed result accepted", res, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetestStopsFailuresAndNoVerdict(t *testing.T) {
|
||||
for _, mode := range []string{"stop", "failure", "no_verdict"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
s, fid := newRetestServer(t)
|
||||
setRetestProvider(s, retestProvider{complete: func(ctx context.Context, _ llm.CompletionRequest) (llm.Message, string, llm.Usage, error) {
|
||||
if mode == "stop" {
|
||||
<-ctx.Done()
|
||||
return llm.Message{}, "", llm.Usage{}, ctx.Err()
|
||||
}
|
||||
if mode == "failure" {
|
||||
return llm.Message{}, "", llm.Usage{}, errors.New("test model failed")
|
||||
}
|
||||
return llm.Message{Role: llm.RoleAssistant, Content: []llm.ContentBlock{llm.TextBlock("未保存结论")}}, "end_turn", llm.Usage{}, nil
|
||||
}})
|
||||
w := retestRequest(s.startFindingRetest, "POST", fid, `{}`)
|
||||
if w.Code != 202 {
|
||||
t.Fatalf("create %d %s", w.Code, w.Body)
|
||||
}
|
||||
if mode == "stop" {
|
||||
var body struct {
|
||||
Retest db.FindingRetest `json:"retest"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &body)
|
||||
stop := retestRequest(s.pgStopConversation, "POST", *body.Retest.ConversationID, `{}`)
|
||||
if stop.Code != 200 || !strings.Contains(stop.Body.String(), "stopping") {
|
||||
t.Fatalf("immediate stop: %d %s", stop.Code, stop.Body)
|
||||
}
|
||||
}
|
||||
waitRetestIdle(t, s)
|
||||
rows, _ := s.m.pg.ListFindingRetests(fid)
|
||||
want := "failed"
|
||||
if mode == "stop" {
|
||||
want = "stopped"
|
||||
}
|
||||
if len(rows) != 1 || rows[0].Status != want || rows[0].Error == "" {
|
||||
t.Fatalf("expected %s: %+v", want, rows)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetestFixedUpdatesFindingThroughConversation(t *testing.T) {
|
||||
s, fid := newRetestServer(t)
|
||||
var calls atomic.Int32
|
||||
setRetestProvider(s, retestProvider{complete: func(context.Context, llm.CompletionRequest) (llm.Message, string, llm.Usage, error) {
|
||||
if calls.Add(1) == 1 {
|
||||
return llm.Message{Role: llm.RoleAssistant, Content: []llm.ContentBlock{{Type: llm.BlockToolUse, ID: "fixed-result", Name: "record_finding_retest_result", Input: json.RawMessage(`{"verdict":"fixed","summary":"修复生效","evidence":"原触发条件失效,正常对照仍可用。"}`)}}}, "tool_use", llm.Usage{}, nil
|
||||
}
|
||||
return llm.Message{Role: llm.RoleAssistant, Content: []llm.ContentBlock{llm.TextBlock("结论已保存")}}, "end_turn", llm.Usage{}, nil
|
||||
}})
|
||||
w := retestRequest(s.startFindingRetest, "POST", fid, `{}`)
|
||||
if w.Code != http.StatusAccepted {
|
||||
t.Fatalf("create: %d %s", w.Code, w.Body)
|
||||
}
|
||||
waitRetestIdle(t, s)
|
||||
f, err := s.m.pg.GetFinding(fid)
|
||||
if err != nil || f.Status != db.FindingFixed || f.Evidence != "original proof" {
|
||||
t.Fatalf("fixed finding=%+v err=%v", f, err)
|
||||
}
|
||||
// The new manual status is also accepted by the existing update API.
|
||||
w = retestRequest(s.patchFinding, "PATCH", fid, `{"status":"fixed"}`)
|
||||
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"status":"fixed"`) {
|
||||
t.Fatalf("manual fixed: %d %s", w.Code, w.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetestValidationSeedingAndScope(t *testing.T) {
|
||||
s, fid := newRetestServer(t)
|
||||
for _, tc := range []struct {
|
||||
id int64
|
||||
body string
|
||||
code int
|
||||
}{{0, `{}`, 400}, {fid, `{`, 400}, {fid, `{"notes":"` + strings.Repeat("文", 4001) + `"}`, 400}, {999999999, `{}`, 404}, {fid, `{}`, 503}} {
|
||||
w := retestRequest(s.startFindingRetest, "POST", tc.id, tc.body)
|
||||
if w.Code != tc.code {
|
||||
t.Fatalf("code=%d want=%d %s", w.Code, tc.code, w.Body)
|
||||
}
|
||||
}
|
||||
rows, _ := s.m.pg.ListFindingRetests(fid)
|
||||
if len(rows) != 0 {
|
||||
t.Fatal("invalid request created state")
|
||||
}
|
||||
for _, tool := range s.findingRetestTools() {
|
||||
res, err := tool.Call(t.Context(), json.RawMessage(`{"verdict":"fixed","summary":"x","evidence":"y"}`), nil)
|
||||
if err != nil || !res.IsError {
|
||||
t.Fatal("unassociated conversation accepted", tool.Name(), res, err)
|
||||
}
|
||||
}
|
||||
a, _ := s.m.pg.GetAgentByKey(db.FindingRetestAgentKey)
|
||||
if a == nil || a.Builtin {
|
||||
t.Fatal("missing editable retester")
|
||||
}
|
||||
var triggers int
|
||||
if err := s.m.pg.QueryRow(`SELECT count(*) FROM agent_triggers WHERE agent_key=$1`, db.FindingRetestAgentKey).Scan(&triggers); err != nil || triggers != 0 {
|
||||
t.Fatal("unexpected automatic trigger", triggers, err)
|
||||
}
|
||||
if _, err := s.m.pg.SavePrompt(a.ID, "customized retest prompt", "test", "test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.seedFindingRetester(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var prompt string
|
||||
if err := s.m.pg.QueryRow(`SELECT p.template_text FROM agent_prompts p JOIN agents a ON a.current_prompt_id=p.id WHERE a.key=$1`, a.Key).Scan(&prompt); err != nil || prompt != "customized retest prompt" {
|
||||
t.Fatal("seed overwrote prompt", prompt, err)
|
||||
}
|
||||
_, _ = s.m.pg.SavePrompt(a.ID, agent.RetesterDefaultPrompt, "restore", "test")
|
||||
}
|
||||
@@ -355,6 +355,10 @@ func NewManager(dir, proxyAddr string) (*Manager, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := pg.RecoverFindingRetests(); err != nil {
|
||||
pg.Close()
|
||||
return nil, fmt.Errorf("recover finding retests: %w", err)
|
||||
}
|
||||
if err := pg.EnsureLLMRecordsTable(); err != nil {
|
||||
log.Printf("[llmrec] create table: %v", err)
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ func jsonResult(v any) (actool.Result, error) {
|
||||
//nolint:unused // used as the hostTools provider in wireAgentAugment
|
||||
func (s *Server) hostTools() ([]actool.CoreTool, map[string][]string) {
|
||||
tools := append(s.m.HostTools(), s.orchestrationTools()...)
|
||||
tools = append(tools, s.findingRetestTools()...)
|
||||
tools = append(tools, s.platformTools()...) // 平台操作工具(建改 skill/工具/MCP,给 Auto 用)
|
||||
custom, err := s.customTools()
|
||||
if err != nil {
|
||||
|
||||
+9
-3
@@ -217,9 +217,12 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
|
||||
}
|
||||
wireAgentAugment(m.pg, s.skillDir, s.hostTools) // 可见 skills/MCP + 流量/编排 host 工具装配进 agent 工具集
|
||||
domainReg := buildDomainReg(m.Assets())
|
||||
wireTools(m.pg, domainReg) // 内置工具表:按 agent 过滤 + 覆盖描述/schema + 注入默认值
|
||||
seedPrompts(m.pg) // 内置 agent 默认提示词正文播种进 agent_prompts(仅空时)
|
||||
s.seedOrchestrationTools() // P2 跨任务编排工具 seed 进 tools 表(可按 agent 绑定)
|
||||
wireTools(m.pg, domainReg) // 内置工具表:按 agent 过滤 + 覆盖描述/schema + 注入默认值
|
||||
seedPrompts(m.pg) // 内置 agent 默认提示词正文播种进 agent_prompts(仅空时)
|
||||
s.seedOrchestrationTools() // P2 跨任务编排工具 seed 进 tools 表(可按 agent 绑定)
|
||||
if err := s.seedFindingRetester(); err != nil {
|
||||
log.Printf("[retester] seed: %v", err)
|
||||
}
|
||||
s.seedPythonInterpreter() // 自定义脚本工具:开机检测 python 解释器入库(仅空时)
|
||||
go newScheduler(s).Run(s.ctx) // P3 触发器调度(定时/finding/目标事件),仅自定义 agent
|
||||
// Fill the tool cache for any enabled MCP that has none yet (notably the
|
||||
@@ -814,6 +817,9 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /api/exploration/findings/{id}", s.getFinding)
|
||||
mux.HandleFunc("GET /api/exploration/findings/{id}/lineage", s.findingLineage)
|
||||
mux.HandleFunc("POST /api/exploration/findings/{id}/deepen", s.deepenFinding)
|
||||
mux.HandleFunc("GET /api/exploration/findings/{id}/retests", s.listFindingRetests)
|
||||
mux.HandleFunc("GET /api/exploration/findings/retests/active", s.listActiveFindingRetests)
|
||||
mux.HandleFunc("POST /api/exploration/findings/{id}/retests", s.startFindingRetest)
|
||||
mux.HandleFunc("PATCH /api/exploration/findings/{id}", s.patchFinding)
|
||||
mux.HandleFunc("DELETE /api/exploration/findings/{id}", s.deleteFinding)
|
||||
mux.HandleFunc("GET /api/exploration/intents", s.intents)
|
||||
|
||||
Reference in new issue
Block a user