1144 lines
35 KiB
Go
1144 lines
35 KiB
Go
package agents_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"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 {
|
|
t *testing.T
|
|
ctx context.Context
|
|
store *store.Store
|
|
events chan api.Event
|
|
user api.User
|
|
repo string
|
|
remote string
|
|
project api.Project
|
|
task api.Task
|
|
|
|
worktrees string
|
|
leads string
|
|
}
|
|
|
|
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), worktrees: t.TempDir(), leads: t.TempDir()}
|
|
|
|
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)
|
|
}
|
|
|
|
e.repo = scratchRepo(t)
|
|
e.remote = filepath.Join(t.TempDir(), "remote.git")
|
|
gitOut(t, e.repo, "init", "--quiet", "--bare", "--initial-branch=main", e.remote)
|
|
gitOut(t, e.repo, "remote", "add", git.Remote, e.remote)
|
|
gitOut(t, e.repo, "push", "--quiet", git.Remote, "main")
|
|
e.project = api.Project{Id: store.NewID(), Name: "demo", RepoPath: e.repo, DefaultBranch: "main", Remote: e.remote, CreatedBy: login, CreatedAt: time.Now().UTC()}
|
|
err = e.store.CreateProject(ctx, e.project)
|
|
if err != nil {
|
|
t.Fatalf("create project: %v", err)
|
|
}
|
|
|
|
e.task = e.addTask()
|
|
return e
|
|
}
|
|
|
|
func (e *env) addTask() api.Task {
|
|
e.t.Helper()
|
|
id := store.NewID()
|
|
task := api.Task{
|
|
Id: id,
|
|
ProjectId: e.project.Id,
|
|
Title: "test",
|
|
Branch: "byre/" + id,
|
|
WorktreePath: filepath.Join(e.t.TempDir(), id),
|
|
Status: api.TaskStatusActive,
|
|
CreatedBy: login,
|
|
CreatedAt: time.Now().UTC(),
|
|
}
|
|
|
|
var err error
|
|
task.BaseRef, err = git.AddWorktree(e.repo, task.WorktreePath, task.Branch, "main")
|
|
if err != nil {
|
|
e.t.Fatalf("add worktree: %v", err)
|
|
}
|
|
err = e.store.CreateTask(e.ctx, task, login)
|
|
if err != nil {
|
|
e.t.Fatalf("create task: %v", err)
|
|
}
|
|
return task
|
|
}
|
|
|
|
func (e *env) manager() *agents.Manager {
|
|
m := agents.New(e.store, agents.DefaultLimits, e.worktrees, e.leads)
|
|
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()
|
|
|
|
startHead := gitOut(t, e.task.WorktreePath, "rev-parse", "HEAD")
|
|
sess, err := m.Create(e.ctx, e.task, e.user, `Create a file hi.txt containing exactly the word 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", "turn.ended", "session.context"} {
|
|
if !has(events, kind) {
|
|
t.Fatalf("missing %s in %v", kind, kinds(events))
|
|
}
|
|
}
|
|
|
|
for _, ev := range events {
|
|
if ev.Kind != "turn.ended" {
|
|
continue
|
|
}
|
|
id, _ := ev.Payload["inbox_item_id"].(string)
|
|
if id == "" {
|
|
t.Fatalf("turn.ended has no summary inbox item: %v", ev.Payload)
|
|
}
|
|
item, err := e.store.GetInboxItem(e.ctx, id)
|
|
if err != nil {
|
|
t.Fatalf("get summary item: %v", err)
|
|
}
|
|
if item.Kind != api.Report || item.Payload["text"] != ev.Payload["result"] {
|
|
t.Fatalf("summary item %+v doesn't carry the turn's final message %q", item, ev.Payload["result"])
|
|
}
|
|
}
|
|
|
|
if !has(events, "snapshot.created") {
|
|
events = append(events, e.waitFor("snapshot", func(ev api.Event) bool { return ev.Kind == "snapshot.created" })...)
|
|
}
|
|
snaps, err := e.store.ListSnapshots(e.ctx, e.task.Id)
|
|
if err != nil || len(snaps) == 0 {
|
|
t.Fatalf("no snapshots after a turn that wrote a file: %+v %v", snaps, err)
|
|
}
|
|
latest := snaps[len(snaps)-1]
|
|
if ref := gitOut(t, e.repo, "rev-parse", git.SnapshotRef(e.task.Id, latest.N)); ref != latest.CommitSha {
|
|
t.Fatalf("snapshot ref %s, want %s", ref, latest.CommitSha)
|
|
}
|
|
if got := gitOut(t, e.repo, "show", latest.CommitSha+":hi.txt"); strings.TrimSpace(got) != "hi" {
|
|
t.Fatalf("hi.txt in the latest snapshot: %q", got)
|
|
}
|
|
if snaps[0].Trigger != api.SnapshotTriggerToolCall {
|
|
t.Fatalf("first snapshot trigger %s, want tool_call", snaps[0].Trigger)
|
|
}
|
|
|
|
window := 0
|
|
for _, ev := range events {
|
|
if ev.Kind == "session.context" {
|
|
window = ev.Payload["window"].(int)
|
|
}
|
|
}
|
|
if window <= 0 {
|
|
t.Fatalf("no context window reported after the turn ended")
|
|
}
|
|
|
|
head := strings.TrimSpace(gitOut(t, e.task.WorktreePath, "rev-parse", "HEAD"))
|
|
for _, ev := range events {
|
|
if ev.Kind == "user.message" && ev.Payload["head"] != strings.TrimSpace(startHead) {
|
|
t.Fatalf("user.message head %v, want the HEAD the turn started from %s", ev.Payload["head"], startHead)
|
|
}
|
|
if ev.Kind == "turn.ended" && ev.Payload["commit"] != head {
|
|
t.Fatalf("turn.ended commit %v, want the new HEAD %s", ev.Payload["commit"], head)
|
|
}
|
|
}
|
|
log := gitOut(t, e.task.WorktreePath, "log", "-1", "--format=%an <%ae>|%cn <%ce>", "--name-only")
|
|
if !strings.HasPrefix(log, gitName+" <"+gitEmail+">|"+gitName+" <"+gitEmail+">") || !strings.Contains(log, "hi.txt") {
|
|
t.Fatalf("byre's turn commit is wrong:\n%s", log)
|
|
}
|
|
if status := gitOut(t, e.task.WorktreePath, "status", "--porcelain", "--untracked-files=all"); status != "" {
|
|
t.Fatalf("worktree dirty after the turn commit:\n%s", status)
|
|
}
|
|
|
|
_, err = m.Send(e.ctx, sess.Id, login, "Reply with the single word ok.", "haiku")
|
|
if err != nil {
|
|
t.Fatalf("send: %v", err)
|
|
}
|
|
|
|
events = e.waitIdle()
|
|
reply := ""
|
|
switched := false
|
|
contextModel := ""
|
|
for _, ev := range events {
|
|
switch ev.Kind {
|
|
case "agent.message":
|
|
reply += ev.Payload["text"].(string)
|
|
case "session.model":
|
|
switched = ev.Payload["model"] == "haiku"
|
|
case "session.context":
|
|
contextModel = ev.Payload["model"].(string)
|
|
}
|
|
}
|
|
if !strings.Contains(strings.ToLower(reply), "ok") {
|
|
t.Fatalf("follow-up reply %q", reply)
|
|
}
|
|
if !switched || !strings.Contains(contextModel, "haiku") {
|
|
t.Fatalf("model switch: session.model recorded %v, context model %q", switched, contextModel)
|
|
}
|
|
stored, err := e.store.GetSession(e.ctx, sess.Id)
|
|
if err != nil {
|
|
t.Fatalf("get session: %v", err)
|
|
}
|
|
if stored.Model != "haiku" {
|
|
t.Fatalf("session model %q after switch", stored.Model)
|
|
}
|
|
|
|
_, 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)
|
|
first := agents.New(e.store, agents.DefaultLimits, e.worktrees, e.leads)
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
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.`
|
|
|
|
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.Question {
|
|
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()
|
|
e.respondAs(m, login, item, typ, option, text, state)
|
|
}
|
|
|
|
func (e *env) respondAs(m *agents.Manager, user string, 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: user,
|
|
Type: typ,
|
|
Attachments: []api.Attachment{},
|
|
CreatedAt: time.Now().UTC(),
|
|
}
|
|
if option != "" {
|
|
resp.Options = &[]string{option}
|
|
}
|
|
if text != "" {
|
|
resp.Text = &text
|
|
}
|
|
|
|
err := e.store.Respond(e.ctx, user, []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]
|
|
options, _ := q.Payload["options"].([]any)
|
|
recommended, _ := q.Payload["recommended"].([]any)
|
|
if !q.Blocking || q.Priority != api.Blocked || len(options) != 2 || len(recommended) != 1 || !strings.Contains(fmt.Sprint(recommended[0]), "red") {
|
|
t.Fatalf("unexpected question %+v", q)
|
|
}
|
|
blue := ""
|
|
for _, o := range options {
|
|
if strings.Contains(fmt.Sprint(o), "blue") {
|
|
blue = fmt.Sprint(o)
|
|
}
|
|
}
|
|
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.Option, 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)
|
|
}
|
|
}
|
|
|
|
const shapePrompt = `Ask me with ask_human (not blocking) whether shape.txt should say circle or square, recommending circle. ` +
|
|
"Then, without waiting for my answer, run these Bash commands one at a time, each as its own tool call: `sleep 8`, then `echo second`, then `echo third`. " +
|
|
`After those, write shape.txt containing exactly my answer if you have received it by then, or exactly unknown if not.`
|
|
|
|
func TestAnswerDeliveredMidTurn(t *testing.T) {
|
|
t.Parallel()
|
|
e := newEnv(t)
|
|
m := e.manager()
|
|
|
|
sess, err := m.Create(e.ctx, e.task, e.user, shapePrompt, testModel)
|
|
if err != nil {
|
|
t.Fatalf("create session: %v", err)
|
|
}
|
|
e.waitFor("question filed", func(ev api.Event) bool {
|
|
return ev.Kind == "inbox.created" && ev.Payload["kind"] == string(api.Question)
|
|
})
|
|
|
|
questions := e.openQuestions(sess.Id)
|
|
if len(questions) != 1 {
|
|
t.Fatalf("want one open question, got %+v", questions)
|
|
}
|
|
square := ""
|
|
options, _ := questions[0].Payload["options"].([]any)
|
|
for _, o := range options {
|
|
if strings.Contains(fmt.Sprint(o), "square") {
|
|
square = fmt.Sprint(o)
|
|
}
|
|
}
|
|
e.respond(m, questions[0], api.Option, square, "", api.Answered)
|
|
events := e.waitIdle()
|
|
|
|
midTurn := false
|
|
for _, ev := range events {
|
|
if ev.Kind == "user.message" && ev.Payload["mid_turn"] == true && strings.Contains(ev.Payload["text"].(string), "chose: "+square) {
|
|
midTurn = true
|
|
}
|
|
if ev.Kind == "turn.ended" && !midTurn {
|
|
t.Fatalf("turn ended before the answer was delivered mid-turn: %v", kinds(events))
|
|
}
|
|
}
|
|
if !midTurn {
|
|
t.Fatalf("answer was not delivered mid-turn: %v", kinds(events))
|
|
}
|
|
|
|
out, err := os.ReadFile(filepath.Join(e.task.WorktreePath, "shape.txt"))
|
|
if err != nil {
|
|
t.Fatalf("agent didn't write shape.txt: %v", err)
|
|
}
|
|
if strings.TrimSpace(string(out)) != "square" {
|
|
t.Fatalf("shape.txt contains %q, want square", 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 TestCompactCarriesHandoff(t *testing.T) {
|
|
t.Parallel()
|
|
e := newEnv(t)
|
|
m := e.manager()
|
|
|
|
old, err := m.Create(e.ctx, e.task, e.user, `Remember the codeword AZURE-17; a later message will ask for it. Reply with the single word ok.`, testModel)
|
|
if err != nil {
|
|
t.Fatalf("create session: %v", err)
|
|
}
|
|
e.waitIdle()
|
|
|
|
_, err = m.Compact(e.ctx, old.Id)
|
|
if err != nil {
|
|
t.Fatalf("compact: %v", err)
|
|
}
|
|
events := e.waitFor("new session idle", func(ev api.Event) bool {
|
|
return ev.Kind == "session.state" && ev.Payload["state"] == string(api.AgentSessionStateIdle) &&
|
|
ev.AgentSessionId != nil && *ev.AgentSessionId != old.Id
|
|
})
|
|
|
|
for _, ev := range events {
|
|
if ev.Kind == "turn.ended" && *ev.AgentSessionId == old.Id {
|
|
if ev.Payload["handoff"] != true || ev.Payload["inbox_item_id"] != nil {
|
|
t.Fatalf("handoff turn.ended %v: want handoff and no inbox summary", ev.Payload)
|
|
}
|
|
}
|
|
if ev.Kind == "user.message" && ev.Actor == "byre" && *ev.AgentSessionId == old.Id && ev.Payload["handoff"] != true {
|
|
t.Fatalf("handoff prompt %v: want handoff flag", ev.Payload)
|
|
}
|
|
}
|
|
|
|
sessions, err := e.store.ListSessions(e.ctx, e.task.Id)
|
|
if err != nil {
|
|
t.Fatalf("list sessions: %v", err)
|
|
}
|
|
if len(sessions) != 2 {
|
|
t.Fatalf("want the old and the new session, got %+v", sessions)
|
|
}
|
|
var next api.AgentSession
|
|
for _, s := range sessions {
|
|
switch s.Id {
|
|
case old.Id:
|
|
if s.State != api.AgentSessionStateCompleted {
|
|
t.Fatalf("old session is %s, want completed", s.State)
|
|
}
|
|
default:
|
|
next = s
|
|
}
|
|
}
|
|
if next.State != api.AgentSessionStateIdle || next.Model != old.Model {
|
|
t.Fatalf("new session %+v, want idle on model %q", next, old.Model)
|
|
}
|
|
|
|
_, err = m.Send(e.ctx, next.Id, login, "What was the codeword? Reply with just the codeword.", "")
|
|
if err != nil {
|
|
t.Fatalf("send to new session: %v", err)
|
|
}
|
|
reply := ""
|
|
for _, ev := range e.waitIdle() {
|
|
if ev.Kind == "agent.message" {
|
|
reply += ev.Payload["text"].(string)
|
|
}
|
|
}
|
|
if !strings.Contains(reply, "AZURE-17") {
|
|
t.Fatalf("handoff lost the codeword; new session said %q", reply)
|
|
}
|
|
}
|
|
|
|
func TestMergeIn(t *testing.T) {
|
|
t.Parallel()
|
|
e := newEnv(t)
|
|
m := e.manager()
|
|
|
|
_, err := m.Create(e.ctx, e.task, e.user, `Create a file task.txt containing exactly the word task.`, testModel)
|
|
if err != nil {
|
|
t.Fatalf("create session: %v", err)
|
|
}
|
|
e.waitIdle()
|
|
|
|
err = os.WriteFile(filepath.Join(e.repo, "main.txt"), []byte("main\n"), 0o644)
|
|
if err != nil {
|
|
t.Fatalf("write main.txt: %v", err)
|
|
}
|
|
gitOut(t, e.repo, "add", "main.txt")
|
|
gitOut(t, e.repo, "commit", "--message", "main change")
|
|
mainHead := gitOut(t, e.repo, "rev-parse", "HEAD")
|
|
|
|
_, err = m.MergeIn(e.ctx, e.task, e.user, "main")
|
|
if err != nil {
|
|
t.Fatalf("merge in: %v", err)
|
|
}
|
|
e.waitIdle()
|
|
|
|
parents := strings.Fields(gitOut(t, e.task.WorktreePath, "log", "-1", "--format=%P"))
|
|
if len(parents) != 2 || parents[1] != mainHead {
|
|
t.Fatalf("HEAD parents %v, want a merge of main %s", parents, mainHead)
|
|
}
|
|
for _, f := range []string{"task.txt", "main.txt"} {
|
|
_, err = os.Stat(filepath.Join(e.task.WorktreePath, f))
|
|
if err != nil {
|
|
t.Fatalf("%s missing after merge: %v", f, err)
|
|
}
|
|
}
|
|
if status := gitOut(t, e.task.WorktreePath, "status", "--porcelain", "--untracked-files=all"); status != "" {
|
|
t.Fatalf("worktree dirty after the merge turn:\n%s", status)
|
|
}
|
|
}
|
|
|
|
func TestMergeResolvesConflict(t *testing.T) {
|
|
t.Parallel()
|
|
e := newEnv(t)
|
|
m := e.manager()
|
|
|
|
_, err := m.Create(e.ctx, e.task, e.user, `Create a file notes.txt containing exactly the line "task".`, testModel)
|
|
if err != nil {
|
|
t.Fatalf("create session: %v", err)
|
|
}
|
|
e.waitIdle()
|
|
err = os.WriteFile(filepath.Join(e.task.WorktreePath, "uncommitted"), []byte("three\n"), 0o644)
|
|
if err != nil {
|
|
t.Fatalf("write uncommitted: %v", err)
|
|
}
|
|
|
|
err = os.WriteFile(filepath.Join(e.repo, "notes.txt"), []byte("main\n"), 0o644)
|
|
if err != nil {
|
|
t.Fatalf("write notes.txt: %v", err)
|
|
}
|
|
gitOut(t, e.repo, "add", "notes.txt")
|
|
gitOut(t, e.repo, "commit", "--message", "main change")
|
|
moved := gitOut(t, e.repo, "rev-parse", "HEAD")
|
|
|
|
_, err = m.Merge(e.ctx, e.task, e.project, e.user)
|
|
if err != nil {
|
|
t.Fatalf("merge: %v", err)
|
|
}
|
|
merged := e.waitFor("task merged", func(ev api.Event) bool { return ev.Kind == "task.merged" })
|
|
if pushed := merged[len(merged)-1].Payload["pushed"]; pushed != "origin/main" {
|
|
t.Fatalf("task.merged pushed %v, want origin/main", pushed)
|
|
}
|
|
|
|
task, err := e.store.GetTask(e.ctx, e.task.Id)
|
|
if err != nil {
|
|
t.Fatalf("get task: %v", err)
|
|
}
|
|
if task.Status != api.TaskStatusMerged || task.MergeCommit == nil {
|
|
t.Fatalf("task after merge %+v", task)
|
|
}
|
|
if head := gitOut(t, e.repo, "rev-parse", "main"); head != *task.MergeCommit {
|
|
t.Fatalf("main at %s, want merge commit %s", head, *task.MergeCommit)
|
|
}
|
|
if head := gitOut(t, e.remote, "rev-parse", "main"); head != *task.MergeCommit {
|
|
t.Fatalf("remote main at %s, want merge commit %s", head, *task.MergeCommit)
|
|
}
|
|
if parent := gitOut(t, e.repo, "rev-parse", "main^"); parent != moved {
|
|
t.Fatalf("merge commit parent %s, want %s", parent, moved)
|
|
}
|
|
if log := gitOut(t, e.repo, "log", "-1", "--format=%s|%b|%an|%cn"); log != task.MergeTitle+"||"+gitName+"|"+gitName {
|
|
t.Fatalf("unexpected merge commit %s, want title %q", log, task.MergeTitle)
|
|
}
|
|
t.Logf("merge commit title: %s", task.MergeTitle)
|
|
notes, err := os.ReadFile(filepath.Join(e.repo, "notes.txt"))
|
|
if err != nil {
|
|
t.Fatalf("read notes.txt: %v", err)
|
|
}
|
|
if !strings.Contains(string(notes), "task") || !strings.Contains(string(notes), "main") || strings.Contains(string(notes), "<<<<<<<") {
|
|
t.Fatalf("notes.txt on main doesn't hold both sides:\n%s", notes)
|
|
}
|
|
_, err = os.Stat(filepath.Join(e.repo, "uncommitted"))
|
|
if err != nil {
|
|
t.Fatalf("uncommitted file not on main: %v", err)
|
|
}
|
|
if status := gitOut(t, e.repo, "status", "--porcelain"); status != "" {
|
|
t.Fatalf("main checkout dirty after merge: %s", status)
|
|
}
|
|
if branches := gitOut(t, e.repo, "branch", "--list", e.task.Branch); branches != "" {
|
|
t.Fatalf("branch still exists: %s", branches)
|
|
}
|
|
}
|
|
|
|
func TestMergeCoAuthors(t *testing.T) {
|
|
t.Parallel()
|
|
e := newEnv(t)
|
|
m := e.manager()
|
|
|
|
people := []struct{ login, name, gitName, gitEmail string }{
|
|
{"carol@example.com", "Carol", "Carol Test", "carol@byre.invalid"},
|
|
{"bob@example.com", "Bob", "Bob Test", "bob@byre.invalid"},
|
|
{"dave@example.com", "Dave", "", ""},
|
|
}
|
|
const dismisser = "erin@example.com"
|
|
_, err := e.store.EnsureUser(e.ctx, dismisser, "Erin")
|
|
if err != nil {
|
|
t.Fatalf("ensure user: %v", err)
|
|
}
|
|
_, err = e.store.UpdateUserGit(e.ctx, dismisser, "Erin Test", "erin@byre.invalid")
|
|
if err != nil {
|
|
t.Fatalf("update user: %v", err)
|
|
}
|
|
for _, p := range people {
|
|
_, err := e.store.EnsureUser(e.ctx, p.login, p.name)
|
|
if err != nil {
|
|
t.Fatalf("ensure user: %v", err)
|
|
}
|
|
if p.gitName != "" {
|
|
_, err = e.store.UpdateUserGit(e.ctx, p.login, p.gitName, p.gitEmail)
|
|
if err != nil {
|
|
t.Fatalf("update user: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
options, _ := questions[0].Payload["options"].([]any)
|
|
e.respondAs(m, people[0].login, questions[0], api.Option, fmt.Sprint(options[0]), "", api.Answered)
|
|
e.waitIdle()
|
|
|
|
for _, p := range people[1:] {
|
|
_, err = m.Send(e.ctx, sess.Id, p.login, "Reply with the single word ok.", "")
|
|
if err != nil {
|
|
t.Fatalf("send as %s: %v", p.login, err)
|
|
}
|
|
e.waitIdle()
|
|
}
|
|
|
|
reports, err := e.store.ListInbox(e.ctx, store.InboxFilter{TaskID: e.task.Id, State: api.Open})
|
|
if err != nil || len(reports) == 0 {
|
|
t.Fatalf("want open reports to dismiss: %+v %v", reports, err)
|
|
}
|
|
e.respondAs(m, dismisser, reports[0], api.Dismiss, "", "", api.Dismissed)
|
|
|
|
coAuthors, err := m.CoAuthors(e.ctx, e.task.Id, login)
|
|
if err != nil {
|
|
t.Fatalf("co-authors: %v", err)
|
|
}
|
|
got := []string{}
|
|
for _, p := range coAuthors {
|
|
got = append(got, p.Login+"|"+p.GitName+"|"+p.GitEmail)
|
|
}
|
|
want := []string{}
|
|
for _, p := range people {
|
|
want = append(want, p.login+"|"+p.gitName+"|"+p.gitEmail)
|
|
}
|
|
if strings.Join(got, ",") != strings.Join(want, ",") {
|
|
t.Fatalf("co-authors %v, want %v", got, want)
|
|
}
|
|
|
|
_, err = m.Merge(e.ctx, e.task, e.project, e.user)
|
|
if err != nil {
|
|
t.Fatalf("merge: %v", err)
|
|
}
|
|
e.waitFor("task merged", func(ev api.Event) bool { return ev.Kind == "task.merged" })
|
|
|
|
message := gitOut(t, e.repo, "log", "-1", "--format=%B")
|
|
t.Logf("merge commit message:\n%s", message)
|
|
if author := gitOut(t, e.repo, "log", "-1", "--format=%an <%ae>|%cn <%ce>"); author != gitName+" <"+gitEmail+">|"+gitName+" <"+gitEmail+">" {
|
|
t.Fatalf("merge commit author|committer %s, want %s", author, gitName)
|
|
}
|
|
cmd := exec.Command("git", "interpret-trailers", "--parse")
|
|
cmd.Stdin = strings.NewReader(message)
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
t.Fatalf("interpret-trailers: %v", err)
|
|
}
|
|
trailers := strings.TrimSpace(string(out))
|
|
wantTrailers := "Co-authored-by: Carol Test <carol@byre.invalid>\nCo-authored-by: Bob Test <bob@byre.invalid>"
|
|
if trailers != wantTrailers {
|
|
t.Fatalf("trailers:\n%s\nwant:\n%s", trailers, wantTrailers)
|
|
}
|
|
}
|
|
|
|
func TestMergeFetchesMovedRemote(t *testing.T) {
|
|
t.Parallel()
|
|
e := newEnv(t)
|
|
m := e.manager()
|
|
|
|
_, err := m.Create(e.ctx, e.task, e.user, `Create a file task.txt containing exactly the word task.`, testModel)
|
|
if err != nil {
|
|
t.Fatalf("create session: %v", err)
|
|
}
|
|
e.waitIdle()
|
|
|
|
other := t.TempDir()
|
|
gitOut(t, other, "clone", "--quiet", e.remote, ".")
|
|
err = os.WriteFile(filepath.Join(other, "elsewhere.txt"), []byte("elsewhere\n"), 0o644)
|
|
if err != nil {
|
|
t.Fatalf("write elsewhere.txt: %v", err)
|
|
}
|
|
gitOut(t, other, "add", "elsewhere.txt")
|
|
gitOut(t, other, "commit", "--message", "pushed elsewhere")
|
|
gitOut(t, other, "push", "--quiet", "origin", "main")
|
|
moved := gitOut(t, other, "rev-parse", "HEAD")
|
|
|
|
_, err = m.Merge(e.ctx, e.task, e.project, e.user)
|
|
if err != nil {
|
|
t.Fatalf("merge: %v", err)
|
|
}
|
|
events := e.waitFor("task merged", func(ev api.Event) bool { return ev.Kind == "task.merged" })
|
|
|
|
for _, ev := range events {
|
|
if ev.Kind == "inbox.created" && ev.Payload["kind"] == string(api.SessionError) {
|
|
t.Fatalf("merge filed an error: %v", ev.Payload)
|
|
}
|
|
}
|
|
|
|
task, err := e.store.GetTask(e.ctx, e.task.Id)
|
|
if err != nil {
|
|
t.Fatalf("get task: %v", err)
|
|
}
|
|
if task.Status != api.TaskStatusMerged || task.MergeCommit == nil {
|
|
t.Fatalf("task after merge %+v", task)
|
|
}
|
|
for _, dir := range []string{e.repo, e.remote} {
|
|
if head := gitOut(t, dir, "rev-parse", "main"); head != *task.MergeCommit {
|
|
t.Fatalf("%s main at %s, want merge commit %s", dir, head, *task.MergeCommit)
|
|
}
|
|
}
|
|
if parent := gitOut(t, e.remote, "rev-parse", "main^"); parent != moved {
|
|
t.Fatalf("merge commit parent %s, want the moved remote head %s", parent, moved)
|
|
}
|
|
for _, f := range []string{"task.txt", "elsewhere.txt"} {
|
|
_, err = os.Stat(filepath.Join(e.repo, f))
|
|
if err != nil {
|
|
t.Fatalf("%s missing from main after merge: %v", f, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCreateTaskTool(t *testing.T) {
|
|
t.Parallel()
|
|
e := newEnv(t)
|
|
m := e.manager()
|
|
|
|
gitOut(t, e.task.WorktreePath, "-c", "user.name=x", "-c", "user.email=x@byre.invalid", "commit", "--allow-empty", "--message", "parent work")
|
|
parentHead := strings.TrimSpace(gitOut(t, e.task.WorktreePath, "rev-parse", "HEAD"))
|
|
|
|
_, err := m.Create(e.ctx, e.task, e.user, `Call the byre create_task tool once with title "child", base "this_task", and prompt "Reply with the single word ok." Then stop.`, testModel)
|
|
if err != nil {
|
|
t.Fatalf("create session: %v", err)
|
|
}
|
|
e.waitFor("child session", func(ev api.Event) bool {
|
|
return ev.Kind == "session.created" && ev.TaskId != nil && *ev.TaskId != e.task.Id
|
|
})
|
|
|
|
tasks, err := e.store.ListTasks(e.ctx, e.project.Id)
|
|
if err != nil {
|
|
t.Fatalf("list tasks: %v", err)
|
|
}
|
|
var child api.Task
|
|
for _, task := range tasks {
|
|
if task.Id != e.task.Id {
|
|
child = task
|
|
}
|
|
}
|
|
if child.Title != "child" || child.CreatedBy != login || filepath.Dir(child.WorktreePath) != filepath.Join(e.worktrees, e.project.Id) {
|
|
t.Fatalf("child task %+v", child)
|
|
}
|
|
|
|
sessions, err := e.store.ListSessions(e.ctx, child.Id)
|
|
if err != nil || len(sessions) != 1 || sessions[0].Model != testModel || sessions[0].CreatedBy != login {
|
|
t.Fatalf("child sessions %+v %v", sessions, err)
|
|
}
|
|
|
|
if child.BaseRef != parentHead {
|
|
t.Fatalf("child base %s, want the parent task's HEAD %s", child.BaseRef, parentHead)
|
|
}
|
|
}
|
|
|
|
func TestStartTodo(t *testing.T) {
|
|
t.Parallel()
|
|
e := newEnv(t)
|
|
m := e.manager()
|
|
|
|
writeTestFile(t, filepath.Join(e.repo, "TODO.md"), "# TODO\n\n- Create hello.txt\n It holds the single line `hi`.\n- Leave this one alone\n")
|
|
gitOut(t, e.repo, "add", "TODO.md")
|
|
gitOut(t, e.repo, "commit", "--quiet", "--message", "add todo")
|
|
|
|
lead := login + " (lead)"
|
|
_, err := m.StartTodo(e.ctx, e.project, e.user, "No such item", testModel, lead)
|
|
if !errors.Is(err, store.ErrNotFound) {
|
|
t.Fatalf("start unknown item: %v", err)
|
|
}
|
|
|
|
task, err := m.StartTodo(e.ctx, e.project, e.user, "Create hello.txt", testModel, lead)
|
|
if err != nil {
|
|
t.Fatalf("start todo: %v", err)
|
|
}
|
|
if task.TodoItem == nil || *task.TodoItem != "Create hello.txt" || task.Title != "Create hello.txt" {
|
|
t.Fatalf("todo task %+v", task)
|
|
}
|
|
|
|
_, err = m.StartTodo(e.ctx, e.project, e.user, "Create hello.txt", testModel, lead)
|
|
if !errors.Is(err, agents.ErrConflict) {
|
|
t.Fatalf("second start of the same item: %v", err)
|
|
}
|
|
|
|
events := e.waitFor("todo turn end", func(ev api.Event) bool {
|
|
return ev.Kind == "turn.ended" && ev.TaskId != nil && *ev.TaskId == task.Id
|
|
})
|
|
for _, ev := range events {
|
|
if ev.Kind == "task.created" && ev.TaskId != nil && *ev.TaskId == task.Id && (ev.Actor != lead || ev.Payload["created_by"] != login) {
|
|
t.Fatalf("task.created actor %q, created_by %v", ev.Actor, ev.Payload["created_by"])
|
|
}
|
|
if ev.Kind == "user.message" && ev.TaskId != nil && *ev.TaskId == task.Id {
|
|
text, _ := ev.Payload["text"].(string)
|
|
if !strings.Contains(text, "- Create hello.txt\n It holds the single line `hi`.") || !strings.Contains(text, "remove this item from TODO.md") {
|
|
t.Fatalf("first prompt %q", text)
|
|
}
|
|
}
|
|
}
|
|
|
|
stored, err := e.store.GetTask(e.ctx, task.Id)
|
|
if err != nil || stored.TodoItem == nil || *stored.TodoItem != "Create hello.txt" {
|
|
t.Fatalf("stored task %+v %v", stored, err)
|
|
}
|
|
|
|
todoAfter, err := os.ReadFile(filepath.Join(task.WorktreePath, "TODO.md"))
|
|
if err != nil {
|
|
t.Fatalf("read worktree TODO.md: %v", err)
|
|
}
|
|
t.Logf("TODO.md after the turn:\n%s", todoAfter)
|
|
if strings.Contains(string(todoAfter), "Create hello.txt") || !strings.Contains(string(todoAfter), "Leave this one alone") {
|
|
t.Fatalf("agent didn't remove exactly its item from TODO.md")
|
|
}
|
|
}
|
|
|
|
func writeTestFile(t *testing.T, path, content string) {
|
|
t.Helper()
|
|
err := os.WriteFile(path, []byte(content), 0o644)
|
|
if err != nil {
|
|
t.Fatalf("write %s: %v", path, err)
|
|
}
|
|
}
|
|
|
|
func TestQuestionReachesInbox(t *testing.T) {
|
|
t.Parallel()
|
|
e := newEnv(t)
|
|
m := e.manager()
|
|
|
|
sess, err := m.Create(e.ctx, e.task, e.user, `I want a file hi.txt that says either red or blue. Before you create anything, find out from me which one.`, testModel)
|
|
if err != nil {
|
|
t.Fatalf("create session: %v", err)
|
|
}
|
|
events := e.waitIdle()
|
|
|
|
questions := e.openQuestions(sess.Id)
|
|
if len(questions) == 0 {
|
|
t.Fatalf("the prose question never became an inbox question: %v", kinds(events))
|
|
}
|
|
}
|
|
|
|
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.Other, "", "Before I pick: which of the two did you recommend, and why?", api.Answered)
|
|
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)
|
|
}
|
|
|
|
e.respond(m, second, api.Other, "", "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)
|
|
}
|
|
}
|
|
|
|
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}}, e.worktrees, e.leads)
|
|
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 || len(usage.Running) != 1 || len(usage.Queued) != 1 || usage.Queued[0].TaskId != second.TaskId {
|
|
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))
|
|
}
|
|
}
|
|
|
|
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))
|
|
}
|