Add todo mode and replace the plan with TODO.md
This commit is contained in:
@@ -3,9 +3,12 @@ package agents
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/claude"
|
||||
"g.fc.run/misc/byre/internal/todo"
|
||||
)
|
||||
|
||||
const guidance = `You are running inside byre. The human reads your work in byre's web UI, not a terminal.
|
||||
@@ -55,11 +58,21 @@ func (m *Manager) bounceProseQuestion(l *live) claude.HookFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func remindGuidance(ctx context.Context, input json.RawMessage) (map[string]any, error) {
|
||||
return map[string]any{
|
||||
"hookSpecificOutput": map[string]any{
|
||||
"hookEventName": "UserPromptSubmit",
|
||||
"additionalContext": guidance,
|
||||
},
|
||||
}, nil
|
||||
func taskGuidance(task api.Task) string {
|
||||
if task.TodoItem == nil {
|
||||
return guidance
|
||||
}
|
||||
return guidance + fmt.Sprintf("\n- This task was started from the item %q in %s at the repo root. When the work is done, remove that item from %s in this worktree, so the removal lands in the same merge as the work.",
|
||||
*task.TodoItem, todo.File, todo.File)
|
||||
}
|
||||
|
||||
func remindGuidance(text string) claude.HookFunc {
|
||||
return func(ctx context.Context, input json.RawMessage) (map[string]any, error) {
|
||||
return map[string]any{
|
||||
"hookSpecificOutput": map[string]any{
|
||||
"hookEventName": "UserPromptSubmit",
|
||||
"additionalContext": text,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -559,15 +559,16 @@ func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task ap
|
||||
projectID: task.ProjectId,
|
||||
}
|
||||
|
||||
g := taskGuidance(task)
|
||||
opts := claude.Options{
|
||||
Cwd: task.WorktreePath,
|
||||
Model: sess.Model,
|
||||
GitName: creator.GitName,
|
||||
GitEmail: creator.GitEmail,
|
||||
Guidance: guidance,
|
||||
Guidance: g,
|
||||
MCPServers: []*mcp.Server{{Name: "byre", Version: "0.0.0", Tools: []mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.createTaskTool(l), m.setMergeTitleTool(l)}}},
|
||||
Hooks: map[string][]claude.HookMatcher{
|
||||
"UserPromptSubmit": {{Func: remindGuidance}},
|
||||
"UserPromptSubmit": {{Func: remindGuidance(g)}},
|
||||
"Stop": {{Func: m.bounceProseQuestion(l)}},
|
||||
"PostToolUse": {{Func: m.snapshotAfterTool(l)}},
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ package agents_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -873,6 +874,68 @@ func TestCreateTaskTool(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartTodo(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
m := e.manager()
|
||||
|
||||
writeTestFile(t, filepath.Join(e.repo, "TODO.md"), "# Todo\n\n- Create hello.txt\n It holds the single line `hi`.\n- Leave this one alone\n")
|
||||
gitOut(t, e.repo, "add", "TODO.md")
|
||||
gitOut(t, e.repo, "commit", "--quiet", "--message", "add todo")
|
||||
|
||||
_, err := m.StartTodo(e.ctx, e.project, e.user, "No such item", testModel)
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("start unknown item: %v", err)
|
||||
}
|
||||
|
||||
task, err := m.StartTodo(e.ctx, e.project, e.user, "Create hello.txt", testModel)
|
||||
if err != nil {
|
||||
t.Fatalf("start todo: %v", err)
|
||||
}
|
||||
if task.TodoItem == nil || *task.TodoItem != "Create hello.txt" || task.Title != "Create hello.txt" {
|
||||
t.Fatalf("todo task %+v", task)
|
||||
}
|
||||
|
||||
_, err = m.StartTodo(e.ctx, e.project, e.user, "Create hello.txt", testModel)
|
||||
if !errors.Is(err, agents.ErrConflict) {
|
||||
t.Fatalf("second start of the same item: %v", err)
|
||||
}
|
||||
|
||||
events := e.waitFor("todo turn end", func(ev api.Event) bool {
|
||||
return ev.Kind == "turn.ended" && ev.TaskId != nil && *ev.TaskId == task.Id
|
||||
})
|
||||
for _, ev := range events {
|
||||
if ev.Kind == "user.message" && ev.TaskId != nil && *ev.TaskId == task.Id {
|
||||
text, _ := ev.Payload["text"].(string)
|
||||
if !strings.Contains(text, "- Create hello.txt\n It holds the single line `hi`.") || !strings.Contains(text, "remove this item from TODO.md") {
|
||||
t.Fatalf("first prompt %q", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stored, err := e.store.GetTask(e.ctx, task.Id)
|
||||
if err != nil || stored.TodoItem == nil || *stored.TodoItem != "Create hello.txt" {
|
||||
t.Fatalf("stored task %+v %v", stored, err)
|
||||
}
|
||||
|
||||
todoAfter, err := os.ReadFile(filepath.Join(task.WorktreePath, "TODO.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read worktree TODO.md: %v", err)
|
||||
}
|
||||
t.Logf("TODO.md after the turn:\n%s", todoAfter)
|
||||
if strings.Contains(string(todoAfter), "Create hello.txt") || !strings.Contains(string(todoAfter), "Leave this one alone") {
|
||||
t.Fatalf("agent didn't remove exactly its item from TODO.md")
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
err := os.WriteFile(path, []byte(content), 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuestionReachesInbox(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
|
||||
@@ -13,9 +13,58 @@ import (
|
||||
"g.fc.run/misc/byre/internal/git"
|
||||
"g.fc.run/misc/byre/internal/mcp"
|
||||
"g.fc.run/misc/byre/internal/store"
|
||||
"g.fc.run/misc/byre/internal/todo"
|
||||
)
|
||||
|
||||
func (m *Manager) CreateTask(ctx context.Context, project api.Project, title, createdBy, base string) (api.Task, error) {
|
||||
return m.createTask(ctx, project, title, createdBy, base, nil)
|
||||
}
|
||||
|
||||
func (m *Manager) StartTodo(ctx context.Context, project api.Project, creator api.User, title, model string) (api.Task, error) {
|
||||
if creator.GitName == "" || creator.GitEmail == "" {
|
||||
return api.Task{}, ErrNoGitIdentity
|
||||
}
|
||||
|
||||
src, found, err := git.ReadFile(project.RepoPath, project.DefaultBranch, todo.File)
|
||||
if err != nil {
|
||||
return api.Task{}, err
|
||||
}
|
||||
if !found {
|
||||
return api.Task{}, fmt.Errorf("%w: %s has no %s", store.ErrNotFound, project.DefaultBranch, todo.File)
|
||||
}
|
||||
item, found := todo.Find(todo.Parse(src), title)
|
||||
if !found {
|
||||
return api.Task{}, fmt.Errorf("%w: %s on %s has no item %q", store.ErrNotFound, todo.File, project.DefaultBranch, title)
|
||||
}
|
||||
|
||||
tasks, err := m.store.ListTasks(ctx, project.Id)
|
||||
if err != nil {
|
||||
return api.Task{}, err
|
||||
}
|
||||
for _, t := range tasks {
|
||||
if t.Status == api.TaskStatusActive && t.TodoItem != nil && *t.TodoItem == title {
|
||||
return api.Task{}, fmt.Errorf("%w: task %s is already working on %q", ErrConflict, t.Id, title)
|
||||
}
|
||||
}
|
||||
|
||||
task, err := m.createTask(ctx, project, item.Title, creator.Login, project.DefaultBranch, &item.Title)
|
||||
if err != nil {
|
||||
return api.Task{}, err
|
||||
}
|
||||
|
||||
_, err = m.create(ctx, task, creator, todoPrompt(item), model, creator.Login, nil)
|
||||
if err != nil {
|
||||
return api.Task{}, fmt.Errorf("task %s created, but its agent did not start: %w", task.Id, err)
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func todoPrompt(item api.TodoItem) string {
|
||||
return fmt.Sprintf("Work on this item from %[1]s at the repo root:\n\n%[2]s\n\nWhen the work is done, remove this item from %[1]s in this worktree, so the removal lands in the same merge as the work.",
|
||||
todo.File, todo.Markdown(item))
|
||||
}
|
||||
|
||||
func (m *Manager) createTask(ctx context.Context, project api.Project, title, createdBy, base string, todoItem *string) (api.Task, error) {
|
||||
id := store.NewID()
|
||||
t := api.Task{
|
||||
Id: id,
|
||||
@@ -27,6 +76,7 @@ func (m *Manager) CreateTask(ctx context.Context, project api.Project, title, cr
|
||||
Status: api.TaskStatusActive,
|
||||
CreatedBy: createdBy,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
TodoItem: todoItem,
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
Reference in New Issue
Block a user