1use std::collections::HashMap;
93use std::path::{Path, PathBuf};
94use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
95use std::sync::{Arc, Mutex as StdMutex, MutexGuard};
96
97use serde::{Deserialize, Serialize};
98use serde_json::{json, Value};
99use tokio::sync::{mpsc, oneshot};
100
101use super::discussion_record::DiscussionRecord;
102use crate::assistant::governance::AssistantDurability;
103use crate::assistant::{
104 bind_default_substrate, build_assistant_runtime_with_tools, prompt, AssistantConfig,
105 AssistantService,
106};
107use crate::coder::native_loop::TurnGenerator;
108use crate::handler::JsonRpcMessage;
109use crate::session::{ClientSession, ServerState, WsChannel};
110
111const DISCUSSION_MUTATION_REFUSAL: &str = "This conversation stage is read-only; this is not a file-permission problem or a user refusing the requested change. Use prepare_coding_task with the requested change and constraints so the user can review it and start coding. Do not change file permissions or ask the user to do so.";
112
113const DISCUSS_MAX_TURNS: u32 = 12;
116
117const PROMOTE_MAX_ATTEMPTS: u32 = 3;
121
122pub(crate) const MAX_OPEN_DISCUSSIONS: usize = 8;
133
134const DISCUSSION_IDLE_TTL_SECS: u64 = 60 * 60;
138
139const DISCUSS_EVENT_BUFFER_MAX: usize = 2000;
143
144const TRANSCRIPT_MAX_TURNS: usize = 40;
148
149const DISTILL_WINDOW_TURNS: usize = 12;
152
153const DISCUSS_MESSAGE_MAX_BYTES: usize = 64 * 1024;
162
163const DISCUSS_SUBSCRIBER_QUEUE: usize = DISCUSS_EVENT_BUFFER_MAX + 128;
170
171const DISCUSS_SEND_TIMEOUT: std::time::Duration = crate::coder::rpc::FANOUT_WRITE_TIMEOUT;
175
176pub type DiscussionMap = HashMap<String, Arc<DiscussionEntry>>;
178
179fn lock<T>(m: &StdMutex<T>) -> MutexGuard<'_, T> {
187 m.lock().unwrap_or_else(|e| e.into_inner())
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct DiscussEvent {
194 pub discussion_id: String,
195 pub seq: u64,
196 pub ts: u64,
197 #[serde(flatten)]
198 pub kind: DiscussEventKind,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
204#[serde(tag = "type", rename_all = "snake_case")]
205pub enum DiscussEventKind {
206 UserMessage {
207 text: String,
208 },
209 AssistantDelta {
211 text: String,
212 },
213 AssistantMessage {
215 text: String,
216 },
217 ToolCall {
218 tool: String,
219 params_preview: String,
220 },
221 ToolResult {
222 tool: String,
223 ok: bool,
224 preview: String,
225 },
226 TaskPrepared {
227 proposed_intent: String,
228 constraints: Vec<String>,
229 },
230 TurnComplete {},
231 Error {
232 message: String,
233 },
234}
235
236enum StreamCmd {
239 Emit(DiscussEventKind, oneshot::Sender<u64>),
240 Attach {
241 client_id: String,
242 channel: Arc<WsChannel>,
243 from_seq: u64,
244 replayed: oneshot::Sender<u64>,
245 },
246 Detach(String),
247}
248
249#[derive(Default)]
262struct TurnSlot {
263 closed: bool,
267 handle: Option<tokio::task::JoinHandle<()>>,
268}
269
270struct InFlightGuard(Arc<DiscussionEntry>);
283
284impl Drop for InFlightGuard {
285 fn drop(&mut self) {
286 self.0.in_flight.store(false, Ordering::SeqCst);
287 self.0.touch();
288 }
289}
290
291struct TurnRecordGuard {
305 entry: Arc<DiscussionEntry>,
306 text: String,
307 dispatched: bool,
308}
309
310impl Drop for TurnRecordGuard {
311 fn drop(&mut self) {
312 if !self.dispatched {
313 self.entry.rollback_turn("Operator", &self.text);
314 }
315 }
316}
317
318pub struct DiscussionEntry {
320 pub id: String,
321 pub repo: PathBuf,
323 pub repo_summary: String,
326 project_context: String,
327 pub created_at: u64,
328 pub model: Option<String>,
329 owner_client_id: String,
332 principal: String,
333 record_root: PathBuf,
334 pub events: Arc<tokio::sync::Mutex<Vec<DiscussEvent>>>,
337 cmds: mpsc::UnboundedSender<StreamCmd>,
339 turns: AtomicU64,
341 in_flight: AtomicBool,
346 start_lock: Arc<tokio::sync::Mutex<()>>,
347 last_active: AtomicU64,
349 turn_task: StdMutex<TurnSlot>,
352 _slot: tokio::sync::OwnedSemaphorePermit,
355 service: Arc<AssistantService>,
357 durability: Arc<crate::assistant::durability::LocalAssistantDurability>,
358 generator: Arc<dyn TurnGenerator>,
361 transcript: StdMutex<Vec<(&'static str, String)>>,
365 last_promote: StdMutex<Option<(String, Vec<String>)>>,
369 task_proposal: super::task_proposal::PreparedTask,
370}
371
372impl DiscussionEntry {
373 fn save_pending_task(&self, proposal: Option<(String, Vec<String>)>) -> Result<(), String> {
374 let mut record = DiscussionRecord::load(&self.record_root, &self.id, &self.principal)?;
375 record.pending_task = proposal;
376 record.save_model(&self.record_root)
377 }
378
379 pub fn constraints(&self) -> Vec<String> {
381 lock(&self.last_promote)
382 .as_ref()
383 .map(|(_, c)| c.clone())
384 .unwrap_or_default()
385 }
386
387 pub fn is_answering(&self) -> bool {
389 self.in_flight.load(Ordering::SeqCst)
390 }
391
392 fn touch(&self) {
393 self.last_active.store(now_secs(), Ordering::SeqCst);
394 }
395
396 fn idle_secs(&self) -> u64 {
397 now_secs().saturating_sub(self.last_active.load(Ordering::SeqCst))
398 }
399
400 fn record_turn(&self, role: &'static str, text: &str) {
401 if text.trim().is_empty() {
402 return;
403 }
404 let mut t = lock(&self.transcript);
405 t.push((role, text.to_string()));
406 let len = t.len();
408 if len > TRANSCRIPT_MAX_TURNS {
409 t.drain(..len - TRANSCRIPT_MAX_TURNS);
410 }
411 }
412
413 fn rollback_turn(&self, role: &'static str, text: &str) {
417 let mut t = lock(&self.transcript);
418 if t.last().is_some_and(|(r, s)| *r == role && s == text) {
419 t.pop();
420 }
421 }
422
423 fn distill_transcript(&self) -> String {
425 let t = lock(&self.transcript);
426 let start = t.len().saturating_sub(DISTILL_WINDOW_TURNS);
427 t[start..]
428 .iter()
429 .map(|(role, text)| format!("{role}: {text}"))
430 .collect::<Vec<_>>()
431 .join("\n\n")
432 }
433
434 fn transcript_is_empty(&self) -> bool {
435 lock(&self.transcript).is_empty()
436 }
437
438 async fn emit(&self, kind: DiscussEventKind) -> u64 {
443 let (tx, rx) = oneshot::channel();
444 if self.cmds.send(StreamCmd::Emit(kind, tx)).is_err() {
445 return 0; }
447 rx.await.unwrap_or(0)
448 }
449
450 fn cancel_turn(&self) {
457 self.durability.revoke();
458 self.service.cancel(&self.id);
459 {
460 let mut slot = lock(&self.turn_task);
461 slot.closed = true;
462 if let Some(handle) = slot.handle.take() {
463 handle.abort();
464 }
465 }
466 self.in_flight.store(false, Ordering::SeqCst);
467 }
468
469 fn spawn_turn<F>(&self, make: F) -> bool
475 where
476 F: FnOnce() -> tokio::task::JoinHandle<()>,
477 {
478 let mut slot = lock(&self.turn_task);
479 if slot.closed {
480 return false;
481 }
482 slot.handle = Some(make());
484 true
485 }
486
487 fn summary_row(&self) -> Value {
488 json!({
489 "discussion_id": self.id,
490 "repo": self.repo,
491 "created_at": self.created_at,
492 "turns": self.turns.load(Ordering::SeqCst),
493 })
494 }
495}
496
497fn now_secs() -> u64 {
498 std::time::SystemTime::now()
499 .duration_since(std::time::UNIX_EPOCH)
500 .map(|d| d.as_secs())
501 .unwrap_or(0)
502}
503
504fn event_frame(event: &DiscussEvent) -> Option<String> {
505 serde_json::to_string(&json!({
506 "jsonrpc": "2.0",
507 "method": "coder.discuss.event",
508 "params": event,
509 }))
510 .ok()
511}
512
513struct Subscriber {
520 frames: mpsc::Sender<String>,
521 task: tokio::task::JoinHandle<()>,
522}
523
524impl Drop for Subscriber {
525 fn drop(&mut self) {
529 self.task.abort();
530 }
531}
532
533fn spawn_subscriber(channel: Arc<WsChannel>) -> Subscriber {
534 let (frames, mut rx) = mpsc::channel::<String>(DISCUSS_SUBSCRIBER_QUEUE);
535 let task = tokio::spawn(async move {
536 while let Some(frame) = rx.recv().await {
537 if tokio::time::timeout(
538 DISCUSS_SEND_TIMEOUT,
539 crate::coder::rpc::send_frame(&channel, &frame),
540 )
541 .await
542 .is_err()
543 {
544 break;
548 }
549 }
550 });
551 Subscriber { frames, task }
552}
553
554fn spawn_discuss_drain(
565 discussion_id: String,
566 events: Arc<tokio::sync::Mutex<Vec<DiscussEvent>>>,
567) -> mpsc::UnboundedSender<StreamCmd> {
568 let (tx, mut rx) = mpsc::unbounded_channel::<StreamCmd>();
569 tokio::spawn(async move {
570 let mut subscribers: HashMap<String, Subscriber> = HashMap::new();
571 let mut next_seq: u64 = 0;
572 while let Some(cmd) = rx.recv().await {
573 match cmd {
574 StreamCmd::Emit(kind, reply) => {
575 let seq = next_seq;
576 next_seq += 1;
577 let event = DiscussEvent {
578 discussion_id: discussion_id.clone(),
579 seq,
580 ts: now_secs(),
581 kind,
582 };
583 let frame = event_frame(&event);
584 {
585 let mut buffer = events.lock().await;
586 buffer.push(event);
587 let len = buffer.len();
588 if len > DISCUSS_EVENT_BUFFER_MAX {
589 buffer.drain(..len - DISCUSS_EVENT_BUFFER_MAX);
590 }
591 } let _ = reply.send(seq);
593 if let Some(frame) = &frame {
594 subscribers.retain(|client_id, s| {
598 let ok = s.frames.try_send(frame.clone()).is_ok();
599 if !ok {
600 tracing::warn!(
601 discussion_id = %discussion_id,
602 client_id = %client_id,
603 "discussion subscriber is not draining; dropping it"
604 );
605 }
606 ok
607 });
608 }
609 }
610 StreamCmd::Attach {
611 client_id,
612 channel,
613 from_seq,
614 replayed,
615 } => {
616 let frames: Vec<String> = {
618 let buffer = events.lock().await;
619 buffer
620 .iter()
621 .filter(|e| e.seq >= from_seq)
622 .filter_map(event_frame)
623 .collect()
624 };
625 let subscriber = spawn_subscriber(channel);
626 let mut n = 0u64;
629 for frame in frames {
630 if subscriber.frames.try_send(frame).is_err() {
631 break;
632 }
633 n += 1;
634 }
635 subscribers.insert(client_id, subscriber);
636 let _ = replayed.send(n);
637 }
638 StreamCmd::Detach(client_id) => {
639 subscribers.remove(&client_id);
640 }
641 }
642 }
643 });
645 tx
646}
647
648pub async fn start_discussion(
658 state: &Arc<ServerState>,
659 repo: &Path,
660 owner_client_id: &str,
661 engine: Arc<car_inference::InferenceEngine>,
662 generator: Arc<dyn TurnGenerator>,
663) -> Result<Value, String> {
664 open_discussion(
665 state,
666 repo,
667 owner_client_id,
668 engine,
669 generator,
670 owner_client_id,
671 None,
672 )
673 .await
674}
675
676pub(super) async fn open_discussion(
677 state: &Arc<ServerState>,
678 repo: &Path,
679 owner_client_id: &str,
680 engine: Arc<car_inference::InferenceEngine>,
681 generator: Arc<dyn TurnGenerator>,
682 principal: &str,
683 resume_id: Option<&str>,
684) -> Result<Value, String> {
685 open_discussion_with_model(
686 state,
687 repo,
688 owner_client_id,
689 engine,
690 generator,
691 principal,
692 resume_id,
693 None,
694 )
695 .await
696}
697
698#[allow(clippy::too_many_arguments)]
699async fn open_discussion_with_model(
700 state: &Arc<ServerState>,
701 repo: &Path,
702 owner_client_id: &str,
703 engine: Arc<car_inference::InferenceEngine>,
704 generator: Arc<dyn TurnGenerator>,
705 principal: &str,
706 resume_id: Option<&str>,
707 requested_model: Option<&str>,
708) -> Result<Value, String> {
709 let _recovery = match resume_id {
710 Some(_) => Some(state.coder_discussion_recovery.lock().await),
711 None => None,
712 };
713 let saved = resume_id
714 .map(|id| DiscussionRecord::load(&state.journal_dir, id, principal))
715 .transpose()?;
716 if let Some(record) = &saved {
717 if state
718 .coder_discussions
719 .lock()
720 .await
721 .contains_key(&record.id)
722 {
723 return Err(
724 "conversation is already open; close its other window before resuming".into(),
725 );
726 }
727 }
728 let probe = repo.to_path_buf();
732 let repo = tokio::task::spawn_blocking(move || {
733 super::rpc::repo_toplevel(&probe)
736 .map_err(|e| format!("{e} — discuss needs a repo to ground itself in"))
737 })
738 .await
739 .map_err(|e| format!("repo probe failed: {e}"))??;
740
741 if saved.as_ref().is_some_and(|record| record.repo != repo) {
742 return Err("saved conversation belongs to a different repository".into());
743 }
744 if let (Some(record), Some(requested)) = (&saved, requested_model) {
745 let requested = requested.trim();
746 let requested = if requested.is_empty() || requested == "auto" {
747 None
748 } else {
749 Some(requested)
750 };
751 if requested != record.model.as_deref() {
752 let runs =
753 coding_runs(state, &record.id, &repo, super::rpc::coder_state_dir()?).await?;
754 if runs.iter().any(|run| {
755 !matches!(
756 run["state"].as_str(),
757 Some("merged" | "reported" | "failed" | "abandoned")
758 )
759 }) {
760 return Err(
761 "Finish or stop this conversation's active task before changing its model."
762 .into(),
763 );
764 }
765 }
766 }
767
768 reap_idle(state).await;
771 let slot = state
778 .coder_discussion_slots
779 .clone()
780 .try_acquire_owned()
781 .map_err(|_| {
782 format!(
783 "{MAX_OPEN_DISCUSSIONS} discussions are already open — close one with \
784 coder.discuss.close before starting another"
785 )
786 })?;
787
788 let summarize = repo.clone();
789 let (repo_summary, project_context) = tokio::task::spawn_blocking(move || {
790 (
791 super::rpc::summarize_repo(&summarize),
792 super::project_context::project_context(&summarize).unwrap_or_default(),
793 )
794 })
795 .await
796 .map_err(|e| format!("repo context failed: {e}"))?;
797
798 let mut env = bind_default_substrate(true, false, &repo, None).await;
803 env.clamp_reads = true;
807 let task_proposal = Arc::new(StdMutex::new(None));
808 let proposal_tool = Arc::new(super::task_proposal::TaskProposalTool(
809 task_proposal.clone(),
810 ));
811 let mut asm = build_assistant_runtime_with_tools(
812 engine.clone(),
813 env,
814 None,
815 None,
816 None,
817 None,
818 false,
819 vec![super::task_proposal::TaskProposalTool::definition()],
820 vec![proposal_tool],
821 )
822 .await?;
823 asm.tools.retain(|tool| {
829 matches!(
830 tool["name"].as_str(),
831 Some(
832 "read_file"
833 | "list_dir"
834 | "find_files"
835 | "grep_files"
836 | "calculate"
837 | "web_search"
838 | "http_request"
839 | "prepare_coding_task"
840 )
841 )
842 });
843 let system = format!(
844 "{}\n\n{project_context}\n\nRepository guidance applies to your analysis and proposed work; it does not expand the read-only permissions below.\n\nYou are the coding assistant for repository {}. \
845 When the user asks you to implement or fix something, call prepare_coding_task with \
846 their requested change and constraints. This is how you begin implementation from \
847 this conversation. The interface will show an editable task and verification review \
848 before execution. For questions and planning-only requests, inspect the repository \
849 and answer without preparing a task. Use real paths. \
850 The current conversation tools can inspect the repository and prepare coding tasks; \
851 file edits and shell commands run in the subsequent coding task. A refused write \
852 in this stage is NOT evidence that the file is read-only or that the user declined \
853 implementation. Do not repeat earlier file-permission claims without current evidence \
854 or ask the user to change permissions to start coding. Use prepare_coding_task instead. \
855 Reads outside the repository remain refused. Do not require the user to know a \
856 command or runtime API. Preparing is not execution: never say files have changed \
857 or work has started.",
858 prompt::chat_prompt(&asm.identity, &asm.description, &asm.tools),
859 repo.display()
860 );
861 let model = match requested_model {
862 Some(value) if value.trim().is_empty() || value.trim() == "auto" => None,
863 Some(value) => Some(value.trim().to_string()),
864 None => saved.as_ref().and_then(|record| record.model.clone()),
865 };
866 if let Some(model) = &model {
867 let schema = engine.model_schema(model).ok_or_else(|| {
868 format!("Unknown model '{model}'. Use `car models list --capability tool_use` to choose a model, or --model auto to clear the saved choice.")
869 })?;
870 if !schema
871 .capabilities
872 .contains(&car_inference::schema::ModelCapability::ToolUse)
873 {
874 return Err(format!(
875 "Model '{model}' cannot call repository tools. Choose a tool-capable model with `car models list --capability tool_use`, or use --model auto."
876 ));
877 }
878 }
879 let generator: Arc<dyn TurnGenerator> = match &model {
880 Some(model) => Arc::new(super::discussion_model::DiscussionModel {
881 inner: generator,
882 model: model.clone(),
883 }),
884 None => generator,
885 };
886 let cfg = AssistantConfig {
887 model: model.clone(),
888 strict_model: model.is_some(),
889 max_turns: DISCUSS_MAX_TURNS,
890 tools: asm.tools.clone(),
891 gated_tools: asm.gated_tools.clone(),
892 approval_policy: None,
893 proactive_memory: None,
897 tool_memory: None,
898 tool_labels: None,
899 todos: None,
902 value_store_previews: crate::assistant::agent_loop::VALUE_STORE_PREVIEWS_DEFAULT,
906 response_format: None,
907 context_window_override: None,
908 refuse_unadvertised_tools: false,
909 response_format_validator: None,
910 delegate_budget: None,
911 };
912 let mut record = saved.unwrap_or_else(|| DiscussionRecord {
913 id: format!("disc-{}", uuid::Uuid::new_v4().simple()),
914 repo: repo.clone(),
915 principal: principal.to_string(),
916 created_at: now_secs(),
917 model: model.clone(),
918 pending_task: None,
919 });
920 record.model = model.clone();
921 let id = record.id.clone();
922 let durability = Arc::new(crate::assistant::durability::LocalAssistantDurability::new(
923 state.sync_subsystem()?,
924 id.clone(),
925 repo.clone(),
926 ));
927 let mut messages = if resume_id.is_some() {
928 durability
929 .load_checkpoint(&id)
930 .await?
931 .ok_or("saved conversation has no durable transcript")?
932 .messages
933 } else {
934 Vec::new()
935 };
936 if let Some(car_inference::Message::System { content }) = messages.first_mut() {
939 *content = system.clone();
940 } else {
941 messages.insert(
942 0,
943 car_inference::Message::System {
944 content: system.clone(),
945 },
946 );
947 }
948 durability
949 .checkpoint(
950 &id,
951 &messages,
952 if resume_id.is_some() {
953 "conversation_resume"
954 } else {
955 "conversation_open"
956 },
957 None,
958 )
959 .await?;
960 if resume_id.is_none() {
961 record.save(&state.journal_dir)?;
962 } else if requested_model.is_some() {
963 record.save_model(&state.journal_dir)?;
964 }
965 let service = Arc::new(AssistantService::new_durable(
966 generator.clone(),
967 Arc::new(asm.runtime),
968 cfg,
969 system,
970 durability.clone(),
971 repo.clone(),
972 ));
973
974 let events = Arc::new(tokio::sync::Mutex::new(Vec::new()));
975 let cmds = spawn_discuss_drain(id.clone(), events.clone());
976 let entry = Arc::new(DiscussionEntry {
977 id: id.clone(),
978 repo: repo.clone(),
979 repo_summary: repo_summary.clone(),
980 project_context,
981 created_at: record.created_at,
982 model: model.clone(),
983 owner_client_id: owner_client_id.to_string(),
984 principal: principal.to_string(),
985 record_root: state.journal_dir.clone(),
986 events,
987 cmds,
988 turns: AtomicU64::new(0),
989 in_flight: AtomicBool::new(false),
990 start_lock: Arc::new(tokio::sync::Mutex::new(())),
991 last_active: AtomicU64::new(now_secs()),
992 turn_task: StdMutex::new(TurnSlot::default()),
993 _slot: slot,
994 service,
995 durability,
996 generator,
997 transcript: StdMutex::new(Vec::new()),
998 last_promote: StdMutex::new(record.pending_task.clone()),
999 task_proposal,
1000 });
1001 for message in messages {
1006 let (role, kind, text) = match message {
1007 car_inference::Message::User { content } => ("Operator", true, content),
1008 car_inference::Message::Assistant {
1009 content,
1010 tool_calls,
1011 ..
1012 } if tool_calls.is_empty() => ("Assistant", false, content),
1013 car_inference::Message::Assistant { tool_calls, .. } => {
1014 for call in tool_calls {
1015 entry
1016 .emit(DiscussEventKind::ToolCall {
1017 tool: call.name,
1018 params_preview: preview(
1019 &serde_json::to_string(&call.arguments).unwrap_or_default(),
1020 ),
1021 })
1022 .await;
1023 }
1024 continue;
1025 }
1026 _ => continue,
1027 };
1028 if text.trim().is_empty() {
1029 continue;
1030 }
1031 entry.record_turn(role, &text);
1032 if kind {
1033 entry.emit(DiscussEventKind::UserMessage { text }).await;
1034 } else {
1035 entry.turns.fetch_add(1, Ordering::SeqCst);
1036 entry
1037 .emit(DiscussEventKind::AssistantMessage { text })
1038 .await;
1039 }
1040 }
1041 if let Some((proposed_intent, constraints)) = record.pending_task {
1042 entry
1043 .emit(DiscussEventKind::TaskPrepared {
1044 proposed_intent,
1045 constraints,
1046 })
1047 .await;
1048 }
1049 state
1050 .coder_discussions
1051 .lock()
1052 .await
1053 .insert(id.clone(), entry);
1054
1055 Ok(json!({
1056 "discussion_id": id,
1057 "repo": repo,
1058 "repo_summary": repo_summary,
1059 "persistent": true,
1060 "resumed": resume_id.is_some(),
1061 "model": model,
1062 }))
1063}
1064
1065async fn reap_idle(state: &Arc<ServerState>) {
1067 let stale: Vec<Arc<DiscussionEntry>> = {
1068 let open = state.coder_discussions.lock().await;
1069 open.values()
1070 .filter(|e| e.idle_secs() > DISCUSSION_IDLE_TTL_SECS)
1071 .cloned()
1072 .collect()
1073 };
1074 for entry in stale {
1075 entry.cancel_turn();
1076 state.coder_discussions.lock().await.remove(&entry.id);
1077 }
1078}
1079
1080async fn get_discussion(
1081 state: &Arc<ServerState>,
1082 discussion_id: &str,
1083) -> Result<Arc<DiscussionEntry>, String> {
1084 state
1085 .coder_discussions
1086 .lock()
1087 .await
1088 .get(discussion_id)
1089 .cloned()
1090 .ok_or_else(|| {
1091 format!(
1092 "no open discussion '{discussion_id}' — reopen a saved conversation with \
1093 coder.discuss.start {{repo, resume_id}}, or start a new one"
1094 )
1095 })
1096}
1097
1098pub(super) async fn selected_model(
1099 state: &Arc<ServerState>,
1100 discussion_id: &str,
1101) -> Result<Option<String>, String> {
1102 Ok(get_discussion(state, discussion_id).await?.model.clone())
1103}
1104
1105pub(crate) async fn get_owned_discussion(
1114 state: &Arc<ServerState>,
1115 discussion_id: &str,
1116 client_id: &str,
1117) -> Result<Arc<DiscussionEntry>, String> {
1118 let entry = get_discussion(state, discussion_id).await?;
1119 if entry.owner_client_id != client_id {
1120 return Err(format!(
1121 "discussion '{discussion_id}' belongs to another connection — a discussion is \
1122 owned by the connection that opened it and closes with it; start your own with \
1123 coder.discuss.start"
1124 ));
1125 }
1126 Ok(entry)
1127}
1128
1129pub async fn send_message(
1142 state: &Arc<ServerState>,
1143 discussion_id: &str,
1144 client_id: &str,
1145 text: &str,
1146) -> Result<Value, String> {
1147 let entry = get_owned_discussion(state, discussion_id, client_id).await?;
1148 if text.trim().is_empty() {
1149 return Err("discuss message is empty".to_string());
1150 }
1151 if text.len() > DISCUSS_MESSAGE_MAX_BYTES {
1152 return Err(format!(
1153 "that message is {} bytes; the limit is {DISCUSS_MESSAGE_MAX_BYTES}. A discussion \
1154 keeps every message in its transcript, its replay buffer, and its distillation \
1155 prompt — point at a file in the repo instead of pasting it",
1156 text.len()
1157 ));
1158 }
1159 if entry
1160 .in_flight
1161 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
1162 .is_err()
1163 {
1164 return Err(format!(
1165 "{discussion_id} is still answering the previous message — wait for \
1166 `turn_complete` before sending another"
1167 ));
1168 }
1169 let mut guard = Some(InFlightGuard(entry.clone()));
1180 entry.touch();
1181 entry.record_turn("Operator", text);
1182 let mut recorded = TurnRecordGuard {
1186 entry: entry.clone(),
1187 text: text.to_string(),
1188 dispatched: false,
1189 };
1190
1191 let task_entry = entry.clone();
1192 let task_state = state.clone();
1193 let prompt_text = text.to_string();
1194 let (seq_tx, seq_rx) = oneshot::channel::<u64>();
1204 let dispatched = entry.spawn_turn(|| {
1207 *lock(&entry.last_promote) = None;
1210 let guard = guard.take();
1211 tokio::spawn(async move {
1212 let _guard = guard;
1215 let seq = task_entry
1216 .emit(DiscussEventKind::UserMessage {
1217 text: prompt_text.clone(),
1218 })
1219 .await;
1220 let _ = seq_tx.send(seq);
1221 run_turn(task_state, task_entry, prompt_text).await;
1222 })
1223 });
1224 if !dispatched {
1225 return Err(format!(
1226 "{discussion_id} was closed while your message was being dispatched — nothing is \
1227 running; start a new discussion"
1228 ));
1229 }
1230 recorded.dispatched = true;
1232
1233 let first_seq = seq_rx.await.unwrap_or(0);
1237 Ok(json!({ "ok": true, "seq": first_seq }))
1238}
1239
1240async fn coding_context(
1244 state: &Arc<ServerState>,
1245 entry: &DiscussionEntry,
1246) -> Result<String, String> {
1247 let dir = super::rpc::coder_state_dir()?;
1248 let mut rows = coding_runs(state, &entry.id, &entry.repo, dir).await?;
1249 let total = rows.len();
1250 rows.truncate(8);
1251 if total > rows.len() {
1252 rows.push(json!({"older_runs_omitted": total - rows.len()}));
1253 }
1254 if rows.is_empty() {
1255 return Ok(String::new());
1256 }
1257 Ok(format!(
1258 "Linked coding runs (current observations; older observations may be stale):\n{}\nThese runs use isolated worktrees. Repository reads still target the conversation repository; do not assume its checkout contains a run's changes. A passing check or published branch does not establish deployment. Ask for review of retained work before proposing to start over. After checkout delivery, a new task captures current checkout files, including later manual edits and deletions. After branch delivery, it starts from the recorded result commit. A retained native worktree with execution_stopped=true can be reopened by the next task. Other unfinished work still requires recovery checks.",
1259 serde_json::to_string(&rows).map_err(|e| e.to_string())?
1260 ))
1261}
1262
1263async fn coding_runs(
1264 state: &Arc<ServerState>,
1265 discussion_id: &str,
1266 repo: &Path,
1267 dir: PathBuf,
1268) -> Result<Vec<Value>, String> {
1269 let entries: Vec<_> = state
1270 .coder_sessions
1271 .lock()
1272 .await
1273 .values()
1274 .cloned()
1275 .collect();
1276 let mut rows = Vec::new();
1277 let mut live_ids = std::collections::HashSet::new();
1278 for entry in entries {
1279 let session = entry.session.lock().await;
1280 live_ids.insert(session.id.clone());
1281 if session.discussion_id.as_deref() == Some(discussion_id) && session.repo == repo {
1282 rows.push(coding_run_row(&session, true));
1283 }
1284 }
1285 let discussion_id = discussion_id.to_string();
1286 let repo = repo.to_path_buf();
1287 let saved = tokio::task::spawn_blocking(move || {
1288 super::session::CoderSession::list(&dir)
1289 .into_iter()
1290 .filter(|session| {
1291 !live_ids.contains(&session.id)
1292 && session.discussion_id.as_deref() == Some(discussion_id.as_str())
1293 && session.repo == repo
1294 })
1295 .map(|session| coding_run_row(&session, false))
1296 .collect::<Vec<_>>()
1297 })
1298 .await
1299 .map_err(|e| format!("read linked coding runs: {e}"))?;
1300 rows.extend(saved);
1301 let superseded: std::collections::HashSet<String> = rows
1302 .iter()
1303 .filter_map(|row| row["resumed_from"].as_str().map(str::to_string))
1304 .collect();
1305 rows.retain(|row| {
1306 !row["session_id"]
1307 .as_str()
1308 .is_some_and(|id| superseded.contains(id))
1309 });
1310 rows.sort_by_key(|row| std::cmp::Reverse(row["updated_at"].as_u64().unwrap_or(0)));
1311 Ok(rows)
1312}
1313
1314fn coding_run_row(session: &super::session::CoderSession, live: bool) -> Value {
1315 let omitted_guidance = session.steering_messages.len().saturating_sub(8);
1316 let guidance: Vec<Value> = session.steering_messages[omitted_guidance..]
1317 .iter()
1318 .map(|text| {
1319 json!({
1320 "text": text.chars().take(1000).collect::<String>(),
1321 "truncated": text.chars().count() > 1000,
1322 })
1323 })
1324 .collect();
1325 let checks: Vec<Value> = session.last_check_results.iter().take(20).map(|check| json!({
1326 "name": check.name.chars().take(160).collect::<String>(),
1327 "passed": check.passed,
1328 "exit_code": check.exit_code,
1329 "timed_out": check.timed_out,
1330 "deadline_clamped": check.deadline_clamped,
1331 "output_tail": check.output_tail.chars().rev().take(800).collect::<String>().chars().rev().collect::<String>(),
1332 })).collect();
1333 json!({
1334 "session_id": session.id, "state": session.state.as_str(), "live": live,
1335 "intent": session.intent.chars().take(1000).collect::<String>(),
1336 "updated_at": session.updated_at, "result_branch": session.result_branch,
1337 "result_commit": session.result_commit,
1338 "result_delivery": session.result_delivery,
1339 "resumed_from": session.resumed_from,
1340 "execution_stopped": session.execution_stopped,
1341 "operator_guidance": guidance,
1342 "operator_guidance_omitted": omitted_guidance,
1343 "engine": session.engine.label(),
1344 "worktree": session.workspace_path.as_ref().filter(|path| path.is_dir()),
1345 "error": session.error.as_ref().map(|error| error.chars().take(1000).collect::<String>()),
1346 "failure_kind": session.failure_kind, "checks": checks,
1347 "checks_omitted": session.last_check_results.len().saturating_sub(checks.len()),
1348 })
1349}
1350
1351pub(super) async fn retained_workspace(
1354 state: &Arc<ServerState>,
1355 discussion_id: &str,
1356 repo: &Path,
1357 dir: PathBuf,
1358) -> Result<Option<(String, PathBuf)>, String> {
1359 let rows = coding_runs(state, discussion_id, repo, dir.clone()).await?;
1360 let retained: Vec<_> = rows
1361 .iter()
1362 .filter(|row| {
1363 matches!(row["state"].as_str(), Some("failed" | "abandoned"))
1364 && row["worktree"].is_string()
1365 })
1366 .collect();
1367 if retained.len() > 1 {
1368 return Err("Multiple unfinished worktrees belong to this conversation. Review them before choosing work to continue.".into());
1369 }
1370 let Some(row) = retained.first() else {
1371 return Ok(None);
1372 };
1373 if row["engine"] != "native" || row["execution_stopped"] != true {
1374 return Err("The previous task's execution has not been confirmed stopped. Its work is retained; automatic recovery cannot safely reopen it yet.".into());
1375 }
1376 let path = PathBuf::from(
1377 row["worktree"]
1378 .as_str()
1379 .ok_or("missing retained worktree")?,
1380 )
1381 .canonicalize()
1382 .map_err(|e| format!("retained worktree: {e}"))?;
1383 let root = dir
1384 .join("worktrees")
1385 .canonicalize()
1386 .map_err(|e| e.to_string())?;
1387 if path.parent() != Some(root.as_path()) {
1388 return Err("retained worktree is outside this daemon's workspace directory".into());
1389 }
1390 Ok(Some((
1391 row["session_id"]
1392 .as_str()
1393 .ok_or("missing retained task id")?
1394 .to_string(),
1395 path,
1396 )))
1397}
1398
1399pub(super) async fn followup_base(
1403 state: &Arc<ServerState>,
1404 discussion_id: &str,
1405 repo: &Path,
1406 dir: PathBuf,
1407) -> Result<Option<String>, String> {
1408 let rows = coding_runs(state, discussion_id, repo, dir).await?;
1409 followup_base_from(&rows)
1410}
1411
1412fn followup_base_from(rows: &[Value]) -> Result<Option<String>, String> {
1413 for run in rows {
1414 match run["state"].as_str() {
1415 Some("merged") if rows.iter().any(|other| other["state"] == "merged"
1416 && other["updated_at"] == run["updated_at"]
1417 && other["result_commit"] != run["result_commit"]) => {
1418 return Err("Multiple deliveries have the same recorded timestamp. Choose an explicit base revision; CAR cannot safely infer their order.".into());
1419 }
1420 Some("merged") => {
1421 let commit = run["result_commit"].as_str().ok_or_else(|| "The previous run predates saved result revisions. Choose an explicit base revision before continuing; CAR will not silently start again from repository HEAD.".to_string())?;
1422 return Ok((run["result_delivery"] != "checkout").then(|| commit.to_string()));
1426 },
1427 Some("failed" | "abandoned") if !run["worktree"].is_null() => return Err(format!(
1428 "The previous run has unfinished changes at {}. Review that work before starting over; resuming that worktree is not yet supported.", run["worktree"].as_str().unwrap_or("the retained worktree")
1429 )),
1430 _ => {},
1431 }
1432 }
1433 Ok(None)
1434}
1435
1436pub(super) async fn claim_coding_start(
1440 state: &Arc<ServerState>,
1441 discussion_id: &str,
1442 repo: &Path,
1443 dir: PathBuf,
1444) -> Result<tokio::sync::OwnedMutexGuard<()>, String> {
1445 if get_discussion(state, discussion_id).await?.repo != repo {
1446 return Err("The task repository differs from this conversation's repository.".into());
1447 }
1448 claim_coding_start_at(state, discussion_id, dir).await
1449}
1450
1451async fn claim_coding_start_at(
1452 state: &Arc<ServerState>,
1453 discussion_id: &str,
1454 dir: PathBuf,
1455) -> Result<tokio::sync::OwnedMutexGuard<()>, String> {
1456 let entry = get_discussion(state, discussion_id).await?;
1457 let guard = entry.start_lock.clone().lock_owned().await;
1458 let current = get_discussion(state, discussion_id).await?;
1461 if !Arc::ptr_eq(&entry, ¤t) {
1462 return Err("conversation was reopened; retry the task".into());
1463 }
1464 let runs = coding_runs(state, discussion_id, &entry.repo, dir).await?;
1465 if let Some(run) = runs.iter().find(|row| {
1466 row.get("state").is_some_and(|state| {
1467 !matches!(
1468 state.as_str(),
1469 Some("merged" | "reported" | "failed" | "abandoned")
1470 )
1471 })
1472 }) {
1473 return Err(format!("Conversation already has unfinished work in {} ({}). Open it from /sessions to continue or cancel it before starting another task.", run["session_id"].as_str().unwrap_or("unknown"), run["state"].as_str().unwrap_or("unknown")));
1474 }
1475 Ok(guard)
1476}
1477
1478async fn run_turn(state: Arc<ServerState>, entry: Arc<DiscussionEntry>, text: String) {
1481 *lock(&entry.task_proposal) = None;
1482 if let Err(message) = entry.save_pending_task(None) {
1483 entry.emit(DiscussEventKind::Error { message }).await;
1484 entry.emit(DiscussEventKind::TurnComplete {}).await;
1485 return;
1486 }
1487 let context = match coding_context(&state, &entry).await {
1488 Ok(context) => context,
1489 Err(error) => {
1490 entry.emit(DiscussEventKind::Error { message: error }).await;
1491 entry.emit(DiscussEventKind::TurnComplete {}).await;
1492 return;
1493 }
1494 };
1495 let sink_entry = entry.clone();
1496 let assembled: Arc<StdMutex<String>> = Arc::new(StdMutex::new(String::new()));
1497 let sink_assembled = assembled.clone();
1498
1499 let service = entry.service.clone();
1500 let sink_service = service.clone();
1503 let id = entry.id.clone();
1504 service
1505 .handle_turn_with_context(
1506 &id,
1507 &text,
1508 None,
1509 None,
1510 Some(&context),
1511 move |payload: Value| {
1512 let entry = sink_entry.clone();
1513 let assembled = sink_assembled.clone();
1514 let service = sink_service.clone();
1515 async move {
1516 let kind = payload.get("kind").and_then(Value::as_str).unwrap_or("");
1517 match kind {
1518 "token" => {
1519 let delta = payload
1520 .get("delta")
1521 .and_then(Value::as_str)
1522 .unwrap_or_default()
1523 .to_string();
1524 if delta.is_empty() {
1525 return;
1526 }
1527 lock(&assembled).push_str(&delta);
1528 entry
1529 .emit(DiscussEventKind::AssistantDelta { text: delta })
1530 .await;
1531 }
1532 "tool_call" => {
1533 let tool = payload
1534 .get("tool")
1535 .and_then(Value::as_str)
1536 .unwrap_or("tool")
1537 .to_string();
1538 let params_preview = payload
1539 .get("params")
1540 .map(|p| preview(&p.to_string()))
1541 .unwrap_or_default();
1542 entry
1543 .emit(DiscussEventKind::ToolCall {
1544 tool,
1545 params_preview,
1546 })
1547 .await;
1548 }
1549 "approval_pending" => {
1555 let tool = payload
1556 .get("tool")
1557 .and_then(Value::as_str)
1558 .unwrap_or("tool")
1559 .to_string();
1560 if let Some(approval_id) =
1561 payload.get("approval_id").and_then(Value::as_str)
1562 {
1563 service.deny_approval(
1564 approval_id,
1565 DISCUSSION_MUTATION_REFUSAL.to_string(),
1566 );
1567 }
1568 entry
1569 .emit(DiscussEventKind::ToolResult {
1570 tool,
1571 ok: false,
1572 preview: DISCUSSION_MUTATION_REFUSAL.to_string(),
1573 })
1574 .await;
1575 }
1576 "done" => {
1577 let text = payload
1578 .get("text")
1579 .and_then(Value::as_str)
1580 .unwrap_or_default()
1581 .to_string();
1582 let text = if text.trim().is_empty() {
1583 lock(&assembled).clone()
1584 } else {
1585 text
1586 };
1587 entry.record_turn("Assistant", &text);
1588 entry.turns.fetch_add(1, Ordering::SeqCst);
1589 entry
1590 .emit(DiscussEventKind::AssistantMessage { text })
1591 .await;
1592 let proposal = lock(&entry.task_proposal).take();
1593 if let Some((proposed_intent, constraints)) = proposal {
1594 if let Err(message) = entry.save_pending_task(Some((
1595 proposed_intent.clone(),
1596 constraints.clone(),
1597 ))) {
1598 entry.emit(DiscussEventKind::Error { message }).await;
1599 entry.emit(DiscussEventKind::TurnComplete {}).await;
1600 return;
1601 }
1602 *lock(&entry.last_promote) =
1603 Some((proposed_intent.clone(), constraints.clone()));
1604 entry
1605 .emit(DiscussEventKind::TaskPrepared {
1606 proposed_intent,
1607 constraints,
1608 })
1609 .await;
1610 }
1611 entry.emit(DiscussEventKind::TurnComplete {}).await;
1612 }
1613 "error" => {
1614 let message = payload
1615 .get("error")
1616 .and_then(Value::as_str)
1617 .unwrap_or("discussion turn failed")
1618 .to_string();
1619 entry.emit(DiscussEventKind::Error { message }).await;
1620 entry.emit(DiscussEventKind::TurnComplete {}).await;
1621 }
1622 "auth_required" => {
1636 let message = payload
1637 .get("message")
1638 .and_then(Value::as_str)
1639 .unwrap_or("this discussion needs a Parslee sign-in")
1640 .to_string();
1641 entry.emit(DiscussEventKind::Error { message }).await;
1642 entry.emit(DiscussEventKind::TurnComplete {}).await;
1643 }
1644 _ => {}
1645 }
1646 }
1647 },
1648 )
1649 .await;
1650}
1651
1652fn preview(s: &str) -> String {
1653 const CAP: usize = 200;
1654 if s.chars().count() <= CAP {
1655 return s.to_string();
1656 }
1657 let mut out: String = s.chars().take(CAP).collect();
1658 out.push('…');
1659 out
1660}
1661
1662pub async fn promote(
1673 state: &Arc<ServerState>,
1674 discussion_id: &str,
1675 client_id: &str,
1676) -> Result<Value, String> {
1677 let entry = get_owned_discussion(state, discussion_id, client_id).await?;
1678 if entry.is_answering() {
1679 return Err(format!(
1680 "{discussion_id} is still answering — try again in a moment"
1681 ));
1682 }
1683 if entry.transcript_is_empty() {
1684 return Err(
1685 "this discussion has no turns yet — say what you are trying to do first".to_string(),
1686 );
1687 }
1688 let _start_guard = claim_coding_start(
1689 state,
1690 discussion_id,
1691 &entry.repo,
1692 super::rpc::coder_state_dir()?,
1693 )
1694 .await?;
1695 let context = coding_context(state, &entry).await?;
1696 let repo_context = format!(
1697 "{}\n{}\n{}",
1698 entry.repo_summary, entry.project_context, context
1699 );
1700 let (intent, constraints) =
1701 distill(&entry.generator, &entry.distill_transcript(), &repo_context).await?;
1702 entry.save_pending_task(Some((intent.clone(), constraints.clone())))?;
1703 *lock(&entry.last_promote) = Some((intent.clone(), constraints.clone()));
1704 entry.touch();
1705 Ok(json!({
1706 "discussion_id": entry.id,
1707 "proposed_intent": intent,
1708 "constraints": constraints,
1709 }))
1710}
1711
1712async fn distill(
1716 generator: &Arc<dyn TurnGenerator>,
1717 transcript: &str,
1718 repo_summary: &str,
1719) -> Result<(String, Vec<String>), String> {
1720 let mut last_err = String::from("no attempt was made");
1721 for _ in 0..PROMOTE_MAX_ATTEMPTS {
1722 let prompt = format!(
1723 "A developer has been discussing a change to a codebase. Distill the discussion \
1724 into ONE actionable coding intent plus the constraints they agreed on.\n\n\
1725 REPOSITORY\n{repo_summary}\n\n\
1726 DISCUSSION (most recent turns)\n{transcript}\n\n\
1727 Return ONLY a JSON object, no prose and no code fences:\n\
1728 {{\n \"proposed_intent\": \"one paragraph, imperative, what to change and why\",\n \
1729 \"constraints\": [\"a thing the change must not break or must respect\"]\n}}\n\n\
1730 Rules:\n\
1731 - `proposed_intent` is an INSTRUCTION, not a summary of the conversation. Never \
1732 quote the transcript back.\n\
1733 - Include only constraints actually agreed in the discussion. If none were, \
1734 return an empty array — do not invent any.\n"
1735 );
1736 let text = match generator
1737 .generate(car_inference::GenerateRequest {
1738 prompt,
1739 params: car_inference::GenerateParams {
1740 temperature: 0.0,
1741 max_tokens: 1024,
1742 thinking: car_inference::tasks::generate::ThinkingMode::Off,
1743 ..Default::default()
1744 },
1745 ..Default::default()
1746 })
1747 .await
1748 {
1749 Ok(r) => r.text,
1750 Err(e) => {
1751 last_err = format!("generation failed: {e}");
1752 continue;
1753 }
1754 };
1755 let value = match super::contract::extract_json_object(&text) {
1756 Ok(v) => v,
1757 Err(e) => {
1758 last_err = format!("output did not parse: {e}");
1759 continue;
1760 }
1761 };
1762 let intent = value
1763 .get("proposed_intent")
1764 .and_then(Value::as_str)
1765 .unwrap_or_default()
1766 .trim()
1767 .to_string();
1768 if intent.is_empty() {
1769 last_err = "the model returned no proposed_intent".to_string();
1770 continue;
1771 }
1772 let constraints: Vec<String> = value
1773 .get("constraints")
1774 .and_then(Value::as_array)
1775 .map(|a| {
1776 a.iter()
1777 .filter_map(Value::as_str)
1778 .map(str::trim)
1779 .filter(|s| !s.is_empty())
1780 .map(str::to_string)
1781 .collect()
1782 })
1783 .unwrap_or_default();
1784 return Ok((intent, constraints));
1785 }
1786 Err(format!(
1787 "could not distill this discussion into an intent after {PROMOTE_MAX_ATTEMPTS} \
1788 attempts: {last_err}"
1789 ))
1790}
1791
1792pub(super) async fn consume_prepared_task(
1795 state: &Arc<ServerState>,
1796 id: &str,
1797) -> Result<(), String> {
1798 get_discussion(state, id).await?.save_pending_task(None)
1799}
1800
1801pub async fn constraints_for_start(
1813 state: &Arc<ServerState>,
1814 discussion_id: &str,
1815) -> Result<Vec<String>, String> {
1816 let entry = get_discussion(state, discussion_id).await?;
1817 if entry.is_answering() {
1818 return Err(format!(
1819 "{discussion_id} is still answering — wait for `turn_complete` before starting a \
1820 run from it, or the constraints would be distilled from a question with no \
1821 answer beside it"
1822 ));
1823 }
1824 let cached = entry.constraints();
1825 if !cached.is_empty() {
1826 return Ok(cached);
1827 }
1828 if lock(&entry.last_promote).is_some() {
1829 return Ok(Vec::new());
1831 }
1832 if entry.transcript_is_empty() {
1833 return Ok(Vec::new());
1834 }
1835 match distill(
1836 &entry.generator,
1837 &entry.distill_transcript(),
1838 &format!("{}\n{}", entry.repo_summary, entry.project_context),
1839 )
1840 .await
1841 {
1842 Ok((intent, constraints)) => {
1843 *lock(&entry.last_promote) = Some((intent, constraints.clone()));
1844 Ok(constraints)
1845 }
1846 Err(e) => {
1847 tracing::warn!(discussion_id, "discussion constraints unavailable: {e}");
1848 Err(format!(
1849 "Could not carry the conversation's requirements into this task: {e}. \
1850 No task was started. Retry Build when model access is available."
1851 ))
1852 }
1853 }
1854}
1855
1856pub async fn close(
1859 state: &Arc<ServerState>,
1860 discussion_id: &str,
1861 client_id: &str,
1862) -> Result<Value, String> {
1863 get_owned_discussion(state, discussion_id, client_id).await?;
1866 let entry = state.coder_discussions.lock().await.remove(discussion_id);
1867 let Some(entry) = entry else {
1868 return Err(format!("no open discussion '{discussion_id}'"));
1869 };
1870 entry.cancel_turn();
1875 Ok(json!({ "ok": true }))
1876}
1877
1878pub async fn drop_subscriptions_for_client(state: &ServerState, client_id: &str) {
1887 let (owned, others): (Vec<_>, Vec<_>) = {
1888 let open = state.coder_discussions.lock().await;
1889 open.values()
1890 .cloned()
1891 .partition(|e| e.owner_client_id == client_id)
1892 };
1893 for entry in &others {
1894 let _ = entry.cmds.send(StreamCmd::Detach(client_id.to_string()));
1895 }
1896 if owned.is_empty() {
1897 return;
1898 }
1899 let mut open = state.coder_discussions.lock().await;
1900 for entry in owned {
1901 entry.cancel_turn();
1902 open.remove(&entry.id);
1903 }
1904}
1905
1906#[derive(Deserialize)]
1911struct StartParams {
1912 repo: PathBuf,
1913 #[serde(default)]
1914 resume_id: Option<String>,
1915 #[serde(default)]
1916 model: Option<String>,
1917}
1918
1919pub async fn handle_discuss_start(
1920 req: &JsonRpcMessage,
1921 state: &Arc<ServerState>,
1922 session: &Arc<ClientSession>,
1923) -> Result<Value, String> {
1924 let params: StartParams =
1925 serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
1926 let engine = crate::handler::get_inference_engine(state).clone();
1927 let generator: Arc<dyn TurnGenerator> = engine.clone();
1928 if let Some(model) = params
1929 .model
1930 .as_deref()
1931 .map(str::trim)
1932 .filter(|m| !m.is_empty() && *m != "auto")
1933 {
1934 if !engine.knows_model(model) {
1935 return Err(format!(
1936 "Unknown model '{model}'. Use `car models list` to choose an available model id."
1937 ));
1938 }
1939 }
1940 let principal = discussion_principal(session).await;
1944 open_discussion_with_model(
1945 state,
1946 ¶ms.repo,
1947 &session.client_id,
1948 engine,
1949 generator,
1950 &principal,
1951 params.resume_id.as_deref(),
1952 params.model.as_deref(),
1953 )
1954 .await
1955}
1956
1957#[derive(Deserialize)]
1958struct SendParams {
1959 discussion_id: String,
1960 text: String,
1961}
1962
1963pub async fn handle_discuss_send(
1964 req: &JsonRpcMessage,
1965 state: &Arc<ServerState>,
1966 session: &Arc<ClientSession>,
1967) -> Result<Value, String> {
1968 let params: SendParams =
1969 serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
1970 send_message(
1971 state,
1972 ¶ms.discussion_id,
1973 &session.client_id,
1974 ¶ms.text,
1975 )
1976 .await
1977}
1978
1979#[derive(Deserialize)]
1980struct DiscussionIdParams {
1981 discussion_id: String,
1982}
1983
1984#[derive(Deserialize)]
1985struct SubscribeParams {
1986 discussion_id: String,
1987 #[serde(default)]
1988 from_seq: u64,
1989}
1990
1991pub async fn handle_discuss_subscribe(
1992 req: &JsonRpcMessage,
1993 state: &Arc<ServerState>,
1994 session: &Arc<ClientSession>,
1995) -> Result<Value, String> {
1996 let params: SubscribeParams =
1997 serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
1998 let entry = get_owned_discussion(state, ¶ms.discussion_id, &session.client_id).await?;
1999 entry.touch();
2002 let (tx, rx) = oneshot::channel();
2006 entry
2007 .cmds
2008 .send(StreamCmd::Attach {
2009 client_id: session.client_id.clone(),
2010 channel: session.channel.clone(),
2011 from_seq: params.from_seq,
2012 replayed: tx,
2013 })
2014 .map_err(|_| format!("discussion '{}' is closing", params.discussion_id))?;
2015 let replayed = rx.await.unwrap_or(0);
2016 Ok(json!({ "events_replayed": replayed }))
2017}
2018
2019pub async fn handle_discuss_unsubscribe(
2020 req: &JsonRpcMessage,
2021 state: &Arc<ServerState>,
2022 session: &Arc<ClientSession>,
2023) -> Result<Value, String> {
2024 let params: DiscussionIdParams =
2025 serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
2026 if let Ok(entry) = get_discussion(state, ¶ms.discussion_id).await {
2027 let _ = entry
2028 .cmds
2029 .send(StreamCmd::Detach(session.client_id.clone()));
2030 }
2031 Ok(json!({ "ok": true }))
2032}
2033
2034pub async fn handle_discuss_promote(
2035 req: &JsonRpcMessage,
2036 state: &Arc<ServerState>,
2037 session: &Arc<ClientSession>,
2038) -> Result<Value, String> {
2039 let params: DiscussionIdParams =
2040 serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
2041 let state = state.clone();
2046 let client_id = session.client_id.clone();
2047 let mut generation = tokio::task::JoinSet::new();
2048 generation.spawn(async move { promote(&state, ¶ms.discussion_id, &client_id).await });
2049 generation
2050 .join_next()
2051 .await
2052 .ok_or("conversation planning task did not start")?
2053 .map_err(|error| format!("conversation planning task failed: {error}"))?
2054}
2055
2056pub async fn handle_discuss_close(
2057 req: &JsonRpcMessage,
2058 state: &Arc<ServerState>,
2059 session: &Arc<ClientSession>,
2060) -> Result<Value, String> {
2061 let params: DiscussionIdParams =
2062 serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
2063 close(state, ¶ms.discussion_id, &session.client_id).await
2064}
2065
2066async fn discussion_principal(session: &Arc<ClientSession>) -> String {
2067 match session.agent_id.lock().await.as_deref() {
2068 Some(id) => format!("agent:{id}"),
2069 None => "operator".to_string(),
2070 }
2071}
2072
2073pub async fn handle_discuss_list(
2080 state: &Arc<ServerState>,
2081 session: &Arc<ClientSession>,
2082) -> Result<Value, String> {
2083 let mut rows: Vec<Value> = state
2084 .coder_discussions
2085 .lock()
2086 .await
2087 .values()
2088 .filter(|e| e.owner_client_id == session.client_id)
2089 .map(|e| e.summary_row())
2090 .collect();
2091 rows.sort_by_key(|v| std::cmp::Reverse(v["created_at"].as_u64().unwrap_or(0)));
2092 let principal = discussion_principal(session).await;
2093 let records = DiscussionRecord::list(&state.journal_dir, &principal)?;
2094 let live = state.coder_discussions.lock().await;
2095 let saved: Vec<Value> = records
2096 .into_iter()
2097 .filter(|record| !live.contains_key(&record.id))
2098 .map(|record| {
2099 json!({
2100 "discussion_id": record.id,
2101 "repo": record.repo,
2102 "created_at": record.created_at,
2103 })
2104 })
2105 .collect();
2106 Ok(json!({ "discussions": rows, "saved": saved }))
2107}
2108
2109#[cfg(test)]
2110mod tests {
2111 use super::*;
2112 use async_trait::async_trait;
2113 use car_inference::{GenerateRequest, InferenceResult};
2114 use std::sync::atomic::AtomicUsize;
2115
2116 fn turn(text: &str, tool_calls: Value) -> InferenceResult {
2117 serde_json::from_value(json!({
2118 "text": text, "tool_calls": tool_calls,
2119 "trace_id": "t", "model_used": "scripted", "latency_ms": 0,
2120 }))
2121 .expect("scripted InferenceResult shape")
2122 }
2123
2124 struct Script {
2125 turns: Vec<InferenceResult>,
2126 cursor: AtomicUsize,
2127 }
2128
2129 #[async_trait]
2130 impl TurnGenerator for Script {
2131 async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
2132 let i = self.cursor.fetch_add(1, Ordering::SeqCst);
2133 self.turns
2134 .get(i)
2135 .cloned()
2136 .ok_or_else(|| "script exhausted".to_string())
2137 }
2138 }
2139
2140 struct Blocking {
2148 gate: Arc<tokio::sync::Notify>,
2149 }
2150
2151 #[async_trait]
2152 impl TurnGenerator for Blocking {
2153 async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
2154 self.gate.notified().await;
2155 Ok(turn("done at last", json!([])))
2156 }
2157 }
2158
2159 struct Counting {
2161 calls: Arc<AtomicUsize>,
2162 }
2163
2164 #[async_trait]
2165 impl TurnGenerator for Counting {
2166 async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
2167 self.calls.fetch_add(1, Ordering::SeqCst);
2168 Ok(turn("counted", json!([])))
2169 }
2170 }
2171
2172 fn init_repo(dir: &Path) {
2173 for args in [
2174 vec!["init", "-q", "-b", "main"],
2175 vec![
2176 "-c",
2177 "user.name=t",
2178 "-c",
2179 "user.email=t@t",
2180 "commit",
2181 "-q",
2182 "--allow-empty",
2183 "-m",
2184 "init",
2185 ],
2186 ] {
2187 let out = std::process::Command::new("git")
2188 .arg("-C")
2189 .arg(dir)
2190 .args(&args)
2191 .output()
2192 .unwrap();
2193 assert!(
2194 out.status.success(),
2195 "{}",
2196 String::from_utf8_lossy(&out.stderr)
2197 );
2198 }
2199 }
2200
2201 fn engine(root: &Path) -> Arc<car_inference::InferenceEngine> {
2202 let mut cfg = car_inference::InferenceConfig::default();
2203 cfg.models_dir = root.join("models");
2204 Arc::new(car_inference::InferenceEngine::new(cfg))
2205 }
2206
2207 fn state() -> (Arc<ServerState>, tempfile::TempDir) {
2210 let journal = tempfile::tempdir().unwrap();
2211 let state = Arc::new(ServerState::standalone(journal.path().to_path_buf()));
2212 (state, journal)
2213 }
2214
2215 async fn start(
2216 state: &Arc<ServerState>,
2217 repo: &Path,
2218 generator: Arc<dyn TurnGenerator>,
2219 ) -> String {
2220 let started = start_discussion(state, repo, "owner-1", engine(repo), generator)
2221 .await
2222 .unwrap();
2223 started["discussion_id"].as_str().unwrap().to_string()
2224 }
2225
2226 async fn client(state: &Arc<ServerState>, id: &str) -> Arc<ClientSession> {
2229 state
2230 .create_session(id, Arc::new(crate::session::WsChannel::test_stub()))
2231 .await
2232 .unwrap()
2233 }
2234
2235 struct CaptureSink(Arc<StdMutex<Vec<String>>>);
2240
2241 impl futures::Sink<tokio_tungstenite::tungstenite::Message> for CaptureSink {
2242 type Error = tokio_tungstenite::tungstenite::Error;
2243
2244 fn poll_ready(
2245 self: std::pin::Pin<&mut Self>,
2246 _: &mut std::task::Context<'_>,
2247 ) -> std::task::Poll<Result<(), Self::Error>> {
2248 std::task::Poll::Ready(Ok(()))
2249 }
2250
2251 fn start_send(
2252 self: std::pin::Pin<&mut Self>,
2253 item: tokio_tungstenite::tungstenite::Message,
2254 ) -> Result<(), Self::Error> {
2255 if let tokio_tungstenite::tungstenite::Message::Text(text) = item {
2256 lock(&self.0).push(text.to_string());
2257 }
2258 Ok(())
2259 }
2260
2261 fn poll_flush(
2262 self: std::pin::Pin<&mut Self>,
2263 _: &mut std::task::Context<'_>,
2264 ) -> std::task::Poll<Result<(), Self::Error>> {
2265 std::task::Poll::Ready(Ok(()))
2266 }
2267
2268 fn poll_close(
2269 self: std::pin::Pin<&mut Self>,
2270 _: &mut std::task::Context<'_>,
2271 ) -> std::task::Poll<Result<(), Self::Error>> {
2272 std::task::Poll::Ready(Ok(()))
2273 }
2274 }
2275
2276 fn capturing_channel() -> (Arc<WsChannel>, Arc<StdMutex<Vec<String>>>) {
2280 let frames = Arc::new(StdMutex::new(Vec::new()));
2281 let sink: crate::session::WsSink = Box::pin(CaptureSink(frames.clone()));
2282 let channel = Arc::new(WsChannel {
2283 write: tokio::sync::Mutex::new(sink),
2284 pending: tokio::sync::Mutex::new(HashMap::new()),
2285 active_actions: tokio::sync::Mutex::new(HashMap::new()),
2286 next_id: AtomicU64::new(0),
2287 });
2288 (channel, frames)
2289 }
2290
2291 fn rpc_req(params: Value) -> JsonRpcMessage {
2292 serde_json::from_value(json!({ "jsonrpc": "2.0", "id": 1, "params": params }))
2293 .expect("JsonRpcMessage shape")
2294 }
2295
2296 fn delivered_seqs(frames: &Arc<StdMutex<Vec<String>>>) -> Vec<u64> {
2298 lock(frames)
2299 .iter()
2300 .map(|f| serde_json::from_str::<Value>(f).expect("a lane frame must be JSON"))
2301 .inspect(|v| assert_eq!(v["method"], "coder.discuss.event", "unexpected frame: {v}"))
2302 .map(|v| {
2303 v["params"]["seq"]
2304 .as_u64()
2305 .expect("every event carries a seq")
2306 })
2307 .collect()
2308 }
2309
2310 async fn wait_for_turn_complete(entry: &Arc<DiscussionEntry>) {
2311 for _ in 0..400 {
2312 {
2313 let events = entry.events.lock().await;
2314 if events
2315 .iter()
2316 .any(|e| matches!(e.kind, DiscussEventKind::TurnComplete {}))
2317 {
2318 return;
2319 }
2320 }
2321 tokio::time::sleep(std::time::Duration::from_millis(25)).await;
2322 }
2323 panic!("discussion turn never completed");
2324 }
2325
2326 async fn wait_for_idle(entry: &Arc<DiscussionEntry>) {
2327 tokio::time::timeout(std::time::Duration::from_secs(10), async {
2328 while entry.is_answering() {
2329 tokio::task::yield_now().await;
2330 }
2331 })
2332 .await
2333 .expect("discussion turn did not release its guard");
2334 }
2335
2336 #[tokio::test]
2337 async fn conversation_recovers_after_restart_with_same_identity_and_full_model_history() {
2338 let repo = tempfile::tempdir().unwrap();
2339 init_repo(repo.path());
2340 std::fs::write(repo.path().join("AGENTS.md"), "Initial repository rule.").unwrap();
2341 std::fs::write(repo.path().join("CLAUDE.md"), "Preserve exported names.").unwrap();
2342 let (state, journal) = state();
2343 let script = || {
2344 Arc::new(Script {
2345 turns: vec![turn("Remember the export regression.", json!([]))],
2346 cursor: AtomicUsize::new(0),
2347 }) as Arc<dyn TurnGenerator>
2348 };
2349 let started = open_discussion(
2350 &state,
2351 repo.path(),
2352 "connection-1",
2353 engine(repo.path()),
2354 script(),
2355 "operator",
2356 None,
2357 )
2358 .await
2359 .unwrap();
2360 let id = started["discussion_id"].as_str().unwrap().to_string();
2361 let entry = get_discussion(&state, &id).await.unwrap();
2362 let checkpoint = entry
2363 .durability
2364 .load_checkpoint(&id)
2365 .await
2366 .unwrap()
2367 .unwrap();
2368 let system = serde_json::to_string(&checkpoint.messages[0]).unwrap();
2369 assert!(system.contains("Initial repository rule."));
2370 assert!(system.contains("Preserve exported names."));
2371 assert!(system.contains("does not expand the read-only permissions"));
2372 send_message(
2373 &state,
2374 &id,
2375 "connection-1",
2376 "Investigate the export regression.",
2377 )
2378 .await
2379 .unwrap();
2380 wait_for_turn_complete(&entry).await;
2381 wait_for_idle(&entry).await;
2382 let err = open_discussion(
2383 &state,
2384 repo.path(),
2385 "connection-2",
2386 engine(repo.path()),
2387 script(),
2388 "operator",
2389 Some(&id),
2390 )
2391 .await
2392 .unwrap_err();
2393 assert!(err.contains("already open"));
2394 close(&state, &id, "connection-1").await.unwrap();
2395 assert!(entry
2396 .durability
2397 .checkpoint(&id, &[], "late writer", None)
2398 .await
2399 .is_err());
2400 drop(entry);
2401 drop(state);
2402
2403 std::fs::write(repo.path().join("AGENTS.md"), "Updated repository rule.").unwrap();
2404 let restarted = Arc::new(ServerState::standalone(journal.path().to_path_buf()));
2405 assert!(open_discussion(
2406 &restarted,
2407 repo.path(),
2408 "foreign",
2409 engine(repo.path()),
2410 script(),
2411 "agent:foreign",
2412 Some(&id)
2413 )
2414 .await
2415 .is_err());
2416 let resumed = open_discussion(
2417 &restarted,
2418 repo.path(),
2419 "connection-2",
2420 engine(repo.path()),
2421 script(),
2422 "operator",
2423 Some(&id),
2424 )
2425 .await
2426 .unwrap();
2427 assert_eq!(resumed["discussion_id"], id);
2428 assert_eq!(resumed["resumed"], true);
2429 let entry = get_discussion(&restarted, &id).await.unwrap();
2430 assert_eq!(entry.turns.load(Ordering::SeqCst), 1);
2431 assert!(lock(&entry.transcript)
2432 .iter()
2433 .any(|(_, text)| text == "Remember the export regression."));
2434 send_message(
2435 &restarted,
2436 &id,
2437 "connection-2",
2438 "Now explain the next step.",
2439 )
2440 .await
2441 .unwrap();
2442 wait_for_idle(&entry).await;
2443 let checkpoint = entry
2444 .durability
2445 .load_checkpoint(&id)
2446 .await
2447 .unwrap()
2448 .unwrap();
2449 let history = serde_json::to_string(&checkpoint.messages).unwrap();
2450 assert!(history.contains("Updated repository rule."));
2451 assert!(!history.contains("Initial repository rule."));
2452 assert!(history.contains("Preserve exported names."));
2453 assert!(history.contains("Investigate the export regression."));
2454 assert!(history.contains("Remember the export regression."));
2455 assert!(history.contains("Now explain the next step."));
2456 assert!(get_owned_discussion(&restarted, &id, "connection-1")
2457 .await
2458 .is_err());
2459 close(&restarted, &id, "connection-2").await.unwrap();
2460 }
2461
2462 #[tokio::test]
2463 async fn discuss_start_rejects_a_non_git_directory() {
2464 let dir = tempfile::tempdir().unwrap();
2465 let (state, _journal) = state();
2466 let script: Arc<dyn TurnGenerator> = Arc::new(Script {
2467 turns: vec![],
2468 cursor: AtomicUsize::new(0),
2469 });
2470 let err = start_discussion(&state, dir.path(), "owner-1", engine(dir.path()), script)
2471 .await
2472 .unwrap_err();
2473 assert!(
2474 err.contains("is not a git repository")
2475 && err.contains("discuss needs a repo to ground itself in"),
2476 "operator-readable non-repo error, got: {err}"
2477 );
2478 }
2479
2480 #[tokio::test]
2481 async fn conversation_advertises_relevant_tools_without_mutations() {
2482 struct Capture(Arc<StdMutex<Vec<Value>>>);
2483 #[async_trait]
2484 impl TurnGenerator for Capture {
2485 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
2486 *lock(&self.0) = req.tools.unwrap_or_default();
2487 Ok(turn("Here is how the repository is organized.", json!([])))
2488 }
2489 }
2490 let repo = tempfile::tempdir().unwrap();
2491 init_repo(repo.path());
2492 let (state, _journal) = state();
2493 let tools = Arc::new(StdMutex::new(Vec::new()));
2494 let id = start(&state, repo.path(), Arc::new(Capture(tools.clone()))).await;
2495 send_message(&state, &id, "owner-1", "Explain the repository")
2496 .await
2497 .unwrap();
2498 let entry = get_discussion(&state, &id).await.unwrap();
2499 wait_for_idle(&entry).await;
2500 let captured = lock(&tools);
2501 let names: Vec<_> = captured
2502 .iter()
2503 .filter_map(|def| def["name"].as_str())
2504 .collect();
2505 for required in [
2506 "read_file",
2507 "list_dir",
2508 "find_files",
2509 "grep_files",
2510 "prepare_coding_task",
2511 "web_search",
2512 "http_request",
2513 ] {
2514 assert!(names.contains(&required), "missing {required}: {names:?}");
2515 }
2516 assert!(
2517 names.len() <= 8,
2518 "unrelated tools leaked into coding conversation: {names:?}"
2519 );
2520 assert!(!names.contains(&"write_file"));
2521 assert!(!names.contains(&"shell"));
2522 assert!(
2523 lock(&entry.last_promote).is_none(),
2524 "a question must not automatically prepare a task"
2525 );
2526 eprintln!(
2527 "conversation tool payload: {} tools, {} JSON bytes",
2528 names.len(),
2529 serde_json::to_vec(&*captured).unwrap().len()
2530 );
2531 }
2532
2533 #[tokio::test]
2534 async fn project_policy_can_refuse_task_preparation() {
2535 let repo = tempfile::tempdir().unwrap();
2536 init_repo(repo.path());
2537 let policies = repo.path().join(".car/policies");
2538 std::fs::create_dir_all(&policies).unwrap();
2539 std::fs::write(
2540 policies.join("rules.toml"),
2541 "deny_tool = [\"prepare_coding_task\"]\n",
2542 )
2543 .unwrap();
2544 let (state, _journal) = state();
2545 let script: Arc<dyn TurnGenerator> = Arc::new(Script {
2546 turns: vec![
2547 turn(
2548 "",
2549 json!([{"id":"prepare-denied", "name":"prepare_coding_task",
2550 "arguments":{"intent":"Fix parser", "constraints":[]}}]),
2551 ),
2552 turn(
2553 "Task preparation is unavailable under repository policy.",
2554 json!([]),
2555 ),
2556 ],
2557 cursor: AtomicUsize::new(0),
2558 });
2559 let id = start(&state, repo.path(), script).await;
2560 send_message(&state, &id, "owner-1", "Fix parser")
2561 .await
2562 .unwrap();
2563 let entry = get_discussion(&state, &id).await.unwrap();
2564 wait_for_turn_complete(&entry).await;
2565 assert!(lock(&entry.last_promote).is_none());
2566 assert!(!entry
2567 .events
2568 .lock()
2569 .await
2570 .iter()
2571 .any(|event| matches!(event.kind, DiscussEventKind::TaskPrepared { .. })));
2572 }
2573
2574 #[tokio::test]
2575 async fn implementation_request_prepares_task_without_starting_execution() {
2576 let repo = tempfile::tempdir().unwrap();
2577 init_repo(repo.path());
2578 let (state, _journal) = state();
2579 let script: Arc<dyn TurnGenerator> = Arc::new(Script {
2580 turns: vec![
2581 turn(
2582 "",
2583 json!([{"id":"prepare-1", "name":"prepare_coding_task",
2584 "arguments":{"intent":"Fix the parser", "constraints":["Preserve public APIs"]}}]),
2585 ),
2586 turn("The parser task is ready for review.", json!([])),
2587 ],
2588 cursor: AtomicUsize::new(0),
2589 });
2590 let id = start(&state, repo.path(), script).await;
2591 send_message(
2592 &state,
2593 &id,
2594 "owner-1",
2595 "Please fix the parser and preserve public APIs.",
2596 )
2597 .await
2598 .unwrap();
2599 let entry = get_discussion(&state, &id).await.unwrap();
2600 wait_for_turn_complete(&entry).await;
2601 assert_eq!(entry.constraints(), vec!["Preserve public APIs"]);
2602 let events = entry.events.lock().await;
2603 assert!(events.iter().any(|event| matches!(&event.kind,
2604 DiscussEventKind::TaskPrepared { proposed_intent, constraints }
2605 if proposed_intent == "Fix the parser" && constraints == &["Preserve public APIs"])));
2606 assert!(
2607 state.coder_sessions.lock().await.is_empty(),
2608 "preparing must not create a coding session"
2609 );
2610 assert!(!repo.path().join("parser.rs").exists());
2611 drop(events);
2612 wait_for_idle(&entry).await;
2613 let journal = state.journal_dir.clone();
2614 close(&state, &id, "owner-1").await.unwrap();
2615 drop(entry);
2616 drop(state);
2617 let restarted = Arc::new(ServerState::standalone(journal));
2618 let no_turns = || -> Arc<dyn TurnGenerator> {
2619 Arc::new(Script {
2620 turns: vec![],
2621 cursor: AtomicUsize::new(0),
2622 })
2623 };
2624 open_discussion(
2625 &restarted,
2626 repo.path(),
2627 "owner-2",
2628 engine(repo.path()),
2629 no_turns(),
2630 "owner-1",
2631 Some(&id),
2632 )
2633 .await
2634 .unwrap();
2635 let resumed = get_discussion(&restarted, &id).await.unwrap();
2636 assert_eq!(resumed.constraints(), vec!["Preserve public APIs"]);
2637 assert!(
2638 resumed
2639 .events
2640 .lock()
2641 .await
2642 .iter()
2643 .any(|event| matches!(&event.kind,
2644 DiscussEventKind::ToolCall { tool, params_preview }
2645 if tool == "prepare_coding_task" && params_preview.contains("Fix the parser"))),
2646 "resume must show the recorded tool call without executing it again"
2647 );
2648 assert!(restarted.coder_sessions.lock().await.is_empty());
2649 assert!(resumed.events.lock().await.iter().any(|event| matches!(&event.kind,
2650 DiscussEventKind::TaskPrepared { proposed_intent, .. } if proposed_intent == "Fix the parser")));
2651 consume_prepared_task(&restarted, &id).await.unwrap();
2652 close(&restarted, &id, "owner-2").await.unwrap();
2653 open_discussion(
2654 &restarted,
2655 repo.path(),
2656 "owner-3",
2657 engine(repo.path()),
2658 no_turns(),
2659 "owner-1",
2660 Some(&id),
2661 )
2662 .await
2663 .unwrap();
2664 let consumed = get_discussion(&restarted, &id).await.unwrap();
2665 assert!(!consumed
2666 .events
2667 .lock()
2668 .await
2669 .iter()
2670 .any(|event| matches!(event.kind, DiscussEventKind::TaskPrepared { .. })));
2671 }
2672
2673 #[tokio::test]
2675 async fn a_discussion_writes_nothing_in_the_repo() {
2676 let repo = tempfile::tempdir().unwrap();
2677 init_repo(repo.path());
2678 std::fs::write(repo.path().join("keep.txt"), "original").unwrap();
2679 let (state, _journal) = state();
2680
2681 let script: Arc<dyn TurnGenerator> = Arc::new(Script {
2682 turns: vec![
2683 turn(
2684 "",
2685 json!([{
2686 "id": "c1", "name": "write_file",
2687 "arguments": {"path": "sneaky.txt", "content": "written by a discussion"}
2688 }]),
2689 ),
2690 turn(
2691 "",
2692 json!([{
2693 "id": "c2", "name": "shell",
2694 "arguments": {"command": "printf x > shelled.txt"}
2695 }]),
2696 ),
2697 turn(
2698 "I cannot edit from a discussion; here is what I would change.",
2699 json!([]),
2700 ),
2701 ],
2702 cursor: AtomicUsize::new(0),
2703 });
2704
2705 let id = start(&state, repo.path(), script).await;
2706 assert!(id.starts_with("disc-"));
2707 send_message(
2708 &state,
2709 &id,
2710 "owner-1",
2711 "can you just make the change for me?",
2712 )
2713 .await
2714 .unwrap();
2715 let entry = get_discussion(&state, &id).await.unwrap();
2716 wait_for_turn_complete(&entry).await;
2717
2718 assert!(
2719 !repo.path().join("sneaky.txt").exists(),
2720 "a discussion must not create files in the repo"
2721 );
2722 assert!(
2723 !repo.path().join("shelled.txt").exists(),
2724 "a discussion must not run shell commands that write"
2725 );
2726 assert_eq!(
2727 std::fs::read_to_string(repo.path().join("keep.txt")).unwrap(),
2728 "original"
2729 );
2730
2731 let events = entry.events.lock().await;
2732 assert!(
2733 events.iter().any(|e| matches!(
2734 &e.kind,
2735 DiscussEventKind::ToolResult { ok, preview, .. }
2736 if !ok && preview.contains("read-only")
2737 )),
2738 "the denial must surface as a tool_result"
2739 );
2740 drop(events);
2741 wait_for_idle(&entry).await;
2742 let checkpoint = entry
2743 .durability
2744 .load_checkpoint(&id)
2745 .await
2746 .unwrap()
2747 .unwrap();
2748 let refusals: Vec<_> = checkpoint
2749 .messages
2750 .iter()
2751 .filter_map(|message| {
2752 if let car_inference::Message::ToolResult { content, .. } = message {
2753 Some(content.as_str())
2754 } else {
2755 None
2756 }
2757 })
2758 .collect();
2759 assert!(refusals
2760 .iter()
2761 .any(|text| text.contains("prepare_coding_task")
2762 && text.contains("not a file-permission problem")));
2763 assert!(!refusals
2764 .iter()
2765 .any(|text| text.contains("declined by user")));
2766 }
2767
2768 #[tokio::test]
2772 async fn a_discussion_cannot_read_outside_the_repo() {
2773 let outside = tempfile::tempdir().unwrap();
2774 let secret_path = outside.path().join("credentials.txt");
2775 std::fs::write(&secret_path, "sk-ant-SUPERSECRETVALUE").unwrap();
2776
2777 let repo = tempfile::tempdir().unwrap();
2778 init_repo(repo.path());
2779 let (state, _journal) = state();
2780
2781 let script: Arc<dyn TurnGenerator> = Arc::new(Script {
2782 turns: vec![
2783 turn(
2785 "",
2786 json!([{
2787 "id": "c1", "name": "read_file",
2788 "arguments": {"path": secret_path.to_string_lossy()}
2789 }]),
2790 ),
2791 turn(
2793 "",
2794 json!([{
2795 "id": "c2", "name": "grep_files",
2796 "arguments": {"path": outside.path().to_string_lossy(), "pattern": "sk-ant-"}
2797 }]),
2798 ),
2799 turn("I can only read inside this repository.", json!([])),
2800 ],
2801 cursor: AtomicUsize::new(0),
2802 });
2803
2804 let id = start(&state, repo.path(), script).await;
2805 send_message(
2806 &state,
2807 &id,
2808 "owner-1",
2809 "what credentials does this project use?",
2810 )
2811 .await
2812 .unwrap();
2813 let entry = get_discussion(&state, &id).await.unwrap();
2814 wait_for_turn_complete(&entry).await;
2815
2816 let events = entry.events.lock().await;
2817 let stream = serde_json::to_string(&*events).unwrap();
2818 assert!(
2819 !stream.contains("SUPERSECRETVALUE"),
2820 "a discussion must never stream content from outside its repo: {stream}"
2821 );
2822 }
2823
2824 #[tokio::test]
2825 async fn selected_model_survives_reopen_and_auto_clears_it() {
2826 struct Recording {
2827 requests: Arc<StdMutex<Vec<(Option<String>, bool)>>>,
2828 }
2829 #[async_trait]
2830 impl TurnGenerator for Recording {
2831 async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
2832 lock(&self.requests).push((req.model, req.params.strict_model));
2833 Ok(turn("A grounded reply.", json!([])))
2834 }
2835 }
2836 let repo = tempfile::tempdir().unwrap();
2837 init_repo(repo.path());
2838 let (state, journal) = state();
2839 let mut cfg = car_inference::InferenceConfig::default();
2840 cfg.models_dir = journal.path().join("models");
2841 let engine = Arc::new(car_inference::InferenceEngine::new(cfg));
2842 let requests = Arc::new(StdMutex::new(Vec::new()));
2843 let generator: Arc<dyn TurnGenerator> = Arc::new(Recording {
2844 requests: requests.clone(),
2845 });
2846 let rejected = open_discussion_with_model(
2847 &state,
2848 repo.path(),
2849 "owner",
2850 engine.clone(),
2851 generator.clone(),
2852 "operator",
2853 None,
2854 Some("qwen/qwen3-embedding-0.6b:q8_0"),
2855 )
2856 .await
2857 .unwrap_err();
2858 assert!(
2859 rejected.contains("cannot call repository tools"),
2860 "{rejected}"
2861 );
2862 assert!(requests.lock().unwrap().is_empty());
2863 let first = open_discussion_with_model(
2864 &state,
2865 repo.path(),
2866 "owner",
2867 engine.clone(),
2868 generator.clone(),
2869 "operator",
2870 None,
2871 Some("anthropic/claude-opus-4-6:latest"),
2872 )
2873 .await
2874 .unwrap();
2875 let id = first["discussion_id"].as_str().unwrap();
2876 assert_eq!(first["model"], "anthropic/claude-opus-4-6:latest");
2877 for selection in [None, Some("auto")] {
2878 send_message(&state, id, "owner", "What is here?")
2879 .await
2880 .unwrap();
2881 let entry = get_discussion(&state, id).await.unwrap();
2882 wait_for_idle(&entry).await;
2883 close(&state, id, "owner").await.unwrap();
2884 let reopened = open_discussion_with_model(
2885 &state,
2886 repo.path(),
2887 "owner",
2888 engine.clone(),
2889 generator.clone(),
2890 "operator",
2891 Some(id),
2892 selection,
2893 )
2894 .await
2895 .unwrap();
2896 if selection.is_none() {
2897 assert_eq!(reopened["model"], "anthropic/claude-opus-4-6:latest");
2898 } else {
2899 assert!(reopened["model"].is_null());
2900 }
2901 }
2902 send_message(&state, id, "owner", "Continue.")
2903 .await
2904 .unwrap();
2905 wait_for_idle(&get_discussion(&state, id).await.unwrap()).await;
2906 close(&state, id, "owner").await.unwrap();
2907 let captured = lock(&requests);
2908 assert_eq!(
2909 *captured,
2910 vec![
2911 (Some("anthropic/claude-opus-4-6:latest".into()), true),
2912 (Some("anthropic/claude-opus-4-6:latest".into()), true),
2913 (None, false)
2914 ]
2915 );
2916 assert!(DiscussionRecord::load(&state.journal_dir, id, "operator")
2917 .unwrap()
2918 .model
2919 .is_none());
2920 }
2921
2922 #[tokio::test]
2923 async fn starting_refuses_to_drop_requirements_when_distillation_fails() {
2924 let repo = tempfile::tempdir().unwrap();
2925 init_repo(repo.path());
2926 let (state, _journal) = state();
2927 let script: Arc<dyn TurnGenerator> = Arc::new(Script {
2928 turns: vec![turn("I will preserve the public API.", json!([]))],
2930 cursor: AtomicUsize::new(0),
2931 });
2932 let id = start(&state, repo.path(), script).await;
2933 send_message(&state, &id, "owner-1", "Preserve the public API.")
2934 .await
2935 .unwrap();
2936 let entry = get_discussion(&state, &id).await.unwrap();
2937 wait_for_idle(&entry).await;
2938
2939 let error = constraints_for_start(&state, &id).await.unwrap_err();
2940 assert!(error.contains("No task was started"), "{error}");
2941 assert!(error.contains("Retry Build"), "{error}");
2942 assert!(lock(&entry.last_promote).is_none());
2943 assert!(!entry.transcript_is_empty());
2944 assert!(state.coder_sessions.lock().await.is_empty());
2945 close(&state, &id, "owner-1").await.unwrap();
2946 }
2947
2948 #[tokio::test]
2949 async fn promote_distills_an_intent_and_starts_nothing() {
2950 let repo = tempfile::tempdir().unwrap();
2951 init_repo(repo.path());
2952 let (state, _journal) = state();
2953
2954 let script: Arc<dyn TurnGenerator> = Arc::new(Script {
2955 turns: vec![
2956 turn("The Windows path is the risky one.", json!([])),
2957 turn(
2958 r#"{"proposed_intent":"Make the config loader resolve paths on Windows.",
2959 "constraints":["do not change the POSIX behavior"]}"#,
2960 json!([]),
2961 ),
2962 turn("Understood, preserve both platforms.", json!([])),
2963 ],
2964 cursor: AtomicUsize::new(0),
2965 });
2966
2967 let id = start(&state, repo.path(), script).await;
2968 send_message(
2969 &state,
2970 &id,
2971 "owner-1",
2972 "what is fragile about the config loader?",
2973 )
2974 .await
2975 .unwrap();
2976 let entry = get_discussion(&state, &id).await.unwrap();
2977 wait_for_turn_complete(&entry).await;
2978
2979 wait_for_idle(&entry).await;
2980 let promoted = promote(&state, &id, "owner-1").await.unwrap();
2981 assert_eq!(
2982 promoted["proposed_intent"],
2983 "Make the config loader resolve paths on Windows."
2984 );
2985 assert_eq!(
2986 promoted["constraints"],
2987 json!(["do not change the POSIX behavior"])
2988 );
2989 assert!(state.coder_sessions.lock().await.is_empty());
2990 assert_eq!(
2991 constraints_for_start(&state, &id).await.unwrap(),
2992 vec!["do not change the POSIX behavior".to_string()]
2993 );
2994 send_message(
2995 &state,
2996 &id,
2997 "owner-1",
2998 "Also preserve Windows compatibility.",
2999 )
3000 .await
3001 .unwrap();
3002 assert!(
3003 lock(&entry.last_promote).is_none(),
3004 "a new turn must invalidate the old plan constraints"
3005 );
3006 wait_for_idle(&entry).await;
3007 close(&state, &id, "owner-1").await.unwrap();
3008 }
3009
3010 #[tokio::test]
3021 async fn an_account_refusal_ends_the_discussion_turn_with_its_remedy() {
3022 struct SignedOut;
3023 #[async_trait]
3024 impl TurnGenerator for SignedOut {
3025 async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
3026 panic!("the assistant loop must generate through the typed seam")
3027 }
3028
3029 async fn generate_assistant(
3030 &self,
3031 _req: GenerateRequest,
3032 ) -> Result<InferenceResult, crate::coder::native_loop::AssistantGenerateError>
3033 {
3034 Err(crate::coder::native_loop::AssistantGenerateError::from(
3035 car_inference::InferenceError::CredentialUnavailable {
3036 provider: "parslee".into(),
3037 model: "parslee/advisor".into(),
3038 reason: car_inference::CredentialFailure::SignedOut,
3039 detail: "no account is signed in. Run `car auth login`".into(),
3040 },
3041 ))
3042 }
3043 }
3044
3045 let repo = tempfile::tempdir().unwrap();
3046 init_repo(repo.path());
3047 let (state, _journal) = state();
3048 let generator: Arc<dyn TurnGenerator> = Arc::new(SignedOut);
3049 let id = start(&state, repo.path(), generator).await;
3050
3051 send_message(&state, &id, "owner-1", "what is fragile here?")
3052 .await
3053 .unwrap();
3054 let entry = get_discussion(&state, &id).await.unwrap();
3055 wait_for_turn_complete(&entry).await;
3056
3057 let kinds: Vec<DiscussEventKind> = {
3058 let events = entry.events.lock().await;
3059 events.iter().map(|e| e.kind.clone()).collect()
3060 };
3061 let message = kinds
3062 .iter()
3063 .find_map(|k| match k {
3064 DiscussEventKind::Error { message } => Some(message.clone()),
3065 _ => None,
3066 })
3067 .expect("the refusal must reach the client as a terminal error");
3068 assert_eq!(
3069 message,
3070 crate::assistant::AUTH_REQUIRED_SIGNED_OUT_MESSAGE,
3071 "the remedy is the daemon's approved copy, verbatim"
3072 );
3073 assert!(
3074 matches!(kinds.last(), Some(DiscussEventKind::TurnComplete {})),
3075 "the turn must end with TurnComplete: {kinds:?}"
3076 );
3077
3078 send_message(&state, &id, "owner-1", "and the second question?")
3081 .await
3082 .expect("a completed turn must accept the next message");
3083 }
3084
3085 #[tokio::test]
3089 async fn a_turn_in_flight_blocks_a_second_send_and_promote() {
3090 let repo = tempfile::tempdir().unwrap();
3091 init_repo(repo.path());
3092 let (state, _journal) = state();
3093 let gate = Arc::new(tokio::sync::Notify::new());
3094 let generator: Arc<dyn TurnGenerator> = Arc::new(Blocking { gate: gate.clone() });
3095
3096 let id = start(&state, repo.path(), generator).await;
3097 send_message(&state, &id, "owner-1", "first question")
3098 .await
3099 .unwrap();
3100
3101 let entry = get_discussion(&state, &id).await.unwrap();
3102 for _ in 0..200 {
3103 if entry.is_answering() {
3104 break;
3105 }
3106 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
3107 }
3108 assert!(entry.is_answering(), "the turn should be in flight");
3109
3110 let err = send_message(&state, &id, "owner-1", "second question")
3111 .await
3112 .unwrap_err();
3113 assert!(
3114 err.contains("still answering"),
3115 "a concurrent send must be refused, not silently lose a turn: {err}"
3116 );
3117 let err = promote(&state, &id, "owner-1").await.unwrap_err();
3118 assert!(
3119 err.contains("still answering"),
3120 "promote must not distill a half-finished turn: {err}"
3121 );
3122
3123 gate.notify_one();
3124 wait_for_turn_complete(&entry).await;
3125 }
3126
3127 #[tokio::test]
3130 async fn close_cancels_an_in_flight_turn() {
3131 let repo = tempfile::tempdir().unwrap();
3132 init_repo(repo.path());
3133 let (state, _journal) = state();
3134 let gate = Arc::new(tokio::sync::Notify::new());
3135 let generator: Arc<dyn TurnGenerator> = Arc::new(Blocking { gate });
3136
3137 let id = start(&state, repo.path(), generator).await;
3138 send_message(&state, &id, "owner-1", "a broad question")
3139 .await
3140 .unwrap();
3141 let entry = get_discussion(&state, &id).await.unwrap();
3142 for _ in 0..200 {
3143 if entry.is_answering() {
3144 break;
3145 }
3146 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
3147 }
3148
3149 close(&state, &id, "owner-1").await.unwrap();
3150 assert!(!entry.is_answering(), "close must stop the turn");
3151 assert!(state.coder_discussions.lock().await.is_empty());
3152 }
3153
3154 #[tokio::test]
3168 async fn a_send_cancelled_after_dispatch_leaves_the_discussion_usable() {
3169 let repo = tempfile::tempdir().unwrap();
3170 init_repo(repo.path());
3171 let (state, _journal) = state();
3172 let script: Arc<dyn TurnGenerator> = Arc::new(Script {
3173 turns: vec![
3174 turn("answered anyway", json!([])),
3175 turn("answered on the retry", json!([])),
3176 ],
3177 cursor: AtomicUsize::new(0),
3178 });
3179 let id = start(&state, repo.path(), script).await;
3180 let entry = get_discussion(&state, &id).await.unwrap();
3181
3182 let mut send = Box::pin(send_message(
3187 &state,
3188 &id,
3189 "owner-1",
3190 "the message whose reply frame gets cancelled",
3191 ));
3192 assert!(
3193 matches!(futures::poll!(send.as_mut()), std::task::Poll::Pending),
3194 "the fixture needs the send parked on its cursor"
3195 );
3196 assert!(
3197 entry.is_answering(),
3198 "the fixture needs the CAS to have run"
3199 );
3200 drop(send);
3201
3202 wait_for_turn_complete(&entry).await;
3204 for _ in 0..200 {
3205 if !entry.is_answering() {
3206 break;
3207 }
3208 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
3209 }
3210 assert!(
3211 !entry.is_answering(),
3212 "a cancelled handler must not strand `in_flight`"
3213 );
3214
3215 send_message(&state, &id, "owner-1", "second try")
3217 .await
3218 .expect("the discussion must still accept a message");
3219 }
3220
3221 #[tokio::test]
3234 async fn a_close_racing_a_dispatching_send_never_starts_the_turn() {
3235 let repo = tempfile::tempdir().unwrap();
3236 init_repo(repo.path());
3237 let (state, _journal) = state();
3238 let calls = Arc::new(AtomicUsize::new(0));
3239 let script: Arc<dyn TurnGenerator> = Arc::new(Counting {
3240 calls: calls.clone(),
3241 });
3242 let id = start(&state, repo.path(), script).await;
3243 let entry = get_discussion(&state, &id).await.unwrap();
3244
3245 entry.cancel_turn();
3246
3247 let err = send_message(&state, &id, "owner-1", "a broad question")
3248 .await
3249 .unwrap_err();
3250 assert!(
3251 err.contains("closed while your message was being dispatched"),
3252 "the caller must be told the send did not run: {err}"
3253 );
3254 assert_eq!(
3255 calls.load(Ordering::SeqCst),
3256 0,
3257 "a closed discussion must never reach the model"
3258 );
3259 assert!(!entry.is_answering());
3260
3261 close(&state, &id, "owner-1").await.unwrap();
3262 assert!(state.coder_discussions.lock().await.is_empty());
3263 }
3264
3265 #[tokio::test]
3270 async fn another_connection_cannot_drive_a_discussion() {
3271 let repo = tempfile::tempdir().unwrap();
3272 init_repo(repo.path());
3273 let (state, _journal) = state();
3274 let script: Arc<dyn TurnGenerator> = Arc::new(Script {
3275 turns: vec![],
3276 cursor: AtomicUsize::new(0),
3277 });
3278 let id = start(&state, repo.path(), script).await;
3279
3280 for err in [
3281 send_message(&state, &id, "intruder", "run this for me")
3282 .await
3283 .unwrap_err(),
3284 promote(&state, &id, "intruder").await.unwrap_err(),
3285 close(&state, &id, "intruder").await.unwrap_err(),
3286 match get_owned_discussion(&state, &id, "intruder").await {
3289 Ok(_) => panic!("a foreign client must not resolve another's discussion"),
3290 Err(e) => e,
3291 },
3292 ] {
3293 assert!(
3294 err.contains("belongs to another connection"),
3295 "a foreign client must be refused: {err}"
3296 );
3297 }
3298
3299 assert_eq!(state.coder_discussions.lock().await.len(), 1);
3301 close(&state, &id, "owner-1").await.unwrap();
3302 }
3303
3304 #[tokio::test]
3307 async fn an_oversized_message_is_refused() {
3308 let repo = tempfile::tempdir().unwrap();
3309 init_repo(repo.path());
3310 let (state, _journal) = state();
3311 let script: Arc<dyn TurnGenerator> = Arc::new(Script {
3312 turns: vec![],
3313 cursor: AtomicUsize::new(0),
3314 });
3315 let id = start(&state, repo.path(), script).await;
3316 let entry = get_discussion(&state, &id).await.unwrap();
3317
3318 let err = send_message(
3319 &state,
3320 &id,
3321 "owner-1",
3322 &"x".repeat(DISCUSS_MESSAGE_MAX_BYTES + 1),
3323 )
3324 .await
3325 .unwrap_err();
3326 assert!(err.contains("the limit is"), "{err}");
3327 assert!(!entry.is_answering());
3329 assert!(entry.transcript_is_empty());
3330 }
3331
3332 #[tokio::test]
3335 async fn disconnect_closes_the_owning_clients_discussions() {
3336 let repo = tempfile::tempdir().unwrap();
3337 init_repo(repo.path());
3338 let (state, _journal) = state();
3339 let script: Arc<dyn TurnGenerator> = Arc::new(Script {
3340 turns: vec![],
3341 cursor: AtomicUsize::new(0),
3342 });
3343 let id = start(&state, repo.path(), script).await;
3344 assert_eq!(state.coder_discussions.lock().await.len(), 1);
3345
3346 drop_subscriptions_for_client(&state, "someone-else").await;
3348 assert_eq!(state.coder_discussions.lock().await.len(), 1);
3349
3350 drop_subscriptions_for_client(&state, "owner-1").await;
3352 assert!(state.coder_discussions.lock().await.is_empty());
3353 assert!(get_discussion(&state, &id).await.is_err());
3354 }
3355
3356 #[tokio::test]
3362 async fn open_discussions_are_capped() {
3363 let repo = tempfile::tempdir().unwrap();
3364 init_repo(repo.path());
3365 let (state, _journal) = state();
3366 let held: Vec<_> = (0..MAX_OPEN_DISCUSSIONS)
3367 .map(|_| {
3368 state
3369 .coder_discussion_slots
3370 .clone()
3371 .try_acquire_owned()
3372 .expect("a fresh daemon has every slot free")
3373 })
3374 .collect();
3375
3376 let script: Arc<dyn TurnGenerator> = Arc::new(Script {
3377 turns: vec![],
3378 cursor: AtomicUsize::new(0),
3379 });
3380 let err = start_discussion(&state, repo.path(), "owner-1", engine(repo.path()), script)
3381 .await
3382 .unwrap_err();
3383 assert!(err.contains("already open"), "{err}");
3384
3385 drop(held);
3387 let script: Arc<dyn TurnGenerator> = Arc::new(Script {
3388 turns: vec![],
3389 cursor: AtomicUsize::new(0),
3390 });
3391 start_discussion(&state, repo.path(), "owner-1", engine(repo.path()), script)
3392 .await
3393 .expect("a released slot must be reusable");
3394 }
3395
3396 #[tokio::test]
3405 async fn concurrent_starts_cannot_exceed_the_open_discussion_cap() {
3406 let repo = tempfile::tempdir().unwrap();
3407 init_repo(repo.path());
3408 let (state, _journal) = state();
3409 let _held: Vec<_> = (0..MAX_OPEN_DISCUSSIONS - 1)
3410 .map(|_| {
3411 state
3412 .coder_discussion_slots
3413 .clone()
3414 .try_acquire_owned()
3415 .unwrap()
3416 })
3417 .collect();
3418
3419 let mut racers = Vec::new();
3420 for _ in 0..4 {
3421 let state = state.clone();
3422 let repo = repo.path().to_path_buf();
3423 racers.push(tokio::spawn(async move {
3424 let script: Arc<dyn TurnGenerator> = Arc::new(Script {
3425 turns: vec![],
3426 cursor: AtomicUsize::new(0),
3427 });
3428 start_discussion(&state, &repo, "owner-1", engine(&repo), script).await
3429 }));
3430 }
3431
3432 let mut admitted = 0;
3433 let mut refused = 0;
3434 for racer in racers {
3435 match racer.await.unwrap() {
3436 Ok(_) => admitted += 1,
3437 Err(e) => {
3438 assert!(e.contains("already open"), "unexpected refusal: {e}");
3439 refused += 1;
3440 }
3441 }
3442 }
3443 assert_eq!(admitted, 1, "exactly one racer may take the last slot");
3444 assert_eq!(refused, 3);
3445 assert_eq!(
3446 state.coder_discussions.lock().await.len(),
3447 1,
3448 "the registry must never exceed the cap"
3449 );
3450 }
3451
3452 #[tokio::test]
3453 async fn unknown_discussion_ids_are_clear_errors() {
3454 let (state, _journal) = state();
3455 for err in [
3456 send_message(&state, "disc-nope", "owner-1", "hi")
3457 .await
3458 .unwrap_err(),
3459 promote(&state, "disc-nope", "owner-1").await.unwrap_err(),
3460 constraints_for_start(&state, "disc-nope")
3461 .await
3462 .unwrap_err(),
3463 ] {
3464 assert!(err.contains("disc-nope"), "must name the id, got: {err}");
3465 }
3466 assert!(close(&state, "disc-nope", "owner-1").await.is_err());
3467 }
3468
3469 #[test]
3470 fn checkout_followup_uses_current_files_but_requires_a_known_delivery() {
3471 assert_eq!(
3472 followup_base_from(&[
3473 json!({"state":"merged", "result_delivery":"checkout", "result_commit":"saved"})
3474 ])
3475 .unwrap(),
3476 None
3477 );
3478 assert!(
3479 followup_base_from(&[json!({"state":"merged", "result_delivery":"checkout"})]).is_err()
3480 );
3481 assert_eq!(
3482 followup_base_from(&[
3483 json!({"state":"merged", "result_delivery":"branch", "result_commit":"saved"})
3484 ])
3485 .unwrap(),
3486 Some("saved".into())
3487 );
3488 }
3489
3490 #[test]
3491 fn followup_never_substitutes_head_for_known_work() {
3492 assert!(followup_base_from(&[
3493 json!({"state": "merged", "updated_at": 1, "result_commit": "one"}),
3494 json!({"state": "merged", "updated_at": 1, "result_commit": "two"}),
3495 ])
3496 .is_err());
3497 assert!(
3498 followup_base_from(&[json!({"state": "merged", "result_branch": "moved"})]).is_err()
3499 );
3500 assert!(
3501 followup_base_from(&[json!({"state": "failed", "worktree": "/retained"})]).is_err()
3502 );
3503 let rows = vec![
3504 json!({"state": "failed", "worktree": null}),
3505 json!({"state": "merged", "result_commit": "fixed-revision"}),
3506 ];
3507 assert_eq!(
3508 followup_base_from(&rows).unwrap().as_deref(),
3509 Some("fixed-revision")
3510 );
3511 }
3512
3513 #[tokio::test]
3514 async fn linked_work_is_grounded_scoped_and_blocks_overlapping_starts() {
3515 use super::super::router::EngineChoice;
3516 use super::super::session::{CoderSession, CoderState};
3517 let repo = tempfile::tempdir().unwrap();
3518 init_repo(repo.path());
3519 let (state, _journal) = state();
3520 let dir = tempfile::tempdir().unwrap();
3521 let id = start(
3522 &state,
3523 repo.path(),
3524 Arc::new(Script {
3525 turns: vec![],
3526 cursor: AtomicUsize::new(0),
3527 }),
3528 )
3529 .await;
3530 let entry = get_discussion(&state, &id).await.unwrap();
3531 assert!(
3532 claim_coding_start(&state, &id, Path::new("/different"), dir.path().into())
3533 .await
3534 .unwrap_err()
3535 .contains("repository")
3536 );
3537 let guard = claim_coding_start_at(&state, &id, dir.path().into())
3538 .await
3539 .unwrap();
3540 assert!(tokio::time::timeout(
3541 std::time::Duration::from_millis(20),
3542 claim_coding_start_at(&state, &id, dir.path().into())
3543 )
3544 .await
3545 .is_err());
3546 let mut run = CoderSession::new(
3547 &entry.repo,
3548 "repair export",
3549 EngineChoice::Native,
3550 3,
3551 Some(dir.path().into()),
3552 );
3553 run.discussion_id = Some(id.clone());
3554 run.state = CoderState::NeedsApproval;
3555 run.workspace_path = Some(repo.path().into());
3556 run.persist().unwrap();
3557 drop(guard);
3558 let error = claim_coding_start_at(&state, &id, dir.path().into())
3559 .await
3560 .unwrap_err();
3561 assert!(error.contains(&run.id));
3562 assert!(error.contains("unfinished work"));
3563 run.state = CoderState::Failed;
3564 run.error = Some("export check failed".into());
3565 run.persist().unwrap();
3566 let mut unrelated = CoderSession::new(
3567 &entry.repo,
3568 "private other conversation",
3569 EngineChoice::Native,
3570 3,
3571 Some(dir.path().into()),
3572 );
3573 unrelated.discussion_id = Some("other".into());
3574 unrelated.persist().unwrap();
3575 let rows = coding_runs(&state, &id, &entry.repo, dir.path().into())
3576 .await
3577 .unwrap();
3578 assert_eq!(rows.len(), 1);
3579 assert_eq!(rows[0]["error"], "export check failed");
3580 assert_eq!(rows[0]["live"], false);
3581 assert!(
3582 retained_workspace(&state, &id, &entry.repo, dir.path().into())
3583 .await
3584 .unwrap_err()
3585 .contains("not been confirmed stopped")
3586 );
3587 run.execution_stopped = true;
3588 run.persist().unwrap();
3589 assert!(
3590 retained_workspace(&state, &id, &entry.repo, dir.path().into())
3591 .await
3592 .is_err(),
3593 "a stopped task cannot adopt a path outside the daemon's worktrees"
3594 );
3595 assert!(
3596 coding_runs(&state, &id, Path::new("/different"), dir.path().into())
3597 .await
3598 .unwrap()
3599 .is_empty()
3600 );
3601 assert!(claim_coding_start_at(&state, &id, dir.path().into())
3602 .await
3603 .is_ok());
3604 close(&state, &id, "owner-1").await.unwrap();
3605 }
3606
3607 #[tokio::test]
3608 async fn saved_list_survives_restart_and_filters_principals_and_live_owners() {
3609 let repo = tempfile::tempdir().unwrap();
3610 init_repo(repo.path());
3611 let (state, journal) = state();
3612 let owner = client(&state, "operator-1").await;
3613 let other = client(&state, "agent-1").await;
3614 *other.agent_id.lock().await = Some("foreign".into());
3615 let script: Arc<dyn TurnGenerator> = Arc::new(Script {
3616 turns: vec![],
3617 cursor: AtomicUsize::new(0),
3618 });
3619 let opened = open_discussion(
3620 &state,
3621 repo.path(),
3622 &owner.client_id,
3623 engine(repo.path()),
3624 script,
3625 "operator",
3626 None,
3627 )
3628 .await
3629 .unwrap();
3630 let id = opened["discussion_id"].as_str().unwrap();
3631 assert!(handle_discuss_list(&state, &owner).await.unwrap()["saved"]
3632 .as_array()
3633 .unwrap()
3634 .is_empty());
3635 close(&state, id, &owner.client_id).await.unwrap();
3636 let restarted = Arc::new(ServerState::standalone(journal.path().to_path_buf()));
3637 let saved = handle_discuss_list(&restarted, &owner).await.unwrap();
3638 assert_eq!(saved["saved"][0]["discussion_id"], id);
3639 assert!(saved["discussions"].as_array().unwrap().is_empty());
3640 assert!(
3641 handle_discuss_list(&restarted, &other).await.unwrap()["saved"]
3642 .as_array()
3643 .unwrap()
3644 .is_empty()
3645 );
3646 }
3647
3648 #[tokio::test]
3649 async fn list_and_close_track_open_discussions() {
3650 let repo = tempfile::tempdir().unwrap();
3651 init_repo(repo.path());
3652 let (state, _journal) = state();
3653 let script: Arc<dyn TurnGenerator> = Arc::new(Script {
3654 turns: vec![],
3655 cursor: AtomicUsize::new(0),
3656 });
3657 let id = start(&state, repo.path(), script).await;
3658 let owner = client(&state, "owner-1").await;
3659
3660 let listed = handle_discuss_list(&state, &owner).await.unwrap();
3661 assert_eq!(listed["discussions"].as_array().unwrap().len(), 1);
3662 assert_eq!(listed["discussions"][0]["discussion_id"], id.as_str());
3663 assert_eq!(listed["discussions"][0]["turns"], 0);
3664
3665 let stranger = client(&state, "someone-else").await;
3667 let listed = handle_discuss_list(&state, &stranger).await.unwrap();
3668 assert!(
3669 listed["discussions"].as_array().unwrap().is_empty(),
3670 "another connection must not see this discussion: {listed}"
3671 );
3672
3673 assert_eq!(
3674 close(&state, &id, "owner-1").await.unwrap(),
3675 json!({ "ok": true })
3676 );
3677 let listed = handle_discuss_list(&state, &owner).await.unwrap();
3678 assert!(listed["discussions"].as_array().unwrap().is_empty());
3679 }
3680
3681 #[tokio::test]
3705 async fn a_subscriber_receives_every_seq_exactly_once_across_its_attach() {
3706 let repo = tempfile::tempdir().unwrap();
3707 init_repo(repo.path());
3708 let (state, _journal) = state();
3709 let script: Arc<dyn TurnGenerator> = Arc::new(Script {
3710 turns: vec![],
3711 cursor: AtomicUsize::new(0),
3712 });
3713 let id = start(&state, repo.path(), script).await;
3714 let entry = get_discussion(&state, &id).await.unwrap();
3715
3716 let (channel, frames) = capturing_channel();
3717 let owner = state
3718 .create_session("owner-1", channel.clone())
3719 .await
3720 .unwrap();
3721
3722 let racing = {
3726 let entry = entry.clone();
3727 tokio::spawn(async move {
3728 for i in 0..30u64 {
3729 entry
3730 .emit(DiscussEventKind::AssistantDelta {
3731 text: format!("during-{i}"),
3732 })
3733 .await;
3734 }
3735 })
3736 };
3737 while entry.events.lock().await.is_empty() {
3740 tokio::task::yield_now().await;
3741 }
3742 let subscribed = handle_discuss_subscribe(
3743 &rpc_req(json!({ "discussion_id": id, "from_seq": 0 })),
3744 &state,
3745 &owner,
3746 )
3747 .await
3748 .unwrap();
3749 racing.await.unwrap();
3750
3751 for i in 0..20u64 {
3753 entry
3754 .emit(DiscussEventKind::AssistantDelta {
3755 text: format!("after-{i}"),
3756 })
3757 .await;
3758 }
3759
3760 const TOTAL: usize = 50;
3761 let replayed = subscribed["events_replayed"].as_u64().unwrap();
3762 assert!(
3763 replayed > 0,
3764 "the attach replayed nothing, so this test never exercised the \
3765 replay hop it exists to cover"
3766 );
3767 assert!(
3768 replayed <= 30,
3769 "replay cannot exceed what was emitted before the attach: {replayed}"
3770 );
3771
3772 let mut seqs = Vec::new();
3773 for _ in 0..400 {
3774 seqs = delivered_seqs(&frames);
3775 if seqs.len() >= TOTAL {
3776 break;
3777 }
3778 tokio::time::sleep(std::time::Duration::from_millis(25)).await;
3779 }
3780 assert_eq!(
3781 seqs,
3782 (0..TOTAL as u64).collect::<Vec<_>>(),
3783 "a subscriber must receive seq 0..{TOTAL} once each, in order"
3784 );
3785
3786 const RESUME_FROM: u64 = 17;
3791 let (resumed_channel, resumed_frames) = capturing_channel();
3792 let resumed = state
3795 .create_session("owner-1", resumed_channel)
3796 .await
3797 .unwrap();
3798 let reattached = handle_discuss_subscribe(
3799 &rpc_req(json!({ "discussion_id": id, "from_seq": RESUME_FROM })),
3800 &state,
3801 &resumed,
3802 )
3803 .await
3804 .unwrap();
3805 assert_eq!(
3806 reattached["events_replayed"].as_u64().unwrap(),
3807 TOTAL as u64 - RESUME_FROM,
3808 "a resume from {RESUME_FROM} must replay seq {RESUME_FROM}..{TOTAL}"
3809 );
3810
3811 let mut resumed_seqs = Vec::new();
3812 for _ in 0..400 {
3813 resumed_seqs = delivered_seqs(&resumed_frames);
3814 if resumed_seqs.len() >= TOTAL - RESUME_FROM as usize {
3815 break;
3816 }
3817 tokio::time::sleep(std::time::Duration::from_millis(25)).await;
3818 }
3819 assert_eq!(
3820 resumed_seqs,
3821 (RESUME_FROM..TOTAL as u64).collect::<Vec<_>>(),
3822 "a resume must start AT its cursor, not one past it"
3823 );
3824 }
3825
3826 #[tokio::test]
3830 async fn a_dispatched_send_emits_the_user_message_before_any_delta() {
3831 let repo = tempfile::tempdir().unwrap();
3832 init_repo(repo.path());
3833 let (state, _journal) = state();
3834 let script: Arc<dyn TurnGenerator> = Arc::new(Script {
3835 turns: vec![turn("here is what I would change", json!([]))],
3836 cursor: AtomicUsize::new(0),
3837 });
3838 let id = start(&state, repo.path(), script).await;
3839 let entry = get_discussion(&state, &id).await.unwrap();
3840
3841 let sent = send_message(&state, &id, "owner-1", "what should this change do?")
3842 .await
3843 .unwrap();
3844 assert_eq!(
3845 sent["seq"], 0,
3846 "the reported cursor is the user_message's own seq"
3847 );
3848 wait_for_turn_complete(&entry).await;
3849
3850 let events = entry.events.lock().await;
3851 assert!(
3852 matches!(events[0].kind, DiscussEventKind::UserMessage { .. }),
3853 "the operator's message must be the turn's first event, got: {:?}",
3854 events[0].kind
3855 );
3856 assert!(
3857 events.len() > 1,
3858 "the turn produced nothing to order against"
3859 );
3860 assert!(
3861 !events[1..]
3862 .iter()
3863 .any(|e| matches!(e.kind, DiscussEventKind::UserMessage { .. })),
3864 "exactly one user_message per send"
3865 );
3866 }
3867
3868 #[tokio::test]
3878 async fn a_wedged_subscriber_is_shed_and_the_turn_still_completes() {
3879 let repo = tempfile::tempdir().unwrap();
3880 init_repo(repo.path());
3881 let (state, _journal) = state();
3882 let script: Arc<dyn TurnGenerator> = Arc::new(Script {
3883 turns: vec![turn("here is what I would change", json!([]))],
3884 cursor: AtomicUsize::new(0),
3885 });
3886 let id = start(&state, repo.path(), script).await;
3887 let entry = get_discussion(&state, &id).await.unwrap();
3888
3889 let (channel, _frames) = capturing_channel();
3890 let owner = state
3891 .create_session("owner-1", channel.clone())
3892 .await
3893 .unwrap();
3894 let unsubscribed = Arc::strong_count(&channel);
3895 handle_discuss_subscribe(
3896 &rpc_req(json!({ "discussion_id": id, "from_seq": 0 })),
3897 &state,
3898 &owner,
3899 )
3900 .await
3901 .unwrap();
3902 assert_eq!(
3903 Arc::strong_count(&channel),
3904 unsubscribed + 1,
3905 "the lane must hold this subscriber's channel"
3906 );
3907
3908 let stuck = channel.write.lock().await;
3910
3911 let started = std::time::Instant::now();
3912 send_message(&state, &id, "owner-1", "what should this change do?")
3913 .await
3914 .unwrap();
3915 let mut completed = false;
3916 for _ in 0..120 {
3917 if entry
3918 .events
3919 .lock()
3920 .await
3921 .iter()
3922 .any(|e| matches!(e.kind, DiscussEventKind::TurnComplete {}))
3923 {
3924 completed = true;
3925 break;
3926 }
3927 tokio::time::sleep(std::time::Duration::from_millis(25)).await;
3928 }
3929 assert!(
3930 completed && started.elapsed() < DISCUSS_SEND_TIMEOUT,
3931 "the turn must not wait on a wedged subscriber's socket ({:?} elapsed)",
3932 started.elapsed()
3933 );
3934
3935 for i in 0..(DISCUSS_SUBSCRIBER_QUEUE + 64) {
3939 entry
3940 .emit(DiscussEventKind::AssistantDelta {
3941 text: format!("overflow-{i}"),
3942 })
3943 .await;
3944 }
3945 let mut shed = false;
3946 for _ in 0..200 {
3947 if Arc::strong_count(&channel) == unsubscribed {
3948 shed = true;
3949 break;
3950 }
3951 tokio::time::sleep(std::time::Duration::from_millis(25)).await;
3952 }
3953 assert!(
3954 shed,
3955 "a subscriber that is not draining must be shed, not retained"
3956 );
3957 drop(stuck);
3958 }
3959
3960 #[tokio::test]
3977 async fn a_refused_send_leaves_no_unanswered_turn_in_the_transcript() {
3978 let repo = tempfile::tempdir().unwrap();
3979 init_repo(repo.path());
3980 let (state, _journal) = state();
3981 let calls = Arc::new(AtomicUsize::new(0));
3982 let script: Arc<dyn TurnGenerator> = Arc::new(Counting {
3983 calls: calls.clone(),
3984 });
3985 let id = start(&state, repo.path(), script).await;
3986 let entry = get_discussion(&state, &id).await.unwrap();
3987
3988 entry.cancel_turn();
3992 let err = send_message(&state, &id, "owner-1", "should we rewrite the scheduler?")
3993 .await
3994 .unwrap_err();
3995 assert!(
3996 err.contains("closed while your message was being dispatched"),
3997 "expected a refused dispatch, got: {err}"
3998 );
3999
4000 assert!(
4001 !entry.is_answering(),
4002 "a refused dispatch must not strand `in_flight`"
4003 );
4004 assert!(
4005 entry.transcript_is_empty(),
4006 "a question no turn will answer must not survive in the transcript: {:?}",
4007 lock(&entry.transcript)
4008 );
4009 assert!(
4010 !entry
4011 .events
4012 .lock()
4013 .await
4014 .iter()
4015 .any(|e| matches!(e.kind, DiscussEventKind::UserMessage { .. })),
4016 "...nor reach the replay buffer and every subscriber"
4017 );
4018 let err = promote(&state, &id, "owner-1").await.unwrap_err();
4020 assert!(
4021 err.contains("no turns yet"),
4022 "promote must refuse an empty discussion rather than distill a stranded \
4023 question: {err}"
4024 );
4025 assert!(
4026 constraints_for_start(&state, &id).await.unwrap().is_empty(),
4027 "coder.start must not distill constraints from a stranded question"
4028 );
4029 assert_eq!(
4030 calls.load(Ordering::SeqCst),
4031 0,
4032 "no turn ran, so nothing reached the model"
4033 );
4034 }
4035
4036 #[tokio::test]
4039 async fn concurrent_emits_stay_seq_ordered_in_the_buffer() {
4040 let repo = tempfile::tempdir().unwrap();
4041 init_repo(repo.path());
4042 let (state, _journal) = state();
4043 let script: Arc<dyn TurnGenerator> = Arc::new(Script {
4044 turns: vec![],
4045 cursor: AtomicUsize::new(0),
4046 });
4047 let id = start(&state, repo.path(), script).await;
4048 let entry = get_discussion(&state, &id).await.unwrap();
4049
4050 let mut tasks = Vec::new();
4051 for i in 0..50 {
4052 let e = entry.clone();
4053 tasks.push(tokio::spawn(async move {
4054 e.emit(DiscussEventKind::AssistantDelta {
4055 text: format!("chunk-{i}"),
4056 })
4057 .await
4058 }));
4059 }
4060 for t in tasks {
4061 t.await.unwrap();
4062 }
4063
4064 let events = entry.events.lock().await;
4065 assert_eq!(events.len(), 50);
4066 for (i, e) in events.iter().enumerate() {
4067 assert_eq!(e.seq, i as u64, "buffer must be in seq order");
4068 }
4069 }
4070
4071 #[test]
4072 fn discuss_event_json_shape_is_ws_friendly() {
4073 let e = DiscussEvent {
4074 discussion_id: "disc-x".into(),
4075 seq: 7,
4076 ts: 1,
4077 kind: DiscussEventKind::AssistantDelta {
4078 text: "hello".into(),
4079 },
4080 };
4081 let v = serde_json::to_value(&e).unwrap();
4082 assert_eq!(v["type"], "assistant_delta");
4083 assert_eq!(v["text"], "hello");
4084 assert_eq!(v["seq"], 7);
4085 assert_eq!(v["discussion_id"], "disc-x");
4086
4087 let v = serde_json::to_value(DiscussEvent {
4088 discussion_id: "disc-x".into(),
4089 seq: 8,
4090 ts: 1,
4091 kind: DiscussEventKind::TurnComplete {},
4092 })
4093 .unwrap();
4094 assert_eq!(v["type"], "turn_complete");
4095 }
4096}