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>
232 lines
5.6 KiB
Go
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
|
|
})
|
|
}
|