Why: Accepted retrieval and tool sources could become stale, and a pending HITL answer could release a changed source set. Evidence: App and run-manager suites cover per-preflight resource verification, authorization epochs, and a second exact-source preflight after HITL. Impact: Project work fails closed when authorization, source bytes, run binding, or the source watermark changes. Refs: TT-N03 TT-R01
236 lines
10 KiB
Go
236 lines
10 KiB
Go
package runmgr
|
|
|
|
import (
|
|
"context"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"agent-system-backend/internal/profiles"
|
|
backendruntime "agent-system-backend/internal/runtime"
|
|
"agent-system-backend/pkg/extension"
|
|
core "agent-system-core"
|
|
)
|
|
|
|
type projectGateRunner struct{ calls atomic.Int32 }
|
|
|
|
func (*projectGateRunner) Name() string { return "project-gate-test" }
|
|
|
|
func (r *projectGateRunner) Execute(backendruntime.RunCtx) error {
|
|
r.calls.Add(1)
|
|
return nil
|
|
}
|
|
|
|
type projectReplyRunner struct{}
|
|
|
|
func (*projectReplyRunner) Name() string { return "project-reply-test" }
|
|
func (*projectReplyRunner) Execute(rc backendruntime.RunCtx) error {
|
|
return rc.SetAssistantParts([]core.Part{{Type: core.PartText, Text: "最终项目答复"}})
|
|
}
|
|
|
|
func enableProjectGateForTest(m *Manager) {
|
|
m.SetProjectWorkGate(
|
|
func(context.Context, string, string) (extension.ProjectWorkDecision, error) {
|
|
return extension.ProjectWorkDecision{State: extension.ProjectWorkProceed, DecisionID: "test-decision"}, nil
|
|
},
|
|
func(context.Context, extension.ProjectRiskAnswer) (extension.ProjectWorkDecision, error) {
|
|
return extension.ProjectWorkDecision{State: extension.ProjectWorkProceed, DecisionID: "test-decision"}, nil
|
|
},
|
|
)
|
|
}
|
|
|
|
func TestProjectWorkGateClarifiesBeforeProceeding(t *testing.T) {
|
|
preflights, responses, questions := 0, 0, 0
|
|
preflight := func(context.Context, string, string) (extension.ProjectWorkDecision, error) {
|
|
preflights++
|
|
if preflights == 1 {
|
|
return extension.ProjectWorkDecision{
|
|
State: extension.ProjectWorkClarify, RiskID: "risk-1", RiskRevision: "r1",
|
|
Question: "效率低于人工是指更慢吗?", Options: []string{"效率更高且成本更低", "完成时间和成本更低", "确实允许效率更低"},
|
|
}, nil
|
|
}
|
|
return extension.ProjectWorkDecision{State: extension.ProjectWorkProceed, DecisionID: "decision-after-recheck"}, nil
|
|
}
|
|
respond := func(_ context.Context, answer extension.ProjectRiskAnswer) (extension.ProjectWorkDecision, error) {
|
|
responses++
|
|
if answer.Actor != "alice" || answer.ProjectID != "project-1" || answer.RiskID != "risk-1" || answer.RiskRevision != "r1" || answer.Answer != "效率更高且成本更低" || answer.IdempotencyKey == "" {
|
|
t.Fatalf("untrusted risk answer: %+v", answer)
|
|
}
|
|
return extension.ProjectWorkDecision{State: extension.ProjectWorkProceed, DecisionID: "decision-1"}, nil
|
|
}
|
|
ask := func(card core.HITLCard) (core.HITLResponse, error) {
|
|
questions++
|
|
if card.Question == "" || len(card.Choices) != 3 || !card.Blocked {
|
|
t.Fatalf("risk decision did not become a blocking HITL card: %+v", card)
|
|
}
|
|
return core.HITLResponse{ChoiceID: "option-0"}, nil
|
|
}
|
|
decision, err := resolveProjectWorkDecision(context.Background(), "alice", "project-1", "run-1", "conversation.run", preflight, respond, ask)
|
|
if err != nil || decision.State != extension.ProjectWorkProceed || preflights != 2 || responses != 1 || questions != 1 {
|
|
t.Fatalf("gate did not wait for clarification: decision=%+v preflight=%d responses=%d questions=%d err=%v", decision, preflights, responses, questions, err)
|
|
}
|
|
}
|
|
|
|
func TestProjectWorkGateNeverTreatsPendingOrMismatchedProceedAsApproval(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
decision extension.ProjectWorkDecision
|
|
}{
|
|
{name: "pending", decision: extension.ProjectWorkDecision{State: extension.ProjectWorkPending}},
|
|
{name: "unbound proceed", decision: extension.ProjectWorkDecision{State: extension.ProjectWorkProceed}},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
preflight := func(context.Context, string, string) (extension.ProjectWorkDecision, error) { return tc.decision, nil }
|
|
decision, err := resolveProjectWorkDecision(context.Background(), "alice", "project-1", "run-1", "conversation.run", preflight, nil, nil)
|
|
if err == nil || decision.State == extension.ProjectWorkProceed {
|
|
t.Fatalf("unsafe decision proceeded: %+v err=%v", decision, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunContextUsesHITLForProjectRiskBeforeContinuing(t *testing.T) {
|
|
rc, st := newExecEnv(t)
|
|
rc.run.ID = "run-risk-gate"
|
|
rc.run.Status = core.RunRunning
|
|
if err := st.CreateRun(&rc.run); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rc.h = &runHandle{hitlCh: make(chan hitlAnswer, 1)}
|
|
rc.h.hitlCh <- hitlAnswer{resp: core.HITLResponse{ChoiceID: "option-0"}}
|
|
rc.mgr.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { return true, nil })
|
|
responded, preflights := false, 0
|
|
rc.mgr.SetProjectWorkGate(
|
|
func(context.Context, string, string) (extension.ProjectWorkDecision, error) {
|
|
preflights++
|
|
if preflights > 1 {
|
|
return extension.ProjectWorkDecision{State: extension.ProjectWorkProceed, DecisionID: "decision-after-recheck"}, nil
|
|
}
|
|
return extension.ProjectWorkDecision{State: extension.ProjectWorkClarify, RiskID: "risk-1", RiskRevision: "r1", Question: "效率具体指什么?", Options: []string{"效率更高"}}, nil
|
|
},
|
|
func(_ context.Context, answer extension.ProjectRiskAnswer) (extension.ProjectWorkDecision, error) {
|
|
responded = answer.Answer == "效率更高"
|
|
return extension.ProjectWorkDecision{State: extension.ProjectWorkProceed, DecisionID: "decision-1"}, nil
|
|
},
|
|
)
|
|
if err := rc.requireProjectWorkDecision("conversation.run"); err != nil || !responded || preflights != 2 {
|
|
t.Fatalf("run did not recheck after committed human clarification: responded=%t preflights=%d err=%v", responded, preflights, err)
|
|
}
|
|
cards, err := st.ActiveHITLCards("alice", rc.run.ConversationID)
|
|
if err != nil || len(cards) != 1 || cards[0].Question != "效率具体指什么?" {
|
|
t.Fatalf("clarification card was not persisted: %+v %v", cards, err)
|
|
}
|
|
}
|
|
|
|
func TestConversationRunWaitsForProjectSourceAnalysisBeforeRunner(t *testing.T) {
|
|
rc, st := newExecEnv(t)
|
|
conv, err := st.GetConversation("alice", rc.run.ConversationID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
message := core.Message{ConversationID: conv.ID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "要求效率和成本都低于人工"}}}
|
|
if err := st.AppendMessageWithRequest(&message, "risk-request"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runner := &projectGateRunner{}
|
|
rc.mgr.runner = runner
|
|
rc.mgr.profiles = profiles.New(st)
|
|
rc.mgr.active = map[string]*runHandle{}
|
|
rc.mgr.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { return true, nil })
|
|
ready := atomic.Bool{}
|
|
rc.mgr.SetProjectWorkGate(
|
|
func(context.Context, string, string) (extension.ProjectWorkDecision, error) {
|
|
if !ready.Load() {
|
|
return extension.ProjectWorkDecision{State: extension.ProjectWorkPending}, nil
|
|
}
|
|
return extension.ProjectWorkDecision{State: extension.ProjectWorkProceed, DecisionID: "decision-1"}, nil
|
|
},
|
|
func(context.Context, extension.ProjectRiskAnswer) (extension.ProjectWorkDecision, error) {
|
|
return extension.ProjectWorkDecision{State: extension.ProjectWorkProceed, DecisionID: "decision-1"}, nil
|
|
},
|
|
)
|
|
run, err := rc.mgr.StartConversationRun(conv, message, nil, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
time.Sleep(80 * time.Millisecond)
|
|
if runner.calls.Load() != 0 {
|
|
t.Fatal("model runner started while project sources were still pending")
|
|
}
|
|
linked, err := st.MessageByID(string(message.ID))
|
|
if err != nil || linked.RunID != run.ID {
|
|
t.Fatalf("committed user source was not bound before preflight: %+v %v", linked, err)
|
|
}
|
|
ready.Store(true)
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for runner.calls.Load() == 0 && time.Now().Before(deadline) {
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
if runner.calls.Load() != 1 {
|
|
t.Fatalf("runner calls=%d, want one after committed proceed", runner.calls.Load())
|
|
}
|
|
}
|
|
|
|
func TestProjectAssistantTextIsNotStreamedBeforeFinalRiskDecision(t *testing.T) {
|
|
rc, _ := newExecEnv(t)
|
|
rc.run.ID = "run-buffered-project-reply"
|
|
enableProjectGateForTest(rc.mgr)
|
|
if err := rc.Emit(core.EvMessageDelta, core.MessageDeltaPayload{Index: 0, Text: "未经风险检查的正文"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rc.mgr.events.mu.Lock()
|
|
buffered := len(rc.mgr.events.buf[string(rc.run.ConversationID)])
|
|
rc.mgr.events.mu.Unlock()
|
|
if buffered != 0 {
|
|
t.Fatalf("project reply leaked %d message delta(s) before final decision", buffered)
|
|
}
|
|
}
|
|
|
|
func TestFinalProjectReplyWaitsForRiskDecisionBeforeCommit(t *testing.T) {
|
|
rc, st := newExecEnv(t)
|
|
conv, err := st.GetConversation("alice", rc.run.ConversationID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
message := core.Message{ConversationID: conv.ID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "项目问题"}}}
|
|
if err := st.AppendMessageWithRequest(&message, "final-risk-request"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rc.mgr.runner = &projectReplyRunner{}
|
|
rc.mgr.profiles = profiles.New(st)
|
|
rc.mgr.active = map[string]*runHandle{}
|
|
rc.mgr.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { return true, nil })
|
|
finalReady := atomic.Bool{}
|
|
rc.mgr.SetProjectWorkGate(
|
|
func(_ context.Context, _ string, action string) (extension.ProjectWorkDecision, error) {
|
|
if action == "assistant.final" && !finalReady.Load() {
|
|
return extension.ProjectWorkDecision{State: extension.ProjectWorkPending}, nil
|
|
}
|
|
return extension.ProjectWorkDecision{State: extension.ProjectWorkProceed, DecisionID: "decision-" + action}, nil
|
|
},
|
|
func(context.Context, extension.ProjectRiskAnswer) (extension.ProjectWorkDecision, error) {
|
|
return extension.ProjectWorkDecision{State: extension.ProjectWorkProceed, DecisionID: "decision-risk"}, nil
|
|
},
|
|
)
|
|
if _, err := rc.mgr.StartConversationRun(conv, message, nil, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
time.Sleep(80 * time.Millisecond)
|
|
messages, err := st.ListMessages(string(conv.ID))
|
|
if err != nil || len(messages) != 1 {
|
|
t.Fatalf("assistant reply committed before final risk decision: %+v %v", messages, err)
|
|
}
|
|
finalReady.Store(true)
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
messages, err = st.ListMessages(string(conv.ID))
|
|
if err == nil && len(messages) == 2 {
|
|
break
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
if len(messages) != 2 || messages[1].Role != core.RoleAssistant || messages[1].Parts[0].Text != "最终项目答复" {
|
|
t.Fatalf("approved final reply was not committed once: %+v", messages)
|
|
}
|
|
}
|