feat(helexa-acp): replay session history on session/load
Some checks failed
CI / Format (push) Successful in 31s
build-prerelease / Resolve version stamps (push) Successful in 48s
CI / Test (push) Failing after 1m19s
CI / Clippy (push) Successful in 2m56s
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 / Build cortex binary (push) Successful in 4m17s
build-prerelease / Package cortex RPM (push) Successful in 1m26s
build-prerelease / Build neuron-blackwell (push) Successful in 5m52s
build-prerelease / Build neuron-ampere (push) Successful in 7m49s
build-prerelease / Build neuron-ada (push) Successful in 5m8s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 2m57s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 3m0s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m45s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 1m1s

session/list and session/load were both implemented but clicking
a session in Zed's thread picker still left the agent panel
empty. Zed (and ACP clients in general) doesn't cache the
transcript for custom agent_servers entries — it only owns
conversation state for first-party agents. For custom agents the
expectation is that session/load returns successfully and the
agent then re-emits the conversation as a stream of session/update
notifications so the client can rebuild its view.

Implement that replay path:

- handle_load_session now returns (LoadSessionResponse, Vec<Message>)
  so the caller has the history available after the in-memory
  hydration finishes.
- The session/load closure responds to the request *first*, then
  spawns a task that calls replay_history off the dispatch loop.
