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 // resulting child has no emulator / ring buffer / status pointer set // the way newChild would. func mkChild(id, name, parent string) *Child { return &Child{ID: id, Name: name, ParentID: parent} } func TestClassifySendMessageOrchestratorToChild(t *testing.T) { parent := mkChild("p_aaa", "claude-1", "") child := mkChild("p_bbb", "codex-1", "p_aaa") line, err := classifySendMessage(parent, child, parent.ID, "hi child") if err != nil { t.Fatalf("expected success, got %v", err) } if !strings.HasPrefix(line, "[orchestrator] hi child") { t.Fatalf("parent→child should tag [orchestrator], got %q", line) } } func TestClassifySendMessageChildToParent(t *testing.T) { parent := mkChild("p_aaa", "claude-1", "") child := mkChild("p_bbb", "codex-1", "p_aaa") line, err := classifySendMessage(child, parent, child.ID, "status update") if err != nil { t.Fatalf("expected success, got %v", err) } if !strings.Contains(line, "[sub-agent:codex-1]") { t.Fatalf("child→parent should tag [sub-agent:], got %q", line) } } func TestClassifySendMessageSiblingRejected(t *testing.T) { parent := mkChild("p_aaa", "claude-1", "") sibA := mkChild("p_bbb", "codex-1", "p_aaa") sibB := mkChild("p_ccc", "codex-2", "p_aaa") _ = parent _, err := classifySendMessage(sibA, sibB, sibA.ID, "hey") if err == nil { t.Fatalf("sibling send_message should fail with not_related") } if !strings.Contains(err.Error(), "neither parent nor child") { t.Fatalf("error should mention sibling routing rule, got %v", err) } } func TestClassifySendMessageUnrelatedRejected(t *testing.T) { // Two unrelated top-level processes — they have no shared lineage. a := mkChild("p_aaa", "claude-1", "") b := mkChild("p_bbb", "codex-1", "") _, err := classifySendMessage(a, b, a.ID, "hi") if err == nil { t.Fatalf("unrelated top-level send_message should fail") } } func TestClassifySendMessageCannotSendToSelf(t *testing.T) { c := mkChild("p_aaa", "claude-1", "") _, err := classifySendMessage(c, c, c.ID, "talking to myself") if err == nil { t.Fatalf("self-send should fail") } } func TestClassifySendMessageNilCallerAcceptsTopLevel(t *testing.T) { // Caller arrived over an MCP connection without a resolved patterm // identity (top-level tool client). Target is top-level; should // land as [orchestrator]. target := mkChild("p_aaa", "claude-1", "") line, err := classifySendMessage(nil, target, "" /* unknown caller id */, "go") if err != nil { t.Fatalf("expected success for nil-caller → top-level target, got %v", err) } if !strings.HasPrefix(line, "[orchestrator] go") { t.Fatalf("expected [orchestrator] tag, got %q", line) } } func TestClassifySendMessageNilCallerRejectsNonTopLevelTarget(t *testing.T) { parent := mkChild("p_aaa", "claude-1", "") child := mkChild("p_bbb", "codex-1", "p_aaa") _ = parent _, err := classifySendMessage(nil, child, "", "hi") if err == nil { t.Fatalf("nil caller → non-top-level should fail") } } 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) } if !strings.Contains(out, "send_message") { t.Fatalf("wrapper should mention send_message reporting, got %q", out) } if !strings.Contains(out, "close_process") || !strings.Contains(out, "scratchpad") { t.Fatalf("wrapper should mention cleanup (close_process / scratchpad), got %q", out) } if !strings.HasSuffix(out, "ship feature X") { t.Fatalf("wrapper should keep original instructions at the tail, got %q", out) } if strings.Contains(out, "\n") || strings.Contains(out, "\r") { t.Fatalf("wrapper must be single-line (writeInput splits CR/LF), got %q", out) } } 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 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 := buildAgentPrompt("", true, nil); out != "" { t.Fatalf("empty instructions should stay empty, got %q", out) } } func TestMCPContentCapsPreferRecentStreamBytes(t *testing.T) { got, gotBytes, truncated, dropped := capBytesTail([]byte("abcdefghijklmnop"), 6) if got != "klmnop" || gotBytes != 6 || !truncated || dropped != 10 { t.Fatalf("capBytesTail = (%q, %d, %v, %d)", got, gotBytes, truncated, dropped) } } func TestMCPGridCapKeepsHeadAndTail(t *testing.T) { got, gotBytes, truncated, dropped := capTextMiddle("abcdefghijklmnopqrstuvwxyz", 24) if gotBytes != 24 || !truncated || dropped != 2 { t.Fatalf("capTextMiddle metadata = (%d, %v, %d), content %q", gotBytes, truncated, dropped, got) } if !strings.Contains(got, "...[truncated]...") { t.Fatalf("capTextMiddle missing marker: %q", got) } } func TestScratchpadReadPagesLargeContent(t *testing.T) { t.Setenv("XDG_DATA_HOME", t.TempDir()) store, err := scratchpad.Open("test-project") if err != nil { t.Fatalf("scratchpad open: %v", err) } if _, err := store.Write("notes.md", "abcdefghijklmnopqrstuvwxyz", ""); err != nil { t.Fatalf("scratchpad write: %v", err) } h := &toolHost{pads: store} res, err := h.ScratchpadRead(mcp.ScratchpadReadArgs{Name: "notes.md", Offset: 5, MaxBytes: 7}) if err != nil { t.Fatalf("ScratchpadRead: %v", err) } if res.Content != "fghijkl" || !res.Truncated || res.NextOffset != 12 || res.TotalBytes != 26 { t.Fatalf("ScratchpadRead result = %+v", res) } } func TestHelpLifecycleTopicCoversCleanup(t *testing.T) { resp := helpFor("lifecycle") if resp.Topic != "lifecycle" { t.Fatalf("expected topic=lifecycle, got %q", resp.Topic) } for _, kw := range []string{"close_process", "spawn", "sub-agent"} { if !strings.Contains(resp.Content, kw) { t.Fatalf("lifecycle help should mention %q, got %q", kw, resp.Content) } } if !containsString(resp.RelatedTools, "close_process") { t.Fatalf("lifecycle help should list close_process in related tools, got %v", resp.RelatedTools) } } func TestHelpTopicsIndexListsLifecycle(t *testing.T) { resp := helpFor("topics") if !strings.Contains(resp.Content, "lifecycle") { t.Fatalf("topics index should advertise the lifecycle topic, got %q", resp.Content) } } func TestHelpSpawningPointsAtLifecycle(t *testing.T) { resp := helpFor("spawning") if !strings.Contains(resp.Content, "lifecycle") { t.Fatalf("spawning help should reference the lifecycle topic, got %q", resp.Content) } if !containsString(resp.RelatedTools, "close_process") { t.Fatalf("spawning help should include close_process in related tools, got %v", resp.RelatedTools) } } // TestAvailableToolsAdvertisesAllTimerTools makes sure orchestrators // and sub-agents discover the full timer surface via whoami — not just // timer_wait. Otherwise agents using whoami for orientation would never // learn about timer_set, timer_fire_when_idle_*, timer_pause/resume, // timer_cancel, and timer_list. func TestAvailableToolsAdvertisesAllTimerTools(t *testing.T) { want := []string{ "timer_wait", "timer_set", "timer_fire_when_idle_any", "timer_fire_when_idle_all", "timer_cancel", "timer_pause", "timer_resume", "timer_list", } for _, role := range []mcp.CallerRole{mcp.RoleOrchestrator, mcp.RoleSubAgent} { 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) } } } } 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. func TestHelpTimersDocumentsAllTools(t *testing.T) { resp := helpFor("timers") if resp.Topic != "timers" { t.Fatalf("topic: %q", resp.Topic) } want := []string{ "timer_wait", "timer_set", "timer_fire_when_idle_any", "timer_fire_when_idle_all", "timer_cancel", "timer_pause", "timer_resume", "timer_list", } for _, w := range want { if !containsString(resp.RelatedTools, w) { t.Fatalf("timers help missing %q in related tools: %v", w, resp.RelatedTools) } } } func containsString(haystack []string, needle string) bool { for _, s := range haystack { if s == needle { return true } } return false }