1use harness_core::{
12 Action, CompactionStage, Event, Hook, HookOutcome, ModelOutput, ToolResult, World,
13};
14use serde::{Deserialize, Serialize};
15use std::fs::OpenOptions;
16use std::io::Write;
17use std::path::Path;
18use std::sync::Mutex;
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
23#[serde(tag = "kind", rename_all = "snake_case")]
24#[non_exhaustive]
25pub enum SessionEvent {
26 Start {
27 ts_ms: i64,
28 source: String,
29 },
30 PreModel {
31 ts_ms: i64,
32 history_len: usize,
33 tools_count: usize,
34 },
35 PostModel {
36 ts_ms: i64,
37 output: ModelOutput,
38 },
39 PreTool {
40 ts_ms: i64,
41 action: Action,
42 },
43 PostTool {
44 ts_ms: i64,
45 call_id: String,
46 result: ToolResult,
47 },
48 ToolTimed {
52 ts_ms: i64,
53 call_id: String,
54 started_ms: i64,
55 finished_ms: i64,
56 },
57 Sensor {
58 ts_ms: i64,
59 id: String,
60 signals: usize,
61 },
62 PreCompact {
63 ts_ms: i64,
64 stage: CompactionStage,
65 },
66 PostCompact {
67 ts_ms: i64,
68 stage: CompactionStage,
69 #[serde(default)]
72 before: u32,
73 #[serde(default)]
74 after: u32,
75 },
76 Heartbeat {
77 ts_ms: i64,
78 iter: u32,
79 },
80 BudgetWarning {
84 ts_ms: i64,
85 ratio: f32,
86 },
87 End {
88 ts_ms: i64,
89 },
90}
91
92pub struct SessionRecorder {
98 file: Mutex<std::fs::File>,
99}
100
101impl SessionRecorder {
102 pub fn new(path: &Path) -> std::io::Result<Self> {
104 if let Some(parent) = path.parent() {
105 std::fs::create_dir_all(parent)?;
106 }
107 let f = OpenOptions::new().create(true).append(true).open(path)?;
108 Ok(Self {
109 file: Mutex::new(f),
110 })
111 }
112
113 fn write(&self, ev: &SessionEvent) {
114 let Ok(mut f) = self.file.lock() else {
115 return;
116 };
117 match serde_json::to_string(ev) {
118 Ok(s) => {
119 if let Err(e) = writeln!(f, "{s}") {
120 tracing::warn!(error=%e, "session recorder write failed");
121 }
122 }
123 Err(e) => tracing::warn!(error=%e, "session recorder serialize failed"),
124 }
125 }
126}
127
128impl Hook for SessionRecorder {
129 fn name(&self) -> &str {
130 "session-recorder"
131 }
132 fn matches(&self, _ev: &Event<'_>) -> bool {
133 true
134 }
135
136 fn fire(&self, ev: &Event<'_>, world: &mut World) -> HookOutcome {
137 let ts = world.clock.now_ms();
138 let session_ev = match ev {
139 Event::SessionStart { source } => Some(SessionEvent::Start {
140 ts_ms: ts,
141 source: format!("{source:?}"),
142 }),
143 Event::PreModel { ctx } => Some(SessionEvent::PreModel {
144 ts_ms: ts,
145 history_len: ctx.history.len(),
146 tools_count: ctx.tools.len(),
147 }),
148 Event::PostModel { out } => Some(SessionEvent::PostModel {
149 ts_ms: ts,
150 output: (*out).clone(),
151 }),
152 Event::PreToolUse { action } => Some(SessionEvent::PreTool {
153 ts_ms: ts,
154 action: (*action).clone(),
155 }),
156 Event::PostToolUse { action, result } => Some(SessionEvent::PostTool {
157 ts_ms: ts,
158 call_id: action.call_id.clone(),
159 result: (*result).clone(),
160 }),
161 Event::ToolTimed {
162 action,
163 started_ms,
164 finished_ms,
165 } => Some(SessionEvent::ToolTimed {
166 ts_ms: ts,
167 call_id: action.call_id.clone(),
168 started_ms: *started_ms,
169 finished_ms: *finished_ms,
170 }),
171 Event::PostSensor { sensor, signals } => Some(SessionEvent::Sensor {
172 ts_ms: ts,
173 id: (*sensor).clone(),
174 signals: signals.len(),
175 }),
176 Event::PreCompact { stage } => Some(SessionEvent::PreCompact {
177 ts_ms: ts,
178 stage: *stage,
179 }),
180 Event::PostCompact {
181 stage,
182 before,
183 after,
184 } => Some(SessionEvent::PostCompact {
185 ts_ms: ts,
186 stage: *stage,
187 before: *before,
188 after: *after,
189 }),
190 Event::Heartbeat { iter } => Some(SessionEvent::Heartbeat {
191 ts_ms: ts,
192 iter: *iter,
193 }),
194 Event::BudgetWarning { ratio } => Some(SessionEvent::BudgetWarning {
195 ts_ms: ts,
196 ratio: *ratio,
197 }),
198 Event::SessionEnd => Some(SessionEvent::End { ts_ms: ts }),
199 _ => None,
200 };
201 if let Some(e) = session_ev {
202 self.write(&e);
203 }
204 HookOutcome::Allow
205 }
206}
207
208pub fn read_session(path: &Path) -> std::io::Result<Vec<SessionEvent>> {
213 let content = std::fs::read_to_string(path)?;
214 let mut events = Vec::new();
215 for (i, line) in content.lines().enumerate() {
216 let line = line.trim();
217 if line.is_empty() {
218 continue;
219 }
220 match serde_json::from_str(line) {
221 Ok(e) => events.push(e),
222 Err(err) => tracing::warn!(line=i+1, error=%err, "session log line skipped"),
223 }
224 }
225 Ok(events)
226}
227
228pub fn replay_as_mock(events: &[SessionEvent]) -> harness_models::MockModel {
232 use harness_models::{MockModel, MockResponse};
233 let mut m = MockModel::new().with_name("replay");
234 for e in events {
235 if let SessionEvent::PostModel { output, .. } = e {
236 m = m.script(MockResponse {
237 text: output.text.clone(),
238 tool_calls: output.tool_calls.clone(),
239 stop_reason: output.stop_reason,
240 input_tokens: output.usage.input_tokens,
241 output_tokens: output.usage.output_tokens,
242 reasoning: output.reasoning.clone(),
243 images: output.images.clone(),
247 });
248 }
249 }
250 m
251}
252
253pub fn replay_as_mock_via_events(events: &[SessionEvent]) -> harness_models::MockModel {
255 replay_as_mock(events)
256}
257
258#[derive(Debug, Clone, Default)]
260pub struct SessionStats {
261 pub events: usize,
262 pub model_calls: usize,
263 pub tool_calls: usize,
264 pub iters: u32,
265 pub input_tokens: u32,
266 pub output_tokens: u32,
267 pub stages_run: usize,
268 pub duration_ms: i64,
269}
270
271impl SessionStats {
272 pub fn from(events: &[SessionEvent]) -> Self {
273 let mut s = Self {
274 events: events.len(),
275 ..Default::default()
276 };
277 let mut first_ts: Option<i64> = None;
278 let mut last_ts: Option<i64> = None;
279 for e in events {
280 let ts = match e {
281 SessionEvent::Start { ts_ms, .. }
282 | SessionEvent::PreModel { ts_ms, .. }
283 | SessionEvent::PostModel { ts_ms, .. }
284 | SessionEvent::PreTool { ts_ms, .. }
285 | SessionEvent::PostTool { ts_ms, .. }
286 | SessionEvent::ToolTimed { ts_ms, .. }
287 | SessionEvent::Sensor { ts_ms, .. }
288 | SessionEvent::PreCompact { ts_ms, .. }
289 | SessionEvent::PostCompact { ts_ms, .. }
290 | SessionEvent::Heartbeat { ts_ms, .. }
291 | SessionEvent::BudgetWarning { ts_ms, .. }
292 | SessionEvent::End { ts_ms } => *ts_ms,
293 };
294 if first_ts.is_none() {
295 first_ts = Some(ts);
296 }
297 last_ts = Some(ts);
298
299 match e {
300 SessionEvent::PostModel { output, .. } => {
301 s.model_calls += 1;
302 s.input_tokens += output.usage.input_tokens;
303 s.output_tokens += output.usage.output_tokens;
304 }
305 SessionEvent::PreTool { .. } => s.tool_calls += 1,
306 SessionEvent::PostCompact { .. } => s.stages_run += 1,
307 SessionEvent::Heartbeat { iter, .. } => s.iters = s.iters.max(*iter + 1),
308 _ => {}
309 }
310 }
311 s.duration_ms = match (first_ts, last_ts) {
312 (Some(a), Some(b)) => b - a,
313 _ => 0,
314 };
315 s
316 }
317}
318
319pub fn format_event_verbose(e: &SessionEvent) -> String {
326 match e {
327 SessionEvent::Start { source, .. } => format!("session start ({source})"),
328 SessionEvent::Heartbeat { iter, .. } => format!("iter {iter}"),
329 SessionEvent::PreModel {
330 history_len,
331 tools_count,
332 ..
333 } => format!("→ model (history={history_len}, tools={tools_count})"),
334 SessionEvent::PostModel { output, .. } => {
335 let mut out = format!(
336 "← model: {} tool_call(s) [{}/{} tok, stop={:?}]",
337 output.tool_calls.len(),
338 output.usage.input_tokens,
339 output.usage.output_tokens,
340 output.stop_reason,
341 );
342 if let Some(text) = output.text.as_deref().filter(|s| !s.is_empty()) {
343 out.push_str("\n text: ");
344 out.push_str(&truncate(text, 400));
345 }
346 if let Some(reasoning) = output.reasoning.as_deref().filter(|s| !s.is_empty()) {
347 out.push_str("\n reasoning: ");
348 out.push_str(&truncate(reasoning, 200));
349 }
350 out
351 }
352 SessionEvent::PreTool { action, .. } => {
353 let args = action.args.to_string();
354 format!(" → tool {} args={}", action.tool, truncate(&args, 240))
355 }
356 SessionEvent::PostTool {
357 call_id, result, ..
358 } => {
359 let preview = preview_tool_result(result);
360 format!(
361 " ← tool {} ok={} {}",
362 call_id,
363 result.ok,
364 if preview.is_empty() {
365 String::new()
366 } else {
367 format!("\n {preview}")
368 }
369 )
370 }
371 SessionEvent::ToolTimed {
372 call_id,
373 started_ms,
374 finished_ms,
375 ..
376 } => format!(" ⏱ tool {call_id} ran {}ms", finished_ms - started_ms),
377 SessionEvent::Sensor { id, signals, .. } => {
378 format!(" ⚑ sensor {id}: {signals} signal(s)")
379 }
380 SessionEvent::PreCompact { stage, .. } => format!(" ⇩ pre-compact {stage:?}"),
381 SessionEvent::PostCompact { stage, .. } => format!(" ⇧ post-compact {stage:?}"),
382 SessionEvent::BudgetWarning { ratio, .. } => {
383 if *ratio >= 1.0 {
384 "≫ budget exhausted — forcing tool-less final-synthesis pass".into()
385 } else {
386 format!("≫ budget warning (used {:.0}%)", ratio * 100.0)
387 }
388 }
389 SessionEvent::End { .. } => "session end".into(),
390 }
391}
392
393fn preview_tool_result(r: &ToolResult) -> String {
399 let v = &r.content;
400 if !r.ok {
401 if let Some(errors) = v.get("errors").and_then(|x| x.as_array()) {
403 let joined: Vec<String> = errors
404 .iter()
405 .filter_map(|e| e.as_str().map(String::from))
406 .collect();
407 if !joined.is_empty() {
408 let hint = v
409 .get("hint")
410 .and_then(|x| x.as_str())
411 .map(|h| format!(" | hint: {h}"))
412 .unwrap_or_default();
413 return format!("errors=[{}]{hint}", truncate(&joined.join("; "), 240));
414 }
415 }
416 if let Some(msg) = v.get("message").and_then(|x| x.as_str()) {
417 return format!("message={}", truncate(msg, 240));
418 }
419 if let Some(err) = v.get("error").and_then(|x| x.as_str()) {
420 return format!("error={}", truncate(err, 240));
421 }
422 }
423 let s = v.to_string();
425 if s == "null" || s == "{}" {
426 String::new()
427 } else {
428 format!("preview={}", truncate(&s, 240))
429 }
430}
431
432fn truncate(s: &str, max: usize) -> String {
433 let chars: Vec<char> = s.chars().collect();
435 if chars.len() <= max {
436 s.replace('\n', " ⏎ ")
437 } else {
438 let head: String = chars[..max].iter().collect();
439 format!(
440 "{}… ({} chars total)",
441 head.replace('\n', " ⏎ "),
442 chars.len()
443 )
444 }
445}
446
447pub fn format_event_short(e: &SessionEvent) -> String {
450 match e {
451 SessionEvent::Start { source, .. } => format!("session start ({source})"),
452 SessionEvent::Heartbeat { iter, .. } => format!("iter {iter}"),
453 SessionEvent::PreModel {
454 history_len,
455 tools_count,
456 ..
457 } => {
458 format!("→ model (history={history_len}, tools={tools_count})")
459 }
460 SessionEvent::PostModel { output, .. } => {
461 let calls = output.tool_calls.len();
462 let txt = output
463 .text
464 .as_deref()
465 .unwrap_or("")
466 .chars()
467 .take(60)
468 .collect::<String>();
469 if calls > 0 {
470 format!(
471 "← model: {} tool_call(s) [{}/{} tok]",
472 calls, output.usage.input_tokens, output.usage.output_tokens
473 )
474 } else {
475 format!(
476 "← model: {:?} [{}/{} tok]",
477 txt, output.usage.input_tokens, output.usage.output_tokens
478 )
479 }
480 }
481 SessionEvent::PreTool { action, .. } => {
482 format!(" → tool {} args={}", action.tool, action.args)
483 }
484 SessionEvent::PostTool {
485 call_id, result, ..
486 } => {
487 format!(" ← tool {} ok={}", call_id, result.ok)
488 }
489 SessionEvent::ToolTimed {
490 call_id,
491 started_ms,
492 finished_ms,
493 ..
494 } => format!(" ⏱ tool {call_id} ran {}ms", finished_ms - started_ms),
495 SessionEvent::Sensor { id, signals, .. } => format!(" ⚑ sensor {id}: {signals} signal(s)"),
496 SessionEvent::PreCompact { stage, .. } => format!(" ⇩ pre-compact {stage:?}"),
497 SessionEvent::PostCompact { stage, .. } => format!(" ⇧ post-compact {stage:?}"),
498 SessionEvent::BudgetWarning { ratio, .. } => {
499 format!("≫ budget warning (used {:.0}%)", ratio * 100.0)
500 }
501 SessionEvent::End { .. } => "session end".into(),
502 }
503}
504
505#[derive(Default)]
516pub struct LiveProgressHook {
517 iter: std::sync::atomic::AtomicU32,
518}
519
520impl LiveProgressHook {
521 pub fn new() -> Self {
522 Self::default()
523 }
524}
525
526impl Hook for LiveProgressHook {
527 fn name(&self) -> &str {
528 "live-progress"
529 }
530 fn matches(&self, _ev: &Event<'_>) -> bool {
531 true
532 }
533 fn fire(&self, ev: &Event<'_>, world: &mut World) -> HookOutcome {
534 let ts = world.clock.now_ms();
535 let iter = self.iter.load(std::sync::atomic::Ordering::Relaxed);
536 if let Event::Custom {
537 name: "model_progress",
538 data,
539 } = ev
540 {
541 eprintln!(
542 "[iter={iter}] … generating {:.0}s, {} chars so far ({:.0} chars/s)",
543 data["elapsed_s"].as_f64().unwrap_or(0.0),
544 data["chars"].as_u64().unwrap_or(0),
545 data["chars_per_s"].as_f64().unwrap_or(0.0),
546 );
547 return HookOutcome::Allow;
548 }
549 let session_ev = match ev {
552 Event::SessionStart { source } => Some(SessionEvent::Start {
553 ts_ms: ts,
554 source: format!("{source:?}"),
555 }),
556 Event::PreModel { ctx } => Some(SessionEvent::PreModel {
557 ts_ms: ts,
558 history_len: ctx.history.len(),
559 tools_count: ctx.tools.len(),
560 }),
561 Event::PostModel { out } => Some(SessionEvent::PostModel {
562 ts_ms: ts,
563 output: (*out).clone(),
564 }),
565 Event::PreToolUse { action } => Some(SessionEvent::PreTool {
566 ts_ms: ts,
567 action: (*action).clone(),
568 }),
569 Event::PostToolUse { action, result } => Some(SessionEvent::PostTool {
570 ts_ms: ts,
571 call_id: action.call_id.clone(),
572 result: (*result).clone(),
573 }),
574 Event::Heartbeat { iter: i } => {
575 self.iter.store(*i, std::sync::atomic::Ordering::Relaxed);
576 Some(SessionEvent::Heartbeat {
577 ts_ms: ts,
578 iter: *i,
579 })
580 }
581 Event::PreCompact { stage } => Some(SessionEvent::PreCompact {
582 ts_ms: ts,
583 stage: *stage,
584 }),
585 Event::PostCompact {
586 stage,
587 before,
588 after,
589 } => Some(SessionEvent::PostCompact {
590 ts_ms: ts,
591 stage: *stage,
592 before: *before,
593 after: *after,
594 }),
595 Event::BudgetWarning { ratio } => Some(SessionEvent::BudgetWarning {
596 ts_ms: ts,
597 ratio: *ratio,
598 }),
599 Event::SessionEnd => Some(SessionEvent::End { ts_ms: ts }),
600 _ => None,
601 };
602 if let Some(e) = session_ev {
603 for line in format_event_verbose(&e).lines() {
604 eprintln!("[iter={iter}] {line}");
605 }
606 }
607 HookOutcome::Allow
608 }
609}
610
611#[cfg(test)]
612mod tests {
613 use super::*;
614
615 fn sample_log() -> Vec<SessionEvent> {
616 vec![
617 SessionEvent::Start {
618 ts_ms: 0,
619 source: "Startup".into(),
620 },
621 SessionEvent::Heartbeat { ts_ms: 1, iter: 0 },
622 SessionEvent::PreModel {
623 ts_ms: 2,
624 history_len: 1,
625 tools_count: 3,
626 },
627 SessionEvent::PostModel {
628 ts_ms: 100,
629 output: ModelOutput {
630 text: Some("hi".into()),
631 ..Default::default()
632 },
633 },
634 SessionEvent::End { ts_ms: 110 },
635 ]
636 }
637
638 #[test]
639 fn stats_compute_correctly() {
640 let s = SessionStats::from(&sample_log());
641 assert_eq!(s.events, 5);
642 assert_eq!(s.model_calls, 1);
643 assert_eq!(s.iters, 1);
644 assert_eq!(s.duration_ms, 110);
645 }
646
647 #[test]
648 fn round_trip_via_serde() {
649 let original = sample_log();
650 let json: Vec<String> = original
651 .iter()
652 .map(|e| serde_json::to_string(e).unwrap())
653 .collect();
654 let parsed: Vec<SessionEvent> = json
655 .iter()
656 .map(|s| serde_json::from_str::<SessionEvent>(s).unwrap())
657 .collect();
658 assert_eq!(parsed.len(), original.len());
659 assert!(
660 matches!(parsed[3], SessionEvent::PostModel { ref output, .. } if output.text.as_deref() == Some("hi"))
661 );
662 }
663}