- replay_history walks the persisted history and emits one
  session/update per turn:
    Role::User           → UserMessageChunk(text)
    Role::Assistant text → AgentMessageChunk(text)
    Role::Assistant tool → AgentMessageChunk for any accompanying
                           text + one ToolCall card per call (with
                           kind/title/raw_input rendered the same
                           way as the live dispatch path)
    Role::Tool result    → ToolCallUpdate matching the assistant's
                           call id, status: Completed, content set
                           to the result text
    Role::System         → skipped (system prompts aren't shown)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 16:02:00 +03:00
parent 0bbb9b752d
commit 0d841a4981

View File

@@ -141,13 +141,26 @@ impl Agent {
.on_receive_request( .on_receive_request(
{ {
let inner = inner.clone(); let inner = inner.clone();
async move |req: LoadSessionRequest, responder, _cx| match handle_load_session( async move |req: LoadSessionRequest, responder, cx: ConnectionTo<Client>| {
&inner, req, let session_id = req.session_id.clone();
) match handle_load_session(&inner, req).await {
.await Ok((resp, history)) => {
{ let send_result = responder.respond(resp);
Ok(resp) => responder.respond(resp), // History replay happens off the
Err(e) => responder.respond_with_internal_error(format!("{e:#}")), // dispatch loop so the load reply
// returns immediately. Zed receives
// the response, then sees a stream
// of session/update events that
// repopulate the chat panel.
let cx_clone = cx.clone();
let _ = cx.spawn(async move {
replay_history(&cx_clone, &session_id, &history);
Ok(())
});
send_result
}
Err(e) => responder.respond_with_internal_error(format!("{e:#}")),
}
} }
}, },
agent_client_protocol::on_receive_request!(), agent_client_protocol::on_receive_request!(),
@@ -297,15 +310,17 @@ async fn handle_new_session(
async fn handle_load_session( async fn handle_load_session(
inner: &AgentInner, inner: &AgentInner,
req: LoadSessionRequest, req: LoadSessionRequest,
) -> anyhow::Result<LoadSessionResponse> { ) -> anyhow::Result<(LoadSessionResponse, Vec<Message>)> {
if !req.cwd.is_absolute() { if !req.cwd.is_absolute() {
anyhow::bail!("session cwd must be absolute, got {}", req.cwd.display()); anyhow::bail!("session cwd must be absolute, got {}", req.cwd.display());
} }
let persisted = store::load(&req.session_id)?; let persisted = store::load(&req.session_id)?;
// Snapshot the values we need for logging + the response // Snapshot the values we need for logging, the response, and
// before we move pieces of `persisted` into `state`. // the post-load history replay before we move pieces of
// `persisted` into `state`.
let model_id = persisted.model_id.clone(); let model_id = persisted.model_id.clone();
let mode_id = persisted.mode_id.clone(); let mode_id = persisted.mode_id.clone();
let history_for_replay = persisted.history.clone();
let history_turns = persisted.history.len(); let history_turns = persisted.history.len();
let mut state = SessionState::new(req.cwd.clone(), persisted.model_id); let mut state = SessionState::new(req.cwd.clone(), persisted.model_id);
@@ -326,7 +341,158 @@ async fn handle_load_session(
SessionModeId::new(mode_id), SessionModeId::new(mode_id),
default_mode_state().available_modes, default_mode_state().available_modes,
); );
Ok(LoadSessionResponse::new().modes(modes)) Ok((LoadSessionResponse::new().modes(modes), history_for_replay))
}
/// Re-emit a session's persisted history as `session/update`
/// notifications so an ACP client (Zed) can render the prior chat
/// after a `session/load`. Without this, even a successful load
/// leaves the agent panel blank because Zed doesn't cache the
/// transcript client-side for custom agent_servers entries — that
/// caching only happens for first-party agents where Zed itself
/// owns the conversation state.
///
/// Mapping:
///
/// - `Role::User` text → `SessionUpdate::UserMessageChunk`
/// - `Role::Assistant` text → `SessionUpdate::AgentMessageChunk`
/// - `Role::Assistant` with tool calls → text chunk (if any) plus
/// one `ToolCall` event per call. We emit each with status =
/// `Completed` because the call already ran; the matching
/// `Role::Tool` result message is folded into the card's
/// content via a subsequent `ToolCallUpdate`.
/// - `Role::Tool` (tool result) → `ToolCallUpdate` carrying the
/// result text, keyed by `tool_call_id` so it lands on the
/// right card.
/// - `Role::System` → skipped; system prompts aren't rendered.
fn replay_history(cx: &ConnectionTo<Client>, session_id: &SessionId, history: &[Message]) {
use agent_client_protocol::schema::{
Content, ToolCall as AcpToolCall, ToolCallContent, ToolCallId, ToolCallStatus,
ToolCallUpdate, ToolCallUpdateFields,
};
fn tool_kind_for(name: &str) -> agent_client_protocol::schema::ToolKind {
use agent_client_protocol::schema::ToolKind;
match name {
"read_file" | "list_dir" => ToolKind::Read,
"write_file" | "edit_file" => ToolKind::Edit,
"bash" => ToolKind::Execute,
_ => ToolKind::Other,
}
}
fn title_for(name: &str, args_json: &str) -> String {
match (
name,
serde_json::from_str::<serde_json::Value>(args_json).ok(),
) {
("read_file", Some(v)) => format!(
"Read {}",
v.get("path").and_then(|p| p.as_str()).unwrap_or("?")
),
("write_file", Some(v)) => format!(
"Write {}",
v.get("path").and_then(|p| p.as_str()).unwrap_or("?")
),
("edit_file", Some(v)) => format!(
"Edit {}",
v.get("path").and_then(|p| p.as_str()).unwrap_or("?")
),
("list_dir", Some(v)) => format!(
"List {}",
v.get("path").and_then(|p| p.as_str()).unwrap_or("?")
),
("bash", Some(v)) => {
let cmd = v.get("command").and_then(|p| p.as_str()).unwrap_or("?");
let snippet = if cmd.len() > 60 {
format!("{}", &cmd[..60])
} else {
cmd.to_string()
};
format!("Run: {snippet}")
}
(other, _) => format!("Tool: {other}"),
}
}
let send = |update: SessionUpdate| {
let notif = SessionNotification::new(session_id.clone(), update);
if let Err(e) = cx.send_notification(notif) {
tracing::warn!(
error = %format!("{e:#}"),
"replay: failed to forward history event"
);
}
};
let mut total_events: usize = 0;
for msg in history {
match (msg.role, &msg.content) {
(Role::User, MessageContent::Text { text }) => {
send(SessionUpdate::UserMessageChunk(text_chunk(text.clone())));
total_events += 1;
}
(Role::Assistant, MessageContent::Text { text }) => {
send(SessionUpdate::AgentMessageChunk(text_chunk(text.clone())));
total_events += 1;
}
(Role::Assistant, MessageContent::ToolCalls { text, calls }) => {
if let Some(t) = text
&& !t.is_empty()
{
send(SessionUpdate::AgentMessageChunk(text_chunk(t.clone())));
total_events += 1;
}
for call in calls {
let raw_input = serde_json::from_str::<serde_json::Value>(&call.arguments)
.unwrap_or_else(|_| serde_json::Value::String(call.arguments.clone()));
let card = AcpToolCall::new(
ToolCallId::new(call.id.clone()),
title_for(&call.name, &call.arguments),
)
.kind(tool_kind_for(&call.name))
.status(ToolCallStatus::Completed)
.raw_input(raw_input);
send(SessionUpdate::ToolCall(card));
total_events += 1;
}
}
(
Role::Tool,
MessageContent::ToolResult {
tool_call_id,
content,
},
) => {
let update = ToolCallUpdate::new(
ToolCallId::new(tool_call_id.clone()),
ToolCallUpdateFields::new()
.status(ToolCallStatus::Completed)
.content(vec![ToolCallContent::Content(Content::new(
ContentBlock::Text(TextContent::new(content.clone())),
))]),
);
send(SessionUpdate::ToolCallUpdate(update));
total_events += 1;
}
(Role::System, _) => {
// System prompts aren't shown in the chat panel.
}
(role, content) => {
tracing::debug!(
?role,
?content,
"replay: unrecognised (role, content) shape; skipping"
);
}
}
}
tracing::info!(
session_id = %session_id.0,
events = total_events,
history_turns = history.len(),
"session history replayed to client"
);
} }
/// Enumerate persisted sessions for the `session/list` ACP method. /// Enumerate persisted sessions for the `session/list` ACP method.