harn_vm/orchestration/playground/
transcript.rs1use 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 executor: None,
120 parsing: None,
121 raw_input: None,
122 raw_input_partial: None,
123 audit: None,
124 },
125 ));
126
127 let i = bump(20, &mut now, &mut idx);
128 events.push(envelope(
129 i,
130 now,
131 AgentEvent::Plan {
132 session_id: session_id.clone(),
133 plan: pr_plan(pr),
134 },
135 ));
136
137 tool_call_counter += 1;
138 let checks_id = format!("call_{tool_call_counter}");
139 let i = bump(20, &mut now, &mut idx);
140 events.push(envelope(
141 i,
142 now,
143 AgentEvent::ToolCall {
144 session_id: session_id.clone(),
145 tool_call_id: checks_id.clone(),
146 tool_name: "gh_pr_checks_list".to_string(),
147 kind: None,
148 status: ToolCallStatus::Pending,
149 raw_input: json!({"repo": format!("{}/{}", state.owner, pr.repo), "pr_number": pr.number}),
150 parsing: None,
151 audit: None,
152 },
153 ));
154 let i = bump(40, &mut now, &mut idx);
155 events.push(envelope(
156 i,
157 now,
158 AgentEvent::ToolCallUpdate {
159 session_id: session_id.clone(),
160 tool_call_id: checks_id,
161 tool_name: "gh_pr_checks_list".to_string(),
162 status: ToolCallStatus::Completed,
163 raw_output: Some(checks_summary(pr)),
164 error: None,
165 duration_ms: Some(40),
166 execution_duration_ms: Some(40),
167 error_category: None,
168 mutation_status: crate::agent_events::ToolMutationStatus::Unknown,
169 changed_paths: None,
170 executor: None,
171 parsing: None,
172 raw_input: None,
173 raw_input_partial: None,
174 audit: None,
175 },
176 ));
177
178 let i = bump(10, &mut now, &mut idx);
179 events.push(envelope(
180 i,
181 now,
182 AgentEvent::Plan {
183 session_id: session_id.clone(),
184 plan: risk_plan(pr),
185 },
186 ));
187 }
188
189 let i = bump(50, &mut now, &mut idx);
190 events.push(envelope(
191 i,
192 now,
193 AgentEvent::AgentThoughtChunk {
194 session_id,
195 content: format!(
196 "Sweep complete: {} PR(s) inspected, {} require follow-up",
197 state
198 .pull_requests
199 .values()
200 .filter(|p| p.state == "open")
201 .count(),
202 state
203 .pull_requests
204 .values()
205 .filter(|p| p.state == "open" && needs_followup(p))
206 .count()
207 ),
208 },
209 ));
210
211 let _ = bump(0, &mut now, &mut idx);
212 events
213}
214
215fn pr_summary(pr: &PlaygroundPullRequest) -> Value {
216 let failing: Vec<String> = pr
217 .checks
218 .iter()
219 .filter(|c| {
220 c.conclusion
221 .as_deref()
222 .map(|c| matches!(c, "failure" | "timed_out" | "cancelled"))
223 .unwrap_or(false)
224 })
225 .map(|c| c.name.clone())
226 .collect();
227 json!({
228 "repo": pr.repo,
229 "pr_number": pr.number,
230 "title": pr.title,
231 "state": pr.state,
232 "head_branch": pr.head_branch,
233 "base_branch": pr.base_branch,
234 "mergeable": pr.mergeable,
235 "mergeable_state": pr.mergeable_state,
236 "failing_checks": failing,
237 "stale_threads": Vec::<String>::new(),
238 "merge_conflicts": pr.mergeable_state == "dirty",
239 "merge_queue_status": pr.merge_queue_status,
240 })
241}
242
243fn pr_plan(pr: &PlaygroundPullRequest) -> Value {
244 json!({
245 "step": "intake",
246 "repo": pr.repo,
247 "pr_number": pr.number,
248 "head_branch": pr.head_branch,
249 "approval_required": false,
250 })
251}
252
253fn risk_plan(pr: &PlaygroundPullRequest) -> Value {
254 let risk = if needs_followup(pr) { "high" } else { "low" };
255 json!({
256 "step": "decide_risk",
257 "repo": pr.repo,
258 "pr_number": pr.number,
259 "review_risk": risk,
260 "approval_required": false,
261 })
262}
263
264fn checks_summary(pr: &PlaygroundPullRequest) -> Value {
265 let runs: Vec<Value> = pr
266 .checks
267 .iter()
268 .map(|c| {
269 json!({
270 "name": c.name,
271 "status": c.status,
272 "conclusion": c.conclusion,
273 })
274 })
275 .collect();
276 json!({"check_runs": runs})
277}
278
279fn needs_followup(pr: &PlaygroundPullRequest) -> bool {
280 pr.mergeable_state == "behind"
281 || pr.mergeable_state == "dirty"
282 || pr.mergeable_state == "blocked"
283 || pr
284 .checks
285 .iter()
286 .any(|c| matches!(c.conclusion.as_deref(), Some("failure" | "timed_out")))
287}