Files
Agent-System-Backend/internal/runmgr/background_test.go
T
weichenandCursor 46ce1b2c8e Let subagents run in the background and be reviewed
Why: A parent turn should be able to finish while children keep working, and a review should keep only findings that a second pass confirms. Users also need to override a profile's tools, model, and turn cap without adding dispatch tools.
Evidence: go test ./... passed. New cases cover overlay rejection, role mapping, queued notices, and dropping FAIL or unparsed findings.
Impact: Notices live only in memory, and each conversation allows three background children. Squad writer steps now use the writer profile.
Refs: feature/agent-harness-p3b
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 15:29:01 +00:00

232 lines
5.6 KiB
Go

package runmgr
import (
"strings"
"sync"
"testing"
"time"
"agent-system-backend/internal/runtime"
core "agent-system-core"
)
type gateRunner struct {
entered chan struct{}
release chan struct{}
once sync.Once
parts []core.Part
err error
spawn func(runtime.RunCtx) error
}
func (g *gateRunner) Name() string { return "gate" }
func (g *gateRunner) Execute(rc runtime.RunCtx) error {
if g.entered != nil {
g.once.Do(func() { close(g.entered) })
}
if g.release != nil {
<-g.release
}
if g.spawn != nil {
return g.spawn(rc)
}
if g.err != nil {
return g.err
}
if len(g.parts) > 0 {
return rc.SetAssistantParts(g.parts)
}
return nil
}
type noopRunner struct{}
func (noopRunner) Name() string { return "noop" }
func (noopRunner) Execute(rc runtime.RunCtx) error {
return rc.SetAssistantParts([]core.Part{{Type: core.PartText, Text: "已汇报"}})
}
func waitUntil(t *testing.T, cond func() bool) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("timed out")
}
func systemTexts(t *testing.T, c *runCtx) []string {
t.Helper()
msgs, err := c.mgr.st.ListMessages(c.run.ConversationID)
if err != nil {
t.Fatal(err)
}
var out []string
for _, m := range msgs {
if m.Role != core.RoleSystem {
continue
}
for _, p := range m.Parts {
if p.Type == core.PartText {
out = append(out, p.Text)
}
}
}
return out
}
func TestBackgroundDoesNotBlockParent(t *testing.T) {
entered := make(chan struct{})
release := make(chan struct{})
runner := &gateRunner{
entered: entered, release: release,
parts: []core.Part{{Type: core.PartText, Text: "后台结果"}},
}
c, _ := newSpawnEnv(t, runner)
c.mgr.runner = noopRunner{}
id, err := c.SpawnBackground("agent.sub.explore", "检索", "查资料", "")
if err != nil || id == "" {
t.Fatalf("spawn: %v %s", err, id)
}
select {
case <-entered:
case <-time.After(time.Second):
t.Fatal("child did not start")
}
close(release)
waitUntil(t, func() bool {
if len(systemTexts(t, c)) != 1 {
return false
}
c.mgr.convMu.Lock()
busy := c.mgr.convActive[c.run.ConversationID]
c.mgr.convMu.Unlock()
return !busy
})
text := systemTexts(t, c)[0]
if !strings.Contains(text, "检索") || !strings.Contains(text, "<system-reminder>") || !strings.Contains(text, "后台结果") {
t.Fatalf("notice = %s", text)
}
}
func TestBackgroundQueuesUntilIdleThenMerges(t *testing.T) {
runner := &gateRunner{parts: []core.Part{{Type: core.PartText, Text: "完成了"}}}
c, st := newSpawnEnv(t, runner)
c.mgr.runner = noopRunner{}
if err := c.mgr.claimConversation(c.run.ConversationID); err != nil {
t.Fatal(err)
}
if _, err := c.SpawnBackground("agent.sub.general", "甲", "任务甲", ""); err != nil {
t.Fatal(err)
}
if _, err := c.SpawnBackground("agent.sub.general", "乙", "任务乙", ""); err != nil {
t.Fatal(err)
}
waitUntil(t, func() bool {
c.mgr.noticeMu.Lock()
defer c.mgr.noticeMu.Unlock()
return len(c.mgr.notices[c.run.ConversationID]) == 2
})
if len(systemTexts(t, c)) != 0 {
t.Fatal("notice delivered while conversation was busy")
}
c.mgr.releaseConversation(c.run.ConversationID)
c.mgr.deliverNotices(c.run.ConversationID)
waitUntil(t, func() bool {
if len(systemTexts(t, c)) != 1 {
return false
}
c.mgr.convMu.Lock()
busy := c.mgr.convActive[c.run.ConversationID]
c.mgr.convMu.Unlock()
return !busy
})
text := systemTexts(t, c)[0]
if !strings.Contains(text, "甲") || !strings.Contains(text, "乙") {
t.Fatalf("merged notice = %s", text)
}
runs, err := st.ListRunsByConversation(c.run.ConversationID)
if err != nil {
t.Fatal(err)
}
conv := 0
for _, r := range runs {
if r.Kind == core.RunConversation {
conv++
}
}
if conv != 1 {
t.Fatalf("conversation runs = %d, want 1", conv)
}
hist := c.History()
seen := false
for _, m := range hist {
if m.Role == core.RoleSystem {
seen = true
}
}
if !seen {
t.Fatal("history dropped the notice")
}
}
func TestBackgroundChildCannotDelegate(t *testing.T) {
got := make(chan error, 1)
runner := &gateRunner{spawn: func(rc runtime.RunCtx) error {
_, err := rc.(interface {
SpawnAgent(agentID, name, task, hint string) (core.AgentResultPayload, error)
}).SpawnAgent("agent.sub.general", "再派", "继续", "")
got <- err
return nil
}}
c, _ := newSpawnEnv(t, runner)
c.mgr.runner = noopRunner{}
if _, err := c.SpawnBackground("agent.sub.general", "后台", "任务", ""); err != nil {
t.Fatal(err)
}
defer func() {
waitUntil(t, func() bool {
c.mgr.convMu.Lock()
busy := c.mgr.convActive[c.run.ConversationID]
c.mgr.convMu.Unlock()
return !busy && len(systemTexts(t, c)) == 1
})
}()
select {
case err := <-got:
if err == nil || !strings.Contains(err.Error(), "子代理不能再派发子代理") {
t.Fatalf("err = %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("child did not try to delegate")
}
}
func TestBackgroundCap(t *testing.T) {
release := make(chan struct{})
runner := &gateRunner{release: release}
c, _ := newSpawnEnv(t, runner)
c.mgr.runner = noopRunner{}
if err := c.mgr.claimConversation(c.run.ConversationID); err != nil {
t.Fatal(err)
}
for i := 0; i < 3; i++ {
if _, err := c.SpawnBackground("agent.sub.general", "n", "t", ""); err != nil {
t.Fatal(err)
}
}
if _, err := c.SpawnBackground("agent.sub.general", "overflow", "t", ""); err == nil || !strings.Contains(err.Error(), "3") {
t.Fatalf("cap err = %v", err)
}
close(release)
waitUntil(t, func() bool {
c.mgr.noticeMu.Lock()
defer c.mgr.noticeMu.Unlock()
return c.mgr.bgInFlight[c.run.ConversationID] == 0
})
}