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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:10:34 +08:00
commit a789495a98
1551 changed files with 718128 additions and 0 deletions
+21
View File
@@ -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"
+646
View File
@@ -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
}
}
+737
View File
@@ -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