72
use agent_client_protocol::schema::v1::{
73
AgentCapabilities, AuthMethod, AuthMethodAgent, AuthenticateRequest, AuthenticateResponse,
74
AvailableCommand, AvailableCommandInput, AvailableCommandsUpdate, CancelNotification,
75
- ConfigOptionUpdate, ContentBlock, ContentChunk, EmbeddedResourceResource, ForkSessionRequest,
76
- ForkSessionResponse, Implementation, InitializeRequest, InitializeResponse, LoadSessionRequest,
77
- LoadSessionResponse, Meta, NewSessionRequest, NewSessionResponse, PermissionOption,
78
- PermissionOptionKind, Plan, PlanEntry, PlanEntryPriority, PlanEntryStatus, PromptRequest,
79
- PromptResponse, RequestPermissionOutcome, RequestPermissionRequest,
75
+ ConfigOptionUpdate, ContentBlock, ContentChunk, DeleteSessionRequest, DeleteSessionResponse,
76
+ EmbeddedResourceResource, ForkSessionRequest, ForkSessionResponse, Implementation,
77
+ InitializeRequest, InitializeResponse, ListSessionsRequest, ListSessionsResponse,
78
+ LoadSessionRequest, LoadSessionResponse, Meta, NewSessionRequest, NewSessionResponse,
79
+ PermissionOption, PermissionOptionKind, Plan, PlanEntry, PlanEntryPriority, PlanEntryStatus,
80
+ PromptRequest, PromptResponse, RequestPermissionOutcome, RequestPermissionRequest,
81
SessionAdditionalDirectoriesCapabilities, SessionCapabilities, SessionConfigOption,
82
SessionConfigOptionCategory, SessionConfigSelectOption, SessionConfigValueId,
82
- SessionForkCapabilities, SessionId, SessionNotification, SessionUpdate,
83
- SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, StopReason, ToolCall,
84
- ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind, UnstructuredCommandInput,
83
+ SessionDeleteCapabilities, SessionForkCapabilities, SessionId, SessionInfo,
84
+ SessionListCapabilities, SessionNotification, SessionUpdate, SetSessionConfigOptionRequest,
85
+ SetSessionConfigOptionResponse, StopReason, ToolCall, ToolCallStatus, ToolCallUpdate,
86
+ ToolCallUpdateFields, ToolKind, UnstructuredCommandInput,
87
};
88
use agent_client_protocol::{Agent, ByteStreams, Client, ConnectionTo, Responder};
89
use onde::inference::{ChatEngine, GgufModelConfig};
366
}
367
}
368
369
+/// First user message, trimmed to one short line — the thread label a client
370
+/// shows in its history list.
371
+fn title_from_history(history: &[serde_json::Value]) -> Option<String> {
372
+ let text = history
373
+ .iter()
374
+ .find(|message| message["role"] == "user")
375
+ .map(history_message_text)?;
376
+ let collapsed = text.split_whitespace().collect::<Vec<_>>().join(" ");
377
+ if collapsed.is_empty() {
378
+ return None;
379
+ }
380
+ const MAX_LEN: usize = 60;
381
+ if collapsed.chars().count() <= MAX_LEN {
382
+ return Some(collapsed);
383
+ }
384
+ let mut truncated: String = collapsed.chars().take(MAX_LEN).collect();
385
+ if let Some(last_space) = truncated.rfind(' ') {
386
+ truncated.truncate(last_space);
387
+ }
388
+ truncated.push('…');
389
+ Some(truncated)
390
+}
391
+
392
+/// RFC 3339 formatting for `SessionInfo.updated_at`, sourced from the
393
+/// transcript file's mtime rather than a separately tracked timestamp.
394
+fn rfc3339(time: std::time::SystemTime) -> String {
395
+ chrono::DateTime::<chrono::Utc>::from(time).to_rfc3339()
396
+}
397
+
398
/// Rebuild the `session/update` notifications that re-draw a saved conversation
399
/// in the client.
400
///
1287
AgentCapabilities::default()
1288
.load_session(true)
1289
.session_capabilities(
1259
- SessionCapabilities::new()
1260
- .fork(SessionForkCapabilities::new())
1261
- // Without this, a client that has several directories
1262
- // open never sends the extra ones: Zed drops every root
1263
- // but the first and tells the user the agent has no
1264
- // multi-root support. See `workspace.rs`.
1265
- .additional_directories(SessionAdditionalDirectoriesCapabilities::new()),
1266
- ),
1290
+ SessionCapabilities::new()
1291
+ .fork(SessionForkCapabilities::new())
1292
+ // Without this, a client that has several directories
1293
+ // open never sends the extra ones: Zed drops every root
1294
+ // but the first and tells the user the agent has no
1295
+ // multi-root support. See `workspace.rs`.
1296
+ .additional_directories(SessionAdditionalDirectoriesCapabilities::new())
1297
+ .list(SessionListCapabilities::new())
1298
+ .delete(SessionDeleteCapabilities::new()),
1299
+ ),
1300
)
1301
.meta(initialize_meta()))
1302
}
1367
additional_roots
1368
}
1369
1370
+ /// Serve `session/list`: a pure filesystem read, no engine or backend
1371
+ /// access, so it stays answerable while a turn is in flight (registered
1372
+ /// inline, not in the `turn_lock` group).
1373
+ ///
1374
+ /// Sessions with no metadata sidecar are skipped — that covers transcripts
1375
+ /// saved before this existed and the singleton `tui`/`headless` entries,
1376
+ /// neither of which has a meaningful cwd for an editor's thread list.
1377
+ async fn handle_list_sessions(
1378
+ &self,
1379
+ args: ListSessionsRequest,
1380
+ ) -> agent_client_protocol::Result<ListSessionsResponse> {
1381
+ let sessions = session_store::list()
1382
+ .into_iter()
1383
+ .filter_map(|entry| {
1384
+ let meta = entry.meta?;
1385
+ if let Some(cwd) = &args.cwd
1386
+ && *cwd != meta.cwd
1387
+ {
1388
+ return None;
1389
+ }
1390
+ Some(
1391
+ SessionInfo::new(SessionId::new(entry.id), meta.cwd)
1392
+ .additional_directories(meta.additional_directories)
1393
+ .title(meta.title)
1394
+ .updated_at(rfc3339(entry.modified)),
1395
+ )
1396
+ })
1397
+ .collect();
1398
+
1399
+ Ok(ListSessionsResponse::new(sessions))
1400
+ }
1401
+
1402
+ /// Serve `session/delete`: removes the transcript and its metadata
1403
+ /// sidecar, and drops any permission grants so they don't outlive the
1404
+ /// thread. Registered in the `turn_lock` group — without the lock a
1405
+ /// delete could land between a turn's inference and its end-of-turn
1406
+ /// `session_store::save`, and the save would resurrect the file the user
1407
+ /// just removed.
1408
+ async fn handle_delete_session(
1409
+ &self,
1410
+ args: DeleteSessionRequest,
1411
+ ) -> agent_client_protocol::Result<DeleteSessionResponse> {
1412
+ log::info!("delete_session: id={}", args.session_id);
1413
+ session_store::delete(&args.session_id.to_string());
1414
+ permissions::reset_session(&args.session_id.to_string());
1415
+ Ok(DeleteSessionResponse::new())
1416
+ }
1417
+
1418
async fn handle_load_session(
1419
&self,
1420
cx: &ConnectionTo<Client>,
2091
log::warn!("prompt({}) session save failed: {error}", session_id);
2092
}
2093
2094
+ // Metadata sidecar for `session/list`. Written every turn — cheap, and
2095
+ // self-healing for sessions that predate this change.
2096
+ if let Some(cwd) = self.session_cwd.lock().ok().and_then(|guard| guard.clone()) {
2097
+ let meta = session_store::SessionMeta {
2098
+ cwd,
2099
+ additional_directories: workspace::additional_roots(),
2100
+ title: title_from_history(&snapshot),
2101
+ };
2102
+ if let Err(error) = session_store::save_meta(&session_id.to_string(), &meta) {
2103
+ log::warn!("prompt({}) session meta save failed: {error}", session_id);
2104
+ }
2105
+ }
2106
+
2107
log::info!("prompt({}) complete — {} tool round(s)", session_id, round);
2108
Ok(PromptResponse::new(StopReason::EndTurn))
2109
}
3734
},
3735
agent_client_protocol::on_receive_request!(),
3736
)
3737
+ .on_receive_request(
3738
+ {
3739
+ let state = Arc::clone(&state);
3740
+ async move |req: ListSessionsRequest, responder, _cx: ConnectionTo<Client>| {
3741
+ handle_response(responder, state.handle_list_sessions(req).await)
3742
+ }
3743
+ },
3744
+ agent_client_protocol::on_receive_request!(),
3745
+ )
3746
// Turn-affecting handlers below run in spawned tasks, serialized by
3747
// `turn_lock`, so the dispatch loop stays free to route client
3748
// responses (permission answers) while a turn is in flight. Awaiting a
3823
},
3824
agent_client_protocol::on_receive_request!(),
3825
)
3826
+ .on_receive_request(
3827
+ {
3828
+ let state = Arc::clone(&state);
3829
+ async move |req: DeleteSessionRequest, responder, cx: ConnectionTo<Client>| {
3830
+ let state = Arc::clone(&state);
3831
+ cx.spawn(async move {
3832
+ let _turn = state.turn_lock.lock().await;
3833
+ handle_response(responder, state.handle_delete_session(req).await)
3834
+ })
3835
+ }
3836
+ },
3837
+ agent_client_protocol::on_receive_request!(),
3838
+ )
3839
.on_receive_notification(
3840
{
3841
let state = Arc::clone(&state);
4082
assert!(multi.contains("absolute paths"));
4083
}
4084
4085
+ #[test]
4086
+ fn title_from_history_picks_the_first_user_message() {
4087
+ let history = vec![
4088
+ serde_json::json!({ "role": "system", "content": "project context" }),
4089
+ serde_json::json!({ "role": "user", "content": " what do\nthe notes say? " }),
4090
+ serde_json::json!({ "role": "assistant", "content": "The notes say hello." }),
4091
+ serde_json::json!({ "role": "user", "content": "a later question" }),
4092
+ ];
4093
+ assert_eq!(
4094
+ title_from_history(&history),
4095
+ Some("what do the notes say?".to_string())
4096
+ );
4097
+
4098
+ assert_eq!(title_from_history(&[]), None);
4099
+
4100
+ let no_user = vec![serde_json::json!({ "role": "system", "content": "just context" })];
4101
+ assert_eq!(title_from_history(&no_user), None);
4102
+
4103
+ let blank = vec![serde_json::json!({ "role": "user", "content": " " })];
4104
+ assert_eq!(title_from_history(&blank), None);
4105
+ }
4106
+
4107
+ #[test]
4108
+ fn title_from_history_truncates_long_text_on_a_word_boundary() {
4109
+ let text = "a".repeat(50) + " " + &"b".repeat(50);
4110
+ let history = vec![serde_json::json!({ "role": "user", "content": text })];
4111
+ let title = title_from_history(&history).unwrap();
4112
+ assert!(title.ends_with('…'), "{title}");
4113
+ assert!(title.chars().count() <= 61, "{title}");
4114
+ assert!(
4115
+ title.starts_with(&"a".repeat(50)),
4116
+ "must truncate on a word boundary rather than mid-word: {title}"
4117
+ );
4118
+ }
4119
+
4120
+ #[test]
4121
+ fn rfc3339_formats_a_known_epoch_second() {
4122
+ let time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000);
4123
+ assert_eq!(rfc3339(time), "2023-11-14T22:13:20+00:00");
4124
+ }
4125
+
4126
#[test]
4127
fn history_replay_redraws_the_conversation_for_the_client() {
4128
let history = vec![