Why: models need a capped way to call several tools from one script, and a slow MCP server must not block the first prompt. Evidence: GOWORK=off go test ./runtime/codeorch/ ./runtime/tools/ ./runtime/mcp/ ./runtime/policy/ ./adapters/native/ Impact: run_tool_script stays unregistered until the caller turns it on. Deferred tools stay out of the model list. An unready MCP server connects in the background. Refs: feature/code-orchestration Co-authored-by: Cursor <cursoragent@cursor.com>
411 lines
12 KiB
Go
411 lines
12 KiB
Go
package native
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
osexec "os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"agent-native/exec"
|
|
"agent-native/runtime/llm"
|
|
"agent-native/runtime/mcp"
|
|
"agent-native/runtime/subagent"
|
|
"agent-native/runtime/tools"
|
|
)
|
|
|
|
func TestBackendExecToolsWiredWithOptionsAndRequestEnv(t *testing.T) {
|
|
bash, err := osexec.LookPath("bash")
|
|
if err != nil {
|
|
t.Fatalf("bash not available: %v", err)
|
|
}
|
|
dir := t.TempDir()
|
|
b := New(Options{
|
|
ModelOptions: ModelOptions{
|
|
Model: "fake",
|
|
Stream: script(
|
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
|
ID: "c1", Name: "run_command",
|
|
Arguments: `{"command":"echo role=$ROLE_FLAG step=$STEP_FLAG > exec_out.txt"}`,
|
|
}}},
|
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
|
ID: "c2", Name: "write_file",
|
|
Arguments: `{"path":"output.md","content":"done"}`,
|
|
}}},
|
|
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
|
|
),
|
|
},
|
|
ToolOptions: ToolOptions{
|
|
Exec: &tools.ExecConfig{Bash: bash, Env: []string{"PATH=/usr/bin:/bin", "ROLE_FLAG=backend"}},
|
|
},
|
|
})
|
|
res, err := b.Execute(context.Background(), exec.Request{
|
|
WorkDir: dir, Prompt: "run", Targets: []string{"output.md"},
|
|
Env: map[string]string{"STEP_FLAG": "request"},
|
|
})
|
|
if err != nil || !res.Success {
|
|
t.Fatalf("execute: err=%v res=%+v logs=%s", err, res, res.Logs)
|
|
}
|
|
data, err := os.ReadFile(filepath.Join(dir, "exec_out.txt"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(data), "role=backend") || !strings.Contains(string(data), "step=request") {
|
|
t.Fatalf("env merge wrong: %q", data)
|
|
}
|
|
}
|
|
|
|
func TestBackendExecToolsAbsentWithoutOptions(t *testing.T) {
|
|
stream, c := captureStream()
|
|
b := New(Options{
|
|
ModelOptions: ModelOptions{
|
|
Model: "fake",
|
|
Stream: stream,
|
|
},
|
|
})
|
|
res, err := b.Execute(context.Background(), exec.Request{
|
|
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
|
|
ToolProfile: exec.ToolProfileReasoning,
|
|
})
|
|
if err != nil || !res.Success {
|
|
t.Fatalf("execute: err=%v res=%+v", err, res)
|
|
}
|
|
// reasoning 档本无工具;改用 workspace 档断言 decls 不含执行工具。
|
|
stream2, c2 := captureStream()
|
|
b2 := New(Options{
|
|
ModelOptions: ModelOptions{
|
|
Model: "fake",
|
|
Stream: stream2,
|
|
},
|
|
})
|
|
_, _ = b2.Execute(context.Background(), exec.Request{
|
|
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
|
|
ToolProfile: exec.ToolProfileWorkspace,
|
|
})
|
|
for _, names := range c2.toolDecls {
|
|
for _, n := range names {
|
|
if n == "run_command" || n == "run_python" {
|
|
t.Fatalf("exec tools must not register without Options.Exec, got %v", names)
|
|
}
|
|
}
|
|
}
|
|
_ = c
|
|
}
|
|
|
|
func TestBackendAllowlistCanDisableExecTools(t *testing.T) {
|
|
bash, _ := osexec.LookPath("bash")
|
|
stream, c := captureStream()
|
|
b := New(Options{
|
|
ModelOptions: ModelOptions{
|
|
Model: "fake",
|
|
Stream: stream,
|
|
},
|
|
ToolOptions: ToolOptions{
|
|
Exec: &tools.ExecConfig{Bash: bash, Env: []string{"PATH=/usr/bin:/bin"}},
|
|
},
|
|
})
|
|
res, err := b.Execute(context.Background(), exec.Request{
|
|
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
|
|
ToolAllowlist: []string{"read_file", "write_file", "edit_file", "list_dir", "grep", "glob", "multi_edit"},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Success {
|
|
t.Fatalf("write-only allowlist without write? (unexpected) res=%+v", res)
|
|
}
|
|
for _, names := range c.toolDecls {
|
|
for _, n := range names {
|
|
if n == "run_command" {
|
|
t.Fatalf("allowlist must be able to strip run_command, got %v", names)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// fakeSearchProvider 与 tools 包测试同构的最小搜索替身。
|
|
type fakeSearchProvider struct{ query string }
|
|
|
|
func (f *fakeSearchProvider) Search(_ context.Context, req tools.SearchRequest) (tools.SearchResponse, error) {
|
|
f.query = req.Query
|
|
return tools.SearchResponse{Answer: "ans", Results: []tools.SearchResult{{Title: "T", Content: "C", Score: 0.9}}}, nil
|
|
}
|
|
|
|
func TestBackendSearchToolsWiredViaOptions(t *testing.T) {
|
|
provider := &fakeSearchProvider{}
|
|
stream, c := captureStream(
|
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
|
ID: "c1", Name: "web_search", Arguments: `{"query":"golang generics"}`,
|
|
}}},
|
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
|
ID: "c2", Name: "write_file",
|
|
Arguments: `{"path":"output.md","content":"done"}`,
|
|
}}},
|
|
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
|
|
)
|
|
b := New(Options{
|
|
ModelOptions: ModelOptions{
|
|
Model: "fake",
|
|
Stream: stream,
|
|
},
|
|
ToolOptions: ToolOptions{
|
|
Search: provider,
|
|
},
|
|
})
|
|
dir := t.TempDir()
|
|
res, err := b.Execute(context.Background(), exec.Request{
|
|
WorkDir: dir, Prompt: "search", Targets: []string{"output.md"},
|
|
})
|
|
if err != nil || !res.Success {
|
|
t.Fatalf("execute: err=%v res=%+v logs=%s", err, res, res.Logs)
|
|
}
|
|
if provider.query != "golang generics" {
|
|
t.Fatalf("provider should receive the query, got %q", provider.query)
|
|
}
|
|
// 搜索工具确实出现在 decls 里。
|
|
found := false
|
|
for _, names := range c.toolDecls {
|
|
for _, n := range names {
|
|
if n == "web_search" || n == "tavily_search" {
|
|
found = true
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("search tools must be declared when Options.Search is set")
|
|
}
|
|
}
|
|
|
|
func TestBackendSearchToolsAbsentWithoutOptions(t *testing.T) {
|
|
stream, c := captureStream()
|
|
b := New(Options{
|
|
ModelOptions: ModelOptions{
|
|
Model: "fake",
|
|
Stream: stream,
|
|
},
|
|
})
|
|
_, _ = b.Execute(context.Background(), exec.Request{
|
|
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
|
|
ToolProfile: exec.ToolProfileWorkspace,
|
|
})
|
|
for _, names := range c.toolDecls {
|
|
for _, n := range names {
|
|
if n == "web_search" || n == "tavily_search" {
|
|
t.Fatalf("search tools must not register without Options.Search, got %v", names)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBackendMCPToolsWiredViaOptions(t *testing.T) {
|
|
exe, err := osexec.LookPath(os.Args[0])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// os.Args[0] 在 go test 下指向测试二进制——用其自身作为 MCP server。
|
|
mgr, err := mcp.NewManager([]mcp.ServerConfig{{
|
|
Name: "fake", Command: exe,
|
|
Env: append(os.Environ(), "AGENT_NATIVE_TEST_MCP_STDIO=1"),
|
|
}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer mgr.Close()
|
|
if err := mgr.RegisterInto(context.Background(), tools.NewRegistry(t.TempDir())); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stream, c := captureStream(
|
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
|
ID: "c1", Name: "mcp__fake__echo", Arguments: `{"text":"via-backend"}`,
|
|
}}},
|
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
|
ID: "c2", Name: "write_file",
|
|
Arguments: `{"path":"output.md","content":"done"}`,
|
|
}}},
|
|
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
|
|
)
|
|
b := New(Options{
|
|
ModelOptions: ModelOptions{
|
|
Model: "fake",
|
|
Stream: stream,
|
|
},
|
|
ToolOptions: ToolOptions{
|
|
MCP: mgr,
|
|
},
|
|
})
|
|
dir := t.TempDir()
|
|
res, err2 := b.Execute(context.Background(), exec.Request{
|
|
WorkDir: dir, Prompt: "call mcp", Targets: []string{"output.md"},
|
|
})
|
|
if err2 != nil || !res.Success {
|
|
t.Fatalf("execute: err=%v res=%+v logs=%s", err2, res, res.Logs)
|
|
}
|
|
found := false
|
|
for _, names := range c.toolDecls {
|
|
for _, n := range names {
|
|
if n == "mcp__fake__echo" {
|
|
found = true
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("mcp bridge tools must be declared to the model")
|
|
}
|
|
}
|
|
|
|
func TestBackendSkillsIndexAndLoader(t *testing.T) {
|
|
skillsRoot := t.TempDir()
|
|
skillDir := filepath.Join(skillsRoot, "docx-author")
|
|
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\ndescription: 生成文档交付物\n---\n\n配方正文"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stream, c := captureStream(
|
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
|
ID: "c1", Name: "load_skill", Arguments: `{"name":"docx-author"}`,
|
|
}}},
|
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
|
ID: "c2", Name: "write_file", Arguments: `{"path":"output.md","content":"done"}`,
|
|
}}},
|
|
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
|
|
)
|
|
b := New(Options{
|
|
ModelOptions: ModelOptions{
|
|
Model: "fake",
|
|
Stream: stream,
|
|
},
|
|
ToolOptions: ToolOptions{
|
|
SkillsRoots: []string{skillsRoot},
|
|
},
|
|
})
|
|
dir := t.TempDir()
|
|
res, err := b.Execute(context.Background(), exec.Request{
|
|
WorkDir: dir, Prompt: "写", Targets: []string{"output.md"}, Skills: []string{"docx-author"},
|
|
})
|
|
if err != nil || !res.Success {
|
|
t.Fatalf("execute: err=%v res=%+v logs=%s", err, res, res.Logs)
|
|
}
|
|
if !strings.Contains(c.system, "- docx-author — 生成文档交付物") {
|
|
t.Fatalf("skill index missing in system prompt:\n%s", c.system)
|
|
}
|
|
if !strings.Contains(c.system, "Declared for this task") || !strings.Contains(c.system, "docx-author") {
|
|
t.Fatalf("declared skills missing:\n%s", c.system)
|
|
}
|
|
sawLoader := false
|
|
for _, names := range c.toolDecls {
|
|
for _, n := range names {
|
|
if n == "load_skill" {
|
|
sawLoader = true
|
|
}
|
|
}
|
|
}
|
|
if !sawLoader {
|
|
t.Fatal("load_skill must be declared when skills exist")
|
|
}
|
|
}
|
|
|
|
func TestBackendDeclaredSkillMissingFailsClosed(t *testing.T) {
|
|
b := New(Options{
|
|
ModelOptions: ModelOptions{
|
|
Model: "fake",
|
|
Stream: script(),
|
|
},
|
|
ToolOptions: ToolOptions{
|
|
SkillsRoots: []string{t.TempDir()},
|
|
},
|
|
})
|
|
res, err := b.Execute(context.Background(), exec.Request{
|
|
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
|
|
Skills: []string{"ghost-skill"},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Success || !strings.Contains(res.ErrorReason, "declared skill not installed: ghost-skill") {
|
|
t.Fatalf("missing declared skill must fail-closed, got %+v", res)
|
|
}
|
|
}
|
|
|
|
func TestBackendWorkspaceSkillsRootAutoMerged(t *testing.T) {
|
|
stream, c := captureStream(
|
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
|
ID: "c1", Name: "write_file", Arguments: `{"path":"output.md","content":"done"}`,
|
|
}}},
|
|
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
|
|
)
|
|
b := New(Options{
|
|
ModelOptions: ModelOptions{
|
|
Model: "fake",
|
|
Stream: stream,
|
|
},
|
|
})
|
|
dir := t.TempDir()
|
|
wsSkill := filepath.Join(dir, ".agent", "skills", "ws-skill")
|
|
if err := os.MkdirAll(wsSkill, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(wsSkill, "SKILL.md"), []byte("---\ndescription: 工作区技能\n---\n正文"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res, err := b.Execute(context.Background(), exec.Request{
|
|
WorkDir: dir, Prompt: "x", Targets: []string{"output.md"},
|
|
})
|
|
if err != nil || !res.Success {
|
|
t.Fatalf("execute: err=%v res=%+v", err, res)
|
|
}
|
|
if !strings.Contains(c.system, "ws-skill") {
|
|
t.Fatalf("workspace skills root must be merged into index:\n%s", c.system)
|
|
}
|
|
}
|
|
|
|
func TestBackendSubagentWiredViaOptions(t *testing.T) {
|
|
var delegated *subagent.Request
|
|
stream, c := captureStream(
|
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
|
ID: "c1", Name: "delegate_task",
|
|
Arguments: `{"goal":"查证数据","write_scope":"research/","done_criteria":"三来源"}`,
|
|
}}},
|
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
|
ID: "c2", Name: "write_file",
|
|
Arguments: `{"path":"output.md","content":"done"}`,
|
|
}}},
|
|
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
|
|
)
|
|
b := New(Options{
|
|
ModelOptions: ModelOptions{
|
|
Model: "fake",
|
|
Stream: stream,
|
|
},
|
|
ToolOptions: ToolOptions{
|
|
Subagent: func(ctx context.Context, req subagent.Request) (subagent.Result, error) {
|
|
delegated = &req
|
|
return subagent.Result{Success: true, Text: "结论已查明"}, nil
|
|
},
|
|
},
|
|
})
|
|
dir := t.TempDir()
|
|
res, err := b.Execute(context.Background(), exec.Request{
|
|
WorkDir: dir, Prompt: "分工", Targets: []string{"output.md"},
|
|
})
|
|
if err != nil || !res.Success {
|
|
t.Fatalf("execute: err=%v res=%+v logs=%s", err, res, res.Logs)
|
|
}
|
|
if delegated == nil || delegated.Goal != "查证数据" {
|
|
t.Fatalf("factory not invoked with fields: %+v", delegated)
|
|
}
|
|
sawDelegate := false
|
|
for _, names := range c.toolDecls {
|
|
for _, n := range names {
|
|
if n == "delegate_task" {
|
|
sawDelegate = true
|
|
}
|
|
}
|
|
}
|
|
if !sawDelegate {
|
|
t.Fatal("delegate_task must be declared when Subagent factory is set")
|
|
}
|
|
}
|