Teach parent agents to clean up the processes they spawn

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.
This commit is contained in:
2026-05-14 21:17:03 +01:00
parent b361d12d14
commit 56fd461fb3
6 changed files with 147 additions and 9 deletions

View File

@@ -97,3 +97,79 @@ func TestClassifySendMessageNilCallerRejectsNonTopLevelTarget(t *testing.T) {
}
}
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
}