Skip to content

Commit 97bb6b4

Browse files
authored
Merge pull request #9 from melionel/lixiaoli-20260127
chore: Add C# hosted agent testing to CI/CD pipeline
2 parents 0b4c6a9 + 12f1a1f commit 97bb6b4

File tree

3 files changed

+214
-64
lines changed

3 files changed

+214
-64
lines changed

.github/scripts/discover_hosted_samples.py

Lines changed: 77 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,16 @@
22
"""
33
Discover valid hosted agent samples for GitHub Actions matrix.
44
5-
A valid hosted agent sample is a directory containing:
5+
A valid Python hosted agent sample is a directory containing:
66
- agent.yaml (defines the hosted agent configuration)
77
- main.py (entry point)
88
- requirements.txt (dependencies)
99
10+
A valid C# hosted agent sample is a directory containing:
11+
- agent.yaml (defines the hosted agent configuration)
12+
- *.csproj (C# project file)
13+
- Program.cs (entry point)
14+
1015
Outputs JSON array of sample paths relative to repository root.
1116
"""
1217

@@ -15,60 +20,106 @@
1520
from pathlib import Path
1621

1722

18-
def find_hosted_samples(base_path: Path) -> list[dict]:
19-
"""Find all valid hosted agent sample directories.
20-
23+
def find_python_hosted_samples(base_path: Path) -> list[dict]:
24+
"""Find all valid Python hosted agent sample directories.
25+
2126
Args:
22-
base_path: Path to the hosted-agents directory
23-
27+
base_path: Path to the python/hosted-agents directory
28+
2429
Returns:
25-
List of dicts with sample info (path, name, framework)
30+
List of dicts with sample info (path, name, framework, language)
2631
"""
2732
samples = []
28-
33+
2934
for agent_yaml in base_path.rglob("agent.yaml"):
3035
sample_dir = agent_yaml.parent
31-
36+
3237
# Validate required files exist
3338
if not (sample_dir / "main.py").exists():
3439
continue
3540
if not (sample_dir / "requirements.txt").exists():
3641
continue
37-
42+
3843
# Determine framework based on parent directory
3944
rel_path = sample_dir.relative_to(base_path)
4045
parts = rel_path.parts
4146
framework = parts[0] if len(parts) > 1 else "unknown"
42-
47+
48+
# Get path relative to repo root
49+
repo_root = base_path.parent.parent.parent
50+
sample_path = str(sample_dir.relative_to(repo_root)).replace("\\", "/")
51+
52+
samples.append({
53+
"path": sample_path,
54+
"name": sample_dir.name,
55+
"framework": framework,
56+
"language": "python"
57+
})
58+
59+
return sorted(samples, key=lambda x: x["path"])
60+
61+
62+
def find_csharp_hosted_samples(base_path: Path) -> list[dict]:
63+
"""Find all valid C# hosted agent sample directories.
64+
65+
Args:
66+
base_path: Path to the csharp/hosted-agents directory
67+
68+
Returns:
69+
List of dicts with sample info (path, name, framework, language)
70+
"""
71+
samples = []
72+
73+
for agent_yaml in base_path.rglob("agent.yaml"):
74+
sample_dir = agent_yaml.parent
75+
76+
# Validate required files exist
77+
csproj_files = list(sample_dir.glob("*.csproj"))
78+
if not csproj_files:
79+
continue
80+
if not (sample_dir / "Program.cs").exists():
81+
continue
82+
83+
# Determine framework based on parent directory
84+
rel_path = sample_dir.relative_to(base_path)
85+
parts = rel_path.parts
86+
framework = parts[0] if len(parts) > 1 else "csharp"
87+
4388
# Get path relative to repo root
4489
repo_root = base_path.parent.parent.parent
4590
sample_path = str(sample_dir.relative_to(repo_root)).replace("\\", "/")
46-
91+
4792
samples.append({
4893
"path": sample_path,
4994
"name": sample_dir.name,
50-
"framework": framework
95+
"framework": framework,
96+
"language": "csharp"
5197
})
52-
98+
5399
return sorted(samples, key=lambda x: x["path"])
54100

55101

56102
def main():
57-
# Default path relative to repo root
58103
repo_root = Path(__file__).parent.parent.parent
59-
hosted_agents_path = repo_root / "samples" / "python" / "hosted-agents"
60-
61-
if not hosted_agents_path.exists():
62-
print(f"Error: Path not found: {hosted_agents_path}", file=sys.stderr)
63-
sys.exit(1)
64-
65-
samples = find_hosted_samples(hosted_agents_path)
66-
67-
if not samples:
104+
all_samples = []
105+
106+
# Discover Python samples
107+
python_path = repo_root / "samples" / "python" / "hosted-agents"
108+
if python_path.exists():
109+
python_samples = find_python_hosted_samples(python_path)
110+
all_samples.extend(python_samples)
111+
112+
# Discover C# samples
113+
csharp_path = repo_root / "samples" / "csharp" / "hosted-agents"
114+
if csharp_path.exists():
115+
csharp_samples = find_csharp_hosted_samples(csharp_path)
116+
all_samples.extend(csharp_samples)
117+
118+
if not all_samples:
68119
print("Warning: No valid hosted agent samples found", file=sys.stderr)
69-
120+
70121
# Output JSON for GitHub Actions
71-
print(json.dumps(samples))
122+
print(json.dumps(all_samples))
72123

73124

74125
if __name__ == "__main__":

.github/scripts/test_hosted_sample.py

Lines changed: 123 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@
2828
DEFAULT_TEST_INPUT = "Hello, please introduce yourself briefly."
2929
SERVER_PORT = 8088
3030
SERVER_URL = f"http://localhost:{SERVER_PORT}"
31-
STARTUP_TIMEOUT = 30 # seconds
31+
STARTUP_TIMEOUT = 30 # seconds for Python
32+
DOTNET_STARTUP_TIMEOUT = 60 # seconds for C# (longer due to JIT)
33+
DOTNET_BUILD_TIMEOUT = 300 # 5 minutes for NuGet restore + compile
3234
REQUEST_TIMEOUT = 120 # seconds
3335

3436

@@ -37,18 +39,71 @@ def extract_test_input(agent_yaml_path: Path) -> str:
3739
try:
3840
with open(agent_yaml_path) as f:
3941
config = yaml.safe_load(f)
40-
42+
4143
examples = config.get("metadata", {}).get("example", [])
4244
if examples and isinstance(examples, list):
4345
for example in examples:
4446
if example.get("role") == "user" and example.get("content"):
4547
return example["content"]
4648
except Exception as e:
4749
print(f"Warning: Could not parse agent.yaml for test input: {e}")
48-
50+
4951
return DEFAULT_TEST_INPUT
5052

5153

54+
def detect_language(sample_path: Path) -> str:
55+
"""Detect the language/framework of a sample based on marker files."""
56+
if (sample_path / "main.py").exists() and (sample_path / "requirements.txt").exists():
57+
return "python"
58+
59+
csproj_files = list(sample_path.glob("*.csproj"))
60+
if csproj_files and (sample_path / "Program.cs").exists():
61+
return "csharp"
62+
63+
return "unknown"
64+
65+
66+
def find_csproj(sample_path: Path) -> Path | None:
67+
"""Find the .csproj file in the sample directory."""
68+
csproj_files = list(sample_path.glob("*.csproj"))
69+
return csproj_files[0] if csproj_files else None
70+
71+
72+
def build_csharp_sample(sample_path: Path, csproj_path: Path) -> tuple[bool, str]:
73+
"""Build a C# sample using dotnet build.
74+
75+
Returns:
76+
Tuple of (success, error_message)
77+
"""
78+
print(f"Building C# project {csproj_path.name}...")
79+
try:
80+
result = subprocess.run(
81+
["dotnet", "build", str(csproj_path), "-c", "Release"],
82+
cwd=str(sample_path),
83+
capture_output=True,
84+
text=True,
85+
timeout=DOTNET_BUILD_TIMEOUT
86+
)
87+
if result.returncode != 0:
88+
return False, f"Build failed: {result.stderr[:1000]}"
89+
return True, ""
90+
except subprocess.TimeoutExpired:
91+
return False, f"Build timed out after {DOTNET_BUILD_TIMEOUT} seconds"
92+
except FileNotFoundError:
93+
return False, "dotnet CLI not found. Ensure .NET SDK is installed."
94+
95+
96+
def start_csharp_server(sample_path: Path, csproj_path: Path) -> subprocess.Popen:
97+
"""Start a C# sample server using dotnet run."""
98+
return subprocess.Popen(
99+
["dotnet", "run", "--project", str(csproj_path), "-c", "Release", "--no-build"],
100+
cwd=str(sample_path),
101+
stdout=subprocess.PIPE,
102+
stderr=subprocess.PIPE,
103+
text=True
104+
)
105+
106+
52107
def wait_for_server(timeout: int = STARTUP_TIMEOUT) -> bool:
53108
"""Wait for the server to be ready to accept connections."""
54109
start_time = time.time()
@@ -97,57 +152,88 @@ def run_test(sample_path: Path) -> dict:
97152
"error": None,
98153
"details": {}
99154
}
100-
155+
101156
agent_yaml_path = sample_path / "agent.yaml"
102-
main_py_path = sample_path / "main.py"
103-
requirements_path = sample_path / "requirements.txt"
104-
105-
# Validate required files
157+
158+
# Validate agent.yaml exists
106159
if not agent_yaml_path.exists():
107160
result["error"] = "agent.yaml not found"
108161
return result
109-
110-
if not main_py_path.exists():
111-
result["error"] = "main.py not found"
162+
163+
# Detect language
164+
language = detect_language(sample_path)
165+
result["details"]["language"] = language
166+
167+
if language == "unknown":
168+
result["error"] = "Could not detect sample language (no main.py or *.csproj found)"
112169
return result
113-
170+
114171
# Extract test input
115172
test_input = extract_test_input(agent_yaml_path)
116173
result["details"]["test_input"] = test_input[:100] + "..." if len(test_input) > 100 else test_input
117-
118-
# Install dependencies
119-
print(f"Installing dependencies from {requirements_path}...")
120-
try:
121-
subprocess.run(
122-
[sys.executable, "-m", "pip", "install", "-r", str(requirements_path), "-q"],
123-
check=True,
124-
capture_output=True,
174+
175+
# Language-specific setup and server start
176+
if language == "python":
177+
main_py_path = sample_path / "main.py"
178+
requirements_path = sample_path / "requirements.txt"
179+
180+
if not main_py_path.exists():
181+
result["error"] = "main.py not found"
182+
return result
183+
184+
# Install dependencies
185+
print(f"Installing dependencies from {requirements_path}...")
186+
try:
187+
subprocess.run(
188+
[sys.executable, "-m", "pip", "install", "-r", str(requirements_path), "-q"],
189+
check=True,
190+
capture_output=True,
191+
text=True
192+
)
193+
except subprocess.CalledProcessError as e:
194+
result["error"] = f"Failed to install dependencies: {e.stderr}"
195+
return result
196+
197+
# Start server
198+
print(f"Starting Python server for {sample_path.name}...")
199+
server_process = subprocess.Popen(
200+
[sys.executable, str(main_py_path)],
201+
cwd=str(sample_path),
202+
stdout=subprocess.PIPE,
203+
stderr=subprocess.PIPE,
125204
text=True
126205
)
127-
except subprocess.CalledProcessError as e:
128-
result["error"] = f"Failed to install dependencies: {e.stderr}"
129-
return result
130-
131-
# Start the server
132-
print(f"Starting server for {sample_path.name}...")
133-
server_process = subprocess.Popen(
134-
[sys.executable, str(main_py_path)],
135-
cwd=str(sample_path),
136-
stdout=subprocess.PIPE,
137-
stderr=subprocess.PIPE,
138-
text=True
139-
)
140-
206+
startup_timeout = STARTUP_TIMEOUT
207+
208+
elif language == "csharp":
209+
csproj_path = find_csproj(sample_path)
210+
if not csproj_path:
211+
result["error"] = "No .csproj file found"
212+
return result
213+
214+
# Build the project
215+
build_success, build_error = build_csharp_sample(sample_path, csproj_path)
216+
if not build_success:
217+
result["error"] = build_error
218+
return result
219+
220+
result["details"]["build"] = "success"
221+
222+
# Start server
223+
print(f"Starting C# server for {sample_path.name}...")
224+
server_process = start_csharp_server(sample_path, csproj_path)
225+
startup_timeout = DOTNET_STARTUP_TIMEOUT
226+
141227
try:
142228
# Wait for server to be ready
143-
print(f"Waiting for server to start (timeout: {STARTUP_TIMEOUT}s)...")
144-
if not wait_for_server(STARTUP_TIMEOUT):
229+
print(f"Waiting for server to start (timeout: {startup_timeout}s)...")
230+
if not wait_for_server(startup_timeout):
145231
# Check if process died
146232
if server_process.poll() is not None:
147233
stdout, stderr = server_process.communicate()
148234
result["error"] = f"Server process exited unexpectedly. stderr: {stderr[:500]}"
149235
else:
150-
result["error"] = f"Server did not start within {STARTUP_TIMEOUT} seconds"
236+
result["error"] = f"Server did not start within {startup_timeout} seconds"
151237
return result
152238

153239
result["details"]["server_started"] = True

0 commit comments

Comments
 (0)