Skip to content
This repository was archived by the owner on Apr 8, 2026. It is now read-only.

Commit eea7651

Browse files
committed
test: add integration tests for branch freshness, MCP, policy
Add focused integration coverage for real git branch freshness checks, degraded MCP startup reporting, policy routing, prompt misdelivery recovery, and telemetry JSONL roundtrips. Constraint: Keep coverage isolated to new integration test files so existing in-progress workspace edits stay untouched Rejected: Expand existing unit tests instead | user requested integration coverage across runtime and telemetry boundaries Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep these scenarios in integration tests because they depend on cross-module behavior and serialized output contracts Tested: cargo test --workspace Not-tested: Remote push hooks or CI-only environment differences
1 parent f0e5a9d commit eea7651

2 files changed

Lines changed: 385 additions & 0 deletions

File tree

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
use std::collections::BTreeMap;
2+
use std::env;
3+
use std::fs;
4+
use std::path::{Path, PathBuf};
5+
use std::process::Command;
6+
use std::sync::{Mutex, OnceLock};
7+
use std::time::{Duration, SystemTime, UNIX_EPOCH};
8+
9+
use runtime::{
10+
apply_policy, attempt_recovery, check_freshness, recipe_for, BranchFreshness, DiffScope,
11+
FailureScenario, LaneBlocker, LaneContext, McpDegradedReport, McpFailedServer,
12+
McpLifecyclePhase, McpLifecycleValidator, PolicyAction, PolicyCondition, PolicyEngine,
13+
PolicyRule, RecoveryContext, RecoveryResult, RecoveryStep, ReviewStatus, StaleBranchAction,
14+
StaleBranchPolicy, WorkerFailureKind, WorkerRegistry, WorkerStatus,
15+
};
16+
17+
fn temp_dir(prefix: &str) -> PathBuf {
18+
let nanos = SystemTime::now()
19+
.duration_since(UNIX_EPOCH)
20+
.expect("time should be after epoch")
21+
.as_nanos();
22+
std::env::temp_dir().join(format!("{prefix}-{nanos}"))
23+
}
24+
25+
fn run_git(cwd: &Path, args: &[&str]) {
26+
let status = Command::new("git")
27+
.args(args)
28+
.current_dir(cwd)
29+
.status()
30+
.unwrap_or_else(|error| panic!("git {} failed to execute: {error}", args.join(" ")));
31+
assert!(
32+
status.success(),
33+
"git {} exited with {status}",
34+
args.join(" ")
35+
);
36+
}
37+
38+
fn init_repo(path: &Path) {
39+
fs::create_dir_all(path).expect("create repo dir");
40+
run_git(path, &["init", "--quiet", "-b", "main"]);
41+
run_git(path, &["config", "user.email", "tests@example.com"]);
42+
run_git(path, &["config", "user.name", "Runtime Integration Tests"]);
43+
fs::write(path.join("init.txt"), "initial\n").expect("write init file");
44+
run_git(path, &["add", "."]);
45+
run_git(path, &["commit", "-m", "initial commit", "--quiet"]);
46+
}
47+
48+
fn commit_file(path: &Path, file: &str, contents: &str, message: &str) {
49+
fs::write(path.join(file), contents).expect("write file");
50+
run_git(path, &["add", file]);
51+
run_git(path, &["commit", "-m", message, "--quiet"]);
52+
}
53+
54+
fn cwd_lock() -> &'static Mutex<()> {
55+
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
56+
LOCK.get_or_init(|| Mutex::new(()))
57+
}
58+
59+
struct CurrentDirGuard {
60+
original: PathBuf,
61+
}
62+
63+
impl CurrentDirGuard {
64+
fn change_to(path: &Path) -> Self {
65+
let original = env::current_dir().expect("read current dir");
66+
env::set_current_dir(path).expect("set current dir");
67+
Self { original }
68+
}
69+
}
70+
71+
impl Drop for CurrentDirGuard {
72+
fn drop(&mut self) {
73+
env::set_current_dir(&self.original).expect("restore current dir");
74+
}
75+
}
76+
77+
#[test]
78+
fn branch_freshness_detection_surfaces_stale_fix_history() {
79+
let root = temp_dir("runtime-branch-freshness");
80+
init_repo(&root);
81+
82+
run_git(&root, &["checkout", "-b", "topic"]);
83+
run_git(&root, &["checkout", "main"]);
84+
commit_file(&root, "fix1.txt", "timeout fix\n", "fix: resolve timeout");
85+
commit_file(&root, "fix2.txt", "hotpatch\n", "fix: apply hotpatch");
86+
87+
let freshness = {
88+
let _cwd_guard = cwd_lock()
89+
.lock()
90+
.unwrap_or_else(std::sync::PoisonError::into_inner);
91+
let _dir_guard = CurrentDirGuard::change_to(&root);
92+
check_freshness("topic", "main")
93+
};
94+
95+
match &freshness {
96+
BranchFreshness::Stale {
97+
commits_behind,
98+
missing_fixes,
99+
} => {
100+
assert_eq!(*commits_behind, 2);
101+
assert_eq!(
102+
missing_fixes,
103+
&vec![
104+
"fix: apply hotpatch".to_string(),
105+
"fix: resolve timeout".to_string(),
106+
]
107+
);
108+
}
109+
other => panic!("expected stale branch, got {other:?}"),
110+
}
111+
112+
let action = apply_policy(&freshness, StaleBranchPolicy::Block);
113+
assert!(matches!(action, StaleBranchAction::Block { .. }));
114+
115+
fs::remove_dir_all(&root).expect("cleanup temp repo");
116+
}
117+
118+
#[test]
119+
fn mcp_degraded_startup_reports_recoverable_timeout_and_missing_tools() {
120+
let mut validator = McpLifecycleValidator::new();
121+
for phase in [
122+
McpLifecyclePhase::ConfigLoad,
123+
McpLifecyclePhase::ServerRegistration,
124+
McpLifecyclePhase::SpawnConnect,
125+
McpLifecyclePhase::InitializeHandshake,
126+
] {
127+
assert!(matches!(
128+
validator.run_phase(phase),
129+
runtime::McpPhaseResult::Success { .. }
130+
));
131+
}
132+
133+
let timeout = validator.record_timeout(
134+
McpLifecyclePhase::ToolDiscovery,
135+
Duration::from_secs(5),
136+
Some("demo".to_string()),
137+
BTreeMap::from([("transport".to_string(), "stdio".to_string())]),
138+
);
139+
140+
let error = match timeout {
141+
runtime::McpPhaseResult::Timeout { phase, error, .. } => {
142+
assert_eq!(phase, McpLifecyclePhase::ToolDiscovery);
143+
assert!(error.recoverable);
144+
assert_eq!(error.server_name.as_deref(), Some("demo"));
145+
error
146+
}
147+
other => panic!("expected timeout result, got {other:?}"),
148+
};
149+
150+
let degraded = McpDegradedReport::new(
151+
vec!["alpha".to_string()],
152+
vec![McpFailedServer {
153+
server_name: "demo".to_string(),
154+
phase: McpLifecyclePhase::ToolDiscovery,
155+
error,
156+
}],
157+
vec!["mcp__alpha__ping".to_string()],
158+
vec![
159+
"mcp__alpha__ping".to_string(),
160+
"mcp__demo__echo".to_string(),
161+
],
162+
);
163+
164+
assert_eq!(
165+
validator.state().current_phase(),
166+
Some(McpLifecyclePhase::ErrorSurfacing)
167+
);
168+
assert_eq!(degraded.working_servers, vec!["alpha".to_string()]);
169+
assert_eq!(
170+
degraded.available_tools,
171+
vec!["mcp__alpha__ping".to_string()]
172+
);
173+
assert_eq!(degraded.missing_tools, vec!["mcp__demo__echo".to_string()]);
174+
assert_eq!(degraded.failed_servers.len(), 1);
175+
assert_eq!(
176+
degraded.failed_servers[0].phase,
177+
McpLifecyclePhase::ToolDiscovery
178+
);
179+
}
180+
181+
#[test]
182+
fn policy_routing_distinguishes_startup_recovery_from_merge_ready_lanes() {
183+
let engine = PolicyEngine::new(vec![
184+
PolicyRule::new(
185+
"recover-startup",
186+
PolicyCondition::StartupBlocked,
187+
PolicyAction::Chain(vec![
188+
PolicyAction::RecoverOnce,
189+
PolicyAction::Notify {
190+
channel: "#ops".to_string(),
191+
},
192+
]),
193+
5,
194+
),
195+
PolicyRule::new(
196+
"merge-ready",
197+
PolicyCondition::And(vec![
198+
PolicyCondition::GreenAt { level: 3 },
199+
PolicyCondition::ReviewPassed,
200+
PolicyCondition::ScopedDiff,
201+
]),
202+
PolicyAction::MergeToDev,
203+
20,
204+
),
205+
]);
206+
207+
let startup_blocked = LaneContext::new(
208+
"lane-blocked",
209+
3,
210+
Duration::from_secs(15 * 60),
211+
LaneBlocker::Startup,
212+
ReviewStatus::Pending,
213+
DiffScope::Scoped,
214+
false,
215+
);
216+
assert_eq!(
217+
engine.evaluate(&startup_blocked),
218+
vec![
219+
PolicyAction::RecoverOnce,
220+
PolicyAction::Notify {
221+
channel: "#ops".to_string(),
222+
},
223+
]
224+
);
225+
226+
let merge_ready = LaneContext::new(
227+
"lane-ready",
228+
3,
229+
Duration::from_secs(15 * 60),
230+
LaneBlocker::None,
231+
ReviewStatus::Approved,
232+
DiffScope::Scoped,
233+
false,
234+
);
235+
assert_eq!(
236+
engine.evaluate(&merge_ready),
237+
vec![PolicyAction::MergeToDev]
238+
);
239+
}
240+
241+
#[test]
242+
fn prompt_misdelivery_arms_replay_and_maps_to_recovery_recipe() {
243+
let root = temp_dir("runtime-prompt-misdelivery");
244+
fs::create_dir_all(&root).expect("create worker root");
245+
246+
let registry = WorkerRegistry::new();
247+
let worker = registry.create(root.to_str().expect("utf8 path"), &[], true);
248+
249+
let ready = registry
250+
.observe(&worker.worker_id, "Ready for your input\n>")
251+
.expect("worker should become ready");
252+
assert_eq!(ready.status, WorkerStatus::ReadyForPrompt);
253+
254+
let running = registry
255+
.send_prompt(&worker.worker_id, Some("Investigate flaky boot"))
256+
.expect("prompt send should succeed");
257+
assert_eq!(running.status, WorkerStatus::Running);
258+
259+
let recovered = registry
260+
.observe(
261+
&worker.worker_id,
262+
"% Investigate flaky boot\nzsh: command not found: Investigate",
263+
)
264+
.expect("misdelivery observe should succeed");
265+
assert_eq!(recovered.status, WorkerStatus::ReadyForPrompt);
266+
assert_eq!(
267+
recovered.replay_prompt.as_deref(),
268+
Some("Investigate flaky boot")
269+
);
270+
271+
let failure = recovered
272+
.last_error
273+
.expect("worker should record a prompt delivery failure");
274+
assert_eq!(failure.kind, WorkerFailureKind::PromptDelivery);
275+
276+
let scenario = FailureScenario::from_worker_failure_kind(failure.kind);
277+
assert_eq!(scenario, FailureScenario::PromptMisdelivery);
278+
assert_eq!(
279+
recipe_for(&scenario).steps,
280+
vec![RecoveryStep::RedirectPromptToAgent]
281+
);
282+
283+
let mut context = RecoveryContext::new();
284+
let result = attempt_recovery(&scenario, &mut context);
285+
assert_eq!(result, RecoveryResult::Recovered { steps_taken: 1 });
286+
287+
fs::remove_dir_all(&root).expect("cleanup worker root");
288+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
use std::fs;
2+
use std::sync::Arc;
3+
use std::time::{SystemTime, UNIX_EPOCH};
4+
5+
use serde_json::{Map, Value};
6+
use telemetry::{
7+
AnalyticsEvent, JsonlTelemetrySink, SessionTraceRecord, SessionTracer, TelemetryEvent,
8+
};
9+
10+
fn temp_log_path() -> std::path::PathBuf {
11+
let nanos = SystemTime::now()
12+
.duration_since(UNIX_EPOCH)
13+
.expect("time should be after epoch")
14+
.as_nanos();
15+
std::env::temp_dir().join(format!("telemetry-roundtrip-{nanos}.jsonl"))
16+
}
17+
18+
#[test]
19+
fn telemetry_roundtrip_preserves_structured_jsonl_events() {
20+
let path = temp_log_path();
21+
let sink = Arc::new(JsonlTelemetrySink::new(&path).expect("sink should create file"));
22+
let tracer = SessionTracer::new("session-integration", sink);
23+
24+
let mut request_attributes = Map::new();
25+
request_attributes.insert(
26+
"model".to_string(),
27+
Value::String("claude-sonnet".to_string()),
28+
);
29+
30+
tracer.record_http_request_started(1, "POST", "/v1/messages", request_attributes.clone());
31+
tracer.record_http_request_succeeded(
32+
1,
33+
"POST",
34+
"/v1/messages",
35+
200,
36+
Some("req_123".to_string()),
37+
request_attributes,
38+
);
39+
tracer.record_analytics(
40+
AnalyticsEvent::new("cli", "prompt_sent").with_property("turn", Value::from(1)),
41+
);
42+
43+
let events = fs::read_to_string(&path)
44+
.expect("telemetry log should be readable")
45+
.lines()
46+
.map(|line| serde_json::from_str::<TelemetryEvent>(line).expect("line should deserialize"))
47+
.collect::<Vec<_>>();
48+
49+
assert_eq!(events.len(), 6);
50+
assert!(matches!(
51+
&events[0],
52+
TelemetryEvent::HttpRequestStarted {
53+
session_id,
54+
attempt: 1,
55+
method,
56+
path,
57+
..
58+
} if session_id == "session-integration" && method == "POST" && path == "/v1/messages"
59+
));
60+
assert!(matches!(
61+
&events[1],
62+
TelemetryEvent::SessionTrace(SessionTraceRecord { sequence: 0, name, .. })
63+
if name == "http_request_started"
64+
));
65+
assert!(matches!(
66+
&events[2],
67+
TelemetryEvent::HttpRequestSucceeded {
68+
session_id,
69+
attempt: 1,
70+
method,
71+
path,
72+
status: 200,
73+
request_id,
74+
..
75+
} if session_id == "session-integration"
76+
&& method == "POST"
77+
&& path == "/v1/messages"
78+
&& request_id.as_deref() == Some("req_123")
79+
));
80+
assert!(matches!(
81+
&events[3],
82+
TelemetryEvent::SessionTrace(SessionTraceRecord { sequence: 1, name, .. })
83+
if name == "http_request_succeeded"
84+
));
85+
assert!(matches!(
86+
&events[4],
87+
TelemetryEvent::Analytics(analytics)
88+
if analytics.namespace == "cli" && analytics.action == "prompt_sent"
89+
));
90+
assert!(matches!(
91+
&events[5],
92+
TelemetryEvent::SessionTrace(SessionTraceRecord { sequence: 2, name, .. })
93+
if name == "analytics"
94+
));
95+
96+
let _ = fs::remove_file(path);
97+
}

0 commit comments

Comments
 (0)