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::TurnStart {
55 session_id: session_id.clone(),
56 iteration: 1,
57 },
58 ));
59
60 let i = bump(10, &mut now, &mut idx);
61 events.push(envelope(
62 i,
63 now,
64 AgentEvent::AgentThoughtChunk {
65 session_id: session_id.clone(),
66 content: format!(
67 "Sweep playground scenario={} ({} repos, {} PRs)",
68 state.scenario,
69 state.repos.len(),
70 state.pull_requests.len()
71 ),
72 },
73 ));
74
75 let mut prs: Vec<&PlaygroundPullRequest> = state
76 .pull_requests
77 .values()
78 .filter(|pr| pr.state == "open")
79 .collect();
80 prs.sort_by_key(|pr| (pr.repo.clone(), pr.number));
81
82 let mut tool_call_counter = 0u64;
83 for pr in prs {
84 tool_call_counter += 1;
85 let intake_id = format!("call_{tool_call_counter}");
86 let i = bump(20, &mut now, &mut idx);
87 events.push(envelope(
88 i,
89 now,
90 AgentEvent::ToolCall {
91 session_id: session_id.clone(),
92 tool_call_id: intake_id.clone(),
93 tool_name: "gh_pull_request_get".to_string(),
94 kind: None,
95 status: ToolCallStatus::Pending,
96 raw_input: json!({"repo": format!("{}/{}", state.owner, pr.repo), "pr_number": pr.number}),
97 parsing: None,
98 audit: None,
99 },
100 ));
101 let i = bump(40, &mut now, &mut idx);
102 events.push(envelope(
103 i,
104 now,
105 AgentEvent::ToolCallUpdate {
106 session_id: session_id.clone(),
107 tool_call_id: intake_id,
108 tool_name: "gh_pull_request_get".to_string(),
109 status: ToolCallStatus::Completed,
110 raw_output: Some(pr_summary(pr)),
111 error: None,
112 duration_ms: Some(40),
113 execution_duration_ms: Some(40),
114 error_category: None,
115 executor: None,
116 parsing: None,
117 raw_input: None,
118 raw_input_partial: None,
119 audit: None,
120 },
121 ));
122
123 let i = bump(20, &mut now, &mut idx);
124 events.push(envelope(
125 i,
126 now,
127 AgentEvent::Plan {
128 session_id: session_id.clone(),
129 plan: pr_plan(pr),
130 },
131 ));
132
133 tool_call_counter += 1;
134 let checks_id = format!("call_{tool_call_counter}");
135 let i = bump(20, &mut now, &mut idx);
136 events.push(envelope(
137 i,
138 now,
139 AgentEvent::ToolCall {
140 session_id: session_id.clone(),
141 tool_call_id: checks_id.clone(),
142 tool_name: "gh_pr_checks_list".to_string(),
143 kind: None,
144 status: ToolCallStatus::Pending,
145 raw_input: json!({"repo": format!("{}/{}", state.owner, pr.repo), "pr_number": pr.number}),
146 parsing: None,
147 audit: None,
148 },
149 ));
150 let i = bump(40, &mut now, &mut idx);
151 events.push(envelope(
152 i,
153 now,
154 AgentEvent::ToolCallUpdate {
155 session_id: session_id.clone(),
156 tool_call_id: checks_id,
157 tool_name: "gh_pr_checks_list".to_string(),
158 status: ToolCallStatus::Completed,
159 raw_output: Some(checks_summary(pr)),
160 error: None,
161 duration_ms: Some(40),
162 execution_duration_ms: Some(40),
163 error_category: None,
164 executor: None,
165 parsing: None,
166 raw_input: None,
167 raw_input_partial: None,
168 audit: None,
169 },
170 ));
171
172 let i = bump(10, &mut now, &mut idx);
173 events.push(envelope(
174 i,
175 now,
176 AgentEvent::Plan {
177 session_id: session_id.clone(),
178 plan: risk_plan(pr),
179 },
180 ));
181 }
182
183 let i = bump(50, &mut now, &mut idx);
184 events.push(envelope(
185 i,
186 now,
187 AgentEvent::AgentThoughtChunk {
188 session_id: session_id.clone(),
189 content: format!(
190 "Sweep complete: {} PR(s) inspected, {} require follow-up",
191 state
192 .pull_requests
193 .values()
194 .filter(|p| p.state == "open")
195 .count(),
196 state
197 .pull_requests
198 .values()
199 .filter(|p| p.state == "open" && needs_followup(p))
200 .count()
201 ),
202 },
203 ));
204
205 let _ = bump(0, &mut now, &mut idx);
206 events
207}
208
209fn pr_summary(pr: &PlaygroundPullRequest) -> Value {
210 let failing: Vec<String> = pr
211 .checks
212 .iter()
213 .filter(|c| {
214 c.conclusion
215 .as_deref()
216 .map(|c| matches!(c, "failure" | "timed_out" | "cancelled"))
217 .unwrap_or(false)
218 })
219 .map(|c| c.name.clone())
220 .collect();
221 json!({
222 "repo": pr.repo,
223 "pr_number": pr.number,
224 "title": pr.title,
225 "state": pr.state,
226 "head_branch": pr.head_branch,
227 "base_branch": pr.base_branch,
228 "mergeable": pr.mergeable,
229 "mergeable_state": pr.mergeable_state,
230 "failing_checks": failing,
231 "stale_threads": Vec::<String>::new(),
232 "merge_conflicts": pr.mergeable_state == "dirty",
233 "merge_queue_status": pr.merge_queue_status,
234 })
235}
236
237fn pr_plan(pr: &PlaygroundPullRequest) -> Value {
238 json!({
239 "step": "intake",
240 "repo": pr.repo,
241 "pr_number": pr.number,
242 "head_branch": pr.head_branch,
243 "approval_required": false,
244 })
245}
246
247fn risk_plan(pr: &PlaygroundPullRequest) -> Value {
248 let risk = if needs_followup(pr) { "high" } else { "low" };
249 json!({
250 "step": "decide_risk",
251 "repo": pr.repo,
252 "pr_number": pr.number,
253 "review_risk": risk,
254 "approval_required": false,
255 })
256}
257
258fn checks_summary(pr: &PlaygroundPullRequest) -> Value {
259 let runs: Vec<Value> = pr
260 .checks
261 .iter()
262 .map(|c| {
263 json!({
264 "name": c.name,
265 "status": c.status,
266 "conclusion": c.conclusion,
267 })
268 })
269 .collect();
270 json!({"check_runs": runs})
271}
272
273fn needs_followup(pr: &PlaygroundPullRequest) -> bool {
274 pr.mergeable_state == "behind"
275 || pr.mergeable_state == "dirty"
276 || pr.mergeable_state == "blocked"
277 || pr
278 .checks
279 .iter()
280 .any(|c| matches!(c.conclusion.as_deref(), Some("failure" | "timed_out")))
281}