Add a `lifecycle` help topic spelling out that the caller owns the
processes it spawns and should `close_process` when a sub-agent or
spawned child is no longer needed. The `spawn_agent` and `spawn_process`
descriptions advertised via `tools/list` now restate the same duty
inline (with a pointer to `help('lifecycle')`), so vendor TUIs see the
expectation at the moment they reach for the tool. The `spawning` topic
and `topics` index cross-reference the new content.
Bundles two already-staged improvements that fall in the same area:
- OnChildSpawned primes the snapshot-replay budget for new panes so
diff-based vendor TUIs come up clean without a manual Ctrl+W/Ctrl+S
refresh.
- TODO drops the three items now actioned (prompt-injection preface,
agent cleanup duty, opencode→claude view corruption) and keeps the
unicode `<?>` entry with the investigation notes.
176 lines
5.6 KiB
Go
176 lines
5.6 KiB
Go
package app
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// 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:<name>], 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 TestWrapSubAgentPromptPrependsSystemBlockWhenParented(t *testing.T) {
|
|
out := wrapSubAgentPrompt("ship feature X", true)
|
|
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 TestWrapSubAgentPromptPassthroughWhenNoParent(t *testing.T) {
|
|
out := wrapSubAgentPrompt("hello", false)
|
|
if out != "hello" {
|
|
t.Fatalf("expected passthrough for top-level spawn, got %q", out)
|
|
}
|
|
}
|
|
|
|
func TestWrapSubAgentPromptEmptyStaysEmpty(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 != "" {
|
|
t.Fatalf("empty instructions should stay empty, got %q", out)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
func containsString(haystack []string, needle string) bool {
|
|
for _, s := range haystack {
|
|
if s == needle {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|