Add task sidebar workflow

This commit is contained in:
2026-06-24 12:28:56 +01:00
parent 45263d59f8
commit d133839194
33 changed files with 1890 additions and 134 deletions
+99 -7
View File
@@ -1,11 +1,14 @@
package app
import (
"path/filepath"
"strings"
"testing"
"github.com/hjbdev/patterm/internal/mcp"
"github.com/hjbdev/patterm/internal/preset"
"github.com/hjbdev/patterm/internal/scratchpad"
taskstore "github.com/hjbdev/patterm/internal/task"
)
// mkChild builds a Child without starting a PTY. Use sparingly — the
@@ -100,8 +103,8 @@ func TestClassifySendMessageNilCallerRejectsNonTopLevelTarget(t *testing.T) {
}
}
func TestWrapSubAgentPromptPrependsSystemBlockWhenParented(t *testing.T) {
out := wrapSubAgentPrompt("ship feature X", true)
func TestBuildAgentPromptPrependsSystemBlockWhenParented(t *testing.T) {
out := buildAgentPrompt("ship feature X", true, nil)
if !strings.HasPrefix(out, "[system:") {
t.Fatalf("expected prepended [system: …] block, got %q", out)
}
@@ -119,18 +122,18 @@ func TestWrapSubAgentPromptPrependsSystemBlockWhenParented(t *testing.T) {
}
}
func TestWrapSubAgentPromptPassthroughWhenNoParent(t *testing.T) {
out := wrapSubAgentPrompt("hello", false)
func TestBuildAgentPromptPassthroughWhenNoParent(t *testing.T) {
out := buildAgentPrompt("hello", false, nil)
if out != "hello" {
t.Fatalf("expected passthrough for top-level spawn, got %q", out)
}
}
func TestWrapSubAgentPromptEmptyStaysEmpty(t *testing.T) {
func TestBuildAgentPromptEmptyStaysEmpty(t *testing.T) {
// Empty instructions mean "no inject" upstream; we must not synthesize
// content here or LaunchAgent would type the system block into an
// otherwise-idle agent.
if out := wrapSubAgentPrompt("", true); out != "" {
if out := buildAgentPrompt("", true, nil); out != "" {
t.Fatalf("empty instructions should stay empty, got %q", out)
}
}
@@ -215,7 +218,7 @@ func TestAvailableToolsAdvertisesAllTimerTools(t *testing.T) {
"timer_cancel", "timer_pause", "timer_resume", "timer_list",
}
for _, role := range []mcp.CallerRole{mcp.RoleOrchestrator, mcp.RoleSubAgent} {
tools := availableToolsForRole(role)
tools := availableToolsForRole(role, false)
for _, w := range want {
if !containsString(tools, w) {
t.Fatalf("role %q missing %q in available tools: %v", role, w, tools)
@@ -224,6 +227,95 @@ func TestAvailableToolsAdvertisesAllTimerTools(t *testing.T) {
}
}
func TestWhoAmITaskContextIsConditional(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
tasks, err := taskstore.Open("projkey")
if err != nil {
t.Fatalf("task open: %v", err)
}
task, err := tasks.Create("Fix sidebar")
if err != nil {
t.Fatalf("task create: %v", err)
}
sess := NewSession(t.TempDir(), "projkey")
unbound := newChildEntry("p_unbound", "agent", KindAgent, []string{"sh"}, nil, "", "", "", "")
bound := newChildEntry("p_bound", "agent", KindAgent, []string{"sh"}, nil, "", task.ID, "", "")
addTestChild(sess, unbound)
addTestChild(sess, bound)
h := newToolHost(sess, nil, tasks, nil, preset.Set{}, nil, 80, 24)
if got := h.WhoAmI(unbound.ID, true); got.Task != nil || containsString(got.AvailableTools, "task_register_worktree") {
t.Fatalf("unbound whoami leaked task context/tools: %+v", got)
}
got := h.WhoAmI(bound.ID, true)
if got.Task == nil || got.Task.ID != task.ID || got.Task.Title != task.Title {
t.Fatalf("bound whoami missing task: %+v", got)
}
if !containsString(got.AvailableTools, "task_register_worktree") {
t.Fatalf("bound whoami missing task_register_worktree: %+v", got.AvailableTools)
}
}
func TestRegisterTaskWorktreeResolvesAgainstCallerWorkDir(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
tasks, err := taskstore.Open("projkey")
if err != nil {
t.Fatalf("task open: %v", err)
}
task, err := tasks.Create("Fix sidebar")
if err != nil {
t.Fatalf("task create: %v", err)
}
workDir := t.TempDir()
sess := NewSession(t.TempDir(), "projkey")
caller := newChildEntry("p_bound", "agent", KindAgent, []string{"sh"}, nil, "", task.ID, workDir, "")
addTestChild(sess, caller)
h := newToolHost(sess, nil, tasks, nil, preset.Set{}, nil, 80, 24)
info, err := h.RegisterTaskWorktree(caller.ID, mcp.TaskRegisterWorktreeArgs{Path: "../worktree", Branch: "task-branch"})
if err != nil {
t.Fatalf("register: %v", err)
}
wantPath := filepath.Clean(filepath.Join(workDir, "../worktree"))
if len(info.Worktrees) != 1 || info.Worktrees[0].Path != wantPath || info.Worktrees[0].Branch != "task-branch" || info.Worktrees[0].CreatedByProcessID != caller.ID {
t.Fatalf("registered worktree = %+v, want path %q branch/task", info.Worktrees, wantPath)
}
}
func TestSpawnProcessInheritsCallerTask(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
tasks, err := taskstore.Open("projkey")
if err != nil {
t.Fatalf("task open: %v", err)
}
task, err := tasks.Create("Fix sidebar")
if err != nil {
t.Fatalf("task create: %v", err)
}
sess := NewSession(t.TempDir(), "projkey")
defer sess.Shutdown()
caller := newChildEntry("p_bound", "agent", KindAgent, []string{"sh"}, nil, "", task.ID, "", "")
addTestChild(sess, caller)
launcher := NewLauncher(sess, "", 80, 24)
h := newToolHost(sess, nil, tasks, launcher, preset.Set{}, nil, 80, 24)
info, err := h.SpawnProcess(caller.ID, mcp.SpawnProcessArgs{Argv: []string{"sh", "-lc", "exit 0"}})
if err != nil {
t.Fatalf("spawn process: %v", err)
}
spawned := sess.FindChild(info.ID)
if spawned == nil || spawned.TaskID != task.ID {
t.Fatalf("spawned child task = %+v, want %q", spawned, task.ID)
}
}
func addTestChild(sess *Session, c *Child) {
sess.mu.Lock()
defer sess.mu.Unlock()
sess.children[c.ID] = c
sess.order = append(sess.order, c.ID)
}
// TestHelpTimersDocumentsAllTools mirrors the whoami check for the
// help("timers") topic — the related-tools list must enumerate every
// timer_* tool so callers reading help can dispatch them.