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