Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ Each release is published to [crates.io](https://crates.io/crates/txcript),

## Unreleased

### Fixed

- Resolve a session id that exists in several stores to the most recently modified copy. `view`, `export`, `continue` and `crop` used to load whichever copy the file system listed first; discovery now breaks start-time ties by file mtime.

### Added

- Add read-side session lineage (`Meta::lineage`): fork/continue/spawn relationships read from
Expand Down
156 changes: 156 additions & 0 deletions cli/tests/duplicate_ids.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]

//! Copies of one session id (Claude Code writes a second copy under another
//! project folder when a session is resumed from another cwd) must resolve to
//! the most recently modified copy, whichever order the file system walks the
//! project folders in. Runs the real `txcript` binary against a synthetic
//! Claude Code store in a temp dir; every other store is pointed at an empty
//! temp dir so no real history is read.

use std::fs;
use std::path::Path;
use std::process::Command;
use std::time::{Duration, SystemTime};

const ID: &str = "11111111-aaaa-4aaa-8aaa-000000000001";
const OTHER_ID: &str = "11111111-bbbb-4bbb-8bbb-000000000002";

fn line(id: &str, n: usize, text: &str) -> String {
format!(
r#"{{"type":"user","uuid":"u{n}","parentUuid":null,"sessionId":"{id}","cwd":"/work/proj","timestamp":"2026-01-02T03:04:{n:02}.000Z","message":{{"role":"user","content":"{text}"}}}}"#
)
}

/// Write a copy of session `id` into project folder `dir` with `messages`
/// lines and a file mtime `mtime` seconds after a fixed epoch. The last message of
/// a copy names the copy, so output shows which one was loaded.
fn write_copy(projects: &Path, dir: &str, id: &str, messages: usize, tag: &str, mtime: u64) {
let folder = projects.join(dir);
fs::create_dir_all(&folder).unwrap();
let mut body = String::new();
for n in 1..messages {
body.push_str(&line(id, n, "shared history"));
body.push('\n');
}
body.push_str(&line(id, messages, tag));
body.push('\n');
let path = folder.join(format!("{id}.jsonl"));
fs::write(&path, body).unwrap();
let file = fs::File::options().write(true).open(&path).unwrap();
file.set_modified(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000 + mtime))
.unwrap();
}

struct Env {
home: tempfile::TempDir,
}

impl Env {
fn new() -> Self {
Self {
home: tempfile::tempdir().unwrap(),
}
}

fn projects(&self) -> std::path::PathBuf {
self.home.path().join(".claude").join("projects")
}

fn run(&self, args: &[&str]) -> (bool, String) {
let empty = self.home.path().join("empty");
let mut cmd = Command::new(env!("CARGO_BIN_EXE_txcript"));
cmd.env_clear()
.env("HOME", self.home.path())
.env("USERPROFILE", self.home.path())
.env("CLAUDE_CONFIG_DIR", self.home.path().join(".claude"));
for var in [
"CODEX_HOME",
"XDG_DATA_HOME",
"XDG_CONFIG_HOME",
"PI_CODING_AGENT_DIR",
"OPENCODE_DB",
"HERMES_HOME",
"FX_HOME",
"GROK_HOME",
"COWORK_SESSIONS_DIR",
"AMP_THREADS_DIR",
"CURSOR_DESKTOP_USER_DIR",
"TXCRIPT_GROK_BOT_ROOT",
] {
cmd.env(var, &empty);
}
let out = cmd
.args(args)
.args(["--from", "claude-code"])
.output()
.unwrap();
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
(out.status.success(), text)
}
}

/// Four copies whose mtime order is unrelated to their folder names or
/// creation order, so neither can pick the newest by accident.
fn four_copies(env: &Env) {
let p = env.projects();
write_copy(&p, "m-dir", ID, 4, "COPY-OLDEST", 10);
write_copy(&p, "a-dir", ID, 5, "COPY-NEWEST", 40);
write_copy(&p, "z-dir", ID, 4, "COPY-MIDDLE", 30);
write_copy(&p, "b-dir", ID, 4, "COPY-OLD", 20);
}

#[test]
fn view_loads_the_newest_copy_of_a_duplicated_id() {
let env = Env::new();
four_copies(&env);
let (ok, out) = env.run(&["view", ID]);
assert!(ok, "{out}");
assert!(out.contains("COPY-NEWEST"), "{out}");
assert!(
!out.contains("COPY-OLD") && !out.contains("COPY-MIDDLE"),
"{out}"
);
}

#[test]
fn export_loads_the_newest_copy_of_a_duplicated_id() {
let env = Env::new();
four_copies(&env);
let (ok, out) = env.run(&["export", ID]);
assert!(ok, "{out}");
assert!(out.contains("COPY-NEWEST"), "{out}");
}

#[test]
fn an_id_prefix_resolves_to_the_newest_copy() {
let env = Env::new();
four_copies(&env);
let (ok, out) = env.run(&["view", "11111111-aaaa"]);
assert!(ok, "{out}");
assert!(out.contains("COPY-NEWEST"), "{out}");
}

#[test]
fn different_ids_sharing_a_prefix_stay_ambiguous() {
let env = Env::new();
four_copies(&env);
write_copy(&env.projects(), "a-dir", OTHER_ID, 4, "OTHER", 50);
let (ok, out) = env.run(&["view", "11111111"]);
assert!(!ok, "{out}");
assert!(out.contains("prefixes 2 session ids"), "{out}");
}

