use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex as StdMutex, MutexGuard};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tokio::sync::{mpsc, oneshot};
use crate::assistant::{
bind_default_substrate, build_assistant_runtime, prompt, AssistantConfig, AssistantService,
};
use crate::coder::native_loop::TurnGenerator;
use crate::handler::JsonRpcMessage;
use crate::session::{ClientSession, ServerState, WsChannel};
const DISCUSS_MAX_TURNS: u32 = 12;
const PROMOTE_MAX_ATTEMPTS: u32 = 3;
pub(crate) const MAX_OPEN_DISCUSSIONS: usize = 8;
const DISCUSSION_IDLE_TTL_SECS: u64 = 60 * 60;
const DISCUSS_EVENT_BUFFER_MAX: usize = 2000;
const TRANSCRIPT_MAX_TURNS: usize = 40;
const DISTILL_WINDOW_TURNS: usize = 12;
const DISCUSS_MESSAGE_MAX_BYTES: usize = 64 * 1024;
const DISCUSS_SUBSCRIBER_QUEUE: usize = DISCUSS_EVENT_BUFFER_MAX + 128;
const DISCUSS_SEND_TIMEOUT: std::time::Duration = crate::coder::rpc::FANOUT_WRITE_TIMEOUT;
pub type DiscussionMap = HashMap<String, Arc<DiscussionEntry>>;
fn lock<T>(m: &StdMutex<T>) -> MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscussEvent {
pub discussion_id: String,
pub seq: u64,
pub ts: u64,
#[serde(flatten)]
pub kind: DiscussEventKind,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum DiscussEventKind {
UserMessage {
text: String,
},
AssistantDelta {
text: String,
},
AssistantMessage {
text: String,
},
ToolCall {
tool: String,
params_preview: String,
},
ToolResult {
tool: String,
ok: bool,
preview: String,
},
TurnComplete {},
Error {
message: String,
},
}
enum StreamCmd {
Emit(DiscussEventKind, oneshot::Sender<u64>),
Attach {
client_id: String,
channel: Arc<WsChannel>,
from_seq: u64,
replayed: oneshot::Sender<u64>,
},
Detach(String),
}
#[derive(Default)]
struct TurnSlot {
closed: bool,
handle: Option<tokio::task::JoinHandle<()>>,
}
struct InFlightGuard(Arc<DiscussionEntry>);
impl Drop for InFlightGuard {
fn drop(&mut self) {
self.0.in_flight.store(false, Ordering::SeqCst);
self.0.touch();
}
}
struct TurnRecordGuard {
entry: Arc<DiscussionEntry>,
text: String,
dispatched: bool,
}
impl Drop for TurnRecordGuard {
fn drop(&mut self) {
if !self.dispatched {
self.entry.rollback_turn("Operator", &self.text);
}
}
}
pub struct DiscussionEntry {
pub id: String,
pub repo: PathBuf,
pub repo_summary: String,
pub created_at: u64,
owner_client_id: String,
pub events: Arc<tokio::sync::Mutex<Vec<DiscussEvent>>>,
cmds: mpsc::UnboundedSender<StreamCmd>,
turns: AtomicU64,
in_flight: AtomicBool,
last_active: AtomicU64,
turn_task: StdMutex<TurnSlot>,
_slot: tokio::sync::OwnedSemaphorePermit,
service: Arc<AssistantService>,
generator: Arc<dyn TurnGenerator>,
transcript: StdMutex<Vec<(&'static str, String)>>,
last_promote: StdMutex<Option<(String, Vec<String>)>>,
}
impl DiscussionEntry {
pub fn constraints(&self) -> Vec<String> {
lock(&self.last_promote)
.as_ref()
.map(|(_, c)| c.clone())
.unwrap_or_default()
}
pub fn is_answering(&self) -> bool {
self.in_flight.load(Ordering::SeqCst)
}
fn touch(&self) {
self.last_active.store(now_secs(), Ordering::SeqCst);
}
fn idle_secs(&self) -> u64 {
now_secs().saturating_sub(self.last_active.load(Ordering::SeqCst))
}
fn record_turn(&self, role: &'static str, text: &str) {
if text.trim().is_empty() {
return;
}
let mut t = lock(&self.transcript);
t.push((role, text.to_string()));
let len = t.len();
if len > TRANSCRIPT_MAX_TURNS {
t.drain(..len - TRANSCRIPT_MAX_TURNS);
}
}
fn rollback_turn(&self, role: &'static str, text: &str) {
let mut t = lock(&self.transcript);
if t.last().is_some_and(|(r, s)| *r == role && s == text) {
t.pop();
}
}
fn distill_transcript(&self) -> String {
let t = lock(&self.transcript);
let start = t.len().saturating_sub(DISTILL_WINDOW_TURNS);
t[start..]
.iter()
.map(|(role, text)| format!("{role}: {text}"))
.collect::<Vec<_>>()
.join("\n\n")
}
fn transcript_is_empty(&self) -> bool {
lock(&self.transcript).is_empty()
}
async fn emit(&self, kind: DiscussEventKind) -> u64 {
let (tx, rx) = oneshot::channel();
if self.cmds.send(StreamCmd::Emit(kind, tx)).is_err() {
return 0; }
rx.await.unwrap_or(0)
}
fn cancel_turn(&self) {
self.service.cancel(&self.id);
{
let mut slot = lock(&self.turn_task);
slot.closed = true;
if let Some(handle) = slot.handle.take() {
handle.abort();
}
}
self.in_flight.store(false, Ordering::SeqCst);
}
fn spawn_turn<F>(&self, make: F) -> bool
where
F: FnOnce() -> tokio::task::JoinHandle<()>,
{
let mut slot = lock(&self.turn_task);
if slot.closed {
return false;
}
slot.handle = Some(make());
true
}
fn summary_row(&self) -> Value {
json!({
"discussion_id": self.id,
"repo": self.repo,
"created_at": self.created_at,
"turns": self.turns.load(Ordering::SeqCst),
})
}
}
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn event_frame(event: &DiscussEvent) -> Option<String> {
serde_json::to_string(&json!({
"jsonrpc": "2.0",
"method": "coder.discuss.event",
"params": event,
}))
.ok()
}
struct Subscriber {
frames: mpsc::Sender<String>,
task: tokio::task::JoinHandle<()>,
}
impl Drop for Subscriber {
fn drop(&mut self) {
self.task.abort();
}
}
fn spawn_subscriber(channel: Arc<WsChannel>) -> Subscriber {
let (frames, mut rx) = mpsc::channel::<String>(DISCUSS_SUBSCRIBER_QUEUE);
let task = tokio::spawn(async move {
while let Some(frame) = rx.recv().await {
if tokio::time::timeout(
DISCUSS_SEND_TIMEOUT,
crate::coder::rpc::send_frame(&channel, &frame),
)
.await
.is_err()
{
break;
}
}
});
Subscriber { frames, task }
}
fn spawn_discuss_drain(
discussion_id: String,
events: Arc<tokio::sync::Mutex<Vec<DiscussEvent>>>,
) -> mpsc::UnboundedSender<StreamCmd> {
let (tx, mut rx) = mpsc::unbounded_channel::<StreamCmd>();
tokio::spawn(async move {
let mut subscribers: HashMap<String, Subscriber> = HashMap::new();
let mut next_seq: u64 = 0;
while let Some(cmd) = rx.recv().await {
match cmd {
StreamCmd::Emit(kind, reply) => {
let seq = next_seq;
next_seq += 1;
let event = DiscussEvent {
discussion_id: discussion_id.clone(),
seq,
ts: now_secs(),
kind,
};
let frame = event_frame(&event);
{
let mut buffer = events.lock().await;
buffer.push(event);
let len = buffer.len();
if len > DISCUSS_EVENT_BUFFER_MAX {
buffer.drain(..len - DISCUSS_EVENT_BUFFER_MAX);
}
} let _ = reply.send(seq);
if let Some(frame) = &frame {
subscribers.retain(|client_id, s| {
let ok = s.frames.try_send(frame.clone()).is_ok();
if !ok {
tracing::warn!(
discussion_id = %discussion_id,
client_id = %client_id,
"discussion subscriber is not draining; dropping it"
);
}
ok
});
}
}
StreamCmd::Attach {
client_id,
channel,
from_seq,
replayed,
} => {
let frames: Vec<String> = {
let buffer = events.lock().await;
buffer
.iter()
.filter(|e| e.seq >= from_seq)
.filter_map(event_frame)
.collect()
};
let subscriber = spawn_subscriber(channel);
let mut n = 0u64;
for frame in frames {
if subscriber.frames.try_send(frame).is_err() {
break;
}
n += 1;
}
subscribers.insert(client_id, subscriber);
let _ = replayed.send(n);
}
StreamCmd::Detach(client_id) => {
subscribers.remove(&client_id);
}
}
}
});
tx
}
pub async fn start_discussion(
state: &Arc<ServerState>,
repo: &Path,
owner_client_id: &str,
engine: Arc<car_inference::InferenceEngine>,
generator: Arc<dyn TurnGenerator>,
) -> Result<Value, String> {
let probe = repo.to_path_buf();
let repo = tokio::task::spawn_blocking(move || {
let repo = probe
.canonicalize()
.map_err(|e| format!("repo path {}: {e}", probe.display()))?;
if !super::rpc::is_git_repo(&repo) {
return Err(format!(
"{} is not a git repository — discuss needs a repo to ground itself in",
repo.display()
));
}
Ok(repo)
})
.await
.map_err(|e| format!("repo probe failed: {e}"))??;
reap_idle(state).await;
let slot = state
.coder_discussion_slots
.clone()
.try_acquire_owned()
.map_err(|_| {
format!(
"{MAX_OPEN_DISCUSSIONS} discussions are already open — close one with \
coder.discuss.close before starting another"
)
})?;
let summarize = repo.clone();
let repo_summary = tokio::task::spawn_blocking(move || super::rpc::summarize_repo(&summarize))
.await
.map_err(|e| format!("repo summary failed: {e}"))?;
let mut env = bind_default_substrate(true, false, &repo, None).await;
env.clamp_reads = true;
let asm = build_assistant_runtime(engine, env, None, None, None, None).await;
let system = format!(
"{}\n\nYou are in a DISCUSSION about this repository, not a work session. \
You have read-only access, scoped to this repository: you can read and reason \
about the code here, but any attempt to write a file, run a shell command, or \
read outside {} WILL be refused. Do not propose to make the change yourself — \
help the operator decide what the change should be, what it must not break, and \
how they would know it worked. Be concrete and cite real paths from the repo.",
prompt::chat_prompt(&asm.description, &asm.tools),
repo.display()
);
let cfg = AssistantConfig {
model: None,
strict_model: false,
max_turns: DISCUSS_MAX_TURNS,
tools: asm.tools.clone(),
gated_tools: asm.gated_tools.clone(),
approval_policy: None,
proactive_memory: None,
tool_labels: None,
todos: None,
value_store_previews: false,
};
let service = Arc::new(AssistantService::new(
generator.clone(),
Arc::new(asm.runtime),
cfg,
system,
));
let id = format!("disc-{}", uuid::Uuid::new_v4().simple());
let events = Arc::new(tokio::sync::Mutex::new(Vec::new()));
let cmds = spawn_discuss_drain(id.clone(), events.clone());
let entry = Arc::new(DiscussionEntry {
id: id.clone(),
repo: repo.clone(),
repo_summary: repo_summary.clone(),
created_at: now_secs(),
owner_client_id: owner_client_id.to_string(),
events,
cmds,
turns: AtomicU64::new(0),
in_flight: AtomicBool::new(false),
last_active: AtomicU64::new(now_secs()),
turn_task: StdMutex::new(TurnSlot::default()),
_slot: slot,
service,
generator,
transcript: StdMutex::new(Vec::new()),
last_promote: StdMutex::new(None),
});
state
.coder_discussions
.lock()
.await
.insert(id.clone(), entry);
Ok(json!({
"discussion_id": id,
"repo": repo,
"repo_summary": repo_summary,
}))
}
async fn reap_idle(state: &Arc<ServerState>) {
let stale: Vec<Arc<DiscussionEntry>> = {
let open = state.coder_discussions.lock().await;
open.values()
.filter(|e| e.idle_secs() > DISCUSSION_IDLE_TTL_SECS)
.cloned()
.collect()
};
for entry in stale {
entry.cancel_turn();
state.coder_discussions.lock().await.remove(&entry.id);
}
}
async fn get_discussion(
state: &Arc<ServerState>,
discussion_id: &str,
) -> Result<Arc<DiscussionEntry>, String> {
state
.coder_discussions
.lock()
.await
.get(discussion_id)
.cloned()
.ok_or_else(|| {
format!(
"no open discussion '{discussion_id}' — discussions are in-memory and do not \
survive a daemon restart; start a new one with coder.discuss.start"
)
})
}
pub(crate) async fn get_owned_discussion(
state: &Arc<ServerState>,
discussion_id: &str,
client_id: &str,
) -> Result<Arc<DiscussionEntry>, String> {
let entry = get_discussion(state, discussion_id).await?;
if entry.owner_client_id != client_id {
return Err(format!(
"discussion '{discussion_id}' belongs to another connection — a discussion is \
owned by the connection that opened it and closes with it; start your own with \
coder.discuss.start"
));
}
Ok(entry)
}
pub async fn send_message(
state: &Arc<ServerState>,
discussion_id: &str,
client_id: &str,
text: &str,
) -> Result<Value, String> {
let entry = get_owned_discussion(state, discussion_id, client_id).await?;
if text.trim().is_empty() {
return Err("discuss message is empty".to_string());
}
if text.len() > DISCUSS_MESSAGE_MAX_BYTES {
return Err(format!(
"that message is {} bytes; the limit is {DISCUSS_MESSAGE_MAX_BYTES}. A discussion \
keeps every message in its transcript, its replay buffer, and its distillation \
prompt — point at a file in the repo instead of pasting it",
text.len()
));
}
if entry
.in_flight
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
return Err(format!(
"{discussion_id} is still answering the previous message — wait for \
`turn_complete` before sending another"
));
}
let mut guard = Some(InFlightGuard(entry.clone()));
entry.touch();
entry.record_turn("Operator", text);
let mut recorded = TurnRecordGuard {
entry: entry.clone(),
text: text.to_string(),
dispatched: false,
};
let task_entry = entry.clone();
let prompt_text = text.to_string();
let (seq_tx, seq_rx) = oneshot::channel::<u64>();
let dispatched = entry.spawn_turn(|| {
let guard = guard.take();
tokio::spawn(async move {
let _guard = guard;
let seq = task_entry
.emit(DiscussEventKind::UserMessage {
text: prompt_text.clone(),
})
.await;
let _ = seq_tx.send(seq);
run_turn(task_entry, prompt_text).await;
})
});
if !dispatched {
return Err(format!(
"{discussion_id} was closed while your message was being dispatched — nothing is \
running; start a new discussion"
));
}
recorded.dispatched = true;
let first_seq = seq_rx.await.unwrap_or(0);
Ok(json!({ "ok": true, "seq": first_seq }))
}
async fn run_turn(entry: Arc<DiscussionEntry>, text: String) {
let sink_entry = entry.clone();
let assembled: Arc<StdMutex<String>> = Arc::new(StdMutex::new(String::new()));
let sink_assembled = assembled.clone();
let service = entry.service.clone();
let sink_service = service.clone();
let id = entry.id.clone();
service
.handle_turn(&id, &text, None, move |payload: Value| {
let entry = sink_entry.clone();
let assembled = sink_assembled.clone();
let service = sink_service.clone();
async move {
let kind = payload.get("kind").and_then(Value::as_str).unwrap_or("");
match kind {
"token" => {
let delta = payload
.get("delta")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if delta.is_empty() {
return;
}
lock(&assembled).push_str(&delta);
entry
.emit(DiscussEventKind::AssistantDelta { text: delta })
.await;
}
"tool_call" => {
let tool = payload
.get("tool")
.and_then(Value::as_str)
.unwrap_or("tool")
.to_string();
let params_preview = payload
.get("params")
.map(|p| preview(&p.to_string()))
.unwrap_or_default();
entry
.emit(DiscussEventKind::ToolCall {
tool,
params_preview,
})
.await;
}
"approval_pending" => {
let tool = payload
.get("tool")
.and_then(Value::as_str)
.unwrap_or("tool")
.to_string();
if let Some(approval_id) =
payload.get("approval_id").and_then(Value::as_str)
{
service.resolve_approval(approval_id, false);
}
entry
.emit(DiscussEventKind::ToolResult {
tool,
ok: false,
preview: "refused: a discussion is read-only — it cannot write \
files or run commands. Describe the change instead; \
`coder.start` is what performs it."
.to_string(),
})
.await;
}
"done" => {
let text = payload
.get("text")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let text = if text.trim().is_empty() {
lock(&assembled).clone()
} else {
text
};
entry.record_turn("Assistant", &text);
entry.turns.fetch_add(1, Ordering::SeqCst);
entry
.emit(DiscussEventKind::AssistantMessage { text })
.await;
entry.emit(DiscussEventKind::TurnComplete {}).await;
}
"error" => {
let message = payload
.get("error")
.and_then(Value::as_str)
.unwrap_or("discussion turn failed")
.to_string();
entry.emit(DiscussEventKind::Error { message }).await;
entry.emit(DiscussEventKind::TurnComplete {}).await;
}
_ => {}
}
}
})
.await;
}
fn preview(s: &str) -> String {
const CAP: usize = 200;
if s.chars().count() <= CAP {
return s.to_string();
}
let mut out: String = s.chars().take(CAP).collect();
out.push('…');
out
}
pub async fn promote(
state: &Arc<ServerState>,
discussion_id: &str,
client_id: &str,
) -> Result<Value, String> {
let entry = get_owned_discussion(state, discussion_id, client_id).await?;
if entry.is_answering() {
return Err(format!(
"{discussion_id} is still answering — try again in a moment"
));
}
if entry.transcript_is_empty() {
return Err(
"this discussion has no turns yet — say what you are trying to do first".to_string(),
);
}
let (intent, constraints) = distill(
&entry.generator,
&entry.distill_transcript(),
&entry.repo_summary,
)
.await?;
*lock(&entry.last_promote) = Some((intent.clone(), constraints.clone()));
entry.touch();
Ok(json!({
"discussion_id": entry.id,
"proposed_intent": intent,
"constraints": constraints,
}))
}
async fn distill(
generator: &Arc<dyn TurnGenerator>,
transcript: &str,
repo_summary: &str,
) -> Result<(String, Vec<String>), String> {
let mut last_err = String::from("no attempt was made");
for _ in 0..PROMOTE_MAX_ATTEMPTS {
let prompt = format!(
"A developer has been discussing a change to a codebase. Distill the discussion \
into ONE actionable coding intent plus the constraints they agreed on.\n\n\
REPOSITORY\n{repo_summary}\n\n\
DISCUSSION (most recent turns)\n{transcript}\n\n\
Return ONLY a JSON object, no prose and no code fences:\n\
{{\n \"proposed_intent\": \"one paragraph, imperative, what to change and why\",\n \
\"constraints\": [\"a thing the change must not break or must respect\"]\n}}\n\n\
Rules:\n\
- `proposed_intent` is an INSTRUCTION, not a summary of the conversation. Never \
quote the transcript back.\n\
- Include only constraints actually agreed in the discussion. If none were, \
return an empty array — do not invent any.\n"
);
let text = match generator
.generate(car_inference::GenerateRequest {
prompt,
params: car_inference::GenerateParams {
temperature: 0.0,
max_tokens: 1024,
thinking: car_inference::tasks::generate::ThinkingMode::Off,
..Default::default()
},
..Default::default()
})
.await
{
Ok(r) => r.text,
Err(e) => {
last_err = format!("generation failed: {e}");
continue;
}
};
let value = match super::contract::extract_json_object(&text) {
Ok(v) => v,
Err(e) => {
last_err = format!("output did not parse: {e}");
continue;
}
};
let intent = value
.get("proposed_intent")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_string();
if intent.is_empty() {
last_err = "the model returned no proposed_intent".to_string();
continue;
}
let constraints: Vec<String> = value
.get("constraints")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect()
})
.unwrap_or_default();
return Ok((intent, constraints));
}
Err(format!(
"could not distill this discussion into an intent after {PROMOTE_MAX_ATTEMPTS} \
attempts: {last_err}"
))
}
pub async fn constraints_for_start(
state: &Arc<ServerState>,
discussion_id: &str,
) -> Result<Vec<String>, String> {
let entry = get_discussion(state, discussion_id).await?;
let cached = entry.constraints();
if !cached.is_empty() {
return Ok(cached);
}
if entry.is_answering() {
return Err(format!(
"{discussion_id} is still answering — wait for `turn_complete` before starting a \
run from it, or the constraints would be distilled from a question with no \
answer beside it"
));
}
if lock(&entry.last_promote).is_some() {
return Ok(Vec::new());
}
if entry.transcript_is_empty() {
return Ok(Vec::new());
}
match distill(
&entry.generator,
&entry.distill_transcript(),
&entry.repo_summary,
)
.await
{
Ok((intent, constraints)) => {
*lock(&entry.last_promote) = Some((intent, constraints.clone()));
Ok(constraints)
}
Err(e) => {
tracing::warn!(discussion_id, "discussion constraints unavailable: {e}");
Ok(Vec::new())
}
}
}
pub async fn close(
state: &Arc<ServerState>,
discussion_id: &str,
client_id: &str,
) -> Result<Value, String> {
get_owned_discussion(state, discussion_id, client_id).await?;
let entry = state.coder_discussions.lock().await.remove(discussion_id);
let Some(entry) = entry else {
return Err(format!("no open discussion '{discussion_id}'"));
};
entry.cancel_turn();
Ok(json!({ "ok": true }))
}
pub async fn drop_subscriptions_for_client(state: &ServerState, client_id: &str) {
let (owned, others): (Vec<_>, Vec<_>) = {
let open = state.coder_discussions.lock().await;
open.values()
.cloned()
.partition(|e| e.owner_client_id == client_id)
};
for entry in &others {
let _ = entry.cmds.send(StreamCmd::Detach(client_id.to_string()));
}
if owned.is_empty() {
return;
}
let mut open = state.coder_discussions.lock().await;
for entry in owned {
entry.cancel_turn();
open.remove(&entry.id);
}
}
#[derive(Deserialize)]
struct StartParams {
repo: PathBuf,
}
pub async fn handle_discuss_start(
req: &JsonRpcMessage,
state: &Arc<ServerState>,
session: &Arc<ClientSession>,
) -> Result<Value, String> {
let params: StartParams =
serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
let engine = crate::handler::get_inference_engine(state).clone();
let generator: Arc<dyn TurnGenerator> = engine.clone();
start_discussion(state, ¶ms.repo, &session.client_id, engine, generator).await
}
#[derive(Deserialize)]
struct SendParams {
discussion_id: String,
text: String,
}
pub async fn handle_discuss_send(
req: &JsonRpcMessage,
state: &Arc<ServerState>,
session: &Arc<ClientSession>,
) -> Result<Value, String> {
let params: SendParams =
serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
send_message(
state,
¶ms.discussion_id,
&session.client_id,
¶ms.text,
)
.await
}
#[derive(Deserialize)]
struct DiscussionIdParams {
discussion_id: String,
}
#[derive(Deserialize)]
struct SubscribeParams {
discussion_id: String,
#[serde(default)]
from_seq: u64,
}
pub async fn handle_discuss_subscribe(
req: &JsonRpcMessage,
state: &Arc<ServerState>,
session: &Arc<ClientSession>,
) -> Result<Value, String> {
let params: SubscribeParams =
serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
let entry = get_owned_discussion(state, ¶ms.discussion_id, &session.client_id).await?;
entry.touch();
let (tx, rx) = oneshot::channel();
entry
.cmds
.send(StreamCmd::Attach {
client_id: session.client_id.clone(),
channel: session.channel.clone(),
from_seq: params.from_seq,
replayed: tx,
})
.map_err(|_| format!("discussion '{}' is closing", params.discussion_id))?;
let replayed = rx.await.unwrap_or(0);
Ok(json!({ "events_replayed": replayed }))
}
pub async fn handle_discuss_unsubscribe(
req: &JsonRpcMessage,
state: &Arc<ServerState>,
session: &Arc<ClientSession>,
) -> Result<Value, String> {
let params: DiscussionIdParams =
serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
if let Ok(entry) = get_discussion(state, ¶ms.discussion_id).await {
let _ = entry
.cmds
.send(StreamCmd::Detach(session.client_id.clone()));
}
Ok(json!({ "ok": true }))
}
pub async fn handle_discuss_promote(
req: &JsonRpcMessage,
state: &Arc<ServerState>,
session: &Arc<ClientSession>,
) -> Result<Value, String> {
let params: DiscussionIdParams =
serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
promote(state, ¶ms.discussion_id, &session.client_id).await
}
pub async fn handle_discuss_close(
req: &JsonRpcMessage,
state: &Arc<ServerState>,
session: &Arc<ClientSession>,
) -> Result<Value, String> {
let params: DiscussionIdParams =
serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
close(state, ¶ms.discussion_id, &session.client_id).await
}
pub async fn handle_discuss_list(
state: &Arc<ServerState>,
session: &Arc<ClientSession>,
) -> Result<Value, String> {
let mut rows: Vec<Value> = state
.coder_discussions
.lock()
.await
.values()
.filter(|e| e.owner_client_id == session.client_id)
.map(|e| e.summary_row())
.collect();
rows.sort_by_key(|v| std::cmp::Reverse(v["created_at"].as_u64().unwrap_or(0)));
Ok(json!({ "discussions": rows }))
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use car_inference::{GenerateRequest, InferenceResult};
use std::sync::atomic::AtomicUsize;
fn turn(text: &str, tool_calls: Value) -> InferenceResult {
serde_json::from_value(json!({
"text": text, "tool_calls": tool_calls,
"trace_id": "t", "model_used": "scripted", "latency_ms": 0,
}))
.expect("scripted InferenceResult shape")
}
struct Script {
turns: Vec<InferenceResult>,
cursor: AtomicUsize,
}
#[async_trait]
impl TurnGenerator for Script {
async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
let i = self.cursor.fetch_add(1, Ordering::SeqCst);
self.turns
.get(i)
.cloned()
.ok_or_else(|| "script exhausted".to_string())
}
}
struct Blocking {
gate: Arc<tokio::sync::Notify>,
}
#[async_trait]
impl TurnGenerator for Blocking {
async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
self.gate.notified().await;
Ok(turn("done at last", json!([])))
}
}
struct Counting {
calls: Arc<AtomicUsize>,
}
#[async_trait]
impl TurnGenerator for Counting {
async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(turn("counted", json!([])))
}
}
fn init_repo(dir: &Path) {
for args in [
vec!["init", "-q", "-b", "main"],
vec![
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"commit",
"-q",
"--allow-empty",
"-m",
"init",
],
] {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(&args)
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
}
}
fn engine(root: &Path) -> Arc<car_inference::InferenceEngine> {
let mut cfg = car_inference::InferenceConfig::default();
cfg.models_dir = root.join("models");
Arc::new(car_inference::InferenceEngine::new(cfg))
}
fn state() -> (Arc<ServerState>, tempfile::TempDir) {
let journal = tempfile::tempdir().unwrap();
let state = Arc::new(ServerState::standalone(journal.path().to_path_buf()));
(state, journal)
}
async fn start(
state: &Arc<ServerState>,
repo: &Path,
generator: Arc<dyn TurnGenerator>,
) -> String {
let started = start_discussion(state, repo, "owner-1", engine(repo), generator)
.await
.unwrap();
started["discussion_id"].as_str().unwrap().to_string()
}
async fn client(state: &Arc<ServerState>, id: &str) -> Arc<ClientSession> {
state
.create_session(id, Arc::new(crate::session::WsChannel::test_stub()))
.await
}
struct CaptureSink(Arc<StdMutex<Vec<String>>>);
impl futures::Sink<tokio_tungstenite::tungstenite::Message> for CaptureSink {
type Error = tokio_tungstenite::tungstenite::Error;
fn poll_ready(
self: std::pin::Pin<&mut Self>,
_: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
std::task::Poll::Ready(Ok(()))
}
fn start_send(
self: std::pin::Pin<&mut Self>,
item: tokio_tungstenite::tungstenite::Message,
) -> Result<(), Self::Error> {
if let tokio_tungstenite::tungstenite::Message::Text(text) = item {
lock(&self.0).push(text.to_string());
}
Ok(())
}
fn poll_flush(
self: std::pin::Pin<&mut Self>,
_: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
std::task::Poll::Ready(Ok(()))
}
fn poll_close(
self: std::pin::Pin<&mut Self>,
_: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
std::task::Poll::Ready(Ok(()))
}
}
fn capturing_channel() -> (Arc<WsChannel>, Arc<StdMutex<Vec<String>>>) {
let frames = Arc::new(StdMutex::new(Vec::new()));
let sink: crate::session::WsSink = Box::pin(CaptureSink(frames.clone()));
let channel = Arc::new(WsChannel {
write: tokio::sync::Mutex::new(sink),
pending: tokio::sync::Mutex::new(HashMap::new()),
next_id: AtomicU64::new(0),
});
(channel, frames)
}
fn rpc_req(params: Value) -> JsonRpcMessage {
serde_json::from_value(json!({ "jsonrpc": "2.0", "id": 1, "params": params }))
.expect("JsonRpcMessage shape")
}
fn delivered_seqs(frames: &Arc<StdMutex<Vec<String>>>) -> Vec<u64> {
lock(frames)
.iter()
.map(|f| serde_json::from_str::<Value>(f).expect("a lane frame must be JSON"))
.inspect(|v| assert_eq!(v["method"], "coder.discuss.event", "unexpected frame: {v}"))
.map(|v| {
v["params"]["seq"]
.as_u64()
.expect("every event carries a seq")
})
.collect()
}
async fn wait_for_turn_complete(entry: &Arc<DiscussionEntry>) {
for _ in 0..400 {
{
let events = entry.events.lock().await;
if events
.iter()
.any(|e| matches!(e.kind, DiscussEventKind::TurnComplete {}))
{
return;
}
}
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
}
panic!("discussion turn never completed");
}
#[tokio::test]
async fn discuss_start_rejects_a_non_git_directory() {
let dir = tempfile::tempdir().unwrap();
let (state, _journal) = state();
let script: Arc<dyn TurnGenerator> = Arc::new(Script {
turns: vec![],
cursor: AtomicUsize::new(0),
});
let err = start_discussion(&state, dir.path(), "owner-1", engine(dir.path()), script)
.await
.unwrap_err();
assert!(
err.contains("is not a git repository")
&& err.contains("discuss needs a repo to ground itself in"),
"operator-readable non-repo error, got: {err}"
);
}
#[tokio::test]
async fn a_discussion_writes_nothing_in_the_repo() {
let repo = tempfile::tempdir().unwrap();
init_repo(repo.path());
std::fs::write(repo.path().join("keep.txt"), "original").unwrap();
let (state, _journal) = state();
let script: Arc<dyn TurnGenerator> = Arc::new(Script {
turns: vec![
turn(
"",
json!([{
"id": "c1", "name": "write_file",
"arguments": {"path": "sneaky.txt", "content": "written by a discussion"}
}]),
),
turn(
"",
json!([{
"id": "c2", "name": "shell",
"arguments": {"command": "printf x > shelled.txt"}
}]),
),
turn(
"I cannot edit from a discussion; here is what I would change.",
json!([]),
),
],
cursor: AtomicUsize::new(0),
});
let id = start(&state, repo.path(), script).await;
assert!(id.starts_with("disc-"));
send_message(
&state,
&id,
"owner-1",
"can you just make the change for me?",
)
.await
.unwrap();
let entry = get_discussion(&state, &id).await.unwrap();
wait_for_turn_complete(&entry).await;
assert!(
!repo.path().join("sneaky.txt").exists(),
"a discussion must not create files in the repo"
);
assert!(
!repo.path().join("shelled.txt").exists(),
"a discussion must not run shell commands that write"
);
assert_eq!(
std::fs::read_to_string(repo.path().join("keep.txt")).unwrap(),
"original"
);
let events = entry.events.lock().await;
assert!(
events.iter().any(|e| matches!(
&e.kind,
DiscussEventKind::ToolResult { ok, preview, .. }
if !ok && preview.contains("read-only")
)),
"the denial must surface as a tool_result"
);
}
#[tokio::test]
async fn a_discussion_cannot_read_outside_the_repo() {
let outside = tempfile::tempdir().unwrap();
let secret_path = outside.path().join("credentials.txt");
std::fs::write(&secret_path, "sk-ant-SUPERSECRETVALUE").unwrap();
let repo = tempfile::tempdir().unwrap();
init_repo(repo.path());
let (state, _journal) = state();
let script: Arc<dyn TurnGenerator> = Arc::new(Script {
turns: vec![
turn(
"",
json!([{
"id": "c1", "name": "read_file",
"arguments": {"path": secret_path.to_string_lossy()}
}]),
),
turn(
"",
json!([{
"id": "c2", "name": "grep_files",
"arguments": {"path": outside.path().to_string_lossy(), "pattern": "sk-ant-"}
}]),
),
turn("I can only read inside this repository.", json!([])),
],
cursor: AtomicUsize::new(0),
});
let id = start(&state, repo.path(), script).await;
send_message(
&state,
&id,
"owner-1",
"what credentials does this project use?",
)
.await
.unwrap();
let entry = get_discussion(&state, &id).await.unwrap();
wait_for_turn_complete(&entry).await;
let events = entry.events.lock().await;
let stream = serde_json::to_string(&*events).unwrap();
assert!(
!stream.contains("SUPERSECRETVALUE"),
"a discussion must never stream content from outside its repo: {stream}"
);
}
#[tokio::test]
async fn promote_distills_an_intent_and_starts_nothing() {
let repo = tempfile::tempdir().unwrap();
init_repo(repo.path());
let (state, _journal) = state();
let script: Arc<dyn TurnGenerator> = Arc::new(Script {
turns: vec![
turn("The Windows path is the risky one.", json!([])),
turn(
r#"{"proposed_intent":"Make the config loader resolve paths on Windows.",
"constraints":["do not change the POSIX behavior"]}"#,
json!([]),
),
],
cursor: AtomicUsize::new(0),
});
let id = start(&state, repo.path(), script).await;
send_message(
&state,
&id,
"owner-1",
"what is fragile about the config loader?",
)
.await
.unwrap();
let entry = get_discussion(&state, &id).await.unwrap();
wait_for_turn_complete(&entry).await;
let promoted = promote(&state, &id, "owner-1").await.unwrap();
assert_eq!(
promoted["proposed_intent"],
"Make the config loader resolve paths on Windows."
);
assert_eq!(
promoted["constraints"],
json!(["do not change the POSIX behavior"])
);
assert!(state.coder_sessions.lock().await.is_empty());
assert_eq!(
constraints_for_start(&state, &id).await.unwrap(),
vec!["do not change the POSIX behavior".to_string()]
);
}
#[tokio::test]
async fn a_turn_in_flight_blocks_a_second_send_and_promote() {
let repo = tempfile::tempdir().unwrap();
init_repo(repo.path());
let (state, _journal) = state();
let gate = Arc::new(tokio::sync::Notify::new());
let generator: Arc<dyn TurnGenerator> = Arc::new(Blocking { gate: gate.clone() });
let id = start(&state, repo.path(), generator).await;
send_message(&state, &id, "owner-1", "first question")
.await
.unwrap();
let entry = get_discussion(&state, &id).await.unwrap();
for _ in 0..200 {
if entry.is_answering() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
assert!(entry.is_answering(), "the turn should be in flight");
let err = send_message(&state, &id, "owner-1", "second question")
.await
.unwrap_err();
assert!(
err.contains("still answering"),
"a concurrent send must be refused, not silently lose a turn: {err}"
);
let err = promote(&state, &id, "owner-1").await.unwrap_err();
assert!(
err.contains("still answering"),
"promote must not distill a half-finished turn: {err}"
);
gate.notify_one();
wait_for_turn_complete(&entry).await;
}
#[tokio::test]
async fn close_cancels_an_in_flight_turn() {
let repo = tempfile::tempdir().unwrap();
init_repo(repo.path());
let (state, _journal) = state();
let gate = Arc::new(tokio::sync::Notify::new());
let generator: Arc<dyn TurnGenerator> = Arc::new(Blocking { gate });
let id = start(&state, repo.path(), generator).await;
send_message(&state, &id, "owner-1", "a broad question")
.await
.unwrap();
let entry = get_discussion(&state, &id).await.unwrap();
for _ in 0..200 {
if entry.is_answering() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
close(&state, &id, "owner-1").await.unwrap();
assert!(!entry.is_answering(), "close must stop the turn");
assert!(state.coder_discussions.lock().await.is_empty());
}
#[tokio::test]
async fn a_send_cancelled_after_dispatch_leaves_the_discussion_usable() {
let repo = tempfile::tempdir().unwrap();
init_repo(repo.path());
let (state, _journal) = state();
let script: Arc<dyn TurnGenerator> = Arc::new(Script {
turns: vec![
turn("answered anyway", json!([])),
turn("answered on the retry", json!([])),
],
cursor: AtomicUsize::new(0),
});
let id = start(&state, repo.path(), script).await;
let entry = get_discussion(&state, &id).await.unwrap();
let mut send = Box::pin(send_message(
&state,
&id,
"owner-1",
"the message whose reply frame gets cancelled",
));
assert!(
matches!(futures::poll!(send.as_mut()), std::task::Poll::Pending),
"the fixture needs the send parked on its cursor"
);
assert!(
entry.is_answering(),
"the fixture needs the CAS to have run"
);
drop(send);
wait_for_turn_complete(&entry).await;
for _ in 0..200 {
if !entry.is_answering() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
assert!(
!entry.is_answering(),
"a cancelled handler must not strand `in_flight`"
);
send_message(&state, &id, "owner-1", "second try")
.await
.expect("the discussion must still accept a message");
}
#[tokio::test]
async fn a_close_racing_a_dispatching_send_never_starts_the_turn() {
let repo = tempfile::tempdir().unwrap();
init_repo(repo.path());
let (state, _journal) = state();
let calls = Arc::new(AtomicUsize::new(0));
let script: Arc<dyn TurnGenerator> = Arc::new(Counting {
calls: calls.clone(),
});
let id = start(&state, repo.path(), script).await;
let entry = get_discussion(&state, &id).await.unwrap();
entry.cancel_turn();
let err = send_message(&state, &id, "owner-1", "a broad question")
.await
.unwrap_err();
assert!(
err.contains("closed while your message was being dispatched"),
"the caller must be told the send did not run: {err}"
);
assert_eq!(
calls.load(Ordering::SeqCst),
0,
"a closed discussion must never reach the model"
);
assert!(!entry.is_answering());
close(&state, &id, "owner-1").await.unwrap();
assert!(state.coder_discussions.lock().await.is_empty());
}
#[tokio::test]
async fn another_connection_cannot_drive_a_discussion() {
let repo = tempfile::tempdir().unwrap();
init_repo(repo.path());
let (state, _journal) = state();
let script: Arc<dyn TurnGenerator> = Arc::new(Script {
turns: vec![],
cursor: AtomicUsize::new(0),
});
let id = start(&state, repo.path(), script).await;
for err in [
send_message(&state, &id, "intruder", "run this for me")
.await
.unwrap_err(),
promote(&state, &id, "intruder").await.unwrap_err(),
close(&state, &id, "intruder").await.unwrap_err(),
match get_owned_discussion(&state, &id, "intruder").await {
Ok(_) => panic!("a foreign client must not resolve another's discussion"),
Err(e) => e,
},
] {
assert!(
err.contains("belongs to another connection"),
"a foreign client must be refused: {err}"
);
}
assert_eq!(state.coder_discussions.lock().await.len(), 1);
close(&state, &id, "owner-1").await.unwrap();
}
#[tokio::test]
async fn an_oversized_message_is_refused() {
let repo = tempfile::tempdir().unwrap();
init_repo(repo.path());
let (state, _journal) = state();
let script: Arc<dyn TurnGenerator> = Arc::new(Script {
turns: vec![],
cursor: AtomicUsize::new(0),
});
let id = start(&state, repo.path(), script).await;
let entry = get_discussion(&state, &id).await.unwrap();
let err = send_message(
&state,
&id,
"owner-1",
&"x".repeat(DISCUSS_MESSAGE_MAX_BYTES + 1),
)
.await
.unwrap_err();
assert!(err.contains("the limit is"), "{err}");
assert!(!entry.is_answering());
assert!(entry.transcript_is_empty());
}
#[tokio::test]
async fn disconnect_closes_the_owning_clients_discussions() {
let repo = tempfile::tempdir().unwrap();
init_repo(repo.path());
let (state, _journal) = state();
let script: Arc<dyn TurnGenerator> = Arc::new(Script {
turns: vec![],
cursor: AtomicUsize::new(0),
});
let id = start(&state, repo.path(), script).await;
assert_eq!(state.coder_discussions.lock().await.len(), 1);
drop_subscriptions_for_client(&state, "someone-else").await;
assert_eq!(state.coder_discussions.lock().await.len(), 1);
drop_subscriptions_for_client(&state, "owner-1").await;
assert!(state.coder_discussions.lock().await.is_empty());
assert!(get_discussion(&state, &id).await.is_err());
}
#[tokio::test]
async fn open_discussions_are_capped() {
let repo = tempfile::tempdir().unwrap();
init_repo(repo.path());
let (state, _journal) = state();
let held: Vec<_> = (0..MAX_OPEN_DISCUSSIONS)
.map(|_| {
state
.coder_discussion_slots
.clone()
.try_acquire_owned()
.expect("a fresh daemon has every slot free")
})
.collect();
let script: Arc<dyn TurnGenerator> = Arc::new(Script {
turns: vec![],
cursor: AtomicUsize::new(0),
});
let err = start_discussion(&state, repo.path(), "owner-1", engine(repo.path()), script)
.await
.unwrap_err();
assert!(err.contains("already open"), "{err}");
drop(held);
let script: Arc<dyn TurnGenerator> = Arc::new(Script {
turns: vec![],
cursor: AtomicUsize::new(0),
});
start_discussion(&state, repo.path(), "owner-1", engine(repo.path()), script)
.await
.expect("a released slot must be reusable");
}
#[tokio::test]
async fn concurrent_starts_cannot_exceed_the_open_discussion_cap() {
let repo = tempfile::tempdir().unwrap();
init_repo(repo.path());
let (state, _journal) = state();
let _held: Vec<_> = (0..MAX_OPEN_DISCUSSIONS - 1)
.map(|_| {
state
.coder_discussion_slots
.clone()
.try_acquire_owned()
.unwrap()
})
.collect();
let mut racers = Vec::new();
for _ in 0..4 {
let state = state.clone();
let repo = repo.path().to_path_buf();
racers.push(tokio::spawn(async move {
let script: Arc<dyn TurnGenerator> = Arc::new(Script {
turns: vec![],
cursor: AtomicUsize::new(0),
});
start_discussion(&state, &repo, "owner-1", engine(&repo), script).await
}));
}
let mut admitted = 0;
let mut refused = 0;
for racer in racers {
match racer.await.unwrap() {
Ok(_) => admitted += 1,
Err(e) => {
assert!(e.contains("already open"), "unexpected refusal: {e}");
refused += 1;
}
}
}
assert_eq!(admitted, 1, "exactly one racer may take the last slot");
assert_eq!(refused, 3);
assert_eq!(
state.coder_discussions.lock().await.len(),
1,
"the registry must never exceed the cap"
);
}
#[tokio::test]
async fn unknown_discussion_ids_are_clear_errors() {
let (state, _journal) = state();
for err in [
send_message(&state, "disc-nope", "owner-1", "hi")
.await
.unwrap_err(),
promote(&state, "disc-nope", "owner-1").await.unwrap_err(),
constraints_for_start(&state, "disc-nope")
.await
.unwrap_err(),
] {
assert!(err.contains("disc-nope"), "must name the id, got: {err}");
}
assert!(close(&state, "disc-nope", "owner-1").await.is_err());
}
#[tokio::test]
async fn list_and_close_track_open_discussions() {
let repo = tempfile::tempdir().unwrap();
init_repo(repo.path());
let (state, _journal) = state();
let script: Arc<dyn TurnGenerator> = Arc::new(Script {
turns: vec![],
cursor: AtomicUsize::new(0),
});
let id = start(&state, repo.path(), script).await;
let owner = client(&state, "owner-1").await;
let listed = handle_discuss_list(&state, &owner).await.unwrap();
assert_eq!(listed["discussions"].as_array().unwrap().len(), 1);
assert_eq!(listed["discussions"][0]["discussion_id"], id.as_str());
assert_eq!(listed["discussions"][0]["turns"], 0);
let stranger = client(&state, "someone-else").await;
let listed = handle_discuss_list(&state, &stranger).await.unwrap();
assert!(
listed["discussions"].as_array().unwrap().is_empty(),
"another connection must not see this discussion: {listed}"
);
assert_eq!(
close(&state, &id, "owner-1").await.unwrap(),
json!({ "ok": true })
);
let listed = handle_discuss_list(&state, &owner).await.unwrap();
assert!(listed["discussions"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn a_subscriber_receives_every_seq_exactly_once_across_its_attach() {
let repo = tempfile::tempdir().unwrap();
init_repo(repo.path());
let (state, _journal) = state();
let script: Arc<dyn TurnGenerator> = Arc::new(Script {
turns: vec![],
cursor: AtomicUsize::new(0),
});
let id = start(&state, repo.path(), script).await;
let entry = get_discussion(&state, &id).await.unwrap();
let (channel, frames) = capturing_channel();
let owner = state.create_session("owner-1", channel.clone()).await;
let racing = {
let entry = entry.clone();
tokio::spawn(async move {
for i in 0..30u64 {
entry
.emit(DiscussEventKind::AssistantDelta {
text: format!("during-{i}"),
})
.await;
}
})
};
while entry.events.lock().await.is_empty() {
tokio::task::yield_now().await;
}
let subscribed = handle_discuss_subscribe(
&rpc_req(json!({ "discussion_id": id, "from_seq": 0 })),
&state,
&owner,
)
.await
.unwrap();
racing.await.unwrap();
for i in 0..20u64 {
entry
.emit(DiscussEventKind::AssistantDelta {
text: format!("after-{i}"),
})
.await;
}
const TOTAL: usize = 50;
let replayed = subscribed["events_replayed"].as_u64().unwrap();
assert!(
replayed > 0,
"the attach replayed nothing, so this test never exercised the \
replay hop it exists to cover"
);
assert!(
replayed <= 30,
"replay cannot exceed what was emitted before the attach: {replayed}"
);
let mut seqs = Vec::new();
for _ in 0..400 {
seqs = delivered_seqs(&frames);
if seqs.len() >= TOTAL {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
}
assert_eq!(
seqs,
(0..TOTAL as u64).collect::<Vec<_>>(),
"a subscriber must receive seq 0..{TOTAL} once each, in order"
);
const RESUME_FROM: u64 = 17;
let (resumed_channel, resumed_frames) = capturing_channel();
let resumed = state.create_session("owner-1", resumed_channel).await;
let reattached = handle_discuss_subscribe(
&rpc_req(json!({ "discussion_id": id, "from_seq": RESUME_FROM })),
&state,
&resumed,
)
.await
.unwrap();
assert_eq!(
reattached["events_replayed"].as_u64().unwrap(),
TOTAL as u64 - RESUME_FROM,
"a resume from {RESUME_FROM} must replay seq {RESUME_FROM}..{TOTAL}"
);
let mut resumed_seqs = Vec::new();
for _ in 0..400 {
resumed_seqs = delivered_seqs(&resumed_frames);
if resumed_seqs.len() >= TOTAL - RESUME_FROM as usize {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
}
assert_eq!(
resumed_seqs,
(RESUME_FROM..TOTAL as u64).collect::<Vec<_>>(),
"a resume must start AT its cursor, not one past it"
);
}
#[tokio::test]
async fn a_dispatched_send_emits_the_user_message_before_any_delta() {
let repo = tempfile::tempdir().unwrap();
init_repo(repo.path());
let (state, _journal) = state();
let script: Arc<dyn TurnGenerator> = Arc::new(Script {
turns: vec![turn("here is what I would change", json!([]))],
cursor: AtomicUsize::new(0),
});
let id = start(&state, repo.path(), script).await;
let entry = get_discussion(&state, &id).await.unwrap();
let sent = send_message(&state, &id, "owner-1", "what should this change do?")
.await
.unwrap();
assert_eq!(
sent["seq"], 0,
"the reported cursor is the user_message's own seq"
);
wait_for_turn_complete(&entry).await;
let events = entry.events.lock().await;
assert!(
matches!(events[0].kind, DiscussEventKind::UserMessage { .. }),
"the operator's message must be the turn's first event, got: {:?}",
events[0].kind
);
assert!(
events.len() > 1,
"the turn produced nothing to order against"
);
assert!(
!events[1..]
.iter()
.any(|e| matches!(e.kind, DiscussEventKind::UserMessage { .. })),
"exactly one user_message per send"
);
}
#[tokio::test]
async fn a_wedged_subscriber_is_shed_and_the_turn_still_completes() {
let repo = tempfile::tempdir().unwrap();
init_repo(repo.path());
let (state, _journal) = state();
let script: Arc<dyn TurnGenerator> = Arc::new(Script {
turns: vec![turn("here is what I would change", json!([]))],
cursor: AtomicUsize::new(0),
});
let id = start(&state, repo.path(), script).await;
let entry = get_discussion(&state, &id).await.unwrap();
let (channel, _frames) = capturing_channel();
let owner = state.create_session("owner-1", channel.clone()).await;
let unsubscribed = Arc::strong_count(&channel);
handle_discuss_subscribe(
&rpc_req(json!({ "discussion_id": id, "from_seq": 0 })),
&state,
&owner,
)
.await
.unwrap();
assert_eq!(
Arc::strong_count(&channel),
unsubscribed + 1,
"the lane must hold this subscriber's channel"
);
let stuck = channel.write.lock().await;
let started = std::time::Instant::now();
send_message(&state, &id, "owner-1", "what should this change do?")
.await
.unwrap();
let mut completed = false;
for _ in 0..120 {
if entry
.events
.lock()
.await
.iter()
.any(|e| matches!(e.kind, DiscussEventKind::TurnComplete {}))
{
completed = true;
break;
}
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
}
assert!(
completed && started.elapsed() < DISCUSS_SEND_TIMEOUT,
"the turn must not wait on a wedged subscriber's socket ({:?} elapsed)",
started.elapsed()
);
for i in 0..(DISCUSS_SUBSCRIBER_QUEUE + 64) {
entry
.emit(DiscussEventKind::AssistantDelta {
text: format!("overflow-{i}"),
})
.await;
}
let mut shed = false;
for _ in 0..200 {
if Arc::strong_count(&channel) == unsubscribed {
shed = true;
break;
}
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
}
assert!(
shed,
"a subscriber that is not draining must be shed, not retained"
);
drop(stuck);
}
#[tokio::test]
async fn a_refused_send_leaves_no_unanswered_turn_in_the_transcript() {
let repo = tempfile::tempdir().unwrap();
init_repo(repo.path());
let (state, _journal) = state();
let calls = Arc::new(AtomicUsize::new(0));
let script: Arc<dyn TurnGenerator> = Arc::new(Counting {
calls: calls.clone(),
});
let id = start(&state, repo.path(), script).await;
let entry = get_discussion(&state, &id).await.unwrap();
entry.cancel_turn();
let err = send_message(&state, &id, "owner-1", "should we rewrite the scheduler?")
.await
.unwrap_err();
assert!(
err.contains("closed while your message was being dispatched"),
"expected a refused dispatch, got: {err}"
);
assert!(
!entry.is_answering(),
"a refused dispatch must not strand `in_flight`"
);
assert!(
entry.transcript_is_empty(),
"a question no turn will answer must not survive in the transcript: {:?}",
lock(&entry.transcript)
);
assert!(
!entry
.events
.lock()
.await
.iter()
.any(|e| matches!(e.kind, DiscussEventKind::UserMessage { .. })),
"...nor reach the replay buffer and every subscriber"
);
let err = promote(&state, &id, "owner-1").await.unwrap_err();
assert!(
err.contains("no turns yet"),
"promote must refuse an empty discussion rather than distill a stranded \
question: {err}"
);
assert!(
constraints_for_start(&state, &id).await.unwrap().is_empty(),
"coder.start must not distill constraints from a stranded question"
);
assert_eq!(
calls.load(Ordering::SeqCst),
0,
"no turn ran, so nothing reached the model"
);
}
#[tokio::test]
async fn concurrent_emits_stay_seq_ordered_in_the_buffer() {
let repo = tempfile::tempdir().unwrap();
init_repo(repo.path());
let (state, _journal) = state();
let script: Arc<dyn TurnGenerator> = Arc::new(Script {
turns: vec![],
cursor: AtomicUsize::new(0),
});
let id = start(&state, repo.path(), script).await;
let entry = get_discussion(&state, &id).await.unwrap();
let mut tasks = Vec::new();
for i in 0..50 {
let e = entry.clone();
tasks.push(tokio::spawn(async move {
e.emit(DiscussEventKind::AssistantDelta {
text: format!("chunk-{i}"),
})
.await
}));
}
for t in tasks {
t.await.unwrap();
}
let events = entry.events.lock().await;
assert_eq!(events.len(), 50);
for (i, e) in events.iter().enumerate() {
assert_eq!(e.seq, i as u64, "buffer must be in seq order");
}
}
#[test]
fn discuss_event_json_shape_is_ws_friendly() {
let e = DiscussEvent {
discussion_id: "disc-x".into(),
seq: 7,
ts: 1,
kind: DiscussEventKind::AssistantDelta {
text: "hello".into(),
},
};
let v = serde_json::to_value(&e).unwrap();
assert_eq!(v["type"], "assistant_delta");
assert_eq!(v["text"], "hello");
assert_eq!(v["seq"], 7);
assert_eq!(v["discussion_id"], "disc-x");
let v = serde_json::to_value(DiscussEvent {
discussion_id: "disc-x".into(),
seq: 8,
ts: 1,
kind: DiscussEventKind::TurnComplete {},
})
.unwrap();
assert_eq!(v["type"], "turn_complete");
}
}