Adds `SessionView { Terminal, Structured }` to maestro-protocol, a
defaulted `view` field on `Session` and `CreateSessionRequest`, and
sidecar round-trip tests for both a legacy payload with no `view` key
and a `Structured` session. Nothing reads the field yet; every other
struct literal across the workspace picks up `Default::default()`
(Terminal) to keep compiling.
Gitea #133.
173 lines
5.4 KiB
Rust
173 lines
5.4 KiB
Rust
//! Naming a session after its first prompt (#131): what the daemon lets a
|
|
//! naming run change, and what it protects. The naming run itself (`claude -p`)
|
|
//! is not invoked here — these cover the claim/apply rules around it.
|
|
#![cfg(feature = "haiku-autoname")]
|
|
|
|
mod common;
|
|
|
|
use std::sync::Arc;
|
|
|
|
use common::TmuxFixture;
|
|
use maestro_daemon::sidecar::SidecarStore;
|
|
use maestro_daemon::worktree::WorktreeManager;
|
|
use maestro_daemon::SessionManager;
|
|
use maestro_protocol::{display, CatalogEvent, CreateSessionRequest, NameSource};
|
|
use tempfile::TempDir;
|
|
use uuid::Uuid;
|
|
|
|
struct Fixture {
|
|
manager: SessionManager,
|
|
sidecar: SidecarStore,
|
|
_tmux: TmuxFixture,
|
|
_state_dir: TempDir,
|
|
}
|
|
|
|
fn fixture() -> Fixture {
|
|
let tmux = TmuxFixture::new();
|
|
let state_dir = TempDir::new().unwrap();
|
|
let sidecar = SidecarStore::new(state_dir.path().join("sessions"));
|
|
let manager = SessionManager::new(
|
|
Arc::new(tmux.tmux.clone()),
|
|
sidecar.clone(),
|
|
WorktreeManager::new(None),
|
|
state_dir.path().join("fifos"),
|
|
);
|
|
Fixture {
|
|
manager,
|
|
sidecar,
|
|
_tmux: tmux,
|
|
_state_dir: state_dir,
|
|
}
|
|
}
|
|
|
|
async fn spawn(manager: &SessionManager, name: Option<&str>) -> Uuid {
|
|
let metadata = match name {
|
|
Some(n) => std::collections::HashMap::from([(
|
|
"name".to_string(),
|
|
serde_json::Value::String(n.to_string()),
|
|
)]),
|
|
None => Default::default(),
|
|
};
|
|
manager
|
|
.create(CreateSessionRequest {
|
|
view: Default::default(),
|
|
cwd: "/tmp".into(),
|
|
command: "sleep 60".into(),
|
|
env: Default::default(),
|
|
metadata,
|
|
worktree: None,
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.id
|
|
}
|
|
|
|
async fn name_of(manager: &SessionManager, id: Uuid) -> Option<String> {
|
|
let snapshot = manager.snapshot().await;
|
|
let s = snapshot.into_iter().find(|s| s.id == id)?;
|
|
s.metadata
|
|
.get("name")
|
|
.and_then(|v| v.as_str())
|
|
.map(str::to_string)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_prompt_for_an_unknown_session_is_not_found() {
|
|
let fx = fixture();
|
|
let err = fx.manager.claim_autoname(Uuid::new_v4()).await.unwrap_err();
|
|
assert!(matches!(err, maestro_daemon::SessionError::NotFound(_)));
|
|
}
|
|
|
|
/// The first prompt claims the session's one naming attempt; a second finds
|
|
/// it taken, so two prompts in a row cannot start two runs.
|
|
#[tokio::test]
|
|
async fn only_the_first_prompt_claims_the_naming_attempt() {
|
|
let fx = fixture();
|
|
let id = spawn(&fx.manager, None).await;
|
|
|
|
assert!(fx.manager.claim_autoname(id).await.unwrap());
|
|
assert!(!fx.manager.claim_autoname(id).await.unwrap());
|
|
}
|
|
|
|
/// A name a person chose is never claimed, so the naming run never starts for
|
|
/// it in the first place.
|
|
#[tokio::test]
|
|
async fn a_user_named_session_is_never_claimed() {
|
|
let fx = fixture();
|
|
let id = spawn(&fx.manager, Some("amber-falcon")).await;
|
|
|
|
assert!(!fx.manager.claim_autoname(id).await.unwrap());
|
|
fx.manager.apply_autoname(id, "haiku-name").await.unwrap();
|
|
assert_eq!(
|
|
name_of(&fx.manager, id).await.as_deref(),
|
|
Some("amber-falcon"),
|
|
"a chosen name survives a naming run that reports late"
|
|
);
|
|
}
|
|
|
|
/// The generated handle is replaced, the session settles at `Haiku`, and the
|
|
/// new name reaches memory, disk and the event stream (#131).
|
|
#[tokio::test]
|
|
async fn a_generated_handle_is_replaced_and_settles() {
|
|
let fx = fixture();
|
|
let id = spawn(&fx.manager, None).await;
|
|
let mut events = fx.manager.events();
|
|
fx.manager.claim_autoname(id).await.unwrap();
|
|
|
|
fx.manager
|
|
.apply_autoname(id, "sidebar activity rail")
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
name_of(&fx.manager, id).await.as_deref(),
|
|
Some("sidebar activity rail")
|
|
);
|
|
let persisted = fx.sidecar.read(id).unwrap().unwrap();
|
|
assert_eq!(display::name_source(&persisted), NameSource::Haiku);
|
|
assert!(!display::is_provisional_name(&persisted));
|
|
|
|
let event = tokio::time::timeout(std::time::Duration::from_secs(5), events.recv())
|
|
.await
|
|
.expect("an update within the timeout")
|
|
.expect("the event channel stays open");
|
|
match event {
|
|
CatalogEvent::SessionUpdated { session } => {
|
|
assert_eq!(session.id, id);
|
|
assert_eq!(display::name_source(&session), NameSource::Haiku);
|
|
}
|
|
other => panic!("expected SessionUpdated, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// A rename that lands while the naming run is out wins: the run's answer is
|
|
/// dropped rather than overwriting what the person just typed (#131).
|
|
#[tokio::test]
|
|
async fn a_rename_during_the_run_beats_the_naming_result() {
|
|
let fx = fixture();
|
|
let id = spawn(&fx.manager, None).await;
|
|
fx.manager.claim_autoname(id).await.unwrap();
|
|
|
|
fx.manager.rename(id, "crimson-otter").await.unwrap();
|
|
fx.manager.apply_autoname(id, "haiku-name").await.unwrap();
|
|
|
|
assert_eq!(
|
|
name_of(&fx.manager, id).await.as_deref(),
|
|
Some("crimson-otter")
|
|
);
|
|
}
|
|
|
|
/// A naming run that produced nothing usable leaves the handle alone.
|
|
#[tokio::test]
|
|
async fn a_blank_name_changes_nothing() {
|
|
let fx = fixture();
|
|
let id = spawn(&fx.manager, None).await;
|
|
let before = name_of(&fx.manager, id).await;
|
|
|
|
fx.manager.apply_autoname(id, " ").await.unwrap();
|
|
|
|
assert_eq!(name_of(&fx.manager, id).await, before);
|
|
let live = fx.sidecar.read(id).unwrap().unwrap();
|
|
assert_eq!(display::name_source(&live), NameSource::Generated);
|
|
}
|