use sessionwiki::adapters;
use sessionwiki::model::Session;
use std::path::PathBuf;
fn fixture(rel: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures")
.join(rel)
}
fn parse(tool: &str, rel: &str) -> Session {
let adapter = adapters::by_name(tool).expect("adapter exists");
adapters::parse_session(adapter.as_ref(), &fixture(rel)).expect("parse succeeds")
}
fn roles(s: &Session) -> Vec<&'static str> {
s.messages.iter().map(|m| m.role.label()).collect()
}
#[test]
fn claude_main_session() {
let s = parse(
"claude-code",
"claude-code/proj-a/0a000000-0000-4000-8000-000000000001.jsonl",
);
assert_eq!(s.title, "Fix CORS preflight failing on /auth routes");
assert_eq!(s.tool, "claude-code");
assert_eq!(s.project, "/home/dev/proj-a");
assert!(!s.subagent);
assert_eq!(
roles(&s),
[
"user",
"assistant",
"tool",
"tool",
"tool",
"tool",
"assistant"
]
);
assert_eq!(s.messages[2].text, "→ Bash(rg -n cors src/) ⇒ ok · 1 line");
assert_eq!(
s.messages[3].text,
"→ Edit(/home/dev/proj-a/src/middleware/mod.rs) ⇒ error · 2 lines — Edit failed"
);
assert_eq!(
s.messages[4].text,
"→ Edit(/home/dev/proj-a/src/middleware/mod.rs) ⇒ pending"
);
assert_eq!(
s.messages[5].text,
"→ Write(/home/dev/proj-a/tests/cors_preflight.rs) ⇒ ok · 5 lines"
);
assert!(matches!(
&s.messages[5].tool,
Some(sessionwiki::model::ToolEvent::Summary(summary))
if summary.output.as_deref().is_some_and(|output| output.lines().count() == 5)
));
assert!(s
.messages
.iter()
.all(|m| !m.text.contains("harness boilerplate")));
assert!(s.messages[0].text.contains("Preflight requests"));
assert!(s.messages.iter().any(|m| m.text.contains("14 tests pass")));
assert_eq!(
s.touched,
[
"/home/dev/proj-a/src/middleware/mod.rs",
"/home/dev/proj-a/tests/cors_preflight.rs"
]
);
assert_eq!(
s.started.map(|t| t.to_rfc3339()),
Some("2026-06-09T14:01:00+00:00".to_string())
);
}
#[test]
fn claude_subagent_is_flagged() {
let s = parse(
"claude-code",
"claude-code/proj-a/0a000000-0000-4000-8000-000000000001/subagents/agent-deadbeef.jsonl",
);
assert!(s.subagent, "files under /subagents/ must be flagged");
assert_eq!(roles(&s), ["user", "assistant"]);
assert_eq!(s.title, "Search the codebase for all CORS references.");
}
#[test]
fn claude_ai_title_precedes_first_user_message() {
let s = parse(
"claude-code",
"claude-code/proj-a/0a000000-0000-4000-8000-000000000002.jsonl",
);
assert_eq!(s.title, "Repair synthetic schema drift");
assert_eq!(roles(&s), ["user"]);
}
#[test]
fn codex_session_with_schema_variants() {
let s = parse(
"codex",
"codex/rollout-2026-06-11T13-00-00-019eb9b2-1466-7e93-8b85-5b596295e96b.jsonl",
);
assert_eq!(s.tool, "codex");
assert_eq!(s.project, "/home/dev/api-server");
assert_eq!(s.title, "Write property-based tests for the rate limiter.");
assert_eq!(
roles(&s),
["user", "tool", "tool", "assistant", "user", "assistant"]
);
assert!(s
.messages
.iter()
.all(|m| !m.text.contains("environment_context")));
assert_eq!(s.messages[1].text, "→ shell(cargo test) ⇒ pending");
assert!(s.messages[2]
.text
.starts_with("→ shell(apply_patch *** Begin Patch"));
assert!(s.messages[2].text.ends_with(" ⇒ ok · 1 line"));
assert!(s
.messages
.iter()
.all(|message| !message.text.contains("10000 cases passed")));
let Some(sessionwiki::model::ToolEvent::Summary(summary)) = &s.messages[2].tool else {
panic!("the apply_patch call should retain the anonymous structured result");
};
assert_eq!(summary.output.as_deref(), Some("10000 cases passed"));
assert_eq!(
sessionwiki::tool_summary::render_output(summary, 8 * 1024, 80).as_deref(),
Some("```text\n10000 cases passed\n```")
);
assert!(s.messages.iter().any(|m| m.text.contains("2.1M ops/sec")));
assert_eq!(
s.touched,
["src/rate_limiter.rs", "tests/rate_limiter_props.rs"]
);
}
#[test]
fn codex_deduplicates_user_message_schema_variants() {
let s = parse(
"codex",
"codex/rollout-2026-07-13T07-05-40-duplicate-user.jsonl",
);
assert_eq!(roles(&s), ["user"]);
assert_eq!(s.messages[0].text, "Explain the synthetic parser failure.");
}
#[test]
fn codex_drops_current_agents_instructions_boilerplate() {
let s = parse(
"codex",
"codex/rollout-2026-07-13T16-06-13-agents-instructions.jsonl",
);
assert_eq!(s.title, "Fix the synthetic parser.");
assert_eq!(roles(&s), ["user"]);
assert!(s
.messages
.iter()
.all(|message| !message.text.starts_with("# AGENTS.md instructions")));
}
#[test]
fn codex_keeps_genuinely_repeated_prompts() {
let s = parse(
"codex",
"codex/rollout-2026-07-13T08-00-00-repeated-prompt.jsonl",
);
assert_eq!(roles(&s), ["user", "assistant", "user"]);
assert_eq!(s.messages[0].text, "continue");
assert_eq!(s.messages[2].text, "continue");
}
#[test]
fn codex_parses_legacy_bare_messages() {
let s = parse(
"codex",
"codex/rollout-2025-08-25T00-04-27-bare-message.jsonl",
);
assert_eq!(roles(&s), ["user", "assistant"]);
assert_eq!(s.title, "Parse the legacy synthetic rollout.");
}
#[test]
fn codex_indexes_custom_tool_calls_and_their_patched_paths() {
let s = parse(
"codex",
"codex/rollout-2026-07-13T16-05-05-custom-tool-call.jsonl",
);
assert_eq!(roles(&s), ["user", "tool"]);
assert_eq!(
s.messages[1].text,
"→ apply_patch(src/synthetic.rs) ⇒ pending"
);
assert_eq!(s.touched, ["src/synthetic.rs"]);
}
#[test]
fn codex_structured_tool_calls_and_results_are_summarized() {
let s = parse(
"codex",
"codex/rollout-2026-10-06T12-00-00-structured-tools.jsonl",
);
let tool_lines: Vec<_> = s
.messages
.iter()
.filter(|message| message.role.label() == "tool")
.map(|message| message.text.as_str())
.collect();
assert_eq!(
tool_lines,
[
"→ shell(cargo check) ⇒ ok · 1 line",
"→ shell(cargo test) ⇒ error · 2 lines — error[E0425]: unresolved synthetic name",
"→ shell(print-many) ⇒ ok · 10 lines",
"→ apply_patch(src/codex_synthetic.rs) ⇒ ok · 2 lines",
"→ shell(printf block output) ⇒ ok · 2 lines",
"→ shell(older-result) ⇒ ok · 1 line",
"→ shell(echo pending) ⇒ pending",
"→ exec_command(git status --short && echo \"done\") ⇒ ok · 2 lines",
"→ exec_command(pwd; rg -n needle missing.rs) ⇒ error · 3 lines — /home/dev/proj",
]
);
assert!(tool_lines.iter().all(|line| !line.contains(" {")));
assert_eq!(s.touched, ["src/codex_synthetic.rs"]);
}
#[test]
fn codex_plain_text_command_results_carry_their_exit_codes() {
let s = parse(
"codex",
"codex/rollout-2026-05-06T12-00-00-text-results.jsonl",
);
let tool_lines: Vec<_> = s
.messages
.iter()
.filter(|message| message.role.label() == "tool")
.map(|message| message.text.as_str())
.collect();
assert_eq!(
tool_lines,
[
"→ exec_command(cargo test) ⇒ error · 2 lines — error[E0425]: unresolved synthetic name",
"→ exec_command(cargo check) ⇒ ok · 1 line",
"→ exec_command(sleep 30) ⇒ error · 1 line — timed out after 10s",
"→ exec_command(pwd) ⇒ ok · 1 line",
]
);
}
#[test]
fn codex_mcp_tool_failures_are_marked_as_errors() {
let s = parse(
"codex",
"codex/rollout-2026-05-07T12-00-00-mcp-errors.jsonl",
);
let tool_lines: Vec<_> = s
.messages
.iter()
.filter(|message| message.role.label() == "tool")
.map(|message| message.text.as_str())
.collect();
assert_eq!(tool_lines.len(), 3, "{tool_lines:?}");
assert!(tool_lines[0].contains("⇒ error"), "{}", tool_lines[0]);
assert!(tool_lines[1].contains("⇒ ok"), "{}", tool_lines[1]);
assert!(tool_lines[2].contains("⇒ error"), "{}", tool_lines[2]);
}
#[test]
fn codex_mcp_calls_recorded_only_as_events_are_indexed() {
let s = parse(
"codex",
"codex/rollout-2026-07-08T12-00-00-mcp-events-only.jsonl",
);
let tool_lines: Vec<_> = s
.messages
.iter()
.filter(|message| message.role.label() == "tool")
.map(|message| message.text.as_str())
.collect();
assert_eq!(
tool_lines,
[
"→ navigate(https://example.invalid/a) ⇒ ok · 2 lines",
"→ navigate(https://example.invalid/b) ⇒ error · 1 line — tool call failed: timeout",
"→ navigate(https://example.invalid/c) ⇒ ok · 1 line",
]
);
}
#[test]
fn gemini_session() {
let s = parse(
"gemini",
"gemini/myproject/chats/session-2026-06-08T10-00-abcd1234.json",
);
assert_eq!(s.tool, "gemini");
assert_eq!(s.project, "myproject");
assert_eq!(roles(&s), ["user", "assistant"]);
assert!(s.messages[0].text.contains("iframe"));
assert!(s.messages[1].text.contains("1급 스코프")); assert_eq!(
s.ended.map(|t| t.to_rfc3339()),
Some("2026-06-08T10:00:30+00:00".to_string())
);
}
#[test]
fn gemini_tool_calls_and_results_are_summarized() {
let s = parse(
"gemini",
"gemini/myproject/chats/session-2026-06-08T11-00-tools.json",
);
let tool_lines: Vec<_> = s
.messages
.iter()
.filter(|message| message.role.label() == "tool")
.map(|message| message.text.as_str())
.collect();
assert_eq!(
tool_lines,
[
"→ run_shell_command(cargo test) ⇒ ok · 1 line",
"→ run_shell_command(cargo test --broken) ⇒ error · 1 line — permission denied",
"→ read_file(src/lib.rs) ⇒ ok · 12 lines",
"→ list_files(src) ⇒ ok · 1 line",
]
);
assert_eq!(
roles(&s),
[
"user",
"assistant",
"tool",
"tool",
"tool",
"tool",
"assistant"
]
);
}
#[test]
fn opencode_multi_file_session() {
let s = parse("opencode", "opencode/storage/session/proj1/ses_aaa.json");
assert_eq!(s.tool, "opencode");
assert_eq!(s.title, "Add retry to the HTTP client"); assert_eq!(s.project, "/home/dev/myapp"); assert!(!s.subagent);
assert_eq!(
roles(&s),
["user", "assistant", "tool", "tool", "tool", "tool"]
);
assert!(s.messages[0]
.text
.contains("retry with exponential backoff"));
assert!(s
.messages
.iter()
.any(|m| m.text.contains("3-attempt retry")));
assert!(s
.messages
.iter()
.all(|m| !m.text.contains("thinking about"))); let tool_lines: Vec<_> = s
.messages
.iter()
.filter(|m| m.text.starts_with("→ "))
.map(|m| m.text.as_str())
.collect();
assert_eq!(
tool_lines,
[
"→ edit(/home/dev/myapp/src/http/client.ts) ⇒ ok · 1 line",
"→ bash(false) ⇒ error · 2 lines — Command failed: exit 1",
"→ read(README.md) ⇒ ok · 10 lines",
"→ read(config.json) ⇒ pending"
]
);
assert_eq!(
s.touched,
[
"/home/dev/myapp/src/http/client.ts",
"/home/dev/myapp/src/util.ts"
]
);
assert_eq!(
s.started,
chrono::DateTime::from_timestamp_millis(1718630400000)
);
}
#[test]
fn cline_xml_and_native_tool_edits() {
let s = parse(
"cline",
"cline/tasks/1749477660000/api_conversation_history.json",
);
assert_eq!(s.tool, "cline");
assert_eq!(s.title, "Add a hello function to src/main.py"); assert_eq!(s.project, "/home/dev/app"); assert!(!s.subagent);
assert_eq!(
roles(&s),
[
"user",
"assistant",
"tool",
"assistant",
"tool",
"tool",
"tool",
"assistant"
]
);
assert_eq!(
s.messages[2].text,
"→ write_to_file(src/main.py) ⇒ ok · 1 line"
);
assert_eq!(
s.messages[4].text,
"→ write_to_file(tests/test_main.py) ⇒ ok · 1 line"
);
assert_eq!(
s.messages[5].text,
"→ execute_command(cargo test) ⇒ error · 2 lines — Error: cargo test failed"
);
assert_eq!(
s.messages[6].text,
"→ read_file(src/output.txt) ⇒ ok · 10 lines"
);
assert_eq!(s.messages[0].text, "Add a hello function to src/main.py");
assert!(s
.messages
.iter()
.all(|m| !m.text.contains("environment_details")));
assert!(s.messages.iter().all(|m| !m.text.contains("] Result:")));
assert!(s
.messages
.iter()
.all(|m| !m.text.contains("successfully saved")));
assert_eq!(s.touched, ["src/main.py", "tests/test_main.py"]);
assert_eq!(
s.started,
chrono::DateTime::from_timestamp_millis(1749477660000)
);
assert_eq!(
s.ended,
chrono::DateTime::from_timestamp_millis(1749477700000)
);
}
#[test]
fn gajae_jsonl_session() {
let s = parse(
"gajae-code",
"gajae-code/--home--dev--proj--/2025-12-09T00-53-29-825Z_ffae836b-9420-4060-ac13-7745215f90ff.jsonl",
);
assert_eq!(s.tool, "gajae-code");
assert_eq!(s.title, "refactor the parser"); assert_eq!(s.project, "/home/dev/proj"); assert!(!s.subagent);
assert_eq!(
roles(&s),
[
"user",
"assistant",
"tool",
"assistant",
"tool",
"assistant",
"tool",
"assistant",
"tool",
"assistant"
]
);
assert!(s
.messages
.iter()
.all(|m| !m.text.contains("considering the structure")));
let tool_lines: Vec<_> = s
.messages
.iter()
.filter(|m| m.text.starts_with("→ "))
.map(|m| m.text.as_str())
.collect();
assert_eq!(
tool_lines,
[
"→ write(src/parse.ts) ⇒ ok · 1 line",
"→ read(config.ts) ⇒ pending",
"→ ast_edit({\"paths\":[\"src/parse.ts\",\"src/helper.ts\"]}) ⇒ ok · 10 lines",
"→ edit(src/parse.ts) ⇒ error · 2 lines — Edit failed"
]
);
assert_eq!(s.touched, ["src/parse.ts", "src/helper.ts"]);
assert_eq!(
s.started.map(|t| t.to_rfc3339()),
Some("2025-12-09T00:53:29.825+00:00".to_string())
);
assert_eq!(
s.ended.map(|t| t.to_rfc3339()),
Some("2025-12-09T00:53:40+00:00".to_string())
);
}
#[test]
fn continue_session_with_tool_edit() {
let s = parse(
"continue",
"continue/sessions/5f1d2c9a-8b34-4e21-9d6e-7a0c1b2e3f44.json",
);
assert_eq!(s.tool, "continue");
assert_eq!(s.title, "Add retry to the HTTP client");
assert_eq!(s.project, "/home/alex/projects/acme-api");
assert!(!s.subagent);
assert_eq!(
roles(&s),
[
"user",
"assistant",
"tool",
"assistant",
"tool",
"assistant",
"tool"
]
);
assert_eq!(
s.messages[2].text,
"→ edit_existing_file(src/http.ts) ⇒ ok · 1 line"
);
assert_eq!(
s.messages[4].text,
"→ run_command(cargo test) ⇒ error · 2 lines — Error: cargo test failed"
);
assert_eq!(
s.messages[6].text,
"→ read_file(logs/test-output.txt) ⇒ ok · 10 lines"
);
assert_eq!(s.touched, ["src/http.ts"]);
assert_eq!(
s.started,
chrono::DateTime::from_timestamp_millis(1718600000000)
);
assert_eq!(s.ended, None);
}
#[test]
fn gptme_session() {
let s = parse(
"gptme",
"gptme/fix-cors-bug-20260608-abcd/conversation.jsonl",
);
assert_eq!(s.tool, "gptme");
assert_eq!(s.project, "fix-cors-bug-20260608-abcd");
assert_eq!(roles(&s), ["user", "assistant", "user", "assistant"]);
assert_eq!(
s.started,
chrono::DateTime::parse_from_rfc3339("2026-06-08T10:00:01Z")
.ok()
.map(|t| t.with_timezone(&chrono::Utc))
);
assert!(s.messages[0].text.contains("CORS preflight"));
}
#[test]
fn gptme_markdown_tool_calls_are_summarized_without_inferred_error_status() {
let s = parse("gptme", "gptme/tool-summary/conversation.jsonl");
let tool_lines: Vec<_> = s
.messages
.iter()
.filter(|message| message.role.label() == "tool")
.map(|message| message.text.as_str())
.collect();
assert_eq!(
tool_lines,
[
"→ shell(cargo test) ⇒ ok · 1 line",
"→ shell(cargo test --broken) ⇒ ok · 1 line",
"→ shell(seq 1 12) ⇒ ok · 12 lines",
]
);
assert_eq!(roles(&s), ["user", "tool", "tool", "tool", "assistant"]);
}
#[test]
fn gptme_malformed_lines_do_not_panic() {
let adapter = adapters::by_name("gptme").unwrap();
let result = adapter.parse(&fixture(
"gptme/fix-cors-bug-20260608-abcd/conversation.jsonl",
));
assert!(result.is_ok());
}
#[test]
fn missing_file_errors_without_panicking() {
let adapter = adapters::by_name("codex").unwrap();
assert!(adapter
.parse(&fixture("codex/does-not-exist.jsonl"))
.is_err());
}
#[test]
fn aider_golden() {
let path = fixture("aider/myrepo/.aider.chat.history.md");
let adapter = adapters::by_name("aider").unwrap();
let s = adapters::parse_session_key(
adapter.as_ref(),
&format!("{}\u{1f}0", path.to_string_lossy()),
)
.unwrap();
assert_eq!(s.tool, "aider");
assert_eq!(s.title, "add a retry to the webhook");
let roles: Vec<&str> = s.messages.iter().map(|m| m.role.label()).collect();
assert_eq!(roles, vec!["user", "assistant", "tool"]);
assert_eq!(s.touched, vec!["src/webhook.py", "src/retry.py"]); assert!(s.started.is_some());
assert!(s.messages.iter().all(|m| m.ts.is_none()));
assert!(adapter
.parse_key(&format!("{}\u{1f}99", path.to_string_lossy()))
.is_err());
}
#[test]
fn every_adapter_is_addressable_by_name() {
for tool in [
"claude-code",
"codex",
"gemini",
"opencode",
"cline",
"roo-code",
"kilo-code",
"gajae-code",
"continue",
"gptme",
"aider",
] {
assert!(adapters::by_name(tool).is_some(), "{tool} should resolve");
}
assert!(adapters::by_name("nonexistent").is_none());
}
#[test]
fn gemini_project_is_the_recorded_root_not_the_store_slug() {
let dir = tempfile::tempdir().unwrap();
let store = dir.path().join("myproject");
std::fs::create_dir_all(store.join("chats")).unwrap();
let chat = store.join("chats/session-2026-06-08T10-00-abcd1234.json");
std::fs::copy(
fixture("gemini/myproject/chats/session-2026-06-08T10-00-abcd1234.json"),
&chat,
)
.unwrap();
std::fs::write(store.join(".project_root"), "/work/myproject\n").unwrap();
let s = adapters::by_name("gemini").unwrap().parse(&chat).unwrap();
assert_eq!(s.project, "/work/myproject");
let data = tempfile::tempdir().unwrap();
std::env::set_var("SESSIONWIKI_DATA", data.path());
let conn = sessionwiki::index::open().unwrap();
conn.execute(
"INSERT INTO files(path, mtime, size, session_id, tool, project, title, started, msg_count, kind)
VALUES (?1, 0, 0, 'g-1', 'gemini', 'myproject', 't', '2026-06-08T10:00:00+00:00', 2, 'main')",
[chat.to_string_lossy()],
)
.unwrap();
conn.execute("DELETE FROM meta WHERE key = 'gemini_project_upto'", [])
.unwrap();
drop(conn);
let conn = sessionwiki::index::open().unwrap();
let project: String = conn
.query_row(
"SELECT project FROM files WHERE session_id = 'g-1'",
[],
|r| r.get(0),
)
.unwrap();
std::env::remove_var("SESSIONWIKI_DATA");
assert_eq!(project, "/work/myproject");
}
#[test]
fn prodex_consult_task_becomes_a_two_message_session() {
let s = parse(
"prodex",
"prodex/repo-a/.bridge/tasks/task_20260707_090000_gpt-pro-consult.json",
);
assert_eq!(s.tool, "prodex");
assert_eq!(s.id, "567f374d7a70");
assert!(s.title.starts_with("Should the retry loop"), "{}", s.title);
assert_eq!(roles(&s), ["user", "assistant"]);
assert!(s.messages[0].text.contains("exponential backoff"), "prompt");
assert!(
s.messages[1].text.contains("thundering herds"),
"artifact text used as the answer: {}",
s.messages[1].text
);
assert!(s.started.is_some(), "claimed_at parsed");
assert!(s.project.ends_with("repo-a"), "{}", s.project);
}
#[test]
fn prodex_pending_task_is_a_one_message_session() {
let s = parse(
"prodex",
"prodex/repo-a/.bridge/tasks/task_20260707_100000_open-question.json",
);
assert_eq!(roles(&s), ["user"]);
assert!(s.started.is_some(), "falls back to the id timestamp");
}
#[test]
fn prodex_thread_url_comes_from_the_bridge_session() {
let task = fixture("prodex/repo-a/.bridge/tasks/task_20260707_090000_gpt-pro-consult.json");
let url = sessionwiki::adapters::prodex_thread_url(&task);
assert_eq!(
url.as_deref(),
Some("https://chatgpt.com/c/aaaa1111-2222-3333-4444-555566667777")
);
let dir = tempfile::tempdir().unwrap();
let tasks = dir.path().join(".bridge/tasks");
let sessions = dir.path().join(".bridge/sessions");
std::fs::create_dir_all(&tasks).unwrap();
std::fs::create_dir_all(&sessions).unwrap();
let t = tasks.join("task_x.json");
std::fs::write(&t, "{}").unwrap();
std::fs::write(
sessions.join("s.json"),
r#"{"thread":"file:///etc/passwd"}"#,
)
.unwrap();
assert_eq!(sessionwiki::adapters::prodex_thread_url(&t), None);
}
#[test]
fn prodex_artifact_without_result_is_still_the_answer() {
let s = parse(
"prodex",
"prodex/repo-a/.bridge/tasks/task_20260707_110000_crash-path.json",
);
assert_eq!(roles(&s), ["user", "assistant"]);
assert!(
s.messages[1].text.contains("logical shards"),
"{}",
s.messages[1].text
);
}
#[test]
fn prodex_multibyte_task_id_never_panics() {
let dir = tempfile::tempdir().unwrap();
let tasks = dir.path().join(".bridge/tasks");
std::fs::create_dir_all(&tasks).unwrap();
let p = tasks.join("bad.json");
std::fs::write(
&p,
r#"{"id":"task_12345é7_123456","title":"t","prompt":"p"}"#,
)
.unwrap();
let adapter = sessionwiki::adapters::by_name("prodex").unwrap();
let s = adapters::parse_session(adapter.as_ref(), &p).expect("parse must not panic");
assert!(
s.started.is_none(),
"unparseable stamp -> None, not a crash"
);
}