Add ask_human and the inbox with batched responses and delivery to agents
This commit is contained in:
@@ -296,6 +296,156 @@ 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".`
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func scratchRepo(t *testing.T) string {
|
||||
dir := t.TempDir()
|
||||
gitOut(t, dir, "init", "--initial-branch=main")
|
||||
|
||||
Reference in New Issue
Block a user