Files
Agent-System-Backend/internal/runmgr/subagent_n2_test.go
T
weichen f1302ed5b3 Enable canonical versioned Go module consumption
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
2026-10-13 15:47:16 +00:00

224 lines
8.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package runmgr
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/store"
core "git-ssh.u1680832.nyat.app/weichen/Agent-System-Core.git"
)
// ---- N2 WP5:子代理独立转录、中断续跑、通知持久化 ----
// 派生即落 request.json;完成时 transcript.jsonl + done.json 落定。
func TestSubagentTranscriptFilesWritten(t *testing.T) {
c, _ := newSpawnEnv(t, &gateRunner{parts: []core.Part{{Type: core.PartText, Text: "结果文本"}}})
ws := t.TempDir()
c.mgr.SetWorkspaceRoot(ws)
res, err := c.SpawnSubagent("检索", "查资料", "提示语")
if err != nil {
t.Fatal(err)
}
dir := filepath.Join(ws, c.run.Owner, c.run.ConversationID, "subagents", res.AgentRun)
reqBody, err := os.ReadFile(filepath.Join(dir, "request.json"))
if err != nil {
t.Fatalf("request.json: %v", err)
}
if !strings.Contains(string(reqBody), "查资料") {
t.Fatalf("request.json 内容:%s", reqBody)
}
trBody, err := os.ReadFile(filepath.Join(dir, "transcript.jsonl"))
if err != nil || !strings.Contains(string(trBody), "结果文本") {
t.Fatalf("transcript.jsonl:%v %s", err, trBody)
}
doneBody, err := os.ReadFile(filepath.Join(dir, "done.json"))
if err != nil || !strings.Contains(string(doneBody), "completed") {
t.Fatalf("done.json:%v %s", err, doneBody)
}
}
// 中断子 run 凭 request.json 续跑:同任务重派、hint 带前次转录路径、
// resumed.json 防重复续跑。
func TestResumeInterruptedChildrenRespawns(t *testing.T) {
runner := &gateRunner{parts: []core.Part{{Type: core.PartText, Text: "续跑结果"}}}
c, st := newSpawnEnv(t, runner)
c.mgr.runner = noopRunner{}
ws := t.TempDir()
c.mgr.SetWorkspaceRoot(ws)
// 父 run 落库(续跑要经 store 取回派生上下文)。
parentRun := c.run
if err := st.CreateRun(&parentRun); err != nil {
t.Fatal(err)
}
// 造一个中断的子 run 现场(等价于崩溃前的派生)。
child := core.Run{
ID: store.NewID("run"), ParentRunID: c.run.ID, ConversationID: c.run.ConversationID,
ProjectID: c.run.ProjectID, Owner: c.run.Owner, Kind: core.RunSubagent, ProfileID: c.run.ProfileID,
Status: core.RunRunning, CreatedAt: time.Now().UTC(),
}
if err := st.CreateRun(&child); err != nil {
t.Fatal(err)
}
c.mgr.writeSubagentRequest(child, "agent.sub.explore", "检索", "原始任务", "原始提示", true)
if _, err := st.SealInterrupted(child.ID); err != nil {
t.Fatal(err)
}
before, err := st.SubagentRunIDs(c.run.ConversationID)
if err != nil {
t.Fatal(err)
}
c.mgr.ResumeInterruptedChildren()
ids, err := st.SubagentRunIDs(c.run.ConversationID)
if err != nil {
t.Fatal(err)
}
if len(ids) != len(before)+1 {
t.Fatalf("续跑应新增一个子 run:before=%v after=%v", before, ids)
}
// resumed.json 幂等:第二次调用不再新增。
c.mgr.ResumeInterruptedChildren()
ids2, _ := st.SubagentRunIDs(c.run.ConversationID)
if len(ids2) != len(ids) {
t.Fatalf("重复续跑:%v → %v", ids, ids2)
}
}
// 通知先落库后投递;模拟重启后 LoadPendingNotices 重投并清库。
func TestNoticesPersistAndRedeliverAfterRestart(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)
}
waitUntil(t, func() bool {
items, err := st.ListNotices()
return err == nil && len(items) == 1
})
// 模拟重启:同一 store 上的新 Manager 只接通知面(会话仍忙时不应丢)。
c.mgr.releaseConversation(c.run.ConversationID)
m2 := New(st, c.mgr.bus, nil, c.mgr.profiles, c.mgr.settings, noopRunner{}, runner, nil, nil)
m2.LoadPendingNotices()
waitUntil(t, func() bool {
items, err := st.ListNotices()
return err == nil && len(items) == 0
})
texts := systemTexts(t, c)
if len(texts) == 0 || !strings.Contains(texts[len(texts)-1], "后台结果") {
t.Fatalf("重投的通知正文缺失:%v", texts)
}
}
// 适配层 Background 分支:Request.Background=true → SpawnBackground(语义
// 与产品 delegate 的 background 参数一致)。在本包内用 runCtx 直验。
func TestSpawnBackgroundRecordsDetach(t *testing.T) {
runner := &gateRunner{parts: []core.Part{{Type: core.PartText, Text: "bg"}}}
c, _ := newSpawnEnv(t, runner)
c.mgr.runner = noopRunner{}
id, err := c.SpawnBackground("agent.sub.general", "后台", "任务", "")
if err != nil {
t.Fatal(err)
}
run, err := c.mgr.st.GetRunByID(id)
if err != nil {
t.Fatal(err)
}
if run.Kind != core.RunSubagent || run.Status != core.RunCompleted && run.Status != core.RunRunning {
t.Fatalf("run = %+v", run)
}
}
// N3 WP1:done.json 语义分流——优雅停机写的 interrupted 不挡续跑(一次且仅一次);
// completed 终态挡续跑。
func TestResumeAfterGracefulInterruptMarker(t *testing.T) {
runner := &gateRunner{parts: []core.Part{{Type: core.PartText, Text: "续跑结果"}}}
c, st := newSpawnEnv(t, runner)
c.mgr.runner = noopRunner{}
ws := t.TempDir()
c.mgr.SetWorkspaceRoot(ws)
parentRun := c.run
if err := st.CreateRun(&parentRun); err != nil {
t.Fatal(err)
}
child := core.Run{
ID: store.NewID("run"), ParentRunID: c.run.ID, ConversationID: c.run.ConversationID,
ProjectID: c.run.ProjectID, Owner: c.run.Owner, Kind: core.RunSubagent, ProfileID: c.run.ProfileID,
Status: core.RunRunning, CreatedAt: time.Now().UTC(),
}
if err := st.CreateRun(&child); err != nil {
t.Fatal(err)
}
c.mgr.writeSubagentRequest(child, "agent.sub.explore", "检索", "原始任务", "原始提示", true)
// 模拟优雅停机:partial 转录 + done.json{status:"interrupted"}。
c.mgr.writeSubagentTranscript(child, []core.Part{{Type: core.PartText, Text: "半截现场"}}, "interrupted")
if _, err := st.SealInterrupted(child.ID); err != nil {
t.Fatal(err)
}
before, _ := st.SubagentRunIDs(c.run.ConversationID)
c.mgr.ResumeInterruptedChildren()
ids, _ := st.SubagentRunIDs(c.run.ConversationID)
if len(ids) != len(before)+1 {
t.Fatalf("优雅停机现场应被续跑一次:before=%v after=%v", before, ids)
}
c.mgr.ResumeInterruptedChildren()
ids2, _ := st.SubagentRunIDs(c.run.ConversationID)
if len(ids2) != len(ids) {
t.Fatalf("重复续跑:%v → %v", ids, ids2)
}
}
func TestResumeSkipsCompletedMarker(t *testing.T) {
c, st := newSpawnEnv(t, &gateRunner{})
c.mgr.runner = noopRunner{}
ws := t.TempDir()
c.mgr.SetWorkspaceRoot(ws)
parentRun := c.run
if err := st.CreateRun(&parentRun); err != nil {
t.Fatal(err)
}
child := core.Run{
ID: store.NewID("run"), ParentRunID: c.run.ID, ConversationID: c.run.ConversationID,
ProjectID: c.run.ProjectID, Owner: c.run.Owner, Kind: core.RunSubagent, ProfileID: c.run.ProfileID,
Status: core.RunRunning, CreatedAt: time.Now().UTC(),
}
if err := st.CreateRun(&child); err != nil {
t.Fatal(err)
}
c.mgr.writeSubagentRequest(child, "agent.sub.explore", "检索", "原始任务", "", true)
c.mgr.writeSubagentTranscript(child, []core.Part{{Type: core.PartText, Text: "已完成"}}, "completed")
if _, err := st.SealInterrupted(child.ID); err != nil {
t.Fatal(err)
}
before, _ := st.SubagentRunIDs(c.run.ConversationID)
c.mgr.ResumeInterruptedChildren()
ids, _ := st.SubagentRunIDs(c.run.ConversationID)
if len(ids) != len(before) {
t.Fatalf("completed 终态不得续跑:before=%v after=%v", before, ids)
}
}
// N3 WP6:子代理记账链——AddUsage 累计拆分字段,finishChild 合并进 child.Usage。
func TestAddUsageAccumulatesSplitFields(t *testing.T) {
c, _ := newSpawnEnv(t, &gateRunner{})
c.h = &runHandle{}
c.AddUsage(core.Usage{PromptTokens: 100, CompletionTokens: 10, LastPromptTokens: 100, LastCompletionTokens: 10, CacheReadTokens: 50, CacheWriteTokens: 4})
c.AddUsage(core.Usage{PromptTokens: 60, CompletionTokens: 6, LastPromptTokens: 60, LastCompletionTokens: 6, CacheReadTokens: 20, CacheWriteTokens: 2})
c.mgr.mu.Lock()
defer c.mgr.mu.Unlock()
if c.h.promptTokens != 160 || c.h.completionTokens != 16 ||
c.h.lastPromptTokens != 60 || c.h.lastCompletionTokens != 6 ||
c.h.cacheReadTokens != 70 || c.h.cacheWriteTokens != 6 {
t.Fatalf("记账链:%+v", c.h)
}
}