package approvalreview import ( "context" "errors" "strings" "sync" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/audit" ) // recordingLLM 是记录请求的模型适配器桩:返回既定内容或既定错误。 type recordingLLM struct { content string err error mu sync.Mutex reqs []model.Request } func (m *recordingLLM) Name() string { return "recording-llm" } func (m *recordingLLM) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) { m.mu.Lock() m.reqs = append(m.reqs, req) m.mu.Unlock() if m.err != nil { return model.Response{}, m.err } return model.Response{Content: m.content, Message: model.TextMessage(model.RoleAssistant, m.content)}, nil } func (m *recordingLLM) ChatStream(context.Context, model.Request, func(model.StreamEvent)) (model.Response, error) { return model.Response{}, nil } func (m *recordingLLM) last() model.Request { m.mu.Lock() defer m.mu.Unlock() return m.reqs[len(m.reqs)-1] } func (m *recordingLLM) count() int { m.mu.Lock() defer m.mu.Unlock() return len(m.reqs) } // promptText 拼接请求里所有消息的文本,便于断言问句确实带上了任务与调用摘要。 func (m *recordingLLM) promptText() string { req := m.last() var b strings.Builder for _, msg := range req.Messages { for _, c := range msg.Content { b.WriteString(c.Text) b.WriteString("\n") } } return b.String() } // TestNewLLMJudge_NilAdapter:无模型适配器时返回 nil ⇒ 门对子代理的敏感调用走 // fail-closed(绝不因为没有判定器就放行)。 func TestNewLLMJudge_NilAdapter(t *testing.T) { t.Parallel() if got := NewLLMJudge(nil, nil); got != nil { t.Fatalf("NewLLMJudge(nil) = %v, want nil", got) } } // TestJudgeSubagentCall_AsksAStructuredQuestion:单轮轻量问句里带上了被派任务与 // 本次调用摘要,且不塞正文(问句只由这两段结构化输入组成)。 func TestJudgeSubagentCall_AsksAStructuredQuestion(t *testing.T) { t.Parallel() llm := &recordingLLM{content: `{"allow": true, "reason": ""}`} judge := NewLLMJudge(llm, nil) verdict, err := judge.JudgeSubagentCall(context.Background(), audit.JudgeRequest{ Task: "整理 data/ 下的导出文件", Actor: "昔涟", Action: "执行命令:terminal rm -rf /", }) if err != nil { t.Fatalf("JudgeSubagentCall: %v", err) } if !verdict.Allowed { t.Fatalf("verdict = %+v, want 放行", verdict) } prompt := llm.promptText() for _, want := range []string{"整理 data/ 下的导出文件", "执行命令:terminal rm -rf /", "昔涟"} { if !strings.Contains(prompt, want) { t.Fatalf("问句里缺少 %q,got: %s", want, prompt) } } if req := llm.last(); req.Purpose != judgePurpose || req.MaxTokens != judgeMaxTokens { t.Fatalf("模型请求 = (purpose=%q, maxTokens=%d), want (%q, %d)", req.Purpose, req.MaxTokens, judgePurpose, judgeMaxTokens) } if llm.count() != 1 { t.Fatalf("模型调用次数 = %d, want 1(单轮轻量)", llm.count()) } } // TestJudgeSubagentCall_ParsesDenial:判「越界」时把理由交回调用方(它要进审计与 // 前端小字)。围栏包裹的 JSON 也能识别。 func TestJudgeSubagentCall_ParsesDenial(t *testing.T) { t.Parallel() llm := &recordingLLM{content: "```json\n{\"allow\": false, \"reason\": \"任务里没有执行命令这一项\"}\n```"} verdict, err := NewLLMJudge(llm, nil).JudgeSubagentCall(context.Background(), audit.JudgeRequest{ Task: "整理导出文件", Action: "执行命令:terminal rm -rf /", }) if err != nil { t.Fatalf("JudgeSubagentCall: %v", err) } if verdict.Allowed { t.Fatal("verdict.Allowed = true, want false") } if verdict.Reason != "任务里没有执行命令这一项" { t.Fatalf("理由 = %q, want 判定器给出的理由", verdict.Reason) } } // TestJudgeSubagentCall_FailClosedOnBadOutput:模型报错、输出不是 JSON、缺 allow // 字段 —— 一律报错(由 Approver 按「判不出来就不放行」处理)。 func TestJudgeSubagentCall_FailClosedOnBadOutput(t *testing.T) { t.Parallel() tests := []struct { name string llm *recordingLLM }{ {name: "model error", llm: &recordingLLM{err: errors.New("provider down")}}, {name: "not json", llm: &recordingLLM{content: "允许"}}, {name: "broken json", llm: &recordingLLM{content: `{"allow": `}}, {name: "missing allow field", llm: &recordingLLM{content: `{"reason": "看起来没问题"}`}}, {name: "empty output", llm: &recordingLLM{content: ""}}, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() _, err := NewLLMJudge(tt.llm, nil).JudgeSubagentCall(context.Background(), audit.JudgeRequest{ Task: "整理导出文件", Action: "执行命令:terminal rm -rf /", }) if err == nil { t.Fatal("err = nil, want 判不出来(fail-closed)") } }) } }