Why: Allow consumers to download an immutable module revision without nested source checkouts or local path replacements. Evidence: SELF package tests and the prior canonical-path rejection are recorded in the Dev dependency-workspace task card; remote consumption is replayed against the committed candidate. Impact: Change the module identity and its Go imports while preserving API behavior. Existing historical submodule revisions remain buildable. Refs: Versioned dependencies U1/U3; feature/dependency-workspace
214 lines
6.5 KiB
Go
214 lines
6.5 KiB
Go
package runmgr
|
||
|
||
import (
|
||
"errors"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
|
||
"git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/cardsvc"
|
||
"git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/runtime"
|
||
core "git-ssh.u1680832.nyat.app/weichen/Agent-System-Core.git"
|
||
)
|
||
|
||
// newCardSpawnEnv 在后台派发环境上接线收件箱卡片服务,并捕获发出的卡片事件
|
||
// (任务快慢双速:慢通道进度卡 → 完成回投的验证环境)。
|
||
func newCardSpawnEnv(t *testing.T, runner runtime.Runner) (*runCtx, *[]string) {
|
||
t.Helper()
|
||
c, _ := newSpawnEnv(t, runner)
|
||
var mu sync.Mutex
|
||
var events []string
|
||
c.mgr.cards = cardsvc.New(c.mgr.st, func(conversationID, runID string, typ core.StreamEventType, payload any) error {
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
events = append(events, string(typ))
|
||
return nil
|
||
})
|
||
return c, &events
|
||
}
|
||
|
||
func cardEvents(list *[]string) []string {
|
||
var out []string
|
||
for _, e := range *list {
|
||
if strings.HasPrefix(e, "card.") {
|
||
out = append(out, e)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func TestBackgroundCardLifecycleOnSuccess(t *testing.T) {
|
||
runner := &captureRunner{parts: []core.Part{
|
||
{Type: core.PartText, Text: "深度解析完成:三份材料均相关,要点已汇总。"},
|
||
{Type: core.PartArtifactRef, Artifact: &core.ArtifactRef{ArtifactID: "art_0123456789ab", Name: "解析摘要.md"}},
|
||
}}
|
||
c, events := newCardSpawnEnv(t, runner)
|
||
c.mgr.runner = noopRunner{}
|
||
if _, err := c.SpawnBackground("agent.sub.general", "深度解析员", "逐页解析上传材料", ""); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
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
|
||
})
|
||
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(cards) != 1 {
|
||
t.Fatalf("cards = %d, want 1", len(cards))
|
||
}
|
||
card := cards[0]
|
||
if card.Kind != core.CardKindProgress || card.Title != "深度解析员" {
|
||
t.Fatalf("card = %+v", card)
|
||
}
|
||
if card.Status != core.CardDone || card.Tone != "ok" {
|
||
t.Fatalf("status = %s tone = %s, want done/ok", card.Status, card.Tone)
|
||
}
|
||
var md string
|
||
for _, n := range card.Body {
|
||
if n.Type == core.UIMarkdown {
|
||
md = n.Markdown
|
||
}
|
||
}
|
||
if !strings.Contains(md, "深度解析完成") || !strings.Contains(md, "解析摘要.md") {
|
||
t.Fatalf("result body = %q", md)
|
||
}
|
||
if card.SourceRunID == "" {
|
||
t.Fatal("card not linked to child run")
|
||
}
|
||
got := cardEvents(events)
|
||
for _, want := range []string{"card.created", "card.updated", "card.status"} {
|
||
found := false
|
||
for _, e := range got {
|
||
if e == want {
|
||
found = true
|
||
}
|
||
}
|
||
if !found {
|
||
t.Fatalf("events %v missing %s", got, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestBackgroundCardLifecycleOnFailure(t *testing.T) {
|
||
runner := &gateRunner{err: errors.New("boom: 材料解析失败")}
|
||
c, _ := newCardSpawnEnv(t, runner)
|
||
c.mgr.runner = noopRunner{}
|
||
if _, err := c.SpawnBackground("agent.sub.general", "深度解析员", "逐页解析上传材料", ""); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
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
|
||
})
|
||
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(cards) != 1 {
|
||
t.Fatalf("cards = %d, want 1", len(cards))
|
||
}
|
||
card := cards[0]
|
||
if card.Status != core.CardFailed || card.Tone != "danger" {
|
||
t.Fatalf("status = %s tone = %s, want failed/danger", card.Status, card.Tone)
|
||
}
|
||
var md string
|
||
statusLabel := ""
|
||
for _, n := range card.Body {
|
||
if n.Type == core.UIMarkdown {
|
||
md = n.Markdown
|
||
}
|
||
if n.Type == core.UIKV {
|
||
for _, kv := range n.KV {
|
||
if kv[0] == core.TaskSummaryKVStatus {
|
||
statusLabel = kv[1]
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if !strings.Contains(md, "执行失败") {
|
||
t.Fatalf("failure body = %q", md)
|
||
}
|
||
if statusLabel != core.TaskSummaryStatusFailed {
|
||
t.Fatalf("status kv = %q, want %q", statusLabel, core.TaskSummaryStatusFailed)
|
||
}
|
||
}
|
||
|
||
func TestBackgroundCardCreateUsesL1SkeletonNotRawTask(t *testing.T) {
|
||
runner := &holdRunner{release: make(chan struct{})}
|
||
c, _ := newCardSpawnEnv(t, runner)
|
||
c.mgr.runner = noopRunner{}
|
||
raw := "逐页解析上传材料并对照验收条件" + strings.Repeat("LONGTASK", 40)
|
||
if _, err := c.SpawnBackground("agent.sub.general", "深度解析员", raw, ""); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
waitUntil(t, func() bool {
|
||
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
|
||
return err == nil && len(cards) == 1
|
||
})
|
||
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
card := cards[0]
|
||
var md string
|
||
for _, n := range card.Body {
|
||
if n.Type == core.UIMarkdown {
|
||
md = n.Markdown
|
||
}
|
||
}
|
||
if strings.Contains(md, "LONGTASK") || strings.Contains(md, raw[:32]) {
|
||
t.Fatalf("create body still embeds raw task: %q", md)
|
||
}
|
||
if md != core.TaskSummaryNoFindingsYet {
|
||
t.Fatalf("create markdown = %q, want honest placeholder", md)
|
||
}
|
||
close(runner.release)
|
||
waitUntil(t, func() bool {
|
||
c.mgr.convMu.Lock()
|
||
busy := c.mgr.convActive[c.run.ConversationID]
|
||
c.mgr.convMu.Unlock()
|
||
return !busy
|
||
})
|
||
}
|
||
|
||
// TestBackgroundCardSkippedWithoutInbox:收件箱未启用(cards==nil)时后台派发
|
||
// 不建卡也不报错——既有 background_test 环境即此形态,这里显式断言不回归。
|
||
func TestBackgroundCardSkippedWithoutInbox(t *testing.T) {
|
||
runner := &captureRunner{parts: []core.Part{{Type: core.PartText, Text: "完成"}}}
|
||
c, _ := newSpawnEnv(t, runner)
|
||
c.mgr.runner = noopRunner{}
|
||
if c.mgr.cards != nil {
|
||
t.Fatal("env unexpectedly wired cards")
|
||
}
|
||
if _, err := c.SpawnBackground("agent.sub.general", "后台", "任务", ""); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
waitUntil(t, func() bool { return len(systemTexts(t, c)) == 1 })
|
||
}
|
||
|
||
// TestPartsTextStripsInlineThink:MiniMax 行内 <think> 块不进子代理摘要/回投卡。
|
||
func TestPartsTextStripsInlineThink(t *testing.T) {
|
||
got := partsText([]core.Part{{Type: core.PartText, Text: "<think>先读文件</think>\n正文开始\n<think>再想</think>结论"}})
|
||
if strings.Contains(got, "<think>") || strings.Contains(got, "先读文件") || strings.Contains(got, "再想") {
|
||
t.Fatalf("think leaked: %q", got)
|
||
}
|
||
if !strings.Contains(got, "正文开始") || !strings.Contains(got, "结论") {
|
||
t.Fatalf("body lost: %q", got)
|
||
}
|
||
if got := partsText([]core.Part{{Type: core.PartText, Text: "半截 <think> 未闭合保守保留"}}); !strings.Contains(got, "半截") {
|
||
t.Fatalf("unclosed think should keep text: %q", got)
|
||
}
|
||
}
|