package operations import ( "errors" "strings" "testing" "orchestra/internal/domain" "orchestra/internal/registry" ) func request(q string) domain.DecisionRequest { return domain.DecisionRequest{ Question: q, Why: "both behaviours are valid and two external callers depend on the answer", Options: []domain.DecisionOption{ {ID: "preserve", Description: "keep the old contract", Tradeoff: "larger implementation"}, {ID: "break", Description: "change the contract", Tradeoff: "consumers must migrate"}, }, Evidence: []string{"internal/cache/cache.go:44 documents neither behaviour"}, } } // Real ambiguity blocks with one bounded question, the human answers through // the ordinary decision path, and the task resumes. func TestDecisionRequestBlocksAndResumes(t *testing.T) { s, id := phaseStore(t) lease(t, s, id) project := registry.Project{ID: "p"} if _, err := RequestHumanDecision(s, project, id, request("should the old cache contract stay compatible?")); err != nil { t.Fatal(err) } blocked, _ := s.Task(id) if blocked.State != domain.StateBlocked || blocked.BlockReason != domain.BlockReasonHumanDecision { t.Fatalf("task = %+v", blocked) } if blocked.DecisionRequest == nil || blocked.DecisionRequest.Question == "" { t.Fatal("the question is not projected onto the task") } for _, want := range []string{"Human decision required", "stay compatible", "preserve", "consumers must migrate"} { if !strings.Contains(blocked.Blocker, want) { t.Fatalf("blocker missing %q:\n%s", want, blocked.Blocker) } } // Asking again while waiting is refused. if _, err := RequestHumanDecision(s, project, id, request("another question?")); !errors.Is(err, domain.ErrConflict) { t.Fatalf("want ErrConflict, got %v", err) } // Nothing resumes before the human replies. if events, err := ResumeAnsweredBlockers(s); err != nil || len(events) != 0 { t.Fatalf("resumed early: %v %v", events, err) } humanReply(t, s, id, "d1", "break compatibility and update the two callers") events, err := ResumeAnsweredBlockers(s) if err != nil || len(events) != 1 { t.Fatalf("events=%v err=%v", events, err) } resumed, _ := s.Task(id) if resumed.State != domain.StateQueued { t.Fatalf("state = %s", resumed.State) } // The question is gone from the projection. The log still has it, so a // resolved question never reappears in a later context. if resumed.DecisionRequest != nil { t.Fatalf("resolved question still on the task: %+v", resumed.DecisionRequest) } // And a second sweep is idempotent. if events, err := ResumeAnsweredBlockers(s); err != nil || len(events) != 0 { t.Fatalf("resumed twice: %v %v", events, err) } } // A rotation between the question and the answer must not re-ask it. func TestResolvedQuestionIsNotRepeatedAfterRotation(t *testing.T) { s, id := phaseStore(t) lease(t, s, id) project := registry.Project{ID: "p"} if _, err := RequestHumanDecision(s, project, id, request("preserve compatibility?")); err != nil { t.Fatal(err) } humanReply(t, s, id, "d1", "break it") if _, err := ResumeAnsweredBlockers(s); err != nil { t.Fatal(err) } // A later session sees no pending question, and the budget records that // one was spent. got, _ := s.Task(id) if got.DecisionRequest != nil { t.Fatal("question repeated after resume") } if n := countDecisionRequests(s, id); n != 1 { t.Fatalf("requests counted = %d", n) } } // The budget stops a task turning into an interview. func TestDecisionBudgetBecomesOperatorRequired(t *testing.T) { s, id := phaseStore(t) project := registry.Project{ID: "p"} project.HumanDecisions.MaxRequestsPerTask = 2 for i, q := range []string{"first?", "second?"} { // An answered question returns the task to the queue, so the next // session leases it again before it can ask. lease(t, s, id) if _, err := RequestHumanDecision(s, project, id, request(q)); err != nil { t.Fatalf("request %d: %v", i, err) } humanReply(t, s, id, "d"+q, "answered") if _, err := ResumeAnsweredBlockers(s); err != nil { t.Fatal(err) } } lease(t, s, id) _, err := RequestHumanDecision(s, project, id, request("third?")) if !errors.Is(err, ErrDecisionBudgetSpent) { t.Fatalf("want ErrDecisionBudgetSpent, got %v", err) } got, _ := s.Task(id) if got.BlockReason != domain.BlockReasonOperatorRequired { t.Fatalf("block reason = %q", got.BlockReason) } // A reply must not resume a task that spent its budget. An operator does. humanReply(t, s, id, "d-late", "just keep going") if events, err := ResumeAnsweredBlockers(s); err != nil || len(events) != 0 { t.Fatalf("operator_required resumed on a reply: %v %v", events, err) } } // Bounds are the whole defence against an interview arriving as one request. func TestDecisionRequestBoundsRejectInterviews(t *testing.T) { s, id := phaseStore(t) project := registry.Project{ID: "p"} cases := map[string]domain.DecisionRequest{ "no question": {Why: "w"}, "no why": {Question: "q"}, "multiline": {Question: "line one\nline two", Why: "w"}, "long": {Question: strings.Repeat("x", 501), Why: "w"}, "five options": {Question: "q", Why: "w", Options: []domain.DecisionOption{ {ID: "a", Description: "d"}, {ID: "b", Description: "d"}, {ID: "c", Description: "d"}, {ID: "d", Description: "d"}, {ID: "e", Description: "d"}, }}, "duplicate options": {Question: "q", Why: "w", Options: []domain.DecisionOption{ {ID: "a", Description: "d"}, {ID: "a", Description: "d"}, }}, "nine evidence lines": {Question: "q", Why: "w", Evidence: []string{"1", "2", "3", "4", "5", "6", "7", "8", "9"}}, } for name, req := range cases { if _, err := RequestHumanDecision(s, project, id, req); !errors.Is(err, domain.ErrInvalid) { t.Fatalf("%s: want ErrInvalid, got %v", name, err) } } if got, _ := s.Task(id); got.State == domain.StateBlocked { t.Fatal("a rejected request must not block the task") } } // An out-of-scope discovery is recorded and does not block anything, and never // reaches agent context. func TestDeferredFindingDoesNotBlock(t *testing.T) { s, id := phaseStore(t) before, _ := s.Task(id) if _, err := RecordDeferredFinding(s, id, domain.DeferredFinding{ Summary: "identity clustering could be redesigned", Why: "unrelated to speaker attribution and out of this task's scope", }); err != nil { t.Fatal(err) } after, _ := s.Task(id) if after.State != before.State || after.DecisionRequest != nil { t.Fatalf("deferred finding changed task state: %+v", after) } found := DeferredFindings(s, id) if len(found) != 1 || found[0].Summary != "identity clustering could be redesigned" { t.Fatalf("findings = %+v", found) } if _, err := RecordDeferredFinding(s, id, domain.DeferredFinding{Summary: " "}); !errors.Is(err, domain.ErrInvalid) { t.Fatal("an empty finding must be rejected") } }