1use std::collections::HashMap;
92use std::path::{Path, PathBuf};
93use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
94use std::sync::{Arc, Mutex as StdMutex, MutexGuard};
95
96use serde::{Deserialize, Serialize};
97use serde_json::{json, Value};
98use tokio::sync::{mpsc, oneshot};
99
100use crate::assistant::{
101 bind_default_substrate, build_assistant_runtime, prompt, AssistantConfig, AssistantService,
102};
103use crate::coder::native_loop::TurnGenerator;
104use crate::handler::JsonRpcMessage;
105use crate::session::{ClientSession, ServerState, WsChannel};
106
107const DISCUSS_MAX_TURNS: u32 = 12;
110
111const PROMOTE_MAX_ATTEMPTS: u32 = 3;
115
116pub(crate) const MAX_OPEN_DISCUSSIONS: usize = 8;
127
128const DISCUSSION_IDLE_TTL_SECS: u64 = 60 * 60;
132
133const DISCUSS_EVENT_BUFFER_MAX: usize = 2000;
137
138const TRANSCRIPT_MAX_TURNS: usize = 40;
142
143const DISTILL_WINDOW_TURNS: usize = 12;
146
147const DISCUSS_MESSAGE_MAX_BYTES: usize = 64 * 1024;
156
157const DISCUSS_SUBSCRIBER_QUEUE: usize = DISCUSS_EVENT_BUFFER_MAX + 128;
164
165const DISCUSS_SEND_TIMEOUT: std::time::Duration = crate::coder::rpc::FANOUT_WRITE_TIMEOUT;
169
170pub type DiscussionMap = HashMap<String, Arc<DiscussionEntry>>;
172
173fn lock<T>(m: &StdMutex<T>) -> MutexGuard<'_, T> {
181 m.lock().unwrap_or_else(|e| e.into_inner())
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct DiscussEvent {
188 pub discussion_id: String,
189 pub seq: u64,
190 pub ts: u64,
191 #[serde(flatten)]
192 pub kind: DiscussEventKind,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize)]
198#[serde(tag = "type", rename_all = "snake_case")]
199pub enum DiscussEventKind {
200 UserMessage {
201 text: String,
202 },
203 AssistantDelta {
205 text: String,
206 },
207 AssistantMessage {
209 text: String,
210 },
211 ToolCall {
212 tool: String,
213 params_preview: String,
214 },
215 ToolResult {
216 tool: String,
217 ok: bool,
218 preview: String,
219 },
220 TurnComplete {},
221 Error {
222 message: String,
223 },
224}
225
226enum StreamCmd {
229 Emit(DiscussEventKind, oneshot::Sender<u64>),
230 Attach {
231 client_id: String,
232 channel: Arc<WsChannel>,
233 from_seq: u64,
234 replayed: oneshot::Sender<u64>,
235 },
236 Detach(String),
237}
238
239#[derive(Default)]
252struct TurnSlot {
253 closed: bool,
257 handle: Option<tokio::task::JoinHandle<()>>,
258}
259
260struct InFlightGuard(Arc<DiscussionEntry>);
273
274impl Drop for InFlightGuard {
275 fn drop(&mut self) {
276 self.0.in_flight.store(false, Ordering::SeqCst);
277 self.0.touch();
278 }
279}
280
281struct TurnRecordGuard {
295 entry: Arc<DiscussionEntry>,
296 text: String,
297 dispatched: bool,
298}
299
300impl Drop for TurnRecordGuard {
301 fn drop(&mut self) {
302 if !self.dispatched {
303 self.entry.rollback_turn("Operator", &self.text);
304 }
305 }
306}
307
308pub struct DiscussionEntry {
310 pub id: String,
311 pub repo: PathBuf,
313 pub repo_summary: String,
316 pub created_at: u64,
317 owner_client_id: String,
320 pub events: Arc<tokio::sync::Mutex<Vec<DiscussEvent>>>,
323 cmds: mpsc::UnboundedSender<StreamCmd>,
325 turns: AtomicU64,
327 in_flight: AtomicBool,
332 last_active: AtomicU64,
334 turn_task: StdMutex<TurnSlot>,
337 _slot: tokio::sync::OwnedSemaphorePermit,
340 service: Arc<AssistantService>,
342 generator: Arc<dyn TurnGenerator>,
345 transcript: StdMutex<Vec<(&'static str, String)>>,
349 last_promote: StdMutex<Option<(String, Vec<String>)>>,
353}
354
355impl DiscussionEntry {
356 pub fn constraints(&self) -> Vec<String> {
358 lock(&self.last_promote)
359 .as_ref()
360 .map(|(_, c)| c.clone())
361 .unwrap_or_default()
362 }
363
364 pub fn is_answering(&self) -> bool {
366 self.in_flight.load(Ordering::SeqCst)
367 }
368
369 fn touch(&self) {
370 self.last_active.store(now_secs(), Ordering::SeqCst);
371 }
372
373 fn idle_secs(&self) -> u64 {
374 now_secs().saturating_sub(self.last_active.load(Ordering::SeqCst))
375 }
376
377 fn record_turn(&self, role: &'static str, text: &str) {
378 if text.trim().is_empty() {
379 return;
380 }
381 let mut t = lock(&self.transcript);
382 t.push((role, text.to_string()));
383 let len = t.len();
385 if len > TRANSCRIPT_MAX_TURNS {
386 t.drain(..len - TRANSCRIPT_MAX_TURNS);
387 }
388 }
389
390 fn rollback_turn(&self, role: &'static str, text: &str) {
394 let mut t = lock(&self.transcript);
395 if t.last().is_some_and(|(r, s)| *r == role && s == text) {
396 t.pop();
397 }
398 }
399
400 fn distill_transcript(&self) -> String {
402 let t = lock(&self.transcript);
403 let start = t.len().saturating_sub(DISTILL_WINDOW_TURNS);
404 t[start..]
405 .iter()
406 .map(|(role, text)| format!("{role}: {text}"))
407 .collect::<Vec<_>>()
408 .join("\n\n")
409 }
410
411 fn transcript_is_empty(&self) -> bool {
412 lock(&self.transcript).is_empty()
413 }
414
415 async fn emit(&self, kind: DiscussEventKind) -> u64 {
420 let (tx, rx) = oneshot::channel();
421 if self.cmds.send(StreamCmd::Emit(kind, tx)).is_err() {
422 return 0; }
424 rx.await.unwrap_or(0)
425 }
426
427 fn cancel_turn(&self) {
434 self.service.cancel(&self.id);
435 {
436 let mut slot = lock(&self.turn_task);
437 slot.closed = true;
438 if let Some(handle) = slot.handle.take() {
439 handle.abort();
440 }
441 }
442 self.in_flight.store(false, Ordering::SeqCst);
443 }
444
445 fn spawn_turn<F>(&self, make: F) -> bool
451 where
452 F: FnOnce() -> tokio::task::JoinHandle<()>,
453 {
454 let mut slot = lock(&self.turn_task);
455 if slot.closed {
456 return false;
457 }
458 slot.handle = Some(make());
460 true
461 }
462
463 fn summary_row(&self) -> Value {
464 json!({
465 "discussion_id": self.id,
466 "repo": self.repo,
467 "created_at": self.created_at,
468 "turns": self.turns.load(Ordering::SeqCst),
469 })
470 }
471}
472
473fn now_secs() -> u64 {
474 std::time::SystemTime::now()
475 .duration_since(std::time::UNIX_EPOCH)
476 .map(|d| d.as_secs())
477 .unwrap_or(0)
478}
479
480fn event_frame(event: &DiscussEvent) -> Option<String> {
481 serde_json::to_string(&json!({
482 "jsonrpc": "2.0",
483 "method": "coder.discuss.event",
484 "params": event,
485 }))
486 .ok()
487}
488
489struct Subscriber {
496 frames: mpsc::Sender<String>,
497 task: tokio::task::JoinHandle<()>,
498}
499
500impl Drop for Subscriber {
501 fn drop(&mut self) {
505 self.task.abort();
506 }
507}
508
509fn spawn_subscriber(channel: Arc<WsChannel>) -> Subscriber {
510 let (frames, mut rx) = mpsc::channel::<String>(DISCUSS_SUBSCRIBER_QUEUE);
511 let task = tokio::spawn(async move {
512 while let Some(frame) = rx.recv().await {
513 if tokio::time::timeout(
514 DISCUSS_SEND_TIMEOUT,
515 crate::coder::rpc::send_frame(&channel, &frame),
516 )
517 .await
518 .is_err()
519 {
520 break;
524 }
525 }
526 });
527 Subscriber { frames, task }
528}
529
530fn spawn_discuss_drain(
541 discussion_id: String,
542 events: Arc<tokio::sync::Mutex<Vec<DiscussEvent>>>,
543) -> mpsc::UnboundedSender<StreamCmd> {
544 let (tx, mut rx) = mpsc::unbounded_channel::<StreamCmd>();
545 tokio::spawn(async move {
546 let mut subscribers: HashMap<String, Subscriber> = HashMap::new();
547 let mut next_seq: u64 = 0;
548 while let Some(cmd) = rx.recv().await {
549 match cmd {
550 StreamCmd::Emit(kind, reply) => {
551 let seq = next_seq;
552 next_seq += 1;
553 let event = DiscussEvent {
554 discussion_id: discussion_id.clone(),
555 seq,
556 ts: now_secs(),
557 kind,
558 };
559 let frame = event_frame(&event);
560 {
561 let mut buffer = events.lock().await;
562 buffer.push(event);
563 let len = buffer.len();
564 if len > DISCUSS_EVENT_BUFFER_MAX {
565 buffer.drain(..len - DISCUSS_EVENT_BUFFER_MAX);
566 }
567 } let _ = reply.send(seq);
569 if let Some(frame) = &frame {
570 subscribers.retain(|client_id, s| {
574 let ok = s.frames.try_send(frame.clone()).is_ok();
575 if !ok {
576 tracing::warn!(
577 discussion_id = %discussion_id,
578 client_id = %client_id,
579 "discussion subscriber is not draining; dropping it"
580 );
581 }
582 ok
583 });
584 }
585 }
586 StreamCmd::Attach {
587 client_id,
588 channel,
589 from_seq,
590 replayed,
591 } => {
592 let frames: Vec<String> = {
594 let buffer = events.lock().await;
595 buffer
596 .iter()
597 .filter(|e| e.seq >= from_seq)
598 .filter_map(event_frame)
599 .collect()
600 };
601 let subscriber = spawn_subscriber(channel);
602 let mut n = 0u64;
605 for frame in frames {
606 if subscriber.frames.try_send(frame).is_err() {
607 break;
608 }
609 n += 1;
610 }
611 subscribers.insert(client_id, subscriber);
612 let _ = replayed.send(n);
613 }
614 StreamCmd::Detach(client_id) => {
615 subscribers.remove(&client_id);
616 }
617 }
618 }
619 });
621 tx
622}
623
624pub async fn start_discussion(
634 state: &Arc<ServerState>,
635 repo: &Path,
636 owner_client_id: &str,
637 engine: Arc<car_inference::InferenceEngine>,
638 generator: Arc<dyn TurnGenerator>,
639) -> Result<Value, String> {
640 let probe = repo.to_path_buf();
644 let repo = tokio::task::spawn_blocking(move || {
645 let repo = probe
646 .canonicalize()
647 .map_err(|e| format!("repo path {}: {e}", probe.display()))?;
648 if !super::rpc::is_git_repo(&repo) {
649 return Err(format!(
650 "{} is not a git repository — discuss needs a repo to ground itself in",
651 repo.display()
652 ));
653 }
654 Ok(repo)
655 })
656 .await
657 .map_err(|e| format!("repo probe failed: {e}"))??;
658
659 reap_idle(state).await;
662 let slot = state
669 .coder_discussion_slots
670 .clone()
671 .try_acquire_owned()
672 .map_err(|_| {
673 format!(
674 "{MAX_OPEN_DISCUSSIONS} discussions are already open — close one with \
675 coder.discuss.close before starting another"
676 )
677 })?;
678
679 let summarize = repo.clone();
680 let repo_summary = tokio::task::spawn_blocking(move || super::rpc::summarize_repo(&summarize))
681 .await
682 .map_err(|e| format!("repo summary failed: {e}"))?;
683
684 let mut env = bind_default_substrate(true, false, &repo, None).await;
689 env.clamp_reads = true;
693 let asm = build_assistant_runtime(engine, env, None, None, None, None, false).await?;
695 let system = format!(
696 "{}\n\nYou are in a DISCUSSION about this repository, not a work session. \
697 You have read-only access, scoped to this repository: you can read and reason \
698 about the code here, but any attempt to write a file, run a shell command, or \
699 read outside {} WILL be refused. Do not propose to make the change yourself — \
700 help the operator decide what the change should be, what it must not break, and \
701 how they would know it worked. Be concrete and cite real paths from the repo.",
702 prompt::chat_prompt(&asm.identity, &asm.description, &asm.tools),
703 repo.display()
704 );
705 let cfg = AssistantConfig {
706 model: None,
707 strict_model: false,
708 max_turns: DISCUSS_MAX_TURNS,
709 tools: asm.tools.clone(),
710 gated_tools: asm.gated_tools.clone(),
711 approval_policy: None,
712 proactive_memory: None,
716 tool_memory: None,
717 tool_labels: None,
718 todos: None,
721 value_store_previews: crate::assistant::agent_loop::VALUE_STORE_PREVIEWS_DEFAULT,
725 response_format: None,
726 context_window_override: None,
727 refuse_unadvertised_tools: false,
728 response_format_validator: None,
729 delegate_budget: None,
730 };
731 let service = Arc::new(AssistantService::new(
732 generator.clone(),
733 Arc::new(asm.runtime),
734 cfg,
735 system,
736 ));
737
738 let id = format!("disc-{}", uuid::Uuid::new_v4().simple());
739 let events = Arc::new(tokio::sync::Mutex::new(Vec::new()));
740 let cmds = spawn_discuss_drain(id.clone(), events.clone());
741 let entry = Arc::new(DiscussionEntry {
742 id: id.clone(),
743 repo: repo.clone(),
744 repo_summary: repo_summary.clone(),
745 created_at: now_secs(),
746 owner_client_id: owner_client_id.to_string(),
747 events,
748 cmds,
749 turns: AtomicU64::new(0),
750 in_flight: AtomicBool::new(false),
751 last_active: AtomicU64::new(now_secs()),
752 turn_task: StdMutex::new(TurnSlot::default()),
753 _slot: slot,
754 service,
755 generator,
756 transcript: StdMutex::new(Vec::new()),
757 last_promote: StdMutex::new(None),
758 });
759 state
760 .coder_discussions
761 .lock()
762 .await
763 .insert(id.clone(), entry);
764
765 Ok(json!({
766 "discussion_id": id,
767 "repo": repo,
768 "repo_summary": repo_summary,
769 }))
770}
771
772async fn reap_idle(state: &Arc<ServerState>) {
774 let stale: Vec<Arc<DiscussionEntry>> = {
775 let open = state.coder_discussions.lock().await;
776 open.values()
777 .filter(|e| e.idle_secs() > DISCUSSION_IDLE_TTL_SECS)
778 .cloned()
779 .collect()
780 };
781 for entry in stale {
782 entry.cancel_turn();
783 state.coder_discussions.lock().await.remove(&entry.id);
784 }
785}
786
787async fn get_discussion(
788 state: &Arc<ServerState>,
789 discussion_id: &str,
790) -> Result<Arc<DiscussionEntry>, String> {
791 state
792 .coder_discussions
793 .lock()
794 .await
795 .get(discussion_id)
796 .cloned()
797 .ok_or_else(|| {
798 format!(
799 "no open discussion '{discussion_id}' — discussions are in-memory and do not \
800 survive a daemon restart; start a new one with coder.discuss.start"
801 )
802 })
803}
804
805pub(crate) async fn get_owned_discussion(
814 state: &Arc<ServerState>,
815 discussion_id: &str,
816 client_id: &str,
817) -> Result<Arc<DiscussionEntry>, String> {
818 let entry = get_discussion(state, discussion_id).await?;
819 if entry.owner_client_id != client_id {
820 return Err(format!(
821 "discussion '{discussion_id}' belongs to another connection — a discussion is \
822 owned by the connection that opened it and closes with it; start your own with \
823 coder.discuss.start"
824 ));
825 }
826 Ok(entry)
827}
828
829pub async fn send_message(
842 state: &Arc<ServerState>,
843 discussion_id: &str,
844 client_id: &str,
845 text: &str,
846) -> Result<Value, String> {
847 let entry = get_owned_discussion(state, discussion_id, client_id).await?;
848 if text.trim().is_empty() {
849 return Err("discuss message is empty".to_string());
850 }
851 if text.len() > DISCUSS_MESSAGE_MAX_BYTES {
852 return Err(format!(
853 "that message is {} bytes; the limit is {DISCUSS_MESSAGE_MAX_BYTES}. A discussion \
854 keeps every message in its transcript, its replay buffer, and its distillation \
855 prompt — point at a file in the repo instead of pasting it",
856 text.len()
857 ));
858 }
859 if entry
860 .in_flight
861 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
862 .is_err()
863 {
864 return Err(format!(
865 "{discussion_id} is still answering the previous message — wait for \
866 `turn_complete` before sending another"
867 ));
868 }
869 let mut guard = Some(InFlightGuard(entry.clone()));
880 entry.touch();
881 entry.record_turn("Operator", text);
882 let mut recorded = TurnRecordGuard {
886 entry: entry.clone(),
887 text: text.to_string(),
888 dispatched: false,
889 };
890
891 let task_entry = entry.clone();
892 let prompt_text = text.to_string();
893 let (seq_tx, seq_rx) = oneshot::channel::<u64>();
903 let dispatched = entry.spawn_turn(|| {
906 let guard = guard.take();
907 tokio::spawn(async move {
908 let _guard = guard;
911 let seq = task_entry
912 .emit(DiscussEventKind::UserMessage {
913 text: prompt_text.clone(),
914 })
915 .await;
916 let _ = seq_tx.send(seq);
917 run_turn(task_entry, prompt_text).await;
918 })
919 });
920 if !dispatched {
921 return Err(format!(
922 "{discussion_id} was closed while your message was being dispatched — nothing is \
923 running; start a new discussion"
924 ));
925 }
926 recorded.dispatched = true;
928
929 let first_seq = seq_rx.await.unwrap_or(0);
933 Ok(json!({ "ok": true, "seq": first_seq }))
934}
935
936async fn run_turn(entry: Arc<DiscussionEntry>, text: String) {
939 let sink_entry = entry.clone();
940 let assembled: Arc<StdMutex<String>> = Arc::new(StdMutex::new(String::new()));
941 let sink_assembled = assembled.clone();
942
943 let service = entry.service.clone();
944 let sink_service = service.clone();
947 let id = entry.id.clone();
948 service
949 .handle_turn(&id, &text, None, move |payload: Value| {
950 let entry = sink_entry.clone();
951 let assembled = sink_assembled.clone();
952 let service = sink_service.clone();
953 async move {
954 let kind = payload.get("kind").and_then(Value::as_str).unwrap_or("");
955 match kind {
956 "token" => {
957 let delta = payload
958 .get("delta")
959 .and_then(Value::as_str)
960 .unwrap_or_default()
961 .to_string();
962 if delta.is_empty() {
963 return;
964 }
965 lock(&assembled).push_str(&delta);
966 entry
967 .emit(DiscussEventKind::AssistantDelta { text: delta })
968 .await;
969 }
970 "tool_call" => {
971 let tool = payload
972 .get("tool")
973 .and_then(Value::as_str)
974 .unwrap_or("tool")
975 .to_string();
976 let params_preview = payload
977 .get("params")
978 .map(|p| preview(&p.to_string()))
979 .unwrap_or_default();
980 entry
981 .emit(DiscussEventKind::ToolCall {
982 tool,
983 params_preview,
984 })
985 .await;
986 }
987 "approval_pending" => {
993 let tool = payload
994 .get("tool")
995 .and_then(Value::as_str)
996 .unwrap_or("tool")
997 .to_string();
998 if let Some(approval_id) =
999 payload.get("approval_id").and_then(Value::as_str)
1000 {
1001 service.resolve_approval(approval_id, false);
1002 }
1003 entry
1004 .emit(DiscussEventKind::ToolResult {
1005 tool,
1006 ok: false,
1007 preview: "refused: a discussion is read-only — it cannot write \
1008 files or run commands. Describe the change instead; \
1009 `coder.start` is what performs it."
1010 .to_string(),
1011 })
1012 .await;
1013 }
1014 "done" => {
1015 let text = payload
1016 .get("text")
1017 .and_then(Value::as_str)
1018 .unwrap_or_default()
1019 .to_string();
1020 let text = if text.trim().is_empty() {
1021 lock(&assembled).clone()
1022 } else {
1023 text
1024 };
1025 entry.record_turn("Assistant", &text);
1026 entry.turns.fetch_add(1, Ordering::SeqCst);
1027 entry
1028 .emit(DiscussEventKind::AssistantMessage { text })
1029 .await;
1030 entry.emit(DiscussEventKind::TurnComplete {}).await;
1031 }
1032 "error" => {
1033 let message = payload
1034 .get("error")
1035 .and_then(Value::as_str)
1036 .unwrap_or("discussion turn failed")
1037 .to_string();
1038 entry.emit(DiscussEventKind::Error { message }).await;
1039 entry.emit(DiscussEventKind::TurnComplete {}).await;
1040 }
1041 _ => {}
1042 }
1043 }
1044 })
1045 .await;
1046}
1047
1048fn preview(s: &str) -> String {
1049 const CAP: usize = 200;
1050 if s.chars().count() <= CAP {
1051 return s.to_string();
1052 }
1053 let mut out: String = s.chars().take(CAP).collect();
1054 out.push('…');
1055 out
1056}
1057
1058pub async fn promote(
1069 state: &Arc<ServerState>,
1070 discussion_id: &str,
1071 client_id: &str,
1072) -> Result<Value, String> {
1073 let entry = get_owned_discussion(state, discussion_id, client_id).await?;
1074 if entry.is_answering() {
1075 return Err(format!(
1076 "{discussion_id} is still answering — try again in a moment"
1077 ));
1078 }
1079 if entry.transcript_is_empty() {
1080 return Err(
1081 "this discussion has no turns yet — say what you are trying to do first".to_string(),
1082 );
1083 }
1084 let (intent, constraints) = distill(
1085 &entry.generator,
1086 &entry.distill_transcript(),
1087 &entry.repo_summary,
1088 )
1089 .await?;
1090 *lock(&entry.last_promote) = Some((intent.clone(), constraints.clone()));
1091 entry.touch();
1092 Ok(json!({
1093 "discussion_id": entry.id,
1094 "proposed_intent": intent,
1095 "constraints": constraints,
1096 }))
1097}
1098
1099async fn distill(
1103 generator: &Arc<dyn TurnGenerator>,
1104 transcript: &str,
1105 repo_summary: &str,
1106) -> Result<(String, Vec<String>), String> {
1107 let mut last_err = String::from("no attempt was made");
1108 for _ in 0..PROMOTE_MAX_ATTEMPTS {
1109 let prompt = format!(
1110 "A developer has been discussing a change to a codebase. Distill the discussion \
1111 into ONE actionable coding intent plus the constraints they agreed on.\n\n\
1112 REPOSITORY\n{repo_summary}\n\n\
1113 DISCUSSION (most recent turns)\n{transcript}\n\n\
1114 Return ONLY a JSON object, no prose and no code fences:\n\
1115 {{\n \"proposed_intent\": \"one paragraph, imperative, what to change and why\",\n \
1116 \"constraints\": [\"a thing the change must not break or must respect\"]\n}}\n\n\
1117 Rules:\n\
1118 - `proposed_intent` is an INSTRUCTION, not a summary of the conversation. Never \
1119 quote the transcript back.\n\
1120 - Include only constraints actually agreed in the discussion. If none were, \
1121 return an empty array — do not invent any.\n"
1122 );
1123 let text = match generator
1124 .generate(car_inference::GenerateRequest {
1125 prompt,
1126 params: car_inference::GenerateParams {
1127 temperature: 0.0,
1128 max_tokens: 1024,
1129 thinking: car_inference::tasks::generate::ThinkingMode::Off,
1130 ..Default::default()
1131 },
1132 ..Default::default()
1133 })
1134 .await
1135 {
1136 Ok(r) => r.text,
1137 Err(e) => {
1138 last_err = format!("generation failed: {e}");
1139 continue;
1140 }
1141 };
1142 let value = match super::contract::extract_json_object(&text) {
1143 Ok(v) => v,
1144 Err(e) => {
1145 last_err = format!("output did not parse: {e}");
1146 continue;
1147 }
1148 };
1149 let intent = value
1150 .get("proposed_intent")
1151 .and_then(Value::as_str)
1152 .unwrap_or_default()
1153 .trim()
1154 .to_string();
1155 if intent.is_empty() {
1156 last_err = "the model returned no proposed_intent".to_string();
1157 continue;
1158 }
1159 let constraints: Vec<String> = value
1160 .get("constraints")
1161 .and_then(Value::as_array)
1162 .map(|a| {
1163 a.iter()
1164 .filter_map(Value::as_str)
1165 .map(str::trim)
1166 .filter(|s| !s.is_empty())
1167 .map(str::to_string)
1168 .collect()
1169 })
1170 .unwrap_or_default();
1171 return Ok((intent, constraints));
1172 }
1173 Err(format!(
1174 "could not distill this discussion into an intent after {PROMOTE_MAX_ATTEMPTS} \
1175 attempts: {last_err}"
1176 ))
1177}
1178
1179pub async fn constraints_for_start(
1191 state: &Arc<ServerState>,
1192 discussion_id: &str,
1193) -> Result<Vec<String>, String> {
1194 let entry = get_discussion(state, discussion_id).await?;
1195 let cached = entry.constraints();
1196 if !cached.is_empty() {
1197 return Ok(cached);
1198 }
1199 if entry.is_answering() {
1200 return Err(format!(
1201 "{discussion_id} is still answering — wait for `turn_complete` before starting a \
1202 run from it, or the constraints would be distilled from a question with no \
1203 answer beside it"
1204 ));
1205 }
1206 if lock(&entry.last_promote).is_some() {
1207 return Ok(Vec::new());
1209 }
1210 if entry.transcript_is_empty() {
1211 return Ok(Vec::new());
1212 }
1213 match distill(
1214 &entry.generator,
1215 &entry.distill_transcript(),
1216 &entry.repo_summary,
1217 )
1218 .await
1219 {
1220 Ok((intent, constraints)) => {
1221 *lock(&entry.last_promote) = Some((intent, constraints.clone()));
1222 Ok(constraints)
1223 }
1224 Err(e) => {
1225 tracing::warn!(discussion_id, "discussion constraints unavailable: {e}");
1226 Ok(Vec::new())
1227 }
1228 }
1229}
1230
1231pub async fn close(
1234 state: &Arc<ServerState>,
1235 discussion_id: &str,
1236 client_id: &str,
1237) -> Result<Value, String> {
1238 get_owned_discussion(state, discussion_id, client_id).await?;
1241 let entry = state.coder_discussions.lock().await.remove(discussion_id);
1242 let Some(entry) = entry else {
1243 return Err(format!("no open discussion '{discussion_id}'"));
1244 };
1245 entry.cancel_turn();
1250 Ok(json!({ "ok": true }))
1251}
1252
1253pub async fn drop_subscriptions_for_client(state: &ServerState, client_id: &str) {
1262 let (owned, others): (Vec<_>, Vec<_>) = {
1263 let open = state.coder_discussions.lock().await;
1264 open.values()
1265 .cloned()
1266 .partition(|e| e.owner_client_id == client_id)
1267 };
1268 for entry in &others {
1269 let _ = entry.cmds.send(StreamCmd::Detach(client_id.to_string()));
1270 }
1271 if owned.is_empty() {
1272 return;
1273 }
1274 let mut open = state.coder_discussions.lock().await;
1275 for entry in owned {
1276 entry.cancel_turn();
1277 open.remove(&entry.id);
1278 }
1279}
1280
1281#[derive(Deserialize)]
1286struct StartParams {
1287 repo: PathBuf,
1288}
1289
1290pub async fn handle_discuss_start(
1291 req: &JsonRpcMessage,
1292 state: &Arc<ServerState>,
1293 session: &Arc<ClientSession>,
1294) -> Result<Value, String> {
1295 let params: StartParams =
1296 serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
1297 let engine = crate::handler::get_inference_engine(state).clone();
1298 let generator: Arc<dyn TurnGenerator> = engine.clone();
1299 start_discussion(state, ¶ms.repo, &session.client_id, engine, generator).await
1300}
1301
1302#[derive(Deserialize)]
1303struct SendParams {
1304 discussion_id: String,
1305 text: String,
1306}
1307
1308pub async fn handle_discuss_send(
1309 req: &JsonRpcMessage,
1310 state: &Arc<ServerState>,
1311 session: &Arc<ClientSession>,
1312) -> Result<Value, String> {
1313 let params: SendParams =
1314 serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
1315 send_message(
1316 state,
1317 ¶ms.discussion_id,
1318 &session.client_id,
1319 ¶ms.text,
1320 )
1321 .await
1322}
1323
1324#[derive(Deserialize)]
1325struct DiscussionIdParams {
1326 discussion_id: String,
1327}
1328
1329#[derive(Deserialize)]
1330struct SubscribeParams {
1331 discussion_id: String,
1332 #[serde(default)]
1333 from_seq: u64,
1334}
1335
1336pub async fn handle_discuss_subscribe(
1337 req: &JsonRpcMessage,
1338 state: &Arc<ServerState>,
1339 session: &Arc<ClientSession>,
1340) -> Result<Value, String> {
1341 let params: SubscribeParams =
1342 serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
1343 let entry = get_owned_discussion(state, ¶ms.discussion_id, &session.client_id).await?;
1344 entry.touch();
1347 let (tx, rx) = oneshot::channel();
1351 entry
1352 .cmds
1353 .send(StreamCmd::Attach {
1354 client_id: session.client_id.clone(),
1355 channel: session.channel.clone(),
1356 from_seq: params.from_seq,
1357 replayed: tx,
1358 })
1359 .map_err(|_| format!("discussion '{}' is closing", params.discussion_id))?;
1360 let replayed = rx.await.unwrap_or(0);
1361 Ok(json!({ "events_replayed": replayed }))
1362}
1363
1364pub async fn handle_discuss_unsubscribe(
1365 req: &JsonRpcMessage,
1366 state: &Arc<ServerState>,
1367 session: &Arc<ClientSession>,
1368) -> Result<Value, String> {
1369 let params: DiscussionIdParams =
1370 serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
1371 if let Ok(entry) = get_discussion(state, ¶ms.discussion_id).await {
1372 let _ = entry
1373 .cmds
1374 .send(StreamCmd::Detach(session.client_id.clone()));
1375 }
1376 Ok(json!({ "ok": true }))
1377}
1378
1379pub async fn handle_discuss_promote(
1380 req: &JsonRpcMessage,
1381 state: &Arc<ServerState>,
1382 session: &Arc<ClientSession>,
1383) -> Result<Value, String> {
1384 let params: DiscussionIdParams =
1385 serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
1386 promote(state, ¶ms.discussion_id, &session.client_id).await
1387}
1388
1389pub async fn handle_discuss_close(
1390 req: &JsonRpcMessage,
1391 state: &Arc<ServerState>,
1392 session: &Arc<ClientSession>,
1393) -> Result<Value, String> {
1394 let params: DiscussionIdParams =
1395 serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
1396 close(state, ¶ms.discussion_id, &session.client_id).await
1397}
1398
1399pub async fn handle_discuss_list(
1405 state: &Arc<ServerState>,
1406 session: &Arc<ClientSession>,
1407) -> Result<Value, String> {
1408 let mut rows: Vec<Value> = state
1409 .coder_discussions
1410 .lock()
1411 .await
1412 .values()
1413 .filter(|e| e.owner_client_id == session.client_id)
1414 .map(|e| e.summary_row())
1415 .collect();
1416 rows.sort_by_key(|v| std::cmp::Reverse(v["created_at"].as_u64().unwrap_or(0)));
1417 Ok(json!({ "discussions": rows }))
1418}
1419
1420#[cfg(test)]
1421mod tests {
1422 use super::*;
1423 use async_trait::async_trait;
1424 use car_inference::{GenerateRequest, InferenceResult};
1425 use std::sync::atomic::AtomicUsize;
1426
1427 fn turn(text: &str, tool_calls: Value) -> InferenceResult {
1428 serde_json::from_value(json!({
1429 "text": text, "tool_calls": tool_calls,
1430 "trace_id": "t", "model_used": "scripted", "latency_ms": 0,
1431 }))
1432 .expect("scripted InferenceResult shape")
1433 }
1434
1435 struct Script {
1436 turns: Vec<InferenceResult>,
1437 cursor: AtomicUsize,
1438 }
1439
1440 #[async_trait]
1441 impl TurnGenerator for Script {
1442 async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
1443 let i = self.cursor.fetch_add(1, Ordering::SeqCst);
1444 self.turns
1445 .get(i)
1446 .cloned()
1447 .ok_or_else(|| "script exhausted".to_string())
1448 }
1449 }
1450
1451 struct Blocking {
1459 gate: Arc<tokio::sync::Notify>,
1460 }
1461
1462 #[async_trait]
1463 impl TurnGenerator for Blocking {
1464 async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
1465 self.gate.notified().await;
1466 Ok(turn("done at last", json!([])))
1467 }
1468 }
1469
1470 struct Counting {
1472 calls: Arc<AtomicUsize>,
1473 }
1474
1475 #[async_trait]
1476 impl TurnGenerator for Counting {
1477 async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
1478 self.calls.fetch_add(1, Ordering::SeqCst);
1479 Ok(turn("counted", json!([])))
1480 }
1481 }
1482
1483 fn init_repo(dir: &Path) {
1484 for args in [
1485 vec!["init", "-q", "-b", "main"],
1486 vec![
1487 "-c",
1488 "user.name=t",
1489 "-c",
1490 "user.email=t@t",
1491 "commit",
1492 "-q",
1493 "--allow-empty",
1494 "-m",
1495 "init",
1496 ],
1497 ] {
1498 let out = std::process::Command::new("git")
1499 .arg("-C")
1500 .arg(dir)
1501 .args(&args)
1502 .output()
1503 .unwrap();
1504 assert!(
1505 out.status.success(),
1506 "{}",
1507 String::from_utf8_lossy(&out.stderr)
1508 );
1509 }
1510 }
1511
1512 fn engine(root: &Path) -> Arc<car_inference::InferenceEngine> {
1513 let mut cfg = car_inference::InferenceConfig::default();
1514 cfg.models_dir = root.join("models");
1515 Arc::new(car_inference::InferenceEngine::new(cfg))
1516 }
1517
1518 fn state() -> (Arc<ServerState>, tempfile::TempDir) {
1521 let journal = tempfile::tempdir().unwrap();
1522 let state = Arc::new(ServerState::standalone(journal.path().to_path_buf()));
1523 (state, journal)
1524 }
1525
1526 async fn start(
1527 state: &Arc<ServerState>,
1528 repo: &Path,
1529 generator: Arc<dyn TurnGenerator>,
1530 ) -> String {
1531 let started = start_discussion(state, repo, "owner-1", engine(repo), generator)
1532 .await
1533 .unwrap();
1534 started["discussion_id"].as_str().unwrap().to_string()
1535 }
1536
1537 async fn client(state: &Arc<ServerState>, id: &str) -> Arc<ClientSession> {
1540 state
1541 .create_session(id, Arc::new(crate::session::WsChannel::test_stub()))
1542 .await
1543 .unwrap()
1544 }
1545
1546 struct CaptureSink(Arc<StdMutex<Vec<String>>>);
1551
1552 impl futures::Sink<tokio_tungstenite::tungstenite::Message> for CaptureSink {
1553 type Error = tokio_tungstenite::tungstenite::Error;
1554
1555 fn poll_ready(
1556 self: std::pin::Pin<&mut Self>,
1557 _: &mut std::task::Context<'_>,
1558 ) -> std::task::Poll<Result<(), Self::Error>> {
1559 std::task::Poll::Ready(Ok(()))
1560 }
1561
1562 fn start_send(
1563 self: std::pin::Pin<&mut Self>,
1564 item: tokio_tungstenite::tungstenite::Message,
1565 ) -> Result<(), Self::Error> {
1566 if let tokio_tungstenite::tungstenite::Message::Text(text) = item {
1567 lock(&self.0).push(text.to_string());
1568 }
1569 Ok(())
1570 }
1571
1572 fn poll_flush(
1573 self: std::pin::Pin<&mut Self>,
1574 _: &mut std::task::Context<'_>,
1575 ) -> std::task::Poll<Result<(), Self::Error>> {
1576 std::task::Poll::Ready(Ok(()))
1577 }
1578
1579 fn poll_close(
1580 self: std::pin::Pin<&mut Self>,
1581 _: &mut std::task::Context<'_>,
1582 ) -> std::task::Poll<Result<(), Self::Error>> {
1583 std::task::Poll::Ready(Ok(()))
1584 }
1585 }
1586
1587 fn capturing_channel() -> (Arc<WsChannel>, Arc<StdMutex<Vec<String>>>) {
1591 let frames = Arc::new(StdMutex::new(Vec::new()));
1592 let sink: crate::session::WsSink = Box::pin(CaptureSink(frames.clone()));
1593 let channel = Arc::new(WsChannel {
1594 write: tokio::sync::Mutex::new(sink),
1595 pending: tokio::sync::Mutex::new(HashMap::new()),
1596 active_actions: tokio::sync::Mutex::new(HashMap::new()),
1597 next_id: AtomicU64::new(0),
1598 });
1599 (channel, frames)
1600 }
1601
1602 fn rpc_req(params: Value) -> JsonRpcMessage {
1603 serde_json::from_value(json!({ "jsonrpc": "2.0", "id": 1, "params": params }))
1604 .expect("JsonRpcMessage shape")
1605 }
1606
1607 fn delivered_seqs(frames: &Arc<StdMutex<Vec<String>>>) -> Vec<u64> {
1609 lock(frames)
1610 .iter()
1611 .map(|f| serde_json::from_str::<Value>(f).expect("a lane frame must be JSON"))
1612 .inspect(|v| assert_eq!(v["method"], "coder.discuss.event", "unexpected frame: {v}"))
1613 .map(|v| {
1614 v["params"]["seq"]
1615 .as_u64()
1616 .expect("every event carries a seq")
1617 })
1618 .collect()
1619 }
1620
1621 async fn wait_for_turn_complete(entry: &Arc<DiscussionEntry>) {
1622 for _ in 0..400 {
1623 {
1624 let events = entry.events.lock().await;
1625 if events
1626 .iter()
1627 .any(|e| matches!(e.kind, DiscussEventKind::TurnComplete {}))
1628 {
1629 return;
1630 }
1631 }
1632 tokio::time::sleep(std::time::Duration::from_millis(25)).await;
1633 }
1634 panic!("discussion turn never completed");
1635 }
1636
1637 #[tokio::test]
1638 async fn discuss_start_rejects_a_non_git_directory() {
1639 let dir = tempfile::tempdir().unwrap();
1640 let (state, _journal) = state();
1641 let script: Arc<dyn TurnGenerator> = Arc::new(Script {
1642 turns: vec![],
1643 cursor: AtomicUsize::new(0),
1644 });
1645 let err = start_discussion(&state, dir.path(), "owner-1", engine(dir.path()), script)
1646 .await
1647 .unwrap_err();
1648 assert!(
1649 err.contains("is not a git repository")
1650 && err.contains("discuss needs a repo to ground itself in"),
1651 "operator-readable non-repo error, got: {err}"
1652 );
1653 }
1654
1655 #[tokio::test]
1657 async fn a_discussion_writes_nothing_in_the_repo() {
1658 let repo = tempfile::tempdir().unwrap();
1659 init_repo(repo.path());
1660 std::fs::write(repo.path().join("keep.txt"), "original").unwrap();
1661 let (state, _journal) = state();
1662
1663 let script: Arc<dyn TurnGenerator> = Arc::new(Script {
1664 turns: vec![
1665 turn(
1666 "",
1667 json!([{
1668 "id": "c1", "name": "write_file",
1669 "arguments": {"path": "sneaky.txt", "content": "written by a discussion"}
1670 }]),
1671 ),
1672 turn(
1673 "",
1674 json!([{
1675 "id": "c2", "name": "shell",
1676 "arguments": {"command": "printf x > shelled.txt"}
1677 }]),
1678 ),
1679 turn(
1680 "I cannot edit from a discussion; here is what I would change.",
1681 json!([]),
1682 ),
1683 ],
1684 cursor: AtomicUsize::new(0),
1685 });
1686
1687 let id = start(&state, repo.path(), script).await;
1688 assert!(id.starts_with("disc-"));
1689 send_message(
1690 &state,
1691 &id,
1692 "owner-1",
1693 "can you just make the change for me?",
1694 )
1695 .await
1696 .unwrap();
1697 let entry = get_discussion(&state, &id).await.unwrap();
1698 wait_for_turn_complete(&entry).await;
1699
1700 assert!(
1701 !repo.path().join("sneaky.txt").exists(),
1702 "a discussion must not create files in the repo"
1703 );
1704 assert!(
1705 !repo.path().join("shelled.txt").exists(),
1706 "a discussion must not run shell commands that write"
1707 );
1708 assert_eq!(
1709 std::fs::read_to_string(repo.path().join("keep.txt")).unwrap(),
1710 "original"
1711 );
1712
1713 let events = entry.events.lock().await;
1714 assert!(
1715 events.iter().any(|e| matches!(
1716 &e.kind,
1717 DiscussEventKind::ToolResult { ok, preview, .. }
1718 if !ok && preview.contains("read-only")
1719 )),
1720 "the denial must surface as a tool_result"
1721 );
1722 }
1723
1724 #[tokio::test]
1728 async fn a_discussion_cannot_read_outside_the_repo() {
1729 let outside = tempfile::tempdir().unwrap();
1730 let secret_path = outside.path().join("credentials.txt");
1731 std::fs::write(&secret_path, "sk-ant-SUPERSECRETVALUE").unwrap();
1732
1733 let repo = tempfile::tempdir().unwrap();
1734 init_repo(repo.path());
1735 let (state, _journal) = state();
1736
1737 let script: Arc<dyn TurnGenerator> = Arc::new(Script {
1738 turns: vec![
1739 turn(
1741 "",
1742 json!([{
1743 "id": "c1", "name": "read_file",
1744 "arguments": {"path": secret_path.to_string_lossy()}
1745 }]),
1746 ),
1747 turn(
1749 "",
1750 json!([{
1751 "id": "c2", "name": "grep_files",
1752 "arguments": {"path": outside.path().to_string_lossy(), "pattern": "sk-ant-"}
1753 }]),
1754 ),
1755 turn("I can only read inside this repository.", json!([])),
1756 ],
1757 cursor: AtomicUsize::new(0),
1758 });
1759
1760 let id = start(&state, repo.path(), script).await;
1761 send_message(
1762 &state,
1763 &id,
1764 "owner-1",
1765 "what credentials does this project use?",
1766 )
1767 .await
1768 .unwrap();
1769 let entry = get_discussion(&state, &id).await.unwrap();
1770 wait_for_turn_complete(&entry).await;
1771
1772 let events = entry.events.lock().await;
1773 let stream = serde_json::to_string(&*events).unwrap();
1774 assert!(
1775 !stream.contains("SUPERSECRETVALUE"),
1776 "a discussion must never stream content from outside its repo: {stream}"
1777 );
1778 }
1779
1780 #[tokio::test]
1781 async fn promote_distills_an_intent_and_starts_nothing() {
1782 let repo = tempfile::tempdir().unwrap();
1783 init_repo(repo.path());
1784 let (state, _journal) = state();
1785
1786 let script: Arc<dyn TurnGenerator> = Arc::new(Script {
1787 turns: vec![
1788 turn("The Windows path is the risky one.", json!([])),
1789 turn(
1790 r#"{"proposed_intent":"Make the config loader resolve paths on Windows.",
1791 "constraints":["do not change the POSIX behavior"]}"#,
1792 json!([]),
1793 ),
1794 ],
1795 cursor: AtomicUsize::new(0),
1796 });
1797
1798 let id = start(&state, repo.path(), script).await;
1799 send_message(
1800 &state,
1801 &id,
1802 "owner-1",
1803 "what is fragile about the config loader?",
1804 )
1805 .await
1806 .unwrap();
1807 let entry = get_discussion(&state, &id).await.unwrap();
1808 wait_for_turn_complete(&entry).await;
1809
1810 let promoted = promote(&state, &id, "owner-1").await.unwrap();
1811 assert_eq!(
1812 promoted["proposed_intent"],
1813 "Make the config loader resolve paths on Windows."
1814 );
1815 assert_eq!(
1816 promoted["constraints"],
1817 json!(["do not change the POSIX behavior"])
1818 );
1819 assert!(state.coder_sessions.lock().await.is_empty());
1820 assert_eq!(
1821 constraints_for_start(&state, &id).await.unwrap(),
1822 vec!["do not change the POSIX behavior".to_string()]
1823 );
1824 }
1825
1826 #[tokio::test]
1830 async fn a_turn_in_flight_blocks_a_second_send_and_promote() {
1831 let repo = tempfile::tempdir().unwrap();
1832 init_repo(repo.path());
1833 let (state, _journal) = state();
1834 let gate = Arc::new(tokio::sync::Notify::new());
1835 let generator: Arc<dyn TurnGenerator> = Arc::new(Blocking { gate: gate.clone() });
1836
1837 let id = start(&state, repo.path(), generator).await;
1838 send_message(&state, &id, "owner-1", "first question")
1839 .await
1840 .unwrap();
1841
1842 let entry = get_discussion(&state, &id).await.unwrap();
1843 for _ in 0..200 {
1844 if entry.is_answering() {
1845 break;
1846 }
1847 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1848 }
1849 assert!(entry.is_answering(), "the turn should be in flight");
1850
1851 let err = send_message(&state, &id, "owner-1", "second question")
1852 .await
1853 .unwrap_err();
1854 assert!(
1855 err.contains("still answering"),
1856 "a concurrent send must be refused, not silently lose a turn: {err}"
1857 );
1858 let err = promote(&state, &id, "owner-1").await.unwrap_err();
1859 assert!(
1860 err.contains("still answering"),
1861 "promote must not distill a half-finished turn: {err}"
1862 );
1863
1864 gate.notify_one();
1865 wait_for_turn_complete(&entry).await;
1866 }
1867
1868 #[tokio::test]
1871 async fn close_cancels_an_in_flight_turn() {
1872 let repo = tempfile::tempdir().unwrap();
1873 init_repo(repo.path());
1874 let (state, _journal) = state();
1875 let gate = Arc::new(tokio::sync::Notify::new());
1876 let generator: Arc<dyn TurnGenerator> = Arc::new(Blocking { gate });
1877
1878 let id = start(&state, repo.path(), generator).await;
1879 send_message(&state, &id, "owner-1", "a broad question")
1880 .await
1881 .unwrap();
1882 let entry = get_discussion(&state, &id).await.unwrap();
1883 for _ in 0..200 {
1884 if entry.is_answering() {
1885 break;
1886 }
1887 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1888 }
1889
1890 close(&state, &id, "owner-1").await.unwrap();
1891 assert!(!entry.is_answering(), "close must stop the turn");
1892 assert!(state.coder_discussions.lock().await.is_empty());
1893 }
1894
1895 #[tokio::test]
1909 async fn a_send_cancelled_after_dispatch_leaves_the_discussion_usable() {
1910 let repo = tempfile::tempdir().unwrap();
1911 init_repo(repo.path());
1912 let (state, _journal) = state();
1913 let script: Arc<dyn TurnGenerator> = Arc::new(Script {
1914 turns: vec![
1915 turn("answered anyway", json!([])),
1916 turn("answered on the retry", json!([])),
1917 ],
1918 cursor: AtomicUsize::new(0),
1919 });
1920 let id = start(&state, repo.path(), script).await;
1921 let entry = get_discussion(&state, &id).await.unwrap();
1922
1923 let mut send = Box::pin(send_message(
1928 &state,
1929 &id,
1930 "owner-1",
1931 "the message whose reply frame gets cancelled",
1932 ));
1933 assert!(
1934 matches!(futures::poll!(send.as_mut()), std::task::Poll::Pending),
1935 "the fixture needs the send parked on its cursor"
1936 );
1937 assert!(
1938 entry.is_answering(),
1939 "the fixture needs the CAS to have run"
1940 );
1941 drop(send);
1942
1943 wait_for_turn_complete(&entry).await;
1945 for _ in 0..200 {
1946 if !entry.is_answering() {
1947 break;
1948 }
1949 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1950 }
1951 assert!(
1952 !entry.is_answering(),
1953 "a cancelled handler must not strand `in_flight`"
1954 );
1955
1956 send_message(&state, &id, "owner-1", "second try")
1958 .await
1959 .expect("the discussion must still accept a message");
1960 }
1961
1962 #[tokio::test]
1975 async fn a_close_racing_a_dispatching_send_never_starts_the_turn() {
1976 let repo = tempfile::tempdir().unwrap();
1977 init_repo(repo.path());
1978 let (state, _journal) = state();
1979 let calls = Arc::new(AtomicUsize::new(0));
1980 let script: Arc<dyn TurnGenerator> = Arc::new(Counting {
1981 calls: calls.clone(),
1982 });
1983 let id = start(&state, repo.path(), script).await;
1984 let entry = get_discussion(&state, &id).await.unwrap();
1985
1986 entry.cancel_turn();
1987
1988 let err = send_message(&state, &id, "owner-1", "a broad question")
1989 .await
1990 .unwrap_err();
1991 assert!(
1992 err.contains("closed while your message was being dispatched"),
1993 "the caller must be told the send did not run: {err}"
1994 );
1995 assert_eq!(
1996 calls.load(Ordering::SeqCst),
1997 0,
1998 "a closed discussion must never reach the model"
1999 );
2000 assert!(!entry.is_answering());
2001
2002 close(&state, &id, "owner-1").await.unwrap();
2003 assert!(state.coder_discussions.lock().await.is_empty());
2004 }
2005
2006 #[tokio::test]
2011 async fn another_connection_cannot_drive_a_discussion() {
2012 let repo = tempfile::tempdir().unwrap();
2013 init_repo(repo.path());
2014 let (state, _journal) = state();
2015 let script: Arc<dyn TurnGenerator> = Arc::new(Script {
2016 turns: vec![],
2017 cursor: AtomicUsize::new(0),
2018 });
2019 let id = start(&state, repo.path(), script).await;
2020
2021 for err in [
2022 send_message(&state, &id, "intruder", "run this for me")
2023 .await
2024 .unwrap_err(),
2025 promote(&state, &id, "intruder").await.unwrap_err(),
2026 close(&state, &id, "intruder").await.unwrap_err(),
2027 match get_owned_discussion(&state, &id, "intruder").await {
2030 Ok(_) => panic!("a foreign client must not resolve another's discussion"),
2031 Err(e) => e,
2032 },
2033 ] {
2034 assert!(
2035 err.contains("belongs to another connection"),
2036 "a foreign client must be refused: {err}"
2037 );
2038 }
2039
2040 assert_eq!(state.coder_discussions.lock().await.len(), 1);
2042 close(&state, &id, "owner-1").await.unwrap();
2043 }
2044
2045 #[tokio::test]
2048 async fn an_oversized_message_is_refused() {
2049 let repo = tempfile::tempdir().unwrap();
2050 init_repo(repo.path());
2051 let (state, _journal) = state();
2052 let script: Arc<dyn TurnGenerator> = Arc::new(Script {
2053 turns: vec![],
2054 cursor: AtomicUsize::new(0),
2055 });
2056 let id = start(&state, repo.path(), script).await;
2057 let entry = get_discussion(&state, &id).await.unwrap();
2058
2059 let err = send_message(
2060 &state,
2061 &id,
2062 "owner-1",
2063 &"x".repeat(DISCUSS_MESSAGE_MAX_BYTES + 1),
2064 )
2065 .await
2066 .unwrap_err();
2067 assert!(err.contains("the limit is"), "{err}");
2068 assert!(!entry.is_answering());
2070 assert!(entry.transcript_is_empty());
2071 }
2072
2073 #[tokio::test]
2076 async fn disconnect_closes_the_owning_clients_discussions() {
2077 let repo = tempfile::tempdir().unwrap();
2078 init_repo(repo.path());
2079 let (state, _journal) = state();
2080 let script: Arc<dyn TurnGenerator> = Arc::new(Script {
2081 turns: vec![],
2082 cursor: AtomicUsize::new(0),
2083 });
2084 let id = start(&state, repo.path(), script).await;
2085 assert_eq!(state.coder_discussions.lock().await.len(), 1);
2086
2087 drop_subscriptions_for_client(&state, "someone-else").await;
2089 assert_eq!(state.coder_discussions.lock().await.len(), 1);
2090
2091 drop_subscriptions_for_client(&state, "owner-1").await;
2093 assert!(state.coder_discussions.lock().await.is_empty());
2094 assert!(get_discussion(&state, &id).await.is_err());
2095 }
2096
2097 #[tokio::test]
2103 async fn open_discussions_are_capped() {
2104 let repo = tempfile::tempdir().unwrap();
2105 init_repo(repo.path());
2106 let (state, _journal) = state();
2107 let held: Vec<_> = (0..MAX_OPEN_DISCUSSIONS)
2108 .map(|_| {
2109 state
2110 .coder_discussion_slots
2111 .clone()
2112 .try_acquire_owned()
2113 .expect("a fresh daemon has every slot free")
2114 })
2115 .collect();
2116
2117 let script: Arc<dyn TurnGenerator> = Arc::new(Script {
2118 turns: vec![],
2119 cursor: AtomicUsize::new(0),
2120 });
2121 let err = start_discussion(&state, repo.path(), "owner-1", engine(repo.path()), script)
2122 .await
2123 .unwrap_err();
2124 assert!(err.contains("already open"), "{err}");
2125
2126 drop(held);
2128 let script: Arc<dyn TurnGenerator> = Arc::new(Script {
2129 turns: vec![],
2130 cursor: AtomicUsize::new(0),
2131 });
2132 start_discussion(&state, repo.path(), "owner-1", engine(repo.path()), script)
2133 .await
2134 .expect("a released slot must be reusable");
2135 }
2136
2137 #[tokio::test]
2146 async fn concurrent_starts_cannot_exceed_the_open_discussion_cap() {
2147 let repo = tempfile::tempdir().unwrap();
2148 init_repo(repo.path());
2149 let (state, _journal) = state();
2150 let _held: Vec<_> = (0..MAX_OPEN_DISCUSSIONS - 1)
2151 .map(|_| {
2152 state
2153 .coder_discussion_slots
2154 .clone()
2155 .try_acquire_owned()
2156 .unwrap()
2157 })
2158 .collect();
2159
2160 let mut racers = Vec::new();
2161 for _ in 0..4 {
2162 let state = state.clone();
2163 let repo = repo.path().to_path_buf();
2164 racers.push(tokio::spawn(async move {
2165 let script: Arc<dyn TurnGenerator> = Arc::new(Script {
2166 turns: vec![],
2167 cursor: AtomicUsize::new(0),
2168 });
2169 start_discussion(&state, &repo, "owner-1", engine(&repo), script).await
2170 }));
2171 }
2172
2173 let mut admitted = 0;
2174 let mut refused = 0;
2175 for racer in racers {
2176 match racer.await.unwrap() {
2177 Ok(_) => admitted += 1,
2178 Err(e) => {
2179 assert!(e.contains("already open"), "unexpected refusal: {e}");
2180 refused += 1;
2181 }
2182 }
2183 }
2184 assert_eq!(admitted, 1, "exactly one racer may take the last slot");
2185 assert_eq!(refused, 3);
2186 assert_eq!(
2187 state.coder_discussions.lock().await.len(),
2188 1,
2189 "the registry must never exceed the cap"
2190 );
2191 }
2192
2193 #[tokio::test]
2194 async fn unknown_discussion_ids_are_clear_errors() {
2195 let (state, _journal) = state();
2196 for err in [
2197 send_message(&state, "disc-nope", "owner-1", "hi")
2198 .await
2199 .unwrap_err(),
2200 promote(&state, "disc-nope", "owner-1").await.unwrap_err(),
2201 constraints_for_start(&state, "disc-nope")
2202 .await
2203 .unwrap_err(),
2204 ] {
2205 assert!(err.contains("disc-nope"), "must name the id, got: {err}");
2206 }
2207 assert!(close(&state, "disc-nope", "owner-1").await.is_err());
2208 }
2209
2210 #[tokio::test]
2211 async fn list_and_close_track_open_discussions() {
2212 let repo = tempfile::tempdir().unwrap();
2213 init_repo(repo.path());
2214 let (state, _journal) = state();
2215 let script: Arc<dyn TurnGenerator> = Arc::new(Script {
2216 turns: vec![],
2217 cursor: AtomicUsize::new(0),
2218 });
2219 let id = start(&state, repo.path(), script).await;
2220 let owner = client(&state, "owner-1").await;
2221
2222 let listed = handle_discuss_list(&state, &owner).await.unwrap();
2223 assert_eq!(listed["discussions"].as_array().unwrap().len(), 1);
2224 assert_eq!(listed["discussions"][0]["discussion_id"], id.as_str());
2225 assert_eq!(listed["discussions"][0]["turns"], 0);
2226
2227 let stranger = client(&state, "someone-else").await;
2229 let listed = handle_discuss_list(&state, &stranger).await.unwrap();
2230 assert!(
2231 listed["discussions"].as_array().unwrap().is_empty(),
2232 "another connection must not see this discussion: {listed}"
2233 );
2234
2235 assert_eq!(
2236 close(&state, &id, "owner-1").await.unwrap(),
2237 json!({ "ok": true })
2238 );
2239 let listed = handle_discuss_list(&state, &owner).await.unwrap();
2240 assert!(listed["discussions"].as_array().unwrap().is_empty());
2241 }
2242
2243 #[tokio::test]
2267 async fn a_subscriber_receives_every_seq_exactly_once_across_its_attach() {
2268 let repo = tempfile::tempdir().unwrap();
2269 init_repo(repo.path());
2270 let (state, _journal) = state();
2271 let script: Arc<dyn TurnGenerator> = Arc::new(Script {
2272 turns: vec![],
2273 cursor: AtomicUsize::new(0),
2274 });
2275 let id = start(&state, repo.path(), script).await;
2276 let entry = get_discussion(&state, &id).await.unwrap();
2277
2278 let (channel, frames) = capturing_channel();
2279 let owner = state
2280 .create_session("owner-1", channel.clone())
2281 .await
2282 .unwrap();
2283
2284 let racing = {
2288 let entry = entry.clone();
2289 tokio::spawn(async move {
2290 for i in 0..30u64 {
2291 entry
2292 .emit(DiscussEventKind::AssistantDelta {
2293 text: format!("during-{i}"),
2294 })
2295 .await;
2296 }
2297 })
2298 };
2299 while entry.events.lock().await.is_empty() {
2302 tokio::task::yield_now().await;
2303 }
2304 let subscribed = handle_discuss_subscribe(
2305 &rpc_req(json!({ "discussion_id": id, "from_seq": 0 })),
2306 &state,
2307 &owner,
2308 )
2309 .await
2310 .unwrap();
2311 racing.await.unwrap();
2312
2313 for i in 0..20u64 {
2315 entry
2316 .emit(DiscussEventKind::AssistantDelta {
2317 text: format!("after-{i}"),
2318 })
2319 .await;
2320 }
2321
2322 const TOTAL: usize = 50;
2323 let replayed = subscribed["events_replayed"].as_u64().unwrap();
2324 assert!(
2325 replayed > 0,
2326 "the attach replayed nothing, so this test never exercised the \
2327 replay hop it exists to cover"
2328 );
2329 assert!(
2330 replayed <= 30,
2331 "replay cannot exceed what was emitted before the attach: {replayed}"
2332 );
2333
2334 let mut seqs = Vec::new();
2335 for _ in 0..400 {
2336 seqs = delivered_seqs(&frames);
2337 if seqs.len() >= TOTAL {
2338 break;
2339 }
2340 tokio::time::sleep(std::time::Duration::from_millis(25)).await;
2341 }
2342 assert_eq!(
2343 seqs,
2344 (0..TOTAL as u64).collect::<Vec<_>>(),
2345 "a subscriber must receive seq 0..{TOTAL} once each, in order"
2346 );
2347
2348 const RESUME_FROM: u64 = 17;
2353 let (resumed_channel, resumed_frames) = capturing_channel();
2354 let resumed = state
2357 .create_session("owner-1", resumed_channel)
2358 .await
2359 .unwrap();
2360 let reattached = handle_discuss_subscribe(
2361 &rpc_req(json!({ "discussion_id": id, "from_seq": RESUME_FROM })),
2362 &state,
2363 &resumed,
2364 )
2365 .await
2366 .unwrap();
2367 assert_eq!(
2368 reattached["events_replayed"].as_u64().unwrap(),
2369 TOTAL as u64 - RESUME_FROM,
2370 "a resume from {RESUME_FROM} must replay seq {RESUME_FROM}..{TOTAL}"
2371 );
2372
2373 let mut resumed_seqs = Vec::new();
2374 for _ in 0..400 {
2375 resumed_seqs = delivered_seqs(&resumed_frames);
2376 if resumed_seqs.len() >= TOTAL - RESUME_FROM as usize {
2377 break;
2378 }
2379 tokio::time::sleep(std::time::Duration::from_millis(25)).await;
2380 }
2381 assert_eq!(
2382 resumed_seqs,
2383 (RESUME_FROM..TOTAL as u64).collect::<Vec<_>>(),
2384 "a resume must start AT its cursor, not one past it"
2385 );
2386 }
2387
2388 #[tokio::test]
2392 async fn a_dispatched_send_emits_the_user_message_before_any_delta() {
2393 let repo = tempfile::tempdir().unwrap();
2394 init_repo(repo.path());
2395 let (state, _journal) = state();
2396 let script: Arc<dyn TurnGenerator> = Arc::new(Script {
2397 turns: vec![turn("here is what I would change", json!([]))],
2398 cursor: AtomicUsize::new(0),
2399 });
2400 let id = start(&state, repo.path(), script).await;
2401 let entry = get_discussion(&state, &id).await.unwrap();
2402
2403 let sent = send_message(&state, &id, "owner-1", "what should this change do?")
2404 .await
2405 .unwrap();
2406 assert_eq!(
2407 sent["seq"], 0,
2408 "the reported cursor is the user_message's own seq"
2409 );
2410 wait_for_turn_complete(&entry).await;
2411
2412 let events = entry.events.lock().await;
2413 assert!(
2414 matches!(events[0].kind, DiscussEventKind::UserMessage { .. }),
2415 "the operator's message must be the turn's first event, got: {:?}",
2416 events[0].kind
2417 );
2418 assert!(
2419 events.len() > 1,
2420 "the turn produced nothing to order against"
2421 );
2422 assert!(
2423 !events[1..]
2424 .iter()
2425 .any(|e| matches!(e.kind, DiscussEventKind::UserMessage { .. })),
2426 "exactly one user_message per send"
2427 );
2428 }
2429
2430 #[tokio::test]
2440 async fn a_wedged_subscriber_is_shed_and_the_turn_still_completes() {
2441 let repo = tempfile::tempdir().unwrap();
2442 init_repo(repo.path());
2443 let (state, _journal) = state();
2444 let script: Arc<dyn TurnGenerator> = Arc::new(Script {
2445 turns: vec![turn("here is what I would change", json!([]))],
2446 cursor: AtomicUsize::new(0),
2447 });
2448 let id = start(&state, repo.path(), script).await;
2449 let entry = get_discussion(&state, &id).await.unwrap();
2450
2451 let (channel, _frames) = capturing_channel();
2452 let owner = state
2453 .create_session("owner-1", channel.clone())
2454 .await
2455 .unwrap();
2456 let unsubscribed = Arc::strong_count(&channel);
2457 handle_discuss_subscribe(
2458 &rpc_req(json!({ "discussion_id": id, "from_seq": 0 })),
2459 &state,
2460 &owner,
2461 )
2462 .await
2463 .unwrap();
2464 assert_eq!(
2465 Arc::strong_count(&channel),
2466 unsubscribed + 1,
2467 "the lane must hold this subscriber's channel"
2468 );
2469
2470 let stuck = channel.write.lock().await;
2472
2473 let started = std::time::Instant::now();
2474 send_message(&state, &id, "owner-1", "what should this change do?")
2475 .await
2476 .unwrap();
2477 let mut completed = false;
2478 for _ in 0..120 {
2479 if entry
2480 .events
2481 .lock()
2482 .await
2483 .iter()
2484 .any(|e| matches!(e.kind, DiscussEventKind::TurnComplete {}))
2485 {
2486 completed = true;
2487 break;
2488 }
2489 tokio::time::sleep(std::time::Duration::from_millis(25)).await;
2490 }
2491 assert!(
2492 completed && started.elapsed() < DISCUSS_SEND_TIMEOUT,
2493 "the turn must not wait on a wedged subscriber's socket ({:?} elapsed)",
2494 started.elapsed()
2495 );
2496
2497 for i in 0..(DISCUSS_SUBSCRIBER_QUEUE + 64) {
2501 entry
2502 .emit(DiscussEventKind::AssistantDelta {
2503 text: format!("overflow-{i}"),
2504 })
2505 .await;
2506 }
2507 let mut shed = false;
2508 for _ in 0..200 {
2509 if Arc::strong_count(&channel) == unsubscribed {
2510 shed = true;
2511 break;
2512 }
2513 tokio::time::sleep(std::time::Duration::from_millis(25)).await;
2514 }
2515 assert!(
2516 shed,
2517 "a subscriber that is not draining must be shed, not retained"
2518 );
2519 drop(stuck);
2520 }
2521
2522 #[tokio::test]
2539 async fn a_refused_send_leaves_no_unanswered_turn_in_the_transcript() {
2540 let repo = tempfile::tempdir().unwrap();
2541 init_repo(repo.path());
2542 let (state, _journal) = state();
2543 let calls = Arc::new(AtomicUsize::new(0));
2544 let script: Arc<dyn TurnGenerator> = Arc::new(Counting {
2545 calls: calls.clone(),
2546 });
2547 let id = start(&state, repo.path(), script).await;
2548 let entry = get_discussion(&state, &id).await.unwrap();
2549
2550 entry.cancel_turn();
2554 let err = send_message(&state, &id, "owner-1", "should we rewrite the scheduler?")
2555 .await
2556 .unwrap_err();
2557 assert!(
2558 err.contains("closed while your message was being dispatched"),
2559 "expected a refused dispatch, got: {err}"
2560 );
2561
2562 assert!(
2563 !entry.is_answering(),
2564 "a refused dispatch must not strand `in_flight`"
2565 );
2566 assert!(
2567 entry.transcript_is_empty(),
2568 "a question no turn will answer must not survive in the transcript: {:?}",
2569 lock(&entry.transcript)
2570 );
2571 assert!(
2572 !entry
2573 .events
2574 .lock()
2575 .await
2576 .iter()
2577 .any(|e| matches!(e.kind, DiscussEventKind::UserMessage { .. })),
2578 "...nor reach the replay buffer and every subscriber"
2579 );
2580 let err = promote(&state, &id, "owner-1").await.unwrap_err();
2582 assert!(
2583 err.contains("no turns yet"),
2584 "promote must refuse an empty discussion rather than distill a stranded \
2585 question: {err}"
2586 );
2587 assert!(
2588 constraints_for_start(&state, &id).await.unwrap().is_empty(),
2589 "coder.start must not distill constraints from a stranded question"
2590 );
2591 assert_eq!(
2592 calls.load(Ordering::SeqCst),
2593 0,
2594 "no turn ran, so nothing reached the model"
2595 );
2596 }
2597
2598 #[tokio::test]
2601 async fn concurrent_emits_stay_seq_ordered_in_the_buffer() {
2602 let repo = tempfile::tempdir().unwrap();
2603 init_repo(repo.path());
2604 let (state, _journal) = state();
2605 let script: Arc<dyn TurnGenerator> = Arc::new(Script {
2606 turns: vec![],
2607 cursor: AtomicUsize::new(0),
2608 });
2609 let id = start(&state, repo.path(), script).await;
2610 let entry = get_discussion(&state, &id).await.unwrap();
2611
2612 let mut tasks = Vec::new();
2613 for i in 0..50 {
2614 let e = entry.clone();
2615 tasks.push(tokio::spawn(async move {
2616 e.emit(DiscussEventKind::AssistantDelta {
2617 text: format!("chunk-{i}"),
2618 })
2619 .await
2620 }));
2621 }
2622 for t in tasks {
2623 t.await.unwrap();
2624 }
2625
2626 let events = entry.events.lock().await;
2627 assert_eq!(events.len(), 50);
2628 for (i, e) in events.iter().enumerate() {
2629 assert_eq!(e.seq, i as u64, "buffer must be in seq order");
2630 }
2631 }
2632
2633 #[test]
2634 fn discuss_event_json_shape_is_ws_friendly() {
2635 let e = DiscussEvent {
2636 discussion_id: "disc-x".into(),
2637 seq: 7,
2638 ts: 1,
2639 kind: DiscussEventKind::AssistantDelta {
2640 text: "hello".into(),
2641 },
2642 };
2643 let v = serde_json::to_value(&e).unwrap();
2644 assert_eq!(v["type"], "assistant_delta");
2645 assert_eq!(v["text"], "hello");
2646 assert_eq!(v["seq"], 7);
2647 assert_eq!(v["discussion_id"], "disc-x");
2648
2649 let v = serde_json::to_value(DiscussEvent {
2650 discussion_id: "disc-x".into(),
2651 seq: 8,
2652 ts: 1,
2653 kind: DiscussEventKind::TurnComplete {},
2654 })
2655 .unwrap();
2656 assert_eq!(v["type"], "turn_complete");
2657 }
2658}