feat(helexa-acp): session/list so Zed can discover sessions to resume
All checks were successful
build-prerelease / Resolve version stamps (push) Successful in 28s
CI / Format (push) Successful in 28s
CI / Clippy (push) Successful in 2m45s
build-prerelease / Build cortex binary (push) Successful in 4m41s
CI / Test (push) Successful in 4m58s
build-prerelease / Build neuron-blackwell (push) Successful in 6m4s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
build-prerelease / Package cortex RPM (push) Successful in 1m21s
build-prerelease / Build neuron-ampere (push) Successful in 7m36s
build-prerelease / Build neuron-ada (push) Successful in 5m40s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 2m57s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 3m3s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m40s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 1m3s

Stage 3b only implemented the trailing half of resume: write
sessions to disk + handle session/load. But Zed (and any ACP
client) needs `session/list` to discover *which* session belongs
to the workspace it's reopening — without it, the client only
knows how to mint new sessions and resume never fires even
though the JSON sits ready on disk.

Add the missing pieces:

- store::list / list_in_dir — enumerate {id}.json under
  sessions_dir(), optionally filter by cwd, sort recent-first.
  Skips unparseable files with a warn rather than aborting.
- store::unix_to_iso8601 — RFC 3339 formatter for
  SessionInfo.updated_at; pulls chrono in directly (already in
  the dep tree transitively).
- agent::handle_list_sessions — wires the request to the store,
  builds SessionInfo entries with derived titles (first user
  turn, truncated to 60 chars).
- agent::initialize_response — advertise
  session_capabilities.list = {} alongside the existing
  load_session: true.

Verified end-to-end against the user's real hxa-1.json
(60-turn beat conversation): `session/list` returns the entry
with cwd, derived title, and ISO 8601 timestamp.

4 new store unit tests for list filtering, missing-dir
handling, unparseable-file skipping, and ISO 8601 formatting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 14:34:41 +03:00
parent 5aac1ffc59
commit 0bbb9b752d
4 changed files with 206 additions and 8 deletions

View File

@@ -33,6 +33,10 @@ tokio-util = { version = "0.7", features = ["rt"] }
eventsource-stream = "0.2"
async-stream = "0.3"
url = { version = "2", features = ["serde"] }
# Already transitively pulled via the ACP SDK; declared directly so we
# can format ISO 8601 timestamps for `SessionInfo.updated_at` in the
# session/list response.
chrono = { version = "0.4", default-features = false, features = ["std"] }
[[bin]]
name = "helexa-acp"

View File

