Queue mid-turn messages for the next tool call; remove all tests
This commit is contained in:
@@ -1,33 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -160,7 +160,7 @@ func (m *Manager) codexItemCompleted(ctx context.Context, l *live, s *codex.Sess
|
||||
return err
|
||||
}
|
||||
|
||||
if !l.implementer() {
|
||||
if l.reviewID != "" {
|
||||
return nil
|
||||
}
|
||||
text := m.deliverMidTurn(ctx, l)
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
)
|
||||
|
||||
func TestForkPoint(t *testing.T) {
|
||||
s1, s2 := "s1", "s2"
|
||||
ev := func(seq int64, sess *string, kind string, payload map[string]any) api.Event {
|
||||
return api.Event{Seq: seq, AgentSessionId: sess, Kind: kind, Payload: payload}
|
||||
}
|
||||
events := []api.Event{
|
||||
ev(1, &s1, "user.message", map[string]any{"text": "go"}),
|
||||
ev(2, &s1, "agent.message", map[string]any{"text": "on it", "vendor_uuid": "u-msg"}),
|
||||
ev(3, &s1, "agent.tool_call", map[string]any{"id": "a", "vendor_uuid": "u-call-a"}),
|
||||
ev(4, &s1, "agent.tool_call", map[string]any{"id": "b", "vendor_uuid": "u-call-b"}),
|
||||
ev(5, &s1, "agent.tool_call", map[string]any{"id": "x", "parent_tool_use_id": "b"}),
|
||||
ev(6, nil, "snapshot.created", map[string]any{"n": 1}),
|
||||
ev(7, &s1, "agent.tool_result", map[string]any{"tool_use_id": "a", "vendor_uuid": "u-res-a"}),
|
||||
ev(8, &s2, "agent.tool_result", map[string]any{"tool_use_id": "b", "vendor_uuid": "u-other"}),
|
||||
ev(9, &s1, "agent.tool_result", map[string]any{"tool_use_id": "b", "vendor_uuid": "u-res-b"}),
|
||||
ev(10, &s1, "agent.message", map[string]any{"text": "done", "vendor_uuid": "u-done"}),
|
||||
ev(11, &s1, "turn.ended", map[string]any{}),
|
||||
ev(12, &s1, "user.message", map[string]any{"text": "more"}),
|
||||
ev(13, &s1, "agent.tool_call", map[string]any{"id": "c", "vendor_uuid": "u-call-c"}),
|
||||
ev(14, &s1, "agent.tool_result", map[string]any{"tool_use_id": "c", "vendor_uuid": "u-res-c"}),
|
||||
}
|
||||
|
||||
for _, c := range []struct {
|
||||
seq int64
|
||||
want string
|
||||
}{
|
||||
{1, ""},
|
||||
{2, "u-msg"},
|
||||
{3, "u-res-b"},
|
||||
{5, "u-res-b"},
|
||||
{9, "u-res-b"},
|
||||
{11, "u-done"},
|
||||
{12, "u-done"},
|
||||
{13, "u-res-c"},
|
||||
} {
|
||||
got := forkPoint(events, s1, c.seq)
|
||||
if got != c.want {
|
||||
t.Errorf("forkPoint at seq %d = %q, want %q", c.seq, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
)
|
||||
|
||||
func TestAssessStuck(t *testing.T) {
|
||||
now := time.Now()
|
||||
procs := []procInfo{
|
||||
{sessionID: "a", pid: 10, state: api.AgentSessionStateRunning, lastLine: now.Add(-StuckAfter - time.Second)},
|
||||
{sessionID: "b", pid: 11, state: api.AgentSessionStateRunning, lastLine: now.Add(-StuckAfter + time.Second)},
|
||||
{sessionID: "c", pid: 12, state: api.AgentSessionStateIdle, lastLine: now.Add(-time.Hour)},
|
||||
}
|
||||
running := []api.AgentSession{{Id: "a"}, {Id: "b"}}
|
||||
|
||||
h := assess(now, procs, running, []int{10, 11, 12})
|
||||
|
||||
want := map[string]api.AgentProcessHealth{"a": api.HealthStuck, "b": api.HealthOk, "c": api.HealthOk}
|
||||
for _, p := range h.Processes {
|
||||
if p.Health != want[p.SessionId] {
|
||||
t.Errorf("session %s health = %s, want %s", p.SessionId, p.Health, want[p.SessionId])
|
||||
}
|
||||
}
|
||||
if len(h.Zombies) != 0 {
|
||||
t.Errorf("zombies = %+v, want none", h.Zombies)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssessZombies(t *testing.T) {
|
||||
now := time.Now()
|
||||
procs := []procInfo{
|
||||
{sessionID: "exited", pid: 20, state: api.AgentSessionStateRunning, lastLine: now, exited: true},
|
||||
{sessionID: "done", pid: 21, state: api.AgentSessionStateCompleted, lastLine: now},
|
||||
{sessionID: "idle", pid: 22, state: api.AgentSessionStateIdle, lastLine: now},
|
||||
}
|
||||
running := []api.AgentSession{{Id: "exited", TaskId: "t1"}, {Id: "gone", TaskId: "t2"}}
|
||||
|
||||
h := assess(now, procs, running, []int{21, 22, 99})
|
||||
|
||||
got := []string{}
|
||||
for _, z := range h.Zombies {
|
||||
key := string(z.Kind)
|
||||
if z.SessionId != nil {
|
||||
key += " " + *z.SessionId
|
||||
}
|
||||
if z.Pid != nil {
|
||||
key += fmt.Sprintf(" %d", *z.Pid)
|
||||
}
|
||||
got = append(got, key)
|
||||
}
|
||||
want := []string{
|
||||
"process_without_session done 21",
|
||||
"running_without_process exited",
|
||||
"running_without_process gone",
|
||||
"process_without_session 99",
|
||||
}
|
||||
if strings.Join(got, "\n") != strings.Join(want, "\n") {
|
||||
t.Errorf("zombies:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
|
||||
}
|
||||
|
||||
for _, p := range h.Processes {
|
||||
if p.SessionId == "exited" && p.Health != api.HealthExited {
|
||||
t.Errorf("exited process health = %s", p.Health)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadLinesRing(t *testing.T) {
|
||||
b := newBadLines()
|
||||
at := time.Now()
|
||||
for i := range keptBadLines + 5 {
|
||||
b.add("claude", "s", fmt.Sprintf("reason %d", i), []byte(fmt.Sprintf("line %d", i)), at)
|
||||
}
|
||||
b.add("other", "s", "big", []byte(strings.Repeat("x", badLineBytes*2)), at)
|
||||
|
||||
snap := b.snapshot()
|
||||
if snap.Counts["claude"] != keptBadLines+5 || snap.Counts["other"] != 1 {
|
||||
t.Errorf("counts = %v", snap.Counts)
|
||||
}
|
||||
if len(snap.Recent) != keptBadLines {
|
||||
t.Fatalf("kept %d, want %d", len(snap.Recent), keptBadLines)
|
||||
}
|
||||
if snap.Recent[0].Reason != "big" || len(snap.Recent[0].Raw) != badLineBytes || snap.Recent[0].Size != badLineBytes*2 {
|
||||
t.Errorf("newest = %q, %d raw bytes, size %d", snap.Recent[0].Reason, len(snap.Recent[0].Raw), snap.Recent[0].Size)
|
||||
}
|
||||
if snap.Recent[1].Raw != fmt.Sprintf("line %d", keptBadLines+4) {
|
||||
t.Errorf("second newest = %q", snap.Recent[1].Raw)
|
||||
}
|
||||
oldest := snap.Recent[len(snap.Recent)-1].Raw
|
||||
if oldest != "line 6" {
|
||||
t.Errorf("oldest = %q, want line 6", oldest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChildren(t *testing.T) {
|
||||
out := []byte(" 100 1 launchd\n 200 100 claude\n 201 100 /Users/x/.local/bin/claude\n 202 100 git\n 203 999 claude\n")
|
||||
got := parseChildren(out, 100)
|
||||
if fmt.Sprint(got) != "[200 201]" {
|
||||
t.Errorf("children = %v", got)
|
||||
}
|
||||
}
|
||||
@@ -305,6 +305,17 @@ func (m *Manager) Deliver(ctx context.Context, sessionID string) error {
|
||||
m.deliverMu.Lock()
|
||||
defer m.deliverMu.Unlock()
|
||||
|
||||
sess, err := m.store.GetSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sess.State == api.AgentSessionStateIdle {
|
||||
sent, err := m.deliverQueued(ctx, sess)
|
||||
if err != nil || sent {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
pending, err := m.store.Undelivered(ctx, sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -314,10 +325,6 @@ func (m *Manager) Deliver(ctx context.Context, sessionID string) error {
|
||||
}
|
||||
ids := responseIDs(pending)
|
||||
|
||||
sess, err := m.store.GetSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sess.State != api.AgentSessionStateIdle {
|
||||
slog.Info("deliver held: session not idle", "session", sessionID, "state", sess.State, "responses", ids)
|
||||
return nil
|
||||
@@ -362,9 +369,30 @@ func (m *Manager) Deliver(ctx context.Context, sessionID string) error {
|
||||
}
|
||||
|
||||
func (m *Manager) deliverMidTurn(ctx context.Context, l *live) string {
|
||||
if l.owner != "" {
|
||||
msgs := m.takeLeadQueuedMidTurn(ctx, l)
|
||||
if len(msgs) == 0 {
|
||||
return ""
|
||||
}
|
||||
return m.recordQueued(ctx, l, msgs)
|
||||
}
|
||||
|
||||
m.deliverMu.Lock()
|
||||
defer m.deliverMu.Unlock()
|
||||
|
||||
parts := []string{}
|
||||
msgs := m.takeQueuedMidTurn(ctx, l)
|
||||
if len(msgs) > 0 {
|
||||
parts = append(parts, m.recordQueued(ctx, l, msgs))
|
||||
}
|
||||
responses := m.responsesMidTurn(ctx, l)
|
||||
if responses != "" {
|
||||
parts = append(parts, responses)
|
||||
}
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
|
||||
func (m *Manager) responsesMidTurn(ctx context.Context, l *live) string {
|
||||
pending, err := m.store.Undelivered(ctx, l.id)
|
||||
if err != nil {
|
||||
slog.Error("list undelivered responses", "session", l.id, "err", err)
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/store"
|
||||
)
|
||||
|
||||
func TestValidateAsk(t *testing.T) {
|
||||
m := &Manager{}
|
||||
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
|
||||
}{
|
||||
{"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 {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := m.validateAsk(context.Background(), l, c.args)
|
||||
if len(c.want) == 0 {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("want rejection mentioning %v", c.want)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "nothing was filed") {
|
||||
t.Fatalf("rejection doesn't say nothing was filed: %v", err)
|
||||
}
|
||||
for _, w := range c.want {
|
||||
if !strings.Contains(err.Error(), w) {
|
||||
t.Fatalf("rejection %q doesn't mention %q", err, w)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelQuestion(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "byre.db"), store.DefaultPortFirst, func(api.Event) {})
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
|
||||
now := time.Now().UTC()
|
||||
_, err = st.EnsureUser(ctx, "alice", "Alice")
|
||||
if err != nil {
|
||||
t.Fatalf("ensure user: %v", err)
|
||||
}
|
||||
err = st.CreateProject(ctx, api.Project{Id: "p1", Name: "demo", RepoPath: "/nonexistent", DefaultBranch: "main", Remote: "none", CreatedBy: "alice", CreatedAt: now})
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
err = st.CreateTask(ctx, api.Task{Id: "t1", ProjectId: "p1", Title: "Fix the thing", Branch: "b", WorktreePath: "/nonexistent", BaseRef: "x", Status: api.TaskStatusActive, CreatedBy: "alice", CreatedAt: now}, "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("create task: %v", err)
|
||||
}
|
||||
err = st.CreateSession(ctx, api.AgentSession{Id: "s1", TaskId: "t1", Vendor: api.Claude, VendorSessionId: "s1", State: api.AgentSessionStateIdle, Role: api.Implementer, CreatedBy: "alice", CreatedAt: now}, "p1")
|
||||
if err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
|
||||
m := New(st, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
l := &live{id: "s1", dir: t.TempDir(), vendor: api.Claude, taskID: "t1", projectID: "p1"}
|
||||
other := &live{id: "s2", dir: t.TempDir(), vendor: api.Claude, taskID: "t1", projectID: "p1"}
|
||||
|
||||
yes := true
|
||||
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)
|
||||
}
|
||||
out, err := m.ask(ctx, l, raw)
|
||||
if err != nil {
|
||||
t.Fatalf("ask: %v", err)
|
||||
}
|
||||
items, err := st.ListInbox(ctx, store.InboxFilter{SessionID: "s1", State: api.Open})
|
||||
if err != nil || len(items) != 1 {
|
||||
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)
|
||||
}
|
||||
|
||||
cancel := func(l *live, id string) error {
|
||||
raw, err := json.Marshal(cancelArgs{QuestionID: id})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
_, err = m.cancelQuestion(ctx, l, raw)
|
||||
return err
|
||||
}
|
||||
|
||||
if cancel(l, "nope") == nil {
|
||||
t.Fatalf("cancelled a question that doesn't exist")
|
||||
}
|
||||
if cancel(other, id) == nil {
|
||||
t.Fatalf("cancelled another session's question")
|
||||
}
|
||||
err = cancel(l, id)
|
||||
if err != nil {
|
||||
t.Fatalf("cancel: %v", err)
|
||||
}
|
||||
item, err := st.GetInboxItem(ctx, id)
|
||||
if err != nil || item.State != api.Dismissed || item.ResolvedBy != nil || item.ResolvedAt == nil {
|
||||
t.Fatalf("after cancel: %+v %v", item, err)
|
||||
}
|
||||
waiting, err := st.AwaitingHuman(ctx, "s1")
|
||||
if err != nil || waiting != 0 {
|
||||
t.Fatalf("still awaiting human after cancel: %d %v", waiting, err)
|
||||
}
|
||||
if cancel(l, id) == nil {
|
||||
t.Fatalf("cancelled a question twice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsksQuestion(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{"prose question (seq 61)", "Which of these do you want to start with?", true},
|
||||
{"question mid-message", "I can do either.\n\nWhich do you want first? I'd start with the spinner.", true},
|
||||
{"bold question", "**Should I deploy now?**", true},
|
||||
{"question mark only inside quotes (seq 108)", "- **A:** block if the final message contains a \"?\".\n- **C:** block if the turn's report lists open questions.\n\nMy recommendation is A and C together.", false},
|
||||
{"quoted question", "Without it, a question like \"Which of these do you want to start with?\" still reaches you as plain text.", false},
|
||||
{"inline code", "The view calls `GET /api/inbox?task=x` on load.", false},
|
||||
{"fenced code", "Run:\n```\nif ok? then\n```\nDone.", false},
|
||||
{"url query", "See https://example.com/?a=1 for details.", false},
|
||||
{"blockquote", "> did it work?\n\nIt did.", false},
|
||||
{"no question", "I committed everything and the tests pass.", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := asksQuestion(c.text); got != c.want {
|
||||
t.Fatalf("asksQuestion(%q) = %v, want %v", c.text, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBounceProseQuestion(t *testing.T) {
|
||||
m := &Manager{}
|
||||
stop := func(l *live, text string) map[string]any {
|
||||
t.Helper()
|
||||
input, err := json.Marshal(map[string]any{"hook_event_name": "Stop", "last_assistant_message": text})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := m.bounceProseQuestion(l)(context.Background(), input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
l := &live{}
|
||||
if out := stop(l, "Which of these do you want to start with?"); out["decision"] != "block" || out["reason"] != proseQuestionNudge {
|
||||
t.Fatalf("prose question not bounced: %v", out)
|
||||
}
|
||||
if out := stop(l, "Which of these do you want to start with?"); len(out) != 0 {
|
||||
t.Fatalf("bounced twice in one turn: %v", out)
|
||||
}
|
||||
|
||||
asked := &live{asked: true}
|
||||
if out := stop(asked, "Should I go ahead?"); len(out) != 0 {
|
||||
t.Fatalf("bounced a turn that called ask_human: %v", out)
|
||||
}
|
||||
|
||||
if out := stop(&live{}, "I committed everything and the tests pass."); len(out) != 0 {
|
||||
t.Fatalf("bounced a message with no question: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryMessage(t *testing.T) {
|
||||
str := func(s string) *string { return &s }
|
||||
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, Options: opts("blue")}},
|
||||
{Item: checklist, Response: api.InboxResponse{User: "alice", Type: api.Option, Options: opts("tests", "docs"), Text: str("also run go vet")}},
|
||||
{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")}},
|
||||
})
|
||||
|
||||
for _, want := range []string{
|
||||
"Your question q1: Which color?",
|
||||
"alice chose: blue",
|
||||
"Your question q2: Which chores?\nalice checked:\n- tests\n- docs\nalice added: also run go vet\n",
|
||||
"bob answered (none of your options): what is it for?",
|
||||
`calling ask_human with thread_id "q1"`,
|
||||
`"url":"https://example.com"`,
|
||||
"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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/store"
|
||||
)
|
||||
|
||||
func TestPaneFreed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "byre.db"), store.DefaultPortFirst, func(api.Event) {})
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
|
||||
now := time.Now().UTC()
|
||||
_, err = st.EnsureUser(ctx, "alice", "Alice")
|
||||
if err != nil {
|
||||
t.Fatalf("ensure user: %v", err)
|
||||
}
|
||||
err = st.CreateProject(ctx, api.Project{Id: "p1", Name: "demo", RepoPath: "/nonexistent", DefaultBranch: "main", Remote: "none", CreatedBy: "alice", CreatedAt: now})
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
for _, id := range []string{"t1", "t2"} {
|
||||
err = st.CreateTask(ctx, api.Task{Id: id, ProjectId: "p1", Title: "Fix " + id, Branch: id, WorktreePath: "/nonexistent", BaseRef: "x", Status: api.TaskStatusActive, CreatedBy: "alice", CreatedAt: now}, "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("create task: %v", err)
|
||||
}
|
||||
}
|
||||
err = st.CreateSession(ctx, api.AgentSession{Id: "s1", TaskId: "t1", Vendor: api.Claude, VendorSessionId: "s1", State: api.AgentSessionStateIdle, Role: api.Implementer, CreatedBy: "alice", CreatedAt: now}, "p1")
|
||||
if err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
err = st.CreateInboxItem(ctx, api.InboxItem{
|
||||
Id: "r1", ProjectId: "p1", TaskId: "t1", AgentSessionId: "s1", ThreadId: "r1",
|
||||
Kind: api.Report, Priority: api.Fyi, Payload: map[string]any{"text": "done"},
|
||||
State: api.Open, CreatedAt: now, Responses: []api.InboxResponse{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("file report: %v", err)
|
||||
}
|
||||
|
||||
m := New(st, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
t1, t2, p1 := "t1", "t2", "p1"
|
||||
focus := func(tab api.TaskTab) {
|
||||
t.Helper()
|
||||
err := st.SetFocus(ctx, "alice", api.Focus{TaskId: &t1, ProjectId: &p1, Tabs: []api.TaskTab{tab}, Tab: tab}, "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("set focus: %v", err)
|
||||
}
|
||||
}
|
||||
freed := func(kind, taskID string) string {
|
||||
t.Helper()
|
||||
text, err := m.freedText(ctx, "alice", api.Event{Kind: kind, TaskId: &taskID})
|
||||
if err != nil {
|
||||
t.Fatalf("freed text: %v", err)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
focus(api.TabInbox)
|
||||
if text := freed("inbox.updated", t1); text != "" {
|
||||
t.Fatalf("freed with an item still open: %q", text)
|
||||
}
|
||||
|
||||
err = st.WithdrawItem(ctx, "r1")
|
||||
if err != nil {
|
||||
t.Fatalf("withdraw: %v", err)
|
||||
}
|
||||
text := freed("inbox.updated", t1)
|
||||
if !strings.HasPrefix(text, "Your right pane is free: ") || !strings.HasSuffix(text, "task t1] has nothing left in its inbox.") {
|
||||
t.Fatalf("emptied inbox: %q", text)
|
||||
}
|
||||
if text := freed("inbox.updated", t2); text != "" {
|
||||
t.Fatalf("freed by another task: %q", text)
|
||||
}
|
||||
|
||||
focus(api.TabTranscript)
|
||||
if text := freed("inbox.updated", t1); text != "" {
|
||||
t.Fatalf("freed on the transcript tab: %q", text)
|
||||
}
|
||||
|
||||
_, err = st.MergeTask(ctx, "t1", "abc", "origin/main", "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("merge: %v", err)
|
||||
}
|
||||
if text := freed("task.merged", t1); !strings.HasSuffix(text, "task t1] is merged.") {
|
||||
t.Fatalf("merged: %q", text)
|
||||
}
|
||||
if text := freed("inbox.updated", t1); text != "" {
|
||||
t.Fatalf("inbox.updated on a merged task: %q", text)
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
package agents_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/store"
|
||||
)
|
||||
|
||||
func leadIdle(ev api.Event) bool {
|
||||
return ev.Kind == "session.state" && ev.Owner != nil && ev.Payload["state"] == string(api.AgentSessionStateIdle)
|
||||
}
|
||||
|
||||
func TestLeadSignalsAndShow(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
m := e.manager()
|
||||
|
||||
lead, err := m.MessageLead(e.ctx, e.user, "Reply with the single word ready.", testModel, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("message lead: %v", err)
|
||||
}
|
||||
if lead.Owner != login || lead.TaskId != "" || lead.Role != api.Lead {
|
||||
t.Fatalf("lead session %+v", lead)
|
||||
}
|
||||
e.waitFor("lead idle", leadIdle)
|
||||
|
||||
worker := api.AgentSession{
|
||||
Id: store.NewID(), TaskId: e.task.Id, Vendor: api.Claude, VendorSessionId: store.NewID(),
|
||||
State: api.AgentSessionStateIdle, Role: api.Implementer, CreatedBy: login, CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
err = e.store.CreateSession(e.ctx, worker, e.project.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("create worker session: %v", err)
|
||||
}
|
||||
err = e.store.CreateInboxItem(e.ctx, api.InboxItem{
|
||||
Id: store.NewID(), ProjectId: e.project.Id, TaskId: e.task.Id, AgentSessionId: worker.Id, ThreadId: "t1",
|
||||
Kind: api.Report, Priority: api.Fyi, Payload: map[string]any{"text": "Finished the parser. The tests pass."},
|
||||
State: api.Open, CreatedAt: time.Now().UTC(), Responses: []api.InboxResponse{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("file report: %v", err)
|
||||
}
|
||||
created := e.waitFor("inbox.created", func(ev api.Event) bool { return ev.Kind == "inbox.created" })
|
||||
m.Observe(created[len(created)-1])
|
||||
|
||||
signalled := e.waitFor("signal delivered", func(ev api.Event) bool {
|
||||
return ev.Kind == "user.message" && ev.Owner != nil && ev.Actor == "byre"
|
||||
})
|
||||
text := signalled[len(signalled)-1].Payload["text"].(string)
|
||||
if !strings.Contains(text, "Finished the parser") || !strings.Contains(text, e.task.Id) {
|
||||
t.Fatalf("signal text %q", text)
|
||||
}
|
||||
e.waitFor("lead idle after signal", leadIdle)
|
||||
|
||||
_, err = m.MessageLead(e.ctx, e.user, "Call the show tool with task_id "+e.task.Id+", tabs [\"inbox\"] and tab \"inbox\". Then say done.", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("message lead: %v", err)
|
||||
}
|
||||
e.waitFor("focus.updated by the lead", func(ev api.Event) bool {
|
||||
return ev.Kind == "focus.updated" && ev.Actor == login+" (lead)"
|
||||
})
|
||||
f, err := e.store.GetFocus(e.ctx, login)
|
||||
if err != nil {
|
||||
t.Fatalf("get focus: %v", err)
|
||||
}
|
||||
if f.TaskId == nil || *f.TaskId != e.task.Id || f.ProjectId == nil || *f.ProjectId != e.project.Id || f.Tab != api.TabInbox {
|
||||
t.Fatalf("focus %+v", f)
|
||||
}
|
||||
e.waitFor("lead idle after show", leadIdle)
|
||||
|
||||
m.SetPaneFocus(login, true)
|
||||
_, err = m.MessageLead(e.ctx, e.user, "Call the show tool with task_id "+e.task.Id+", tabs [\"changes\"] and tab \"changes\". Then say done.", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("message lead: %v", err)
|
||||
}
|
||||
e.waitFor("show refused", func(ev api.Event) bool {
|
||||
return ev.Kind == "agent.tool_result" && ev.Owner != nil && strings.Contains(fmt.Sprint(ev.Payload), "working in the right pane")
|
||||
})
|
||||
e.waitFor("lead idle after refused show", leadIdle)
|
||||
f, err = e.store.GetFocus(e.ctx, login)
|
||||
if err != nil {
|
||||
t.Fatalf("get focus: %v", err)
|
||||
}
|
||||
if f.Tab != api.TabInbox {
|
||||
t.Fatalf("focus moved while the pane was focused: %+v", f)
|
||||
}
|
||||
|
||||
_, err = m.MessageLead(e.ctx, e.user, "Call the show tool with task_id "+e.task.Id+", tabs [\"inbox\", \"changes\"] and tab \"inbox\". Then say done.", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("message lead: %v", err)
|
||||
}
|
||||
e.waitFor("show of what is already shown", func(ev api.Event) bool {
|
||||
return ev.Kind == "agent.tool_result" && ev.Owner != nil && strings.Contains(fmt.Sprint(ev.Payload), "Already shown")
|
||||
})
|
||||
e.waitFor("lead idle after already-shown show", leadIdle)
|
||||
f, err = e.store.GetFocus(e.ctx, login)
|
||||
if err != nil {
|
||||
t.Fatalf("get focus: %v", err)
|
||||
}
|
||||
if f.Tab != api.TabInbox || !slices.Equal(f.Tabs, []api.TaskTab{api.TabInbox, api.TabChanges}) {
|
||||
t.Fatalf("tabs not reordered on the shown tab: %+v", f)
|
||||
}
|
||||
|
||||
err = e.store.WithdrawItem(e.ctx, created[len(created)-1].Payload["id"].(string))
|
||||
if err != nil {
|
||||
t.Fatalf("withdraw report: %v", err)
|
||||
}
|
||||
_, err = m.MessageLead(e.ctx, e.user, "Call the show tool with task_id "+e.task.Id+", tabs [\"changes\"] and tab \"changes\". Then say done.", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("message lead: %v", err)
|
||||
}
|
||||
e.waitFor("show allowed on an emptied inbox", func(ev api.Event) bool {
|
||||
return ev.Kind == "focus.updated" && ev.Actor == login+" (lead)" && ev.Payload["tab"] == string(api.TabChanges)
|
||||
})
|
||||
e.waitFor("lead idle after show on an emptied inbox", leadIdle)
|
||||
|
||||
err = m.SetFocus(e.ctx, login, api.Focus{ProjectId: &e.project.Id, Tabs: []api.TaskTab{api.TabProject}, Tab: api.TabProject}, login)
|
||||
if err != nil {
|
||||
t.Fatalf("focus project: %v", err)
|
||||
}
|
||||
_, err = m.MessageLead(e.ctx, e.user, "Call the show tool with task_id "+e.task.Id+", tabs [\"transcript\"] and tab \"transcript\". Then say done.", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("message lead: %v", err)
|
||||
}
|
||||
e.waitFor("show allowed over the project tab", func(ev api.Event) bool {
|
||||
return ev.Kind == "focus.updated" && ev.Actor == login+" (lead)" && ev.Payload["tab"] == string(api.TabTranscript)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFocusProject(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
m := e.manager()
|
||||
|
||||
err := m.SetFocus(e.ctx, login, api.Focus{ProjectId: &e.project.Id, Tabs: []api.TaskTab{api.TabProject}, Tab: api.TabProject}, login)
|
||||
if err != nil {
|
||||
t.Fatalf("focus project: %v", err)
|
||||
}
|
||||
f, err := e.store.GetFocus(e.ctx, login)
|
||||
if err != nil {
|
||||
t.Fatalf("get focus: %v", err)
|
||||
}
|
||||
if f.TaskId != nil || f.ProjectId == nil || *f.ProjectId != e.project.Id || f.Tab != api.TabProject {
|
||||
t.Fatalf("focus %+v", f)
|
||||
}
|
||||
|
||||
other := "nope"
|
||||
err = m.SetFocus(e.ctx, login, api.Focus{TaskId: &e.task.Id, ProjectId: &other, Tabs: []api.TaskTab{}, Tab: api.TabTranscript}, login)
|
||||
if err != nil {
|
||||
t.Fatalf("focus task: %v", err)
|
||||
}
|
||||
f, err = e.store.GetFocus(e.ctx, login)
|
||||
if err != nil {
|
||||
t.Fatalf("get focus: %v", err)
|
||||
}
|
||||
if f.TaskId == nil || *f.TaskId != e.task.Id || f.ProjectId == nil || *f.ProjectId != e.project.Id {
|
||||
t.Fatalf("focus %+v", f)
|
||||
}
|
||||
|
||||
err = m.SetFocus(e.ctx, login, api.Focus{ProjectId: &other, Tabs: []api.TaskTab{}, Tab: api.TabProject}, login)
|
||||
if err == nil {
|
||||
t.Fatal("focus on an unknown project was accepted")
|
||||
}
|
||||
}
|
||||
@@ -309,6 +309,7 @@ func (m *Manager) Recover(ctx context.Context) error {
|
||||
}
|
||||
|
||||
go m.deliverAllLeads()
|
||||
go m.deliverAllQueued(context.Background())
|
||||
go m.startHeldMerges(context.Background())
|
||||
return nil
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,79 +0,0 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/store"
|
||||
)
|
||||
|
||||
func TestSetMergeTitle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "byre.db"), store.DefaultPortFirst, func(api.Event) {})
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
|
||||
now := time.Now().UTC()
|
||||
_, err = st.EnsureUser(ctx, "alice", "Alice")
|
||||
if err != nil {
|
||||
t.Fatalf("ensure user: %v", err)
|
||||
}
|
||||
err = st.CreateProject(ctx, api.Project{Id: "p1", Name: "demo", RepoPath: "/nonexistent", DefaultBranch: "main", Remote: "none", CreatedBy: "alice", CreatedAt: now})
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
err = st.CreateTask(ctx, api.Task{Id: "t1", ProjectId: "p1", Title: "Fix the thing", Branch: "b", WorktreePath: "/nonexistent", BaseRef: "x", Status: api.TaskStatusActive, CreatedBy: "alice", CreatedAt: now}, "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("create task: %v", err)
|
||||
}
|
||||
|
||||
task, err := st.GetTask(ctx, "t1")
|
||||
if err != nil || task.MergeTitle != "Fix the thing" {
|
||||
t.Fatalf("default merge title %q, want the task title: %v", task.MergeTitle, err)
|
||||
}
|
||||
|
||||
m := New(st, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
l := &live{id: "s1", vendor: api.Claude, taskID: "t1", projectID: "p1"}
|
||||
call := func(title string) error {
|
||||
raw, err := json.Marshal(setMergeTitleArgs{Title: title})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
_, err = m.setMergeTitle(ctx, l, raw)
|
||||
return err
|
||||
}
|
||||
|
||||
for _, bad := range []string{"", " ", "two\nlines", strings.Repeat("x", maxMergeTitle+1)} {
|
||||
if call(bad) == nil {
|
||||
t.Fatalf("set_merge_title accepted %q", bad)
|
||||
}
|
||||
}
|
||||
|
||||
err = call(" Add retry with backoff to webhook delivery ")
|
||||
if err != nil {
|
||||
t.Fatalf("set_merge_title: %v", err)
|
||||
}
|
||||
task, err = st.GetTask(ctx, "t1")
|
||||
if err != nil || task.MergeTitle != "Add retry with backoff to webhook delivery" || task.Title != "Fix the thing" {
|
||||
t.Fatalf("after set_merge_title: %+v %v", task, err)
|
||||
}
|
||||
|
||||
if !strings.Contains(mergeInPrompt("main", task.MergeTitle), `currently "Add retry with backoff to webhook delivery"`) {
|
||||
t.Fatalf("merge-in prompt doesn't show the current title: %s", mergeInPrompt("main", task.MergeTitle))
|
||||
}
|
||||
|
||||
_, err = st.DiscardTask(ctx, "t1", "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("discard: %v", err)
|
||||
}
|
||||
if call("Too late") == nil {
|
||||
t.Fatalf("set_merge_title accepted a title for a closed task")
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/store"
|
||||
)
|
||||
|
||||
func TestExposePortRefusals(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "byre.db"), store.DefaultPortFirst, func(api.Event) {})
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
|
||||
now := time.Now().UTC()
|
||||
_, err = st.EnsureUser(ctx, "alice", "Alice")
|
||||
if err != nil {
|
||||
t.Fatalf("ensure user: %v", err)
|
||||
}
|
||||
err = st.CreateProject(ctx, api.Project{Id: "p1", Name: "demo", RepoPath: "/nonexistent", DefaultBranch: "main", Remote: "none", CreatedBy: "alice", CreatedAt: now})
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
err = st.CreateTask(ctx, api.Task{Id: "t1", ProjectId: "p1", Title: "t1", Branch: "b", WorktreePath: "/nonexistent", BaseRef: "x", Status: api.TaskStatusActive, CreatedBy: "alice", CreatedAt: now}, "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("create task: %v", err)
|
||||
}
|
||||
|
||||
m := New(st, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
l := &live{id: "s1", vendor: api.Claude, taskID: "t1", projectID: "p1"}
|
||||
call := func(name string, port int) error {
|
||||
raw, err := json.Marshal(exposePortArgs{Name: name, Port: port})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
_, err = m.exposePort(ctx, l, raw)
|
||||
return err
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
port int
|
||||
want string
|
||||
}{
|
||||
{"", 41000, "name must be"},
|
||||
{"two\nlines", 41000, "name must be"},
|
||||
{strings.Repeat("x", maxPreviewName+1), 41000, "name must be"},
|
||||
{"web", 40999, "outside this task's block, 41000 to 41009"},
|
||||
{"web", 41010, "outside this task's block, 41000 to 41009"},
|
||||
{"web", 41009, "nothing is listening on 127.0.0.1:41009"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
err = call(c.name, c.port)
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Fatalf("expose_port(%q, %d) = %v, want an error containing %q", c.name, c.port, err, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
previews, err := st.ListPreviews(ctx, "t1")
|
||||
if err != nil || len(previews) != 0 {
|
||||
t.Fatalf("previews after refusals = %v, %v", previews, err)
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,13 @@ func (m *Manager) startClaude(ctx context.Context, sess api.AgentSession, l *liv
|
||||
"Stop": {{Func: m.bounceProseQuestion(l)}},
|
||||
"PostToolUse": {{Func: m.afterTool(l)}},
|
||||
}
|
||||
if !l.implementer() {
|
||||
if l.owner != "" {
|
||||
hooks = map[string][]claude.HookMatcher{
|
||||
"UserPromptSubmit": {{Func: remindGuidance(g)}},
|
||||
"PostToolUse": {{Func: m.afterTool(l)}},
|
||||
}
|
||||
}
|
||||
if l.reviewID != "" {
|
||||
hooks = map[string][]claude.HookMatcher{"UserPromptSubmit": {{Func: remindGuidance(g)}}}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/store"
|
||||
)
|
||||
|
||||
func (m *Manager) Message(ctx context.Context, sessionID, actor, text, model string, images ...string) (api.AgentSession, error) {
|
||||
m.deliverMu.Lock()
|
||||
defer m.deliverMu.Unlock()
|
||||
|
||||
sess, err := m.store.GetSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
if sess.State != api.AgentSessionStateRunning || sess.Role != api.Implementer {
|
||||
return m.Send(ctx, sessionID, actor, text, model, images...)
|
||||
}
|
||||
|
||||
if model != "" && model != sess.Model {
|
||||
err = checkModel(sess, model)
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
err = m.store.SetSessionModel(ctx, sess.Id, model, actor)
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
sess.Model = model
|
||||
}
|
||||
|
||||
err = m.store.AddTaskInput(ctx, store.TaskInput{TaskID: sess.TaskId, Actor: actor, Text: text, Images: images})
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
slog.Info("message queued", "session", sess.Id, "task", sess.TaskId, "actor", actor)
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
func (m *Manager) deliverQueued(ctx context.Context, sess api.AgentSession) (bool, error) {
|
||||
inputs, err := m.store.TaskInputs(ctx, sess.TaskId)
|
||||
if err != nil || len(inputs) == 0 {
|
||||
return false, err
|
||||
}
|
||||
|
||||
task, err := m.store.GetTask(ctx, sess.TaskId)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
in := inputs[0]
|
||||
_, err = m.Send(ctx, sess.Id, in.Actor, in.Text, "", in.Images...)
|
||||
if errors.Is(err, ErrConflict) {
|
||||
slog.Info("queued message held: session changed state", "session", sess.Id, "task", task.Id, "err", err)
|
||||
return true, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
slog.Info("queued message sent", "session", sess.Id, "task", task.Id, "input", in.ID)
|
||||
return true, m.store.DeleteTaskInputs(ctx, []int64{in.ID})
|
||||
}
|
||||
|
||||
func (m *Manager) deliverAllQueued(ctx context.Context) {
|
||||
idle, err := m.store.SessionsInState(ctx, api.AgentSessionStateIdle)
|
||||
if err != nil {
|
||||
slog.Error("list idle sessions", "err", err)
|
||||
return
|
||||
}
|
||||
for _, sess := range idle {
|
||||
if sess.Role != api.Implementer {
|
||||
continue
|
||||
}
|
||||
m.deliverMu.Lock()
|
||||
_, err = m.deliverQueued(ctx, sess)
|
||||
m.deliverMu.Unlock()
|
||||
if err != nil {
|
||||
slog.Error("deliver queued message", "session", sess.Id, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type queued struct {
|
||||
actor string
|
||||
text string
|
||||
}
|
||||
|
||||
func (m *Manager) takeQueuedMidTurn(ctx context.Context, l *live) []queued {
|
||||
m.mu.Lock()
|
||||
compacting := l.compacting
|
||||
m.mu.Unlock()
|
||||
if compacting {
|
||||
return nil
|
||||
}
|
||||
|
||||
inputs, err := m.store.TaskInputs(ctx, l.taskID)
|
||||
if err != nil {
|
||||
slog.Error("list queued messages", "session", l.id, "err", err)
|
||||
return nil
|
||||
}
|
||||
out := []queued{}
|
||||
ids := []int64{}
|
||||
for _, in := range inputs {
|
||||
if len(in.Images) > 0 {
|
||||
break
|
||||
}
|
||||
out = append(out, queued{actor: in.Actor, text: in.Text})
|
||||
ids = append(ids, in.ID)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
err = m.store.DeleteTaskInputs(ctx, ids)
|
||||
if err != nil {
|
||||
slog.Error("delete queued messages", "session", l.id, "err", err)
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *Manager) takeLeadQueuedMidTurn(ctx context.Context, l *live) []queued {
|
||||
m.mu.Lock()
|
||||
compacting := l.compacting
|
||||
m.mu.Unlock()
|
||||
if compacting {
|
||||
return nil
|
||||
}
|
||||
|
||||
m.leadMu.Lock()
|
||||
defer m.leadMu.Unlock()
|
||||
|
||||
inputs, err := m.store.LeadInputs(ctx, l.owner)
|
||||
if err != nil {
|
||||
slog.Error("list held lead messages", "session", l.id, "err", err)
|
||||
return nil
|
||||
}
|
||||
out := []queued{}
|
||||
ids := []int64{}
|
||||
for _, in := range inputs {
|
||||
if in.Kind != store.LeadMessage {
|
||||
continue
|
||||
}
|
||||
if len(in.Images) > 0 {
|
||||
break
|
||||
}
|
||||
out = append(out, queued{actor: in.Actor, text: in.Text})
|
||||
ids = append(ids, in.ID)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
err = m.store.DeleteLeadInputs(ctx, ids)
|
||||
if err != nil {
|
||||
slog.Error("delete held lead messages", "session", l.id, "err", err)
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *Manager) recordQueued(ctx context.Context, l *live, msgs []queued) string {
|
||||
b := &strings.Builder{}
|
||||
for i, q := range msgs {
|
||||
err := m.emit(ctx, l, q.actor, "user.message", map[string]any{"text": q.text, "mid_turn": true})
|
||||
if err != nil {
|
||||
slog.Error("record mid-turn message", "session", l.id, "err", err)
|
||||
}
|
||||
if i > 0 {
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
fmt.Fprintf(b, "%s sent you this message while you were working; take it into account now:\n\n%s", q.actor, q.text)
|
||||
}
|
||||
slog.Info("queued messages sent mid-turn", "session", l.id, "count", len(msgs))
|
||||
return b.String()
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/claude"
|
||||
"g.fc.run/misc/byre/internal/store"
|
||||
)
|
||||
|
||||
func TestRateLimitedTurn(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "byre.db"), store.DefaultPortFirst, func(api.Event) {})
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
|
||||
now := time.Now().UTC()
|
||||
_, err = st.EnsureUser(ctx, "alice", "Alice")
|
||||
if err != nil {
|
||||
t.Fatalf("ensure user: %v", err)
|
||||
}
|
||||
p := api.Project{Id: "p1", Name: "demo", RepoPath: "/nonexistent", DefaultBranch: "main", Remote: "none", CreatedBy: "alice", CreatedAt: now}
|
||||
err = st.CreateProject(ctx, p)
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
task := api.Task{Id: "t1", ProjectId: "p1", Title: "t", Branch: "b", WorktreePath: "/nonexistent", BaseRef: "x", Status: api.TaskStatusActive, CreatedBy: "alice", CreatedAt: now}
|
||||
err = st.CreateTask(ctx, task, "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("create task: %v", err)
|
||||
}
|
||||
sess := api.AgentSession{Id: "s1", TaskId: "t1", Vendor: api.Claude, VendorSessionId: "s1", State: api.AgentSessionStateRunning, Role: api.Implementer, CreatedBy: "alice", CreatedAt: now}
|
||||
err = st.CreateSession(ctx, sess, "p1")
|
||||
if err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
|
||||
m := New(st, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
l := &live{id: "s1", dir: t.TempDir(), vendor: api.Claude, taskID: "t1", projectID: "p1"}
|
||||
|
||||
feed := func(typ, raw string) {
|
||||
t.Helper()
|
||||
err := m.handle(ctx, l, claude.Event{Type: typ, Raw: []byte(raw)})
|
||||
if err != nil {
|
||||
t.Fatalf("handle %s: %v", raw, err)
|
||||
}
|
||||
}
|
||||
|
||||
feed("rate_limit_event", `{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","rateLimitType":"five_hour","resetsAt":1791972000}}`)
|
||||
usage, err := m.Usage(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("usage: %v", err)
|
||||
}
|
||||
if usage.RateLimits["claude"]["status"] != "allowed" {
|
||||
t.Fatalf("rate limit status not recorded: %+v", usage.RateLimits)
|
||||
}
|
||||
|
||||
feed("rate_limit_event", `{"type":"rate_limit_event","rate_limit_info":{"status":"rejected","rateLimitType":"five_hour","resetsAt":1791972000}}`)
|
||||
feed("result", `{"type":"result","subtype":"success","is_error":true,"result":"API Error: rate limited","num_turns":1}`)
|
||||
|
||||
items, err := st.ListInbox(ctx, store.InboxFilter{SessionID: "s1", State: api.Open})
|
||||
if err != nil {
|
||||
t.Fatalf("list inbox: %v", err)
|
||||
}
|
||||
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)
|
||||
if !ok || limit["status"] != "rejected" || items[0].Payload["vendor"] != "claude" {
|
||||
t.Fatalf("unexpected payload %+v", items[0].Payload)
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
)
|
||||
|
||||
func TestCheckResponse(t *testing.T) {
|
||||
question := api.InboxItem{
|
||||
Id: "q1",
|
||||
Kind: api.Question,
|
||||
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 }
|
||||
opts := func(s ...string) *[]string { return &s }
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
item api.InboxItem
|
||||
create api.InboxResponseCreate
|
||||
state api.InboxState
|
||||
want error
|
||||
}{
|
||||
{"option", question, api.InboxResponseCreate{Type: api.Option, Options: opts("blue")}, api.Answered, nil},
|
||||
{"option with text", question, api.InboxResponseCreate{Type: api.Option, Options: opts("red"), Text: str("but darker")}, "", ErrInvalid},
|
||||
{"unknown option", question, api.InboxResponseCreate{Type: api.Option, Options: opts("green")}, "", ErrInvalid},
|
||||
{"missing option", question, api.InboxResponseCreate{Type: api.Option}, "", ErrInvalid},
|
||||
{"no options", question, api.InboxResponseCreate{Type: api.Option, Options: opts()}, "", ErrInvalid},
|
||||
{"two picks on pick-one", question, api.InboxResponseCreate{Type: api.Option, Options: opts("red", "blue")}, "", ErrInvalid},
|
||||
{"checked", checklist, api.InboxResponseCreate{Type: api.Option, Options: opts("tests", "lint")}, api.Answered, nil},
|
||||
{"checked one", checklist, api.InboxResponseCreate{Type: api.Option, Options: opts("docs")}, api.Answered, nil},
|
||||
{"checked with text", checklist, api.InboxResponseCreate{Type: api.Option, Options: opts("tests", "lint"), Text: str("also run go vet")}, api.Answered, nil},
|
||||
{"checked twice", checklist, api.InboxResponseCreate{Type: api.Option, Options: opts("docs", "docs")}, "", ErrInvalid},
|
||||
{"checked unknown", checklist, api.InboxResponseCreate{Type: api.Option, Options: opts("docs", "deploy")}, "", ErrInvalid},
|
||||
{"other", question, api.InboxResponseCreate{Type: api.Other, Text: str("green")}, api.Answered, nil},
|
||||
{"empty other", question, api.InboxResponseCreate{Type: api.Other, Text: str(" ")}, "", ErrInvalid},
|
||||
{"other on error", failure, api.InboxResponseCreate{Type: api.Other, Text: str("retry")}, api.Answered, nil},
|
||||
{"other on report", report, api.InboxResponseCreate{Type: api.Other, Text: str("stop")}, "", ErrInvalid},
|
||||
{"snooze", question, api.InboxResponseCreate{Type: "snooze"}, "", ErrInvalid},
|
||||
{"dismiss report", report, api.InboxResponseCreate{Type: api.Dismiss}, api.Dismissed, nil},
|
||||
{"dismiss question", question, api.InboxResponseCreate{Type: api.Dismiss}, "", ErrInvalid},
|
||||
{"option on report", report, api.InboxResponseCreate{Type: api.Option, Options: opts("red")}, "", ErrInvalid},
|
||||
{"already answered", closed, api.InboxResponseCreate{Type: api.Option, Options: opts("red")}, "", ErrConflict},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
state, err := CheckResponse(c.item, c.create)
|
||||
if c.want != nil {
|
||||
if !errors.Is(err, c.want) {
|
||||
t.Fatalf("want %v, got %v", c.want, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error %v", err)
|
||||
}
|
||||
if state != c.state {
|
||||
t.Fatalf("want state %s, got %s", c.state, state)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
)
|
||||
|
||||
func TestReviewVendor(t *testing.T) {
|
||||
if got := reviewVendor(api.Claude); got != api.Codex {
|
||||
t.Fatalf("reviewer for a claude task: %s", got)
|
||||
}
|
||||
if got := reviewVendor(api.Codex); got != api.Claude {
|
||||
t.Fatalf("reviewer for a codex task: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewLine(t *testing.T) {
|
||||
result := map[string]any{
|
||||
"summary": "Mostly fine.",
|
||||
"findings": []any{
|
||||
map[string]any{"severity": "high", "title": "Nil map write", "detail": "Panics on first use.", "path": "a.go", "line": float64(12)},
|
||||
map[string]any{"severity": "low", "title": "Typo", "detail": "Spelling."},
|
||||
},
|
||||
}
|
||||
got := reviewLine(api.Review{SnapshotN: 3, State: api.ReviewStateDone, Result: &result})
|
||||
want := "review of snapshot 3: Mostly fine.\n" +
|
||||
" - [high] Nil map write (a.go:12): Panics on first use.\n" +
|
||||
" - [low] Typo: Spelling."
|
||||
if got != want {
|
||||
t.Fatalf("reviewLine:\n%s\nwant:\n%s", got, want)
|
||||
}
|
||||
|
||||
failed := reviewLine(api.Review{SnapshotN: 4, State: api.ReviewStateFailed, Error: "boom"})
|
||||
if failed != "review of snapshot 4 failed: boom" {
|
||||
t.Fatalf("failed reviewLine %q", failed)
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package agents_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
)
|
||||
|
||||
const reviewAndAskPrompt = "Create a file hi.txt containing exactly the word hi. " +
|
||||
"Then ask the human with byre's ask_human tool whether to keep the file: blocking, with the choices `- (*) Keep it` and `- ( ) Delete it`. Then end your turn."
|
||||
|
||||
func TestSnapshotReview(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
m := e.manager()
|
||||
|
||||
sess, err := m.Create(e.ctx, e.task, e.user, reviewAndAskPrompt, testModel)
|
||||
if err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
e.waitIdle()
|
||||
|
||||
events := e.waitFor("review finished", func(ev api.Event) bool { return ev.Kind == "review.finished" })
|
||||
finished := events[len(events)-1]
|
||||
review, err := e.store.GetReview(e.ctx, finished.Payload["id"].(string))
|
||||
if err != nil {
|
||||
t.Fatalf("get review: %v", err)
|
||||
}
|
||||
if review.State != api.ReviewStateDone || review.SnapshotN != 1 || review.Vendor != string(api.Codex) || review.Result == nil {
|
||||
t.Fatalf("review %+v", review)
|
||||
}
|
||||
if _, ok := (*review.Result)["summary"].(string); !ok {
|
||||
t.Fatalf("review result has no summary: %+v", *review.Result)
|
||||
}
|
||||
|
||||
questions := e.openQuestions(sess.Id)
|
||||
if len(questions) != 1 || len(questions[0].Reviews) != 1 || questions[0].Reviews[0].Id != review.Id {
|
||||
t.Fatalf("question doesn't show its turn's review: %+v", questions)
|
||||
}
|
||||
|
||||
sessions, err := e.store.ListSessions(e.ctx, e.task.Id)
|
||||
if err != nil || len(sessions) != 1 || sessions[0].Id != sess.Id {
|
||||
t.Fatalf("task sessions %+v %v, want only the implementer", sessions, err)
|
||||
}
|
||||
|
||||
dir := filepath.Join(e.worktrees, e.project.Id, "review-"+review.Id)
|
||||
for range 100 {
|
||||
_, err = os.Stat(dir)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
break
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("review worktree %s still there: %v", dir, err)
|
||||
}
|
||||
reviewer, err := e.store.GetSession(e.ctx, review.AgentSessionId)
|
||||
if err != nil || reviewer.State != api.AgentSessionStateCompleted || reviewer.Role != api.Reviewer {
|
||||
t.Fatalf("reviewer session %+v %v", reviewer, err)
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/git"
|
||||
"g.fc.run/misc/byre/internal/store"
|
||||
)
|
||||
|
||||
func TestSnapshots(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
events := make(chan api.Event, 256)
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "byre.db"), store.DefaultPortFirst, func(ev api.Event) { events <- ev })
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
|
||||
repo := t.TempDir()
|
||||
gitIn := func(dir string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=Root", "GIT_AUTHOR_EMAIL=root@byre.invalid", "GIT_COMMITTER_NAME=Root", "GIT_COMMITTER_EMAIL=root@byre.invalid")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
gitIn(repo, "init", "--initial-branch=main")
|
||||
err = os.WriteFile(filepath.Join(repo, "tracked.txt"), []byte("one\n"), 0o644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gitIn(repo, "add", "tracked.txt")
|
||||
gitIn(repo, "commit", "--message", "root")
|
||||
|
||||
now := time.Now().UTC()
|
||||
_, err = st.EnsureUser(ctx, "alice", "Alice")
|
||||
if err != nil {
|
||||
t.Fatalf("ensure user: %v", err)
|
||||
}
|
||||
project := api.Project{Id: "p1", Name: "demo", RepoPath: repo, DefaultBranch: "main", Remote: "none", CreatedBy: "alice", CreatedAt: now}
|
||||
err = st.CreateProject(ctx, project)
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
task := api.Task{Id: "t1", ProjectId: "p1", Title: "snap", Branch: "byre/t1", WorktreePath: filepath.Join(t.TempDir(), "t1"), Status: api.TaskStatusActive, CreatedBy: "alice", CreatedAt: now}
|
||||
task.BaseRef, err = git.AddWorktree(repo, task.WorktreePath, task.Branch, "main")
|
||||
if err != nil {
|
||||
t.Fatalf("add worktree: %v", err)
|
||||
}
|
||||
err = st.CreateTask(ctx, task, "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("create task: %v", err)
|
||||
}
|
||||
err = st.CreateSession(ctx, api.AgentSession{Id: "s1", TaskId: "t1", Vendor: api.Claude, VendorSessionId: "s1", State: api.AgentSessionStateIdle, Role: api.Implementer, CreatedBy: "alice", CreatedAt: now}, "p1")
|
||||
if err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
|
||||
m := New(st, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
l := &live{id: "s1", dir: task.WorktreePath, gitName: "Alice Test", gitEmail: "alice@byre.invalid", vendor: api.Claude, taskID: "t1", projectID: "p1"}
|
||||
write := func(name, content string) {
|
||||
t.Helper()
|
||||
err := os.WriteFile(filepath.Join(task.WorktreePath, name), []byte(content), 0o644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
snapshots := func() []api.Snapshot {
|
||||
t.Helper()
|
||||
snaps, err := st.ListSnapshots(ctx, "t1")
|
||||
if err != nil {
|
||||
t.Fatalf("list snapshots: %v", err)
|
||||
}
|
||||
return snaps
|
||||
}
|
||||
indexPath := gitIn(task.WorktreePath, "rev-parse", "--path-format=absolute", "--git-path", "index")
|
||||
indexBefore, err := os.ReadFile(indexPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
write("tracked.txt", "two\n")
|
||||
write("untracked.txt", "new\n")
|
||||
taken, err := m.takeSnapshot(ctx, l, api.SnapshotTriggerMessage, 0)
|
||||
if err != nil || taken == nil || taken.N != 1 {
|
||||
t.Fatalf("snapshot: %+v %v", taken, err)
|
||||
}
|
||||
snaps := snapshots()
|
||||
if len(snaps) != 1 || snaps[0].N != 1 || snaps[0].Trigger != api.SnapshotTriggerMessage || snaps[0].AgentSessionId != "s1" {
|
||||
t.Fatalf("after the first change: %+v", snaps)
|
||||
}
|
||||
first := snaps[0]
|
||||
if ref := gitIn(repo, "rev-parse", "refs/byre/tasks/t1/snap/1"); ref != first.CommitSha {
|
||||
t.Fatalf("snap/1 is %s, want %s", ref, first.CommitSha)
|
||||
}
|
||||
if tree := gitIn(repo, "rev-parse", first.CommitSha+"^{tree}"); tree != first.TreeSha {
|
||||
t.Fatalf("commit tree %s, want %s", tree, first.TreeSha)
|
||||
}
|
||||
if parent := gitIn(repo, "rev-parse", first.CommitSha+"^"); parent != task.BaseRef {
|
||||
t.Fatalf("snapshot parent %s, want the worktree HEAD %s", parent, task.BaseRef)
|
||||
}
|
||||
if got := gitIn(repo, "show", first.CommitSha+":untracked.txt"); got != "new" {
|
||||
t.Fatalf("untracked.txt in snapshot: %q", got)
|
||||
}
|
||||
if got := gitIn(repo, "show", first.CommitSha+":tracked.txt"); got != "two" {
|
||||
t.Fatalf("tracked.txt in snapshot: %q", got)
|
||||
}
|
||||
|
||||
indexAfter, err := os.ReadFile(indexPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(indexBefore) != string(indexAfter) {
|
||||
t.Fatalf("snapshot modified the worktree's index")
|
||||
}
|
||||
if staged := gitIn(task.WorktreePath, "diff", "--cached", "--name-only"); staged != "" {
|
||||
t.Fatalf("snapshot staged files in the real index: %s", staged)
|
||||
}
|
||||
|
||||
taken, err = m.takeSnapshot(ctx, l, api.SnapshotTriggerMessage, 1)
|
||||
if err != nil || taken != nil {
|
||||
t.Fatalf("unchanged snapshot: %+v %v", taken, err)
|
||||
}
|
||||
if snaps := snapshots(); len(snaps) != 1 {
|
||||
t.Fatalf("an unchanged tree made a snapshot: %+v", snaps)
|
||||
}
|
||||
|
||||
write("untracked.txt", "newer\n")
|
||||
m.snapshotSoon(l, api.SnapshotTriggerTurnEnd, 0)
|
||||
deadline := time.After(30 * time.Second)
|
||||
for created := false; !created; {
|
||||
select {
|
||||
case ev := <-events:
|
||||
created = ev.Kind == "snapshot.created" && ev.Payload["n"] == float64(2)
|
||||
case <-deadline:
|
||||
t.Fatalf("no snapshot.created event")
|
||||
}
|
||||
}
|
||||
snaps = snapshots()
|
||||
if len(snaps) != 2 || snaps[1].N != 2 || snaps[1].Trigger != api.SnapshotTriggerTurnEnd || snaps[1].EventSeq < first.EventSeq {
|
||||
t.Fatalf("after the second change: %+v", snaps)
|
||||
}
|
||||
if got := gitIn(repo, "show", "refs/byre/tasks/t1/snap/2:untracked.txt"); got != "newer" {
|
||||
t.Fatalf("untracked.txt in snap/2: %q", got)
|
||||
}
|
||||
|
||||
err = m.RemoveTask(ctx, task, project, "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("remove task: %v", err)
|
||||
}
|
||||
_, err = st.DiscardTask(ctx, "t1", "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("discard: %v", err)
|
||||
}
|
||||
if refs := gitIn(repo, "for-each-ref", "refs/byre/"); refs != "" {
|
||||
t.Fatalf("snapshot refs left after discard:\n%s", refs)
|
||||
}
|
||||
if snaps := snapshots(); len(snaps) != 2 {
|
||||
t.Fatalf("snapshot rows after discard: %+v", snaps)
|
||||
}
|
||||
|
||||
m.snapshotSoon(l, api.SnapshotTriggerMessage, 0)
|
||||
if refs := gitIn(repo, "for-each-ref", "refs/byre/"); refs != "" {
|
||||
t.Fatalf("snapshot taken after discard:\n%s", refs)
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/claude"
|
||||
"g.fc.run/misc/byre/internal/store"
|
||||
)
|
||||
|
||||
func TestSubagentEvents(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "byre.db"), store.DefaultPortFirst, func(api.Event) {})
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
|
||||
now := time.Now().UTC()
|
||||
_, err = st.EnsureUser(ctx, "alice", "Alice")
|
||||
if err != nil {
|
||||
t.Fatalf("ensure user: %v", err)
|
||||
}
|
||||
err = st.CreateProject(ctx, api.Project{Id: "p1", Name: "demo", RepoPath: "/nonexistent", DefaultBranch: "main", Remote: "none", CreatedBy: "alice", CreatedAt: now})
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
err = st.CreateTask(ctx, api.Task{Id: "t1", ProjectId: "p1", Title: "t", Branch: "b", WorktreePath: "/nonexistent", BaseRef: "x", Status: api.TaskStatusActive, CreatedBy: "alice", CreatedAt: now}, "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("create task: %v", err)
|
||||
}
|
||||
|
||||
m := New(st, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
l := &live{id: "s1", dir: t.TempDir(), vendor: api.Claude, taskID: "t1", projectID: "p1"}
|
||||
|
||||
feed := func(typ, raw string) {
|
||||
t.Helper()
|
||||
err := m.handle(ctx, l, claude.Event{Type: typ, Raw: []byte(raw)})
|
||||
if err != nil {
|
||||
t.Fatalf("handle %s: %v", raw, err)
|
||||
}
|
||||
}
|
||||
|
||||
feed("assistant", `{"type":"assistant","message":{"model":"m","content":[{"type":"tool_use","id":"toolu_agent","name":"Agent","input":{"subagent_type":"general-purpose","description":"d","prompt":"p"}}],"usage":{"input_tokens":2,"cache_read_input_tokens":100,"output_tokens":17}},"parent_tool_use_id":null}`)
|
||||
feed("assistant", `{"type":"assistant","message":{"model":"m","content":[{"type":"tool_use","id":"toolu_bash","name":"Bash","input":{"command":"ls"}}],"usage":{"input_tokens":2,"cache_creation_input_tokens":33494,"output_tokens":20}},"parent_tool_use_id":"toolu_agent","agent_id":"a1"}`)
|
||||
feed("user", `{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_bash","type":"tool_result","content":"adsb-tools","is_error":false}]},"parent_tool_use_id":"toolu_agent","agent_id":"a1"}`)
|
||||
feed("assistant", `{"type":"assistant","message":{"model":"m","content":[{"type":"text","text":"The first entry is adsb-tools."}],"usage":{"input_tokens":2,"output_tokens":3}},"parent_tool_use_id":"toolu_agent","agent_id":"a1"}`)
|
||||
|
||||
events, err := st.TaskEvents(ctx, "t1", 0, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("task events: %v", err)
|
||||
}
|
||||
got := []string{}
|
||||
for _, ev := range events {
|
||||
if ev.AgentSessionId == nil {
|
||||
continue
|
||||
}
|
||||
parent, _ := ev.Payload["parent_tool_use_id"].(string)
|
||||
got = append(got, ev.Kind+" "+parent)
|
||||
}
|
||||
want := []string{
|
||||
"agent.tool_call ",
|
||||
"session.context ",
|
||||
"agent.tool_call toolu_agent",
|
||||
"agent.tool_result toolu_agent",
|
||||
"agent.message toolu_agent",
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("want %q, got %q", want, got)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("want %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
if l.contextUsed != 119 {
|
||||
t.Fatalf("subagent usage changed the session's context: %d", l.contextUsed)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user