#[test]
fn copies_with_the_same_mtime_resolve_to_one_of_them_without_error() {
let env = Env::new();
let p = env.projects();
write_copy(&p, "a-dir", ID, 4, "COPY-A", 10);
write_copy(&p, "b-dir", ID, 4, "COPY-B", 10);
let (ok, out) = env.run(&["view", ID]);
assert!(ok, "{out}");
assert!(out.contains("COPY-A") ^ out.contains("COPY-B"), "{out}");
}
2 changes: 1 addition & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ txcript export <id>[#range] # write a session as a Simple document
[--out <file>] # write to <file> instead of stdout
```

A session id is any unambiguous prefix of the full id, or the session's exact title. `txcript resume` is an alias for `continue`. `--since` and `--until` take RFC 3339 timestamps or bare `YYYY-MM-DD` dates.
A session id is any unambiguous prefix of the full id, or the session's exact title. When several copies share one id (Claude Code writes a second copy when a session is resumed from another directory), the most recently modified copy is used. `txcript resume` is an alias for `continue`. `--since` and `--until` take RFC 3339 timestamps or bare `YYYY-MM-DD` dates.

`continue` writes the session where the target harness keeps its sessions, then launches that harness on it, handing over the terminal:

Expand Down
97 changes: 95 additions & 2 deletions src/local.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ enum Locator {
}

/// Every local session, newest first. Unreadable stores and sessions are
/// skipped.
/// skipped. Sessions that started at the same instant (such as copies of one
/// id) are ordered by [`Session::updated_at`], newest first.
#[must_use]
pub fn discover() -> Vec<Session> {
discover_with(|_, _| {})
Expand Down Expand Up @@ -192,10 +193,24 @@ pub fn discover_with(mut on_store: impl FnMut(HarnessId, usize)) -> Vec<Session>
}
}

out.sort_by_key(|s| std::cmp::Reverse(s.meta.timestamp));
sort_newest_first(&mut out);
out
}

/// Newest start time first. Copies of one session (Claude Code writes a
/// second store when a session is resumed from another cwd) share a start
/// time, so ties go to the most recently modified file: the first copy of an
/// id is the newest. A missing `updated_at` sorts after a known one; remaining
/// ties keep discovery order (the sort is stable).
fn sort_newest_first(sessions: &mut [Session]) {
sessions.sort_by_key(|s| {
(
std::cmp::Reverse(s.meta.timestamp),
std::cmp::Reverse(s.updated_at),
)
});
}

/// Discover one harness, surfacing remote errors when a live source is
/// explicitly requested. Other harnesses retain the tolerant aggregate scan.
///
Expand Down Expand Up @@ -1256,3 +1271,81 @@ mod resume_template_tests {
assert_eq!(session.location(), "hermes db session sess-1");
}
}

#[cfg(test)]
mod sort_tests {
use super::{HarnessId, Locator, Meta, PathBuf, Session, sort_newest_first};
use chrono::{DateTime, Utc};

fn at(s: &str) -> DateTime<Utc> {
s.parse().unwrap_or_else(|e| panic!("{e}"))
}

/// A file-backed session; `tag` marks the copy in its path.
fn session(id: &str, started: &str, updated: Option<&str>, tag: &str) -> Session {
Session {
harness: HarnessId::ClaudeCode,
meta: Meta {
id: id.to_string(),
timestamp: at(started),
cwd: None,
git_branch: None,
title: None,
cli_version: None,
model: None,
lineage: None,
},
updated_at: updated.map(at),
locator: Locator::Path(PathBuf::from(tag)),
}
}

fn order(sessions: &[Session]) -> Vec<String> {
sessions.iter().map(Session::location).collect()
}

const START: &str = "2026-01-02T03:04:05Z";

#[test]
fn copies_of_one_id_sort_by_newest_file_first_whatever_the_walk_order() {
let mut sessions = vec![
session("s", START, Some("2026-01-01T00:00:00Z"), "oldest"),
session("s", START, Some("2026-01-04T00:00:00Z"), "newest"),
session("s", START, Some("2026-01-03T00:00:00Z"), "middle"),
];
sort_newest_first(&mut sessions);
assert_eq!(order(&sessions), ["newest", "middle", "oldest"]);
}

#[test]
fn start_time_still_outranks_file_mtime() {
let mut sessions = vec![
session(
"old",
"2026-01-01T00:00:00Z",
Some("2026-02-01T00:00:00Z"),
"old",
),
session(
"new",
"2026-01-02T00:00:00Z",
Some("2026-01-02T00:00:00Z"),
"new",
),
];
sort_newest_first(&mut sessions);
assert_eq!(order(&sessions), ["new", "old"]);
}

#[test]
fn a_known_mtime_beats_a_missing_one_and_ties_keep_discovery_order() {
let mut sessions = vec![
session("s", START, None, "none-1"),
session("s", START, Some("2026-01-01T00:00:00Z"), "tie-1"),
session("s", START, None, "none-2"),
session("s", START, Some("2026-01-01T00:00:00Z"), "tie-2"),
];
sort_newest_first(&mut sessions);
assert_eq!(order(&sessions), ["tie-1", "tie-2", "none-1", "none-2"]);
}
}
3 changes: 3 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,5 +70,8 @@ can compare across machines.
- Every test file opens with `#![allow(clippy::expect_used, clippy::panic,
clippy::unwrap_used)]`; the production lint set (workspace `Cargo.toml`)
denies all three.
- CLI behavior that needs the real binary (id resolution, for one) is tested
in `cli/tests/`: the `txcript` binary runs against a synthetic store in a
temp dir, with every store environment variable pointed at an empty one.
- Feature-gated surfaces gate at the module declaration in `main.rs`
(`search`) or on the test (`opencode` store).