@@ -19,9 +19,10 @@ use std::sync::atomic::{AtomicU64, Ordering};
use agent_client_protocol::schema::{
AgentCapabilities, CancelNotification, ContentBlock, InitializeRequest, InitializeResponse,
LoadSessionRequest, LoadSessionResponse, NewSessionRequest, NewSessionResponse,
PromptCapabilities, PromptRequest, PromptResponse, SessionId, SessionMode, SessionModeId,
SessionModeState, SessionNotification, SessionUpdate, SetSessionModeRequest,
ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse,
NewSessionRequest, NewSessionResponse, PromptCapabilities, PromptRequest, PromptResponse,
SessionCapabilities, SessionId, SessionInfo, SessionListCapabilities, SessionMode,
SessionModeId, SessionModeState, SessionNotification, SessionUpdate, SetSessionModeRequest,
SetSessionModeResponse, StopReason, TextContent,
};
use agent_client_protocol::{Agent as AgentRole, Client, ConnectionTo, Dispatch, Stdio};
@@ -151,6 +152,15 @@ impl Agent {
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_request(
async move |req: ListSessionsRequest, responder, _cx| match handle_list_sessions(
req,
) {
Ok(resp) => responder.respond(resp),
Err(e) => responder.respond_with_internal_error(format!("{e:#}")),
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_request(
{
let inner = inner.clone();
@@ -221,14 +231,18 @@ fn initialize_response(req: &InitializeRequest) -> InitializeResponse {
// Stage 2: text-only prompts. Image / audio / embedded resources
// flip on in later stages.
let prompt_caps = PromptCapabilities::default();
// Stage 3b: advertise both the top-level `load_session` flag and
// the `session/list` sub-capability. Zed (and other ACP clients)
// uses `session/list` to discover the session id that belongs to
// a workspace before sending `session/load` — without it, the
// client only knows how to mint new sessions and resume never
// fires regardless of what's on disk.
let session_caps =
SessionCapabilities::default().list(Some(SessionListCapabilities::default()));
InitializeResponse::new(req.protocol_version).agent_capabilities(
AgentCapabilities::new()
.prompt_capabilities(prompt_caps)
// Stage 3b: `session/load` is implemented. Persisted
// sessions live on disk under
// `$XDG_DATA_HOME/helexa-acp/sessions/`; clients (Zed)
// can hand us back any session_id we previously
// minted to resume the conversation.
.session_capabilities(session_caps)
.load_session(true),
)
}
@@ -315,6 +329,58 @@ async fn handle_load_session(
Ok(LoadSessionResponse::new().modes(modes))
}
/// Enumerate persisted sessions for the `session/list` ACP method.
///
/// Zed calls this on workspace open to find the session belonging
/// to the cwd it's reopening — without it, even though `session/load`
/// works, the client has no way to discover the session_id and
/// always falls back to `session/new`. That's exactly the
/// "history didn't survive the restart" symptom.
///
/// Cursor pagination from the request is accepted but ignored:
/// helexa-acp's session counts are too small to need it. We always
/// return the whole filtered list with `next_cursor = None`.
fn handle_list_sessions(req: ListSessionsRequest) -> anyhow::Result<ListSessionsResponse> {
let sessions = store::list(req.cwd.as_deref())?;
let infos: Vec<SessionInfo> = sessions
.into_iter()
.map(|s| {
let mut info = SessionInfo::new(SessionId::new(s.session_id), s.cwd);
info = info.title(derive_session_title(&s.history));
info = info.updated_at(store::unix_to_iso8601(s.updated_at));
info
})
.collect();
tracing::info!(
cwd = ?req.cwd,
count = infos.len(),
"session/list responded"
);
Ok(ListSessionsResponse::new(infos))
}
/// Best-effort human-readable title for a session, derived from the
/// first user turn's text (truncated to ~60 chars). Empty string
/// becomes `None` so Zed can fall back to its own placeholder.
fn derive_session_title(history: &[Message]) -> Option<String> {
history
.iter()
.find_map(|msg| match (msg.role, &msg.content) {
(Role::User, MessageContent::Text { text }) => Some(text.as_str()),
_ => None,
})
.map(|s| {
let trimmed = s.trim();
if trimmed.chars().count() > 60 {
let prefix: String = trimmed.chars().take(60).collect();
format!("{prefix}")
} else {
trimmed.to_string()
}
})
.filter(|s| !s.is_empty())
}
/// The two modes every Stage 3 session advertises. Stage 7 may grow
/// this list (e.g. "plan" for plan-only output, "ask" for read-only),
/// but Default + Bypass cover the two operationally distinct

View File

@@ -116,6 +116,64 @@ pub fn load_from_dir(
Ok(session)
}
/// List all persisted sessions, optionally filtered by `cwd`. Used
/// by the `session/list` handler so a client (Zed) can find the
/// session that belongs to the workspace it's reopening.
///
/// `filter_cwd = None` returns every session on disk. `Some(path)`
/// returns only sessions whose persisted `cwd` is exactly equal.
///
/// Files that fail to parse are skipped with a warning rather than
/// aborting the whole list — one corrupt session shouldn't make
/// the resume picker unusable.
pub fn list(filter_cwd: Option<&std::path::Path>) -> anyhow::Result<Vec<PersistedSession>> {
let dir = sessions_dir()
.ok_or_else(|| anyhow::anyhow!("can't resolve XDG_DATA_HOME or HOME for session store"))?;
list_in_dir(&dir, filter_cwd)
}
/// Explicit-dir variant for tests, mirroring [`save_to_dir`] /
/// [`load_from_dir`].
pub fn list_in_dir(
dir: &std::path::Path,
filter_cwd: Option<&std::path::Path>,
) -> anyhow::Result<Vec<PersistedSession>> {
let read = match std::fs::read_dir(dir) {
Ok(r) => r,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(anyhow::anyhow!("read_dir {}: {e}", dir.display())),
};
let mut out = Vec::new();
for entry in read.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
match std::fs::read(&path).and_then(|bytes| {
serde_json::from_slice::<PersistedSession>(&bytes).map_err(std::io::Error::other)
}) {
Ok(session) => {
if let Some(want) = filter_cwd
&& session.cwd != want
{
continue;
}
out.push(session);
}
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"store: skipping unparseable session file"
);
}
}
}
// Most-recent first by updated_at.
out.sort_by_key(|s| std::cmp::Reverse(s.updated_at));
Ok(out)
}
/// Seconds-since-epoch, saturating to 0 if the system clock is
/// behind epoch (which shouldn't happen but the type system
/// requires a fallible read).
@@ -126,6 +184,16 @@ pub fn now_secs() -> u64 {
.unwrap_or(0)
}
/// Format seconds-since-epoch as an ISO 8601 / RFC 3339 string
/// (`YYYY-MM-DDTHH:MM:SSZ`) for `SessionInfo.updated_at`. Returns
/// `None` for values outside the representable range, in which
/// case the caller should omit the field.
pub fn unix_to_iso8601(secs: u64) -> Option<String> {
use chrono::TimeZone;
let dt = chrono::Utc.timestamp_opt(secs as i64, 0).single()?;
Some(dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
}
/// Strip anything that isn't a safe filename character so a
/// mischievous (or just unconventional) session id can't escape
/// the sessions directory.
@@ -265,6 +333,65 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn list_filters_by_cwd_and_sorts_recent_first() {
let dir = unique_dir();
let mut a = sample("a");
a.cwd = PathBuf::from("/home/me/proj-x");
a.updated_at = 1_700_000_010;
let mut b = sample("b");
b.cwd = PathBuf::from("/home/me/proj-x");
b.updated_at = 1_700_000_020;
let mut c = sample("c");
c.cwd = PathBuf::from("/home/me/elsewhere");
c.updated_at = 1_700_000_030;
save_to_dir(&dir, &a).unwrap();
save_to_dir(&dir, &b).unwrap();
save_to_dir(&dir, &c).unwrap();
let proj_x = PathBuf::from("/home/me/proj-x");
let list = list_in_dir(&dir, Some(&proj_x)).unwrap();
let ids: Vec<&str> = list.iter().map(|s| s.session_id.as_str()).collect();
// Filtered to proj-x; b before a because b is more recent.
assert_eq!(ids, vec!["b", "a"]);
let all = list_in_dir(&dir, None).unwrap();
assert_eq!(all.len(), 3);
// Global list still sorted recent-first across all cwds.
assert_eq!(all[0].session_id, "c");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn list_returns_empty_for_missing_dir() {
let dir = unique_dir().join("does-not-exist");
let list = list_in_dir(&dir, None).unwrap();
assert!(list.is_empty());
}
#[test]
fn list_skips_unparseable_files() {
let dir = unique_dir();
save_to_dir(&dir, &sample("good")).unwrap();
std::fs::write(dir.join("garbage.json"), b"{not valid json").unwrap();
let list = list_in_dir(&dir, None).unwrap();
// Garbage skipped; good survives.
assert_eq!(list.len(), 1);
assert_eq!(list[0].session_id, "good");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn iso8601_formats_unix_seconds() {
// 2024-01-01T00:00:00Z is 1704067200 unix seconds.
assert_eq!(
unix_to_iso8601(1_704_067_200),
Some("2024-01-01T00:00:00Z".into())
);
assert_eq!(unix_to_iso8601(0), Some("1970-01-01T00:00:00Z".into()));
}
#[test]
fn sanitize_id_rejects_path_traversal() {
// `../../etc/passwd` — 6 non-alnum chars before "etc"