package reverse_think import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "strings" "testing" "time" "git.flytoex.net/yuanwei/flyto-agent/pkg/counterfactual" ) func validPrompt() Prompt { return Prompt{ Scenario: "决定是否在 staging.Record 加 Deliverable 字段", OptionA: "加专用字段", OptionB: "复用 Metadata map", Recommendation: "B", RecommendationReason: "保 schema-agnostic / 不破坏现有调用方 / 复用扩展槽", } } func TestRenderRequiresAllFields(t *testing.T) { p := validPrompt() if _, err := Render(p); err != nil { t.Fatalf("Render valid prompt: %v", err) } p.Scenario = "" if _, err := Render(p); !errors.Is(err, ErrPromptIncomplete) { t.Errorf("Render empty Scenario: got %v want ErrPromptIncomplete", err) } p = validPrompt() p.RecommendationReason = " " if _, err := Render(p); !errors.Is(err, ErrPromptIncomplete) { t.Errorf("Render whitespace-only Reason: got %v want ErrPromptIncomplete", err) } } func TestRenderContainsTemplateAnchors(t *testing.T) { out, err := Render(validPrompt()) if err != nil { t.Fatal(err) } wantSubstrings := []string{ "反向思维分析", "场景:", "选项 A:", "选项 B:", "我的建议: B", "hidden_assumptions", "failure_scenarios", "verdict", "A_holds | B_wins | depends_on_X", } for _, s := range wantSubstrings { if !strings.Contains(out, s) { t.Errorf("Render output missing %q", s) } } } func mockSuccessHandler(payload counterfactual.Deliverable) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // Echo a minimal Anthropic envelope. The text block carries the // raw JSON the LLM would produce per SKILL.md template (only the // 4 core fields, no metadata). raw, _ := json.Marshal(struct { HiddenAssumptions []string `json:"hidden_assumptions"` FailureScenarios []string `json:"failure_scenarios"` Verdict string `json:"verdict"` VerdictReason string `json:"verdict_reason"` }{ HiddenAssumptions: payload.HiddenAssumptions, FailureScenarios: payload.FailureScenarios, Verdict: string(payload.Verdict), VerdictReason: payload.VerdictReason, }) envelope := anthropicResponse{ Content: []anthropicContentBlock{ {Type: "thinking", Text: "this is the model's chain of thought, ignore"}, {Type: "text", Text: string(raw)}, }, } w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(envelope) } } func TestRunSuccessRoundtrip(t *testing.T) { want := counterfactual.Deliverable{ HiddenAssumptions: []string{"assumes single tenant"}, FailureScenarios: []string{"breaks under multi-tenant"}, Verdict: counterfactual.VerdictBWins, VerdictReason: "alternative tolerates multi-tenant", } server := httptest.NewServer(mockSuccessHandler(want)) defer server.Close() fixedTime := time.Date(2026, 4, 25, 10, 0, 0, 0, time.UTC) c := &Client{ APIKey: "test-key", Endpoint: server.URL, Now: func() time.Time { return fixedTime }, } got, err := c.Run(context.Background(), validPrompt(), Annotations{ ToolName: "DatabaseWrite", Step: counterfactual.StepReverse, DecisionID: "rec-1", }) if err != nil { t.Fatalf("Run: %v", err) } if got.Verdict != want.Verdict { t.Errorf("Verdict: got %q want %q", got.Verdict, want.Verdict) } if got.VerdictReason != want.VerdictReason { t.Errorf("VerdictReason mismatch") } if got.ToolName != "DatabaseWrite" { t.Errorf("ToolName not stamped: got %q", got.ToolName) } if got.Step != counterfactual.StepReverse { t.Errorf("Step not stamped") } if got.DecisionID != "rec-1" { t.Errorf("DecisionID not stamped") } if !got.OccurredAt.Equal(fixedTime) { t.Errorf("OccurredAt: got %v want %v", got.OccurredAt, fixedTime) } } func TestRunRequiresAPIKey(t *testing.T) { c := &Client{} _, err := c.Run(context.Background(), validPrompt(), Annotations{}) if !errors.Is(err, ErrAPIKeyRequired) { t.Errorf("Run without APIKey: got %v want ErrAPIKeyRequired", err) } } func TestRunPropagatesPromptError(t *testing.T) { c := &Client{APIKey: "k"} bad := validPrompt() bad.Scenario = "" _, err := c.Run(context.Background(), bad, Annotations{}) if !errors.Is(err, ErrPromptIncomplete) { t.Errorf("Run with empty prompt: got %v want ErrPromptIncomplete", err) } } func TestRunHandlesEndpointError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"error":"backend down"}`)) })) defer server.Close() c := &Client{APIKey: "k", Endpoint: server.URL} _, err := c.Run(context.Background(), validPrompt(), Annotations{}) if !errors.Is(err, ErrEndpointFailed) { t.Errorf("Run with 500 response: got %v want ErrEndpointFailed wrapper", err) } } func TestRunHandlesMissingTextBlock(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { envelope := anthropicResponse{ Content: []anthropicContentBlock{ {Type: "thinking", Text: "only thinking, no text"}, }, } _ = json.NewEncoder(w).Encode(envelope) })) defer server.Close() c := &Client{APIKey: "k", Endpoint: server.URL} _, err := c.Run(context.Background(), validPrompt(), Annotations{}) if !errors.Is(err, ErrNoTextContent) { t.Errorf("Run with thinking-only response: got %v want ErrNoTextContent", err) } } func TestRunHandlesInvalidDeliverableJSON(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { envelope := anthropicResponse{ Content: []anthropicContentBlock{ {Type: "text", Text: "not json at all"}, }, } _ = json.NewEncoder(w).Encode(envelope) })) defer server.Close() c := &Client{APIKey: "k", Endpoint: server.URL} _, err := c.Run(context.Background(), validPrompt(), Annotations{}) if !errors.Is(err, ErrParseDeliverable) { t.Errorf("Run with non-JSON text: got %v want ErrParseDeliverable", err) } } func TestRunValidatesDeliverable(t *testing.T) { // Server returns valid Anthropic envelope but Verdict is empty, // which fails Deliverable.Validate. Run wraps that failure as // ErrParseDeliverable so callers have a single sentinel for "the // LLM gave us something that didn't fit". // // 服务器返回合法 Anthropic 信封但 Verdict 空, 触发 Deliverable.Validate 失败. // Run 把该失败包成 ErrParseDeliverable, 调用方有单一 sentinel 表 "LLM 给的 // 东西不合规". server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { raw := `{"hidden_assumptions":[],"failure_scenarios":[],"verdict":"","verdict_reason":"empty"}` envelope := anthropicResponse{ Content: []anthropicContentBlock{{Type: "text", Text: raw}}, } _ = json.NewEncoder(w).Encode(envelope) })) defer server.Close() c := &Client{APIKey: "k", Endpoint: server.URL} _, err := c.Run(context.Background(), validPrompt(), Annotations{}) if !errors.Is(err, ErrParseDeliverable) { t.Errorf("Run with empty verdict: got %v want ErrParseDeliverable", err) } } func TestRunSendsCorrectHeaders(t *testing.T) { var gotAuth, gotVersion, gotContentType string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotAuth = r.Header.Get("Authorization") gotVersion = r.Header.Get("anthropic-version") gotContentType = r.Header.Get("Content-Type") // Minimal valid response so Run doesn't error on parse. raw := `{"hidden_assumptions":[],"failure_scenarios":[],"verdict":"A_holds","verdict_reason":"ok"}` envelope := anthropicResponse{ Content: []anthropicContentBlock{{Type: "text", Text: raw}}, } _ = json.NewEncoder(w).Encode(envelope) })) defer server.Close() c := &Client{APIKey: "secret-key-xyz", Endpoint: server.URL} _, err := c.Run(context.Background(), validPrompt(), Annotations{}) if err != nil { t.Fatalf("Run: %v", err) } if gotAuth != "Bearer secret-key-xyz" { t.Errorf("Authorization: got %q", gotAuth) } if gotVersion != anthropicVersion { t.Errorf("anthropic-version: got %q want %q", gotVersion, anthropicVersion) } if gotContentType != "application/json" { t.Errorf("Content-Type: got %q", gotContentType) } } func TestRunUsesDefaults(t *testing.T) { // Verify Endpoint / Model / MaxTokens defaults kick in when zero. // Inspect the request body the server receives. // // 验证 Endpoint / Model / MaxTokens 零值时回退到默认. 检查服务器收到的请求体. var gotModel string var gotMaxTokens int server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req anthropicRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { t.Fatalf("decode request: %v", err) } gotModel = req.Model gotMaxTokens = req.MaxTokens raw := `{"hidden_assumptions":[],"failure_scenarios":[],"verdict":"A_holds","verdict_reason":"ok"}` envelope := anthropicResponse{ Content: []anthropicContentBlock{{Type: "text", Text: raw}}, } _ = json.NewEncoder(w).Encode(envelope) })) defer server.Close() // Client with only APIKey and Endpoint (server URL); Model and // MaxTokens left zero so defaults apply. c := &Client{APIKey: "k", Endpoint: server.URL} _, err := c.Run(context.Background(), validPrompt(), Annotations{}) if err != nil { t.Fatalf("Run: %v", err) } if gotModel != DefaultModel { t.Errorf("Model: got %q want %q", gotModel, DefaultModel) } if gotMaxTokens != DefaultMaxTokens { t.Errorf("MaxTokens: got %d want %d", gotMaxTokens, DefaultMaxTokens) } } func TestRunRespectsCanceledContext(t *testing.T) { // A pre-cancelled context must short-circuit: the HTTP roundtrip // returns ctx.Err immediately. We do not test mid-flight cancellation // against an httptest server here because httptest.Server.Close in // the test teardown blocks until handler goroutines drain, and a // hand-rolled "handler waits on r.Context().Done()" pattern races // against that drain in CI -- the simpler "ctx already cancelled" // case covers the contract the caller relies on. // // 已取消的 context 必须短路: HTTP roundtrip 立即返回 ctx.Err. 不测中途 // 取消针对 httptest 服务器的场景, 因为 httptest.Server.Close 在测试卸下时 // 阻塞等 handler goroutine 排空, 自写 "handler 等 r.Context().Done()" 模式 // 在 CI 与排空竞争 -- "ctx 已取消" 这个简单场景已覆盖调用方依赖的契约. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Should never be invoked; ctx is cancelled before Run dials. t.Errorf("handler reached despite cancelled ctx") })) defer server.Close() ctx, cancel := context.WithCancel(context.Background()) cancel() c := &Client{APIKey: "k", Endpoint: server.URL} _, err := c.Run(ctx, validPrompt(), Annotations{}) if err == nil { t.Fatal("Run should error when context is already cancelled") } if !strings.Contains(fmt.Sprintf("%v", err), "context") { t.Errorf("error should mention context cancellation, got: %v", err) } }