Some checks failed
build-prerelease / Package helexa-neuron-ada RPM (push) Blocked by required conditions
build-prerelease / Package helexa-neuron-ampere RPM (push) Blocked by required conditions
build-prerelease / Package helexa-neuron-blackwell RPM (push) Blocked by required conditions
build-prerelease / Resolve version stamps (push) Successful in 37s
CI / Format (push) Successful in 36s
CI / Clippy (push) Successful in 2m44s
CI / Test (push) Successful in 5m3s
build-prerelease / Build cortex binary (push) Successful in 4m36s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (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 1m27s
build-prerelease / Build neuron-blackwell (push) Successful in 6m37s
build-prerelease / Build neuron-ampere (push) Successful in 8m12s
build-prerelease / Build neuron-ada (push) Successful in 5m32s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled
Plan mode is the most restrictive of the three session modes: bash is disabled outright, writes are confined to a per-project plan directory under $XDG_DATA_HOME/helexa-acp/plans/<basename>-<8hex>/, and reads / list_dir are unrestricted. The system prompt is rebuilt at the top of every round so a mid-turn switch into (or out of) plan mode takes effect on the next streaming round, and plan mode appends a 3-option menu instructing the model to stop and let the user pick how to proceed once the plan is complete. The project id is basename + FNV-1a-32 of the cwd so it stays stable across runs (SipHash's DefaultHasher reseeds per process), while still disambiguating multiple checkouts that share a final path component. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
189 lines
6.4 KiB
Rust
189 lines
6.4 KiB
Rust
//! Per-session state for the ACP agent loop.
|
|
//!
|
|
//! Concurrency:
|
|
//!
|
|
//! - [`SessionStore`] is an `Arc<RwLock<HashMap<SessionId, …>>>`. The map
|
|
//! itself is read-mostly: it changes only on `session/new` and never
|
|
//! shrinks during Stage 2, so an `RwLock` keeps concurrent reads
|
|
//! contention-free.
|
|
//! - Each session is wrapped in its own `Arc<Mutex<SessionState>>`. Holding
|
|
//! one session's lock doesn't block requests against any other session,
|
|
//! which matters once a client opens multiple sessions in parallel.
|
|
//!
|
|
//! All operations hold a lock only long enough to copy out (or mutate) the
|
|
//! state they need — never across an `await` that drives the upstream
|
|
//! provider stream.
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
|
|
use agent_client_protocol::schema::{SessionId, SessionModeId};
|
|
use tokio::sync::{Mutex, RwLock};
|
|
use tokio_util::sync::CancellationToken;
|
|
|
|
use crate::provider::Message;
|
|
|
|
/// Mode id advertised as the gated default. Writes / bash prompt for
|
|
/// permission via `session/request_permission`.
|
|
pub const MODE_DEFAULT: &str = "default";
|
|
|
|
/// Mode id advertised as "auto-allow everything". Matches the
|
|
/// favorite name (`bypassPermissions`) Zed clients tend to reference.
|
|
pub const MODE_BYPASS: &str = "bypassPermissions";
|
|
|
|
/// Mode id for read-and-plan-only operation. The model may read files
|
|
/// and list directories freely, may write *only* into the per-project
|
|
/// plan directory under `$XDG_DATA_HOME/helexa-acp/plans/<project-id>/`,
|
|
/// and cannot run shell commands. Designed for "draft the
|
|
/// implementation plan, then I'll review and let you execute" flows.
|
|
pub const MODE_PLAN: &str = "plan";
|
|
|
|
/// State carried for a single ACP session.
|
|
///
|
|
/// Mutated under `Mutex<SessionState>`; never share a clone across
|
|
/// tasks expecting to see the same `cancel` token — clone the token
|
|
/// explicitly when handing it to the streaming task.
|
|
#[derive(Debug)]
|
|
pub struct SessionState {
|
|
/// Conversation history in chronological order (user / assistant
|
|
/// turns). The system prompt is *not* stored here — it's built
|
|
/// fresh per request so any cwd / config changes take effect.
|
|
pub history: Vec<Message>,
|
|
/// Working directory the client opened the session against. Used
|
|
/// by [`crate::prompt::build_system_prompt`] and (Stage 3) by
|
|
/// filesystem tools.
|
|
pub cwd: PathBuf,
|
|
/// Currently-selected model id. Format is either a bare model id
|
|
/// (resolved against the default endpoint) or `endpoint:model`.
|
|
/// Mutated by `session/set_model` in Stage 4; Stage 2 sets it
|
|
/// once at session creation and never changes it.
|
|
pub model_id: String,
|
|
/// Cancellation handle for the in-flight prompt, if any. A fresh
|
|
/// token is installed at the start of every `session/prompt`
|
|
/// request; `session/cancel` fires this one. Between prompts the
|
|
/// token is "spent" — firing it does nothing — which is fine,
|
|
/// `session/cancel` is a no-op when there's nothing to cancel.
|
|
pub cancel: CancellationToken,
|
|
/// Permission gating mode. Stage 3 advertises two ids in
|
|
/// `NewSessionResponse.modes`: [`MODE_DEFAULT`] (writes / bash
|
|
/// prompt the user) and [`MODE_BYPASS`] (auto-allow). Mutated by
|
|
/// `session/set_mode`.
|
|
pub mode_id: SessionModeId,
|
|
}
|
|
|
|
impl SessionState {
|
|
pub fn new(cwd: PathBuf, model_id: String) -> Self {
|
|
Self {
|
|
history: Vec::new(),
|
|
cwd,
|
|
model_id,
|
|
cancel: CancellationToken::new(),
|
|
mode_id: SessionModeId::new(MODE_DEFAULT),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Concurrent map of live sessions.
|
|
///
|
|
/// Cloning is cheap (`Arc` bump). Pass clones into every handler that
|
|
/// needs session access; never hold a clone across an `.await` that
|
|
/// could outlive the request.
|
|
pub type SessionStore = Arc<RwLock<HashMap<SessionId, Arc<Mutex<SessionState>>>>>;
|
|
|
|
/// Fresh, empty session store.
|
|
pub fn new_store() -> SessionStore {
|
|
Arc::new(RwLock::new(HashMap::new()))
|
|
}
|
|
|
|
/// Look up a session by id. Returns `None` if no such session is registered.
|
|
pub async fn get(store: &SessionStore, id: &SessionId) -> Option<Arc<Mutex<SessionState>>> {
|
|
store.read().await.get(id).cloned()
|
|
}
|
|
|
|
/// Register a fresh session. Overwrites any prior entry with the same id
|
|
/// (which should never happen — ids are uniquely generated by the agent).
|
|
pub async fn insert(store: &SessionStore, id: SessionId, state: SessionState) {
|
|
store.write().await.insert(id, Arc::new(Mutex::new(state)));
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::provider::{MessageContent, Role};
|
|
|
|
fn id(s: &str) -> SessionId {
|
|
SessionId::new(s)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn insert_then_get_round_trip() {
|
|
let store = new_store();
|
|
let state = SessionState::new(PathBuf::from("/tmp"), "m".into());
|
|
insert(&store, id("s1"), state).await;
|
|
let got = get(&store, &id("s1")).await.expect("session present");
|
|
let locked = got.lock().await;
|
|
assert_eq!(locked.cwd, PathBuf::from("/tmp"));
|
|
assert_eq!(locked.model_id, "m");
|
|
assert!(locked.history.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn missing_session_is_none() {
|
|
let store = new_store();
|
|
assert!(get(&store, &id("nope")).await.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn history_is_per_session() {
|
|
let store = new_store();
|
|
insert(
|
|
&store,
|
|
id("a"),
|
|
SessionState::new(PathBuf::from("/a"), "m".into()),
|
|
)
|
|
.await;
|
|
insert(
|
|
&store,
|
|
id("b"),
|
|
SessionState::new(PathBuf::from("/b"), "m".into()),
|
|
)
|
|
.await;
|
|
|
|
// Appending to a's history must not affect b's.
|
|
get(&store, &id("a"))
|
|
.await
|
|
.unwrap()
|
|
.lock()
|
|
.await
|
|
.history
|
|
.push(Message {
|
|
role: Role::User,
|
|
content: MessageContent::Text {
|
|
text: "hello".into(),
|
|
},
|
|
});
|
|
|
|
assert_eq!(
|
|
get(&store, &id("a"))
|
|
.await
|
|
.unwrap()
|
|
.lock()
|
|
.await
|
|
.history
|
|
.len(),
|
|
1
|
|
);
|
|
assert_eq!(
|
|
get(&store, &id("b"))
|
|
.await
|
|
.unwrap()
|
|
.lock()
|
|
.await
|
|
.history
|
|
.len(),
|
|
0
|
|
);
|
|
}
|
|
}
|