Inline question choices, compact one-click inbox cards, drop snooze

This commit is contained in:
Ian Gulliver
2026-10-14 23:34:39 -07:00
parent 4dc86e5479
commit c7cfad88a1
30 changed files with 1022 additions and 647 deletions
+46
View File
@@ -0,0 +1,46 @@
package agents
import (
"regexp"
"strings"
)
var (
choiceLine = regexp.MustCompile(`^\s*[-*+]\s+(\([ *]\)|\[[ xX]\])\s+(\S.*?)\s*$`)
choiceFence = regexp.MustCompile("^\\s*(```|~~~)")
)
type choice struct {
text string
checkbox bool
recommended bool
}
func parseChoices(markdown string) []choice {
choices := []choice{}
fence := ""
for _, line := range strings.Split(markdown, "\n") {
if f := choiceFence.FindStringSubmatch(line); f != nil {
switch fence {
case "":
fence = f[1]
case f[1]:
fence = ""
}
continue
}
if fence != "" {
continue
}
m := choiceLine.FindStringSubmatch(line)
if m == nil {
continue
}
choices = append(choices, choice{
text: m[2],
checkbox: m[1][0] == '[',
recommended: m[1][1] != ' ',
})
}
return choices
}
+33
View File
@@ -0,0 +1,33 @@
package agents
import (
"slices"
"testing"
)
func TestParseChoices(t *testing.T) {
src := "Background with a [link](https://example.com) and (parens).\n\n" +
"- ( ) **Merge now**: ships today\n" +
"* (*) Wait for review \n" +
" - [ ] nested checkbox\n" +
"+ [x] checked\n" +
"- [X] upper\n" +
"- plain item\n" +
"-( ) no space\n" +
"- ( )\n" +
"```\n- (*) fenced\n```\n" +
"~~~\n- ( ) tilde fenced\n~~~\n" +
"> - ( ) quoted\n"
got := parseChoices(src)
want := []choice{
{text: "**Merge now**: ships today"},
{text: "Wait for review", recommended: true},
{text: "nested checkbox", checkbox: true},
{text: "checked", checkbox: true, recommended: true},
{text: "upper", checkbox: true, recommended: true},
}
if !slices.Equal(got, want) {
t.Fatalf("parseChoices:\n got %+v\nwant %+v", got, want)
}
}
+3 -1
View File
@@ -11,11 +11,13 @@ import (
const guidance = `You are running inside byre. The human reads your work in byre's web UI, not a terminal.
- Ask every question with mcp__byre__ask_human, never in prose.
- End every turn with a short final message for the human: what you did, anything you departed from in the plan or instructions, risks, and what's next. byre shows it as the turn's summary in the transcript and the inbox. State every departure from the plan.
- In that final message and in ask_human context, put long supporting detail (logs, full lists, data) in collapsed sections the human expands on demand: a line <details><summary>Title</summary>, the Markdown content, then a line </details>.
- Don't commit. byre commits everything in the worktree at the end of each turn, using the turn's final message.
- Don't push, don't switch or touch other branches, and don't run git maintenance (gc, prune, worktree prune). byre handles merging and pushing.`
const proseQuestionNudge = "Your final message asks the human something in prose. Ask it with mcp__byre__ask_human " +
"(context, question, options, recommended_option, why_blocked), then end your turn with a short summary."
"(context with the choices inline as `- ( ) choice` / `- (*) recommended` or `- [ ] choice` / `- [x] recommended` list items, question, why_blocked), " +
"then end your turn with a short summary."
var (
fencedCode = regexp.MustCompile("(?s)```.*?```")
+61 -24
View File
@@ -21,14 +21,12 @@ var attachmentKinds = []api.AttachmentKind{
}
type askArgs struct {
Context string `json:"context"`
Question string `json:"question"`
WhyBlocked string `json:"why_blocked"`
Options []string `json:"options"`
RecommendedOption string `json:"recommended_option"`
Attachments []api.Attachment `json:"attachments"`
Blocking *bool `json:"blocking"`
ThreadID string `json:"thread_id"`
Context string `json:"context"`
Question string `json:"question"`
WhyBlocked string `json:"why_blocked"`
Attachments []api.Attachment `json:"attachments"`
Blocking *bool `json:"blocking"`
ThreadID string `json:"thread_id"`
}
func (m *Manager) askTool(l *live) mcp.Tool {
@@ -37,18 +35,20 @@ func (m *Manager) askTool(l *live) mcp.Tool {
Description: "Ask the human a question through their inbox. Use this for every question; never ask in plain prose. " +
"The human answers from their inbox without having read your transcript, so every question must stand on its own: " +
"put the background they need in context. " +
"Offer concrete options and recommend one. If blocking is true, end your turn after asking; " +
"Write the choices inline in context as Markdown list items, each with what it would concretely do: " +
"`- ( ) choice` for pick-one choices (mark the one you recommend `- (*) choice`), or " +
"`- [ ] choice` for pick-any choices (mark the ones you recommend `- [x] choice`). " +
"The human picks them where they appear; there is no separate list of options. " +
"If blocking is true, end your turn after asking; " +
"the answer arrives as your next message. If blocking is false, continue with work that doesn't depend on the answer. " +
"Ask several questions in one turn by calling this once per question; they are answered together. " +
"To answer a follow-up question from the human, call this again with the thread_id they gave you.",
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {
"context": {"type": "string", "description": "Everything the human needs to answer well without reading your transcript: what you are working on, what you found, the relevant facts and data, and what each option would concretely do. Several paragraphs is normal. Markdown is rendered."},
"context": {"type": "string", "description": "Everything the human needs to answer well without reading your transcript: what you are working on, what you found, the relevant facts and data, and the choices. Several paragraphs is normal. Markdown is rendered. Each choice is a list item on its own line, at least two, all of one kind: '- ( ) Merge now: ships the fix today' / '- (*) Wait for review: ...' (pick one; exactly one (*) marks your recommendation), or '- [ ] Fix the tests' / '- [x] Update the docs' (pick any; [x] marks the ones you recommend). The choice's text is the whole line after the marker, and it comes back verbatim as the answer. Put long supporting detail (logs, full data, per-option analysis) in collapsed sections the human expands on demand: a line '<details><summary>Title</summary>', the Markdown content, then a line '</details>'."},
"question": {"type": "string", "description": "The question itself, in one or two sentences. Markdown is rendered."},
"why_blocked": {"type": "string", "description": "What you can't do until this is answered, and why you can't decide it yourself."},
"options": {"type": "array", "items": {"type": "string"}, "minItems": 2},
"recommended_option": {"type": "string", "description": "One of options."},
"attachments": {"type": "array", "items": {
"type": "object",
"properties": {
@@ -68,7 +68,7 @@ func (m *Manager) askTool(l *live) mcp.Tool {
"blocking": {"type": "boolean"},
"thread_id": {"type": "string"}
},
"required": ["context", "question", "why_blocked", "options", "recommended_option", "blocking"]
"required": ["context", "question", "why_blocked", "blocking"]
}`),
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
return m.ask(ctx, l, raw)
@@ -104,13 +104,24 @@ func (m *Manager) ask(ctx context.Context, l *live, raw json.RawMessage) (string
attachments = []api.Attachment{}
}
choices := parseChoices(args.Context)
options := []string{}
recommended := []string{}
for _, c := range choices {
options = append(options, c.text)
if c.recommended {
recommended = append(recommended, c.text)
}
}
payload := map[string]any{
"context": args.Context,
"question": args.Question,
"why_blocked": args.WhyBlocked,
"options": args.Options,
"recommended_option": args.RecommendedOption,
"attachments": attachments,
"context": args.Context,
"question": args.Question,
"why_blocked": args.WhyBlocked,
"options": options,
"multiple": choices[0].checkbox,
"recommended": recommended,
"attachments": attachments,
}
err = m.store.CreateInboxItem(ctx, m.newItem(l, id, thread, api.Question, priority, *args.Blocking, store.ToPayload(payload)))
@@ -193,11 +204,30 @@ func (m *Manager) validateAsk(ctx context.Context, l *live, args askArgs) error
if strings.TrimSpace(args.WhyBlocked) == "" {
problems = append(problems, "why_blocked is empty: say what you can't do until this is answered and why you can't decide it yourself")
}
if len(args.Options) < 2 {
problems = append(problems, "options needs at least two concrete choices")
choices := parseChoices(args.Context)
if len(choices) < 2 {
problems = append(problems, "context needs at least two choices, each a list item on its own line: `- ( ) choice` (pick one) or `- [ ] choice` (pick any)")
}
if !slices.Contains(args.Options, args.RecommendedOption) {
problems = append(problems, "recommended_option must be exactly one of options")
seen := map[string]bool{}
radios := 0
recommendedRadios := 0
for _, c := range choices {
if seen[c.text] {
problems = append(problems, fmt.Sprintf("choice %q appears twice; each choice's text must be unique", c.text))
}
seen[c.text] = true
if !c.checkbox {
radios++
if c.recommended {
recommendedRadios++
}
}
}
if radios > 0 && radios < len(choices) {
problems = append(problems, "choices mix `( )` and `[ ]`: use one kind per question")
}
if radios == len(choices) && len(choices) > 0 && recommendedRadios != 1 {
problems = append(problems, "mark exactly one pick-one choice as recommended with `- (*) choice`")
}
if args.Blocking == nil {
problems = append(problems, "blocking is required")
@@ -337,7 +367,14 @@ func deliveryMessage(pending []store.Pending) string {
switch r.Type {
case api.Option:
fmt.Fprintf(b, "%s chose: %s\n", r.User, *r.Option)
if p.Item.Payload["multiple"] == true {
fmt.Fprintf(b, "%s checked:\n", r.User)
for _, o := range *r.Options {
fmt.Fprintf(b, "- %s\n", o)
}
} else {
fmt.Fprintf(b, "%s chose: %s\n", r.User, (*r.Options)[0])
}
case api.Other:
if p.Item.Kind == api.Question {
fmt.Fprintf(b, "%s answered (none of your options): %s\n", r.User, *r.Text)
+30 -9
View File
@@ -3,6 +3,7 @@ package agents
import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"testing"
@@ -17,17 +18,29 @@ func TestValidateAsk(t *testing.T) {
l := &live{id: "s1"}
yes := true
ask := func(context string) askArgs {
return askArgs{Context: context, Question: "q", WhyBlocked: "w", Blocking: &yes}
}
withAttachment := ask("- (*) a\n- ( ) b")
withAttachment.Attachments = []api.Attachment{{Kind: "video"}}
cases := []struct {
name string
args askArgs
want []string
}{
{"valid", askArgs{Context: "c", Question: "q", WhyBlocked: "w", Options: []string{"a", "b"}, RecommendedOption: "a", Blocking: &yes}, nil},
{"empty", askArgs{}, []string{"context is empty", "question is empty", "why_blocked is empty", "at least two", "recommended_option", "blocking is required"}},
{"one option", askArgs{Context: "c", Question: "q", WhyBlocked: "w", Options: []string{"a"}, RecommendedOption: "a", Blocking: &yes}, []string{"at least two"}},
{"bad recommendation", askArgs{Context: "c", Question: "q", WhyBlocked: "w", Options: []string{"a", "b"}, RecommendedOption: "c", Blocking: &yes}, []string{"recommended_option"}},
{"bad attachment", askArgs{Context: "c", Question: "q", WhyBlocked: "w", Options: []string{"a", "b"}, RecommendedOption: "a", Blocking: &yes,
Attachments: []api.Attachment{{Kind: "video"}}}, []string{`attachments[0].kind "video"`}},
{"pick one", ask("Background.\n\n- (*) a: does a\n- ( ) b: does b"), nil},
{"pick any", ask("- [ ] a\n- [x] b\n- [X] c"), nil},
{"pick any, none recommended", ask("- [ ] a\n- [ ] b"), nil},
{"empty", askArgs{}, []string{"context is empty", "question is empty", "why_blocked is empty", "at least two choices", "blocking is required"}},
{"plain list", ask("- a\n- b"), []string{"at least two choices"}},
{"one choice", ask("- (*) a"), []string{"at least two choices"}},
{"in a code fence", ask("```\n- (*) a\n- ( ) b\n```"), []string{"at least two choices"}},
{"no recommendation", ask("- ( ) a\n- ( ) b"), []string{"exactly one"}},
{"two recommendations", ask("- (*) a\n- (*) b"), []string{"exactly one"}},
{"mixed", ask("- (*) a\n- [ ] b"), []string{"mix"}},
{"duplicate", ask("- (*) a\n- ( ) a"), []string{`"a" appears twice`}},
{"bad attachment", withAttachment, []string{`attachments[0].kind "video"`}},
}
for _, c := range cases {
@@ -85,7 +98,7 @@ func TestCancelQuestion(t *testing.T) {
other := &live{id: "s2", dir: t.TempDir(), vendor: api.Claude, taskID: "t1", projectID: "p1"}
yes := true
raw, err := json.Marshal(askArgs{Context: "c", Question: "Red or blue?", WhyBlocked: "w", Options: []string{"red", "blue"}, RecommendedOption: "red", Blocking: &yes})
raw, err := json.Marshal(askArgs{Context: "- (*) red\n- ( ) blue", Question: "Red or blue?", WhyBlocked: "w", Blocking: &yes})
if err != nil {
t.Fatalf("marshal: %v", err)
}
@@ -98,6 +111,10 @@ func TestCancelQuestion(t *testing.T) {
t.Fatalf("inbox after ask: %+v %v", items, err)
}
id := items[0].Id
payload := items[0].Payload
if fmt.Sprint(payload["options"]) != "[red blue]" || payload["multiple"] != false || fmt.Sprint(payload["recommended"]) != "[red]" {
t.Fatalf("choices not parsed from context: %+v", payload)
}
if !strings.Contains(out, "question_id: "+id) || !strings.Contains(out, "cancel_question") {
t.Fatalf("ask result doesn't give the id and cancel_question: %s", out)
}
@@ -195,11 +212,14 @@ func TestBounceProseQuestion(t *testing.T) {
func TestDeliveryMessage(t *testing.T) {
str := func(s string) *string { return &s }
question := api.InboxItem{Id: "q1", ThreadId: "q1", Kind: api.Question, Payload: map[string]any{"question": "Which color?"}}
opts := func(s ...string) *[]string { return &s }
question := api.InboxItem{Id: "q1", ThreadId: "q1", Kind: api.Question, Payload: map[string]any{"question": "Which color?", "multiple": false}}
checklist := api.InboxItem{Id: "q2", ThreadId: "q2", Kind: api.Question, Payload: map[string]any{"question": "Which chores?", "multiple": true}}
failure := api.InboxItem{Id: "e1", ThreadId: "e1", Kind: api.SessionError, Payload: map[string]any{"error": "process died"}}
msg := deliveryMessage([]store.Pending{
{Item: question, Response: api.InboxResponse{User: "alice", Type: api.Option, Option: str("blue")}},
{Item: question, Response: api.InboxResponse{User: "alice", Type: api.Option, Options: opts("blue")}},
{Item: checklist, Response: api.InboxResponse{User: "alice", Type: api.Option, Options: opts("tests", "docs")}},
{Item: question, Response: api.InboxResponse{User: "bob", Type: api.Other, Text: str("what is it for?"),
Attachments: []api.Attachment{{Kind: api.AttachmentKindUrl, Url: str("https://example.com")}}}},
{Item: failure, Response: api.InboxResponse{User: "alice", Type: api.Other, Text: str("stop and write tests")}},
@@ -208,6 +228,7 @@ func TestDeliveryMessage(t *testing.T) {
for _, want := range []string{
"Your question q1: Which color?",
"alice chose: blue",
"Your question q2: Which chores?\nalice checked:\n- tests\n- docs\n",
"bob answered (none of your options): what is it for?",
`calling ask_human with thread_id "q1"`,
`"url":"https://example.com"`,
+12 -4
View File
@@ -417,7 +417,7 @@ func (e *env) respond(m *agents.Manager, item api.InboxItem, typ api.InboxRespon
CreatedAt: time.Now().UTC(),
}
if option != "" {
resp.Option = &option
resp.Options = &[]string{option}
}
if text != "" {
resp.Text = &text
@@ -449,9 +449,17 @@ func TestAskHumanRoundTrip(t *testing.T) {
t.Fatalf("want one open question, got %+v", questions)
}
q := questions[0]
if !q.Blocking || q.Priority != api.Blocked || q.Payload["recommended_option"] != "red" {
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")
}
@@ -461,12 +469,12 @@ func TestAskHumanRoundTrip(t *testing.T) {
t.Fatalf("awaiting human: %d %v", waiting, err)
}
e.respond(m, q, api.Option, "blue", "", api.Answered)
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") {
if ev.Kind == "user.message" && strings.Contains(ev.Payload["text"].(string), "chose: "+blue) {
delivered = true
}
}