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("---\nname: docx-author\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("---\nname: ws-skill\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") } }