1use serde_json::{json, Value};
11
12use crate::agent_events::{AgentEvent, PersistedAgentEvent, ToolCallStatus};
13
14use super::state::{PlaygroundPullRequest, PlaygroundState};
15
16pub struct TranscriptOptions {
17 pub session_id: String,
18}
19
20impl Default for TranscriptOptions {
21 fn default() -> Self {
22 TranscriptOptions {
23 session_id: "merge-captain-playground".to_string(),
24 }
25 }
26}
27
28pub fn synthesize_sweep(
29 state: &PlaygroundState,
30 options: &TranscriptOptions,
31) -> Vec<PersistedAgentEvent> {
32 let mut events: Vec<PersistedAgentEvent> = Vec::new();
33 let mut now = state.now_ms;
34 let mut idx: u64 = 0;
35 let bump = |delta: i64, now: &mut i64, idx: &mut u64| {
36 *now = now.saturating_add(delta);
37 let value = *idx;
38 *idx += 1;
39 value
40 };
41
42 let session_id = options.session_id.clone();
43 let envelope = |index: u64, at: i64, event: AgentEvent| PersistedAgentEvent {
44 index,
45 emitted_at_ms: at,
46 frame_depth: Some(0),
47 event,
48 };
49
50 let i = bump(0, &mut now, &mut idx);
51 events.push(envelope(
52 i,
53 now,
54 AgentEvent::IterationStart {
55 session_id: session_id.clone(),
56 iteration: 1,
57 provider: String::new(),
58 model: String::new(),
59 },
60 ));
61
62 let i = bump(10, &mut now, &mut idx);
63 events.push(envelope(
64 i,
65 now,
66 AgentEvent::AgentThoughtChunk {
67 session_id: session_id.clone(),
68 content: format!(
69 "Sweep playground scenario={} ({} repos, {} PRs)",
70 state.scenario,
71 state.repos.len(),
72 state.pull_requests.len()
73 ),
74 },
75 ));
76
77 let mut prs: Vec<&PlaygroundPullRequest> = state
78 .pull_requests
79 .values()
80 .filter(|pr| pr.state == "open")
81 .collect();
82 prs.sort_by_key(|pr| (pr.repo.clone(), pr.number));
83
84 let mut tool_call_counter = 0u64;
85 for pr in prs {
86 tool_call_counter += 1;
87 let intake_id = format!("call_{tool_call_counter}");
88 let i = bump(20, &mut now, &mut idx);
89 events.push(envelope(
90 i,
91 now,
92 AgentEvent::ToolCall {
93 session_id: session_id.clone(),
94 tool_call_id: intake_id.clone(),
95 tool_name: "gh_pull_request_get".to_string(),
96 kind: None,
97 status: ToolCallStatus::Pending,
98 raw_input: json!({"repo": format!("{}/{}", state.owner, pr.repo), "pr_number": pr.number}),
99 parsing: None,
100 audit: None,
101 },
102 ));
103 let i = bump(40, &mut now, &mut idx);
104 events.push(envelope(
105 i,
106 now,
107 AgentEvent::ToolCallUpdate {
108 session_id: session_id.clone(),
109 tool_call_id: intake_id,
110 tool_name: "gh_pull_request_get".to_string(),
111 status: ToolCallStatus::Completed,
112 raw_output: Some(pr_summary(pr)),
113 error: None,
114 duration_ms: Some(40),
115 execution_duration_ms: Some(40),
116 error_category: None,
117 mutation_status: crate::agent_events::ToolMutationStatus::Unknown,
118 changed_paths: None,
119 data: None,
120 executor: None,
121 parsing: None,
122 raw_input: None,
123 raw_input_partial: None,
124 audit: None,
125 },
126 ));
127
128 let i = bump(20, &mut now, &mut idx);
129 events.push(envelope(
130 i,
131 now,
132 AgentEvent::OrchestrationDecision {
133 session_id: session_id.clone(),
134 decision: pr_plan(pr),
135 },
136 ));
137
138 tool_call_counter += 1;
139 let checks_id = format!("call_{tool_call_counter}");
140 let i = bump(20, &mut now, &mut idx);
141 events.push(envelope(
142 i,
143 now,
144 AgentEvent::ToolCall {
145 session_id: session_id.clone(),
146 tool_call_id: checks_id.clone(),
147 tool_name: "gh_pr_checks_list".to_string(),
148 kind: None,
149 status: ToolCallStatus::Pending,
150 raw_input: json!({"repo": format!("{}/{}", state.owner, pr.repo), "pr_number": pr.number}),
151 parsing: None,
152 audit: None,
153 },
154 ));
155 let i = bump(40, &mut now, &mut idx);
156 events.push(envelope(
157 i,
158 now,
159 AgentEvent::ToolCallUpdate {
160 session_id: session_id.clone(),
161 tool_call_id: checks_id,
162 tool_name: "gh_pr_checks_list".to_string(),
163 status: ToolCallStatus::Completed,
164 raw_output: Some(checks_summary(pr)),
165 error: None,
166 duration_ms: Some(40),
167 execution_duration_ms: Some(40),
168 error_category: None,
169 mutation_status: crate::agent_events::ToolMutationStatus::Unknown,
170 changed_paths: None,
171 data: None,
172 executor: None,
173 parsing: None,
174 raw_input: None,
175 raw_input_partial: None,
176 audit: None,
177 },
178 ));
179
180 let i = bump(10, &mut now, &mut idx);
181 events.push(envelope(
182 i,
183 now,
184 AgentEvent::OrchestrationDecision {
185 session_id: session_id.clone(),
186 decision: risk_plan(pr),
187 },
188 ));
189 }
190
191 let i = bump(50, &mut now, &mut idx);
192 events.push(envelope(
193 i,
194 now,
195 AgentEvent::AgentThoughtChunk {
196 session_id,
197 content: format!(
198 "Sweep complete: {} PR(s) inspected, {} require follow-up",
199 state
200 .pull_requests
201 .values()
202 .filter(|p| p.state == "open")
203 .count(),
204 state
205 .pull_requests
206 .values()
207 .filter(|p| p.state == "open" && needs_followup(p))
208 .count()
209 ),
210 },
211 ));
212
213 let _ = bump(0, &mut now, &mut idx);
214 events
215}
216
217fn pr_summary(pr: &PlaygroundPullRequest) -> Value {
218 let failing: Vec<String> = pr
219 .checks
220 .iter()
221 .filter(|c| {
222 c.conclusion
223 .as_deref()
224 .map(|c| matches!(c, "failure" | "timed_out" | "cancelled"))
225 .unwrap_or(false)
226 })
227 .map(|c| c.name.clone())
228 .collect();
229 json!({
230 "repo": pr.repo,
231 "pr_number": pr.number,
232 "title": pr.title,
233 "state": pr.state,
234 "head_branch": pr.head_branch,
235 "base_branch": pr.base_branch,
236 "mergeable": pr.mergeable,
237 "mergeable_state": pr.mergeable_state,
238 "failing_checks": failing,
239 "stale_threads": Vec::<String>::new(),
240 "merge_conflicts": pr.mergeable_state == "dirty",
241 "merge_queue_status": pr.merge_queue_status,
242 })
243}
244
245fn pr_plan(pr: &PlaygroundPullRequest) -> Value {
246 json!({
247 "step": "intake",
248 "repo": pr.repo,
249 "pr_number": pr.number,
250 "head_branch": pr.head_branch,
251 "approval_required": false,
252 })
253}
254
255fn risk_plan(pr: &PlaygroundPullRequest) -> Value {
256 let risk = if needs_followup(pr) { "high" } else { "low" };
257 json!({
258 "step": "decide_risk",
259 "repo": pr.repo,
260 "pr_number": pr.number,
261 "review_risk": risk,
262 "approval_required": false,
263 })
264}
265
266fn checks_summary(pr: &PlaygroundPullRequest) -> Value {
267 let runs: Vec<Value> = pr
268 .checks
269 .iter()
270 .map(|c| {
271 json!({
272 "name": c.name,
273 "status": c.status,
274 "conclusion": c.conclusion,
275 })
276 })
277 .collect();
278 json!({"check_runs": runs})
279}
280
281fn needs_followup(pr: &PlaygroundPullRequest) -> bool {
282 pr.mergeable_state == "behind"
283 || pr.mergeable_state == "dirty"
284 || pr.mergeable_state == "blocked"
285 || pr
286 .checks
287 .iter()
288 .any(|c| matches!(c.conclusion.as_deref(), Some("failure" | "timed_out")))
289}