package agents import ( "context" "encoding/json" "fmt" "path/filepath" "strings" "testing" "time" "g.fc.run/misc/byre/internal/api" "g.fc.run/misc/byre/internal/store" ) func TestValidateAsk(t *testing.T) { m := &Manager{} l := &live{id: "s1"} yes := true ask := func(context string) askArgs { return askArgs{Context: context, Question: "q", WhyBlocked: "w", Blocking: &yes} } withAttachment := ask("- (*) a\n- ( ) b") withAttachment.Attachments = []api.Attachment{{Kind: "video"}} cases := []struct { name string args askArgs want []string }{ {"pick one", ask("Background.\n\n- (*) a: does a\n- ( ) b: does b"), nil}, {"pick any", ask("- [ ] a\n- [x] b\n- [X] c"), nil}, {"pick any, none recommended", ask("- [ ] a\n- [ ] b"), nil}, {"empty", askArgs{}, []string{"context is empty", "question is empty", "why_blocked is empty", "at least two choices", "blocking is required"}}, {"plain list", ask("- a\n- b"), []string{"at least two choices"}}, {"one choice", ask("- (*) a"), []string{"at least two choices"}}, {"in a code fence", ask("```\n- (*) a\n- ( ) b\n```"), []string{"at least two choices"}}, {"no recommendation", ask("- ( ) a\n- ( ) b"), []string{"exactly one"}}, {"two recommendations", ask("- (*) a\n- (*) b"), []string{"exactly one"}}, {"mixed", ask("- (*) a\n- [ ] b"), []string{"mix"}}, {"duplicate", ask("- (*) a\n- ( ) a"), []string{`"a" appears twice`}}, {"bad attachment", withAttachment, []string{`attachments[0].kind "video"`}}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { err := m.validateAsk(context.Background(), l, c.args) if len(c.want) == 0 { if err != nil { t.Fatalf("unexpected error %v", err) } return } if err == nil { t.Fatalf("want rejection mentioning %v", c.want) } if !strings.Contains(err.Error(), "nothing was filed") { t.Fatalf("rejection doesn't say nothing was filed: %v", err) } for _, w := range c.want { if !strings.Contains(err.Error(), w) { t.Fatalf("rejection %q doesn't mention %q", err, w) } } }) } } func TestCancelQuestion(t *testing.T) { ctx := context.Background() st, err := store.Open(filepath.Join(t.TempDir(), "byre.db"), func(api.Event) {}) if err != nil { t.Fatalf("open store: %v", err) } t.Cleanup(func() { st.Close() }) now := time.Now().UTC() _, err = st.EnsureUser(ctx, "alice", "Alice") if err != nil { t.Fatalf("ensure user: %v", err) } err = st.CreateProject(ctx, api.Project{Id: "p1", Name: "demo", RepoPath: "/nonexistent", DefaultBranch: "main", Remote: "none", CreatedBy: "alice", CreatedAt: now}) if err != nil { t.Fatalf("create project: %v", err) } err = st.CreateTask(ctx, api.Task{Id: "t1", ProjectId: "p1", Title: "Fix the thing", Branch: "b", WorktreePath: "/nonexistent", BaseRef: "x", Status: api.TaskStatusActive, CreatedBy: "alice", CreatedAt: now}) if err != nil { t.Fatalf("create task: %v", err) } err = st.CreateSession(ctx, api.AgentSession{Id: "s1", TaskId: "t1", Vendor: api.Claude, VendorSessionId: "s1", State: api.AgentSessionStateIdle, Role: api.Implementer, CreatedBy: "alice", CreatedAt: now}, "p1") if err != nil { t.Fatalf("create session: %v", err) } m := New(st, DefaultLimits, t.TempDir()) l := &live{id: "s1", dir: t.TempDir(), vendor: api.Claude, taskID: "t1", projectID: "p1"} other := &live{id: "s2", dir: t.TempDir(), vendor: api.Claude, taskID: "t1", projectID: "p1"} yes := true raw, err := json.Marshal(askArgs{Context: "- (*) red\n- ( ) blue", Question: "Red or blue?", WhyBlocked: "w", Blocking: &yes}) if err != nil { t.Fatalf("marshal: %v", err) } out, err := m.ask(ctx, l, raw) if err != nil { t.Fatalf("ask: %v", err) } items, err := st.ListInbox(ctx, store.InboxFilter{SessionID: "s1", State: api.Open}) if err != nil || len(items) != 1 { t.Fatalf("inbox after ask: %+v %v", items, err) } id := items[0].Id payload := items[0].Payload if fmt.Sprint(payload["options"]) != "[red blue]" || payload["multiple"] != false || fmt.Sprint(payload["recommended"]) != "[red]" { t.Fatalf("choices not parsed from context: %+v", payload) } if !strings.Contains(out, "question_id: "+id) || !strings.Contains(out, "cancel_question") { t.Fatalf("ask result doesn't give the id and cancel_question: %s", out) } cancel := func(l *live, id string) error { raw, err := json.Marshal(cancelArgs{QuestionID: id}) if err != nil { t.Fatalf("marshal: %v", err) } _, err = m.cancelQuestion(ctx, l, raw) return err } if cancel(l, "nope") == nil { t.Fatalf("cancelled a question that doesn't exist") } if cancel(other, id) == nil { t.Fatalf("cancelled another session's question") } err = cancel(l, id) if err != nil { t.Fatalf("cancel: %v", err) } item, err := st.GetInboxItem(ctx, id) if err != nil || item.State != api.Dismissed || item.ResolvedBy != nil || item.ResolvedAt == nil { t.Fatalf("after cancel: %+v %v", item, err) } waiting, err := st.AwaitingHuman(ctx, "s1") if err != nil || waiting != 0 { t.Fatalf("still awaiting human after cancel: %d %v", waiting, err) } if cancel(l, id) == nil { t.Fatalf("cancelled a question twice") } } func TestAsksQuestion(t *testing.T) { cases := []struct { name string text string want bool }{ {"prose question (seq 61)", "Which of these do you want to start with?", true}, {"question mid-message", "I can do either.\n\nWhich do you want first? I'd start with the spinner.", true}, {"bold question", "**Should I deploy now?**", true}, {"question mark only inside quotes (seq 108)", "- **A:** block if the final message contains a \"?\".\n- **C:** block if the turn's report lists open questions.\n\nMy recommendation is A and C together.", false}, {"quoted question", "Without it, a question like \"Which of these do you want to start with?\" still reaches you as plain text.", false}, {"inline code", "The view calls `GET /api/inbox?task=x` on load.", false}, {"fenced code", "Run:\n```\nif ok? then\n```\nDone.", false}, {"url query", "See https://example.com/?a=1 for details.", false}, {"blockquote", "> did it work?\n\nIt did.", false}, {"no question", "I committed everything and the tests pass.", false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { if got := asksQuestion(c.text); got != c.want { t.Fatalf("asksQuestion(%q) = %v, want %v", c.text, got, c.want) } }) } } func TestBounceProseQuestion(t *testing.T) { m := &Manager{} stop := func(l *live, text string) map[string]any { t.Helper() input, err := json.Marshal(map[string]any{"hook_event_name": "Stop", "last_assistant_message": text}) if err != nil { t.Fatal(err) } out, err := m.bounceProseQuestion(l)(context.Background(), input) if err != nil { t.Fatal(err) } return out } l := &live{} if out := stop(l, "Which of these do you want to start with?"); out["decision"] != "block" || out["reason"] != proseQuestionNudge { t.Fatalf("prose question not bounced: %v", out) } if out := stop(l, "Which of these do you want to start with?"); len(out) != 0 { t.Fatalf("bounced twice in one turn: %v", out) } asked := &live{asked: true} if out := stop(asked, "Should I go ahead?"); len(out) != 0 { t.Fatalf("bounced a turn that called ask_human: %v", out) } if out := stop(&live{}, "I committed everything and the tests pass."); len(out) != 0 { t.Fatalf("bounced a message with no question: %v", out) } } func TestDeliveryMessage(t *testing.T) { str := func(s string) *string { return &s } opts := func(s ...string) *[]string { return &s } question := api.InboxItem{Id: "q1", ThreadId: "q1", Kind: api.Question, Payload: map[string]any{"question": "Which color?", "multiple": false}} checklist := api.InboxItem{Id: "q2", ThreadId: "q2", Kind: api.Question, Payload: map[string]any{"question": "Which chores?", "multiple": true}} failure := api.InboxItem{Id: "e1", ThreadId: "e1", Kind: api.SessionError, Payload: map[string]any{"error": "process died"}} msg := deliveryMessage([]store.Pending{ {Item: question, Response: api.InboxResponse{User: "alice", Type: api.Option, Options: opts("blue")}}, {Item: checklist, Response: api.InboxResponse{User: "alice", Type: api.Option, Options: opts("tests", "docs")}}, {Item: question, Response: api.InboxResponse{User: "bob", Type: api.Other, Text: str("what is it for?"), Attachments: []api.Attachment{{Kind: api.AttachmentKindUrl, Url: str("https://example.com")}}}}, {Item: failure, Response: api.InboxResponse{User: "alice", Type: api.Other, Text: str("stop and write tests")}}, }) for _, want := range []string{ "Your question q1: Which color?", "alice chose: blue", "Your question q2: Which chores?\nalice checked:\n- tests\n- docs\n", "bob answered (none of your options): what is it for?", `calling ask_human with thread_id "q1"`, `"url":"https://example.com"`, "Your error e1: process died", "alice replied: stop and write tests", } { if !strings.Contains(msg, want) { t.Fatalf("message missing %q:\n%s", want, msg) } } }