Files
byre/internal/agents/inbox_internal_test.go
T

222 lines
8.0 KiB
Go
Raw Normal View History

package agents
import (
"context"
2026-10-14 19:35:34 -07:00
"encoding/json"
"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
cases := []struct {
name string
args askArgs
want []string
}{
{"valid", askArgs{Context: "c", Question: "q", WhyBlocked: "w", Options: []string{"a", "b"}, RecommendedOption: "a", Blocking: &yes}, nil},
{"empty", askArgs{}, []string{"context is empty", "question is empty", "why_blocked is empty", "at least two", "recommended_option", "blocking is required"}},
{"one option", askArgs{Context: "c", Question: "q", WhyBlocked: "w", Options: []string{"a"}, RecommendedOption: "a", Blocking: &yes}, []string{"at least two"}},
{"bad recommendation", askArgs{Context: "c", Question: "q", WhyBlocked: "w", Options: []string{"a", "b"}, RecommendedOption: "c", Blocking: &yes}, []string{"recommended_option"}},
{"bad attachment", askArgs{Context: "c", Question: "q", WhyBlocked: "w", Options: []string{"a", "b"}, RecommendedOption: "a", Blocking: &yes,
Attachments: []api.Attachment{{Kind: "video"}}}, []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: "c", Question: "Red or blue?", WhyBlocked: "w", Options: []string{"red", "blue"}, RecommendedOption: "red", 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
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")
}
}
2026-10-14 19:35:34 -07:00
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 }
question := api.InboxItem{Id: "q1", ThreadId: "q1", Kind: api.Question, Payload: map[string]any{"question": "Which color?"}}
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, Option: str("blue")}},
{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",
"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)
}
}
}