2026-10-13 22:30:39 -07:00
package agents_test
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"g.fc.run/misc/byre/internal/agents"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/git"
"g.fc.run/misc/byre/internal/store"
)
const longCommandPrompt = "Run the bash command `ping -c 120 127.0.0.1` in the foreground (not in the background) and wait for it to finish, then say done."
const (
testModel = "sonnet"
login = "alice@example.com"
gitName = "Alice Test"
gitEmail = "alice@byre.invalid"
)
type env struct {
2026-10-14 09:40:12 -07:00
t * testing . T
ctx context . Context
store * store . Store
events chan api . Event
user api . User
repo string
project api . Project
task api . Task
2026-10-13 22:30:39 -07:00
}
func newEnv ( t * testing . T ) * env {
ctx , cancel := context . WithTimeout ( context . Background (), 5 * time . Minute )
t . Cleanup ( cancel )
e := & env { t : t , ctx : ctx , events : make ( chan api . Event , 4096 )}
var err error
e . store , err = store . Open ( filepath . Join ( t . TempDir (), "byre.db" ), func ( ev api . Event ) { e . events <- ev })
if err != nil {
t . Fatalf ( "open store: %v" , err )
}
t . Cleanup ( func () { e . store . Close () })
_ , err = e . store . EnsureUser ( ctx , login , "Alice" )
if err != nil {
t . Fatalf ( "ensure user: %v" , err )
}
e . user , err = e . store . UpdateUserGit ( ctx , login , gitName , gitEmail )
if err != nil {
t . Fatalf ( "update user: %v" , err )
}
2026-10-14 09:40:12 -07:00
e . repo = scratchRepo ( t )
e . project = api . Project { Id : store . NewID (), Name : "demo" , RepoPath : e . repo , DefaultBranch : "main" , Remote : "none" , CreatedBy : login , CreatedAt : time . Now (). UTC ()}
err = e . store . CreateProject ( ctx , e . project )
2026-10-13 22:30:39 -07:00
if err != nil {
t . Fatalf ( "create project: %v" , err )
}
2026-10-14 09:40:12 -07:00
e . task = e . addTask ()
return e
}
func ( e * env ) addTask () api . Task {
e . t . Helper ()
2026-10-13 22:30:39 -07:00
id := store . NewID ()
2026-10-14 09:40:12 -07:00
task := api . Task {
2026-10-13 22:30:39 -07:00
Id : id ,
2026-10-14 09:40:12 -07:00
ProjectId : e . project . Id ,
2026-10-13 22:30:39 -07:00
Title : "test" ,
Branch : "byre/" + id ,
2026-10-14 09:40:12 -07:00
WorktreePath : filepath . Join ( e . t . TempDir (), id ),
2026-10-13 22:30:39 -07:00
Status : api . TaskStatusActive ,
CreatedBy : login ,
CreatedAt : time . Now (). UTC (),
}
2026-10-14 09:40:12 -07:00
var err error
task . BaseRef , err = git . AddWorktree ( e . repo , task . WorktreePath , task . Branch , "main" )
2026-10-13 22:30:39 -07:00
if err != nil {
2026-10-14 09:40:12 -07:00
e . t . Fatalf ( "add worktree: %v" , err )
2026-10-13 22:30:39 -07:00
}
2026-10-14 09:40:12 -07:00
err = e . store . CreateTask ( e . ctx , task )
2026-10-13 22:30:39 -07:00
if err != nil {
2026-10-14 09:40:12 -07:00
e . t . Fatalf ( "create task: %v" , err )
2026-10-13 22:30:39 -07:00
}
2026-10-14 09:40:12 -07:00
return task
2026-10-13 22:30:39 -07:00
}
func ( e * env ) manager () * agents . Manager {
2026-10-14 09:40:12 -07:00
m := agents . New ( e . store , agents . DefaultLimits )
2026-10-13 22:30:39 -07:00
e . t . Cleanup ( m . Close )
return m
}
func ( e * env ) waitFor ( desc string , match func ( api . Event ) bool ) [] api . Event {
e . t . Helper ()
seen := [] api . Event {}
for {
select {
case ev := <- e . events :
seen = append ( seen , ev )
e . t . Logf ( "event %d %s %v" , ev . Seq , ev . Kind , ev . Payload )
if match ( ev ) {
return seen
}
case <- e . ctx . Done ():
e . t . Fatalf ( "timed out waiting for %s" , desc )
}
}
}
func ( e * env ) waitIdle () [] api . Event {
e . t . Helper ()
return e . waitFor ( "session idle" , func ( ev api . Event ) bool {
return ev . Kind == "session.state" && ev . Payload [ "state" ] == string ( api . AgentSessionStateIdle )
})
}
func kinds ( events [] api . Event ) [] string {
out := [] string {}
for _ , ev := range events {
out = append ( out , ev . Kind )
}
return out
}
func has ( events [] api . Event , kind string ) bool {
for _ , ev := range events {
if ev . Kind == kind {
return true
}
}
return false
}
func TestTurnAndFollowUp ( t * testing . T ) {
t . Parallel ()
e := newEnv ( t )
m := e . manager ()
sess , err := m . Create ( e . ctx , e . task , e . user , `Create a file hi.txt containing exactly the word hi, then git add and git commit it with the message "add hi".` , testModel )
if err != nil {
t . Fatalf ( "create session: %v" , err )
}
if sess . State != api . AgentSessionStateRunning {
t . Fatalf ( "new session is %s, want running" , sess . State )
}
events := e . waitIdle ()
for _ , kind := range [] string { "session.created" , "user.message" , "agent.tool_call" , "agent.tool_result" , "agent.report" , "turn.ended" } {
if ! has ( events , kind ) {
t . Fatalf ( "missing %s in %v" , kind , kinds ( events ))
}
}
log := gitOut ( t , e . task . WorktreePath , "log" , "-1" , "--format=%an <%ae>" )
if log != gitName + " <" + gitEmail + ">" {
t . Fatalf ( "agent committed as %q" , log )
}
_ , err = m . Send ( e . ctx , sess . Id , login , "Reply with the single word ok." )
if err != nil {
t . Fatalf ( "send: %v" , err )
}
events = e . waitIdle ()
reply := ""
for _ , ev := range events {
if ev . Kind == "agent.message" {
reply += ev . Payload [ "text" ].( string )
}
}
if ! strings . Contains ( strings . ToLower ( reply ), "ok" ) {
t . Fatalf ( "follow-up reply %q" , reply )
}
_ , err = m . Send ( e . ctx , sess . Id , login , "again" )
if err != nil {
t . Fatalf ( "send while idle: %v" , err )
}
_ , err = m . Send ( e . ctx , sess . Id , login , "too soon" )
if err == nil {
t . Fatalf ( "send while running should conflict" )
}
e . waitIdle ()
}
func TestInterrupt ( t * testing . T ) {
t . Parallel ()
e := newEnv ( t )
m := e . manager ()
sess , err := m . Create ( e . ctx , e . task , e . user , longCommandPrompt , testModel )
if err != nil {
t . Fatalf ( "create session: %v" , err )
}
e . waitFor ( "tool call" , func ( ev api . Event ) bool { return ev . Kind == "agent.tool_call" })
time . Sleep ( 2 * time . Second )
at := time . Now ()
_ , err = m . Interrupt ( e . ctx , sess . Id )
if err != nil {
t . Fatalf ( "interrupt: %v" , err )
}
events := e . waitIdle ()
if time . Since ( at ) > 30 * time . Second {
t . Fatalf ( "interrupt took %s" , time . Since ( at ))
}
for _ , ev := range events {
if ev . Kind != "agent.tool_result" {
continue
}
if ev . Payload [ "is_error" ] != true || strings . Contains ( fmt . Sprint ( ev . Payload [ "content" ]), "Blocked" ) {
t . Fatalf ( "command finished or was refused instead of being interrupted: %v" , ev . Payload )
}
}
}
func TestRecoverMidTurn ( t * testing . T ) {
t . Parallel ()
e := newEnv ( t )
2026-10-14 09:40:12 -07:00
first := agents . New ( e . store , agents . DefaultLimits )
2026-10-13 22:30:39 -07:00
sess , err := first . Create ( e . ctx , e . task , e . user , "Run the bash command `sleep 15` in the foreground (not in the background). After it finishes, create a file done.txt containing exactly the word done." , testModel )
if err != nil {
t . Fatalf ( "create session: %v" , err )
}
e . waitFor ( "tool call" , func ( ev api . Event ) bool { return ev . Kind == "agent.tool_call" })
first . Close ()
stored , err := e . store . GetSession ( e . ctx , sess . Id )
if err != nil {
t . Fatalf ( "get session: %v" , err )
}
if stored . State != api . AgentSessionStateRunning {
t . Fatalf ( "killed mid-turn session is %s, want running" , stored . State )
}
second := e . manager ()
err = second . Recover ( e . ctx )
if err != nil {
t . Fatalf ( "recover: %v" , err )
}
events := e . waitIdle ()
notified := false
for _ , ev := range events {
if ev . Kind == "user.message" && ev . Actor == "byre" {
notified = true
}
}
if ! notified {
t . Fatalf ( "no restart notice in %v" , kinds ( events ))
}
out , err := os . ReadFile ( filepath . Join ( e . task . WorktreePath , "done.txt" ))
if err != nil {
t . Fatalf ( "resumed agent did not finish: %v" , err )
}
if strings . TrimSpace ( string ( out )) != "done" {
t . Fatalf ( "done.txt contains %q" , out )
}
}
func TestStopTask ( t * testing . T ) {
t . Parallel ()
e := newEnv ( t )
m := e . manager ()
sess , err := m . Create ( e . ctx , e . task , e . user , longCommandPrompt , testModel )
if err != nil {
t . Fatalf ( "create session: %v" , err )
}
e . waitFor ( "tool call" , func ( ev api . Event ) bool { return ev . Kind == "agent.tool_call" })
err = m . StopTask ( e . ctx , e . task . Id , login )
if err != nil {
t . Fatalf ( "stop task: %v" , err )
}
stored , err := e . store . GetSession ( e . ctx , sess . Id )
if err != nil {
t . Fatalf ( "get session: %v" , err )
}
if stored . State != api . AgentSessionStateCompleted || m . LiveCount () != 0 {
t . Fatalf ( "after stop: state %s, live %d" , stored . State , m . LiveCount ())
}
_ , err = m . Send ( e . ctx , sess . Id , login , "hello?" )
if err == nil {
t . Fatalf ( "send to completed session should conflict" )
}
}
2026-10-14 07:02:56 -07:00
const colorPrompt = `I need a file color.txt containing exactly one color name. Don't pick it yourself: ` +
`ask me with ask_human (blocking) whether it should be red or blue, recommending red. ` +
`When you get my answer, write the file and commit it with the message "add color".`
func ( e * env ) openQuestions ( sessionID string ) [] api . InboxItem {
e . t . Helper ()
items , err := e . store . ListInbox ( e . ctx , store . InboxFilter { SessionID : sessionID , State : api . Open })
if err != nil {
e . t . Fatalf ( "list inbox: %v" , err )
}
questions := [] api . InboxItem {}
for _ , item := range items {
if item . Kind == api . InboxItemKindQuestion {
questions = append ( questions , item )
}
}
return questions
}
func ( e * env ) respond ( m * agents . Manager , item api . InboxItem , typ api . InboxResponseType , option , text string , state api . InboxState ) {
e . t . Helper ()
resp := api . InboxResponse {
Id : store . NewID (),
ItemId : item . Id ,
User : login ,
Type : typ ,
Attachments : [] api . Attachment {},
CreatedAt : time . Now (). UTC (),
}
if option != "" {
resp . Option = & option
}
if text != "" {
resp . Text = & text
}
err := e . store . Respond ( e . ctx , login , [] store . Resolution {{ Response : resp , State : state }})
if err != nil {
e . t . Fatalf ( "respond: %v" , err )
}
err = m . Deliver ( e . ctx , item . AgentSessionId )
if err != nil {
e . t . Fatalf ( "deliver: %v" , err )
}
}
func TestAskHumanRoundTrip ( t * testing . T ) {
t . Parallel ()
e := newEnv ( t )
m := e . manager ()
sess , err := m . Create ( e . ctx , e . task , e . user , colorPrompt , testModel )
if err != nil {
t . Fatalf ( "create session: %v" , err )
}
e . waitIdle ()
questions := e . openQuestions ( sess . Id )
if len ( questions ) != 1 {
t . Fatalf ( "want one open question, got %+v" , questions )
}
q := questions [ 0 ]
if ! q . Blocking || q . Priority != api . Blocked || q . Payload [ "recommended_option" ] != "red" {
t . Fatalf ( "unexpected question %+v" , q )
}
if _ , err := os . Stat ( filepath . Join ( e . task . WorktreePath , "color.txt" )); err == nil {
t . Fatalf ( "agent wrote color.txt before getting an answer" )
}
waiting , err := e . store . AwaitingHuman ( e . ctx , sess . Id )
if err != nil || waiting != 1 {
t . Fatalf ( "awaiting human: %d %v" , waiting , err )
}
e . respond ( m , q , api . InboxResponseTypeOption , "blue" , "" , api . Answered )
events := e . waitIdle ()
delivered := false
for _ , ev := range events {
if ev . Kind == "user.message" && strings . Contains ( ev . Payload [ "text" ].( string ), "chose: blue" ) {
delivered = true
}
}
if ! delivered {
t . Fatalf ( "answer was not delivered: %v" , kinds ( events ))
}
out , err := os . ReadFile ( filepath . Join ( e . task . WorktreePath , "color.txt" ))
if err != nil {
t . Fatalf ( "agent didn't write color.txt: %v" , err )
}
if strings . TrimSpace ( string ( out )) != "blue" {
t . Fatalf ( "color.txt contains %q, want blue" , out )
}
pending , err := e . store . Undelivered ( e . ctx , sess . Id )
if err != nil || len ( pending ) != 0 {
t . Fatalf ( "responses left undelivered: %+v %v" , pending , err )
}
}
func TestFollowUpQuestionThread ( t * testing . T ) {
t . Parallel ()
e := newEnv ( t )
m := e . manager ()
sess , err := m . Create ( e . ctx , e . task , e . user , colorPrompt , testModel )
if err != nil {
t . Fatalf ( "create session: %v" , err )
}
e . waitIdle ()
questions := e . openQuestions ( sess . Id )
if len ( questions ) != 1 {
t . Fatalf ( "want one open question, got %+v" , questions )
}
first := questions [ 0 ]
e . respond ( m , first , api . InboxResponseTypeQuestion , "" , "Before I pick: which of the two did you recommend, and why?" , api . Open )
e . waitIdle ()
questions = e . openQuestions ( sess . Id )
if len ( questions ) != 1 {
t . Fatalf ( "want one open question after the follow-up, got %+v" , questions )
}
second := questions [ 0 ]
if second . Id == first . Id || second . ThreadId != first . ThreadId {
t . Fatalf ( "follow-up didn't continue the thread: first %s/%s, second %s/%s" , first . Id , first . ThreadId , second . Id , second . ThreadId )
}
old , err := e . store . GetInboxItem ( e . ctx , first . Id )
if err != nil {
t . Fatalf ( "get first question: %v" , err )
}
if old . State != api . Superseded {
t . Fatalf ( "first question is %s, want superseded" , old . State )
}
e . respond ( m , second , api . InboxResponseTypeFreeForm , "" , "Use green instead of either." , api . Answered )
e . waitIdle ()
out , err := os . ReadFile ( filepath . Join ( e . task . WorktreePath , "color.txt" ))
if err != nil {
t . Fatalf ( "agent didn't write color.txt: %v" , err )
}
if strings . TrimSpace ( string ( out )) != "green" {
t . Fatalf ( "color.txt contains %q, want green" , out )
}
}
2026-10-14 09:40:12 -07:00
func TestConcurrencyQueue ( t * testing . T ) {
t . Parallel ()
e := newEnv ( t )
m := agents . New ( e . store , agents . Limits { Global : 1 , PerVendor : map [ api . AgentSessionVendor ] int { api . Claude : 1 }})
t . Cleanup ( m . Close )
first , err := m . Create ( e . ctx , e . task , e . user , longCommandPrompt , testModel )
if err != nil {
t . Fatalf ( "create first: %v" , err )
}
e . waitFor ( "first tool call" , func ( ev api . Event ) bool { return ev . Kind == "agent.tool_call" })
second , err := m . Create ( e . ctx , e . addTask (), e . user , "Reply with the single word ok." , testModel )
if err != nil {
t . Fatalf ( "create second: %v" , err )
}
if second . State != api . AgentSessionStateQueued {
t . Fatalf ( "second session is %s with one slot taken, want queued" , second . State )
}
usage , err := m . Usage ( e . ctx )
if err != nil || usage . Running != 1 || usage . Queued != 1 {
t . Fatalf ( "usage %+v %v" , usage , err )
}
cancelled , err := m . Interrupt ( e . ctx , second . Id )
if err != nil || cancelled . State != api . AgentSessionStateIdle {
t . Fatalf ( "cancel queued turn: %+v %v" , cancelled , err )
}
second , err = m . Send ( e . ctx , second . Id , login , "Reply with the single word ok." )
if err != nil || second . State != api . AgentSessionStateQueued {
t . Fatalf ( "requeue: %+v %v" , second , err )
}
_ , err = m . Interrupt ( e . ctx , first . Id )
if err != nil {
t . Fatalf ( "interrupt first: %v" , err )
}
e . waitFor ( "first idle" , func ( ev api . Event ) bool {
return ev . Kind == "session.state" && * ev . AgentSessionId == first . Id && ev . Payload [ "state" ] == string ( api . AgentSessionStateIdle )
})
events := e . waitFor ( "second idle" , func ( ev api . Event ) bool {
return ev . Kind == "session.state" && * ev . AgentSessionId == second . Id && ev . Payload [ "state" ] == string ( api . AgentSessionStateIdle )
})
replied := false
for _ , ev := range events {
if ev . Kind == "agent.message" && * ev . AgentSessionId == second . Id && strings . Contains ( strings . ToLower ( ev . Payload [ "text" ].( string )), "ok" ) {
replied = true
}
}
if ! replied {
t . Fatalf ( "queued turn never ran: %v" , kinds ( events ))
}
}
2026-10-13 22:30:39 -07:00
func scratchRepo ( t * testing . T ) string {
dir := t . TempDir ()
gitOut ( t , dir , "init" , "--initial-branch=main" )
gitOut ( t , dir , "commit" , "--allow-empty" , "--message" , "root" )
return dir
}
func gitOut ( t * testing . T , dir string , args ... string ) string {
t . Helper ()
cmd := exec . Command ( "git" , args ... )
cmd . Dir = dir
cmd . Env = append ( os . Environ (),
"GIT_AUTHOR_NAME=Root" ,
"GIT_AUTHOR_EMAIL=root@byre.invalid" ,
"GIT_COMMITTER_NAME=Root" ,
"GIT_COMMITTER_EMAIL=root@byre.invalid" ,
)
out , err := cmd . CombinedOutput ()
if err != nil {
t . Fatalf ( "git %v: %v\n%s" , args , err , out )
}
return strings . TrimSpace ( string ( out ))
}