chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "jcode-protocol"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
jcode-batch-types = { path = "../jcode-batch-types" }
|
||||
jcode-config-types = { path = "../jcode-config-types" }
|
||||
jcode-message-types = { path = "../jcode-message-types" }
|
||||
jcode-plan = { path = "../jcode-plan" }
|
||||
jcode-provider-core = { path = "../jcode-provider-core" }
|
||||
jcode-selfdev-types = { path = "../jcode-selfdev-types" }
|
||||
jcode-session-types = { path = "../jcode-session-types" }
|
||||
jcode-side-panel-types = { path = "../jcode-side-panel-types" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
anyhow = "1"
|
||||
rand = "0.9"
|
||||
@@ -0,0 +1,646 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use super::{
|
||||
AgentInfo, AgentStatusSnapshot, AwaitedMemberStatus, ContextEntry, HistoryMessage,
|
||||
PlanGraphStatus, SwarmChannelInfo, ToolCallSummary,
|
||||
};
|
||||
|
||||
pub fn format_comm_plan_followup(summary: &PlanGraphStatus) -> String {
|
||||
let mut parts = Vec::new();
|
||||
parts.push(format!("active={}", summary.active_ids.len()));
|
||||
if !summary.next_ready_ids.is_empty() {
|
||||
parts.push(format!("next={}", summary.next_ready_ids.join(", ")));
|
||||
}
|
||||
if !summary.newly_ready_ids.is_empty() {
|
||||
parts.push(format!(
|
||||
"newly_ready={}",
|
||||
summary.newly_ready_ids.join(", ")
|
||||
));
|
||||
}
|
||||
parts.join(" · ")
|
||||
}
|
||||
|
||||
pub fn default_comm_cleanup_target_statuses() -> Vec<String> {
|
||||
vec![
|
||||
"ready".to_string(),
|
||||
"completed".to_string(),
|
||||
"failed".to_string(),
|
||||
"stopped".to_string(),
|
||||
"crashed".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn default_comm_run_await_statuses() -> Vec<String> {
|
||||
vec![
|
||||
"ready".to_string(),
|
||||
"completed".to_string(),
|
||||
"failed".to_string(),
|
||||
"stopped".to_string(),
|
||||
"crashed".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn default_comm_await_target_statuses() -> Vec<String> {
|
||||
vec![
|
||||
"ready".to_string(),
|
||||
"completed".to_string(),
|
||||
"stopped".to_string(),
|
||||
"failed".to_string(),
|
||||
"crashed".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn comm_cleanup_candidate_session_ids(
|
||||
owner_session_id: &str,
|
||||
members: &[AgentInfo],
|
||||
target_status: &[String],
|
||||
requested_session_ids: &[String],
|
||||
force: bool,
|
||||
) -> Vec<String> {
|
||||
let status_filter: HashSet<&str> = target_status.iter().map(String::as_str).collect();
|
||||
let requested: HashSet<&str> = requested_session_ids.iter().map(String::as_str).collect();
|
||||
let restrict_to_requested = !requested.is_empty();
|
||||
let mut ids = members
|
||||
.iter()
|
||||
.filter(|member| member.session_id != owner_session_id)
|
||||
.filter(|member| !restrict_to_requested || requested.contains(member.session_id.as_str()))
|
||||
.filter(|member| {
|
||||
member
|
||||
.status
|
||||
.as_deref()
|
||||
.is_some_and(|status| status_filter.contains(status))
|
||||
})
|
||||
.filter(|member| {
|
||||
force || member.report_back_to_session_id.as_deref() == Some(owner_session_id)
|
||||
})
|
||||
.map(|member| member.session_id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
ids.sort();
|
||||
ids
|
||||
}
|
||||
|
||||
pub fn format_comm_context_entries(entries: &[ContextEntry]) -> String {
|
||||
if entries.is_empty() {
|
||||
"No shared context found.".to_string()
|
||||
} else {
|
||||
let mut output = String::from("Shared context from other agents:\n\n");
|
||||
for entry in entries {
|
||||
let from = entry.from_name.as_deref().unwrap_or(&entry.from_session);
|
||||
output.push_str(&format!(
|
||||
" {} (from {}): {}\n",
|
||||
entry.key, from, entry.value
|
||||
));
|
||||
}
|
||||
output
|
||||
}
|
||||
}
|
||||
|
||||
pub fn duplicate_comm_friendly_names<'a>(
|
||||
names: impl IntoIterator<Item = Option<&'a str>>,
|
||||
) -> HashSet<&'a str> {
|
||||
let mut counts = HashMap::<&'a str, usize>::new();
|
||||
for name in names.into_iter().flatten() {
|
||||
*counts.entry(name).or_default() += 1;
|
||||
}
|
||||
counts
|
||||
.into_iter()
|
||||
.filter_map(|(name, count)| (count > 1).then_some(name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn comm_session_display_suffix(session_id: &str) -> &str {
|
||||
let suffix = session_id.rsplit('_').next().unwrap_or(session_id);
|
||||
if suffix.len() > 6 {
|
||||
&suffix[suffix.len() - 6..]
|
||||
} else {
|
||||
suffix
|
||||
}
|
||||
}
|
||||
|
||||
pub fn comm_display_friendly_name(
|
||||
friendly_name: Option<&str>,
|
||||
session_id: &str,
|
||||
duplicate_names: &HashSet<&str>,
|
||||
) -> String {
|
||||
match friendly_name {
|
||||
Some(name) if duplicate_names.contains(name) => {
|
||||
format!("{} [{}]", name, comm_session_display_suffix(session_id))
|
||||
}
|
||||
Some(name) => name.to_string(),
|
||||
None => session_id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_comm_members(current_session_id: &str, members: &[AgentInfo]) -> String {
|
||||
if members.is_empty() {
|
||||
"No other agents in this codebase.".to_string()
|
||||
} else {
|
||||
let duplicate_names = duplicate_comm_friendly_names(
|
||||
members.iter().map(|member| member.friendly_name.as_deref()),
|
||||
);
|
||||
let mut output = String::from("Agents in this codebase:\n\n");
|
||||
for member in members {
|
||||
let name = comm_display_friendly_name(
|
||||
member.friendly_name.as_deref(),
|
||||
&member.session_id,
|
||||
&duplicate_names,
|
||||
);
|
||||
let session = &member.session_id;
|
||||
let role = member.role.as_deref().unwrap_or("agent");
|
||||
let files = member.files_touched.join(", ");
|
||||
let status = member.status.as_deref().unwrap_or("unknown");
|
||||
let is_me = session == current_session_id;
|
||||
let role_label = if role != "agent" {
|
||||
format!(" [{}]", role)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// Status line: lifecycle + detail, then a contextual age label.
|
||||
// For an idle/ready agent the "age" is how long it has been idle;
|
||||
// for a running agent it is how long the current turn has run.
|
||||
let detail_suffix = member
|
||||
.detail
|
||||
.as_deref()
|
||||
.map(|detail| format!(" — {}", detail))
|
||||
.unwrap_or_default();
|
||||
// Stable task label: what this agent was spawned/assigned for.
|
||||
// Skip when the transient detail already says the same thing.
|
||||
let task_suffix = match member.task_label.as_deref() {
|
||||
Some(task)
|
||||
if !task.trim().is_empty()
|
||||
&& member.detail.as_deref().is_none_or(|d| !d.contains(task)) =>
|
||||
{
|
||||
format!("\n Task: {}", task)
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
let age_suffix = match member.status_age_secs {
|
||||
Some(age) if status == "ready" || status == "idle" => {
|
||||
format!(" · idle {}", format_secs(age))
|
||||
}
|
||||
Some(age) if status == "running" => format!(" · {}", format_secs(age)),
|
||||
Some(age) => format!(" · {} ago", format_secs(age)),
|
||||
None => String::new(),
|
||||
};
|
||||
// Last observed activity (tokens/tools/heartbeats). status_age only
|
||||
// tracks lifecycle transitions, so a worker mid-turn for minutes
|
||||
// looks stale without this even while it streams tokens.
|
||||
let activity_age_suffix = match member.last_activity_age_secs {
|
||||
Some(age) if status == "running" || status == "queued" => {
|
||||
format!(" · active {} ago", format_secs(age))
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
// Live activity: what the agent is doing right now.
|
||||
let activity_suffix = match member.activity.as_ref() {
|
||||
Some(activity) if activity.is_processing => {
|
||||
match activity.current_tool_name.as_deref() {
|
||||
Some(tool) => format!("\n Activity: working ({})", tool),
|
||||
None => "\n Activity: thinking".to_string(),
|
||||
}
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
// Progress: todos completed / total.
|
||||
let progress_suffix = match (member.todos_completed, member.todos_total) {
|
||||
(Some(done), Some(total)) if total > 0 => {
|
||||
format!("\n Progress: {}/{} todos", done, total)
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
// Live work signal: recent token churn + cumulative + turns.
|
||||
let mut work_meta = Vec::new();
|
||||
if let (Some(recent), Some(window)) =
|
||||
(member.recent_total_tokens, member.recent_window_secs)
|
||||
&& recent > 0
|
||||
{
|
||||
work_meta.push(format!("{} tok/{}s", format_count(recent), window));
|
||||
}
|
||||
if let Some(turns) = member.turn_count.filter(|turns| *turns > 0) {
|
||||
work_meta.push(format!("{} turns", turns));
|
||||
}
|
||||
if let Some(total) = member.cumulative_total_tokens.filter(|total| *total > 0) {
|
||||
work_meta.push(format!("{} tok total", format_count(total)));
|
||||
}
|
||||
let work_suffix = if work_meta.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("\n Work: {}", work_meta.join(" · "))
|
||||
};
|
||||
|
||||
// Model line.
|
||||
let model_suffix = match (
|
||||
member.provider_name.as_deref(),
|
||||
member.provider_model.as_deref(),
|
||||
) {
|
||||
(Some(provider), Some(model)) => format!("\n Model: {}/{}", provider, model),
|
||||
(None, Some(model)) => format!("\n Model: {}", model),
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
let mut extra_meta = Vec::new();
|
||||
if member.is_headless == Some(true) {
|
||||
extra_meta.push("headless".to_string());
|
||||
}
|
||||
if let Some(owner) = member.report_back_to_session_id.as_deref() {
|
||||
if owner == current_session_id {
|
||||
extra_meta.push("owned_by_you".to_string());
|
||||
} else {
|
||||
extra_meta.push(format!("owned_by={owner}"));
|
||||
}
|
||||
}
|
||||
if let Some(attachments) = member.live_attachments {
|
||||
extra_meta.push(format!("attachments={attachments}"));
|
||||
}
|
||||
let meta_suffix = if extra_meta.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("\n Meta: {}", extra_meta.join(" · "))
|
||||
};
|
||||
|
||||
// Completion report when the agent has finished.
|
||||
let report_suffix = match member.latest_completion_report.as_deref() {
|
||||
Some(report) if !report.trim().is_empty() => {
|
||||
format!("\n Report: {}", truncate_report(report))
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
output.push_str(&format!(
|
||||
" {}{} ({})\n Status: {}{}{}{}{}{}{}{}{}{}{}{}\n",
|
||||
name,
|
||||
role_label,
|
||||
if is_me { "you" } else { session },
|
||||
status,
|
||||
detail_suffix,
|
||||
age_suffix,
|
||||
activity_age_suffix,
|
||||
task_suffix,
|
||||
activity_suffix,
|
||||
progress_suffix,
|
||||
work_suffix,
|
||||
model_suffix,
|
||||
if files.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("\n Files: {}", files)
|
||||
},
|
||||
meta_suffix,
|
||||
report_suffix,
|
||||
));
|
||||
}
|
||||
output
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a duration in seconds into a compact human label (e.g. `45s`, `3m`, `2h`).
|
||||
fn format_secs(secs: u64) -> String {
|
||||
if secs < 60 {
|
||||
format!("{}s", secs)
|
||||
} else if secs < 3600 {
|
||||
format!("{}m", secs / 60)
|
||||
} else {
|
||||
format!("{}h", secs / 3600)
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a token count compactly (e.g. `850`, `12.3k`, `1.2M`).
|
||||
fn format_count(count: u64) -> String {
|
||||
if count < 1_000 {
|
||||
count.to_string()
|
||||
} else if count < 1_000_000 {
|
||||
format!("{:.1}k", count as f64 / 1_000.0)
|
||||
} else {
|
||||
format!("{:.1}M", count as f64 / 1_000_000.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate a completion report to a single compact line for the roster view.
|
||||
fn truncate_report(report: &str) -> String {
|
||||
const MAX: usize = 120;
|
||||
let one_line: String = report.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if one_line.chars().count() > MAX {
|
||||
let truncated: String = one_line.chars().take(MAX).collect();
|
||||
format!("{}…", truncated)
|
||||
} else {
|
||||
one_line
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_comm_tool_summary(target: &str, calls: &[ToolCallSummary]) -> String {
|
||||
if calls.is_empty() {
|
||||
format!("No tool calls found for {}", target)
|
||||
} else {
|
||||
let call_count = calls.len();
|
||||
let mut output = format!(
|
||||
"Tool call summary for {} ({} call{}):\n\n",
|
||||
target,
|
||||
call_count,
|
||||
if call_count == 1 { "" } else { "s" }
|
||||
);
|
||||
for call in calls {
|
||||
output.push_str(&format!(" {} — {}\n", call.tool_name, call.brief_output));
|
||||
}
|
||||
output
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_comm_status_snapshot(snapshot: &AgentStatusSnapshot) -> String {
|
||||
let target = snapshot
|
||||
.friendly_name
|
||||
.as_deref()
|
||||
.unwrap_or(&snapshot.session_id);
|
||||
let status = snapshot.status.as_deref().unwrap_or("unknown");
|
||||
let mut output = format!(
|
||||
"Status snapshot for {} ({})\n\n",
|
||||
target, snapshot.session_id
|
||||
);
|
||||
output.push_str(&format!(" Lifecycle: {}", status));
|
||||
if let Some(detail) = snapshot.detail.as_deref() {
|
||||
output.push_str(&format!(" — {}", detail));
|
||||
}
|
||||
output.push('\n');
|
||||
|
||||
let activity = snapshot
|
||||
.activity
|
||||
.as_ref()
|
||||
.map(|activity| match activity.current_tool_name.as_deref() {
|
||||
Some(tool_name) => format!("busy ({tool_name})"),
|
||||
None if activity.is_processing => "busy".to_string(),
|
||||
_ => "idle".to_string(),
|
||||
})
|
||||
.unwrap_or_else(|| "idle".to_string());
|
||||
output.push_str(&format!(" Activity: {}\n", activity));
|
||||
|
||||
if let Some(role) = snapshot.role.as_deref() {
|
||||
output.push_str(&format!(" Role: {}\n", role));
|
||||
}
|
||||
if let Some(swarm_id) = snapshot.swarm_id.as_deref() {
|
||||
output.push_str(&format!(" Swarm: {}\n", swarm_id));
|
||||
}
|
||||
|
||||
let mut meta = Vec::new();
|
||||
if snapshot.is_headless == Some(true) {
|
||||
meta.push("headless".to_string());
|
||||
}
|
||||
if let Some(attachments) = snapshot.live_attachments {
|
||||
meta.push(format!("attachments={attachments}"));
|
||||
}
|
||||
if let Some(age_secs) = snapshot.last_activity_age_secs {
|
||||
meta.push(format!("active={} ago", format_secs(age_secs)));
|
||||
}
|
||||
if let Some(age_secs) = snapshot.status_age_secs {
|
||||
meta.push(format!("status_age={}s", age_secs));
|
||||
}
|
||||
if let Some(age_secs) = snapshot.joined_age_secs {
|
||||
meta.push(format!("joined={}s", age_secs));
|
||||
}
|
||||
if !meta.is_empty() {
|
||||
output.push_str(&format!(" Meta: {}\n", meta.join(" · ")));
|
||||
}
|
||||
|
||||
if snapshot.provider_name.is_some() || snapshot.provider_model.is_some() {
|
||||
let provider = snapshot.provider_name.as_deref().unwrap_or("unknown");
|
||||
let model = snapshot.provider_model.as_deref().unwrap_or("unknown");
|
||||
output.push_str(&format!(" Provider: {} / {}\n", provider, model));
|
||||
}
|
||||
|
||||
if snapshot.files_touched.is_empty() {
|
||||
output.push_str(" Files: (none)\n");
|
||||
} else {
|
||||
output.push_str(&format!(" Files: {}\n", snapshot.files_touched.join(", ")));
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
pub fn format_comm_plan_status(summary: &PlanGraphStatus) -> String {
|
||||
let swarm_id = summary.swarm_id.as_deref().unwrap_or("unknown");
|
||||
let mut output = format!(
|
||||
"Plan status for swarm {}\n\n Version: {}\n Mode: {}\n Items: {}\n",
|
||||
swarm_id, summary.version, summary.mode, summary.item_count
|
||||
);
|
||||
// Growth accounting: deep mode is meant to outgrow its seed (decomposition,
|
||||
// gate-injected gaps). Surfacing seeded-vs-grown makes a plan that never
|
||||
// grew visibly under-explored.
|
||||
if summary.mode.eq_ignore_ascii_case("deep") && summary.item_count > 0 {
|
||||
output.push_str(&format!(
|
||||
" Growth: {} seeded -> {} nodes ({} machinery-grown)",
|
||||
summary.seeded_count, summary.item_count, summary.grown_count
|
||||
));
|
||||
if summary.grown_count == 0 {
|
||||
output.push_str(
|
||||
" — the graph has not grown beyond its seed yet; \
|
||||
expect expand_node decomposition and gate-injected gaps",
|
||||
);
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
|
||||
output.push_str(&format!(
|
||||
" Ready: {}\n",
|
||||
if summary.ready_ids.is_empty() {
|
||||
"(none)".to_string()
|
||||
} else {
|
||||
summary.ready_ids.join(", ")
|
||||
}
|
||||
));
|
||||
output.push_str(&format!(
|
||||
" Next up: {}\n",
|
||||
if summary.next_ready_ids.is_empty() {
|
||||
"(none)".to_string()
|
||||
} else {
|
||||
summary.next_ready_ids.join(", ")
|
||||
}
|
||||
));
|
||||
if !summary.newly_ready_ids.is_empty() {
|
||||
output.push_str(&format!(
|
||||
" Newly ready: {}\n",
|
||||
summary.newly_ready_ids.join(", ")
|
||||
));
|
||||
}
|
||||
if !summary.blocked_ids.is_empty() {
|
||||
output.push_str(&format!(" Blocked: {}\n", summary.blocked_ids.join(", ")));
|
||||
}
|
||||
if !summary.active_ids.is_empty() {
|
||||
output.push_str(&format!(" Active: {}\n", summary.active_ids.join(", ")));
|
||||
}
|
||||
if !summary.completed_ids.is_empty() {
|
||||
output.push_str(&format!(
|
||||
" Completed: {}\n",
|
||||
summary.completed_ids.join(", ")
|
||||
));
|
||||
}
|
||||
if !summary.failed_ids.is_empty() {
|
||||
output.push_str(&format!(
|
||||
" Failed (terminal without completing): {}\n",
|
||||
summary.failed_ids.join(", ")
|
||||
));
|
||||
// Recorded failure reasons (from the durable task progress): a run
|
||||
// that burned nodes on e.g. a 401 credential wave must explain itself
|
||||
// here instead of only listing ids.
|
||||
for id in &summary.failed_ids {
|
||||
if let Some(reason) = summary.failed_reasons.get(id) {
|
||||
output.push_str(&format!(" {}: {}\n", id, reason));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !summary.low_confidence_ids.is_empty() {
|
||||
output.push_str(&format!(
|
||||
" Low confidence (completed but shaky; widen with follow-up nodes): {}\n",
|
||||
summary.low_confidence_ids.join(", ")
|
||||
));
|
||||
}
|
||||
if !summary.cycle_ids.is_empty() {
|
||||
output.push_str(&format!(" Cycles: {}\n", summary.cycle_ids.join(", ")));
|
||||
}
|
||||
if !summary.unresolved_dependency_ids.is_empty() {
|
||||
output.push_str(&format!(
|
||||
" Missing deps: {}\n",
|
||||
summary.unresolved_dependency_ids.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
pub fn format_comm_context_history(target: &str, messages: &[HistoryMessage]) -> String {
|
||||
if messages.is_empty() {
|
||||
format!("No conversation history for {}", target)
|
||||
} else {
|
||||
let mut output = format!(
|
||||
"Conversation context for {} ({} messages):\n\n",
|
||||
target,
|
||||
messages.len()
|
||||
);
|
||||
for msg in messages {
|
||||
let truncated = if msg.content.len() > 500 {
|
||||
format!("{}...", &msg.content[..500])
|
||||
} else {
|
||||
msg.content.clone()
|
||||
};
|
||||
output.push_str(&format!("[{}] {}\n\n", msg.role, truncated));
|
||||
}
|
||||
output
|
||||
}
|
||||
}
|
||||
|
||||
pub fn truncate_comm_completion_report(report: &str) -> String {
|
||||
const MAX_REPORT_CHARS: usize = 4000;
|
||||
let report = report.trim();
|
||||
if report.chars().count() <= MAX_REPORT_CHARS {
|
||||
return report.to_string();
|
||||
}
|
||||
let suffix = "\n\n[Report truncated by jcode.]";
|
||||
let keep = MAX_REPORT_CHARS.saturating_sub(suffix.chars().count());
|
||||
let mut out: String = report.chars().take(keep).collect();
|
||||
out.push_str(suffix);
|
||||
out
|
||||
}
|
||||
|
||||
pub fn latest_assistant_comm_report(messages: &[HistoryMessage]) -> Option<String> {
|
||||
messages.iter().rev().find_map(|message| {
|
||||
if message.role != "assistant" {
|
||||
return None;
|
||||
}
|
||||
let report = message.content.trim();
|
||||
(!report.is_empty()).then(|| truncate_comm_completion_report(report))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resolve_optional_comm_target_session(
|
||||
target: Option<String>,
|
||||
current_session: &str,
|
||||
) -> String {
|
||||
match target {
|
||||
Some(target) if target == "current" => current_session.to_string(),
|
||||
Some(target) => target,
|
||||
None => current_session.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_comm_awaited_members_with_reports(
|
||||
completed: bool,
|
||||
summary: &str,
|
||||
members: &[AwaitedMemberStatus],
|
||||
reports: &HashMap<String, String>,
|
||||
) -> String {
|
||||
// An any-mode wait can complete while some members are still pending, so
|
||||
// only claim "All members done" when every member actually matched.
|
||||
let all_done = members.iter().all(|member| member.done);
|
||||
let mut output = if completed && all_done {
|
||||
format!("All members done. {}\n", summary)
|
||||
} else if completed {
|
||||
format!("Await satisfied. {}\n", summary)
|
||||
} else {
|
||||
format!("Await incomplete. {}\n", summary)
|
||||
};
|
||||
|
||||
if !members.is_empty() {
|
||||
let duplicate_names = duplicate_comm_friendly_names(
|
||||
members.iter().map(|member| member.friendly_name.as_deref()),
|
||||
);
|
||||
output.push_str("\nMember statuses:\n");
|
||||
for member in members {
|
||||
let name = comm_display_friendly_name(
|
||||
member.friendly_name.as_deref(),
|
||||
&member.session_id,
|
||||
&duplicate_names,
|
||||
);
|
||||
let icon = if member.done { "✓" } else { "✗" };
|
||||
output.push_str(&format!(" {} {} ({})\n", icon, name, member.status));
|
||||
}
|
||||
}
|
||||
|
||||
let mut report_members: Vec<_> = members
|
||||
.iter()
|
||||
.filter_map(|member| {
|
||||
member
|
||||
.completion_report
|
||||
.as_ref()
|
||||
.or_else(|| reports.get(&member.session_id))
|
||||
.map(|report| (member, report))
|
||||
})
|
||||
.collect();
|
||||
report_members.sort_by(|(left, _), (right, _)| left.session_id.cmp(&right.session_id));
|
||||
if !report_members.is_empty() {
|
||||
let duplicate_names = duplicate_comm_friendly_names(
|
||||
members.iter().map(|member| member.friendly_name.as_deref()),
|
||||
);
|
||||
output.push_str("\nCompletion reports:\n");
|
||||
for (member, report) in report_members {
|
||||
let name = comm_display_friendly_name(
|
||||
member.friendly_name.as_deref(),
|
||||
&member.session_id,
|
||||
&duplicate_names,
|
||||
);
|
||||
output.push_str(&format!(
|
||||
"\n--- {} ({}) ---\n{}\n",
|
||||
name, member.status, report
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
pub fn format_comm_channels(channels: &[SwarmChannelInfo]) -> String {
|
||||
if channels.is_empty() {
|
||||
"No swarm channels found.".to_string()
|
||||
} else {
|
||||
let mut output = String::from("Swarm channels:\n\n");
|
||||
for channel in channels {
|
||||
output.push_str(&format!(
|
||||
" #{} — {} subscriber{}\n",
|
||||
channel.channel,
|
||||
channel.member_count,
|
||||
if channel.member_count == 1 { "" } else { "s" }
|
||||
));
|
||||
}
|
||||
output
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,737 @@
|
||||
//! Client-server protocol for jcode
|
||||
//!
|
||||
//! Uses newline-delimited JSON over Unix socket.
|
||||
//! Server streams events back to clients during message processing.
|
||||
//!
|
||||
//! Socket types:
|
||||
//! - Main socket: TUI/client communication with agent
|
||||
//! - Agent socket: Inter-agent communication (AI-to-AI)
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
mod comm_format;
|
||||
mod notifications;
|
||||
|
||||
pub use comm_format::*;
|
||||
pub use notifications::{FeatureToggle, NotificationType};
|
||||
|
||||
use jcode_batch_types::BatchProgress;
|
||||
use jcode_message_types::{InputShellResult, ToolCall};
|
||||
use jcode_plan::{PlanItem, VersionedPlan, next_runnable_item_ids, summarize_plan_graph};
|
||||
use jcode_side_panel_types::{SidePanelSnapshot, snapshot_is_empty};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[path = "protocol_memory.rs"]
|
||||
mod memory_snapshots;
|
||||
|
||||
pub use memory_snapshots::{
|
||||
MemoryActivitySnapshot, MemoryPipelineSnapshot, MemoryStateSnapshot, MemoryStepResultSnapshot,
|
||||
MemoryStepStatusSnapshot,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TranscriptMode {
|
||||
Insert,
|
||||
Append,
|
||||
Replace,
|
||||
#[default]
|
||||
Send,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CommDeliveryMode {
|
||||
Notify,
|
||||
Interrupt,
|
||||
Wake,
|
||||
}
|
||||
|
||||
/// A message in conversation history (for sync)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HistoryMessage {
|
||||
pub role: String,
|
||||
pub content: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_data: Option<ToolCall>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SessionActivitySnapshot {
|
||||
pub is_processing: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_tool_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct TokenUsageTotals {
|
||||
pub messages_with_token_usage: usize,
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
/// Input tokens from requests where the provider reported cache telemetry.
|
||||
/// This may be lower than `input_tokens` for providers or older sessions that
|
||||
/// did not expose cache-read/cache-write fields.
|
||||
pub cache_reported_input_tokens: u64,
|
||||
pub cache_read_input_tokens: u64,
|
||||
pub cache_creation_input_tokens: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(transparent)]
|
||||
pub struct AuthProviderId(pub String);
|
||||
|
||||
impl AuthProviderId {
|
||||
pub fn new(value: impl Into<String>) -> Self {
|
||||
Self(value.into())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
self.0.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(transparent)]
|
||||
pub struct RuntimeProviderKey(pub String);
|
||||
|
||||
impl RuntimeProviderKey {
|
||||
pub fn new(value: impl Into<String>) -> Self {
|
||||
Self(value.into())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
self.0.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(transparent)]
|
||||
pub struct CatalogNamespace(pub String);
|
||||
|
||||
impl CatalogNamespace {
|
||||
pub fn new(value: impl Into<String>) -> Self {
|
||||
Self(value.into())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
self.0.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AuthCredentialSource {
|
||||
ApiKeyFile,
|
||||
ProcessEnv,
|
||||
OAuthTokenStore,
|
||||
ExternalImport,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AuthMethod {
|
||||
TuiPasteApiKey,
|
||||
RemoteTuiPasteApiKey,
|
||||
CliLogin,
|
||||
EnvFilePreseeded,
|
||||
ProcessEnvPreseeded,
|
||||
OAuthBrowser,
|
||||
DeviceCode,
|
||||
ExternalImport,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct AuthChanged {
|
||||
pub provider: AuthProviderId,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub credential_source: Option<AuthCredentialSource>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub auth_method: Option<AuthMethod>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub expected_runtime: Option<RuntimeProviderKey>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub expected_catalog_namespace: Option<CatalogNamespace>,
|
||||
}
|
||||
|
||||
impl AuthChanged {
|
||||
pub fn new(provider: impl Into<String>) -> Self {
|
||||
Self {
|
||||
provider: AuthProviderId::new(provider),
|
||||
credential_source: None,
|
||||
auth_method: None,
|
||||
expected_runtime: None,
|
||||
expected_catalog_namespace: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type ReloadRecoverySnapshot = jcode_selfdev_types::ReloadRecoveryDirective;
|
||||
|
||||
mod wire;
|
||||
pub use wire::TaskGraphNodeSpec;
|
||||
pub use wire::{Request, ServerEvent};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCallSummary {
|
||||
pub tool_name: String,
|
||||
pub brief_output: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub timestamp_secs: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SwarmChannelInfo {
|
||||
pub channel: String,
|
||||
pub member_count: usize,
|
||||
}
|
||||
|
||||
/// A shared context entry
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ContextEntry {
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
pub from_session: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub from_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Info about an agent
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct AgentInfo {
|
||||
pub session_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub friendly_name: Option<String>,
|
||||
/// Files this agent has touched
|
||||
pub files_touched: Vec<String>,
|
||||
/// Current lifecycle status (ready, running, completed, failed, stopped, etc.)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<String>,
|
||||
/// Optional status detail (current task, error, etc.)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub detail: Option<String>,
|
||||
/// Stable label of the task/role this member was spawned or assigned for.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub task_label: Option<String>,
|
||||
/// Role: "agent" or "coordinator"
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub role: Option<String>,
|
||||
/// Whether this member is a headless spawned session.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub is_headless: Option<bool>,
|
||||
/// Session that owns report-back/cleanup responsibility for this member.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub report_back_to_session_id: Option<String>,
|
||||
/// Latest structured completion report submitted by this member, if any.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub latest_completion_report: Option<String>,
|
||||
/// Number of currently attached live client connections.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub live_attachments: Option<usize>,
|
||||
/// Seconds since the last status change.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub status_age_secs: Option<u64>,
|
||||
/// Seconds since the last observed activity (token usage, turn start,
|
||||
/// tool events, or swarm task heartbeats). Unlike `status_age_secs`,
|
||||
/// which measures the last lifecycle transition, this reflects whether
|
||||
/// the agent is actually doing work right now.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_activity_age_secs: Option<u64>,
|
||||
/// Live activity (whether processing + current tool name).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub activity: Option<SessionActivitySnapshot>,
|
||||
/// Provider name (e.g. "anthropic").
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub provider_name: Option<String>,
|
||||
/// Provider model id.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub provider_model: Option<String>,
|
||||
/// Number of turns the agent has run this session.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub turn_count: Option<u64>,
|
||||
/// Tokens churned (total, including cache) within the recent lookback window.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub recent_total_tokens: Option<u64>,
|
||||
/// Output tokens produced within the recent lookback window.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub recent_output_tokens: Option<u64>,
|
||||
/// Width of the recent-token lookback window, in seconds.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub recent_window_secs: Option<u64>,
|
||||
/// Cumulative total tokens observed for the session lifetime.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cumulative_total_tokens: Option<u64>,
|
||||
/// Number of completed todos for this agent's session.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub todos_completed: Option<usize>,
|
||||
/// Total number of todos for this agent's session.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub todos_total: Option<usize>,
|
||||
}
|
||||
|
||||
/// Lightweight status snapshot for a swarm member.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentStatusSnapshot {
|
||||
pub session_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub friendly_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub swarm_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub detail: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub role: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub is_headless: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub live_attachments: Option<usize>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub status_age_secs: Option<u64>,
|
||||
/// Seconds since the last observed activity (tokens, turns, tool events,
|
||||
/// or swarm task heartbeats), independent of lifecycle transitions.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_activity_age_secs: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub joined_age_secs: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub files_touched: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub activity: Option<SessionActivitySnapshot>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub provider_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub provider_model: Option<String>,
|
||||
}
|
||||
|
||||
/// Lightweight swarm plan graph summary for planner-friendly reads.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct PlanGraphStatus {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub swarm_id: Option<String>,
|
||||
pub version: u64,
|
||||
pub item_count: usize,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub ready_ids: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub blocked_ids: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub active_ids: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub completed_ids: Vec<String>,
|
||||
/// Terminal without completing: failed, stopped, or crashed items. A plan
|
||||
/// whose run "finished" with entries here did not finish cleanly, so
|
||||
/// schedulers and reports must surface these instead of reading the
|
||||
/// terminal state as success.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub failed_ids: Vec<String>,
|
||||
/// Recorded failure reason per failed item id (from the durable task
|
||||
/// progress checkpoint, e.g. "task failed: Anthropic API error (401
|
||||
/// Unauthorized)"). Lets `plan_status` and schedulers explain *why* a node
|
||||
/// failed (and classify waves of credential failures) instead of only
|
||||
/// listing failed ids. Only failed items with a recorded reason appear.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub failed_reasons: BTreeMap<String, String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub cycle_ids: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub unresolved_dependency_ids: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub next_ready_ids: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub newly_ready_ids: Vec<String>,
|
||||
/// Completed (non-gate) items whose artifact self-reported LOW confidence.
|
||||
/// Shaky coverage the coordinator should widen with follow-up nodes; deep
|
||||
/// gates are also blocked from passing over these while unaddressed.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub low_confidence_ids: Vec<String>,
|
||||
/// Engine mode for this plan: "deep" (comprehensive, gated, wide fan-out) or
|
||||
/// "light" (cheap fan-out). Lets schedulers like `run_plan` pick a
|
||||
/// mode-appropriate concurrency policy. Defaults to "light" for legacy plans.
|
||||
#[serde(default = "default_plan_mode")]
|
||||
pub mode: String,
|
||||
/// Growth accounting: nodes from the initial seed (legacy/unknown origins
|
||||
/// count as seeded).
|
||||
#[serde(default)]
|
||||
pub seeded_count: usize,
|
||||
/// Growth accounting: machinery-generated nodes (expand children, gate-
|
||||
/// injected gaps, and the gates themselves). `seeded_count + grown_count ==
|
||||
/// item_count`. A deep plan with `grown_count == 0` never decomposed or
|
||||
/// gated anything, which almost always means under-exploration.
|
||||
#[serde(default)]
|
||||
pub grown_count: usize,
|
||||
}
|
||||
|
||||
fn default_plan_mode() -> String {
|
||||
"light".to_string()
|
||||
}
|
||||
|
||||
impl PlanGraphStatus {
|
||||
pub fn empty_for_swarm(swarm_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
swarm_id: Some(swarm_id.into()),
|
||||
version: 0,
|
||||
item_count: 0,
|
||||
ready_ids: Vec::new(),
|
||||
blocked_ids: Vec::new(),
|
||||
active_ids: Vec::new(),
|
||||
completed_ids: Vec::new(),
|
||||
failed_ids: Vec::new(),
|
||||
failed_reasons: BTreeMap::new(),
|
||||
cycle_ids: Vec::new(),
|
||||
unresolved_dependency_ids: Vec::new(),
|
||||
next_ready_ids: Vec::new(),
|
||||
newly_ready_ids: Vec::new(),
|
||||
low_confidence_ids: Vec::new(),
|
||||
mode: default_plan_mode(),
|
||||
seeded_count: 0,
|
||||
grown_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_versioned_plan(
|
||||
swarm_id: impl Into<String>,
|
||||
plan: &VersionedPlan,
|
||||
next_ready_limit: Option<usize>,
|
||||
newly_ready_ids: Vec<String>,
|
||||
) -> Self {
|
||||
let graph = summarize_plan_graph(&plan.items);
|
||||
let growth = jcode_plan::bridge::growth_stats(plan);
|
||||
let failed_reasons: BTreeMap<String, String> = graph
|
||||
.failed_ids
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
plan.task_progress
|
||||
.get(id)
|
||||
.and_then(|progress| progress.checkpoint_summary.clone())
|
||||
.map(|reason| (id.clone(), reason))
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
swarm_id: Some(swarm_id.into()),
|
||||
version: plan.version,
|
||||
item_count: plan.items.len(),
|
||||
ready_ids: graph.ready_ids,
|
||||
blocked_ids: graph.blocked_ids,
|
||||
active_ids: graph.active_ids,
|
||||
completed_ids: graph.completed_ids,
|
||||
failed_ids: graph.failed_ids,
|
||||
failed_reasons,
|
||||
cycle_ids: graph.cycle_ids,
|
||||
unresolved_dependency_ids: graph.unresolved_dependency_ids,
|
||||
next_ready_ids: next_runnable_item_ids(&plan.items, next_ready_limit),
|
||||
newly_ready_ids,
|
||||
low_confidence_ids: jcode_plan::bridge::low_confidence_completed_ids(plan),
|
||||
mode: plan.mode.clone(),
|
||||
seeded_count: growth.seeded,
|
||||
grown_count: growth.grown(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Swarm member status for lifecycle updates
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SwarmMemberStatus {
|
||||
pub session_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub friendly_name: Option<String>,
|
||||
/// Lifecycle status (ready, running, completed, failed, stopped, etc.)
|
||||
pub status: String,
|
||||
/// Optional detail (task, error, etc.)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub detail: Option<String>,
|
||||
/// Stable label of the task/role this member was spawned or assigned for.
|
||||
/// Unlike `detail`, it is not overwritten by transient status updates.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub task_label: Option<String>,
|
||||
/// Role: "agent" or "coordinator"
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub role: Option<String>,
|
||||
/// Whether this member is a headless spawned session.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub is_headless: Option<bool>,
|
||||
/// Number of currently attached live client connections.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub live_attachments: Option<usize>,
|
||||
/// Seconds since the last status change.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub status_age_secs: Option<u64>,
|
||||
/// Recent streamed output tail for live inline rendering (last few lines of
|
||||
/// the agent's in-progress assistant text). Only populated for swarm
|
||||
/// members when inline streaming taps are active.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub output_tail: Option<String>,
|
||||
/// Session id this member reports back to (its spawner/parent in the swarm
|
||||
/// tree). Walking this chain reconstructs the spawn tree, which lets a
|
||||
/// client scope the inline gallery to the subtree it actually spawned.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub report_back_to_session_id: Option<String>,
|
||||
/// Todo/plan progress as (completed, total) for this member, when known.
|
||||
/// Surfaced on the inline swarm strip as a compact "C/T" counter.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub todo_progress: Option<(u32, u32)>,
|
||||
/// Compact snapshot of this member's todo list (content + status), capped
|
||||
/// by the producer. Rendered in the focused inline swarm panel so the
|
||||
/// coordinator can see what each agent is working through.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub todo_items: Vec<SwarmTodoItem>,
|
||||
/// Ephemeral runtime metadata used by the live swarm card.
|
||||
#[serde(default, skip_serializing_if = "SwarmMemberRuntime::is_empty")]
|
||||
pub runtime: SwarmMemberRuntime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SwarmMemberRuntime {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub provider: Option<String>,
|
||||
/// Human-facing credential route, such as "OAuth" or "API key".
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub auth_method: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub effort: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub elapsed_secs: Option<u64>,
|
||||
}
|
||||
|
||||
impl SwarmMemberRuntime {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.model.is_none()
|
||||
&& self.provider.is_none()
|
||||
&& self.auth_method.is_none()
|
||||
&& self.effort.is_none()
|
||||
&& self.elapsed_secs.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// One compact todo entry crossing the swarm status boundary. Only the
|
||||
/// display essentials travel; full todo metadata stays in the owning session.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SwarmTodoItem {
|
||||
pub content: String,
|
||||
/// "pending", "in_progress", or "completed".
|
||||
pub status: String,
|
||||
/// The three most recent tool calls made while this todo was active.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub tool_intents: Vec<SwarmToolIntent>,
|
||||
}
|
||||
|
||||
/// Display-only tool activity nested beneath an active swarm todo.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SwarmToolIntent {
|
||||
/// Internal correlation key used by the server to update a running call.
|
||||
/// It is intentionally omitted from the wire payload.
|
||||
#[serde(default, skip_serializing)]
|
||||
pub tool_call_id: String,
|
||||
pub tool_name: String,
|
||||
pub intent: String,
|
||||
/// "running", "completed", or "error".
|
||||
pub status: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub progress: Option<SwarmToolProgress>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SwarmToolProgress {
|
||||
pub current: u64,
|
||||
pub total: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub unit: Option<String>,
|
||||
}
|
||||
|
||||
/// Status of a member being awaited by comm_await_members
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AwaitedMemberStatus {
|
||||
pub session_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub friendly_name: Option<String>,
|
||||
pub status: String,
|
||||
/// Whether this member reached the target status
|
||||
pub done: bool,
|
||||
/// Latest structured completion report submitted by this member, if any.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub completion_report: Option<String>,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
pub fn id(&self) -> u64 {
|
||||
match self {
|
||||
Request::Message { id, .. } => *id,
|
||||
Request::Cancel { id } => *id,
|
||||
Request::BackgroundTool { id } => *id,
|
||||
Request::SoftInterrupt { id, .. } => *id,
|
||||
Request::CancelSoftInterrupts { id } => *id,
|
||||
Request::Clear { id } => *id,
|
||||
Request::Rewind { id, .. } => *id,
|
||||
Request::RewindUndo { id } => *id,
|
||||
Request::Ping { id } => *id,
|
||||
Request::GetState { id } => *id,
|
||||
Request::DebugCommand { id, .. } => *id,
|
||||
Request::ClientDebugCommand { id, .. } => *id,
|
||||
Request::ClientDebugResponse { id, .. } => *id,
|
||||
Request::Subscribe { id, .. } => *id,
|
||||
Request::GetHistory { id } => *id,
|
||||
Request::GetModelCatalog { id } => *id,
|
||||
Request::GetCompactedHistory { id, .. } => *id,
|
||||
Request::Reload { id, .. } => *id,
|
||||
Request::ResumeSession { id, .. } => *id,
|
||||
Request::ResumeAllSessions { id } => *id,
|
||||
Request::NotifySession { id, .. } => *id,
|
||||
Request::Transcript { id, .. } => *id,
|
||||
Request::InputShell { id, .. } => *id,
|
||||
Request::CycleModel { id, .. } => *id,
|
||||
Request::RefreshModels { id } => *id,
|
||||
Request::SetModel { id, .. } => *id,
|
||||
Request::SetRoute { id, .. } => *id,
|
||||
Request::SetSubagentModel { id, .. } => *id,
|
||||
Request::RunSubagent { id, .. } => *id,
|
||||
Request::SetReasoningEffort { id, .. } => *id,
|
||||
Request::SetServiceTier { id, .. } => *id,
|
||||
Request::SetTransport { id, .. } => *id,
|
||||
Request::SetPremiumMode { id, .. } => *id,
|
||||
Request::SetFeature { id, .. } => *id,
|
||||
Request::SetCompactionMode { id, .. } => *id,
|
||||
Request::RenameSession { id, .. } => *id,
|
||||
Request::Split { id } => *id,
|
||||
Request::Transfer { id } => *id,
|
||||
Request::Compact { id } => *id,
|
||||
Request::TriggerMemoryExtraction { id } => *id,
|
||||
Request::NotifyAuthChanged { id, .. } => *id,
|
||||
Request::SwitchAnthropicAccount { id, .. } => *id,
|
||||
Request::SwitchOpenAiAccount { id, .. } => *id,
|
||||
Request::StdinResponse { id, .. } => *id,
|
||||
Request::AgentRegister { id, .. } => *id,
|
||||
Request::AgentTask { id, .. } => *id,
|
||||
Request::AgentCapabilities { id } => *id,
|
||||
Request::AgentContext { id } => *id,
|
||||
Request::CommShare { id, .. } => *id,
|
||||
Request::CommRead { id, .. } => *id,
|
||||
Request::CommMessage { id, .. } => *id,
|
||||
Request::CommList { id, .. } => *id,
|
||||
Request::CommListChannels { id, .. } => *id,
|
||||
Request::CommChannelMembers { id, .. } => *id,
|
||||
Request::CommProposePlan { id, .. } => *id,
|
||||
Request::CommApprovePlan { id, .. } => *id,
|
||||
Request::CommRejectPlan { id, .. } => *id,
|
||||
Request::CommSeedGraph { id, .. } => *id,
|
||||
Request::CommExpandNode { id, .. } => *id,
|
||||
Request::CommCompleteNode { id, .. } => *id,
|
||||
Request::CommInjectGap { id, .. } => *id,
|
||||
Request::CommSpawn { id, .. } => *id,
|
||||
Request::CommListModels { id, .. } => *id,
|
||||
Request::CommStop { id, .. } => *id,
|
||||
Request::CommAssignRole { id, .. } => *id,
|
||||
Request::CommSummary { id, .. } => *id,
|
||||
Request::CommStatus { id, .. } => *id,
|
||||
Request::CommReport { id, .. } => *id,
|
||||
Request::CommReadContext { id, .. } => *id,
|
||||
Request::CommResyncPlan { id, .. } => *id,
|
||||
Request::CommPlanStatus { id, .. } => *id,
|
||||
Request::CommAssignTask { id, .. } => *id,
|
||||
Request::CommAssignNext { id, .. } => *id,
|
||||
Request::CommTaskControl { id, .. } => *id,
|
||||
Request::CommSubscribeChannel { id, .. } => *id,
|
||||
Request::CommUnsubscribeChannel { id, .. } => *id,
|
||||
Request::CommAwaitMembers { id, .. } => *id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_lightweight_control_request(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Request::Ping { .. }
|
||||
| Request::CommShare { .. }
|
||||
| Request::CommRead { .. }
|
||||
| Request::CommMessage { .. }
|
||||
| Request::CommList { .. }
|
||||
| Request::CommListChannels { .. }
|
||||
| Request::CommChannelMembers { .. }
|
||||
| Request::CommProposePlan { .. }
|
||||
| Request::CommApprovePlan { .. }
|
||||
| Request::CommRejectPlan { .. }
|
||||
| Request::CommSeedGraph { .. }
|
||||
| Request::CommExpandNode { .. }
|
||||
| Request::CommCompleteNode { .. }
|
||||
| Request::CommInjectGap { .. }
|
||||
| Request::CommSpawn { .. }
|
||||
| Request::CommListModels { .. }
|
||||
| Request::CommStop { .. }
|
||||
| Request::CommAssignRole { .. }
|
||||
| Request::CommSummary { .. }
|
||||
| Request::CommStatus { .. }
|
||||
| Request::CommReport { .. }
|
||||
| Request::CommPlanStatus { .. }
|
||||
| Request::CommReadContext { .. }
|
||||
| Request::CommResyncPlan { .. }
|
||||
| Request::CommAssignTask { .. }
|
||||
| Request::CommAssignNext { .. }
|
||||
| Request::CommTaskControl { .. }
|
||||
| Request::CommSubscribeChannel { .. }
|
||||
| Request::CommUnsubscribeChannel { .. }
|
||||
| Request::CommAwaitMembers { .. }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn default_model_direction() -> i8 {
|
||||
1
|
||||
}
|
||||
|
||||
/// Encode an event as a newline-terminated JSON string
|
||||
pub fn encode_event(event: &ServerEvent) -> String {
|
||||
let mut json = serde_json::to_string(event).unwrap_or_else(|_| "{}".to_string());
|
||||
json.push('\n');
|
||||
json
|
||||
}
|
||||
|
||||
/// Decode a request from a JSON string.
|
||||
///
|
||||
/// Handles a legacy/desktop compatibility shape where a model switch was sent as
|
||||
/// `{"type":"set_route","model":"..."}` (a bare model string under the
|
||||
/// `set_route` tag). The current protocol reserves the `set_route` tag for the
|
||||
/// structured [`Request::SetRoute`] variant (which carries a `selection`
|
||||
/// object), so this older shape is normalized into [`Request::SetModel`] here
|
||||
/// instead of via a serde `alias`. Using an alias would make `SetModel` also
|
||||
/// claim the `set_route` tag and, because serde dispatches internally-tagged
|
||||
/// enums by tag rather than by fields, shadow the structured variant entirely
|
||||
/// (every real route switch would then fail with `missing field \`model\``).
|
||||
pub fn decode_request(line: &str) -> Result<Request, serde_json::Error> {
|
||||
match serde_json::from_str::<Request>(line) {
|
||||
Ok(request) => Ok(request),
|
||||
Err(error) => {
|
||||
if let Some(request) = decode_legacy_set_route_model(line) {
|
||||
Ok(request)
|
||||
} else {
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recognize the legacy `{"type":"set_route","id":N,"model":"..."}` shape and
|
||||
/// translate it into [`Request::SetModel`]. Returns `None` for anything else
|
||||
/// (including the current structured `set_route` payload that carries a
|
||||
/// `selection` object) so the original decode error is surfaced unchanged.
|
||||
fn decode_legacy_set_route_model(line: &str) -> Option<Request> {
|
||||
let value: serde_json::Value = serde_json::from_str(line).ok()?;
|
||||
let obj = value.as_object()?;
|
||||
if obj.get("type")?.as_str()? != "set_route" {
|
||||
return None;
|
||||
}
|
||||
// The structured route switch carries `selection`; never reinterpret it.
|
||||
if obj.contains_key("selection") {
|
||||
return None;
|
||||
}
|
||||
let model = obj.get("model")?.as_str()?.to_string();
|
||||
let id = obj.get("id").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
Some(Request::SetModel { id, model })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "protocol_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,48 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Type of notification from another agent
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum NotificationType {
|
||||
/// Another agent touched a file you've worked with
|
||||
#[serde(rename = "file_conflict")]
|
||||
FileConflict {
|
||||
path: String,
|
||||
/// What the other agent did: "read", "wrote", "edited"
|
||||
operation: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
intent: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
summary: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
detail: Option<String>,
|
||||
},
|
||||
/// Another agent shared context
|
||||
#[serde(rename = "shared_context")]
|
||||
SharedContext { key: String, value: String },
|
||||
/// Direct message from another agent
|
||||
#[serde(rename = "message")]
|
||||
Message {
|
||||
/// Message scope: "dm", "channel", or "broadcast"
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
scope: Option<String>,
|
||||
/// Channel name for channel messages (e.g. "parser")
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
channel: Option<String>,
|
||||
/// Sender-provided one-line summary of the message. Receiving UIs
|
||||
/// render this collapsed with an expand control instead of the full
|
||||
/// body. Populated from the `tldr` field of swarm sends/reports.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
tldr: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Runtime feature names that can be toggled per session
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum FeatureToggle {
|
||||
Memory,
|
||||
Swarm,
|
||||
Autoreview,
|
||||
Autojudge,
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MemoryStateSnapshot {
|
||||
Idle,
|
||||
Embedding,
|
||||
SidecarChecking { count: usize },
|
||||
FoundRelevant { count: usize },
|
||||
Extracting { reason: String },
|
||||
Maintaining { phase: String },
|
||||
ToolAction { action: String, detail: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MemoryStepStatusSnapshot {
|
||||
Pending,
|
||||
Running,
|
||||
Done,
|
||||
Error,
|
||||
Skipped,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct MemoryStepResultSnapshot {
|
||||
pub summary: String,
|
||||
pub latency_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct MemoryPipelineSnapshot {
|
||||
pub search: MemoryStepStatusSnapshot,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub search_result: Option<MemoryStepResultSnapshot>,
|
||||
pub verify: MemoryStepStatusSnapshot,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub verify_result: Option<MemoryStepResultSnapshot>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub verify_progress: Option<(usize, usize)>,
|
||||
pub inject: MemoryStepStatusSnapshot,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub inject_result: Option<MemoryStepResultSnapshot>,
|
||||
pub maintain: MemoryStepStatusSnapshot,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub maintain_result: Option<MemoryStepResultSnapshot>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct MemoryActivitySnapshot {
|
||||
pub state: MemoryStateSnapshot,
|
||||
#[serde(default)]
|
||||
pub state_age_ms: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pipeline: Option<MemoryPipelineSnapshot>,
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
use super::*;
|
||||
use anyhow::{Result, anyhow};
|
||||
|
||||
fn parse_request_json(json: &str) -> Result<Request> {
|
||||
serde_json::from_str(json).map_err(Into::into)
|
||||
}
|
||||
|
||||
fn parse_event_json(json: &str) -> Result<ServerEvent> {
|
||||
serde_json::from_str(json).map_err(Into::into)
|
||||
}
|
||||
|
||||
include!("protocol_tests/core_events.rs");
|
||||
include!("protocol_tests/comm_requests.rs");
|
||||
include!("protocol_tests/comm_responses.rs");
|
||||
include!("protocol_tests/comm_format_awaited.rs");
|
||||
include!("protocol_tests/misc_events.rs");
|
||||
include!("protocol_tests/randomized.rs");
|
||||
@@ -0,0 +1,55 @@
|
||||
fn awaited_member(session_id: &str, done: bool) -> AwaitedMemberStatus {
|
||||
AwaitedMemberStatus {
|
||||
session_id: session_id.to_string(),
|
||||
friendly_name: Some(session_id.to_string()),
|
||||
status: if done { "completed" } else { "running" }.to_string(),
|
||||
done,
|
||||
completion_report: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn awaited_members_header_all_done() {
|
||||
let members = vec![awaited_member("fox", true), awaited_member("wolf", true)];
|
||||
let output = format_comm_awaited_members_with_reports(
|
||||
true,
|
||||
"All 2 members are done: fox, wolf",
|
||||
&members,
|
||||
&std::collections::HashMap::new(),
|
||||
);
|
||||
assert!(
|
||||
output.starts_with("All members done."),
|
||||
"expected all-done header, got: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn awaited_members_header_any_mode_partial_match() {
|
||||
let members = vec![awaited_member("fox", true), awaited_member("wolf", false)];
|
||||
let output = format_comm_awaited_members_with_reports(
|
||||
true,
|
||||
"Matched 1 member: fox",
|
||||
&members,
|
||||
&std::collections::HashMap::new(),
|
||||
);
|
||||
assert!(
|
||||
output.starts_with("Await satisfied."),
|
||||
"any-mode partial match must not claim all members are done, got: {output}"
|
||||
);
|
||||
assert!(!output.starts_with("All members done."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn awaited_members_header_incomplete() {
|
||||
let members = vec![awaited_member("fox", false)];
|
||||
let output = format_comm_awaited_members_with_reports(
|
||||
false,
|
||||
"Timed out. Still waiting on: fox (running)",
|
||||
&members,
|
||||
&std::collections::HashMap::new(),
|
||||
);
|
||||
assert!(
|
||||
output.starts_with("Await incomplete."),
|
||||
"expected incomplete header, got: {output}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
#[test]
|
||||
fn test_comm_propose_plan_roundtrip() -> Result<()> {
|
||||
let req = Request::CommProposePlan {
|
||||
id: 42,
|
||||
session_id: "sess_a".to_string(),
|
||||
items: vec![PlanItem {
|
||||
content: "Refactor parser".to_string(),
|
||||
status: "pending".to_string(),
|
||||
priority: "high".to_string(),
|
||||
id: "p1".to_string(),
|
||||
subsystem: None,
|
||||
file_scope: Vec::new(),
|
||||
blocked_by: vec!["p0".to_string()],
|
||||
assigned_to: Some("sess_b".to_string()),
|
||||
}],
|
||||
};
|
||||
let json = serde_json::to_string(&req)?;
|
||||
let decoded = parse_request_json(&json)?;
|
||||
assert_eq!(decoded.id(), 42);
|
||||
let Request::CommProposePlan { items, .. } = decoded else {
|
||||
return Err(anyhow!("wrong request type"));
|
||||
};
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].id, "p1");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stdin_response_roundtrip() -> Result<()> {
|
||||
let req = Request::StdinResponse {
|
||||
id: 99,
|
||||
request_id: "stdin-call_abc-1".to_string(),
|
||||
input: "my_password".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&req)?;
|
||||
assert!(json.contains("\"type\":\"stdin_response\""));
|
||||
assert!(json.contains("\"request_id\":\"stdin-call_abc-1\""));
|
||||
assert!(json.contains("\"input\":\"my_password\""));
|
||||
|
||||
let decoded = parse_request_json(&json)?;
|
||||
assert_eq!(decoded.id(), 99);
|
||||
let Request::StdinResponse {
|
||||
request_id, input, ..
|
||||
} = decoded
|
||||
else {
|
||||
return Err(anyhow!("expected StdinResponse"));
|
||||
};
|
||||
assert_eq!(request_id, "stdin-call_abc-1");
|
||||
assert_eq!(input, "my_password");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stdin_response_deserialize_from_json() -> Result<()> {
|
||||
let json = r#"{"type":"stdin_response","id":5,"request_id":"req-42","input":"hello world"}"#;
|
||||
let decoded = parse_request_json(json)?;
|
||||
assert_eq!(decoded.id(), 5);
|
||||
let Request::StdinResponse {
|
||||
request_id, input, ..
|
||||
} = decoded
|
||||
else {
|
||||
return Err(anyhow!("expected StdinResponse"));
|
||||
};
|
||||
assert_eq!(request_id, "req-42");
|
||||
assert_eq!(input, "hello world");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stdin_request_event_roundtrip() -> Result<()> {
|
||||
let event = ServerEvent::StdinRequest {
|
||||
request_id: "stdin-xyz-1".to_string(),
|
||||
prompt: "Password: ".to_string(),
|
||||
is_password: true,
|
||||
tool_call_id: "call_abc".to_string(),
|
||||
};
|
||||
let json = encode_event(&event);
|
||||
assert!(json.contains("\"type\":\"stdin_request\""));
|
||||
assert!(json.contains("\"is_password\":true"));
|
||||
|
||||
let decoded = parse_event_json(json.trim())?;
|
||||
let ServerEvent::StdinRequest {
|
||||
request_id,
|
||||
prompt,
|
||||
is_password,
|
||||
tool_call_id,
|
||||
} = decoded
|
||||
else {
|
||||
return Err(anyhow!("expected StdinRequest"));
|
||||
};
|
||||
assert_eq!(request_id, "stdin-xyz-1");
|
||||
assert_eq!(prompt, "Password: ");
|
||||
assert!(is_password);
|
||||
assert_eq!(tool_call_id, "call_abc");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stdin_request_event_defaults() -> Result<()> {
|
||||
// is_password defaults to false when not present
|
||||
let json = r#"{"type":"stdin_request","request_id":"r1","prompt":"","tool_call_id":"tc1"}"#;
|
||||
let decoded = parse_event_json(json)?;
|
||||
let ServerEvent::StdinRequest { is_password, .. } = decoded else {
|
||||
return Err(anyhow!("expected StdinRequest"));
|
||||
};
|
||||
assert!(!is_password, "is_password should default to false");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_comm_await_members_roundtrip() -> Result<()> {
|
||||
let req = Request::CommAwaitMembers {
|
||||
id: 55,
|
||||
session_id: "sess_waiter".to_string(),
|
||||
target_status: vec!["completed".to_string(), "stopped".to_string()],
|
||||
session_ids: vec!["sess_a".to_string(), "sess_b".to_string()],
|
||||
mode: Some("any".to_string()),
|
||||
timeout_secs: Some(120),
|
||||
background: false,
|
||||
notify: false,
|
||||
wake: false,
|
||||
};
|
||||
let json = serde_json::to_string(&req)?;
|
||||
assert!(json.contains("\"type\":\"comm_await_members\""));
|
||||
let decoded = parse_request_json(&json)?;
|
||||
assert_eq!(decoded.id(), 55);
|
||||
let Request::CommAwaitMembers {
|
||||
session_id,
|
||||
target_status,
|
||||
session_ids,
|
||||
mode,
|
||||
timeout_secs,
|
||||
background,
|
||||
notify,
|
||||
wake,
|
||||
..
|
||||
} = decoded
|
||||
else {
|
||||
return Err(anyhow!("expected CommAwaitMembers"));
|
||||
};
|
||||
assert_eq!(session_id, "sess_waiter");
|
||||
assert_eq!(target_status, vec!["completed", "stopped"]);
|
||||
assert_eq!(session_ids, vec!["sess_a", "sess_b"]);
|
||||
assert_eq!(mode.as_deref(), Some("any"));
|
||||
assert_eq!(timeout_secs, Some(120));
|
||||
assert!(!background);
|
||||
assert!(!notify);
|
||||
assert!(!wake);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_comm_await_members_defaults() -> Result<()> {
|
||||
let json =
|
||||
r#"{"type":"comm_await_members","id":1,"session_id":"s1","target_status":["completed"]}"#;
|
||||
let decoded = parse_request_json(json)?;
|
||||
let Request::CommAwaitMembers {
|
||||
session_ids,
|
||||
mode,
|
||||
timeout_secs,
|
||||
background,
|
||||
notify,
|
||||
wake,
|
||||
..
|
||||
} = decoded
|
||||
else {
|
||||
return Err(anyhow!("expected CommAwaitMembers"));
|
||||
};
|
||||
assert!(
|
||||
session_ids.is_empty(),
|
||||
"session_ids should default to empty"
|
||||
);
|
||||
assert_eq!(mode, None, "mode should default to None");
|
||||
assert_eq!(timeout_secs, None, "timeout_secs should default to None");
|
||||
assert!(background, "background should default to true");
|
||||
assert!(notify, "notify should default to true");
|
||||
assert!(wake, "wake should default to true");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_comm_report_roundtrip() -> Result<()> {
|
||||
let req = Request::CommReport {
|
||||
id: 57,
|
||||
session_id: "sess_worker".to_string(),
|
||||
status: Some("ready".to_string()),
|
||||
message: "Implemented report action.".to_string(),
|
||||
validation: Some("Focused tests passed.".to_string()),
|
||||
follow_up: Some("None.".to_string()),
|
||||
tldr: None,
|
||||
};
|
||||
let json = serde_json::to_string(&req)?;
|
||||
assert!(json.contains("\"type\":\"comm_report\""));
|
||||
let decoded = parse_request_json(&json)?;
|
||||
assert_eq!(decoded.id(), 57);
|
||||
let Request::CommReport {
|
||||
session_id,
|
||||
status,
|
||||
message,
|
||||
validation,
|
||||
follow_up,
|
||||
..
|
||||
} = decoded
|
||||
else {
|
||||
return Err(anyhow!("expected CommReport"));
|
||||
};
|
||||
assert_eq!(session_id, "sess_worker");
|
||||
assert_eq!(status.as_deref(), Some("ready"));
|
||||
assert_eq!(message, "Implemented report action.");
|
||||
assert_eq!(validation.as_deref(), Some("Focused tests passed."));
|
||||
assert_eq!(follow_up.as_deref(), Some("None."));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_comm_report_response_roundtrip() -> Result<()> {
|
||||
let event = ServerEvent::CommReportResponse {
|
||||
id: 57,
|
||||
status: "ready".to_string(),
|
||||
message: "Report recorded.".to_string(),
|
||||
};
|
||||
let json = encode_event(&event);
|
||||
assert!(json.contains("\"type\":\"comm_report_response\""));
|
||||
let decoded = parse_event_json(json.trim())?;
|
||||
let ServerEvent::CommReportResponse {
|
||||
id,
|
||||
status,
|
||||
message,
|
||||
} = decoded
|
||||
else {
|
||||
return Err(anyhow!("expected CommReportResponse"));
|
||||
};
|
||||
assert_eq!(id, 57);
|
||||
assert_eq!(status, "ready");
|
||||
assert_eq!(message, "Report recorded.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_comm_await_members_response_roundtrip() -> Result<()> {
|
||||
let event = ServerEvent::CommAwaitMembersResponse {
|
||||
id: 55,
|
||||
completed: true,
|
||||
members: vec![
|
||||
AwaitedMemberStatus {
|
||||
session_id: "sess_a".to_string(),
|
||||
friendly_name: Some("fox".to_string()),
|
||||
status: "completed".to_string(),
|
||||
done: true,
|
||||
completion_report: None,
|
||||
},
|
||||
AwaitedMemberStatus {
|
||||
session_id: "sess_b".to_string(),
|
||||
friendly_name: Some("wolf".to_string()),
|
||||
status: "stopped".to_string(),
|
||||
done: true,
|
||||
completion_report: None,
|
||||
},
|
||||
],
|
||||
summary: "All 2 members are done: fox, wolf".to_string(),
|
||||
background_started: false,
|
||||
};
|
||||
let json = encode_event(&event);
|
||||
assert!(json.contains("\"type\":\"comm_await_members_response\""));
|
||||
let decoded = parse_event_json(json.trim())?;
|
||||
let ServerEvent::CommAwaitMembersResponse {
|
||||
id,
|
||||
completed,
|
||||
members,
|
||||
summary,
|
||||
..
|
||||
} = decoded
|
||||
else {
|
||||
return Err(anyhow!("expected CommAwaitMembersResponse"));
|
||||
};
|
||||
assert_eq!(id, 55);
|
||||
assert!(completed);
|
||||
assert_eq!(members.len(), 2);
|
||||
assert_eq!(members[0].friendly_name.as_deref(), Some("fox"));
|
||||
assert!(members[0].done);
|
||||
assert_eq!(members[1].status, "stopped");
|
||||
assert!(summary.contains("fox"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_comm_task_control_roundtrip() -> Result<()> {
|
||||
let req = Request::CommTaskControl {
|
||||
id: 58,
|
||||
session_id: "sess_coord".to_string(),
|
||||
action: "salvage".to_string(),
|
||||
task_id: "task_42".to_string(),
|
||||
target_session: Some("sess_replacement".to_string()),
|
||||
message: Some("Recover partial progress first.".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&req)?;
|
||||
assert!(json.contains("\"type\":\"comm_task_control\""));
|
||||
let decoded = parse_request_json(&json)?;
|
||||
assert_eq!(decoded.id(), 58);
|
||||
let Request::CommTaskControl {
|
||||
session_id,
|
||||
action,
|
||||
task_id,
|
||||
target_session,
|
||||
message,
|
||||
..
|
||||
} = decoded
|
||||
else {
|
||||
return Err(anyhow!("expected CommTaskControl"));
|
||||
};
|
||||
assert_eq!(session_id, "sess_coord");
|
||||
assert_eq!(action, "salvage");
|
||||
assert_eq!(task_id, "task_42");
|
||||
assert_eq!(target_session.as_deref(), Some("sess_replacement"));
|
||||
assert_eq!(message.as_deref(), Some("Recover partial progress first."));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_comm_assign_task_roundtrip_without_explicit_task_id() -> Result<()> {
|
||||
let req = Request::CommAssignTask {
|
||||
id: 57,
|
||||
session_id: "sess_coord".to_string(),
|
||||
target_session: None,
|
||||
task_id: None,
|
||||
message: Some("Take the next highest-priority runnable task.".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&req)?;
|
||||
assert!(json.contains("\"type\":\"comm_assign_task\""));
|
||||
assert!(!json.contains("\"task_id\""));
|
||||
let decoded = parse_request_json(&json)?;
|
||||
assert_eq!(decoded.id(), 57);
|
||||
let Request::CommAssignTask {
|
||||
session_id,
|
||||
target_session,
|
||||
task_id,
|
||||
message,
|
||||
..
|
||||
} = decoded
|
||||
else {
|
||||
return Err(anyhow!("expected CommAssignTask"));
|
||||
};
|
||||
assert_eq!(session_id, "sess_coord");
|
||||
assert_eq!(target_session, None);
|
||||
assert_eq!(task_id, None);
|
||||
assert_eq!(
|
||||
message.as_deref(),
|
||||
Some("Take the next highest-priority runnable task.")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_comm_assign_task_response_roundtrip() -> Result<()> {
|
||||
let event = ServerEvent::CommAssignTaskResponse {
|
||||
id: 60,
|
||||
task_id: "task-7".to_string(),
|
||||
target_session: "sess_worker".to_string(),
|
||||
};
|
||||
let json = encode_event(&event);
|
||||
assert!(json.contains("\"type\":\"comm_assign_task_response\""));
|
||||
let decoded = parse_event_json(json.trim())?;
|
||||
let ServerEvent::CommAssignTaskResponse {
|
||||
id,
|
||||
task_id,
|
||||
target_session,
|
||||
} = decoded
|
||||
else {
|
||||
return Err(anyhow!("expected CommAssignTaskResponse"));
|
||||
};
|
||||
assert_eq!(id, 60);
|
||||
assert_eq!(task_id, "task-7");
|
||||
assert_eq!(target_session, "sess_worker");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_comm_assign_next_roundtrip() -> Result<()> {
|
||||
let req = Request::CommAssignNext {
|
||||
id: 60,
|
||||
session_id: "sess_coord".to_string(),
|
||||
target_session: Some("sess_worker".to_string()),
|
||||
working_dir: Some("/tmp/project".to_string()),
|
||||
prefer_spawn: Some(true),
|
||||
spawn_if_needed: Some(true),
|
||||
message: Some("Take the next runnable task.".to_string()),
|
||||
model: Some("gpt-5.5".to_string()),
|
||||
effort: Some("low".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&req)?;
|
||||
assert!(json.contains("\"type\":\"comm_assign_next\""));
|
||||
let decoded = parse_request_json(&json)?;
|
||||
assert_eq!(decoded.id(), 60);
|
||||
let Request::CommAssignNext {
|
||||
session_id,
|
||||
target_session,
|
||||
working_dir,
|
||||
prefer_spawn,
|
||||
spawn_if_needed,
|
||||
message,
|
||||
model,
|
||||
effort,
|
||||
..
|
||||
} = decoded
|
||||
else {
|
||||
return Err(anyhow!("expected CommAssignNext"));
|
||||
};
|
||||
assert_eq!(session_id, "sess_coord");
|
||||
assert_eq!(target_session.as_deref(), Some("sess_worker"));
|
||||
assert_eq!(working_dir.as_deref(), Some("/tmp/project"));
|
||||
assert_eq!(prefer_spawn, Some(true));
|
||||
assert_eq!(spawn_if_needed, Some(true));
|
||||
assert_eq!(message.as_deref(), Some("Take the next runnable task."));
|
||||
assert_eq!(model.as_deref(), Some("gpt-5.5"));
|
||||
assert_eq!(effort.as_deref(), Some("low"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_comm_stop_roundtrip_with_force() -> Result<()> {
|
||||
let req = Request::CommStop {
|
||||
id: 61,
|
||||
session_id: "sess_coord".to_string(),
|
||||
target_session: "sess_worker".to_string(),
|
||||
force: Some(true),
|
||||
};
|
||||
let json = serde_json::to_string(&req)?;
|
||||
assert!(json.contains("\"type\":\"comm_stop\""));
|
||||
assert!(json.contains("\"force\":true"));
|
||||
let decoded = parse_request_json(&json)?;
|
||||
assert_eq!(decoded.id(), 61);
|
||||
let Request::CommStop {
|
||||
session_id,
|
||||
target_session,
|
||||
force,
|
||||
..
|
||||
} = decoded
|
||||
else {
|
||||
return Err(anyhow!("expected CommStop"));
|
||||
};
|
||||
assert_eq!(session_id, "sess_coord");
|
||||
assert_eq!(target_session, "sess_worker");
|
||||
assert_eq!(force, Some(true));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_comm_spawn_roundtrip_with_optional_nonce() -> Result<()> {
|
||||
let req = Request::CommSpawn {
|
||||
id: 59,
|
||||
session_id: "sess_coord".to_string(),
|
||||
working_dir: Some("/tmp/project".to_string()),
|
||||
initial_message: Some("Start here".to_string()),
|
||||
request_nonce: Some("planner-fresh-123".to_string()),
|
||||
spawn_mode: Some("headless".to_string()),
|
||||
model: Some("openai-api:gpt-5.5".to_string()),
|
||||
effort: Some("low".to_string()),
|
||||
label: Some("review auth flow".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&req)?;
|
||||
assert!(json.contains("\"type\":\"comm_spawn\""));
|
||||
assert!(json.contains("\"request_nonce\":\"planner-fresh-123\""));
|
||||
assert!(json.contains("\"spawn_mode\":\"headless\""));
|
||||
assert!(json.contains("\"model\":\"openai-api:gpt-5.5\""));
|
||||
assert!(json.contains("\"effort\":\"low\""));
|
||||
assert!(json.contains("\"label\":\"review auth flow\""));
|
||||
let decoded = parse_request_json(&json)?;
|
||||
assert_eq!(decoded.id(), 59);
|
||||
let Request::CommSpawn {
|
||||
session_id,
|
||||
working_dir,
|
||||
initial_message,
|
||||
request_nonce,
|
||||
spawn_mode,
|
||||
model,
|
||||
effort,
|
||||
label,
|
||||
..
|
||||
} = decoded
|
||||
else {
|
||||
return Err(anyhow!("expected CommSpawn"));
|
||||
};
|
||||
assert_eq!(session_id, "sess_coord");
|
||||
assert_eq!(working_dir.as_deref(), Some("/tmp/project"));
|
||||
assert_eq!(initial_message.as_deref(), Some("Start here"));
|
||||
assert_eq!(request_nonce.as_deref(), Some("planner-fresh-123"));
|
||||
assert_eq!(spawn_mode.as_deref(), Some("headless"));
|
||||
assert_eq!(model.as_deref(), Some("openai-api:gpt-5.5"));
|
||||
assert_eq!(effort.as_deref(), Some("low"));
|
||||
assert_eq!(label.as_deref(), Some("review auth flow"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_comm_spawn_decodes_without_model_or_effort() -> Result<()> {
|
||||
// Older clients omit the model/effort fields entirely.
|
||||
let json = r#"{"type":"comm_spawn","id":60,"session_id":"sess_coord"}"#;
|
||||
let decoded = parse_request_json(json)?;
|
||||
let Request::CommSpawn { model, effort, label, .. } = decoded else {
|
||||
return Err(anyhow!("expected CommSpawn"));
|
||||
};
|
||||
assert_eq!(model, None);
|
||||
assert_eq!(effort, None);
|
||||
assert_eq!(label, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_comm_list_models_roundtrip() -> Result<()> {
|
||||
let req = Request::CommListModels {
|
||||
id: 61,
|
||||
session_id: "sess_coord".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&req)?;
|
||||
assert!(json.contains("\"type\":\"comm_list_models\""));
|
||||
let decoded = parse_request_json(&json)?;
|
||||
assert_eq!(decoded.id(), 61);
|
||||
assert!(decoded.is_lightweight_control_request());
|
||||
let Request::CommListModels { session_id, .. } = decoded else {
|
||||
return Err(anyhow!("expected CommListModels"));
|
||||
};
|
||||
assert_eq!(session_id, "sess_coord");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reload_force_defaults_true_for_legacy_clients() -> Result<()> {
|
||||
// Old clients (and the desktop Swift enum, which has no reload case) send a
|
||||
// reload request with no `force` field. It must default to true so their
|
||||
// behavior stays unconditional, matching the pre-#291 protocol.
|
||||
let json = r#"{"type":"reload","id":7}"#;
|
||||
let decoded = parse_request_json(json)?;
|
||||
let Request::Reload { id, force } = decoded else {
|
||||
return Err(anyhow!("expected Reload"));
|
||||
};
|
||||
assert_eq!(id, 7);
|
||||
assert!(force, "missing force must default to true");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reload_force_roundtrip() -> Result<()> {
|
||||
for force in [false, true] {
|
||||
let req = Request::Reload { id: 9, force };
|
||||
let json = serde_json::to_string(&req)?;
|
||||
assert!(json.contains("\"type\":\"reload\""));
|
||||
let decoded = parse_request_json(&json)?;
|
||||
let Request::Reload {
|
||||
id,
|
||||
force: decoded_force,
|
||||
} = decoded
|
||||
else {
|
||||
return Err(anyhow!("expected Reload"));
|
||||
};
|
||||
assert_eq!(id, 9);
|
||||
assert_eq!(decoded_force, force);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
#[test]
|
||||
fn test_swarm_plan_event_roundtrip_with_summary() -> Result<()> {
|
||||
let event = ServerEvent::SwarmPlan {
|
||||
swarm_id: "swarm_123".to_string(),
|
||||
version: 7,
|
||||
items: vec![PlanItem {
|
||||
content: "Investigate planner state".to_string(),
|
||||
status: "queued".to_string(),
|
||||
priority: "high".to_string(),
|
||||
id: "task-1".to_string(),
|
||||
subsystem: None,
|
||||
file_scope: Vec::new(),
|
||||
blocked_by: vec![],
|
||||
assigned_to: None,
|
||||
}],
|
||||
participants: vec!["session_fox".to_string()],
|
||||
reason: Some("task_completed".to_string()),
|
||||
summary: Some(PlanGraphStatus {
|
||||
swarm_id: Some("swarm_123".to_string()),
|
||||
version: 7,
|
||||
item_count: 1,
|
||||
ready_ids: vec!["task-1".to_string()],
|
||||
blocked_ids: Vec::new(),
|
||||
active_ids: Vec::new(),
|
||||
completed_ids: Vec::new(),
|
||||
failed_ids: Vec::new(),
|
||||
failed_reasons: Default::default(),
|
||||
cycle_ids: Vec::new(),
|
||||
unresolved_dependency_ids: Vec::new(),
|
||||
next_ready_ids: vec!["task-1".to_string()],
|
||||
newly_ready_ids: Vec::new(),
|
||||
low_confidence_ids: Vec::new(),
|
||||
mode: "light".to_string(),
|
||||
seeded_count: 1,
|
||||
grown_count: 0,
|
||||
}),
|
||||
};
|
||||
let json = encode_event(&event);
|
||||
assert!(json.contains("\"type\":\"swarm_plan\""));
|
||||
assert!(json.contains("\"summary\""));
|
||||
let decoded = parse_event_json(json.trim())?;
|
||||
let ServerEvent::SwarmPlan {
|
||||
swarm_id,
|
||||
version,
|
||||
items,
|
||||
participants,
|
||||
reason,
|
||||
summary,
|
||||
} = decoded
|
||||
else {
|
||||
return Err(anyhow!("expected SwarmPlan event"));
|
||||
};
|
||||
assert_eq!(swarm_id, "swarm_123");
|
||||
assert_eq!(version, 7);
|
||||
assert_eq!(participants, vec!["session_fox"]);
|
||||
assert_eq!(reason.as_deref(), Some("task_completed"));
|
||||
assert_eq!(items.len(), 1);
|
||||
let summary = summary.ok_or_else(|| anyhow!("expected plan summary"))?;
|
||||
assert_eq!(summary.ready_ids, vec!["task-1"]);
|
||||
assert_eq!(summary.next_ready_ids, vec!["task-1"]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_comm_task_control_response_roundtrip() -> Result<()> {
|
||||
let event = ServerEvent::CommTaskControlResponse {
|
||||
id: 61,
|
||||
action: "start".to_string(),
|
||||
task_id: "task-1".to_string(),
|
||||
target_session: Some("sess_worker".to_string()),
|
||||
status: "running".to_string(),
|
||||
summary: PlanGraphStatus {
|
||||
swarm_id: Some("swarm_123".to_string()),
|
||||
version: 3,
|
||||
item_count: 2,
|
||||
ready_ids: vec!["task-2".to_string()],
|
||||
blocked_ids: Vec::new(),
|
||||
active_ids: vec!["task-1".to_string()],
|
||||
completed_ids: vec!["setup".to_string()],
|
||||
failed_ids: Vec::new(),
|
||||
failed_reasons: Default::default(),
|
||||
cycle_ids: Vec::new(),
|
||||
unresolved_dependency_ids: Vec::new(),
|
||||
next_ready_ids: vec!["task-2".to_string()],
|
||||
newly_ready_ids: vec!["task-2".to_string()],
|
||||
low_confidence_ids: Vec::new(),
|
||||
mode: "deep".to_string(),
|
||||
seeded_count: 2,
|
||||
grown_count: 0,
|
||||
},
|
||||
};
|
||||
let json = encode_event(&event);
|
||||
assert!(json.contains("\"type\":\"comm_task_control_response\""));
|
||||
let decoded = parse_event_json(json.trim())?;
|
||||
let ServerEvent::CommTaskControlResponse {
|
||||
id,
|
||||
action,
|
||||
task_id,
|
||||
target_session,
|
||||
status,
|
||||
summary,
|
||||
} = decoded
|
||||
else {
|
||||
return Err(anyhow!("expected CommTaskControlResponse"));
|
||||
};
|
||||
assert_eq!(id, 61);
|
||||
assert_eq!(action, "start");
|
||||
assert_eq!(task_id, "task-1");
|
||||
assert_eq!(target_session.as_deref(), Some("sess_worker"));
|
||||
assert_eq!(status, "running");
|
||||
assert_eq!(summary.next_ready_ids, vec!["task-2"]);
|
||||
assert_eq!(summary.newly_ready_ids, vec!["task-2"]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_comm_status_roundtrip() -> Result<()> {
|
||||
let req = Request::CommStatus {
|
||||
id: 56,
|
||||
session_id: "sess_watcher".to_string(),
|
||||
target_session: "sess_peer".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&req)?;
|
||||
assert!(json.contains("\"type\":\"comm_status\""));
|
||||
let decoded = parse_request_json(&json)?;
|
||||
assert_eq!(decoded.id(), 56);
|
||||
let Request::CommStatus {
|
||||
session_id,
|
||||
target_session,
|
||||
..
|
||||
} = decoded
|
||||
else {
|
||||
return Err(anyhow!("expected CommStatus"));
|
||||
};
|
||||
assert_eq!(session_id, "sess_watcher");
|
||||
assert_eq!(target_session, "sess_peer");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_comm_plan_status_roundtrip() -> Result<()> {
|
||||
let req = Request::CommPlanStatus {
|
||||
id: 59,
|
||||
session_id: "sess_coord".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&req)?;
|
||||
assert!(json.contains("\"type\":\"comm_plan_status\""));
|
||||
let decoded = parse_request_json(&json)?;
|
||||
assert_eq!(decoded.id(), 59);
|
||||
let Request::CommPlanStatus { session_id, .. } = decoded else {
|
||||
return Err(anyhow!("expected CommPlanStatus"));
|
||||
};
|
||||
assert_eq!(session_id, "sess_coord");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_comm_members_roundtrip_includes_status() -> Result<()> {
|
||||
let event = ServerEvent::CommMembers {
|
||||
id: 9,
|
||||
members: vec![AgentInfo {
|
||||
session_id: "sess-peer".to_string(),
|
||||
friendly_name: Some("bear".to_string()),
|
||||
files_touched: vec!["src/main.rs".to_string()],
|
||||
status: Some("running".to_string()),
|
||||
detail: Some("working on tests".to_string()),
|
||||
role: Some("agent".to_string()),
|
||||
is_headless: Some(true),
|
||||
report_back_to_session_id: Some("sess-coord".to_string()),
|
||||
latest_completion_report: Some("Done.".to_string()),
|
||||
live_attachments: Some(0),
|
||||
status_age_secs: Some(12),
|
||||
..Default::default()
|
||||
}],
|
||||
};
|
||||
|
||||
let json = encode_event(&event);
|
||||
assert!(json.contains("\"type\":\"comm_members\""));
|
||||
assert!(json.contains("\"status\":\"running\""));
|
||||
|
||||
let decoded = parse_event_json(json.trim())?;
|
||||
let ServerEvent::CommMembers { id, members } = decoded else {
|
||||
return Err(anyhow!("expected CommMembers"));
|
||||
};
|
||||
assert_eq!(id, 9);
|
||||
assert_eq!(members.len(), 1);
|
||||
assert_eq!(members[0].friendly_name.as_deref(), Some("bear"));
|
||||
assert_eq!(members[0].status.as_deref(), Some("running"));
|
||||
assert_eq!(members[0].detail.as_deref(), Some("working on tests"));
|
||||
assert_eq!(members[0].is_headless, Some(true));
|
||||
assert_eq!(
|
||||
members[0].report_back_to_session_id.as_deref(),
|
||||
Some("sess-coord")
|
||||
);
|
||||
assert_eq!(
|
||||
members[0].latest_completion_report.as_deref(),
|
||||
Some("Done.")
|
||||
);
|
||||
assert_eq!(members[0].live_attachments, Some(0));
|
||||
assert_eq!(members[0].status_age_secs, Some(12));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_close_requested_roundtrip() -> Result<()> {
|
||||
let event = ServerEvent::SessionCloseRequested {
|
||||
reason: "Stopped by coordinator coord".to_string(),
|
||||
};
|
||||
let json = encode_event(&event);
|
||||
assert!(json.contains("\"type\":\"session_close_requested\""));
|
||||
let decoded = parse_event_json(json.trim())?;
|
||||
let ServerEvent::SessionCloseRequested { reason } = decoded else {
|
||||
return Err(anyhow!("expected SessionCloseRequested"));
|
||||
};
|
||||
assert_eq!(reason, "Stopped by coordinator coord");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_comm_status_response_roundtrip() -> Result<()> {
|
||||
let event = ServerEvent::CommStatusResponse {
|
||||
id: 57,
|
||||
snapshot: AgentStatusSnapshot {
|
||||
session_id: "sess-peer".to_string(),
|
||||
friendly_name: Some("bear".to_string()),
|
||||
swarm_id: Some("swarm-test".to_string()),
|
||||
status: Some("running".to_string()),
|
||||
detail: Some("working on tests".to_string()),
|
||||
role: Some("agent".to_string()),
|
||||
is_headless: Some(true),
|
||||
live_attachments: Some(0),
|
||||
status_age_secs: Some(5),
|
||||
last_activity_age_secs: Some(2),
|
||||
joined_age_secs: Some(30),
|
||||
files_touched: vec!["src/main.rs".to_string()],
|
||||
activity: Some(SessionActivitySnapshot {
|
||||
is_processing: true,
|
||||
current_tool_name: Some("bash".to_string()),
|
||||
}),
|
||||
provider_name: None,
|
||||
provider_model: None,
|
||||
},
|
||||
};
|
||||
|
||||
let json = encode_event(&event);
|
||||
assert!(json.contains("\"type\":\"comm_status_response\""));
|
||||
let decoded = parse_event_json(json.trim())?;
|
||||
let ServerEvent::CommStatusResponse { id, snapshot } = decoded else {
|
||||
return Err(anyhow!("expected CommStatusResponse"));
|
||||
};
|
||||
assert_eq!(id, 57);
|
||||
assert_eq!(snapshot.session_id, "sess-peer");
|
||||
assert_eq!(snapshot.friendly_name.as_deref(), Some("bear"));
|
||||
assert_eq!(
|
||||
snapshot
|
||||
.activity
|
||||
.and_then(|activity| activity.current_tool_name),
|
||||
Some("bash".to_string())
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
#[test]
|
||||
fn test_request_roundtrip() -> Result<()> {
|
||||
let req = Request::Message {
|
||||
id: 1,
|
||||
content: "hello".to_string(),
|
||||
images: vec![],
|
||||
system_reminder: None,
|
||||
};
|
||||
let json = serde_json::to_string(&req)?;
|
||||
let decoded = parse_request_json(&json)?;
|
||||
assert_eq!(decoded.id(), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compacted_history_request_roundtrip() -> Result<()> {
|
||||
let req = Request::GetCompactedHistory {
|
||||
id: 7,
|
||||
visible_messages: 64,
|
||||
};
|
||||
let json = serde_json::to_string(&req)?;
|
||||
assert!(json.contains("\"type\":\"get_compacted_history\""));
|
||||
let decoded = parse_request_json(&json)?;
|
||||
assert_eq!(decoded.id(), 7);
|
||||
let Request::GetCompactedHistory {
|
||||
visible_messages, ..
|
||||
} = decoded
|
||||
else {
|
||||
return Err(anyhow!("wrong request type"));
|
||||
};
|
||||
assert_eq!(visible_messages, 64);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_notify_auth_changed_provider_hint_is_optional() -> Result<()> {
|
||||
let legacy = r#"{"type":"notify_auth_changed","id":9}"#;
|
||||
let decoded = parse_request_json(legacy)?;
|
||||
let Request::NotifyAuthChanged { id, provider, auth } = decoded else {
|
||||
return Err(anyhow!("wrong request type"));
|
||||
};
|
||||
assert_eq!(id, 9);
|
||||
assert_eq!(provider, None);
|
||||
assert_eq!(auth, None);
|
||||
|
||||
let req = Request::NotifyAuthChanged {
|
||||
id: 10,
|
||||
provider: Some("azure-openai".to_string()),
|
||||
auth: None,
|
||||
};
|
||||
let json = serde_json::to_string(&req)?;
|
||||
assert!(json.contains("\"provider\":\"azure-openai\""));
|
||||
let decoded = parse_request_json(&json)?;
|
||||
let Request::NotifyAuthChanged { id, provider, auth } = decoded else {
|
||||
return Err(anyhow!("wrong request type"));
|
||||
};
|
||||
assert_eq!(id, 10);
|
||||
assert_eq!(provider.as_deref(), Some("azure-openai"));
|
||||
assert_eq!(auth, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_notify_auth_changed_typed_auth_payload_roundtrip() -> Result<()> {
|
||||
let req = Request::NotifyAuthChanged {
|
||||
id: 11,
|
||||
provider: Some("cerebras".to_string()),
|
||||
auth: Some(AuthChanged {
|
||||
provider: AuthProviderId::new("cerebras"),
|
||||
credential_source: Some(AuthCredentialSource::ApiKeyFile),
|
||||
auth_method: Some(AuthMethod::RemoteTuiPasteApiKey),
|
||||
expected_runtime: Some(RuntimeProviderKey::new("openai-compatible")),
|
||||
expected_catalog_namespace: Some(CatalogNamespace::new("cerebras")),
|
||||
}),
|
||||
};
|
||||
let json = serde_json::to_string(&req)?;
|
||||
assert!(json.contains("\"provider\":\"cerebras\""));
|
||||
assert!(json.contains("\"auth_method\":\"remote_tui_paste_api_key\""));
|
||||
assert!(json.contains("\"expected_runtime\":\"openai-compatible\""));
|
||||
assert!(json.contains("\"expected_catalog_namespace\":\"cerebras\""));
|
||||
|
||||
let decoded = parse_request_json(&json)?;
|
||||
let Request::NotifyAuthChanged { id, provider, auth } = decoded else {
|
||||
return Err(anyhow!("wrong request type"));
|
||||
};
|
||||
assert_eq!(id, 11);
|
||||
assert_eq!(provider.as_deref(), Some("cerebras"));
|
||||
let auth = auth.expect("typed auth payload should roundtrip");
|
||||
assert_eq!(auth.provider.as_str(), "cerebras");
|
||||
assert_eq!(auth.credential_source, Some(AuthCredentialSource::ApiKeyFile));
|
||||
assert_eq!(auth.auth_method, Some(AuthMethod::RemoteTuiPasteApiKey));
|
||||
assert_eq!(
|
||||
auth.expected_runtime.as_ref().map(RuntimeProviderKey::as_str),
|
||||
Some("openai-compatible")
|
||||
);
|
||||
assert_eq!(
|
||||
auth.expected_catalog_namespace
|
||||
.as_ref()
|
||||
.map(CatalogNamespace::as_str),
|
||||
Some("cerebras")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rewind_request_roundtrip() -> Result<()> {
|
||||
let req = Request::Rewind {
|
||||
id: 8,
|
||||
message_index: 3,
|
||||
};
|
||||
let json = serde_json::to_string(&req)?;
|
||||
assert!(json.contains("\"type\":\"rewind\""));
|
||||
let decoded = parse_request_json(&json)?;
|
||||
assert_eq!(decoded.id(), 8);
|
||||
let Request::Rewind { message_index, .. } = decoded else {
|
||||
return Err(anyhow!("wrong request type"));
|
||||
};
|
||||
assert_eq!(message_index, 3);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rewind_undo_request_roundtrip() -> Result<()> {
|
||||
let req = Request::RewindUndo { id: 9 };
|
||||
let json = serde_json::to_string(&req)?;
|
||||
assert!(json.contains("\"type\":\"rewind_undo\""));
|
||||
let decoded = parse_request_json(&json)?;
|
||||
assert_eq!(decoded.id(), 9);
|
||||
let Request::RewindUndo { .. } = decoded else {
|
||||
return Err(anyhow!("wrong request type"));
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_session_request_roundtrip() -> Result<()> {
|
||||
let req = Request::RenameSession {
|
||||
id: 10,
|
||||
title: Some("Release planning".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&req)?;
|
||||
assert!(json.contains("\"type\":\"rename_session\""));
|
||||
assert!(json.contains("\"title\":\"Release planning\""));
|
||||
let decoded = parse_request_json(&json)?;
|
||||
assert_eq!(decoded.id(), 10);
|
||||
let Request::RenameSession { title, .. } = decoded else {
|
||||
return Err(anyhow!("wrong request type"));
|
||||
};
|
||||
assert_eq!(title.as_deref(), Some("Release planning"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_session_clear_request_roundtrip_omits_title() -> Result<()> {
|
||||
let req = Request::RenameSession {
|
||||
id: 11,
|
||||
title: None,
|
||||
};
|
||||
let json = serde_json::to_string(&req)?;
|
||||
assert!(json.contains("\"type\":\"rename_session\""));
|
||||
assert!(!json.contains("\"title\""));
|
||||
let decoded = parse_request_json(&json)?;
|
||||
assert_eq!(decoded.id(), 11);
|
||||
let Request::RenameSession { title, .. } = decoded else {
|
||||
return Err(anyhow!("wrong request type"));
|
||||
};
|
||||
assert!(title.is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_roundtrip() -> Result<()> {
|
||||
let event = ServerEvent::TextDelta {
|
||||
text: "hello".to_string(),
|
||||
};
|
||||
let json = encode_event(&event);
|
||||
let decoded = parse_event_json(json.trim())?;
|
||||
let ServerEvent::TextDelta { text } = decoded else {
|
||||
return Err(anyhow!("wrong event type"));
|
||||
};
|
||||
assert_eq!(text, "hello");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_renamed_event_roundtrip() -> Result<()> {
|
||||
let event = ServerEvent::SessionRenamed {
|
||||
session_id: "sess_123".to_string(),
|
||||
title: Some("Release planning".to_string()),
|
||||
display_title: "Release planning".to_string(),
|
||||
};
|
||||
let json = encode_event(&event);
|
||||
assert!(json.contains("\"type\":\"session_renamed\""));
|
||||
let decoded = parse_event_json(json.trim())?;
|
||||
let ServerEvent::SessionRenamed {
|
||||
session_id,
|
||||
title,
|
||||
display_title,
|
||||
} = decoded
|
||||
else {
|
||||
return Err(anyhow!("wrong event type"));
|
||||
};
|
||||
assert_eq!(session_id, "sess_123");
|
||||
assert_eq!(title.as_deref(), Some("Release planning"));
|
||||
assert_eq!(display_title, "Release planning");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_interrupted_event_decodes_from_json() -> Result<()> {
|
||||
let json = r#"{"type":"interrupted"}"#;
|
||||
let decoded = parse_event_json(json)?;
|
||||
let ServerEvent::Interrupted = decoded else {
|
||||
return Err(anyhow!("wrong event type"));
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_connection_type_event_roundtrip() -> Result<()> {
|
||||
let event = ServerEvent::ConnectionType {
|
||||
connection: "websocket".to_string(),
|
||||
};
|
||||
let json = encode_event(&event);
|
||||
let decoded = parse_event_json(json.trim())?;
|
||||
let ServerEvent::ConnectionType { connection } = decoded else {
|
||||
return Err(anyhow!("wrong event type"));
|
||||
};
|
||||
assert_eq!(connection, "websocket");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status_detail_event_roundtrip() -> Result<()> {
|
||||
let event = ServerEvent::StatusDetail {
|
||||
detail: "reusing websocket".to_string(),
|
||||
};
|
||||
let json = encode_event(&event);
|
||||
let decoded = parse_event_json(json.trim())?;
|
||||
let ServerEvent::StatusDetail { detail } = decoded else {
|
||||
return Err(anyhow!("wrong event type"));
|
||||
};
|
||||
assert_eq!(detail, "reusing websocket");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generated_image_event_roundtrip() -> Result<()> {
|
||||
let event = ServerEvent::GeneratedImage {
|
||||
id: "ig_123".to_string(),
|
||||
path: "/tmp/generated.png".to_string(),
|
||||
metadata_path: Some("/tmp/generated.json".to_string()),
|
||||
output_format: "png".to_string(),
|
||||
revised_prompt: Some("A polished image prompt".to_string()),
|
||||
};
|
||||
let json = encode_event(&event);
|
||||
assert!(json.contains("\"type\":\"generated_image\""));
|
||||
let decoded = parse_event_json(json.trim())?;
|
||||
let ServerEvent::GeneratedImage {
|
||||
id,
|
||||
path,
|
||||
metadata_path,
|
||||
output_format,
|
||||
revised_prompt,
|
||||
} = decoded
|
||||
else {
|
||||
return Err(anyhow!("wrong event type"));
|
||||
};
|
||||
assert_eq!(id, "ig_123");
|
||||
assert_eq!(path, "/tmp/generated.png");
|
||||
assert_eq!(metadata_path.as_deref(), Some("/tmp/generated.json"));
|
||||
assert_eq!(output_format, "png");
|
||||
assert_eq!(revised_prompt.as_deref(), Some("A polished image prompt"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_side_pane_images_event_roundtrip() -> Result<()> {
|
||||
let event = ServerEvent::SidePaneImages {
|
||||
session_id: "session_active".to_string(),
|
||||
images: vec![jcode_session_types::RenderedImage {
|
||||
media_type: "image/png".to_string(),
|
||||
data: "base64-data".to_string(),
|
||||
label: Some("openclaw.png".to_string()),
|
||||
source: jcode_session_types::RenderedImageSource::ToolResult {
|
||||
tool_name: "read".to_string(),
|
||||
},
|
||||
anchor: None,
|
||||
}],
|
||||
};
|
||||
let json = encode_event(&event);
|
||||
assert!(json.contains("\"type\":\"side_pane_images\""));
|
||||
let decoded = parse_event_json(json.trim())?;
|
||||
let ServerEvent::SidePaneImages { session_id, images } = decoded else {
|
||||
return Err(anyhow!("wrong event type"));
|
||||
};
|
||||
assert_eq!(session_id, "session_active");
|
||||
assert_eq!(images.len(), 1);
|
||||
assert_eq!(images[0].media_type, "image/png");
|
||||
assert_eq!(images[0].label.as_deref(), Some("openclaw.png"));
|
||||
assert_eq!(
|
||||
images[0].source,
|
||||
jcode_session_types::RenderedImageSource::ToolResult {
|
||||
tool_name: "read".to_string(),
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_interrupted_event_roundtrip() -> Result<()> {
|
||||
let event = ServerEvent::Interrupted;
|
||||
let json = encode_event(&event);
|
||||
assert!(json.contains("\"type\":\"interrupted\""));
|
||||
let decoded = parse_event_json(json.trim())?;
|
||||
let ServerEvent::Interrupted = decoded else {
|
||||
return Err(anyhow!("wrong event type"));
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_history_event_decodes_without_compaction_mode_for_older_servers() -> Result<()> {
|
||||
let json = r#"{
|
||||
"type":"history",
|
||||
"id":1,
|
||||
"session_id":"ses_test_123",
|
||||
"messages":[],
|
||||
"provider_name":"openai",
|
||||
"provider_model":"gpt-5.4",
|
||||
"available_models":["gpt-5.4"],
|
||||
"connection_type":"websocket"
|
||||
}"#;
|
||||
let decoded = parse_event_json(json)?;
|
||||
let ServerEvent::History {
|
||||
provider_name,
|
||||
provider_model,
|
||||
available_models,
|
||||
connection_type,
|
||||
compaction_mode,
|
||||
side_panel,
|
||||
..
|
||||
} = decoded
|
||||
else {
|
||||
return Err(anyhow!("wrong event type"));
|
||||
};
|
||||
assert_eq!(provider_name.as_deref(), Some("openai"));
|
||||
assert_eq!(provider_model.as_deref(), Some("gpt-5.4"));
|
||||
assert_eq!(available_models, vec!["gpt-5.4"]);
|
||||
assert_eq!(connection_type.as_deref(), Some("websocket"));
|
||||
assert_eq!(
|
||||
compaction_mode,
|
||||
jcode_config_types::CompactionMode::Reactive
|
||||
);
|
||||
assert!(!side_panel.has_pages());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_history_event_roundtrip_preserves_side_panel_snapshot() -> Result<()> {
|
||||
let event = ServerEvent::History {
|
||||
id: 101,
|
||||
session_id: "ses_test_456".to_string(),
|
||||
messages: vec![HistoryMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: "hello".to_string(),
|
||||
tool_calls: None,
|
||||
tool_data: None,
|
||||
}],
|
||||
images: Vec::new(),
|
||||
provider_name: Some("openai".to_string()),
|
||||
provider_model: Some("gpt-5.4".to_string()),
|
||||
available_models: vec!["gpt-5.4".to_string()],
|
||||
available_model_routes: Vec::new(),
|
||||
mcp_servers: Vec::new(),
|
||||
skills: Vec::new(),
|
||||
total_tokens: Some((123, 45)),
|
||||
token_usage_totals: Some(TokenUsageTotals {
|
||||
messages_with_token_usage: 2,
|
||||
input_tokens: 123,
|
||||
output_tokens: 45,
|
||||
cache_reported_input_tokens: 100,
|
||||
cache_read_input_tokens: 80,
|
||||
cache_creation_input_tokens: 10,
|
||||
}),
|
||||
all_sessions: Vec::new(),
|
||||
client_count: None,
|
||||
is_canary: None,
|
||||
reload_recovery: None,
|
||||
server_version: None,
|
||||
server_name: None,
|
||||
server_icon: None,
|
||||
server_has_update: None,
|
||||
was_interrupted: None,
|
||||
connection_type: Some("websocket".to_string()),
|
||||
status_detail: None,
|
||||
upstream_provider: None,
|
||||
resolved_credential: None,
|
||||
reasoning_effort: None,
|
||||
service_tier: None,
|
||||
subagent_model: None,
|
||||
autoreview_enabled: None,
|
||||
autojudge_enabled: None,
|
||||
compaction_mode: jcode_config_types::CompactionMode::Reactive,
|
||||
activity: None,
|
||||
side_panel: jcode_side_panel_types::SidePanelSnapshot {
|
||||
focused_page_id: Some("page-1".to_string()),
|
||||
pages: vec![jcode_side_panel_types::SidePanelPage {
|
||||
id: "page-1".to_string(),
|
||||
title: "Notes".to_string(),
|
||||
file_path: "/tmp/notes.md".to_string(),
|
||||
format: jcode_side_panel_types::SidePanelPageFormat::Markdown,
|
||||
source: jcode_side_panel_types::SidePanelPageSource::Managed,
|
||||
content: "# Notes".to_string(),
|
||||
updated_at_ms: 42,
|
||||
}],
|
||||
},
|
||||
};
|
||||
let json = encode_event(&event);
|
||||
let decoded = parse_event_json(json.trim())?;
|
||||
let ServerEvent::History {
|
||||
id,
|
||||
side_panel,
|
||||
messages,
|
||||
provider_name,
|
||||
provider_model,
|
||||
total_tokens,
|
||||
token_usage_totals,
|
||||
..
|
||||
} = decoded
|
||||
else {
|
||||
return Err(anyhow!("expected History event"));
|
||||
};
|
||||
assert_eq!(id, 101);
|
||||
assert_eq!(provider_name.as_deref(), Some("openai"));
|
||||
assert_eq!(provider_model.as_deref(), Some("gpt-5.4"));
|
||||
assert_eq!(total_tokens, Some((123, 45)));
|
||||
assert_eq!(
|
||||
token_usage_totals.map(|totals| totals.cache_read_input_tokens),
|
||||
Some(80)
|
||||
);
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert_eq!(side_panel.focused_page_id.as_deref(), Some("page-1"));
|
||||
assert_eq!(side_panel.pages.len(), 1);
|
||||
assert_eq!(side_panel.pages[0].title, "Notes");
|
||||
assert_eq!(side_panel.pages[0].content, "# Notes");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compacted_history_event_roundtrip() -> Result<()> {
|
||||
let event = ServerEvent::CompactedHistory {
|
||||
id: 77,
|
||||
session_id: "ses_compact_123".to_string(),
|
||||
messages: vec![HistoryMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: "older response".to_string(),
|
||||
tool_calls: None,
|
||||
tool_data: None,
|
||||
}],
|
||||
images: Vec::new(),
|
||||
compacted_total: 128,
|
||||
compacted_visible: 64,
|
||||
compacted_remaining: 64,
|
||||
compacted_hidden_prompts: 3,
|
||||
};
|
||||
let json = encode_event(&event);
|
||||
assert!(json.contains("\"type\":\"compacted_history\""));
|
||||
let decoded = parse_event_json(json.trim())?;
|
||||
let ServerEvent::CompactedHistory {
|
||||
id,
|
||||
session_id,
|
||||
messages,
|
||||
compacted_total,
|
||||
compacted_visible,
|
||||
compacted_remaining,
|
||||
..
|
||||
} = decoded
|
||||
else {
|
||||
return Err(anyhow!("expected CompactedHistory event"));
|
||||
};
|
||||
assert_eq!(id, 77);
|
||||
assert_eq!(session_id, "ses_compact_123");
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert_eq!(messages[0].content, "older response");
|
||||
assert_eq!(compacted_total, 128);
|
||||
assert_eq!(compacted_visible, 64);
|
||||
assert_eq!(compacted_remaining, 64);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_side_panel_state_event_roundtrip() -> Result<()> {
|
||||
let event = ServerEvent::SidePanelState {
|
||||
snapshot: jcode_side_panel_types::SidePanelSnapshot {
|
||||
focused_page_id: Some("page-1".to_string()),
|
||||
pages: vec![jcode_side_panel_types::SidePanelPage {
|
||||
id: "page-1".to_string(),
|
||||
title: "Notes".to_string(),
|
||||
file_path: "/tmp/notes.md".to_string(),
|
||||
format: jcode_side_panel_types::SidePanelPageFormat::Markdown,
|
||||
source: jcode_side_panel_types::SidePanelPageSource::Managed,
|
||||
content: "updated".to_string(),
|
||||
updated_at_ms: 99,
|
||||
}],
|
||||
},
|
||||
};
|
||||
let json = encode_event(&event);
|
||||
assert!(json.contains("\"type\":\"side_panel_state\""));
|
||||
let decoded = parse_event_json(json.trim())?;
|
||||
let ServerEvent::SidePanelState { snapshot } = decoded else {
|
||||
return Err(anyhow!("expected SidePanelState event"));
|
||||
};
|
||||
assert_eq!(snapshot.focused_page_id.as_deref(), Some("page-1"));
|
||||
assert_eq!(snapshot.pages.len(), 1);
|
||||
assert_eq!(snapshot.pages[0].title, "Notes");
|
||||
assert_eq!(snapshot.pages[0].content, "updated");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_event_retry_after_roundtrip() -> Result<()> {
|
||||
let event = ServerEvent::Error {
|
||||
id: 42,
|
||||
message: "rate limited".to_string(),
|
||||
retry_after_secs: Some(17),
|
||||
};
|
||||
let json = encode_event(&event);
|
||||
let decoded = parse_event_json(json.trim())?;
|
||||
let ServerEvent::Error {
|
||||
id,
|
||||
message,
|
||||
retry_after_secs,
|
||||
} = decoded
|
||||
else {
|
||||
return Err(anyhow!("wrong event type"));
|
||||
};
|
||||
assert_eq!(id, 42);
|
||||
assert_eq!(message, "rate limited");
|
||||
assert_eq!(retry_after_secs, Some(17));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_event_retry_after_back_compat_default() -> Result<()> {
|
||||
let json = r#"{"type":"error","id":7,"message":"oops"}"#;
|
||||
let decoded = parse_event_json(json)?;
|
||||
let ServerEvent::Error {
|
||||
id,
|
||||
message,
|
||||
retry_after_secs,
|
||||
} = decoded
|
||||
else {
|
||||
return Err(anyhow!("wrong event type"));
|
||||
};
|
||||
assert_eq!(id, 7);
|
||||
assert_eq!(message, "oops");
|
||||
assert_eq!(retry_after_secs, None);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
#[test]
|
||||
fn test_transcript_request_roundtrip() -> Result<()> {
|
||||
let req = Request::Transcript {
|
||||
id: 77,
|
||||
text: "hello from whisper".to_string(),
|
||||
mode: TranscriptMode::Send,
|
||||
session_id: Some("sess_abc".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&req)?;
|
||||
assert!(json.contains("\"type\":\"transcript\""));
|
||||
let decoded = parse_request_json(&json)?;
|
||||
assert_eq!(decoded.id(), 77);
|
||||
let Request::Transcript {
|
||||
text,
|
||||
mode,
|
||||
session_id,
|
||||
..
|
||||
} = decoded
|
||||
else {
|
||||
return Err(anyhow!("expected Transcript request"));
|
||||
};
|
||||
assert_eq!(text, "hello from whisper");
|
||||
assert_eq!(mode, TranscriptMode::Send);
|
||||
assert_eq!(session_id.as_deref(), Some("sess_abc"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transcript_event_roundtrip() -> Result<()> {
|
||||
let event = ServerEvent::Transcript {
|
||||
text: "dictated text".to_string(),
|
||||
mode: TranscriptMode::Replace,
|
||||
};
|
||||
let json = encode_event(&event);
|
||||
assert!(json.contains("\"type\":\"transcript\""));
|
||||
let decoded = parse_event_json(json.trim())?;
|
||||
let ServerEvent::Transcript { text, mode } = decoded else {
|
||||
return Err(anyhow!("expected Transcript event"));
|
||||
};
|
||||
assert_eq!(text, "dictated text");
|
||||
assert_eq!(mode, TranscriptMode::Replace);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_memory_activity_event_roundtrip() -> Result<()> {
|
||||
let event = ServerEvent::MemoryActivity {
|
||||
activity: MemoryActivitySnapshot {
|
||||
state: MemoryStateSnapshot::SidecarChecking { count: 3 },
|
||||
state_age_ms: 275,
|
||||
pipeline: Some(MemoryPipelineSnapshot {
|
||||
search: MemoryStepStatusSnapshot::Done,
|
||||
search_result: Some(MemoryStepResultSnapshot {
|
||||
summary: "5 hits".to_string(),
|
||||
latency_ms: 14,
|
||||
}),
|
||||
verify: MemoryStepStatusSnapshot::Running,
|
||||
verify_result: None,
|
||||
verify_progress: Some((1, 3)),
|
||||
inject: MemoryStepStatusSnapshot::Pending,
|
||||
inject_result: None,
|
||||
maintain: MemoryStepStatusSnapshot::Pending,
|
||||
maintain_result: None,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
let json = encode_event(&event);
|
||||
assert!(json.contains("\"type\":\"memory_activity\""));
|
||||
let decoded = parse_event_json(json.trim())?;
|
||||
let ServerEvent::MemoryActivity { activity } = decoded else {
|
||||
return Err(anyhow!("expected MemoryActivity event"));
|
||||
};
|
||||
assert_eq!(
|
||||
activity.state,
|
||||
MemoryStateSnapshot::SidecarChecking { count: 3 }
|
||||
);
|
||||
assert_eq!(activity.state_age_ms, 275);
|
||||
let pipeline = activity
|
||||
.pipeline
|
||||
.ok_or_else(|| anyhow!("pipeline snapshot"))?;
|
||||
assert_eq!(pipeline.search, MemoryStepStatusSnapshot::Done);
|
||||
assert_eq!(pipeline.verify, MemoryStepStatusSnapshot::Running);
|
||||
assert_eq!(pipeline.verify_progress, Some((1, 3)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_input_shell_request_roundtrip() -> Result<()> {
|
||||
let req = Request::InputShell {
|
||||
id: 88,
|
||||
command: "ls -la".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&req)?;
|
||||
assert!(json.contains("\"type\":\"input_shell\""));
|
||||
let decoded = parse_request_json(&json)?;
|
||||
assert_eq!(decoded.id(), 88);
|
||||
let Request::InputShell { id, command } = decoded else {
|
||||
return Err(anyhow!("expected InputShell request"));
|
||||
};
|
||||
assert_eq!(id, 88);
|
||||
assert_eq!(command, "ls -la");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_input_shell_result_event_roundtrip() -> Result<()> {
|
||||
let event = ServerEvent::InputShellResult {
|
||||
result: jcode_message_types::InputShellResult {
|
||||
command: "pwd".to_string(),
|
||||
cwd: Some("/tmp/project".to_string()),
|
||||
output: "/tmp/project\n".to_string(),
|
||||
exit_code: Some(0),
|
||||
duration_ms: 7,
|
||||
truncated: false,
|
||||
failed_to_start: false,
|
||||
},
|
||||
};
|
||||
let json = encode_event(&event);
|
||||
assert!(json.contains("\"type\":\"input_shell_result\""));
|
||||
let decoded = parse_event_json(json.trim())?;
|
||||
let ServerEvent::InputShellResult { result } = decoded else {
|
||||
return Err(anyhow!("expected InputShellResult event"));
|
||||
};
|
||||
assert_eq!(result.command, "pwd");
|
||||
assert_eq!(result.cwd.as_deref(), Some("/tmp/project"));
|
||||
assert_eq!(result.exit_code, Some(0));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_protocol_enum_roundtrips_cover_wire_names() -> Result<()> {
|
||||
let transcript_modes = [
|
||||
(TranscriptMode::Insert, "insert"),
|
||||
(TranscriptMode::Append, "append"),
|
||||
(TranscriptMode::Replace, "replace"),
|
||||
(TranscriptMode::Send, "send"),
|
||||
];
|
||||
for (mode, wire) in transcript_modes {
|
||||
let json = serde_json::to_string(&mode)?;
|
||||
assert_eq!(json, format!("\"{}\"", wire));
|
||||
let decoded: TranscriptMode = serde_json::from_str(&json)?;
|
||||
assert_eq!(decoded, mode);
|
||||
}
|
||||
|
||||
let delivery_modes = [
|
||||
(CommDeliveryMode::Notify, "notify"),
|
||||
(CommDeliveryMode::Interrupt, "interrupt"),
|
||||
(CommDeliveryMode::Wake, "wake"),
|
||||
];
|
||||
for (mode, wire) in delivery_modes {
|
||||
let json = serde_json::to_string(&mode)?;
|
||||
assert_eq!(json, format!("\"{}\"", wire));
|
||||
let decoded: CommDeliveryMode = serde_json::from_str(&json)?;
|
||||
assert_eq!(decoded, mode);
|
||||
}
|
||||
|
||||
let feature_toggles = [
|
||||
(FeatureToggle::Memory, "memory"),
|
||||
(FeatureToggle::Swarm, "swarm"),
|
||||
(FeatureToggle::Autoreview, "autoreview"),
|
||||
(FeatureToggle::Autojudge, "autojudge"),
|
||||
];
|
||||
for (feature, wire) in feature_toggles {
|
||||
let json = serde_json::to_string(&feature)?;
|
||||
assert_eq!(json, format!("\"{}\"", wire));
|
||||
let decoded: FeatureToggle = serde_json::from_str(&json)?;
|
||||
assert_eq!(decoded, feature);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_feature_roundtrip() -> Result<()> {
|
||||
let req = Request::SetFeature {
|
||||
id: 77,
|
||||
feature: FeatureToggle::Swarm,
|
||||
enabled: true,
|
||||
};
|
||||
let json = serde_json::to_string(&req)?;
|
||||
assert!(json.contains("\"type\":\"set_feature\""));
|
||||
let decoded = parse_request_json(&json)?;
|
||||
let Request::SetFeature {
|
||||
id,
|
||||
feature,
|
||||
enabled,
|
||||
} = decoded
|
||||
else {
|
||||
return Err(anyhow!("expected SetFeature"));
|
||||
};
|
||||
assert_eq!(id, 77);
|
||||
assert_eq!(feature, FeatureToggle::Swarm);
|
||||
assert!(enabled);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_route_deserializes_as_set_model_compat_alias() -> Result<()> {
|
||||
// Legacy/desktop compatibility shape: a bare model string under the
|
||||
// `set_route` tag. `decode_request` (not raw serde) normalizes it.
|
||||
let decoded = decode_request(r#"{"type":"set_route","id":42,"model":"claude-opus-4-5"}"#)?;
|
||||
let Request::SetModel { id, model } = decoded else {
|
||||
return Err(anyhow!(
|
||||
"expected set_route compatibility alias to decode as SetModel"
|
||||
));
|
||||
};
|
||||
assert_eq!(id, 42);
|
||||
assert_eq!(model, "claude-opus-4-5");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_structured_set_route_decodes_as_set_route_not_set_model() -> Result<()> {
|
||||
// Regression for the "Invalid request: missing field `model`" bug seen when
|
||||
// switching models via the picker: a structured `set_route` request (with a
|
||||
// `selection` object, no `model` field) must decode as `Request::SetRoute`,
|
||||
// not be shadowed by the legacy `set_model` compatibility path.
|
||||
let request = Request::SetRoute {
|
||||
id: 7,
|
||||
selection: jcode_provider_core::RouteSelection {
|
||||
model: "gpt-5.5".to_string(),
|
||||
runtime_key: jcode_provider_core::RuntimeKey::OpenAIApiKey,
|
||||
api_method: "openai-api".to_string(),
|
||||
provider_label: "OpenAI".to_string(),
|
||||
detail: String::new(),
|
||||
},
|
||||
};
|
||||
let line = serde_json::to_string(&request)?;
|
||||
assert!(line.contains("\"type\":\"set_route\""));
|
||||
|
||||
let decoded = decode_request(&line)?;
|
||||
let Request::SetRoute { id, selection } = decoded else {
|
||||
return Err(anyhow!(
|
||||
"expected structured set_route to decode as SetRoute, got {decoded:?}"
|
||||
));
|
||||
};
|
||||
assert_eq!(id, 7);
|
||||
assert_eq!(selection.model, "gpt-5.5");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_subscribe_request_roundtrip_preserves_session_takeover_flags() -> Result<()> {
|
||||
let req = Request::Subscribe {
|
||||
id: 89,
|
||||
working_dir: Some("/tmp/project".to_string()),
|
||||
selfdev: Some(true),
|
||||
target_session_id: Some("sess_target".to_string()),
|
||||
client_instance_id: Some("client-123".to_string()),
|
||||
client_has_local_history: true,
|
||||
allow_session_takeover: true,
|
||||
terminal_env: vec![("ZELLIJ_SESSION_NAME".to_string(), "sessionB".to_string())],
|
||||
};
|
||||
let json = serde_json::to_string(&req)?;
|
||||
assert!(json.contains("\"type\":\"subscribe\""));
|
||||
let decoded = parse_request_json(&json)?;
|
||||
let Request::Subscribe {
|
||||
id,
|
||||
working_dir,
|
||||
selfdev,
|
||||
target_session_id,
|
||||
client_instance_id,
|
||||
client_has_local_history,
|
||||
allow_session_takeover,
|
||||
terminal_env,
|
||||
} = decoded
|
||||
else {
|
||||
return Err(anyhow!("expected Subscribe"));
|
||||
};
|
||||
assert_eq!(id, 89);
|
||||
assert_eq!(working_dir.as_deref(), Some("/tmp/project"));
|
||||
assert_eq!(selfdev, Some(true));
|
||||
assert_eq!(target_session_id.as_deref(), Some("sess_target"));
|
||||
assert_eq!(client_instance_id.as_deref(), Some("client-123"));
|
||||
assert!(client_has_local_history);
|
||||
assert!(allow_session_takeover);
|
||||
assert_eq!(
|
||||
terminal_env,
|
||||
vec![("ZELLIJ_SESSION_NAME".to_string(), "sessionB".to_string())]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_subscribe_request_defaults_optional_flags() -> Result<()> {
|
||||
let json = r#"{"type":"subscribe","id":91}"#;
|
||||
let decoded = parse_request_json(json)?;
|
||||
let Request::Subscribe {
|
||||
id,
|
||||
working_dir,
|
||||
selfdev,
|
||||
target_session_id,
|
||||
client_instance_id,
|
||||
client_has_local_history,
|
||||
allow_session_takeover,
|
||||
terminal_env,
|
||||
} = decoded
|
||||
else {
|
||||
return Err(anyhow!("expected Subscribe"));
|
||||
};
|
||||
assert_eq!(id, 91);
|
||||
assert_eq!(working_dir, None);
|
||||
assert_eq!(selfdev, None);
|
||||
assert_eq!(target_session_id, None);
|
||||
assert_eq!(client_instance_id, None);
|
||||
assert!(!client_has_local_history);
|
||||
assert!(!allow_session_takeover);
|
||||
assert!(terminal_env.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resume_session_defaults_sync_flags() -> Result<()> {
|
||||
let json = r#"{"type":"resume_session","id":92,"session_id":"sess_resume"}"#;
|
||||
let decoded = parse_request_json(json)?;
|
||||
let Request::ResumeSession {
|
||||
id,
|
||||
session_id,
|
||||
client_instance_id,
|
||||
client_has_local_history,
|
||||
allow_session_takeover,
|
||||
} = decoded
|
||||
else {
|
||||
return Err(anyhow!("expected ResumeSession"));
|
||||
};
|
||||
assert_eq!(id, 92);
|
||||
assert_eq!(session_id, "sess_resume");
|
||||
assert_eq!(client_instance_id, None);
|
||||
assert!(!client_has_local_history);
|
||||
assert!(!allow_session_takeover);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resume_all_sessions_request_roundtrip() -> Result<()> {
|
||||
let req = Request::ResumeAllSessions { id: 451 };
|
||||
let json = serde_json::to_string(&req)?;
|
||||
assert!(json.contains("\"type\":\"resume_all_sessions\""));
|
||||
let decoded = parse_request_json(&json)?;
|
||||
let Request::ResumeAllSessions { id } = decoded else {
|
||||
return Err(anyhow!("expected ResumeAllSessions"));
|
||||
};
|
||||
assert_eq!(id, 451);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resume_all_result_event_roundtrip() -> Result<()> {
|
||||
let event = ServerEvent::ResumeAllResult {
|
||||
id: 451,
|
||||
resumed: 2,
|
||||
skipped: 1,
|
||||
resumed_sessions: vec!["fox".to_string(), "owl".to_string()],
|
||||
message: "Resuming 2 interrupted sessions: fox, owl.".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&event)?;
|
||||
assert!(json.contains("\"type\":\"resume_all_result\""));
|
||||
let decoded = parse_event_json(&json)?;
|
||||
let ServerEvent::ResumeAllResult {
|
||||
id,
|
||||
resumed,
|
||||
skipped,
|
||||
resumed_sessions,
|
||||
message,
|
||||
} = decoded
|
||||
else {
|
||||
return Err(anyhow!("expected ResumeAllResult"));
|
||||
};
|
||||
assert_eq!(id, 451);
|
||||
assert_eq!(resumed, 2);
|
||||
assert_eq!(skipped, 1);
|
||||
assert_eq!(resumed_sessions, vec!["fox".to_string(), "owl".to_string()]);
|
||||
assert_eq!(message, "Resuming 2 interrupted sessions: fox, owl.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_request_roundtrip_preserves_images_and_system_reminder() -> Result<()> {
|
||||
let req = Request::Message {
|
||||
id: 88,
|
||||
content: "inspect this".to_string(),
|
||||
images: vec![
|
||||
("image/png".to_string(), "AAA".to_string()),
|
||||
("image/jpeg".to_string(), "BBB".to_string()),
|
||||
],
|
||||
system_reminder: Some("be concise".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&req)?;
|
||||
let decoded = parse_request_json(&json)?;
|
||||
let Request::Message {
|
||||
id,
|
||||
content,
|
||||
images,
|
||||
system_reminder,
|
||||
} = decoded
|
||||
else {
|
||||
return Err(anyhow!("expected Message"));
|
||||
};
|
||||
assert_eq!(id, 88);
|
||||
assert_eq!(content, "inspect this");
|
||||
assert_eq!(images.len(), 2);
|
||||
assert_eq!(images[0].0, "image/png");
|
||||
assert_eq!(images[1].0, "image/jpeg");
|
||||
assert_eq!(system_reminder.as_deref(), Some("be concise"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_guardrail_event_roundtrip() -> Result<()> {
|
||||
let event = ServerEvent::ProviderGuardrail {
|
||||
stop_reason: Some("refusal".to_string()),
|
||||
message: "Provider guardrail stopped the response".to_string(),
|
||||
};
|
||||
let json = encode_event(&event);
|
||||
assert!(json.contains("\"type\":\"provider_guardrail\""));
|
||||
let decoded = parse_event_json(json.trim())?;
|
||||
let ServerEvent::ProviderGuardrail {
|
||||
stop_reason,
|
||||
message,
|
||||
} = decoded
|
||||
else {
|
||||
return Err(anyhow!("expected ProviderGuardrail event"));
|
||||
};
|
||||
assert_eq!(stop_reason.as_deref(), Some("refusal"));
|
||||
assert_eq!(message, "Provider guardrail stopped the response");
|
||||
|
||||
// stop_reason is optional on the wire.
|
||||
let decoded = parse_event_json(
|
||||
r#"{"type":"provider_guardrail","message":"blocked"}"#,
|
||||
)?;
|
||||
let ServerEvent::ProviderGuardrail { stop_reason, .. } = decoded else {
|
||||
return Err(anyhow!("expected ProviderGuardrail event"));
|
||||
};
|
||||
assert!(stop_reason.is_none());
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
#[test]
|
||||
fn test_protocol_request_roundtrip_randomized_samples() -> Result<()> {
|
||||
use rand::{Rng, SeedableRng};
|
||||
|
||||
fn sample_ascii(rng: &mut rand::rngs::StdRng, max_len: usize) -> String {
|
||||
let len = rng.random_range(0..=max_len);
|
||||
(0..len)
|
||||
.map(|_| char::from(rng.random_range(b'a'..=b'z')))
|
||||
.collect()
|
||||
}
|
||||
|
||||
let mut rng = rand::rngs::StdRng::seed_from_u64(0xC0DEC0DE);
|
||||
|
||||
for id in 0..32u64 {
|
||||
let content = sample_ascii(&mut rng, 24);
|
||||
let images = if rng.random_bool(0.5) {
|
||||
vec![("image/png".to_string(), sample_ascii(&mut rng, 12))]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let system_reminder = if rng.random_bool(0.5) {
|
||||
Some(sample_ascii(&mut rng, 20))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let req = Request::Message {
|
||||
id,
|
||||
content: content.clone(),
|
||||
images: images.clone(),
|
||||
system_reminder: system_reminder.clone(),
|
||||
};
|
||||
let decoded = parse_request_json(&serde_json::to_string(&req)?)?;
|
||||
let Request::Message {
|
||||
id: decoded_id,
|
||||
content: decoded_content,
|
||||
images: decoded_images,
|
||||
system_reminder: decoded_system_reminder,
|
||||
} = decoded
|
||||
else {
|
||||
return Err(anyhow!("expected randomized Message"));
|
||||
};
|
||||
assert_eq!(decoded_id, id);
|
||||
assert_eq!(decoded_content, content);
|
||||
assert_eq!(decoded_images, images);
|
||||
assert_eq!(decoded_system_reminder, system_reminder);
|
||||
}
|
||||
|
||||
for id in 100..132u64 {
|
||||
let working_dir = rng
|
||||
.random_bool(0.5)
|
||||
.then(|| format!("/tmp/{}", sample_ascii(&mut rng, 12)));
|
||||
let selfdev = rng.random_bool(0.5).then(|| rng.random_bool(0.5));
|
||||
let target_session_id = rng.random_bool(0.5).then(|| format!("sess_{}", id));
|
||||
let client_instance_id = rng.random_bool(0.5).then(|| format!("client-{}", id));
|
||||
let client_has_local_history = rng.random_bool(0.5);
|
||||
let allow_session_takeover = rng.random_bool(0.5);
|
||||
let req = Request::Subscribe {
|
||||
id,
|
||||
working_dir: working_dir.clone(),
|
||||
selfdev,
|
||||
target_session_id: target_session_id.clone(),
|
||||
client_instance_id: client_instance_id.clone(),
|
||||
client_has_local_history,
|
||||
allow_session_takeover,
|
||||
terminal_env: Vec::new(),
|
||||
};
|
||||
let decoded = parse_request_json(&serde_json::to_string(&req)?)?;
|
||||
let Request::Subscribe {
|
||||
id: decoded_id,
|
||||
working_dir: decoded_working_dir,
|
||||
selfdev: decoded_selfdev,
|
||||
target_session_id: decoded_target_session_id,
|
||||
client_instance_id: decoded_client_instance_id,
|
||||
client_has_local_history: decoded_client_has_local_history,
|
||||
allow_session_takeover: decoded_allow_session_takeover,
|
||||
terminal_env: _,
|
||||
} = decoded
|
||||
else {
|
||||
return Err(anyhow!("expected randomized Subscribe"));
|
||||
};
|
||||
assert_eq!(decoded_id, id);
|
||||
assert_eq!(decoded_working_dir, working_dir);
|
||||
assert_eq!(decoded_selfdev, selfdev);
|
||||
assert_eq!(decoded_target_session_id, target_session_id);
|
||||
assert_eq!(decoded_client_instance_id, client_instance_id);
|
||||
assert_eq!(decoded_client_has_local_history, client_has_local_history);
|
||||
assert_eq!(decoded_allow_session_takeover, allow_session_takeover);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resume_session_roundtrip_preserves_client_sync_flags() -> Result<()> {
|
||||
let req = Request::ResumeSession {
|
||||
id: 90,
|
||||
session_id: "sess_resume".to_string(),
|
||||
client_instance_id: Some("client-456".to_string()),
|
||||
client_has_local_history: true,
|
||||
allow_session_takeover: true,
|
||||
};
|
||||
let json = serde_json::to_string(&req)?;
|
||||
assert!(json.contains("\"type\":\"resume_session\""));
|
||||
let decoded = parse_request_json(&json)?;
|
||||
let Request::ResumeSession {
|
||||
id,
|
||||
session_id,
|
||||
client_instance_id,
|
||||
client_has_local_history,
|
||||
allow_session_takeover,
|
||||
} = decoded
|
||||
else {
|
||||
return Err(anyhow!("expected ResumeSession"));
|
||||
};
|
||||
assert_eq!(id, 90);
|
||||
assert_eq!(session_id, "sess_resume");
|
||||
assert_eq!(client_instance_id.as_deref(), Some("client-456"));
|
||||
assert!(client_has_local_history);
|
||||
assert!(allow_session_takeover);
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user