mirror of
https://github.com/atuinsh/atuin.git
synced 2026-10-12 02:11:09 +00:00
test(ai-session): e2e tests for capture, sync and resume; scan new dirs in the tree watcher (atuinsh/atuin#4368)
This commit is contained in:
committed by
Ellie Huxtable
parent
db63ed5d15
commit
e62f6f7206
Generated
+2
@@ -280,6 +280,7 @@ dependencies = [
|
||||
"atuin-octavo",
|
||||
"atuin-pty-proxy",
|
||||
"atuin-scripts",
|
||||
"atuin-server",
|
||||
"atuin-vt100",
|
||||
"axoupdater",
|
||||
"clap",
|
||||
@@ -314,6 +315,7 @@ dependencies = [
|
||||
"rpassword",
|
||||
"rstest",
|
||||
"runtime-format",
|
||||
"rusqlite",
|
||||
"rust-embed",
|
||||
"rustix",
|
||||
"secrecy",
|
||||
|
||||
@@ -64,12 +64,7 @@ where
|
||||
pending.push(debounced.event);
|
||||
}
|
||||
}
|
||||
if !pending.is_empty() {
|
||||
let kinds = Self::resolve_kinds(&self.stat, &pending).await;
|
||||
for event in &pending {
|
||||
self.observe_event(event, &kinds);
|
||||
}
|
||||
}
|
||||
self.apply(&pending).await;
|
||||
if force_scan {
|
||||
self.walk.poll(&mut self.tracker).await;
|
||||
}
|
||||
@@ -82,6 +77,45 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Bring the tracker in line with a batch of filesystem events, and with what is in any
|
||||
/// directory they created.
|
||||
async fn apply(&mut self, events: &[notify::Event]) {
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
let kinds = Self::resolve_kinds(&self.stat, events).await;
|
||||
for event in events {
|
||||
self.observe_event(event, &kinds);
|
||||
}
|
||||
self.walk.discover(Self::appeared_dirs(events, &kinds), &mut self.tracker).await;
|
||||
}
|
||||
|
||||
/// The directories `events` say appeared (created, or moved in) that are still there. Of a
|
||||
/// rename with both ends, only where it went: a case-only rename on a case-insensitive
|
||||
/// filesystem leaves the old name resolving to the same directory, which would be walked
|
||||
/// again under it.
|
||||
fn appeared_dirs(
|
||||
events: &[notify::Event],
|
||||
kinds: &HashMap<Arc<Path>, PathFingerprint>,
|
||||
) -> Vec<Arc<Path>> {
|
||||
events
|
||||
.iter()
|
||||
.flat_map(|event| match event.kind {
|
||||
EventKind::Create(_)
|
||||
| EventKind::Modify(ModifyKind::Name(
|
||||
RenameMode::To | RenameMode::Any | RenameMode::Other,
|
||||
)) => event.paths.as_slice(),
|
||||
EventKind::Modify(ModifyKind::Name(RenameMode::Both)) => {
|
||||
event.paths.get(1..).unwrap_or_default()
|
||||
}
|
||||
_ => &[],
|
||||
})
|
||||
.filter_map(|path| kinds.get_key_value(path.as_path()))
|
||||
.filter(|(_, fingerprint)| matches!(fingerprint, PathFingerprint::Dir))
|
||||
.map(|(path, _)| Arc::clone(path))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Stat every path referenced by `events`, keeping only the ones that still exist.
|
||||
async fn resolve_kinds<'a>(
|
||||
stat: &StatPoller,
|
||||
@@ -259,6 +293,46 @@ mod tests {
|
||||
assert_eq!(found.len(), yielded);
|
||||
}
|
||||
|
||||
/// A directory created with a file already in it -- written before the backend began watching
|
||||
/// the directory, so no event of its own says so -- yields the file at once, not at the next
|
||||
/// full scan. So does one nested deeper, and one in a directory moved in. A rename with both
|
||||
/// ends walks only where it went, even while the old name still resolves (a case-only rename
|
||||
/// on a case-insensitive filesystem), so no file is yielded under a name it no longer has.
|
||||
#[rstest]
|
||||
#[case::created(EventKind::Create(notify::event::CreateKind::Folder))]
|
||||
#[case::moved_in(EventKind::Modify(ModifyKind::Name(RenameMode::To)))]
|
||||
#[case::renamed(EventKind::Modify(ModifyKind::Name(RenameMode::Both)))]
|
||||
#[tokio::test]
|
||||
async fn a_new_directory_yields_the_files_already_in_it(#[case] kind: EventKind) {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let dir = root.path().join("project");
|
||||
std::fs::create_dir_all(dir.join("nested")).unwrap();
|
||||
for file in [dir.join("a.jsonl"), dir.join("nested/b.jsonl")] {
|
||||
std::fs::write(file, "{}\n").unwrap();
|
||||
}
|
||||
// Where a rename came from, still there: a stand-in for the old name of a case-only one.
|
||||
let old = root.path().join("old");
|
||||
std::fs::create_dir_all(&old).unwrap();
|
||||
std::fs::write(old.join("c.jsonl"), "{}\n").unwrap();
|
||||
let pool = BlockingPool::new(NonZeroUsize::MIN);
|
||||
let (found, files) = flume::unbounded();
|
||||
let mut poller = TreeWatcherPoller::new(
|
||||
WalkPoller::new(pool.clone(), root.path().to_owned(), true),
|
||||
StatPoller::new(pool, Duration::ZERO),
|
||||
FileTracker::new(|_: &Path| true, found),
|
||||
);
|
||||
|
||||
let paths = match kind {
|
||||
EventKind::Modify(ModifyKind::Name(RenameMode::Both)) => vec![old, dir.clone()],
|
||||
_ => vec![dir.clone()],
|
||||
};
|
||||
poller.apply(&[event(kind, paths)]).await;
|
||||
|
||||
let yielded: HashSet<PathBuf> = files.drain().map(|f| f.path().to_owned()).collect();
|
||||
let want = HashSet::from([dir.join("a.jsonl"), dir.join("nested/b.jsonl")]);
|
||||
assert_eq!(yielded, want);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
fn observe_remove_event_forgets() {
|
||||
let (mut poller, found) = poller();
|
||||
|
||||
@@ -52,6 +52,30 @@ impl WalkPoller {
|
||||
tracker.reconcile(walk);
|
||||
}
|
||||
|
||||
/// Walk the trees under `dirs` in the pool and observe everything in them, pruning nothing.
|
||||
///
|
||||
/// For directories that just appeared: the backend starts watching one only once it has seen
|
||||
/// it created, so whatever was written into it before then (a new file in a new directory,
|
||||
/// the usual case) raises no event of its own, and would wait for the next full scan.
|
||||
/// A directory inside another of `dirs` (`mkdir -p` reports each level) is walked once, with
|
||||
/// the outermost.
|
||||
pub async fn discover<F>(&self, mut dirs: Vec<Arc<Path>>, tracker: &mut FileTracker<F>)
|
||||
where
|
||||
F: Fn(&Path) -> bool,
|
||||
{
|
||||
if dirs.is_empty() || !self.recursive {
|
||||
return;
|
||||
}
|
||||
// Sorted, a directory comes right after any other it is inside.
|
||||
dirs.sort();
|
||||
dirs.dedup_by(|inner, outer| inner.starts_with(&**outer));
|
||||
let walks = self.pool.run(move || dirs.iter().map(|dir| Self::scan(dir, true)).collect());
|
||||
let walks: Vec<WalkResult> = walks.await.unwrap_or_default();
|
||||
for (path, fingerprint) in walks.into_iter().flat_map(|walk| walk.entries) {
|
||||
tracker.observe_fingerprint(path, fingerprint);
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk `root` best-effort, returning every readable entry plus the set of directories that
|
||||
/// were fully read without error.
|
||||
fn scan(root: &Path, recursive: bool) -> WalkResult {
|
||||
|
||||
@@ -166,10 +166,14 @@ tree-sitter-powershell = { workspace = true }
|
||||
windows-sys = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# An in-process sync server for the AI session e2e tests (tests/e2e_ai_sessions.rs).
|
||||
atuin-server = { workspace = true }
|
||||
divan = { workspace = true }
|
||||
parking_lot = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
rstest = { workspace = true }
|
||||
# opencode keeps its sessions in SQLite: the e2e tests write one as it does.
|
||||
rusqlite = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
toml_edit = { workspace = true, features = ["serde"] }
|
||||
|
||||
|
||||
@@ -13,6 +13,16 @@ macOS, with Homebrew Bash on macOS.
|
||||
- `e2e_fresh_install`: CLI startup, keys, shell init, and doctor.
|
||||
- `e2e_pty`: shell hooks, search, selection, quoting, resize, and filters.
|
||||
- `e2e_daemon`: startup, concurrent writers, persistence, and restart.
|
||||
- `e2e_ai_sessions`: AI agent sessions captured from each agent's transcripts,
|
||||
synced between two machines through an in-process sync server, and resumed:
|
||||
restored, caught up, forked, switched, or continued in another agent.
|
||||
- `e2e_ai_resume_picker`: `atuin ai resume`'s picker and chooser on a PTY, and
|
||||
through each shell's widget.
|
||||
|
||||
The AI session tests write transcripts the way Claude Code, Codex, opencode and
|
||||
Pi do (`common/agents.rs`), and put stand-ins for those agents on `PATH` that
|
||||
print how they were run. Set `ATUIN_E2E_LOG` (an `ATUIN_LOG` filter) to have
|
||||
their daemons log more for a failure to show.
|
||||
|
||||
## Add a shell setup
|
||||
|
||||
|
||||
@@ -0,0 +1,700 @@
|
||||
//! AI coding agents as the daemon sees them: their transcripts, written the way each agent writes
|
||||
//! its own, and stand-in executables that only say how they were run.
|
||||
//!
|
||||
//! Every turn written here is a prompt, some reasoning, one shell command with its output, and a
|
||||
//! reply, each holding a word no other turn has, so a test can tell exactly which of them a
|
||||
//! session, a search or a restored transcript carries.
|
||||
|
||||
use std::fs;
|
||||
use std::io::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
|
||||
/// A coding agent atuin captures sessions from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Agent {
|
||||
Claude,
|
||||
Codex,
|
||||
Opencode,
|
||||
Pi,
|
||||
}
|
||||
|
||||
impl Agent {
|
||||
pub const ALL: [Self; 4] = [Self::Claude, Self::Codex, Self::Opencode, Self::Pi];
|
||||
|
||||
/// The `harness` atuin's JSON output names it by.
|
||||
pub fn harness(self) -> &'static str {
|
||||
match self {
|
||||
Self::Claude => "claude-code",
|
||||
Self::Codex => "codex",
|
||||
Self::Opencode => "opencode",
|
||||
Self::Pi => "pi",
|
||||
}
|
||||
}
|
||||
|
||||
/// Its executable, and the name `atuin ai resume --in` takes.
|
||||
pub fn program(self) -> &'static str {
|
||||
match self {
|
||||
Self::Claude => "claude",
|
||||
Self::Codex => "codex",
|
||||
Self::Opencode => "opencode",
|
||||
Self::Pi => "pi",
|
||||
}
|
||||
}
|
||||
|
||||
/// The name it gives the shell tool every turn here calls.
|
||||
pub fn tool(self) -> &'static str {
|
||||
match self {
|
||||
Self::Claude => "Bash",
|
||||
Self::Codex => "exec_command",
|
||||
Self::Opencode | Self::Pi => "bash",
|
||||
}
|
||||
}
|
||||
|
||||
/// The name atuin's status lines give it.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Claude => "Claude Code",
|
||||
Self::Codex => "Codex",
|
||||
Self::Opencode => "opencode",
|
||||
Self::Pi => "Pi",
|
||||
}
|
||||
}
|
||||
|
||||
/// A fresh session id of the agent's own shape.
|
||||
pub fn new_id(self) -> String {
|
||||
let uuid = atuin_common::utils::uuid_v7();
|
||||
match self {
|
||||
Self::Opencode => format!("ses_{}", uuid.as_simple()),
|
||||
_ => uuid.as_hyphenated().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where, under `home`, the agent keeps its sessions: created up front, as a daemon only looks
|
||||
/// for a missing one again after a backoff.
|
||||
pub fn root(self, home: &Path) -> PathBuf {
|
||||
match self {
|
||||
Self::Claude => home.join(".claude/projects"),
|
||||
Self::Codex => home.join(".codex/sessions"),
|
||||
Self::Opencode => home.join(".local/share/opencode/opencode.db"),
|
||||
Self::Pi => home.join(".pi/agent/sessions"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lay out the agent's data under `home` as the agent itself would before its first session.
|
||||
pub fn install(self, home: &Path) {
|
||||
match self {
|
||||
Self::Opencode => opencode_db(home),
|
||||
// Codex keeps a lock per open thread here; with the directory present, atuin asks the
|
||||
// locks whether a session is open, rather than every `codex` process on the machine.
|
||||
Self::Codex => {
|
||||
fs::create_dir_all(self.root(home)).unwrap();
|
||||
fs::create_dir_all(home.join(".codex/thread-writer-locks")).unwrap();
|
||||
}
|
||||
_ => fs::create_dir_all(self.root(home)).unwrap(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A word found nowhere else, for searches and for telling turns apart.
|
||||
pub fn word() -> String {
|
||||
format!("w{}", atuin_common::utils::uuid_v7().as_simple())
|
||||
}
|
||||
|
||||
/// One exchange: a prompt, reasoning, a shell command and its output, and the reply.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Turn {
|
||||
pub prompt: String,
|
||||
pub thinking: String,
|
||||
pub command: String,
|
||||
pub output: String,
|
||||
pub reply: String,
|
||||
}
|
||||
|
||||
impl Turn {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
prompt: format!("please look into {}", word()),
|
||||
thinking: format!("thinking about {}", word()),
|
||||
command: format!("echo {}", word()),
|
||||
output: format!("output {}", word()),
|
||||
reply: format!("all done with {}", word()),
|
||||
}
|
||||
}
|
||||
|
||||
/// What this turn says in conversation, as a session's messages carry it: its prompt and its
|
||||
/// reply.
|
||||
pub fn said(&self) -> [&str; 2] {
|
||||
[&self.prompt, &self.reply]
|
||||
}
|
||||
|
||||
/// The turn as a transcript holds it, reasoning aside: its prompt, the command it ran, what
|
||||
/// that printed, and its reply.
|
||||
pub fn whole(&self) -> [&str; 4] {
|
||||
[&self.prompt, &self.command, &self.output, &self.reply]
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything `turns` hold, reasoning aside, in order (see [`Turn::whole`]).
|
||||
pub fn whole(turns: &[Turn]) -> Vec<String> {
|
||||
turns.iter().flat_map(|t| t.whole().map(str::to_owned)).collect()
|
||||
}
|
||||
|
||||
/// Everything `turns` say in conversation, in order.
|
||||
pub fn said(turns: &[Turn]) -> Vec<String> {
|
||||
turns.iter().flat_map(|t| t.said().map(str::to_owned)).collect()
|
||||
}
|
||||
|
||||
/// A session's transcript on one machine.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Transcript {
|
||||
pub agent: Agent,
|
||||
pub id: String,
|
||||
/// The transcript file, or for opencode its database.
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
/// Each line a millisecond after the last, so every agent orders them as written.
|
||||
fn tick() -> OffsetDateTime {
|
||||
static LAST: AtomicI64 = AtomicI64::new(0);
|
||||
let now = i64::try_from(OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000_000).unwrap();
|
||||
let last =
|
||||
LAST.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |last| Some(now.max(last + 1)));
|
||||
let ms = now.max(last.unwrap() + 1);
|
||||
OffsetDateTime::from_unix_timestamp_nanos(i128::from(ms) * 1_000_000).unwrap()
|
||||
}
|
||||
|
||||
fn rfc3339(at: OffsetDateTime) -> String {
|
||||
at.format(&Rfc3339).unwrap()
|
||||
}
|
||||
|
||||
fn millis(at: OffsetDateTime) -> i64 {
|
||||
i64::try_from(at.unix_timestamp_nanos() / 1_000_000).unwrap()
|
||||
}
|
||||
|
||||
fn short() -> String {
|
||||
atuin_common::utils::uuid_v7().as_simple().to_string()
|
||||
}
|
||||
|
||||
impl Transcript {
|
||||
/// Write a new session of `agent`, working in `cwd`, under `home`.
|
||||
pub fn create(home: &Path, agent: Agent, cwd: &Path, turns: &[Turn]) -> Self {
|
||||
let id = agent.new_id();
|
||||
let started = tick();
|
||||
let path = match agent {
|
||||
Agent::Claude => agent.root(home).join(claude_project(cwd)).join(format!("{id}.jsonl")),
|
||||
Agent::Codex => {
|
||||
let day = started.format(time::macros::format_description!("[year]/[month]/[day]"));
|
||||
let stamp = started.format(time::macros::format_description!(
|
||||
"[year]-[month]-[day]T[hour]-[minute]-[second]"
|
||||
));
|
||||
agent
|
||||
.root(home)
|
||||
.join(day.unwrap())
|
||||
.join(format!("rollout-{}-{id}.jsonl", stamp.unwrap()))
|
||||
}
|
||||
Agent::Pi => {
|
||||
let dir = format!(
|
||||
"--{}--",
|
||||
cwd.display().to_string().trim_matches('/').replace('/', "-")
|
||||
);
|
||||
let stamp = rfc3339(started).replace([':', '.'], "-");
|
||||
agent.root(home).join(dir).join(format!("{stamp}_{id}.jsonl"))
|
||||
}
|
||||
Agent::Opencode => agent.root(home),
|
||||
};
|
||||
let transcript = Self { agent, id, path };
|
||||
match agent {
|
||||
Agent::Claude | Agent::Pi | Agent::Codex => {
|
||||
fs::create_dir_all(transcript.path.parent().unwrap()).unwrap();
|
||||
let header = match agent {
|
||||
Agent::Codex => vec![json!({
|
||||
"timestamp": rfc3339(started),
|
||||
"type": "session_meta",
|
||||
"payload": {
|
||||
"id": transcript.id,
|
||||
"timestamp": rfc3339(started),
|
||||
"cwd": cwd,
|
||||
"originator": "codex_cli_rs",
|
||||
"cli_version": "0.50.0",
|
||||
"instructions": null,
|
||||
"git": {"branch": "main"},
|
||||
},
|
||||
})],
|
||||
Agent::Pi => vec![json!({
|
||||
"type": "session",
|
||||
"version": 3,
|
||||
"id": transcript.id,
|
||||
"timestamp": rfc3339(started),
|
||||
"cwd": cwd,
|
||||
})],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
write_lines(&transcript.path, &header);
|
||||
}
|
||||
Agent::Opencode => {
|
||||
let title = format!("opencode session {}", word());
|
||||
opencode_events(home, &[(
|
||||
"session.created.1",
|
||||
json!({
|
||||
"sessionID": transcript.id,
|
||||
"info": {
|
||||
"id": transcript.id,
|
||||
"title": title,
|
||||
"directory": cwd,
|
||||
"time": {"created": millis(started), "updated": millis(started)},
|
||||
},
|
||||
}),
|
||||
)]);
|
||||
}
|
||||
}
|
||||
transcript.append(cwd, turns);
|
||||
transcript
|
||||
}
|
||||
|
||||
/// A Claude Code subagent `parent` spawned, working in `cwd`: its own transcript beside the
|
||||
/// parent's, whose lines name the parent's session.
|
||||
pub fn claude_subagent(parent: &Self, cwd: &Path, turns: &[Turn]) -> Self {
|
||||
assert_eq!(parent.agent, Agent::Claude);
|
||||
let id = format!("agent-{}", &short()[16..]);
|
||||
let dir = parent.path.with_extension("").join("subagents");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let subagent = Self {
|
||||
agent: Agent::Claude,
|
||||
path: dir.join(format!("{id}.jsonl")),
|
||||
id,
|
||||
};
|
||||
subagent.append(cwd, turns);
|
||||
subagent
|
||||
}
|
||||
|
||||
/// Where this agent keeps the transcript of session `id` under `home`, when it has one.
|
||||
pub fn find(home: &Path, agent: Agent, id: &str) -> Option<Self> {
|
||||
assert_ne!(agent, Agent::Opencode, "opencode keeps every session in one database");
|
||||
let mut dirs = vec![agent.root(home)];
|
||||
while let Some(dir) = dirs.pop() {
|
||||
// A directory gone or unreadable holds nothing to find: the rest may.
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
dirs.push(path);
|
||||
} else if path.file_stem().unwrap().to_string_lossy().ends_with(id) {
|
||||
let id = id.to_owned();
|
||||
return Some(Self { agent, id, path });
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The transcript as it stands.
|
||||
pub fn read(&self) -> String {
|
||||
fs::read_to_string(&self.path).unwrap()
|
||||
}
|
||||
|
||||
/// Carry the session on with `turns`, as the agent would: after whatever its transcript ends
|
||||
/// with, whoever wrote it.
|
||||
pub fn append(&self, cwd: &Path, turns: &[Turn]) {
|
||||
match self.agent {
|
||||
Agent::Claude => self.append_claude(cwd, turns),
|
||||
Agent::Codex => self.append_codex(turns),
|
||||
Agent::Pi => self.append_pi(turns),
|
||||
Agent::Opencode => self.append_opencode(cwd, turns),
|
||||
}
|
||||
}
|
||||
|
||||
/// The last line of the transcript that has `key`, as the agent finds where to go on from.
|
||||
fn last(&self, key: &str) -> Option<String> {
|
||||
fs::read_to_string(&self.path)
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
|
||||
.filter(|line| line["type"] != "session")
|
||||
.filter_map(|line| line[key].as_str().map(str::to_owned))
|
||||
.next_back()
|
||||
}
|
||||
|
||||
fn append_claude(&self, cwd: &Path, turns: &[Turn]) {
|
||||
let mut parent = self.last("uuid");
|
||||
// A subagent's lines name the session it works for.
|
||||
let session = match self.id.strip_prefix("agent-") {
|
||||
Some(_) => self
|
||||
.path
|
||||
.ancestors()
|
||||
.nth(2)
|
||||
.unwrap()
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
None => self.id.clone(),
|
||||
};
|
||||
let mut lines = Vec::new();
|
||||
let mut line = |kind: &str, message: Value| {
|
||||
let uuid = uuid::Uuid::new_v4().to_string();
|
||||
lines.push(json!({
|
||||
"parentUuid": parent,
|
||||
"isSidechain": session != self.id,
|
||||
"userType": "external",
|
||||
"cwd": cwd,
|
||||
"sessionId": session,
|
||||
"version": "2.1.0",
|
||||
"gitBranch": "main",
|
||||
"type": kind,
|
||||
"message": message,
|
||||
"uuid": uuid,
|
||||
"timestamp": rfc3339(tick()),
|
||||
}));
|
||||
parent = Some(uuid);
|
||||
};
|
||||
for turn in turns {
|
||||
let call = format!("toolu_{}", short());
|
||||
let usage = json!({
|
||||
"input_tokens": 10,
|
||||
"output_tokens": 5,
|
||||
"cache_read_input_tokens": 0,
|
||||
"cache_creation_input_tokens": 0,
|
||||
});
|
||||
line("user", json!({"role": "user", "content": turn.prompt}));
|
||||
line(
|
||||
"assistant",
|
||||
json!({
|
||||
"id": format!("msg_{}", short()),
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-opus-5-5",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": turn.thinking, "signature": "sig"},
|
||||
{"type": "tool_use", "id": call, "name": self.agent.tool(), "input": {"command": turn.command}},
|
||||
],
|
||||
"stop_reason": "tool_use",
|
||||
"usage": usage,
|
||||
}),
|
||||
);
|
||||
line(
|
||||
"user",
|
||||
json!({
|
||||
"role": "user",
|
||||
"content": [{"type": "tool_result", "tool_use_id": call, "content": turn.output, "is_error": false}],
|
||||
}),
|
||||
);
|
||||
line(
|
||||
"assistant",
|
||||
json!({
|
||||
"id": format!("msg_{}", short()),
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-opus-5-5",
|
||||
"content": [{"type": "text", "text": turn.reply}],
|
||||
"stop_reason": "end_turn",
|
||||
"usage": usage,
|
||||
}),
|
||||
);
|
||||
}
|
||||
write_lines(&self.path, &lines);
|
||||
}
|
||||
|
||||
fn append_codex(&self, turns: &[Turn]) {
|
||||
let mut lines = Vec::new();
|
||||
let mut item = |payload: Value| {
|
||||
lines.push(
|
||||
json!({"timestamp": rfc3339(tick()), "type": "response_item", "payload": payload}),
|
||||
);
|
||||
};
|
||||
for turn in turns {
|
||||
let call = format!("call_{}", short());
|
||||
item(
|
||||
json!({"type": "message", "role": "user", "content": [{"type": "input_text", "text": turn.prompt}]}),
|
||||
);
|
||||
item(json!({
|
||||
"type": "reasoning",
|
||||
"summary": [{"type": "summary_text", "text": turn.thinking}],
|
||||
"encrypted_content": null,
|
||||
}));
|
||||
item(json!({
|
||||
"type": "function_call",
|
||||
"name": self.agent.tool(),
|
||||
"arguments": json!({"cmd": turn.command}).to_string(),
|
||||
"call_id": call,
|
||||
}));
|
||||
item(json!({
|
||||
"type": "function_call_output",
|
||||
"call_id": call,
|
||||
"output": json!({"output": turn.output, "metadata": {"exit_code": 0}}).to_string(),
|
||||
}));
|
||||
item(
|
||||
json!({"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": turn.reply}]}),
|
||||
);
|
||||
}
|
||||
write_lines(&self.path, &lines);
|
||||
}
|
||||
|
||||
fn append_pi(&self, turns: &[Turn]) {
|
||||
let mut parent = self.last("id");
|
||||
let mut lines = Vec::new();
|
||||
let mut entry = |message: Value| {
|
||||
let id = short()[24..].to_owned();
|
||||
let at = tick();
|
||||
let mut message = message;
|
||||
message["timestamp"] = json!(millis(at));
|
||||
lines.push(json!({
|
||||
"type": "message",
|
||||
"id": id,
|
||||
"parentId": parent,
|
||||
"timestamp": rfc3339(at),
|
||||
"message": message,
|
||||
}));
|
||||
parent = Some(id);
|
||||
};
|
||||
let usage = json!({
|
||||
"input": 10, "output": 5, "cacheRead": 0, "cacheWrite": 0, "totalTokens": 15,
|
||||
"cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "total": 0},
|
||||
});
|
||||
for turn in turns {
|
||||
let call = format!("toolu_{}", short());
|
||||
entry(json!({"role": "user", "content": [{"type": "text", "text": turn.prompt}]}));
|
||||
entry(json!({
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": turn.thinking},
|
||||
{"type": "toolCall", "id": call, "name": self.agent.tool(), "arguments": {"command": turn.command}},
|
||||
],
|
||||
"api": "anthropic-messages",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-opus-5-5",
|
||||
"usage": usage,
|
||||
"stopReason": "toolUse",
|
||||
}));
|
||||
entry(json!({
|
||||
"role": "toolResult",
|
||||
"toolCallId": call,
|
||||
"toolName": self.agent.tool(),
|
||||
"content": [{"type": "text", "text": turn.output}],
|
||||
"isError": false,
|
||||
}));
|
||||
entry(json!({
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": turn.reply}],
|
||||
"api": "anthropic-messages",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-opus-5-5",
|
||||
"usage": usage,
|
||||
"stopReason": "stop",
|
||||
}));
|
||||
}
|
||||
write_lines(&self.path, &lines);
|
||||
}
|
||||
|
||||
fn append_opencode(&self, cwd: &Path, turns: &[Turn]) {
|
||||
let home = self.path.ancestors().nth(4).unwrap();
|
||||
let mut events = Vec::new();
|
||||
for turn in turns {
|
||||
let (asked, answered) = (tick(), tick());
|
||||
let (user, assistant) = (format!("msg_{}", short()), format!("msg_{}", short()));
|
||||
// A part's own clock, else the time it was written, orders it.
|
||||
let part = |message: &str, part: Value, at: OffsetDateTime| {
|
||||
let mut part = part;
|
||||
part["id"] = json!(format!("prt_{}", short()));
|
||||
part["messageID"] = json!(message);
|
||||
part["sessionID"] = json!(self.id);
|
||||
(
|
||||
"message.part.updated.1",
|
||||
json!({"sessionID": self.id, "part": part, "time": millis(at)}),
|
||||
)
|
||||
};
|
||||
let done = |at: OffsetDateTime| json!({"start": millis(at), "end": millis(tick())});
|
||||
events.push(("message.updated.1", json!({
|
||||
"sessionID": self.id,
|
||||
"info": {"id": user, "sessionID": self.id, "role": "user", "time": {"created": millis(asked)}},
|
||||
})));
|
||||
events.push(part(&user, json!({"type": "text", "text": turn.prompt}), asked));
|
||||
events.push((
|
||||
"message.updated.1",
|
||||
json!({
|
||||
"sessionID": self.id,
|
||||
"info": {
|
||||
"id": assistant,
|
||||
"sessionID": self.id,
|
||||
"role": "assistant",
|
||||
"parentID": user,
|
||||
"modelID": "claude-opus-5-5",
|
||||
"providerID": "anthropic",
|
||||
"path": {"cwd": cwd, "root": cwd},
|
||||
"time": {"created": millis(answered)},
|
||||
},
|
||||
}),
|
||||
));
|
||||
events.push(part(
|
||||
&assistant,
|
||||
json!({"type": "reasoning", "text": turn.thinking, "time": done(answered)}),
|
||||
answered,
|
||||
));
|
||||
events.push(part(
|
||||
&assistant,
|
||||
json!({
|
||||
"type": "tool",
|
||||
"callID": format!("call_{}", short()),
|
||||
"tool": self.agent.tool(),
|
||||
"state": {
|
||||
"status": "completed",
|
||||
"input": {"command": turn.command},
|
||||
"output": turn.output,
|
||||
"time": done(answered),
|
||||
},
|
||||
}),
|
||||
answered,
|
||||
));
|
||||
events.push(part(
|
||||
&assistant,
|
||||
json!({"type": "text", "text": turn.reply, "time": done(answered)}),
|
||||
answered,
|
||||
));
|
||||
}
|
||||
opencode_events(home, &events);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_lines(path: &Path, lines: &[Value]) {
|
||||
let mut file = fs::OpenOptions::new().create(true).append(true).open(path).unwrap();
|
||||
let mut text = String::new();
|
||||
for line in lines {
|
||||
text.push_str(&line.to_string());
|
||||
text.push('\n');
|
||||
}
|
||||
// One write, so the daemon never reads half a turn.
|
||||
file.write_all(text.as_bytes()).unwrap();
|
||||
}
|
||||
|
||||
/// Claude Code's directory for the project at `cwd`: every character but a letter or digit a dash.
|
||||
fn claude_project(cwd: &Path) -> String {
|
||||
cwd.display()
|
||||
.to_string()
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
c
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// opencode's database: the event log it records sessions in, and the tables it projects them
|
||||
/// into.
|
||||
fn opencode_db(home: &Path) {
|
||||
let path = Agent::Opencode.root(home);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let db = rusqlite::Connection::open(&path).unwrap();
|
||||
db.execute_batch(
|
||||
"PRAGMA journal_mode = WAL;
|
||||
CREATE TABLE event (id TEXT PRIMARY KEY, aggregate_id TEXT NOT NULL, seq INTEGER NOT \
|
||||
NULL, type TEXT NOT NULL, data TEXT NOT NULL);
|
||||
CREATE UNIQUE INDEX event_aggregate_seq_idx ON event (aggregate_id, seq);
|
||||
CREATE TABLE session (id TEXT PRIMARY KEY, parent_id TEXT, directory TEXT NOT NULL, title \
|
||||
TEXT NOT NULL, revert TEXT, time_created INTEGER NOT NULL, time_updated INTEGER NOT \
|
||||
NULL);
|
||||
CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, time_created INTEGER \
|
||||
NOT NULL, time_updated INTEGER NOT NULL, data TEXT NOT NULL);
|
||||
CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT NOT NULL, session_id TEXT NOT \
|
||||
NULL, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, data TEXT NOT NULL);",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Append `events` to opencode's event log, each continuing its session's sequence, and project
|
||||
/// them, in one transaction as opencode commits them.
|
||||
fn opencode_events(home: &Path, events: &[(&str, Value)]) {
|
||||
let mut db = rusqlite::Connection::open(Agent::Opencode.root(home)).unwrap();
|
||||
db.busy_timeout(std::time::Duration::from_secs(10)).unwrap();
|
||||
let tx = db.transaction().unwrap();
|
||||
for (kind, data) in events {
|
||||
let session = data["sessionID"].as_str().unwrap();
|
||||
tx.execute(
|
||||
"INSERT INTO event (id, aggregate_id, seq, type, data) VALUES (?1, ?2, (SELECT \
|
||||
coalesce(max(seq), -1) + 1 FROM event WHERE aggregate_id = ?2), ?3, ?4)",
|
||||
rusqlite::params![format!("evt_{}", short()), session, kind, data.to_string()],
|
||||
)
|
||||
.unwrap();
|
||||
// The projections keep a row's info without the ids their columns hold.
|
||||
let without = |value: &Value, keys: &[&str]| {
|
||||
let mut value = value.clone();
|
||||
for key in keys {
|
||||
value.as_object_mut().unwrap().remove(*key);
|
||||
}
|
||||
value.to_string()
|
||||
};
|
||||
match *kind {
|
||||
"session.created.1" => {
|
||||
let info = &data["info"];
|
||||
tx.execute(
|
||||
"INSERT INTO session (id, directory, title, time_created, time_updated) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?4)",
|
||||
rusqlite::params![
|
||||
session,
|
||||
info["directory"].as_str(),
|
||||
info["title"].as_str(),
|
||||
info["time"]["created"].as_i64()
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
"message.updated.1" => {
|
||||
let info = &data["info"];
|
||||
let created = info["time"]["created"].as_i64();
|
||||
tx.execute(
|
||||
"INSERT INTO message (id, session_id, time_created, time_updated, data) \
|
||||
VALUES (?1, ?2, ?3, ?3, ?4)",
|
||||
rusqlite::params![
|
||||
info["id"].as_str(),
|
||||
session,
|
||||
created,
|
||||
without(info, &["id", "sessionID"])
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
_ => {
|
||||
let part = &data["part"];
|
||||
tx.execute(
|
||||
"INSERT INTO part (id, message_id, session_id, time_created, time_updated, \
|
||||
data) VALUES (?1, ?2, ?3, ?4, ?4, ?5)",
|
||||
rusqlite::params![
|
||||
part["id"].as_str(),
|
||||
part["messageID"].as_str(),
|
||||
session,
|
||||
data["time"].as_i64(),
|
||||
without(part, &["id", "messageID", "sessionID"])
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
tx.commit().unwrap();
|
||||
}
|
||||
|
||||
/// Stand-ins for the agents' executables, on `PATH` ahead of anything installed: each prints how
|
||||
/// it was run and where, and exits.
|
||||
pub fn install_fakes(bin: &Path, agents: &[Agent]) {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
fs::create_dir_all(bin).unwrap();
|
||||
for agent in agents {
|
||||
let path = bin.join(agent.program());
|
||||
fs::write(&path, format!("#!/bin/sh\necho \"AGENT-RAN {} $* IN $PWD\"\n", agent.program()))
|
||||
.unwrap();
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// The line a fake agent prints when run with `args` in `cwd`.
|
||||
pub fn ran(agent: Agent, args: &str, cwd: &Path) -> String {
|
||||
format!("AGENT-RAN {} {args} IN {}", agent.program(), cwd.display())
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
//! Machines running atuin with AI session capture on, and a sync server between them.
|
||||
//!
|
||||
//! A [`Machine`] is a temporary home with its own daemon, data and host id, the agents of
|
||||
//! [`agents`](crate::agents) laid out in it and stand-ins for their executables on its `PATH`.
|
||||
//! Two machines given the same [`SyncServer`] share one account, so what one captures the other's
|
||||
//! daemon syncs down, as on two computers.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::os::unix::process::CommandExt as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::Value;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::agents::{Agent, install_fakes};
|
||||
use crate::common::{FreshEnv, Process, TIMEOUT, output};
|
||||
|
||||
/// An atuin sync server on a port of its own, run on a runtime of its own so the blocking tests
|
||||
/// that use it never starve it.
|
||||
pub struct SyncServer {
|
||||
pub address: String,
|
||||
_dir: TempDir,
|
||||
runtime: Option<tokio::runtime::Runtime>,
|
||||
}
|
||||
|
||||
impl SyncServer {
|
||||
pub fn start() -> Self {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let settings = atuin_server::Settings {
|
||||
host: "127.0.0.1".to_owned(),
|
||||
port: 0,
|
||||
path: String::new(),
|
||||
open_registration: true,
|
||||
max_record_size: atuin_common::units::ByteSize::b(1024 * 1024 * 1024),
|
||||
register_webhook_url: None,
|
||||
register_webhook_username: String::new(),
|
||||
db_settings: atuin_server::db::DbSettings {
|
||||
db_uri: format!("sqlite://{}", dir.path().join("server.db").display())
|
||||
.parse()
|
||||
.unwrap(),
|
||||
},
|
||||
metrics: atuin_server::settings::Metrics::default(),
|
||||
fake_version: None,
|
||||
};
|
||||
let listener = runtime.block_on(tokio::net::TcpListener::bind("127.0.0.1:0")).unwrap();
|
||||
let addr: SocketAddr = listener.local_addr().unwrap();
|
||||
runtime.spawn(async move {
|
||||
atuin_server::launch_with_tcp_listener(settings, listener, std::future::pending())
|
||||
.await
|
||||
.expect("sync server failed");
|
||||
});
|
||||
let server = Self {
|
||||
address: format!("http://{addr}"),
|
||||
_dir: dir,
|
||||
runtime: Some(runtime),
|
||||
};
|
||||
wait("the sync server", || std::net::TcpStream::connect(addr).is_ok());
|
||||
server
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SyncServer {
|
||||
fn drop(&mut self) {
|
||||
if let Some(runtime) = self.runtime.take() {
|
||||
runtime.shutdown_background();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll `check` until it gives a value, and return that; after [`TIMEOUT`], fail with what it
|
||||
/// last said was missing.
|
||||
pub fn wait_for<T>(what: &str, mut check: impl FnMut() -> Result<T, String>) -> T {
|
||||
let deadline = Instant::now() + TIMEOUT;
|
||||
loop {
|
||||
match check() {
|
||||
Ok(value) => return value,
|
||||
Err(why) => assert!(Instant::now() < deadline, "timed out waiting for {what}: {why}"),
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll `ready` until it holds, failing after [`TIMEOUT`].
|
||||
pub fn wait(what: &str, mut ready: impl FnMut() -> bool) {
|
||||
wait_for(what, || {
|
||||
if ready() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(String::new())
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// One computer: a home, a daemon capturing sessions, and the agents' executables.
|
||||
pub struct Machine {
|
||||
pub env: FreshEnv,
|
||||
/// The agents' stand-ins. Not in the temporary home: that may be on a filesystem that runs
|
||||
/// nothing.
|
||||
bin: TempDir,
|
||||
daemon: Option<Process>,
|
||||
}
|
||||
|
||||
/// Cargo's scratch directory for integration tests, made if it isn't there: a test archive run in
|
||||
/// a fresh checkout has none.
|
||||
fn target_tmp() -> &'static Path {
|
||||
let dir = Path::new(env!("CARGO_TARGET_TMPDIR"));
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// Where a test keeps what both machines see, like a checkout at the same path on each: a
|
||||
/// session's working directory has to exist on the machine resuming it for the agent to start
|
||||
/// there.
|
||||
pub fn shared_dir() -> TempDir {
|
||||
tempfile::Builder::new().prefix("project-").tempdir_in(target_tmp()).unwrap()
|
||||
}
|
||||
|
||||
impl Machine {
|
||||
/// A machine on its own, syncing nowhere.
|
||||
pub fn new() -> Self {
|
||||
Self::with_config("", "capture_sessions = true\n")
|
||||
}
|
||||
|
||||
/// A machine syncing nowhere with the settings `top` (top-level keys, then any tables) and
|
||||
/// the `[ai]` settings `ai`.
|
||||
pub fn with_config(top: &str, ai: &str) -> Self {
|
||||
Self::configured(None, top, ai)
|
||||
}
|
||||
|
||||
/// A machine with the settings `top` and `[ai]` settings `ai`, syncing through the server at
|
||||
/// `sync`, if any, as often as its daemon can.
|
||||
fn configured(sync: Option<&str>, top: &str, ai: &str) -> Self {
|
||||
let env = FreshEnv::new();
|
||||
let bin = tempfile::Builder::new().prefix("bin-").tempdir_in(target_tmp()).unwrap();
|
||||
install_fakes(bin.path(), &Agent::ALL);
|
||||
for agent in Agent::ALL {
|
||||
agent.install(env.home());
|
||||
}
|
||||
let (auto_sync, address, frequency) = match sync {
|
||||
Some(address) => {
|
||||
(true, format!("sync_address = \"{address}\"\n"), "sync_frequency = 1\n")
|
||||
}
|
||||
None => (false, String::new(), ""),
|
||||
};
|
||||
env.write_config(&format!(
|
||||
"local_timeout = 15\nauto_sync = {auto_sync}\n{address}{top}[daemon]\nenabled = \
|
||||
true\nautostart = false\n{frequency}[ai]\n{ai}"
|
||||
));
|
||||
// Finish migrations before the daemon and the commands race for the databases.
|
||||
env.run(&["store", "status"]);
|
||||
Self {
|
||||
env,
|
||||
bin,
|
||||
daemon: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Two machines of one account on `server`, their daemons running.
|
||||
pub fn pair(server: &SyncServer) -> (Self, Self) {
|
||||
let one = Self::syncing(server, None);
|
||||
let key = one.run(&["key", "--base64"]).trim().to_owned();
|
||||
let two = Self::syncing(server, Some((&one, &key)));
|
||||
(one, two)
|
||||
}
|
||||
|
||||
/// A machine syncing through `server`: registering a new account, or logging into `login`'s
|
||||
/// with its key. Logged in before its daemon starts, which keeps the key it starts with.
|
||||
fn syncing(server: &SyncServer, login: Option<(&Self, &str)>) -> Self {
|
||||
let mut machine = Self::configured(Some(&server.address), "", "capture_sessions = true\n");
|
||||
match login {
|
||||
None => {
|
||||
let user = machine.user();
|
||||
machine.run(&[
|
||||
"register",
|
||||
"-u",
|
||||
&user,
|
||||
"-e",
|
||||
&format!("{user}@example.com"),
|
||||
"-p",
|
||||
&user,
|
||||
]);
|
||||
}
|
||||
Some((other, key)) => {
|
||||
let user = other.user();
|
||||
machine.run(&["login", "-u", &user, "-p", &user, "-k", key]);
|
||||
}
|
||||
}
|
||||
machine.start_daemon();
|
||||
machine
|
||||
}
|
||||
|
||||
/// The account name this machine registers, and the password: unique to the home.
|
||||
fn user(&self) -> String {
|
||||
let name = self.env.home().file_name().unwrap().to_string_lossy();
|
||||
name.replace(|c: char| !c.is_ascii_alphanumeric(), "")
|
||||
}
|
||||
|
||||
pub fn home(&self) -> &Path {
|
||||
self.env.home()
|
||||
}
|
||||
|
||||
/// This machine's `PATH`: the agents' stand-ins first.
|
||||
pub fn path_var(&self) -> String {
|
||||
format!("{}:{}", self.bin.path().display(), self.env.path_var())
|
||||
}
|
||||
|
||||
/// `atuin args` on this machine, finding the agents' stand-ins on its `PATH`.
|
||||
pub fn atuin(&self, args: &[&str]) -> Command {
|
||||
let mut command = self.env.atuin(args);
|
||||
command.env("PATH", self.path_var());
|
||||
// The harness turns automatic sync off for every command; the config decides here.
|
||||
command.env_remove("ATUIN_AUTO_SYNC");
|
||||
command
|
||||
}
|
||||
|
||||
pub fn run(&self, args: &[&str]) -> String {
|
||||
output(self.atuin(args))
|
||||
}
|
||||
|
||||
pub fn start_daemon(&mut self) {
|
||||
assert!(self.daemon.is_none(), "the daemon is already running");
|
||||
let mut daemon = self.atuin(&["daemon", "start", "--show-logs"]);
|
||||
// `ATUIN_E2E_LOG` sets what the daemons log, as `ATUIN_LOG` does, for a failure to show.
|
||||
if let Some(filter) = std::env::var_os("ATUIN_E2E_LOG") {
|
||||
daemon.env("ATUIN_LOG", filter);
|
||||
}
|
||||
let daemon = Process::spawn(daemon);
|
||||
let socket = self.env.socket();
|
||||
wait("the daemon", || socket.exists());
|
||||
self.daemon = Some(daemon);
|
||||
// `ai session` commands wait for its first replay of the store, so once one answers, the
|
||||
// daemon is capturing.
|
||||
self.sessions();
|
||||
}
|
||||
|
||||
pub fn stop_daemon(&mut self) {
|
||||
let daemon = self.daemon.take().expect("the daemon isn't running");
|
||||
self.run(&["daemon", "stop"]);
|
||||
let socket = self.env.socket();
|
||||
wait("the daemon to stop", || !socket.exists());
|
||||
drop(daemon);
|
||||
}
|
||||
|
||||
/// The daemon's log so far, for a failure message.
|
||||
pub fn logs(&self) -> String {
|
||||
self.daemon.as_ref().map(Process::logs).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Every session the daemon has, as `atuin ai session list` gives them.
|
||||
pub fn sessions(&self) -> Vec<Value> {
|
||||
let list = self.run(&["ai", "session", "list", "--style", "json"]);
|
||||
serde_json::from_str::<Value>(&list).unwrap().as_array().unwrap().clone()
|
||||
}
|
||||
|
||||
/// The session `id` (the agent's), as `atuin ai session list` gives it.
|
||||
pub fn session(&self, id: &str) -> Option<Value> {
|
||||
self.sessions().into_iter().find(|s| s["session_id"] == id)
|
||||
}
|
||||
|
||||
/// The session `id` and its messages, as `atuin ai session show` gives them.
|
||||
pub fn show(&self, id: &str) -> Value {
|
||||
let show = self.run(&["ai", "session", "show", id, "--style", "json"]);
|
||||
serde_json::from_str(&show).unwrap()
|
||||
}
|
||||
|
||||
/// The text of each message of session `id` that says something in conversation, in order.
|
||||
pub fn said(&self, id: &str) -> Vec<String> {
|
||||
said_in(&self.show(id))
|
||||
}
|
||||
|
||||
/// Wait for `check` to give a value, as [`wait_for`], saying on failure what the daemon
|
||||
/// logged as well.
|
||||
fn wait_for<T>(&self, what: &str, mut check: impl FnMut() -> Result<T, String>) -> T {
|
||||
wait_for(what, || check().map_err(|why| format!("{why}\ndaemon log:\n{}", self.logs())))
|
||||
}
|
||||
|
||||
/// What session `id` says in conversation, or why there's nothing to say.
|
||||
fn current(&self, id: &str) -> Result<Vec<String>, String> {
|
||||
match self.session(id) {
|
||||
Some(_) => Ok(self.said(id)),
|
||||
None => Err(format!("there's no session {id}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for session `id` to hold exactly `want` in conversation, failing with what it holds.
|
||||
pub fn wait_until_said(&self, id: &str, want: &[String]) {
|
||||
self.wait_for(&format!("session {id} to say {want:#?}"), || {
|
||||
let said = self.current(id)?;
|
||||
if said == want {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("it says {said:#?}"))
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Wait for session `id` to hold `want` in conversation, in order, among whatever else.
|
||||
pub fn wait_until_holds(&self, id: &str, want: &[String]) {
|
||||
self.wait_for(&format!("session {id} to hold {want:#?}"), || {
|
||||
let said = self.current(id)?.join("\n");
|
||||
if holds_in_order(&said, want) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("it says {said:?}"))
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Wait for a session of `agent` whose parent is session `parent` to appear, and return it.
|
||||
/// A new session is listed as soon as its first row is stored, which can be before the row
|
||||
/// that names its parent.
|
||||
pub fn wait_for_child(&self, agent: Agent, parent: &str) -> Value {
|
||||
self.wait_for(&format!("a {agent:?} session under {parent}"), || {
|
||||
self.sessions()
|
||||
.into_iter()
|
||||
.find(|s| s["harness"] == agent.harness() && s["parent"]["session_id"] == parent)
|
||||
.ok_or_else(|| "none yet".to_owned())
|
||||
})
|
||||
}
|
||||
|
||||
/// `atuin ai resume args`, run from `cwd` as a shell there would, without a terminal: never
|
||||
/// the picker, which a test can't answer.
|
||||
pub fn resume(&self, args: &[&str], cwd: &Path) -> Output {
|
||||
let mut command = self.atuin(&[&["ai", "resume"], args].concat());
|
||||
command.current_dir(cwd).env("PWD", cwd);
|
||||
// SAFETY: `setsid` is async-signal-safe and touches no memory of this process.
|
||||
unsafe {
|
||||
command.pre_exec(|| rustix::process::setsid().map(drop).map_err(Into::into));
|
||||
}
|
||||
Process::spawn(command).wait()
|
||||
}
|
||||
|
||||
/// This machine's host id.
|
||||
pub fn host_id(&self) -> String {
|
||||
let status = self.run(&["store", "status"]);
|
||||
status
|
||||
.lines()
|
||||
.find_map(|l| l.strip_prefix("host: ")?.strip_suffix(" <- CURRENT HOST"))
|
||||
.unwrap_or_else(|| panic!("no current host in {status}"))
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
/// The way atuin names this machine to another: `@` and the end of its host id.
|
||||
pub fn short_host(&self) -> String {
|
||||
let id = self.host_id();
|
||||
format!("@{}", &id[id.len() - 8..])
|
||||
}
|
||||
|
||||
/// Atuin's data directory here.
|
||||
pub fn data_dir(&self) -> PathBuf {
|
||||
self.env.data_dir()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Machine {
|
||||
fn drop(&mut self) {
|
||||
if self.daemon.is_some() {
|
||||
let _ = Process::spawn(self.env.atuin(&["daemon", "stop"])).try_wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The text of each message of `show` (`atuin ai session show --style json`) that says something
|
||||
/// in conversation: a prompt, or a reply.
|
||||
pub fn said_in(show: &Value) -> Vec<String> {
|
||||
show["messages"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|m| m["role"] == "user" || m["role"] == "assistant")
|
||||
.flat_map(|m| m["content"].as_array().unwrap().iter())
|
||||
.filter(|c| c["type"] == "text")
|
||||
.map(|c| c["text"].as_str().unwrap().to_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One tool call of a session, as `atuin ai session show --style json` gives it.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Call {
|
||||
pub name: String,
|
||||
/// What it was given, as JSON text; empty when capture kept none.
|
||||
pub input: String,
|
||||
/// What it returned; empty when capture kept none.
|
||||
pub output: String,
|
||||
}
|
||||
|
||||
/// Every tool call of `show`, in order, each with what it returned.
|
||||
pub fn calls_in(show: &Value) -> Vec<Call> {
|
||||
let content: Vec<&Value> = show["messages"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.flat_map(|m| m["content"].as_array().unwrap())
|
||||
.collect();
|
||||
let text = |value: &Value| value.as_str().unwrap_or_default().to_owned();
|
||||
content
|
||||
.iter()
|
||||
.filter(|c| c["type"] == "tool_call")
|
||||
.map(|call| Call {
|
||||
name: text(&call["name"]),
|
||||
input: text(&call["input"]),
|
||||
output: content
|
||||
.iter()
|
||||
.find(|c| c["type"] == "tool_result" && c["tool_use_id"] == call["id"])
|
||||
.map(|result| text(&result["content"]))
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether `haystack` holds each of `needles`, in order.
|
||||
pub fn holds_in_order(haystack: &str, needles: &[String]) -> bool {
|
||||
let mut rest = haystack;
|
||||
needles.iter().all(|needle| match rest.find(needle.as_str()) {
|
||||
Some(at) => {
|
||||
rest = &rest[at + needle.len()..];
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// `output`'s stdout and stderr, asserting it succeeded.
|
||||
pub fn succeeded(output: &Output) -> (String, String) {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
|
||||
assert!(output.status.success(), "failed:\nstdout: {stdout}\nstderr: {stderr}");
|
||||
(stdout, stderr)
|
||||
}
|
||||
|
||||
/// `output`'s stderr, asserting it failed.
|
||||
pub fn failed(output: &Output) -> String {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"succeeded:\nstdout: {}\nstderr: {stderr}",
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
);
|
||||
stderr
|
||||
}
|
||||
@@ -36,6 +36,32 @@ pub struct Shell {
|
||||
|
||||
impl Shell {
|
||||
pub fn start(path: &Path, settings: Option<&str>) -> Option<Self> {
|
||||
let setup = Setup::find(path)?;
|
||||
let env = FreshEnv::new();
|
||||
if let Some(settings) = settings {
|
||||
env.write_config(settings);
|
||||
}
|
||||
// Finish migrations before background shell hooks can open the databases.
|
||||
env.run(&["store", "status"]);
|
||||
let (pty, config) = setup.spawn(&env, BTreeMap::new(), "");
|
||||
Some(Self { pty, env, config })
|
||||
}
|
||||
}
|
||||
|
||||
/// A shell setup whose shell, and every file it needs, is installed.
|
||||
pub struct Setup {
|
||||
path: PathBuf,
|
||||
config: ShellConfig,
|
||||
executable: PathBuf,
|
||||
/// Where each file it needs is.
|
||||
files: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl Setup {
|
||||
/// The shell setup at `path`; `None` (or a failure, where they are required) when its shell
|
||||
/// or a file it needs isn't installed. Before anything is set up for it, so a setup skipped
|
||||
/// costs nothing.
|
||||
pub fn find(path: &Path) -> Option<Self> {
|
||||
let config: ShellConfig = toml_edit::de::from_str(&fs::read_to_string(path).unwrap())
|
||||
.unwrap_or_else(|e| panic!("{}: {e}", path.display()));
|
||||
let override_var = format!("ATUIN_E2E_{}", config.shell.to_uppercase());
|
||||
@@ -52,7 +78,7 @@ impl Shell {
|
||||
missing(path, &config.shell);
|
||||
return None;
|
||||
};
|
||||
let mut vars = BTreeMap::new();
|
||||
let mut files = BTreeMap::new();
|
||||
for (name, default) in &config.required_files {
|
||||
let value = std::env::var(name).unwrap_or_else(|_| {
|
||||
default.replace("$HOME", &std::env::var("HOME").unwrap_or_default())
|
||||
@@ -61,20 +87,44 @@ impl Shell {
|
||||
missing(path, &format!("{name}={value}"));
|
||||
return None;
|
||||
}
|
||||
vars.insert(name.clone(), value);
|
||||
files.insert(name.clone(), value);
|
||||
}
|
||||
let env = FreshEnv::new();
|
||||
if let Some(settings) = settings {
|
||||
env.write_config(settings);
|
||||
Some(Self {
|
||||
path: path.to_owned(),
|
||||
config,
|
||||
executable,
|
||||
files,
|
||||
})
|
||||
}
|
||||
|
||||
/// The shell started in `env`, with `vars` set (over the files it needs, where they name the
|
||||
/// same) and `init_args` passed to `atuin init`, at its prompt.
|
||||
pub fn spawn(
|
||||
self,
|
||||
env: &FreshEnv,
|
||||
mut vars: BTreeMap<String, String>,
|
||||
init_args: &str,
|
||||
) -> (PtyShell, ShellConfig) {
|
||||
let Self {
|
||||
path,
|
||||
mut config,
|
||||
executable,
|
||||
files,
|
||||
} = self;
|
||||
for (name, value) in files {
|
||||
vars.entry(name).or_insert(value);
|
||||
}
|
||||
if !init_args.is_empty() {
|
||||
let init = format!("atuin init {}", config.shell);
|
||||
assert!(config.script.contains(&init), "{}: no `{init}`", path.display());
|
||||
config.script = config.script.replace(&init, &format!("{init} {init_args}"));
|
||||
}
|
||||
// Finish migrations before background shell hooks can open the databases.
|
||||
env.run(&["store", "status"]);
|
||||
let rc = env.home().join(&config.rc);
|
||||
fs::create_dir_all(rc.parent().unwrap()).unwrap();
|
||||
fs::write(rc, &config.script).unwrap();
|
||||
let pty = PtyShell::spawn(&executable, &config.args, &env, &vars);
|
||||
let pty = PtyShell::spawn(&executable, &config.args, env, &vars);
|
||||
pty.wait_for_prompt();
|
||||
Some(Self { pty, env, config })
|
||||
(pty, config)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//! `atuin ai resume`'s picker on a terminal: choosing a session, and where to resume it, runs the
|
||||
//! agent on it -- straight from the picker, and through each shell's widget, with and without
|
||||
//! `enter_accept`.
|
||||
|
||||
#![cfg(all(unix, feature = "daemon", feature = "ai"))]
|
||||
|
||||
mod common;
|
||||
|
||||
#[allow(dead_code, reason = "shared with the other AI session tests")]
|
||||
#[path = "common/agents.rs"]
|
||||
mod agents;
|
||||
#[allow(dead_code, reason = "shared with the other AI session tests")]
|
||||
#[path = "common/ai.rs"]
|
||||
mod ai;
|
||||
#[allow(dead_code, reason = "shared with the other e2e test binaries")]
|
||||
#[path = "common/pty.rs"]
|
||||
mod pty;
|
||||
#[allow(dead_code, reason = "shared with the other e2e test binaries")]
|
||||
#[path = "common/shell.rs"]
|
||||
mod shell;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use agents::{Agent, Transcript, Turn, ran, said};
|
||||
use ai::{Machine, shared_dir};
|
||||
use pty::PtyShell;
|
||||
use rstest::rstest;
|
||||
|
||||
/// A machine with two Claude Code sessions captured, both run in `project`: the first is the one
|
||||
/// to pick.
|
||||
fn two_sessions(machine: &mut Machine, project: &Path) -> (Transcript, Turn) {
|
||||
machine.start_daemon();
|
||||
let (wanted, other) = (Turn::new(), Turn::new());
|
||||
let other_session =
|
||||
Transcript::create(machine.home(), Agent::Claude, project, std::slice::from_ref(&other));
|
||||
let session =
|
||||
Transcript::create(machine.home(), Agent::Claude, project, std::slice::from_ref(&wanted));
|
||||
for (session, turn) in [(&session, &wanted), (&other_session, &other)] {
|
||||
machine.wait_until_said(&session.id, &said(std::slice::from_ref(turn)));
|
||||
}
|
||||
(session, wanted)
|
||||
}
|
||||
|
||||
/// Search the open picker for `turn`'s session by a word of its prompt, wait until it is the
|
||||
/// one shown, and accept it: the chooser opens on where to resume it.
|
||||
fn pick(pty: &PtyShell, turn: &Turn) {
|
||||
let word = turn.prompt.split(' ').next_back().unwrap();
|
||||
pty.wait_for("2 sessions");
|
||||
pty.send_str(word);
|
||||
pty.wait_for_screen("only the session searched for", |s| {
|
||||
s.contains(" 1 session") && s.contains(word)
|
||||
});
|
||||
pty.send_enter();
|
||||
pty.wait_for("Resume in");
|
||||
}
|
||||
|
||||
/// The digit of the chooser's line for continuing in `agent`, once it is drawn.
|
||||
fn line_for(pty: &PtyShell, agent: Agent) -> char {
|
||||
let is_line = |l: &str| l.contains(agent.label()) && l.contains("continue");
|
||||
let screen =
|
||||
pty.wait_for_screen(&format!("the line for {agent:?}"), |s| s.lines().any(is_line));
|
||||
let line = screen.lines().find(|l| is_line(l)).unwrap();
|
||||
line.chars().find(char::is_ascii_digit).unwrap()
|
||||
}
|
||||
|
||||
/// With `enter_accept`, the picker runs the agent in place of itself: on the session chosen, in
|
||||
/// its own agent, where it ran, or -- chosen by its digit in the chooser -- on a new session
|
||||
/// continuing it in another.
|
||||
#[rstest]
|
||||
#[case::its_own_agent(None)]
|
||||
#[case::another_agent(Some(Agent::Codex))]
|
||||
fn the_picker_resumes_the_session_chosen(#[case] into: Option<Agent>) {
|
||||
let project = shared_dir();
|
||||
let mut machine = Machine::with_config("enter_accept = true\n", "capture_sessions = true\n");
|
||||
let (session, turn) = two_sessions(&mut machine, project.path());
|
||||
|
||||
let vars = BTreeMap::from([("PATH".to_owned(), machine.path_var())]);
|
||||
let pty = PtyShell::spawn(
|
||||
Path::new(env!("CARGO_BIN_EXE_atuin")),
|
||||
&["ai".to_owned(), "resume".to_owned()],
|
||||
&machine.env,
|
||||
&vars,
|
||||
);
|
||||
pick(&pty, &turn);
|
||||
|
||||
let Some(into) = into else {
|
||||
pty.send_enter();
|
||||
pty.wait_for(&ran(Agent::Claude, &format!("--resume {}", session.id), project.path()));
|
||||
return;
|
||||
};
|
||||
pty.send(line_for(&pty, into).to_string().as_bytes());
|
||||
let continued = machine.wait_for_child(into, &session.id);
|
||||
assert_eq!(continued["parent_kind"], "continuation");
|
||||
let id = continued["session_id"].as_str().unwrap();
|
||||
pty.wait_for(&ran(into, &format!("resume {id}"), project.path()));
|
||||
}
|
||||
|
||||
/// Through the shell widget, the session chosen resumes in the shell: run at once with
|
||||
/// `enter_accept`, else left on the command line to run.
|
||||
#[rstest]
|
||||
fn the_shell_widget_resumes_the_session_chosen(
|
||||
#[files("tests/shells/*.toml")] setup: PathBuf,
|
||||
#[values(false, true)] enter_accept: bool,
|
||||
) {
|
||||
let Some(setup) = shell::Setup::find(&setup) else {
|
||||
return;
|
||||
};
|
||||
let project = shared_dir();
|
||||
let top = format!("enter_accept = {enter_accept}\n");
|
||||
let mut machine = Machine::with_config(&top, "capture_sessions = true\n");
|
||||
let (session, turn) = two_sessions(&mut machine, project.path());
|
||||
|
||||
let vars = BTreeMap::from([("PATH".to_owned(), machine.path_var())]);
|
||||
let (pty, _) = setup.spawn(&machine.env, vars, "--bind-ai-resume");
|
||||
// ctrl-]
|
||||
pty.send(b"\x1d");
|
||||
pick(&pty, &turn);
|
||||
pty.send_enter();
|
||||
|
||||
let command = format!("claude --resume {}", session.id);
|
||||
if !enter_accept {
|
||||
pty.wait_for_screen("the command on the command line", |s| {
|
||||
!s.contains("Resume in")
|
||||
&& s.lines().any(|l| l.contains(shell::PROMPT) && l.contains(&command))
|
||||
});
|
||||
assert!(!pty.screen().contains("AGENT-RAN"), "it ran before it was accepted");
|
||||
pty.send_enter();
|
||||
}
|
||||
pty.wait_for_line(&ran(Agent::Claude, &format!("--resume {}", session.id), project.path()));
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
//! AI coding-agent sessions, end to end: captured from each agent's own transcripts by the daemon,
|
||||
//! synced between machines, and resumed with `atuin ai resume` -- restored, caught up, forked,
|
||||
//! switched or continued in another agent -- by the real binary, against transcripts written the
|
||||
//! way each agent writes them.
|
||||
//!
|
||||
//! Each test checks something a user relies on that no single crate can see whole: that what an
|
||||
//! agent wrote is what atuin shows and syncs, minus what it must never keep; that a session
|
||||
//! written back out reads as the same session to the agent and to atuin; and that what `resume`
|
||||
//! runs is the agent, on that session, where it ran.
|
||||
|
||||
#![cfg(all(unix, feature = "daemon", feature = "ai", feature = "sync"))]
|
||||
|
||||
mod common;
|
||||
|
||||
#[allow(dead_code, reason = "shared with the PTY tests")]
|
||||
#[path = "common/agents.rs"]
|
||||
mod agents;
|
||||
#[allow(dead_code, reason = "shared with the PTY tests")]
|
||||
#[path = "common/ai.rs"]
|
||||
mod ai;
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use agents::{Agent, Transcript, Turn, said, whole, word};
|
||||
use ai::{Machine, SyncServer, shared_dir, succeeded};
|
||||
use atuin_common::harnesstools::resume::quote;
|
||||
use atuin_common::secrets::REDACTED;
|
||||
use regex::Regex;
|
||||
use rstest::{fixture, rstest};
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// A machine on its own, its daemon capturing.
|
||||
#[fixture]
|
||||
fn machine() -> Machine {
|
||||
let mut machine = Machine::new();
|
||||
machine.start_daemon();
|
||||
machine
|
||||
}
|
||||
|
||||
/// Where the sessions under test ran.
|
||||
#[fixture]
|
||||
fn project() -> TempDir {
|
||||
shared_dir()
|
||||
}
|
||||
|
||||
/// Whether `query` finds session `id` in `machine`'s search.
|
||||
fn finds(machine: &Machine, query: &str, id: &str) -> bool {
|
||||
let found = machine.run(&["ai", "session", "search", query, "--style", "json"]);
|
||||
let found: serde_json::Value = serde_json::from_str(&found).unwrap();
|
||||
found.as_array().unwrap().iter().any(|m| m["session"]["session_id"] == id)
|
||||
}
|
||||
|
||||
/// The last word of `text`: the one [`agents::word`] made, found nowhere else.
|
||||
fn last_word(text: &str) -> &str {
|
||||
text.split(' ').next_back().unwrap()
|
||||
}
|
||||
|
||||
/// An agent's transcript is captured whole: listed with where it ran, its conversation in order,
|
||||
/// and each tool call with what it was given and what it returned -- or, with `capture_tools`
|
||||
/// off, the tool's name alone. Reasoning is kept only as a marker, a secret anywhere is redacted,
|
||||
/// and search finds whole words of what was said and what a call was given, never what it
|
||||
/// returned.
|
||||
#[rstest]
|
||||
fn capture_keeps_the_conversation_and_tool_calls(
|
||||
project: TempDir,
|
||||
#[values(Agent::Claude, Agent::Codex, Agent::Opencode, Agent::Pi)] agent: Agent,
|
||||
#[values(true, false)] capture_tools: bool,
|
||||
) {
|
||||
let ai = format!("capture_sessions = true\ncapture_tools = {capture_tools}\n");
|
||||
let mut machine = Machine::with_config("", &ai);
|
||||
machine.start_daemon();
|
||||
let secret = format!("ghp_{}{}", &word()[1..], "abcd");
|
||||
let mut turns = vec![Turn::new(), Turn::new()];
|
||||
turns[1].prompt = format!("use the token {secret} for {}", word());
|
||||
turns[1].output = format!("token {secret} for {}", word());
|
||||
let transcript = Transcript::create(machine.home(), agent, project.path(), &turns);
|
||||
|
||||
let mut want = said(&turns);
|
||||
want[2] = want[2].replace(&secret, REDACTED);
|
||||
machine.wait_until_said(&transcript.id, &want);
|
||||
|
||||
let session = machine.session(&transcript.id).unwrap();
|
||||
assert_eq!(session["harness"], agent.harness());
|
||||
assert_eq!(session["cwd"], project.path().to_str().unwrap());
|
||||
|
||||
let show = machine.show(&transcript.id);
|
||||
let calls = ai::calls_in(&show);
|
||||
assert_eq!(calls.len(), turns.len(), "one call a turn: {calls:#?}");
|
||||
for (call, turn) in calls.iter().zip(&turns) {
|
||||
assert_eq!(call.name, agent.tool());
|
||||
let output = turn.output.replace(&secret, REDACTED);
|
||||
if capture_tools {
|
||||
assert!(call.input.contains(&turn.command), "{call:#?}");
|
||||
assert!(call.output.contains(&output), "{call:#?}");
|
||||
} else {
|
||||
assert_eq!((call.input.as_str(), call.output.as_str()), ("", ""), "{call:#?}");
|
||||
}
|
||||
}
|
||||
let shown = show.to_string();
|
||||
for hidden in [&turns[0].thinking, &turns[1].thinking, &secret] {
|
||||
assert!(!shown.contains(hidden.as_str()), "{hidden:?} was captured: {shown}");
|
||||
}
|
||||
|
||||
let id = &transcript.id;
|
||||
for said in [&turns[0].prompt, &turns[1].reply] {
|
||||
assert!(finds(&machine, last_word(said), id), "{said:?} didn't find the session");
|
||||
}
|
||||
// Words match whole; only the picker, searching as you type, matches a prefix.
|
||||
let prefix = &last_word(&turns[0].prompt)[..20];
|
||||
assert!(!finds(&machine, prefix, id), "{prefix:?} found the session");
|
||||
let command = last_word(&turns[0].command);
|
||||
assert_eq!(finds(&machine, command, id), capture_tools, "search for the command");
|
||||
for hidden in [&turns[0].thinking, &turns[0].output, &secret] {
|
||||
assert!(!finds(&machine, last_word(hidden), id), "{hidden:?} found the session");
|
||||
}
|
||||
}
|
||||
|
||||
/// The history and output filters apply to the commands an agent runs as to the ones typed: one
|
||||
/// history would leave out is kept by its tool's name alone, and one whose output the output
|
||||
/// capture would leave out keeps what it ran, not what it printed.
|
||||
#[rstest]
|
||||
fn capture_applies_the_history_and_output_filters_to_agent_commands(project: TempDir) {
|
||||
let top =
|
||||
"history_filter = [\"^echo dropped-\"]\n[output]\ncommand_filter = [\"^echo quiet-\"]\n";
|
||||
let mut machine = Machine::with_config(top, "capture_sessions = true\n");
|
||||
machine.start_daemon();
|
||||
let mut turns = vec![Turn::new(), Turn::new(), Turn::new()];
|
||||
turns[0].command = format!("echo dropped-{}", word());
|
||||
turns[1].command = format!("echo quiet-{}", word());
|
||||
let transcript = Transcript::create(machine.home(), Agent::Claude, project.path(), &turns);
|
||||
machine.wait_until_said(&transcript.id, &said(&turns));
|
||||
|
||||
let calls = ai::calls_in(&machine.show(&transcript.id));
|
||||
let kept: Vec<(bool, bool)> = calls
|
||||
.iter()
|
||||
.zip(&turns)
|
||||
.map(|(call, turn)| {
|
||||
(call.input.contains(&turn.command), call.output.contains(&turn.output))
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(kept, [(false, false), (true, false), (true, true)], "{calls:#?}");
|
||||
assert!(calls.iter().all(|call| call.name == Agent::Claude.tool()), "{calls:#?}");
|
||||
}
|
||||
|
||||
/// A transcript the agent goes on writing is followed, and one it wrote to while the daemon was
|
||||
/// down is picked up where it was left: either way, every turn is in the session exactly once.
|
||||
#[rstest]
|
||||
fn capture_follows_a_transcript_exactly_once_across_restarts(
|
||||
mut machine: Machine,
|
||||
project: TempDir,
|
||||
#[values(Agent::Claude, Agent::Codex, Agent::Opencode, Agent::Pi)] agent: Agent,
|
||||
) {
|
||||
let turns: Vec<Turn> = (0..3).map(|_| Turn::new()).collect();
|
||||
|
||||
let transcript = Transcript::create(machine.home(), agent, project.path(), &turns[..1]);
|
||||
machine.wait_until_said(&transcript.id, &said(&turns[..1]));
|
||||
|
||||
transcript.append(project.path(), &turns[1..2]);
|
||||
machine.wait_until_said(&transcript.id, &said(&turns[..2]));
|
||||
|
||||
machine.stop_daemon();
|
||||
transcript.append(project.path(), &turns[2..]);
|
||||
machine.start_daemon();
|
||||
machine.wait_until_said(&transcript.id, &said(&turns));
|
||||
}
|
||||
|
||||
/// Turning capture on in the config takes effect in the running daemon, which then captures what
|
||||
/// the agents wrote while it was off.
|
||||
#[rstest]
|
||||
fn capture_starts_when_the_config_turns_it_on(project: TempDir) {
|
||||
let mut machine = Machine::with_config("", "capture_sessions = false\n");
|
||||
machine.start_daemon();
|
||||
let turns = [Turn::new()];
|
||||
let transcript = Transcript::create(machine.home(), Agent::Claude, project.path(), &turns);
|
||||
// Longer than the daemon takes to see a new transcript when capturing.
|
||||
std::thread::sleep(std::time::Duration::from_secs(3));
|
||||
assert_eq!(machine.sessions(), Vec::<serde_json::Value>::new(), "captured while off");
|
||||
|
||||
let config = machine.home().join(".config/atuin/config.toml");
|
||||
let on = std::fs::read_to_string(&config)
|
||||
.unwrap()
|
||||
.replace("capture_sessions = false", "capture_sessions = true");
|
||||
std::fs::write(&config, on).unwrap();
|
||||
machine.wait_until_said(&transcript.id, &said(&turns));
|
||||
}
|
||||
|
||||
/// The command `atuin ai resume --print` prints to resume `transcript` in its own agent, from
|
||||
/// `cwd`.
|
||||
fn resume_line(transcript: &Transcript, cwd: &Path) -> String {
|
||||
let id = transcript.id.as_str();
|
||||
let args = match transcript.agent {
|
||||
Agent::Claude => vec!["--resume", id],
|
||||
Agent::Codex => vec!["resume", id],
|
||||
Agent::Opencode => vec!["--session", id],
|
||||
Agent::Pi => vec!["--session", transcript.path.to_str().unwrap()],
|
||||
};
|
||||
let mut line =
|
||||
format!("cd -- {} && {}", quote(cwd.to_str().unwrap()), transcript.agent.program());
|
||||
for arg in args {
|
||||
line.push(' ');
|
||||
line.push_str("e(arg));
|
||||
}
|
||||
line
|
||||
}
|
||||
|
||||
/// A subagent isn't a session to resume, but the session it works for, which is listed as its
|
||||
/// parent: naming the subagent resumes that, saying so.
|
||||
#[rstest]
|
||||
fn a_subagent_resumes_as_the_session_it_works_for(machine: Machine, project: TempDir) {
|
||||
let turns = [Turn::new()];
|
||||
let parent = Transcript::create(machine.home(), Agent::Claude, project.path(), &turns);
|
||||
let subagent = Transcript::claude_subagent(&parent, project.path(), &[Turn::new()]);
|
||||
machine.wait_until_said(&parent.id, &said(&turns));
|
||||
let session = machine.wait_for_child(Agent::Claude, &parent.id);
|
||||
assert_eq!(session["session_id"], subagent.id.as_str());
|
||||
assert_eq!(session["parent_kind"], "subagent");
|
||||
|
||||
let (stdout, stderr) = succeeded(&machine.resume(&[&subagent.id, "--print"], machine.home()));
|
||||
assert_eq!(stdout.trim(), resume_line(&parent, project.path()));
|
||||
assert!(stderr.contains(&format!("{} is a subagent", subagent.id)), "{stderr}");
|
||||
}
|
||||
|
||||
/// A session whose transcript is on this machine resumes in its agent where it ran, named by the
|
||||
/// agent's id, by its atuin id, or by enough of that, and is left as it was. How a name is
|
||||
/// resolved is the same for every agent; where its transcript is, and how it resumes, isn't.
|
||||
#[rstest]
|
||||
#[case::claude(Agent::Claude, "agent id")]
|
||||
#[case::codex(Agent::Codex, "agent id")]
|
||||
#[case::opencode(Agent::Opencode, "agent id")]
|
||||
#[case::pi(Agent::Pi, "agent id")]
|
||||
#[case::by_atuin_id(Agent::Opencode, "atuin id")]
|
||||
#[case::by_atuin_id_prefix(Agent::Opencode, "atuin id prefix")]
|
||||
fn a_local_session_resumes_in_its_agent_where_it_ran(
|
||||
machine: Machine,
|
||||
project: TempDir,
|
||||
#[case] agent: Agent,
|
||||
#[case] named_by: &str,
|
||||
) {
|
||||
let turns = [Turn::new()];
|
||||
let transcript = Transcript::create(machine.home(), agent, project.path(), &turns);
|
||||
machine.wait_until_said(&transcript.id, &said(&turns));
|
||||
let before = std::fs::read(&transcript.path).unwrap();
|
||||
|
||||
let atuin_id =
|
||||
machine.session(&transcript.id).unwrap()["atuin_id"].as_str().unwrap().to_owned();
|
||||
let name = match named_by {
|
||||
"agent id" => transcript.id.clone(),
|
||||
"atuin id" => atuin_id,
|
||||
// Past the timestamp the id starts with, which sessions started together share.
|
||||
_ => atuin_id[..16].to_owned(),
|
||||
};
|
||||
let (stdout, stderr) = succeeded(&machine.resume(&[&name, "--print"], machine.home()));
|
||||
assert_eq!(stdout.trim(), resume_line(&transcript, project.path()), "{stderr}");
|
||||
assert_eq!(std::fs::read(&transcript.path).unwrap(), before, "the transcript changed");
|
||||
}
|
||||
|
||||
/// Two machines of one account, and a session captured on the first, on the second written back
|
||||
/// out from sync by resuming it there: what the second's agent would open.
|
||||
struct Restored {
|
||||
_server: SyncServer,
|
||||
project: TempDir,
|
||||
here: Machine,
|
||||
there: Machine,
|
||||
turns: Vec<Turn>,
|
||||
/// The session on the machine it was captured on.
|
||||
original: Transcript,
|
||||
/// Its transcript written back out on the other.
|
||||
restored: Transcript,
|
||||
}
|
||||
|
||||
fn restored(agent: Agent) -> Restored {
|
||||
let server = SyncServer::start();
|
||||
let project = project();
|
||||
let (there, here) = Machine::pair(&server);
|
||||
let turns = vec![Turn::new(), Turn::new()];
|
||||
let original = Transcript::create(there.home(), agent, project.path(), &turns);
|
||||
there.wait_until_said(&original.id, &said(&turns));
|
||||
here.wait_until_said(&original.id, &said(&turns));
|
||||
assert!(Transcript::find(here.home(), agent, &original.id).is_none());
|
||||
|
||||
let (stdout, stderr) = succeeded(&here.resume(&[&original.id, "--print"], here.home()));
|
||||
let restored = Transcript::find(here.home(), agent, &original.id)
|
||||
.unwrap_or_else(|| panic!("nothing restored: {stdout} {stderr}"));
|
||||
assert_eq!(stdout.trim(), resume_line(&restored, project.path()), "{stderr}");
|
||||
Restored {
|
||||
_server: server,
|
||||
project,
|
||||
here,
|
||||
there,
|
||||
turns,
|
||||
original,
|
||||
restored,
|
||||
}
|
||||
}
|
||||
|
||||
/// A session captured on one machine is listed on another, under the same atuin id, and resuming
|
||||
/// it there writes it back out as its agent's own transcript: the conversation and its tool calls
|
||||
/// as they were, without the reasoning sync never held. The agent going on with it
|
||||
/// there carries on the same session -- nothing in it read twice -- which syncs back.
|
||||
#[rstest]
|
||||
fn a_session_from_another_machine_restores_as_the_same_session(
|
||||
#[values(Agent::Claude, Agent::Codex, Agent::Pi)] agent: Agent,
|
||||
) {
|
||||
let Restored {
|
||||
_server,
|
||||
project,
|
||||
here,
|
||||
there,
|
||||
turns,
|
||||
original,
|
||||
restored,
|
||||
} = restored(agent);
|
||||
assert_eq!(
|
||||
here.session(&original.id).unwrap()["atuin_id"],
|
||||
there.session(&original.id).unwrap()["atuin_id"]
|
||||
);
|
||||
|
||||
let text = restored.read();
|
||||
assert!(ai::holds_in_order(&text, &whole(&turns)), "the session isn't all there:\n{text}");
|
||||
assert!(!text.contains("[ran a shell command]"), "a tool call became a note:\n{text}");
|
||||
for turn in &turns {
|
||||
assert!(!text.contains(&turn.thinking), "the reasoning was restored:\n{text}");
|
||||
}
|
||||
let said = said(&turns);
|
||||
|
||||
let more = vec![Turn::new()];
|
||||
restored.append(project.path(), &more);
|
||||
let all = [said, agents::said(&more)].concat();
|
||||
here.wait_until_said(&original.id, &all);
|
||||
there.wait_until_said(&original.id, &all);
|
||||
}
|
||||
|
||||
/// A copy behind the session's other machine catches up with it as it resumes, saying so, and
|
||||
/// once caught up resumes as it is.
|
||||
#[rstest]
|
||||
fn a_copy_behind_another_machine_catches_up_as_it_resumes(
|
||||
#[values(Agent::Claude, Agent::Codex, Agent::Pi)] agent: Agent,
|
||||
) {
|
||||
let Restored {
|
||||
_server,
|
||||
project,
|
||||
here,
|
||||
there,
|
||||
mut turns,
|
||||
original,
|
||||
restored,
|
||||
} = restored(agent);
|
||||
turns.push(Turn::new());
|
||||
original.append(project.path(), &turns[2..]);
|
||||
here.wait_until_said(&original.id, &said(&turns));
|
||||
|
||||
let (stdout, stderr) = succeeded(&here.resume(&[&original.id, "--print"], here.home()));
|
||||
assert_eq!(stdout.trim(), resume_line(&restored, project.path()));
|
||||
let caught_up =
|
||||
Regex::new(&format!(r"^atuin: caught up: \d+ messages? from {}\n$", there.short_host()));
|
||||
assert!(caught_up.unwrap().is_match(&stderr), "{stderr}");
|
||||
let text = restored.read();
|
||||
assert!(ai::holds_in_order(&text, &said(&turns)), "not caught up:\n{text}");
|
||||
|
||||
let (_, stderr) = succeeded(&here.resume(&[&original.id, "--print"], here.home()));
|
||||
assert_eq!(stderr, "", "caught up twice");
|
||||
assert_eq!(restored.read(), text);
|
||||
}
|
||||
|
||||
/// Where a copy and the session's other machine went different ways, resuming it is a choice:
|
||||
/// it fails saying so, and naming the flags that choose. `--as-is` resumes the copy untouched,
|
||||
/// `--fork` forks the newest branch into a new session, and `--switch` puts the copy on the
|
||||
/// other machine's branch, keeping it whole as a backup.
|
||||
#[rstest]
|
||||
fn a_diverged_copy_resumes_only_as_chosen(
|
||||
#[values(Agent::Claude, Agent::Codex, Agent::Pi)] agent: Agent,
|
||||
) {
|
||||
let Restored {
|
||||
_server,
|
||||
project,
|
||||
here,
|
||||
there,
|
||||
turns,
|
||||
original,
|
||||
restored,
|
||||
} = restored(agent);
|
||||
// Theirs is the newest, which a copy elsewhere would catch up to.
|
||||
let (ours, theirs) = (Turn::new(), Turn::new());
|
||||
restored.append(project.path(), std::slice::from_ref(&ours));
|
||||
original.append(project.path(), std::slice::from_ref(&theirs));
|
||||
ai::wait("both branches", || {
|
||||
let show = here.show(&original.id).to_string();
|
||||
show.contains(&theirs.reply) && show.contains(&ours.reply)
|
||||
});
|
||||
let copy = restored.read();
|
||||
let resume =
|
||||
|flags: &[&str]| here.resume(&[&[original.id.as_str()][..], flags].concat(), here.home());
|
||||
|
||||
let stderr = ai::failed(&resume(&["--print"]));
|
||||
assert!(
|
||||
stderr.contains(&format!("this copy went another way than {}'s", there.short_host())),
|
||||
"{stderr}"
|
||||
);
|
||||
for flag in ["--as-is", "--fork", "--switch"] {
|
||||
assert!(stderr.contains(flag), "{flag} not offered: {stderr}");
|
||||
}
|
||||
assert_eq!(restored.read(), copy);
|
||||
|
||||
let (stdout, _) = succeeded(&resume(&["--as-is", "--print"]));
|
||||
assert_eq!(stdout.trim(), resume_line(&restored, project.path()));
|
||||
assert_eq!(restored.read(), copy);
|
||||
|
||||
let (_, stderr) = succeeded(&resume(&["--fork", "--print"]));
|
||||
assert!(stderr.contains(&format!("forked into a new {} session", agent.label())), "{stderr}");
|
||||
let fork = here.wait_for_child(agent, &original.id);
|
||||
assert_eq!(fork["parent_kind"], "fork");
|
||||
let fork_id = fork["session_id"].as_str().unwrap();
|
||||
let newest = [said(&turns), said(std::slice::from_ref(&theirs))].concat();
|
||||
here.wait_until_holds(fork_id, &newest);
|
||||
assert_eq!(restored.read(), copy);
|
||||
|
||||
let (_, stderr) = succeeded(&resume(&["--switch", "--print"]));
|
||||
let backup = stderr
|
||||
.split("(your copy is at ")
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split(')').next())
|
||||
.unwrap_or_else(|| panic!("no backup named: {stderr}"));
|
||||
assert!(
|
||||
stderr.starts_with(&format!("atuin: switched to {}'s branch: ", there.short_host())),
|
||||
"{stderr}"
|
||||
);
|
||||
assert!(
|
||||
Path::new(backup).starts_with(here.data_dir().join("ai/switched").join(agent.harness())),
|
||||
"{backup}"
|
||||
);
|
||||
assert_eq!(std::fs::read_to_string(backup).unwrap(), copy, "the backup isn't the copy");
|
||||
let text = restored.read();
|
||||
let theirs_now = [said(&turns), said(std::slice::from_ref(&theirs))].concat();
|
||||
assert!(ai::holds_in_order(&text, &theirs_now), "not switched:\n{text}");
|
||||
assert!(!text.contains(&ours.reply), "our branch is still in it:\n{text}");
|
||||
}
|
||||
|
||||
/// Forking writes the session out as a new one of its agent, with the whole conversation, linked
|
||||
/// to it as a fork, and resumes that; the original is left as it was.
|
||||
#[rstest]
|
||||
fn forking_a_session_leaves_it_and_links_the_fork(
|
||||
machine: Machine,
|
||||
project: TempDir,
|
||||
#[values(Agent::Claude, Agent::Codex, Agent::Pi)] agent: Agent,
|
||||
) {
|
||||
let turns = vec![Turn::new(), Turn::new()];
|
||||
let original = Transcript::create(machine.home(), agent, project.path(), &turns);
|
||||
machine.wait_until_said(&original.id, &said(&turns));
|
||||
let before = original.read();
|
||||
|
||||
let (stdout, stderr) =
|
||||
succeeded(&machine.resume(&[&original.id, "--fork", "--print"], machine.home()));
|
||||
assert_eq!(stderr.trim(), format!("atuin: forked into a new {} session", agent.label()));
|
||||
let fork = machine.wait_for_child(agent, &original.id);
|
||||
assert_eq!(fork["parent_kind"], "fork");
|
||||
let fork =
|
||||
Transcript::find(machine.home(), agent, fork["session_id"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(stdout.trim(), resume_line(&fork, project.path()));
|
||||
machine.wait_until_holds(&fork.id, &said(&turns));
|
||||
assert_eq!(original.read(), before, "the original changed");
|
||||
}
|
||||
|
||||
/// Continuing a session in another agent writes it out as a new session of that agent, which
|
||||
/// carries the conversation on, each shell command now that agent's own shell tool with what it
|
||||
/// printed, and only the reasoning left out, as the status line says. It is linked to the
|
||||
/// original as its continuation, and resumed.
|
||||
#[rstest]
|
||||
#[case::claude_to_codex(Agent::Claude, Agent::Codex)]
|
||||
#[case::claude_to_pi(Agent::Claude, Agent::Pi)]
|
||||
#[case::codex_to_claude(Agent::Codex, Agent::Claude)]
|
||||
#[case::codex_to_pi(Agent::Codex, Agent::Pi)]
|
||||
#[case::opencode_to_claude(Agent::Opencode, Agent::Claude)]
|
||||
#[case::opencode_to_codex(Agent::Opencode, Agent::Codex)]
|
||||
#[case::pi_to_claude(Agent::Pi, Agent::Claude)]
|
||||
#[case::pi_to_codex(Agent::Pi, Agent::Codex)]
|
||||
fn continuing_in_another_agent_carries_the_conversation(
|
||||
machine: Machine,
|
||||
project: TempDir,
|
||||
#[case] from: Agent,
|
||||
#[case] to: Agent,
|
||||
) {
|
||||
let turns = vec![Turn::new(), Turn::new()];
|
||||
let original = Transcript::create(machine.home(), from, project.path(), &turns);
|
||||
machine.wait_until_said(&original.id, &said(&turns));
|
||||
|
||||
let (stdout, stderr) = succeeded(
|
||||
&machine.resume(&[&original.id, "--in", to.program(), "--print"], machine.home()),
|
||||
);
|
||||
assert_eq!(stderr.trim(), format!("atuin: continuing in {}: reasoning dropped", to.label()));
|
||||
let continued = machine.wait_for_child(to, &original.id);
|
||||
assert_eq!(continued["parent_kind"], "continuation");
|
||||
assert_eq!(continued["parent"]["harness"], from.harness());
|
||||
let continued =
|
||||
Transcript::find(machine.home(), to, continued["session_id"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(stdout.trim(), resume_line(&continued, project.path()));
|
||||
|
||||
machine.wait_until_holds(&continued.id, &said(&turns));
|
||||
let calls = ai::calls_in(&machine.show(&continued.id));
|
||||
assert_eq!(calls.len(), turns.len(), "one call a turn: {calls:#?}");
|
||||
for (call, turn) in calls.iter().zip(&turns) {
|
||||
assert_eq!(call.name, to.tool(), "{call:#?}");
|
||||
assert!(call.input.contains(&turn.command), "{call:#?}");
|
||||
assert!(call.output.contains(&turn.output), "{call:#?}");
|
||||
}
|
||||
let text = continued.read();
|
||||
for turn in &turns {
|
||||
assert!(!text.contains(&turn.thinking), "the reasoning was carried over:\n{text}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The index is the record store's to rebuild: what every session says, and how sessions link,
|
||||
/// is the same after `atuin store rebuild ai-session` as before.
|
||||
#[rstest]
|
||||
fn rebuilding_the_index_reproduces_it(machine: Machine, project: TempDir) {
|
||||
let mut ids = Vec::new();
|
||||
let turns = [Turn::new()];
|
||||
for agent in Agent::ALL {
|
||||
let transcript = Transcript::create(machine.home(), agent, project.path(), &turns);
|
||||
machine.wait_until_said(&transcript.id, &said(&turns));
|
||||
ids.push(transcript.id);
|
||||
}
|
||||
// A continuation, for the link between sessions.
|
||||
succeeded(&machine.resume(&[&ids[0], "--in", "codex", "--print"], machine.home()));
|
||||
let continued = machine.wait_for_child(Agent::Codex, &ids[0]);
|
||||
ids.push(continued["session_id"].as_str().unwrap().to_owned());
|
||||
machine.wait_until_holds(&ids[4], &said(&turns));
|
||||
|
||||
let index = |machine: &Machine| -> Vec<serde_json::Value> {
|
||||
ids.iter().map(|id| machine.show(id)).collect()
|
||||
};
|
||||
let before = index(&machine);
|
||||
let out = machine.run(&["store", "rebuild", "ai-session"]);
|
||||
assert!(out.contains("rebuilding"), "{out}");
|
||||
// `ai session` commands wait for the rebuild.
|
||||
assert_eq!(index(&machine), before);
|
||||
}
|
||||
@@ -75,10 +75,10 @@ after the daemon restarts. Sessions already captured keep what they had.
|
||||
atuin ai session search "flaky test"
|
||||
```
|
||||
|
||||
- The last word of the query matches as a prefix once it's at least two
|
||||
characters long, so `atuin ai session search "migr"` finds sessions
|
||||
mentioning "migration." A single character still matches only a whole word.
|
||||
Earlier words always match whole.
|
||||
- Each word of the query matches a whole word: `atuin ai session search
|
||||
"migration"` finds sessions mentioning migration, but `"migr"` doesn't.
|
||||
The `atuin ai resume` picker searches as you type, so there the last word
|
||||
also matches as a prefix once it's at least two characters long.
|
||||
- An empty query (`atuin ai session search ""`) lists the newest sessions
|
||||
first, rather than returning nothing.
|
||||
- `--harness` limits the search to one agent.
|
||||
|
||||
Reference in New Issue
Block a user