243 lines
9.0 KiB
Go
243 lines
9.0 KiB
Go
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"), store.DefaultPortFirst, 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}, "alice")
|
|
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, t.TempDir(), t.TempDir(), 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"), Text: str("also run go vet")}},
|
|
{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\nalice added: also run go vet\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)
|
|
}
|
|
}
|
|
}
|