2026-10-14 07:02:56 -07:00
package agents
import (
"context"
2026-10-14 19:35:34 -07:00
"encoding/json"
2026-10-14 23:34:39 -07:00
"fmt"
2026-10-14 22:57:01 -07:00
"path/filepath"
2026-10-14 07:02:56 -07:00
"strings"
"testing"
2026-10-14 22:57:01 -07:00
"time"
2026-10-14 07:02:56 -07:00
"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
2026-10-14 23:34:39 -07:00
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" }}
2026-10-14 07:02:56 -07:00
cases := [] struct {
name string
args askArgs
want [] string
}{
2026-10-14 23:34:39 -07:00
{ "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"` }},
2026-10-14 07:02:56 -07:00
}
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 )
}
}
})
}
}
2026-10-14 22:57:01 -07:00
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 )
}
2026-10-15 10:12:44 -07:00
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" )
2026-10-14 22:57:01 -07:00
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 )
}
2026-10-15 09:40:30 -07:00
m := New ( st , DefaultLimits , t . TempDir (), t . TempDir ())
2026-10-14 22:57:01 -07:00
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
2026-10-14 23:34:39 -07:00
raw , err := json . Marshal ( askArgs { Context : "- (*) red\n- ( ) blue" , Question : "Red or blue?" , WhyBlocked : "w" , Blocking : & yes })
2026-10-14 22:57:01 -07:00
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
2026-10-14 23:34:39 -07:00
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 )
}
2026-10-14 22:57:01 -07:00
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 )
}
}
2026-10-14 07:02:56 -07:00
func TestDeliveryMessage ( t * testing . T ) {
str := func ( s string ) * string { return & s }
2026-10-14 23:34:39 -07:00
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 }}
2026-10-14 10:37:04 -07:00
failure := api . InboxItem { Id : "e1" , ThreadId : "e1" , Kind : api . SessionError , Payload : map [ string ] any { "error" : "process died" }}
2026-10-14 07:02:56 -07:00
msg := deliveryMessage ([] store . Pending {
2026-10-14 23:34:39 -07:00
{ Item : question , Response : api . InboxResponse { User : "alice" , Type : api . Option , Options : opts ( "blue" )}},
2026-10-14 23:54:13 -07:00
{ Item : checklist , Response : api . InboxResponse { User : "alice" , Type : api . Option , Options : opts ( "tests" , "docs" ), Text : str ( "also run go vet" )}},
2026-10-14 10:37:04 -07:00
{ Item : question , Response : api . InboxResponse { User : "bob" , Type : api . Other , Text : str ( "what is it for?" ),
2026-10-14 07:02:56 -07:00
Attachments : [] api . Attachment {{ Kind : api . AttachmentKindUrl , Url : str ( "https://example.com" )}}}},
2026-10-14 10:37:04 -07:00
{ Item : failure , Response : api . InboxResponse { User : "alice" , Type : api . Other , Text : str ( "stop and write tests" )}},
2026-10-14 07:02:56 -07:00
})
for _ , want := range [] string {
"Your question q1: Which color?" ,
"alice chose: blue" ,
2026-10-14 23:54:13 -07:00
"Your question q2: Which chores?\nalice checked:\n- tests\n- docs\nalice added: also run go vet\n" ,
2026-10-14 10:37:04 -07:00
"bob answered (none of your options): what is it for?" ,
2026-10-14 07:02:56 -07:00
`calling ask_human with thread_id "q1"` ,
`"url":"https://example.com"` ,
2026-10-14 10:37:04 -07:00
"Your error e1: process died" ,
"alice replied: stop and write tests" ,
2026-10-14 07:02:56 -07:00
} {
if ! strings . Contains ( msg , want ) {
t . Fatalf ( "message missing %q:\n%s" , want , msg )
}
}
}