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