Add ask_human and the inbox with batched responses and delivery to agents

This commit is contained in:
Ian Gulliver
2026-10-14 07:02:56 -07:00
parent 09951e64e7
commit befd476598
16 changed files with 1962 additions and 129 deletions
+150
View File
@@ -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")