a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
835 lines
25 KiB
Rust
835 lines
25 KiB
Rust
use super::*;
|
|
use crate::plan::PlanItem;
|
|
use crate::protocol::SwarmMemberStatus;
|
|
use crate::session::{StoredReplayEvent, StoredReplayEventKind};
|
|
use chrono::{Duration, Utc};
|
|
use std::ffi::OsString;
|
|
|
|
fn lock_env() -> std::sync::MutexGuard<'static, ()> {
|
|
crate::storage::lock_test_env()
|
|
}
|
|
|
|
struct EnvVarGuard {
|
|
key: &'static str,
|
|
prev: Option<OsString>,
|
|
}
|
|
|
|
impl EnvVarGuard {
|
|
fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
|
|
let prev = std::env::var_os(key);
|
|
crate::env::set_var(key, value);
|
|
Self { key, prev }
|
|
}
|
|
}
|
|
|
|
impl Drop for EnvVarGuard {
|
|
fn drop(&mut self) {
|
|
if let Some(prev) = &self.prev {
|
|
crate::env::set_var(self.key, prev);
|
|
} else {
|
|
crate::env::remove_var(self.key);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_timeline_roundtrip() {
|
|
let events = vec![
|
|
TimelineEvent {
|
|
t: 0,
|
|
kind: TimelineEventKind::UserMessage {
|
|
text: "hello".to_string(),
|
|
},
|
|
},
|
|
TimelineEvent {
|
|
t: 500,
|
|
kind: TimelineEventKind::Thinking { duration: 1000 },
|
|
},
|
|
TimelineEvent {
|
|
t: 1500,
|
|
kind: TimelineEventKind::StreamText {
|
|
text: "Hi there!".to_string(),
|
|
speed: 80,
|
|
},
|
|
},
|
|
TimelineEvent {
|
|
t: 2000,
|
|
kind: TimelineEventKind::Done,
|
|
},
|
|
];
|
|
|
|
// Serialize to JSON
|
|
let json = serde_json::to_string_pretty(&events).unwrap();
|
|
assert!(json.contains("user_message"));
|
|
assert!(json.contains("stream_text"));
|
|
|
|
// Deserialize back
|
|
let parsed: Vec<TimelineEvent> = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(parsed.len(), 4);
|
|
assert_eq!(parsed[0].t, 0);
|
|
assert_eq!(parsed[2].t, 1500);
|
|
}
|
|
|
|
#[test]
|
|
fn test_timeline_to_replay_events() {
|
|
let events = vec![
|
|
TimelineEvent {
|
|
t: 0,
|
|
kind: TimelineEventKind::StreamText {
|
|
text: "Hello world".to_string(),
|
|
speed: 80,
|
|
},
|
|
},
|
|
TimelineEvent {
|
|
t: 500,
|
|
kind: TimelineEventKind::Done,
|
|
},
|
|
];
|
|
|
|
let replay_events = timeline_to_replay_events(&events);
|
|
assert!(!replay_events.is_empty());
|
|
|
|
// First event should be a Server(TextDelta)
|
|
match &replay_events[0].1 {
|
|
ReplayEvent::Server(ServerEvent::TextDelta { text }) => assert!(!text.is_empty()),
|
|
_ => panic!("Expected Server(TextDelta)"),
|
|
}
|
|
|
|
// Last event should be Server(Done)
|
|
match &replay_events.last().unwrap().1 {
|
|
ReplayEvent::Server(ServerEvent::Done { .. }) => {}
|
|
_ => panic!("Expected Server(Done)"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_timeline_to_replay_events_caps_initial_idle() {
|
|
let events = vec![
|
|
TimelineEvent {
|
|
t: 8_000,
|
|
kind: TimelineEventKind::UserMessage {
|
|
text: "hello".to_string(),
|
|
},
|
|
},
|
|
TimelineEvent {
|
|
t: 8_500,
|
|
kind: TimelineEventKind::Thinking { duration: 800 },
|
|
},
|
|
];
|
|
|
|
let replay_events = timeline_to_replay_events(&events);
|
|
assert_eq!(replay_events[0].0, 0);
|
|
assert!(matches!(
|
|
replay_events[0].1,
|
|
ReplayEvent::UserMessage { .. }
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_cap_initial_replay_idle_shifts_timeline_start() {
|
|
let mut events = vec![
|
|
TimelineEvent {
|
|
t: 8_000,
|
|
kind: TimelineEventKind::UserMessage {
|
|
text: "hello".to_string(),
|
|
},
|
|
},
|
|
TimelineEvent {
|
|
t: 8_750,
|
|
kind: TimelineEventKind::Thinking { duration: 800 },
|
|
},
|
|
];
|
|
|
|
cap_initial_replay_idle(&mut events);
|
|
assert_eq!(events[0].t, 0);
|
|
assert_eq!(events[1].t, 750);
|
|
}
|
|
|
|
#[test]
|
|
fn test_tool_events() {
|
|
let events = vec![
|
|
TimelineEvent {
|
|
t: 0,
|
|
kind: TimelineEventKind::ToolStart {
|
|
name: "file_read".to_string(),
|
|
input: serde_json::json!({"file_path": "/tmp/test.rs"}),
|
|
},
|
|
},
|
|
TimelineEvent {
|
|
t: 500,
|
|
kind: TimelineEventKind::ToolDone {
|
|
name: "file_read".to_string(),
|
|
output: "fn main() {}".to_string(),
|
|
is_error: false,
|
|
},
|
|
},
|
|
];
|
|
|
|
let replay_events = timeline_to_replay_events(&events);
|
|
let types: Vec<&str> = replay_events
|
|
.iter()
|
|
.filter_map(|(_, e)| match e {
|
|
ReplayEvent::Server(se) => Some(match se {
|
|
ServerEvent::ToolStart { .. } => "start",
|
|
ServerEvent::ToolInput { .. } => "input",
|
|
ServerEvent::ToolExec { .. } => "exec",
|
|
ServerEvent::ToolDone { .. } => "done",
|
|
_ => "other",
|
|
}),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
assert!(types.contains(&"start"));
|
|
assert!(types.contains(&"exec"));
|
|
assert!(types.contains(&"done"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_user_message_and_thinking() {
|
|
let events = vec![
|
|
TimelineEvent {
|
|
t: 0,
|
|
kind: TimelineEventKind::UserMessage {
|
|
text: "hello".to_string(),
|
|
},
|
|
},
|
|
TimelineEvent {
|
|
t: 500,
|
|
kind: TimelineEventKind::Thinking { duration: 800 },
|
|
},
|
|
TimelineEvent {
|
|
t: 1300,
|
|
kind: TimelineEventKind::StreamText {
|
|
text: "Hi!".to_string(),
|
|
speed: 80,
|
|
},
|
|
},
|
|
];
|
|
|
|
let replay_events = timeline_to_replay_events(&events);
|
|
|
|
// First should be UserMessage
|
|
assert!(matches!(
|
|
&replay_events[0].1,
|
|
ReplayEvent::UserMessage { .. }
|
|
));
|
|
|
|
// Second should be StartProcessing
|
|
assert!(matches!(&replay_events[1].1, ReplayEvent::StartProcessing));
|
|
|
|
// Third should be Server(TextDelta)
|
|
assert!(matches!(
|
|
&replay_events[2].1,
|
|
ReplayEvent::Server(ServerEvent::TextDelta { .. })
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_export_timeline_includes_persisted_swarm_replay_events() {
|
|
let base = Utc::now();
|
|
let mut session = Session::create_with_id("session_replay_swarm_test".to_string(), None, None);
|
|
session.created_at = base;
|
|
session.updated_at = base;
|
|
session.replay_events = vec![
|
|
StoredReplayEvent {
|
|
timestamp: base + Duration::milliseconds(100),
|
|
kind: StoredReplayEventKind::DisplayMessage {
|
|
role: "swarm".to_string(),
|
|
title: Some("DM from fox".to_string()),
|
|
content: "Take parser tests".to_string(),
|
|
},
|
|
},
|
|
StoredReplayEvent {
|
|
timestamp: base + Duration::milliseconds(200),
|
|
kind: StoredReplayEventKind::SwarmStatus {
|
|
members: vec![SwarmMemberStatus {
|
|
session_id: "session_fox".to_string(),
|
|
friendly_name: Some("fox".to_string()),
|
|
status: "running".to_string(),
|
|
detail: Some("parser tests".to_string()),
|
|
task_label: None,
|
|
role: Some("agent".to_string()),
|
|
is_headless: None,
|
|
live_attachments: None,
|
|
status_age_secs: None,
|
|
output_tail: None,
|
|
report_back_to_session_id: None,
|
|
todo_progress: None,
|
|
todo_items: Vec::new(),
|
|
runtime: crate::protocol::SwarmMemberRuntime::default(),
|
|
}],
|
|
},
|
|
},
|
|
StoredReplayEvent {
|
|
timestamp: base + Duration::milliseconds(300),
|
|
kind: StoredReplayEventKind::SwarmPlan {
|
|
swarm_id: "swarm_123".to_string(),
|
|
version: 2,
|
|
items: vec![PlanItem {
|
|
content: "Run parser tests".to_string(),
|
|
status: "running".to_string(),
|
|
priority: "high".to_string(),
|
|
id: "task-1".to_string(),
|
|
subsystem: None,
|
|
file_scope: Vec::new(),
|
|
blocked_by: vec![],
|
|
assigned_to: Some("session_fox".to_string()),
|
|
}],
|
|
participants: vec!["session_fox".to_string()],
|
|
reason: Some("proposal approved".to_string()),
|
|
},
|
|
},
|
|
];
|
|
|
|
let timeline = export_timeline(&session);
|
|
assert!(timeline.iter().any(|event| matches!(
|
|
&event.kind,
|
|
TimelineEventKind::DisplayMessage { role, title, content }
|
|
if role == "swarm"
|
|
&& title.as_deref() == Some("DM from fox")
|
|
&& content == "Take parser tests"
|
|
)));
|
|
assert!(timeline.iter().any(|event| matches!(
|
|
&event.kind,
|
|
TimelineEventKind::SwarmStatus { members }
|
|
if members.len() == 1 && members[0].status == "running"
|
|
)));
|
|
assert!(timeline.iter().any(|event| matches!(
|
|
&event.kind,
|
|
TimelineEventKind::SwarmPlan { swarm_id, version, items, .. }
|
|
if swarm_id == "swarm_123" && *version == 2 && items.len() == 1
|
|
)));
|
|
}
|
|
|
|
#[test]
|
|
fn test_timeline_to_replay_events_converts_swarm_replay_events() {
|
|
let timeline = vec![
|
|
TimelineEvent {
|
|
t: 100,
|
|
kind: TimelineEventKind::DisplayMessage {
|
|
role: "swarm".to_string(),
|
|
title: Some("Broadcast · oak".to_string()),
|
|
content: "Plan updated".to_string(),
|
|
},
|
|
},
|
|
TimelineEvent {
|
|
t: 200,
|
|
kind: TimelineEventKind::SwarmStatus {
|
|
members: vec![SwarmMemberStatus {
|
|
session_id: "session_oak".to_string(),
|
|
friendly_name: Some("oak".to_string()),
|
|
status: "completed".to_string(),
|
|
detail: None,
|
|
task_label: None,
|
|
role: Some("agent".to_string()),
|
|
is_headless: None,
|
|
live_attachments: None,
|
|
status_age_secs: None,
|
|
output_tail: None,
|
|
report_back_to_session_id: None,
|
|
todo_progress: None,
|
|
todo_items: Vec::new(),
|
|
runtime: crate::protocol::SwarmMemberRuntime::default(),
|
|
}],
|
|
},
|
|
},
|
|
TimelineEvent {
|
|
t: 300,
|
|
kind: TimelineEventKind::SwarmPlan {
|
|
swarm_id: "swarm_abc".to_string(),
|
|
version: 7,
|
|
items: vec![PlanItem {
|
|
content: "Integrate results".to_string(),
|
|
status: "pending".to_string(),
|
|
priority: "high".to_string(),
|
|
id: "task-7".to_string(),
|
|
subsystem: None,
|
|
file_scope: Vec::new(),
|
|
blocked_by: vec![],
|
|
assigned_to: None,
|
|
}],
|
|
participants: vec![],
|
|
reason: None,
|
|
},
|
|
},
|
|
];
|
|
|
|
let replay_events = timeline_to_replay_events(&timeline);
|
|
assert!(replay_events.iter().any(|(_, event)| matches!(
|
|
event,
|
|
ReplayEvent::DisplayMessage { role, title, content }
|
|
if role == "swarm"
|
|
&& title.as_deref() == Some("Broadcast · oak")
|
|
&& content == "Plan updated"
|
|
)));
|
|
assert!(replay_events.iter().any(|(_, event)| matches!(
|
|
event,
|
|
ReplayEvent::SwarmStatus { members }
|
|
if members.len() == 1 && members[0].status == "completed"
|
|
)));
|
|
assert!(replay_events.iter().any(|(_, event)| matches!(
|
|
event,
|
|
ReplayEvent::SwarmPlan { swarm_id, version, items }
|
|
if swarm_id == "swarm_abc" && *version == 7 && items.len() == 1
|
|
)));
|
|
}
|
|
|
|
#[test]
|
|
fn test_load_swarm_sessions_discovers_related_sessions() {
|
|
let _env_lock = lock_env();
|
|
let temp_home = tempfile::Builder::new()
|
|
.prefix("jcode-replay-swarm-test-")
|
|
.tempdir()
|
|
.expect("create temp JCODE_HOME");
|
|
let _home = EnvVarGuard::set("JCODE_HOME", temp_home.path().as_os_str());
|
|
|
|
let mut seed = Session::create_with_id("session_seed".to_string(), None, None);
|
|
seed.working_dir = Some("/tmp/repo".to_string());
|
|
seed.record_swarm_status_event(vec![SwarmMemberStatus {
|
|
session_id: "session_seed".to_string(),
|
|
friendly_name: Some("seed".to_string()),
|
|
status: "running".to_string(),
|
|
detail: None,
|
|
task_label: None,
|
|
role: Some("coordinator".to_string()),
|
|
is_headless: None,
|
|
live_attachments: None,
|
|
status_age_secs: None,
|
|
output_tail: None,
|
|
report_back_to_session_id: None,
|
|
todo_progress: None,
|
|
todo_items: Vec::new(),
|
|
runtime: crate::protocol::SwarmMemberRuntime::default(),
|
|
}]);
|
|
seed.save().unwrap();
|
|
|
|
let mut child = Session::create_with_id(
|
|
"session_child".to_string(),
|
|
Some(seed.id.clone()),
|
|
Some("child".to_string()),
|
|
);
|
|
child.working_dir = Some("/tmp/repo".to_string());
|
|
child.record_swarm_plan_event(
|
|
"swarm_x".to_string(),
|
|
1,
|
|
vec![PlanItem {
|
|
content: "Task".to_string(),
|
|
status: "running".to_string(),
|
|
priority: "high".to_string(),
|
|
id: "task-1".to_string(),
|
|
subsystem: None,
|
|
file_scope: Vec::new(),
|
|
blocked_by: vec![],
|
|
assigned_to: Some(seed.id.clone()),
|
|
}],
|
|
vec![seed.id.clone(), child.id.clone()],
|
|
None,
|
|
);
|
|
child.save().unwrap();
|
|
|
|
let mut unrelated = Session::create_with_id("session_other".to_string(), None, None);
|
|
unrelated.working_dir = Some("/tmp/other".to_string());
|
|
unrelated.save().unwrap();
|
|
|
|
let loaded = load_swarm_sessions("session_seed", false).unwrap();
|
|
let ids: Vec<_> = loaded.iter().map(|s| s.session.id.as_str()).collect();
|
|
assert!(ids.contains(&"session_seed"));
|
|
assert!(ids.contains(&"session_child"));
|
|
assert!(!ids.contains(&"session_other"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_compose_swarm_buffers_combines_panes() {
|
|
use ratatui::{buffer::Buffer, layout::Rect, style::Style};
|
|
|
|
let mut left = Buffer::empty(Rect::new(0, 0, 4, 2));
|
|
left[(0, 0)].set_symbol("L").set_style(Style::default());
|
|
let mut right = Buffer::empty(Rect::new(0, 0, 4, 2));
|
|
right[(0, 0)].set_symbol("R").set_style(Style::default());
|
|
|
|
let panes = vec![
|
|
SwarmPaneFrames {
|
|
session_id: "left".to_string(),
|
|
title: "left".to_string(),
|
|
frames: vec![(0.0, left)],
|
|
},
|
|
SwarmPaneFrames {
|
|
session_id: "right".to_string(),
|
|
title: "right".to_string(),
|
|
frames: vec![(0.0, right)],
|
|
},
|
|
];
|
|
|
|
let frames = compose_swarm_buffers(&panes, 8, 2, 1, 2);
|
|
assert!(!frames.is_empty());
|
|
let buf = &frames[0].1;
|
|
assert_eq!(buf[(0, 0)].symbol(), "L");
|
|
assert_eq!(buf[(4, 0)].symbol(), "R");
|
|
}
|
|
|
|
#[test]
|
|
fn test_tool_ids_match_between_start_and_done() {
|
|
let events = vec![
|
|
TimelineEvent {
|
|
t: 0,
|
|
kind: TimelineEventKind::ToolStart {
|
|
name: "file_read".to_string(),
|
|
input: serde_json::json!({"file_path": "/tmp/test.rs"}),
|
|
},
|
|
},
|
|
TimelineEvent {
|
|
t: 500,
|
|
kind: TimelineEventKind::ToolDone {
|
|
name: "file_read".to_string(),
|
|
output: "fn main() {}".to_string(),
|
|
is_error: false,
|
|
},
|
|
},
|
|
];
|
|
|
|
let replay_events = timeline_to_replay_events(&events);
|
|
|
|
let start_id = replay_events.iter().find_map(|(_, e)| match e {
|
|
ReplayEvent::Server(ServerEvent::ToolStart { id, .. }) => Some(id.clone()),
|
|
_ => None,
|
|
});
|
|
let exec_id = replay_events.iter().find_map(|(_, e)| match e {
|
|
ReplayEvent::Server(ServerEvent::ToolExec { id, .. }) => Some(id.clone()),
|
|
_ => None,
|
|
});
|
|
let done_id = replay_events.iter().find_map(|(_, e)| match e {
|
|
ReplayEvent::Server(ServerEvent::ToolDone { id, .. }) => Some(id.clone()),
|
|
_ => None,
|
|
});
|
|
|
|
assert!(start_id.is_some(), "Should have ToolStart");
|
|
assert_eq!(start_id, exec_id, "ToolStart and ToolExec IDs must match");
|
|
assert_eq!(start_id, done_id, "ToolStart and ToolDone IDs must match");
|
|
}
|
|
|
|
#[test]
|
|
fn test_batch_tool_input_preserved() {
|
|
let batch_input = serde_json::json!({
|
|
"tool_calls": [
|
|
{"tool": "file_read", "parameters": {"file_path": "/tmp/a.rs"}},
|
|
{"tool": "file_read", "parameters": {"file_path": "/tmp/b.rs"}},
|
|
{"tool": "file_grep", "parameters": {"pattern": "foo"}},
|
|
]
|
|
});
|
|
|
|
let events = vec![
|
|
TimelineEvent {
|
|
t: 0,
|
|
kind: TimelineEventKind::ToolStart {
|
|
name: "batch".to_string(),
|
|
input: batch_input.clone(),
|
|
},
|
|
},
|
|
TimelineEvent {
|
|
t: 1000,
|
|
kind: TimelineEventKind::ToolDone {
|
|
name: "batch".to_string(),
|
|
output: "--- [1] file_read ---\nok\n--- [2] file_read ---\nok\n--- [3] file_grep ---\nok".to_string(),
|
|
is_error: false,
|
|
},
|
|
},
|
|
];
|
|
|
|
let replay_events = timeline_to_replay_events(&events);
|
|
|
|
// Verify the ToolInput delta contains the batch input
|
|
let input_delta = replay_events.iter().find_map(|(_, e)| match e {
|
|
ReplayEvent::Server(ServerEvent::ToolInput { delta }) => Some(delta.clone()),
|
|
_ => None,
|
|
});
|
|
assert!(
|
|
input_delta.is_some(),
|
|
"Should have ToolInput with batch params"
|
|
);
|
|
let parsed: serde_json::Value = serde_json::from_str(&input_delta.unwrap()).unwrap();
|
|
let tool_calls = parsed.get("tool_calls").and_then(|v| v.as_array());
|
|
assert_eq!(
|
|
tool_calls.map(|a| a.len()),
|
|
Some(3),
|
|
"Batch should have 3 tool calls"
|
|
);
|
|
|
|
// Verify IDs match
|
|
let start_id = replay_events.iter().find_map(|(_, e)| match e {
|
|
ReplayEvent::Server(ServerEvent::ToolStart { id, .. }) => Some(id.clone()),
|
|
_ => None,
|
|
});
|
|
let done_id = replay_events.iter().find_map(|(_, e)| match e {
|
|
ReplayEvent::Server(ServerEvent::ToolDone { id, .. }) => Some(id.clone()),
|
|
_ => None,
|
|
});
|
|
assert_eq!(
|
|
start_id, done_id,
|
|
"Batch ToolStart and ToolDone IDs must match"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_auto_edit_compresses_tool_spans() {
|
|
let events = vec![
|
|
TimelineEvent {
|
|
t: 0,
|
|
kind: TimelineEventKind::UserMessage { text: "hi".into() },
|
|
},
|
|
TimelineEvent {
|
|
t: 500,
|
|
kind: TimelineEventKind::Thinking { duration: 800 },
|
|
},
|
|
TimelineEvent {
|
|
t: 1300,
|
|
kind: TimelineEventKind::StreamText {
|
|
text: "Let me check.".into(),
|
|
speed: 80,
|
|
},
|
|
},
|
|
TimelineEvent {
|
|
t: 2000,
|
|
kind: TimelineEventKind::ToolStart {
|
|
name: "file_read".into(),
|
|
input: serde_json::json!({}),
|
|
},
|
|
},
|
|
TimelineEvent {
|
|
t: 12000,
|
|
kind: TimelineEventKind::ToolDone {
|
|
name: "file_read".into(),
|
|
output: "ok".into(),
|
|
is_error: false,
|
|
},
|
|
},
|
|
TimelineEvent {
|
|
t: 13000,
|
|
kind: TimelineEventKind::StreamText {
|
|
text: "Done!".into(),
|
|
speed: 80,
|
|
},
|
|
},
|
|
TimelineEvent {
|
|
t: 14000,
|
|
kind: TimelineEventKind::Done,
|
|
},
|
|
];
|
|
|
|
let opts = AutoEditOpts {
|
|
tool_max_ms: 800,
|
|
gap_max_ms: 2000,
|
|
think_max_ms: 1200,
|
|
response_delay_max_ms: 1000,
|
|
};
|
|
let edited = auto_edit_timeline(&events, &opts);
|
|
|
|
assert_eq!(edited.len(), events.len());
|
|
|
|
let tool_start_t = edited[3].t;
|
|
let tool_done_t = edited[4].t;
|
|
let tool_span = tool_done_t - tool_start_t;
|
|
assert!(
|
|
tool_span <= 800,
|
|
"Tool span should be compressed to ≤800ms, got {tool_span}ms"
|
|
);
|
|
|
|
assert!(
|
|
edited[5].t > tool_done_t,
|
|
"Events after tool_done should still be ordered"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_auto_edit_compresses_post_tool_idle_gap() {
|
|
let events = vec![
|
|
TimelineEvent {
|
|
t: 0,
|
|
kind: TimelineEventKind::UserMessage { text: "hi".into() },
|
|
},
|
|
TimelineEvent {
|
|
t: 500,
|
|
kind: TimelineEventKind::Thinking { duration: 800 },
|
|
},
|
|
TimelineEvent {
|
|
t: 1500,
|
|
kind: TimelineEventKind::ToolStart {
|
|
name: "selfdev".into(),
|
|
input: serde_json::json!({"action": "reload"}),
|
|
},
|
|
},
|
|
TimelineEvent {
|
|
t: 2500,
|
|
kind: TimelineEventKind::ToolDone {
|
|
name: "selfdev".into(),
|
|
output: "Reload initiated. Process restarting...".into(),
|
|
is_error: false,
|
|
},
|
|
},
|
|
TimelineEvent {
|
|
t: 48000,
|
|
kind: TimelineEventKind::Thinking { duration: 800 },
|
|
},
|
|
TimelineEvent {
|
|
t: 49000,
|
|
kind: TimelineEventKind::StreamText {
|
|
text: "Reloaded.".into(),
|
|
speed: 80,
|
|
},
|
|
},
|
|
];
|
|
|
|
let opts = AutoEditOpts::default();
|
|
let edited = auto_edit_timeline(&events, &opts);
|
|
|
|
let tool_done_t = edited[3].t;
|
|
let resumed_t = edited[4].t;
|
|
let gap = resumed_t - tool_done_t;
|
|
assert!(
|
|
gap <= opts.response_delay_max_ms,
|
|
"Gap after tool completion should be compressed to ≤{}ms, got {gap}ms",
|
|
opts.response_delay_max_ms
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_auto_edit_compresses_inter_prompt_gaps() {
|
|
let events = vec![
|
|
TimelineEvent {
|
|
t: 0,
|
|
kind: TimelineEventKind::UserMessage {
|
|
text: "first".into(),
|
|
},
|
|
},
|
|
TimelineEvent {
|
|
t: 500,
|
|
kind: TimelineEventKind::Thinking { duration: 800 },
|
|
},
|
|
TimelineEvent {
|
|
t: 1500,
|
|
kind: TimelineEventKind::StreamText {
|
|
text: "response".into(),
|
|
speed: 80,
|
|
},
|
|
},
|
|
TimelineEvent {
|
|
t: 2000,
|
|
kind: TimelineEventKind::Done,
|
|
},
|
|
TimelineEvent {
|
|
t: 30000,
|
|
kind: TimelineEventKind::UserMessage {
|
|
text: "second".into(),
|
|
},
|
|
},
|
|
TimelineEvent {
|
|
t: 30500,
|
|
kind: TimelineEventKind::Thinking { duration: 800 },
|
|
},
|
|
TimelineEvent {
|
|
t: 31500,
|
|
kind: TimelineEventKind::StreamText {
|
|
text: "response2".into(),
|
|
speed: 80,
|
|
},
|
|
},
|
|
TimelineEvent {
|
|
t: 32000,
|
|
kind: TimelineEventKind::Done,
|
|
},
|
|
];
|
|
|
|
let opts = AutoEditOpts::default();
|
|
let edited = auto_edit_timeline(&events, &opts);
|
|
|
|
let done_t = edited[3].t;
|
|
let next_user_t = edited[4].t;
|
|
let gap = next_user_t - done_t;
|
|
assert!(
|
|
gap <= 2000,
|
|
"Gap between turns should be compressed to ≤2000ms, got {gap}ms"
|
|
);
|
|
|
|
let total_original = events.last().unwrap().t;
|
|
let total_edited = edited.last().unwrap().t;
|
|
assert!(
|
|
total_edited < total_original,
|
|
"Total time should be shorter: {total_edited} < {total_original}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_auto_edit_clamps_thinking() {
|
|
let events = vec![
|
|
TimelineEvent {
|
|
t: 0,
|
|
kind: TimelineEventKind::UserMessage { text: "hi".into() },
|
|
},
|
|
TimelineEvent {
|
|
t: 500,
|
|
kind: TimelineEventKind::Thinking { duration: 5000 },
|
|
},
|
|
TimelineEvent {
|
|
t: 5500,
|
|
kind: TimelineEventKind::StreamText {
|
|
text: "ok".into(),
|
|
speed: 80,
|
|
},
|
|
},
|
|
];
|
|
|
|
let opts = AutoEditOpts {
|
|
think_max_ms: 1200,
|
|
..Default::default()
|
|
};
|
|
let edited = auto_edit_timeline(&events, &opts);
|
|
|
|
match &edited[1].kind {
|
|
TimelineEventKind::Thinking { duration } => {
|
|
assert_eq!(*duration, 1200, "Thinking should be clamped to 1200ms");
|
|
}
|
|
_ => panic!("Expected Thinking event"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_auto_edit_preserves_already_fast_timeline() {
|
|
let events = vec![
|
|
TimelineEvent {
|
|
t: 0,
|
|
kind: TimelineEventKind::UserMessage { text: "hi".into() },
|
|
},
|
|
TimelineEvent {
|
|
t: 200,
|
|
kind: TimelineEventKind::Thinking { duration: 500 },
|
|
},
|
|
TimelineEvent {
|
|
t: 700,
|
|
kind: TimelineEventKind::StreamText {
|
|
text: "hello!".into(),
|
|
speed: 80,
|
|
},
|
|
},
|
|
TimelineEvent {
|
|
t: 900,
|
|
kind: TimelineEventKind::Done,
|
|
},
|
|
TimelineEvent {
|
|
t: 1500,
|
|
kind: TimelineEventKind::UserMessage { text: "bye".into() },
|
|
},
|
|
];
|
|
|
|
let opts = AutoEditOpts::default();
|
|
let edited = auto_edit_timeline(&events, &opts);
|
|
|
|
for (orig, ed) in events.iter().zip(edited.iter()) {
|
|
assert_eq!(orig.t, ed.t, "Fast timeline should not be modified");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_auto_edit_empty_timeline() {
|
|
let edited = auto_edit_timeline(&[], &AutoEditOpts::default());
|
|
assert!(edited.is_empty());
|
|
}
|