Bold questions
This commit is contained in:
+187
-16
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user