Files
Agent-Native/adapters/native/exec_tools_test.go
T
weichen b2638da64c Add an MCP client (stdio and streamable HTTP) with namespaced tool bridging
Why: external tool ecosystems speak MCP; the kernel had no way to
mount them, so every external capability needed a bespoke builtin.

Evidence: go vet ./... clean; go test ./... 11 packages ok. Tests run
against a REAL stdio server (test binary re-exec via
mcp.RunFakeServer) and httptest HTTP servers: initialize handshake,
tools/list with pagination cap, tools/call success / tool-level
isError / unknown-tool rpc error, JSON and SSE response modes,
Mcp-Session-Id capture, manager registration as mcp__<server>__<tool>,
namespace wildcard allowlist (mcp__fake__* keeps only that namespace,
unknown wildcard fails closed), unreachable server failing the step,
hung server bounded by per-request timeout, config validation
(duplicate names, dual/missing transport, bad name chars), and an
adapter end-to-end execution calling an MCP tool.

Impact: new runtime/mcp package; native.Options gains MCP *mcp.Manager
and Close() for process cleanup; tools.Registry.Restrict gains
prefix/* namespace entries; server-provided metadata is capped (64
tools/server, 1 KiB descriptions, 64 KiB text results) and treated as
untrusted input.

Refs: plan B5; agent-execution standard (tool providers get
authorized capability only).
2026-10-01 05:43:28 +00:00

212 lines
6.9 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/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{
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},
),
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{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{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{
Model: "fake", Stream: stream,
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{Model: "fake", Stream: stream, 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{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()
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{Model: "fake", Stream: stream, 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")
}
}