use crate::timeline::{
Step, Usage, assistant_text_step, attach_usage_to_first, compute_durations, parse_iso_ms,
pretty_json, tool_result_step, tool_use_step, user_text_step,
};
use anyhow::{Context, Result};
use std::path::Path;
pub fn load(path: &Path) -> Result<Vec<Step>> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("reading Vercel AI SDK session: {}", path.display()))?;
let root: serde_json::Value = serde_json::from_str(&content)
.with_context(|| format!("parsing Vercel AI SDK session: {}", path.display()))?;
let mut steps = Vec::new();
if let Some(user) = extract_user_prompt(&root) {
let trimmed = user.trim();
if !trimmed.is_empty() {
steps.push(user_text_step(trimmed));
}
}
match root.get("steps").and_then(|v| v.as_array()) {
Some(step_array) => {
for step in step_array {
append_step(step, &root, &mut steps);
}
}
None => append_step(&root, &root, &mut steps),
}
compute_durations(&mut steps);
Ok(steps)
}
fn extract_user_prompt(root: &serde_json::Value) -> Option<String> {
if let Some(s) = root.get("prompt").and_then(|v| v.as_str()) {
return Some(s.to_string());
}
let messages = root.get("messages")?.as_array()?;
for m in messages {
if m.get("role").and_then(|v| v.as_str()) != Some("user") {
continue;
}
if let Some(s) = m.get("content").and_then(|v| v.as_str()) {
return Some(s.to_string());
}
if let Some(parts) = m.get("content").and_then(|v| v.as_array()) {
let text = concat_text_parts(parts);
if !text.is_empty() {
return Some(text);
}
}
if let Some(parts) = m.get("parts").and_then(|v| v.as_array()) {
let text = concat_text_parts(parts);
if !text.is_empty() {
return Some(text);
}
}
}
None
}
fn concat_text_parts(parts: &[serde_json::Value]) -> String {
parts
.iter()
.filter(|p| {
matches!(p.get("type").and_then(|v| v.as_str()), Some("text") | None)
})
.filter_map(|p| p.get("text").and_then(|v| v.as_str()))
.collect::<Vec<_>>()
.join("")
}
fn append_step(step: &serde_json::Value, root: &serde_json::Value, steps: &mut Vec<Step>) {
let first_idx = steps.len();
let ts = step
.get("response")
.and_then(|r| r.get("timestamp"))
.and_then(|v| v.as_str())
.and_then(parse_iso_ms);
if let Some(text) = step
.get("text")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
{
let mut s = assistant_text_step(text);
s.timestamp_ms = ts;
steps.push(s);
}
if let Some(calls) = step.get("toolCalls").and_then(|v| v.as_array()) {
for tc in calls {
let name = tc
.get("toolName")
.and_then(|v| v.as_str())
.unwrap_or("(unknown)");
let id = tc.get("toolCallId").and_then(|v| v.as_str()).unwrap_or("");
let args = tc.get("args").cloned().unwrap_or(serde_json::Value::Null);
let mut s = tool_use_step(id, name, &pretty_json(&args));
s.timestamp_ms = ts;
steps.push(s);
}
}
if let Some(results) = step.get("toolResults").and_then(|v| v.as_array()) {
for tr in results {
let name = tr
.get("toolName")
.and_then(|v| v.as_str())
.unwrap_or("(unknown)");
let id = tr.get("toolCallId").and_then(|v| v.as_str()).unwrap_or("");
let args = tr.get("args").cloned().unwrap_or(serde_json::Value::Null);
let result_val = tr.get("result");
let result_text = match result_val {
Some(serde_json::Value::String(s)) => s.clone(),
Some(v) => pretty_json(v),
None => String::new(),
};
let mut s = tool_result_step(id, &result_text, Some(name), Some(&pretty_json(&args)));
s.timestamp_ms = ts;
steps.push(s);
}
}
if steps.len() > first_idx {
let usage = extract_usage(step).unwrap_or_default();
let model = extract_model(step).or_else(|| extract_model(root));
attach_usage_to_first(steps, first_idx, model.as_deref(), &usage);
}
}
fn extract_usage(obj: &serde_json::Value) -> Option<Usage> {
let u = obj.get("usage")?;
let get = |keys: &[&str]| -> Option<u64> {
for k in keys {
if let Some(n) = u.get(*k).and_then(|v| v.as_u64()) {
return Some(n);
}
}
None
};
let usage = Usage {
tokens_in: get(&["promptTokens", "inputTokens"]),
tokens_out: get(&["completionTokens", "outputTokens"]),
cache_read: get(&["cachedInputTokens", "cacheReadInputTokens"]),
cache_create: get(&["cacheCreationInputTokens"]),
};
let all_zero = [
usage.tokens_in,
usage.tokens_out,
usage.cache_read,
usage.cache_create,
]
.iter()
.all(|v| matches!(v, Some(0) | None));
if all_zero {
return None;
}
Some(usage)
}
fn extract_model(obj: &serde_json::Value) -> Option<String> {
if let Some(m) = obj
.get("response")
.and_then(|r| r.get("modelId"))
.and_then(|v| v.as_str())
{
return Some(m.to_string());
}
for k in ["modelId", "model"] {
if let Some(s) = obj.get(k).and_then(|v| v.as_str()) {
return Some(s.to_string());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::timeline::StepKind;
use std::io::Write;
use tempfile::NamedTempFile;
fn write_file(content: &str) -> NamedTempFile {
let mut f = NamedTempFile::new().unwrap();
f.write_all(content.as_bytes()).unwrap();
f
}
#[test]
fn parses_fixture_end_to_end() {
let steps = load(Path::new("../../assets/sample_vercel_ai_session.json")).unwrap();
assert_eq!(steps.len(), 5);
assert_eq!(steps[0].kind, StepKind::UserText);
assert!(steps[0].detail.contains("List files"));
assert_eq!(steps[1].kind, StepKind::AssistantText);
assert!(steps[1].detail.contains("list_dir tool"));
assert_eq!(steps[2].kind, StepKind::ToolUse);
assert!(steps[2].label.contains("list_dir"));
assert_eq!(steps[3].kind, StepKind::ToolResult);
assert!(steps[3].detail.contains("README.md"));
assert_eq!(steps[4].kind, StepKind::AssistantText);
}
#[test]
fn first_step_usage_attaches_to_first_assistant_text() {
let steps = load(Path::new("../../assets/sample_vercel_ai_session.json")).unwrap();
assert_eq!(steps[1].model.as_deref(), Some("gpt-5"));
assert_eq!(steps[1].tokens_in, Some(120));
assert_eq!(steps[1].tokens_out, Some(45));
}
#[test]
fn tool_result_step_from_zero_usage_step_carries_model_but_no_tokens() {
let steps = load(Path::new("../../assets/sample_vercel_ai_session.json")).unwrap();
assert_eq!(steps[3].tokens_in, None);
assert_eq!(steps[3].tokens_out, None);
}
#[test]
fn third_step_usage_attaches_to_final_assistant_text() {
let steps = load(Path::new("../../assets/sample_vercel_ai_session.json")).unwrap();
let last = steps.last().unwrap();
assert_eq!(last.kind, StepKind::AssistantText);
assert_eq!(last.tokens_in, Some(180));
assert_eq!(last.tokens_out, Some(30));
}
#[test]
fn single_step_no_steps_array_is_treated_as_one_step() {
let json = r#"{
"text": "ok",
"finishReason": "stop",
"usage": {"promptTokens": 10, "completionTokens": 5},
"response": {"modelId": "gpt-5"},
"messages": [{"role": "user", "content": "hi"}]
}"#;
let f = write_file(json);
let steps = load(f.path()).unwrap();
assert_eq!(steps.len(), 2);
assert_eq!(steps[0].kind, StepKind::UserText);
assert!(steps[0].detail.contains("hi"));
assert_eq!(steps[1].kind, StepKind::AssistantText);
assert_eq!(steps[1].tokens_in, Some(10));
assert_eq!(steps[1].tokens_out, Some(5));
assert_eq!(steps[1].model.as_deref(), Some("gpt-5"));
}
#[test]
fn v5_input_output_token_names_work() {
let json = r#"{
"text": "ok",
"usage": {"inputTokens": 42, "outputTokens": 17},
"response": {"modelId": "gpt-5"},
"messages": [{"role": "user", "content": "q"}]
}"#;
let f = write_file(json);
let steps = load(f.path()).unwrap();
assert_eq!(steps[1].tokens_in, Some(42));
assert_eq!(steps[1].tokens_out, Some(17));
}
#[test]
fn prompt_string_pulled_as_user_turn() {
let json = r#"{
"prompt": "write fibonacci",
"text": "def fib",
"usage": {"promptTokens": 1, "completionTokens": 1},
"response": {"modelId": "gpt-5"}
}"#;
let f = write_file(json);
let steps = load(f.path()).unwrap();
assert_eq!(steps[0].kind, StepKind::UserText);
assert!(steps[0].detail.contains("write fibonacci"));
}
#[test]
fn content_array_parts_for_user_message() {
let json = r#"{
"text": "ok",
"usage": {"promptTokens": 1, "completionTokens": 1},
"response": {"modelId": "gpt-5"},
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "hello"},
{"type": "text", "text": " world"}
]
}]
}"#;
let f = write_file(json);
let steps = load(f.path()).unwrap();
assert_eq!(steps[0].kind, StepKind::UserText);
assert!(steps[0].detail.contains("hello world"));
}
#[test]
fn tool_call_shape_preserves_args_as_object() {
let json = r#"{
"text": "ok",
"toolCalls": [{
"type": "tool-call",
"toolCallId": "call_1",
"toolName": "search",
"args": {"q": "rust", "limit": 3}
}],
"usage": {"promptTokens": 1, "completionTokens": 1},
"response": {"modelId": "gpt-5"},
"messages": [{"role": "user", "content": "q"}]
}"#;
let f = write_file(json);
let steps = load(f.path()).unwrap();
assert_eq!(steps.len(), 3);
assert_eq!(steps[2].kind, StepKind::ToolUse);
assert!(steps[2].detail.contains("\"q\""));
assert!(steps[2].detail.contains("\"rust\""));
assert!(steps[2].detail.contains("\"limit\""));
}
#[test]
fn usage_with_all_zeros_does_not_attach() {
let json = r#"{
"steps": [{
"stepType": "tool-result",
"text": "",
"toolResults": [{
"toolCallId": "call_1",
"toolName": "search",
"args": {},
"result": "nothing"
}],
"usage": {"promptTokens": 0, "completionTokens": 0, "totalTokens": 0},
"response": {"modelId": "gpt-5"}
}],
"messages": [{"role": "user", "content": "q"}]
}"#;
let f = write_file(json);
let steps = load(f.path()).unwrap();
assert_eq!(steps[1].kind, StepKind::ToolResult);
assert_eq!(steps[1].tokens_in, None);
assert_eq!(steps[1].tokens_out, None);
assert_eq!(steps[1].model.as_deref(), Some("gpt-5"));
}
}