Bold questions

This commit is contained in:
Ian Gulliver
2026-10-14 19:35:34 -07:00
parent ab4c3d2c6f
commit 1ea055682b
54 changed files with 3696 additions and 762 deletions
+187 -16
View File
@@ -34,13 +34,15 @@ type env struct {
repo string
project api.Project
task api.Task
worktrees 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)}
e := &env{t: t, ctx: ctx, events: make(chan api.Event, 4096), worktrees: t.TempDir()}
var err error
e.store, err = store.Open(filepath.Join(t.TempDir(), "byre.db"), func(ev api.Event) { e.events <- ev })
@@ -96,7 +98,7 @@ func (e *env) addTask() api.Task {
}
func (e *env) manager() *agents.Manager {
m := agents.New(e.store, agents.DefaultLimits)
m := agents.New(e.store, agents.DefaultLimits, e.worktrees)
e.t.Cleanup(m.Close)
return m
}
@@ -147,7 +149,8 @@ func TestTurnAndFollowUp(t *testing.T) {
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)
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)
}
@@ -156,12 +159,29 @@ func TestTurnAndFollowUp(t *testing.T) {
}
events := e.waitIdle()
for _, kind := range []string{"session.created", "user.message", "agent.tool_call", "agent.tool_result", "agent.report", "turn.ended", "session.context"} {
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"])
}
}
window := 0
for _, ev := range events {
if ev.Kind == "session.context" {
@@ -172,32 +192,61 @@ func TestTurnAndFollowUp(t *testing.T) {
t.Fatalf("no context window reported after the turn ended")
}
log := gitOut(t, e.task.WorktreePath, "log", "-1", "--format=%an <%ae>")
if log != gitName+" <"+gitEmail+">" {
t.Fatalf("agent committed as %q", log)
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.")
_, 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 {
if ev.Kind == "agent.message" {
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")
_, 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")
_, err = m.Send(e.ctx, sess.Id, login, "too soon", "")
if err == nil {
t.Fatalf("send while running should conflict")
}
@@ -240,7 +289,7 @@ func TestInterrupt(t *testing.T) {
func TestRecoverMidTurn(t *testing.T) {
t.Parallel()
e := newEnv(t)
first := agents.New(e.store, agents.DefaultLimits)
first := agents.New(e.store, agents.DefaultLimits, e.worktrees)
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 {
@@ -309,7 +358,7 @@ func TestStopTask(t *testing.T) {
t.Fatalf("after stop: state %s, live %d", stored.State, m.LiveCount())
}
_, err = m.Send(e.ctx, sess.Id, login, "hello?")
_, err = m.Send(e.ctx, sess.Id, login, "hello?", "")
if err == nil {
t.Fatalf("send to completed session should conflict")
}
@@ -317,7 +366,7 @@ func TestStopTask(t *testing.T) {
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".`
`When you get my answer, write the file.`
func (e *env) openQuestions(sessionID string) []api.InboxItem {
e.t.Helper()
@@ -416,6 +465,128 @@ func TestAskHumanRoundTrip(t *testing.T) {
}
}
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)
}
}
}
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 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 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)
@@ -460,7 +631,7 @@ func TestFollowUpQuestionThread(t *testing.T) {
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}})
m := agents.New(e.store, agents.Limits{Global: 1, PerVendor: map[api.AgentSessionVendor]int{api.Claude: 1}}, e.worktrees)
t.Cleanup(m.Close)
first, err := m.Create(e.ctx, e.task, e.user, longCommandPrompt, testModel)
@@ -487,7 +658,7 @@ func TestConcurrencyQueue(t *testing.T) {
t.Fatalf("cancel queued turn: %+v %v", cancelled, err)
}
second, err = m.Send(e.ctx, second.Id, login, "Reply with the single word ok.")
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)
}