Merge branch 'main' of git.arirex.me:iris/ai-app

This commit is contained in:
iris committed 2026-08-30 14:27:31 -04:00
commit d981d63bcc
5 files changed
+549 -69

No files matched your search

+25
View File
@@ -312,6 +312,20 @@ day:
which keeps the session and its transcript, and `POST .../start` brings
the process back on the same conversation — or delete the session, which
ends the conversation too.
- **A message or a command sent to a stopped session starts it.** `POST
.../message`, `.../command` and `.../compact` go through
`SessionManager::send_message` and `::run_command`, which start a process
first when the session is known to have exited and then hand the thing to
the driver that has one behind it. Only on `exited`: `unknown` has a
process that may well be reading its fifo. `/rename` starts one too, and
for a sharper reason than the rest: the CLI keeps its own copy of the
name, that copy is what its session picker and other agents' session
lists show, and a session is only ever *given* a name at birth — every
later start is a `--resume` — so a rename that reached no process would
leave the two lists disagreeing for good. Its save happens before the
telling, so a failure there says the telling failed rather than the
rename. So the Start button is for when you want a process and nothing to
say to it yet.
- **Each session directory now holds `process.json`, `stdin.fifo`,
`stdout.log` and `stderr.log`.** `stdout.log` is the driver's input, read
from the byte offset in `process.json`; removing either by hand while the
@@ -323,6 +337,17 @@ day:
launch has just started a process reports `idle`, because `exited` is the
word that refuses every command and offers a phone the chance to start a
second CLI on a live conversation.
- **`exited` is never taken on trust; it is checked against the process
record** (`corrected` in `session/mod.rs`). It is the one status that draws
the phone's Start button and lets `start_session` build a driver, so a
record that is not known to be dead makes it false and the session reports
`unknown` instead. Without that, a session adopted at a backend start kept
the transcript's `exited` while its CLI was running, Start was accepted
every press, and each press left another reader on the same process —
which reads on screen as one reply written several times, interleaved
(`GotGotGot it — it — it —`), not as anything to do with a button.
A driver that `start_session` replaces gets `Driver::detach` for the same
reason: swapping the `Arc` does not end the tasks the old one is running.
- Remote sessions are adopted too. The pid recorded for one is the **`ssh`
client's**, on this machine — that is the process the backend owns, and it
lives as long as the remote command does. (This said "local only" until
+63
View File
@@ -351,6 +351,69 @@ better answer until its output says otherwise. Coming from the driver also
orders it against the exit `follow` reports, which a status written from the
manager could not be.
**`Exited` is a claim about a process, and the record is what settles it.**
Adopting saying nothing left one word standing that a live process
contradicts. A session whose process was reported gone and then found again
at the next backend start kept `Exited` from the transcript — and `Exited` is
the word that draws a Start button. Start was then accepted every time it was
pressed, and since starting replaces the driver, each press attached *another*
reader to the one process: every line the CLI wrote was translated once per
reader, so three presses put three interleaved copies of one reply on screen.
Two rules come out of it, and neither is optional:
- **`Exited` is checked against `session::process` before it is believed** —
`corrected`, called in `launch` and again in `start_session`. A record that
is not known to be dead makes it false, and what replaces it is `Unknown`:
there is a process, and nothing here has heard from it, which is the answer
`status_of_unlaunched` already gave to the same question. Every other status
is left exactly as it was — those are the pump's, written from what the
process itself said, and none of them authorises starting anything. The
correction goes out through the sink for the reason above: written into the
manager's view alone it would be the list and the screen disagreeing again.
- **A driver that is replaced is detached.** Swapping the `Arc` does not end
the tasks the old one is running. `Driver::detach` is what does — it already
existed for the backend going away — and it is the whole of what a driver
whose process has exited is owed.
**A message or a command starts the process if there isn't one** (decided
2026-08-30). Refusing was work handed back: read the status word, find the
other button, press it, type the thing again. Both plainly mean "do this
now", and `--resume` puts the new process on the same conversation, so
nothing about what was typed changes — only whether there was anything there
to read it. A rename is included, and for a sharper reason than the rest:
Claude Code keeps its own copy of the name, that copy is what its session
picker shows and what other agents read when they list sessions, and a
session is only ever *given* a name at birth, since every later start is a
`--resume`. So a rename that reached no process would leave the two lists
disagreeing permanently, with this app's the only one that had moved — and
the cost of a resume buys the one thing renaming is for. It stays
`rename_session` rather than becoming a command like the others, because the
name is persisted and listed as well as forwarded and that is one operation;
the save happens first, so a failure to start reports that the telling
failed, not the rename. The manager's `send_message` and `run_command` and
the Start button ask one function
(`start_if_exited`) and want opposite answers from it: "there is already a
process" is a refusal worth showing to somebody who pressed Start, and
nothing at all to a message. Deciding it in one place under one write lock is
also what stops two requests arriving together from starting two CLIs. Only
`Exited` starts anything, for the reason above — `Unknown` has a process that
may well be reading its fifo, and what was typed goes to the driver as it
always did.
A command needs one thing a message does not. `Commands::submit` refuses on
`Exited`, and a driver that has just started a process announces `Idle`
through the sink rather than writing it — so a command judged against the
session's own status would be refused by the word the start had just
replaced. `start_if_exited` returning `Exited` is what says a process was
started, so `run_command` judges against `Idle` from there rather than
re-reading a status the pump may not have caught up with.
The phone's half is that the process button is disabled while its own request
is in flight, so a second press cannot be decided against a status the first
one has not changed yet. That is a courtesy rather than the fix: the server
refuses the second request either way, because a phone that has lost the
stream cannot be relied on to know.
On the phone this is one button in the composer, left of Send, whose mark and
colour say what pressing it would do now: an orange pause while a turn is
running (interrupt — the process stays), a red stop when it is not (end the
@@ -568,6 +568,12 @@ fun SessionScreen(
var compactingFor by remember { mutableStateOf<Long?>(null) }
var streamError by remember { mutableStateOf<String?>(null) }
var actionError by remember { mutableStateOf<String?>(null) }
// Whether the composer's process button has a request out. What it does next is decided from
// the session's status, and the status only changes once the server has answered and the
// stream has carried it back -- so two presses in that gap are two requests, both decided
// against the state before either of them. The server refuses the second one, but a control
// that can be pressed while its own last press is still in flight is asking to be.
var processInFlight by remember { mutableStateOf(false) }
val context = LocalContext.current
// Seeded from what was left in the box last time and written back on every keystroke, so
// leaving the screen -- or the system reclaiming the app -- does not throw away a half-typed
@@ -1030,7 +1036,7 @@ fun SessionScreen(
}
}
fun act(onFailure: () -> Unit = {}, action: () -> Unit) {
fun act(onFailure: () -> Unit = {}, onDone: () -> Unit = {}, action: () -> Unit) {
scope.launch {
try {
withContext(Dispatchers.IO) { action() }
@@ -1038,6 +1044,11 @@ fun SessionScreen(
} catch (e: ApiException) {
actionError = e.message
onFailure()
} finally {
// Whatever happened, including the failure above: a caller that re-enables a
// control here must get it back on the path where the request was refused too,
// or the refusal is what disables the control permanently.
onDone()
}
}
}
@@ -1538,7 +1549,13 @@ fun SessionScreen(
else -> ProcessAction.Stop
}
Button(
onClick = { act { process.perform(settings, summary.id) } },
onClick = {
processInFlight = true
act(onDone = { processInFlight = false }) {
process.perform(settings, summary.id)
}
},
enabled = !processInFlight,
colors = actionButtonColors(process.colour()),
) {
Glyph(
+32 -11
View File
@@ -16,6 +16,7 @@
//! (a backlog past CATCH_UP_LIMIT arrives as a
//! `reset` frame plus the newest window)
//! POST /sessions/{id}/message {text, attachmentIds?}
//! (starts the process first if it has exited)
//! POST /sessions/{id}/answer {questionId, answers} (questions and permissions)
//! POST /sessions/{id}/interrupt stop the running turn; the process stays
//! POST /sessions/{id}/stop end the process; the session and transcript stay
@@ -23,6 +24,7 @@
//! POST /sessions/{id}/title {title}
//! POST /sessions/{id}/model {model}
//! POST /sessions/{id}/command {text} -- /compact, /clear, /rename x, or the dialect's own
//! (starts the process first if it has exited)
//! POST /sessions/{id}/compact
//! POST /sessions/{id}/attachments multipart image upload -> {id}, referenced by /message
//! GET /sessions/{id}/files/{name} images the session produced or was sent
@@ -640,11 +642,16 @@ async fn message(
UrlPath(id): UrlPath<String>,
axum::Json(body): axum::Json<MessageRequest>,
) -> Result<StatusCode, ApiError> {
let session = lookup(&manager, &id)?;
// For the 404 a session that is not here has always answered with; the
// send itself goes through the manager, which may have to start a
// process before there is anything to send to.
lookup(&manager, &id)?;
if body.text.trim().is_empty() && body.attachment_ids.is_empty() {
return Err(ApiError::BadRequest("message is empty".to_string()));
}
session.send_message(body.text, body.attachment_ids);
manager
.send_message(&id, body.text, body.attachment_ids)
.map_err(bad_request)?;
Ok(StatusCode::NO_CONTENT)
}
@@ -834,15 +841,26 @@ async fn command(
Some((name, rest)) => (name, rest.trim()),
None => (text, ""),
};
match (name, rest) {
("/compact", _) => lookup(&manager, &id)?.run_command(SessionCommand::Compact),
("/clear", _) => lookup(&manager, &id)?.run_command(SessionCommand::Clear),
// Through the manager, not the session: a name is persisted and
// listed as well as forwarded, and that is one operation.
// All of these start the session's process first if it has exited: a
// command is something somebody asked the session to do, and answering
// that its process is gone hands back the work of starting one.
//
// A rename still goes through `rename_session` rather than being a
// command like the rest, because the name is persisted and listed as
// well as forwarded, and that is one operation. It starts a process
// too, and for a sharper reason than the others -- see there.
let command = match (name, rest) {
("/compact", _) => SessionCommand::Compact,
("/clear", _) => SessionCommand::Clear,
("/rename", "") => return Err(bad_request(anyhow::anyhow!("a session needs a name"))),
("/rename", title) => manager.rename_session(&id, title).map_err(bad_request)?,
_ => lookup(&manager, &id)?.run_command(SessionCommand::Raw(text.to_string())),
}
("/rename", title) => {
manager.rename_session(&id, title).map_err(bad_request)?;
return Ok(StatusCode::NO_CONTENT);
}
_ => SessionCommand::Raw(text.to_string()),
};
lookup(&manager, &id)?;
manager.run_command(&id, command).map_err(bad_request)?;
Ok(StatusCode::NO_CONTENT)
}
@@ -850,7 +868,10 @@ async fn compact(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
) -> Result<StatusCode, ApiError> {
lookup(&manager, &id)?.compact();
lookup(&manager, &id)?;
manager
.run_command(&id, SessionCommand::Compact)
.map_err(bad_request)?;
Ok(StatusCode::NO_CONTENT)
}
+410 -56
View File
@@ -381,13 +381,6 @@ impl LiveSession {
self.driver().answer_question(question_id, answers);
}
/// Asks the session to run a command on itself, now or at the next
/// boundary. See [`Commands`] for why it may not be now.
pub fn run_command(&self, command: SessionCommand) {
self.commands
.submit(command, *self.shared.status.lock().unwrap());
}
pub fn interrupt(&self) {
self.driver().interrupt();
}
@@ -399,13 +392,6 @@ impl LiveSession {
self.driver().detach();
}
/// Compacts at the next boundary. Through the command queue like
/// every other instruction to the session, so pressing it during a
/// turn holds rather than writing into that turn.
pub fn compact(&self) {
self.run_command(SessionCommand::Compact);
}
pub fn subscribe(&self) -> broadcast::Receiver<SeqEvent> {
self.events.subscribe()
}
@@ -1004,6 +990,15 @@ impl SessionManager {
/// unlike the model and the permission mode, this is settled here and
/// the driver is *told*, rather than asked and believed: see
/// [`Driver::set_title`].
///
/// Telling it is not decoration, which is why this starts a stopped
/// session like any other command. Claude Code keeps its own copy of
/// the name, and that copy is what its session picker shows and what
/// other agents see when they list sessions -- and a session is only
/// ever *given* a name at birth, since every later start is a
/// `--resume`. So a rename that reached no process would leave the two
/// lists disagreeing permanently, with the app's the only one that had
/// moved.
pub fn rename_session(&self, id: &str, title: &str) -> Result<()> {
let title = title.trim();
// An empty name is not a name, and it is what a cleared field
@@ -1012,25 +1007,37 @@ impl SessionManager {
if title.is_empty() {
bail!("a session needs a name");
}
let mut inner = self.inner.write().unwrap();
if !inner.config.sessions.iter().any(|meta| meta.id == id) {
bail!("no session {id}");
{
let mut inner = self.inner.write().unwrap();
if !inner.config.sessions.iter().any(|meta| meta.id == id) {
bail!("no session {id}");
}
let mut candidate = inner.config.clone();
for meta in candidate.sessions.iter_mut().filter(|meta| meta.id == id) {
meta.title = title.to_string();
}
candidate.save(&self.config_path)?;
inner.config = candidate;
// The name is this server's and changes now, whatever happens
// next: the list shows it immediately, and the process is told
// at the next boundary.
if let Some(session) = inner.live.get(id) {
*session.shared.title.lock().unwrap() = title.to_string();
}
}
let mut candidate = inner.config.clone();
for meta in candidate.sessions.iter_mut().filter(|meta| meta.id == id) {
meta.title = title.to_string();
}
candidate.save(&self.config_path)?;
inner.config = candidate;
if let Some(session) = inner.live.get(id) {
// The name is this server's and changes now. Telling whatever
// runs the session is a command, and commands wait for the
// turn to end -- so the list shows the new name immediately
// and the CLI is told at the next boundary.
*session.shared.title.lock().unwrap() = title.to_string();
session.run_command(SessionCommand::SetTitle(title.to_string()));
}
Ok(())
// Dropped the lock first -- `run_command` takes it again to decide
// whether anything needs starting, and this is not a reentrant one.
//
// The context matters more than it looks: the rename above is saved
// by the time this can fail, so a bare error would report a rename
// that did not happen. What failed is only the telling.
self.run_command(id, SessionCommand::SetTitle(title.to_string()))
.with_context(|| {
format!(
"renamed to \"{title}\" here, but the session's own copy of the name could \
not be changed"
)
})
}
pub fn set_session_model(&self, id: &str, model: &str) -> Result<()> {
@@ -1100,9 +1107,14 @@ impl SessionManager {
Ok(())
}
/// Starts a process for a session whose process has ended, continuing
/// the same conversation -- for Claude Code, the `--resume` that crash
/// recovery already uses.
/// Starts a process for a session whose process has ended, for somebody
/// who asked for exactly that.
///
/// Anything other than a session known to have exited is a refusal to
/// report, because the person pressing this expects a process to appear
/// and is owed the reason one did not. [`SessionManager::send_message`]
/// asks the same question of [`SessionManager::start_if_exited`] and
/// wants the opposite answer.
///
/// Only the driver is new. The transcript, the event pump and the stream
/// every open phone is reading stay as they were, so this is not a
@@ -1110,10 +1122,87 @@ impl SessionManager {
/// writer of the transcript, which relaunching the whole session would
/// not be.
///
/// Refused unless the session is *known* to have exited. `Unknown` means
/// nobody could find out whether the process is alive, and starting one
/// on that is precisely the second-CLI-on-one-conversation fault that
/// `session::process` exists to prevent.
pub fn start_session(&self, id: &str) -> Result<()> {
match self.start_if_exited(id)? {
SessionStatus::Exited => Ok(()),
SessionStatus::Unknown => {
bail!("there is still a process recorded for this session, so nothing was started")
}
_ => bail!("this session is already running"),
}
}
/// Hands a message to a session, starting its process first if that
/// session has none.
///
/// Sending is the one instruction that plainly means "do this now", so a
/// session whose CLI has ended starts it rather than answering that it
/// cannot -- which left the person holding the phone to read a status
/// word, find a second button, press it, and type the message again.
/// `--resume` puts the new process on the same conversation, so nothing
/// about the message changes; only whether there was anything there to
/// read it.
///
/// Started before the message rather than after, because starting
/// replaces the driver and the driver that takes the message has to be
/// the one with a process behind it.
pub fn send_message(&self, id: &str, text: String, images: Vec<ImageRef>) -> Result<()> {
// Only `Exited` starts anything -- see `start_if_exited`. A session
// this cannot say has exited keeps the behaviour it always had: the
// message goes to the driver, which answers for it.
self.start_if_exited(id)?;
self.session(id)
.with_context(|| format!("no session {id}"))?
.send_message(text, images);
Ok(())
}
/// Runs one of the session's own commands, starting its process first
/// if that session has none.
///
/// The same reasoning as [`SessionManager::send_message`], and for the
/// same reason it is not left to each caller: a command is something
/// somebody asked the session to do, and answering "its process has
/// exited" hands back the work of starting one. `/compact` on a
/// stopped session is the case that shows it -- the thing being asked
/// for is exactly what a stopped session needs before it is useful
/// again.
///
pub fn run_command(&self, id: &str, command: SessionCommand) -> Result<()> {
// Judged against the status *after* the start, not the one that
// caused it. A driver that has just started a process announces
// `idle` through the sink and the pump may not have recorded it
// yet, so reading the session's own status here would refuse the
// command the start was for -- `Commands::submit` refuses on
// `Exited`, which is exactly the word that has just stopped being
// true. `start_if_exited` returning `Exited` is what says a process
// was started; anything else is a status nothing has invalidated.
let status = match self.start_if_exited(id)? {
SessionStatus::Exited => SessionStatus::Idle,
found => found,
};
self.session(id)
.with_context(|| format!("no session {id}"))?
.commands
.submit(command, status);
Ok(())
}
/// Starts a process for the session if it is known to have exited, and
/// reports what the session was found to be doing either way. `Exited`
/// is therefore the one returned value that means something was started.
///
/// One decision with two callers who want opposite things from it: a
/// Start button treats "there is already a process" as a refusal worth
/// showing, and a message being sent treats it as nothing at all.
/// Deciding it here, under the one write lock, is also what stops two
/// requests that arrive together from starting two CLIs on one
/// conversation.
///
/// Nothing is started on `Unknown`. That means nobody could find out
/// whether the process is alive, and starting one on that is precisely
/// the second-CLI-on-one-conversation fault `session::process` exists to
/// prevent.
///
/// What the session then *reports* is the driver's to say, not this
/// function's: the phone's list reads the manager's status and the
@@ -1122,7 +1211,7 @@ impl SessionManager {
/// is what a status set here without an event produced, visible as a
/// stop button that turned into a play button a moment after the screen
/// opened.
pub fn start_session(&self, id: &str) -> Result<()> {
fn start_if_exited(&self, id: &str) -> Result<SessionStatus> {
let mut inner = self.inner.write().unwrap();
let meta = inner
.config
@@ -1132,17 +1221,27 @@ impl SessionManager {
.with_context(|| format!("no session {id}"))?
.clone();
let existing = inner.live.get(id).cloned();
let dir = self.data_dir.join(id);
let status = match &existing {
Some(session) => *session.shared.status.lock().unwrap(),
None => status_of_unlaunched(&self.data_dir.join(id)),
Some(session) => {
let last = *session.shared.status.lock().unwrap();
let now = corrected(last, &dir);
if now != last {
// Published, not merely acted on. The phone is drawing a
// Start button on the strength of the word this has just
// disproved, and it learns what a session is doing from
// the stream like everything else -- so a correction
// nobody sends leaves that button there to be pressed
// again, and again. Through the sink, which keeps the
// pump the only writer of the status.
let _ = session.sink.send(Event::Status { state: now });
}
now
}
None => status_of_unlaunched(&dir),
};
match status {
SessionStatus::Exited => {}
SessionStatus::Unknown => bail!(
"this machine won't say whether this session's process is still running, so \
nothing was started"
),
_ => bail!("this session is already running"),
if status != SessionStatus::Exited {
return Ok(status);
}
// Fresh from the config, like every other launch: a model or a
// permission mode changed while the session was stopped is what it
@@ -1150,6 +1249,12 @@ impl SessionManager {
let (setup, provider) = resolve(&inner.config, &meta)?;
match existing {
Some(session) => {
// The driver being replaced is still reading this session's
// output, and replacing the value it lives in does not end
// the tasks that do it. Its process has exited -- that is
// how this line was reached -- so there is nothing left to
// preserve and `detach` is the whole of what it is owed.
session.driver().detach();
*session.driver.lock().unwrap() = make_driver(
&meta,
&setup,
@@ -1182,7 +1287,7 @@ impl SessionManager {
// here as well would be a second writer of the same fact, and the
// one that cannot see whether the process it is describing is still
// there.
Ok(())
Ok(SessionStatus::Exited)
}
/// Kills the process, releases everything the spawn created, and
@@ -1226,6 +1331,38 @@ impl SessionManager {
/// reports `Unknown` too: this server is not driving it, so it genuinely
/// does not know what it is doing -- and that is worth a word that means
/// "wait", not one that means "act".
/// The last word about a session, with the one status that cannot be taken
/// on trust checked against the only authority on it.
///
/// `Exited` is not just a description: it is the word that offers a phone a
/// Start button and lets [`SessionManager::start_session`] build a second
/// CLI against a conversation. So before it is believed it is checked
/// against the process record, and a record that is not known to be dead
/// makes it false. What replaces it is `Unknown` -- there is a process, and
/// nothing here has heard from it -- which is the same answer
/// [`status_of_unlaunched`] gives to the same question.
///
/// Every other status is left exactly as it was. Those are the pump's,
/// written from what the process itself said, and none of them authorises
/// starting anything.
///
/// This was reachable and did happen: a session adopted at server start
/// keeps the transcript's last word, so one whose process was reported gone
/// and then found again read as `exited` while it was running. Start was
/// accepted every time it was pressed, each press attaching another reader
/// to the one process, and every line it wrote was then translated once per
/// reader -- three presses put three interleaved copies of one reply on
/// screen.
fn corrected(status: SessionStatus, session_dir: &Path) -> SessionStatus {
if status != SessionStatus::Exited {
return status;
}
match process::recorded(session_dir) {
Some((_, process::Liveness::Alive | process::Liveness::Unknown)) => SessionStatus::Unknown,
Some((_, process::Liveness::Dead)) | None => SessionStatus::Exited,
}
}
fn status_of_unlaunched(session_dir: &Path) -> SessionStatus {
match process::recorded(session_dir) {
// Nothing was ever recorded: an echo session, or one whose
@@ -1422,6 +1559,7 @@ fn launch(
wg_app_link::private::create_dir(&dir)?;
let transcript_path = dir.join("transcript.jsonl");
let mut transcript = Transcript::open(&transcript_path)?;
let last_status = transcript.last_status().unwrap_or(SessionStatus::Idle);
// Before the driver starts, so the token is there when it looks and
// the history is already in the transcript a phone will read.
if let Some(seed) = seed {
@@ -1439,8 +1577,11 @@ fn launch(
// What it was last known to be doing, not an assumption. A driver
// that has something to say corrects this within its first poll;
// one adopting a process that has been quiet says nothing, and
// this is then the only true answer available.
status: Mutex::new(transcript.last_status().unwrap_or(SessionStatus::Idle)),
// this is then the only true answer available. Where it is *not*
// the true answer, the correction goes through the sink below
// rather than being written here, so that it reaches the
// transcript too -- see there.
status: Mutex::new(last_status),
title: Mutex::new(meta.title.clone()),
// What the transcript last recorded, not the clock: this server has
// just been told nothing, and `now()` claimed every relaunched
@@ -1494,6 +1635,22 @@ fn launch(
);
}
// Before the driver, because the driver may start a process and say so,
// and that has to be the later word of the two.
//
// Sent rather than written into `shared`: the session screen replays the
// transcript and the session list reads `shared`, so a correction made
// in only one of them is the two of them describing one session
// differently -- which is how the phone came to show a Start button on a
// running session in the first place. One event, and the pump puts it in
// both.
let corrected_status = corrected(last_status, &dir);
if corrected_status != last_status {
let _ = sink.send(Event::Status {
state: corrected_status,
});
}
let driver = Arc::new(Mutex::new(make_driver(
&meta,
setup,
@@ -1877,7 +2034,9 @@ mod tests {
})
.await;
session.run_command(SessionCommand::Clear);
manager
.run_command(&info.id, SessionCommand::Clear)
.expect("clear");
let held = collect_until(&mut rx, |event| {
matches!(
event,
@@ -2188,7 +2347,9 @@ mod tests {
})
.await;
session.run_command(SessionCommand::Raw("/tool held".to_string()));
manager
.run_command(&info.id, SessionCommand::Raw("/tool held".to_string()))
.expect("command");
let seen = collect_until(&mut rx, |event| {
matches!(event, Event::CommandQueued { .. })
})
@@ -2232,7 +2393,9 @@ mod tests {
let session = manager.session(&info.id).expect("live session");
let mut rx = session.subscribe();
session.run_command(SessionCommand::Raw("/tool now".to_string()));
manager
.run_command(&info.id, SessionCommand::Raw("/tool now".to_string()))
.expect("command");
let seen = collect_until(&mut rx, |event| matches!(event, Event::ToolStart { .. })).await;
// Sent, and never queued: a session between turns has nothing to
// wait for, and a phone should not draw a bubble that resolves in
@@ -2389,7 +2552,9 @@ mod tests {
// A clear leaves it unmeasured: the conversation is gone, and how
// much is left is a thing nobody has counted.
session.run_command(SessionCommand::Clear);
manager
.run_command(&info.id, SessionCommand::Clear)
.expect("clear");
collect_until(&mut rx, |event| matches!(event, Event::Cleared)).await;
assert_eq!(manager.sessions()[0].context_tokens, None);
@@ -2493,6 +2658,195 @@ mod tests {
));
}
/// A message and a command both mean "now", so neither answers that the
/// session's process has gone -- they start one and go to it.
///
/// Refusing was the old behaviour and it was work handed back: read the
/// status word, find the other button, press it, type the thing again.
/// `--resume` puts the new process on the same conversation, so what it
/// reads is what was typed.
///
/// Both halves in one test because they are one rule. A command is the
/// half that can fail on its own: `Commands::submit` refuses on
/// `Exited`, and the start it has just been given announces `Idle`
/// through the sink rather than writing it -- so a command judged
/// against the session's own status would be refused by the word the
/// start replaced, in a window a test is the only thing likely to hit.
#[tokio::test]
async fn an_instruction_starts_the_process_a_stopped_session_has_not_got() {
let dir = tempfile::tempdir().expect("tempdir");
let config_path = dir.path().join("config.ron");
let data_dir = dir.path().join("sessions");
seed_echo_only(&config_path);
let manager = SessionManager::new(config_path, data_dir.clone(), data_dir.join("models"))
.expect("manager");
let info = manager.spawn_session(echo_spec()).expect("spawn");
let session = manager.session(&info.id).expect("live session");
let mut rx = session.subscribe();
let _ = session.sink.send(Event::Status {
state: SessionStatus::Exited,
});
collect_until(&mut rx, |event| {
matches!(
event,
Event::Status {
state: SessionStatus::Exited
}
)
})
.await;
manager
.send_message(&info.id, "carry on".to_string(), Vec::new())
.expect("send to a stopped session");
collect_until(
&mut rx,
|event| matches!(event, Event::UserMessage { text, .. } if text == "carry on"),
)
.await;
// And the session is running again, not merely written to: a message
// delivered to a session still reporting `exited` is one the phone
// draws under a Start button.
assert_ne!(manager.sessions()[0].status, SessionStatus::Exited);
let _ = session.sink.send(Event::Status {
state: SessionStatus::Exited,
});
collect_until(&mut rx, |event| {
matches!(
event,
Event::Status {
state: SessionStatus::Exited
}
)
})
.await;
manager
.run_command(&info.id, SessionCommand::Clear)
.expect("clear a stopped session");
collect_until(&mut rx, |event| matches!(event, Event::Cleared)).await;
assert_ne!(manager.sessions()[0].status, SessionStatus::Exited);
}
/// A rename is not decoration, so it starts a stopped session too.
///
/// Claude Code keeps its own copy of the name; that copy is what its
/// session picker shows and what other agents read when they list
/// sessions, and a session is only ever *given* a name at birth, since
/// every later start is a `--resume`. So a rename that reached no
/// process would leave the two lists disagreeing permanently, with this
/// app's the only one that had moved.
#[tokio::test]
async fn a_rename_reaches_the_process_even_when_one_has_to_be_started() {
let dir = tempfile::tempdir().expect("tempdir");
let config_path = dir.path().join("config.ron");
let data_dir = dir.path().join("sessions");
seed_echo_only(&config_path);
let manager = SessionManager::new(config_path, data_dir.clone(), data_dir.join("models"))
.expect("manager");
let info = manager.spawn_session(echo_spec()).expect("spawn");
let session = manager.session(&info.id).expect("live session");
let mut rx = session.subscribe();
let _ = session.sink.send(Event::Status {
state: SessionStatus::Exited,
});
collect_until(&mut rx, |event| {
matches!(
event,
Event::Status {
state: SessionStatus::Exited
}
)
})
.await;
manager
.rename_session(&info.id, "the new name")
.expect("rename a stopped session");
collect_until(
&mut rx,
|event| matches!(event, Event::CommandSent { text, .. } if text == "/rename the new name"),
)
.await;
// Both halves: the name this server lists changed, and it was told
// to a process rather than only written down.
assert_eq!(manager.sessions()[0].title, "the new name");
assert_ne!(manager.sessions()[0].status, SessionStatus::Exited);
}
/// The status is a claim about a process, and the process record is
/// what settles it.
///
/// Without this the phone offered Start on a session whose CLI was
/// running, and taking it up attached a second reader to that one
/// process rather than failing -- so the session went on saying
/// `exited`, the button stayed, and each further press added another
/// reader. On screen that was one reply written as many times as the
/// button had been pressed, interleaved word by word.
#[tokio::test]
async fn a_stale_exited_does_not_start_anything_while_a_process_is_recorded() {
let dir = tempfile::tempdir().expect("tempdir");
let config_path = dir.path().join("config.ron");
let data_dir = dir.path().join("sessions");
seed_echo_only(&config_path);
let manager = SessionManager::new(config_path, data_dir.clone(), data_dir.join("models"))
.expect("manager");
let info = manager.spawn_session(echo_spec()).expect("spawn");
let session = manager.session(&info.id).expect("live session");
let mut rx = session.subscribe();
// A live process for this session: this test's own, which is the
// one process certain to still be there when the guard looks.
let record = process::Record::of(
std::process::id(),
process::Detail::Stdio { stdout_read: 0 },
)
.expect("record this process");
process::write(&data_dir.join(&info.id), &record);
let _ = session.sink.send(Event::Status {
state: SessionStatus::Exited,
});
collect_until(&mut rx, |event| {
matches!(
event,
Event::Status {
state: SessionStatus::Exited
}
)
})
.await;
let refused = manager
.start_session(&info.id)
.expect_err("a process is recorded");
assert!(
refused.to_string().contains("still a process recorded"),
"said: {refused:#}"
);
// And the word that was wrong is taken back, on the stream and in
// the transcript -- otherwise the button that asked for this is
// still there, still saying Start.
collect_until(&mut rx, |event| {
matches!(
event,
Event::Status {
state: SessionStatus::Unknown
}
)
})
.await;
assert_eq!(manager.sessions()[0].status, SessionStatus::Unknown);
assert_eq!(
Transcript::open(&data_dir.join(&info.id).join("transcript.jsonl"))
.expect("reopen transcript")
.last_status(),
Some(SessionStatus::Unknown),
);
}
#[tokio::test]
async fn a_restart_relaunches_sessions_and_continues_the_numbering() {
let dir = tempfile::tempdir().expect("tempdir");