Files
Agent-System-Backend/internal/runtime/sub_loop_test.go
weichen aed45d7f31 Deliver background results through durable nonblocking work cards
Why: Background notice runs blocked chat, truncated results, and duplicated deliverable execution.

Evidence: Focused run manager, card, server, recovery and race regressions pass locally; final combination checks are tracked in Dev.

Impact: Persist full results and optional choices, fence dispatch by run identity, retain public child process, and repair legacy projections without a schema change. No paid model trial was run.

Refs: feature/workbench-flow-completion
2026-10-15 07:05:57 +00:00

266 lines
7.7 KiB
Go

package runtime
import (
nruntime "agent-native/runtime"
"context"
"strings"
"testing"
agentllm "agent-native/runtime/llm"
"agent-system-backend/internal/prompts"
core "agent-system-core"
)
func TestSubagentPublicReasoningIsRetainedAsProcess(t *testing.T) {
rc := &subRC{fakeRunCtx: fakeRunCtx{ctx: context.Background(), prof: subProfile()}}
st := &subRun{rc: rc}
st.onEvent(nruntime.Event{Type: "turn_start"})
st.onEvent(nruntime.Event{Type: "reasoning_delta", Text: "先核对"})
st.onEvent(nruntime.Event{Type: "reasoning_delta", Text: "文档依据"})
st.onEvent(nruntime.Event{Type: "text_delta", Text: "完整的最终结果"})
st.onEvent(nruntime.Event{Type: "message_end"})
if len(st.parts) != 2 || st.parts[0].Type != core.PartThinking || !st.parts[0].Process || st.parts[0].Text != "先核对文档依据" || st.parts[1].Type != core.PartText || st.parts[1].Process {
t.Fatalf("reasoning/final separation lost: %+v", st.parts)
}
}
type subRC struct {
fakeRunCtx
id string
hint string
task string
tools []core.ToolDesc
}
func (s *subRC) SubagentID() string { return s.id }
func (s *subRC) Subhint() string { return s.hint }
func (s *subRC) Subtask() string { return s.task }
func (s *subRC) Tools() []core.ToolDesc { return s.tools }
func subProfile() core.AgentProfile {
return core.AgentProfile{Model: core.ModelRoute{Chat: "test-model", Lightweight: "lite-model"}}
}
func subTools() []core.ToolDesc {
names := []string{
"knowledge_search", "web_search", "delegate_task", "squad_orchestrate",
"run_python", "register_artifact", "write_artifact", "quality_review",
"fs_read_file", "fs_list_dir",
}
out := make([]core.ToolDesc, 0, len(names))
for _, name := range names {
out = append(out, core.ToolDesc{Name: name, Description: name, Parameters: []byte(`{"type":"object"}`)})
}
return out
}
func mustPrompts(t *testing.T) *prompts.Service {
t.Helper()
lib, err := prompts.DefaultLibrary()
if err != nil {
t.Fatal(err)
}
svc, err := prompts.NewService(nil, lib)
if err != nil {
t.Fatal(err)
}
return svc
}
func TestSubagentLoopRunsToolThenFinal(t *testing.T) {
turns := [][]agentllm.Event{
textDeltaTurn("先查一下。", toolCall("knowledge_search", `{"q":"x"}`)),
textDeltaTurn("查完了。"),
}
var decls []agentllm.ToolDecl
var user string
base := scriptStream(turns, nil)
rc := &subRC{
fakeRunCtx: fakeRunCtx{ctx: context.Background(), prof: subProfile()},
id: "agent.sub.explore",
hint: "只要路径",
task: "找出配置文件",
tools: subTools(),
}
r := &SubLLMRunner{
Prompts: mustPrompts(t),
Stream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
if model != "lite-model" {
t.Errorf("explore model = %q", model)
}
if decls == nil {
decls = tools
}
for _, m := range msgs {
if m.Role == agentllm.RoleUser {
user = m.Content
}
}
return base(ctx, model, msgs, tools, opts)
},
}
if err := r.Execute(rc); err != nil {
t.Fatal(err)
}
if rc.toolRuns != 1 {
t.Fatalf("tool runs = %d", rc.toolRuns)
}
if !strings.Contains(user, "找出配置文件") || !strings.Contains(user, "执行提示:只要路径") {
t.Fatalf("user message = %q", user)
}
names := map[string]bool{}
for _, d := range decls {
names[d.Name] = true
}
if !names["knowledge_search"] || names["delegate_task"] || names["squad_orchestrate"] || names["run_python"] {
t.Fatalf("decls = %v", names)
}
var sawFinal, sawProcess bool
for _, p := range rc.assistant {
if p.Type == core.PartText && p.Text == "查完了。" && !p.Process {
sawFinal = true
}
if p.Type == core.PartToolCall {
sawProcess = true
}
}
if !sawFinal || !sawProcess {
t.Fatalf("parts = %+v", rc.assistant)
}
}
func TestSubagentWriterUsesChatModel(t *testing.T) {
var model string
base := scriptStream([][]agentllm.Event{textDeltaTurn("写好了。")}, nil)
rc := &subRC{
fakeRunCtx: fakeRunCtx{ctx: context.Background(), prof: subProfile()},
id: "agent.sub.writer",
task: "写一段",
tools: subTools(),
}
r := &SubLLMRunner{
Prompts: mustPrompts(t),
Stream: func(ctx context.Context, m string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
model = m
return base(ctx, m, msgs, tools, opts)
},
}
if err := r.Execute(rc); err != nil {
t.Fatal(err)
}
if model != "test-model" {
t.Fatalf("writer model = %q", model)
}
}
func TestSubagentMaxTurnsUnbounded(t *testing.T) {
turns := make([][]agentllm.Event, 8)
for i := 0; i < 7; i++ {
turns[i] = textDeltaTurn("", toolCall("knowledge_search", `{}`))
}
turns[7] = textDeltaTurn("计划写完了。")
calls := 0
base := scriptStream(turns, nil)
rc := &subRC{
fakeRunCtx: fakeRunCtx{ctx: context.Background(), prof: subProfile()},
id: "agent.sub.plan",
task: "列步骤",
tools: subTools(),
}
r := &SubLLMRunner{
Prompts: mustPrompts(t),
Stream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
calls++
return base(ctx, model, msgs, tools, opts)
},
}
if err := r.Execute(rc); err != nil {
t.Fatal(err)
}
if calls != 8 {
t.Fatalf("stream calls = %d, want past the old cap of 6", calls)
}
}
func TestSubagentArtifactPart(t *testing.T) {
rc := &subRC{
fakeRunCtx: fakeRunCtx{
ctx: context.Background(),
prof: subProfile(),
execArtifact: &core.Artifact{ID: "art_0123456789ab", Name: "note.md", Kind: "document"},
},
id: "agent.sub.general",
task: "记一笔",
tools: subTools(),
}
r := &SubLLMRunner{
Prompts: mustPrompts(t),
Stream: scriptStream([][]agentllm.Event{
textDeltaTurn("", toolCall("write_artifact", `{}`)),
textDeltaTurn("记下了。"),
}, nil),
}
if err := r.Execute(rc); err != nil {
t.Fatal(err)
}
var saw bool
for _, p := range rc.assistant {
if p.Type == core.PartArtifactRef && p.Artifact != nil && p.Artifact.ArtifactID == "art_0123456789ab" && p.Artifact.Name == "note.md" {
saw = true
}
}
if !saw {
t.Fatalf("parts = %+v", rc.assistant)
}
}
type spawnRC struct {
fakeRunCtx
agent string
called int
}
func (s *spawnRC) SpawnAgent(agentID, name, task, hint string) (core.AgentResultPayload, error) {
s.called++
s.agent = agentID
return core.AgentResultPayload{Summary: "写好了\n工件:art_0123456789ab 说明.md;工件:art_ffffffffffff 没有"}, nil
}
func TestDelegatePinsExistingArtifact(t *testing.T) {
for _, tc := range []struct {
name string
args string
agent string
}{
{name: "writer", args: `{"agent":"writer","name":"写手","task":"写说明"}`, agent: "agent.sub.writer"},
{name: "default", args: `{"name":"助手","task":"看一下"}`, agent: "agent.sub.general"},
} {
t.Run(tc.name, func(t *testing.T) {
rc := &spawnRC{fakeRunCtx: fakeRunCtx{
ctx: context.Background(),
prof: subProfile(),
execArtifact: &core.Artifact{ID: "art_0123456789ab", Name: "说明.md", Kind: "document"},
}}
r := &LLMRunner{Stream: scriptStream([][]agentllm.Event{
textDeltaTurn("", agentllm.ToolCall{ID: "c-del", Name: "delegate_task", Arguments: tc.args}),
textDeltaTurn("完成。"),
}, nil)}
if err := r.Execute(rc); err != nil {
t.Fatal(err)
}
if rc.called != 1 || rc.agent != tc.agent {
t.Fatalf("spawn agent=%q called=%d", rc.agent, rc.called)
}
var pinned []string
for _, p := range rc.assistant {
if p.Type == core.PartArtifactRef && p.Artifact != nil {
pinned = append(pinned, p.Artifact.ArtifactID)
}
}
if len(pinned) != 1 || pinned[0] != "art_0123456789ab" {
t.Fatalf("pinned = %v", pinned)
}
})
}
}