Render Markdown, add ask_human context, simplify inbox responses, group inbox by task

This commit is contained in:
Ian Gulliver
2026-10-14 10:37:04 -07:00
parent e9fd7de556
commit ca2644374d
25 changed files with 950 additions and 283 deletions
+25 -19
View File
@@ -22,6 +22,7 @@ var attachmentKinds = []api.AttachmentKind{
}
type askArgs struct {
Context string `json:"context"`
Question string `json:"question"`
WhyBlocked string `json:"why_blocked"`
Options []string `json:"options"`
@@ -35,6 +36,8 @@ func (m *Manager) askTool(l *live) mcp.Tool {
return mcp.Tool{
Name: "ask_human",
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, call report and 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. " +
@@ -42,7 +45,8 @@ func (m *Manager) askTool(l *live) mcp.Tool {
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {
"question": {"type": "string"},
"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."},
"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."},
@@ -65,7 +69,7 @@ func (m *Manager) askTool(l *live) mcp.Tool {
"blocking": {"type": "boolean"},
"thread_id": {"type": "string"}
},
"required": ["question", "why_blocked", "options", "recommended_option", "blocking"]
"required": ["context", "question", "why_blocked", "options", "recommended_option", "blocking"]
}`),
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
return m.ask(ctx, l, raw)
@@ -102,6 +106,7 @@ func (m *Manager) ask(ctx context.Context, l *live, raw json.RawMessage) (string
}
payload := map[string]any{
"context": args.Context,
"question": args.Question,
"why_blocked": args.WhyBlocked,
"options": args.Options,
@@ -109,7 +114,7 @@ func (m *Manager) ask(ctx context.Context, l *live, raw json.RawMessage) (string
"attachments": attachments,
}
err = m.store.CreateInboxItem(ctx, m.newItem(l, id, thread, api.InboxItemKindQuestion, priority, *args.Blocking, store.ToPayload(payload)))
err = m.store.CreateInboxItem(ctx, m.newItem(l, id, thread, api.Question, priority, *args.Blocking, store.ToPayload(payload)))
if err != nil {
return "", err
}
@@ -123,6 +128,9 @@ func (m *Manager) ask(ctx context.Context, l *live, raw json.RawMessage) (string
func (m *Manager) validateAsk(ctx context.Context, l *live, args askArgs) error {
problems := []string{}
if strings.TrimSpace(args.Context) == "" {
problems = append(problems, "context is empty: give the background the human needs to answer without reading your transcript")
}
if strings.TrimSpace(args.Question) == "" {
problems = append(problems, "question is empty")
}
@@ -164,7 +172,7 @@ func (m *Manager) validateAsk(ctx context.Context, l *live, args askArgs) error
func (m *Manager) fileReport(l *live, r report.Report) {
id := store.NewID()
err := m.store.CreateInboxItem(context.Background(), m.newItem(l, id, id, api.InboxItemKindReport, api.Fyi, false, store.ToPayload(r)))
err := m.store.CreateInboxItem(context.Background(), m.newItem(l, id, id, api.Report, api.Fyi, false, store.ToPayload(r)))
if err != nil {
slog.Error("file report", "session", l.id, "err", err)
}
@@ -172,7 +180,7 @@ func (m *Manager) fileReport(l *live, r report.Report) {
func (m *Manager) fileError(l *live, msg string) {
id := store.NewID()
err := m.store.CreateInboxItem(context.Background(), m.newItem(l, id, id, api.InboxItemKindSessionError, api.Blocked, true, map[string]any{"error": msg}))
err := m.store.CreateInboxItem(context.Background(), m.newItem(l, id, id, api.SessionError, api.Blocked, true, map[string]any{"error": msg}))
if err != nil {
slog.Error("file session error", "session", l.id, "err", err)
}
@@ -181,7 +189,7 @@ func (m *Manager) fileError(l *live, msg string) {
func (m *Manager) fileRateLimited(l *live, info map[string]any, failure string) {
id := store.NewID()
payload := map[string]any{"vendor": string(l.vendor), "rate_limit": info, "error": failure}
err := m.store.CreateInboxItem(context.Background(), m.newItem(l, id, id, api.InboxItemKindRateLimited, api.Blocked, true, payload))
err := m.store.CreateInboxItem(context.Background(), m.newItem(l, id, id, api.RateLimited, api.Blocked, true, payload))
if err != nil {
slog.Error("file rate limit", "session", l.id, "err", err)
}
@@ -261,28 +269,26 @@ func deliveryMessage(pending []store.Pending) string {
b.WriteString("\n")
switch p.Item.Kind {
case api.InboxItemKindQuestion:
case api.Question:
fmt.Fprintf(b, "Your question %s: %s\n", p.Item.Id, p.Item.Payload["question"])
case api.InboxItemKindReport:
case api.Report:
fmt.Fprintf(b, "Your report %s\n", p.Item.Id)
case api.InboxItemKindSessionError:
case api.SessionError:
fmt.Fprintf(b, "Your error %s: %s\n", p.Item.Id, p.Item.Payload["error"])
case api.InboxItemKindRateLimited:
case api.RateLimited:
fmt.Fprintf(b, "Your rate-limited turn %s: %s\n", p.Item.Id, p.Item.Payload["error"])
}
switch r.Type {
case api.InboxResponseTypeOption:
case api.Option:
fmt.Fprintf(b, "%s chose: %s\n", r.User, *r.Option)
if r.Text != nil && *r.Text != "" {
fmt.Fprintf(b, "Note: %s\n", *r.Text)
case api.Other:
if p.Item.Kind == api.Question {
fmt.Fprintf(b, "%s answered (none of your options): %s\n", r.User, *r.Text)
fmt.Fprintf(b, "If this asks you something back, answer by calling ask_human with thread_id %q.\n", p.Item.ThreadId)
} else {
fmt.Fprintf(b, "%s replied: %s\n", r.User, *r.Text)
}
case api.InboxResponseTypeFreeForm:
fmt.Fprintf(b, "%s answered (none of your options): %s\n", r.User, *r.Text)
case api.InboxResponseTypeRedirect:
fmt.Fprintf(b, "%s redirected you. These instructions replace your current direction: %s\n", r.User, *r.Text)
case api.InboxResponseTypeQuestion:
fmt.Fprintf(b, "%s asked a follow-up question: %s\nAnswer it by calling ask_human with thread_id %q.\n", r.User, *r.Text, p.Item.ThreadId)
}
for _, a := range r.Attachments {
+13 -14
View File
@@ -19,11 +19,11 @@ func TestValidateAsk(t *testing.T) {
args askArgs
want []string
}{
{"valid", askArgs{Question: "q", WhyBlocked: "w", Options: []string{"a", "b"}, RecommendedOption: "a", Blocking: &yes}, nil},
{"empty", askArgs{}, []string{"question is empty", "why_blocked is empty", "at least two", "recommended_option", "blocking is required"}},
{"one option", askArgs{Question: "q", WhyBlocked: "w", Options: []string{"a"}, RecommendedOption: "a", Blocking: &yes}, []string{"at least two"}},
{"bad recommendation", askArgs{Question: "q", WhyBlocked: "w", Options: []string{"a", "b"}, RecommendedOption: "c", Blocking: &yes}, []string{"recommended_option"}},
{"bad attachment", askArgs{Question: "q", WhyBlocked: "w", Options: []string{"a", "b"}, RecommendedOption: "a", Blocking: &yes,
{"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"`}},
}
@@ -53,25 +53,24 @@ func TestValidateAsk(t *testing.T) {
func TestDeliveryMessage(t *testing.T) {
str := func(s string) *string { return &s }
question := api.InboxItem{Id: "q1", ThreadId: "q1", Kind: api.InboxItemKindQuestion, Payload: map[string]any{"question": "Which color?"}}
report := api.InboxItem{Id: "r1", ThreadId: "r1", Kind: api.InboxItemKindReport, Payload: map[string]any{}}
question := api.InboxItem{Id: "q1", ThreadId: "q1", Kind: api.Question, Payload: map[string]any{"question": "Which color?"}}
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.InboxResponseTypeOption, Option: str("blue"), Text: str("navy if possible")}},
{Item: question, Response: api.InboxResponse{User: "bob", Type: api.InboxResponseTypeQuestion, Text: str("what is it for?"),
{Item: question, Response: api.InboxResponse{User: "alice", Type: api.Option, Option: str("blue")}},
{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: report, Response: api.InboxResponse{User: "alice", Type: api.InboxResponseTypeRedirect, Text: str("stop and write tests")}},
{Item: failure, Response: api.InboxResponse{User: "alice", Type: api.Other, Text: str("stop and write tests")}},
})
for _, want := range []string{
"Your question q1: Which color?",
"alice chose: blue",
"Note: navy if possible",
"bob asked a follow-up question: what is it for?",
"bob answered (none of your options): what is it for?",
`calling ask_human with thread_id "q1"`,
`"url":"https://example.com"`,
"Your report r1",
"alice redirected you. These instructions replace your current direction: stop and write tests",
"Your error e1: process died",
"alice replied: stop and write tests",
} {
if !strings.Contains(msg, want) {
t.Fatalf("message missing %q:\n%s", want, msg)
+4 -12
View File
@@ -317,7 +317,7 @@ func (e *env) openQuestions(sessionID string) []api.InboxItem {
}
questions := []api.InboxItem{}
for _, item := range items {
if item.Kind == api.InboxItemKindQuestion {
if item.Kind == api.Question {
questions = append(questions, item)
}
}
@@ -379,7 +379,7 @@ func TestAskHumanRoundTrip(t *testing.T) {
t.Fatalf("awaiting human: %d %v", waiting, err)
}
e.respond(m, q, api.InboxResponseTypeOption, "blue", "", api.Answered)
e.respond(m, q, api.Option, "blue", "", api.Answered)
events := e.waitIdle()
delivered := false
@@ -423,7 +423,7 @@ func TestFollowUpQuestionThread(t *testing.T) {
}
first := questions[0]
e.respond(m, first, api.InboxResponseTypeQuestion, "", "Before I pick: which of the two did you recommend, and why?", api.Open)
e.respond(m, first, api.Other, "", "Before I pick: which of the two did you recommend, and why?", api.Answered)
e.waitIdle()
questions = e.openQuestions(sess.Id)
@@ -435,15 +435,7 @@ func TestFollowUpQuestionThread(t *testing.T) {
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.respond(m, second, api.Other, "", "Use green instead of either.", api.Answered)
e.waitIdle()
out, err := os.ReadFile(filepath.Join(e.task.WorktreePath, "color.txt"))
+1 -1
View File
@@ -68,7 +68,7 @@ func TestRateLimitedTurn(t *testing.T) {
if err != nil {
t.Fatalf("list inbox: %v", err)
}
if len(items) != 1 || items[0].Kind != api.InboxItemKindRateLimited || items[0].Priority != api.Blocked {
if len(items) != 1 || items[0].Kind != api.RateLimited || items[0].Priority != api.Blocked {
t.Fatalf("want one blocked rate_limited item, got %+v", items)
}
limit, ok := items[0].Payload["rate_limit"].(map[string]any)
+66 -75
View File
@@ -110,22 +110,22 @@ func (e AttachmentKind) Valid() bool {
// Defines values for InboxItemKind.
const (
InboxItemKindQuestion InboxItemKind = "question"
InboxItemKindRateLimited InboxItemKind = "rate_limited"
InboxItemKindReport InboxItemKind = "report"
InboxItemKindSessionError InboxItemKind = "session_error"
Question InboxItemKind = "question"
RateLimited InboxItemKind = "rate_limited"
Report InboxItemKind = "report"
SessionError InboxItemKind = "session_error"
)
// Valid indicates whether the value is a known member of the InboxItemKind enum.
func (e InboxItemKind) Valid() bool {
switch e {
case InboxItemKindQuestion:
case Question:
return true
case InboxItemKindRateLimited:
case RateLimited:
return true
case InboxItemKindReport:
case Report:
return true
case InboxItemKindSessionError:
case SessionError:
return true
default:
return false
@@ -158,28 +158,22 @@ func (e InboxItemPriority) Valid() bool {
// Defines values for InboxResponseType.
const (
InboxResponseTypeDismiss InboxResponseType = "dismiss"
InboxResponseTypeFreeForm InboxResponseType = "free_form"
InboxResponseTypeOption InboxResponseType = "option"
InboxResponseTypeQuestion InboxResponseType = "question"
InboxResponseTypeRedirect InboxResponseType = "redirect"
InboxResponseTypeSnooze InboxResponseType = "snooze"
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.
func (e InboxResponseType) Valid() bool {
switch e {
case InboxResponseTypeDismiss:
case Dismiss:
return true
case InboxResponseTypeFreeForm:
case Option:
return true
case InboxResponseTypeOption:
case Other:
return true
case InboxResponseTypeQuestion:
return true
case InboxResponseTypeRedirect:
return true
case InboxResponseTypeSnooze:
case Snooze:
return true
default:
return false
@@ -191,7 +185,6 @@ const (
Answered InboxState = "answered"
Dismissed InboxState = "dismissed"
Open InboxState = "open"
Redirected InboxState = "redirected"
Superseded InboxState = "superseded"
)
@@ -204,8 +197,6 @@ func (e InboxState) Valid() bool {
return true
case Open:
return true
case Redirected:
return true
case Superseded:
return true
default:
@@ -308,7 +299,7 @@ type InboxItem struct {
Id string `json:"id"`
Kind InboxItemKind `json:"kind"`
// Payload question: question, why_blocked, options, recommended_option, attachments. report: done, open_questions, next_steps, risks, deviations. session_error: error. rate_limited: vendor, rate_limit (the vendor's rate-limit status), error.
// Payload question: context, question, why_blocked, options, recommended_option, attachments. report: done, next_steps, risks, deviations. 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"`
@@ -357,7 +348,7 @@ type InboxResponseCreate struct {
// SnoozeUntil snooze: when the item reappears.
SnoozeUntil *time.Time `json:"snooze_until,omitempty"`
// Text Required for free_form, redirect, question; an optional note on option.
// Text other: the text sent to the agent.
Text *string `json:"text,omitempty"`
Type InboxResponseType `json:"type"`
}
@@ -1261,54 +1252,54 @@ 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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",
"L6a82mz+HwAA//8=",
}
// decodeSpec returns the embedded OpenAPI spec as raw JSON bytes,
+4 -6
View File
@@ -14,11 +14,10 @@ const maxNudges = 3
const nudge = "You must call the report tool (mcp__byre__report) before ending your turn."
type Report struct {
Done []string `json:"done"`
OpenQuestions []string `json:"open_questions"`
NextSteps []string `json:"next_steps"`
Risks []string `json:"risks"`
Deviations []string `json:"deviations"`
Done []string `json:"done"`
NextSteps []string `json:"next_steps"`
Risks []string `json:"risks"`
Deviations []string `json:"deviations"`
}
type Gate struct {
@@ -50,7 +49,6 @@ func (g *Gate) Tool() mcp.Tool {
"type": "object",
"properties": {
"done": {"type": "array", "items": {"type": "string"}},
"open_questions": {"type": "array", "items": {"type": "string"}},
"next_steps": {"type": "array", "items": {"type": "string"}},
"risks": {"type": "array", "items": {"type": "string"}},
"deviations": {"type": "array", "items": {"type": "string"}}
+12 -24
View File
@@ -145,54 +145,42 @@ func checkResponse(item api.InboxItem, c api.InboxResponseCreate) (api.InboxStat
if c.Text != nil {
text = strings.TrimSpace(*c.Text)
}
question := item.Kind == api.InboxItemKindQuestion
question := item.Kind == api.Question
bad := func(format string, args ...any) (api.InboxState, *httpError) {
return "", &httpError{http.StatusBadRequest, fmt.Errorf("item %s: "+format, append([]any{item.Id}, args...)...)}
}
switch c.Type {
case api.InboxResponseTypeOption:
case api.Option:
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")
}
return api.Answered, nil
case api.InboxResponseTypeFreeForm:
if !question {
return bad("only questions take answers")
}
if text == "" {
return bad("free_form needs text")
if text != "" {
return bad("option takes no text; use other")
}
return api.Answered, nil
case api.InboxResponseTypeQuestion:
if !question {
return bad("only questions take follow-up questions")
case api.Other:
if item.Kind == api.Report {
return bad("reports are only dismissed")
}
if text == "" {
return bad("question needs text")
return bad("other needs text")
}
return api.Open, nil
return api.Answered, nil
case api.InboxResponseTypeRedirect:
if text == "" {
return bad("redirect needs text")
}
return api.Redirected, nil
case api.InboxResponseTypeSnooze:
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.InboxResponseTypeDismiss:
case api.Dismiss:
if question {
return bad("questions need an answer or a redirect, not a dismissal")
return bad("questions need an option or other, not a dismissal")
}
return api.Dismissed, nil
}
+17 -17
View File
@@ -11,11 +11,12 @@ import (
func TestCheckResponse(t *testing.T) {
question := api.InboxItem{
Id: "q1",
Kind: api.InboxItemKindQuestion,
Kind: api.Question,
State: api.Open,
Payload: map[string]any{"options": []any{"red", "blue"}},
}
report := api.InboxItem{Id: "r1", Kind: api.InboxItemKindReport, State: api.Open, Payload: map[string]any{}}
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
@@ -30,21 +31,20 @@ func TestCheckResponse(t *testing.T) {
state api.InboxState
status int
}{
{"option", question, api.InboxResponseCreate{Type: api.InboxResponseTypeOption, Option: str("blue")}, api.Answered, 0},
{"option with note", question, api.InboxResponseCreate{Type: api.InboxResponseTypeOption, Option: str("red"), Text: str("but darker")}, api.Answered, 0},
{"unknown option", question, api.InboxResponseCreate{Type: api.InboxResponseTypeOption, Option: str("green")}, "", http.StatusBadRequest},
{"missing option", question, api.InboxResponseCreate{Type: api.InboxResponseTypeOption}, "", http.StatusBadRequest},
{"free form", question, api.InboxResponseCreate{Type: api.InboxResponseTypeFreeForm, Text: str("green")}, api.Answered, 0},
{"empty free form", question, api.InboxResponseCreate{Type: api.InboxResponseTypeFreeForm, Text: str(" ")}, "", http.StatusBadRequest},
{"follow-up question", question, api.InboxResponseCreate{Type: api.InboxResponseTypeQuestion, Text: str("why?")}, api.Open, 0},
{"redirect question", question, api.InboxResponseCreate{Type: api.InboxResponseTypeRedirect, Text: str("stop")}, api.Redirected, 0},
{"redirect report", report, api.InboxResponseCreate{Type: api.InboxResponseTypeRedirect, Text: str("stop")}, api.Redirected, 0},
{"snooze", question, api.InboxResponseCreate{Type: api.InboxResponseTypeSnooze, SnoozeUntil: &future}, api.Open, 0},
{"snooze into past", question, api.InboxResponseCreate{Type: api.InboxResponseTypeSnooze, SnoozeUntil: &past}, "", http.StatusBadRequest},
{"dismiss report", report, api.InboxResponseCreate{Type: api.InboxResponseTypeDismiss}, api.Dismissed, 0},
{"dismiss question", question, api.InboxResponseCreate{Type: api.InboxResponseTypeDismiss}, "", http.StatusBadRequest},
{"option on report", report, api.InboxResponseCreate{Type: api.InboxResponseTypeOption, Option: str("red")}, "", http.StatusBadRequest},
{"already answered", closed, api.InboxResponseCreate{Type: api.InboxResponseTypeOption, Option: str("red")}, "", http.StatusConflict},
{"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},
{"missing option", question, api.InboxResponseCreate{Type: api.Option}, "", 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},
{"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},
}
for _, c := range cases {
+7 -7
View File
@@ -24,7 +24,7 @@ func priorityRank(p api.InboxItemPriority) int {
func DeliverableResponse(t api.InboxResponseType) bool {
switch t {
case api.InboxResponseTypeOption, api.InboxResponseTypeFreeForm, api.InboxResponseTypeRedirect, api.InboxResponseTypeQuestion:
case api.Option, api.Other:
return true
}
return false
@@ -116,7 +116,7 @@ func (s *Store) ListInbox(ctx context.Context, f InboxFilter) ([]api.InboxItem,
args = append(args, now())
}
rows, err := s.db.QueryContext(ctx, `SELECT id FROM inbox_items WHERE `+strings.Join(where, " AND ")+` ORDER BY priority, created_at`, args...)
rows, err := s.db.QueryContext(ctx, `SELECT id FROM inbox_items WHERE `+strings.Join(where, " AND ")+` ORDER BY created_at`, args...)
if err != nil {
return nil, err
}
@@ -195,9 +195,9 @@ func (s *Store) AwaitingHuman(ctx context.Context, sessionID string) (int, error
err := s.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM inbox_items i
WHERE i.agent_session_id = ? AND i.kind = ? AND i.blocking AND i.state = ?
AND NOT EXISTS (SELECT 1 FROM inbox_responses r WHERE r.item_id = i.id AND r.type IN (?, ?, ?, ?))
`, sessionID, string(api.InboxItemKindQuestion), string(api.Open),
string(api.InboxResponseTypeOption), string(api.InboxResponseTypeFreeForm), string(api.InboxResponseTypeRedirect), string(api.InboxResponseTypeQuestion)).Scan(&n)
AND NOT EXISTS (SELECT 1 FROM inbox_responses r WHERE r.item_id = i.id AND r.type IN (?, ?))
`, sessionID, string(api.Question), string(api.Open),
string(api.Option), string(api.Other)).Scan(&n)
return n, err
}
@@ -209,9 +209,9 @@ type Pending struct {
func (s *Store) Undelivered(ctx context.Context, sessionID string) ([]Pending, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT r.id, r.item_id FROM inbox_responses r JOIN inbox_items i ON i.id = r.item_id
WHERE i.agent_session_id = ? AND r.delivered_at IS NULL AND r.type IN (?, ?, ?, ?)
WHERE i.agent_session_id = ? AND r.delivered_at IS NULL AND r.type IN (?, ?)
ORDER BY r.created_at
`, sessionID, string(api.InboxResponseTypeOption), string(api.InboxResponseTypeFreeForm), string(api.InboxResponseTypeRedirect), string(api.InboxResponseTypeQuestion))
`, sessionID, string(api.Option), string(api.Other))
if err != nil {
return nil, err
}
+3
View File
@@ -122,6 +122,9 @@ CREATE TABLE turn_queue (
text TEXT NOT NULL,
created_at TEXT NOT NULL
);
`, `
UPDATE inbox_responses SET type = 'other' WHERE type IN ('free_form', 'redirect', 'question');
UPDATE inbox_items SET state = 'answered' WHERE state = 'redirected';
`}
type Store struct {