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
690 lines
26 KiB
Rust
690 lines
26 KiB
Rust
use super::*;
|
|
|
|
struct EnvVarGuard {
|
|
key: &'static str,
|
|
prev: Option<std::ffi::OsString>,
|
|
}
|
|
|
|
impl EnvVarGuard {
|
|
fn set_path(key: &'static str, value: &std::path::Path) -> 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.take() {
|
|
crate::env::set_var(self.key, prev);
|
|
} else {
|
|
crate::env::remove_var(self.key);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_truncate_title() {
|
|
assert_eq!(truncate_title("short"), "short");
|
|
assert_eq!(truncate_title("line1\nline2"), "line1");
|
|
|
|
let long = "a".repeat(100);
|
|
let truncated = truncate_title(&long);
|
|
assert!(truncated.ends_with("..."));
|
|
assert!(truncated.len() <= 80);
|
|
}
|
|
|
|
#[test]
|
|
fn test_convert_text_content() {
|
|
let content = ClaudeCodeContent::Text("hello".to_string());
|
|
let blocks = convert_content_blocks(&content);
|
|
assert_eq!(blocks.len(), 1);
|
|
match &blocks[0] {
|
|
ContentBlock::Text { text, .. } => assert_eq!(text, "hello"),
|
|
_ => panic!("Expected text block"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_convert_empty_content() {
|
|
let content = ClaudeCodeContent::Empty;
|
|
let blocks = convert_content_blocks(&content);
|
|
assert!(blocks.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_convert_blocks_content() {
|
|
let content = ClaudeCodeContent::Blocks(vec![
|
|
ClaudeCodeContentBlock::Text {
|
|
text: "hello".to_string(),
|
|
},
|
|
ClaudeCodeContentBlock::Thinking {
|
|
thinking: "let me think".to_string(),
|
|
_signature: None,
|
|
},
|
|
ClaudeCodeContentBlock::ToolUse {
|
|
id: "tool1".to_string(),
|
|
name: "bash".to_string(),
|
|
input: serde_json::json!({"cmd": "ls"}),
|
|
},
|
|
]);
|
|
let blocks = convert_content_blocks(&content);
|
|
assert_eq!(blocks.len(), 3);
|
|
|
|
match &blocks[0] {
|
|
ContentBlock::Text { text, .. } => assert_eq!(text, "hello"),
|
|
_ => panic!("Expected text"),
|
|
}
|
|
match &blocks[1] {
|
|
ContentBlock::Reasoning { text } => assert_eq!(text, "let me think"),
|
|
_ => panic!("Expected reasoning"),
|
|
}
|
|
match &blocks[2] {
|
|
ContentBlock::ToolUse { name, .. } => assert_eq!(name, "bash"),
|
|
_ => panic!("Expected tool use"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_discover_projects_uses_sandboxed_external_home() {
|
|
let _guard = crate::storage::lock_test_env();
|
|
let temp = tempfile::TempDir::new().unwrap();
|
|
let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path());
|
|
|
|
let project_dir = temp.path().join("external/.claude/projects/demo");
|
|
std::fs::create_dir_all(&project_dir).unwrap();
|
|
std::fs::write(
|
|
project_dir.join("sessions-index.json"),
|
|
r#"{"version":1,"entries":[]}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let projects = discover_projects().unwrap();
|
|
assert_eq!(projects, vec![project_dir.join("sessions-index.json")]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_list_claude_code_sessions_uses_live_transcripts_when_index_is_stale() {
|
|
let _guard = crate::storage::lock_test_env();
|
|
let temp = tempfile::TempDir::new().unwrap();
|
|
let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path());
|
|
|
|
let project_dir = temp.path().join("external/.claude/projects/demo-project");
|
|
std::fs::create_dir_all(&project_dir).unwrap();
|
|
|
|
let indexed_session_path = project_dir.join("live-session-1.jsonl");
|
|
std::fs::write(
|
|
&indexed_session_path,
|
|
concat!(
|
|
"{\"type\":\"user\",\"uuid\":\"u1\",\"sessionId\":\"live-session-1\",\"cwd\":\"/tmp/demo-project\",\"message\":{\"role\":\"user\",\"content\":\"Investigate the login bug\"},\"timestamp\":\"2026-04-04T12:00:00Z\"}\n",
|
|
"{\"type\":\"assistant\",\"uuid\":\"a1\",\"parentUuid\":\"u1\",\"sessionId\":\"live-session-1\",\"cwd\":\"/tmp/demo-project\",\"message\":{\"role\":\"assistant\",\"model\":\"claude-sonnet-4-6\",\"content\":\"I can help with that.\"},\"timestamp\":\"2026-04-04T12:05:00Z\"}\n"
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
let orphan_session_path = project_dir.join("orphan-session-2.jsonl");
|
|
std::fs::write(
|
|
&orphan_session_path,
|
|
concat!(
|
|
"{\"type\":\"user\",\"uuid\":\"u2\",\"sessionId\":\"orphan-session-2\",\"cwd\":\"/tmp/demo-project\",\"message\":{\"role\":\"user\",\"content\":\"Summarize the deployment issue\"},\"timestamp\":\"2026-04-05T09:00:00Z\"}\n",
|
|
"{\"type\":\"assistant\",\"uuid\":\"a2\",\"parentUuid\":\"u2\",\"sessionId\":\"orphan-session-2\",\"cwd\":\"/tmp/demo-project\",\"message\":{\"role\":\"assistant\",\"model\":\"claude-sonnet-4-6\",\"content\":\"Here is the deployment summary.\"},\"timestamp\":\"2026-04-05T09:01:00Z\"}\n"
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
std::fs::write(
|
|
project_dir.join("sessions-index.json"),
|
|
concat!(
|
|
"{\"version\":1,\"entries\":[",
|
|
"{\"sessionId\":\"live-session-1\",",
|
|
"\"fullPath\":\"/missing/live-session-1.jsonl\",",
|
|
"\"firstPrompt\":\"Investigate the login bug\",",
|
|
"\"summary\":\"Investigate the login bug\",",
|
|
"\"messageCount\":2,",
|
|
"\"created\":\"2026-04-04T12:00:00Z\",",
|
|
"\"modified\":\"2026-04-04T12:05:00Z\",",
|
|
"\"projectPath\":\"/tmp/demo-project\"",
|
|
"}] }"
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
let sessions = list_claude_code_sessions().unwrap();
|
|
|
|
let indexed = sessions
|
|
.iter()
|
|
.find(|session| session.session_id == "live-session-1")
|
|
.expect("indexed live transcript should be discovered");
|
|
assert_eq!(indexed.full_path, indexed_session_path.to_string_lossy());
|
|
assert_eq!(
|
|
indexed.summary.as_deref(),
|
|
Some("Investigate the login bug")
|
|
);
|
|
assert_eq!(indexed.project_path.as_deref(), Some("/tmp/demo-project"));
|
|
|
|
let orphan = sessions
|
|
.iter()
|
|
.find(|session| session.session_id == "orphan-session-2")
|
|
.expect("orphan live transcript should be discovered");
|
|
assert_eq!(orphan.full_path, orphan_session_path.to_string_lossy());
|
|
assert_eq!(orphan.first_prompt, "Summarize the deployment issue");
|
|
assert_eq!(orphan.message_count, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_list_claude_code_sessions_uses_index_metadata_without_parsing_transcript() {
|
|
let _guard = crate::storage::lock_test_env();
|
|
let temp = tempfile::TempDir::new().unwrap();
|
|
let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path());
|
|
|
|
let project_dir = temp.path().join("external/.claude/projects/demo-project");
|
|
std::fs::create_dir_all(&project_dir).unwrap();
|
|
|
|
let transcript_path = project_dir.join("indexed-session.jsonl");
|
|
std::fs::write(&transcript_path, "{this is not valid jsonl}\n").unwrap();
|
|
|
|
std::fs::write(
|
|
project_dir.join("sessions-index.json"),
|
|
format!(
|
|
concat!(
|
|
"{{\"version\":1,\"entries\":[",
|
|
"{{\"sessionId\":\"indexed-session\",",
|
|
"\"fullPath\":\"{}\",",
|
|
"\"firstPrompt\":\"Investigate the login bug\",",
|
|
"\"summary\":\"Investigate the login bug\",",
|
|
"\"messageCount\":2,",
|
|
"\"created\":\"2026-04-04T12:00:00Z\",",
|
|
"\"modified\":\"2026-04-04T12:05:00Z\",",
|
|
"\"projectPath\":\"/tmp/demo-project\"",
|
|
"}}]}}"
|
|
),
|
|
transcript_path.display()
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
let sessions = list_claude_code_sessions().unwrap();
|
|
let session = sessions
|
|
.iter()
|
|
.find(|session| session.session_id == "indexed-session")
|
|
.expect("indexed session should be listed from index metadata");
|
|
|
|
assert_eq!(session.message_count, 2);
|
|
assert_eq!(
|
|
session.summary.as_deref(),
|
|
Some("Investigate the login bug")
|
|
);
|
|
assert_eq!(session.first_prompt, "Investigate the login bug");
|
|
}
|
|
|
|
#[test]
|
|
fn test_list_claude_code_sessions_skips_empty_index_entries_without_messages() {
|
|
let _guard = crate::storage::lock_test_env();
|
|
let temp = tempfile::TempDir::new().unwrap();
|
|
let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path());
|
|
|
|
let project_dir = temp.path().join("external/.claude/projects/demo-project");
|
|
std::fs::create_dir_all(&project_dir).unwrap();
|
|
|
|
let transcript_path = project_dir.join("empty-session.jsonl");
|
|
std::fs::write(
|
|
&transcript_path,
|
|
"{\"type\":\"system\",\"sessionId\":\"empty-session\"}\n",
|
|
)
|
|
.unwrap();
|
|
|
|
std::fs::write(
|
|
project_dir.join("sessions-index.json"),
|
|
format!(
|
|
concat!(
|
|
"{{\"version\":1,\"entries\":[",
|
|
"{{\"sessionId\":\"empty-session\",",
|
|
"\"fullPath\":\"{}\",",
|
|
"\"firstPrompt\":\"\",",
|
|
"\"summary\":\"\",",
|
|
"\"messageCount\":0",
|
|
"}}]}}"
|
|
),
|
|
transcript_path.display()
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
let sessions = list_claude_code_sessions().unwrap();
|
|
assert!(
|
|
sessions.is_empty(),
|
|
"empty placeholder sessions should be hidden"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_import_claude_session_uses_recovered_live_transcript() {
|
|
let _guard = crate::storage::lock_test_env();
|
|
let temp = tempfile::TempDir::new().unwrap();
|
|
let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path());
|
|
|
|
let project_dir = temp.path().join("external/.claude/projects/demo-project");
|
|
std::fs::create_dir_all(&project_dir).unwrap();
|
|
|
|
let transcript_path = project_dir.join("live-session-1.jsonl");
|
|
std::fs::write(
|
|
&transcript_path,
|
|
concat!(
|
|
"{\"type\":\"user\",\"uuid\":\"u1\",\"sessionId\":\"live-session-1\",\"cwd\":\"/tmp/demo-project\",\"message\":{\"role\":\"user\",\"content\":\"Investigate the login bug\"},\"timestamp\":\"2026-04-04T12:00:00Z\"}\n",
|
|
"{\"type\":\"assistant\",\"uuid\":\"a1\",\"parentUuid\":\"u1\",\"sessionId\":\"live-session-1\",\"cwd\":\"/tmp/demo-project\",\"message\":{\"role\":\"assistant\",\"model\":\"claude-sonnet-4-6\",\"content\":\"I can help with that.\"},\"timestamp\":\"2026-04-04T12:05:00Z\"}\n"
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
std::fs::write(
|
|
project_dir.join("sessions-index.json"),
|
|
concat!(
|
|
"{\"version\":1,\"entries\":[",
|
|
"{\"sessionId\":\"live-session-1\",",
|
|
"\"fullPath\":\"/missing/live-session-1.jsonl\",",
|
|
"\"firstPrompt\":\"Investigate the login bug\",",
|
|
"\"summary\":\"Investigate the login bug\",",
|
|
"\"messageCount\":2,",
|
|
"\"created\":\"2026-04-04T12:00:00Z\",",
|
|
"\"modified\":\"2026-04-04T12:05:00Z\",",
|
|
"\"projectPath\":\"/tmp/demo-project\"",
|
|
"}] }"
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
let imported = import_session("live-session-1").unwrap();
|
|
assert_eq!(
|
|
imported.id,
|
|
imported_claude_code_session_id("live-session-1")
|
|
);
|
|
assert_eq!(imported.provider_key.as_deref(), Some("claude-code"));
|
|
assert_eq!(imported.working_dir.as_deref(), Some("/tmp/demo-project"));
|
|
assert_eq!(imported.model.as_deref(), Some("claude-sonnet-4-6"));
|
|
assert_eq!(imported.messages.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_import_pi_session_creates_jcode_snapshot() {
|
|
let _guard = crate::storage::lock_test_env();
|
|
let temp = tempfile::TempDir::new().unwrap();
|
|
let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path());
|
|
|
|
let pi_dir = temp.path().join("external/.pi/agent/sessions/project");
|
|
std::fs::create_dir_all(&pi_dir).unwrap();
|
|
let session_path = pi_dir.join("session.jsonl");
|
|
std::fs::write(
|
|
&session_path,
|
|
concat!(
|
|
"{\"type\":\"session\",\"id\":\"pi-session-1\",\"timestamp\":\"2026-04-05T19:00:00Z\",\"cwd\":\"/tmp/pi-demo\"}\n",
|
|
"{\"type\":\"model_change\",\"timestamp\":\"2026-04-05T19:00:01Z\",\"provider\":\"pi\",\"modelId\":\"pi-model\"}\n",
|
|
"{\"type\":\"message\",\"timestamp\":\"2026-04-05T19:00:02Z\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"hello pi\"}]}}\n",
|
|
"{\"type\":\"message\",\"timestamp\":\"2026-04-05T19:00:03Z\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"hi back\"}]}}\n"
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
let imported = import_pi_session(&session_path.to_string_lossy()).unwrap();
|
|
assert_eq!(
|
|
imported.id,
|
|
imported_pi_session_id(&session_path.to_string_lossy())
|
|
);
|
|
assert_eq!(imported.provider_key.as_deref(), Some("pi"));
|
|
assert_eq!(imported.model.as_deref(), Some("pi-model"));
|
|
assert_eq!(imported.working_dir.as_deref(), Some("/tmp/pi-demo"));
|
|
assert_eq!(imported.messages.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_import_opencode_session_creates_jcode_snapshot() {
|
|
let _guard = crate::storage::lock_test_env();
|
|
let temp = tempfile::TempDir::new().unwrap();
|
|
let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path());
|
|
|
|
let session_dir = temp
|
|
.path()
|
|
.join("external/.local/share/opencode/storage/session/global");
|
|
let message_dir = temp
|
|
.path()
|
|
.join("external/.local/share/opencode/storage/message/ses_test_opencode");
|
|
let user_part_dir = temp
|
|
.path()
|
|
.join("external/.local/share/opencode/storage/part/msg-user");
|
|
let assistant_part_dir = temp
|
|
.path()
|
|
.join("external/.local/share/opencode/storage/part/msg-assistant");
|
|
std::fs::create_dir_all(&session_dir).unwrap();
|
|
std::fs::create_dir_all(&message_dir).unwrap();
|
|
std::fs::create_dir_all(&user_part_dir).unwrap();
|
|
std::fs::create_dir_all(&assistant_part_dir).unwrap();
|
|
|
|
std::fs::write(
|
|
session_dir.join("ses_test_opencode.json"),
|
|
concat!(
|
|
"{",
|
|
"\"id\":\"ses_test_opencode\",",
|
|
"\"directory\":\"/tmp/opencode-demo\",",
|
|
"\"title\":\"OpenCode imported\",",
|
|
"\"time\":{\"created\":1775415600000,\"updated\":1775415605000}",
|
|
"}"
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
std::fs::write(
|
|
message_dir.join("msg-user.json"),
|
|
concat!(
|
|
"{",
|
|
"\"id\":\"msg-user\",",
|
|
"\"role\":\"user\",",
|
|
"\"time\":{\"created\":1775415601000},",
|
|
"\"model\":{\"providerID\":\"opencode\",\"modelID\":\"big-pickle\"}",
|
|
"}"
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
std::fs::write(
|
|
message_dir.join("msg-assistant.json"),
|
|
concat!(
|
|
"{",
|
|
"\"id\":\"msg-assistant\",",
|
|
"\"role\":\"assistant\",",
|
|
"\"time\":{\"created\":1775415602000},",
|
|
"\"providerID\":\"opencode\",",
|
|
"\"modelID\":\"big-pickle\"",
|
|
"}"
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
// Modern OpenCode (Go storage) keeps message body text in part files.
|
|
std::fs::write(
|
|
user_part_dir.join("prt-user.json"),
|
|
concat!(
|
|
"{",
|
|
"\"id\":\"prt-user\",",
|
|
"\"messageID\":\"msg-user\",",
|
|
"\"type\":\"text\",",
|
|
"\"text\":\"Investigate provider routing\"",
|
|
"}"
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
std::fs::write(
|
|
assistant_part_dir.join("prt-assistant.json"),
|
|
concat!(
|
|
"{",
|
|
"\"id\":\"prt-assistant\",",
|
|
"\"messageID\":\"msg-assistant\",",
|
|
"\"type\":\"text\",",
|
|
"\"text\":\"Found the bad provider switch\"",
|
|
"}"
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
let imported = import_opencode_session("ses_test_opencode").unwrap();
|
|
assert_eq!(
|
|
imported.id,
|
|
imported_opencode_session_id("ses_test_opencode")
|
|
);
|
|
assert_eq!(imported.provider_key.as_deref(), Some("opencode"));
|
|
assert_eq!(imported.model.as_deref(), Some("big-pickle"));
|
|
assert_eq!(imported.working_dir.as_deref(), Some("/tmp/opencode-demo"));
|
|
assert_eq!(imported.messages.len(), 2);
|
|
let all_text: String = imported
|
|
.messages
|
|
.iter()
|
|
.flat_map(|m| m.content.iter())
|
|
.filter_map(|block| match block {
|
|
ContentBlock::Text { text, .. } => Some(text.as_str()),
|
|
_ => None,
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
assert!(
|
|
all_text.contains("Investigate provider routing"),
|
|
"expected user part text to be imported: {all_text:?}"
|
|
);
|
|
assert!(
|
|
all_text.contains("Found the bad provider switch"),
|
|
"expected assistant part text to be imported: {all_text:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_resume_target_to_jcode_imports_codex_session() {
|
|
let _guard = crate::storage::lock_test_env();
|
|
let temp = tempfile::TempDir::new().unwrap();
|
|
let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path());
|
|
|
|
let codex_dir = temp.path().join("external/.codex/sessions/2026/04/05");
|
|
std::fs::create_dir_all(&codex_dir).unwrap();
|
|
std::fs::write(
|
|
codex_dir.join("rollout.jsonl"),
|
|
concat!(
|
|
"{\"timestamp\":\"2026-04-05T19:00:00Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"codex-resolve-test\",\"timestamp\":\"2026-04-05T18:59:00Z\",\"cwd\":\"/tmp/codex-resolve\"}}\n",
|
|
"{\"timestamp\":\"2026-04-05T19:00:01Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Fix codex resume\"}]}}\n",
|
|
"{\"timestamp\":\"2026-04-05T19:00:02Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Done\"}]}}\n"
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
let resolved =
|
|
resolve_resume_target_to_jcode(&jcode_session_types::ResumeTarget::CodexSession {
|
|
session_id: "codex-resolve-test".to_string(),
|
|
session_path: codex_dir
|
|
.join("rollout.jsonl")
|
|
.to_string_lossy()
|
|
.to_string(),
|
|
})
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
resolved,
|
|
jcode_session_types::ResumeTarget::JcodeSession {
|
|
session_id: imported_codex_session_id("codex-resolve-test"),
|
|
}
|
|
);
|
|
let loaded = Session::load(&imported_codex_session_id("codex-resolve-test")).unwrap();
|
|
assert_eq!(loaded.messages.len(), 2);
|
|
}
|
|
|
|
/// The resume picker builds a `ClaudeCodeSession` target with id `claude:<id>`
|
|
/// and a transcript path; selecting it routes through
|
|
/// `resolve_resume_target_to_jcode`, which must import the transcript and hand
|
|
/// back a resumable `imported_cc_<id>` jcode session. This guards the full
|
|
/// detect -> import -> resume round-trip for Claude Code (previously only Codex
|
|
/// had coverage here).
|
|
#[test]
|
|
fn test_resolve_resume_target_to_jcode_imports_claude_code_session() {
|
|
let _guard = crate::storage::lock_test_env();
|
|
let temp = tempfile::TempDir::new().unwrap();
|
|
let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path());
|
|
|
|
let project_dir = temp.path().join("external/.claude/projects/demo-project");
|
|
std::fs::create_dir_all(&project_dir).unwrap();
|
|
let transcript_path = project_dir.join("claude-resolve-test.jsonl");
|
|
std::fs::write(
|
|
&transcript_path,
|
|
concat!(
|
|
"{\"type\":\"user\",\"uuid\":\"u1\",\"sessionId\":\"claude-resolve-test\",\"cwd\":\"/tmp/demo-project\",\"message\":{\"role\":\"user\",\"content\":\"Fix the resume round-trip\"},\"timestamp\":\"2026-04-04T12:00:00Z\"}\n",
|
|
"{\"type\":\"assistant\",\"uuid\":\"a1\",\"parentUuid\":\"u1\",\"sessionId\":\"claude-resolve-test\",\"cwd\":\"/tmp/demo-project\",\"message\":{\"role\":\"assistant\",\"model\":\"claude-sonnet-4-6\",\"content\":\"On it.\"},\"timestamp\":\"2026-04-04T12:01:00Z\"}\n"
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
let resolved =
|
|
resolve_resume_target_to_jcode(&jcode_session_types::ResumeTarget::ClaudeCodeSession {
|
|
session_id: "claude-resolve-test".to_string(),
|
|
session_path: transcript_path.to_string_lossy().to_string(),
|
|
})
|
|
.unwrap();
|
|
|
|
let imported_id = imported_claude_code_session_id("claude-resolve-test");
|
|
assert_eq!(
|
|
resolved,
|
|
jcode_session_types::ResumeTarget::JcodeSession {
|
|
session_id: imported_id.clone(),
|
|
}
|
|
);
|
|
|
|
// The id the picker would also derive via `imported_session_id_for_target`
|
|
// must match the snapshot actually written to disk.
|
|
assert_eq!(
|
|
imported_session_id_for_target(&jcode_session_types::ResumeTarget::ClaudeCodeSession {
|
|
session_id: "claude-resolve-test".to_string(),
|
|
session_path: transcript_path.to_string_lossy().to_string(),
|
|
}),
|
|
Some(imported_id.clone())
|
|
);
|
|
|
|
let loaded = Session::load(&imported_id).unwrap();
|
|
assert_eq!(loaded.messages.len(), 2);
|
|
assert_eq!(
|
|
loaded.provider_session_id.as_deref(),
|
|
Some("claude-resolve-test")
|
|
);
|
|
assert_eq!(loaded.provider_key.as_deref(), Some("claude-code"));
|
|
}
|
|
|
|
/// Regression for silent data loss: the picker hides the imported jcode session
|
|
/// (any `imported_*` stem) and only shows the external `claude:<id>` entry, so
|
|
/// re-selecting a Claude session re-enters `import_session_from_file`. If the
|
|
/// user already resumed and continued that imported session inside jcode, a
|
|
/// blind re-import previously overwrote the snapshot and dropped the jcode-side
|
|
/// messages. The continuation must be preserved instead.
|
|
#[test]
|
|
fn test_reimporting_claude_session_preserves_jcode_continuation() {
|
|
let _guard = crate::storage::lock_test_env();
|
|
let temp = tempfile::TempDir::new().unwrap();
|
|
let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path());
|
|
|
|
let project_dir = temp.path().join("external/.claude/projects/demo-project");
|
|
std::fs::create_dir_all(&project_dir).unwrap();
|
|
let transcript_path = project_dir.join("claude-continued.jsonl");
|
|
std::fs::write(
|
|
&transcript_path,
|
|
concat!(
|
|
"{\"type\":\"user\",\"uuid\":\"u1\",\"sessionId\":\"claude-continued\",\"cwd\":\"/tmp/demo-project\",\"message\":{\"role\":\"user\",\"content\":\"original prompt\"},\"timestamp\":\"2026-04-04T12:00:00Z\"}\n",
|
|
"{\"type\":\"assistant\",\"uuid\":\"a1\",\"parentUuid\":\"u1\",\"sessionId\":\"claude-continued\",\"cwd\":\"/tmp/demo-project\",\"message\":{\"role\":\"assistant\",\"content\":\"original reply\"},\"timestamp\":\"2026-04-04T12:01:00Z\"}\n"
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
// First selection imports the transcript.
|
|
let imported = import_session_from_file(&transcript_path, "claude-continued").unwrap();
|
|
assert_eq!(imported.messages.len(), 2);
|
|
let imported_id = imported_claude_code_session_id("claude-continued");
|
|
|
|
// User resumes inside jcode and appends a jcode-only follow-up message.
|
|
let mut session = Session::load(&imported_id).unwrap();
|
|
session.append_stored_message(StoredMessage {
|
|
id: "jcode-continuation".to_string(),
|
|
role: Role::User,
|
|
content: vec![ContentBlock::Text {
|
|
text: "jcode-only follow up".to_string(),
|
|
cache_control: None,
|
|
}],
|
|
display_role: None,
|
|
timestamp: None,
|
|
tool_duration_ms: None,
|
|
token_usage: None,
|
|
});
|
|
session.save().unwrap();
|
|
assert_eq!(Session::load(&imported_id).unwrap().messages.len(), 3);
|
|
|
|
// Re-selecting the external entry re-enters import; the continuation must survive.
|
|
let resumed =
|
|
resolve_resume_target_to_jcode(&jcode_session_types::ResumeTarget::ClaudeCodeSession {
|
|
session_id: "claude-continued".to_string(),
|
|
session_path: transcript_path.to_string_lossy().to_string(),
|
|
})
|
|
.unwrap();
|
|
assert_eq!(
|
|
resumed,
|
|
jcode_session_types::ResumeTarget::JcodeSession {
|
|
session_id: imported_id.clone(),
|
|
}
|
|
);
|
|
|
|
let after = Session::load(&imported_id).unwrap();
|
|
assert_eq!(
|
|
after.messages.len(),
|
|
3,
|
|
"jcode-side continuation must not be clobbered by re-import"
|
|
);
|
|
let preserved = after.messages.iter().flat_map(|m| m.content.iter()).any(
|
|
|block| matches!(block, ContentBlock::Text { text, .. } if text == "jcode-only follow up"),
|
|
);
|
|
assert!(
|
|
preserved,
|
|
"the jcode-only follow up message must be preserved"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_import_cursor_session_creates_jcode_snapshot() {
|
|
let _guard = crate::storage::lock_test_env();
|
|
let temp = tempfile::TempDir::new().unwrap();
|
|
let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path());
|
|
|
|
// Cursor stores transcripts at
|
|
// ~/.cursor/projects/<project>/agent-transcripts/<uuid>/<uuid>.jsonl where the
|
|
// project dir encodes the cwd with `-` separators.
|
|
let session_id = "11111111-2222-3333-4444-555555555555";
|
|
let transcript_dir = temp.path().join(format!(
|
|
"external/.cursor/projects/tmp-cursor-demo/agent-transcripts/{session_id}"
|
|
));
|
|
std::fs::create_dir_all(&transcript_dir).unwrap();
|
|
let transcript_path = transcript_dir.join(format!("{session_id}.jsonl"));
|
|
std::fs::write(
|
|
&transcript_path,
|
|
concat!(
|
|
"{\"role\":\"user\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"CURSOR_USER_MARKER refactor router\"}]}}\n",
|
|
"{\"role\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"CURSOR_ASSISTANT_MARKER done\"}]}}\n",
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
let imported = import_cursor_session(session_id).unwrap();
|
|
assert_eq!(imported.id, imported_cursor_session_id(session_id));
|
|
assert_eq!(imported.provider_key.as_deref(), Some("cursor"));
|
|
assert_eq!(imported.messages.len(), 2);
|
|
let all_text: String = imported
|
|
.messages
|
|
.iter()
|
|
.flat_map(|m| m.content.iter())
|
|
.filter_map(|block| match block {
|
|
ContentBlock::Text { text, .. } => Some(text.as_str()),
|
|
_ => None,
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
assert!(
|
|
all_text.contains("CURSOR_USER_MARKER refactor router"),
|
|
"expected user text to import: {all_text:?}"
|
|
);
|
|
assert!(
|
|
all_text.contains("CURSOR_ASSISTANT_MARKER done"),
|
|
"expected assistant text to import: {all_text:?}"
|
|
);
|
|
|
|
// Resolving the resume target should import and remap to the jcode snapshot.
|
|
let resumed = crate::import::resolve_resume_target_to_jcode(
|
|
&jcode_session_types::ResumeTarget::CursorSession {
|
|
session_id: session_id.to_string(),
|
|
session_path: transcript_path.to_string_lossy().to_string(),
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
resumed,
|
|
jcode_session_types::ResumeTarget::JcodeSession {
|
|
session_id: imported_cursor_session_id(session_id),
|
|
}
|
|
);
|
|
}
|