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
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:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -1819,6 +1819,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"chrono",
|
||||||
"eventsource-stream",
|
"eventsource-stream",
|
||||||
"futures",
|
"futures",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ tokio-util = { version = "0.7", features = ["rt"] }
|
|||||||
eventsource-stream = "0.2"
|
eventsource-stream = "0.2"
|
||||||
async-stream = "0.3"
|
async-stream = "0.3"
|
||||||
url = { version = "2", features = ["serde"] }
|
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]]
|
[[bin]]
|
||||||
name = "helexa-acp"
|
name = "helexa-acp"
|
||||||
|
|||||||
@@ -19,9 +19,10 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
|||||||
|
|
||||||
use agent_client_protocol::schema::{
|
use agent_client_protocol::schema::{
|
||||||
AgentCapabilities, CancelNotification, ContentBlock, InitializeRequest, InitializeResponse,
|
AgentCapabilities, CancelNotification, ContentBlock, InitializeRequest, InitializeResponse,
|
||||||
LoadSessionRequest, LoadSessionResponse, NewSessionRequest, NewSessionResponse,
|
ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse,
|
||||||
PromptCapabilities, PromptRequest, PromptResponse, SessionId, SessionMode, SessionModeId,
|
NewSessionRequest, NewSessionResponse, PromptCapabilities, PromptRequest, PromptResponse,
|
||||||
SessionModeState, SessionNotification, SessionUpdate, SetSessionModeRequest,
|
SessionCapabilities, SessionId, SessionInfo, SessionListCapabilities, SessionMode,
|
||||||
|
SessionModeId, SessionModeState, SessionNotification, SessionUpdate, SetSessionModeRequest,
|
||||||
SetSessionModeResponse, StopReason, TextContent,
|
SetSessionModeResponse, StopReason, TextContent,
|
||||||
};
|
};
|
||||||
use agent_client_protocol::{Agent as AgentRole, Client, ConnectionTo, Dispatch, Stdio};
|
use agent_client_protocol::{Agent as AgentRole, Client, ConnectionTo, Dispatch, Stdio};
|
||||||
@@ -151,6 +152,15 @@ impl Agent {
|
|||||||
},
|
},
|
||||||
agent_client_protocol::on_receive_request!(),
|
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(
|
.on_receive_request(
|
||||||
{
|
{
|
||||||
let inner = inner.clone();
|
let inner = inner.clone();
|
||||||
@@ -221,14 +231,18 @@ fn initialize_response(req: &InitializeRequest) -> InitializeResponse {
|
|||||||
// Stage 2: text-only prompts. Image / audio / embedded resources
|
// Stage 2: text-only prompts. Image / audio / embedded resources
|
||||||
// flip on in later stages.
|
// flip on in later stages.
|
||||||
let prompt_caps = PromptCapabilities::default();
|
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(
|
InitializeResponse::new(req.protocol_version).agent_capabilities(
|
||||||
AgentCapabilities::new()
|
AgentCapabilities::new()
|
||||||
.prompt_capabilities(prompt_caps)
|
.prompt_capabilities(prompt_caps)
|
||||||
// Stage 3b: `session/load` is implemented. Persisted
|
.session_capabilities(session_caps)
|
||||||
// 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.
|
|
||||||
.load_session(true),
|
.load_session(true),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -315,6 +329,58 @@ async fn handle_load_session(
|
|||||||
Ok(LoadSessionResponse::new().modes(modes))
|
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
|
/// 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),
|
/// this list (e.g. "plan" for plan-only output, "ask" for read-only),
|
||||||
/// but Default + Bypass cover the two operationally distinct
|
/// but Default + Bypass cover the two operationally distinct
|
||||||
|
|||||||
@@ -116,6 +116,64 @@ pub fn load_from_dir(
|
|||||||
Ok(session)
|
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
|
/// Seconds-since-epoch, saturating to 0 if the system clock is
|
||||||
/// behind epoch (which shouldn't happen but the type system
|
/// behind epoch (which shouldn't happen but the type system
|
||||||
/// requires a fallible read).
|
/// requires a fallible read).
|
||||||
@@ -126,6 +184,16 @@ pub fn now_secs() -> u64 {
|
|||||||
.unwrap_or(0)
|
.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
|
/// Strip anything that isn't a safe filename character so a
|
||||||
/// mischievous (or just unconventional) session id can't escape
|
/// mischievous (or just unconventional) session id can't escape
|
||||||
/// the sessions directory.
|
/// the sessions directory.
|
||||||
@@ -265,6 +333,65 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(&dir);
|
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]
|
#[test]
|
||||||
fn sanitize_id_rejects_path_traversal() {
|
fn sanitize_id_rejects_path_traversal() {
|
||||||
// `../../etc/passwd` — 6 non-alnum chars before "etc"
|
// `../../etc/passwd` — 6 non-alnum chars before "etc"
|
||||||
|
|||||||
Reference in New Issue
Block a user