1use super::*;
2use crate::ambient;
3
4pub fn register(engine: &Arc<WshRpcEngine>, state: &AppState) {
5 register_session_activity_summary(engine, state);
6 register_session_next_prompt_suggestion(engine, state);
7 register_session_resume_preflight_handler(engine, state);
8 register_session_archive_handler(engine, state);
9 register_session_restore_handler(engine, state);
10 register_session_export_handler(engine, state);
11}
12
13fn register_session_resume_preflight_handler(engine: &Arc<WshRpcEngine>, state: &AppState) {
20 let mstore = state.mstore.clone();
21 let id_store = state.id_store.clone();
24 let identity_store = state.identity_store.clone();
25 let filestore = state.filestore.clone();
27
28 engine.register_typed(
29 COMMAND_SESSION_RESUME_PREFLIGHT,
30 move |cmd: CommandSessionResumePreflightData, _ctx| {
31 let mstore = mstore.clone();
32 let id_store = id_store.clone();
33 let identity_store = identity_store.clone();
34 let filestore = filestore.clone();
35 async move {
36
37 let block = mstore
38 .must_get::<Block>(&cmd.block_id)
39 .map_err(|e| format!("session:resume_preflight: {e}"))?;
40
41 let bound_config_dir = crate::identity::resolver::resolve_bound_oauth_config_dir(
42 &mstore,
43 &id_store,
44 &identity_store,
45 &cmd.block_id,
46 );
47 let mut input = preflight_input_from_meta(&block.meta, bound_config_dir);
48 let session_id_field = obj::meta_get_string(&block.meta, "agent:session_id_field", "session_id");
49 let block_id = cmd.block_id.clone();
50
51 let result = tokio::task::spawn_blocking(move || {
56 if input.session_id.is_empty() {
57 input.history_session_id =
58 crate::backend::blockcontroller::persistent::pane_history_session_id(
59 Some(&filestore),
60 Some(&mstore),
61 &block_id,
62 &session_id_field,
63 )
64 .unwrap_or_default();
65 }
66 let uid = mstore
70 .instance_get_active_for_block(&block_id)
71 .ok()
72 .flatten()
73 .map(|instance| instance.id)
74 .filter(|uid| !uid.trim().is_empty());
75 let gfs = crate::backend::agent_session::global_transcript_store();
76 if let (Some(uid), Some(gfs)) = (uid.as_deref().map(str::trim), gfs) {
77 input.chain_head = crate::backend::continuity_segments::chain_head(gfs, uid)
78 .map(crate::backend::continuity_segments::with_legacy_identity);
79 if input.chain_head.is_none() {
82 input.fork_source = crate::backend::continuity_segments::fork_source_head(&mstore, gfs, uid);
83 }
84 }
85 input.identity_key =
86 crate::identity::account_email::identity_key_from_oauth_dir("claude", &input.config_dir);
87 crate::backend::resume_preflight::preflight(&input)
88 })
89 .await
90 .map_err(|e| format!("session:resume_preflight: {e}"))?;
91
92 tracing::info!(
93 block_id = %cmd.block_id,
94 verdict = %result.verdict.as_str(),
95 duration_ms = result.duration_ms,
96 "session:resume_preflight"
97 );
98
99 Ok(SessionResumePreflightResult {
100 block_id: cmd.block_id,
101 verdict: result.verdict.as_str().to_string(),
102 session_id: result.session_id,
103 recoverable_session_id: result.recoverable_session_id,
104 steps: result
105 .steps
106 .into_iter()
107 .map(|s| ResumePreflightStep {
108 id: s.id.to_string(),
109 label: s.label,
110 ok: s.ok,
111 detail: s.detail,
112 duration_ms: s.duration_ms,
113 })
114 .collect(),
115 duration_ms: result.duration_ms,
116 })
117 }
118 },
119 );
120}
121
122fn preflight_input_from_meta(
162 meta: &crate::backend::obj::MetaMapType,
163 bound_config_dir: Option<std::path::PathBuf>,
164) -> crate::backend::resume_preflight::PreflightInput {
165 let config_dir = match bound_config_dir {
168 Some(dir) => dir.to_string_lossy().to_string(),
169 None => crate::backend::blockcontroller::cmd_env_of(meta)
170 .remove("CLAUDE_CONFIG_DIR")
171 .unwrap_or_default(),
172 };
173
174 crate::backend::resume_preflight::PreflightInput {
175 resume_flag: obj::meta_get_string(meta, "agent:resume_flag", "--resume"),
176 session_id: obj::meta_get_string(meta, "agent:sessionid", ""),
177 working_dir: obj::meta_get_string(meta, "cmd:cwd", ""),
178 config_dir,
179 history_session_id: String::new(),
180 chain_head: None,
181 identity_key: None,
182 fork_source: None,
183 }
184}
185
186fn register_session_archive_handler(engine: &Arc<WshRpcEngine>, state: &AppState) {
187 let mstore = state.mstore.clone();
188 let filestore = state.filestore.clone();
189 let broker = state.broker.clone();
190
191 engine.register_typed(
192 COMMAND_SESSION_ARCHIVE,
193 move |cmd: CommandSessionArchiveData, _ctx| {
194 let mstore = mstore.clone();
195 let filestore = filestore.clone();
196 let broker = broker.clone();
197 async move {
198
199 tracing::info!(block_id = %cmd.block_id, "session:archive");
200
201 let archive_dir = session_archive::default_archive_dir()
202 .ok_or_else(|| "cannot determine home directory".to_string())?;
203
204 let (archived_bytes, archived_at) =
208 crate::backend::blockcontroller::shell::with_transcript_order(&cmd.block_id, || {
209 let archived = session_archive::archive_session_output(
210 &mstore,
211 &filestore,
212 &cmd.block_id,
213 &archive_dir,
214 )?;
215 crate::backend::blockcontroller::shell::publish_transcript_changed(
217 &broker,
218 &cmd.block_id,
219 crate::backend::mps::FILE_OP_DELETE,
220 &filestore,
221 );
222 Ok::<_, String>(archived)
223 })?;
224
225 Ok(SessionArchiveResult {
226 block_id: cmd.block_id,
227 archived_bytes,
228 archived_at,
229 })
230 }
231 },
232 );
233}
234
235fn register_session_restore_handler(engine: &Arc<WshRpcEngine>, state: &AppState) {
236 let mstore = state.mstore.clone();
237 let filestore = state.filestore.clone();
238 let broker = state.broker.clone();
239
240 engine.register_typed(
241 COMMAND_SESSION_RESTORE,
242 move |cmd: CommandSessionRestoreData, _ctx| {
243 let mstore = mstore.clone();
244 let filestore = filestore.clone();
245 let broker = broker.clone();
246 async move {
247
248 tracing::info!(block_id = %cmd.block_id, "session:restore");
249
250 let restored_bytes =
256 crate::backend::blockcontroller::shell::with_transcript_order(&cmd.block_id, || {
257 let restored = session_archive::restore_session_output(
258 &mstore,
259 &filestore,
260 &cmd.block_id,
261 )?;
262 crate::backend::blockcontroller::shell::publish_transcript_changed(
264 &broker,
265 &cmd.block_id,
266 crate::backend::mps::FILE_OP_REPLACE,
267 &filestore,
268 );
269 Ok::<_, String>(restored)
270 })?;
271
272 Ok(SessionRestoreResult {
273 block_id: cmd.block_id,
274 restored_bytes,
275 })
276 }
277 },
278 );
279}
280
281fn register_session_export_handler(engine: &Arc<WshRpcEngine>, state: &AppState) {
282 let mstore = state.mstore.clone();
283 let filestore = state.filestore.clone();
284
285 engine.register_typed(
286 COMMAND_SESSION_EXPORT,
287 move |cmd: CommandSessionExportData, _ctx| {
288 let mstore = mstore.clone();
289 let filestore = filestore.clone();
290 async move {
291
292 tracing::info!(block_id = %cmd.block_id, "session:export");
293
294 let (raw_bytes, line_count) = session_archive::read_session_output(
295 &mstore,
296 &filestore,
297 &cmd.block_id,
298 )?;
299
300 let byte_count = raw_bytes.len() as u64;
301 let content = base64::engine::general_purpose::STANDARD.encode(&raw_bytes);
302
303 Ok(SessionExportResult {
304 content,
305 line_count,
306 byte_count,
307 })
308 }
309 },
310 );
311}
312
313fn empty_summary_result() -> ActivitySummaryResult {
314 ActivitySummaryResult { summary: String::new(), tokens: None }
315}
316
317fn register_session_activity_summary(engine: &Arc<WshRpcEngine>, state: &AppState) {
318 let mstore = state.mstore.clone();
319 let filestore = state.filestore.clone();
320 let event_bus = state.event_bus.clone();
321
322 engine.register_typed(
323 COMMAND_SESSION_ACTIVITY_SUMMARY,
324 move |cmd: CommandActivitySummaryData, _ctx| {
325 let mstore = mstore.clone();
326 let filestore = filestore.clone();
327 let event_bus = event_bus.clone();
328 async move {
329 let Some(mut slot) = ambient::call::admit(
337 &ambient::purpose::ACTIVITY_SUMMARY,
338 cmd.block_id.clone(),
339 cmd.generation,
340 )
341 .await
342 else {
343 return Ok(empty_summary_result());
344 };
345
346 let word_target = cmd.word_target.unwrap_or(7).max(3).min(20);
347
348 let block: Block = mstore
349 .get(&cmd.block_id)
350 .map_err(|e| format!("session:activity_summary: {e}"))?
351 .ok_or_else(|| format!("BLOCK_NOT_FOUND: {}", cmd.block_id))?;
352
353 let user_message = cmd
361 .user_message
362 .as_deref()
363 .map(str::trim)
364 .filter(|s| !s.is_empty())
365 .map(str::to_string);
366 let activity = match user_message {
367 Some(_) => None,
368 None => ambient::digest::read_recent_activity_digest(&filestore, &cmd.block_id),
369 };
370
371 let stored_title = obj::meta_get_string(&block.meta, ambient::title::META_TITLE, "");
376 let current_title = if ambient::validate::is_usable_title(&stored_title) {
377 stored_title
378 } else {
379 String::new()
380 };
381
382 if user_message.is_none() && activity.is_none() && current_title.is_empty() {
385 slot.abandon(ambient::outcome::Outcome::EmptyDigest);
386 return Ok(empty_summary_result());
387 }
388
389 let Some(target) = ambient::call::CliTarget::from_meta(&block.meta) else {
390 tracing::debug!(block_id = %cmd.block_id, "session:activity_summary: no CLI path in meta");
391 return Ok(empty_summary_result());
392 };
393
394 let prompt = ambient::prompt::build_session_title_prompt(
395 ¤t_title,
396 user_message.as_deref(),
397 activity.as_deref(),
398 word_target,
399 );
400 let limits = ambient::validate::title_limits(word_target);
401 let reply = slot
402 .run_held(&target, &prompt, |raw| {
403 ambient::reply::judge_line(raw, |t| ambient::validate::accept_line(t, &limits))
404 })
405 .await;
406
407 let mut stored = false;
414 if !reply.text.is_empty() {
415 let still_current = || !slot.is_superseded();
416 match ambient::title::store_title(&mstore, &cmd.block_id, &reply.text, ambient::title::Replace::IfNews, still_current) {
417 Ok(true) => {
418 stored = true;
419 crate::backend::blockcontroller::core::broadcast_block_update(&mstore, &event_bus, &cmd.block_id);
420 }
421 Ok(false) => {}
422 Err(e) => tracing::warn!(block_id = %cmd.block_id, error = %e, "session:activity_summary: could not store the title"),
423 }
424 }
425 Ok(ActivitySummaryResult { summary: if stored { reply.text } else { String::new() }, tokens: reply.tokens })
426 }
427 },
428 );
429}
430
431fn empty_suggestion_result() -> NextPromptSuggestionResult {
432 NextPromptSuggestionResult { suggestion: String::new(), tokens: None }
433}
434
435fn register_session_next_prompt_suggestion(engine: &Arc<WshRpcEngine>, state: &AppState) {
436 let mstore = state.mstore.clone();
437 let filestore = state.filestore.clone();
438
439 engine.register_typed(
440 COMMAND_SESSION_NEXT_PROMPT_SUGGESTION,
441 move |cmd: CommandNextPromptSuggestionData, _ctx| {
442 let mstore = mstore.clone();
443 let filestore = filestore.clone();
444 async move {
445 let Some(slot) = ambient::call::admit(
450 &ambient::purpose::NEXT_PROMPT_SUGGESTION,
451 cmd.block_id.clone(),
452 cmd.generation,
453 )
454 .await
455 else {
456 return Ok(empty_suggestion_result());
457 };
458
459 let block: Block = mstore
460 .get(&cmd.block_id)
461 .map_err(|e| format!("session:next_prompt_suggestion: {e}"))?
462 .ok_or_else(|| format!("BLOCK_NOT_FOUND: {}", cmd.block_id))?;
463
464 let activity = match cmd.activity.filter(|entries| !entries.is_empty()) {
471 Some(entries) => ambient::digest::activity_from_entries(&cmd.block_id, entries)
472 .filter(|a| a.ending != ambient::digest::TurnEnding::UserLast)
473 .or_else(|| ambient::digest::read_recent_activity(&filestore, &cmd.block_id)),
474 None => ambient::digest::read_recent_activity(&filestore, &cmd.block_id),
475 };
476 let Some(activity) = activity else {
477 slot.abandon(ambient::outcome::Outcome::EmptyDigest);
478 return Ok(empty_suggestion_result());
479 };
480 if activity.ending != ambient::digest::TurnEnding::Statement {
485 slot.abandon(ambient::outcome::Outcome::Gated);
486 return Ok(empty_suggestion_result());
487 }
488
489 let Some(target) = ambient::call::CliTarget::from_meta(&block.meta) else {
490 tracing::debug!(block_id = %cmd.block_id, "session:next_prompt_suggestion: no CLI path in meta");
491 return Ok(empty_suggestion_result());
492 };
493
494 let prompt = ambient::prompt::build_next_prompt_prompt(&activity.text);
495 let reply = slot
496 .run(&target, &prompt, |raw| ambient::reply::judge_line(raw, ambient::validate::accept_next_prompt))
497 .await;
498
499 Ok(NextPromptSuggestionResult { suggestion: reply.text, tokens: reply.tokens })
502 }
503 },
504 );
505}
506
507#[cfg(test)]
508mod preflight_input_tests {
509 use super::preflight_input_from_meta;
510
511 fn meta_of(pairs: &[(&str, serde_json::Value)]) -> crate::backend::obj::MetaMapType {
520 let mut m = crate::backend::obj::MetaMapType::new();
521 for (k, v) in pairs {
522 m.insert(k.to_string(), v.clone());
523 }
524 m
525 }
526
527 #[test]
532 fn absent_resume_flag_defaults_to_the_same_value_the_spawn_path_uses() {
533 let input = preflight_input_from_meta(&meta_of(&[]), None);
534 assert_eq!(
535 input.resume_flag, "--resume",
536 "must match agent_handlers/input.rs:400's default, or the preflight reports Unknown for panes whose spawn really will --resume",
537 );
538 }
539
540 #[test]
544 fn an_explicitly_empty_resume_flag_is_preserved_not_defaulted() {
545 let input = preflight_input_from_meta(&meta_of(&[("agent:resume_flag", serde_json::json!(""))]), None);
546 assert_eq!(input.resume_flag, "");
547 }
548
549 #[test]
550 fn an_explicit_resume_flag_is_read_verbatim() {
551 let input = preflight_input_from_meta(&meta_of(&[("agent:resume_flag", serde_json::json!("-r"))]), None);
552 assert_eq!(input.resume_flag, "-r");
553 }
554
555 #[test]
556 fn config_dir_is_read_out_of_the_cmd_env_map() {
557 let input = preflight_input_from_meta(&meta_of(&[(
558 "cmd:env",
559 serde_json::json!({ "CLAUDE_CONFIG_DIR": "/home/dev/.claude", "OTHER": "x" }),
560 )]), None);
561 assert_eq!(input.config_dir, "/home/dev/.claude");
562 }
563
564 #[test]
568 fn a_missing_config_dir_is_empty_rather_than_a_guess() {
569 assert_eq!(preflight_input_from_meta(&meta_of(&[]), None).config_dir, "");
570 assert_eq!(
571 preflight_input_from_meta(&meta_of(&[("cmd:env", serde_json::json!({ "PATH": "/usr/bin" }))]), None)
572 .config_dir,
573 ""
574 );
575 assert_eq!(
576 preflight_input_from_meta(&meta_of(&[("cmd:env", serde_json::json!("not-an-object"))]), None).config_dir,
577 ""
578 );
579 }
580
581 #[test]
582 fn session_id_and_working_dir_default_to_empty() {
583 let input = preflight_input_from_meta(&meta_of(&[]), None);
584 assert_eq!(input.session_id, "");
585 assert_eq!(input.working_dir, "");
586
587 let input = preflight_input_from_meta(&meta_of(&[
588 ("agent:sessionid", serde_json::json!("sid-1")),
589 ("cmd:cwd", serde_json::json!("/work/dir")),
590 ]), None);
591 assert_eq!(input.session_id, "sid-1");
592 assert_eq!(input.working_dir, "/work/dir");
593 }
594
595 #[test]
599 fn a_bound_identity_config_dir_overrides_the_cmd_env_snapshot() {
600 let meta = meta_of(&[(
601 "cmd:env",
602 serde_json::json!({ "CLAUDE_CONFIG_DIR": "/stale/from/spawn/snapshot" }),
603 )]);
604 let input = preflight_input_from_meta(
605 &meta,
606 Some(std::path::PathBuf::from("/identities/acct-7/claude")),
607 );
608 assert_eq!(
609 input.config_dir, "/identities/acct-7/claude",
610 "the identity-resolved dir is where the CLI will actually look",
611 );
612 }
613
614 #[test]
616 fn a_bound_identity_config_dir_is_used_with_no_cmd_env_present() {
617 let input = preflight_input_from_meta(
618 &meta_of(&[]),
619 Some(std::path::PathBuf::from("/identities/acct-9/claude")),
620 );
621 assert_eq!(input.config_dir, "/identities/acct-9/claude");
622 }
623
624 #[test]
628 fn an_unbound_pane_still_falls_back_to_cmd_env() {
629 let meta = meta_of(&[("cmd:env", serde_json::json!({ "CLAUDE_CONFIG_DIR": "/home/dev/.claude" }))]);
630 assert_eq!(preflight_input_from_meta(&meta, None).config_dir, "/home/dev/.claude");
631 }
632}