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
}
}
+116 -137
View File
@@ -209,7 +209,6 @@ const (
Dismiss InboxResponseType = "dismiss"
Option InboxResponseType = "option"
Other InboxResponseType = "other"
Snooze InboxResponseType = "snooze"
)
// Valid indicates whether the value is a known member of the InboxResponseType enum.
@@ -221,8 +220,6 @@ func (e InboxResponseType) Valid() bool {
return true
case Other:
return true
case Snooze:
return true
default:
return false
}
@@ -449,17 +446,16 @@ type InboxItem struct {
Id string `json:"id"`
Kind InboxItemKind `json:"kind"`
// Payload question: context, question, why_blocked, options, recommended_option, attachments. report: text (the final message of a turn that ended normally). session_error: error. rate_limited: vendor, rate_limit (the vendor's rate-limit status), error.
Payload map[string]interface{} `json:"payload"`
Priority InboxItemPriority `json:"priority"`
ProjectId string `json:"project_id"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
ResolvedBy *string `json:"resolved_by,omitempty"`
Responses []InboxResponse `json:"responses"`
SnoozedUntil *time.Time `json:"snoozed_until,omitempty"`
State InboxState `json:"state"`
TaskId string `json:"task_id"`
ThreadId string `json:"thread_id"`
// Payload question: context (Markdown with the choices inline as `- ( ) text` / `- (*) text` pick-one or `- [ ] text` / `- [x] text` pick-any list items), question, why_blocked, options (each choice's text, in order), multiple (true for pick-any), recommended (the marked choices' text), attachments. report: text (the final message of a turn that ended normally). session_error: error. rate_limited: vendor, rate_limit (the vendor's rate-limit status), error.
Payload map[string]interface{} `json:"payload"`
Priority InboxItemPriority `json:"priority"`
ProjectId string `json:"project_id"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
ResolvedBy *string `json:"resolved_by,omitempty"`
Responses []InboxResponse `json:"responses"`
State InboxState `json:"state"`
TaskId string `json:"task_id"`
ThreadId string `json:"thread_id"`
}
// InboxItemKind defines model for InboxItem.Kind.
@@ -480,8 +476,7 @@ type InboxResponse struct {
DeliveredAt *time.Time `json:"delivered_at,omitempty"`
Id string `json:"id"`
ItemId string `json:"item_id"`
Option *string `json:"option,omitempty"`
SnoozeUntil *time.Time `json:"snooze_until,omitempty"`
Options *[]string `json:"options,omitempty"`
Text *string `json:"text,omitempty"`
Type InboxResponseType `json:"type"`
User string `json:"user"`
@@ -492,11 +487,8 @@ type InboxResponseCreate struct {
Attachments *[]Attachment `json:"attachments,omitempty"`
ItemId string `json:"item_id"`
// Option option: one of the question's options.
Option *string `json:"option,omitempty"`
// SnoozeUntil snooze: when the item reappears.
SnoozeUntil *time.Time `json:"snooze_until,omitempty"`
// Options option: the chosen options; exactly one for a pick-one question, one or more for a pick-any question.
Options *[]string `json:"options,omitempty"`
// Text other: the text sent to the agent.
Text *string `json:"text,omitempty"`
@@ -756,10 +748,9 @@ type StreamEventsParams struct {
// ListInboxParams defines parameters for ListInbox.
type ListInboxParams struct {
ProjectId *string `form:"project_id,omitempty" json:"project_id,omitempty"`
TaskId *string `form:"task_id,omitempty" json:"task_id,omitempty"`
State *InboxState `form:"state,omitempty" json:"state,omitempty"`
IncludeSnoozed *bool `form:"include_snoozed,omitempty" json:"include_snoozed,omitempty"`
ProjectId *string `form:"project_id,omitempty" json:"project_id,omitempty"`
TaskId *string `form:"task_id,omitempty" json:"task_id,omitempty"`
State *InboxState `form:"state,omitempty" json:"state,omitempty"`
}
// RemovePushSubscriptionParams defines parameters for RemovePushSubscription.
@@ -1079,19 +1070,6 @@ func (siw *ServerInterfaceWrapper) ListInbox(w http.ResponseWriter, r *http.Requ
return
}
// ------------- Optional query parameter "include_snoozed" -------------
err = runtime.BindQueryParameterWithOptions("form", true, false, "include_snoozed", r.URL.Query(), &params.IncludeSnoozed, runtime.BindQueryParameterOptions{Type: "boolean", Format: ""})
if err != nil {
var requiredError *runtime.RequiredParameterError
if errors.As(err, &requiredError) {
siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "include_snoozed"})
} else {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "include_snoozed", Err: err})
}
return
}
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.ListInbox(w, r, params)
}))
@@ -2004,105 +1982,106 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
// const string: with thousands of chunks the chained `+` fold is several
// times slower for the Go compiler than parsing a slice literal.
var swaggerSpec = []string{
"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",
"Hcb+fbj9YfYvm3e6vf2fAAAA//8=",
"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",
"8ZDdxWGNsQ5j/z5c1zD5p8073d7+bwAAAP//",
}
// decodeSpec returns the embedded OpenAPI spec as raw JSON bytes,
+16 -13
View File
@@ -25,9 +25,6 @@ func (s *Server) ListInbox(w http.ResponseWriter, r *http.Request, params api.Li
if params.State != nil {
f.State = *params.State
}
if params.IncludeSnoozed != nil {
f.IncludeSnoozed = *params.IncludeSnoozed
}
items, err := s.store.ListInbox(r.Context(), f)
if err != nil {
@@ -97,10 +94,9 @@ func (s *Server) RespondInbox(w http.ResponseWriter, r *http.Request) {
ItemId: item.Id,
User: actor(r),
Type: create.Type,
Option: create.Option,
Options: create.Options,
Text: create.Text,
Attachments: attachments,
SnoozeUntil: create.SnoozeUntil,
CreatedAt: time.Now().UTC(),
},
})
@@ -155,8 +151,21 @@ func checkResponse(item api.InboxItem, c api.InboxResponseCreate) (api.InboxStat
if !question {
return bad("only questions have options")
}
if c.Option == nil || !hasOption(item, *c.Option) {
return bad("option must be one of the question's options")
if c.Options == nil || len(*c.Options) == 0 {
return bad("option needs the chosen options")
}
if item.Payload["multiple"] != true && len(*c.Options) != 1 {
return bad("a pick-one question takes exactly one option")
}
seen := map[string]bool{}
for _, o := range *c.Options {
if !hasOption(item, o) {
return bad("%q is not one of the question's options", o)
}
if seen[o] {
return bad("%q chosen twice", o)
}
seen[o] = true
}
if text != "" {
return bad("option takes no text; use other")
@@ -172,12 +181,6 @@ func checkResponse(item api.InboxItem, c api.InboxResponseCreate) (api.InboxStat
}
return api.Answered, nil
case api.Snooze:
if c.SnoozeUntil == nil || !c.SnoozeUntil.After(time.Now()) {
return bad("snooze needs a future snooze_until")
}
return api.Open, nil
case api.Dismiss:
if question {
return bad("questions need an option or other, not a dismissal")
+19 -10
View File
@@ -3,7 +3,6 @@ package server
import (
"net/http"
"testing"
"time"
"g.fc.run/misc/byre/internal/api"
)
@@ -15,14 +14,19 @@ func TestCheckResponse(t *testing.T) {
State: api.Open,
Payload: map[string]any{"options": []any{"red", "blue"}},
}
checklist := api.InboxItem{
Id: "q2",
Kind: api.Question,
State: api.Open,
Payload: map[string]any{"options": []any{"tests", "docs", "lint"}, "multiple": true},
}
report := api.InboxItem{Id: "r1", Kind: api.Report, State: api.Open, Payload: map[string]any{}}
failure := api.InboxItem{Id: "e1", Kind: api.SessionError, State: api.Open, Payload: map[string]any{}}
closed := question
closed.State = api.Answered
str := func(s string) *string { return &s }
future := time.Now().Add(time.Hour)
past := time.Now().Add(-time.Hour)
opts := func(s ...string) *[]string { return &s }
cases := []struct {
name string
@@ -31,20 +35,25 @@ func TestCheckResponse(t *testing.T) {
state api.InboxState
status int
}{
{"option", question, api.InboxResponseCreate{Type: api.Option, Option: str("blue")}, api.Answered, 0},
{"option with text", question, api.InboxResponseCreate{Type: api.Option, Option: str("red"), Text: str("but darker")}, "", http.StatusBadRequest},
{"unknown option", question, api.InboxResponseCreate{Type: api.Option, Option: str("green")}, "", http.StatusBadRequest},
{"option", question, api.InboxResponseCreate{Type: api.Option, Options: opts("blue")}, api.Answered, 0},
{"option with text", question, api.InboxResponseCreate{Type: api.Option, Options: opts("red"), Text: str("but darker")}, "", http.StatusBadRequest},
{"unknown option", question, api.InboxResponseCreate{Type: api.Option, Options: opts("green")}, "", http.StatusBadRequest},
{"missing option", question, api.InboxResponseCreate{Type: api.Option}, "", http.StatusBadRequest},
{"no options", question, api.InboxResponseCreate{Type: api.Option, Options: opts()}, "", http.StatusBadRequest},
{"two picks on pick-one", question, api.InboxResponseCreate{Type: api.Option, Options: opts("red", "blue")}, "", http.StatusBadRequest},
{"checked", checklist, api.InboxResponseCreate{Type: api.Option, Options: opts("tests", "lint")}, api.Answered, 0},
{"checked one", checklist, api.InboxResponseCreate{Type: api.Option, Options: opts("docs")}, api.Answered, 0},
{"checked twice", checklist, api.InboxResponseCreate{Type: api.Option, Options: opts("docs", "docs")}, "", http.StatusBadRequest},
{"checked unknown", checklist, api.InboxResponseCreate{Type: api.Option, Options: opts("docs", "deploy")}, "", http.StatusBadRequest},
{"other", question, api.InboxResponseCreate{Type: api.Other, Text: str("green")}, api.Answered, 0},
{"empty other", question, api.InboxResponseCreate{Type: api.Other, Text: str(" ")}, "", http.StatusBadRequest},
{"other on error", failure, api.InboxResponseCreate{Type: api.Other, Text: str("retry")}, api.Answered, 0},
{"other on report", report, api.InboxResponseCreate{Type: api.Other, Text: str("stop")}, "", http.StatusBadRequest},
{"snooze", question, api.InboxResponseCreate{Type: api.Snooze, SnoozeUntil: &future}, api.Open, 0},
{"snooze into past", question, api.InboxResponseCreate{Type: api.Snooze, SnoozeUntil: &past}, "", http.StatusBadRequest},
{"snooze", question, api.InboxResponseCreate{Type: "snooze"}, "", http.StatusBadRequest},
{"dismiss report", report, api.InboxResponseCreate{Type: api.Dismiss}, api.Dismissed, 0},
{"dismiss question", question, api.InboxResponseCreate{Type: api.Dismiss}, "", http.StatusBadRequest},
{"option on report", report, api.InboxResponseCreate{Type: api.Option, Option: str("red")}, "", http.StatusBadRequest},
{"already answered", closed, api.InboxResponseCreate{Type: api.Option, Option: str("red")}, "", http.StatusConflict},
{"option on report", report, api.InboxResponseCreate{Type: api.Option, Options: opts("red")}, "", http.StatusBadRequest},
{"already answered", closed, api.InboxResponseCreate{Type: api.Option, Options: opts("red")}, "", http.StatusConflict},
}
for _, c := range cases {
+33 -38
View File
@@ -88,11 +88,10 @@ func (s *Store) GetInboxItem(ctx context.Context, id string) (api.InboxItem, err
}
type InboxFilter struct {
ProjectID string
TaskID string
SessionID string
State api.InboxState
IncludeSnoozed bool
ProjectID string
TaskID string
SessionID string
State api.InboxState
}
func (s *Store) ListInbox(ctx context.Context, f InboxFilter) ([]api.InboxItem, error) {
@@ -111,10 +110,6 @@ func (s *Store) ListInbox(ctx context.Context, f InboxFilter) ([]api.InboxItem,
where = append(where, "agent_session_id = ?")
args = append(args, f.SessionID)
}
if !f.IncludeSnoozed {
where = append(where, "(snoozed_until IS NULL OR snoozed_until <= ?)")
args = append(args, now())
}
rows, err := s.db.QueryContext(ctx, `SELECT id FROM inbox_items WHERE `+strings.Join(where, " AND ")+` ORDER BY created_at`, args...)
if err != nil {
@@ -162,21 +157,26 @@ func (s *Store) Respond(ctx context.Context, actor string, resolutions []Resolut
return err
}
var options *string
if resp.Options != nil {
b, err := json.Marshal(*resp.Options)
if err != nil {
return err
}
s := string(b)
options = &s
}
_, err = tx.ExecContext(ctx, `
INSERT INTO inbox_responses (id, item_id, user_login, type, option, text, attachments, snooze_until, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, resp.Id, resp.ItemId, resp.User, string(resp.Type), resp.Option, resp.Text, string(attachments),
formatTimePtr(resp.SnoozeUntil), formatTime(resp.CreatedAt))
INSERT INTO inbox_responses (id, item_id, user_login, type, options, text, attachments, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, resp.Id, resp.ItemId, resp.User, string(resp.Type), options, resp.Text, string(attachments), formatTime(resp.CreatedAt))
if err != nil {
return err
}
if r.State == api.Open {
_, err = tx.ExecContext(ctx, `UPDATE inbox_items SET snoozed_until = ? WHERE id = ?`, formatTimePtr(resp.SnoozeUntil), resp.ItemId)
} else {
_, err = tx.ExecContext(ctx, `UPDATE inbox_items SET state = ?, resolved_by = ?, resolved_at = ?, snoozed_until = NULL WHERE id = ?`,
string(r.State), actor, now(), resp.ItemId)
}
_, err = tx.ExecContext(ctx, `UPDATE inbox_items SET state = ?, resolved_by = ?, resolved_at = ? WHERE id = ?`,
string(r.State), actor, now(), resp.ItemId)
if err != nil {
return err
}
@@ -194,7 +194,7 @@ var ErrNotOpen = errors.New("inbox item is not open")
func (s *Store) WithdrawItem(ctx context.Context, id string) error {
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
res, err := tx.ExecContext(ctx, `UPDATE inbox_items SET state = ?, resolved_at = ?, snoozed_until = NULL WHERE id = ? AND state = ?`,
res, err := tx.ExecContext(ctx, `UPDATE inbox_items SET state = ?, resolved_at = ? WHERE id = ? AND state = ?`,
string(api.Dismissed), now(), id, string(api.Open))
if err != nil {
return err
@@ -228,7 +228,7 @@ func dismissTaskInbox(ctx context.Context, tx *sql.Tx, emit emitFunc, taskID, ac
rows.Close()
for _, id := range ids {
_, err = tx.ExecContext(ctx, `UPDATE inbox_items SET state = ?, resolved_by = ?, resolved_at = ?, snoozed_until = NULL WHERE id = ?`,
_, err = tx.ExecContext(ctx, `UPDATE inbox_items SET state = ?, resolved_by = ?, resolved_at = ? WHERE id = ?`,
string(api.Dismissed), actor, now(), id)
if err != nil {
return err
@@ -324,16 +324,15 @@ func getItem(ctx context.Context, q querier, id string) (api.InboxItem, error) {
payload := ""
state := ""
createdAt := ""
snoozed := sql.NullString{}
resolvedBy := sql.NullString{}
resolvedAt := sql.NullString{}
err := q.QueryRowContext(ctx, `
SELECT id, project_id, task_id, agent_session_id, thread_id, kind, priority, blocking, payload, state,
snoozed_until, created_at, resolved_by, resolved_at
created_at, resolved_by, resolved_at
FROM inbox_items WHERE id = ?
`, id).Scan(&item.Id, &item.ProjectId, &item.TaskId, &item.AgentSessionId, &item.ThreadId, &kind, &priority, &item.Blocking,
&payload, &state, &snoozed, &createdAt, &resolvedBy, &resolvedAt)
&payload, &state, &createdAt, &resolvedBy, &resolvedAt)
if err != nil {
return item, err
}
@@ -349,10 +348,6 @@ func getItem(ctx context.Context, q querier, id string) (api.InboxItem, error) {
if err != nil {
return item, err
}
item.SnoozedUntil, err = parseTimePtr(snoozed)
if err != nil {
return item, err
}
item.ResolvedAt, err = parseTimePtr(resolvedAt)
if err != nil {
return item, err
@@ -367,7 +362,7 @@ func getItem(ctx context.Context, q querier, id string) (api.InboxItem, error) {
func getResponses(ctx context.Context, q querier, itemID string) ([]api.InboxResponse, error) {
rows, err := q.QueryContext(ctx, `
SELECT id, item_id, user_login, type, option, text, attachments, snooze_until, created_at, delivered_at
SELECT id, item_id, user_login, type, options, text, attachments, created_at, delivered_at
FROM inbox_responses WHERE item_id = ? ORDER BY created_at
`, itemID)
if err != nil {
@@ -379,21 +374,25 @@ func getResponses(ctx context.Context, q querier, itemID string) ([]api.InboxRes
for rows.Next() {
r := api.InboxResponse{}
typ := ""
option := sql.NullString{}
options := sql.NullString{}
text := sql.NullString{}
attachments := ""
snooze := sql.NullString{}
createdAt := ""
delivered := sql.NullString{}
err = rows.Scan(&r.Id, &r.ItemId, &r.User, &typ, &option, &text, &attachments, &snooze, &createdAt, &delivered)
err = rows.Scan(&r.Id, &r.ItemId, &r.User, &typ, &options, &text, &attachments, &createdAt, &delivered)
if err != nil {
return nil, err
}
r.Type = api.InboxResponseType(typ)
if option.Valid {
r.Option = &option.String
if options.Valid {
chosen := []string{}
err = json.Unmarshal([]byte(options.String), &chosen)
if err != nil {
return nil, err
}
r.Options = &chosen
}
if text.Valid {
r.Text = &text.String
@@ -402,10 +401,6 @@ func getResponses(ctx context.Context, q querier, itemID string) ([]api.InboxRes
if err != nil {
return nil, err
}
r.SnoozeUntil, err = parseTimePtr(snooze)
if err != nil {
return nil, err
}
r.CreatedAt, err = parseTime(createdAt)
if err != nil {
return nil, err
+53
View File
@@ -194,6 +194,59 @@ func TestBlobsMigrationOverPositionalCopy(t *testing.T) {
}
}
func TestInlineQuestionChoicesMigration(t *testing.T) {
db := openTestDB(t, filepath.Join(t.TempDir(), "byre.db"))
i := slices.IndexFunc(migrations, func(m migration) bool { return m.name == "inline-question-choices" })
err := migrate(db, migrations[:i])
if err != nil {
t.Fatalf("migrate: %v", err)
}
insertReport(t, db, "r1", `{"text":"x"}`)
_, err = db.Exec(`
INSERT INTO inbox_items (id, project_id, task_id, agent_session_id, thread_id, kind, priority, blocking, payload, state, created_at)
VALUES ('q1', 'p1', 't1', 's1', 'q1', 'question', 0, 1,
'{"context":"Pick a color.","question":"Which?","why_blocked":"w","options":["red","blue"],"recommended_option":"blue","attachments":[]}', 'answered', 'x');
INSERT INTO inbox_responses (id, item_id, user_login, type, option, text, attachments, created_at) VALUES ('a1', 'q1', 'alice', 'option', 'red', NULL, '[]', 'x');
INSERT INTO inbox_responses (id, item_id, user_login, type, option, text, attachments, created_at) VALUES ('a2', 'q1', 'alice', 'other', NULL, 'green', '[]', 'x');
`)
if err != nil {
t.Fatalf("insert question: %v", err)
}
err = migrate(db, migrations)
if err != nil {
t.Fatalf("migrate: %v", err)
}
payload := reportPayload(t, db, "q1")
want := `{"context":"Pick a color.\n\n- ( ) red\n- (*) blue","question":"Which?","why_blocked":"w","options":["red","blue"],"attachments":[],"multiple":false,"recommended":["blue"]}`
if payload != want {
t.Fatalf("question payload\n got %s\nwant %s", payload, want)
}
if reportPayload(t, db, "r1") != `{"text":"x"}` {
t.Fatalf("report payload changed")
}
options := map[string]sql.NullString{}
rows, err := db.Query(`SELECT id, options FROM inbox_responses`)
if err != nil {
t.Fatalf("responses: %v", err)
}
defer rows.Close()
for rows.Next() {
id := ""
o := sql.NullString{}
err = rows.Scan(&id, &o)
if err != nil {
t.Fatalf("scan: %v", err)
}
options[id] = o
}
if options["a1"].String != `["red"]` || options["a2"].Valid {
t.Fatalf("response options %+v", options)
}
}
func TestFlattenReportPayloadsRunsTwice(t *testing.T) {
db := openTestDB(t, filepath.Join(t.TempDir(), "byre.db"))
err := migrate(db, migrations)
+14
View File
@@ -192,6 +192,20 @@ CREATE TABLE snapshots (
created_at TEXT NOT NULL,
UNIQUE (task_id, n)
);
`}, {"inline-question-choices", `
ALTER TABLE inbox_responses ADD COLUMN options TEXT;
UPDATE inbox_responses SET options = json_array(option) WHERE option IS NOT NULL;
ALTER TABLE inbox_responses DROP COLUMN option;
UPDATE inbox_items SET payload = json_set(json_remove(payload, '$.recommended_option'),
'$.context', json_extract(payload, '$.context') || char(10) || char(10) || (
SELECT group_concat(CASE WHEN o.value = json_extract(inbox_items.payload, '$.recommended_option') THEN '- (*) ' ELSE '- ( ) ' END || o.value, char(10))
FROM json_each(inbox_items.payload, '$.options') o),
'$.multiple', json('false'),
'$.recommended', json_array(json_extract(payload, '$.recommended_option')))
WHERE kind = 'question' AND json_type(payload, '$.recommended_option') IS NOT NULL;
`}, {"unsnooze-inbox-items", `
UPDATE inbox_items SET snoozed_until = NULL WHERE snoozed_until IS NOT NULL;
`}}
type Store struct {