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
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "jcode-agent-runtime"
version = "0.1.0"
edition = "2024"
[lib]
name = "jcode_agent_runtime"
path = "src/lib.rs"
[dependencies]
thiserror = "1"
tokio = { version = "1", features = ["sync"] }
[dev-dependencies]
tokio = { version = "1", features = ["sync", "rt-multi-thread", "time", "macros"] }
+282
View File
@@ -0,0 +1,282 @@
use std::sync::Arc;
/// A soft interrupt message queued for injection at the next safe point.
#[derive(Debug, Clone)]
pub struct SoftInterruptMessage {
pub content: String,
/// If true, can skip remaining tools when injected at point C.
pub urgent: bool,
pub source: SoftInterruptSource,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SoftInterruptSource {
User,
System,
BackgroundTask,
}
/// Thread-safe soft interrupt queue that can be accessed without holding the agent lock.
pub type SoftInterruptQueue = Arc<std::sync::Mutex<Vec<SoftInterruptMessage>>>;
/// Signal to move the currently executing tool to background.
/// Uses std::sync so it can be set without async from outside the agent lock.
pub type BackgroundToolSignal = Arc<std::sync::atomic::AtomicBool>;
/// Signal to gracefully stop generation.
pub type GracefulShutdownSignal = Arc<std::sync::atomic::AtomicBool>;
/// Async-aware interrupt signal that combines AtomicBool (sync read) with
/// tokio::Notify (async wake). Eliminates spin-loops during tool execution.
#[derive(Clone)]
pub struct InterruptSignal {
flag: Arc<std::sync::atomic::AtomicBool>,
/// Monotonic fire counter. Lets owners of a timed/deferred reset detect
/// that a *newer* fire landed in the meantime and skip the reset instead
/// of erasing a cancel the target has not observed yet (issue #428).
epoch: Arc<std::sync::atomic::AtomicU64>,
notify: Arc<tokio::sync::Notify>,
}
impl InterruptSignal {
pub fn new() -> Self {
Self {
flag: Arc::new(std::sync::atomic::AtomicBool::new(false)),
epoch: Arc::new(std::sync::atomic::AtomicU64::new(0)),
notify: Arc::new(tokio::sync::Notify::new()),
}
}
pub fn fire(&self) {
self.epoch.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.flag.store(true, std::sync::atomic::Ordering::SeqCst);
self.notify.notify_waiters();
}
pub fn is_set(&self) -> bool {
self.flag.load(std::sync::atomic::Ordering::SeqCst)
}
pub fn reset(&self) {
self.flag.store(false, std::sync::atomic::Ordering::SeqCst);
}
/// Current fire epoch. Capture this right after a [`fire`](Self::fire) to
/// later reset only that specific fire via
/// [`reset_if_epoch`](Self::reset_if_epoch).
pub fn epoch(&self) -> u64 {
self.epoch.load(std::sync::atomic::Ordering::SeqCst)
}
/// Reset the signal only if no newer [`fire`](Self::fire) happened since
/// `epoch` was captured. Returns `true` when the reset was applied.
///
/// If a racing fire lands between the epoch check and the reset, the
/// fire is restored (flag re-set and waiters re-notified) so no cancel
/// is ever silently erased.
pub fn reset_if_epoch(&self, epoch: u64) -> bool {
if self.epoch.load(std::sync::atomic::Ordering::SeqCst) != epoch {
return false;
}
self.flag.store(false, std::sync::atomic::Ordering::SeqCst);
if self.epoch.load(std::sync::atomic::Ordering::SeqCst) != epoch {
// A newer fire raced with the reset; restore it.
self.flag.store(true, std::sync::atomic::Ordering::SeqCst);
self.notify.notify_waiters();
return false;
}
true
}
pub async fn notified(&self) {
let mut notified = std::pin::pin!(self.notify.notified());
// Explicitly register this waiter with the Notify before checking the
// flag. `notify_waiters()` (used by `fire()`) wakes only registered
// waiters; current tokio registers a `notified()` future at creation,
// but `enable()` makes the registration explicit rather than relying
// on that version-specific guarantee, since a lost wakeup here parks
// the cancel path (agent stream loop, tool-wait select) until an
// unrelated event arrives (issue #428).
notified.as_mut().enable();
if self.is_set() {
return;
}
notified.await;
}
pub fn as_atomic(&self) -> Arc<std::sync::atomic::AtomicBool> {
Arc::clone(&self.flag)
}
/// True when `other` is a clone of this signal (shares the same state).
/// Used by cancel fan-out to avoid double-firing the same signal and by
/// diagnostics that need to detect stale signal instances (issue #428).
pub fn same_instance(&self, other: &Self) -> bool {
Arc::ptr_eq(&self.flag, &other.flag)
}
}
impl Default for InterruptSignal {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, thiserror::Error)]
#[error("{message}")]
pub struct StreamError {
pub message: String,
pub retry_after_secs: Option<u64>,
}
impl StreamError {
pub fn new(message: String, retry_after_secs: Option<u64>) -> Self {
Self {
message,
retry_after_secs,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
/// Documents the tokio semantics `InterruptSignal::notified()` relies on:
/// current tokio guarantees a `notified()` future receives wakeups from
/// `notify_waiters()` from the moment it is *created*, even before its
/// first poll. The explicit `enable()` in `notified()` makes that
/// registration explicit instead of relying on the version-specific
/// creation-time guarantee (hardening for issue #428).
#[tokio::test]
async fn notified_future_receives_notify_waiters_from_creation() {
let notify = tokio::sync::Notify::new();
// Created before the notification, not yet polled: must be woken.
let created_before = notify.notified();
notify.notify_waiters();
tokio::time::timeout(Duration::from_millis(100), created_before)
.await
.expect("a notified() future created before notify_waiters() must be woken");
// Created after the notification: must NOT be woken (notify_waiters
// stores no permit). This is why fire() also sets the atomic flag.
let created_after = notify.notified();
assert!(
tokio::time::timeout(Duration::from_millis(50), created_after)
.await
.is_err(),
"notify_waiters() must not store a permit for future waiters"
);
}
/// Probabilistic race hammer for issue #428: `fire()` must never be lost
/// regardless of where the waiter is between creating the `notified()`
/// future and its first poll. The agent stream loop recreates this future
/// per stream event, so under fast token streams the pre-fix race made
/// Esc/Ctrl+C cancels appear to be ignored.
#[test]
fn fire_never_loses_wakeup_while_notified_races() {
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_time()
.build()
.expect("runtime");
rt.block_on(async {
for i in 0..2000 {
let signal = InterruptSignal::new();
let waiter = {
let signal = signal.clone();
tokio::spawn(async move { signal.notified().await })
};
// Fire concurrently: the waiter may be anywhere between
// future creation and first poll.
signal.fire();
tokio::time::timeout(Duration::from_secs(2), waiter)
.await
.unwrap_or_else(|_| {
panic!(
"lost wakeup on iteration {i}: notified() missed fire() (issue #428)"
)
})
.expect("waiter task must not panic");
}
});
}
/// A fire() that happened before notified() is observed immediately.
#[tokio::test]
async fn notified_returns_immediately_when_already_fired() {
let signal = InterruptSignal::new();
signal.fire();
tokio::time::timeout(Duration::from_millis(100), signal.notified())
.await
.expect("pre-fired signal must resolve notified() immediately");
}
/// reset() clears the flag so subsequent notified() calls wait again.
#[tokio::test]
async fn reset_clears_fired_state() {
let signal = InterruptSignal::new();
signal.fire();
assert!(signal.is_set());
signal.reset();
assert!(!signal.is_set());
let waited = tokio::time::timeout(Duration::from_millis(50), signal.notified()).await;
assert!(waited.is_err(), "reset signal must park notified() again");
}
/// reset_if_epoch() clears the flag only for the fire that captured the
/// epoch. A deferred reset (e.g. the server's 500ms timer for detached
/// turns) must not erase a newer cancel fired in the meantime, otherwise
/// rapid repeated Esc presses cancel each other out (issue #428).
#[test]
fn reset_if_epoch_skips_when_newer_fire_landed() {
let signal = InterruptSignal::new();
signal.fire();
let first_epoch = signal.epoch();
// A second cancel (repeated Esc) fires before the deferred reset runs.
signal.fire();
assert!(
!signal.reset_if_epoch(first_epoch),
"stale deferred reset must be skipped"
);
assert!(
signal.is_set(),
"newer cancel must survive the stale deferred reset"
);
// The reset scheduled for the latest fire still works.
let second_epoch = signal.epoch();
assert!(signal.reset_if_epoch(second_epoch));
assert!(!signal.is_set());
// And a reset for an already-consumed epoch stays a no-op.
assert!(!signal.reset_if_epoch(first_epoch));
}
/// A fire() racing the flag-clear inside reset_if_epoch() is restored
/// rather than silently erased.
#[test]
fn reset_if_epoch_never_erases_concurrent_fire() {
for _ in 0..2000 {
let signal = InterruptSignal::new();
signal.fire();
let epoch = signal.epoch();
let firer = {
let signal = signal.clone();
std::thread::spawn(move || signal.fire())
};
let _ = signal.reset_if_epoch(epoch);
firer.join().expect("firer thread");
assert!(
signal.is_set(),
"a concurrent fire() must never be erased by reset_if_epoch()"
);
}
}
}
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "jcode-ambient-types"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
chrono = { version = "0.4", features = ["serde"] }
serde = { version = "1", features = ["derive"] }
+32
View File
@@ -0,0 +1,32 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum UsageSource {
User,
Ambient,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UsageRecord {
pub timestamp: DateTime<Utc>,
pub source: UsageSource,
pub tokens_input: u32,
pub tokens_output: u32,
pub provider: String,
}
impl UsageRecord {
pub fn total_tokens(&self) -> u64 {
self.tokens_input as u64 + self.tokens_output as u64
}
}
#[derive(Debug, Clone)]
pub struct RateLimitInfo {
pub limit_tokens: Option<u64>,
pub remaining_tokens: Option<u64>,
pub limit_requests: Option<u64>,
pub remaining_requests: Option<u64>,
pub reset_at: Option<DateTime<Utc>>,
}
+141
View File
@@ -0,0 +1,141 @@
[package]
name = "jcode-app-core"
version = "0.1.0"
edition = "2024"
publish = false
# Application core for jcode: all non-presentation modules (server, agent,
# provider, auth, session, tool, config, etc.) extracted out of the monolithic
# root `jcode` crate so they compile as a separate rustc unit. The root `jcode`
# crate (cli + tui + bin) re-exports this crate's modules via `pub use
# jcode_app_core::*`, preserving every existing `crate::<module>` path.
[lib]
name = "jcode_app_core"
path = "src/lib.rs"
[dependencies]
# NOTE: the jemalloc allocator stats live in `jcode-base` (process_memory) and
# local embeddings live in `jcode-base` (embedding); this crate forwards those
# features to jcode-base rather than depending on the backing crates directly.
# Async runtime
tokio = { version = "1", features = ["fs", "io-std", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] }
tokio-util = { version = "0.7", features = ["rt"] }
futures = "0.3"
async-trait = "0.1"
# HTTP client
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "blocking", "charset", "http2", "system-proxy", "rustls-tls", "rustls-tls-native-roots"] }
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["raw_value"] }
# CLI
# File operations
glob = "0.3"
similar = "2" # diffing for edits
# Utilities
dirs = "5" # home directory
anyhow = "1"
libc = "0.2" # Unix system calls (flock)
chrono = { version = "0.4", features = ["serde"] }
regex = "1"
urlencoding = "2" # URL encoding for web search
uuid = { version = "1", features = ["v4", "v5"] }
# Embeddings (local inference) live in jcode-base; this crate forwards the
# `embeddings` feature to jcode-base rather than depending on jcode-embedding.
jcode-import-core = { path = "../jcode-import-core" }
# OAuth
base64 = "0.22"
sha2 = "0.10"
rand = "0.9.3"
url = "2"
open = "5" # Open URLs in browser
jcode-agent-runtime = { path = "../jcode-agent-runtime" }
jcode-ambient-types = { path = "../jcode-ambient-types" }
jcode-notify-email = { path = "../jcode-notify-email" }
jcode-provider-core = { path = "../jcode-provider-core" }
# NOTE: jcode-app-core does NOT depend on any jcode-tui-* crate. They were
# unused dead dependency edges here (the TUI declares them itself). Removing
# them stops a jcode-tui-* edit from cascading a recompile through app-core.
jcode-update-core = { path = "../jcode-update-core" }
jcode-terminal-image = { path = "../jcode-terminal-image" }
# Streaming
tokio-stream = "0.1"
# TUI
ratatui = "0.30"
crossterm = { version = "0.29", features = ["event-stream"] }
# Markdown & syntax highlighting
unicode-width = "0.2" # Unicode character display width
# PDF parsing (behind feature flag)
jcode-pdf = { path = "../jcode-pdf", optional = true }
jcode-build-support = { path = "../jcode-build-support" }
jcode-build-meta = { path = "../jcode-build-meta" }
# Foundational layer (provider, auth, config, session, message, memory, ...).
# Re-exported via `pub use jcode_base::*` in lib.rs. default-features=false so
# this crate controls jcode-base's optional features (see [features] below).
jcode-base = { path = "../jcode-base", default-features = false }
jcode-core = { path = "../jcode-core" }
jcode-message-types = { path = "../jcode-message-types" }
jcode-overnight-core = { path = "../jcode-overnight-core" }
jcode-plan = { path = "../jcode-plan" }
jcode-swarm-core = { path = "../jcode-swarm-core" }
jcode-selfdev-types = { path = "../jcode-selfdev-types" }
jcode-session-types = { path = "../jcode-session-types" }
jcode-setup-hints = { path = "../jcode-setup-hints" }
jcode-task-types = { path = "../jcode-task-types" }
jcode-tool-core = { path = "../jcode-tool-core" }
jcode-tool-types = { path = "../jcode-tool-types" }
# Archive extraction (for auto-update)
flate2 = "1"
tar = "0.4"
tempfile = "3"
agentgrep = { git = "https://github.com/1jehuang/agentgrep.git", tag = "v0.1.6" }
[features]
default = ["pdf", "embeddings", "bedrock"]
# jemalloc allocator stats live in jcode-base (process_memory); forward there.
jemalloc = ["jcode-base/jemalloc"]
jemalloc-prof = ["jcode-base/jemalloc-prof"]
# local embeddings live in jcode-base (embedding); forward there.
embeddings = ["jcode-base/embeddings"]
# live AWS Bedrock support lives in jcode-base (provider/bedrock); forward there.
bedrock = ["jcode-base/bedrock"]
# PDF text extraction lives in this crate (tool/read.rs).
pdf = ["dep:jcode-pdf"]
# Compiles this crate's test-only helpers/accessors (e.g. the
# ExternalAuthReviewCandidate read accessors) as `pub` and forwards
# jcode-base's `test-support` so downstream crates' test targets can reach the
# whole stack's helpers. Never enabled in normal (non-test) builds.
test-support = ["jcode-base/test-support"]
[dev-dependencies]
# Used by async streaming tests (ambient/runner_tests, server/client_actions_tests).
async-stream = "0.3"
# Enables jcode-base's test-support helpers (storage::lock_test_env,
# auth::test_sandbox, bus::reset_models_updated_publish_state_for_tests) for
# *this* crate's own test target. Feature unification applies this only to
# test/bench/example builds, not to a normal `cargo build`.
jcode-base = { path = "../jcode-base", features = ["test-support"] }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_System_Threading"] }
[target.'cfg(target_os = "macos")'.dependencies]
global-hotkey = "0.7"
# Native desktop control for the `macos_computer_use` tool (synthetic mouse/keyboard
# events, cursor positioning, display bounds). `highsierra` enables the
# CGEventCreateScrollWheelEvent2 binding used for scroll.
core-graphics = { version = "0.23", features = ["highsierra"] }
+997
View File
@@ -0,0 +1,997 @@
#![cfg_attr(test, allow(clippy::await_holding_lock))]
mod compaction;
mod environment;
mod inline_tail;
mod interrupts;
mod messages;
mod prompting;
mod provider;
mod response_recovery;
mod status;
mod streaming;
mod tools;
mod turn_execution;
mod turn_loops;
mod turn_streaming_mpsc;
mod utils;
use self::streaming::{send_stream_keepalive_mpsc, stream_keepalive_ticker};
use self::tools::{
cap_sdk_tool_content_for_history, cap_tool_output_for_history, print_tool_summary,
tool_output_side_pane_images, tool_output_to_content_blocks,
};
use self::utils::trace_enabled;
use crate::build;
use crate::bus::{Bus, BusEvent, SubagentStatus, ToolEvent, ToolStatus};
use crate::cache_tracker::CacheTracker;
use crate::compaction::CompactionEvent;
use crate::id;
use crate::logging;
use crate::message::{
ContentBlock, Message, Role, StreamEvent, TOOL_OUTPUT_MISSING_TEXT, ToolCall, ToolDefinition,
};
use crate::protocol::{HistoryMessage, ServerEvent};
use crate::provider::{NativeToolResult, Provider, ProviderRuntimeState};
use crate::session::{GitState, Session, SessionStatus, StoredDisplayRole, StoredMessage};
use crate::skill::SkillRegistry;
use crate::tool::{Registry, ToolContext, ToolExecutionMode};
use anyhow::Result;
use futures::StreamExt;
use std::collections::{HashMap, HashSet};
use std::hash::{Hash, Hasher};
use std::io::{self, Write};
use std::path::PathBuf;
use std::sync::{Arc, LazyLock, Mutex as StdMutex};
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
use interrupts::{NoToolCallOutcome, PostToolInterruptOutcome};
pub use jcode_agent_runtime::{
BackgroundToolSignal, GracefulShutdownSignal, InterruptSignal, SoftInterruptMessage,
SoftInterruptQueue, SoftInterruptSource, StreamError,
};
const JCODE_NATIVE_TOOLS: &[&str] = &["selfdev", "communicate"];
static RECOVERED_TEXT_WRAPPED_TOOL_CALLS: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
static JCODE_REPO_SOURCE_STATE: LazyLock<(Option<String>, Option<bool>)> = LazyLock::new(|| {
crate::build::get_repo_dir()
.map(|repo_dir| {
(
build::current_git_hash(&repo_dir).ok(),
build::is_working_tree_dirty(&repo_dir).ok(),
)
})
.unwrap_or((None, None))
});
static WORKING_GIT_STATE_CACHE: LazyLock<StdMutex<HashMap<PathBuf, Option<GitState>>>> =
LazyLock::new(|| StdMutex::new(HashMap::new()));
const STREAM_KEEPALIVE_PONG_ID: u64 = 0;
fn stable_hash_str(value: &str) -> u64 {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
value.hash(&mut hasher);
hasher.finish()
}
fn stable_hash_json<T: serde::Serialize + ?Sized>(value: &T) -> u64 {
let encoded = serde_json::to_string(value).unwrap_or_default();
stable_hash_str(&encoded)
}
fn stable_json_len<T: serde::Serialize + ?Sized>(value: &T) -> usize {
serde_json::to_string(value)
.map(|encoded| encoded.len())
.unwrap_or_default()
}
fn message_hashes(messages: &[Message]) -> Vec<u64> {
// Hash the cache-relevant projection, not the raw Message. Raw hashing
// keys off non-transmitted metadata (timestamp, tool_duration_ms,
// ReasoningTrace blocks, cache_control markers), which triggers spurious
// harness:_prefix_changed KV-cache miss reports when the same message is
// re-serialized with backfilled metadata on the next turn.
crate::message::cache_relevant_message_hashes(messages)
}
fn kv_cache_request_event(
messages: &[Message],
tools: &[ToolDefinition],
system_static: &str,
ephemeral_messages: &[Message],
) -> ServerEvent {
let ephemeral_hash = if ephemeral_messages.is_empty() {
None
} else {
Some(stable_hash_json(ephemeral_messages))
};
ServerEvent::KvCacheRequest {
system_static_hash: stable_hash_str(system_static),
tools_hash: stable_hash_json(tools),
messages_hash: stable_hash_json(&crate::message::cache_relevant_messages(messages)),
message_hashes: message_hashes(messages),
message_count: messages.len(),
tool_count: tools.len(),
system_static_chars: system_static.chars().count(),
tools_json_chars: stable_json_len(tools),
messages_json_chars: stable_json_len(messages),
ephemeral_hash,
ephemeral_chars: stable_json_len(ephemeral_messages),
ephemeral_message_count: ephemeral_messages.len(),
}
}
fn log_agent_provider_stream_lifecycle(
level: logging::LogLevel,
agent: &Agent,
phase: &str,
api_start: Instant,
fields: Vec<(&str, String)>,
) {
let mut owned = vec![
("phase".to_string(), phase.to_string()),
("provider".to_string(), agent.provider.name().to_string()),
("model".to_string(), agent.provider.model()),
("session_id".to_string(), agent.session.id.clone()),
(
"provider_session_id".to_string(),
agent
.provider_session_id
.clone()
.unwrap_or_else(|| "none".to_string()),
),
(
"connection_type".to_string(),
agent
.last_connection_type
.clone()
.unwrap_or_else(|| "unknown".to_string()),
),
(
"elapsed_ms".to_string(),
api_start.elapsed().as_millis().to_string(),
),
];
owned.extend(
fields
.into_iter()
.map(|(key, value)| (key.to_string(), value)),
);
logging::event(level, "AGENT_PROVIDER_STREAM_LIFECYCLE", owned);
}
/// Token usage from the last API request
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct TokenUsage {
pub input_tokens: u64,
pub output_tokens: u64,
pub cache_read_input_tokens: Option<u64>,
pub cache_creation_input_tokens: Option<u64>,
}
#[derive(Debug, Clone)]
struct RewindUndoSnapshot {
messages: Vec<StoredMessage>,
provider_session_id: Option<String>,
session_provider_session_id: Option<String>,
visible_message_count: usize,
}
pub struct Agent {
provider: Arc<dyn Provider>,
registry: Registry,
skills: Arc<SkillRegistry>,
session: Session,
active_skill: Option<String>,
allowed_tools: Option<HashSet<String>>,
disabled_tools: HashSet<String>,
/// Provider-specific session ID for conversation resume (e.g., Claude Code CLI session)
provider_session_id: Option<String>,
/// Last upstream provider (OpenRouter) observed for this session
last_upstream_provider: Option<String>,
/// Last observed transport/connection type for this session
last_connection_type: Option<String>,
/// Last provider-supplied human-readable transport detail for this session
last_status_detail: Option<String>,
/// Pending swarm alerts to inject into the next turn
pending_alerts: Vec<String>,
/// Transient reminder injected into provider requests for the current turn only.
/// Not persisted to session history.
current_turn_system_reminder: Option<String>,
/// Tool call ids observed in the current session transcript.
tool_call_ids: HashSet<String>,
/// Tool result ids observed in the current session transcript.
tool_result_ids: HashSet<String>,
/// Number of stored session messages already indexed for missing tool-output repair.
tool_output_scan_index: usize,
/// Soft interrupt queue: messages to inject at next safe point without cancelling
/// Uses std::sync::Mutex so it can be accessed without async, even while agent is processing
soft_interrupt_queue: SoftInterruptQueue,
/// Signal from client to move the currently executing tool to background
background_tool_signal: InterruptSignal,
/// Signal to gracefully stop generation (checkpoint partial response and exit)
graceful_shutdown: InterruptSignal,
/// Client-side cache tracking for detecting append-only violations
cache_tracker: CacheTracker,
/// Last token usage from API request (for debug socket queries)
last_usage: TokenUsage,
/// Locked tool list: once the first API request is sent, freeze the tool list
/// to avoid cache invalidation when MCP tools arrive asynchronously.
/// Cleared on compaction/reset.
locked_tools: Option<Vec<ToolDefinition>>,
/// One-shot guard for the async MCP-registration race (#206).
///
/// MCP servers connect on a background task and register `mcp__*` tools
/// seconds after the session starts (we deliberately do NOT block the first
/// turn on MCP connection, so the user can talk to the agent immediately).
/// The first turn therefore locks a snapshot without MCP tools. We allow
/// exactly one rebuild to pick them up — an intentional, one-time provider
/// prompt-cache miss. Once that rebuild happens (or we confirm there are no
/// MCP tools to wait for), this is set so the per-turn registry scan stops.
/// Reset whenever the tool list is intentionally unlocked.
mcp_late_register_resolved: bool,
/// Override system prompt (used by ambient mode to inject a custom prompt)
system_prompt_override: Option<String>,
/// Whether memory features are enabled for this session
memory_enabled: bool,
/// One-step undo snapshot captured before the most recent rewind.
rewind_undo_snapshot: Option<RewindUndoSnapshot>,
/// Channel for tools to request stdin input from the user
stdin_request_tx: Option<tokio::sync::mpsc::UnboundedSender<crate::tool::StdinInputRequest>>,
/// Canonical reducer-backed view of runtime provider/model selection.
provider_runtime_state: ProviderRuntimeState,
/// When true, this session is an inline swarm worker: stream a throttled
/// output tail to the global bus so the coordinator's inline gallery can
/// render a live viewport. Off for normal sessions to avoid bus traffic.
inline_output_tap: bool,
/// Rolling activity tail (text + tool markers) for the inline output tap.
/// Persists across turns so the coordinator's viewport never blanks at
/// turn boundaries or freezes during long tool calls.
inline_tail: inline_tail::InlineTailBuffer,
}
impl Agent {
fn should_track_client_cache(&self) -> bool {
match std::env::var("JCODE_TRACK_CLIENT_CACHE") {
Ok(value) => {
let value = value.trim();
!value.is_empty() && value != "0" && !value.eq_ignore_ascii_case("false")
}
Err(_) => false,
}
}
fn build_base(
provider: Arc<dyn Provider>,
registry: Registry,
session: Session,
allowed_tools: Option<HashSet<String>>,
disabled_tools: HashSet<String>,
) -> Self {
let skills = SkillRegistry::shared_snapshot();
let initial_provider_model = provider.model();
let agent = Self {
provider,
registry,
skills,
session,
active_skill: None,
allowed_tools,
disabled_tools,
provider_session_id: None,
last_upstream_provider: None,
last_connection_type: None,
last_status_detail: None,
pending_alerts: Vec::new(),
current_turn_system_reminder: None,
tool_call_ids: HashSet::new(),
tool_result_ids: HashSet::new(),
tool_output_scan_index: 0,
soft_interrupt_queue: Arc::new(std::sync::Mutex::new(Vec::new())),
background_tool_signal: InterruptSignal::new(),
graceful_shutdown: InterruptSignal::new(),
cache_tracker: CacheTracker::new(),
last_usage: TokenUsage::default(),
locked_tools: None,
mcp_late_register_resolved: false,
system_prompt_override: None,
memory_enabled: crate::config::config().features.memory,
rewind_undo_snapshot: None,
stdin_request_tx: None,
provider_runtime_state: ProviderRuntimeState::observed(initial_provider_model),
inline_output_tap: false,
inline_tail: inline_tail::InlineTailBuffer::default(),
};
crate::tool::set_session_tool_policy(
&agent.session.id,
agent.allowed_tools.clone(),
agent.disabled_tools.clone(),
);
agent
}
fn current_skills_snapshot(&self) -> Arc<SkillRegistry> {
// Global skills come from the process-wide shared registry; the
// project-local overlay is composed fresh from this session's
// workspace root so per-repo skills are session-scoped, immediately
// visible, and never leak across sessions (issue #457).
let global = self
.registry
.skills()
.try_read()
.map(|skills| Arc::new(skills.clone()))
.unwrap_or_else(|_| self.skills.clone());
let working_dir = self
.session
.working_dir
.as_deref()
.map(std::path::Path::new);
Arc::new(SkillRegistry::effective_for_working_dir(
&global,
working_dir,
))
}
pub fn available_skill_names(&self) -> Vec<String> {
self.current_skills_snapshot()
.list()
.iter()
.map(|skill| skill.name.clone())
.collect()
}
pub fn new(provider: Arc<dyn Provider>, registry: Registry) -> Self {
Self::new_with_initial_working_dir(provider, registry, None)
}
pub(crate) fn new_with_initial_working_dir(
provider: Arc<dyn Provider>,
registry: Registry,
working_dir: Option<&str>,
) -> Self {
let tool_selection = crate::config::config().tools.selection();
let mut session = Session::create(None, None);
if let Some(working_dir) = working_dir {
session.working_dir = Some(working_dir.to_string());
}
let mut agent = Self::build_base(
provider,
registry,
session,
tool_selection.allowed_tools,
tool_selection.disabled_tools,
);
agent.session.mark_active();
agent.session.model = Some(agent.provider.model());
agent.session.provider_key =
crate::session::derive_session_provider_key(agent.provider.name());
agent.session.ensure_initial_session_context_message();
agent.seed_compaction_from_session();
agent.log_env_snapshot("create");
agent.fire_session_lifecycle_hook("session_start", "create");
crate::telemetry::begin_session_with_parent(
agent.provider.name(),
&agent.provider.model(),
agent.session.parent_id.clone(),
false,
);
agent
}
pub fn new_with_session(
provider: Arc<dyn Provider>,
registry: Registry,
session: Session,
allowed_tools: Option<HashSet<String>>,
) -> Self {
let tool_selection = if let Some(allowed_tools) = allowed_tools {
crate::config::ToolSelection {
allowed_tools: Some(allowed_tools),
disabled_tools: HashSet::new(),
}
} else {
crate::config::config().tools.selection()
};
let mut agent = Self::build_base(
provider,
registry,
session,
tool_selection.allowed_tools,
tool_selection.disabled_tools,
);
agent.session.mark_active();
if agent.session.provider_key.is_none() {
agent.session.provider_key =
crate::session::derive_session_provider_key(agent.provider.name());
}
if let Some(model) = agent.session.model.clone() {
let model_request =
crate::provider::MultiProvider::model_switch_request_for_session_route(
&model,
agent.session.provider_key.as_deref(),
agent.session.route_api_method.as_deref(),
);
if let Err(e) = crate::provider::set_model_with_auth_refresh(
agent.provider.as_ref(),
&model_request,
) {
logging::error(&format!(
"Failed to restore session model '{}' via '{}': {}",
model, model_request, e
));
}
} else {
agent.session.model = Some(agent.provider.model());
}
agent.restore_reasoning_effort_from_session();
agent.session.ensure_initial_session_context_message();
agent.sync_memory_dedup_state_from_session();
agent.seed_compaction_from_session();
agent.log_env_snapshot("attach");
agent.fire_session_lifecycle_hook("session_start", "attach");
crate::telemetry::begin_session_with_parent(
agent.provider.name(),
&agent.provider.model(),
agent.session.parent_id.clone(),
false,
);
agent
}
fn seed_compaction_from_session(&mut self) {
logging::info(&format!(
"seed_compaction_from_session: session has {} messages",
self.session.messages.len()
));
let compaction = self.registry.compaction();
let mut manager = match compaction.try_write() {
Ok(manager) => manager,
Err(_) => {
logging::warn(
"seed_compaction_from_session: compaction lock unavailable, skipping restore",
);
return;
}
};
manager.reset();
let budget = self.provider.context_window();
manager.set_budget(budget);
if let Some(state) = self.session.compaction.as_ref() {
manager.restore_persisted_stored_state_with(state, &self.session.messages);
} else {
manager.seed_restored_stored_messages_with(&self.session.messages);
}
let sanitized_state = if manager.discard_oversized_openai_native_compaction() {
Some(manager.persisted_state())
} else {
None
};
logging::info(&format!(
"seed_compaction_from_session: seeded compaction with {} messages",
self.session.messages.len()
));
drop(manager);
if let Some(state) = sanitized_state {
self.session.compaction = state;
self.persist_session_best_effort("sanitized oversized OpenAI native compaction");
}
}
fn sync_memory_dedup_state_from_session(&self) {
crate::memory::sync_injected_memories(
&self.session.id,
&self.session.injected_memory_ids(),
);
}
fn record_memory_injection_in_session(&mut self, memory: &crate::memory::PendingMemory) {
let count = memory.count.max(1);
let age_ms = memory.computed_at.elapsed().as_millis() as u64;
let summary = if count == 1 {
"🧠 auto-recalled 1 memory".to_string()
} else {
format!("🧠 auto-recalled {} memories", count)
};
let display_prompt = memory.display_prompt.clone().unwrap_or_else(|| {
if memory.prompt.trim().is_empty() {
"# Memory\n\n## Notes\n1. (empty injection payload)".to_string()
} else {
memory.prompt.clone()
}
});
self.session.record_memory_injection(
summary,
display_prompt,
count as u32,
age_ms,
memory.memory_ids.clone(),
);
if let Err(err) = self.session.save() {
logging::warn(&format!(
"Failed to persist memory injection for session {}: {}",
self.session.id, err
));
}
}
fn memory_injection_message(memory: &crate::memory::PendingMemory) -> Message {
Message::user(&format!(
"<system-reminder>\n{}\n</system-reminder>",
memory.prompt
))
}
pub(super) fn prepare_memory_injection_message(
&mut self,
memory: &crate::memory::PendingMemory,
) -> (Message, bool) {
let message = Self::memory_injection_message(memory);
let persist = crate::config::config().features.persist_memory_injections;
if persist {
self.add_message_with_display_role(
Role::User,
message.content.clone(),
Some(StoredDisplayRole::System),
);
self.persist_session_best_effort("persisted memory injection message");
}
(message, persist)
}
fn persist_session_best_effort(&mut self, context: &str) {
if let Err(err) = self.session.save() {
logging::warn(&format!(
"Failed to persist {} for session {}: {}",
context, self.session.id, err
));
}
}
fn reset_runtime_state_for_session_change(&mut self) {
self.active_skill = None;
self.last_upstream_provider = None;
self.last_connection_type = None;
self.last_status_detail = None;
self.pending_alerts.clear();
self.current_turn_system_reminder = None;
self.reset_tool_output_tracking();
if let Ok(mut queue) = self.soft_interrupt_queue.lock() {
queue.clear();
}
self.background_tool_signal.reset();
self.graceful_shutdown.reset();
self.cache_tracker.reset();
self.last_usage = TokenUsage::default();
self.locked_tools = None;
self.mcp_late_register_resolved = false;
self.rewind_undo_snapshot = None;
}
fn sync_session_compaction_state_from_manager(
&mut self,
manager: &crate::compaction::CompactionManager,
) {
let new_state = manager.persisted_state();
if self.session.compaction != new_state {
self.session.compaction = new_state;
if let Err(err) = self.session.save() {
logging::error(&format!(
"Failed to persist compaction state for session {}: {}",
self.session.id, err
));
}
}
}
fn apply_openai_native_compaction(
&mut self,
encrypted_content: String,
compacted_count: usize,
) -> Result<()> {
let encrypted_content_len = encrypted_content.len();
let (summary_text, openai_encrypted_content) =
if crate::provider::openai_request::openai_encrypted_content_is_sendable(
&encrypted_content,
) {
(String::new(), Some(encrypted_content))
} else {
logging::warn(&format!(
"Discarding oversized OpenAI native compaction payload before persist ({} chars)",
encrypted_content_len,
));
(
crate::provider::openai_request::openai_encrypted_content_fallback_summary(
encrypted_content_len,
),
None,
)
};
let state = crate::session::StoredCompactionState {
summary_text,
openai_encrypted_content,
covers_up_to_turn: compacted_count,
original_turn_count: compacted_count,
compacted_count,
};
self.session.compaction = Some(state.clone());
let compaction = self.registry.compaction();
if let Ok(mut manager) = compaction.try_write() {
manager.set_budget(self.provider.context_window());
manager.restore_persisted_stored_state_with(&state, &self.session.messages);
}
self.cache_tracker.reset();
self.locked_tools = None;
self.mcp_late_register_resolved = false;
self.provider_session_id = None;
self.session.provider_session_id = None;
self.session.save()?;
crate::runtime_memory_log::emit_event(
crate::runtime_memory_log::RuntimeMemoryLogEvent::new(
"native_compaction_applied",
"provider_native_compaction_persisted",
)
.with_session_id(self.session.id.clone())
.force_attribution(),
);
Ok(())
}
fn messages_for_provider(&mut self) -> (Vec<Message>, Option<CompactionEvent>) {
if self.provider.supports_compaction() || self.session.compaction.is_some() {
let compaction = self.registry.compaction();
match compaction.try_write() {
Ok(mut manager) => {
let discarded_oversized_native =
manager.discard_oversized_openai_native_compaction();
let messages = {
let all_messages = self.session.provider_messages();
if self.provider.uses_jcode_compaction() {
let action =
manager.ensure_context_fits(all_messages, self.provider.clone());
match action {
crate::compaction::CompactionAction::BackgroundStarted {
trigger,
} => {
logging::info(&format!(
"Background compaction started ({})",
trigger
));
}
crate::compaction::CompactionAction::HardCompacted(dropped) => {
logging::warn(&format!(
"Emergency hard compact: dropped {} messages (context was critical)",
dropped
));
}
crate::compaction::CompactionAction::None => {}
}
}
manager.messages_for_api_with(all_messages)
};
let event = manager.take_compaction_event();
if event.is_some() || discarded_oversized_native {
self.sync_session_compaction_state_from_manager(&manager);
}
if event.is_some() {
self.note_compaction_applied();
self.persist_session_best_effort("compaction completion");
}
let user_count = messages
.iter()
.filter(|message| matches!(message.role, Role::User))
.count();
let assistant_count = messages.len().saturating_sub(user_count);
logging::info(&format!(
"messages_for_provider (compaction): returning {} messages (user={}, assistant={})",
messages.len(),
user_count,
assistant_count,
));
return (messages, event);
}
Err(_) => {
logging::info("messages_for_provider: compaction lock failed, using session");
}
};
}
let all_messages = self.session.provider_messages();
let messages = all_messages.to_vec();
let user_count = messages
.iter()
.filter(|message| matches!(message.role, Role::User))
.count();
let assistant_count = messages.len().saturating_sub(user_count);
logging::info(&format!(
"messages_for_provider (session): returning {} messages (user={}, assistant={})",
messages.len(),
user_count,
assistant_count,
));
(messages, None)
}
fn record_client_cache_request(&mut self, messages: &[Message]) {
if !self.should_track_client_cache() {
return;
}
let fast_snapshot =
if !self.provider.uses_jcode_compaction() && self.session.compaction.is_none() {
let previous_count = self.cache_tracker.previous_message_count();
let prefix_hashes = self.session.provider_message_prefix_hashes();
let current_count = prefix_hashes.len();
let current_full_hash = prefix_hashes.last().copied();
let prefix_hash_at_previous_count =
if previous_count == 0 || previous_count > current_count {
None
} else {
Some(prefix_hashes[previous_count - 1])
};
Some((
current_count,
prefix_hash_at_previous_count,
current_full_hash,
))
} else {
None
};
let violation =
if let Some((current_count, prefix_hash_at_previous_count, current_full_hash)) =
fast_snapshot
{
self.cache_tracker.record_prefix_hash_snapshot(
current_count,
prefix_hash_at_previous_count,
current_full_hash,
)
} else {
self.cache_tracker.record_request(messages)
};
if let Some(violation) = violation {
logging::warn(&format!(
"CLIENT_CACHE_VIOLATION: {} | turn={} messages={}",
violation.reason, violation.turn, violation.message_count
));
}
}
fn repair_missing_tool_outputs(&mut self) -> usize {
if self.tool_output_scan_index > self.session.messages.len() {
self.reset_tool_output_tracking();
}
let scan_start = self.tool_output_scan_index;
let mut new_result_ids = Vec::new();
let mut assistant_tool_uses: Vec<(usize, Vec<String>)> = Vec::new();
for (index, msg) in self.session.messages.iter().enumerate().skip(scan_start) {
match msg.role {
Role::User => {
for block in &msg.content {
if let ContentBlock::ToolResult { tool_use_id, .. } = block {
new_result_ids.push(tool_use_id.clone());
}
}
}
Role::Assistant => {
let tool_uses = msg
.content
.iter()
.filter_map(|block| match block {
ContentBlock::ToolUse { id, .. } => Some(id.clone()),
_ => None,
})
.collect::<Vec<_>>();
if !tool_uses.is_empty() {
assistant_tool_uses.push((index, tool_uses));
}
}
}
}
self.tool_result_ids.extend(new_result_ids);
let mut missing_repairs: Vec<(usize, Vec<String>)> = Vec::new();
for (index, tool_uses) in assistant_tool_uses {
let mut missing_for_message = Vec::new();
for id in tool_uses {
self.tool_call_ids.insert(id.clone());
if !self.tool_result_ids.contains(&id) {
missing_for_message.push(id);
}
}
if !missing_for_message.is_empty() {
missing_repairs.push((index, missing_for_message));
}
}
self.tool_output_scan_index = self.session.messages.len();
let mut repaired = 0usize;
let mut inserted = 0usize;
for (index, missing_for_message) in missing_repairs {
for (offset, id) in missing_for_message.iter().enumerate() {
let tool_block = ContentBlock::ToolResult {
tool_use_id: id.clone(),
content: TOOL_OUTPUT_MISSING_TEXT.to_string(),
is_error: Some(true),
};
let stored_message = StoredMessage {
id: id::new_id("message"),
role: Role::User,
content: vec![tool_block],
display_role: None,
timestamp: Some(chrono::Utc::now()),
tool_duration_ms: None,
token_usage: None,
};
self.session
.insert_message(index + 1 + inserted + offset, stored_message);
self.tool_result_ids.insert(id.clone());
repaired += 1;
}
inserted += missing_for_message.len();
}
self.tool_output_scan_index = self.session.messages.len();
if repaired > 0 {
self.persist_session_best_effort("missing tool-output repair");
self.cache_tracker.reset();
self.locked_tools = None;
self.mcp_late_register_resolved = false;
}
repaired
}
fn reset_tool_output_tracking(&mut self) {
self.tool_call_ids.clear();
self.tool_result_ids.clear();
self.tool_output_scan_index = 0;
}
pub fn session_id(&self) -> &str {
&self.session.id
}
pub(crate) fn set_working_dir_for_pending_context(&mut self, working_dir: Option<String>) {
if working_dir.is_some() {
self.session.working_dir = working_dir;
self.session.refresh_initial_session_context_message();
}
}
/// Mark this agent session as closed and persist it.
pub fn mark_closed(&mut self) {
crate::telemetry::end_session_with_reason(
self.provider.name(),
&self.provider.model(),
crate::telemetry::SessionEndReason::NormalExit,
);
self.persist_soft_interrupt_snapshot();
self.session.mark_closed();
if !self.session.messages.is_empty() {
self.persist_session_best_effort("session close state");
}
self.fire_session_lifecycle_hook("session_end", "close");
}
/// Fire a session lifecycle observer hook (`session_start`/`session_end`).
/// No-op when the hook is not configured.
pub(crate) fn fire_session_lifecycle_hook(&self, event_name: &'static str, source: &str) {
if !crate::hooks::hook_configured(event_name) {
return;
}
let mut event = crate::hooks::HookEvent::new(event_name)
.session_id(self.session.id.clone())
.field("SOURCE", source)
.field("MODEL", self.provider_model());
if let Some(cwd) = self.working_dir() {
event = event.cwd(cwd);
}
crate::hooks::dispatch_observer(event);
}
pub fn mark_crashed(&mut self, message: Option<String>) {
crate::telemetry::record_crash(
self.provider.name(),
&self.provider.model(),
crate::telemetry::SessionEndReason::Unknown,
);
self.persist_soft_interrupt_snapshot();
self.session.mark_crashed(message);
if !self.session.messages.is_empty() {
self.persist_session_best_effort("session crash state");
}
}
/// Get the last token usage from the most recent API request
pub fn last_usage(&self) -> &TokenUsage {
&self.last_usage
}
pub fn token_usage_totals(&self) -> crate::protocol::TokenUsageTotals {
self.session.token_usage_totals()
}
/// Export the full conversation as a markdown transcript.
pub fn export_conversation_markdown(&self) -> String {
let mut md = String::new();
for msg in &self.session.messages {
let role_label = match msg.role {
Role::User => "User",
Role::Assistant => "Assistant",
};
md.push_str(&format!("### {}\n\n", role_label));
for block in &msg.content {
match block {
ContentBlock::Text { text, .. } => {
md.push_str(text);
md.push_str("\n\n");
}
ContentBlock::Reasoning { text } => {
md.push_str(&format!("*Thinking:* {}\n\n", text));
}
ContentBlock::ReasoningTrace { text } => {
md.push_str(&format!("*Thinking:* {}\n\n", text));
}
ContentBlock::AnthropicThinking { thinking, .. } => {
md.push_str(&format!("*Thinking:* {}\n\n", thinking));
}
ContentBlock::OpenAIReasoning { summary, .. } => {
if !summary.is_empty() {
md.push_str(&format!("*Thinking:* {}\n\n", summary.join("\n")));
}
}
ContentBlock::ToolUse { name, input, .. } => {
let input_str = serde_json::to_string_pretty(input)
.unwrap_or_else(|_| input.to_string());
md.push_str(&format!(
"**Tool: `{}`**\n```json\n{}\n```\n\n",
name, input_str
));
}
ContentBlock::ToolResult {
content, is_error, ..
} => {
let label = if is_error == &Some(true) {
"Error"
} else {
"Result"
};
// Truncate very long results
let display = if content.len() > 2000 {
format!(
"{}... (truncated, {} chars total)",
crate::util::truncate_str(content, 2000),
content.len()
)
} else {
content.clone()
};
md.push_str(&format!("**{}:**\n```\n{}\n```\n\n", label, display));
}
ContentBlock::Image { .. } => {
md.push_str("[Image]\n\n");
}
ContentBlock::OpenAICompaction { .. } => {
md.push_str("[OpenAI native compaction]\n\n");
}
}
}
}
md
}
}
#[cfg(test)]
#[path = "agent_tests.rs"]
mod tests;
@@ -0,0 +1,344 @@
use super::*;
impl Agent {
pub(super) fn note_compaction_applied(&mut self) {
self.cache_tracker.reset();
self.locked_tools = None;
self.provider_session_id = None;
self.session.provider_session_id = None;
}
pub fn poll_compaction_completion_event(&mut self) -> Option<CompactionEvent> {
let provider_messages = self.session.messages_for_provider();
let compaction = self.registry.compaction();
let event = match compaction.try_write() {
Ok(mut manager) => {
let event = manager.poll_compaction_event_with(&provider_messages);
if event.is_some() {
self.sync_session_compaction_state_from_manager(&manager);
}
event
}
Err(_) => return None,
};
if event.is_some() {
self.note_compaction_applied();
self.persist_session_best_effort("compaction completion");
}
event
}
pub fn request_manual_compaction(&mut self) -> (String, bool) {
if !self.provider.supports_compaction() {
return (
"Manual compaction is not available for this provider.".to_string(),
false,
);
}
let provider = self.provider.fork();
let messages = self.session.messages_for_provider();
let compaction = self.registry.compaction();
match compaction.try_write() {
Ok(mut manager) => {
let stats = manager.stats_with(&messages);
let status_msg = format!(
"**Context Status:**\n\
• Messages: {} (active), {} (total history)\n\
• Token usage: ~{}k (estimate ~{}k) / {}k ({:.1}%)\n\
• Has summary: {}\n\
• Compacting: {}",
stats.active_messages,
stats.total_turns,
stats.effective_tokens / 1000,
stats.token_estimate / 1000,
manager.token_budget() / 1000,
stats.context_usage * 100.0,
if stats.has_summary { "yes" } else { "no" },
if stats.is_compacting {
"in progress..."
} else {
"no"
}
);
match manager.force_compact_with(&messages, provider) {
Ok(()) => (
format!(
"{}\n\n📦 **Compacting context** (manual) — summarizing older messages in the background to stay within the context window.\n\
The summary will be applied automatically when ready.",
status_msg
),
true,
),
Err(reason) => (
format!("{status_msg}\n\n⚠ **Cannot compact:** {reason}"),
false,
),
}
}
Err(_) => (
"⚠ Cannot access compaction manager (lock held)".to_string(),
false,
),
}
}
fn is_context_limit_error(error: &str) -> bool {
let lower = error.to_lowercase();
lower.contains("context length")
|| lower.contains("context window")
|| lower.contains("maximum context")
|| lower.contains("max context")
|| lower.contains("token limit")
|| lower.contains("too many tokens")
|| lower.contains("prompt is too long")
|| lower.contains("input is too long")
|| lower.contains("request too large")
|| lower.contains("length limit")
|| lower.contains("maximum tokens")
|| (lower.contains("exceeded") && lower.contains("tokens"))
}
/// Best-effort emergency recovery after a context-limit error.
///
/// Performs a synchronous hard compaction and resets provider session state,
/// allowing the caller to retry the same turn immediately.
pub(super) fn try_auto_compact_after_context_limit(&mut self, error: &str) -> bool {
if crate::provider::openai_request::is_openai_encrypted_content_too_large_error(error)
&& self.try_recover_oversized_openai_native_compaction()
{
return true;
}
// A provider HTTP 413 ("request too large") is a *byte-size* failure
// driven by inline base64 images, not a token-context overflow. Token
// accounting deliberately undercounts images, so ordinary compaction
// would not shrink the payload and the retry would 413 again. Strip
// oversized images first.
if self.try_recover_after_payload_too_large(error) {
return true;
}
if !Self::is_context_limit_error(error) {
return false;
}
if !self.provider.supports_compaction() {
return false;
}
let context_limit = self.provider.context_window() as u64;
let compaction = self.registry.compaction();
let (dropped, usage_pct) = match compaction.try_write() {
Ok(mut manager) => {
let (dropped, usage_pct) = {
let all_messages = self.session.provider_messages();
manager.update_observed_input_tokens(context_limit);
let usage_pct = manager.context_usage_with(all_messages) * 100.0;
let dropped = match manager.hard_compact_with(all_messages) {
Ok(dropped) => dropped,
Err(reason) => {
logging::warn(&format!(
"Context-limit auto-recovery failed: hard compact failed ({})",
reason
));
return false;
}
};
(dropped, usage_pct)
};
self.sync_session_compaction_state_from_manager(&manager);
(dropped, usage_pct)
}
Err(_) => {
logging::warn("Context-limit auto-recovery skipped: compaction manager lock busy");
return false;
}
};
self.cache_tracker.reset();
self.locked_tools = None;
self.provider_session_id = None;
self.session.provider_session_id = None;
logging::warn(&format!(
"Context limit exceeded; auto-compacted and retrying (dropped {} messages, usage was {:.1}%)",
dropped, usage_pct
));
crate::runtime_memory_log::emit_event(
crate::runtime_memory_log::RuntimeMemoryLogEvent::new(
"auto_compaction_applied",
"context_limit_auto_compaction",
)
.with_session_id(self.session.id.clone())
.with_detail(format!(
"dropped_messages={dropped},usage_pct={usage_pct:.1}"
))
.force_attribution(),
);
true
}
/// Best-effort recovery after a provider HTTP 413 "request too large" error.
///
/// This failure is caused by the serialized request body (dominated by inline
/// base64 images) exceeding the provider's size cap, which is independent of
/// the token context window. We strip oversized images from the persisted
/// transcript, oldest-first, down to a conservative byte budget and reset the
/// provider session/cache so the caller can retry the same turn immediately.
fn try_recover_after_payload_too_large(&mut self, error: &str) -> bool {
if !crate::compaction::is_request_payload_too_large_error(error) {
return false;
}
let stripped = self
.session
.strip_oversized_images(crate::compaction::PAYLOAD_IMAGE_CHAR_BUDGET);
if stripped == 0 {
logging::warn(
"Request-too-large recovery skipped: no oversized inline images to strip",
);
return false;
}
// The transcript changed; reseed compaction bookkeeping and reset
// provider session/cache state so the retry sends the reduced payload.
let compaction = self.registry.compaction();
if let Ok(mut manager) = compaction.try_write() {
let provider_messages = self.session.messages_for_provider();
manager.reset();
manager.set_budget(self.provider.context_window());
if let Some(state) = self.session.compaction.as_ref() {
manager.restore_persisted_state_with(state, &provider_messages);
} else {
manager.seed_restored_messages_with(&provider_messages);
}
self.sync_session_compaction_state_from_manager(&manager);
}
self.cache_tracker.reset();
self.locked_tools = None;
self.provider_session_id = None;
self.session.provider_session_id = None;
logging::warn(&format!(
"Request body exceeded provider size limit; stripped {} oversized inline image(s) and retrying",
stripped
));
crate::runtime_memory_log::emit_event(
crate::runtime_memory_log::RuntimeMemoryLogEvent::new(
"payload_too_large_recovered",
"request_payload_too_large",
)
.with_session_id(self.session.id.clone())
.with_detail(format!("images_stripped={stripped}"))
.force_attribution(),
);
true
}
fn try_recover_oversized_openai_native_compaction(&mut self) -> bool {
let compaction = self.registry.compaction();
let recovered = match compaction.try_write() {
Ok(mut manager) => {
if !manager.discard_oversized_openai_native_compaction() {
return false;
}
self.sync_session_compaction_state_from_manager(&manager);
true
}
Err(_) => {
logging::warn(
"OpenAI native compaction recovery skipped: compaction manager lock busy",
);
false
}
};
if !recovered {
return false;
}
self.cache_tracker.reset();
self.locked_tools = None;
self.provider_session_id = None;
self.session.provider_session_id = None;
logging::warn(
"OpenAI native compaction payload exceeded provider size limit; discarded native state and retrying with text fallback",
);
crate::runtime_memory_log::emit_event(
crate::runtime_memory_log::RuntimeMemoryLogEvent::new(
"native_compaction_payload_recovered",
"openai_encrypted_content_too_large",
)
.with_session_id(self.session.id.clone())
.force_attribution(),
);
true
}
fn effective_context_tokens_from_usage(
&self,
input_tokens: u64,
cache_read_input_tokens: Option<u64>,
cache_creation_input_tokens: Option<u64>,
) -> u64 {
// Shared heuristic (jcode-compaction-core): keeps the compaction
// manager's observed-token feed consistent with the client-side
// context display.
crate::compaction::effective_context_tokens_from_usage(
self.provider.name(),
input_tokens,
cache_read_input_tokens,
cache_creation_input_tokens,
)
}
pub(super) fn update_compaction_usage_from_stream(
&mut self,
input_tokens: u64,
cache_read_input_tokens: Option<u64>,
cache_creation_input_tokens: Option<u64>,
) {
if !self.provider.uses_jcode_compaction() || input_tokens == 0 {
return;
}
let observed = self.effective_context_tokens_from_usage(
input_tokens,
cache_read_input_tokens,
cache_creation_input_tokens,
);
let compaction = self.registry.compaction();
if let Ok(mut manager) = compaction.try_write() {
manager.update_observed_input_tokens(observed);
manager.push_token_snapshot(observed);
};
}
/// Push an embedding snapshot for the semantic compaction mode.
/// Called after each assistant turn with a short text snippet.
/// No-op if the embedding model is unavailable or mode is not semantic.
pub(super) fn push_embedding_snapshot_if_semantic(&mut self, text: &str) {
use crate::config::CompactionMode;
let is_semantic = {
let compaction = self.registry.compaction();
compaction
.try_read()
.map(|m| m.mode() == CompactionMode::Semantic)
.unwrap_or(false)
};
if !is_semantic {
return;
}
let compaction = self.registry.compaction();
if let Ok(mut manager) = compaction.try_write() {
manager.push_embedding_snapshot(text);
};
}
}
@@ -0,0 +1,98 @@
use super::{Agent, JCODE_REPO_SOURCE_STATE, WORKING_GIT_STATE_CACHE};
use crate::logging;
use crate::session::{EnvSnapshot, GitState};
use chrono::Utc;
use std::path::Path;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum EnvSnapshotDetail {
Minimal,
Full,
}
pub(super) fn cached_git_state_for_dir(
dir: &Path,
git_state_for_dir: impl Fn(&Path) -> Option<GitState>,
) -> Option<GitState> {
let cache_key = dir.to_path_buf();
if let Ok(cache) = WORKING_GIT_STATE_CACHE.lock()
&& let Some(state) = cache.get(&cache_key)
{
return state.clone();
}
let state = git_state_for_dir(dir);
if let Ok(mut cache) = WORKING_GIT_STATE_CACHE.lock() {
cache.insert(cache_key, state.clone());
}
state
}
impl Agent {
/// Set logging context for this agent's session/provider
pub(super) fn set_log_context(&self) {
logging::set_session(&self.session.id);
logging::set_provider_info(self.provider.name(), &self.provider.model());
}
/// Record a lightweight environment snapshot for post-mortem debugging
pub(super) fn log_env_snapshot(&mut self, reason: &str) {
let snapshot = self.build_env_snapshot(reason, self.env_snapshot_detail());
self.session.record_env_snapshot(snapshot.clone());
if !self.session.messages.is_empty() {
self.persist_session_best_effort("environment snapshot");
}
if let Ok(json) = serde_json::to_string(&snapshot) {
logging::info(&format!("ENV_SNAPSHOT {}", json));
} else {
logging::info("ENV_SNAPSHOT {}");
}
}
pub(super) fn env_snapshot_detail(&self) -> EnvSnapshotDetail {
if self.session.visible_conversation_message_count() == 0 {
EnvSnapshotDetail::Minimal
} else {
EnvSnapshotDetail::Full
}
}
pub(super) fn build_env_snapshot(
&self,
reason: &str,
detail: EnvSnapshotDetail,
) -> EnvSnapshot {
let (jcode_git_hash, jcode_git_dirty) = match detail {
EnvSnapshotDetail::Full => JCODE_REPO_SOURCE_STATE.clone(),
EnvSnapshotDetail::Minimal => (None, None),
};
let working_dir = self.session.working_dir.clone();
let working_git = match detail {
EnvSnapshotDetail::Full => working_dir.as_deref().and_then(|dir| {
cached_git_state_for_dir(Path::new(dir), super::utils::git_state_for_dir)
}),
EnvSnapshotDetail::Minimal => None,
};
EnvSnapshot {
captured_at: Utc::now(),
reason: reason.to_string(),
session_id: self.session.id.clone(),
working_dir,
provider: self.provider.name().to_string(),
model: self.provider.model().to_string(),
jcode_version: jcode_build_meta::VERSION.to_string(),
jcode_git_hash,
jcode_git_dirty,
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
pid: std::process::id(),
is_selfdev: self.session.is_self_dev(),
is_debug: self.session.is_debug,
is_canary: self.session.is_canary,
testing_build: self.session.testing_build.clone(),
working_git,
}
}
}
@@ -0,0 +1,285 @@
//! Rolling live-output tail for inline swarm workers.
//!
//! The coordinator's inline gallery/dock renders a small viewport of each
//! worker's recent activity. Streaming only the in-progress assistant text
//! had two failure modes:
//!
//! 1. Workers spend most wall-clock time inside tool calls, during which no
//! text streams, so the viewport froze on stale prose for the duration.
//! 2. `text_content` resets on every API call, so the viewport blanked at
//! each turn/continuation boundary.
//!
//! [`InlineTailBuffer`] fixes both: it keeps a rolling, capped buffer of
//! *committed* activity lines (finished text segments and tool markers) that
//! survives across turns, plus the current *live* streaming text segment.
//! Tool executions are interleaved as `⚙ name · summary` markers, updated in
//! place with a duration or error state on completion.
use std::collections::VecDeque;
/// Max committed lines retained (matches the gallery viewport budget).
const MAX_LINES: usize = 14;
/// Max total characters in the rendered tail (bus payload cap).
const MAX_CHARS: usize = 1400;
/// Max characters of a tool marker's input summary.
const MAX_SUMMARY_CHARS: usize = 60;
/// Rolling tail of a worker's recent activity: committed lines (text +
/// tool markers) plus the live in-progress assistant text.
#[derive(Debug, Default)]
pub(crate) struct InlineTailBuffer {
/// Finished activity lines, oldest first, capped to [`MAX_LINES`].
committed: VecDeque<String>,
/// In-progress assistant text for the current stream (replaced wholesale
/// on every delta, committed at message end, discarded on rollback).
live: String,
/// Whether the last committed line is an in-flight tool marker that
/// [`Self::finish_tool`] should update in place.
pending_tool_marker: bool,
}
impl InlineTailBuffer {
/// Replace the live streaming text with the accumulated `text` so far.
pub(crate) fn set_live(&mut self, text: &str) {
self.live.clear();
self.live.push_str(text);
}
/// Commit the live text into the rolling buffer (end of a message) and
/// clear it. Empty/whitespace-only live text is discarded.
pub(crate) fn commit_live(&mut self) {
let live = std::mem::take(&mut self.live);
for line in live.lines().filter(|l| !l.trim().is_empty()) {
self.push_committed(line.to_string());
}
}
/// Discard the live text (mid-stream retry rollback replays from the top).
pub(crate) fn clear_live(&mut self) {
self.live.clear();
}
/// Record a tool execution starting. Any live text is committed first so
/// ordering in the tail matches what actually happened.
pub(crate) fn start_tool(&mut self, name: &str, input: &serde_json::Value) {
self.commit_live();
let summary = tool_marker_summary(name, input);
let marker = if summary.is_empty() {
format!("{name}")
} else {
format!("{name} · {summary}")
};
self.push_committed(marker);
self.pending_tool_marker = true;
}
/// Record the in-flight tool finishing, updating its marker in place with
/// a duration (and error flag). If the marker was already evicted by
/// buffer pressure, this is a no-op.
pub(crate) fn finish_tool(&mut self, elapsed_secs: f64, is_error: bool) {
if !self.pending_tool_marker {
return;
}
self.pending_tool_marker = false;
if let Some(last) = self.committed.back_mut() {
let status = if is_error { "" } else { "" };
last.push_str(&format!(" ({}){status}", humanize_secs(elapsed_secs)));
}
}
/// Render the tail for the bus: committed lines then live lines, bounded
/// to the last [`MAX_LINES`] lines / [`MAX_CHARS`] chars.
pub(crate) fn render(&self) -> String {
let live_lines = self
.live
.lines()
.filter(|l| !l.trim().is_empty())
.collect::<Vec<_>>();
let mut lines: Vec<&str> = self
.committed
.iter()
.map(String::as_str)
.chain(live_lines)
.collect();
if lines.len() > MAX_LINES {
lines.drain(..lines.len() - MAX_LINES);
}
let mut tail = lines.join("\n");
if tail.len() > MAX_CHARS {
let start = floor_char_boundary(&tail, tail.len() - MAX_CHARS);
tail = tail[start..].to_string();
}
tail
}
fn push_committed(&mut self, line: String) {
// A new committed line supersedes any pending in-place marker update
// ordering (finish_tool only touches the true last line).
self.pending_tool_marker = false;
self.committed.push_back(line);
while self.committed.len() > MAX_LINES {
self.committed.pop_front();
}
}
}
/// Compact one-line summary of a tool's input for the activity marker.
/// Prefers the model-provided `intent`, then well-known per-tool fields.
fn tool_marker_summary(name: &str, input: &serde_json::Value) -> String {
let raw = jcode_message_types::ToolCall::intent_from_input(input)
.or_else(|| {
let field = match name {
"bash" => "command",
"read" | "write" => "file_path",
"edit" | "multiedit" => "file_path",
"agentgrep" | "websearch" => "query",
"webfetch" => "url",
"task" | "subagent" => "description",
_ => return None,
};
input
.get(field)
.and_then(|v| v.as_str())
.map(str::to_string)
})
.unwrap_or_default();
let flat = raw.split_whitespace().collect::<Vec<_>>().join(" ");
if flat.chars().count() > MAX_SUMMARY_CHARS {
let mut out: String = flat.chars().take(MAX_SUMMARY_CHARS - 1).collect();
out.push('…');
out
} else {
flat
}
}
/// "3s" / "2m10s" style duration for tool markers.
fn humanize_secs(secs: f64) -> String {
if secs < 10.0 {
format!("{secs:.1}s")
} else if secs < 60.0 {
format!("{}s", secs as u64)
} else {
let total = secs as u64;
format!("{}m{}s", total / 60, total % 60)
}
}
/// Largest byte index `<= index` that is a UTF-8 char boundary in `text`.
fn floor_char_boundary(text: &str, index: usize) -> usize {
if index >= text.len() {
return text.len();
}
let mut boundary = index;
while boundary > 0 && !text.is_char_boundary(boundary) {
boundary -= 1;
}
boundary
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn live_text_renders_and_survives_commit() {
let mut tail = InlineTailBuffer::default();
tail.set_live("thinking about the fix\nsecond line");
assert_eq!(tail.render(), "thinking about the fix\nsecond line");
tail.commit_live();
// Next stream starts blank but the previous output is retained.
tail.set_live("");
assert_eq!(tail.render(), "thinking about the fix\nsecond line");
}
#[test]
fn tool_markers_interleave_and_complete_in_place() {
let mut tail = InlineTailBuffer::default();
tail.set_live("Let me check the render pipeline.");
tail.start_tool(
"bash",
&serde_json::json!({"command": "cargo build --profile selfdev"}),
);
let mid = tail.render();
assert!(
mid.contains("Let me check the render pipeline."),
"live text must commit before the marker: {mid}"
);
assert!(
mid.contains("⚙ bash · cargo build --profile selfdev"),
"{mid}"
);
tail.finish_tool(47.2, false);
assert!(tail.render().contains("(47s)"), "{}", tail.render());
tail.start_tool("edit", &serde_json::json!({"file_path": "src/ui.rs"}));
tail.finish_tool(0.3, true);
let done = tail.render();
assert!(done.contains("⚙ edit · src/ui.rs (0.3s) ✗"), "{done}");
}
#[test]
fn marker_prefers_intent_over_raw_input() {
let mut tail = InlineTailBuffer::default();
tail.start_tool(
"bash",
&serde_json::json!({"command": "x", "intent": "run the ui tests"}),
);
assert!(tail.render().contains("⚙ bash · run the ui tests"));
}
#[test]
fn rollback_discards_live_but_keeps_committed() {
let mut tail = InlineTailBuffer::default();
tail.start_tool("read", &serde_json::json!({"file_path": "a.rs"}));
tail.finish_tool(0.1, false);
tail.set_live("partial output that gets replayed");
tail.clear_live();
let out = tail.render();
assert!(out.contains("⚙ read"), "{out}");
assert!(!out.contains("partial output"), "{out}");
}
#[test]
fn caps_lines_and_chars_and_summary_length() {
let mut tail = InlineTailBuffer::default();
for i in 0..40 {
tail.set_live(&format!("line number {i}"));
tail.commit_live();
}
let out = tail.render();
assert!(out.lines().count() <= MAX_LINES);
assert!(out.contains("line number 39"));
assert!(!out.contains("line number 0\n"));
let huge = "x".repeat(5000);
tail.set_live(&huge);
assert!(tail.render().len() <= MAX_CHARS);
let mut tail = InlineTailBuffer::default();
let long_cmd = "cargo test ".repeat(30);
tail.start_tool("bash", &serde_json::json!({ "command": long_cmd }));
let line = tail.render();
assert!(line.chars().count() < 80, "summary must truncate: {line}");
assert!(line.contains('…'), "{line}");
}
#[test]
fn finish_without_pending_marker_is_noop() {
let mut tail = InlineTailBuffer::default();
tail.set_live("hello");
tail.commit_live();
tail.finish_tool(1.0, false);
assert_eq!(tail.render(), "hello");
// Committing new lines invalidates a pending marker: finish after
// commit must not append a duration to unrelated text.
let mut tail = InlineTailBuffer::default();
tail.start_tool("bash", &serde_json::json!({"command": "ls"}));
tail.set_live("output line");
tail.commit_live();
tail.finish_tool(2.0, false);
let out = tail.render();
assert!(!out.contains("output line (2.0s)"), "{out}");
}
}
@@ -0,0 +1,458 @@
use super::Agent;
use crate::logging;
use crate::message::{ContentBlock, Role};
use crate::protocol::ServerEvent;
use crate::session::StoredDisplayRole;
use anyhow::Result;
use jcode_agent_runtime::{
InterruptSignal, SoftInterruptMessage, SoftInterruptQueue, SoftInterruptSource,
};
use std::sync::Arc;
fn soft_interrupt_session_display_role(source: SoftInterruptSource) -> Option<StoredDisplayRole> {
match source {
SoftInterruptSource::User => None,
SoftInterruptSource::System => Some(StoredDisplayRole::System),
SoftInterruptSource::BackgroundTask => Some(StoredDisplayRole::BackgroundTask),
}
}
fn soft_interrupt_protocol_display_role(source: SoftInterruptSource) -> Option<String> {
match source {
SoftInterruptSource::User => None,
SoftInterruptSource::System => Some("system".to_string()),
SoftInterruptSource::BackgroundTask => Some("background_task".to_string()),
}
}
#[derive(Debug, Clone)]
pub(super) struct InjectedSoftInterrupt {
pub(super) content: String,
pub(super) source: SoftInterruptSource,
}
pub(super) enum NoToolCallOutcome {
Break,
ContinueWithoutEvent,
ContinueWithSoftInterrupt {
injected: Vec<InjectedSoftInterrupt>,
point: &'static str,
},
}
pub(super) enum PostToolInterruptOutcome {
NoInterrupt,
SoftInterrupt {
injected: Vec<InjectedSoftInterrupt>,
point: &'static str,
},
}
impl Agent {
pub fn restore_persisted_soft_interrupts(&self) -> usize {
let restored = match crate::soft_interrupt_store::take(self.session_id()) {
Ok(items) => items,
Err(err) => {
logging::warn(&format!(
"Failed to restore persisted soft interrupts for {}: {}",
self.session_id(),
err
));
return 0;
}
};
if restored.is_empty() {
return 0;
}
let restored_count = restored.len();
if let Ok(mut queue) = self.soft_interrupt_queue.lock() {
queue.extend(restored);
} else {
logging::warn(&format!(
"Failed to restore persisted soft interrupts for {} because queue lock was poisoned",
self.session_id()
));
return 0;
}
logging::info(&format!(
"Restored {} persisted soft interrupt(s) for session {}",
restored_count,
self.session_id()
));
restored_count
}
pub fn persist_soft_interrupt_snapshot(&self) {
let pending = match self.soft_interrupt_queue.lock() {
Ok(queue) => queue.clone(),
Err(_) => {
logging::warn(&format!(
"Failed to snapshot soft interrupts for {} because queue lock was poisoned",
self.session_id()
));
return;
}
};
if let Err(err) = crate::soft_interrupt_store::overwrite(self.session_id(), &pending) {
logging::warn(&format!(
"Failed to persist {} soft interrupt(s) for {}: {}",
pending.len(),
self.session_id(),
err
));
}
}
/// Add a swarm alert to be injected into the next turn
pub fn push_alert(&mut self, alert: String) {
self.pending_alerts.push(alert);
}
/// Take all pending alerts (clears the queue)
pub fn take_alerts(&mut self) -> Vec<String> {
std::mem::take(&mut self.pending_alerts)
}
/// Queue a soft interrupt message to be injected at the next safe point.
/// This method can be called even while the agent is processing (uses separate lock).
pub fn queue_soft_interrupt(&self, content: String, urgent: bool, source: SoftInterruptSource) {
let content_bytes = content.len();
let content_chars = content.chars().count();
if let Ok(mut queue) = self.soft_interrupt_queue.lock() {
let pending_before = queue.len();
queue.push(SoftInterruptMessage {
content,
urgent,
source,
});
logging::info(&format!(
"AGENT_SOFT_INTERRUPT_QUEUE_PUSH session={} source={:?} urgent={} content_bytes={} content_chars={} pending_before={} pending_after={}",
self.session_id(),
source,
urgent,
content_bytes,
content_chars,
pending_before,
queue.len()
));
} else {
logging::warn(&format!(
"AGENT_SOFT_INTERRUPT_QUEUE_PUSH_FAILED session={} source={:?} urgent={} content_bytes={} content_chars={} reason=queue_lock_poisoned",
self.session_id(),
source,
urgent,
content_bytes,
content_chars
));
}
}
/// Get a handle to the soft interrupt queue.
/// The server can use this to queue interrupts without holding the agent lock.
pub fn soft_interrupt_queue(&self) -> SoftInterruptQueue {
Arc::clone(&self.soft_interrupt_queue)
}
/// Get a handle to the background tool signal.
/// The server can use this to signal "move tool to background" without holding the agent lock.
pub fn background_tool_signal(&self) -> InterruptSignal {
self.background_tool_signal.clone()
}
pub fn graceful_shutdown_signal(&self) -> InterruptSignal {
self.graceful_shutdown.clone()
}
pub fn request_graceful_shutdown(&self) {
self.graceful_shutdown.fire();
}
pub(super) fn is_graceful_shutdown(&self) -> bool {
self.graceful_shutdown.is_set()
}
/// Check if there are pending soft interrupts
pub fn has_soft_interrupts(&self) -> bool {
self.soft_interrupt_queue
.lock()
.map(|q| !q.is_empty())
.unwrap_or(false)
}
/// Check if there's an urgent soft interrupt that should skip remaining tools
pub fn has_urgent_interrupt(&self) -> bool {
self.soft_interrupt_queue
.lock()
.map(|q| q.iter().any(|m| m.urgent))
.unwrap_or(false)
}
/// Get count of queued soft interrupts
pub fn soft_interrupt_count(&self) -> usize {
self.soft_interrupt_queue
.lock()
.map(|q| q.len())
.unwrap_or(0)
}
/// Get count of pending alerts
pub fn pending_alert_count(&self) -> usize {
self.pending_alerts.len()
}
/// Get pending alerts (for debug visibility)
pub fn pending_alerts_preview(&self) -> Vec<String> {
self.pending_alerts
.iter()
.take(10)
.map(|s| {
if s.len() > 100 {
format!("{}...", crate::util::truncate_str(s, 100))
} else {
s.clone()
}
})
.collect()
}
/// Get comprehensive debug info about agent internal state
pub fn debug_info(&self) -> serde_json::Value {
serde_json::json!({
"provider": self.provider.name(),
"model": self.provider.model(),
"provider_session_id": self.provider_session_id,
"last_upstream_provider": self.last_upstream_provider,
"last_connection_type": self.last_connection_type,
"active_skill": self.active_skill,
"allowed_tools": self.allowed_tools,
"disabled_tools": self.disabled_tools,
"session": {
"id": self.session.id,
"is_canary": self.session.is_canary,
"model": self.session.model,
"working_dir": self.session.working_dir,
"message_count": self.session.messages.len(),
},
"interrupts": {
"soft_interrupt_count": self.soft_interrupt_count(),
"has_urgent": self.has_urgent_interrupt(),
"pending_alert_count": self.pending_alert_count(),
"soft_interrupts": self.soft_interrupts_preview(),
"pending_alerts": self.pending_alerts_preview(),
},
"cache_tracker": {
"turn_count": self.cache_tracker.turn_count(),
"had_violation": self.cache_tracker.had_violation(),
},
"features": {
"memory_enabled": self.memory_enabled,
},
"token_usage": {
"input": self.last_usage.input_tokens,
"output": self.last_usage.output_tokens,
"cache_read": self.last_usage.cache_read_input_tokens,
"cache_write": self.last_usage.cache_creation_input_tokens,
},
})
}
pub fn debug_memory_profile(&self) -> serde_json::Value {
let process = crate::process_memory::snapshot_with_source("agent:memory");
let soft_interrupt_text_bytes: usize = self
.soft_interrupt_queue
.lock()
.map(|queue| queue.iter().map(|msg| msg.content.len()).sum())
.unwrap_or(0);
let pending_alert_text_bytes: usize =
self.pending_alerts.iter().map(|alert| alert.len()).sum();
serde_json::json!({
"process": process,
"session": self.session.debug_memory_profile(),
"interrupts": {
"soft_interrupt_count": self.soft_interrupt_count(),
"soft_interrupt_text_bytes": soft_interrupt_text_bytes,
"pending_alert_count": self.pending_alert_count(),
"pending_alert_text_bytes": pending_alert_text_bytes,
},
"agent": {
"memory_enabled": self.memory_enabled,
"allowed_tools_count": self.allowed_tools.as_ref().map(|tools| tools.len()),
"disabled_tools_count": self.disabled_tools.len(),
"tool_call_ids": self.tool_call_ids.len(),
"tool_result_ids": self.tool_result_ids.len(),
"last_usage": {
"input": self.last_usage.input_tokens,
"output": self.last_usage.output_tokens,
"cache_read": self.last_usage.cache_read_input_tokens,
"cache_write": self.last_usage.cache_creation_input_tokens,
},
}
})
}
/// Get soft interrupt previews (for debug visibility)
pub fn soft_interrupts_preview(&self) -> Vec<(String, bool)> {
self.soft_interrupt_queue
.lock()
.map(|q| {
q.iter()
.take(10)
.map(|m| {
let preview = if m.content.len() > 100 {
format!("{}...", crate::util::truncate_str(&m.content, 100))
} else {
m.content.clone()
};
(preview, m.urgent)
})
.collect()
})
.unwrap_or_default()
}
/// Inject all pending soft interrupt messages into the conversation.
/// Returns the combined message content and clears the queue.
pub(super) fn inject_soft_interrupts(&mut self) -> Vec<InjectedSoftInterrupt> {
let messages: Vec<SoftInterruptMessage> = {
let mut queue = match self.soft_interrupt_queue.lock() {
Ok(queue) => queue,
Err(_) => {
logging::warn(&format!(
"AGENT_SOFT_INTERRUPT_INJECT_LOCK_FAILED session={}",
self.session_id()
));
return Vec::new();
}
};
if queue.is_empty() {
return Vec::new();
}
logging::info(&format!(
"AGENT_SOFT_INTERRUPT_INJECT_DRAIN session={} pending_count={} urgent_count={} total_content_bytes={}",
self.session_id(),
queue.len(),
queue.iter().filter(|message| message.urgent).count(),
queue
.iter()
.map(|message| message.content.len())
.sum::<usize>()
));
queue.drain(..).collect()
};
let mut injected = Vec::new();
let mut current_source: Option<SoftInterruptSource> = None;
let mut current_parts: Vec<String> = Vec::new();
let flush_group = |agent: &mut Self,
injected: &mut Vec<InjectedSoftInterrupt>,
source: SoftInterruptSource,
parts: &mut Vec<String>| {
if parts.is_empty() {
return;
}
let content = parts.join("\n\n");
parts.clear();
agent.add_message_with_display_role(
Role::User,
vec![ContentBlock::Text {
text: content.clone(),
cache_control: None,
}],
soft_interrupt_session_display_role(source),
);
injected.push(InjectedSoftInterrupt { content, source });
};
for message in messages {
match current_source {
Some(source) if source != message.source => {
flush_group(self, &mut injected, source, &mut current_parts);
current_source = Some(message.source);
}
None => current_source = Some(message.source),
_ => {}
}
current_parts.push(message.content);
}
if let Some(source) = current_source {
flush_group(self, &mut injected, source, &mut current_parts);
}
self.persist_session_best_effort("soft interrupt injection");
logging::info(&format!(
"AGENT_SOFT_INTERRUPT_INJECT_COMMIT session={} groups={} total_content_bytes={}",
self.session_id(),
injected.len(),
injected
.iter()
.map(|interrupt| interrupt.content.len())
.sum::<usize>()
));
injected
}
pub(super) fn handle_streaming_no_tool_calls(
&mut self,
stop_reason: Option<&str>,
incomplete_continuations: &mut u32,
) -> Result<NoToolCallOutcome> {
if self.maybe_continue_incomplete_response(stop_reason, incomplete_continuations)? {
return Ok(NoToolCallOutcome::ContinueWithoutEvent);
}
logging::info("Turn complete - no tool calls");
let injected = self.inject_soft_interrupts();
if !injected.is_empty() {
return Ok(NoToolCallOutcome::ContinueWithSoftInterrupt {
injected,
point: "B",
});
}
Ok(NoToolCallOutcome::Break)
}
pub(super) fn take_post_tool_soft_interrupt(&mut self) -> PostToolInterruptOutcome {
let injected = self.inject_soft_interrupts();
if !injected.is_empty() {
PostToolInterruptOutcome::SoftInterrupt {
injected,
point: "D",
}
} else {
PostToolInterruptOutcome::NoInterrupt
}
}
pub(super) fn build_soft_interrupt_events(
injected: Vec<InjectedSoftInterrupt>,
point: &'static str,
tools_skipped: Option<usize>,
) -> Vec<ServerEvent> {
logging::info(&format!(
"AGENT_SOFT_INTERRUPT_EVENTS_BUILD groups={} point={} tools_skipped={:?} total_content_bytes={}",
injected.len(),
point,
tools_skipped,
injected
.iter()
.map(|interrupt| interrupt.content.len())
.sum::<usize>()
));
injected
.into_iter()
.enumerate()
.map(|(idx, interrupt)| ServerEvent::SoftInterruptInjected {
content: interrupt.content,
display_role: soft_interrupt_protocol_display_role(interrupt.source),
point: point.to_string(),
tools_skipped: if idx == 0 { tools_skipped } else { None },
})
.collect()
}
}
@@ -0,0 +1,77 @@
use super::*;
impl Agent {
pub(crate) fn add_message(&mut self, role: Role, content: Vec<ContentBlock>) -> String {
let id = self.session.add_message(role, content);
let compaction = self.registry.compaction();
if let Ok(mut manager) = compaction.try_write() {
if let Some(message) = self.session.messages.last() {
manager.notify_message_added_blocks(&message.content);
} else {
manager.notify_message_added();
}
}
id
}
pub(crate) fn add_message_with_display_role(
&mut self,
role: Role,
content: Vec<ContentBlock>,
display_role: Option<StoredDisplayRole>,
) -> String {
let id = self
.session
.add_message_with_display_role(role, content, display_role);
let compaction = self.registry.compaction();
if let Ok(mut manager) = compaction.try_write() {
if let Some(message) = self.session.messages.last() {
manager.notify_message_added_blocks(&message.content);
} else {
manager.notify_message_added();
}
}
id
}
pub(crate) fn add_message_with_duration(
&mut self,
role: Role,
content: Vec<ContentBlock>,
duration_ms: Option<u64>,
) -> String {
let id = self
.session
.add_message_with_duration(role, content, duration_ms);
let compaction = self.registry.compaction();
if let Ok(mut manager) = compaction.try_write() {
if let Some(message) = self.session.messages.last() {
manager.notify_message_added_blocks(&message.content);
} else {
manager.notify_message_added();
}
}
id
}
pub(crate) fn add_message_ext(
&mut self,
role: Role,
content: Vec<ContentBlock>,
duration_ms: Option<u64>,
token_usage: Option<crate::session::StoredTokenUsage>,
) -> String {
let id = self
.session
.add_message_ext(role, content, duration_ms, token_usage);
let compaction = self.registry.compaction();
if let Ok(mut manager) = compaction.try_write() {
if let Some(message) = self.session.messages.last() {
manager.notify_message_added_blocks(&message.content);
} else {
manager.notify_message_added();
}
}
id
}
}
@@ -0,0 +1,128 @@
use super::Agent;
use crate::logging;
use crate::message::{Message, ToolDefinition};
impl Agent {
pub(super) fn log_prompt_prefix_accounting(
&self,
split: &crate::prompt::SplitSystemPrompt,
tools: &[ToolDefinition],
) {
let system_tokens = split.estimated_tokens();
let tool_tokens = ToolDefinition::aggregate_prompt_token_estimate(tools);
let prefix_tokens = system_tokens + tool_tokens;
logging::info(&format!(
"Prompt prefix estimate: total={} tokens (system={} tools={})",
prefix_tokens, system_tokens, tool_tokens
));
}
pub(super) fn build_memory_prompt_nonblocking_shared(
&self,
messages: std::sync::Arc<[Message]>,
_memory_event_tx: Option<crate::memory::MemoryEventSink>,
) -> Option<crate::memory::PendingMemory> {
if !self.memory_enabled {
return None;
}
let session_id = &self.session.id;
let pending = if crate::message::ends_with_fresh_user_turn(&messages) {
crate::memory::take_pending_memory(session_id)
} else {
None
};
// Use the persistent memory-agent pipeline as the single source of truth.
// Running both this and the legacy MemoryManager background retrieval path
// can prepare overlapping pending prompts for the same turn, which makes
// memory injection feel overly aggressive.
crate::memory_agent::update_context_sync_with_dir(
session_id,
messages,
self.session.working_dir.clone(),
);
pending
}
fn append_current_turn_system_reminder(&self, split: &mut crate::prompt::SplitSystemPrompt) {
let Some(reminder) = self
.current_turn_system_reminder
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
else {
return;
};
if !split.dynamic_part.is_empty() {
split.dynamic_part.push_str("\n\n");
}
split.dynamic_part.push_str("# System Reminder\n\n");
split.dynamic_part.push_str(reminder);
}
/// Build split system prompt for better caching
/// Returns static (cacheable) and dynamic (not cached) parts separately
pub(super) fn build_system_prompt_split(
&self,
memory_prompt: Option<&str>,
) -> crate::prompt::SplitSystemPrompt {
if let Some(ref override_prompt) = self.system_prompt_override {
return crate::prompt::SplitSystemPrompt {
static_part: override_prompt.clone(),
dynamic_part: String::new(),
};
}
let skills = self.current_skills_snapshot();
let skill_prompt = self
.active_skill
.as_ref()
.and_then(|name| skills.get(name).map(|skill| skill.get_prompt().to_string()));
let available_skills: Vec<crate::prompt::SkillInfo> = self
.current_skills_snapshot()
.list()
.iter()
.map(|skill| crate::prompt::SkillInfo {
name: skill.name.clone(),
description: skill.description.clone(),
})
.collect();
let working_dir = self
.session
.working_dir
.as_ref()
.map(std::path::PathBuf::from);
let (mut split, _context_info) = crate::prompt::build_system_prompt_split(
skill_prompt.as_deref(),
&available_skills,
self.session.is_canary,
memory_prompt,
working_dir.as_deref(),
);
self.append_current_turn_system_reminder(&mut split);
crate::prompt::append_swarm_effort_directive(
&mut split,
self.provider.reasoning_effort().as_deref(),
);
split
}
/// Non-blocking memory prompt - takes pending result and spawns check for next turn
#[cfg(test)]
pub(super) fn build_memory_prompt_nonblocking(
&self,
messages: &[Message],
_memory_event_tx: Option<crate::memory::MemoryEventSink>,
) -> Option<crate::memory::PendingMemory> {
self.build_memory_prompt_nonblocking_shared(messages.to_vec().into(), _memory_event_tx)
}
}
+240
View File
@@ -0,0 +1,240 @@
use super::*;
impl Agent {
pub fn set_premium_mode(&self, mode: crate::provider::copilot::PremiumMode) {
self.provider.set_premium_mode(mode);
}
pub fn premium_mode(&self) -> crate::provider::copilot::PremiumMode {
self.provider.premium_mode()
}
pub fn provider_fork(&self) -> Arc<dyn Provider> {
self.provider.fork()
}
pub fn provider_handle(&self) -> Arc<dyn Provider> {
Arc::clone(&self.provider)
}
pub fn available_models(&self) -> Vec<&'static str> {
self.provider.available_models()
}
pub fn available_models_for_switching(&self) -> Vec<String> {
self.provider.available_models_for_switching()
}
pub fn available_models_display(&self) -> Vec<String> {
self.provider.available_models_display()
}
pub fn model_routes(&self) -> Vec<crate::provider::ModelRoute> {
self.provider.model_routes()
}
pub fn model_catalog_snapshot(&self) -> jcode_provider_core::ModelCatalogSnapshot {
jcode_provider_core::ModelCatalogSnapshot::new(
Some(self.provider_name()),
Some(self.provider_model()),
self.available_models_display(),
self.model_routes(),
)
}
pub fn registry(&self) -> Registry {
self.registry.clone()
}
pub async fn compaction_mode(&self) -> crate::config::CompactionMode {
self.registry.compaction().read().await.mode()
}
pub async fn set_compaction_mode(&self, mode: crate::config::CompactionMode) -> Result<()> {
let compaction = self.registry.compaction();
let mut manager = compaction.write().await;
manager.set_mode(mode);
Ok(())
}
pub fn provider_messages(&mut self) -> Vec<Message> {
self.session.messages_for_provider()
}
pub fn set_model(&mut self, model: &str) -> Result<()> {
self.set_model_from_provider_state_event(
model,
crate::provider::ProviderModelSelectionSource::User,
)
}
pub fn set_route_selection(
&mut self,
selection: &crate::provider::RouteSelection,
) -> Result<()> {
self.provider.set_route_selection(selection)?;
let resolved_model = self.provider.model();
self.session.provider_key = Some(selection.runtime_key.stable_id());
self.session.route_api_method = Some(selection.api_method.clone());
self.session.model = Some(resolved_model.clone());
let event = crate::provider::ProviderStateEvent::selected_model(
crate::provider::ProviderModelSelectionSource::User,
resolved_model,
);
self.provider_runtime_state.apply(event);
self.persist_session_best_effort("route selection");
self.log_env_snapshot("set_route_selection");
Ok(())
}
pub(crate) fn set_model_from_auth(&mut self, model: &str) -> Result<()> {
self.set_model_from_provider_state_event(
model,
crate::provider::ProviderModelSelectionSource::Auth,
)
}
fn set_model_from_provider_state_event(
&mut self,
model: &str,
source: crate::provider::ProviderModelSelectionSource,
) -> Result<()> {
crate::provider::set_model_with_auth_refresh(self.provider.as_ref(), model)?;
let resolved_model = self.provider.model();
self.session.provider_key =
crate::provider::MultiProvider::session_provider_key_after_model_switch(
model,
self.provider.name(),
self.session.provider_key.as_deref(),
);
self.session.model = Some(resolved_model.clone());
let event = crate::provider::ProviderStateEvent::selected_model(source, resolved_model);
self.provider_runtime_state.apply(event);
self.persist_session_best_effort("model selection");
self.log_env_snapshot("set_model");
Ok(())
}
pub(crate) fn provider_model_selection_generation(&self) -> u64 {
self.provider_runtime_state.selection_generation()
}
pub(crate) fn user_selected_provider_model_after(&self, generation: u64) -> bool {
self.provider_runtime_state.user_selected_after(generation)
}
pub fn restore_reasoning_effort_from_session(&mut self) {
if let Some(effort) = self.session.reasoning_effort.clone() {
if let Err(e) = self.provider.set_reasoning_effort(&effort) {
crate::logging::error(&format!(
"Failed to restore session reasoning effort '{}': {}",
effort, e
));
}
} else {
self.session.reasoning_effort = self.provider.reasoning_effort();
}
// Mirror the effort into the deadlock-free side-table so server handlers
// (e.g. the swarm seed handler) can learn this session's effort without
// taking the agent lock.
crate::session_effort::record_session_effort(
&self.session.id,
self.session.reasoning_effort.as_deref(),
);
}
pub fn set_reasoning_effort(&mut self, effort: &str) -> Result<Option<String>> {
self.provider.set_reasoning_effort(effort)?;
let current = self.provider.reasoning_effort();
self.session.reasoning_effort = current.clone();
// Keep the side-table in sync (see `restore_reasoning_effort_from_session`).
crate::session_effort::record_session_effort(&self.session.id, current.as_deref());
self.log_env_snapshot("set_reasoning_effort");
self.session.save()?;
Ok(current)
}
pub fn subagent_model(&self) -> Option<String> {
self.session.subagent_model.clone()
}
pub fn set_subagent_model(&mut self, model: Option<String>) -> Result<()> {
self.session.subagent_model = model;
self.log_env_snapshot("set_subagent_model");
self.session.save()?;
Ok(())
}
pub fn session_provider_key(&self) -> Option<String> {
self.session.provider_key.clone()
}
/// API method/runtime route used to select the active model (e.g.
/// "openai-api", "claude-oauth", "openai-compatible:nvidia-nim"). Spawned
/// swarm agents inherit this so they reconstruct the coordinator's exact
/// auth route instead of falling back to the config default.
pub fn session_route_api_method(&self) -> Option<String> {
self.session.route_api_method.clone()
}
/// The credential the active provider will use for the next request, when
/// the provider distinguishes OAuth (subscription) from API key (cost).
/// Resolved authoritatively here so remote clients can render billing/usage
/// without re-deriving it from the provider name.
pub fn active_resolved_credential(&self) -> Option<jcode_provider_core::ResolvedCredential> {
self.provider.active_resolved_credential()
}
pub fn set_session_provider_key(&mut self, provider_key: Option<String>) {
self.session.provider_key = provider_key;
}
pub fn rename_session_title(&mut self, title: Option<String>) -> Result<String> {
self.session.rename_title(title);
self.log_env_snapshot("rename_session");
self.session.save()?;
Ok(self.session.display_title_or_name().to_string())
}
pub fn autoreview_enabled(&self) -> Option<bool> {
self.session.autoreview_enabled
}
pub fn set_autoreview_enabled(&mut self, enabled: bool) -> Result<()> {
self.session.autoreview_enabled = Some(enabled);
self.log_env_snapshot("set_autoreview_enabled");
self.session.save()?;
Ok(())
}
pub fn autojudge_enabled(&self) -> Option<bool> {
self.session.autojudge_enabled
}
pub fn set_autojudge_enabled(&mut self, enabled: bool) -> Result<()> {
self.session.autojudge_enabled = Some(enabled);
self.log_env_snapshot("set_autojudge_enabled");
self.session.save()?;
Ok(())
}
/// Set the working directory for this session
pub fn set_working_dir(&mut self, dir: &str) {
if self.session.working_dir.as_deref() == Some(dir) {
return;
}
self.session.working_dir = Some(dir.to_string());
self.session.refresh_initial_session_context_message();
self.log_env_snapshot("working_dir");
}
/// Get the working directory for this session
pub fn working_dir(&self) -> Option<&str> {
self.session.working_dir.as_deref()
}
/// Get the stored messages (for transcript export)
pub fn messages(&self) -> &[StoredMessage] {
&self.session.messages
}
}
@@ -0,0 +1,248 @@
use super::*;
impl Agent {
fn parse_text_wrapped_tool_call(
text: &str,
) -> Option<(String, String, serde_json::Value, String)> {
let marker = "to=functions.";
let marker_idx = text.find(marker)?;
let after_marker = &text[marker_idx + marker.len()..];
let mut tool_name_end = 0usize;
for (idx, ch) in after_marker.char_indices() {
if ch.is_ascii_alphanumeric() || ch == '_' {
tool_name_end = idx + ch.len_utf8();
} else {
break;
}
}
if tool_name_end == 0 {
return None;
}
let tool_name = after_marker[..tool_name_end].to_string();
let remaining = &after_marker[tool_name_end..];
let mut fallback: Option<(String, String, serde_json::Value, String)> = None;
for (brace_idx, ch) in remaining.char_indices() {
if ch != '{' {
continue;
}
let slice = &remaining[brace_idx..];
let mut stream =
serde_json::Deserializer::from_str(slice).into_iter::<serde_json::Value>();
let parsed = match stream.next() {
Some(Ok(value)) => value,
Some(Err(_)) | None => continue,
};
let consumed = stream.byte_offset();
if !parsed.is_object() {
continue;
}
let prefix = text[..marker_idx].trim_end().to_string();
let suffix = remaining[brace_idx + consumed..].trim().to_string();
if suffix.is_empty() {
return Some((prefix, tool_name.clone(), parsed, suffix));
}
if fallback.is_none() {
fallback = Some((prefix, tool_name.clone(), parsed, suffix));
}
}
fallback
}
pub(super) fn recover_text_wrapped_tool_call(
&self,
text_content: &mut String,
tool_calls: &mut Vec<ToolCall>,
) -> bool {
if !tool_calls.is_empty() || text_content.trim().is_empty() {
return false;
}
let Some((prefix, tool_name, arguments, suffix)) =
Self::parse_text_wrapped_tool_call(text_content)
else {
return false;
};
let mut sanitized = String::new();
if !prefix.is_empty() {
sanitized.push_str(&prefix);
}
if !suffix.is_empty() {
if !sanitized.is_empty() {
sanitized.push('\n');
}
sanitized.push_str(&suffix);
}
*text_content = sanitized;
let call_id = format!("fallback_text_call_{}", id::new_id("call"));
let recovered_total = RECOVERED_TEXT_WRAPPED_TOOL_CALLS
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
+ 1;
logging::warn(&format!(
"[agent] Recovered text-wrapped tool call for '{}' ({}, total={})",
tool_name, call_id, recovered_total
));
let intent = ToolCall::intent_from_input(&arguments);
tool_calls.push(ToolCall {
id: call_id,
name: tool_name,
input: arguments,
intent,
thought_signature: None,
});
true
}
pub(super) fn should_continue_after_stop_reason(stop_reason: &str) -> bool {
let reason = stop_reason.trim().to_ascii_lowercase();
if reason.is_empty() {
return false;
}
if matches!(reason.as_str(), "stop" | "end_turn" | "tool_use") {
return false;
}
reason.contains("incomplete")
|| reason.contains("max_output_tokens")
|| reason.contains("max_tokens")
|| reason.contains("length")
|| reason.contains("trunc")
|| reason.contains("commentary")
}
/// True when the provider's stop reason indicates a model-side
/// guardrail/safety stop (e.g. Anthropic `refusal`), as opposed to a
/// normal end-of-turn or truncation.
pub(crate) fn is_guardrail_stop_reason(stop_reason: Option<&str>) -> bool {
let Some(reason) = stop_reason else {
return false;
};
let reason = reason.trim().to_ascii_lowercase();
matches!(reason.as_str(), "refusal" | "content_filter" | "safety")
|| reason.contains("guardrail")
|| reason.contains("policy_violation")
}
/// Builds the user-facing notice for a turn that ended with no visible
/// assistant output (no text, no tool calls). Returns `None` when the turn
/// looks normal and no notice should be surfaced.
pub(crate) fn provider_guardrail_notice(
stop_reason: Option<&str>,
visible_text_empty: bool,
had_reasoning: bool,
) -> Option<String> {
let guardrail = Self::is_guardrail_stop_reason(stop_reason);
if !guardrail && !visible_text_empty {
return None;
}
let reason_label = stop_reason
.map(str::trim)
.filter(|r| !r.is_empty())
.unwrap_or("unknown");
if guardrail {
return Some(format!(
"Provider guardrail stopped the response (stop_reason: {}). The model declined to answer this request. Rephrasing, narrowing the request, or providing more context may help.",
reason_label
));
}
// Empty visible output with a non-guardrail stop reason: still surface,
// since the user otherwise sees nothing at all.
let reasoning_hint = if had_reasoning {
" after producing only internal reasoning"
} else {
""
};
Some(format!(
"The model ended its turn without any visible output{} (stop_reason: {}). This is usually a provider-side guardrail or filter silently dropping the response. Rephrasing the request may help.",
reasoning_hint, reason_label
))
}
fn continuation_prompt_for_stop_reason(stop_reason: &str) -> String {
format!(
"[System reminder: your previous response ended before completion (stop_reason: {}). Continue exactly where you left off, do not repeat completed content, and if the next step is a tool call, emit the tool call now.]",
stop_reason.trim()
)
}
pub(crate) fn maybe_continue_incomplete_response(
&mut self,
stop_reason: Option<&str>,
attempts: &mut u32,
) -> Result<bool> {
let Some(stop_reason) = stop_reason
.map(str::trim)
.filter(|reason| !reason.is_empty())
else {
return Ok(false);
};
if !Self::should_continue_after_stop_reason(stop_reason) {
return Ok(false);
}
if *attempts >= Self::MAX_INCOMPLETE_CONTINUATION_ATTEMPTS {
logging::warn(&format!(
"Response ended with stop_reason='{}' after {} continuation attempts; returning partial output",
stop_reason, attempts
));
return Ok(false);
}
*attempts += 1;
logging::warn(&format!(
"Response ended with stop_reason='{}'; requesting continuation (attempt {}/{})",
stop_reason,
attempts,
Self::MAX_INCOMPLETE_CONTINUATION_ATTEMPTS
));
self.add_message(
Role::User,
vec![ContentBlock::Text {
text: Self::continuation_prompt_for_stop_reason(stop_reason),
cache_control: None,
}],
);
self.session.save()?;
Ok(true)
}
pub(super) fn filter_truncated_tool_calls(
&mut self,
stop_reason: Option<&str>,
tool_calls: &mut Vec<ToolCall>,
assistant_message_id: Option<&String>,
) {
let stop_reason = stop_reason.unwrap_or("");
if !Self::should_continue_after_stop_reason(stop_reason) {
return;
}
let before = tool_calls.len();
tool_calls.retain(|tc| !tc.input.is_null());
let discarded = before - tool_calls.len();
if discarded > 0 && tool_calls.is_empty() {
logging::warn(&format!(
"Discarded {} tool call(s) with null input (truncated by {}); requesting continuation",
discarded,
if stop_reason.is_empty() {
"unknown"
} else {
stop_reason
}
));
if let Some(msg_id) = assistant_message_id {
self.session.remove_tool_use_blocks(msg_id);
self.persist_session_best_effort("truncated tool-call repair");
}
}
}
}
+168
View File
@@ -0,0 +1,168 @@
use super::*;
impl Agent {
pub fn session_memory_profile_snapshot(
&mut self,
) -> crate::session::SessionMemoryProfileSnapshot {
self.session.memory_profile_snapshot()
}
pub fn message_count(&self) -> usize {
self.session.messages.len()
}
/// Number of model-visible conversation messages (excludes the immutable
/// session-context header and internal system reminders).
pub fn visible_conversation_message_count(&self) -> usize {
self.session.visible_conversation_message_count()
}
/// Role of the most recent model-visible conversation message, if any.
///
/// When this is `User` and the agent is idle, the model still owes a
/// response for that turn (e.g. the turn errored or was interrupted before
/// the assistant replied).
pub fn last_visible_conversation_role(&self) -> Option<Role> {
self.session
.visible_conversation_messages()
.last()
.map(|message| message.role.clone())
}
pub fn last_message_role(&self) -> Option<Role> {
self.session.messages.last().map(|m| m.role.clone())
}
/// Get the text content of the last message (first Text block)
pub fn last_message_text(&self) -> Option<&str> {
self.session.messages.last().and_then(|m| {
m.content.iter().find_map(|block| {
if let ContentBlock::Text { text, .. } = block {
Some(text.as_str())
} else {
None
}
})
})
}
/// Build a transcript string for memory extraction
/// This is a independent method so it can be called before spawning async tasks
pub fn build_transcript_for_extraction(&self) -> String {
let mut transcript = String::new();
for msg in &self.session.messages {
let role = match msg.role {
Role::User => "User",
Role::Assistant => "Assistant",
};
transcript.push_str(&format!("**{}:**\n", role));
for block in &msg.content {
match block {
ContentBlock::Text { text, .. } => {
transcript.push_str(text);
transcript.push('\n');
}
ContentBlock::ToolUse { name, .. } => {
transcript.push_str(&format!("[Used tool: {}]\n", name));
}
ContentBlock::ToolResult { content, .. } => {
let preview = if content.len() > 200 {
format!("{}...", crate::util::truncate_str(content, 200))
} else {
content.clone()
};
transcript.push_str(&format!("[Result: {}]\n", preview));
}
ContentBlock::Reasoning { .. }
| ContentBlock::ReasoningTrace { .. }
| ContentBlock::AnthropicThinking { .. }
| ContentBlock::OpenAIReasoning { .. } => {}
ContentBlock::Image { .. } => {
transcript.push_str("[Image]\n");
}
ContentBlock::OpenAICompaction { .. } => {
transcript.push_str("[OpenAI native compaction]\n");
}
}
}
transcript.push('\n');
}
transcript
}
pub fn last_assistant_text(&self) -> Option<String> {
self.session
.messages
.iter()
.rev()
.find(|msg| msg.role == Role::Assistant)
.map(|msg| {
msg.content
.iter()
.filter_map(|c| {
if let ContentBlock::Text { text, .. } = c {
Some(text.clone())
} else {
None
}
})
.collect::<Vec<_>>()
.join("\n")
})
}
/// Latest non-empty assistant text added at or after `start_index`.
pub fn latest_assistant_text_after(&self, start_index: usize) -> Option<String> {
self.session
.messages
.iter()
.enumerate()
.rev()
.find_map(|(index, message)| {
if index < start_index || !matches!(&message.role, Role::Assistant) {
return None;
}
let text = message
.content
.iter()
.filter_map(|block| match block {
ContentBlock::Text { text, .. } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n\n");
let text = text.trim();
(!text.is_empty()).then(|| text.to_string())
})
}
pub fn last_upstream_provider(&self) -> Option<String> {
self.last_upstream_provider
.clone()
.or_else(|| self.provider.preferred_provider())
}
pub fn last_connection_type(&self) -> Option<String> {
self.last_connection_type.clone()
}
pub fn last_status_detail(&self) -> Option<String> {
self.last_status_detail.clone()
}
pub fn provider_name(&self) -> String {
// `display_name()` resolves the active runtime profile (e.g. NVIDIA NIM)
// for the OpenRouter slot; for all other providers it equals `name()`.
self.provider.display_name()
}
pub fn provider_model(&self) -> String {
self.provider.model().to_string()
}
/// Get the short/friendly name for this session (e.g., "fox")
pub fn session_short_name(&self) -> Option<&str> {
self.session.short_name.as_deref()
}
}
@@ -0,0 +1,26 @@
use super::STREAM_KEEPALIVE_PONG_ID;
use crate::protocol::ServerEvent;
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::time::{self, MissedTickBehavior};
fn stream_keepalive_interval() -> Duration {
if cfg!(test) {
Duration::from_millis(50)
} else {
Duration::from_secs(30)
}
}
pub(super) fn stream_keepalive_ticker() -> time::Interval {
let interval = stream_keepalive_interval();
let mut ticker = time::interval_at(time::Instant::now() + interval, interval);
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
ticker
}
pub(super) fn send_stream_keepalive_mpsc(event_tx: &mpsc::UnboundedSender<ServerEvent>) {
let _ = event_tx.send(ServerEvent::Pong {
id: STREAM_KEEPALIVE_PONG_ID,
});
}
+167
View File
@@ -0,0 +1,167 @@
use crate::message::{ContentBlock, ToolCall};
use crate::tool::ToolOutput;
pub(super) const MAX_TOOL_OUTPUT_CHARS_FOR_HISTORY: usize = 512 * 1024;
pub(super) fn cap_tool_output_for_history(tool_name: &str, mut output: ToolOutput) -> ToolOutput {
if output.output.chars().count() <= MAX_TOOL_OUTPUT_CHARS_FOR_HISTORY {
return output;
}
let original_chars = output.output.chars().count();
let kept = crate::util::truncate_str(&output.output, MAX_TOOL_OUTPUT_CHARS_FOR_HISTORY);
output.output = format!(
"{}\n\n[Tool output truncated by jcode: tool `{}` produced {} chars; kept first {} chars to protect the remote protocol, session history, and prompt cache. Redirect large logs to a file and read targeted sections.]",
kept, tool_name, original_chars, MAX_TOOL_OUTPUT_CHARS_FOR_HISTORY,
);
output
}
pub(super) fn cap_sdk_tool_content_for_history(tool_name: &str, content: String) -> String {
if content.chars().count() <= MAX_TOOL_OUTPUT_CHARS_FOR_HISTORY {
return content;
}
let original_chars = content.chars().count();
let kept = crate::util::truncate_str(&content, MAX_TOOL_OUTPUT_CHARS_FOR_HISTORY);
format!(
"{}\n\n[Tool output truncated by jcode: tool `{}` produced {} chars; kept first {} chars to protect the remote protocol, session history, and prompt cache. Redirect large logs to a file and read targeted sections.]",
kept, tool_name, original_chars, MAX_TOOL_OUTPUT_CHARS_FOR_HISTORY,
)
}
/// Build rendered side-pane images from a tool output's attached images.
///
/// This mirrors how `render_messages_and_images` derives images from persisted
/// session history (source = ToolResult), so live-streamed images match what a
/// later History reload would produce. `tool_name` and `tool_input` provide the
/// label fallback (e.g. the `read` tool's `file_path`); `tool_call_id` anchors
/// the image to its tool message in the transcript.
pub(super) fn tool_output_side_pane_images(
tool_call_id: &str,
tool_name: &str,
tool_input: &serde_json::Value,
output: &ToolOutput,
) -> Vec<jcode_session_types::RenderedImage> {
if output.images.is_empty() {
return Vec::new();
}
let fallback_label = tool_input
.get("file_path")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
output
.images
.iter()
.map(|img| jcode_session_types::RenderedImage {
media_type: img.media_type.clone(),
data: img.data.clone(),
label: img
.label
.as_ref()
.map(|label| label.trim().to_string())
.filter(|label| !label.is_empty())
.or_else(|| fallback_label.clone()),
source: jcode_session_types::RenderedImageSource::ToolResult {
tool_name: tool_name.to_string(),
},
anchor: Some(jcode_session_types::RenderedImageAnchor::ToolCall {
id: tool_call_id.to_string(),
}),
})
.collect()
}
pub(super) fn tool_output_to_content_blocks(
tool_use_id: String,
output: ToolOutput,
) -> Vec<ContentBlock> {
let mut blocks = vec![ContentBlock::ToolResult {
tool_use_id,
content: output.output,
is_error: None,
}];
for img in output.images {
blocks.push(ContentBlock::Image {
media_type: img.media_type,
data: img.data,
});
if let Some(label) = img.label.filter(|label| !label.trim().is_empty()) {
blocks.push(ContentBlock::Text {
text: format!(
"[Attached image associated with the preceding tool result: {}]",
label
),
cache_control: None,
});
}
}
blocks
}
pub(super) fn print_tool_summary(tool: &ToolCall) {
match tool.name.as_str() {
"bash" => {
if let Some(cmd) = tool.input.get("command").and_then(|v| v.as_str()) {
let short = if cmd.len() > 60 {
format!("{}...", crate::util::truncate_str(cmd, 60))
} else {
cmd.to_string()
};
println!("$ {}", short);
}
}
"read" | "write" | "edit" => {
if let Some(path) = tool.input.get("file_path").and_then(|v| v.as_str()) {
println!("{}", path);
}
}
"glob" | "grep" => {
if let Some(pattern) = tool.input.get("pattern").and_then(|v| v.as_str()) {
println!("'{}'", pattern);
}
}
"ls" => {
let path = tool
.input
.get("path")
.and_then(|v| v.as_str())
.unwrap_or(".");
println!("{}", path);
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cap_tool_output_leaves_small_output_unchanged() {
let output = ToolOutput::new("short output");
let capped = cap_tool_output_for_history("bash", output.clone());
assert_eq!(capped.output, output.output);
}
#[test]
fn cap_tool_output_adds_visible_truncation_notice() {
let output = ToolOutput::new("x".repeat(MAX_TOOL_OUTPUT_CHARS_FOR_HISTORY + 10));
let capped = cap_tool_output_for_history("bash", output);
assert!(capped.output.len() < MAX_TOOL_OUTPUT_CHARS_FOR_HISTORY + 1_000);
assert!(capped.output.contains("Tool output truncated by jcode"));
assert!(capped.output.contains("tool `bash` produced"));
assert!(capped.output.contains("Redirect large logs to a file"));
}
#[test]
fn cap_sdk_tool_content_adds_same_notice() {
let capped = cap_sdk_tool_content_for_history(
"custom",
"y".repeat(MAX_TOOL_OUTPUT_CHARS_FOR_HISTORY + 10),
);
assert!(capped.contains("Tool output truncated by jcode"));
assert!(capped.contains("tool `custom` produced"));
}
}
@@ -0,0 +1,917 @@
use super::*;
impl Agent {
/// Run a single turn with the given user message
pub async fn run_once(&mut self, user_message: &str) -> Result<()> {
self.add_message(
Role::User,
vec![ContentBlock::Text {
text: user_message.to_string(),
cache_control: None,
}],
);
self.session.save()?;
if trace_enabled() {
eprintln!("[trace] session_id {}", self.session.id);
}
let _ = self.run_turn(true).await?;
Ok(())
}
pub async fn run_once_capture(&mut self, user_message: &str) -> Result<String> {
self.add_message(
Role::User,
vec![ContentBlock::Text {
text: user_message.to_string(),
cache_control: None,
}],
);
self.session.save()?;
if trace_enabled() {
eprintln!("[trace] session_id {}", self.session.id);
}
self.run_turn(false).await
}
/// Run one conversation turn with streaming events via mpsc channel (per-client)
pub async fn run_once_streaming_mpsc(
&mut self,
user_message: &str,
images: Vec<(String, String)>,
system_reminder: Option<String>,
event_tx: mpsc::UnboundedSender<ServerEvent>,
) -> Result<()> {
// Inject any pending notifications before the user message
let alerts = self.take_alerts();
if !alerts.is_empty() {
let alert_text = format!(
"[NOTIFICATION]\nYou received {} notification(s) from other agents working in this codebase:\n\n{}\n\nUse the communicate tool to coordinate with other agents (prefer dm; broadcast reaches only your spawned subtree).",
alerts.len(),
alerts.join("\n\n---\n\n")
);
self.add_message(
Role::User,
vec![ContentBlock::Text {
text: alert_text,
cache_control: None,
}],
);
}
self.current_turn_system_reminder =
system_reminder.filter(|value| !value.trim().is_empty());
let mut blocks: Vec<ContentBlock> = images
.into_iter()
.map(|(media_type, data)| ContentBlock::Image { media_type, data })
.collect();
blocks.push(ContentBlock::Text {
text: user_message.to_string(),
cache_control: None,
});
if blocks.len() > 1 {
crate::logging::info(&format!(
"Agent received message with {} image(s)",
blocks.len() - 1
));
}
self.add_message(Role::User, blocks);
crate::telemetry::record_turn();
self.session.save()?;
let turn_started_at = Instant::now();
let start_message_index = self.message_count();
self.fire_turn_start_hook("chat");
let result = self.run_turn_streaming_mpsc(event_tx).await;
self.current_turn_system_reminder = None;
self.fire_turn_end_hook(&result, turn_started_at, start_message_index);
result
}
/// Fire the `turn_start` observer hook when a turn begins, before the model
/// starts generating (and before the first `pre_tool`). This lets external
/// integrations (terminal multiplexers, status bars) detect that the agent
/// is actively working during the otherwise-invisible window between prompt
/// submission and the first tool call. No-op (without building the payload)
/// when the hook is not configured.
fn fire_turn_start_hook(&self, source: &str) {
if !crate::hooks::hook_configured("turn_start") {
return;
}
let mut event = crate::hooks::HookEvent::new("turn_start")
.session_id(self.session.id.clone())
.field("MODEL", self.provider_model())
.field("SOURCE", source.to_string());
if let Some(cwd) = self.working_dir() {
event = event.cwd(cwd);
}
crate::hooks::dispatch_observer(event);
}
/// Fire the `turn_end` observer hook with turn outcome metadata.
/// No-op (without building the payload) when the hook is not configured.
fn fire_turn_end_hook(
&self,
result: &Result<()>,
started_at: Instant,
start_message_index: usize,
) {
if !crate::hooks::hook_configured("turn_end") {
return;
}
let status = if result.is_ok() { "ok" } else { "error" };
let mut event = crate::hooks::HookEvent::new("turn_end")
.session_id(self.session.id.clone())
.field("STATUS", status)
.field("DURATION_MS", started_at.elapsed().as_millis().to_string())
.field("MODEL", self.provider_model());
if let Some(cwd) = self.working_dir() {
event = event.cwd(cwd);
}
if let Some(text) = self.latest_assistant_text_after(start_message_index) {
const LAST_TEXT_LIMIT: usize = 4000;
let snippet: String = text.chars().take(LAST_TEXT_LIMIT).collect();
event = event.field("LAST_ASSISTANT_TEXT", snippet);
}
if let Err(error) = result {
const ERROR_LIMIT: usize = 1000;
let message: String = error.to_string().chars().take(ERROR_LIMIT).collect();
event = event.field("ERROR", message);
}
crate::hooks::dispatch_observer(event);
}
/// Clear conversation history
pub fn clear(&mut self) {
let preserve_canary = self.session.is_canary;
let preserve_testing_build = self.session.testing_build.clone();
let preserve_debug = self.session.is_debug;
let preserve_working_dir = self.session.working_dir.clone();
self.session.mark_closed();
self.persist_session_best_effort("pre-clear session close state");
let mut new_session = Session::create(None, None);
new_session.mark_active();
new_session.model = Some(self.provider.model());
new_session.provider_key =
crate::session::derive_session_provider_key(self.provider.name());
new_session.is_canary = preserve_canary;
new_session.testing_build = preserve_testing_build;
new_session.is_debug = preserve_debug;
new_session.working_dir = preserve_working_dir;
new_session.ensure_initial_session_context_message();
self.session = new_session;
self.reset_runtime_state_for_session_change();
self.provider_session_id = None;
self.seed_compaction_from_session();
}
/// Clear provider session so the next turn sends full context.
pub fn reset_provider_session(&mut self) {
self.provider_session_id = None;
self.session.provider_session_id = None;
self.persist_session_best_effort("provider session reset");
}
/// Rewind the conversation to a 1-based visible transcript message index.
///
/// The index is interpreted against the same rendered transcript the TUI
/// numbers in `/rewind` (user/assistant entries only, tool cards and
/// system notices excluded). Mapping through raw stored messages instead
/// would count tool-result messages the UI never numbers, sending
/// `/rewind N` far earlier than the on-screen message N (issue #432).
///
/// Provider-side resumable sessions are reset so the next request sends the
/// truncated context from scratch instead of continuing from a stale upstream
/// conversation.
pub fn rewind_to_message(&mut self, message_index: usize) -> Result<usize, String> {
let targets = self.session.rewind_target_stored_indices();
let message_count = targets.len();
if message_index == 0 || message_index > message_count {
return Err(format!(
"Invalid message number: {}. Valid range: 1-{}",
message_index, message_count
));
}
let stored_len = targets[message_index - 1] + 1;
let removed = message_count - message_index;
self.rewind_undo_snapshot = Some(RewindUndoSnapshot {
messages: self.session.messages.clone(),
provider_session_id: self.provider_session_id.clone(),
session_provider_session_id: self.session.provider_session_id.clone(),
visible_message_count: message_count,
});
self.session.truncate_messages(stored_len);
self.session.updated_at = chrono::Utc::now();
self.provider_session_id = None;
self.session.provider_session_id = None;
self.cache_tracker.reset();
self.locked_tools = None;
self.reset_tool_output_tracking();
self.persist_session_best_effort("conversation rewind");
Ok(removed)
}
pub fn undo_rewind(&mut self) -> Result<usize, String> {
let Some(snapshot) = self.rewind_undo_snapshot.take() else {
return Err("No rewind to undo.".to_string());
};
let current_count = self.session.rewind_target_count();
let restored = snapshot.visible_message_count.saturating_sub(current_count);
self.session.replace_messages(snapshot.messages);
self.provider_session_id = snapshot.provider_session_id;
self.session.provider_session_id = snapshot.session_provider_session_id;
self.session.updated_at = chrono::Utc::now();
self.cache_tracker.reset();
self.locked_tools = None;
self.reset_tool_output_tracking();
self.persist_session_best_effort("conversation rewind undo");
Ok(restored)
}
/// Unlock the tool list so the next API request picks up any new tools.
/// Called after MCP reload or when the user explicitly wants new tools.
pub fn unlock_tools(&mut self) {
if self.locked_tools.is_some() {
logging::info("Tool list unlocked — next request will pick up current tools");
self.locked_tools = None;
self.cache_tracker.reset();
}
// Allow the late-MCP-registration recheck to fire once for the next
// snapshot (e.g. after an explicit `mcp` reload).
self.mcp_late_register_resolved = false;
}
/// Unlock tools if a tool execution may have changed the registry
/// (e.g., mcp connect/disconnect/reload)
pub(super) fn unlock_tools_if_needed(&mut self, tool_name: &str) {
if tool_name == "mcp" {
self.unlock_tools();
}
}
pub fn is_canary(&self) -> bool {
self.session.is_canary
}
pub fn is_debug(&self) -> bool {
self.session.is_debug
}
pub fn set_canary(&mut self, build_hash: &str) {
self.session.set_canary(build_hash);
if let Err(err) = self.session.save() {
logging::error(&format!("Failed to persist canary session state: {}", err));
}
}
/// Mark this session as a debug/test session
/// Set a custom system prompt override (used by ambient mode).
/// When set, this replaces the normal system prompt entirely.
pub fn set_system_prompt(&mut self, prompt: &str) {
self.system_prompt_override = Some(prompt.to_string());
}
pub fn set_debug(&mut self, is_debug: bool) {
self.session.set_debug(is_debug);
if let Err(err) = self.session.save() {
logging::error(&format!("Failed to persist debug session state: {}", err));
}
}
/// Enable or disable memory features for this session.
pub fn set_memory_enabled(&mut self, enabled: bool) {
self.memory_enabled = enabled;
if !enabled {
crate::memory::clear_pending_memory(&self.session.id);
}
}
/// Mark this session as an inline swarm worker. When enabled, the streaming
/// loop publishes a throttled output tail to the global bus so a
/// coordinator can render a live inline gallery viewport for it.
pub fn set_inline_output_tap(&mut self, enabled: bool) {
self.inline_output_tap = enabled;
}
/// Whether this session streams an inline output tail to the bus.
pub(crate) fn inline_output_tap(&self) -> bool {
self.inline_output_tap
}
/// Publish the current rolling activity tail to the bus for the
/// coordinator's inline gallery. No-op unless the inline tap is enabled.
pub(crate) fn publish_inline_tail(&self) {
if !self.inline_output_tap {
return;
}
crate::bus::Bus::global().publish(crate::bus::BusEvent::SwarmOutputTail(
crate::bus::SwarmOutputTail {
session_id: self.session.id.clone(),
tail: self.inline_tail.render(),
},
));
}
/// Check whether memory features are enabled for this session.
pub fn memory_enabled(&self) -> bool {
self.memory_enabled
}
/// Set the stdin request channel for interactive stdin forwarding
pub fn set_stdin_request_tx(
&mut self,
tx: tokio::sync::mpsc::UnboundedSender<crate::tool::StdinInputRequest>,
) {
self.stdin_request_tx = Some(tx);
}
pub(super) async fn tool_definitions(&mut self) -> Vec<ToolDefinition> {
if self.session.is_canary {
self.registry.register_selfdev_tools().await;
}
// Return locked tools if available (prevents cache invalidation from
// tools arriving asynchronously after the first API request).
//
// Exception: MCP servers connect on a background task and register
// `mcp__*` tools seconds after the session starts — typically *after*
// the first turn has already locked the snapshot. We deliberately do
// NOT block the first turn on MCP connection: servers can be slow or
// hang, and we want the user to be able to talk to the agent the moment
// the session spawns. The price is that the first locked snapshot is
// missing MCP tools, and the only other unlock path fires when the model
// calls the `mcp` management tool — which it cannot do without first
// seeing MCP tools (#206).
//
// So, exactly once per locked snapshot, if MCP tools have since appeared
// in the registry, we rebuild. This is a single intentional provider
// prompt-cache miss (the turn MCP tools first appear). The
// `mcp_late_register_resolved` flag makes this a one-shot check so we do
// not rescan the registry on every subsequent turn.
if let Some(ref locked) = self.locked_tools {
if self.mcp_late_register_resolved {
return locked.clone();
}
if self.registry_has_new_mcp_tools(locked).await {
logging::info(
"MCP tools registered after first turn locked the tool snapshot — \
rebuilding once to expose them. This is one intentional prompt-cache \
miss; we accept it so the agent is reachable immediately at spawn \
instead of blocking on MCP connection (#206).",
);
// Latch the one-shot guard and drop the stale snapshot directly.
// We intentionally do NOT call `unlock_tools()` here, because that
// re-arms the guard (it is the explicit-reload path) and would let
// the recheck fire again on every later turn.
self.mcp_late_register_resolved = true;
self.locked_tools = None;
self.cache_tracker.reset();
} else {
// No MCP tools have appeared. They may still be connecting, so
// leave the guard unset and re-check on the next turn. Once they
// appear (or never do, after the registry settles) we stop.
return locked.clone();
}
}
let tools = self.build_filtered_tool_definitions().await;
// Lock the tool list to prevent cache invalidation when more tools
// arrive asynchronously mid-session.
logging::info(&format!(
"Locking tool list at {} tools for cache stability",
tools.len()
));
self.locked_tools = Some(tools.clone());
tools
}
/// Build the agent's tool definitions from the registry, applying the
/// session's `allowed_tools`, `disabled_tools`, and self-dev filters.
async fn build_filtered_tool_definitions(&self) -> Vec<ToolDefinition> {
let mut tools = self.registry.definitions(self.allowed_tools.as_ref()).await;
if !self.disabled_tools.is_empty() {
tools.retain(|tool| !self.disabled_tools.contains(&tool.name));
}
Self::apply_selfdev_tool_surface(&mut tools, self.session.is_canary);
tools
}
/// Tailor the `selfdev` tool definition to the session mode.
///
/// The registry stores a single shared `selfdev` tool with a default
/// (non-self-dev) schema. Self-dev sessions get the full build/test/reload
/// surface; every other session keeps the lightweight on-ramp surface
/// (`enter`, `setup`, `reload`, `status`, `find-config`). The tool stays
/// available in all sessions so the agent can always enter self-dev mode.
fn apply_selfdev_tool_surface(tools: &mut [ToolDefinition], is_canary: bool) {
for tool in tools.iter_mut() {
if tool.name == "selfdev" {
tool.description =
crate::tool::selfdev::SelfDevTool::description_for(is_canary).to_string();
tool.input_schema = crate::tool::selfdev::SelfDevTool::schema_for(is_canary);
}
}
}
/// Returns true if the registry contains `mcp__*` tools (subject to the
/// session's `allowed_tools` filter) that are not present in the currently
/// locked snapshot. Used to detect the async MCP-registration race (#206).
async fn registry_has_new_mcp_tools(&self, locked: &[ToolDefinition]) -> bool {
let registry_names = self.registry.tool_names().await;
let allowed = self.allowed_tools.as_ref();
registry_names.iter().any(|name| {
name.starts_with("mcp__")
&& allowed.map(|set| set.contains(name)).unwrap_or(true)
&& !self.disabled_tools.contains(name)
&& !locked.iter().any(|t| &t.name == name)
})
}
pub async fn tool_names(&self) -> Vec<String> {
self.tool_definitions_for_debug()
.await
.into_iter()
.map(|tool| tool.name)
.collect()
}
/// Get full tool definitions for debug introspection (bypasses lock)
pub async fn tool_definitions_for_debug(&self) -> Vec<crate::message::ToolDefinition> {
if self.session.is_canary {
self.registry.register_selfdev_tools().await;
}
let mut tools = self.registry.definitions(self.allowed_tools.as_ref()).await;
if !self.disabled_tools.is_empty() {
tools.retain(|tool| !self.disabled_tools.contains(&tool.name));
}
Self::apply_selfdev_tool_surface(&mut tools, self.session.is_canary);
tools
}
pub async fn execute_tool(
&self,
name: &str,
input: serde_json::Value,
) -> Result<crate::tool::ToolOutput> {
self.validate_tool_allowed(name)?;
let call_id = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| format!("debug-{}", d.as_millis()))
.unwrap_or_else(|_| "debug".to_string());
let ctx = ToolContext {
session_id: self.session.id.clone(),
message_id: self.session.id.clone(),
tool_call_id: call_id,
working_dir: self.working_dir().map(PathBuf::from),
stdin_request_tx: self.stdin_request_tx.clone(),
graceful_shutdown_signal: Some(self.graceful_shutdown.clone()),
execution_mode: ToolExecutionMode::Direct,
};
self.registry.execute(name, input, ctx).await
}
pub fn add_manual_tool_use(
&mut self,
tool_call_id: String,
tool_name: String,
input: serde_json::Value,
) -> Result<String> {
let message_id = self.add_message(
Role::Assistant,
vec![ContentBlock::ToolUse {
id: tool_call_id,
name: tool_name,
input,
thought_signature: None,
}],
);
self.session.save()?;
Ok(message_id)
}
pub fn add_manual_tool_result(
&mut self,
tool_call_id: String,
output: crate::tool::ToolOutput,
duration_ms: u64,
) -> Result<()> {
let blocks = tool_output_to_content_blocks(tool_call_id, output);
self.add_message_with_duration(Role::User, blocks, Some(duration_ms));
self.session.save()?;
Ok(())
}
pub fn add_manual_tool_error(
&mut self,
tool_call_id: String,
error: String,
duration_ms: u64,
) -> Result<()> {
self.add_message_with_duration(
Role::User,
vec![ContentBlock::ToolResult {
tool_use_id: tool_call_id,
content: error,
is_error: Some(true),
}],
Some(duration_ms),
);
self.session.save()?;
Ok(())
}
pub(super) fn validate_tool_allowed(&self, name: &str) -> Result<()> {
if let Some(allowed) = self.allowed_tools.as_ref()
&& !allowed.contains(name)
{
return Err(anyhow::anyhow!("Tool '{}' is not allowed", name));
}
if self.disabled_tools.contains(name) {
return Err(anyhow::anyhow!("Tool '{}' is disabled", name));
}
Ok(())
}
/// Restore a session by ID (loads from disk)
pub fn restore_session(&mut self, session_id: &str) -> Result<SessionStatus> {
self.restore_session_with_working_dir(session_id, None)
}
pub(crate) fn restore_session_with_working_dir(
&mut self,
session_id: &str,
working_dir: Option<&str>,
) -> Result<SessionStatus> {
let restore_start = Instant::now();
let load_start = Instant::now();
let mut session = Session::load(session_id)?;
if let Some(working_dir) = working_dir {
session.working_dir = Some(working_dir.to_string());
session.refresh_initial_session_context_message();
}
let load_ms = load_start.elapsed().as_millis();
logging::info(&format!(
"Restoring session '{}' with {} messages, provider_session_id: {:?}, status: {}",
session_id,
session.messages.len(),
session.provider_session_id,
session.status.display()
));
let previous_status = session.status.clone();
let assign_start = Instant::now();
let previous_session_id = self.session.id.clone();
// Restore provider_session_id for Claude CLI session resume
self.provider_session_id = session.provider_session_id.clone();
self.session = session;
crate::tool::clear_session_tool_policy(&previous_session_id);
crate::tool::set_session_tool_policy(
&self.session.id,
self.allowed_tools.clone(),
self.disabled_tools.clone(),
);
let assign_ms = assign_start.elapsed().as_millis();
let reset_start = Instant::now();
self.reset_runtime_state_for_session_change();
let restored_soft_interrupts = self.restore_persisted_soft_interrupts();
let reset_ms = reset_start.elapsed().as_millis();
let model_start = Instant::now();
if let Some(model) = self.session.model.clone() {
let model_request =
crate::provider::MultiProvider::model_switch_request_for_session_route(
&model,
self.session.provider_key.as_deref(),
self.session.route_api_method.as_deref(),
);
if let Err(e) =
crate::provider::set_model_with_auth_refresh(self.provider.as_ref(), &model_request)
{
logging::error(&format!(
"Failed to restore session model '{}' via '{}': {}",
model, model_request, e
));
}
} else {
self.session.model = Some(self.provider.model());
}
self.restore_reasoning_effort_from_session();
let model_ms = model_start.elapsed().as_millis();
let mark_active_start = Instant::now();
self.session.mark_active();
let mark_active_ms = mark_active_start.elapsed().as_millis();
self.sync_memory_dedup_state_from_session();
logging::info(&format!(
"restore_session: loaded session {} with {} messages, calling seed_compaction",
session_id,
self.session.messages.len()
));
let compaction_start = Instant::now();
self.seed_compaction_from_session();
let compaction_ms = compaction_start.elapsed().as_millis();
let env_snapshot_start = Instant::now();
self.log_env_snapshot("resume");
let env_snapshot_ms = env_snapshot_start.elapsed().as_millis();
self.fire_session_lifecycle_hook("session_start", "resume");
let save_start = Instant::now();
if let Err(err) = self.session.save() {
logging::error(&format!(
"Failed to persist resumed session state for {}: {}",
session_id, err
));
}
let save_ms = save_start.elapsed().as_millis();
logging::info(&format!(
"[TIMING] restore_session: session={}, messages={}, restored_soft_interrupts={}, load={}ms, assign={}ms, reset={}ms, model={}ms, mark_active={}ms, compaction={}ms, env_snapshot={}ms, save={}ms, total={}ms",
session_id,
self.session.messages.len(),
restored_soft_interrupts,
load_ms,
assign_ms,
reset_ms,
model_ms,
mark_active_ms,
compaction_ms,
env_snapshot_ms,
save_ms,
restore_start.elapsed().as_millis(),
));
logging::info(&format!(
"Session restored: {} messages in session",
self.session.messages.len()
));
Ok(previous_status)
}
/// Get conversation history for sync
pub fn get_history(&self) -> Vec<HistoryMessage> {
crate::session::render_messages(&self.session)
.into_iter()
.map(|msg| HistoryMessage {
role: msg.role,
content: msg.content,
tool_calls: if msg.tool_calls.is_empty() {
None
} else {
Some(msg.tool_calls)
},
tool_data: msg.tool_data,
})
.collect()
}
pub fn get_history_and_rendered_images(
&self,
) -> (Vec<HistoryMessage>, Vec<crate::session::RenderedImage>) {
let (messages, images) = crate::session::render_messages_and_images(&self.session);
let history = messages
.into_iter()
.map(|msg| HistoryMessage {
role: msg.role,
content: msg.content,
tool_calls: if msg.tool_calls.is_empty() {
None
} else {
Some(msg.tool_calls)
},
tool_data: msg.tool_data,
})
.collect();
(history, images)
}
pub fn get_history_and_rendered_images_with_compacted_history(
&self,
compacted_history_visible: usize,
) -> (
Vec<HistoryMessage>,
Vec<crate::session::RenderedImage>,
Option<crate::session::RenderedCompactedHistoryInfo>,
) {
let (messages, images, compacted_info) =
crate::session::render_messages_and_images_with_compacted_history(
&self.session,
compacted_history_visible,
);
let history = messages
.into_iter()
.map(|msg| HistoryMessage {
role: msg.role,
content: msg.content,
tool_calls: if msg.tool_calls.is_empty() {
None
} else {
Some(msg.tool_calls)
},
tool_data: msg.tool_data,
})
.collect();
(history, images, compacted_info)
}
pub fn get_tool_call_summaries(&self, limit: usize) -> Vec<crate::protocol::ToolCallSummary> {
crate::session::summarize_tool_calls(&self.session, limit)
}
/// Start an interactive REPL
pub async fn repl(&mut self) -> Result<()> {
println!("J-Code - Coding Agent");
println!("Type your message, or 'quit' to exit.");
// Show available skills
let skills = self.current_skills_snapshot();
let skill_list = skills.list();
if !skill_list.is_empty() {
println!(
"Available skills: {}",
skill_list
.iter()
.map(|s| format!("/{}", s.name))
.collect::<Vec<_>>()
.join(", ")
);
}
println!();
loop {
print!("> ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let input = input.trim();
if input.is_empty() {
continue;
}
if input == "quit" || input == "exit" {
break;
}
if input == "clear" {
self.clear();
println!("Conversation cleared.");
continue;
}
// Check for skill invocation
if let Some(invocation) = SkillRegistry::parse_invocation(input) {
if let Some(skill) = skills.get(invocation.name) {
println!("Activating skill: {}", skill.name);
println!("{}\n", skill.description);
self.active_skill = Some(invocation.name.to_string());
if let Some(prompt) = invocation.prompt {
if let Err(e) = self.run_once(prompt).await {
eprintln!("\nError: {}\n", e);
}
println!();
}
continue;
} else {
println!("Unknown skill: /{}", invocation.name);
println!(
"Available: {}",
skills
.list()
.iter()
.map(|s| format!("/{}", s.name))
.collect::<Vec<_>>()
.join(", ")
);
continue;
}
}
if let Err(e) = self.run_once(input).await {
eprintln!("\nError: {}\n", e);
}
println!();
}
// Extract memories from session before exiting
self.extract_session_memories().await;
Ok(())
}
/// Extract memories from the session transcript
/// Returns the number of memories extracted, or 0 if none/skipped
pub async fn extract_session_memories(&self) -> usize {
if !self.memory_enabled {
return 0;
}
// Need at least 4 messages for meaningful extraction
if self.session.messages.len() < 4 {
return 0;
}
logging::info(&format!(
"Extracting memories from {} messages",
self.session.messages.len()
));
// Build transcript
let mut transcript = String::new();
for msg in &self.session.messages {
let role = match msg.role {
Role::User => "User",
Role::Assistant => "Assistant",
};
transcript.push_str(&format!("**{}:**\n", role));
for block in &msg.content {
match block {
ContentBlock::Text { text, .. } => {
transcript.push_str(text);
transcript.push('\n');
}
ContentBlock::ToolUse { name, .. } => {
transcript.push_str(&format!("[Used tool: {}]\n", name));
}
ContentBlock::ToolResult { content, .. } => {
let preview = if content.len() > 200 {
format!("{}...", crate::util::truncate_str(content, 200))
} else {
content.clone()
};
transcript.push_str(&format!("[Result: {}]\n", preview));
}
ContentBlock::Reasoning { .. }
| ContentBlock::ReasoningTrace { .. }
| ContentBlock::AnthropicThinking { .. }
| ContentBlock::OpenAIReasoning { .. } => {}
ContentBlock::Image { .. } => {
transcript.push_str("[Image]\n");
}
ContentBlock::OpenAICompaction { .. } => {
transcript.push_str("[OpenAI native compaction]\n");
}
}
}
transcript.push('\n');
}
if !crate::memory::memory_llm_judge_available() {
logging::info("Memory extraction skipped: LLM judge unavailable");
return 0;
}
// Extract using sidecar
let sidecar = crate::sidecar::Sidecar::new();
match sidecar.extract_memories(&transcript).await {
Ok(extracted) if !extracted.is_empty() => {
let manager = self
.session
.working_dir
.as_deref()
.map(|dir| crate::memory::MemoryManager::new().with_project_dir(dir))
.unwrap_or_default();
let mut stored_count = 0;
for memory in &extracted {
let category = crate::memory::MemoryCategory::from_extracted(&memory.category);
let trust = match memory.trust.as_str() {
"high" => crate::memory::TrustLevel::High,
"low" => crate::memory::TrustLevel::Low,
_ => crate::memory::TrustLevel::Medium,
};
let entry = crate::memory::MemoryEntry::new(category, &memory.content)
.with_source(&self.session.id)
.with_trust(trust);
if manager.remember_project(entry).is_ok() {
stored_count += 1;
}
}
if stored_count > 0 {
logging::info(&format!("Extracted {} memories from session", stored_count));
}
stored_count
}
Ok(_) => 0,
Err(e) => {
logging::info(&format!("Memory extraction skipped: {}", e));
0
}
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
use crate::session::GitState;
use std::path::Path;
use std::process::Command;
pub(super) fn trace_enabled() -> bool {
match std::env::var("JCODE_TRACE") {
Ok(value) => {
let value = value.trim();
!value.is_empty() && value != "0" && value.to_lowercase() != "false"
}
Err(_) => false,
}
}
pub(super) fn git_state_for_dir(dir: &Path) -> Option<GitState> {
let root = git_output(dir, &["rev-parse", "--show-toplevel"])?;
let head = git_output(dir, &["rev-parse", "HEAD"]);
let branch = git_output(dir, &["rev-parse", "--abbrev-ref", "HEAD"]);
let dirty = git_output(dir, &["status", "--porcelain"]).map(|out| !out.is_empty());
Some(GitState {
root,
head,
branch,
dirty,
})
}
fn git_output(dir: &Path, args: &[&str]) -> Option<String> {
let output = Command::new("git")
.args(args)
.current_dir(dir)
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
File diff suppressed because it is too large Load Diff
+197
View File
@@ -0,0 +1,197 @@
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
mod directives;
mod manager;
mod paths;
mod persistence;
mod prompt;
pub mod runner;
pub mod scheduler;
pub use directives::{
UserDirective, add_directive, has_pending_directives, load_directives, take_pending_directives,
};
pub use manager::AmbientManager;
pub use persistence::{AmbientLock, ScheduledQueue};
#[cfg(test)]
pub(crate) use prompt::format_duration_rough;
pub use prompt::{
MemoryGraphHealth, RecentSessionInfo, ResourceBudget, build_ambient_system_prompt,
format_minutes_human, format_scheduled_session_message, gather_feedback_memories,
gather_memory_graph_health, gather_recent_sessions,
};
use crate::storage;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/// Context passed from the ambient runner to a visible TUI cycle.
/// Saved to `~/.jcode/ambient/visible_cycle.json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VisibleCycleContext {
pub system_prompt: String,
pub initial_message: String,
}
impl VisibleCycleContext {
pub fn context_path() -> Result<PathBuf> {
Ok(storage::jcode_dir()?
.join("ambient")
.join("visible_cycle.json"))
}
pub fn save(&self) -> Result<()> {
let path = Self::context_path()?;
if let Some(parent) = path.parent() {
storage::ensure_dir(parent)?;
}
storage::write_json(&path, self)
}
pub fn load() -> Result<Self> {
let path = Self::context_path()?;
storage::read_json(&path)
}
pub fn result_path() -> Result<PathBuf> {
Ok(storage::jcode_dir()?
.join("ambient")
.join("cycle_result.json"))
}
}
/// Ambient mode status
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub enum AmbientStatus {
#[default]
Idle,
Running {
detail: String,
},
Scheduled {
next_wake: DateTime<Utc>,
},
Paused {
reason: String,
},
Disabled,
}
/// Priority for scheduled items
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub enum Priority {
Low,
Normal,
High,
}
/// Where a scheduled task should be delivered when it becomes due.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ScheduleTarget {
/// Wake the ambient agent and hand it the queued task.
#[default]
Ambient,
/// Deliver the reminder back into a specific interactive session.
Session { session_id: String },
/// Spawn a single new session derived from the originating session.
Spawn { parent_session_id: String },
}
impl ScheduleTarget {
pub fn is_direct_delivery(&self) -> bool {
matches!(self, Self::Session { .. } | Self::Spawn { .. })
}
}
/// A scheduled ambient task
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScheduledItem {
pub id: String,
pub scheduled_for: DateTime<Utc>,
pub context: String,
pub priority: Priority,
#[serde(default)]
pub target: ScheduleTarget,
pub created_by_session: String,
pub created_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub task_description: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub relevant_files: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git_branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub additional_context: Option<String>,
}
/// Persistent ambient state
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AmbientState {
pub status: AmbientStatus,
pub last_run: Option<DateTime<Utc>>,
pub last_summary: Option<String>,
pub last_compactions: Option<u32>,
pub last_memories_modified: Option<u32>,
pub total_cycles: u64,
}
/// Result from an ambient cycle
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AmbientCycleResult {
pub summary: String,
pub memories_modified: u32,
pub compactions: u32,
pub proactive_work: Option<String>,
pub next_schedule: Option<ScheduleRequest>,
pub started_at: DateTime<Utc>,
pub ended_at: DateTime<Utc>,
pub status: CycleStatus,
/// Full conversation transcript (markdown) for email notifications
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conversation: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum CycleStatus {
Complete,
Interrupted,
Incomplete,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScheduleRequest {
pub wake_in_minutes: Option<u32>,
pub wake_at: Option<DateTime<Utc>>,
pub context: String,
pub priority: Priority,
#[serde(default)]
pub target: ScheduleTarget,
#[serde(default)]
pub created_by_session: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub task_description: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub relevant_files: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git_branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub additional_context: Option<String>,
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
#[path = "ambient_tests.rs"]
mod ambient_tests;
@@ -0,0 +1,76 @@
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use super::paths::ambient_dir;
use crate::storage;
// ---------------------------------------------------------------------------
// User Directives (from email replies)
// ---------------------------------------------------------------------------
/// A user directive received via email reply to an ambient cycle notification.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserDirective {
pub id: String,
pub text: String,
pub received_at: DateTime<Utc>,
pub in_reply_to_cycle: String,
pub consumed: bool,
}
fn directives_path() -> Result<PathBuf> {
Ok(ambient_dir()?.join("directives.json"))
}
pub fn load_directives() -> Vec<UserDirective> {
directives_path()
.ok()
.and_then(|p| {
if p.exists() {
storage::read_json(&p).ok()
} else {
None
}
})
.unwrap_or_default()
}
fn save_directives(directives: &[UserDirective]) -> Result<()> {
storage::write_json(&directives_path()?, directives)
}
/// Store a new directive from an email reply.
pub fn add_directive(text: String, in_reply_to: String) -> Result<()> {
let mut directives = load_directives();
directives.push(UserDirective {
id: format!("dir_{:08x}", rand::random::<u32>()),
text,
received_at: Utc::now(),
in_reply_to_cycle: in_reply_to,
consumed: false,
});
save_directives(&directives)
}
/// Take all unconsumed directives, marking them as consumed.
pub fn take_pending_directives() -> Vec<UserDirective> {
let mut all = load_directives();
let pending: Vec<_> = all.iter().filter(|d| !d.consumed).cloned().collect();
if pending.is_empty() {
return pending;
}
for d in &mut all {
if !d.consumed {
d.consumed = true;
}
}
let _ = save_directives(&all);
pending
}
/// Check if there are any unconsumed directives.
pub fn has_pending_directives() -> bool {
load_directives().iter().any(|d| !d.consumed)
}
@@ -0,0 +1,110 @@
use anyhow::Result;
use chrono::Utc;
use super::paths::{ambient_dir, queue_path, transcripts_dir};
use super::{
AmbientCycleResult, AmbientState, AmbientStatus, ScheduleRequest, ScheduledItem, ScheduledQueue,
};
use crate::config::config;
// ---------------------------------------------------------------------------
// AmbientManager
// ---------------------------------------------------------------------------
pub struct AmbientManager {
state: AmbientState,
queue: ScheduledQueue,
}
impl AmbientManager {
pub fn new() -> Result<Self> {
// Ensure storage layout exists
let _ = ambient_dir()?;
let _ = transcripts_dir()?;
let state = AmbientState::load()?;
let queue = ScheduledQueue::load(queue_path()?);
Ok(Self { state, queue })
}
pub fn is_enabled() -> bool {
config().ambient.enabled
}
/// Check whether it's time to run a cycle based on current state and queue.
pub fn should_run(&self) -> bool {
if !Self::is_enabled() {
return false;
}
match &self.state.status {
AmbientStatus::Disabled | AmbientStatus::Paused { .. } => false,
AmbientStatus::Running { .. } => false, // already running
AmbientStatus::Idle => true,
AmbientStatus::Scheduled { next_wake } => Utc::now() >= *next_wake,
}
}
pub fn record_cycle_result(&mut self, result: AmbientCycleResult) -> Result<()> {
self.state.record_cycle(&result);
self.state.save()?;
// If the cycle produced a schedule request, enqueue it
if let Some(ref req) = result.next_schedule {
self.schedule(req.clone())?;
}
Ok(())
}
/// Remove and return all ready scheduled items.
pub fn take_ready_items(&mut self) -> Vec<ScheduledItem> {
self.queue.pop_ready()
}
/// Remove and return only ready items targeted at direct delivery into a
/// specific resumed or spawned session.
pub fn take_ready_direct_items(&mut self) -> Vec<ScheduledItem> {
self.queue.take_ready_direct_items()
}
/// Add a schedule request to the queue. Returns the item ID.
pub fn schedule(&mut self, request: ScheduleRequest) -> Result<String> {
let id = format!("sched_{:08x}", rand::random::<u32>());
let scheduled_for = request.wake_at.unwrap_or_else(|| {
Utc::now() + chrono::Duration::minutes(request.wake_in_minutes.unwrap_or(30) as i64)
});
let item = ScheduledItem {
id: id.clone(),
scheduled_for,
context: request.context,
priority: request.priority,
target: request.target,
created_by_session: request.created_by_session,
created_at: Utc::now(),
working_dir: request.working_dir,
task_description: request.task_description,
relevant_files: request.relevant_files,
git_branch: request.git_branch,
additional_context: request.additional_context,
};
self.queue.push(item);
Ok(id)
}
/// Cancel a queued scheduled item by ID.
pub fn cancel_schedule(&mut self, id: &str) -> Result<Option<ScheduledItem>> {
self.queue.remove_by_id(id)
}
pub fn state(&self) -> &AmbientState {
&self.state
}
pub fn queue(&self) -> &ScheduledQueue {
&self.queue
}
}
@@ -0,0 +1,32 @@
use anyhow::Result;
use std::path::PathBuf;
use crate::storage;
// ---------------------------------------------------------------------------
// Storage paths
// ---------------------------------------------------------------------------
pub(super) fn ambient_dir() -> Result<PathBuf> {
let dir = storage::jcode_dir()?.join("ambient");
storage::ensure_dir(&dir)?;
Ok(dir)
}
pub(super) fn state_path() -> Result<PathBuf> {
Ok(ambient_dir()?.join("state.json"))
}
pub(super) fn queue_path() -> Result<PathBuf> {
Ok(ambient_dir()?.join("queue.json"))
}
pub(super) fn lock_path() -> Result<PathBuf> {
Ok(ambient_dir()?.join("ambient.lock"))
}
pub(super) fn transcripts_dir() -> Result<PathBuf> {
let dir = ambient_dir()?.join("transcripts");
storage::ensure_dir(&dir)?;
Ok(dir)
}
@@ -0,0 +1,217 @@
use anyhow::Result;
use chrono::Utc;
use std::path::PathBuf;
use super::paths::{lock_path, state_path};
use super::{AmbientCycleResult, AmbientState, AmbientStatus, CycleStatus, ScheduledItem};
use crate::storage;
// ---------------------------------------------------------------------------
// AmbientState persistence
// ---------------------------------------------------------------------------
impl AmbientState {
pub fn load() -> Result<Self> {
let path = state_path()?;
if path.exists() {
storage::read_json(&path)
} else {
Ok(Self::default())
}
}
pub fn save(&self) -> Result<()> {
storage::write_json(&state_path()?, self)
}
pub fn record_cycle(&mut self, result: &AmbientCycleResult) {
self.last_run = Some(result.ended_at);
self.last_summary = Some(result.summary.clone());
self.last_compactions = Some(result.compactions);
self.last_memories_modified = Some(result.memories_modified);
self.total_cycles += 1;
match result.status {
CycleStatus::Complete => {
if let Some(ref req) = result.next_schedule {
let next = req.wake_at.unwrap_or_else(|| {
Utc::now()
+ chrono::Duration::minutes(req.wake_in_minutes.unwrap_or(30) as i64)
});
self.status = AmbientStatus::Scheduled { next_wake: next };
} else {
self.status = AmbientStatus::Idle;
}
}
CycleStatus::Interrupted | CycleStatus::Incomplete => {
self.status = AmbientStatus::Idle;
}
}
}
}
// ---------------------------------------------------------------------------
// ScheduledQueue
// ---------------------------------------------------------------------------
pub struct ScheduledQueue {
items: Vec<ScheduledItem>,
path: PathBuf,
}
impl ScheduledQueue {
pub fn load(path: PathBuf) -> Self {
let items: Vec<ScheduledItem> = if path.exists() {
storage::read_json(&path).unwrap_or_default()
} else {
Vec::new()
};
Self { items, path }
}
pub fn save(&self) -> Result<()> {
storage::write_json(&self.path, &self.items)
}
pub fn push(&mut self, item: ScheduledItem) {
self.items.push(item);
let _ = self.save();
}
/// Remove a scheduled item by ID, persisting the queue when found.
pub fn remove_by_id(&mut self, id: &str) -> Result<Option<ScheduledItem>> {
let Some(index) = self.items.iter().position(|item| item.id == id) else {
return Ok(None);
};
let item = self.items.remove(index);
self.save()?;
Ok(Some(item))
}
/// Pop items whose `scheduled_for` is in the past, sorted by priority
/// (highest first) then by time (earliest first).
pub fn pop_ready(&mut self) -> Vec<ScheduledItem> {
let now = Utc::now();
let (ready, remaining): (Vec<_>, Vec<_>) =
self.items.drain(..).partition(|i| i.scheduled_for <= now);
self.items = remaining;
let mut ready = ready;
// Sort: highest priority first, then earliest scheduled_for
ready.sort_by(|a, b| {
b.priority
.cmp(&a.priority)
.then_with(|| a.scheduled_for.cmp(&b.scheduled_for))
});
if !ready.is_empty() {
let _ = self.save();
}
ready
}
/// Remove and return ready items targeted at a specific direct-delivery session,
/// leaving ambient-targeted queue items intact for the ambient agent to process.
pub fn take_ready_direct_items(&mut self) -> Vec<ScheduledItem> {
let now = Utc::now();
let mut ready_direct = Vec::new();
let mut remaining = Vec::with_capacity(self.items.len());
for item in self.items.drain(..) {
let is_ready = item.scheduled_for <= now;
let is_direct_target = item.target.is_direct_delivery();
if is_ready && is_direct_target {
ready_direct.push(item);
} else {
remaining.push(item);
}
}
self.items = remaining;
if !ready_direct.is_empty() {
let _ = self.save();
}
ready_direct.sort_by(|a, b| {
b.priority
.cmp(&a.priority)
.then_with(|| a.scheduled_for.cmp(&b.scheduled_for))
});
ready_direct
}
pub fn peek_next(&self) -> Option<&ScheduledItem> {
self.items.iter().min_by_key(|i| i.scheduled_for)
}
pub fn len(&self) -> usize {
self.items.len()
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
pub fn items(&self) -> &[ScheduledItem] {
&self.items
}
}
// ---------------------------------------------------------------------------
// AmbientLock (single-instance guard)
// ---------------------------------------------------------------------------
pub struct AmbientLock {
pub(crate) lock_path: PathBuf,
}
impl AmbientLock {
/// Try to acquire the ambient lock.
/// Returns `Ok(Some(lock))` if acquired, `Ok(None)` if another instance
/// already holds it, or `Err` on I/O failure.
pub fn try_acquire() -> Result<Option<Self>> {
let path = lock_path()?;
// Check existing lock
if path.exists() {
if let Ok(contents) = std::fs::read_to_string(&path)
&& let Ok(pid) = contents.trim().parse::<u32>()
&& is_pid_alive(pid)
{
return Ok(None); // Another instance is running
}
let _ = std::fs::remove_file(&path);
}
// Write our PID
let pid = std::process::id();
if let Some(parent) = path.parent() {
storage::ensure_dir(parent)?;
}
std::fs::write(&path, pid.to_string())?;
Ok(Some(Self { lock_path: path }))
}
pub fn release(self) -> Result<()> {
let _ = std::fs::remove_file(&self.lock_path);
// Drop runs, but we already cleaned up
std::mem::forget(self);
Ok(())
}
}
impl Drop for AmbientLock {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.lock_path);
}
}
fn is_pid_alive(pid: u32) -> bool {
crate::platform::is_process_running(pid)
}
+597
View File
@@ -0,0 +1,597 @@
use chrono::{DateTime, Utc};
use super::{AmbientState, Priority, ScheduleTarget, ScheduledItem, take_pending_directives};
// ---------------------------------------------------------------------------
// Ambient System Prompt Builder
// ---------------------------------------------------------------------------
/// Health stats for the memory graph, used in the ambient system prompt.
#[derive(Debug, Clone, Default)]
pub struct MemoryGraphHealth {
pub total: usize,
pub active: usize,
pub inactive: usize,
pub low_confidence: usize,
pub contradictions: usize,
pub missing_embeddings: usize,
pub duplicate_candidates: usize,
pub last_consolidation: Option<DateTime<Utc>>,
}
/// Summary of a recent session for the ambient prompt.
#[derive(Debug, Clone)]
pub struct RecentSessionInfo {
pub id: String,
pub status: String,
pub topic: Option<String>,
pub duration_secs: i64,
pub extraction_status: String,
}
/// Resource budget info for the ambient prompt.
#[derive(Debug, Clone, Default)]
pub struct ResourceBudget {
pub provider: String,
pub tokens_remaining_desc: String,
pub window_resets_desc: String,
pub user_usage_rate_desc: String,
pub cycle_budget_desc: String,
}
/// Gather memory graph health stats from the MemoryManager.
pub fn gather_memory_graph_health(
memory_manager: &crate::memory::MemoryManager,
) -> MemoryGraphHealth {
let mut health = MemoryGraphHealth::default();
// Accumulate stats from project + global graphs
for graph in [
memory_manager.load_project_graph(),
memory_manager.load_global_graph(),
]
.into_iter()
.flatten()
{
let active_count = graph.memories.values().filter(|m| m.active).count();
let inactive_count = graph.memories.values().filter(|m| !m.active).count();
health.total += graph.memories.len();
health.active += active_count;
health.inactive += inactive_count;
// Low confidence: effective confidence < 0.1
health.low_confidence += graph
.memories
.values()
.filter(|m| m.active && m.effective_confidence() < 0.1)
.count();
// Missing embeddings
health.missing_embeddings += graph
.memories
.values()
.filter(|m| m.active && m.embedding.is_none())
.count();
// Count contradiction edges
for edges in graph.edges.values() {
for edge in edges {
if matches!(edge.kind, crate::memory_graph::EdgeKind::Contradicts) {
health.contradictions += 1;
}
}
}
// Use last_cluster_update as a proxy for last consolidation
if let Some(ts) = graph.metadata.last_cluster_update {
match health.last_consolidation {
Some(existing) if ts > existing => health.last_consolidation = Some(ts),
None => health.last_consolidation = Some(ts),
_ => {}
}
}
}
// Contradicts edges are bidirectional, so divide by 2
health.contradictions /= 2;
// Duplicate candidates would require embedding similarity scan;
// placeholder for now — ambient agent will discover them during its cycle.
health.duplicate_candidates = 0;
health
}
/// Gather feedback memories relevant to ambient mode.
///
/// Pulls from two sources:
/// 1. Recent ambient transcripts (summaries of past cycles)
/// 2. Memory graph entries tagged "ambient" or "system"
///
/// Returns formatted strings for inclusion in the ambient system prompt.
pub fn gather_feedback_memories(memory_manager: &crate::memory::MemoryManager) -> Vec<String> {
let mut feedback = Vec::new();
// --- Source 1: Recent ambient transcripts ---
let transcripts_dir = match crate::storage::jcode_dir() {
Ok(d) => d.join("ambient").join("transcripts"),
Err(_) => return feedback,
};
if transcripts_dir.exists()
&& let Ok(dir) = std::fs::read_dir(&transcripts_dir)
{
let mut files: Vec<_> = dir.flatten().collect();
// Sort by filename descending (most recent first)
files.sort_by_key(|entry| std::cmp::Reverse(entry.file_name()));
// Only look at the last 5 transcripts
files.truncate(5);
for entry in files {
if let Ok(content) = std::fs::read_to_string(entry.path())
&& let Ok(transcript) =
serde_json::from_str::<crate::safety::AmbientTranscript>(&content)
{
let status = format!("{:?}", transcript.status);
let summary = transcript.summary.as_deref().unwrap_or("no summary");
let age = format_duration_rough(Utc::now() - transcript.started_at);
feedback.push(format!(
"Past cycle ({} ago, {}): {} memories modified, {} compactions — {}",
age,
status.to_lowercase(),
transcript.memories_modified,
transcript.compactions,
summary,
));
}
}
}
// --- Source 2: Memory graph entries tagged "ambient" or "system" ---
for graph in [
memory_manager.load_project_graph(),
memory_manager.load_global_graph(),
]
.into_iter()
.flatten()
{
for memory in graph.memories.values() {
if !memory.active {
continue;
}
let has_ambient_tag = memory.tags.iter().any(|t| t == "ambient" || t == "system");
if has_ambient_tag {
feedback.push(format!("Memory [{}]: {}", memory.id, memory.content));
}
}
}
feedback
}
/// Gather recent sessions since a given timestamp.
pub fn gather_recent_sessions(since: Option<DateTime<Utc>>) -> Vec<RecentSessionInfo> {
let sessions_dir = match crate::storage::jcode_dir() {
Ok(d) => d.join("sessions"),
Err(_) => return Vec::new(),
};
if !sessions_dir.exists() {
return Vec::new();
}
let cutoff = since.unwrap_or_else(|| Utc::now() - chrono::Duration::hours(24));
// Pre-filter candidate session files by filesystem mtime BEFORE loading and
// parsing them. The sessions directory can hold tens of thousands of files;
// fully parsing every one via Session::load just to drop those older than
// the cutoff is O(all_sessions * parse). A session updated after the cutoff
// has a recent mtime, so we keep only files whose mtime is at or after the
// cutoff (minus a small margin for clock/write skew), then load newest-first
// and stop once we have enough recent sessions.
const RECENT_SESSION_LIMIT: usize = 20;
let mtime_cutoff = cutoff - chrono::Duration::hours(1);
let mut candidates: Vec<(std::path::PathBuf, std::time::SystemTime)> = Vec::new();
if let Ok(entries) = std::fs::read_dir(&sessions_dir) {
for entry in entries.flatten() {
let path = entry.path();
if !path.extension().map(|e| e == "json").unwrap_or(false) {
continue;
}
let Ok(modified) = entry.metadata().and_then(|meta| meta.modified()) else {
// If we can't read mtime, keep the file as a candidate so we
// don't silently drop a possibly-recent session.
candidates.push((path, std::time::SystemTime::UNIX_EPOCH));
continue;
};
let modified_dt: DateTime<Utc> = modified.into();
if modified_dt < mtime_cutoff {
continue;
}
candidates.push((path, modified));
}
}
// Newest files first so we can stop early once we have enough.
candidates.sort_by(|a, b| b.1.cmp(&a.1));
let mut recent = Vec::new();
// Load somewhat more than the final limit by mtime so the subsequent
// id-based sort/truncate picks the true most-recent set even when file
// mtime order and id (timestamp) order disagree near the boundary, while
// still bounding work far below "load every session file".
let load_budget = RECENT_SESSION_LIMIT
.saturating_mul(4)
.max(RECENT_SESSION_LIMIT);
let mut loaded = 0usize;
for (path, _modified) in candidates {
if loaded >= load_budget {
break;
}
if let Some(stem) = path.file_stem().and_then(|s| s.to_str())
&& let Ok(session) = crate::session::Session::load(stem)
{
loaded += 1;
// Skip debug sessions
if session.is_debug {
continue;
}
// Only include sessions updated after cutoff
if session.updated_at < cutoff {
continue;
}
let duration = (session.updated_at - session.created_at)
.num_seconds()
.max(0);
let extraction = if session.messages.is_empty() {
"no messages"
} else {
// Heuristic: if session closed normally, assume extracted
match &session.status {
crate::session::SessionStatus::Closed => "extracted",
crate::session::SessionStatus::Crashed { .. } => "missed",
crate::session::SessionStatus::Active => "in progress",
_ => "unknown",
}
};
recent.push(RecentSessionInfo {
id: session.id.clone(),
status: session.status.display().to_string(),
topic: session.display_title().map(ToOwned::to_owned),
duration_secs: duration,
extraction_status: extraction.to_string(),
});
}
}
// Sort by most recent first (id embeds a timestamp).
recent.sort_by(|a, b| b.id.cmp(&a.id));
recent.truncate(RECENT_SESSION_LIMIT);
recent
}
/// Build the dynamic system prompt for an ambient cycle.
///
/// Populates the template from AMBIENT_MODE.md with real data from the
/// current state, queue, memory graph, sessions, and resource budget.
pub fn build_ambient_system_prompt(
state: &AmbientState,
queue: &[ScheduledItem],
graph_health: &MemoryGraphHealth,
recent_sessions: &[RecentSessionInfo],
feedback_memories: &[String],
budget: &ResourceBudget,
active_user_sessions: usize,
) -> String {
let mut prompt = String::with_capacity(4096);
prompt.push_str(
"You are the ambient agent for jcode. You operate autonomously without \
user prompting. Your job is to maintain and improve the user's \
development environment.\n\n",
);
// --- Current State ---
prompt.push_str("## Current State\n");
if let Some(last_run) = state.last_run {
let ago = Utc::now() - last_run;
let ago_str = format_duration_rough(ago);
prompt.push_str(&format!(
"- Last ambient cycle: {} ({} ago)\n",
last_run.format("%Y-%m-%d %H:%M UTC"),
ago_str,
));
} else {
prompt.push_str("- Last ambient cycle: never (first run)\n");
}
if active_user_sessions > 0 {
prompt.push_str(&format!(
"- Active user sessions: {}\n",
active_user_sessions
));
} else {
prompt.push_str("- Active user sessions: none\n");
}
prompt.push_str(&format!(
"- Total cycles completed: {}\n",
state.total_cycles
));
prompt.push('\n');
// --- Scheduled Queue ---
prompt.push_str("## Scheduled Queue\n");
if queue.is_empty() {
prompt.push_str("Empty -- do general ambient work.\n");
} else {
for item in queue {
let age = Utc::now() - item.created_at;
let priority = match item.priority {
Priority::Low => "low",
Priority::Normal => "normal",
Priority::High => "HIGH",
};
prompt.push_str(&format!(
"- [{}] {} (scheduled {} ago, priority: {})\n",
item.id,
item.context,
format_duration_rough(age),
priority,
));
match &item.target {
ScheduleTarget::Ambient => {}
ScheduleTarget::Session { session_id } => {
prompt.push_str(&format!(" Target session: {}\n", session_id));
}
ScheduleTarget::Spawn { parent_session_id } => {
prompt.push_str(&format!(" Spawn from session: {}\n", parent_session_id));
}
}
if let Some(ref dir) = item.working_dir {
prompt.push_str(&format!(" Working dir: {}\n", dir));
}
if let Some(ref desc) = item.task_description {
prompt.push_str(&format!(" Details: {}\n", desc));
}
if !item.relevant_files.is_empty() {
prompt.push_str(&format!(" Files: {}\n", item.relevant_files.join(", ")));
}
if let Some(ref branch) = item.git_branch {
prompt.push_str(&format!(" Branch: {}\n", branch));
}
if let Some(ref ctx) = item.additional_context {
for line in ctx.lines() {
prompt.push_str(&format!(" {}\n", line));
}
}
}
}
prompt.push('\n');
// --- Recent Sessions ---
prompt.push_str("## Recent Sessions (since last cycle)\n");
if recent_sessions.is_empty() {
prompt.push_str("No sessions since last cycle.\n");
} else {
for s in recent_sessions {
let topic = s.topic.as_deref().unwrap_or("(no title)");
let dur = format_duration_rough(chrono::Duration::seconds(s.duration_secs));
prompt.push_str(&format!(
"- {} | {} | {} | {} | extraction: {}\n",
s.id, s.status, dur, topic, s.extraction_status,
));
}
}
prompt.push('\n');
// --- Memory Graph Health ---
prompt.push_str("## Memory Graph Health\n");
prompt.push_str(&format!(
"- Total memories: {} ({} active, {} inactive)\n",
graph_health.total, graph_health.active, graph_health.inactive,
));
prompt.push_str(&format!(
"- Memories with confidence < 0.1: {}\n",
graph_health.low_confidence,
));
prompt.push_str(&format!(
"- Unresolved contradictions: {}\n",
graph_health.contradictions,
));
prompt.push_str(&format!(
"- Memories without embeddings: {}\n",
graph_health.missing_embeddings,
));
if graph_health.duplicate_candidates > 0 {
prompt.push_str(&format!(
"- Duplicate candidates (similarity > 0.95): {}\n",
graph_health.duplicate_candidates,
));
} else {
prompt.push_str("- Duplicate candidates: run embedding scan to detect\n");
}
if let Some(ts) = graph_health.last_consolidation {
let ago = format_duration_rough(Utc::now() - ts);
prompt.push_str(&format!("- Last consolidation: {} ago\n", ago));
} else {
prompt.push_str("- Last consolidation: never\n");
}
prompt.push('\n');
// --- User Feedback History ---
prompt.push_str("## User Feedback History\n");
if feedback_memories.is_empty() {
prompt.push_str("No feedback memories found about ambient mode yet.\n");
} else {
for mem in feedback_memories {
prompt.push_str(&format!("- {}\n", mem));
}
}
prompt.push('\n');
// --- Resource Budget ---
prompt.push_str("## Resource Budget\n");
prompt.push_str(&format!("- Provider: {}\n", budget.provider));
prompt.push_str(&format!(
"- Tokens remaining in window: {}\n",
budget.tokens_remaining_desc,
));
prompt.push_str(&format!("- Window resets: {}\n", budget.window_resets_desc));
prompt.push_str(&format!(
"- User usage rate: {}\n",
budget.user_usage_rate_desc,
));
prompt.push_str(&format!(
"- Budget for this cycle: {}\n",
budget.cycle_budget_desc,
));
prompt.push('\n');
// --- User Directives (from email/Telegram replies) ---
let pending_directives = take_pending_directives();
if !pending_directives.is_empty() {
prompt.push_str("## User Directives (from replies)\n");
prompt.push_str(
"The user replied to ambient notifications with these instructions. \
Address them as your **top priority** this cycle.\n\n",
);
for dir in &pending_directives {
let ago = format_duration_rough(Utc::now() - dir.received_at);
prompt.push_str(&format!(
"- [reply to cycle {}] ({} ago): {}\n",
dir.in_reply_to_cycle, ago, dir.text,
));
}
prompt.push('\n');
}
// --- Instructions ---
prompt.push_str(
"## Instructions\n\n\
Use the tools that are already available to you in this session. Do \
not search for tools — there is no tool-search/discovery tool, and \
the tools you need are listed below and in your tool definitions.\n\n\
Key tools for this cycle (use these exact names):\n\
- `todo` — plan and track what you'll do this cycle.\n\
- `end_ambient_cycle` — REQUIRED to finish the cycle (see below).\n\
- `schedule_ambient` — schedule your next wake time.\n\
- `request_permission` — get approval before any code change.\n\
- `send_message` — keep the user informed.\n\
Standard tools (`bash`, `read`, `write`, `edit`, `memory`, etc.) are \
also available.\n\n\
Start by using the `todo` tool to plan what you'll do this cycle.\n\n\
Priority order:\n\
1. Execute any scheduled queue items first.\n\
2. Garden the memory graph -- consolidate duplicates, resolve \
contradictions, prune dead memories, verify stale facts, \
extract from missed sessions.\n\
3. Scout for proactive work (only if enabled and past cold start) -- \
look at recent sessions and git history to identify useful work \
the user would appreciate.\n\n\
For gardening: focus on highest-value maintenance first. Duplicates \
and contradictions before pruning. Verify stale facts only if you \
have budget left.\n\n\
For proactive work: be conservative. A bad surprise is worse than \
no surprise. Check the user feedback memories -- if they've rejected \
similar work before, don't do it. Code changes must go on a worktree \
branch with a PR via request_permission.\n\n\
Every request_permission call must be reviewer-ready. Include:\n\
- description: concise summary of what you are about to do\n\
- rationale: why approval is needed right now\n\
- context.summary: what you are working on in this cycle\n\
- context.why_permission_needed: explicit justification for permission\n\
- context.planned_steps, context.files, context.commands (if known)\n\
- context.risks and context.rollback_plan (if relevant)\n\n\
Good sources for scouting proactive work:\n\
- Todoist (via MCP) — check for relevant tasks and deadlines\n\
- Canvas (via MCP) — check for upcoming assignments or deadlines\n\
- Git history — recent commits, open branches, stale PRs\n\
- Session history — patterns in what the user works on\n\n\
When done, you MUST call end_ambient_cycle with a summary of \
everything you did, including compaction count. Always schedule \
your next wake time with context for what you plan to do next.\n\n\
## Messaging Check-ins\n\n\
You have a `send_message` tool. Use it to keep the user informed \
about what you're doing. Send a brief message when you start a cycle \
and when you finish significant work. Keep messages short and useful — \
the user should be able to glance at their messages and know what's happening \
without opening jcode. You can optionally target a specific channel \
(e.g. telegram, discord) or omit channel to send to all.\n",
);
prompt
}
pub fn format_scheduled_session_message(item: &ScheduledItem) -> String {
let mut lines = vec![
"[Scheduled task]".to_string(),
"A scheduled task for this session is now due.".to_string(),
String::new(),
format!(
"Task: {}",
item.task_description.as_deref().unwrap_or(&item.context)
),
];
if let Some(ref dir) = item.working_dir {
lines.push(format!("Working directory: {}", dir));
}
if !item.relevant_files.is_empty() {
lines.push(format!(
"Relevant files: {}",
item.relevant_files.join(", ")
));
}
if let Some(ref branch) = item.git_branch {
lines.push(format!("Branch: {}", branch));
}
if let Some(ref ctx) = item.additional_context {
lines.push(String::new());
lines.push(ctx.clone());
}
lines.join("\n")
}
/// Format a chrono::Duration into a rough human-readable string.
pub(crate) fn format_duration_rough(d: chrono::Duration) -> String {
let secs = d.num_seconds().max(0);
if secs < 60 {
format!("{}s", secs)
} else if secs < 3600 {
format!("{}m", secs / 60)
} else if secs < 86400 {
let h = secs / 3600;
let m = (secs % 3600) / 60;
if m > 0 {
format!("{}h {}m", h, m)
} else {
format!("{}h", h)
}
} else {
let days = secs / 86400;
format!("{}d", days)
}
}
/// Format a number of minutes into a human-friendly string.
/// E.g. 5 → "5m", 90 → "1h 30m", 370 → "6h 10m", 1500 → "1d 1h"
pub fn format_minutes_human(mins: u32) -> String {
if mins < 60 {
format!("{}m", mins)
} else if mins < 1440 {
let h = mins / 60;
let m = mins % 60;
if m > 0 {
format!("{}h {}m", h, m)
} else {
format!("{}h", h)
}
} else {
let d = mins / 1440;
let h = (mins % 1440) / 60;
if h > 0 {
format!("{}d {}h", d, h)
} else {
format!("{}d", d)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,184 @@
use super::AmbientRunnerHandle;
use crate::ambient::{Priority, ScheduleTarget, ScheduledItem};
use crate::message::{Message, Role, StreamEvent, ToolDefinition};
use crate::provider::{EventStream, Provider};
use crate::session::Session;
use anyhow::Result;
use async_stream::stream;
use async_trait::async_trait;
use std::collections::VecDeque;
use std::sync::{Arc, Mutex as StdMutex};
use std::time::Duration;
struct EnvVarGuard {
key: &'static str,
prev: Option<std::ffi::OsString>,
}
impl EnvVarGuard {
fn set_path(key: &'static str, value: &std::path::Path) -> Self {
let prev = std::env::var_os(key);
crate::env::set_var(key, value);
Self { key, prev }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
if let Some(prev) = self.prev.take() {
crate::env::set_var(self.key, prev);
} else {
crate::env::remove_var(self.key);
}
}
}
struct TestProvider;
#[derive(Clone, Default)]
struct StreamingTestProvider {
responses: Arc<StdMutex<VecDeque<Vec<StreamEvent>>>>,
}
impl StreamingTestProvider {
fn queue_response(&self, events: Vec<StreamEvent>) {
self.responses.lock().unwrap().push_back(events);
}
}
#[async_trait]
impl Provider for TestProvider {
async fn complete(
&self,
_messages: &[Message],
_tools: &[ToolDefinition],
_system: &str,
_resume_session_id: Option<&str>,
) -> Result<EventStream> {
Err(anyhow::anyhow!(
"TestProvider should not be used for streaming completions in ambient runner tests"
))
}
fn name(&self) -> &str {
"test"
}
fn fork(&self) -> Arc<dyn Provider> {
Arc::new(TestProvider)
}
}
#[async_trait]
impl Provider for StreamingTestProvider {
async fn complete(
&self,
_messages: &[Message],
_tools: &[ToolDefinition],
_system: &str,
_resume_session_id: Option<&str>,
) -> Result<EventStream> {
let events = self
.responses
.lock()
.unwrap()
.pop_front()
.unwrap_or_default();
let stream = stream! {
for event in events {
yield Ok(event);
}
};
Ok(Box::pin(stream))
}
fn name(&self) -> &str {
"test"
}
fn fork(&self) -> Arc<dyn Provider> {
Arc::new(self.clone())
}
}
#[tokio::test]
async fn runner_stays_alive_to_service_schedules_when_ambient_disabled() {
let _guard = crate::storage::lock_test_env();
let temp = tempfile::tempdir().expect("tempdir");
let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path());
let provider: Arc<dyn Provider> = Arc::new(TestProvider);
let runner = AmbientRunnerHandle::new(Arc::new(crate::safety::SafetySystem::new()));
let task = tokio::spawn(runner.clone().run_loop(provider));
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(
runner.is_running().await,
"runner should remain active for scheduled tasks even with ambient disabled"
);
task.abort();
let _ = task.await;
}
#[tokio::test]
async fn spawn_target_creates_one_child_session_and_runs_task() {
let _guard = crate::storage::lock_test_env();
let temp = tempfile::tempdir().expect("tempdir");
let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path());
let provider = StreamingTestProvider::default();
provider.queue_response(vec![
StreamEvent::TextDelta("Spawned session handled task.".to_string()),
StreamEvent::MessageEnd { stop_reason: None },
]);
let provider: Arc<dyn Provider> = Arc::new(provider);
let mut parent = Session::create_with_id(
"session_parent_spawn_test".to_string(),
None,
Some("Parent".to_string()),
);
parent.working_dir = Some(temp.path().display().to_string());
parent.save().expect("save parent session");
let item = ScheduledItem {
id: "sched_spawn_test".to_string(),
scheduled_for: chrono::Utc::now(),
context: "Follow up later".to_string(),
priority: Priority::Normal,
target: ScheduleTarget::Spawn {
parent_session_id: parent.id.clone(),
},
created_by_session: parent.id.clone(),
created_at: chrono::Utc::now(),
working_dir: parent.working_dir.clone(),
task_description: Some("Follow up later".to_string()),
relevant_files: vec!["src/lib.rs".to_string()],
git_branch: None,
additional_context: Some("Background: spawned schedule test".to_string()),
};
let runner = AmbientRunnerHandle::new(Arc::new(crate::safety::SafetySystem::new()));
let child_session_id = runner
.spawn_session_for_scheduled_item(&provider, &item, &parent.id)
.await
.expect("spawned scheduled task should succeed");
assert_ne!(child_session_id, parent.id);
let child = Session::load(&child_session_id).expect("load spawned child session");
assert_eq!(child.parent_id.as_deref(), Some(parent.id.as_str()));
assert_eq!(child.working_dir, parent.working_dir);
assert!(child.messages.iter().any(|message| {
message.role == Role::User
&& message.content_preview().contains("[Scheduled task]")
&& message.content_preview().contains("Follow up later")
}));
assert!(child.messages.iter().any(|message| {
message.role == Role::Assistant
&& message
.content_preview()
.contains("Spawned session handled task.")
}));
}
@@ -0,0 +1,497 @@
//! Adaptive usage calculator for ambient mode scheduling.
//!
//! Tracks per-call token usage (user vs ambient), maintains a rolling usage log,
//! and computes adaptive intervals for ambient cycles based on rate limit headroom.
use crate::storage;
use chrono::{Duration as ChronoDuration, Utc};
use std::path::PathBuf;
use std::time::Duration;
// ---------------------------------------------------------------------------
// Usage record types
// ---------------------------------------------------------------------------
pub use jcode_ambient_types::{RateLimitInfo, UsageRecord, UsageSource};
// ---------------------------------------------------------------------------
// Usage log — rolling, persisted to disk
// ---------------------------------------------------------------------------
/// How often to auto-save (every N records added).
const SAVE_INTERVAL: usize = 10;
/// Records older than this are pruned on save.
const PRUNE_AGE_HOURS: i64 = 24;
pub struct UsageLog {
records: Vec<UsageRecord>,
path: PathBuf,
unsaved_count: usize,
}
impl UsageLog {
/// Load (or create) the usage log from the default path.
pub fn load() -> Self {
let path = Self::default_path();
let records: Vec<UsageRecord> = if path.exists() {
storage::read_json(&path).unwrap_or_default()
} else {
Vec::new()
};
UsageLog {
records,
path,
unsaved_count: 0,
}
}
fn default_path() -> PathBuf {
storage::jcode_dir()
.unwrap_or_else(|_| std::env::temp_dir())
.join("ambient")
.join("usage.json")
}
/// Add a record and periodically save.
pub fn record(&mut self, record: UsageRecord) {
self.records.push(record);
self.unsaved_count += 1;
if self.unsaved_count >= SAVE_INTERVAL
&& let Err(err) = self.save()
{
crate::logging::warn(&format!(
"Failed to persist ambient usage log '{}': {}",
self.path.display(),
err
));
}
}
/// Rolling average of *user* token usage per minute over `window`.
pub fn user_rate_per_minute(&self, window: Duration) -> f32 {
self.rate_per_minute(UsageSource::User, window)
}
/// Rolling average of *ambient* token usage per minute over `window`.
pub fn ambient_rate_per_minute(&self, window: Duration) -> f32 {
self.rate_per_minute(UsageSource::Ambient, window)
}
/// Total tokens for a given source within a window.
pub fn total_tokens_in_window(&self, source: &UsageSource, window: Duration) -> u64 {
let cutoff = Utc::now() - ChronoDuration::from_std(window).unwrap_or_default();
self.records
.iter()
.filter(|r| r.source == *source && r.timestamp >= cutoff)
.map(|r| r.total_tokens())
.sum()
}
/// Average tokens per ambient cycle (last N cycles).
pub fn avg_tokens_per_ambient_cycle(&self, last_n: usize) -> Option<f64> {
let ambient: Vec<u64> = self
.records
.iter()
.rev()
.filter(|r| r.source == UsageSource::Ambient)
.take(last_n)
.map(|r| r.total_tokens())
.collect();
if ambient.is_empty() {
return None;
}
let sum: u64 = ambient.iter().sum();
Some(sum as f64 / ambient.len() as f64)
}
/// Persist to disk, pruning old records.
pub fn save(&mut self) -> anyhow::Result<()> {
self.prune();
storage::write_json(&self.path, &self.records)?;
self.unsaved_count = 0;
Ok(())
}
// -- internal helpers ---------------------------------------------------
fn rate_per_minute(&self, source: UsageSource, window: Duration) -> f32 {
let cutoff = Utc::now() - ChronoDuration::from_std(window).unwrap_or_default();
let total: u64 = self
.records
.iter()
.filter(|r| r.source == source && r.timestamp >= cutoff)
.map(|r| r.total_tokens())
.sum();
let minutes = window.as_secs_f32() / 60.0;
if minutes > 0.0 {
total as f32 / minutes
} else {
0.0
}
}
fn prune(&mut self) {
let cutoff = Utc::now() - ChronoDuration::hours(PRUNE_AGE_HOURS);
self.records.retain(|r| r.timestamp >= cutoff);
}
}
// ---------------------------------------------------------------------------
// Scheduler config
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
pub struct AmbientSchedulerConfig {
pub min_interval_minutes: u32,
pub max_interval_minutes: u32,
pub pause_on_active_session: bool,
/// Fraction of remaining budget reserved for user. 0.8 means ambient gets
/// at most 20% of headroom.
pub user_budget_reserve: f32,
}
impl Default for AmbientSchedulerConfig {
fn default() -> Self {
AmbientSchedulerConfig {
min_interval_minutes: 5,
max_interval_minutes: 120,
pause_on_active_session: true,
user_budget_reserve: 0.8,
}
}
}
// ---------------------------------------------------------------------------
// Adaptive scheduler
// ---------------------------------------------------------------------------
pub struct AdaptiveScheduler {
pub usage_log: UsageLog,
pub config: AmbientSchedulerConfig,
/// Exponential backoff multiplier (doubles on rate limit hits).
backoff_multiplier: u32,
/// Whether a user session is currently active.
user_active: bool,
}
impl AdaptiveScheduler {
pub fn new(config: AmbientSchedulerConfig) -> Self {
AdaptiveScheduler {
usage_log: UsageLog::load(),
config,
backoff_multiplier: 1,
user_active: false,
}
}
/// Core interval calculation following the algorithm in AMBIENT_MODE.md.
pub fn calculate_interval(&self, rate_limit_info: Option<&RateLimitInfo>) -> Duration {
let max = Duration::from_secs(self.config.max_interval_minutes as u64 * 60);
let min = Duration::from_secs(self.config.min_interval_minutes as u64 * 60);
// If no rate limit info, fall back to max interval.
let info = match rate_limit_info {
Some(i) => i,
None => return self.apply_backoff(max),
};
// window_remaining = reset_time - now
let window_remaining_secs = info
.reset_at
.map(|r| {
let diff = r - Utc::now();
diff.num_seconds().max(0) as f64
})
.unwrap_or(3600.0); // default 1 hour if unknown
let tokens_remaining = info.remaining_tokens.unwrap_or(0) as f64;
if tokens_remaining <= 0.0 || window_remaining_secs <= 0.0 {
return self.apply_backoff(max);
}
// Estimate user consumption from rolling history (last hour).
let user_rate = self
.usage_log
.user_rate_per_minute(Duration::from_secs(3600)) as f64;
// Project user usage for rest of window.
let window_remaining_minutes = window_remaining_secs / 60.0;
let user_projected = user_rate * window_remaining_minutes;
// Ambient budget = (remaining - user_projected) * (1 - reserve)
let ambient_fraction = 1.0 - self.config.user_budget_reserve as f64;
let ambient_budget = (tokens_remaining - user_projected) * ambient_fraction;
if ambient_budget <= 0.0 {
// No headroom — wait until window resets.
return self.apply_backoff(max);
}
// Estimate cost per ambient cycle from recent cycles.
let tokens_per_cycle = self
.usage_log
.avg_tokens_per_ambient_cycle(5)
.unwrap_or(10_000.0); // conservative default
let cycles_available = ambient_budget / tokens_per_cycle;
let interval_secs = if cycles_available > 0.0 {
window_remaining_secs / cycles_available
} else {
window_remaining_secs
};
let interval = Duration::from_secs_f64(interval_secs);
self.apply_backoff(interval.clamp(min, max))
}
/// Returns `true` if the scheduler thinks ambient should pause (user active).
pub fn should_pause(&self) -> bool {
self.config.pause_on_active_session && self.user_active
}
/// Mark user session state.
pub fn set_user_active(&mut self, active: bool) {
self.user_active = active;
}
/// Called when a provider rate limit error occurs.
pub fn on_rate_limit_hit(&mut self) {
self.backoff_multiplier = self.backoff_multiplier.saturating_mul(2).min(64);
}
/// Called after a successful ambient cycle.
pub fn on_successful_cycle(&mut self) {
self.backoff_multiplier = 1;
}
// -- internal --
fn apply_backoff(&self, interval: Duration) -> Duration {
let min = Duration::from_secs(self.config.min_interval_minutes as u64 * 60);
let max = Duration::from_secs(self.config.max_interval_minutes as u64 * 60);
let adjusted = interval.saturating_mul(self.backoff_multiplier);
adjusted.clamp(min, max)
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn make_record(source: UsageSource, tokens: u32, mins_ago: i64) -> UsageRecord {
UsageRecord {
timestamp: Utc::now() - ChronoDuration::minutes(mins_ago),
source,
tokens_input: tokens / 2,
tokens_output: tokens / 2,
provider: "test".to_string(),
}
}
#[test]
fn test_usage_log_rate_per_minute() {
let mut log = UsageLog {
records: Vec::new(),
path: PathBuf::from("/tmp/test_usage.json"),
unsaved_count: 0,
};
// Add 3 user records in the last 30 minutes, 1000 tokens each.
for i in 0..3 {
log.records
.push(make_record(UsageSource::User, 1000, i * 10));
}
let rate = log.user_rate_per_minute(Duration::from_secs(3600));
// 3000 tokens over 60 minutes = 50 tokens/min
assert!((rate - 50.0).abs() < 1.0, "got {}", rate);
}
#[test]
fn test_total_tokens_in_window() {
let mut log = UsageLog {
records: Vec::new(),
path: PathBuf::from("/tmp/test_usage2.json"),
unsaved_count: 0,
};
log.records.push(make_record(UsageSource::User, 500, 10));
log.records.push(make_record(UsageSource::Ambient, 300, 5));
log.records.push(make_record(UsageSource::User, 200, 2));
let user_total = log.total_tokens_in_window(&UsageSource::User, Duration::from_secs(3600));
assert_eq!(user_total, 700);
let ambient_total =
log.total_tokens_in_window(&UsageSource::Ambient, Duration::from_secs(3600));
assert_eq!(ambient_total, 300);
}
#[test]
fn test_avg_tokens_per_ambient_cycle() {
let mut log = UsageLog {
records: Vec::new(),
path: PathBuf::from("/tmp/test_usage3.json"),
unsaved_count: 0,
};
// No ambient records => None.
assert!(log.avg_tokens_per_ambient_cycle(5).is_none());
log.records
.push(make_record(UsageSource::Ambient, 1000, 30));
log.records
.push(make_record(UsageSource::Ambient, 2000, 20));
log.records
.push(make_record(UsageSource::Ambient, 3000, 10));
let avg = log.avg_tokens_per_ambient_cycle(5).unwrap();
assert!((avg - 2000.0).abs() < 1.0, "got {}", avg);
}
#[test]
fn test_scheduler_no_rate_limit_returns_max() {
let config = AmbientSchedulerConfig {
min_interval_minutes: 5,
max_interval_minutes: 120,
..Default::default()
};
let scheduler = AdaptiveScheduler::new(config);
let interval = scheduler.calculate_interval(None);
assert_eq!(interval, Duration::from_secs(120 * 60));
}
#[test]
fn test_scheduler_no_remaining_tokens_returns_max() {
let config = AmbientSchedulerConfig::default();
let scheduler = AdaptiveScheduler::new(config);
let info = RateLimitInfo {
limit_tokens: Some(100_000),
remaining_tokens: Some(0),
limit_requests: None,
remaining_requests: None,
reset_at: Some(Utc::now() + ChronoDuration::hours(1)),
};
let interval = scheduler.calculate_interval(Some(&info));
assert_eq!(interval, Duration::from_secs(120 * 60));
}
#[test]
fn test_scheduler_plenty_of_headroom() {
let config = AmbientSchedulerConfig {
min_interval_minutes: 5,
max_interval_minutes: 120,
user_budget_reserve: 0.8,
..Default::default()
};
let scheduler = AdaptiveScheduler::new(config);
let info = RateLimitInfo {
limit_tokens: Some(1_000_000),
remaining_tokens: Some(500_000),
limit_requests: None,
remaining_requests: None,
reset_at: Some(Utc::now() + ChronoDuration::hours(1)),
};
let interval = scheduler.calculate_interval(Some(&info));
// With 500k remaining, 0 user rate, 20% for ambient = 100k budget.
// Default 10k per cycle => 10 cycles in 60 min => 6 min per cycle.
let mins = interval.as_secs() as f64 / 60.0;
assert!(
(5.0..=10.0).contains(&mins),
"expected 5-10 min, got {:.1}",
mins
);
}
#[test]
fn test_backoff_doubles() {
let config = AmbientSchedulerConfig {
min_interval_minutes: 5,
max_interval_minutes: 120,
..Default::default()
};
let mut scheduler = AdaptiveScheduler::new(config);
let info = RateLimitInfo {
limit_tokens: Some(1_000_000),
remaining_tokens: Some(500_000),
limit_requests: None,
remaining_requests: None,
reset_at: Some(Utc::now() + ChronoDuration::hours(1)),
};
let before = scheduler.calculate_interval(Some(&info));
scheduler.on_rate_limit_hit();
let after = scheduler.calculate_interval(Some(&info));
// After one hit, interval should roughly double (clamped).
assert!(
after >= before,
"after backoff should be >= before: {:?} vs {:?}",
after,
before
);
}
#[test]
fn test_backoff_resets_on_success() {
let config = AmbientSchedulerConfig::default();
let mut scheduler = AdaptiveScheduler::new(config);
scheduler.on_rate_limit_hit();
scheduler.on_rate_limit_hit();
assert!(scheduler.backoff_multiplier > 1);
scheduler.on_successful_cycle();
assert_eq!(scheduler.backoff_multiplier, 1);
}
#[test]
fn test_should_pause() {
let config = AmbientSchedulerConfig {
pause_on_active_session: true,
..Default::default()
};
let mut scheduler = AdaptiveScheduler::new(config);
assert!(!scheduler.should_pause());
scheduler.set_user_active(true);
assert!(scheduler.should_pause());
scheduler.set_user_active(false);
assert!(!scheduler.should_pause());
}
#[test]
fn test_prune_removes_old_records() {
let mut log = UsageLog {
records: Vec::new(),
path: PathBuf::from("/tmp/test_prune.json"),
unsaved_count: 0,
};
// Record from 25 hours ago (should be pruned).
log.records.push(UsageRecord {
timestamp: Utc::now() - ChronoDuration::hours(25),
source: UsageSource::User,
tokens_input: 100,
tokens_output: 100,
provider: "test".to_string(),
});
// Recent record (should survive).
log.records.push(make_record(UsageSource::User, 200, 5));
log.prune();
assert_eq!(log.records.len(), 1);
assert_eq!(log.records[0].total_tokens(), 200);
}
}
@@ -0,0 +1,3 @@
#![cfg_attr(test, allow(clippy::await_holding_lock))]
pub use crate::ambient::runner::AmbientRunnerHandle;
@@ -0,0 +1,3 @@
pub use crate::ambient::scheduler::{
AdaptiveScheduler, AmbientSchedulerConfig, RateLimitInfo, UsageLog, UsageRecord, UsageSource,
};
+457
View File
@@ -0,0 +1,457 @@
use super::*;
use chrono::Duration;
#[test]
fn test_ambient_status_default() {
let status = AmbientStatus::default();
assert_eq!(status, AmbientStatus::Idle);
}
#[test]
fn test_priority_ordering() {
assert!(Priority::High > Priority::Normal);
assert!(Priority::Normal > Priority::Low);
}
#[test]
fn test_scheduled_queue_push_and_pop() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
let mut queue = ScheduledQueue::load(path);
assert!(queue.is_empty());
let past = Utc::now() - Duration::minutes(5);
let future = Utc::now() + Duration::hours(1);
queue.push(ScheduledItem {
id: "s1".into(),
scheduled_for: past,
context: "past item".into(),
priority: Priority::Low,
target: ScheduleTarget::Ambient,
created_by_session: "test".into(),
created_at: Utc::now(),
working_dir: None,
task_description: None,
relevant_files: Vec::new(),
git_branch: None,
additional_context: None,
});
queue.push(ScheduledItem {
id: "s2".into(),
scheduled_for: future,
context: "future item".into(),
priority: Priority::High,
target: ScheduleTarget::Ambient,
created_by_session: "test".into(),
created_at: Utc::now(),
working_dir: None,
task_description: None,
relevant_files: Vec::new(),
git_branch: None,
additional_context: None,
});
assert_eq!(queue.len(), 2);
let ready = queue.pop_ready();
assert_eq!(ready.len(), 1);
assert_eq!(ready[0].id, "s1");
// Future item still in queue
assert_eq!(queue.len(), 1);
assert_eq!(queue.peek_next().unwrap().id, "s2");
}
#[test]
fn test_scheduled_queue_remove_by_id_persists_remaining_items() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
let mut queue = ScheduledQueue::load(path.clone());
let future = Utc::now() + Duration::hours(1);
queue.push(ScheduledItem {
id: "keep".into(),
scheduled_for: future,
context: "keep item".into(),
priority: Priority::Normal,
target: ScheduleTarget::Ambient,
created_by_session: "test".into(),
created_at: Utc::now(),
working_dir: None,
task_description: None,
relevant_files: Vec::new(),
git_branch: None,
additional_context: None,
});
queue.push(ScheduledItem {
id: "cancel".into(),
scheduled_for: future,
context: "cancel item".into(),
priority: Priority::High,
target: ScheduleTarget::Ambient,
created_by_session: "test".into(),
created_at: Utc::now(),
working_dir: None,
task_description: None,
relevant_files: Vec::new(),
git_branch: None,
additional_context: None,
});
let removed = queue.remove_by_id("cancel").unwrap().unwrap();
assert_eq!(removed.id, "cancel");
assert!(queue.remove_by_id("missing").unwrap().is_none());
let reloaded = ScheduledQueue::load(path);
assert_eq!(reloaded.len(), 1);
assert_eq!(reloaded.items()[0].id, "keep");
}
#[test]
fn test_pop_ready_sorts_by_priority_then_time() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
let mut queue = ScheduledQueue::load(path);
let past1 = Utc::now() - Duration::minutes(10);
let past2 = Utc::now() - Duration::minutes(5);
queue.push(ScheduledItem {
id: "low_early".into(),
scheduled_for: past1,
context: "low early".into(),
priority: Priority::Low,
target: ScheduleTarget::Ambient,
created_by_session: "test".into(),
created_at: Utc::now(),
working_dir: None,
task_description: None,
relevant_files: Vec::new(),
git_branch: None,
additional_context: None,
});
queue.push(ScheduledItem {
id: "high_late".into(),
scheduled_for: past2,
context: "high late".into(),
priority: Priority::High,
target: ScheduleTarget::Ambient,
created_by_session: "test".into(),
created_at: Utc::now(),
working_dir: None,
task_description: None,
relevant_files: Vec::new(),
git_branch: None,
additional_context: None,
});
let ready = queue.pop_ready();
assert_eq!(ready.len(), 2);
// High priority should come first
assert_eq!(ready[0].id, "high_late");
assert_eq!(ready[1].id, "low_early");
}
#[test]
fn test_take_ready_direct_items_only_removes_direct_targets() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
let mut queue = ScheduledQueue::load(path);
let past = Utc::now() - Duration::minutes(5);
queue.push(ScheduledItem {
id: "session_due".into(),
scheduled_for: past,
context: "scheduled session task".into(),
priority: Priority::Normal,
target: ScheduleTarget::Session {
session_id: "session_123".into(),
},
created_by_session: "session_123".into(),
created_at: Utc::now(),
working_dir: None,
task_description: None,
relevant_files: Vec::new(),
git_branch: None,
additional_context: None,
});
queue.push(ScheduledItem {
id: "spawn_due".into(),
scheduled_for: past,
context: "spawned session task".into(),
priority: Priority::High,
target: ScheduleTarget::Spawn {
parent_session_id: "session_123".into(),
},
created_by_session: "session_123".into(),
created_at: Utc::now(),
working_dir: None,
task_description: None,
relevant_files: Vec::new(),
git_branch: None,
additional_context: None,
});
queue.push(ScheduledItem {
id: "ambient_due".into(),
scheduled_for: past,
context: "scheduled ambient task".into(),
priority: Priority::High,
target: ScheduleTarget::Ambient,
created_by_session: "ambient".into(),
created_at: Utc::now(),
working_dir: None,
task_description: None,
relevant_files: Vec::new(),
git_branch: None,
additional_context: None,
});
let ready_direct = queue.take_ready_direct_items();
assert_eq!(ready_direct.len(), 2);
assert_eq!(ready_direct[0].id, "spawn_due");
assert_eq!(ready_direct[1].id, "session_due");
assert_eq!(queue.len(), 1);
assert_eq!(queue.items()[0].id, "ambient_due");
}
#[test]
fn test_ambient_state_record_cycle() {
let mut state = AmbientState::default();
assert_eq!(state.total_cycles, 0);
let result = AmbientCycleResult {
summary: "Merged 2 duplicates".into(),
memories_modified: 3,
compactions: 1,
proactive_work: None,
next_schedule: None,
started_at: Utc::now() - Duration::seconds(30),
ended_at: Utc::now(),
status: CycleStatus::Complete,
conversation: None,
};
state.record_cycle(&result);
assert_eq!(state.total_cycles, 1);
assert_eq!(state.last_summary.as_deref(), Some("Merged 2 duplicates"));
assert_eq!(state.last_compactions, Some(1));
assert_eq!(state.last_memories_modified, Some(3));
assert_eq!(state.status, AmbientStatus::Idle);
}
#[test]
fn test_ambient_state_record_cycle_with_schedule() {
let mut state = AmbientState::default();
let result = AmbientCycleResult {
summary: "Done".into(),
memories_modified: 0,
compactions: 0,
proactive_work: None,
next_schedule: Some(ScheduleRequest {
wake_in_minutes: Some(15),
wake_at: None,
context: "check CI".into(),
priority: Priority::Normal,
target: ScheduleTarget::Ambient,
created_by_session: "ambient_test".into(),
working_dir: None,
task_description: None,
relevant_files: Vec::new(),
git_branch: None,
additional_context: None,
}),
started_at: Utc::now() - Duration::seconds(10),
ended_at: Utc::now(),
status: CycleStatus::Complete,
conversation: None,
};
state.record_cycle(&result);
assert!(matches!(state.status, AmbientStatus::Scheduled { .. }));
}
#[test]
fn test_ambient_lock_release() {
// Use a temp dir so we don't conflict with real state
let tmp_dir = tempfile::tempdir().unwrap();
let lock_file = tmp_dir.path().join("test.lock");
// Manually create a lock to test release/drop
std::fs::write(&lock_file, std::process::id().to_string()).unwrap();
let lock = AmbientLock {
lock_path: lock_file.clone(),
};
lock.release().unwrap();
assert!(!lock_file.exists());
}
#[test]
fn test_schedule_id_format() {
let id = format!("sched_{:08x}", rand::random::<u32>());
assert!(id.starts_with("sched_"));
assert_eq!(id.len(), 6 + 8); // "sched_" + 8 hex chars
}
#[test]
fn test_format_duration_rough() {
assert_eq!(format_duration_rough(Duration::seconds(30)), "30s");
assert_eq!(format_duration_rough(Duration::minutes(5)), "5m");
assert_eq!(format_duration_rough(Duration::hours(2)), "2h");
assert_eq!(
format_duration_rough(Duration::hours(2) + Duration::minutes(30)),
"2h 30m"
);
assert_eq!(format_duration_rough(Duration::days(3)), "3d");
assert_eq!(format_duration_rough(Duration::seconds(-5)), "0s");
}
#[test]
fn test_build_ambient_system_prompt_minimal() {
let state = AmbientState::default();
let queue = vec![];
let health = MemoryGraphHealth::default();
let sessions = vec![];
let feedback: Vec<String> = vec![];
let budget = ResourceBudget {
provider: "anthropic-oauth".into(),
tokens_remaining_desc: "unknown".into(),
window_resets_desc: "unknown".into(),
user_usage_rate_desc: "0 tokens/min".into(),
cycle_budget_desc: "stay under 50k tokens".into(),
};
let prompt =
build_ambient_system_prompt(&state, &queue, &health, &sessions, &feedback, &budget, 0);
assert!(prompt.contains("ambient agent for jcode"));
assert!(prompt.contains("## Current State"));
assert!(prompt.contains("never (first run)"));
assert!(prompt.contains("Active user sessions: none"));
assert!(prompt.contains("## Scheduled Queue"));
assert!(prompt.contains("Empty"));
assert!(prompt.contains("## Memory Graph Health"));
assert!(prompt.contains("Total memories: 0"));
assert!(prompt.contains("## User Feedback History"));
assert!(prompt.contains("No feedback memories"));
assert!(prompt.contains("## Resource Budget"));
assert!(prompt.contains("anthropic-oauth"));
assert!(prompt.contains("## Instructions"));
assert!(prompt.contains("end_ambient_cycle"));
assert!(prompt.contains("reviewer-ready"));
assert!(prompt.contains("context.why_permission_needed"));
}
#[test]
fn test_build_ambient_system_prompt_with_data() {
let state = AmbientState {
last_run: Some(Utc::now() - Duration::minutes(15)),
total_cycles: 7,
..Default::default()
};
let queue = vec![ScheduledItem {
id: "sched_001".into(),
scheduled_for: Utc::now(),
context: "Check CI status".into(),
priority: Priority::High,
target: ScheduleTarget::Ambient,
created_by_session: "session_abc".into(),
created_at: Utc::now() - Duration::minutes(10),
working_dir: Some("/home/user/project".into()),
task_description: Some("Check CI status for the main branch".into()),
relevant_files: vec!["src/main.rs".into()],
git_branch: Some("main".into()),
additional_context: Some("Background: Tests were flaky yesterday".into()),
}];
let health = MemoryGraphHealth {
total: 42,
active: 38,
inactive: 4,
low_confidence: 3,
contradictions: 1,
missing_embeddings: 5,
duplicate_candidates: 0,
last_consolidation: Some(Utc::now() - Duration::hours(2)),
};
let sessions = vec![RecentSessionInfo {
id: "session_fox_123".into(),
status: "closed".into(),
topic: Some("Fix auth bug".into()),
duration_secs: 900,
extraction_status: "extracted".into(),
}];
let feedback = vec![
"User approved ambient fixing typos in docs".into(),
"User rejected ambient refactoring tests".into(),
];
let budget = ResourceBudget {
provider: "openai-oauth".into(),
tokens_remaining_desc: "~85k".into(),
window_resets_desc: "in 3h 20m".into(),
user_usage_rate_desc: "120 tokens/min".into(),
cycle_budget_desc: "stay under 15k tokens".into(),
};
let prompt =
build_ambient_system_prompt(&state, &queue, &health, &sessions, &feedback, &budget, 2);
assert!(prompt.contains("15m ago"));
assert!(prompt.contains("Active user sessions: 2"));
assert!(prompt.contains("Total cycles completed: 7"));
assert!(prompt.contains("Check CI status"));
assert!(prompt.contains("HIGH"));
assert!(prompt.contains("42"));
assert!(prompt.contains("38 active"));
assert!(prompt.contains("confidence < 0.1: 3"));
assert!(prompt.contains("contradictions: 1"));
assert!(prompt.contains("without embeddings: 5"));
assert!(prompt.contains("Fix auth bug"));
assert!(prompt.contains("approved ambient fixing typos"));
assert!(prompt.contains("rejected ambient refactoring"));
assert!(prompt.contains("openai-oauth"));
assert!(prompt.contains("~85k"));
assert!(prompt.contains("Working dir: /home/user/project"));
assert!(prompt.contains("Details: Check CI status for the main branch"));
assert!(prompt.contains("Files: src/main.rs"));
assert!(prompt.contains("Branch: main"));
assert!(prompt.contains("Tests were flaky yesterday"));
}
#[test]
fn test_scheduled_queue_items_accessor() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().to_path_buf();
let mut queue = ScheduledQueue::load(path);
queue.push(ScheduledItem {
id: "s1".into(),
scheduled_for: Utc::now(),
context: "test item".into(),
priority: Priority::Normal,
target: ScheduleTarget::Ambient,
created_by_session: "test".into(),
created_at: Utc::now(),
working_dir: None,
task_description: None,
relevant_files: Vec::new(),
git_branch: None,
additional_context: None,
});
let items = queue.items();
assert_eq!(items.len(), 1);
assert_eq!(items[0].id, "s1");
}
+27
View File
@@ -0,0 +1,27 @@
pub use jcode_build_support::{
BinaryChoice, BinaryVersionReport, BuildInfo, BuildManifest, CanaryStatus, CrashInfo,
DevBinarySourceMetadata, MigrationContext, PendingActivation, PublishedBuild,
SELFDEV_CARGO_PROFILE, SelfDevBuildCommand, SelfDevBuildTarget, SharedServerRepair,
SourceState, advance_shared_server_if_tracking_stable, binary_name, binary_stem,
build_log_path, build_progress_path, builds_dir, canary_binary_path, clear_build_progress,
clear_migration_context, client_update_candidate, complete_pending_activation_for_session,
current_binary_build_time_string, current_binary_built_at, current_binary_path,
current_build_info, current_git_diff, current_git_hash, current_git_hash_full,
current_source_state, current_version_file, ensure_source_state_matches, find_dev_binary,
find_repo_in_ancestors, get_commit_message, get_repo_dir, install_binary_at_version,
install_local_release, install_version, is_jcode_repo, is_working_tree_dirty,
launcher_binary_path, launcher_dir, load_migration_context, manifest_path,
migration_context_path, preferred_reload_candidate, promote_version_to_shared_server,
publish_local_current_build, publish_local_current_build_for_source, read_build_progress,
read_current_version, read_shared_server_version, read_stable_version, release_binary_path,
repair_stale_shared_server_channel, repo_build_version, repo_scope_key, resolve_binary_payload,
rollback_pending_activation_for_session, run_selfdev_build, save_migration_context,
selfdev_binary_path, selfdev_build_command, selfdev_build_command_for_target,
shared_server_binary_path, shared_server_tracks_stable, shared_server_update_candidate,
shared_server_version_file, smoke_test_binary, smoke_test_server_binary, stable_binary_path,
stable_version_file, update_canary_symlink, update_current_symlink,
update_launcher_symlink_to_current, update_launcher_symlink_to_stable,
update_shared_server_symlink, update_stable_symlink, version_binary_path,
version_matches_installed_channel, worktree_scope_key, write_build_progress,
write_current_dev_binary_source_metadata, write_dev_binary_source_metadata,
};
+651
View File
@@ -0,0 +1,651 @@
use crate::message::ContentBlock;
use crate::session::{Session, SessionStatus};
use anyhow::Result;
use chrono::{DateTime, Utc};
use std::collections::{BTreeMap, HashSet};
pub use jcode_task_types::{CatchupBrief, PersistedCatchupState};
const CATCHUP_STATE_FILE: &str = "catchup_seen.json";
pub fn needs_catchup(session_id: &str, updated_at: DateTime<Utc>, status: &SessionStatus) -> bool {
if !is_attention_status(status) {
return false;
}
let seen = load_seen_state()
.seen_at_ms_by_session
.get(session_id)
.copied();
needs_catchup_with_seen(updated_at.timestamp_millis(), seen, status)
}
/// Snapshot of the persisted catch-up "seen" state, so callers that need to
/// evaluate many sessions at once (e.g. the session picker building its list)
/// can avoid re-reading and re-parsing `catchup_seen.json` once per session.
#[derive(Clone, Default)]
pub struct CatchupSeenSnapshot {
state: PersistedCatchupState,
}
impl CatchupSeenSnapshot {
/// Load the persisted seen-state once from disk.
pub fn load() -> Self {
Self {
state: load_seen_state(),
}
}
/// Same semantics as [`needs_catchup`] but uses this preloaded snapshot
/// instead of re-reading the state file for every call.
pub fn needs_catchup(
&self,
session_id: &str,
updated_at: DateTime<Utc>,
status: &SessionStatus,
) -> bool {
if !is_attention_status(status) {
return false;
}
let seen = self.state.seen_at_ms_by_session.get(session_id).copied();
needs_catchup_with_seen(updated_at.timestamp_millis(), seen, status)
}
}
pub(crate) fn needs_catchup_with_seen(
updated_at_ms: i64,
seen_at_ms: Option<i64>,
status: &SessionStatus,
) -> bool {
is_attention_status(status) && seen_at_ms.unwrap_or_default() < updated_at_ms
}
pub fn mark_seen(session_id: &str, updated_at: DateTime<Utc>) -> Result<()> {
let mut state = load_seen_state();
state
.seen_at_ms_by_session
.insert(session_id.to_string(), updated_at.timestamp_millis());
save_seen_state(&state)
}
pub fn build_brief(session: &Session) -> CatchupBrief {
let rendered = crate::session::render_messages(session);
let last_user_prompt = rendered
.iter()
.rev()
.find(|msg| msg.role == "user" && !msg.content.trim().is_empty())
.map(|msg| msg.content.trim().to_string());
let latest_agent_response = rendered
.iter()
.rev()
.find(|msg| msg.role == "assistant" && !msg.content.trim().is_empty())
.map(|msg| msg.content.trim().to_string());
let files_touched = collect_touched_files(session);
let tool_counts = collect_tool_counts(session);
let validation_notes = collect_validation_notes(&rendered);
let activity_steps = collect_activity_steps(session);
let (reason, tags) = reason_and_tags(&session.status);
let needs_from_user = infer_needs_from_user(&session.status, latest_agent_response.as_deref());
CatchupBrief {
reason,
tags,
last_user_prompt,
activity_steps,
files_touched,
tool_counts,
validation_notes,
latest_agent_response,
needs_from_user,
updated_at: session.updated_at,
}
}
pub fn render_markdown(
session: &Session,
source_session_id: Option<&str>,
queue_position: Option<(usize, usize)>,
brief: &CatchupBrief,
) -> String {
let display_name = session.display_name().to_string();
let icon = crate::id::session_icon(&display_name);
let status_icon = status_icon(&session.status);
let status_label = status_label(&session.status);
let updated_ago = format_time_ago(brief.updated_at);
let source_label = source_session_id
.and_then(crate::id::extract_session_name)
.unwrap_or("previous session");
let mut out = String::new();
out.push_str("# Catch Up\n\n");
out.push_str(&format!(
"**{} {}** · {} **{}** · updated {}\n\n",
icon, display_name, status_icon, status_label, updated_ago
));
if let Some((index, total)) = queue_position {
out.push_str(&format!("- Queue: **{} of {}**\n", index, total));
}
if source_session_id.is_some() {
out.push_str(&format!("- From: **{}**\n", source_label));
}
out.push_str(&format!("- Session: `{}`\n\n", session.id));
if crate::config::config().features.mermaid && !brief.activity_steps.is_empty() {
out.push_str("```mermaid\nflowchart TD\n");
out.push_str(&format!(
" A[\"Why<br/>{}\"]:::status --> B[\"Your last prompt\"]:::user\n",
mermaid_escape(&brief.reason)
));
let mut prev = 'B';
for (idx, step) in brief.activity_steps.iter().take(4).enumerate() {
let node = ((b'C' + idx as u8) as char).to_string();
out.push_str(&format!(
" {}[\"{}\"]:::step\n",
node,
mermaid_escape(step)
));
out.push_str(&format!(" {} --> {}\n", prev, node));
prev = node.chars().next().unwrap_or('B');
}
out.push_str(&format!(
" {} --> Z[\"Need from you<br/>{}\"]:::decision\n",
prev,
mermaid_escape(&brief.needs_from_user)
));
out.push_str(" classDef status fill:#18331f,stroke:#4caf50,color:#d6ffd9;\n");
out.push_str(" classDef user fill:#1f3659,stroke:#7fb3ff,color:#e8f1ff;\n");
out.push_str(" classDef step fill:#2b2b33,stroke:#9090a0,color:#f0f0f5;\n");
out.push_str(" classDef decision fill:#43284f,stroke:#d38cff,color:#fdefff;\n");
out.push_str("```\n\n");
}
out.push_str("## Why this needs attention\n\n");
out.push_str(&format!("> {}\n\n", brief.reason));
if !brief.tags.is_empty() {
out.push_str(&format!(
"{}\n\n",
brief
.tags
.iter()
.map(|tag| format!("`{}`", tag))
.collect::<Vec<_>>()
.join(" ")
));
}
out.push_str("## Your last prompt\n\n");
if let Some(prompt) = brief.last_user_prompt.as_deref() {
out.push_str(&format!("> {}\n\n", markdown_quote(prompt)));
} else {
out.push_str("> No user prompt found in the restored transcript.\n\n");
}
out.push_str("## What happened\n\n");
if brief.activity_steps.is_empty() {
out.push_str("- No tool activity was reconstructed from the stored transcript.\n\n");
} else {
for step in &brief.activity_steps {
out.push_str(&format!("- {}\n", step));
}
out.push('\n');
}
out.push_str("## What changed\n\n");
if brief.files_touched.is_empty() {
out.push_str("- Files: _no explicit file paths captured_\n");
} else {
out.push_str(&format!(
"- Files: {}\n",
brief
.files_touched
.iter()
.take(5)
.map(|path| format!("`{}`", path))
.collect::<Vec<_>>()
.join(", ")
));
}
if brief.tool_counts.is_empty() {
out.push_str("- Tools: _none captured_\n");
} else {
out.push_str(&format!(
"- Tools: {}\n",
brief
.tool_counts
.iter()
.take(6)
.map(|(name, count)| format!("`{}`×{}", name, count))
.collect::<Vec<_>>()
.join(" · ")
));
}
if brief.validation_notes.is_empty() {
out.push_str("- Validation: _no test/build validation detected_\n\n");
} else {
out.push_str("- Validation:\n");
for note in &brief.validation_notes {
out.push_str(&format!(" - {}\n", note));
}
out.push('\n');
}
out.push_str("## Latest agent response\n\n");
if let Some(response) = brief.latest_agent_response.as_deref() {
out.push_str(&format!("> {}\n\n", markdown_quote(response)));
} else {
out.push_str("> No final assistant response was found.\n\n");
}
out.push_str("## Needs from you\n\n");
out.push_str(&format!("> {}\n\n", brief.needs_from_user));
out.push_str("## Actions\n\n");
out.push_str("- **Enter** — continue in this session\n");
out.push_str("- **/back** — return to the previous session\n");
out.push_str("- **/catchup next** — jump to the next unfinished handoff\n");
out.push_str("- **/resume** — browse all sessions normally\n");
out
}
fn state_path() -> Result<std::path::PathBuf> {
Ok(crate::storage::jcode_dir()?.join(CATCHUP_STATE_FILE))
}
fn load_seen_state() -> PersistedCatchupState {
let Ok(path) = state_path() else {
return PersistedCatchupState::default();
};
std::fs::read_to_string(path)
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or_default()
}
fn save_seen_state(state: &PersistedCatchupState) -> Result<()> {
let path = state_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, serde_json::to_string_pretty(state)?)?;
Ok(())
}
fn is_attention_status(status: &SessionStatus) -> bool {
matches!(
status,
SessionStatus::Closed
| SessionStatus::Reloaded
| SessionStatus::Compacted
| SessionStatus::RateLimited
| SessionStatus::Crashed { .. }
| SessionStatus::Error { .. }
)
}
fn reason_and_tags(status: &SessionStatus) -> (String, Vec<String>) {
match status {
SessionStatus::Closed => (
"Finished and is ready for your next instruction.".to_string(),
vec!["completed".to_string(), "decision needed".to_string()],
),
SessionStatus::Reloaded => (
"Resumed after a reload and may need confirmation before continuing.".to_string(),
vec!["reloaded".to_string(), "review".to_string()],
),
SessionStatus::Compacted => (
"Compacted older context; review the latest result before continuing.".to_string(),
vec!["compacted".to_string(), "review".to_string()],
),
SessionStatus::RateLimited => (
"Paused by rate limiting; decide whether to retry here or move on.".to_string(),
vec!["waiting".to_string(), "rate limited".to_string()],
),
SessionStatus::Crashed { message } => (
message
.as_deref()
.map(|msg| format!("Failed and needs attention: {}", msg.trim()))
.unwrap_or_else(|| {
"Failed and may need intervention before continuing.".to_string()
}),
vec!["failed".to_string(), "intervention".to_string()],
),
SessionStatus::Error { message } => (
format!(
"Stopped with an error and needs attention: {}",
message.trim()
),
vec!["failed".to_string(), "error".to_string()],
),
SessionStatus::Active => ("Still active.".to_string(), vec!["active".to_string()]),
}
}
fn infer_needs_from_user(status: &SessionStatus, latest_response: Option<&str>) -> String {
if matches!(
status,
SessionStatus::Error { .. } | SessionStatus::Crashed { .. }
) {
return "Inspect the failure, decide whether to retry, and redirect follow-up work if needed."
.to_string();
}
let latest_lower = latest_response.unwrap_or_default().to_lowercase();
if [
"decide",
"choose",
"approve",
"which",
"option",
"what do you want",
]
.iter()
.any(|needle| latest_lower.contains(needle))
{
return "Review the proposed options and decide the next step for this session."
.to_string();
}
if matches!(status, SessionStatus::RateLimited) {
return "Decide whether to retry this session now or move on to the next catch-up."
.to_string();
}
"Continue here if you want to direct follow-up work, or jump to the next catch-up.".to_string()
}
fn collect_touched_files(session: &Session) -> Vec<String> {
let mut seen = HashSet::new();
let mut files = Vec::new();
for msg in &session.messages {
for block in &msg.content {
if let ContentBlock::ToolUse { input, .. } = block {
for key in ["file_path", "path"] {
let Some(value) = input.get(key).and_then(|value| value.as_str()) else {
continue;
};
let trimmed = value.trim();
if trimmed.is_empty() || !seen.insert(trimmed.to_string()) {
continue;
}
files.push(trimmed.to_string());
if files.len() >= 12 {
return files;
}
}
}
}
}
files
}
fn collect_tool_counts(session: &Session) -> Vec<(String, usize)> {
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
for msg in &session.messages {
for block in &msg.content {
if let ContentBlock::ToolUse { name, .. } = block {
*counts.entry(name.clone()).or_default() += 1;
}
}
}
let mut counts: Vec<(String, usize)> = counts.into_iter().collect();
counts.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
counts
}
fn collect_activity_steps(session: &Session) -> Vec<String> {
let mut steps = Vec::new();
let mut last = String::new();
for msg in &session.messages {
for block in &msg.content {
let Some(step) = tool_use_step(block) else {
continue;
};
if step == last {
continue;
}
last = step.clone();
steps.push(step);
if steps.len() >= 6 {
return steps;
}
}
}
steps
}
fn tool_use_step(block: &ContentBlock) -> Option<String> {
let ContentBlock::ToolUse { name, input, .. } = block else {
return None;
};
let obj = input.as_object();
match name.as_str() {
"agentgrep" | "grep" | "glob" | "ls" | "codesearch" | "session_search" => {
Some("Searched code and session context".to_string())
}
"read" => Some(
obj.and_then(|map| map.get("file_path").and_then(|v| v.as_str()))
.map(|path| format!("Inspected `{}`", path.trim()))
.unwrap_or_else(|| "Inspected files".to_string()),
),
"edit" | "multiedit" | "write" | "patch" | "apply_patch" => Some(
obj.and_then(|map| map.get("file_path").and_then(|v| v.as_str()))
.map(|path| format!("Updated `{}`", path.trim()))
.unwrap_or_else(|| "Edited files".to_string()),
),
"bash" => {
let command = obj
.and_then(|map| map.get("command").and_then(|v| v.as_str()))
.unwrap_or_default()
.trim();
let lower = command.to_lowercase();
if lower.contains("cargo test")
|| lower.contains("pytest")
|| lower.contains("npm test")
|| lower.contains("pnpm test")
|| lower.contains("go test")
{
Some(format!("Ran tests{}", summarize_shell_suffix(command)))
} else if lower.contains("cargo build")
|| lower.contains("npm run build")
|| lower.contains("pnpm build")
|| lower.contains("go build")
{
Some(format!(
"Built the project{}",
summarize_shell_suffix(command)
))
} else {
Some(format!(
"Ran shell command{}",
summarize_shell_suffix(command)
))
}
}
"communicate" => Some("Coordinated with other agents".to_string()),
"subagent" => Some("Spawned a subagent".to_string()),
"memory" => Some("Queried memory context".to_string()),
"side_panel" | "todo" | "todoread" | "todowrite" | "initiative" => None,
other => Some(format!("Used `{}`", other)),
}
}
fn summarize_shell_suffix(command: &str) -> String {
let trimmed = command.trim();
if trimmed.is_empty() {
String::new()
} else {
format!(" · `{}`", truncate(trimmed, 56))
}
}
fn collect_validation_notes(rendered: &[crate::session::RenderedMessage]) -> Vec<String> {
let mut notes = Vec::new();
for msg in rendered.iter().rev() {
if msg.role != "tool" {
continue;
}
let Some(tool) = msg.tool_data.as_ref() else {
continue;
};
if tool.name != "bash" {
continue;
}
let command = tool
.input
.get("command")
.and_then(|value| value.as_str())
.unwrap_or_default()
.trim();
if command.is_empty() {
continue;
}
let lower = command.to_lowercase();
let label = if lower.contains("test") {
"tests"
} else if lower.contains("build") {
"build"
} else {
continue;
};
let ok = !looks_like_error(&msg.content);
notes.push(format!(
"{} {}: `{}`",
if ok { "" } else { "" },
label,
truncate(command, 64)
));
if notes.len() >= 3 {
break;
}
}
notes.reverse();
notes
}
fn looks_like_error(text: &str) -> bool {
let trimmed = text.trim_start().to_lowercase();
trimmed.starts_with("error:") || trimmed.starts_with("failed:") || trimmed.contains("exit 1")
}
fn status_icon(status: &SessionStatus) -> &'static str {
match status {
SessionStatus::Closed => "🟢",
SessionStatus::Reloaded => "🔄",
SessionStatus::Compacted => "🟠",
SessionStatus::RateLimited => "",
SessionStatus::Crashed { .. } | SessionStatus::Error { .. } => "🔴",
SessionStatus::Active => "",
}
}
fn status_label(status: &SessionStatus) -> &'static str {
match status {
SessionStatus::Closed => "completed",
SessionStatus::Reloaded => "reloaded",
SessionStatus::Compacted => "compacted",
SessionStatus::RateLimited => "waiting",
SessionStatus::Crashed { .. } | SessionStatus::Error { .. } => "failed",
SessionStatus::Active => "active",
}
}
fn format_time_ago(updated_at: DateTime<Utc>) -> String {
let delta = Utc::now().signed_duration_since(updated_at);
if delta.num_seconds() < 60 {
format!("{}s ago", delta.num_seconds().max(0))
} else if delta.num_minutes() < 60 {
format!("{}m ago", delta.num_minutes())
} else if delta.num_hours() < 24 {
format!("{}h ago", delta.num_hours())
} else {
format!("{}d ago", delta.num_days())
}
}
fn truncate(text: &str, max_chars: usize) -> String {
let trimmed = text.trim();
if trimmed.chars().count() <= max_chars {
return trimmed.to_string();
}
let mut out = trimmed
.chars()
.take(max_chars.saturating_sub(1))
.collect::<String>();
out.push('…');
out
}
fn markdown_quote(text: &str) -> String {
truncate(text.replace('\n', " ").trim(), 600)
}
fn mermaid_escape(text: &str) -> String {
text.replace('"', "'")
.replace('\n', "<br/>")
.replace(':', " -")
}
#[cfg(test)]
mod tests {
use super::{build_brief, needs_catchup_with_seen, render_markdown};
use crate::message::{ContentBlock, Role};
use crate::session::{Session, SessionStatus};
#[test]
fn needs_catchup_requires_attention_status_and_newer_than_seen() {
assert!(needs_catchup_with_seen(10, Some(9), &SessionStatus::Closed));
assert!(!needs_catchup_with_seen(
10,
Some(10),
&SessionStatus::Closed
));
assert!(!needs_catchup_with_seen(10, None, &SessionStatus::Active));
}
#[test]
fn render_markdown_includes_key_sections() {
let mut session = Session::create(None, Some("catchup".to_string()));
session.short_name = Some("fox".to_string());
session.status = SessionStatus::Closed;
session.add_message(
Role::User,
vec![ContentBlock::Text {
text: "Implement the catch up side panel".to_string(),
cache_control: None,
}],
);
session.add_message(
Role::Assistant,
vec![ContentBlock::Text {
text: "Searched the relevant session picker code.".to_string(),
cache_control: None,
}],
);
session.add_message(
Role::Assistant,
vec![ContentBlock::ToolUse {
id: "tool_1".to_string(),
name: "read".to_string(),
input: serde_json::json!({"file_path": "src/tui/session_picker.rs"}),
thought_signature: None,
}],
);
session.add_message(
Role::Assistant,
vec![ContentBlock::Text {
text: "Implemented the first pass and left a few follow-up notes.".to_string(),
cache_control: None,
}],
);
let brief = build_brief(&session);
let markdown = render_markdown(&session, Some("session_otter"), Some((1, 3)), &brief);
assert!(markdown.contains("# Catch Up"));
assert!(markdown.contains("## Your last prompt"));
assert!(markdown.contains("## What happened"));
assert!(markdown.contains("## Latest agent response"));
assert!(markdown.contains("## Needs from you"));
assert!(markdown.contains("```mermaid"));
}
}
+953
View File
@@ -0,0 +1,953 @@
use crate::ambient_runner::AmbientRunnerHandle;
use crate::config::SafetyConfig;
use crate::logging;
use async_trait::async_trait;
use std::sync::Arc;
#[async_trait]
pub trait MessageChannel: Send + Sync {
fn name(&self) -> &str;
fn is_send_enabled(&self) -> bool;
fn is_reply_enabled(&self) -> bool;
async fn send(&self, text: &str) -> anyhow::Result<()>;
async fn reply_loop(&self, runner: AmbientRunnerHandle);
}
#[derive(Clone)]
pub struct ChannelRegistry {
channels: Vec<Arc<dyn MessageChannel>>,
}
impl ChannelRegistry {
pub fn from_config(config: &SafetyConfig) -> Self {
let mut channels: Vec<Arc<dyn MessageChannel>> = Vec::new();
if config.telegram_enabled
&& let (Some(token), Some(chat_id)) = (
config.telegram_bot_token.clone(),
config.telegram_chat_id.clone(),
)
{
logging::info(&format!(
"registering telegram notification channel reply_enabled={}",
config.telegram_reply_enabled
));
channels.push(Arc::new(TelegramChannel::new(
token,
chat_id,
config.telegram_reply_enabled,
)));
}
if config.discord_enabled
&& let (Some(token), Some(channel_id)) = (
config.discord_bot_token.clone(),
config.discord_channel_id.clone(),
)
{
logging::info(&format!(
"registering discord notification channel reply_enabled={}",
config.discord_reply_enabled
));
channels.push(Arc::new(DiscordChannel::new(
token,
channel_id,
config.discord_reply_enabled,
config.discord_bot_user_id.clone(),
)));
}
if config.jade_relay_enabled {
match (
config.jade_relay_api_base.clone(),
config.jade_relay_token.clone(),
config.jade_relay_session_id.clone(),
) {
(Some(api_base), Some(token), Some(session_id)) => {
// user_id defaults to the token id when not explicitly set.
let user_id = config
.jade_relay_user_id
.clone()
.or_else(|| config.jade_relay_token_id.clone())
.unwrap_or_else(|| "default".to_string());
logging::info(&format!(
"registering jade relay channel user={} session={} reply_enabled={}",
user_id, session_id, config.jade_relay_reply_enabled
));
channels.push(Arc::new(JadeRelayChannel::new(
api_base,
token,
config.jade_relay_token_id.clone(),
user_id,
session_id,
config.jade_relay_reply_enabled,
)));
}
_ => {
logging::warn(
"jade_relay_enabled but api_base/token/session_id incomplete; skipping",
);
}
}
}
logging::debug(&format!(
"channel registry initialized channel_count={}",
channels.len()
));
Self { channels }
}
pub fn send_all(&self, text: &str) {
if tokio::runtime::Handle::try_current().is_err() {
logging::warn("skipping channel send_all because no Tokio runtime is active");
return;
}
for ch in self.channels.iter().filter(|c| c.is_send_enabled()) {
let ch = Arc::clone(ch);
let text = text.to_string();
tokio::spawn(async move {
logging::debug(&format!("sending notification via {}", ch.name()));
if let Err(e) = ch.send(&text).await {
logging::error(&format!("{} notification failed: {}", ch.name(), e));
}
});
}
}
pub fn spawn_reply_loops(&self, runner: &AmbientRunnerHandle) {
for ch in self.channels.iter().filter(|c| c.is_reply_enabled()) {
let ch = Arc::clone(ch);
let runner = runner.clone();
tokio::spawn(async move {
logging::info(&format!("{} reply loop spawned", ch.name()));
ch.reply_loop(runner).await;
});
}
}
pub fn channel_names(&self) -> Vec<String> {
self.channels.iter().map(|c| c.name().to_string()).collect()
}
pub fn find_by_name(&self, name: &str) -> Option<Arc<dyn MessageChannel>> {
let channel = self.channels.iter().find(|c| c.name() == name).cloned();
if channel.is_none() {
logging::debug(&format!("channel lookup missed name={name}"));
}
channel
}
pub fn send_enabled(&self) -> Vec<Arc<dyn MessageChannel>> {
self.channels
.iter()
.filter(|c| c.is_send_enabled())
.cloned()
.collect()
}
}
// ---------------------------------------------------------------------------
// Telegram channel
// ---------------------------------------------------------------------------
pub struct TelegramChannel {
token: String,
chat_id: String,
reply_enabled: bool,
client: reqwest::Client,
}
impl TelegramChannel {
pub fn new(token: String, chat_id: String, reply_enabled: bool) -> Self {
Self {
token,
chat_id,
reply_enabled,
client: crate::provider::shared_http_client(),
}
}
}
#[async_trait]
impl MessageChannel for TelegramChannel {
fn name(&self) -> &str {
"telegram"
}
fn is_send_enabled(&self) -> bool {
true
}
fn is_reply_enabled(&self) -> bool {
self.reply_enabled
}
async fn send(&self, text: &str) -> anyhow::Result<()> {
logging::debug(&format!(
"sending telegram notification bytes={}",
text.len()
));
crate::telegram::send_message(&self.client, &self.token, &self.chat_id, text).await
}
async fn reply_loop(&self, runner: AmbientRunnerHandle) {
let mut offset: Option<i64> = None;
loop {
match crate::telegram::get_updates(&self.client, &self.token, offset, 30).await {
Ok(updates) => {
if !updates.is_empty() {
logging::debug(&format!(
"telegram reply loop received update_count={}",
updates.len()
));
}
for update in updates {
offset = Some(update.update_id + 1);
let msg = match update.message {
Some(m) => m,
None => continue,
};
if msg.chat.id.to_string() != self.chat_id {
continue;
}
let text = match msg.text {
Some(t) => t,
None => continue,
};
let trimmed = text.trim();
if trimmed.is_empty() {
continue;
}
if let Some(req_id) = crate::notifications::extract_permission_id(trimmed) {
let (approved, message) =
crate::notifications::parse_permission_reply(trimmed);
if let Err(e) = crate::safety::record_permission_via_file(
&req_id,
approved,
"telegram_reply",
message,
) {
logging::error(&format!(
"Failed to record permission from Telegram for {}: {}",
req_id, e
));
} else {
logging::info(&format!(
"Permission {} via Telegram: {}",
if approved { "approved" } else { "denied" },
req_id
));
let _ = self
.send(&format!(
"✅ Permission {} for `{}`",
if approved { "approved" } else { "denied" },
req_id
))
.await;
}
} else {
let injected = runner.inject_message(trimmed, "telegram").await;
logging::info(&format!(
"telegram reply injected into session injected={}",
injected
));
let ack = if injected {
format!("💬 Message sent to active session: _{}_", trimmed)
} else {
format!("📋 Message queued, waking agent: _{}_", trimmed)
};
let _ = self.send(&ack).await;
}
}
}
Err(e) => {
logging::error(&format!("Telegram poll error: {}", e));
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
}
}
}
}
}
// ---------------------------------------------------------------------------
// Discord channel
// ---------------------------------------------------------------------------
pub struct DiscordChannel {
token: String,
channel_id: String,
reply_enabled: bool,
bot_user_id: Option<String>,
client: reqwest::Client,
}
impl DiscordChannel {
pub fn new(
token: String,
channel_id: String,
reply_enabled: bool,
bot_user_id: Option<String>,
) -> Self {
Self {
token,
channel_id,
reply_enabled,
bot_user_id,
client: crate::provider::shared_http_client(),
}
}
async fn poll_messages(&self, after: Option<&str>) -> anyhow::Result<Vec<DiscordMessage>> {
logging::debug(&format!(
"polling discord messages after_present={}",
after.is_some()
));
let mut url = format!(
"https://discord.com/api/v10/channels/{}/messages?limit=10",
self.channel_id
);
if let Some(after_id) = after {
url.push_str(&format!("&after={}", after_id));
}
let resp = self
.client
.get(&url)
.header("Authorization", format!("Bot {}", self.token))
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
logging::warn(&format!("discord message poll returned status={status}"));
anyhow::bail!("Discord messages error ({}): {}", status, body);
}
let messages: Vec<DiscordMessage> = resp.json().await?;
logging::debug(&format!(
"discord message poll returned count={}",
messages.len()
));
Ok(messages)
}
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct DiscordMessage {
pub id: String,
pub content: String,
pub author: DiscordAuthor,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct DiscordAuthor {
pub id: String,
pub bot: Option<bool>,
}
#[async_trait]
impl MessageChannel for DiscordChannel {
fn name(&self) -> &str {
"discord"
}
fn is_send_enabled(&self) -> bool {
true
}
fn is_reply_enabled(&self) -> bool {
self.reply_enabled
}
async fn send(&self, text: &str) -> anyhow::Result<()> {
let url = format!(
"https://discord.com/api/v10/channels/{}/messages",
self.channel_id
);
let resp = self
.client
.post(&url)
.header("Authorization", format!("Bot {}", self.token))
.json(&serde_json::json!({ "content": text }))
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("Discord API error ({}): {}", status, body);
}
logging::info("Discord notification sent");
Ok(())
}
async fn reply_loop(&self, runner: AmbientRunnerHandle) {
let mut last_seen_id: Option<String> = None;
// Get the latest message ID on startup so we don't replay old messages
match self.poll_messages(None).await {
Ok(msgs) => {
if let Some(latest) = msgs.first() {
last_seen_id = Some(latest.id.clone());
}
}
Err(e) => {
logging::error(&format!("Discord initial poll error: {}", e));
}
}
loop {
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
match self.poll_messages(last_seen_id.as_deref()).await {
Ok(msgs) => {
// Discord returns newest first, reverse for chronological order
let mut msgs = msgs;
msgs.reverse();
for msg in msgs {
last_seen_id = Some(msg.id.clone());
// Skip messages from bots (including ourselves)
if msg.author.bot.unwrap_or(false) {
continue;
}
// If we know our bot user ID, also skip our own messages
if let Some(ref bot_id) = self.bot_user_id
&& msg.author.id == *bot_id
{
continue;
}
let trimmed = msg.content.trim();
if trimmed.is_empty() {
continue;
}
if let Some(req_id) = crate::notifications::extract_permission_id(trimmed) {
let (approved, message) =
crate::notifications::parse_permission_reply(trimmed);
if let Err(e) = crate::safety::record_permission_via_file(
&req_id,
approved,
"discord_reply",
message,
) {
logging::error(&format!(
"Failed to record permission from Discord for {}: {}",
req_id, e
));
} else {
logging::info(&format!(
"Permission {} via Discord: {}",
if approved { "approved" } else { "denied" },
req_id
));
let _ = self
.send(&format!(
"✅ Permission {} for `{}`",
if approved { "approved" } else { "denied" },
req_id
))
.await;
}
} else {
let injected = runner.inject_message(trimmed, "discord").await;
logging::info(&format!(
"discord reply injected into session injected={}",
injected
));
let ack = if injected {
format!("💬 Message sent to active session: *{}*", trimmed)
} else {
format!("📋 Message queued, waking agent: *{}*", trimmed)
};
let _ = self.send(&ack).await;
}
}
}
Err(e) => {
logging::error(&format!("Discord poll error: {}", e));
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
}
}
}
}
}
// ---------------------------------------------------------------------------
// Jade cloud relay channel
// ---------------------------------------------------------------------------
/// Remote control via the Jade cloud relay (an append-only per-session event
/// log in AWS). Unlike the WebSocket gateway, nothing listens on this machine:
/// the laptop only makes outbound long-poll requests, so there is no inbound
/// port to attack. A cloud client posts `prompt` events; this channel injects
/// them into the live session and posts the agent's reply back as a `response`
/// event for the cloud client to read.
pub struct JadeRelayChannel {
/// API base URL, normalized to end with a single '/'.
api_base: String,
token: String,
token_id: Option<String>,
user_id: String,
session_id: String,
reply_enabled: bool,
client: reqwest::Client,
}
impl JadeRelayChannel {
pub fn new(
api_base: String,
token: String,
token_id: Option<String>,
user_id: String,
session_id: String,
reply_enabled: bool,
) -> Self {
let api_base = if api_base.ends_with('/') {
api_base
} else {
format!("{}/", api_base)
};
Self {
api_base,
token,
token_id,
user_id,
session_id,
reply_enabled,
client: crate::provider::shared_http_client(),
}
}
fn url(&self, path: &str) -> String {
format!("{}{}", self.api_base, path.trim_start_matches('/'))
}
fn auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
let mut req = req.header("Authorization", format!("Bearer {}", self.token));
if let Some(id) = &self.token_id {
req = req.header("x-jade-token-id", id);
}
req
}
/// Register/heartbeat this device so the cloud can show it as online.
async fn heartbeat(&self, device_id: &str) {
let body = serde_json::json!({
"user_id": self.user_id,
"device_id": device_id,
"label": device_id,
"platform": std::env::consts::OS,
});
let req = self.auth(self.client.post(self.url("v1/devices")).json(&body));
if let Err(e) = req.send().await {
logging::debug(&format!("jade relay heartbeat failed: {}", e));
}
}
/// Long-poll for new prompt events after `after`. Returns (events, next_after).
/// `wait` is the server-side long-poll window in seconds (capped at 25 by the relay).
async fn poll_prompts(&self, after: i64, wait: u32) -> anyhow::Result<(Vec<RelayEvent>, i64)> {
let session = urlencoding_encode(&self.session_id);
let url = self.url(&format!(
"v1/sessions/{}/events?user_id={}&after={}&types=prompt&wait={}",
session,
urlencoding_encode(&self.user_id),
after,
wait
));
let resp = self.auth(self.client.get(&url)).send().await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("jade relay poll error ({}): {}", status, body);
}
let parsed: RelayEventsResponse = resp.json().await?;
Ok((parsed.events, parsed.next_after))
}
/// Post a response event back to the relay for the cloud client to read.
async fn post_response(&self, text: &str, request_seq: i64) -> anyhow::Result<()> {
let session = urlencoding_encode(&self.session_id);
let body = serde_json::json!({
"user_id": self.user_id,
"type": "response",
"text": text,
"request_seq": request_seq,
"origin": "jcode",
});
let resp = self
.auth(
self.client
.post(self.url(&format!("v1/sessions/{}/events", session)))
.json(&body),
)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let detail = resp.text().await.unwrap_or_default();
anyhow::bail!("jade relay post error ({}): {}", status, detail);
}
Ok(())
}
}
#[derive(Debug, serde::Deserialize)]
struct RelayEventsResponse {
#[serde(default)]
events: Vec<RelayEvent>,
#[serde(default)]
next_after: i64,
}
#[derive(Debug, serde::Deserialize)]
struct RelayEvent {
#[serde(default)]
seq: i64,
#[serde(default)]
text: Option<String>,
}
/// Minimal percent-encoding for path/query segments (alnum and -_.~ pass through).
fn urlencoding_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{:02X}", b)),
}
}
out
}
#[async_trait]
impl MessageChannel for JadeRelayChannel {
fn name(&self) -> &str {
"jade_relay"
}
fn is_send_enabled(&self) -> bool {
true
}
fn is_reply_enabled(&self) -> bool {
// Inbound Jade relay prompts are delivered by server::jade_relay so they
// work even when ambient mode is disabled and target the configured live
// Jcode session directly. Keep this channel for outbound notifications
// only; otherwise ambient mode would start a second poller.
let _configured_for_server_listener = self.reply_enabled;
false
}
async fn send(&self, text: &str) -> anyhow::Result<()> {
// Cloud notifications (e.g. ambient cycle summaries) are posted as a
// response event with request_seq=0 (not tied to a specific prompt).
self.post_response(text, 0).await
}
async fn reply_loop(&self, runner: AmbientRunnerHandle) {
let host = std::env::var("HOSTNAME")
.or_else(|_| std::env::var("COMPUTERNAME"))
.unwrap_or_else(|_| "laptop".to_string());
let device_id = format!("jcode-{}", host);
logging::info(&format!(
"jade relay reply loop started channel={}/{}",
self.user_id, self.session_id
));
// Start after the latest existing prompt so we don't replay history.
let mut after: i64 = match self.poll_prompts(0, 0).await {
Ok((_, next)) => next,
Err(e) => {
logging::error(&format!("jade relay init poll failed: {}", e));
0
}
};
let mut last_heartbeat = std::time::Instant::now()
.checked_sub(std::time::Duration::from_secs(60))
.unwrap_or_else(std::time::Instant::now);
loop {
if last_heartbeat.elapsed() >= std::time::Duration::from_secs(30) {
self.heartbeat(&device_id).await;
last_heartbeat = std::time::Instant::now();
}
match self.poll_prompts(after, 20).await {
Ok((events, next_after)) => {
after = next_after;
for ev in events {
let text = ev.text.unwrap_or_default();
let trimmed = text.trim();
if trimmed.is_empty() {
continue;
}
if let Some(req_id) = crate::notifications::extract_permission_id(trimmed) {
let (approved, message) =
crate::notifications::parse_permission_reply(trimmed);
if let Err(e) = crate::safety::record_permission_via_file(
&req_id,
approved,
"jade_relay",
message,
) {
logging::error(&format!(
"Failed to record permission from jade relay for {}: {}",
req_id, e
));
} else {
let _ = self
.post_response(
&format!(
"Permission {} for {}",
if approved { "approved" } else { "denied" },
req_id
),
ev.seq,
)
.await;
}
continue;
}
let injected = runner.inject_message(trimmed, "jade_relay").await;
logging::info(&format!(
"jade relay prompt injected seq={} injected={}",
ev.seq, injected
));
let ack = if injected {
"Message delivered to active session."
} else {
"Message queued; waking agent."
};
if let Err(e) = self.post_response(ack, ev.seq).await {
logging::error(&format!("jade relay ack post failed: {}", e));
}
}
}
Err(e) => {
logging::error(&format!("jade relay poll error: {}", e));
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_discord_message_parse() {
let json = r#"{
"id": "123456",
"content": "hello agent",
"author": {"id": "789", "bot": false}
}"#;
let msg: DiscordMessage = serde_json::from_str(json).unwrap();
assert_eq!(msg.id, "123456");
assert_eq!(msg.content, "hello agent");
assert!(!msg.author.bot.unwrap());
}
#[test]
fn test_discord_bot_message_parse() {
let json = r#"{
"id": "999",
"content": "bot response",
"author": {"id": "111", "bot": true}
}"#;
let msg: DiscordMessage = serde_json::from_str(json).unwrap();
assert!(msg.author.bot.unwrap());
}
#[test]
fn test_relay_events_parse() {
let json = r#"{
"events": [
{"seq": 5, "type": "prompt", "text": "run the tests"},
{"seq": 6, "type": "prompt", "text": "now lint"}
],
"next_after": 6
}"#;
let parsed: RelayEventsResponse = serde_json::from_str(json).unwrap();
assert_eq!(parsed.events.len(), 2);
assert_eq!(parsed.events[0].seq, 5);
assert_eq!(parsed.events[0].text.as_deref(), Some("run the tests"));
assert_eq!(parsed.next_after, 6);
}
#[test]
fn test_relay_events_empty() {
let json = r#"{"events": [], "next_after": 0}"#;
let parsed: RelayEventsResponse = serde_json::from_str(json).unwrap();
assert!(parsed.events.is_empty());
assert_eq!(parsed.next_after, 0);
}
#[test]
fn test_relay_url_encoding() {
assert_eq!(urlencoding_encode("sess-relay-test"), "sess-relay-test");
assert_eq!(urlencoding_encode("a/b c"), "a%2Fb%20c");
assert_eq!(urlencoding_encode("user.name~1_2"), "user.name~1_2");
}
#[test]
fn test_relay_url_join() {
let ch = JadeRelayChannel::new(
"https://example.com/api".to_string(),
"tok".to_string(),
Some("jeremy".to_string()),
"jeremy".to_string(),
"sess-1".to_string(),
true,
);
assert_eq!(ch.url("v1/devices"), "https://example.com/api/v1/devices");
assert_eq!(ch.url("/v1/devices"), "https://example.com/api/v1/devices");
}
#[test]
fn test_relay_registry_wiring() {
// Disabled: not registered.
let cfg = SafetyConfig::default();
let reg = ChannelRegistry::from_config(&cfg);
assert!(!reg.channel_names().iter().any(|n| n == "jade_relay"));
// Enabled but incomplete: skipped with a warning.
let mut cfg = SafetyConfig {
jade_relay_enabled: true,
..SafetyConfig::default()
};
let reg = ChannelRegistry::from_config(&cfg);
assert!(!reg.channel_names().iter().any(|n| n == "jade_relay"));
// Enabled and complete: registered.
cfg.jade_relay_api_base = Some("https://example.com/".to_string());
cfg.jade_relay_token = Some("tok".to_string());
cfg.jade_relay_session_id = Some("sess-1".to_string());
let reg = ChannelRegistry::from_config(&cfg);
assert!(reg.channel_names().iter().any(|n| n == "jade_relay"));
}
/// Live end-to-end test against the real Jade relay. Ignored by default;
/// run with the relay env vars set:
/// JADE_RELAY_API_BASE, JADE_RELAY_TOKEN, JADE_RELAY_TOKEN_ID,
/// JADE_RELAY_USER_ID, JADE_RELAY_SESSION_ID
/// cargo test -p jcode-app-core relay_live -- --ignored --nocapture
#[tokio::test]
#[ignore = "requires live Jade relay credentials"]
async fn test_relay_live_roundtrip() {
let api_base = match std::env::var("JADE_RELAY_API_BASE") {
Ok(v) => v,
Err(_) => {
eprintln!("skipping: JADE_RELAY_API_BASE not set");
return;
}
};
let token = std::env::var("JADE_RELAY_TOKEN").expect("JADE_RELAY_TOKEN");
let token_id = std::env::var("JADE_RELAY_TOKEN_ID").ok();
let user_id = std::env::var("JADE_RELAY_USER_ID").unwrap_or_else(|_| "jeremy".to_string());
let session_id = std::env::var("JADE_RELAY_SESSION_ID")
.unwrap_or_else(|_| format!("rust-live-{}", chrono::Utc::now().timestamp()));
let ch = JadeRelayChannel::new(
api_base,
token,
token_id.clone(),
user_id.clone(),
session_id.clone(),
true,
);
// 1) heartbeat (device register)
ch.heartbeat("jcode-test-device").await;
// 2) baseline cursor: no prompts yet
let (events, after) = ch.poll_prompts(0, 0).await.expect("baseline poll");
eprintln!("baseline: {} events, next_after={}", events.len(), after);
// 3) simulate a cloud client posting a prompt by POSTing a prompt event
let prompt_text = format!(
"hello from rust live test {}",
chrono::Utc::now().timestamp()
);
let prompt_body = serde_json::json!({
"user_id": user_id,
"type": "prompt",
"text": prompt_text,
"origin": "rust-test-client",
});
let resp = ch
.auth(
ch.client
.post(ch.url(&format!(
"v1/sessions/{}/events",
urlencoding_encode(&session_id)
)))
.json(&prompt_body),
)
.send()
.await
.expect("post prompt");
assert!(
resp.status().is_success(),
"post prompt status {}",
resp.status()
);
// 4) the channel polls and sees the prompt
let (events, after2) = ch.poll_prompts(after, 5).await.expect("poll after prompt");
assert!(!events.is_empty(), "expected at least one prompt event");
let prompt_ev = events
.iter()
.find(|e| e.text.as_deref() == Some(prompt_text.as_str()))
.expect("our prompt event present");
eprintln!("received prompt seq={} after2={}", prompt_ev.seq, after2);
// 5) the channel posts a response tied to that prompt's seq
let reply = format!("rust live reply to seq {}", prompt_ev.seq);
ch.post_response(&reply, prompt_ev.seq)
.await
.expect("post response");
// 6) verify the response is visible (poll all event types via raw GET)
let verify_url = ch.url(&format!(
"v1/sessions/{}/events?user_id={}&after=0&types=response&wait=5",
urlencoding_encode(&session_id),
urlencoding_encode(&user_id)
));
let verify: RelayEventsResponse = ch
.auth(ch.client.get(&verify_url))
.send()
.await
.expect("verify get")
.json()
.await
.expect("verify json");
assert!(
verify
.events
.iter()
.any(|e| e.text.as_deref() == Some(reply.as_str())),
"response event should be readable back from the relay"
);
eprintln!("LIVE ROUNDTRIP OK: prompt -> poll -> response verified");
}
}
+727
View File
@@ -0,0 +1,727 @@
//! External-auth-source review and auto-import flow.
//!
//! Discovers credentials left behind by other tools (Claude Code, Codex,
//! Copilot, Cursor, Gemini CLI, ...), asks the user to approve trusting them,
//! and imports approved sources. This is provider/auth domain logic that
//! depends only on core modules (`auth`, `config`, `provider`,
//! `provider_catalog`), so it lives in the core layer and can be driven by
//! both the CLI login flow and the TUI auth UI.
use anyhow::Result;
use std::io::{self, IsTerminal, Write};
use crate::auth;
pub fn can_prompt_for_external_auth() -> bool {
std::io::stdin().is_terminal()
&& std::io::stderr().is_terminal()
&& std::env::var("JCODE_NON_INTERACTIVE").is_err()
}
pub fn external_auth_blocked_message(
provider_name: &str,
source_name: &str,
path: &std::path::Path,
login_hint: &str,
) -> String {
format!(
"Found existing {} credentials from {} at {} but jcode will not read them without confirmation. Re-run in an interactive terminal to approve this auth source for future jcode sessions, or run `{}`.",
provider_name,
source_name,
path.display(),
login_hint
)
}
pub fn prompt_to_trust_external_auth(
provider_name: &str,
source_name: &str,
path: &std::path::Path,
) -> Result<bool> {
eprintln!();
eprintln!(
"Found existing {} credentials from {} at {}.",
provider_name,
source_name,
path.display()
);
eprintln!("jcode will only read that source in place after you approve it.");
eprintln!("It will not move, delete, or rewrite the original auth there.");
eprint!("Trust this auth source for future jcode sessions? [y/N]: ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
Ok(matches!(
input.trim().to_ascii_lowercase().as_str(),
"y" | "yes"
))
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ExternalAuthReviewAction {
SharedExternal(auth::external::ExternalAuthSource),
CodexLegacy,
ClaudeCode,
/// Claude Code's native credentials (macOS Keychain item or
/// `CLAUDE_CODE_OAUTH_TOKEN` env var), which have no stable on-disk path.
ClaudeCodeNative,
GeminiCli,
Copilot(auth::copilot::ExternalCopilotAuthSource),
Cursor(auth::cursor::ExternalCursorAuthSource),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExternalAuthReviewCandidate {
pub(crate) provider_summary: String,
pub(crate) source_name: String,
pub(crate) path: std::path::PathBuf,
action: ExternalAuthReviewAction,
}
// Read-only accessors. Kept available outside tests so onboarding/UI can
// summarize detected import candidates (e.g. for the first-run welcome card).
impl ExternalAuthReviewCandidate {
pub fn provider_summary(&self) -> &str {
&self.provider_summary
}
pub fn source_name(&self) -> &str {
&self.source_name
}
/// Build a synthetic candidate for tests / UI fixtures. The resulting
/// candidate points at the legacy Codex action so it can be summarized and
/// rendered, but is not expected to actually import successfully.
#[doc(hidden)]
pub fn fixture(provider_summary: impl Into<String>, source_name: impl Into<String>) -> Self {
Self {
provider_summary: provider_summary.into(),
source_name: source_name.into(),
path: std::path::PathBuf::from("/dev/null"),
action: ExternalAuthReviewAction::CodexLegacy,
}
}
}
impl ExternalAuthReviewCandidate {
/// Coarse telemetry `(provider, method)` labels for the providers this
/// candidate activates on a successful import. Used by the onboarding flow
/// to record `auth_success` so auto-imported logins show up in the
/// activation funnel (they previously did not, because auto-import never
/// flows through the manual `pending_login` telemetry path).
///
/// The method is reported as `"import"` so import-driven activation can be
/// distinguished from manual login in the funnel.
pub fn telemetry_auth_labels(&self) -> Vec<(&'static str, &'static str)> {
const METHOD: &str = "import";
match &self.action {
ExternalAuthReviewAction::CodexLegacy => vec![("openai", METHOD)],
ExternalAuthReviewAction::ClaudeCode => vec![("claude", METHOD)],
ExternalAuthReviewAction::ClaudeCodeNative => vec![("claude", METHOD)],
ExternalAuthReviewAction::GeminiCli => vec![("gemini", METHOD)],
ExternalAuthReviewAction::Copilot(_) => vec![("copilot", METHOD)],
ExternalAuthReviewAction::Cursor(_) => vec![("cursor", METHOD)],
ExternalAuthReviewAction::SharedExternal(source) => {
auth::external::source_provider_labels(*source)
.into_iter()
.filter_map(|label| {
telemetry_provider_id_for_label(label).map(|id| (id, METHOD))
})
.collect()
}
}
}
}
/// Map a human-facing provider label (as produced by
/// [`auth::external::source_provider_labels`]) to the canonical telemetry
/// provider id used by the activation funnel.
fn telemetry_provider_id_for_label(label: &str) -> Option<&'static str> {
match label {
"OpenAI/Codex" => Some("openai"),
"Claude" => Some("claude"),
"Gemini" => Some("gemini"),
"Antigravity" => Some("antigravity"),
"GitHub Copilot" => Some("copilot"),
"OpenRouter/API-key providers" => Some("openrouter"),
_ => None,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExternalAuthAutoImportOutcome {
pub imported: usize,
pub messages: Vec<String>,
/// Coarse `(provider, method)` telemetry labels for each provider that was
/// successfully imported, so callers can record `auth_success` for the
/// activation funnel. May contain more entries than `imported` when a
/// single source carries multiple providers.
pub imported_auth_labels: Vec<(&'static str, &'static str)>,
}
impl ExternalAuthAutoImportOutcome {
pub fn render_markdown(&self) -> String {
if self.messages.is_empty() {
return "No external logins were imported.".to_string();
}
// Messages are tagged with a leading "✓"/"✕" marker by the importer.
let imported: Vec<&String> = self
.messages
.iter()
.filter(|m| m.starts_with('✓'))
.collect();
let skipped: Vec<&String> = self
.messages
.iter()
.filter(|m| m.starts_with('✕'))
.collect();
let mut out = String::from("**Logins imported**\n");
out.push('\n');
if imported.is_empty() {
out.push_str("No logins could be imported.");
} else {
out.push_str(&format!(
"Reusing {} existing login{}:",
imported.len(),
if imported.len() == 1 { "" } else { "s" }
));
}
for line in &imported {
out.push_str("\n- ");
out.push_str(line.trim_start_matches('✓').trim());
}
if !skipped.is_empty() {
out.push_str(&format!(
"\n\nSkipped {} source{}:",
skipped.len(),
if skipped.len() == 1 { "" } else { "s" }
));
for line in &skipped {
out.push_str("\n- ");
out.push_str(line.trim_start_matches('✕').trim());
}
}
out
}
}
pub fn pending_external_auth_review_candidates() -> Result<Vec<ExternalAuthReviewCandidate>> {
let mut candidates = Vec::new();
for source in auth::external::unconsented_sources() {
let provider_summary = auth::external::source_provider_labels(source).join(", ");
if provider_summary.is_empty() {
continue;
}
candidates.push(ExternalAuthReviewCandidate {
provider_summary,
source_name: source.display_name().to_string(),
path: source.path()?,
action: ExternalAuthReviewAction::SharedExternal(source),
});
}
if auth::codex::has_unconsented_legacy_credentials() {
candidates.push(ExternalAuthReviewCandidate {
provider_summary: "OpenAI/Codex".to_string(),
source_name: "Codex auth.json".to_string(),
path: auth::codex::legacy_auth_file_path()?,
action: ExternalAuthReviewAction::CodexLegacy,
});
}
if let Some(source) = auth::claude::has_unconsented_external_auth()
&& matches!(source, auth::claude::ExternalClaudeAuthSource::ClaudeCode)
{
candidates.push(ExternalAuthReviewCandidate {
provider_summary: "Claude".to_string(),
source_name: source.display_name().to_string(),
path: source.path()?,
action: ExternalAuthReviewAction::ClaudeCode,
});
}
// Claude Code's native credentials live in the macOS Keychain (or the
// CLAUDE_CODE_OAUTH_TOKEN env var), not in the JSON file. Offer them when
// the JSON file was not already detected above, so macOS users (where the
// file usually does not exist) can still import their Claude login.
if !auth::claude::native_source_allowed()
&& !candidates
.iter()
.any(|candidate| matches!(candidate.action, ExternalAuthReviewAction::ClaudeCode))
&& auth::claude::native_credentials_present()
{
candidates.push(ExternalAuthReviewCandidate {
provider_summary: "Claude".to_string(),
source_name: auth::claude::native_source_display_name().to_string(),
path: auth::claude::native_source_path_hint(),
action: ExternalAuthReviewAction::ClaudeCodeNative,
});
}
if auth::gemini::has_unconsented_cli_auth() {
candidates.push(ExternalAuthReviewCandidate {
provider_summary: "Gemini".to_string(),
source_name: "Gemini CLI".to_string(),
path: auth::gemini::gemini_cli_oauth_path()?,
action: ExternalAuthReviewAction::GeminiCli,
});
}
if let Some(source) = auth::copilot::has_unconsented_external_auth()
&& !matches!(
source,
auth::copilot::ExternalCopilotAuthSource::OpenCodeAuth
| auth::copilot::ExternalCopilotAuthSource::PiAuth
)
{
candidates.push(ExternalAuthReviewCandidate {
provider_summary: "GitHub Copilot".to_string(),
source_name: source.display_name().to_string(),
path: source.path(),
action: ExternalAuthReviewAction::Copilot(source),
});
}
if let Some(source) = auth::cursor::has_unconsented_external_auth() {
candidates.push(ExternalAuthReviewCandidate {
provider_summary: "Cursor".to_string(),
source_name: source.display_name().to_string(),
path: source.path()?,
action: ExternalAuthReviewAction::Cursor(source),
});
}
Ok(candidates)
}
pub fn parse_external_auth_review_selection(input: &str, count: usize) -> Result<Vec<usize>> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Ok(Vec::new());
}
if matches!(trimmed.to_ascii_lowercase().as_str(), "a" | "all") {
return Ok((0..count).collect());
}
let mut selected = Vec::new();
for part in trimmed.split(',') {
let value = part.trim();
if value.is_empty() {
continue;
}
let index: usize = value.parse().map_err(|_| {
anyhow::anyhow!(
"Invalid selection '{}'. Enter numbers like 1,3 or 'a' for all.",
value
)
})?;
if index == 0 || index > count {
anyhow::bail!(
"Selection '{}' is out of range. Enter 1-{} or 'a' for all.",
index,
count
);
}
let zero_based = index - 1;
if !selected.contains(&zero_based) {
selected.push(zero_based);
}
}
Ok(selected)
}
fn prompt_to_review_external_auth_sources(
candidates: &[ExternalAuthReviewCandidate],
) -> Result<Vec<usize>> {
if candidates.is_empty() {
return Ok(Vec::new());
}
eprintln!();
eprintln!("Found existing logins that jcode can reuse.");
eprintln!("Nothing has been imported yet.");
eprintln!(
"Approve the sources you want jcode to read in place; rejected sources stay untouched."
);
eprintln!();
for (index, candidate) in candidates.iter().enumerate() {
eprintln!(
" {}. {:<22} via {}",
index + 1,
candidate.provider_summary,
candidate.source_name
);
eprintln!(" {}", candidate.path.display());
}
eprintln!();
eprint!("Approve sources [a=all, Enter=skip, example: 1,3]: ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
parse_external_auth_review_selection(&input, candidates.len())
}
fn approve_external_auth_review_candidate(candidate: &ExternalAuthReviewCandidate) -> Result<()> {
match candidate.action {
ExternalAuthReviewAction::SharedExternal(source) => {
auth::external::trust_external_auth_source(source)?
}
ExternalAuthReviewAction::CodexLegacy => auth::codex::trust_legacy_auth_for_future_use()?,
ExternalAuthReviewAction::ClaudeCode => auth::claude::trust_external_auth_source(
auth::claude::ExternalClaudeAuthSource::ClaudeCode,
)?,
ExternalAuthReviewAction::ClaudeCodeNative => {
// Trust, then snapshot the Keychain/env credentials into jcode's own
// auth.json so future loads and refreshes do not depend on the
// (possibly prompting) Keychain. A successful trust with a failed
// copy still leaves the env-token path usable.
auth::claude::trust_native_source()?;
if let Err(err) = auth::claude::import_native_credentials_into_account() {
crate::logging::warn(&format!(
"Trusted Claude Code native credentials but could not snapshot them into jcode: {err}"
));
}
}
ExternalAuthReviewAction::GeminiCli => auth::gemini::trust_cli_auth_for_future_use()?,
ExternalAuthReviewAction::Copilot(source) => {
auth::copilot::trust_external_auth_source(source)?
}
ExternalAuthReviewAction::Cursor(source) => {
auth::cursor::trust_external_auth_source(source)?
}
}
Ok(())
}
fn revoke_external_auth_review_candidate(candidate: &ExternalAuthReviewCandidate) -> Result<()> {
match candidate.action {
ExternalAuthReviewAction::SharedExternal(source) => {
crate::config::Config::revoke_external_auth_source_for_path(
source.source_id(),
&candidate.path,
)?
}
ExternalAuthReviewAction::CodexLegacy => {
crate::config::Config::revoke_external_auth_source_for_path(
auth::codex::LEGACY_CODEX_AUTH_SOURCE_ID,
&candidate.path,
)?
}
ExternalAuthReviewAction::ClaudeCode => {
crate::config::Config::revoke_external_auth_source_for_path(
auth::claude::CLAUDE_CODE_AUTH_SOURCE_ID,
&candidate.path,
)?
}
ExternalAuthReviewAction::ClaudeCodeNative => {
crate::config::Config::revoke_external_auth_source(
auth::claude::CLAUDE_CODE_NATIVE_AUTH_SOURCE_ID,
)?
}
ExternalAuthReviewAction::GeminiCli => {
crate::config::Config::revoke_external_auth_source_for_path(
auth::gemini::GEMINI_CLI_AUTH_SOURCE_ID,
&candidate.path,
)?
}
ExternalAuthReviewAction::Copilot(source) => {
crate::config::Config::revoke_external_auth_source_for_path(
source.source_id(),
&candidate.path,
)?
}
ExternalAuthReviewAction::Cursor(source) => {
crate::config::Config::revoke_external_auth_source_for_path(
source.source_id(),
&candidate.path,
)?
}
}
Ok(())
}
/// Render a human-friendly note about how soon a token-based credential
/// expires, used to tell the user when a re-login is likely needed without
/// failing the import.
fn token_freshness_note(expires_at_ms: i64) -> String {
let now_ms = chrono::Utc::now().timestamp_millis();
if expires_at_ms <= now_ms {
" The access token is expired; jcode will refresh it on first use, or run /login if that fails.".to_string()
} else {
String::new()
}
}
// Import validation is intentionally *non-destructive*: it only checks that
// reusable credentials are present after trusting the source. It must NOT
// perform a live OAuth refresh, because OAuth refresh tokens are single-use --
// refreshing here would rotate (and thus burn) the source's refresh token and
// then discard the rotated result, breaking both jcode and the original tool.
// Expired tokens are still imported: they get refreshed lazily (and persisted)
// at request time, or the user is prompted to /login.
async fn validate_claude_import() -> Result<String> {
let creds = auth::claude::load_credentials()?;
if creds.access_token.trim().is_empty() && creds.refresh_token.trim().is_empty() {
anyhow::bail!("Claude source did not expose a usable access or refresh token.");
}
Ok(format!(
"Loaded Claude credentials.{}",
token_freshness_note(creds.expires_at)
))
}
async fn validate_openai_import() -> Result<String> {
let creds = auth::codex::load_credentials()?;
if creds.refresh_token.trim().is_empty() {
if creds.access_token.trim().is_empty() {
anyhow::bail!("OpenAI source did not expose a usable token or API key.");
}
return Ok("Loaded OpenAI API key credentials.".to_string());
}
Ok(format!(
"Loaded OpenAI OAuth credentials.{}",
creds
.expires_at
.map(token_freshness_note)
.unwrap_or_default()
))
}
async fn validate_gemini_import() -> Result<String> {
let tokens = auth::gemini::load_tokens()?;
Ok(format!(
"Loaded Gemini credentials.{}",
token_freshness_note(tokens.expires_at)
))
}
async fn validate_antigravity_import() -> Result<String> {
let tokens = auth::antigravity::load_tokens()?;
Ok(format!(
"Loaded Antigravity credentials.{}",
token_freshness_note(tokens.expires_at)
))
}
async fn validate_copilot_import() -> Result<String> {
// Presence check only: confirm a GitHub token is readable. The
// GitHub->Copilot exchange happens lazily at request time.
let _github_token = auth::copilot::load_github_token()?;
Ok("Loaded GitHub Copilot credentials.".to_string())
}
async fn validate_cursor_import() -> Result<String> {
let has_api_key = auth::cursor::has_cursor_api_key();
let has_vscdb = auth::cursor::has_cursor_vscdb_token();
if has_api_key || has_vscdb {
Ok(format!(
"Cursor native source loaded (api_key={}, vscdb_token={}).",
has_api_key, has_vscdb
))
} else {
anyhow::bail!("Cursor source did not expose a usable auth token.")
}
}
fn validate_openrouter_like_import() -> Result<String> {
for (env_key, env_file) in crate::provider_catalog::openrouter_like_api_key_sources() {
if crate::provider_catalog::load_api_key_from_env_or_config(&env_key, &env_file).is_some() {
return Ok(format!("Loaded API key for `{}`.", env_key));
}
}
anyhow::bail!("No reusable API key became available after import.")
}
async fn validate_shared_external_import(
source: auth::external::ExternalAuthSource,
) -> Result<String> {
let mut errors = Vec::new();
for label in auth::external::source_provider_labels(source) {
let result = match label {
"OpenAI/Codex" => validate_openai_import().await,
"Claude" => validate_claude_import().await,
"Gemini" => validate_gemini_import().await,
"Antigravity" => validate_antigravity_import().await,
"GitHub Copilot" => validate_copilot_import().await,
"OpenRouter/API-key providers" => validate_openrouter_like_import(),
_ => continue,
};
match result {
Ok(detail) => return Ok(detail),
Err(err) => errors.push(format!("{}: {}", label, err)),
}
}
anyhow::bail!(errors.join("; "))
}
async fn validate_external_auth_review_candidate(
candidate: &ExternalAuthReviewCandidate,
) -> Result<String> {
match candidate.action {
ExternalAuthReviewAction::SharedExternal(source) => {
validate_shared_external_import(source).await
}
ExternalAuthReviewAction::CodexLegacy => validate_openai_import().await,
ExternalAuthReviewAction::ClaudeCode => validate_claude_import().await,
ExternalAuthReviewAction::ClaudeCodeNative => validate_claude_import().await,
ExternalAuthReviewAction::GeminiCli => validate_gemini_import().await,
ExternalAuthReviewAction::Copilot(_) => validate_copilot_import().await,
ExternalAuthReviewAction::Cursor(_) => validate_cursor_import().await,
}
}
pub async fn maybe_run_external_auth_auto_import_flow() -> Result<Option<usize>> {
if !can_prompt_for_external_auth() {
return Ok(None);
}
let candidates = pending_external_auth_review_candidates()?;
if candidates.is_empty() {
return Ok(None);
}
let selected = prompt_to_review_external_auth_sources(&candidates)?;
let outcome = run_external_auth_auto_import_candidates(&candidates, &selected).await?;
for line in &outcome.messages {
eprintln!("{}", line);
}
auth::AuthStatus::invalidate_cache();
Ok(Some(outcome.imported))
}
pub fn format_external_auth_review_candidates_markdown(
candidates: &[ExternalAuthReviewCandidate],
) -> String {
let mut message = String::from(
"**Auto Import Existing Logins**\n\nFound existing logins that jcode can reuse. Nothing has been imported yet.\n\nReply with `a` to approve all, `1,3` to approve specific sources, or `/cancel` to abort.\n",
);
for (index, candidate) in candidates.iter().enumerate() {
message.push_str(&format!(
"\n{}. **{}** via {}\n - `{}`\n",
index + 1,
candidate.provider_summary,
candidate.source_name,
candidate.path.display()
));
}
message
}
pub async fn run_external_auth_auto_import_candidates(
candidates: &[ExternalAuthReviewCandidate],
selected: &[usize],
) -> Result<ExternalAuthAutoImportOutcome> {
let mut outcome = ExternalAuthAutoImportOutcome {
imported: 0,
messages: Vec::new(),
imported_auth_labels: Vec::new(),
};
for &index in selected {
let Some(candidate) = candidates.get(index) else {
continue;
};
approve_external_auth_review_candidate(candidate)?;
match validate_external_auth_review_candidate(candidate).await {
Ok(detail) => {
outcome.imported += 1;
outcome
.imported_auth_labels
.extend(candidate.telemetry_auth_labels());
outcome.messages.push(format!(
"{} (from {}): {}",
candidate.provider_summary, candidate.source_name, detail
));
}
Err(err) => {
let _ = revoke_external_auth_review_candidate(candidate);
outcome.messages.push(format!(
"{} (from {}): {}",
candidate.provider_summary, candidate.source_name, err
));
}
}
}
auth::AuthStatus::invalidate_cache();
Ok(outcome)
}
#[cfg(test)]
mod render_markdown_tests {
use super::ExternalAuthAutoImportOutcome;
#[test]
fn empty_outcome_reports_nothing_imported() {
let outcome = ExternalAuthAutoImportOutcome {
imported: 0,
messages: Vec::new(),
imported_auth_labels: Vec::new(),
};
assert_eq!(
outcome.render_markdown(),
"No external logins were imported."
);
}
#[test]
fn groups_imported_and_skipped_with_counts() {
let outcome = ExternalAuthAutoImportOutcome {
imported: 2,
messages: vec![
"✓ OpenAI/Codex (from Codex auth.json): Loaded OpenAI OAuth credentials."
.to_string(),
"✓ Claude (from Claude Code): Loaded Claude credentials.".to_string(),
"✕ Cursor (from Cursor native): no usable auth token.".to_string(),
],
imported_auth_labels: vec![("openai", "import"), ("claude", "import")],
};
let md = outcome.render_markdown();
assert!(md.starts_with("**Logins imported**"), "got: {md}");
assert!(md.contains("Reusing 2 existing logins:"), "got: {md}");
assert!(
md.contains("- OpenAI/Codex (from Codex auth.json): Loaded OpenAI OAuth credentials."),
"got: {md}"
);
assert!(md.contains("Skipped 1 source:"), "got: {md}");
assert!(
md.contains("- Cursor (from Cursor native): no usable auth token."),
"got: {md}"
);
// Markers themselves should be stripped from the rendered list.
assert!(!md.contains('✓'), "got: {md}");
assert!(!md.contains('✕'), "got: {md}");
}
#[test]
fn singular_wording_for_one_login() {
let outcome = ExternalAuthAutoImportOutcome {
imported: 1,
messages: vec!["✓ Gemini (from Gemini CLI): Loaded Gemini credentials.".to_string()],
imported_auth_labels: vec![("gemini", "import")],
};
let md = outcome.render_markdown();
assert!(md.contains("Reusing 1 existing login:"), "got: {md}");
}
#[test]
fn fixture_candidate_reports_import_auth_labels() {
use super::ExternalAuthReviewCandidate;
// The fixture points at the legacy Codex action -> OpenAI provider.
let candidate = ExternalAuthReviewCandidate::fixture("OpenAI/Codex", "Codex auth.json");
assert_eq!(
candidate.telemetry_auth_labels(),
vec![("openai", "import")]
);
}
}
+66
View File
@@ -0,0 +1,66 @@
#![allow(
unknown_lints,
clippy::collapsible_match,
clippy::manual_checked_ops,
clippy::unnecessary_sort_by,
clippy::useless_conversion
)]
// The `swarm` tool's `json!` parameter schema is large; the default macro
// recursion limit (128) is exceeded once more properties are added.
#![recursion_limit = "256"]
//! Application core for jcode (upper layer).
//!
//! This crate holds the server/tool/agent layer and its presentation-adjacent
//! leaves. The foundational layer it builds on (provider, auth, config, session,
//! message, memory, telemetry, ...) lives in the `jcode-base` crate and is
//! re-exported here via `pub use jcode_base::*`, so every existing
//! `crate::<module>` path (e.g. `crate::config`, `crate::provider`) keeps
//! resolving unchanged across this crate and the root `jcode` crate, which in
//! turn re-exports this crate via `pub use jcode_app_core::*`.
// Foundational layer: re-export every `jcode-base` module so `crate::<module>`
// paths resolve here exactly as they did before the split.
pub use jcode_base::*;
// Upper layer (server / tool / agent and supporting leaves).
pub mod agent;
pub mod ambient;
pub mod ambient_runner;
pub mod ambient_scheduler;
pub mod build;
pub mod catchup;
pub mod channel;
pub mod external_auth;
pub mod mission;
pub mod network_retry;
pub mod notifications;
pub mod overnight;
pub mod perf;
pub mod replay;
pub mod restart_snapshot;
pub mod server;
pub mod server_spawn;
pub mod session_effort;
pub mod session_launch;
pub mod session_rebuild;
pub mod setup_hints;
pub mod ssh_remote;
pub mod startup_profile;
pub mod tool;
pub mod turn_cancel_registry;
pub mod update;
use std::sync::Mutex;
static CURRENT_SESSION_ID: Mutex<Option<String>> = Mutex::new(None);
pub fn set_current_session(session_id: &str) {
if let Ok(mut guard) = CURRENT_SESSION_ID.lock() {
*guard = Some(session_id.to_string());
}
}
pub fn get_current_session() -> Option<String> {
CURRENT_SESSION_ID.lock().ok()?.clone()
}
@@ -0,0 +1,212 @@
use super::*;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct InputShellResult {
pub command: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
pub output: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
pub duration_ms: u64,
#[serde(default)]
pub truncated: bool,
#[serde(default)]
pub failed_to_start: bool,
}
fn sanitize_fenced_block(text: &str) -> String {
text.replace("```", "``\u{200b}`")
}
pub fn format_input_shell_result_markdown(shell: &InputShellResult) -> String {
let status = if shell.failed_to_start {
"✗ failed to start".to_string()
} else if shell.exit_code == Some(0) {
"✓ exit 0".to_string()
} else if let Some(code) = shell.exit_code {
format!("✗ exit {}", code)
} else {
"✗ terminated".to_string()
};
let mut meta = vec![status, Message::format_duration(shell.duration_ms)];
if let Some(cwd) = shell.cwd.as_deref() {
meta.push(format!("cwd {}", cwd));
}
if shell.truncated {
meta.push("truncated".to_string());
}
let mut message = format!(
"Shell command · {}\n\n{}",
meta.join(" · "),
indent_block(&shell.command)
);
if shell.output.trim().is_empty() {
message.push_str("\n\nNo output.");
} else {
message.push_str("\n\n");
message.push_str(&indent_block(shell.output.trim_end()));
}
message
}
fn indent_block(text: &str) -> String {
text.lines()
.map(|line| format!(" {}", line))
.collect::<Vec<_>>()
.join("\n")
}
pub fn input_shell_status_notice(shell: &InputShellResult) -> String {
if shell.failed_to_start {
"Shell command failed to start".to_string()
} else if shell.exit_code == Some(0) {
"Shell command completed".to_string()
} else if let Some(code) = shell.exit_code {
format!("Shell command failed (exit {})", code)
} else {
"Shell command terminated".to_string()
}
}
fn format_background_task_status(status: &BackgroundTaskStatus) -> &'static str {
match status {
BackgroundTaskStatus::Completed => "✓ completed",
BackgroundTaskStatus::Superseded => "↻ superseded",
BackgroundTaskStatus::Failed => "✗ failed",
BackgroundTaskStatus::Running => "running",
}
}
fn normalize_background_task_preview(preview: &str) -> Option<String> {
let normalized = preview.replace("\r\n", "\n").replace('\r', "\n");
let trimmed = normalized.trim_end();
if trimmed.trim().is_empty() {
None
} else {
Some(sanitize_fenced_block(trimmed))
}
}
pub fn format_background_task_notification_markdown(task: &BackgroundTaskCompleted) -> String {
let exit_code = task
.exit_code
.map(|code| format!("exit {}", code))
.unwrap_or_else(|| "exit n/a".to_string());
let mut message = format!(
"**Background task** `{}` · `{}` · {} · {:.1}s · {}",
task.task_id,
task.tool_name,
format_background_task_status(&task.status),
task.duration_secs,
exit_code,
);
if let Some(preview) = normalize_background_task_preview(&task.output_preview) {
message.push_str(&format!("\n\n```text\n{}\n```", preview));
} else {
message.push_str("\n\n_No output captured._");
}
message.push_str(&format!(
"\n\n_Full output:_ `bg action=\"output\" task_id=\"{}\"`",
task.task_id
));
message
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedBackgroundTaskNotification {
pub task_id: String,
pub tool_name: String,
pub status: String,
pub duration: String,
pub exit_label: String,
pub preview: Option<String>,
pub full_output_command: String,
}
pub fn parse_background_task_notification_markdown(
content: &str,
) -> Option<ParsedBackgroundTaskNotification> {
static HEADER_RE: OnceLock<Option<Regex>> = OnceLock::new();
static FULL_OUTPUT_RE: OnceLock<Option<Regex>> = OnceLock::new();
let header_re = HEADER_RE
.get_or_init(|| {
compile_static_regex(
r"^\*\*Background task\*\* `(?P<task_id>[^`]+)` · `(?P<tool_name>[^`]+)` · (?P<status>.+?) · (?P<duration>[0-9]+(?:\.[0-9]+)?s) · (?P<exit_label>.+)$",
)
})
.as_ref()?;
let full_output_re = FULL_OUTPUT_RE
.get_or_init(|| compile_static_regex(r#"^_Full output:_ `(?P<command>[^`]+)`$"#))
.as_ref()?;
let normalized = content.replace("\r\n", "\n").replace('\r', "\n");
let mut sections = normalized.split("\n\n");
let header = sections.next()?.trim();
let captures = header_re.captures(header)?;
let mut preview: Option<String> = None;
let mut full_output_command: Option<String> = None;
for section in sections {
let trimmed = section.trim();
if trimmed.is_empty() {
continue;
}
if let Some(captures) = full_output_re.captures(trimmed) {
full_output_command = Some(captures["command"].to_string());
continue;
}
if trimmed == "_No output captured._" {
preview = None;
continue;
}
if let Some(fenced) = trimmed
.strip_prefix("```text\n")
.and_then(|body| body.strip_suffix("\n```"))
{
preview = Some(fenced.to_string());
}
}
Some(ParsedBackgroundTaskNotification {
task_id: captures["task_id"].to_string(),
tool_name: captures["tool_name"].to_string(),
status: captures["status"].to_string(),
duration: captures["duration"].to_string(),
exit_label: captures["exit_label"].to_string(),
preview,
full_output_command: full_output_command?,
})
}
pub fn background_task_status_notice(task: &BackgroundTaskCompleted) -> String {
match task.status {
BackgroundTaskStatus::Completed => {
format!("Background task completed · {}", task.tool_name)
}
BackgroundTaskStatus::Superseded => {
format!("Background task superseded · {}", task.tool_name)
}
BackgroundTaskStatus::Failed => match task.exit_code {
Some(code) => format!(
"Background task failed · {} · exit {}",
task.tool_name, code
),
None => format!("Background task failed · {}", task.tool_name),
},
BackgroundTaskStatus::Running => format!("Background task running · {}", task.tool_name),
}
}
+197
View File
@@ -0,0 +1,197 @@
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use crate::prompt::MISSION_CONTINUATION_TEMPLATE;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum MissionStatus {
Active,
Paused,
Blocked,
NeedsDecision,
BudgetLimited,
Complete,
Abandoned,
}
impl MissionStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Active => "active",
Self::Paused => "paused",
Self::Blocked => "blocked",
Self::NeedsDecision => "needs_decision",
Self::BudgetLimited => "budget_limited",
Self::Complete => "complete",
Self::Abandoned => "abandoned",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MissionCheckpoint {
pub at: DateTime<Utc>,
pub summary: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Mission {
pub session_id: String,
pub objective: String,
pub long_horizon_intent: String,
pub status: MissionStatus,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub semantic_expansion: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub success_criteria: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub validation_plan: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub checkpoints: Vec<MissionCheckpoint>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
pub fn load(session_id: &str) -> Result<Option<Mission>> {
let path = mission_path(session_id)?;
if !path.exists() {
return Ok(None);
}
crate::storage::read_json(&path)
}
pub fn set(session_id: &str, objective: &str) -> Result<Mission> {
let objective = objective.trim();
if objective.is_empty() {
anyhow::bail!("mission objective cannot be empty");
}
let now = Utc::now();
let mut mission = load(session_id)?.unwrap_or_else(|| Mission {
session_id: session_id.to_string(),
objective: String::new(),
long_horizon_intent: String::new(),
status: MissionStatus::Active,
semantic_expansion: Vec::new(),
success_criteria: Vec::new(),
validation_plan: Vec::new(),
checkpoints: Vec::new(),
created_at: now,
updated_at: now,
});
mission.objective = objective.to_string();
mission.long_horizon_intent = default_long_horizon_intent(objective);
mission.status = MissionStatus::Active;
mission.updated_at = now;
save(&mission)?;
Ok(mission)
}
pub fn update_status(session_id: &str, status: MissionStatus) -> Result<Option<Mission>> {
let Some(mut mission) = load(session_id)? else {
return Ok(None);
};
mission.status = status;
mission.updated_at = Utc::now();
save(&mission)?;
Ok(Some(mission))
}
pub fn checkpoint(session_id: &str, summary: &str) -> Result<Option<Mission>> {
let summary = summary.trim();
if summary.is_empty() {
anyhow::bail!("checkpoint summary cannot be empty");
}
let Some(mut mission) = load(session_id)? else {
return Ok(None);
};
mission.checkpoints.push(MissionCheckpoint {
at: Utc::now(),
summary: summary.to_string(),
});
mission.updated_at = Utc::now();
save(&mission)?;
Ok(Some(mission))
}
pub fn clear(session_id: &str) -> Result<bool> {
let path = mission_path(session_id)?;
if path.exists() {
std::fs::remove_file(path)?;
Ok(true)
} else {
Ok(false)
}
}
pub fn render_status(mission: &Mission) -> String {
let mut out = format!(
"Mission **{}**\n\nStatus: **{}**\n\nLong-horizon intent: {}",
mission.objective,
mission.status.as_str(),
mission.long_horizon_intent
);
if let Some(last) = mission.checkpoints.last() {
out.push_str(&format!("\n\nLast checkpoint: {}", last.summary));
}
out.push_str("\n\nMission loop: keep updating todos, expand adjacent work, validate progress, and continue until complete, blocked, paused, or a decision is needed.");
out
}
pub fn active_system_reminder(session_id: &str) -> Result<Option<String>> {
let Some(mission) = load(session_id)? else {
return Ok(None);
};
if !matches!(mission.status, MissionStatus::Active) {
return Ok(None);
}
Ok(Some(render_mission_continuation_prompt(&mission)))
}
pub fn render_mission_continuation_prompt(mission: &Mission) -> String {
MISSION_CONTINUATION_TEMPLATE
.replace("{{ objective }}", &escape_xml_text(&mission.objective))
.replace(
"{{ long_horizon_intent }}",
&escape_xml_text(&mission.long_horizon_intent),
)
}
fn escape_xml_text(input: &str) -> String {
input
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
fn save(mission: &Mission) -> Result<()> {
crate::storage::write_json_fast(&mission_path(&mission.session_id)?, mission)
}
fn mission_path(session_id: &str) -> Result<PathBuf> {
Ok(crate::storage::jcode_dir()?
.join("missions")
.join(format!("{}.json", sanitize_session_id(session_id))))
}
fn sanitize_session_id(session_id: &str) -> String {
session_id
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect()
}
fn default_long_horizon_intent(objective: &str) -> String {
format!(
"Interpret `{}` broadly: pursue the literal objective, continuously refresh the todo frontier, include semantically adjacent work that improves the outcome, and preserve long-term quality.",
objective
)
}
+258
View File
@@ -0,0 +1,258 @@
use std::time::Duration;
#[cfg(any(target_os = "linux", target_os = "macos"))]
use tokio::process::Command;
use tokio::time::sleep;
#[cfg(any(target_os = "linux", target_os = "macos"))]
use tokio::time::timeout;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NetworkWaitPlan {
pub reason: String,
pub listener_summary: String,
}
pub fn classify_network_interruption(error: &(dyn std::error::Error + 'static)) -> Option<String> {
let mut parts = Vec::new();
let mut current = Some(error);
while let Some(err) = current {
let text = err.to_string().to_ascii_lowercase();
parts.push(text);
current = err.source();
}
classify_text(&parts.join(" | "))
}
pub fn classify_message(message: &str) -> Option<String> {
classify_text(&message.to_ascii_lowercase())
}
fn classify_text(text: &str) -> Option<String> {
let network_markers = [
"connection reset",
"connection aborted",
"connection refused",
"broken pipe",
"network is unreachable",
"network unreachable",
"host is down",
"no route to host",
"not connected",
// TLS-level drops. rustls reports an abrupt close as "peer closed
// connection without sending TLS close_notify" (its docs URL spells
// it "unexpected-eof", which the plain "unexpected eof" marker below
// does not match).
"close_notify",
"peer closed connection",
"tls handshake eof",
// reqwest/hyper wrap connect-phase failures as "client error
// (Connect)" and connection-level faults as "connection error: ...".
"client error (connect)",
"connection error",
"dns error",
"failed to lookup address",
"temporary failure in name resolution",
"name or service not known",
"could not resolve host",
"couldn't resolve host",
"host is unreachable",
"operation timed out",
"timed out",
"timeout",
"error trying to connect",
"connection closed before message completed",
"unexpected eof",
"end of file before message completed",
];
if network_markers.iter().any(|marker| text.contains(marker)) {
return Some("the network connection appears to have dropped".to_string());
}
None
}
pub fn wait_plan() -> NetworkWaitPlan {
#[cfg(target_os = "linux")]
{
NetworkWaitPlan {
reason: "stream interrupted by a likely network disconnect".to_string(),
listener_summary:
"listening for Linux netlink changes via `ip monitor`; also verifying with reconnect probes"
.to_string(),
}
}
#[cfg(target_os = "macos")]
{
return NetworkWaitPlan {
reason: "stream interrupted by a likely network disconnect".to_string(),
listener_summary:
"listening for macOS route/interface changes via `route -n monitor`; also verifying with reconnect probes"
.to_string(),
};
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
NetworkWaitPlan {
reason: "stream interrupted by a likely network disconnect".to_string(),
listener_summary: "waiting with lightweight reconnect probes".to_string(),
}
}
}
pub async fn wait_until_probably_online() {
let mut delay = Duration::from_secs(1);
loop {
if probe_connectivity().await {
return;
}
wait_for_platform_change_or_delay(delay).await;
delay = (delay * 2).min(Duration::from_secs(30));
}
}
pub async fn is_probably_online() -> bool {
probe_connectivity().await
}
async fn probe_connectivity() -> bool {
let client = jcode_provider_core::shared_http_client();
let request = client
.head("https://www.gstatic.com/generate_204")
.timeout(Duration::from_secs(5));
matches!(request.send().await, Ok(resp) if resp.status().is_success() || resp.status().as_u16() == 204)
}
async fn wait_for_platform_change_or_delay(delay: Duration) {
#[cfg(target_os = "linux")]
{
if command_exists("ip").await {
let fut = wait_for_command_output("ip", &["monitor", "link", "address", "route"]);
let _ = timeout(delay, fut).await;
return;
}
}
#[cfg(target_os = "macos")]
{
if command_exists("route").await {
let fut = wait_for_command_output("route", &["-n", "monitor"]);
let _ = timeout(delay, fut).await;
return;
}
}
sleep(delay).await;
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
async fn command_exists(command: &str) -> bool {
Command::new("sh")
.arg("-c")
.arg(format!(
"command -v {} >/dev/null 2>&1",
shell_escape(command)
))
.status()
.await
.map(|status| status.success())
.unwrap_or(false)
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn shell_escape(value: &str) -> String {
value.replace('\'', "'\\''")
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
async fn wait_for_command_output(command: &str, args: &[&str]) {
let mut command_builder = Command::new(command);
command_builder
.args(args)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.kill_on_drop(true);
let mut child = match command_builder.spawn() {
Ok(child) => child,
Err(_) => return,
};
if let Some(mut stdout) = child.stdout.take() {
use tokio::io::AsyncReadExt;
let mut buf = [0u8; 1];
let _ = stdout.read(&mut buf).await;
}
let _ = child.kill().await;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classifies_common_network_errors() {
assert!(classify_message("connection reset by peer").is_some());
assert!(classify_message("temporary failure in name resolution").is_some());
assert!(classify_message("network is unreachable").is_some());
assert!(classify_message("401 unauthorized").is_none());
}
/// Real error strings harvested from ~/.jcode/logs. Every wifi-outage
/// shape observed in the wild must classify as a network interruption so
/// the wait-for-network path engages instead of failing the turn.
#[test]
fn classifies_real_world_outage_errors() {
let real_errors = [
// DNS failure: wifi down when a new request starts, or link up
// before DHCP delivers DNS servers.
"Failed to send request to Anthropic API: error sending request for url \
(https://api.anthropic.com/v1/messages): client error (Connect): dns error: \
failed to lookup address information: Name or service not known",
"error sending request for url (https://models.dev/api.json): client error \
(Connect): dns error: failed to lookup address information: Temporary failure \
in name resolution",
// Stale pooled HTTP/2 connection dying after an outage.
"Failed to send request to Anthropic API: error sending request for url \
(https://api.anthropic.com/v1/messages): client error (SendRequest): \
http2 error: keep-alive timed out: operation timed out",
// rustls abrupt-close shape (docs URL spells "unexpected-eof" so
// the plain "unexpected eof" marker never matched it).
"error sending request for url (https://generativelanguage.googleapis.com/v1beta/models): \
client error (SendRequest): connection error: peer closed connection without \
sending TLS close_notify: https://docs.rs/rustls/latest/rustls/manual/_03_howto/index.html#unexpected-eof",
// Connect-phase timeout.
"error sending request for url (https://html.duckduckgo.com/html/): \
client error (Connect): operation timed out",
// Connection-level timeout on an established connection.
"error sending request for url (https://dblp.org/search/author/api): \
client error (SendRequest): connection error: timed out",
// TLS handshake dying mid-outage.
"error sending request for url (https://generativelanguage.googleapis.com/v1beta/models): \
client error (Connect): tls handshake eof",
// Body cut off mid-stream.
"error decoding response body: request or response body error: operation timed out",
];
for error in real_errors {
assert!(
classify_message(error).is_some(),
"should classify as network interruption: {error}"
);
}
}
/// Deterministic failures must NOT trigger the wait-for-network path,
/// otherwise a bad API key or exhausted quota would hang forever waiting
/// for connectivity that is already fine.
#[test]
fn does_not_classify_deterministic_failures() {
let deterministic = [
"401 unauthorized",
"403 forbidden: permission_denied",
"invalid x-api-key",
"404 not_found_error: model: claude-fable-5",
"insufficient_quota: your credit balance is too low",
"400 bad request: context length exceeded",
"content_policy_violation",
];
for error in deterministic {
assert!(
classify_message(error).is_none(),
"should NOT classify as network interruption: {error}"
);
}
}
}
+653
View File
@@ -0,0 +1,653 @@
//! Notification dispatcher for ambient mode.
//!
//! Sends notifications via:
//! - ntfy.sh (push notifications to phone)
//! - Desktop notifications (notify-send)
//! - Email (SMTP via lettre)
//!
//! All sends are fire-and-forget: errors are logged, never block.
use crate::config::{SafetyConfig, config};
use crate::logging;
use crate::safety::AmbientTranscript;
use jcode_notify_email::{
ReplyAction, SendEmailRequest, build_permission_email_html, poll_imap_once, send_email,
};
pub use jcode_notify_email::{extract_permission_id, parse_permission_reply};
/// Notification priority levels (maps to ntfy priority header).
#[derive(Debug, Clone, Copy)]
pub enum Priority {
/// Routine cycle summaries
Default,
/// Permission requests, errors
High,
/// Critical safety issues
Urgent,
}
impl Priority {
fn ntfy_value(self) -> &'static str {
match self {
Priority::Default => "3",
Priority::High => "4",
Priority::Urgent => "5",
}
}
fn ntfy_tags(self) -> &'static str {
match self {
Priority::Default => "robot",
Priority::High => "warning",
Priority::Urgent => "rotating_light",
}
}
}
/// Dispatcher that sends notifications through all configured channels.
#[derive(Clone)]
pub struct NotificationDispatcher {
client: reqwest::Client,
config: SafetyConfig,
channels: crate::channel::ChannelRegistry,
}
impl Default for NotificationDispatcher {
fn default() -> Self {
Self::new()
}
}
impl NotificationDispatcher {
pub fn new() -> Self {
let cfg = config().safety.clone();
Self {
client: crate::provider::shared_http_client(),
channels: crate::channel::ChannelRegistry::from_config(&cfg),
config: cfg,
}
}
#[cfg(test)]
pub fn from_config(config: SafetyConfig) -> Self {
Self {
client: crate::provider::shared_http_client(),
channels: crate::channel::ChannelRegistry::from_config(&config),
config,
}
}
/// Send a cycle summary notification (after ambient cycle completes).
pub fn dispatch_cycle_summary(&self, transcript: &AmbientTranscript) {
let title = format!(
"Ambient cycle: {} memories, {} compactions",
transcript.memories_modified, transcript.compactions
);
let safe_body = format_cycle_body_safe(transcript);
let detailed_body = format_cycle_body_detailed(transcript);
let priority = if transcript.pending_permissions > 0 {
Priority::High
} else {
Priority::Default
};
self.send_all(
&title,
&safe_body,
&detailed_body,
priority,
Some(&transcript.session_id),
);
}
/// Send a permission request notification (high priority).
pub fn dispatch_permission_request(&self, action: &str, description: &str, request_id: &str) {
let title = format!("jcode: permission needed ({})", action);
let safe_body = "An ambient action needs your approval. Open jcode to review.".to_string();
let detailed_body = format!(
"Action: {}\n{}\n\nRequest ID: {}\nReview in jcode to approve or deny.",
action, description, request_id
);
// Build rich HTML email with approve/deny buttons
let reply_to = self
.config
.email_from
.as_deref()
.unwrap_or("jcode@localhost");
let email_html = build_permission_email_html(action, description, request_id, reply_to);
self.send_all_with_email_override(
&title,
&safe_body,
&detailed_body,
Priority::High,
Some(request_id),
Some(&email_html),
);
}
/// Send through all configured channels (fire-and-forget).
///
/// `safe_body` is sanitized (no secrets) — used for ntfy (potentially public).
/// `detailed_body` includes full info — used for email and desktop (private channels).
/// `cycle_id` is embedded as Message-ID in emails for reply tracking.
fn send_all(
&self,
title: &str,
safe_body: &str,
detailed_body: &str,
priority: Priority,
cycle_id: Option<&str>,
) {
self.send_all_with_email_override(
title,
safe_body,
detailed_body,
priority,
cycle_id,
None,
);
}
/// Like `send_all`, but with an optional pre-built HTML body for the email channel.
/// When `email_html_override` is Some, it's used directly as the email body instead
/// of converting `detailed_body` through `markdown_to_html_email`.
fn send_all_with_email_override(
&self,
title: &str,
safe_body: &str,
detailed_body: &str,
priority: Priority,
cycle_id: Option<&str>,
email_html_override: Option<&str>,
) {
// Guard: only dispatch if inside a tokio runtime
if tokio::runtime::Handle::try_current().is_err() {
logging::info("Notification skipped: no tokio runtime");
return;
}
// ntfy.sh — uses SAFE body (may be publicly readable)
if let Some(ref topic) = self.config.ntfy_topic {
let client = self.client.clone();
let url = format!("{}/{}", self.config.ntfy_server, topic);
let title = title.to_string();
let body = safe_body.to_string();
tokio::spawn(async move {
if let Err(e) = send_ntfy(&client, &url, &title, &body, priority).await {
logging::error(&format!("ntfy notification failed: {}", e));
}
});
}
// Desktop notification — uses DETAILED body (local machine, private)
if self.config.desktop_notifications {
let title = title.to_string();
let body = detailed_body.to_string();
let urgency = match priority {
Priority::Default => "normal",
Priority::High | Priority::Urgent => "critical",
};
tokio::spawn(async move {
send_desktop(&title, &body, urgency);
});
}
// Email — uses DETAILED body (sent to your own address, private)
// If email_html_override is provided, send it directly as HTML.
if self.config.email_enabled
&& let (Some(to), Some(host), Some(from)) = (
&self.config.email_to,
&self.config.email_smtp_host,
&self.config.email_from,
)
{
let to = to.clone();
let host = host.clone();
let from = from.clone();
let port = self.config.email_smtp_port;
let password = self.config.email_password.clone();
let title = title.to_string();
let body = detailed_body.to_string();
let cycle_id = cycle_id.map(|s| s.to_string());
let html_override = email_html_override.map(|s| s.to_string());
tokio::spawn(async move {
if let Err(e) = send_email(SendEmailRequest {
smtp_host: &host,
smtp_port: port,
from: &from,
to: &to,
password: password.as_deref(),
subject: &title,
body: &body,
cycle_id: cycle_id.as_deref(),
html_override: html_override.as_deref(),
})
.await
{
logging::error(&format!("Email notification failed: {}", e));
} else {
logging::info(&format!("Email notification sent to {}: {}", to, title));
}
});
}
// Message channels (Telegram, Discord, etc.) — uses DETAILED body
let channel_text = format!("*{}*\n\n{}", title, detailed_body);
self.channels.send_all(&channel_text);
}
}
// ---------------------------------------------------------------------------
// ntfy.sh
// ---------------------------------------------------------------------------
async fn send_ntfy(
client: &reqwest::Client,
url: &str,
title: &str,
body: &str,
priority: Priority,
) -> anyhow::Result<()> {
let resp = client
.post(url)
.header("Title", title)
.header("Priority", priority.ntfy_value())
.header("Tags", priority.ntfy_tags())
.body(body.to_string())
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("ntfy returned {}: {}", status, text);
}
logging::info(&format!("ntfy notification sent: {}", title));
Ok(())
}
// ---------------------------------------------------------------------------
// Desktop (cross-platform, fire-and-forget)
// ---------------------------------------------------------------------------
/// Send a local desktop notification without blocking.
///
/// Uses Notification Center via `osascript` on macOS and `notify-send` on
/// Linux. The child process is spawned detached and never waited on; failures
/// are ignored (a missing notifier is not an error).
pub fn send_desktop_notification(title: &str, body: &str) {
send_desktop_notification_rich(title, None, body, None);
}
/// Send a local desktop notification with optional macOS subtitle and sound.
///
/// `subtitle` renders as a second bold line on macOS (ignored elsewhere).
/// `sound` is a Notification Center sound name such as "Glass" or "Ping"
/// (macOS only). Both are best-effort; a missing notifier is not an error.
pub fn send_desktop_notification_rich(
title: &str,
subtitle: Option<&str>,
body: &str,
sound: Option<&str>,
) {
#[cfg(target_os = "macos")]
{
fn applescript_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => {}
_ => out.push(ch),
}
}
out
}
let mut script = format!(
"display notification \"{}\" with title \"{}\"",
applescript_escape(body),
applescript_escape(title)
);
if let Some(subtitle) = subtitle.filter(|s| !s.trim().is_empty()) {
script.push_str(&format!(" subtitle \"{}\"", applescript_escape(subtitle)));
}
if let Some(sound) = sound.filter(|s| !s.trim().is_empty()) {
script.push_str(&format!(" sound name \"{}\"", applescript_escape(sound)));
}
let _ = std::process::Command::new("osascript")
.arg("-e")
.arg(script)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
}
#[cfg(target_os = "linux")]
{
let _ = (subtitle, sound);
let _ = std::process::Command::new("notify-send")
.arg("--app-name=jcode")
.arg(title)
.arg(body)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
let _ = (title, subtitle, body, sound);
}
}
// ---------------------------------------------------------------------------
// Desktop (notify-send)
// ---------------------------------------------------------------------------
fn send_desktop(title: &str, body: &str, urgency: &str) {
// On macOS notify-send does not exist; route through Notification Center.
#[cfg(target_os = "macos")]
{
let _ = urgency;
send_desktop_notification(title, body);
}
#[cfg(not(target_os = "macos"))]
{
let result = std::process::Command::new("notify-send")
.arg("--app-name=jcode")
.arg(format!("--urgency={}", urgency))
.arg("--icon=dialog-information")
.arg(title)
.arg(body)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
match result {
Ok(status) if status.success() => {
logging::info(&format!("Desktop notification sent: {}", title));
}
Ok(status) => {
logging::warn(&format!("notify-send exited with {}", status));
}
Err(e) => {
// notify-send not available - not an error, just skip
logging::info(&format!("notify-send unavailable: {}", e));
}
}
}
}
// ---------------------------------------------------------------------------
// IMAP reply polling
// ---------------------------------------------------------------------------
/// Run an IMAP polling loop checking for replies to ambient emails.
/// Should be spawned as a tokio task alongside the ambient runner.
pub async fn imap_reply_loop(config: SafetyConfig) {
let host = match config.email_imap_host.as_ref() {
Some(h) => h.clone(),
None => {
logging::error("IMAP reply loop: no imap_host configured");
return;
}
};
let port = config.email_imap_port;
let user = match config.email_from.as_ref() {
Some(u) => u.clone(),
None => {
logging::error("IMAP reply loop: no email_from configured");
return;
}
};
let pass = match config.email_password.as_ref() {
Some(p) => p.clone(),
None => {
logging::error("IMAP reply loop: no email password configured");
return;
}
};
logging::info(&format!(
"IMAP reply loop: starting ({}:{}, user: {})",
host, port, user
));
loop {
// Run synchronous IMAP in a blocking task
let h = host.clone();
let u = user.clone();
let p = pass.clone();
let pt = port;
let result = tokio::task::spawn_blocking(move || poll_imap_once(&h, pt, &u, &p)).await;
match result {
Ok(Ok(actions)) => {
for action in &actions {
match action {
ReplyAction::PermissionDecision {
request_id,
approved,
message,
} => {
if let Err(e) = crate::safety::record_permission_via_file(
request_id,
*approved,
"email_reply",
message.clone(),
) {
logging::error(&format!(
"Failed to record permission decision for {}: {}",
request_id, e
));
} else {
logging::info(&format!(
"Permission {} via email: {}",
if *approved { "approved" } else { "denied" },
request_id
));
}
}
ReplyAction::DirectiveReply { cycle_id, text } => {
if let Err(e) =
crate::ambient::add_directive(text.clone(), cycle_id.clone())
{
logging::error(&format!("Failed to save directive: {}", e));
}
}
}
}
if !actions.is_empty() {
logging::info(&format!("IMAP: processed {} email replies", actions.len()));
}
}
Ok(Err(e)) => {
logging::error(&format!("IMAP poll error: {}", e));
}
Err(e) => {
logging::error(&format!("IMAP poll task panicked: {}", e));
}
}
// Poll every 60 seconds
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
}
}
// ---------------------------------------------------------------------------
// Formatting helpers
// ---------------------------------------------------------------------------
/// Sanitized body for potentially public channels (ntfy.sh).
/// Only includes counts and status — no model-generated text.
fn format_cycle_body_safe(transcript: &AmbientTranscript) -> String {
let mut lines = Vec::new();
lines.push(format!("Status: {:?}", transcript.status));
lines.push(format!(
"Memories modified: {}",
transcript.memories_modified
));
lines.push(format!("Compactions: {}", transcript.compactions));
if transcript.pending_permissions > 0 {
lines.push(format!(
"{} permission request(s) pending",
transcript.pending_permissions
));
}
lines.push("Check jcode for full details.".to_string());
lines.join("\n")
}
/// Full detailed body for private channels (email, desktop).
/// Includes the model-generated summary and provider info.
/// Output is markdown — rendered to HTML for email, plain text for desktop.
fn format_cycle_body_detailed(transcript: &AmbientTranscript) -> String {
let mut lines = Vec::new();
if let Some(ref summary) = transcript.summary {
lines.push("# Summary".to_string());
lines.push(String::new());
lines.push(summary.clone());
lines.push(String::new());
}
lines.push("---".to_string());
lines.push(String::new());
lines.push(format!(
"**Status:** {:?} · **Provider:** {} ({}) · **Memories:** {} · **Compactions:** {}",
transcript.status,
transcript.provider,
transcript.model,
transcript.memories_modified,
transcript.compactions,
));
if transcript.pending_permissions > 0 {
lines.push(String::new());
lines.push(format!(
"**⚠ {} permission request(s) pending** — review in jcode",
transcript.pending_permissions
));
}
// Include full conversation transcript if available
if let Some(ref conversation) = transcript.conversation {
lines.push(String::new());
lines.push("---".to_string());
lines.push(String::new());
lines.push("# Full Transcript".to_string());
lines.push(String::new());
lines.push(conversation.clone());
}
lines.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_cycle_body_safe() {
let transcript = AmbientTranscript {
session_id: "test_001".to_string(),
started_at: chrono::Utc::now(),
ended_at: Some(chrono::Utc::now()),
status: crate::safety::TranscriptStatus::Complete,
provider: "claude".to_string(),
model: "claude-sonnet-4".to_string(),
actions: Vec::new(),
pending_permissions: 0,
summary: Some("Cleaned up 3 stale memories.".to_string()),
compactions: 1,
memories_modified: 3,
conversation: None,
};
let body = format_cycle_body_safe(&transcript);
assert!(body.contains("Memories modified: 3"));
assert!(body.contains("Compactions: 1"));
assert!(body.contains("Check jcode for full details"));
// Safe body must NOT include model-generated summary
assert!(!body.contains("Cleaned up"));
assert!(!body.contains("permission"));
}
#[test]
fn test_format_cycle_body_detailed() {
let transcript = AmbientTranscript {
session_id: "test_001".to_string(),
started_at: chrono::Utc::now(),
ended_at: Some(chrono::Utc::now()),
status: crate::safety::TranscriptStatus::Complete,
provider: "claude".to_string(),
model: "claude-sonnet-4".to_string(),
actions: Vec::new(),
pending_permissions: 0,
summary: Some("Cleaned up 3 stale memories.".to_string()),
compactions: 1,
memories_modified: 3,
conversation: Some("### User\n\nBegin cycle.\n\n### Assistant\n\nDone.\n".to_string()),
};
let body = format_cycle_body_detailed(&transcript);
// Detailed body SHOULD include the summary
assert!(body.contains("Cleaned up 3 stale memories."));
assert!(body.contains("**Memories:** 3"));
assert!(body.contains("claude"));
// Should include conversation transcript
assert!(body.contains("# Full Transcript"));
assert!(body.contains("### User"));
assert!(body.contains("Begin cycle."));
}
#[test]
fn test_format_cycle_body_with_pending_permissions() {
let transcript = AmbientTranscript {
session_id: "test_002".to_string(),
started_at: chrono::Utc::now(),
ended_at: Some(chrono::Utc::now()),
status: crate::safety::TranscriptStatus::Complete,
provider: "claude".to_string(),
model: "claude-sonnet-4".to_string(),
actions: Vec::new(),
pending_permissions: 2,
summary: None,
compactions: 0,
memories_modified: 0,
conversation: None,
};
let safe = format_cycle_body_safe(&transcript);
assert!(safe.contains("2 permission request(s) pending"));
assert!(safe.contains("Check jcode for full details"));
let detailed = format_cycle_body_detailed(&transcript);
assert!(detailed.contains("2 permission request(s) pending"));
}
#[test]
fn test_priority_values() {
assert_eq!(Priority::Default.ntfy_value(), "3");
assert_eq!(Priority::High.ntfy_value(), "4");
assert_eq!(Priority::Urgent.ntfy_value(), "5");
}
#[test]
fn test_dispatcher_creation() {
// Just verify it doesn't panic
let cfg = SafetyConfig::default();
let _dispatcher = NotificationDispatcher::from_config(cfg);
}
}
File diff suppressed because it is too large Load Diff
+899
View File
@@ -0,0 +1,899 @@
use std::sync::OnceLock;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PerformanceTier {
Full,
Reduced,
Minimal,
}
impl PerformanceTier {
pub fn label(self) -> &'static str {
match self {
Self::Full => "full",
Self::Reduced => "reduced",
Self::Minimal => "minimal",
}
}
pub fn badge(self) -> Option<&'static str> {
match self {
Self::Full => None,
Self::Reduced => Some("perf:reduced"),
Self::Minimal => Some("perf:minimal"),
}
}
pub fn animations_enabled(self) -> bool {
!matches!(self, Self::Minimal)
}
pub fn idle_animation_enabled(self) -> bool {
matches!(self, Self::Full)
}
pub fn prompt_entry_animation_enabled(self) -> bool {
!matches!(self, Self::Minimal)
}
}
impl std::fmt::Display for PerformanceTier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.label())
}
}
#[derive(Debug, Clone)]
pub struct SystemProfile {
pub load_avg_1m: Option<f64>,
pub cpu_count: Option<usize>,
pub available_memory_mb: Option<u64>,
pub total_memory_mb: Option<u64>,
pub is_ssh: bool,
pub is_wsl: bool,
pub terminal: String,
pub tier: PerformanceTier,
/// True when the host terminal is known to corrupt its GPU glyph atlas
/// under heavy per-cell color/redraw churn (the macOS 26 "garbled glyphs"
/// bug seen in the VS Code integrated terminal and Apple Terminal, where
/// letters like n/m/r/w get re-rendered as stale boxes). When set we run a
/// "glyph-safe" policy that suppresses decorative per-cell color animation
/// and caps full-frame repaints to keep the atlas stable.
pub fragile_glyph_cache: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyntheticSystemProfile {
Native,
Wsl,
WslWindowsTerminal,
}
impl SyntheticSystemProfile {
pub fn label(self) -> &'static str {
match self {
Self::Native => "native",
Self::Wsl => "wsl",
Self::WslWindowsTerminal => "wsl-windows-terminal",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TuiPerfPolicy {
pub tier: PerformanceTier,
pub redraw_fps: u32,
pub animation_fps: u32,
pub enable_decorative_animations: bool,
pub enable_focus_change: bool,
pub enable_mouse_capture: bool,
pub enable_keyboard_enhancement: bool,
pub simplified_model_picker: bool,
pub linked_side_panel_refresh_interval: std::time::Duration,
}
impl SystemProfile {
pub fn load_ratio(&self) -> Option<f64> {
match (self.load_avg_1m, self.cpu_count) {
(Some(load), Some(cpus)) if cpus > 0 => Some(load / cpus as f64),
_ => None,
}
}
pub fn memory_pressure(&self) -> Option<f64> {
match (self.available_memory_mb, self.total_memory_mb) {
(Some(avail), Some(total)) if total > 0 => Some(1.0 - (avail as f64 / total as f64)),
_ => None,
}
}
pub fn is_windows_terminal(&self) -> bool {
self.terminal == "windows-terminal"
}
pub fn is_windows_terminal_family(&self) -> bool {
matches!(
self.terminal.as_str(),
"windows-terminal" | "cmd" | "conhost"
)
}
pub fn is_wsl_windows_terminal(&self) -> bool {
self.is_wsl && self.is_windows_terminal()
}
}
static PROFILE: OnceLock<SystemProfile> = OnceLock::new();
pub fn profile() -> &'static SystemProfile {
PROFILE.get_or_init(detect)
}
pub fn synthetic_profile(kind: SyntheticSystemProfile) -> SystemProfile {
match kind {
SyntheticSystemProfile::Native => SystemProfile {
load_avg_1m: Some(0.2),
cpu_count: Some(8),
available_memory_mb: Some(8192),
total_memory_mb: Some(16384),
is_ssh: false,
is_wsl: false,
terminal: "kitty".to_string(),
tier: PerformanceTier::Full,
fragile_glyph_cache: false,
},
SyntheticSystemProfile::Wsl => SystemProfile {
load_avg_1m: Some(0.4),
cpu_count: Some(8),
available_memory_mb: Some(8192),
total_memory_mb: Some(16384),
is_ssh: false,
is_wsl: true,
terminal: "wezterm".to_string(),
tier: compute_tier(
Some(0.4),
Some(8),
Some(8192),
Some(16384),
false,
true,
"wezterm",
),
fragile_glyph_cache: false,
},
SyntheticSystemProfile::WslWindowsTerminal => SystemProfile {
load_avg_1m: Some(0.4),
cpu_count: Some(8),
available_memory_mb: Some(8192),
total_memory_mb: Some(16384),
is_ssh: false,
is_wsl: true,
terminal: "windows-terminal".to_string(),
tier: compute_tier(
Some(0.4),
Some(8),
Some(8192),
Some(16384),
false,
true,
"windows-terminal",
),
fragile_glyph_cache: false,
},
}
}
pub fn tui_policy() -> TuiPerfPolicy {
tui_policy_for(profile(), &crate::config::config().display)
}
pub fn tui_policy_for(
profile: &SystemProfile,
display: &crate::config::DisplayConfig,
) -> TuiPerfPolicy {
let mut redraw_fps = display.redraw_fps.clamp(1, 120);
let mut animation_fps = display.animation_fps.clamp(1, 120);
let mut enable_decorative_animations = !matches!(profile.tier, PerformanceTier::Minimal);
let mut enable_focus_change = true;
let enable_mouse_capture = display.mouse_capture;
let mut enable_keyboard_enhancement = true;
let mut simplified_model_picker = false;
let mut linked_side_panel_refresh_interval = std::time::Duration::from_millis(250);
if profile.is_wsl || profile.is_windows_terminal_family() {
enable_decorative_animations = false;
}
// Glyph-safe mode for terminals with a fragile GPU glyph atlas (macOS 26
// VS Code integrated terminal / Apple Terminal). The primary fix lives in
// `jcode-tui-style`: colors are quantized to the 256-palette there, which
// bounds the distinct (glyph, color) atlas keys so the animations no longer
// overflow the cache (#330). Here we only trim full-frame repaint pressure
// as cheap insurance; decorative animations stay ON so the experience is
// unchanged apart from slightly reduced color fidelity.
if profile.fragile_glyph_cache {
redraw_fps = redraw_fps.min(30);
}
if profile.is_wsl {
redraw_fps = redraw_fps.min(30);
linked_side_panel_refresh_interval = std::time::Duration::from_millis(500);
}
if profile.is_wsl_windows_terminal() {
redraw_fps = redraw_fps.min(20);
enable_focus_change = false;
enable_keyboard_enhancement = false;
simplified_model_picker = true;
linked_side_panel_refresh_interval = std::time::Duration::from_millis(1000);
}
match profile.tier {
PerformanceTier::Full => {}
PerformanceTier::Reduced => {
redraw_fps = redraw_fps.min(30);
if enable_decorative_animations {
animation_fps = animation_fps.min(24);
}
linked_side_panel_refresh_interval =
linked_side_panel_refresh_interval.max(std::time::Duration::from_millis(500));
}
PerformanceTier::Minimal => {
redraw_fps = redraw_fps.min(12);
enable_decorative_animations = false;
linked_side_panel_refresh_interval =
linked_side_panel_refresh_interval.max(std::time::Duration::from_millis(1000));
}
}
if !enable_decorative_animations {
animation_fps = 1;
}
TuiPerfPolicy {
tier: profile.tier,
redraw_fps,
animation_fps,
enable_decorative_animations,
enable_focus_change,
enable_mouse_capture,
enable_keyboard_enhancement,
simplified_model_picker,
linked_side_panel_refresh_interval,
}
}
pub fn init_background() {
std::thread::spawn(|| {
let p = PROFILE.get_or_init(detect);
crate::logging::info(&format!(
"perf: tier={} terminal={} ssh={} wsl={} glyph_safe={} load={} cpus={} mem_avail={}MB mem_total={}MB",
p.tier,
p.terminal,
p.is_ssh,
p.is_wsl,
p.fragile_glyph_cache,
p.load_avg_1m
.map(|v| format!("{:.1}", v))
.unwrap_or_else(|| "?".into()),
p.cpu_count
.map(|v| v.to_string())
.unwrap_or_else(|| "?".into()),
p.available_memory_mb
.map(|v| v.to_string())
.unwrap_or_else(|| "?".into()),
p.total_memory_mb
.map(|v| v.to_string())
.unwrap_or_else(|| "?".into()),
));
});
}
fn detect() -> SystemProfile {
let is_ssh = std::env::var("SSH_CONNECTION").is_ok() || std::env::var("SSH_TTY").is_ok();
let is_wsl = detect_wsl();
let terminal = detect_terminal();
let (load_avg_1m, cpu_count) = detect_load();
let (available_memory_mb, total_memory_mb) = detect_memory();
let auto_tier = compute_tier(
load_avg_1m,
cpu_count,
available_memory_mb,
total_memory_mb,
is_ssh,
is_wsl,
&terminal,
);
let tier = match crate::config::config().display.performance.as_str() {
"full" => PerformanceTier::Full,
"reduced" => PerformanceTier::Reduced,
"minimal" => PerformanceTier::Minimal,
_ => auto_tier,
};
SystemProfile {
load_avg_1m,
cpu_count,
available_memory_mb,
total_memory_mb,
is_ssh,
is_wsl,
fragile_glyph_cache: detect_fragile_glyph_cache(&terminal),
terminal,
tier,
}
}
/// Detect terminals whose GPU glyph atlas corrupts under heavy per-cell
/// color/redraw churn. On macOS 26 (Tahoe) the VS Code integrated terminal
/// (xterm.js) and Apple Terminal exhibit the "garbled glyphs" bug where a
/// fixed set of similar-shaped letters (n/m/r/w/...) get re-rendered as stale
/// cached boxes once the atlas overflows. Anthropic shipped the same class of
/// bug for Claude Code (anthropics/claude-code#60831, #61562) with the
/// `gpuAcceleration: off` workaround; we instead reduce the churn that
/// surfaces it. GPU-robust terminals (Ghostty, iTerm2, kitty, WezTerm,
/// Alacritty) are unaffected and excluded.
fn detect_fragile_glyph_cache(terminal: &str) -> bool {
// Opt-out / opt-in override for users who want to force the behavior.
if let Ok(raw) = std::env::var("JCODE_GLYPH_SAFE_MODE") {
match raw.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => return true,
"0" | "false" | "no" | "off" => return false,
_ => {}
}
}
// Only macOS surfaces this; other platforms render these terminals fine.
if !cfg!(target_os = "macos") {
return false;
}
matches!(terminal, "vscode" | "apple_terminal")
}
fn compute_tier(
load_avg: Option<f64>,
cpu_count: Option<usize>,
avail_mb: Option<u64>,
_total_mb: Option<u64>,
is_ssh: bool,
is_wsl: bool,
terminal: &str,
) -> PerformanceTier {
if is_ssh {
return PerformanceTier::Minimal;
}
let mut score: i32 = 0;
if let (Some(load), Some(cpus)) = (load_avg, cpu_count) {
let ratio = load / cpus as f64;
if ratio > 2.0 {
score += 3;
} else if ratio > 1.0 {
score += 2;
} else if ratio > 0.8 {
score += 1;
}
}
if let Some(avail) = avail_mb {
if avail < 512 {
score += 3;
} else if avail < 1024 {
score += 2;
} else if avail < 2048 {
score += 1;
}
}
if is_wsl {
score += 1;
}
match terminal {
"windows-terminal" | "cmd" | "conhost" => score += 1,
_ => {}
}
match score {
0..=1 => PerformanceTier::Full,
2..=3 => PerformanceTier::Reduced,
_ => PerformanceTier::Minimal,
}
}
fn detect_wsl() -> bool {
if std::env::var("WSL_DISTRO_NAME").is_ok() || std::env::var("WSLENV").is_ok() {
return true;
}
#[cfg(target_os = "linux")]
{
if let Ok(v) = std::fs::read_to_string("/proc/version") {
let lower = v.to_ascii_lowercase();
if lower.contains("microsoft") || lower.contains("wsl") {
return true;
}
}
}
false
}
fn detect_terminal() -> String {
if std::env::var("WT_SESSION").is_ok() {
return "windows-terminal".to_string();
}
if std::env::var("WEZTERM_EXECUTABLE").is_ok() || std::env::var("WEZTERM_PANE").is_ok() {
return "wezterm".to_string();
}
if std::env::var("KITTY_PID").is_ok() {
return "kitty".to_string();
}
if std::env::var("GHOSTTY_RESOURCES_DIR").is_ok() {
return "ghostty".to_string();
}
if std::env::var("ALACRITTY_WINDOW_ID").is_ok() {
return "alacritty".to_string();
}
if let Ok(tp) = std::env::var("TERM_PROGRAM") {
return tp.to_lowercase();
}
"unknown".to_string()
}
#[cfg(target_os = "linux")]
fn detect_load() -> (Option<f64>, Option<usize>) {
let load = std::fs::read_to_string("/proc/loadavg").ok().and_then(|s| {
s.split_whitespace()
.next()
.and_then(|v| v.parse::<f64>().ok())
});
let cpus = std::fs::read_to_string("/proc/cpuinfo")
.ok()
.map(|s| s.matches("processor\t:").count())
.filter(|&c| c > 0)
.or_else(|| std::thread::available_parallelism().ok().map(|n| n.get()));
(load, cpus)
}
#[cfg(target_os = "macos")]
fn detect_load() -> (Option<f64>, Option<usize>) {
let load = {
let mut loadavg: [libc::c_double; 3] = [0.0; 3];
let n = unsafe { libc::getloadavg(loadavg.as_mut_ptr(), 1) };
if n >= 1 { Some(loadavg[0]) } else { None }
};
let cpus = std::thread::available_parallelism().ok().map(|n| n.get());
(load, cpus)
}
#[cfg(windows)]
fn detect_load() -> (Option<f64>, Option<usize>) {
let cpus = std::thread::available_parallelism().ok().map(|n| n.get());
(None, cpus)
}
#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
fn detect_load() -> (Option<f64>, Option<usize>) {
let cpus = std::thread::available_parallelism().ok().map(|n| n.get());
(None, cpus)
}
#[cfg(target_os = "linux")]
fn detect_memory() -> (Option<u64>, Option<u64>) {
let contents = match std::fs::read_to_string("/proc/meminfo") {
Ok(c) => c,
Err(_) => return (None, None),
};
let mut total_kb: Option<u64> = None;
let mut available_kb: Option<u64> = None;
for line in contents.lines() {
if let Some(rest) = line.strip_prefix("MemTotal:") {
total_kb = parse_meminfo_kb(rest);
} else if let Some(rest) = line.strip_prefix("MemAvailable:") {
available_kb = parse_meminfo_kb(rest);
}
if total_kb.is_some() && available_kb.is_some() {
break;
}
}
(available_kb.map(|k| k / 1024), total_kb.map(|k| k / 1024))
}
#[cfg(target_os = "linux")]
fn parse_meminfo_kb(s: &str) -> Option<u64> {
s.split_whitespace().next()?.parse().ok()
}
#[cfg(windows)]
fn detect_memory() -> (Option<u64>, Option<u64>) {
use std::mem;
#[repr(C)]
struct MemoryStatusEx {
dw_length: u32,
dw_memory_load: u32,
ull_total_phys: u64,
ull_avail_phys: u64,
ull_total_page_file: u64,
ull_avail_page_file: u64,
ull_total_virtual: u64,
ull_avail_virtual: u64,
ull_avail_extended_virtual: u64,
}
unsafe extern "system" {
fn GlobalMemoryStatusEx(lpBuffer: *mut MemoryStatusEx) -> i32;
}
let mut status: MemoryStatusEx = unsafe { mem::zeroed() };
status.dw_length = mem::size_of::<MemoryStatusEx>() as u32;
let ret = unsafe { GlobalMemoryStatusEx(&mut status) };
if ret != 0 {
let total_mb = status.ull_total_phys / (1024 * 1024);
let avail_mb = status.ull_avail_phys / (1024 * 1024);
(Some(avail_mb), Some(total_mb))
} else {
(None, None)
}
}
#[cfg(target_os = "macos")]
fn detect_memory() -> (Option<u64>, Option<u64>) {
let total = {
let mut size: u64 = 0;
let mut len = std::mem::size_of::<u64>();
let name = c"hw.memsize";
let ret = unsafe {
libc::sysctlbyname(
name.as_ptr(),
&mut size as *mut u64 as *mut libc::c_void,
&mut len,
std::ptr::null_mut(),
0,
)
};
if ret == 0 {
Some(size / (1024 * 1024))
} else {
None
}
};
// macOS doesn't have a simple "available" metric like Linux's MemAvailable.
// vm_stat gives pages free + inactive but parsing it adds complexity.
// For tier detection, total memory is sufficient on macOS.
(None, total)
}
#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
fn detect_memory() -> (Option<u64>, Option<u64>) {
(None, None)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ssh_is_minimal() {
let tier = compute_tier(
Some(0.1),
Some(8),
Some(8000),
Some(16000),
true,
false,
"kitty",
);
assert_eq!(tier, PerformanceTier::Minimal);
}
#[test]
fn test_healthy_system_is_full() {
let tier = compute_tier(
Some(0.5),
Some(8),
Some(8000),
Some(16000),
false,
false,
"kitty",
);
assert_eq!(tier, PerformanceTier::Full);
}
#[test]
fn test_high_load_reduces() {
let tier = compute_tier(
Some(12.0),
Some(4),
Some(8000),
Some(16000),
false,
false,
"kitty",
);
assert!(matches!(
tier,
PerformanceTier::Reduced | PerformanceTier::Minimal
));
}
#[test]
fn test_low_memory_reduces() {
let tier = compute_tier(
Some(0.5),
Some(8),
Some(400),
Some(16000),
false,
false,
"kitty",
);
assert!(matches!(
tier,
PerformanceTier::Reduced | PerformanceTier::Minimal
));
}
#[test]
fn test_wsl_penalty() {
let tier_no_wsl = compute_tier(
Some(0.5),
Some(4),
Some(3000),
Some(8000),
false,
false,
"wezterm",
);
let tier_wsl = compute_tier(
Some(0.5),
Some(4),
Some(3000),
Some(8000),
false,
true,
"wezterm",
);
assert!(tier_wsl as i32 >= tier_no_wsl as i32);
}
#[test]
fn test_windows_terminal_penalty() {
let tier_kitty = compute_tier(
Some(0.7),
Some(4),
Some(2500),
Some(8000),
false,
false,
"kitty",
);
let tier_wt = compute_tier(
Some(0.7),
Some(4),
Some(2500),
Some(8000),
false,
false,
"windows-terminal",
);
assert!(tier_wt as i32 >= tier_kitty as i32);
}
#[test]
fn test_profile_accessors() {
let p = SystemProfile {
load_avg_1m: Some(4.0),
cpu_count: Some(8),
available_memory_mb: Some(4000),
total_memory_mb: Some(16000),
is_ssh: false,
is_wsl: false,
terminal: "kitty".to_string(),
tier: PerformanceTier::Full,
fragile_glyph_cache: false,
};
assert!((p.load_ratio().unwrap() - 0.5).abs() < 0.01);
assert!((p.memory_pressure().unwrap() - 0.75).abs() < 0.01);
}
#[test]
fn test_tier_display() {
assert_eq!(PerformanceTier::Full.label(), "full");
assert_eq!(PerformanceTier::Reduced.label(), "reduced");
assert_eq!(PerformanceTier::Minimal.label(), "minimal");
}
#[test]
fn test_badge() {
assert!(PerformanceTier::Full.badge().is_none());
assert!(PerformanceTier::Reduced.badge().is_some());
assert!(PerformanceTier::Minimal.badge().is_some());
}
#[test]
fn test_animation_gates() {
assert!(PerformanceTier::Full.animations_enabled());
assert!(PerformanceTier::Full.idle_animation_enabled());
assert!(PerformanceTier::Full.prompt_entry_animation_enabled());
assert!(PerformanceTier::Reduced.animations_enabled());
assert!(!PerformanceTier::Reduced.idle_animation_enabled());
assert!(PerformanceTier::Reduced.prompt_entry_animation_enabled());
assert!(!PerformanceTier::Minimal.animations_enabled());
assert!(!PerformanceTier::Minimal.idle_animation_enabled());
assert!(!PerformanceTier::Minimal.prompt_entry_animation_enabled());
}
#[test]
fn test_tui_policy_caps_wsl_windows_terminal() {
let profile = synthetic_profile(SyntheticSystemProfile::WslWindowsTerminal);
let mut display = crate::config::DisplayConfig::default();
display.mouse_capture = true;
display.redraw_fps = 60;
display.animation_fps = 60;
let policy = tui_policy_for(&profile, &display);
assert_eq!(policy.tier, PerformanceTier::Reduced);
assert_eq!(policy.redraw_fps, 20);
assert_eq!(policy.animation_fps, 1);
assert!(!policy.enable_decorative_animations);
assert!(!policy.enable_focus_change);
assert!(!policy.enable_keyboard_enhancement);
assert!(policy.simplified_model_picker);
assert!(policy.enable_mouse_capture);
assert_eq!(
policy.linked_side_panel_refresh_interval,
std::time::Duration::from_millis(1000)
);
}
#[test]
fn test_tui_policy_keeps_native_defaults() {
let profile = synthetic_profile(SyntheticSystemProfile::Native);
let mut display = crate::config::DisplayConfig::default();
display.mouse_capture = true;
display.redraw_fps = 48;
display.animation_fps = 50;
let policy = tui_policy_for(&profile, &display);
assert_eq!(policy.tier, PerformanceTier::Full);
assert_eq!(policy.redraw_fps, 48);
assert_eq!(policy.animation_fps, 50);
assert!(policy.enable_decorative_animations);
assert!(policy.enable_focus_change);
assert!(policy.enable_keyboard_enhancement);
assert!(!policy.simplified_model_picker);
assert!(policy.enable_mouse_capture);
assert_eq!(
policy.linked_side_panel_refresh_interval,
std::time::Duration::from_millis(250)
);
}
#[test]
fn test_tui_policy_caps_generic_wsl_without_disabling_terminal_features() {
let profile = synthetic_profile(SyntheticSystemProfile::Wsl);
let mut display = crate::config::DisplayConfig::default();
display.mouse_capture = false;
display.redraw_fps = 60;
display.animation_fps = 60;
let policy = tui_policy_for(&profile, &display);
assert_eq!(policy.redraw_fps, 30);
assert_eq!(policy.animation_fps, 1);
assert!(!policy.enable_decorative_animations);
assert!(policy.enable_focus_change);
assert!(policy.enable_keyboard_enhancement);
assert!(!policy.simplified_model_picker);
assert!(!policy.enable_mouse_capture);
assert_eq!(
policy.linked_side_panel_refresh_interval,
std::time::Duration::from_millis(500)
);
}
#[test]
fn test_tui_policy_disables_decorative_animation_on_windows_terminal_family() {
let profile = SystemProfile {
load_avg_1m: Some(0.2),
cpu_count: Some(8),
available_memory_mb: Some(8192),
total_memory_mb: Some(16384),
is_ssh: false,
is_wsl: false,
terminal: "windows-terminal".to_string(),
tier: PerformanceTier::Full,
fragile_glyph_cache: false,
};
let mut display = crate::config::DisplayConfig::default();
display.redraw_fps = 60;
display.animation_fps = 60;
let policy = tui_policy_for(&profile, &display);
assert_eq!(policy.redraw_fps, 60);
assert_eq!(policy.animation_fps, 1);
assert!(!policy.enable_decorative_animations);
}
#[test]
fn test_detect_runs() {
let p = detect();
assert!(!p.terminal.is_empty());
}
fn glyph_safe_profile(terminal: &str) -> SystemProfile {
SystemProfile {
load_avg_1m: Some(0.2),
cpu_count: Some(8),
available_memory_mb: Some(8192),
total_memory_mb: Some(16384),
is_ssh: false,
is_wsl: false,
terminal: terminal.to_string(),
tier: PerformanceTier::Full,
fragile_glyph_cache: true,
}
}
#[test]
fn test_glyph_safe_mode_keeps_animations_and_caps_redraw() {
// VS Code integrated terminal / Apple Terminal on macOS 26 corrupt the
// GPU glyph atlas under truecolor color churn (#330). The root-cause fix
// is color quantization in jcode-tui-style, so the perf policy keeps
// decorative animations ON and only trims full-frame repaint pressure.
let profile = glyph_safe_profile("vscode");
let mut display = crate::config::DisplayConfig::default();
display.redraw_fps = 60;
display.animation_fps = 60;
let policy = tui_policy_for(&profile, &display);
assert_eq!(policy.tier, PerformanceTier::Full);
assert!(policy.enable_decorative_animations);
assert_eq!(policy.redraw_fps, 30);
// Interactive features stay on; this is purely a rendering mitigation.
assert!(policy.enable_focus_change);
assert!(policy.enable_keyboard_enhancement);
}
#[test]
fn test_non_fragile_terminal_keeps_decorative_animations() {
let profile = SystemProfile {
fragile_glyph_cache: false,
..glyph_safe_profile("ghostty")
};
let mut display = crate::config::DisplayConfig::default();
display.redraw_fps = 60;
display.animation_fps = 60;
let policy = tui_policy_for(&profile, &display);
assert!(policy.enable_decorative_animations);
assert_eq!(policy.redraw_fps, 60);
}
#[cfg(target_os = "macos")]
#[test]
fn test_detect_fragile_glyph_cache_targets_macos_terminals() {
// Env override must not leak between cases.
let prev = std::env::var("JCODE_GLYPH_SAFE_MODE").ok();
unsafe {
std::env::remove_var("JCODE_GLYPH_SAFE_MODE");
}
assert!(detect_fragile_glyph_cache("vscode"));
assert!(detect_fragile_glyph_cache("apple_terminal"));
assert!(!detect_fragile_glyph_cache("ghostty"));
assert!(!detect_fragile_glyph_cache("iterm.app"));
assert!(!detect_fragile_glyph_cache("kitty"));
if let Some(prev) = prev {
unsafe {
std::env::set_var("JCODE_GLYPH_SAFE_MODE", prev);
}
}
}
}
@@ -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,16 @@
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/misc_events.rs");
include!("protocol_tests/randomized.rs");
@@ -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,251 @@
#[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(crate::protocol::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(),
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(),
}),
};
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: crate::protocol::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(),
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(),
},
};
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: None,
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].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,388 @@
#[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 } = decoded else {
return Err(anyhow!("wrong request type"));
};
assert_eq!(id, 9);
assert_eq!(provider, None);
let req = Request::NotifyAuthChanged {
id: 10,
provider: Some("azure-openai".to_string()),
};
let json = serde_json::to_string(&req)?;
assert!(json.contains("\"provider\":\"azure-openai\""));
let decoded = parse_request_json(&json)?;
let Request::NotifyAuthChanged { id, provider } = decoded else {
return Err(anyhow!("wrong request type"));
};
assert_eq!(id, 10);
assert_eq!(provider.as_deref(), Some("azure-openai"));
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_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_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, crate::config::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: crate::config::CompactionMode::Reactive,
activity: None,
side_panel: crate::side_panel::SidePanelSnapshot {
focused_page_id: Some("page-1".to_string()),
pages: vec![crate::side_panel::SidePanelPage {
id: "page-1".to_string(),
title: "Notes".to_string(),
file_path: "/tmp/notes.md".to_string(),
format: crate::side_panel::SidePanelPageFormat::Markdown,
source: crate::side_panel::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: crate::side_panel::SidePanelSnapshot {
focused_page_id: Some("page-1".to_string()),
pages: vec![crate::side_panel::SidePanelPage {
id: "page-1".to_string(),
title: "Notes".to_string(),
file_path: "/tmp/notes.md".to_string(),
format: crate::side_panel::SidePanelPageFormat::Markdown,
source: crate::side_panel::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,319 @@
#[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: crate::message::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_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_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(())
}
@@ -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(())
}
+972
View File
@@ -0,0 +1,972 @@
use crate::message::{ContentBlock, Role};
use crate::protocol::ServerEvent;
use crate::session::{Session, StoredReplayEventKind};
use anyhow::Result;
use chrono::Duration;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
/// A single event in a replay timeline.
///
/// The `t` field is milliseconds from the start of the replay.
/// Edit this value to change pacing in post-production.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimelineEvent {
/// Milliseconds from replay start
pub t: u64,
/// The event payload
#[serde(flatten)]
pub kind: TimelineEventKind,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "event")]
pub enum TimelineEventKind {
/// User message appears instantly
#[serde(rename = "user_message")]
UserMessage { text: String },
/// Assistant starts streaming (sets processing state)
#[serde(rename = "thinking")]
Thinking {
/// How long to show the thinking spinner (ms)
#[serde(default = "default_thinking_duration")]
duration: u64,
},
/// Stream a chunk of assistant text
#[serde(rename = "stream_text")]
StreamText {
text: String,
/// Tokens per second for streaming speed (default 80)
#[serde(default = "default_stream_speed")]
speed: u64,
},
/// Tool call starts
#[serde(rename = "tool_start")]
ToolStart {
name: String,
#[serde(default)]
input: serde_json::Value,
},
/// Tool execution completes
#[serde(rename = "tool_done")]
ToolDone {
name: String,
output: String,
#[serde(default)]
is_error: bool,
},
/// Token usage update (drives context bar)
#[serde(rename = "token_usage")]
TokenUsage {
input: u64,
output: u64,
#[serde(default)]
cache_read: Option<u64>,
#[serde(default)]
cache_creation: Option<u64>,
},
/// Turn complete (commits streaming text, resets to idle)
#[serde(rename = "done")]
Done,
/// Memory injection from auto-recall
#[serde(rename = "memory_injection")]
MemoryInjection {
summary: String,
content: String,
count: u32,
},
/// A persisted non-provider display message.
#[serde(rename = "display_message")]
DisplayMessage {
role: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
title: Option<String>,
content: String,
},
/// Historical swarm status snapshot.
#[serde(rename = "swarm_status")]
SwarmStatus {
members: Vec<crate::protocol::SwarmMemberStatus>,
},
/// Historical swarm plan snapshot.
#[serde(rename = "swarm_plan")]
SwarmPlan {
swarm_id: String,
version: u64,
items: Vec<crate::plan::PlanItem>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
participants: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<String>,
},
}
fn default_thinking_duration() -> u64 {
1200
}
fn default_stream_speed() -> u64 {
80
}
const MAX_INITIAL_REPLAY_IDLE_MS: u64 = 0;
fn cap_initial_replay_idle(events: &mut [TimelineEvent]) {
let Some(first_t) = events.first().map(|event| event.t) else {
return;
};
let shift = first_t.saturating_sub(MAX_INITIAL_REPLAY_IDLE_MS);
if shift == 0 {
return;
}
for event in events {
event.t = event.t.saturating_sub(shift);
}
}
/// Export a session to a replay timeline.
///
/// Uses stored timestamps for real pacing, falls back to estimates.
/// Memory injections from `session.memory_injections` are inserted at the
/// correct positions based on their `before_message` index.
pub fn export_timeline(session: &Session) -> Vec<TimelineEvent> {
let mut events = Vec::new();
let mut t: u64 = 0;
let session_start = session.created_at;
// Track tool IDs for pairing ToolUse → ToolResult
let mut pending_tools: Vec<(String, String, serde_json::Value)> = Vec::new(); // (id, name, input)
// Track memory injections by message index
let mut memory_by_msg: std::collections::HashMap<usize, Vec<_>> =
std::collections::HashMap::new();
for inj in &session.memory_injections {
if let Some(idx) = inj.before_message {
memory_by_msg.entry(idx).or_default().push(inj);
}
}
for (msg_idx, msg) in session.messages.iter().enumerate() {
// Insert memory injections before this message
if let Some(injs) = memory_by_msg.get(&msg_idx) {
for inj in injs {
events.push(TimelineEvent {
t,
kind: TimelineEventKind::MemoryInjection {
summary: inj.summary.clone(),
content: inj.content.clone(),
count: inj.count,
},
});
t += 500; // Brief pause after memory injection
}
}
// Advance time based on stored timestamp
if let Some(ts) = msg.timestamp {
let offset = ts
.signed_duration_since(session_start)
.num_milliseconds()
.max(0) as u64;
if offset > t {
t = offset;
}
}
match msg.role {
Role::User => {
// Check if this is a tool result
let mut has_tool_result = false;
for block in &msg.content {
if let ContentBlock::ToolResult {
tool_use_id,
content,
is_error,
} = block
{
has_tool_result = true;
// Find matching tool start
let tool_name = pending_tools
.iter()
.find(|(id, _, _)| id == tool_use_id)
.map(|(_, name, _)| name.clone())
.unwrap_or_else(|| "tool".to_string());
// Use stored duration or estimate
let duration_ms = msg.tool_duration_ms.unwrap_or(500);
events.push(TimelineEvent {
t,
kind: TimelineEventKind::ToolDone {
name: tool_name,
output: truncate_for_timeline(content),
is_error: is_error.unwrap_or(false),
},
});
t += duration_ms.min(100); // Small gap after tool result
pending_tools.retain(|(id, _, _)| id != tool_use_id);
}
}
if !has_tool_result {
// Regular user message
let text = extract_text(&msg.content);
if !text.is_empty() {
events.push(TimelineEvent {
t,
kind: TimelineEventKind::UserMessage { text },
});
t += 300; // Brief pause after user message
}
}
}
Role::Assistant => {
let text = extract_text(&msg.content);
let tool_uses: Vec<_> = msg
.content
.iter()
.filter_map(|b| {
if let ContentBlock::ToolUse {
id, name, input, ..
} = b
{
Some((id.clone(), name.clone(), input.clone()))
} else {
None
}
})
.collect();
// Thinking phase
if !text.is_empty() || !tool_uses.is_empty() {
events.push(TimelineEvent {
t,
kind: TimelineEventKind::Thinking { duration: 800 },
});
t += 800;
}
// Stream text
if !text.is_empty() {
let speed = 80;
let stream_duration_ms = (text.len() as u64 * 1000) / (speed * 4); // ~4 chars/token
events.push(TimelineEvent {
t,
kind: TimelineEventKind::StreamText {
text: text.clone(),
speed,
},
});
t += stream_duration_ms;
}
// Token usage
if let Some(ref usage) = msg.token_usage {
events.push(TimelineEvent {
t,
kind: TimelineEventKind::TokenUsage {
input: usage.input_tokens,
output: usage.output_tokens,
cache_read: usage.cache_read_input_tokens,
cache_creation: usage.cache_creation_input_tokens,
},
});
}
// Tool calls
for (id, name, input) in &tool_uses {
events.push(TimelineEvent {
t,
kind: TimelineEventKind::ToolStart {
name: name.clone(),
input: input.clone(),
},
});
pending_tools.push((id.clone(), name.clone(), input.clone()));
t += 200; // Small gap between tool starts
}
// Done if no pending tools
if tool_uses.is_empty() {
events.push(TimelineEvent {
t,
kind: TimelineEventKind::Done,
});
t += 200;
}
}
}
}
// Final done if we haven't emitted one
if !events.is_empty() {
let last_is_done = events
.last()
.is_some_and(|e| matches!(e.kind, TimelineEventKind::Done));
if !last_is_done {
events.push(TimelineEvent {
t,
kind: TimelineEventKind::Done,
});
}
}
for replay_event in &session.replay_events {
let offset = replay_event
.timestamp
.signed_duration_since(session_start)
.num_milliseconds()
.max(0) as u64;
let kind = match &replay_event.kind {
StoredReplayEventKind::DisplayMessage {
role,
title,
content,
} => TimelineEventKind::DisplayMessage {
role: role.clone(),
title: title.clone(),
content: content.clone(),
},
StoredReplayEventKind::SwarmStatus { members } => TimelineEventKind::SwarmStatus {
members: members.clone(),
},
StoredReplayEventKind::SwarmPlan {
swarm_id,
version,
items,
participants,
reason,
} => TimelineEventKind::SwarmPlan {
swarm_id: swarm_id.clone(),
version: *version,
items: items.clone(),
participants: participants.clone(),
reason: reason.clone(),
},
};
events.push(TimelineEvent { t: offset, kind });
}
events.sort_by_key(|event| event.t);
cap_initial_replay_idle(&mut events);
events
}
/// Replay-specific server events that don't exist in the normal protocol.
/// These are handled specially in `run_replay`.
#[derive(Debug, Clone)]
#[expect(
clippy::large_enum_variant,
reason = "replay events mirror protocol events directly for simpler playback serialization and handling"
)]
pub enum ReplayEvent {
/// A normal server event
Server(ServerEvent),
/// User message (displayed directly, not via server event)
UserMessage { text: String },
/// Start processing state (shows thinking spinner)
StartProcessing,
/// Memory injection from auto-recall
MemoryInjection {
summary: String,
content: String,
count: u32,
},
/// Persisted non-provider display message.
DisplayMessage {
role: String,
title: Option<String>,
content: String,
},
/// Historical swarm status snapshot.
SwarmStatus {
members: Vec<crate::protocol::SwarmMemberStatus>,
},
/// Historical swarm plan snapshot.
SwarmPlan {
swarm_id: String,
version: u64,
items: Vec<crate::plan::PlanItem>,
},
}
/// Convert a timeline into a sequence of (delay_ms, ReplayEvent) pairs for playback.
pub fn timeline_to_replay_events(timeline: &[TimelineEvent]) -> Vec<(u64, ReplayEvent)> {
let mut out = Vec::new();
let mut prev_t: u64 = 0;
let mut turn_id: u64 = 1;
let mut tool_id_counter: u64 = 0;
let mut pending_tool_ids: Vec<String> = Vec::new();
for event in timeline {
let delay = event.t.saturating_sub(prev_t);
let delay = if out.is_empty() {
MAX_INITIAL_REPLAY_IDLE_MS
} else {
delay
};
prev_t = event.t;
match &event.kind {
TimelineEventKind::UserMessage { text } => {
out.push((delay, ReplayEvent::UserMessage { text: text.clone() }));
}
TimelineEventKind::Thinking { .. } => {
out.push((delay, ReplayEvent::StartProcessing));
}
TimelineEventKind::StreamText { text, speed } => {
let chars_per_chunk = 4; // ~1 token
let ms_per_chunk = if *speed > 0 { 1000 / speed } else { 12 };
let chunks: Vec<String> = text
.chars()
.collect::<Vec<_>>()
.chunks(chars_per_chunk)
.map(|c| c.iter().collect::<String>())
.collect();
for (i, chunk) in chunks.iter().enumerate() {
let chunk_delay = if i == 0 { delay } else { ms_per_chunk };
out.push((
chunk_delay,
ReplayEvent::Server(ServerEvent::TextDelta {
text: chunk.clone(),
}),
));
}
}
TimelineEventKind::ToolStart { name, input } => {
tool_id_counter += 1;
let id = format!("replay_tool_{}", tool_id_counter);
pending_tool_ids.push(id.clone());
out.push((
delay,
ReplayEvent::Server(ServerEvent::ToolStart {
id: id.clone(),
name: name.clone(),
}),
));
let input_str = serde_json::to_string(input).unwrap_or_default();
if !input_str.is_empty() && input_str != "null" {
out.push((
0,
ReplayEvent::Server(ServerEvent::ToolInput { delta: input_str }),
));
}
out.push((
50,
ReplayEvent::Server(ServerEvent::ToolExec {
id: id.clone(),
name: name.clone(),
}),
));
}
TimelineEventKind::ToolDone {
name,
output,
is_error,
} => {
let id = pending_tool_ids.pop().unwrap_or_else(|| {
tool_id_counter += 1;
format!("replay_tool_{}", tool_id_counter)
});
out.push((
delay,
ReplayEvent::Server(ServerEvent::ToolDone {
id,
name: name.clone(),
output: output.clone(),
error: if *is_error {
Some(output.clone())
} else {
None
},
}),
));
}
TimelineEventKind::TokenUsage {
input,
output,
cache_read,
cache_creation,
} => {
out.push((
delay,
ReplayEvent::Server(ServerEvent::TokenUsage {
input: *input,
output: *output,
cache_read_input: *cache_read,
cache_creation_input: *cache_creation,
}),
));
}
TimelineEventKind::Done => {
out.push((
delay,
ReplayEvent::Server(ServerEvent::Done { id: turn_id }),
));
turn_id += 1;
}
TimelineEventKind::MemoryInjection {
summary,
content,
count,
} => {
out.push((
delay,
ReplayEvent::MemoryInjection {
summary: summary.clone(),
content: content.clone(),
count: *count,
},
));
}
TimelineEventKind::DisplayMessage {
role,
title,
content,
} => {
out.push((
delay,
ReplayEvent::DisplayMessage {
role: role.clone(),
title: title.clone(),
content: content.clone(),
},
));
}
TimelineEventKind::SwarmStatus { members } => {
out.push((
delay,
ReplayEvent::SwarmStatus {
members: members.clone(),
},
));
}
TimelineEventKind::SwarmPlan {
swarm_id,
version,
items,
..
} => {
out.push((
delay,
ReplayEvent::SwarmPlan {
swarm_id: swarm_id.clone(),
version: *version,
items: items.clone(),
},
));
}
}
}
out
}
/// Load a session by ID or path
pub fn load_session(id_or_path: &str) -> Result<Session> {
use std::path::Path;
// Try as file path first
let path = Path::new(id_or_path);
if path.exists() {
return Session::load_from_path(path);
}
// Try as session ID in the sessions directory
let sessions_dir = crate::storage::jcode_dir()?.join("sessions");
// Try exact match
let exact = sessions_dir.join(format!("{}.json", id_or_path));
if exact.exists() {
return Session::load_from_path(&exact);
}
// Try prefix match (session_<id>.json or session_<name>_<ts>.json)
for entry in std::fs::read_dir(&sessions_dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
if name.contains(id_or_path) && name.ends_with(".json") {
return Session::load_from_path(&entry.path());
}
}
anyhow::bail!(
"Session not found: '{}'. Provide a session ID, name, or file path.",
id_or_path
);
}
#[derive(Debug, Clone)]
pub struct SwarmReplaySession {
pub session: Session,
pub timeline: Vec<TimelineEvent>,
}
pub fn load_swarm_sessions(
seed_id_or_path: &str,
auto_edit: bool,
) -> Result<Vec<SwarmReplaySession>> {
let seed = load_session(seed_id_or_path)?;
let seed_working_dir = seed.working_dir.clone();
let lower_bound = seed.created_at - Duration::hours(6);
let upper_bound = seed.updated_at + Duration::hours(6);
let sessions_dir = crate::storage::jcode_dir()?.join("sessions");
if !sessions_dir.exists() {
return Ok(vec![SwarmReplaySession {
timeline: maybe_auto_edit(&seed, auto_edit),
session: seed,
}]);
}
let mut all_sessions: Vec<Session> = Vec::new();
for entry in std::fs::read_dir(&sessions_dir)? {
let entry = entry?;
let path = entry.path();
if !path.extension().map(|e| e == "json").unwrap_or(false) {
continue;
}
let Ok(session) = Session::load_from_path(&path) else {
continue;
};
all_sessions.push(session);
}
let mut selected_ids: BTreeSet<String> = BTreeSet::new();
selected_ids.insert(seed.id.clone());
for session in &all_sessions {
if session.id == seed.id {
continue;
}
let same_working_dir =
seed_working_dir.is_some() && session.working_dir == seed_working_dir;
let linked_parent = session.parent_id.as_deref() == Some(seed.id.as_str())
|| seed.parent_id.as_deref() == Some(session.id.as_str())
|| (seed.parent_id.is_some() && session.parent_id == seed.parent_id);
let overlapping_time =
session.updated_at >= lower_bound && session.created_at <= upper_bound;
let has_swarm_events = session.replay_events.iter().any(|evt| {
matches!(
evt.kind,
StoredReplayEventKind::SwarmStatus { .. } | StoredReplayEventKind::SwarmPlan { .. }
)
});
if overlapping_time && (same_working_dir || linked_parent || has_swarm_events) {
selected_ids.insert(session.id.clone());
}
}
let mut selected: Vec<Session> = all_sessions
.into_iter()
.filter(|session| selected_ids.contains(&session.id))
.collect();
if !selected.iter().any(|session| session.id == seed.id) {
selected.push(seed.clone());
}
selected.sort_by(|a, b| {
a.created_at
.cmp(&b.created_at)
.then_with(|| a.id.cmp(&b.id))
});
Ok(selected
.into_iter()
.map(|session| {
let timeline = maybe_auto_edit(&session, auto_edit);
SwarmReplaySession { session, timeline }
})
.collect())
}
fn maybe_auto_edit(session: &Session, auto_edit: bool) -> Vec<TimelineEvent> {
let timeline = export_timeline(session);
if auto_edit {
auto_edit_timeline(&timeline, &AutoEditOpts::default())
} else {
timeline
}
}
#[derive(Debug, Clone)]
pub struct PaneReplayInput {
pub session: Session,
pub timeline: Vec<TimelineEvent>,
}
#[derive(Debug, Clone)]
pub struct SwarmPaneFrames {
pub session_id: String,
pub title: String,
pub frames: Vec<(f64, ratatui::buffer::Buffer)>,
}
pub fn compose_swarm_buffers(
pane_frames: &[SwarmPaneFrames],
width: u16,
height: u16,
fps: u32,
cols: u16,
) -> Vec<(f64, ratatui::buffer::Buffer)> {
use ratatui::{buffer::Buffer, layout::Rect};
if pane_frames.is_empty() {
return Vec::new();
}
let fps = fps.max(1);
let frame_step = 1.0 / fps as f64;
let end_time = pane_frames
.iter()
.filter_map(|pane| pane.frames.last().map(|(t, _)| *t))
.fold(0.0, f64::max);
let pane_count = pane_frames.len() as u16;
let cols = cols.clamp(1, pane_count.max(1));
let rows = pane_count.div_ceil(cols).max(1);
let pane_width = (width / cols).max(1);
let pane_height = (height / rows).max(1);
let mut output = Vec::new();
let mut t = 0.0;
while t <= end_time + frame_step {
let mut canvas = Buffer::empty(Rect::new(0, 0, width, height));
for (idx, pane) in pane_frames.iter().enumerate() {
let idx = idx as u16;
let col = idx % cols;
let row = idx / cols;
let x = col * pane_width;
let y = row * pane_height;
let area = Rect::new(
x,
y,
if col == cols - 1 {
width - x
} else {
pane_width
},
if row == rows - 1 {
height - y
} else {
pane_height
},
);
if let Some(buf) = buffer_at_time(&pane.frames, t) {
blit_buffer(&mut canvas, area, buf);
}
}
output.push((t, canvas));
t += frame_step;
}
output
}
fn buffer_at_time(
frames: &[(f64, ratatui::buffer::Buffer)],
t: f64,
) -> Option<&ratatui::buffer::Buffer> {
let mut current = None;
for (frame_t, buf) in frames {
if *frame_t <= t {
current = Some(buf);
} else {
break;
}
}
current.or_else(|| frames.first().map(|(_, buf)| buf))
}
fn blit_buffer(
dst: &mut ratatui::buffer::Buffer,
area: ratatui::layout::Rect,
src: &ratatui::buffer::Buffer,
) {
for sy in 0..area.height.min(src.area.height) {
for sx in 0..area.width.min(src.area.width) {
let dx = area.x + sx;
let dy = area.y + sy;
if let (Some(src_cell), Some(dst_cell)) = (src.cell((sx, sy)), dst.cell_mut((dx, dy))) {
*dst_cell = src_cell.clone();
}
}
}
}
fn extract_text(blocks: &[ContentBlock]) -> String {
let mut text = String::new();
for block in blocks {
if let ContentBlock::Text { text: t, .. } = block {
if !text.is_empty() {
text.push('\n');
}
text.push_str(t);
}
}
text
}
/// Auto-edit a timeline for demo-quality pacing.
///
/// Compresses dead time so the replay feels snappy:
/// - Tool call execution (tool_start → tool_done): capped to `tool_max_ms`
/// - Gaps between turns (done → next user_message): capped to `gap_max_ms`
/// - Thinking duration: capped to `think_max_ms`
/// - Streaming text and everything else: preserved as-is
pub fn auto_edit_timeline(timeline: &[TimelineEvent], opts: &AutoEditOpts) -> Vec<TimelineEvent> {
if timeline.is_empty() {
return vec![];
}
let mut out: Vec<TimelineEvent> = Vec::with_capacity(timeline.len());
let mut time_shift: i64 = 0; // accumulated shift (negative = earlier)
// Track tool nesting for compressing tool_start→tool_done spans
let mut tool_depth: u32 = 0;
let mut tool_span_start_t: Option<u64> = None;
// Track the end of the most recent top-level tool span so we can
// compress any long idle wait before the assistant resumes.
let mut last_tool_done_t: Option<u64> = None;
// Track done→user_message gaps
let mut last_done_t: Option<u64> = None;
// Track user_message→thinking gaps
let mut last_user_msg_t: Option<u64> = None;
for event in timeline {
let orig_t = event.t;
let mut new_t = (orig_t as i64 + time_shift).max(0) as u64;
// If the assistant sat idle for a long time after a tool completed
// (for example during a selfdev reload), compress that post-tool gap
// before the next later event.
if let Some(tool_done_t) = last_tool_done_t
&& orig_t > tool_done_t
{
let gap = orig_t.saturating_sub(tool_done_t);
if gap > opts.response_delay_max_ms {
time_shift -= (gap - opts.response_delay_max_ms) as i64;
new_t = (orig_t as i64 + time_shift).max(0) as u64;
}
last_tool_done_t = None;
}
match &event.kind {
TimelineEventKind::Thinking { duration } => {
// Clamp gap from done→thinking
if let Some(done_t) = last_done_t.take() {
let gap = orig_t.saturating_sub(done_t);
if gap > opts.gap_max_ms {
time_shift -= (gap - opts.gap_max_ms) as i64;
new_t = (orig_t as i64 + time_shift).max(0) as u64;
}
}
// Clamp gap from user_message→thinking (model response delay)
if let Some(user_t) = last_user_msg_t.take() {
let gap = orig_t.saturating_sub(user_t);
if gap > opts.response_delay_max_ms {
time_shift -= (gap - opts.response_delay_max_ms) as i64;
new_t = (orig_t as i64 + time_shift).max(0) as u64;
}
}
let clamped = (*duration).min(opts.think_max_ms);
out.push(TimelineEvent {
t: new_t,
kind: TimelineEventKind::Thinking { duration: clamped },
});
continue;
}
TimelineEventKind::UserMessage { .. } => {
// Compress gap after last done
if let Some(done_t) = last_done_t.take() {
let gap = orig_t.saturating_sub(done_t);
if gap > opts.gap_max_ms {
time_shift -= (gap - opts.gap_max_ms) as i64;
new_t = (orig_t as i64 + time_shift).max(0) as u64;
}
}
last_user_msg_t = Some(orig_t);
}
TimelineEventKind::ToolStart { .. } => {
if tool_depth == 0 {
tool_span_start_t = Some(orig_t);
}
tool_depth += 1;
}
TimelineEventKind::ToolDone { .. } => {
tool_depth = tool_depth.saturating_sub(1);
if tool_depth == 0 {
if let Some(start_t) = tool_span_start_t.take() {
let span = orig_t.saturating_sub(start_t);
if span > opts.tool_max_ms {
time_shift -= (span - opts.tool_max_ms) as i64;
new_t = (orig_t as i64 + time_shift).max(0) as u64;
}
}
last_tool_done_t = Some(orig_t);
}
}
TimelineEventKind::Done => {
last_done_t = Some(orig_t);
}
_ => {}
}
out.push(TimelineEvent {
t: new_t,
kind: event.kind.clone(),
});
}
out
}
/// Options for [`auto_edit_timeline`].
pub struct AutoEditOpts {
/// Max ms for a tool_start→tool_done span (default: 800)
pub tool_max_ms: u64,
/// Max ms gap between done→next user_message (default: 2000)
pub gap_max_ms: u64,
/// Max ms for thinking duration (default: 1200)
pub think_max_ms: u64,
/// Max ms between user_message→thinking (model response delay, default: 1000)
pub response_delay_max_ms: u64,
}
impl Default for AutoEditOpts {
fn default() -> Self {
Self {
tool_max_ms: 800,
gap_max_ms: 2000,
think_max_ms: 1200,
response_delay_max_ms: 1000,
}
}
}
fn truncate_for_timeline(s: &str) -> String {
if s.len() > 500 {
let mut end = 497;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
format!("{}...", &s[..end])
} else {
s.to_string()
}
}
#[cfg(test)]
mod tests;
+834
View File
@@ -0,0 +1,834 @@
use super::*;
use crate::plan::PlanItem;
use crate::protocol::SwarmMemberStatus;
use crate::session::{StoredReplayEvent, StoredReplayEventKind};
use chrono::{Duration, Utc};
use std::ffi::OsString;
fn lock_env() -> std::sync::MutexGuard<'static, ()> {
crate::storage::lock_test_env()
}
struct EnvVarGuard {
key: &'static str,
prev: Option<OsString>,
}
impl EnvVarGuard {
fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
let prev = std::env::var_os(key);
crate::env::set_var(key, value);
Self { key, prev }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
if let Some(prev) = &self.prev {
crate::env::set_var(self.key, prev);
} else {
crate::env::remove_var(self.key);
}
}
}
#[test]
fn test_timeline_roundtrip() {
let events = vec![
TimelineEvent {
t: 0,
kind: TimelineEventKind::UserMessage {
text: "hello".to_string(),
},
},
TimelineEvent {
t: 500,
kind: TimelineEventKind::Thinking { duration: 1000 },
},
TimelineEvent {
t: 1500,
kind: TimelineEventKind::StreamText {
text: "Hi there!".to_string(),
speed: 80,
},
},
TimelineEvent {
t: 2000,
kind: TimelineEventKind::Done,
},
];
// Serialize to JSON
let json = serde_json::to_string_pretty(&events).unwrap();
assert!(json.contains("user_message"));
assert!(json.contains("stream_text"));
// Deserialize back
let parsed: Vec<TimelineEvent> = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.len(), 4);
assert_eq!(parsed[0].t, 0);
assert_eq!(parsed[2].t, 1500);
}
#[test]
fn test_timeline_to_replay_events() {
let events = vec![
TimelineEvent {
t: 0,
kind: TimelineEventKind::StreamText {
text: "Hello world".to_string(),
speed: 80,
},
},
TimelineEvent {
t: 500,
kind: TimelineEventKind::Done,
},
];
let replay_events = timeline_to_replay_events(&events);
assert!(!replay_events.is_empty());
// First event should be a Server(TextDelta)
match &replay_events[0].1 {
ReplayEvent::Server(ServerEvent::TextDelta { text }) => assert!(!text.is_empty()),
_ => panic!("Expected Server(TextDelta)"),
}
// Last event should be Server(Done)
match &replay_events.last().unwrap().1 {
ReplayEvent::Server(ServerEvent::Done { .. }) => {}
_ => panic!("Expected Server(Done)"),
}
}
#[test]
fn test_timeline_to_replay_events_caps_initial_idle() {
let events = vec![
TimelineEvent {
t: 8_000,
kind: TimelineEventKind::UserMessage {
text: "hello".to_string(),
},
},
TimelineEvent {
t: 8_500,
kind: TimelineEventKind::Thinking { duration: 800 },
},
];
let replay_events = timeline_to_replay_events(&events);
assert_eq!(replay_events[0].0, 0);
assert!(matches!(
replay_events[0].1,
ReplayEvent::UserMessage { .. }
));
}
#[test]
fn test_cap_initial_replay_idle_shifts_timeline_start() {
let mut events = vec![
TimelineEvent {
t: 8_000,
kind: TimelineEventKind::UserMessage {
text: "hello".to_string(),
},
},
TimelineEvent {
t: 8_750,
kind: TimelineEventKind::Thinking { duration: 800 },
},
];
cap_initial_replay_idle(&mut events);
assert_eq!(events[0].t, 0);
assert_eq!(events[1].t, 750);
}
#[test]
fn test_tool_events() {
let events = vec![
TimelineEvent {
t: 0,
kind: TimelineEventKind::ToolStart {
name: "file_read".to_string(),
input: serde_json::json!({"file_path": "/tmp/test.rs"}),
},
},
TimelineEvent {
t: 500,
kind: TimelineEventKind::ToolDone {
name: "file_read".to_string(),
output: "fn main() {}".to_string(),
is_error: false,
},
},
];
let replay_events = timeline_to_replay_events(&events);
let types: Vec<&str> = replay_events
.iter()
.filter_map(|(_, e)| match e {
ReplayEvent::Server(se) => Some(match se {
ServerEvent::ToolStart { .. } => "start",
ServerEvent::ToolInput { .. } => "input",
ServerEvent::ToolExec { .. } => "exec",
ServerEvent::ToolDone { .. } => "done",
_ => "other",
}),
_ => None,
})
.collect();
assert!(types.contains(&"start"));
assert!(types.contains(&"exec"));
assert!(types.contains(&"done"));
}
#[test]
fn test_user_message_and_thinking() {
let events = vec![
TimelineEvent {
t: 0,
kind: TimelineEventKind::UserMessage {
text: "hello".to_string(),
},
},
TimelineEvent {
t: 500,
kind: TimelineEventKind::Thinking { duration: 800 },
},
TimelineEvent {
t: 1300,
kind: TimelineEventKind::StreamText {
text: "Hi!".to_string(),
speed: 80,
},
},
];
let replay_events = timeline_to_replay_events(&events);
// First should be UserMessage
assert!(matches!(
&replay_events[0].1,
ReplayEvent::UserMessage { .. }
));
// Second should be StartProcessing
assert!(matches!(&replay_events[1].1, ReplayEvent::StartProcessing));
// Third should be Server(TextDelta)
assert!(matches!(
&replay_events[2].1,
ReplayEvent::Server(ServerEvent::TextDelta { .. })
));
}
#[test]
fn test_export_timeline_includes_persisted_swarm_replay_events() {
let base = Utc::now();
let mut session = Session::create_with_id("session_replay_swarm_test".to_string(), None, None);
session.created_at = base;
session.updated_at = base;
session.replay_events = vec![
StoredReplayEvent {
timestamp: base + Duration::milliseconds(100),
kind: StoredReplayEventKind::DisplayMessage {
role: "swarm".to_string(),
title: Some("DM from fox".to_string()),
content: "Take parser tests".to_string(),
},
},
StoredReplayEvent {
timestamp: base + Duration::milliseconds(200),
kind: StoredReplayEventKind::SwarmStatus {
members: vec![SwarmMemberStatus {
session_id: "session_fox".to_string(),
friendly_name: Some("fox".to_string()),
status: "running".to_string(),
detail: Some("parser tests".to_string()),
task_label: None,
role: Some("agent".to_string()),
is_headless: None,
live_attachments: None,
status_age_secs: None,
output_tail: None,
report_back_to_session_id: None,
todo_progress: None,
todo_items: Vec::new(),
runtime: crate::protocol::SwarmMemberRuntime::default(),
}],
},
},
StoredReplayEvent {
timestamp: base + Duration::milliseconds(300),
kind: StoredReplayEventKind::SwarmPlan {
swarm_id: "swarm_123".to_string(),
version: 2,
items: vec![PlanItem {
content: "Run parser tests".to_string(),
status: "running".to_string(),
priority: "high".to_string(),
id: "task-1".to_string(),
subsystem: None,
file_scope: Vec::new(),
blocked_by: vec![],
assigned_to: Some("session_fox".to_string()),
}],
participants: vec!["session_fox".to_string()],
reason: Some("proposal approved".to_string()),
},
},
];
let timeline = export_timeline(&session);
assert!(timeline.iter().any(|event| matches!(
&event.kind,
TimelineEventKind::DisplayMessage { role, title, content }
if role == "swarm"
&& title.as_deref() == Some("DM from fox")
&& content == "Take parser tests"
)));
assert!(timeline.iter().any(|event| matches!(
&event.kind,
TimelineEventKind::SwarmStatus { members }
if members.len() == 1 && members[0].status == "running"
)));
assert!(timeline.iter().any(|event| matches!(
&event.kind,
TimelineEventKind::SwarmPlan { swarm_id, version, items, .. }
if swarm_id == "swarm_123" && *version == 2 && items.len() == 1
)));
}
#[test]
fn test_timeline_to_replay_events_converts_swarm_replay_events() {
let timeline = vec![
TimelineEvent {
t: 100,
kind: TimelineEventKind::DisplayMessage {
role: "swarm".to_string(),
title: Some("Broadcast · oak".to_string()),
content: "Plan updated".to_string(),
},
},
TimelineEvent {
t: 200,
kind: TimelineEventKind::SwarmStatus {
members: vec![SwarmMemberStatus {
session_id: "session_oak".to_string(),
friendly_name: Some("oak".to_string()),
status: "completed".to_string(),
detail: None,
task_label: None,
role: Some("agent".to_string()),
is_headless: None,
live_attachments: None,
status_age_secs: None,
output_tail: None,
report_back_to_session_id: None,
todo_progress: None,
todo_items: Vec::new(),
runtime: crate::protocol::SwarmMemberRuntime::default(),
}],
},
},
TimelineEvent {
t: 300,
kind: TimelineEventKind::SwarmPlan {
swarm_id: "swarm_abc".to_string(),
version: 7,
items: vec![PlanItem {
content: "Integrate results".to_string(),
status: "pending".to_string(),
priority: "high".to_string(),
id: "task-7".to_string(),
subsystem: None,
file_scope: Vec::new(),
blocked_by: vec![],
assigned_to: None,
}],
participants: vec![],
reason: None,
},
},
];
let replay_events = timeline_to_replay_events(&timeline);
assert!(replay_events.iter().any(|(_, event)| matches!(
event,
ReplayEvent::DisplayMessage { role, title, content }
if role == "swarm"
&& title.as_deref() == Some("Broadcast · oak")
&& content == "Plan updated"
)));
assert!(replay_events.iter().any(|(_, event)| matches!(
event,
ReplayEvent::SwarmStatus { members }
if members.len() == 1 && members[0].status == "completed"
)));
assert!(replay_events.iter().any(|(_, event)| matches!(
event,
ReplayEvent::SwarmPlan { swarm_id, version, items }
if swarm_id == "swarm_abc" && *version == 7 && items.len() == 1
)));
}
#[test]
fn test_load_swarm_sessions_discovers_related_sessions() {
let _env_lock = lock_env();
let temp_home = tempfile::Builder::new()
.prefix("jcode-replay-swarm-test-")
.tempdir()
.expect("create temp JCODE_HOME");
let _home = EnvVarGuard::set("JCODE_HOME", temp_home.path().as_os_str());
let mut seed = Session::create_with_id("session_seed".to_string(), None, None);
seed.working_dir = Some("/tmp/repo".to_string());
seed.record_swarm_status_event(vec![SwarmMemberStatus {
session_id: "session_seed".to_string(),
friendly_name: Some("seed".to_string()),
status: "running".to_string(),
detail: None,
task_label: None,
role: Some("coordinator".to_string()),
is_headless: None,
live_attachments: None,
status_age_secs: None,
output_tail: None,
report_back_to_session_id: None,
todo_progress: None,
todo_items: Vec::new(),
runtime: crate::protocol::SwarmMemberRuntime::default(),
}]);
seed.save().unwrap();
let mut child = Session::create_with_id(
"session_child".to_string(),
Some(seed.id.clone()),
Some("child".to_string()),
);
child.working_dir = Some("/tmp/repo".to_string());
child.record_swarm_plan_event(
"swarm_x".to_string(),
1,
vec![PlanItem {
content: "Task".to_string(),
status: "running".to_string(),
priority: "high".to_string(),
id: "task-1".to_string(),
subsystem: None,
file_scope: Vec::new(),
blocked_by: vec![],
assigned_to: Some(seed.id.clone()),
}],
vec![seed.id.clone(), child.id.clone()],
None,
);
child.save().unwrap();
let mut unrelated = Session::create_with_id("session_other".to_string(), None, None);
unrelated.working_dir = Some("/tmp/other".to_string());
unrelated.save().unwrap();
let loaded = load_swarm_sessions("session_seed", false).unwrap();
let ids: Vec<_> = loaded.iter().map(|s| s.session.id.as_str()).collect();
assert!(ids.contains(&"session_seed"));
assert!(ids.contains(&"session_child"));
assert!(!ids.contains(&"session_other"));
}
#[test]
fn test_compose_swarm_buffers_combines_panes() {
use ratatui::{buffer::Buffer, layout::Rect, style::Style};
let mut left = Buffer::empty(Rect::new(0, 0, 4, 2));
left[(0, 0)].set_symbol("L").set_style(Style::default());
let mut right = Buffer::empty(Rect::new(0, 0, 4, 2));
right[(0, 0)].set_symbol("R").set_style(Style::default());
let panes = vec![
SwarmPaneFrames {
session_id: "left".to_string(),
title: "left".to_string(),
frames: vec![(0.0, left)],
},
SwarmPaneFrames {
session_id: "right".to_string(),
title: "right".to_string(),
frames: vec![(0.0, right)],
},
];
let frames = compose_swarm_buffers(&panes, 8, 2, 1, 2);
assert!(!frames.is_empty());
let buf = &frames[0].1;
assert_eq!(buf[(0, 0)].symbol(), "L");
assert_eq!(buf[(4, 0)].symbol(), "R");
}
#[test]
fn test_tool_ids_match_between_start_and_done() {
let events = vec![
TimelineEvent {
t: 0,
kind: TimelineEventKind::ToolStart {
name: "file_read".to_string(),
input: serde_json::json!({"file_path": "/tmp/test.rs"}),
},
},
TimelineEvent {
t: 500,
kind: TimelineEventKind::ToolDone {
name: "file_read".to_string(),
output: "fn main() {}".to_string(),
is_error: false,
},
},
];
let replay_events = timeline_to_replay_events(&events);
let start_id = replay_events.iter().find_map(|(_, e)| match e {
ReplayEvent::Server(ServerEvent::ToolStart { id, .. }) => Some(id.clone()),
_ => None,
});
let exec_id = replay_events.iter().find_map(|(_, e)| match e {
ReplayEvent::Server(ServerEvent::ToolExec { id, .. }) => Some(id.clone()),
_ => None,
});
let done_id = replay_events.iter().find_map(|(_, e)| match e {
ReplayEvent::Server(ServerEvent::ToolDone { id, .. }) => Some(id.clone()),
_ => None,
});
assert!(start_id.is_some(), "Should have ToolStart");
assert_eq!(start_id, exec_id, "ToolStart and ToolExec IDs must match");
assert_eq!(start_id, done_id, "ToolStart and ToolDone IDs must match");
}
#[test]
fn test_batch_tool_input_preserved() {
let batch_input = serde_json::json!({
"tool_calls": [
{"tool": "file_read", "parameters": {"file_path": "/tmp/a.rs"}},
{"tool": "file_read", "parameters": {"file_path": "/tmp/b.rs"}},
{"tool": "file_grep", "parameters": {"pattern": "foo"}},
]
});
let events = vec![
TimelineEvent {
t: 0,
kind: TimelineEventKind::ToolStart {
name: "batch".to_string(),
input: batch_input.clone(),
},
},
TimelineEvent {
t: 1000,
kind: TimelineEventKind::ToolDone {
name: "batch".to_string(),
output: "--- [1] file_read ---\nok\n--- [2] file_read ---\nok\n--- [3] file_grep ---\nok".to_string(),
is_error: false,
},
},
];
let replay_events = timeline_to_replay_events(&events);
// Verify the ToolInput delta contains the batch input
let input_delta = replay_events.iter().find_map(|(_, e)| match e {
ReplayEvent::Server(ServerEvent::ToolInput { delta }) => Some(delta.clone()),
_ => None,
});
assert!(
input_delta.is_some(),
"Should have ToolInput with batch params"
);
let parsed: serde_json::Value = serde_json::from_str(&input_delta.unwrap()).unwrap();
let tool_calls = parsed.get("tool_calls").and_then(|v| v.as_array());
assert_eq!(
tool_calls.map(|a| a.len()),
Some(3),
"Batch should have 3 tool calls"
);
// Verify IDs match
let start_id = replay_events.iter().find_map(|(_, e)| match e {
ReplayEvent::Server(ServerEvent::ToolStart { id, .. }) => Some(id.clone()),
_ => None,
});
let done_id = replay_events.iter().find_map(|(_, e)| match e {
ReplayEvent::Server(ServerEvent::ToolDone { id, .. }) => Some(id.clone()),
_ => None,
});
assert_eq!(
start_id, done_id,
"Batch ToolStart and ToolDone IDs must match"
);
}
#[test]
fn test_auto_edit_compresses_tool_spans() {
let events = vec![
TimelineEvent {
t: 0,
kind: TimelineEventKind::UserMessage { text: "hi".into() },
},
TimelineEvent {
t: 500,
kind: TimelineEventKind::Thinking { duration: 800 },
},
TimelineEvent {
t: 1300,
kind: TimelineEventKind::StreamText {
text: "Let me check.".into(),
speed: 80,
},
},
TimelineEvent {
t: 2000,
kind: TimelineEventKind::ToolStart {
name: "file_read".into(),
input: serde_json::json!({}),
},
},
TimelineEvent {
t: 12000,
kind: TimelineEventKind::ToolDone {
name: "file_read".into(),
output: "ok".into(),
is_error: false,
},
},
TimelineEvent {
t: 13000,
kind: TimelineEventKind::StreamText {
text: "Done!".into(),
speed: 80,
},
},
TimelineEvent {
t: 14000,
kind: TimelineEventKind::Done,
},
];
let opts = AutoEditOpts {
tool_max_ms: 800,
gap_max_ms: 2000,
think_max_ms: 1200,
response_delay_max_ms: 1000,
};
let edited = auto_edit_timeline(&events, &opts);
assert_eq!(edited.len(), events.len());
let tool_start_t = edited[3].t;
let tool_done_t = edited[4].t;
let tool_span = tool_done_t - tool_start_t;
assert!(
tool_span <= 800,
"Tool span should be compressed to ≤800ms, got {tool_span}ms"
);
assert!(
edited[5].t > tool_done_t,
"Events after tool_done should still be ordered"
);
}
#[test]
fn test_auto_edit_compresses_post_tool_idle_gap() {
let events = vec![
TimelineEvent {
t: 0,
kind: TimelineEventKind::UserMessage { text: "hi".into() },
},
TimelineEvent {
t: 500,
kind: TimelineEventKind::Thinking { duration: 800 },
},
TimelineEvent {
t: 1500,
kind: TimelineEventKind::ToolStart {
name: "selfdev".into(),
input: serde_json::json!({"action": "reload"}),
},
},
TimelineEvent {
t: 2500,
kind: TimelineEventKind::ToolDone {
name: "selfdev".into(),
output: "Reload initiated. Process restarting...".into(),
is_error: false,
},
},
TimelineEvent {
t: 48000,
kind: TimelineEventKind::Thinking { duration: 800 },
},
TimelineEvent {
t: 49000,
kind: TimelineEventKind::StreamText {
text: "Reloaded.".into(),
speed: 80,
},
},
];
let opts = AutoEditOpts::default();
let edited = auto_edit_timeline(&events, &opts);
let tool_done_t = edited[3].t;
let resumed_t = edited[4].t;
let gap = resumed_t - tool_done_t;
assert!(
gap <= opts.response_delay_max_ms,
"Gap after tool completion should be compressed to ≤{}ms, got {gap}ms",
opts.response_delay_max_ms
);
}
#[test]
fn test_auto_edit_compresses_inter_prompt_gaps() {
let events = vec![
TimelineEvent {
t: 0,
kind: TimelineEventKind::UserMessage {
text: "first".into(),
},
},
TimelineEvent {
t: 500,
kind: TimelineEventKind::Thinking { duration: 800 },
},
TimelineEvent {
t: 1500,
kind: TimelineEventKind::StreamText {
text: "response".into(),
speed: 80,
},
},
TimelineEvent {
t: 2000,
kind: TimelineEventKind::Done,
},
TimelineEvent {
t: 30000,
kind: TimelineEventKind::UserMessage {
text: "second".into(),
},
},
TimelineEvent {
t: 30500,
kind: TimelineEventKind::Thinking { duration: 800 },
},
TimelineEvent {
t: 31500,
kind: TimelineEventKind::StreamText {
text: "response2".into(),
speed: 80,
},
},
TimelineEvent {
t: 32000,
kind: TimelineEventKind::Done,
},
];
let opts = AutoEditOpts::default();
let edited = auto_edit_timeline(&events, &opts);
let done_t = edited[3].t;
let next_user_t = edited[4].t;
let gap = next_user_t - done_t;
assert!(
gap <= 2000,
"Gap between turns should be compressed to ≤2000ms, got {gap}ms"
);
let total_original = events.last().unwrap().t;
let total_edited = edited.last().unwrap().t;
assert!(
total_edited < total_original,
"Total time should be shorter: {total_edited} < {total_original}"
);
}
#[test]
fn test_auto_edit_clamps_thinking() {
let events = vec![
TimelineEvent {
t: 0,
kind: TimelineEventKind::UserMessage { text: "hi".into() },
},
TimelineEvent {
t: 500,
kind: TimelineEventKind::Thinking { duration: 5000 },
},
TimelineEvent {
t: 5500,
kind: TimelineEventKind::StreamText {
text: "ok".into(),
speed: 80,
},
},
];
let opts = AutoEditOpts {
think_max_ms: 1200,
..Default::default()
};
let edited = auto_edit_timeline(&events, &opts);
match &edited[1].kind {
TimelineEventKind::Thinking { duration } => {
assert_eq!(*duration, 1200, "Thinking should be clamped to 1200ms");
}
_ => panic!("Expected Thinking event"),
}
}
#[test]
fn test_auto_edit_preserves_already_fast_timeline() {
let events = vec![
TimelineEvent {
t: 0,
kind: TimelineEventKind::UserMessage { text: "hi".into() },
},
TimelineEvent {
t: 200,
kind: TimelineEventKind::Thinking { duration: 500 },
},
TimelineEvent {
t: 700,
kind: TimelineEventKind::StreamText {
text: "hello!".into(),
speed: 80,
},
},
TimelineEvent {
t: 900,
kind: TimelineEventKind::Done,
},
TimelineEvent {
t: 1500,
kind: TimelineEventKind::UserMessage { text: "bye".into() },
},
];
let opts = AutoEditOpts::default();
let edited = auto_edit_timeline(&events, &opts);
for (orig, ed) in events.iter().zip(edited.iter()) {
assert_eq!(orig.t, ed.t, "Fast timeline should not be modified");
}
}
#[test]
fn test_auto_edit_empty_timeline() {
let edited = auto_edit_timeline(&[], &AutoEditOpts::default());
assert!(edited.is_empty());
}
@@ -0,0 +1,241 @@
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
const AUTO_RESTORE_CRASH_MAX_AGE_HOURS: i64 = 24;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RestartSnapshot {
pub version: u32,
pub created_at: DateTime<Utc>,
#[serde(default)]
pub auto_restore_on_next_start: bool,
pub sessions: Vec<RestartSnapshotSession>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RestartSnapshotSession {
pub session_id: String,
pub display_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_dir: Option<String>,
#[serde(default)]
pub is_selfdev: bool,
}
#[derive(Debug, Clone)]
pub struct RestoreLaunchOutcome {
pub session: RestartSnapshotSession,
pub launched: bool,
pub command: String,
}
#[derive(Debug, Clone)]
pub struct RestoreSnapshotResult {
pub snapshot: RestartSnapshot,
pub outcomes: Vec<RestoreLaunchOutcome>,
}
pub fn snapshot_path() -> Result<PathBuf> {
Ok(crate::storage::jcode_dir()?.join("restart-snapshot.json"))
}
pub fn save_current_snapshot() -> Result<RestartSnapshot> {
let snapshot = capture_current_snapshot()?;
write_snapshot(&snapshot)?;
Ok(snapshot)
}
pub fn write_snapshot(snapshot: &RestartSnapshot) -> Result<()> {
crate::storage::write_json(&snapshot_path()?, snapshot)
}
pub fn load_snapshot() -> Result<RestartSnapshot> {
crate::storage::read_json(&snapshot_path()?)
}
pub fn clear_snapshot() -> Result<bool> {
let path = snapshot_path()?;
if !path.exists() {
return Ok(false);
}
std::fs::remove_file(path)?;
Ok(true)
}
pub fn set_auto_restore_on_next_start(enabled: bool) -> Result<bool> {
let mut snapshot = match load_snapshot() {
Ok(snapshot) => snapshot,
Err(_) => return Ok(false),
};
snapshot.auto_restore_on_next_start = enabled;
write_snapshot(&snapshot)?;
Ok(true)
}
pub fn arm_auto_restore_from_recent_crashes() -> Result<Option<RestartSnapshot>> {
let cutoff = Utc::now() - chrono::Duration::hours(AUTO_RESTORE_CRASH_MAX_AGE_HOURS);
let mut unique_ids = HashSet::new();
let mut captured: Vec<(DateTime<Utc>, RestartSnapshotSession)> = Vec::new();
for (session_id, _) in crate::session::find_recent_crashed_sessions() {
if !unique_ids.insert(session_id.clone()) {
continue;
}
let Ok(session) = crate::session::Session::load(&session_id) else {
continue;
};
if !matches!(
session.status,
crate::session::SessionStatus::Crashed { .. }
) {
continue;
}
let sort_key = session.last_active_at.unwrap_or(session.updated_at);
if sort_key < cutoff {
continue;
}
captured.push((
sort_key,
RestartSnapshotSession {
session_id: session.id.clone(),
display_name: session.display_name().to_string(),
working_dir: session.working_dir.clone(),
is_selfdev: session.is_canary,
},
));
}
if captured.is_empty() {
return Ok(None);
}
captured.sort_by(|a, b| {
a.0.cmp(&b.0)
.then_with(|| a.1.display_name.cmp(&b.1.display_name))
.then_with(|| a.1.session_id.cmp(&b.1.session_id))
});
let snapshot = RestartSnapshot {
version: 1,
created_at: Utc::now(),
auto_restore_on_next_start: true,
sessions: captured.into_iter().map(|(_, session)| session).collect(),
};
write_snapshot(&snapshot)?;
Ok(Some(snapshot))
}
pub fn capture_current_snapshot() -> Result<RestartSnapshot> {
let mut unique_ids = HashSet::new();
let mut captured: Vec<(DateTime<Utc>, RestartSnapshotSession)> = Vec::new();
for session_id in crate::storage::active_session_ids() {
if !unique_ids.insert(session_id.clone()) {
continue;
}
let Ok(mut session) = crate::session::Session::load(&session_id) else {
continue;
};
if session.detect_crash() {
let _ = session.save();
continue;
}
if !matches!(session.status, crate::session::SessionStatus::Active) {
continue;
}
let sort_key = session.last_active_at.unwrap_or(session.updated_at);
captured.push((
sort_key,
RestartSnapshotSession {
session_id: session.id.clone(),
display_name: session.display_name().to_string(),
working_dir: session.working_dir.clone(),
is_selfdev: session.is_canary,
},
));
}
captured.sort_by(|a, b| {
a.0.cmp(&b.0)
.then_with(|| a.1.display_name.cmp(&b.1.display_name))
.then_with(|| a.1.session_id.cmp(&b.1.session_id))
});
Ok(RestartSnapshot {
version: 1,
created_at: Utc::now(),
auto_restore_on_next_start: false,
sessions: captured.into_iter().map(|(_, session)| session).collect(),
})
}
pub fn restore_snapshot(exe: &Path) -> Result<RestoreSnapshotResult> {
let snapshot = load_snapshot()?;
let mut outcomes = Vec::new();
for session in &snapshot.sessions {
let cwd = resolve_session_cwd(session.working_dir.as_deref());
let context = crate::session_launch::SessionSpawnContext::kind("restart");
let launched = if session.is_selfdev {
crate::session_launch::spawn_selfdev_in_new_terminal_with_context(
exe,
&session.session_id,
&cwd,
None,
&context,
)?
} else {
crate::session_launch::spawn_resume_in_new_terminal_with_context(
exe,
&session.session_id,
&cwd,
None,
&context,
)?
};
outcomes.push(RestoreLaunchOutcome {
session: session.clone(),
launched,
command: restore_command_display(exe, session),
});
}
Ok(RestoreSnapshotResult { snapshot, outcomes })
}
fn resolve_session_cwd(configured: Option<&str>) -> PathBuf {
configured
.filter(|path| Path::new(path).is_dir())
.map(PathBuf::from)
.or_else(|| std::env::current_dir().ok())
.unwrap_or_else(|| PathBuf::from("."))
}
fn shell_escape(text: &str) -> String {
format!("'{}'", text.replace('\'', "'\"'\"'"))
}
pub fn restore_command_display(exe: &Path, session: &RestartSnapshotSession) -> String {
let exe = shell_escape(exe.to_string_lossy().as_ref());
if session.is_selfdev {
format!("{} --resume {} self-dev", exe, session.session_id)
} else {
format!("{} --resume {}", exe, session.session_id)
}
}
#[cfg(test)]
#[path = "restart_snapshot_tests.rs"]
mod restart_snapshot_tests;
@@ -0,0 +1,179 @@
use super::{
AUTO_RESTORE_CRASH_MAX_AGE_HOURS, arm_auto_restore_from_recent_crashes,
capture_current_snapshot, clear_snapshot, load_snapshot, save_current_snapshot,
};
use crate::session::Session;
use chrono::Utc;
use std::ffi::OsString;
struct TestEnvGuard {
prev_home: Option<OsString>,
_temp_home: tempfile::TempDir,
_lock: std::sync::MutexGuard<'static, ()>,
}
impl TestEnvGuard {
fn new() -> anyhow::Result<Self> {
let lock = crate::storage::lock_test_env();
let temp_home = tempfile::Builder::new()
.prefix("jcode-restart-snapshot-test-home-")
.tempdir()?;
let prev_home = std::env::var_os("JCODE_HOME");
crate::env::set_var("JCODE_HOME", temp_home.path());
Ok(Self {
prev_home,
_temp_home: temp_home,
_lock: lock,
})
}
}
impl Drop for TestEnvGuard {
fn drop(&mut self) {
if let Some(prev_home) = &self.prev_home {
crate::env::set_var("JCODE_HOME", prev_home);
} else {
crate::env::remove_var("JCODE_HOME");
}
}
}
#[test]
fn capture_current_snapshot_includes_active_sessions_only() {
let _guard = TestEnvGuard::new().expect("setup test env");
let mut active = Session::create(None, Some("Active".to_string()));
active.working_dir = Some("/tmp".to_string());
active.mark_active_with_pid(std::process::id());
active.save().expect("save active session");
let mut closed = Session::create(None, Some("Closed".to_string()));
closed.mark_closed();
closed.save().expect("save closed session");
let snapshot = capture_current_snapshot().expect("capture snapshot");
assert_eq!(snapshot.sessions.len(), 1);
assert_eq!(snapshot.sessions[0].session_id, active.id);
assert_eq!(snapshot.sessions[0].working_dir.as_deref(), Some("/tmp"));
}
#[test]
fn save_and_load_snapshot_round_trip() {
let _guard = TestEnvGuard::new().expect("setup test env");
let mut active = Session::create(None, Some("Restore Me".to_string()));
active.mark_active_with_pid(std::process::id());
active.save().expect("save active session");
let saved = save_current_snapshot().expect("save snapshot");
let loaded = load_snapshot().expect("load snapshot");
assert_eq!(saved.sessions.len(), 1);
assert_eq!(loaded.sessions.len(), 1);
assert!(!loaded.auto_restore_on_next_start);
assert_eq!(loaded.sessions[0].session_id, active.id);
}
#[test]
fn set_auto_restore_updates_saved_snapshot() {
let _guard = TestEnvGuard::new().expect("setup test env");
let mut active = Session::create(None, Some("Auto Restore".to_string()));
active.mark_active_with_pid(std::process::id());
active.save().expect("save active session");
save_current_snapshot().expect("save snapshot");
assert!(super::set_auto_restore_on_next_start(true).expect("set auto restore"));
let loaded = load_snapshot().expect("load snapshot");
assert!(loaded.auto_restore_on_next_start);
}
#[test]
fn clear_snapshot_removes_saved_file() {
let _guard = TestEnvGuard::new().expect("setup test env");
let mut active = Session::create(None, Some("Clear Me".to_string()));
active.mark_active_with_pid(std::process::id());
active.save().expect("save active session");
save_current_snapshot().expect("save snapshot");
assert!(clear_snapshot().expect("clear snapshot"));
assert!(load_snapshot().is_err());
}
#[test]
fn arm_auto_restore_from_recent_crashes_captures_dead_active_sessions() {
let _guard = TestEnvGuard::new().expect("setup test env");
let mut child = std::process::Command::new("sh")
.arg("-c")
.arg("exit 0")
.spawn()
.expect("spawn child");
let dead_pid = child.id();
let _ = child.wait().expect("wait for child");
let mut crashed = Session::create_with_id(
"session_auto_restore_crash".to_string(),
None,
Some("Crash Me".to_string()),
);
crashed.working_dir = Some("/tmp".to_string());
crashed.mark_active_with_pid(dead_pid);
crashed.save().expect("save crashed session");
let snapshot = arm_auto_restore_from_recent_crashes()
.expect("arm crash snapshot")
.expect("expected crash snapshot");
assert!(snapshot.auto_restore_on_next_start);
assert_eq!(snapshot.sessions.len(), 1);
assert_eq!(snapshot.sessions[0].session_id, crashed.id);
assert_eq!(snapshot.sessions[0].working_dir.as_deref(), Some("/tmp"));
let persisted = load_snapshot().expect("load persisted snapshot");
assert!(persisted.auto_restore_on_next_start);
assert_eq!(persisted.sessions.len(), 1);
let refreshed = Session::load(&crashed.id).expect("reload crashed session");
assert!(matches!(
refreshed.status,
crate::session::SessionStatus::Crashed { .. }
));
}
#[test]
fn arm_auto_restore_from_recent_crashes_ignores_old_crashes() {
let _guard = TestEnvGuard::new().expect("setup test env");
let mut child = std::process::Command::new("sh")
.arg("-c")
.arg("exit 0")
.spawn()
.expect("spawn child");
let dead_pid = child.id();
let _ = child.wait().expect("wait for child");
let mut crashed = Session::create_with_id(
"session_old_auto_restore_crash".to_string(),
None,
Some("Old Crash".to_string()),
);
let old_ts = Utc::now() - chrono::Duration::hours(AUTO_RESTORE_CRASH_MAX_AGE_HOURS + 2);
crashed.updated_at = old_ts;
crashed.last_active_at = Some(old_ts);
crashed.status = crate::session::SessionStatus::Active;
crashed.last_pid = Some(dead_pid);
crashed.save().expect("save stale active session");
let active_dir = crate::storage::jcode_dir()
.expect("jcode dir")
.join("active_pids");
std::fs::create_dir_all(&active_dir).expect("create active pid dir");
std::fs::write(active_dir.join(&crashed.id), dead_pid.to_string())
.expect("write active pid file");
assert!(
arm_auto_restore_from_recent_crashes()
.expect("arm stale crash snapshot")
.is_none()
);
assert!(load_snapshot().is_err());
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,278 @@
use crate::protocol::{AwaitedMemberStatus, ServerEvent};
use crate::server::durable_state::{
hashed_request_key, load_json_state, now_unix_ms, save_json_state, state_dir,
};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{RwLock, mpsc};
const AWAIT_MEMBERS_DIR: &str = "jcode-await-members";
const FINAL_STATE_TTL: Duration = Duration::from_secs(6 * 60 * 60);
const PENDING_STATE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersistedAwaitMembersResult {
pub completed: bool,
pub members: Vec<AwaitedMemberStatus>,
pub summary: String,
pub resolved_at_unix_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersistedAwaitMembersState {
pub key: String,
pub session_id: String,
pub swarm_id: String,
pub target_status: Vec<String>,
pub requested_ids: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
pub created_at_unix_ms: u64,
pub deadline_unix_ms: u64,
/// When true, the wait runs as a detached background watcher that delivers
/// its result via notify/wake instead of blocking the requesting turn.
/// Background watchers are auto-resumed at server startup after a reload.
#[serde(default)]
pub background: bool,
/// Surface a completion notification card to attached clients.
#[serde(default = "default_true")]
pub notify: bool,
/// Wake an idle requesting agent on completion (or soft-interrupt if busy).
#[serde(default = "default_true")]
pub wake: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub final_response: Option<PersistedAwaitMembersResult>,
}
fn default_true() -> bool {
true
}
impl PersistedAwaitMembersState {
pub fn is_pending(&self) -> bool {
self.final_response.is_none()
}
pub fn remaining_timeout(&self) -> Duration {
let now = now_unix_ms();
Duration::from_millis(self.deadline_unix_ms.saturating_sub(now))
}
}
#[derive(Clone)]
struct AwaitMembersWaiter {
request_id: u64,
client_event_tx: mpsc::UnboundedSender<ServerEvent>,
}
#[derive(Clone, Default)]
pub(crate) struct AwaitMembersRuntime {
active_keys: Arc<RwLock<HashSet<String>>>,
waiters: Arc<RwLock<HashMap<String, Vec<AwaitMembersWaiter>>>>,
}
impl AwaitMembersRuntime {
pub(super) async fn add_waiter(
&self,
key: &str,
request_id: u64,
client_event_tx: &mpsc::UnboundedSender<ServerEvent>,
) {
let mut waiters = self.waiters.write().await;
waiters
.entry(key.to_string())
.or_default()
.push(AwaitMembersWaiter {
request_id,
client_event_tx: client_event_tx.clone(),
});
}
pub(super) async fn mark_active_if_new(&self, key: &str) -> bool {
let mut active = self.active_keys.write().await;
active.insert(key.to_string())
}
pub(super) async fn clear_active(&self, key: &str) {
self.active_keys.write().await.remove(key);
}
pub(super) async fn retain_open_waiters(&self, key: &str) -> usize {
let mut waiters = self.waiters.write().await;
let Some(entries) = waiters.get_mut(key) else {
return 0;
};
entries.retain(|waiter| !waiter.client_event_tx.is_closed());
let remaining = entries.len();
if remaining == 0 {
waiters.remove(key);
}
remaining
}
pub(super) async fn take_waiters(
&self,
key: &str,
) -> Vec<(u64, mpsc::UnboundedSender<ServerEvent>)> {
self.waiters
.write()
.await
.remove(key)
.unwrap_or_default()
.into_iter()
.map(|waiter| (waiter.request_id, waiter.client_event_tx))
.collect()
}
}
fn is_stale(state: &PersistedAwaitMembersState) -> bool {
let now = now_unix_ms();
if let Some(final_response) = &state.final_response {
now.saturating_sub(final_response.resolved_at_unix_ms) > FINAL_STATE_TTL.as_millis() as u64
} else {
now.saturating_sub(state.deadline_unix_ms) > PENDING_STATE_TTL.as_millis() as u64
}
}
pub(super) fn request_key(
session_id: &str,
swarm_id: &str,
requested_ids: &[String],
target_status: &[String],
mode: Option<&str>,
) -> String {
let mut requested = requested_ids.to_vec();
requested.sort();
let mut target = target_status.to_vec();
target.sort();
hashed_request_key(
session_id,
"await_members",
&[
swarm_id.to_string(),
requested.join("\u{1f}"),
target.join("\u{1f}"),
mode.unwrap_or("all").to_string(),
],
)
}
pub(super) fn load_state(key: &str) -> Option<PersistedAwaitMembersState> {
load_json_state(AWAIT_MEMBERS_DIR, key, is_stale)
}
pub(super) fn save_state(state: &PersistedAwaitMembersState) {
save_json_state(AWAIT_MEMBERS_DIR, &state.key, state, "await_members state")
}
#[expect(
clippy::too_many_arguments,
reason = "pending await state mirrors persisted fields and existing call sites"
)]
pub(super) fn ensure_pending_state(
key: &str,
session_id: &str,
swarm_id: &str,
requested_ids: &[String],
target_status: &[String],
mode: Option<&str>,
deadline_unix_ms: u64,
background: bool,
notify: bool,
wake: bool,
) -> PersistedAwaitMembersState {
if let Some(existing) = load_state(key).filter(PersistedAwaitMembersState::is_pending) {
return existing;
}
let state = PersistedAwaitMembersState {
key: key.to_string(),
session_id: session_id.to_string(),
swarm_id: swarm_id.to_string(),
target_status: target_status.to_vec(),
requested_ids: requested_ids.to_vec(),
mode: mode.map(str::to_string),
created_at_unix_ms: now_unix_ms(),
deadline_unix_ms,
background,
notify,
wake,
final_response: None,
};
save_state(&state);
state
}
pub(super) fn persist_final_response(
state: &PersistedAwaitMembersState,
completed: bool,
members: Vec<AwaitedMemberStatus>,
summary: String,
) -> PersistedAwaitMembersState {
let mut next = state.clone();
next.final_response = Some(PersistedAwaitMembersResult {
completed,
members,
summary,
resolved_at_unix_ms: now_unix_ms(),
});
save_state(&next);
next
}
pub fn pending_await_members_for_session(session_id: &str) -> Vec<PersistedAwaitMembersState> {
let mut pending: Vec<PersistedAwaitMembersState> = all_pending_await_members()
.into_iter()
.filter(|state| state.session_id == session_id)
.collect();
pending.sort_by_key(|state| state.deadline_unix_ms);
pending
}
/// Load every still-pending await state across all sessions, pruning stale
/// files as a side effect. Used both for per-session lookups and for resuming
/// backgrounded watchers after a server reload.
pub(super) fn all_pending_await_members() -> Vec<PersistedAwaitMembersState> {
let now = now_unix_ms();
all_pending_await_members_including_expired()
.into_iter()
.filter(|state| state.deadline_unix_ms > now)
.collect()
}
/// Like [`all_pending_await_members`], but also returns pending states whose
/// deadline has already passed (still within the pending TTL). Startup resume
/// uses this so background awaits that expired while the server was down can
/// be finalized with a timeout instead of silently dropping the promised
/// notify/wake.
pub(super) fn all_pending_await_members_including_expired() -> Vec<PersistedAwaitMembersState> {
let dir = state_dir(AWAIT_MEMBERS_DIR);
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut pending = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let Ok(state) = crate::storage::read_json::<PersistedAwaitMembersState>(&path) else {
continue;
};
if is_stale(&state) {
let _ = std::fs::remove_file(path);
continue;
}
if state.is_pending() {
pending.push(state);
}
}
pending
}
@@ -0,0 +1,580 @@
use super::live_turn::{LiveTurnSwarmContext, run_live_turn_if_idle};
use super::state::SwarmEvent;
use super::{
SessionAgents, SessionInterruptQueues, SwarmMember, fanout_session_event,
queue_soft_interrupt_for_session,
};
use crate::message::{
format_background_task_notification_markdown, format_background_task_progress_markdown,
};
use crate::protocol::{NotificationType, ServerEvent};
use jcode_agent_runtime::SoftInterruptSource;
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::Arc;
use std::sync::atomic::AtomicU64;
use tokio::sync::{RwLock, broadcast};
#[expect(
clippy::too_many_arguments,
reason = "background task completion needs session, interrupt, and swarm status state"
)]
pub(super) async fn dispatch_background_task_completion(
task: &crate::bus::BackgroundTaskCompleted,
sessions: &SessionAgents,
soft_interrupt_queues: &SessionInterruptQueues,
swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>,
swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>,
event_history: &Arc<RwLock<VecDeque<SwarmEvent>>>,
event_counter: &Arc<AtomicU64>,
swarm_event_tx: &broadcast::Sender<SwarmEvent>,
) {
let notification = format_background_task_notification_markdown(task);
if task.notify
&& fanout_session_event(
swarm_members,
&task.session_id,
ServerEvent::Notification {
from_session: "background_task".to_string(),
from_name: Some("background task".to_string()),
notification_type: NotificationType::Message {
scope: Some("background_task".to_string()),
channel: None,
tldr: None,
},
message: notification.clone(),
},
)
.await
== 0
{
crate::logging::warn(&format!(
"Failed to notify attached clients for background task completion on session {}",
task.session_id
));
}
if task.wake
&& !run_live_turn_if_idle(
&task.session_id,
&notification,
Some(
"A background task for this session just finished. Review the completion message and continue if useful."
.to_string(),
),
sessions,
LiveTurnSwarmContext::new(
swarm_members,
swarms_by_id,
event_history,
event_counter,
swarm_event_tx,
),
)
.await
&& !queue_soft_interrupt_for_session(
&task.session_id,
notification.clone(),
false,
SoftInterruptSource::BackgroundTask,
soft_interrupt_queues,
sessions,
)
.await
{
crate::logging::warn(&format!(
"Failed to deliver background task completion to session {}",
task.session_id
));
}
}
/// Deliver the result of a backgrounded `swarm await_members` watcher to the
/// requesting session. Mirrors background-task completion delivery: optionally
/// notify attached clients, then wake an idle agent or queue a soft interrupt
/// for a busy one.
#[expect(
clippy::too_many_arguments,
reason = "swarm await completion needs session, interrupt, and swarm status state"
)]
pub(super) async fn dispatch_swarm_await_completion(
event: &crate::bus::SwarmAwaitCompleted,
sessions: &SessionAgents,
soft_interrupt_queues: &SessionInterruptQueues,
swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>,
swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>,
event_history: &Arc<RwLock<VecDeque<SwarmEvent>>>,
event_counter: &Arc<AtomicU64>,
swarm_event_tx: &broadcast::Sender<SwarmEvent>,
) {
if event.notify
&& fanout_session_event(
swarm_members,
&event.session_id,
ServerEvent::Notification {
from_session: "swarm".to_string(),
from_name: Some("swarm await".to_string()),
notification_type: NotificationType::Message {
scope: Some("swarm_await".to_string()),
channel: None,
tldr: None,
},
message: event.notification.clone(),
},
)
.await
== 0
{
crate::logging::warn(&format!(
"Failed to notify attached clients for swarm await completion on session {}",
event.session_id
));
}
if !event.wake {
return;
}
if !run_live_turn_if_idle(
&event.session_id,
&event.notification,
Some(
"A swarm await you started just resolved. Review the result and continue if useful."
.to_string(),
),
sessions,
LiveTurnSwarmContext::new(
swarm_members,
swarms_by_id,
event_history,
event_counter,
swarm_event_tx,
),
)
.await
&& !queue_soft_interrupt_for_session(
&event.session_id,
event.notification.clone(),
false,
SoftInterruptSource::BackgroundTask,
soft_interrupt_queues,
sessions,
)
.await
{
crate::logging::warn(&format!(
"Failed to deliver swarm await completion to session {}",
event.session_id
));
}
}
pub(super) async fn dispatch_background_task_progress(
task: &crate::bus::BackgroundTaskProgressEvent,
swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>,
) {
let notification = format_background_task_progress_markdown(task);
if fanout_session_event(
swarm_members,
&task.session_id,
ServerEvent::Notification {
from_session: "background_task".to_string(),
from_name: Some("background task".to_string()),
notification_type: NotificationType::Message {
scope: Some("background_task".to_string()),
channel: None,
tldr: None,
},
message: notification,
},
)
.await
== 0
{
crate::logging::warn(&format!(
"Failed to notify attached clients for background task progress on session {}",
task.session_id
));
}
}
/// Update a swarm worker's cached output tail and rebroadcast swarm status so
/// the coordinator's inline gallery can render the live viewport. The tail is
/// already capped by the producer; we only store and fan it out.
pub(super) async fn dispatch_swarm_output_tail(
tail: &crate::bus::SwarmOutputTail,
swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>,
swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>,
) {
let swarm_id = {
let mut members = swarm_members.write().await;
let Some(member) = members.get_mut(&tail.session_id) else {
return;
};
member.output_tail = Some(tail.tail.clone());
member.swarm_id.clone()
};
if let Some(swarm_id) = swarm_id {
super::swarm::broadcast_swarm_status(&swarm_id, swarm_members, swarms_by_id).await;
}
}
/// Update a swarm member's aggregate todo progress (completed/total) and a
/// compact snapshot of the items themselves from a `TodoUpdated` bus event,
/// then rebroadcast swarm status so coordinators see the counter move and the
/// focused inline panel can list what the agent is working through. Only the
/// counts and capped display essentials cross the swarm boundary.
pub(super) async fn dispatch_swarm_todo_progress(
event: &crate::bus::TodoEvent,
swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>,
swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>,
) {
let total = event.todos.len() as u32;
let completed = event
.todos
.iter()
.filter(|t| t.status == "completed")
.count() as u32;
let progress = if total == 0 {
None
} else {
Some((completed, total))
};
let mut items = compact_todo_items(&event.todos);
let swarm_id = {
let mut members = swarm_members.write().await;
let Some(member) = members.get_mut(&event.session_id) else {
return;
};
// Keep tool activity attached while the same todo remains active. A
// transition to a different active item starts a fresh intent history.
let old_active = member
.todo_items
.iter()
.find(|item| item.status == "in_progress");
let new_active = items.iter_mut().find(|item| item.status == "in_progress");
if let (Some(old), Some(new)) = (old_active, new_active)
&& old.content == new.content
{
new.tool_intents = old.tool_intents.clone();
}
if member.todo_progress == progress && member.todo_items == items {
return; // no change, skip the broadcast
}
member.todo_progress = progress;
member.todo_items = items;
member.swarm_id.clone()
};
if let Some(swarm_id) = swarm_id {
super::swarm::broadcast_swarm_status(&swarm_id, swarm_members, swarms_by_id).await;
}
}
/// Mirror a worker's three most recent agent-provided tool intents beneath its
/// active todo. Running/completed/error events update the same correlated row.
pub(super) async fn dispatch_swarm_tool_activity(
event: &crate::bus::ToolEvent,
swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>,
swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>,
) {
let swarm_id = {
let mut members = swarm_members.write().await;
let Some(member) = members.get_mut(&event.session_id) else {
return;
};
if !update_active_todo_tool(&mut member.todo_items, event) {
return;
}
member.swarm_id.clone()
};
if let Some(swarm_id) = swarm_id {
super::swarm::broadcast_swarm_status(&swarm_id, swarm_members, swarms_by_id).await;
}
}
pub(super) async fn dispatch_swarm_runtime_status(
event: &crate::bus::SubagentStatus,
swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>,
swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>,
) {
let Some(model) = event
.model
.as_ref()
.filter(|model| !model.trim().is_empty())
else {
return;
};
let swarm_id = {
let mut members = swarm_members.write().await;
let Some(member) = members.get_mut(&event.session_id) else {
return;
};
if member.runtime.model.as_ref() == Some(model) {
return;
}
member.runtime.model = Some(model.clone());
member.swarm_id.clone()
};
if let Some(swarm_id) = swarm_id {
super::swarm::broadcast_swarm_status(&swarm_id, swarm_members, swarms_by_id).await;
}
}
pub(super) async fn dispatch_swarm_batch_progress(
progress: &crate::bus::BatchProgress,
swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>,
swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>,
) {
if progress.total == 0 {
return;
}
let swarm_id = {
let mut members = swarm_members.write().await;
let Some(member) = members.get_mut(&progress.session_id) else {
return;
};
if !update_active_todo_batch_progress(&mut member.todo_items, progress) {
return;
}
member.swarm_id.clone()
};
if let Some(swarm_id) = swarm_id {
super::swarm::broadcast_swarm_status(&swarm_id, swarm_members, swarms_by_id).await;
}
}
fn update_active_todo_batch_progress(
todo_items: &mut [crate::protocol::SwarmTodoItem],
progress: &crate::bus::BatchProgress,
) -> bool {
let Some(tool) = todo_items
.iter_mut()
.find(|todo| todo.status == "in_progress")
.and_then(|todo| {
todo.tool_intents
.iter_mut()
.find(|tool| tool.tool_call_id == progress.tool_call_id)
})
else {
return false;
};
let next = crate::protocol::SwarmToolProgress {
current: progress.completed as u64,
total: progress.total as u64,
unit: Some("tools".to_string()),
};
if tool.progress.as_ref() == Some(&next) {
return false;
}
tool.progress = Some(next);
true
}
fn update_active_todo_tool(
todo_items: &mut [crate::protocol::SwarmTodoItem],
event: &crate::bus::ToolEvent,
) -> bool {
let Some(intent) = event
.intent
.as_deref()
.map(str::trim)
.filter(|intent| !intent.is_empty())
else {
return false;
};
let Some(active) = todo_items
.iter_mut()
.find(|item| item.status == "in_progress")
else {
return false;
};
let status = event.status.as_str().to_string();
if let Some(existing) = active
.tool_intents
.iter_mut()
.find(|tool| tool.tool_call_id == event.tool_call_id)
{
existing.tool_name = event.tool_name.clone();
existing.intent = cap_chars(intent, SWARM_TOOL_INTENT_CAP);
existing.status = status;
} else {
active.tool_intents.push(crate::protocol::SwarmToolIntent {
tool_call_id: event.tool_call_id.clone(),
tool_name: event.tool_name.clone(),
intent: cap_chars(intent, SWARM_TOOL_INTENT_CAP),
status,
progress: None,
});
if active.tool_intents.len() > SWARM_TOOL_INTENTS_CAP {
active
.tool_intents
.drain(..active.tool_intents.len() - SWARM_TOOL_INTENTS_CAP);
}
}
true
}
/// Max todo entries mirrored across the swarm status boundary per member.
const SWARM_TODO_ITEMS_CAP: usize = 12;
/// Max characters per mirrored todo entry.
const SWARM_TODO_CONTENT_CAP: usize = 120;
const SWARM_TOOL_INTENTS_CAP: usize = 3;
const SWARM_TOOL_INTENT_CAP: usize = 120;
/// Build the capped, display-only todo snapshot that crosses the swarm
/// boundary. Prefers showing the active window: everything from the first
/// non-completed item onward, then backfills with the most recent completed
/// items if there is room left in the cap.
fn compact_todo_items(todos: &[crate::todo::TodoItem]) -> Vec<crate::protocol::SwarmTodoItem> {
let first_open = todos
.iter()
.position(|t| t.status != "completed")
.unwrap_or_else(|| todos.len().saturating_sub(SWARM_TODO_ITEMS_CAP));
// Show a little completed context above the active window when possible.
let start = first_open.saturating_sub(2);
todos
.iter()
.skip(start)
.take(SWARM_TODO_ITEMS_CAP)
.map(|t| crate::protocol::SwarmTodoItem {
content: cap_chars(&t.content, SWARM_TODO_CONTENT_CAP),
status: t.status.clone(),
tool_intents: Vec::new(),
})
.collect()
}
fn cap_chars(s: &str, cap: usize) -> String {
if s.chars().count() <= cap {
return s.to_string();
}
let mut out: String = s.chars().take(cap.saturating_sub(1)).collect();
out.push('…');
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bus::{BatchProgress, ToolEvent, ToolStatus};
fn tool(id: &str, intent: &str, status: ToolStatus) -> ToolEvent {
ToolEvent {
session_id: "worker".into(),
message_id: "message".into(),
tool_call_id: id.into(),
tool_name: "bash".into(),
status,
intent: Some(intent.into()),
title: None,
}
}
#[test]
fn active_todo_keeps_last_three_tool_intents_and_updates_status_in_place() {
let mut items = vec![crate::protocol::SwarmTodoItem {
content: "test token refresh flow".into(),
status: "in_progress".into(),
tool_intents: Vec::new(),
}];
for id in ["one", "two", "three", "four"] {
assert!(update_active_todo_tool(
&mut items,
&tool(id, &format!("intent {id}"), ToolStatus::Running),
));
}
let intents = &items[0].tool_intents;
assert_eq!(intents.len(), 3);
assert_eq!(intents[0].tool_call_id, "two");
assert_eq!(intents[2].tool_call_id, "four");
assert!(update_active_todo_tool(
&mut items,
&tool("four", "intent four", ToolStatus::Completed),
));
assert_eq!(items[0].tool_intents.len(), 3);
assert_eq!(items[0].tool_intents[2].status, "completed");
}
#[test]
fn tool_intent_is_ignored_without_an_active_todo() {
let mut items = vec![crate::protocol::SwarmTodoItem {
content: "done".into(),
status: "completed".into(),
tool_intents: Vec::new(),
}];
assert!(!update_active_todo_tool(
&mut items,
&tool("one", "irrelevant", ToolStatus::Running),
));
assert!(items[0].tool_intents.is_empty());
}
#[test]
fn batch_progress_updates_the_correlated_active_tool() {
let mut items = vec![crate::protocol::SwarmTodoItem {
content: "run targeted tests".into(),
status: "in_progress".into(),
tool_intents: vec![crate::protocol::SwarmToolIntent {
tool_call_id: "batch-1".into(),
tool_name: "batch".into(),
intent: "Run targeted authentication tests".into(),
status: "running".into(),
progress: None,
}],
}];
let progress = BatchProgress {
session_id: "worker".into(),
tool_call_id: "batch-1".into(),
completed: 27,
total: 43,
last_completed: Some("read".into()),
running: Vec::new(),
subcalls: Vec::new(),
};
assert!(update_active_todo_batch_progress(&mut items, &progress));
let captured = items[0].tool_intents[0]
.progress
.as_ref()
.expect("progress captured");
assert_eq!((captured.current, captured.total), (27, 43));
assert!(!update_active_todo_batch_progress(&mut items, &progress));
}
}
pub(super) async fn dispatch_ui_activity(
activity: &crate::bus::UiActivity,
swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>,
) {
let Some(session_id) = activity.session_id.as_deref() else {
return;
};
if fanout_session_event(
swarm_members,
session_id,
ServerEvent::Notification {
from_session: "jcode".to_string(),
from_name: Some("Jcode".to_string()),
notification_type: NotificationType::Message {
scope: Some(activity.kind.scope().to_string()),
channel: None,
tldr: None,
},
message: activity.message.clone(),
},
)
.await
== 0
{
crate::logging::warn(&format!(
"Failed to notify attached clients for UI activity on session {}",
session_id
));
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,803 @@
#![cfg_attr(test, allow(clippy::await_holding_lock))]
use super::{
NotifySessionContext, clone_split_session, handle_notify_session, handle_rename_session,
handle_resume_all_sessions, handle_set_feature,
};
use crate::agent::Agent;
use crate::message::{ContentBlock, Message, Role, StreamEvent, ToolDefinition};
use crate::protocol::{FeatureToggle, ServerEvent};
use crate::provider::{EventStream, Provider};
use crate::server::{ClientConnectionInfo, SwarmMember};
use crate::tool::Registry;
use anyhow::Result;
use async_stream::stream;
use async_trait::async_trait;
use std::collections::{HashMap, HashSet, VecDeque};
use std::path::PathBuf;
use std::sync::{Arc, Mutex as StdMutex};
use std::time::Instant;
use tokio::sync::{Mutex, RwLock, mpsc};
use tokio::time::{Duration, timeout};
#[allow(clippy::type_complexity)]
fn empty_swarm_status_state() -> (
Arc<RwLock<HashMap<String, std::collections::HashSet<String>>>>,
Arc<RwLock<std::collections::VecDeque<crate::server::SwarmEvent>>>,
Arc<std::sync::atomic::AtomicU64>,
tokio::sync::broadcast::Sender<crate::server::SwarmEvent>,
) {
let (swarm_event_tx, _) = tokio::sync::broadcast::channel(16);
(
Arc::new(RwLock::new(HashMap::new())),
Arc::new(RwLock::new(std::collections::VecDeque::new())),
Arc::new(std::sync::atomic::AtomicU64::new(0)),
swarm_event_tx,
)
}
struct MockProvider;
#[derive(Clone, Default)]
struct StreamingMockProvider {
responses: Arc<StdMutex<VecDeque<Vec<StreamEvent>>>>,
}
impl StreamingMockProvider {
fn queue_response(&self, events: Vec<StreamEvent>) {
self.responses.lock().unwrap().push_back(events);
}
}
#[async_trait]
impl Provider for MockProvider {
async fn complete(
&self,
_messages: &[crate::message::Message],
_tools: &[crate::message::ToolDefinition],
_system: &str,
_resume_session_id: Option<&str>,
) -> Result<EventStream> {
Err(anyhow::anyhow!(
"mock provider complete should not be called in client_actions tests"
))
}
fn name(&self) -> &str {
"mock"
}
fn fork(&self) -> Arc<dyn Provider> {
Arc::new(MockProvider)
}
}
#[async_trait]
impl Provider for StreamingMockProvider {
async fn complete(
&self,
_messages: &[Message],
_tools: &[ToolDefinition],
_system: &str,
_resume_session_id: Option<&str>,
) -> Result<EventStream> {
let events = self
.responses
.lock()
.unwrap()
.pop_front()
.unwrap_or_default();
let stream = stream! {
for event in events {
yield Ok(event);
}
};
Ok(Box::pin(stream))
}
fn name(&self) -> &str {
"mock"
}
fn fork(&self) -> Arc<dyn Provider> {
Arc::new(self.clone())
}
}
#[test]
fn clone_split_session_uses_persisted_session_state() {
let _guard = crate::storage::lock_test_env();
let temp = tempfile::tempdir().expect("tempdir");
let prev_home = std::env::var_os("JCODE_HOME");
crate::env::set_var("JCODE_HOME", temp.path());
let mut parent = crate::session::Session::create_with_id(
"session_parent_split_test".to_string(),
None,
None,
);
parent.working_dir = Some("/tmp/jcode-split-test".to_string());
parent.model = Some("gpt-test".to_string());
parent.add_message(
Role::User,
vec![ContentBlock::Text {
text: "hello from parent".to_string(),
cache_control: None,
}],
);
parent.compaction = Some(crate::session::StoredCompactionState {
summary_text: "summary".to_string(),
openai_encrypted_content: None,
covers_up_to_turn: 1,
original_turn_count: 1,
compacted_count: 1,
});
parent.save().expect("save parent");
let (child_id, _child_name) = clone_split_session(&parent.id).expect("clone split");
let child = crate::session::Session::load(&child_id).expect("load child");
assert_eq!(child.parent_id.as_deref(), Some(parent.id.as_str()));
assert_eq!(
child.messages.len(),
parent.messages.len() + 1,
"fork should inherit the transcript plus one fork notice"
);
assert_eq!(
child.messages[0].content_preview(),
parent.messages[0].content_preview()
);
let fork_notice = child.messages.last().expect("fork notice message");
assert_eq!(
fork_notice.display_role,
Some(crate::session::StoredDisplayRole::System),
"fork notice must be hidden from the visible transcript"
);
let fork_notice_text = fork_notice.content_preview();
assert!(
fork_notice_text.contains("forked") && fork_notice_text.contains(parent.id.as_str()),
"fork notice should mention the parent session: {fork_notice_text}"
);
assert_eq!(child.compaction, parent.compaction);
assert_eq!(child.working_dir, parent.working_dir);
assert_eq!(child.model, parent.model);
assert_eq!(child.status, crate::session::SessionStatus::Closed);
assert_ne!(child.id, parent.id);
if let Some(prev_home) = prev_home {
crate::env::set_var("JCODE_HOME", prev_home);
} else {
crate::env::remove_var("JCODE_HOME");
}
}
#[tokio::test]
async fn enabling_swarm_does_not_auto_elect_coordinator() {
let provider: Arc<dyn Provider> = Arc::new(MockProvider);
let registry = Registry::new(provider.clone()).await;
let agent = Arc::new(Mutex::new(Agent::new(provider, registry)));
let (member_event_tx, _member_event_rx) = mpsc::unbounded_channel();
let now = Instant::now();
let session_id = "session_test_swarm_toggle";
let swarm_members = Arc::new(RwLock::new(HashMap::from([(
session_id.to_string(),
crate::server::SwarmMember {
session_id: session_id.to_string(),
event_tx: member_event_tx,
event_txs: HashMap::new(),
working_dir: Some(PathBuf::from("/tmp/jcode-passive-swarm")),
swarm_id: None,
swarm_enabled: false,
status: "ready".to_string(),
detail: None,
task_label: None,
friendly_name: Some("duck".to_string()),
report_back_to_session_id: None,
latest_completion_report: None,
role: "agent".to_string(),
joined_at: now,
last_status_change: now,
is_headless: false,
output_tail: None,
todo_progress: None,
todo_items: Vec::new(),
runtime: crate::protocol::SwarmMemberRuntime::default(),
},
)])));
let swarms_by_id = Arc::new(RwLock::new(HashMap::<String, HashSet<String>>::new()));
let swarm_coordinators = Arc::new(RwLock::new(HashMap::<String, String>::new()));
let channel_subscriptions = Arc::new(RwLock::new(HashMap::<
String,
HashMap<String, HashSet<String>>,
>::new()));
let channel_subscriptions_by_session = Arc::new(RwLock::new(HashMap::<
String,
HashMap<String, HashSet<String>>,
>::new()));
let swarm_plans = Arc::new(RwLock::new(HashMap::new()));
let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel();
let mut swarm_enabled = false;
handle_set_feature(
42,
FeatureToggle::Swarm,
true,
&agent,
session_id,
&Some("duck".to_string()),
&mut swarm_enabled,
&swarm_members,
&swarms_by_id,
&swarm_coordinators,
&channel_subscriptions,
&channel_subscriptions_by_session,
&swarm_plans,
&client_event_tx,
)
.await;
assert!(swarm_enabled);
assert!(swarm_coordinators.read().await.is_empty());
assert_eq!(
swarm_members
.read()
.await
.get(session_id)
.and_then(|member| member.swarm_id.clone())
.as_deref(),
Some("/tmp/jcode-passive-swarm")
);
assert_eq!(
swarm_members
.read()
.await
.get(session_id)
.map(|member| member.role.as_str()),
Some("agent")
);
let events: Vec<_> = std::iter::from_fn(|| client_event_rx.try_recv().ok()).collect();
assert!(
events
.iter()
.any(|event| matches!(event, ServerEvent::Done { id: 42 }))
);
assert!(events.iter().all(|event| {
!matches!(
event,
ServerEvent::Notification { message, .. }
if message == "You are the coordinator for this swarm."
)
}));
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn rename_session_event_uses_agent_session_id_even_when_client_id_is_stale() {
let _guard = crate::storage::lock_test_env();
let temp = tempfile::tempdir().expect("tempdir");
let prev_home = std::env::var_os("JCODE_HOME");
crate::env::set_var("JCODE_HOME", temp.path());
let provider: Arc<dyn Provider> = Arc::new(MockProvider);
let registry = Registry::new(provider.clone()).await;
let agent = Arc::new(Mutex::new(Agent::new(provider, registry)));
let agent_session_id = agent.lock().await.session_id().to_string();
let stale_client_session_id = "session_stale_client_id";
let (member_event_tx, mut member_event_rx) = mpsc::unbounded_channel();
let now = Instant::now();
let swarm_members = Arc::new(RwLock::new(HashMap::from([(
stale_client_session_id.to_string(),
SwarmMember {
session_id: stale_client_session_id.to_string(),
event_tx: member_event_tx,
event_txs: HashMap::new(),
working_dir: None,
swarm_id: None,
swarm_enabled: false,
status: "ready".to_string(),
detail: None,
task_label: None,
friendly_name: Some("stale".to_string()),
report_back_to_session_id: None,
latest_completion_report: None,
role: "agent".to_string(),
joined_at: now,
last_status_change: now,
is_headless: false,
output_tail: None,
todo_progress: None,
todo_items: Vec::new(),
runtime: crate::protocol::SwarmMemberRuntime::default(),
},
)])));
let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel();
handle_rename_session(
99,
Some("Release planning".to_string()),
&agent,
stale_client_session_id,
&swarm_members,
&client_event_tx,
)
.await;
let rename_event = timeout(Duration::from_secs(2), member_event_rx.recv())
.await
.expect("rename event should arrive")
.expect("member event channel should stay open");
match rename_event {
ServerEvent::SessionRenamed {
session_id,
title,
display_title,
} => {
assert_eq!(session_id, agent_session_id);
assert_eq!(title.as_deref(), Some("Release planning"));
assert_eq!(display_title, "Release planning");
}
other => panic!("expected SessionRenamed, got {other:?}"),
}
let client_events: Vec<_> = std::iter::from_fn(|| client_event_rx.try_recv().ok()).collect();
assert!(
client_events
.iter()
.any(|event| matches!(event, ServerEvent::Done { id } if *id == 99))
);
let loaded = crate::session::Session::load(&agent_session_id).expect("renamed session saved");
assert_eq!(loaded.custom_title.as_deref(), Some("Release planning"));
if let Some(prev_home) = prev_home {
crate::env::set_var("JCODE_HOME", prev_home);
} else {
crate::env::remove_var("JCODE_HOME");
}
}
#[tokio::test]
async fn notify_session_runs_scheduled_task_immediately_for_idle_live_session() {
let provider = Arc::new(StreamingMockProvider::default());
provider.queue_response(vec![
StreamEvent::TextDelta("Working on scheduled task.".to_string()),
StreamEvent::MessageEnd { stop_reason: None },
]);
let provider_dyn: Arc<dyn Provider> = provider.clone();
let registry = Registry::new(provider_dyn.clone()).await;
let agent = Arc::new(Mutex::new(Agent::new(provider_dyn, registry)));
let session_id = agent.lock().await.session_id().to_string();
let sessions = Arc::new(RwLock::new(HashMap::<String, Arc<Mutex<Agent>>>::from([(
session_id.clone(),
agent.clone(),
)])));
let soft_interrupt_queues = Arc::new(RwLock::new(HashMap::new()));
let client_connections = Arc::new(RwLock::new(HashMap::from([(
"client-1".to_string(),
ClientConnectionInfo {
client_id: "client-1".to_string(),
session_id: session_id.clone(),
client_instance_id: None,
debug_client_id: Some("debug-1".to_string()),
connected_at: Instant::now(),
last_seen: Instant::now(),
is_processing: false,
current_tool_name: None,
terminal_env: Vec::new(),
disconnect_tx: mpsc::unbounded_channel().0,
},
)])));
let (member_event_tx, mut member_event_rx) = mpsc::unbounded_channel();
let swarm_members = Arc::new(RwLock::new(HashMap::from([(
session_id.clone(),
SwarmMember {
session_id: session_id.clone(),
event_tx: member_event_tx,
event_txs: HashMap::new(),
working_dir: None,
swarm_id: None,
swarm_enabled: false,
status: "ready".to_string(),
detail: None,
task_label: None,
friendly_name: Some("otter".to_string()),
report_back_to_session_id: None,
latest_completion_report: None,
role: "agent".to_string(),
joined_at: Instant::now(),
last_status_change: Instant::now(),
is_headless: false,
output_tail: None,
todo_progress: None,
todo_items: Vec::new(),
runtime: crate::protocol::SwarmMemberRuntime::default(),
},
)])));
let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel();
let (swarms_by_id, event_history, event_counter, swarm_event_tx) = empty_swarm_status_state();
handle_notify_session(
77,
session_id.clone(),
"[Scheduled task]\nTask: Follow up".to_string(),
NotifySessionContext {
sessions: &sessions,
soft_interrupt_queues: &soft_interrupt_queues,
client_connections: &client_connections,
swarm_members: &swarm_members,
swarms_by_id: &swarms_by_id,
event_history: &event_history,
event_counter: &event_counter,
swarm_event_tx: &swarm_event_tx,
client_event_tx: &client_event_tx,
},
)
.await;
let streamed_event = timeout(Duration::from_secs(2), async {
loop {
match member_event_rx.recv().await {
Some(ServerEvent::TextDelta { text })
if text.contains("Working on scheduled task.") =>
{
return text;
}
Some(_) => continue,
None => panic!("live member stream closed before scheduled task ran"),
}
}
})
.await
.expect("scheduled task should start streaming promptly");
assert!(streamed_event.contains("Working on scheduled task."));
let client_events: Vec<_> = std::iter::from_fn(|| client_event_rx.try_recv().ok()).collect();
assert!(
client_events
.iter()
.any(|event| matches!(event, ServerEvent::Done { id } if *id == 77))
);
let guard = agent.lock().await;
assert!(guard.messages().iter().any(|message| {
message.role == Role::User
&& message
.content_preview()
.contains("[Scheduled task] Task: Follow up")
}));
assert!(guard.messages().iter().any(|message| {
message.role == Role::Assistant
&& message
.content_preview()
.contains("Working on scheduled task.")
}));
}
#[tokio::test]
async fn notify_session_queues_soft_interrupt_when_live_session_is_busy() {
let provider: Arc<dyn Provider> = Arc::new(MockProvider);
let registry = Registry::new(provider.clone()).await;
let agent = Arc::new(Mutex::new(Agent::new(provider, registry)));
let session_id = agent.lock().await.session_id().to_string();
let queue = agent.lock().await.soft_interrupt_queue();
let sessions = Arc::new(RwLock::new(HashMap::<String, Arc<Mutex<Agent>>>::from([(
session_id.clone(),
agent.clone(),
)])));
let soft_interrupt_queues = Arc::new(RwLock::new(HashMap::from([(
session_id.clone(),
queue.clone(),
)])));
let client_connections = Arc::new(RwLock::new(HashMap::from([(
"client-1".to_string(),
ClientConnectionInfo {
client_id: "client-1".to_string(),
session_id: session_id.clone(),
client_instance_id: None,
debug_client_id: Some("debug-1".to_string()),
connected_at: Instant::now(),
last_seen: Instant::now(),
is_processing: false,
current_tool_name: None,
terminal_env: Vec::new(),
disconnect_tx: mpsc::unbounded_channel().0,
},
)])));
let (member_event_tx, mut member_event_rx) = mpsc::unbounded_channel();
let swarm_members = Arc::new(RwLock::new(HashMap::from([(
session_id.clone(),
SwarmMember {
session_id: session_id.clone(),
event_tx: member_event_tx,
event_txs: HashMap::new(),
working_dir: None,
swarm_id: None,
swarm_enabled: false,
status: "running".to_string(),
detail: None,
task_label: None,
friendly_name: Some("otter".to_string()),
report_back_to_session_id: None,
latest_completion_report: None,
role: "agent".to_string(),
joined_at: Instant::now(),
last_status_change: Instant::now(),
is_headless: false,
output_tail: None,
todo_progress: None,
todo_items: Vec::new(),
runtime: crate::protocol::SwarmMemberRuntime::default(),
},
)])));
let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel();
let _busy_guard = agent.lock().await;
let (swarms_by_id, event_history, event_counter, swarm_event_tx) = empty_swarm_status_state();
handle_notify_session(
88,
session_id.clone(),
"[Scheduled task]\nTask: Follow up while busy".to_string(),
NotifySessionContext {
sessions: &sessions,
soft_interrupt_queues: &soft_interrupt_queues,
client_connections: &client_connections,
swarm_members: &swarm_members,
swarms_by_id: &swarms_by_id,
event_history: &event_history,
event_counter: &event_counter,
swarm_event_tx: &swarm_event_tx,
client_event_tx: &client_event_tx,
},
)
.await;
let member_event = timeout(Duration::from_secs(2), member_event_rx.recv())
.await
.expect("notification should arrive promptly")
.expect("live member should receive notification");
match member_event {
ServerEvent::Notification {
from_session,
from_name,
message,
..
} => {
assert_eq!(from_session, "schedule");
assert_eq!(from_name.as_deref(), Some("scheduled task"));
assert!(message.contains("Task: Follow up while busy"));
}
other => panic!("expected notification event, got {other:?}"),
}
let queued = queue.lock().unwrap();
assert_eq!(
queued.len(),
1,
"scheduled task should queue as soft interrupt"
);
assert!(queued[0].content.contains("Task: Follow up while busy"));
drop(queued);
let client_events: Vec<_> = std::iter::from_fn(|| client_event_rx.try_recv().ok()).collect();
assert!(
client_events
.iter()
.any(|event| matches!(event, ServerEvent::Done { id } if *id == 88))
);
}
/// Build a live SwarmMember with a real client attachment so the resume-all
/// sweep treats it as live. Returns the member and the receiver for events
/// fanned out to that attachment.
fn live_member(session_id: &str) -> (SwarmMember, mpsc::UnboundedReceiver<ServerEvent>) {
let (attach_tx, attach_rx) = mpsc::unbounded_channel();
let member = SwarmMember {
session_id: session_id.to_string(),
event_tx: mpsc::unbounded_channel().0,
event_txs: HashMap::from([("client-1".to_string(), attach_tx)]),
working_dir: None,
swarm_id: None,
swarm_enabled: false,
status: "ready".to_string(),
detail: None,
task_label: None,
friendly_name: Some("otter".to_string()),
report_back_to_session_id: None,
latest_completion_report: None,
role: "agent".to_string(),
joined_at: Instant::now(),
last_status_change: Instant::now(),
is_headless: false,
output_tail: None,
todo_progress: None,
todo_items: Vec::new(),
runtime: crate::protocol::SwarmMemberRuntime::default(),
};
(member, attach_rx)
}
#[tokio::test]
async fn resume_all_continues_interrupted_idle_live_session() {
let _guard = crate::storage::lock_test_env();
let temp = tempfile::tempdir().expect("tempdir");
let prev_home = std::env::var_os("JCODE_HOME");
crate::env::set_var("JCODE_HOME", temp.path());
let provider = Arc::new(StreamingMockProvider::default());
provider.queue_response(vec![
StreamEvent::TextDelta("Continuing where I left off.".to_string()),
StreamEvent::MessageEnd { stop_reason: None },
]);
let provider_dyn: Arc<dyn Provider> = provider.clone();
let registry = Registry::new(provider_dyn.clone()).await;
let agent = Arc::new(Mutex::new(Agent::new(provider_dyn, registry)));
let session_id = {
let mut guard = agent.lock().await;
// Leave the session with a pending user turn the assistant never answered
// (simulating a turn that errored / was interrupted mid-generation).
guard.add_message(
Role::User,
vec![ContentBlock::Text {
text: "please keep going on the refactor".to_string(),
cache_control: None,
}],
);
guard.session_id().to_string()
};
let sessions = Arc::new(RwLock::new(HashMap::<String, Arc<Mutex<Agent>>>::from([(
session_id.clone(),
agent.clone(),
)])));
let (member, mut attach_rx) = live_member(&session_id);
let swarm_members = Arc::new(RwLock::new(HashMap::from([(session_id.clone(), member)])));
let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel();
let (swarms_by_id, event_history, event_counter, swarm_event_tx) = empty_swarm_status_state();
handle_resume_all_sessions(
91,
&sessions,
&swarm_members,
&swarms_by_id,
&event_history,
&event_counter,
&swarm_event_tx,
&client_event_tx,
)
.await;
// The session should resume and stream the continuation.
let streamed = timeout(Duration::from_secs(2), async {
loop {
match attach_rx.recv().await {
Some(ServerEvent::TextDelta { text })
if text.contains("Continuing where I left off.") =>
{
return text;
}
Some(_) => continue,
None => panic!("live attachment closed before continuation streamed"),
}
}
})
.await
.expect("interrupted session should resume promptly");
assert!(streamed.contains("Continuing where I left off."));
// The requesting client receives a summary describing one resumed session.
let result = timeout(Duration::from_secs(2), async {
loop {
match client_event_rx.recv().await {
Some(event @ ServerEvent::ResumeAllResult { .. }) => return event,
Some(_) => continue,
None => panic!("client channel closed before resume-all result"),
}
}
})
.await
.expect("resume-all result should be emitted");
match result {
ServerEvent::ResumeAllResult {
id,
resumed,
skipped,
..
} => {
assert_eq!(id, 91);
assert_eq!(resumed, 1);
assert_eq!(skipped, 0);
}
other => panic!("expected ResumeAllResult, got {other:?}"),
}
if let Some(home) = prev_home {
crate::env::set_var("JCODE_HOME", home);
} else {
crate::env::remove_var("JCODE_HOME");
}
}
#[tokio::test]
async fn resume_all_skips_session_with_completed_turn() {
let _guard = crate::storage::lock_test_env();
let temp = tempfile::tempdir().expect("tempdir");
let prev_home = std::env::var_os("JCODE_HOME");
crate::env::set_var("JCODE_HOME", temp.path());
let provider: Arc<dyn Provider> = Arc::new(MockProvider);
let registry = Registry::new(provider.clone()).await;
let agent = Arc::new(Mutex::new(Agent::new(provider, registry)));
let session_id = {
let mut guard = agent.lock().await;
// A completed turn: last visible message is from the assistant.
guard.add_message(
Role::User,
vec![ContentBlock::Text {
text: "do the thing".to_string(),
cache_control: None,
}],
);
guard.add_message(
Role::Assistant,
vec![ContentBlock::Text {
text: "done".to_string(),
cache_control: None,
}],
);
guard.session_id().to_string()
};
let sessions = Arc::new(RwLock::new(HashMap::<String, Arc<Mutex<Agent>>>::from([(
session_id.clone(),
agent.clone(),
)])));
let (member, _attach_rx) = live_member(&session_id);
let swarm_members = Arc::new(RwLock::new(HashMap::from([(session_id.clone(), member)])));
let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel();
let (swarms_by_id, event_history, event_counter, swarm_event_tx) = empty_swarm_status_state();
handle_resume_all_sessions(
92,
&sessions,
&swarm_members,
&swarms_by_id,
&event_history,
&event_counter,
&swarm_event_tx,
&client_event_tx,
)
.await;
let result = timeout(Duration::from_secs(2), async {
loop {
match client_event_rx.recv().await {
Some(event @ ServerEvent::ResumeAllResult { .. }) => return event,
Some(_) => continue,
None => panic!("client channel closed before resume-all result"),
}
}
})
.await
.expect("resume-all result should be emitted");
match result {
ServerEvent::ResumeAllResult {
id,
resumed,
skipped,
..
} => {
assert_eq!(id, 92);
assert_eq!(resumed, 0);
assert_eq!(skipped, 1);
}
other => panic!("expected ResumeAllResult, got {other:?}"),
}
if let Some(home) = prev_home {
crate::env::set_var("JCODE_HOME", home);
} else {
crate::env::remove_var("JCODE_HOME");
}
}
@@ -0,0 +1,340 @@
use super::{connect_socket, debug_socket_path, socket_path};
use crate::protocol::{HistoryMessage, Request, ServerEvent, TranscriptMode};
use crate::transport::{ReadHalf, WriteHalf};
use anyhow::Result;
use std::path::PathBuf;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
/// Client for connecting to a running server
pub struct Client {
reader: BufReader<ReadHalf>,
writer: WriteHalf,
next_id: u64,
}
impl Client {
pub async fn connect() -> Result<Self> {
Self::connect_with_path(socket_path()).await
}
pub async fn connect_with_path(path: PathBuf) -> Result<Self> {
let stream = connect_socket(&path).await?;
let (reader, writer) = stream.into_split();
Ok(Self {
reader: BufReader::new(reader),
writer,
next_id: 1,
})
}
pub async fn connect_debug() -> Result<Self> {
Self::connect_debug_with_path(debug_socket_path()).await
}
pub async fn connect_debug_with_path(path: PathBuf) -> Result<Self> {
let stream = connect_socket(&path).await?;
let (reader, writer) = stream.into_split();
Ok(Self {
reader: BufReader::new(reader),
writer,
next_id: 1,
})
}
/// Send a message and return immediately (events come via read_event)
pub async fn send_message(&mut self, content: &str) -> Result<u64> {
let id = self.next_id;
self.next_id += 1;
let request = Request::Message {
id,
content: content.to_string(),
images: vec![],
system_reminder: None,
};
let json = serde_json::to_string(&request)? + "\n";
self.writer.write_all(json.as_bytes()).await?;
Ok(id)
}
/// Subscribe to events
pub async fn subscribe(&mut self) -> Result<u64> {
self.subscribe_with_info(None, None, None, false, false)
.await
}
pub async fn subscribe_with_info(
&mut self,
working_dir: Option<String>,
selfdev: Option<bool>,
target_session_id: Option<String>,
client_has_local_history: bool,
allow_session_takeover: bool,
) -> Result<u64> {
let id = self.next_id;
self.next_id += 1;
let working_dir = match working_dir {
Some(working_dir) => working_dir,
None => std::env::current_dir()?.to_string_lossy().into_owned(),
};
let request = Request::Subscribe {
id,
working_dir: Some(working_dir),
selfdev,
target_session_id,
client_instance_id: None,
client_has_local_history,
allow_session_takeover,
terminal_env: crate::terminal_launch::snapshot_client_terminal_env(),
};
let json = serde_json::to_string(&request)? + "\n";
self.writer.write_all(json.as_bytes()).await?;
Ok(id)
}
/// Read the next event from the server
pub async fn read_event(&mut self) -> Result<ServerEvent> {
let mut line = String::new();
let n = self.reader.read_line(&mut line).await?;
if n == 0 {
anyhow::bail!("Server disconnected");
}
let event: ServerEvent = serde_json::from_str(&line)?;
Ok(event)
}
pub async fn ping(&mut self) -> Result<bool> {
let id = self.next_id;
self.next_id += 1;
let request = Request::Ping { id };
let json = serde_json::to_string(&request)? + "\n";
self.writer.write_all(json.as_bytes()).await?;
loop {
let mut line = String::new();
let n = self.reader.read_line(&mut line).await?;
if n == 0 {
anyhow::bail!("Server disconnected");
}
let event: ServerEvent = serde_json::from_str(&line)?;
match event {
ServerEvent::Pong { id: pong_id } => return Ok(pong_id == id),
ServerEvent::Ack { id: ack_id } if ack_id == id => continue,
ServerEvent::Error { id: error_id, .. } if error_id == id => return Ok(false),
_ => return Ok(false),
}
}
}
pub async fn get_state(&mut self) -> Result<ServerEvent> {
let id = self.next_id;
self.next_id += 1;
let request = Request::GetState { id };
let json = serde_json::to_string(&request)? + "\n";
self.writer.write_all(json.as_bytes()).await?;
let mut line = String::new();
let n = self.reader.read_line(&mut line).await?;
if n == 0 {
anyhow::bail!("Server disconnected");
}
let event: ServerEvent = serde_json::from_str(&line)?;
Ok(event)
}
pub async fn clear(&mut self) -> Result<()> {
let id = self.next_id;
self.next_id += 1;
let request = Request::Clear { id };
let json = serde_json::to_string(&request)? + "\n";
self.writer.write_all(json.as_bytes()).await?;
Ok(())
}
pub async fn get_history(&mut self) -> Result<Vec<HistoryMessage>> {
let event = self.get_history_event().await?;
match event {
ServerEvent::History { messages, .. } => Ok(messages),
_ => Ok(Vec::new()),
}
}
pub async fn get_history_event(&mut self) -> Result<ServerEvent> {
let id = self.next_id;
self.next_id += 1;
let request = Request::GetHistory { id };
let json = serde_json::to_string(&request)? + "\n";
self.writer.write_all(json.as_bytes()).await?;
for _ in 0..10 {
let mut line = String::new();
let n = self.reader.read_line(&mut line).await?;
if n == 0 {
anyhow::bail!("Server disconnected");
}
let event: ServerEvent = serde_json::from_str(&line)?;
match event {
ServerEvent::Ack { .. } => continue,
_ => return Ok(event),
}
}
Ok(ServerEvent::Error {
id,
message: "History response not received".to_string(),
retry_after_secs: None,
})
}
pub async fn resume_session(&mut self, session_id: &str) -> Result<u64> {
self.resume_session_with_options(session_id, false, false)
.await
}
pub async fn resume_session_with_options(
&mut self,
session_id: &str,
client_has_local_history: bool,
allow_session_takeover: bool,
) -> Result<u64> {
let id = self.next_id;
self.next_id += 1;
let request = Request::ResumeSession {
id,
session_id: session_id.to_string(),
client_instance_id: None,
client_has_local_history,
allow_session_takeover,
};
let json = serde_json::to_string(&request)? + "\n";
self.writer.write_all(json.as_bytes()).await?;
Ok(id)
}
pub async fn notify_session(&mut self, session_id: &str, message: &str) -> Result<u64> {
let id = self.next_id;
self.next_id += 1;
let request = Request::NotifySession {
id,
session_id: session_id.to_string(),
message: message.to_string(),
};
let json = serde_json::to_string(&request)? + "\n";
self.writer.write_all(json.as_bytes()).await?;
Ok(id)
}
/// Ask the server to continue every live session that was interrupted and
/// would auto-resume on a reload. Returns the request id so callers can
/// correlate the `ResumeAllResult` event.
pub async fn resume_all_sessions(&mut self) -> Result<u64> {
let id = self.next_id;
self.next_id += 1;
let request = Request::ResumeAllSessions { id };
let json = serde_json::to_string(&request)? + "\n";
self.writer.write_all(json.as_bytes()).await?;
Ok(id)
}
pub async fn send_transcript(
&mut self,
text: &str,
mode: TranscriptMode,
session_id: Option<String>,
) -> Result<u64> {
let id = self.next_id;
self.next_id += 1;
let request = Request::Transcript {
id,
text: text.to_string(),
mode,
session_id,
};
let json = serde_json::to_string(&request)? + "\n";
self.writer.write_all(json.as_bytes()).await?;
Ok(id)
}
pub async fn reload(&mut self) -> Result<()> {
self.reload_with_force(true).await?;
Ok(())
}
/// Request a graceful, conditional reload: when `force` is false the server
/// only reloads if it has a strictly-newer reload candidate binary. Used by
/// `jcode server reload` so an upgrade is picked up without risking a
/// downgrade. Returns the request id so callers can correlate events.
pub async fn reload_with_force(&mut self, force: bool) -> Result<u64> {
let id = self.next_id;
self.next_id += 1;
let request = Request::Reload { id, force };
let json = serde_json::to_string(&request)? + "\n";
self.writer.write_all(json.as_bytes()).await?;
Ok(id)
}
pub async fn cycle_model(&mut self, direction: i8) -> Result<u64> {
let id = self.next_id;
self.next_id += 1;
let request = Request::CycleModel { id, direction };
let json = serde_json::to_string(&request)? + "\n";
self.writer.write_all(json.as_bytes()).await?;
Ok(id)
}
pub async fn refresh_models(&mut self) -> Result<u64> {
let id = self.next_id;
self.next_id += 1;
let request = Request::RefreshModels { id };
let json = serde_json::to_string(&request)? + "\n";
self.writer.write_all(json.as_bytes()).await?;
Ok(id)
}
pub async fn notify_auth_changed(&mut self) -> Result<u64> {
self.notify_auth_changed_for_provider(None).await
}
pub async fn notify_auth_changed_for_provider(
&mut self,
provider: Option<&str>,
) -> Result<u64> {
let id = self.next_id;
self.next_id += 1;
let request = Request::NotifyAuthChanged {
id,
provider: provider.map(str::to_string),
auth: None,
};
let json = serde_json::to_string(&request)? + "\n";
self.writer.write_all(json.as_bytes()).await?;
Ok(id)
}
pub async fn debug_command(&mut self, command: &str, session_id: Option<&str>) -> Result<u64> {
let id = self.next_id;
self.next_id += 1;
let request = Request::DebugCommand {
id,
command: command.to_string(),
session_id: session_id.map(|s| s.to_string()),
};
let json = serde_json::to_string(&request)? + "\n";
self.writer.write_all(json.as_bytes()).await?;
Ok(id)
}
}
@@ -0,0 +1,12 @@
pub(super) use super::client_comm_channels::{
handle_comm_channel_members, handle_comm_list_channels, handle_comm_subscribe_channel,
handle_comm_unsubscribe_channel,
};
pub(super) use super::client_comm_context::{
handle_comm_list, handle_comm_read, handle_comm_share,
};
pub(super) use super::client_comm_message::handle_comm_message;
#[cfg(test)]
#[path = "client_comm_tests.rs"]
mod client_comm_tests;
@@ -0,0 +1,283 @@
use super::{
SwarmEvent, SwarmEventType, SwarmMember, record_swarm_event, subscribe_session_to_channel,
unsubscribe_session_from_channel,
};
use crate::protocol::{AgentInfo, ServerEvent, SwarmChannelInfo};
use jcode_swarm_core::ChannelIndex;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use tokio::sync::{RwLock, broadcast, mpsc};
type ChannelSubscriptions = Arc<RwLock<HashMap<String, HashMap<String, HashSet<String>>>>>;
async fn swarm_id_for_session(
session_id: &str,
swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>,
) -> Option<String> {
let members = swarm_members.read().await;
members.get(session_id).and_then(|m| m.swarm_id.clone())
}
pub(super) async fn handle_comm_list_channels(
id: u64,
req_session_id: String,
client_event_tx: &mpsc::UnboundedSender<ServerEvent>,
swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>,
channel_subscriptions: &ChannelSubscriptions,
) {
let swarm_id = swarm_id_for_session(&req_session_id, swarm_members).await;
if let Some(swarm_id) = swarm_id {
let channels = {
let subs = channel_subscriptions.read().await;
let index = ChannelIndex {
by_swarm_channel: subs.clone(),
by_session: HashMap::new(),
};
let mut channels: Vec<SwarmChannelInfo> = Vec::new();
if let Some(swarm_channels) = index.by_swarm_channel.get(&swarm_id) {
for (channel, members) in swarm_channels {
channels.push(SwarmChannelInfo {
channel: channel.clone(),
member_count: members.len(),
});
}
}
channels.sort_by(|left, right| left.channel.cmp(&right.channel));
channels
};
let _ = client_event_tx.send(ServerEvent::CommChannels { id, channels });
} else {
let _ = client_event_tx.send(ServerEvent::Error {
id,
message: "Not in a swarm. Use a git repository to enable swarm features.".to_string(),
retry_after_secs: None,
});
}
}
pub(super) async fn handle_comm_channel_members(
id: u64,
req_session_id: String,
channel: String,
client_event_tx: &mpsc::UnboundedSender<ServerEvent>,
swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>,
channel_subscriptions: &ChannelSubscriptions,
) {
let swarm_id = swarm_id_for_session(&req_session_id, swarm_members).await;
if let Some(swarm_id) = swarm_id {
let member_ids: Vec<String> = {
let subs = channel_subscriptions.read().await;
let index = ChannelIndex {
by_swarm_channel: subs.clone(),
by_session: HashMap::new(),
};
index.members(&swarm_id, &channel)
};
let members = swarm_members.read().await;
let entries: Vec<AgentInfo> = member_ids
.iter()
.filter_map(|sid: &String| {
members.get(sid).map(|member| AgentInfo {
session_id: sid.clone(),
friendly_name: member.friendly_name.clone(),
files_touched: Vec::new(),
status: Some(member.status.clone()),
detail: member.detail.clone(),
role: Some(member.role.clone()),
is_headless: Some(member.is_headless),
report_back_to_session_id: member.report_back_to_session_id.clone(),
latest_completion_report: member.latest_completion_report.clone(),
live_attachments: Some(member.event_txs.len()),
status_age_secs: Some(member.last_status_change.elapsed().as_secs()),
last_activity_age_secs: crate::session_metrics::last_activity_age_secs(sid),
..Default::default()
})
})
.collect();
let _ = client_event_tx.send(ServerEvent::CommMembers {
id,
members: entries,
});
} else {
let _ = client_event_tx.send(ServerEvent::Error {
id,
message: "Not in a swarm. Use a git repository to enable swarm features.".to_string(),
retry_after_secs: None,
});
}
}
#[expect(
clippy::too_many_arguments,
reason = "channel subscribe updates membership, delivery, and swarm event history together"
)]
pub(super) async fn handle_comm_subscribe_channel(
id: u64,
req_session_id: String,
channel: String,
client_event_tx: &mpsc::UnboundedSender<ServerEvent>,
swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>,
channel_subscriptions: &ChannelSubscriptions,
channel_subscriptions_by_session: &ChannelSubscriptions,
event_history: &Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>,
event_counter: &Arc<std::sync::atomic::AtomicU64>,
swarm_event_tx: &broadcast::Sender<SwarmEvent>,
) {
let started = std::time::Instant::now();
let swarm_id = swarm_id_for_session(&req_session_id, swarm_members).await;
if let Some(swarm_id) = swarm_id {
crate::logging::event_info(
"COMM_LIFECYCLE",
vec![
("phase", "channel_subscribe_start".to_string()),
("request_id", id.to_string()),
("session_id", req_session_id.clone()),
("swarm_id", swarm_id.clone()),
("channel", channel.clone()),
],
);
subscribe_session_to_channel(
&req_session_id,
&swarm_id,
&channel,
channel_subscriptions,
channel_subscriptions_by_session,
)
.await;
record_swarm_event(
event_history,
event_counter,
swarm_event_tx,
req_session_id.clone(),
None,
Some(swarm_id.clone()),
SwarmEventType::Notification {
notification_type: "channel_subscribe".to_string(),
message: channel.clone(),
},
)
.await;
let _ = client_event_tx.send(ServerEvent::Done { id });
crate::logging::event_info(
"COMM_LIFECYCLE",
vec![
("phase", "channel_subscribe_done".to_string()),
("request_id", id.to_string()),
("session_id", req_session_id),
("swarm_id", swarm_id),
("channel", channel),
("elapsed_ms", started.elapsed().as_millis().to_string()),
],
);
} else {
crate::logging::event_warn(
"COMM_LIFECYCLE",
vec![
("phase", "channel_subscribe_error".to_string()),
("request_id", id.to_string()),
("session_id", req_session_id),
("channel", channel),
("error", "not_in_swarm".to_string()),
("elapsed_ms", started.elapsed().as_millis().to_string()),
],
);
let _ = client_event_tx.send(ServerEvent::Error {
id,
message: "Not in a swarm.".to_string(),
retry_after_secs: None,
});
}
}
#[expect(
clippy::too_many_arguments,
reason = "channel unsubscribe updates membership, delivery, and swarm event history together"
)]
pub(super) async fn handle_comm_unsubscribe_channel(
id: u64,
req_session_id: String,
channel: String,
client_event_tx: &mpsc::UnboundedSender<ServerEvent>,
swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>,
channel_subscriptions: &ChannelSubscriptions,
channel_subscriptions_by_session: &ChannelSubscriptions,
event_history: &Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>,
event_counter: &Arc<std::sync::atomic::AtomicU64>,
swarm_event_tx: &broadcast::Sender<SwarmEvent>,
) {
let started = std::time::Instant::now();
let swarm_id = swarm_id_for_session(&req_session_id, swarm_members).await;
if let Some(swarm_id) = swarm_id {
crate::logging::event_info(
"COMM_LIFECYCLE",
vec![
("phase", "channel_unsubscribe_start".to_string()),
("request_id", id.to_string()),
("session_id", req_session_id.clone()),
("swarm_id", swarm_id.clone()),
("channel", channel.clone()),
],
);
unsubscribe_session_from_channel(
&req_session_id,
&swarm_id,
&channel,
channel_subscriptions,
channel_subscriptions_by_session,
)
.await;
record_swarm_event(
event_history,
event_counter,
swarm_event_tx,
req_session_id.clone(),
None,
Some(swarm_id.clone()),
SwarmEventType::Notification {
notification_type: "channel_unsubscribe".to_string(),
message: channel.clone(),
},
)
.await;
let _ = client_event_tx.send(ServerEvent::Done { id });
crate::logging::event_info(
"COMM_LIFECYCLE",
vec![
("phase", "channel_unsubscribe_done".to_string()),
("request_id", id.to_string()),
("session_id", req_session_id),
("swarm_id", swarm_id),
("channel", channel),
("elapsed_ms", started.elapsed().as_millis().to_string()),
],
);
} else {
crate::logging::event_warn(
"COMM_LIFECYCLE",
vec![
("phase", "channel_unsubscribe_error".to_string()),
("request_id", id.to_string()),
("session_id", req_session_id),
("channel", channel),
("error", "not_in_swarm".to_string()),
("elapsed_ms", started.elapsed().as_millis().to_string()),
],
);
let _ = client_event_tx.send(ServerEvent::Error {
id,
message: "Not in a swarm.".to_string(),
retry_after_secs: None,
});
}
}
@@ -0,0 +1,336 @@
use super::debug::ClientConnectionInfo;
use super::{
FileTouchService, SharedContext, SwarmEvent, SwarmEventType, SwarmMember, fanout_session_event,
record_swarm_event,
};
use crate::protocol::{AgentInfo, ContextEntry, NotificationType, ServerEvent};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::{RwLock, broadcast, mpsc};
async fn swarm_id_for_session(
session_id: &str,
swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>,
) -> Option<String> {
let members = swarm_members.read().await;
members.get(session_id).and_then(|m| m.swarm_id.clone())
}
async fn friendly_name_for_session(
session_id: &str,
swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>,
) -> Option<String> {
let members = swarm_members.read().await;
members
.get(session_id)
.and_then(|member| member.friendly_name.clone())
}
#[expect(
clippy::too_many_arguments,
reason = "comm share coordinates delivery state, sessions, swarm membership, and event fanout"
)]
pub(super) async fn handle_comm_share(
id: u64,
req_session_id: String,
key: String,
value: String,
append: bool,
client_event_tx: &mpsc::UnboundedSender<ServerEvent>,
swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>,
swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>,
shared_context: &Arc<RwLock<HashMap<String, HashMap<String, SharedContext>>>>,
event_history: &Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>,
event_counter: &Arc<std::sync::atomic::AtomicU64>,
swarm_event_tx: &broadcast::Sender<SwarmEvent>,
) {
let swarm_id = swarm_id_for_session(&req_session_id, swarm_members).await;
if let Some(swarm_id) = swarm_id {
let friendly_name = friendly_name_for_session(&req_session_id, swarm_members).await;
{
let mut ctx = shared_context.write().await;
let swarm_ctx = ctx.entry(swarm_id.clone()).or_insert_with(HashMap::new);
let now = Instant::now();
let created_at = swarm_ctx.get(&key).map(|c| c.created_at).unwrap_or(now);
let stored_value = if append {
swarm_ctx
.get(&key)
.map(|existing| {
if existing.value.is_empty() {
value.clone()
} else {
format!("{}\n{}", existing.value, value)
}
})
.unwrap_or_else(|| value.clone())
} else {
value.clone()
};
swarm_ctx.insert(
key.clone(),
SharedContext {
key: key.clone(),
value: stored_value.clone(),
from_session: req_session_id.clone(),
from_name: friendly_name.clone(),
created_at,
updated_at: now,
},
);
}
let swarm_session_ids: Vec<String> = {
let swarms = swarms_by_id.read().await;
swarms
.get(&swarm_id)
.map(|sessions| sessions.iter().cloned().collect())
.unwrap_or_default()
};
// Shared-context updates are subtree-scoped like broadcasts: notify only
// the sessions the writer (transitively) spawned, so a share cannot
// become a member-cap-sized notification storm. The coordinator keeps
// whole-swarm reach. Everyone can still `read` the key on demand.
//
// Compute the target set up front and drop the read guard before the
// fanout loop: `fanout_session_event` takes a write lock on members.
let notify_targets: Vec<String> = {
let members = swarm_members.read().await;
let sender_is_coordinator = members
.get(&req_session_id)
.is_some_and(|member| member.role == "coordinator");
swarm_session_ids
.iter()
.filter(|sid| *sid != &req_session_id)
.filter(|sid| {
sender_is_coordinator
|| super::swarm_is_self_or_ancestor(&members, &req_session_id, sid)
})
.cloned()
.collect()
};
for sid in &notify_targets {
{
let _ = fanout_session_event(
swarm_members,
sid,
ServerEvent::Notification {
from_session: req_session_id.clone(),
from_name: friendly_name.clone(),
notification_type: NotificationType::SharedContext {
key: key.clone(),
value: if append {
format!("(appended) {}", value)
} else {
value.clone()
},
},
message: if append {
format!("Appended shared context: {} += {}", key, value)
} else {
format!("Shared context: {} = {}", key, value)
},
},
)
.await;
}
}
record_swarm_event(
event_history,
event_counter,
swarm_event_tx,
req_session_id.clone(),
friendly_name.clone(),
Some(swarm_id.clone()),
SwarmEventType::ContextUpdate {
swarm_id: swarm_id.clone(),
key: key.clone(),
},
)
.await;
let _ = client_event_tx.send(ServerEvent::Done { id });
} else {
let _ = client_event_tx.send(ServerEvent::Error {
id,
message: "Not in a swarm. Use a git repository to enable swarm features.".to_string(),
retry_after_secs: None,
});
}
}
pub(super) async fn handle_comm_read(
id: u64,
req_session_id: String,
key: Option<String>,
client_event_tx: &mpsc::UnboundedSender<ServerEvent>,
swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>,
shared_context: &Arc<RwLock<HashMap<String, HashMap<String, SharedContext>>>>,
) {
let swarm_id = swarm_id_for_session(&req_session_id, swarm_members).await;
let entries = if let Some(swarm_id) = swarm_id {
let ctx = shared_context.read().await;
if let Some(swarm_ctx) = ctx.get(&swarm_id) {
if let Some(k) = key {
swarm_ctx
.get(&k)
.map(|c| {
vec![ContextEntry {
key: c.key.clone(),
value: c.value.clone(),
from_session: c.from_session.clone(),
from_name: c.from_name.clone(),
}]
})
.unwrap_or_default()
} else {
swarm_ctx
.values()
.map(|c| ContextEntry {
key: c.key.clone(),
value: c.value.clone(),
from_session: c.from_session.clone(),
from_name: c.from_name.clone(),
})
.collect()
}
} else {
Vec::new()
}
} else {
Vec::new()
};
let _ = client_event_tx.send(ServerEvent::CommContext { id, entries });
}
#[expect(
clippy::too_many_arguments,
reason = "comm list joins swarm membership, file touches, live sessions, and connection activity"
)]
pub(super) async fn handle_comm_list(
id: u64,
req_session_id: String,
client_event_tx: &mpsc::UnboundedSender<ServerEvent>,
swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>,
swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>,
file_touch: &FileTouchService,
sessions: &super::SessionAgents,
client_connections: &Arc<RwLock<HashMap<String, ClientConnectionInfo>>>,
) {
let swarm_id = swarm_id_for_session(&req_session_id, swarm_members).await;
if let Some(swarm_id) = swarm_id {
let swarm_session_ids: Vec<String> = {
let swarms = swarms_by_id.read().await;
swarms
.get(&swarm_id)
.map(|sessions| sessions.iter().cloned().collect())
.unwrap_or_default()
};
// Snapshot the static member fields first, releasing the members lock
// before gathering per-session runtime extras (which briefly lock
// individual agents and read the connection map).
struct MemberStatic {
session_id: String,
friendly_name: Option<String>,
files: Vec<String>,
status: String,
detail: Option<String>,
task_label: Option<String>,
role: String,
is_headless: bool,
report_back_to_session_id: Option<String>,
latest_completion_report: Option<String>,
live_attachments: usize,
status_age_secs: u64,
}
let statics: Vec<MemberStatic> = {
let members = swarm_members.read().await;
let touches = file_touch.reverse_snapshot().await;
swarm_session_ids
.iter()
.filter_map(|sid| {
members.get(sid).map(|member| {
let mut files: Vec<String> = touches
.get(sid)
.into_iter()
.flat_map(|paths| paths.iter())
.map(|path| path.display().to_string())
.collect();
files.sort();
MemberStatic {
session_id: sid.clone(),
friendly_name: member.friendly_name.clone(),
files,
status: member.status.clone(),
detail: member.detail.clone(),
task_label: member.task_label.clone(),
role: member.role.clone(),
is_headless: member.is_headless,
report_back_to_session_id: member.report_back_to_session_id.clone(),
latest_completion_report: member.latest_completion_report.clone(),
live_attachments: member.event_txs.len(),
status_age_secs: member.last_status_change.elapsed().as_secs(),
}
})
})
.collect()
};
let mut member_list: Vec<AgentInfo> = Vec::with_capacity(statics.len());
for m in statics {
let extras = super::comm_sync::member_runtime_extras(
&m.session_id,
m.status == "running",
sessions,
client_connections,
)
.await;
member_list.push(AgentInfo {
session_id: m.session_id,
friendly_name: m.friendly_name,
files_touched: m.files,
status: Some(m.status),
detail: m.detail,
task_label: m.task_label,
role: Some(m.role),
is_headless: Some(m.is_headless),
report_back_to_session_id: m.report_back_to_session_id,
latest_completion_report: m.latest_completion_report,
live_attachments: Some(m.live_attachments),
status_age_secs: Some(m.status_age_secs),
last_activity_age_secs: extras.last_activity_age_secs,
activity: extras.activity,
provider_name: extras.provider_name,
provider_model: extras.provider_model,
turn_count: extras.turn_count,
recent_total_tokens: extras.recent_total_tokens,
recent_output_tokens: extras.recent_output_tokens,
recent_window_secs: extras.recent_window_secs,
cumulative_total_tokens: extras.cumulative_total_tokens,
todos_completed: extras.todos_completed,
todos_total: extras.todos_total,
});
}
let _ = client_event_tx.send(ServerEvent::CommMembers {
id,
members: member_list,
});
} else {
let _ = client_event_tx.send(ServerEvent::Error {
id,
message: "Not in a swarm. Use a git repository to enable swarm features.".to_string(),
retry_after_secs: None,
});
}
}
@@ -0,0 +1,422 @@
use super::live_turn::{LiveTurnSwarmContext, run_live_turn_if_idle};
use super::{
ClientConnectionInfo, SessionInterruptQueues, SwarmEvent, SwarmEventType, SwarmMember,
fanout_session_event, queue_soft_interrupt_for_session, record_swarm_event, truncate_detail,
};
use crate::agent::Agent;
use crate::protocol::{CommDeliveryMode, NotificationType, ServerEvent};
use jcode_agent_runtime::SoftInterruptSource;
use jcode_swarm_core::ChannelIndex;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use tokio::sync::{Mutex, RwLock, broadcast, mpsc};
type SessionAgents = Arc<RwLock<HashMap<String, Arc<Mutex<Agent>>>>>;
type ChannelSubscriptions = Arc<RwLock<HashMap<String, HashMap<String, HashSet<String>>>>>;
async fn swarm_id_for_session(
session_id: &str,
swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>,
) -> Option<String> {
let members = swarm_members.read().await;
members.get(session_id).and_then(|m| m.swarm_id.clone())
}
async fn friendly_name_for_session(
session_id: &str,
swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>,
) -> Option<String> {
let members = swarm_members.read().await;
members
.get(session_id)
.and_then(|member| member.friendly_name.clone())
}
async fn resolve_dm_target_session(
target: &str,
swarm_session_ids: &[String],
swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>,
) -> anyhow::Result<String> {
if swarm_session_ids
.iter()
.any(|session_id| session_id == target)
{
return Ok(target.to_string());
}
let members = swarm_members.read().await;
let mut matches: Vec<(String, String)> = swarm_session_ids
.iter()
.filter_map(|session_id| {
let member = members.get(session_id)?;
member
.friendly_name
.as_deref()
.filter(|friendly_name| *friendly_name == target)
.map(|friendly_name| (session_id.clone(), friendly_name.to_string()))
})
.collect();
matches.sort_by(|(left_session, _), (right_session, _)| left_session.cmp(right_session));
matches.dedup_by(|(left_session, _), (right_session, _)| left_session == right_session);
match matches.len() {
1 => Ok(matches.remove(0).0),
0 => Err(anyhow::anyhow!(
"Unknown target '{}' - use an exact session_id or a unique friendly name within the swarm.",
target
)),
_ => {
let match_list = matches
.iter()
.map(|(session_id, friendly_name)| format!("{} [{}]", friendly_name, session_id))
.collect::<Vec<_>>()
.join(", ");
Err(anyhow::anyhow!(
"Friendly name '{}' is ambiguous in swarm. Use an exact session id instead. Matches: {}",
target,
match_list
))
}
}
}
fn resolve_comm_delivery_mode(
scope: &str,
delivery: Option<CommDeliveryMode>,
wake: Option<bool>,
) -> CommDeliveryMode {
if let Some(delivery) = delivery {
return delivery;
}
if wake.unwrap_or(false) {
return CommDeliveryMode::Wake;
}
match scope {
"dm" => CommDeliveryMode::Wake,
_ => CommDeliveryMode::Notify,
}
}
#[expect(
clippy::too_many_arguments,
reason = "comm message routes DM, channel, and broadcast delivery with session fanout state"
)]
pub(super) async fn handle_comm_message(
id: u64,
from_session: String,
message: String,
to_session: Option<String>,
channel: Option<String>,
delivery: Option<CommDeliveryMode>,
wake: Option<bool>,
tldr: Option<String>,
client_event_tx: &mpsc::UnboundedSender<ServerEvent>,
sessions: &SessionAgents,
soft_interrupt_queues: &SessionInterruptQueues,
swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>,
swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>,
channel_subscriptions: &ChannelSubscriptions,
event_history: &Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>,
event_counter: &Arc<std::sync::atomic::AtomicU64>,
swarm_event_tx: &broadcast::Sender<SwarmEvent>,
_client_connections: &Arc<RwLock<HashMap<String, ClientConnectionInfo>>>,
) {
let started = std::time::Instant::now();
crate::logging::event_info(
"COMM_LIFECYCLE",
vec![
("phase", "message_start".to_string()),
("request_id", id.to_string()),
("from_session", from_session.clone()),
(
"to_session",
to_session.clone().unwrap_or_else(|| "none".to_string()),
),
(
"channel",
channel.clone().unwrap_or_else(|| "none".to_string()),
),
(
"delivery",
delivery
.map(|mode| format!("{:?}", mode))
.unwrap_or_else(|| "default".to_string()),
),
("wake", wake.unwrap_or(false).to_string()),
("message_chars", message.chars().count().to_string()),
(
"tldr_chars",
tldr.as_deref()
.map(|t| t.chars().count())
.unwrap_or(0)
.to_string(),
),
],
);
let swarm_id = swarm_id_for_session(&from_session, swarm_members).await;
if let Some(swarm_id) = swarm_id {
let friendly_name = friendly_name_for_session(&from_session, swarm_members).await;
let swarm_session_ids: Vec<String> = {
let swarms = swarms_by_id.read().await;
swarms
.get(&swarm_id)
.map(|sessions| sessions.iter().cloned().collect())
.unwrap_or_default()
};
let resolved_to_session = if let Some(ref target) = to_session {
match resolve_dm_target_session(target, &swarm_session_ids, swarm_members).await {
Ok(session_id) => Some(session_id),
Err(message) => {
crate::logging::event_warn(
"COMM_LIFECYCLE",
vec![
("phase", "message_resolve_error".to_string()),
("request_id", id.to_string()),
("from_session", from_session.clone()),
("target", target.clone()),
("error", message.to_string()),
("elapsed_ms", started.elapsed().as_millis().to_string()),
],
);
let _ = client_event_tx.send(ServerEvent::Error {
id,
message: message.to_string(),
retry_after_secs: None,
});
return;
}
}
} else {
None
};
if let Some(ref target) = resolved_to_session
&& !swarm_session_ids.contains(target)
{
crate::logging::event_warn(
"COMM_LIFECYCLE",
vec![
("phase", "message_target_not_in_swarm".to_string()),
("request_id", id.to_string()),
("from_session", from_session.clone()),
("target_session", target.clone()),
("swarm_id", swarm_id.clone()),
("elapsed_ms", started.elapsed().as_millis().to_string()),
],
);
let _ = client_event_tx.send(ServerEvent::Error {
id,
message: format!("DM failed: session '{}' not in swarm", target),
retry_after_secs: None,
});
return;
}
let scope = if resolved_to_session.is_some() {
"dm"
} else if channel.is_some() {
"channel"
} else {
"broadcast"
};
let known_member_ids: std::collections::HashSet<String> = {
let members = swarm_members.read().await;
members.keys().cloned().collect()
};
// Broadcast-style sends are subtree-scoped: a sender reaches only the
// agents it (transitively) spawned, via the report-back ancestry chain.
// The swarm coordinator keeps whole-swarm reach as an escape hatch.
// This prevents one agent from producing a member-cap-sized
// notification storm (see docs/SWARM_TASK_GRAPH.md section 8a).
let subtree_broadcast_targets: Vec<String> = {
let members = swarm_members.read().await;
let sender_is_coordinator = members
.get(&from_session)
.is_some_and(|member| member.role == "coordinator");
swarm_session_ids
.iter()
.filter(|session_id| *session_id != &from_session)
.filter(|session_id| {
sender_is_coordinator
|| super::swarm_is_self_or_ancestor(&members, &from_session, session_id)
})
.cloned()
.collect()
};
let target_sessions: Vec<String> = if let Some(target) = resolved_to_session {
vec![target]
} else if let Some(ref channel_name) = channel {
let subs = channel_subscriptions.read().await;
let index = ChannelIndex {
by_swarm_channel: subs.clone(),
by_session: HashMap::new(),
};
let channel_members = index.members(&swarm_id, channel_name);
if channel_members.is_empty() {
// No subscribers: fall back to the subtree scope rather than
// blasting the whole swarm.
subtree_broadcast_targets.clone()
} else {
channel_members
.into_iter()
.filter(|session_id| session_id != &from_session)
.collect()
}
} else {
subtree_broadcast_targets
};
let mut delivered_targets = 0usize;
for session_id in &target_sessions {
if !swarm_session_ids.contains(session_id) {
continue;
}
if known_member_ids.contains(session_id) {
let from_label = friendly_name
.clone()
.unwrap_or_else(|| from_session[..8.min(from_session.len())].to_string());
let scope_label = match (scope, channel.as_deref()) {
("channel", Some(channel_name)) => format!("#{}", channel_name),
("dm", _) => "DM".to_string(),
_ => "broadcast".to_string(),
};
let delivery_mode = resolve_comm_delivery_mode(scope, delivery, wake);
let notification_msg = format!("{} from {}: {}", scope_label, from_label, message);
let _ = fanout_session_event(
swarm_members,
session_id,
ServerEvent::Notification {
from_session: from_session.clone(),
from_name: friendly_name.clone(),
notification_type: NotificationType::Message {
scope: Some(scope.to_string()),
channel: channel.clone(),
tldr: tldr.clone(),
},
message: notification_msg.clone(),
},
)
.await;
let sender_name = friendly_name
.clone()
.unwrap_or_else(|| from_session.clone());
let reminder = match scope {
"dm" => Some(format!(
"You just received a direct swarm message from {}. Review it and respond or act if useful.",
sender_name
)),
"channel" => Some(format!(
"You just received a swarm channel message in #{} from {}. Review it and respond or act if useful.",
channel.clone().unwrap_or_else(|| "channel".to_string()),
sender_name
)),
_ => Some(format!(
"You just received a swarm broadcast from {}. Review it and respond or act if useful.",
sender_name
)),
};
match delivery_mode {
CommDeliveryMode::Notify => {}
CommDeliveryMode::Interrupt => {
let _ = queue_soft_interrupt_for_session(
session_id,
notification_msg.clone(),
false,
SoftInterruptSource::System,
soft_interrupt_queues,
sessions,
)
.await;
}
CommDeliveryMode::Wake => {
let woke_immediately = run_live_turn_if_idle(
session_id,
&notification_msg,
reminder,
sessions,
LiveTurnSwarmContext::new(
swarm_members,
swarms_by_id,
event_history,
event_counter,
swarm_event_tx,
),
)
.await;
if !woke_immediately {
let _ = queue_soft_interrupt_for_session(
session_id,
notification_msg.clone(),
false,
SoftInterruptSource::System,
soft_interrupt_queues,
sessions,
)
.await;
}
}
}
delivered_targets += 1;
}
}
let scope_value = if scope == "channel" {
format!("#{}", channel.clone().unwrap_or_default())
} else {
scope.to_string()
};
record_swarm_event(
event_history,
event_counter,
swarm_event_tx,
from_session.clone(),
friendly_name.clone(),
Some(swarm_id.clone()),
SwarmEventType::Notification {
notification_type: scope_value,
message: truncate_detail(&message, 220),
},
)
.await;
let _ = client_event_tx.send(ServerEvent::Done { id });
crate::logging::event_info(
"COMM_LIFECYCLE",
vec![
("phase", "message_done".to_string()),
("request_id", id.to_string()),
("from_session", from_session),
("swarm_id", swarm_id),
("scope", scope.to_string()),
("channel", channel.unwrap_or_else(|| "none".to_string())),
("target_count", target_sessions.len().to_string()),
("delivered_targets", delivered_targets.to_string()),
("elapsed_ms", started.elapsed().as_millis().to_string()),
],
);
} else {
crate::logging::event_warn(
"COMM_LIFECYCLE",
vec![
("phase", "message_error".to_string()),
("request_id", id.to_string()),
("from_session", from_session),
("error", "not_in_swarm".to_string()),
("elapsed_ms", started.elapsed().as_millis().to_string()),
],
);
let _ = client_event_tx.send(ServerEvent::Error {
id,
message: "Not in a swarm. Use a git repository to enable swarm features.".to_string(),
retry_after_secs: None,
});
}
}
@@ -0,0 +1,903 @@
use super::{handle_comm_list, handle_comm_message};
use crate::agent::Agent;
use crate::message::{Message, ToolDefinition};
use crate::protocol::{CommDeliveryMode, NotificationType, ServerEvent};
use crate::provider::{EventStream, Provider};
use crate::server::{ClientConnectionInfo, SessionInterruptQueues, SwarmEvent, SwarmMember};
use crate::tool::Registry;
use anyhow::Result;
use async_trait::async_trait;
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, atomic::AtomicU64};
use std::time::{Duration, Instant};
use tokio::sync::{Mutex, RwLock, broadcast, mpsc};
struct TestProvider;
#[async_trait]
impl Provider for TestProvider {
async fn complete(
&self,
_messages: &[Message],
_tools: &[ToolDefinition],
_system: &str,
_resume_session_id: Option<&str>,
) -> Result<EventStream> {
Err(anyhow::anyhow!(
"test provider complete should not be called in client_comm tests"
))
}
fn name(&self) -> &str {
"test"
}
fn fork(&self) -> Arc<dyn Provider> {
Arc::new(TestProvider)
}
}
async fn test_agent() -> Arc<Mutex<Agent>> {
let provider: Arc<dyn Provider> = Arc::new(TestProvider);
let registry = Registry::new(provider.clone()).await;
Arc::new(Mutex::new(Agent::new(provider, registry)))
}
#[tokio::test]
async fn comm_message_default_does_not_queue_soft_interrupt_for_connected_session() {
let sender = test_agent().await;
let target = test_agent().await;
let sender_id = sender.lock().await.session_id().to_string();
let target_id = target.lock().await.session_id().to_string();
let target_queue = target.lock().await.soft_interrupt_queue();
let sessions = Arc::new(RwLock::new(HashMap::from([
(sender_id.clone(), sender.clone()),
(target_id.clone(), target.clone()),
])));
let soft_interrupt_queues: SessionInterruptQueues = Arc::new(RwLock::new(HashMap::new()));
let (sender_event_tx, _sender_event_rx) = mpsc::unbounded_channel();
let (target_event_tx, mut target_event_rx) = mpsc::unbounded_channel();
let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel();
let swarm_id = "swarm-test".to_string();
let swarm_members = Arc::new(RwLock::new(HashMap::from([
(
sender_id.clone(),
SwarmMember {
session_id: sender_id.clone(),
event_tx: sender_event_tx,
event_txs: HashMap::new(),
working_dir: None,
swarm_id: Some(swarm_id.clone()),
swarm_enabled: true,
status: "ready".to_string(),
detail: None,
friendly_name: Some("falcon".to_string()),
report_back_to_session_id: None,
latest_completion_report: None,
role: "coordinator".to_string(),
joined_at: Instant::now(),
last_status_change: Instant::now(),
is_headless: false,
output_tail: None,
todo_progress: None,
todo_items: Vec::new(),
runtime: crate::protocol::SwarmMemberRuntime::default(),
task_label: None,
},
),
(
target_id.clone(),
SwarmMember {
session_id: target_id.clone(),
event_tx: target_event_tx,
event_txs: HashMap::new(),
working_dir: None,
swarm_id: Some(swarm_id.clone()),
swarm_enabled: true,
status: "ready".to_string(),
detail: None,
friendly_name: Some("bear".to_string()),
report_back_to_session_id: None,
latest_completion_report: None,
role: "agent".to_string(),
joined_at: Instant::now(),
last_status_change: Instant::now(),
is_headless: false,
output_tail: None,
todo_progress: None,
todo_items: Vec::new(),
runtime: crate::protocol::SwarmMemberRuntime::default(),
task_label: None,
},
),
])));
let swarms_by_id = Arc::new(RwLock::new(HashMap::from([(
swarm_id.clone(),
HashSet::from([sender_id.clone(), target_id.clone()]),
)])));
let channel_subscriptions = Arc::new(RwLock::new(HashMap::from([(
swarm_id.clone(),
HashMap::from([(
"religion-debate".to_string(),
HashSet::from([target_id.clone()]),
)]),
)])));
let event_history: Arc<RwLock<std::collections::VecDeque<SwarmEvent>>> =
Arc::new(RwLock::new(std::collections::VecDeque::new()));
let event_counter = Arc::new(AtomicU64::new(0));
let (swarm_event_tx, _) = broadcast::channel(16);
let client_connections = Arc::new(RwLock::new(HashMap::from([(
"client-1".to_string(),
ClientConnectionInfo {
client_id: "client-1".to_string(),
session_id: target_id.clone(),
client_instance_id: None,
debug_client_id: None,
connected_at: Instant::now(),
last_seen: Instant::now(),
is_processing: false,
current_tool_name: None,
terminal_env: Vec::new(),
disconnect_tx: mpsc::unbounded_channel().0,
},
)])));
handle_comm_message(
1,
sender_id.clone(),
"hello".to_string(),
None,
Some("religion-debate".to_string()),
None,
None,
None,
&client_event_tx,
&sessions,
&soft_interrupt_queues,
&swarm_members,
&swarms_by_id,
&channel_subscriptions,
&event_history,
&event_counter,
&swarm_event_tx,
&client_connections,
)
.await;
match target_event_rx.recv().await.expect("target notification") {
ServerEvent::Notification {
from_session,
from_name,
notification_type,
message,
} => {
assert_eq!(from_session, sender_id);
assert_eq!(from_name.as_deref(), Some("falcon"));
match notification_type {
NotificationType::Message { scope, channel, .. } => {
assert_eq!(scope.as_deref(), Some("channel"));
assert_eq!(channel.as_deref(), Some("religion-debate"));
}
other => panic!("unexpected notification type: {:?}", other),
}
assert_eq!(message, "#religion-debate from falcon: hello");
}
other => panic!("unexpected event: {:?}", other),
}
match client_event_rx.recv().await.expect("done event") {
ServerEvent::Done { id } => assert_eq!(id, 1),
other => panic!("unexpected client event: {:?}", other),
}
let pending = target_queue.lock().expect("target queue lock");
assert!(
pending.is_empty(),
"connected interactive session should not get synthetic user-message interrupt"
);
}
#[tokio::test]
async fn comm_message_with_wake_queues_soft_interrupt_for_busy_connected_session() {
let sender = test_agent().await;
let target = test_agent().await;
let sender_id = sender.lock().await.session_id().to_string();
let target_id = target.lock().await.session_id().to_string();
let target_queue = target.lock().await.soft_interrupt_queue();
let sessions = Arc::new(RwLock::new(HashMap::from([
(sender_id.clone(), sender.clone()),
(target_id.clone(), target.clone()),
])));
let soft_interrupt_queues: SessionInterruptQueues = Arc::new(RwLock::new(HashMap::new()));
crate::server::register_session_interrupt_queue(
&soft_interrupt_queues,
&target_id,
target_queue.clone(),
)
.await;
let (sender_event_tx, _sender_event_rx) = mpsc::unbounded_channel();
let (target_event_tx, mut target_event_rx) = mpsc::unbounded_channel();
let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel();
let swarm_id = "swarm-test".to_string();
let swarm_members = Arc::new(RwLock::new(HashMap::from([
(
sender_id.clone(),
SwarmMember {
session_id: sender_id.clone(),
event_tx: sender_event_tx,
event_txs: HashMap::new(),
working_dir: None,
swarm_id: Some(swarm_id.clone()),
swarm_enabled: true,
status: "ready".to_string(),
detail: None,
friendly_name: Some("falcon".to_string()),
report_back_to_session_id: None,
latest_completion_report: None,
role: "coordinator".to_string(),
joined_at: Instant::now(),
last_status_change: Instant::now(),
is_headless: false,
output_tail: None,
todo_progress: None,
todo_items: Vec::new(),
runtime: crate::protocol::SwarmMemberRuntime::default(),
task_label: None,
},
),
(
target_id.clone(),
SwarmMember {
session_id: target_id.clone(),
event_tx: target_event_tx,
event_txs: HashMap::new(),
working_dir: None,
swarm_id: Some(swarm_id.clone()),
swarm_enabled: true,
status: "ready".to_string(),
detail: None,
friendly_name: Some("bear".to_string()),
report_back_to_session_id: None,
latest_completion_report: None,
role: "agent".to_string(),
joined_at: Instant::now(),
last_status_change: Instant::now(),
is_headless: false,
output_tail: None,
todo_progress: None,
todo_items: Vec::new(),
runtime: crate::protocol::SwarmMemberRuntime::default(),
task_label: None,
},
),
])));
let swarms_by_id = Arc::new(RwLock::new(HashMap::from([(
swarm_id.clone(),
HashSet::from([sender_id.clone(), target_id.clone()]),
)])));
let channel_subscriptions = Arc::new(RwLock::new(HashMap::new()));
let event_history: Arc<RwLock<std::collections::VecDeque<SwarmEvent>>> =
Arc::new(RwLock::new(std::collections::VecDeque::new()));
let event_counter = Arc::new(AtomicU64::new(0));
let (swarm_event_tx, _) = broadcast::channel(16);
let client_connections = Arc::new(RwLock::new(HashMap::from([(
"client-1".to_string(),
ClientConnectionInfo {
client_id: "client-1".to_string(),
session_id: target_id.clone(),
client_instance_id: None,
debug_client_id: None,
connected_at: Instant::now(),
last_seen: Instant::now(),
is_processing: false,
current_tool_name: None,
terminal_env: Vec::new(),
disconnect_tx: mpsc::unbounded_channel().0,
},
)])));
let _busy_guard = target.lock().await;
tokio::time::timeout(
Duration::from_secs(2),
handle_comm_message(
1,
sender_id.clone(),
"hello now".to_string(),
Some(target_id.clone()),
None,
Some(CommDeliveryMode::Wake),
None,
None,
&client_event_tx,
&sessions,
&soft_interrupt_queues,
&swarm_members,
&swarms_by_id,
&channel_subscriptions,
&event_history,
&event_counter,
&swarm_event_tx,
&client_connections,
),
)
.await
.expect("comm message should not deadlock");
match target_event_rx.recv().await.expect("target notification") {
ServerEvent::Notification {
from_session,
from_name,
notification_type,
message,
} => {
assert_eq!(from_session, sender_id);
assert_eq!(from_name.as_deref(), Some("falcon"));
match notification_type {
NotificationType::Message { scope, channel, .. } => {
assert_eq!(scope.as_deref(), Some("dm"));
assert_eq!(channel, None);
}
other => panic!("unexpected notification type: {:?}", other),
}
assert_eq!(message, "DM from falcon: hello now");
}
other => panic!("unexpected event: {:?}", other),
}
match client_event_rx.recv().await.expect("done event") {
ServerEvent::Done { id } => assert_eq!(id, 1),
other => panic!("unexpected client event: {:?}", other),
}
let pending = target_queue.lock().expect("target queue lock");
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].content, "DM from falcon: hello now");
assert_eq!(
pending[0].source,
jcode_agent_runtime::SoftInterruptSource::System
);
}
#[tokio::test]
async fn comm_list_includes_member_status_and_detail() {
let requester = test_agent().await;
let peer = test_agent().await;
let requester_id = requester.lock().await.session_id().to_string();
let peer_id = peer.lock().await.session_id().to_string();
let swarm_id = "swarm-test".to_string();
let (requester_event_tx, _requester_event_rx) = mpsc::unbounded_channel();
let (peer_event_tx, _peer_event_rx) = mpsc::unbounded_channel();
let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel();
let swarm_members = Arc::new(RwLock::new(HashMap::from([
(
requester_id.clone(),
SwarmMember {
session_id: requester_id.clone(),
event_tx: requester_event_tx,
event_txs: HashMap::new(),
working_dir: None,
swarm_id: Some(swarm_id.clone()),
swarm_enabled: true,
status: "ready".to_string(),
detail: None,
friendly_name: Some("falcon".to_string()),
report_back_to_session_id: None,
latest_completion_report: None,
role: "coordinator".to_string(),
joined_at: Instant::now(),
last_status_change: Instant::now(),
is_headless: false,
output_tail: None,
todo_progress: None,
todo_items: Vec::new(),
runtime: crate::protocol::SwarmMemberRuntime::default(),
task_label: None,
},
),
(
peer_id.clone(),
SwarmMember {
session_id: peer_id.clone(),
event_tx: peer_event_tx,
event_txs: HashMap::new(),
working_dir: None,
swarm_id: Some(swarm_id.clone()),
swarm_enabled: true,
status: "running".to_string(),
detail: Some("working on tests".to_string()),
friendly_name: Some("bear".to_string()),
report_back_to_session_id: None,
latest_completion_report: None,
role: "agent".to_string(),
joined_at: Instant::now(),
last_status_change: Instant::now(),
is_headless: false,
output_tail: None,
todo_progress: None,
todo_items: Vec::new(),
runtime: crate::protocol::SwarmMemberRuntime::default(),
task_label: None,
},
),
])));
let swarms_by_id = Arc::new(RwLock::new(HashMap::from([(
swarm_id,
HashSet::from([requester_id.clone(), peer_id.clone()]),
)])));
let file_touch = crate::server::FileTouchService::new();
let sessions = Arc::new(RwLock::new(HashMap::from([
(requester_id.clone(), requester.clone()),
(peer_id.clone(), peer.clone()),
])));
let client_connections = Arc::new(RwLock::new(HashMap::new()));
handle_comm_list(
1,
requester_id,
&client_event_tx,
&swarm_members,
&swarms_by_id,
&file_touch,
&sessions,
&client_connections,
)
.await;
match client_event_rx.recv().await.expect("comm list response") {
ServerEvent::CommMembers { id, members } => {
assert_eq!(id, 1);
let peer = members
.into_iter()
.find(|member| member.friendly_name.as_deref() == Some("bear"))
.expect("peer entry present");
assert_eq!(peer.status.as_deref(), Some("running"));
assert_eq!(peer.detail.as_deref(), Some("working on tests"));
}
other => panic!("unexpected response: {other:?}"),
}
}
#[tokio::test]
async fn comm_message_accepts_friendly_name_dm_target() {
let sender = test_agent().await;
let target = test_agent().await;
let sender_id = sender.lock().await.session_id().to_string();
let target_id = target.lock().await.session_id().to_string();
let swarm_id = "swarm-test".to_string();
let sessions = Arc::new(RwLock::new(HashMap::from([
(sender_id.clone(), sender.clone()),
(target_id.clone(), target.clone()),
])));
let soft_interrupt_queues: SessionInterruptQueues = Arc::new(RwLock::new(HashMap::new()));
let (sender_event_tx, _sender_event_rx) = mpsc::unbounded_channel();
let (target_event_tx, mut target_event_rx) = mpsc::unbounded_channel();
let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel();
let swarm_members = Arc::new(RwLock::new(HashMap::from([
(
sender_id.clone(),
SwarmMember {
session_id: sender_id.clone(),
event_tx: sender_event_tx,
event_txs: HashMap::new(),
working_dir: None,
swarm_id: Some(swarm_id.clone()),
swarm_enabled: true,
status: "ready".to_string(),
detail: None,
friendly_name: Some("falcon".to_string()),
report_back_to_session_id: None,
latest_completion_report: None,
role: "coordinator".to_string(),
joined_at: Instant::now(),
last_status_change: Instant::now(),
is_headless: false,
output_tail: None,
todo_progress: None,
todo_items: Vec::new(),
runtime: crate::protocol::SwarmMemberRuntime::default(),
task_label: None,
},
),
(
target_id.clone(),
SwarmMember {
session_id: target_id.clone(),
event_tx: target_event_tx,
event_txs: HashMap::new(),
working_dir: None,
swarm_id: Some(swarm_id.clone()),
swarm_enabled: true,
status: "ready".to_string(),
detail: None,
friendly_name: Some("bear".to_string()),
report_back_to_session_id: None,
latest_completion_report: None,
role: "agent".to_string(),
joined_at: Instant::now(),
last_status_change: Instant::now(),
is_headless: false,
output_tail: None,
todo_progress: None,
todo_items: Vec::new(),
runtime: crate::protocol::SwarmMemberRuntime::default(),
task_label: None,
},
),
])));
let swarms_by_id = Arc::new(RwLock::new(HashMap::from([(
swarm_id.clone(),
HashSet::from([sender_id.clone(), target_id.clone()]),
)])));
let channel_subscriptions = Arc::new(RwLock::new(HashMap::new()));
let event_history: Arc<RwLock<std::collections::VecDeque<SwarmEvent>>> =
Arc::new(RwLock::new(std::collections::VecDeque::new()));
let event_counter = Arc::new(AtomicU64::new(0));
let (swarm_event_tx, _) = broadcast::channel(16);
let client_connections = Arc::new(RwLock::new(HashMap::new()));
handle_comm_message(
1,
sender_id.clone(),
"hello bear".to_string(),
Some("bear".to_string()),
None,
Some(CommDeliveryMode::Notify),
None,
None,
&client_event_tx,
&sessions,
&soft_interrupt_queues,
&swarm_members,
&swarms_by_id,
&channel_subscriptions,
&event_history,
&event_counter,
&swarm_event_tx,
&client_connections,
)
.await;
match target_event_rx.recv().await.expect("target notification") {
ServerEvent::Notification {
from_session,
from_name,
notification_type,
message,
} => {
assert_eq!(from_session, sender_id);
assert_eq!(from_name.as_deref(), Some("falcon"));
match notification_type {
NotificationType::Message { scope, channel, .. } => {
assert_eq!(scope.as_deref(), Some("dm"));
assert_eq!(channel, None);
}
other => panic!("unexpected notification type: {:?}", other),
}
assert_eq!(message, "DM from falcon: hello bear");
}
other => panic!("unexpected event: {:?}", other),
}
match client_event_rx.recv().await.expect("done event") {
ServerEvent::Done { id } => assert_eq!(id, 1),
other => panic!("unexpected client event: {:?}", other),
}
}
#[tokio::test]
async fn comm_message_rejects_ambiguous_friendly_name_dm_target() {
let sender = test_agent().await;
let target_one = test_agent().await;
let target_two = test_agent().await;
let sender_id = sender.lock().await.session_id().to_string();
let target_one_id = target_one.lock().await.session_id().to_string();
let target_two_id = target_two.lock().await.session_id().to_string();
let swarm_id = "swarm-test".to_string();
let sessions = Arc::new(RwLock::new(HashMap::from([
(sender_id.clone(), sender.clone()),
(target_one_id.clone(), target_one.clone()),
(target_two_id.clone(), target_two.clone()),
])));
let soft_interrupt_queues: SessionInterruptQueues = Arc::new(RwLock::new(HashMap::new()));
let (sender_event_tx, _sender_event_rx) = mpsc::unbounded_channel();
let (target_one_event_tx, _target_one_event_rx) = mpsc::unbounded_channel();
let (target_two_event_tx, _target_two_event_rx) = mpsc::unbounded_channel();
let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel();
let swarm_members = Arc::new(RwLock::new(HashMap::from([
(
sender_id.clone(),
SwarmMember {
session_id: sender_id.clone(),
event_tx: sender_event_tx,
event_txs: HashMap::new(),
working_dir: None,
swarm_id: Some(swarm_id.clone()),
swarm_enabled: true,
status: "ready".to_string(),
detail: None,
friendly_name: Some("falcon".to_string()),
report_back_to_session_id: None,
latest_completion_report: None,
role: "coordinator".to_string(),
joined_at: Instant::now(),
last_status_change: Instant::now(),
is_headless: false,
output_tail: None,
todo_progress: None,
todo_items: Vec::new(),
runtime: crate::protocol::SwarmMemberRuntime::default(),
task_label: None,
},
),
(
target_one_id.clone(),
SwarmMember {
session_id: target_one_id.clone(),
event_tx: target_one_event_tx,
event_txs: HashMap::new(),
working_dir: None,
swarm_id: Some(swarm_id.clone()),
swarm_enabled: true,
status: "ready".to_string(),
detail: None,
friendly_name: Some("bear".to_string()),
report_back_to_session_id: None,
latest_completion_report: None,
role: "agent".to_string(),
joined_at: Instant::now(),
last_status_change: Instant::now(),
is_headless: false,
output_tail: None,
todo_progress: None,
todo_items: Vec::new(),
runtime: crate::protocol::SwarmMemberRuntime::default(),
task_label: None,
},
),
(
target_two_id.clone(),
SwarmMember {
session_id: target_two_id.clone(),
event_tx: target_two_event_tx,
event_txs: HashMap::new(),
working_dir: None,
swarm_id: Some(swarm_id.clone()),
swarm_enabled: true,
status: "ready".to_string(),
detail: None,
friendly_name: Some("bear".to_string()),
report_back_to_session_id: None,
latest_completion_report: None,
role: "agent".to_string(),
joined_at: Instant::now(),
last_status_change: Instant::now(),
is_headless: false,
output_tail: None,
todo_progress: None,
todo_items: Vec::new(),
runtime: crate::protocol::SwarmMemberRuntime::default(),
task_label: None,
},
),
])));
let swarms_by_id = Arc::new(RwLock::new(HashMap::from([(
swarm_id.clone(),
HashSet::from([
sender_id.clone(),
target_one_id.clone(),
target_two_id.clone(),
]),
)])));
let channel_subscriptions = Arc::new(RwLock::new(HashMap::new()));
let event_history: Arc<RwLock<std::collections::VecDeque<SwarmEvent>>> =
Arc::new(RwLock::new(std::collections::VecDeque::new()));
let event_counter = Arc::new(AtomicU64::new(0));
let (swarm_event_tx, _) = broadcast::channel(16);
let client_connections = Arc::new(RwLock::new(HashMap::new()));
handle_comm_message(
1,
sender_id,
"hello bears".to_string(),
Some("bear".to_string()),
None,
None,
None,
None,
&client_event_tx,
&sessions,
&soft_interrupt_queues,
&swarm_members,
&swarms_by_id,
&channel_subscriptions,
&event_history,
&event_counter,
&swarm_event_tx,
&client_connections,
)
.await;
match client_event_rx.recv().await.expect("error event") {
ServerEvent::Error { id, message, .. } => {
assert_eq!(id, 1);
assert!(message.contains("ambiguous in swarm"), "{message}");
assert!(message.contains("Use an exact session id"), "{message}");
assert!(message.contains(&target_one_id), "{message}");
assert!(message.contains(&target_two_id), "{message}");
assert!(message.contains("bear ["), "{message}");
}
other => panic!("unexpected client event: {:?}", other),
}
}
/// Broadcasts are subtree-scoped: a non-coordinator sender reaches only the
/// agents it (transitively) spawned, never unrelated peers, while a
/// coordinator retains whole-swarm reach.
#[tokio::test]
async fn comm_broadcast_reaches_only_senders_spawned_subtree() {
fn member(
session_id: &str,
role: &str,
report_back_to: Option<&str>,
swarm_id: &str,
) -> (SwarmMember, mpsc::UnboundedReceiver<ServerEvent>) {
let (event_tx, event_rx) = mpsc::unbounded_channel();
(
SwarmMember {
session_id: session_id.to_string(),
event_tx,
event_txs: HashMap::new(),
working_dir: None,
swarm_id: Some(swarm_id.to_string()),
swarm_enabled: true,
status: "ready".to_string(),
detail: None,
friendly_name: Some(session_id.to_string()),
report_back_to_session_id: report_back_to.map(str::to_string),
latest_completion_report: None,
role: role.to_string(),
joined_at: Instant::now(),
last_status_change: Instant::now(),
is_headless: true,
output_tail: None,
todo_progress: None,
todo_items: Vec::new(),
runtime: crate::protocol::SwarmMemberRuntime::default(),
task_label: None,
},
event_rx,
)
}
let swarm_id = "swarm-subtree";
// Tree: coord (coordinator, root)
// sender (root peer) -> child -> grandchild
// outsider (root peer, unrelated)
let (coord, mut coord_rx) = member("coord", "coordinator", None, swarm_id);
let (sender, _sender_rx) = member("sender", "agent", None, swarm_id);
let (child, mut child_rx) = member("child", "agent", Some("sender"), swarm_id);
let (grandchild, mut grandchild_rx) = member("grandchild", "agent", Some("child"), swarm_id);
let (outsider, mut outsider_rx) = member("outsider", "agent", None, swarm_id);
let swarm_members = Arc::new(RwLock::new(HashMap::from([
("coord".to_string(), coord),
("sender".to_string(), sender),
("child".to_string(), child),
("grandchild".to_string(), grandchild),
("outsider".to_string(), outsider),
])));
let swarms_by_id = Arc::new(RwLock::new(HashMap::from([(
swarm_id.to_string(),
HashSet::from([
"coord".to_string(),
"sender".to_string(),
"child".to_string(),
"grandchild".to_string(),
"outsider".to_string(),
]),
)])));
let sessions = Arc::new(RwLock::new(HashMap::new()));
let soft_interrupt_queues: SessionInterruptQueues = Arc::new(RwLock::new(HashMap::new()));
let channel_subscriptions = Arc::new(RwLock::new(HashMap::new()));
let event_history: Arc<RwLock<std::collections::VecDeque<SwarmEvent>>> =
Arc::new(RwLock::new(std::collections::VecDeque::new()));
let event_counter = Arc::new(AtomicU64::new(0));
let (swarm_event_tx, _) = broadcast::channel(16);
let client_connections = Arc::new(RwLock::new(HashMap::new()));
let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel();
handle_comm_message(
1,
"sender".to_string(),
"subtree update".to_string(),
None,
None,
None,
None,
None,
&client_event_tx,
&sessions,
&soft_interrupt_queues,
&swarm_members,
&swarms_by_id,
&channel_subscriptions,
&event_history,
&event_counter,
&swarm_event_tx,
&client_connections,
)
.await;
match client_event_rx.recv().await.expect("done event") {
ServerEvent::Done { id } => assert_eq!(id, 1),
other => panic!("unexpected client event: {:?}", other),
}
// Direct child and transitive grandchild both receive the broadcast.
assert!(matches!(
child_rx.try_recv(),
Ok(ServerEvent::Notification { .. })
));
assert!(matches!(
grandchild_rx.try_recv(),
Ok(ServerEvent::Notification { .. })
));
// Unrelated root peers and the coordinator do not.
assert!(outsider_rx.try_recv().is_err());
assert!(coord_rx.try_recv().is_err());
// Coordinator broadcast still reaches the whole swarm.
handle_comm_message(
2,
"coord".to_string(),
"swarm-wide notice".to_string(),
None,
None,
None,
None,
None,
&client_event_tx,
&sessions,
&soft_interrupt_queues,
&swarm_members,
&swarms_by_id,
&channel_subscriptions,
&event_history,
&event_counter,
&swarm_event_tx,
&client_connections,
)
.await;
match client_event_rx.recv().await.expect("done event") {
ServerEvent::Done { id } => assert_eq!(id, 2),
other => panic!("unexpected client event: {:?}", other),
}
assert!(matches!(
outsider_rx.try_recv(),
Ok(ServerEvent::Notification { .. })
));
assert!(matches!(
child_rx.try_recv(),
Ok(ServerEvent::Notification { .. })
));
}
@@ -0,0 +1,319 @@
use super::{
ClientConnectionInfo, ClientDebugState, FileTouchService, SessionInterruptQueues, SwarmEvent,
SwarmEventType, SwarmMember, VersionedPlan, record_swarm_event, remove_background_tool_signal,
remove_session_channel_subscriptions, remove_session_from_swarm,
remove_session_interrupt_queue, unregister_session_event_sender, update_member_status,
};
use crate::agent::Agent;
use anyhow::Result;
use jcode_agent_runtime::InterruptSignal;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{Mutex, RwLock, broadcast};
type SessionAgents = Arc<RwLock<HashMap<String, Arc<Mutex<Agent>>>>>;
type ChannelSubscriptions = Arc<RwLock<HashMap<String, HashMap<String, HashSet<String>>>>>;
const RELOAD_DISCONNECT_MARKER_MAX_AGE: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DisconnectDisposition {
Closed,
Crashed,
Reloading,
}
fn disconnect_disposition(disconnected_while_processing: bool) -> DisconnectDisposition {
if !disconnected_while_processing {
return DisconnectDisposition::Closed;
}
if crate::server::reload_marker_active(RELOAD_DISCONNECT_MARKER_MAX_AGE) {
DisconnectDisposition::Reloading
} else {
DisconnectDisposition::Crashed
}
}
async fn session_has_live_successor(
client_connections: &Arc<RwLock<HashMap<String, ClientConnectionInfo>>>,
session_id: &str,
) -> bool {
client_connections
.read()
.await
.values()
.any(|info| info.session_id == session_id)
}
#[expect(
clippy::too_many_arguments,
reason = "disconnect cleanup updates sessions, swarms, files, channels, debug state, and shutdown signals together"
)]
pub(super) async fn cleanup_client_connection(
sessions: &SessionAgents,
client_session_id: &str,
client_is_processing: bool,
processing_task: &mut Option<tokio::task::JoinHandle<()>>,
event_handle: tokio::task::JoinHandle<()>,
swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>,
swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>,
swarm_coordinators: &Arc<RwLock<HashMap<String, String>>>,
swarm_plans: &Arc<RwLock<HashMap<String, VersionedPlan>>>,
file_touch: &FileTouchService,
channel_subscriptions: &ChannelSubscriptions,
channel_subscriptions_by_session: &ChannelSubscriptions,
client_debug_state: &Arc<RwLock<ClientDebugState>>,
client_debug_id: &str,
client_connections: &Arc<RwLock<HashMap<String, ClientConnectionInfo>>>,
client_connection_id: &str,
shutdown_signals: &Arc<RwLock<HashMap<String, InterruptSignal>>>,
soft_interrupt_queues: &SessionInterruptQueues,
event_history: &Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>,
event_counter: &Arc<std::sync::atomic::AtomicU64>,
swarm_event_tx: &broadcast::Sender<SwarmEvent>,
) -> Result<()> {
let disconnected_while_processing = client_is_processing
|| processing_task
.as_ref()
.map(|handle| !handle.is_finished())
.unwrap_or(false);
let disposition = disconnect_disposition(disconnected_while_processing);
{
let mut debug_state = client_debug_state.write().await;
debug_state.unregister(client_debug_id);
}
{
let mut connections = client_connections.write().await;
connections.remove(client_connection_id);
}
unregister_session_event_sender(swarm_members, client_session_id, client_connection_id).await;
// Release stale live ownership before slower cleanup so a reconnecting TUI can
// reclaim the same session without tripping duplicate-attach guards.
tokio::task::yield_now().await;
let successor_connected =
session_has_live_successor(client_connections, client_session_id).await;
if successor_connected {
crate::logging::info(&format!(
"Skipping destructive disconnect cleanup for {} because another client is still attached",
client_session_id
));
event_handle.abort();
return Ok(());
}
{
let mut sessions_guard = sessions.write().await;
if let Some(agent_arc) = sessions_guard.remove(client_session_id) {
drop(sessions_guard);
let lock_result =
tokio::time::timeout(std::time::Duration::from_secs(2), agent_arc.lock()).await;
match lock_result {
Ok(mut agent) => {
match disposition {
DisconnectDisposition::Closed => {
agent.mark_closed();
}
DisconnectDisposition::Reloading => {
agent.mark_crashed(Some(
"Server reload interrupted processing".to_string(),
));
}
DisconnectDisposition::Crashed => {
agent.mark_crashed(Some(
"Client disconnected while processing".to_string(),
));
}
}
let memory_enabled = agent.memory_enabled();
let transcript = if memory_enabled {
Some(agent.build_transcript_for_extraction())
} else {
None
};
let sid = client_session_id.to_string();
let working_dir = agent.working_dir().map(|dir| dir.to_string());
drop(agent);
let event = match disposition {
DisconnectDisposition::Closed => {
crate::runtime_memory_log::RuntimeMemoryLogEvent::new(
"session_closed",
"client_disconnected",
)
}
DisconnectDisposition::Crashed => {
crate::runtime_memory_log::RuntimeMemoryLogEvent::new(
"session_crashed",
"client_disconnected_while_processing",
)
}
DisconnectDisposition::Reloading => {
crate::runtime_memory_log::RuntimeMemoryLogEvent::new(
"session_reloading",
"server_reload_disconnect",
)
}
}
.with_session_id(sid.clone())
.force_attribution();
crate::runtime_memory_log::emit_event(event);
if let Some(transcript) = transcript {
crate::memory_agent::trigger_final_extraction_with_dir(
transcript,
sid,
working_dir,
);
}
}
Err(_) => {
crate::logging::warn(&format!(
"Session {} cleanup timed out waiting for agent lock (stuck task); skipping graceful shutdown",
client_session_id
));
}
}
}
}
{
let (status, detail) = match disposition {
DisconnectDisposition::Closed => ("stopped", Some("disconnected".to_string())),
DisconnectDisposition::Crashed => {
("crashed", Some("disconnect while running".to_string()))
}
DisconnectDisposition::Reloading => {
("stopped", Some("server reload in progress".to_string()))
}
};
update_member_status(
client_session_id,
status,
detail,
swarm_members,
swarms_by_id,
Some(event_history),
Some(event_counter),
Some(swarm_event_tx),
)
.await;
let (swarm_id, removed_name) = {
let mut members = swarm_members.write().await;
if let Some(member) = members.remove(client_session_id) {
(member.swarm_id, member.friendly_name)
} else {
(None, None)
}
};
crate::session_metrics::forget(client_session_id);
crate::session_effort::forget_session_effort(client_session_id);
if let Some(ref swarm_id) = swarm_id {
record_swarm_event(
event_history,
event_counter,
swarm_event_tx,
client_session_id.to_string(),
removed_name.clone(),
Some(swarm_id.clone()),
SwarmEventType::MemberChange {
action: "left".to_string(),
},
)
.await;
remove_session_from_swarm(
client_session_id,
swarm_id,
swarm_members,
swarms_by_id,
swarm_coordinators,
swarm_plans,
)
.await;
}
remove_session_channel_subscriptions(
client_session_id,
channel_subscriptions,
channel_subscriptions_by_session,
)
.await;
file_touch.clear_session(client_session_id).await;
}
{
let mut signals = shutdown_signals.write().await;
signals.remove(client_session_id);
}
remove_background_tool_signal(client_session_id);
remove_session_interrupt_queue(soft_interrupt_queues, client_session_id).await;
if let Some(handle) = processing_task.take() {
handle.abort();
}
event_handle.abort();
Ok(())
}
#[cfg(test)]
mod tests {
use super::{DisconnectDisposition, disconnect_disposition};
#[test]
fn idle_disconnect_is_closed() {
assert_eq!(disconnect_disposition(false), DisconnectDisposition::Closed);
}
#[test]
fn running_disconnect_without_reload_is_crash() {
let _guard = crate::storage::lock_test_env();
crate::server::clear_reload_marker();
assert_eq!(disconnect_disposition(true), DisconnectDisposition::Crashed);
}
#[test]
fn running_disconnect_during_reload_is_expected() {
let _guard = crate::storage::lock_test_env();
let runtime = tempfile::TempDir::new().expect("create runtime dir");
crate::env::set_var("JCODE_RUNTIME_DIR", runtime.path());
crate::server::clear_reload_marker();
crate::server::write_reload_state(
"test-request",
"test-hash",
crate::server::ReloadPhase::Starting,
None,
);
assert_eq!(
disconnect_disposition(true),
DisconnectDisposition::Reloading
);
crate::server::clear_reload_marker();
crate::env::remove_var("JCODE_RUNTIME_DIR");
}
#[test]
fn running_disconnect_during_recent_socket_ready_reload_is_expected() {
let _guard = crate::storage::lock_test_env();
let runtime = tempfile::TempDir::new().expect("create runtime dir");
crate::env::set_var("JCODE_RUNTIME_DIR", runtime.path());
crate::server::clear_reload_marker();
crate::server::write_reload_state(
"test-request",
"test-hash",
crate::server::ReloadPhase::SocketReady,
None,
);
assert_eq!(
disconnect_disposition(true),
DisconnectDisposition::Reloading
);
crate::server::clear_reload_marker();
crate::env::remove_var("JCODE_RUNTIME_DIR");
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,174 @@
use crate::protocol::Request;
pub(super) fn interrupt_request_log_fields(
request: &Request,
client_session_id: &str,
client_is_processing: bool,
message_id: Option<u64>,
has_task: bool,
line_bytes: usize,
) -> Option<String> {
let base = |kind: &str, id: u64| {
format!(
"kind={} id={} session={} client_processing={} message_id={:?} has_task={} line_bytes={}",
kind, id, client_session_id, client_is_processing, message_id, has_task, line_bytes
)
};
match request {
Request::Cancel { id } => Some(base("cancel", *id)),
Request::SoftInterrupt {
id,
content,
urgent,
} => Some(format!(
"{} urgent={} content_bytes={} content_chars={}",
base("soft_interrupt", *id),
urgent,
content.len(),
content.chars().count()
)),
Request::CancelSoftInterrupts { id } => Some(base("cancel_soft_interrupts", *id)),
Request::BackgroundTool { id } => Some(base("background_tool", *id)),
_ => None,
}
}
pub(super) fn request_type_from_line(line: &str) -> String {
serde_json::from_str::<serde_json::Value>(line.trim())
.ok()
.and_then(|value| {
value
.get("type")
.and_then(|kind| kind.as_str())
.map(str::to_string)
})
.unwrap_or_else(|| "unknown".to_string())
}
pub(super) fn request_type_is_read_only(kind: &str) -> bool {
matches!(
kind,
"ping"
| "state"
| "get_history"
| "get_model_catalog"
| "get_compacted_history"
| "agent_capabilities"
| "agent_context"
| "comm_read"
| "comm_list"
| "comm_list_channels"
| "comm_channel_members"
| "comm_summary"
| "comm_status"
| "comm_plan_status"
| "comm_read_context"
| "comm_await_members"
)
}
pub(super) fn request_payload_summary(kind: &str, line: &str) -> Vec<(String, String)> {
let mut fields = Vec::new();
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line.trim()) {
let bytes_chars =
|name: &str, value: &serde_json::Value, fields: &mut Vec<(String, String)>| {
if let Some(text) = value.get(name).and_then(|v| v.as_str()) {
fields.push((format!("{}_bytes", name), text.len().to_string()));
fields.push((format!("{}_chars", name), text.chars().count().to_string()));
}
};
for name in [
"content", "message", "prompt", "task", "command", "input", "value",
] {
bytes_chars(name, &value, &mut fields);
}
if let Some(images) = value.get("images").and_then(|v| v.as_array()) {
fields.push(("image_count".to_string(), images.len().to_string()));
}
if let Some(session_id) = value.get("session_id").and_then(|v| v.as_str()) {
fields.push(("request_session_id".to_string(), session_id.to_string()));
}
if let Some(target_session) = value.get("target_session").and_then(|v| v.as_str()) {
fields.push(("target_session".to_string(), target_session.to_string()));
}
if let Some(client_instance_id) = value.get("client_instance_id").and_then(|v| v.as_str()) {
fields.push((
"request_client_instance_id".to_string(),
client_instance_id.to_string(),
));
}
if matches!(kind, "set_model" | "set_subagent_model")
&& let Some(model) = value.get("model").and_then(|v| v.as_str())
{
fields.push(("model".to_string(), model.to_string()));
}
if let Some(title) = value.get("title").and_then(|v| v.as_str()) {
fields.push(("title_chars".to_string(), title.chars().count().to_string()));
}
}
fields
}
pub(super) struct ServerRequestLifecycleFields<'a> {
pub(super) phase: &'a str,
pub(super) request_id: u64,
pub(super) request_kind: &'a str,
pub(super) client_session_id: &'a str,
pub(super) client_connection_id: &'a str,
pub(super) client_instance_id: Option<&'a str>,
pub(super) client_is_processing: bool,
pub(super) message_id: Option<u64>,
pub(super) processing_session_id: Option<&'a str>,
pub(super) line_bytes: usize,
}
pub(super) fn server_request_lifecycle_fields(
input: ServerRequestLifecycleFields<'_>,
) -> Vec<(String, String)> {
let ServerRequestLifecycleFields {
phase,
request_id,
request_kind,
client_session_id,
client_connection_id,
client_instance_id,
client_is_processing,
message_id,
processing_session_id,
line_bytes,
} = input;
let mut fields = vec![
("phase".to_string(), phase.to_string()),
("request_id".to_string(), request_id.to_string()),
("request_kind".to_string(), request_kind.to_string()),
("session_id".to_string(), client_session_id.to_string()),
(
"client_connection_id".to_string(),
client_connection_id.to_string(),
),
(
"client_instance_id".to_string(),
client_instance_id.unwrap_or("none").to_string(),
),
(
"client_processing".to_string(),
client_is_processing.to_string(),
),
(
"message_id".to_string(),
message_id
.map(|id| id.to_string())
.unwrap_or_else(|| "none".to_string()),
),
(
"processing_session_id".to_string(),
processing_session_id.unwrap_or("none").to_string(),
),
("line_bytes".to_string(), line_bytes.to_string()),
];
if let Some(ctx_session) = crate::logging::current_session() {
fields.push(("log_context_session_id".to_string(), ctx_session));
}
fields
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,805 @@
use super::client_comm::{
handle_comm_channel_members, handle_comm_list, handle_comm_list_channels, handle_comm_message,
handle_comm_read, handle_comm_share, handle_comm_subscribe_channel,
handle_comm_unsubscribe_channel,
};
use super::client_writer::write_direct_event;
use super::comm_await::{CommAwaitMembersContext, handle_comm_await_members};
use super::comm_control::{
handle_comm_assign_next, handle_comm_assign_role, handle_comm_assign_task,
handle_comm_task_control,
};
use super::comm_plan::{
handle_comm_approve_plan, handle_comm_propose_plan, handle_comm_reject_plan,
};
use super::comm_session::{handle_comm_list_models, handle_comm_spawn, handle_comm_stop};
use super::comm_sync::{
CommResyncPlanContext, handle_comm_plan_status, handle_comm_read_context,
handle_comm_resync_plan, handle_comm_status, handle_comm_summary,
};
use super::{
AwaitMembersRuntime, ChannelSubscriptions, ClientConnectionInfo, FileTouchService,
SessionAgents, SessionInterruptQueues, SharedContext, SwarmEvent, SwarmMember,
SwarmMutationRuntime, VersionedPlan, format_structured_completion_report, truncate_detail,
update_member_status_with_report_tldr,
};
use crate::config::SwarmSpawnMode;
use crate::protocol::{Request, ServerEvent};
use crate::provider::Provider;
use anyhow::Result;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use tokio::sync::{Mutex, RwLock, broadcast, mpsc};
pub(super) fn parse_swarm_spawn_mode(
id: u64,
spawn_mode: Option<String>,
client_event_tx: &mpsc::UnboundedSender<ServerEvent>,
) -> Option<Option<SwarmSpawnMode>> {
match spawn_mode {
Some(value) => match SwarmSpawnMode::parse(&value) {
Some(mode) => Some(Some(mode)),
None => {
let _ = client_event_tx.send(ServerEvent::Error {
id,
message: format!(
"Invalid spawn_mode '{value}'. Expected one of: visible, headless, inline, auto"
),
retry_after_secs: None,
});
None
}
},
None => Some(None),
}
}
pub(super) struct LightweightControlContext<'a> {
pub(super) sessions: &'a SessionAgents,
pub(super) global_session_id: &'a Arc<RwLock<String>>,
pub(super) provider_template: &'a Arc<dyn Provider>,
pub(super) swarm_members: &'a Arc<RwLock<HashMap<String, SwarmMember>>>,
pub(super) swarms_by_id: &'a Arc<RwLock<HashMap<String, HashSet<String>>>>,
pub(super) shared_context: &'a Arc<RwLock<HashMap<String, HashMap<String, SharedContext>>>>,
pub(super) swarm_plans: &'a Arc<RwLock<HashMap<String, VersionedPlan>>>,
pub(super) swarm_coordinators: &'a Arc<RwLock<HashMap<String, String>>>,
pub(super) file_touch: &'a FileTouchService,
pub(super) channel_subscriptions: &'a ChannelSubscriptions,
pub(super) channel_subscriptions_by_session: &'a ChannelSubscriptions,
pub(super) client_connections: &'a Arc<RwLock<HashMap<String, ClientConnectionInfo>>>,
pub(super) event_history: &'a Arc<RwLock<std::collections::VecDeque<SwarmEvent>>>,
pub(super) event_counter: &'a Arc<std::sync::atomic::AtomicU64>,
pub(super) swarm_event_tx: &'a broadcast::Sender<SwarmEvent>,
pub(super) mcp_pool: &'a Arc<crate::mcp::SharedMcpPool>,
pub(super) soft_interrupt_queues: &'a SessionInterruptQueues,
pub(super) await_members_runtime: &'a AwaitMembersRuntime,
pub(super) swarm_mutation_runtime: &'a SwarmMutationRuntime,
}
pub(super) async fn handle_lightweight_control_request(
request: Request,
writer: Arc<Mutex<crate::transport::WriteHalf>>,
context: LightweightControlContext<'_>,
) -> Result<()> {
let LightweightControlContext {
sessions,
global_session_id,
provider_template,
swarm_members,
swarms_by_id,
shared_context,
swarm_plans,
swarm_coordinators,
file_touch,
channel_subscriptions,
channel_subscriptions_by_session,
client_connections,
event_history,
event_counter,
swarm_event_tx,
mcp_pool,
soft_interrupt_queues,
await_members_runtime,
swarm_mutation_runtime,
} = context;
if let Request::Ping { id } = request {
write_direct_event(&writer, &ServerEvent::Pong { id }).await?;
return Ok(());
}
write_direct_event(&writer, &ServerEvent::Ack { id: request.id() }).await?;
let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel::<ServerEvent>();
let writer_clone = Arc::clone(&writer);
let event_handle = tokio::spawn(async move {
while let Some(event) = client_event_rx.recv().await {
if let Err(error) = write_direct_event(&writer_clone, &event).await {
// Routine on client reload/disconnect; avoid dumping the full
// event (an await response can embed whole completion reports).
let event_desc = crate::logging::truncate_for_log(&format!("{:?}", event), 200);
crate::logging::warn(&format!(
"lightweight control writer failed while sending {}: {}",
event_desc, error
));
break;
}
}
});
match request {
Request::CommShare {
id,
session_id: req_session_id,
key,
value,
append,
} => {
handle_comm_share(
id,
req_session_id,
key,
value,
append,
&client_event_tx,
swarm_members,
swarms_by_id,
shared_context,
event_history,
event_counter,
swarm_event_tx,
)
.await;
}
Request::CommRead {
id,
session_id: req_session_id,
key,
} => {
handle_comm_read(
id,
req_session_id,
key,
&client_event_tx,
swarm_members,
shared_context,
)
.await;
}
Request::CommMessage {
id,
from_session,
message,
to_session,
channel,
delivery,
wake,
tldr,
} => {
handle_comm_message(
id,
from_session,
message,
to_session,
channel,
delivery,
wake,
tldr,
&client_event_tx,
sessions,
soft_interrupt_queues,
swarm_members,
swarms_by_id,
channel_subscriptions,
event_history,
event_counter,
swarm_event_tx,
client_connections,
)
.await;
}
Request::CommList {
id,
session_id: req_session_id,
} => {
handle_comm_list(
id,
req_session_id,
&client_event_tx,
swarm_members,
swarms_by_id,
file_touch,
sessions,
client_connections,
)
.await;
}
Request::CommListChannels {
id,
session_id: req_session_id,
} => {
handle_comm_list_channels(
id,
req_session_id,
&client_event_tx,
swarm_members,
channel_subscriptions,
)
.await;
}
Request::CommChannelMembers {
id,
session_id: req_session_id,
channel,
} => {
handle_comm_channel_members(
id,
req_session_id,
channel,
&client_event_tx,
swarm_members,
channel_subscriptions,
)
.await;
}
Request::CommProposePlan {
id,
session_id: req_session_id,
items,
} => {
handle_comm_propose_plan(
id,
req_session_id,
items,
&client_event_tx,
swarm_members,
swarms_by_id,
shared_context,
swarm_plans,
swarm_coordinators,
sessions,
soft_interrupt_queues,
event_history,
event_counter,
swarm_event_tx,
swarm_mutation_runtime,
)
.await;
}
Request::CommApprovePlan {
id,
session_id: req_session_id,
proposer_session,
} => {
handle_comm_approve_plan(
id,
req_session_id,
proposer_session,
&client_event_tx,
swarm_members,
swarms_by_id,
shared_context,
swarm_plans,
swarm_coordinators,
sessions,
soft_interrupt_queues,
event_history,
event_counter,
swarm_event_tx,
swarm_mutation_runtime,
)
.await;
}
Request::CommRejectPlan {
id,
session_id: req_session_id,
proposer_session,
reason,
} => {
handle_comm_reject_plan(
id,
req_session_id,
proposer_session,
reason,
&client_event_tx,
swarm_members,
shared_context,
swarm_coordinators,
sessions,
soft_interrupt_queues,
event_history,
event_counter,
swarm_event_tx,
swarm_mutation_runtime,
)
.await;
}
Request::CommSeedGraph {
id,
session_id: req_session_id,
mode,
nodes,
} => {
super::comm_graph::handle_comm_seed_graph(
id,
req_session_id,
mode,
nodes,
&client_event_tx,
swarm_members,
swarms_by_id,
swarm_plans,
swarm_coordinators,
event_history,
event_counter,
swarm_event_tx,
)
.await;
}
Request::CommExpandNode {
id,
session_id: req_session_id,
node_id,
children,
} => {
super::comm_graph::handle_comm_expand_node(
id,
req_session_id,
node_id,
children,
&client_event_tx,
swarm_members,
swarms_by_id,
swarm_plans,
swarm_coordinators,
event_history,
event_counter,
swarm_event_tx,
)
.await;
}
Request::CommCompleteNode {
id,
session_id: req_session_id,
node_id,
artifact_json,
} => {
super::comm_graph::handle_comm_complete_node(
id,
req_session_id,
node_id,
artifact_json,
&client_event_tx,
swarm_members,
swarms_by_id,
swarm_plans,
swarm_coordinators,
event_history,
event_counter,
swarm_event_tx,
)
.await;
}
Request::CommInjectGap {
id,
session_id: req_session_id,
gate_id,
nodes,
} => {
super::comm_graph::handle_comm_inject_gap(
id,
req_session_id,
gate_id,
nodes,
&client_event_tx,
swarm_members,
swarms_by_id,
swarm_plans,
swarm_coordinators,
event_history,
event_counter,
swarm_event_tx,
)
.await;
}
Request::CommSpawn {
id,
session_id: req_session_id,
working_dir,
initial_message,
request_nonce,
spawn_mode,
model,
effort,
label,
} => {
let spawn_mode = match parse_swarm_spawn_mode(id, spawn_mode, &client_event_tx) {
Some(spawn_mode) => spawn_mode,
None => return Ok(()),
};
handle_comm_spawn(
id,
req_session_id,
working_dir,
initial_message,
request_nonce,
spawn_mode,
model,
effort,
label,
&client_event_tx,
sessions,
global_session_id,
provider_template,
swarm_members,
swarms_by_id,
swarm_coordinators,
swarm_plans,
channel_subscriptions,
channel_subscriptions_by_session,
event_history,
event_counter,
swarm_event_tx,
mcp_pool,
soft_interrupt_queues,
swarm_mutation_runtime,
client_connections,
)
.await;
}
Request::CommListModels {
id,
session_id: req_session_id,
} => {
handle_comm_list_models(id, &req_session_id, sessions, provider_template, |event| {
let _ = client_event_tx.send(event);
})
.await;
}
Request::CommStop {
id,
session_id: req_session_id,
target_session,
force,
} => {
handle_comm_stop(
id,
req_session_id,
target_session,
force.unwrap_or(false),
&client_event_tx,
sessions,
swarm_members,
swarms_by_id,
swarm_coordinators,
swarm_plans,
channel_subscriptions,
channel_subscriptions_by_session,
event_history,
event_counter,
swarm_event_tx,
soft_interrupt_queues,
swarm_mutation_runtime,
)
.await;
}
Request::CommAssignRole {
id,
session_id: req_session_id,
target_session,
role,
} => {
handle_comm_assign_role(
id,
req_session_id,
target_session,
role,
&client_event_tx,
sessions,
swarm_members,
swarms_by_id,
swarm_coordinators,
swarm_plans,
event_history,
event_counter,
swarm_event_tx,
swarm_mutation_runtime,
)
.await;
}
Request::CommSummary {
id,
session_id: req_session_id,
target_session,
limit,
} => {
handle_comm_summary(
id,
req_session_id,
target_session,
limit,
sessions,
swarm_members,
&client_event_tx,
)
.await;
}
Request::CommStatus {
id,
session_id: req_session_id,
target_session,
} => {
handle_comm_status(
id,
req_session_id,
target_session,
sessions,
swarm_members,
client_connections,
file_touch,
&client_event_tx,
)
.await;
}
Request::CommReport {
id,
session_id: req_session_id,
status,
message,
validation,
follow_up,
tldr,
} => {
let status = status.unwrap_or_else(|| "ready".to_string());
let report = format_structured_completion_report(
&message,
validation.as_deref(),
follow_up.as_deref(),
);
let detail = Some(truncate_detail(&message, 160));
update_member_status_with_report_tldr(
&req_session_id,
&status,
detail,
Some(report.clone()),
tldr,
swarm_members,
swarms_by_id,
Some(event_history),
Some(event_counter),
Some(swarm_event_tx),
)
.await;
let _ = client_event_tx.send(ServerEvent::CommReportResponse {
id,
status,
message: "Report recorded and delivered to the coordinator when applicable."
.to_string(),
});
}
Request::CommPlanStatus {
id,
session_id: req_session_id,
} => {
handle_comm_plan_status(
id,
req_session_id,
swarm_members,
swarm_plans,
&client_event_tx,
)
.await;
}
Request::CommReadContext {
id,
session_id: req_session_id,
target_session,
} => {
handle_comm_read_context(
id,
req_session_id,
target_session,
sessions,
swarm_members,
&client_event_tx,
)
.await;
}
Request::CommResyncPlan {
id,
session_id: req_session_id,
} => {
handle_comm_resync_plan(
id,
req_session_id,
&CommResyncPlanContext {
client_event_tx: &client_event_tx,
swarm_members,
swarms_by_id,
swarm_plans,
swarm_coordinators,
event_history,
event_counter,
swarm_event_tx,
},
)
.await;
}
Request::CommAssignTask {
id,
session_id: req_session_id,
target_session,
task_id,
message,
} => {
handle_comm_assign_task(
id,
req_session_id,
target_session,
task_id,
message,
&client_event_tx,
sessions,
soft_interrupt_queues,
client_connections,
swarm_members,
swarms_by_id,
swarm_plans,
swarm_coordinators,
event_history,
event_counter,
swarm_event_tx,
swarm_mutation_runtime,
)
.await;
}
Request::CommAssignNext {
id,
session_id: req_session_id,
target_session,
working_dir,
prefer_spawn,
spawn_if_needed,
message,
model,
effort,
} => {
handle_comm_assign_next(
id,
req_session_id,
target_session,
working_dir,
prefer_spawn,
spawn_if_needed,
message,
model,
effort,
&client_event_tx,
sessions,
global_session_id,
provider_template,
soft_interrupt_queues,
client_connections,
swarm_members,
swarms_by_id,
swarm_plans,
swarm_coordinators,
event_history,
event_counter,
swarm_event_tx,
mcp_pool,
swarm_mutation_runtime,
)
.await;
}
Request::CommTaskControl {
id,
session_id: req_session_id,
action,
task_id,
target_session,
message,
} => {
handle_comm_task_control(
id,
req_session_id,
action,
task_id,
target_session,
message,
&client_event_tx,
sessions,
soft_interrupt_queues,
client_connections,
swarm_members,
swarms_by_id,
swarm_plans,
swarm_coordinators,
event_history,
event_counter,
swarm_event_tx,
swarm_mutation_runtime,
)
.await;
}
Request::CommSubscribeChannel {
id,
session_id: req_session_id,
channel,
} => {
handle_comm_subscribe_channel(
id,
req_session_id,
channel,
&client_event_tx,
swarm_members,
channel_subscriptions,
channel_subscriptions_by_session,
event_history,
event_counter,
swarm_event_tx,
)
.await;
}
Request::CommUnsubscribeChannel {
id,
session_id: req_session_id,
channel,
} => {
handle_comm_unsubscribe_channel(
id,
req_session_id,
channel,
&client_event_tx,
swarm_members,
channel_subscriptions,
channel_subscriptions_by_session,
event_history,
event_counter,
swarm_event_tx,
)
.await;
}
Request::CommAwaitMembers {
id,
session_id: req_session_id,
target_status,
session_ids: requested_ids,
mode,
timeout_secs,
background,
notify,
wake,
} => {
handle_comm_await_members(
id,
req_session_id,
target_status,
requested_ids,
mode,
timeout_secs,
background,
notify,
wake,
CommAwaitMembersContext {
client_event_tx: &client_event_tx,
swarm_members,
swarms_by_id,
swarm_event_tx,
await_members_runtime,
},
)
.await;
}
other => {
let _ = client_event_tx.send(ServerEvent::Error {
id: other.id(),
message: "unsupported lightweight control request".to_string(),
retry_after_secs: None,
});
}
}
drop(client_event_tx);
let _ = event_handle.await;
Ok(())
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,250 @@
use super::{
claim_live_target_agent, handle_clear_session, handle_reload, handle_resume_session,
mark_remote_reload_started, remove_detached_source_if_unclaimed, rename_shutdown_signal,
restored_session_was_interrupted, session_was_interrupted_by_reload,
subscribe_should_mark_ready,
};
use crate::agent::Agent;
use crate::message::ContentBlock;
use crate::message::{Message, ToolDefinition};
use crate::protocol::ServerEvent;
use crate::provider::{EventStream, Provider};
use crate::server::{
ClientConnectionInfo, ClientDebugState, FileTouchService, SessionInterruptQueues, SwarmEvent,
SwarmMember, VersionedPlan,
};
use crate::tool::Registry;
use anyhow::Result;
use async_trait::async_trait;
use jcode_agent_runtime::InterruptSignal;
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::{Mutex, RwLock, broadcast, mpsc};
struct MockProvider;
fn test_swarm_member(session_id: &str, status: &str) -> SwarmMember {
let (event_tx, _event_rx) = mpsc::unbounded_channel();
SwarmMember {
session_id: session_id.to_string(),
event_tx,
event_txs: HashMap::new(),
working_dir: None,
swarm_id: Some("swarm-test".to_string()),
swarm_enabled: true,
status: status.to_string(),
detail: None,
task_label: None,
friendly_name: Some(session_id.to_string()),
report_back_to_session_id: Some("coord".to_string()),
latest_completion_report: None,
role: "agent".to_string(),
joined_at: Instant::now(),
last_status_change: Instant::now(),
is_headless: false,
output_tail: None,
todo_progress: None,
todo_items: Vec::new(),
runtime: crate::protocol::SwarmMemberRuntime::default(),
}
}
#[tokio::test]
async fn subscribe_does_not_mark_running_startup_worker_ready() {
let swarm_members = Arc::new(RwLock::new(HashMap::from([(
"worker".to_string(),
test_swarm_member("worker", "running"),
)])));
assert!(!subscribe_should_mark_ready("worker", &swarm_members).await);
}
#[tokio::test]
async fn subscribe_marks_non_running_member_ready() {
let swarm_members = Arc::new(RwLock::new(HashMap::from([(
"worker".to_string(),
test_swarm_member("worker", "spawned"),
)])));
assert!(subscribe_should_mark_ready("worker", &swarm_members).await);
}
#[async_trait]
impl Provider for MockProvider {
async fn complete(
&self,
_messages: &[Message],
_tools: &[ToolDefinition],
_system: &str,
_resume_session_id: Option<&str>,
) -> Result<EventStream> {
Err(anyhow::anyhow!(
"mock provider complete should not be called in client_session tests"
))
}
fn name(&self) -> &str {
"mock"
}
fn fork(&self) -> Arc<dyn Provider> {
Arc::new(MockProvider)
}
}
fn test_agent(messages: Vec<crate::session::StoredMessage>) -> Agent {
let provider: Arc<dyn Provider> = Arc::new(MockProvider);
let rt = tokio::runtime::Runtime::new().expect("runtime");
let _guard = rt.enter();
let registry = rt.block_on(Registry::new(provider.clone()));
build_test_agent(provider, registry, messages)
}
fn build_test_agent(
provider: Arc<dyn Provider>,
registry: Registry,
messages: Vec<crate::session::StoredMessage>,
) -> Agent {
let mut session =
crate::session::Session::create_with_id("session_test_reload".to_string(), None, None);
session.model = Some("mock".to_string());
session.replace_messages(messages);
Agent::new_with_session(provider, registry, session, None)
}
fn build_test_agent_with_id(
provider: Arc<dyn Provider>,
registry: Registry,
session_id: &str,
messages: Vec<crate::session::StoredMessage>,
) -> Agent {
let mut session = crate::session::Session::create_with_id(session_id.to_string(), None, None);
session.model = Some("mock".to_string());
session.replace_messages(messages);
Agent::new_with_session(provider, registry, session, None)
}
async fn collect_events_until_done(
client_event_rx: &mut mpsc::UnboundedReceiver<ServerEvent>,
done_id: u64,
) -> Vec<ServerEvent> {
let mut events = Vec::new();
for _ in 0..16 {
let event = tokio::time::timeout(std::time::Duration::from_secs(1), client_event_rx.recv())
.await
.expect("timed out waiting for server event")
.expect("expected server event");
let is_done = matches!(event, ServerEvent::Done { id } if id == done_id);
events.push(event);
if is_done {
break;
}
}
events
}
#[tokio::test]
async fn live_target_claim_is_atomic_with_detached_source_cleanup() {
let provider: Arc<dyn Provider> = Arc::new(MockProvider);
let registry = Registry::new(provider.clone()).await;
for iteration in 0..32 {
let target_id = format!("session_atomic_target_{iteration}");
let source_id = format!("session_atomic_source_{iteration}");
let target_agent = Arc::new(Mutex::new(build_test_agent_with_id(
provider.clone(),
registry.clone(),
&target_id,
Vec::new(),
)));
let source_agent = Arc::new(Mutex::new(build_test_agent_with_id(
provider.clone(),
registry.clone(),
&source_id,
Vec::new(),
)));
let sessions = Arc::new(RwLock::new(HashMap::from([(
target_id.clone(),
Arc::clone(&target_agent),
)])));
let now = Instant::now();
let (disconnect_tx, _disconnect_rx) = mpsc::unbounded_channel();
let connections = Arc::new(RwLock::new(HashMap::from([(
"incoming".to_string(),
ClientConnectionInfo {
client_id: "incoming".to_string(),
session_id: source_id,
client_instance_id: None,
debug_client_id: None,
connected_at: now,
last_seen: now,
is_processing: false,
current_tool_name: None,
terminal_env: Vec::new(),
disconnect_tx,
},
)])));
let barrier = Arc::new(tokio::sync::Barrier::new(3));
let claim = {
let barrier = Arc::clone(&barrier);
let sessions = Arc::clone(&sessions);
let connections = Arc::clone(&connections);
let source_agent = Arc::clone(&source_agent);
let target_id = target_id.clone();
tokio::spawn(async move {
barrier.wait().await;
claim_live_target_agent(
&target_id,
"incoming",
Some("instance-a"),
&source_agent,
&sessions,
&connections,
)
.await
.is_some()
})
};
let cleanup = {
let barrier = Arc::clone(&barrier);
let sessions = Arc::clone(&sessions);
let connections = Arc::clone(&connections);
let target_agent = Arc::clone(&target_agent);
let target_id = target_id.clone();
tokio::spawn(async move {
barrier.wait().await;
remove_detached_source_if_unclaimed(
&target_id,
"cleanup",
&target_agent,
&sessions,
&connections,
)
.await
})
};
barrier.wait().await;
let claimed = claim.await.expect("claim task should complete");
let removed = cleanup.await.expect("cleanup task should complete");
assert_ne!(claimed, removed, "exactly one transition must win");
assert_eq!(
sessions.read().await.contains_key(&target_id),
claimed,
"a successful claim must keep its target registered"
);
if claimed {
let connections = connections.read().await;
let incoming = connections.get("incoming").expect("incoming connection");
assert_eq!(incoming.session_id, target_id);
assert_eq!(incoming.client_instance_id.as_deref(), Some("instance-a"));
}
}
}
#[path = "client_session_tests/clear.rs"]
mod clear_tests;
#[path = "client_session_tests/reload.rs"]
mod reload_tests;
#[path = "client_session_tests/resume.rs"]
mod resume_tests;
@@ -0,0 +1,156 @@
use super::*;
use anyhow::{Result, anyhow};
#[tokio::test]
async fn handle_clear_session_replaces_runtime_handles_and_updates_shutdown_registration()
-> Result<()> {
let _guard = crate::storage::lock_test_env();
let old_session_id = "session_before_clear";
let provider: Arc<dyn Provider> = Arc::new(MockProvider);
let registry = Registry::new(provider.clone()).await;
let agent = Arc::new(Mutex::new(build_test_agent_with_id(
provider.clone(),
registry.clone(),
old_session_id,
Vec::new(),
)));
let old_queue = {
let guard = agent.lock().await;
guard.soft_interrupt_queue()
};
let old_background_signal = {
let guard = agent.lock().await;
guard.background_tool_signal()
};
let old_cancel_signal = {
let guard = agent.lock().await;
guard.graceful_shutdown_signal()
};
let sessions = Arc::new(RwLock::new(HashMap::from([(
old_session_id.to_string(),
Arc::clone(&agent),
)])));
let shutdown_signals = Arc::new(RwLock::new(HashMap::from([(
old_session_id.to_string(),
old_cancel_signal.clone(),
)])));
let soft_interrupt_queues: SessionInterruptQueues = Arc::new(RwLock::new(HashMap::from([(
old_session_id.to_string(),
old_queue.clone(),
)])));
let now = Instant::now();
let client_connections = Arc::new(RwLock::new(HashMap::from([(
"conn_clear".to_string(),
ClientConnectionInfo {
client_id: "conn_clear".to_string(),
session_id: old_session_id.to_string(),
client_instance_id: None,
debug_client_id: Some("debug_clear".to_string()),
connected_at: now,
last_seen: now,
is_processing: false,
current_tool_name: None,
terminal_env: Vec::new(),
disconnect_tx: mpsc::unbounded_channel().0,
},
)])));
let swarm_members = Arc::new(RwLock::new(HashMap::<String, SwarmMember>::new()));
let swarms_by_id = Arc::new(RwLock::new(HashMap::<String, HashSet<String>>::new()));
let file_touch = FileTouchService::new();
let channel_subscriptions = Arc::new(RwLock::new(HashMap::<
String,
HashMap<String, HashSet<String>>,
>::new()));
let channel_subscriptions_by_session = Arc::new(RwLock::new(HashMap::<
String,
HashMap<String, HashSet<String>>,
>::new()));
let swarm_plans = Arc::new(RwLock::new(HashMap::<String, VersionedPlan>::new()));
let event_history = Arc::new(RwLock::new(VecDeque::<SwarmEvent>::new()));
let event_counter = Arc::new(std::sync::atomic::AtomicU64::new(0));
let (swarm_event_tx, _swarm_event_rx) = broadcast::channel::<SwarmEvent>(8);
let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel::<ServerEvent>();
let mut client_session_id = old_session_id.to_string();
handle_clear_session(
7,
false,
&mut client_session_id,
"conn_clear",
&agent,
&provider,
&registry,
&sessions,
&shutdown_signals,
&soft_interrupt_queues,
&client_connections,
&swarm_members,
&swarms_by_id,
&file_touch,
&channel_subscriptions,
&channel_subscriptions_by_session,
&swarm_plans,
&event_history,
&event_counter,
&swarm_event_tx,
&client_event_tx,
)
.await;
assert_ne!(client_session_id, old_session_id);
old_queue
.lock()
.map_err(|_| anyhow!("old queue lock"))?
.push(jcode_agent_runtime::SoftInterruptMessage {
content: "stale queued message".to_string(),
urgent: false,
source: jcode_agent_runtime::SoftInterruptSource::User,
});
old_background_signal.fire();
old_cancel_signal.fire();
let (new_queue, new_background_signal, new_cancel_signal) = {
let guard = agent.lock().await;
(
guard.soft_interrupt_queue(),
guard.background_tool_signal(),
guard.graceful_shutdown_signal(),
)
};
assert!(!Arc::ptr_eq(&old_queue, &new_queue));
assert!(!new_background_signal.is_set());
assert!(!new_cancel_signal.is_set());
assert!(!agent.lock().await.has_soft_interrupts());
let queue_map = soft_interrupt_queues.read().await;
assert!(!queue_map.contains_key(old_session_id));
assert!(queue_map.contains_key(&client_session_id));
drop(queue_map);
let signals = shutdown_signals.read().await;
assert!(!signals.contains_key(old_session_id));
let registered_signal = signals
.get(&client_session_id)
.ok_or_else(|| anyhow!("new session should have shutdown signal"))?
.clone();
drop(signals);
registered_signal.fire();
assert!(new_cancel_signal.is_set());
let first = client_event_rx
.recv()
.await
.ok_or_else(|| anyhow!("session id event"))?;
assert!(matches!(first, ServerEvent::SessionId { .. }));
let second = client_event_rx
.recv()
.await
.ok_or_else(|| anyhow!("done event"))?;
assert!(matches!(second, ServerEvent::Done { id: 7 }));
Ok(())
}
@@ -0,0 +1,495 @@
use super::*;
use anyhow::{Result, anyhow};
#[test]
fn detects_reload_interrupted_generation_text() {
let agent = test_agent(vec![crate::session::StoredMessage {
id: "msg_1".to_string(),
role: crate::message::Role::Assistant,
content: vec![ContentBlock::Text {
text: "partial\n\n[generation interrupted - server reloading]".to_string(),
cache_control: None,
}],
display_role: None,
timestamp: None,
tool_duration_ms: None,
token_usage: None,
}]);
assert!(session_was_interrupted_by_reload(&agent));
}
#[test]
fn detects_reload_interrupted_tool_result() {
let agent = test_agent(vec![crate::session::StoredMessage {
id: "msg_2".to_string(),
role: crate::message::Role::User,
content: vec![ContentBlock::ToolResult {
tool_use_id: "tool_1".to_string(),
content: "[Tool 'bash' interrupted by server reload after 0.2s]".to_string(),
is_error: Some(true),
}],
display_role: None,
timestamp: None,
tool_duration_ms: None,
token_usage: None,
}]);
assert!(session_was_interrupted_by_reload(&agent));
}
#[test]
fn detects_reload_skipped_tool_result() {
let agent = test_agent(vec![crate::session::StoredMessage {
id: "msg_3".to_string(),
role: crate::message::Role::User,
content: vec![ContentBlock::ToolResult {
tool_use_id: "tool_2".to_string(),
content: "[Skipped - server reloading]".to_string(),
is_error: Some(true),
}],
display_role: None,
timestamp: None,
tool_duration_ms: None,
token_usage: None,
}]);
assert!(session_was_interrupted_by_reload(&agent));
}
#[test]
fn detects_selfdev_reload_tool_result_even_when_not_marked_error() {
let agent = test_agent(vec![crate::session::StoredMessage {
id: "msg_3b".to_string(),
role: crate::message::Role::User,
content: vec![ContentBlock::ToolResult {
tool_use_id: "tool_2b".to_string(),
content: "Reload initiated. Process restarting...".to_string(),
is_error: Some(false),
}],
display_role: None,
timestamp: None,
tool_duration_ms: None,
token_usage: None,
}]);
assert!(session_was_interrupted_by_reload(&agent));
}
#[test]
fn ignores_normal_tool_errors() {
let agent = test_agent(vec![crate::session::StoredMessage {
id: "msg_4".to_string(),
role: crate::message::Role::User,
content: vec![ContentBlock::ToolResult {
tool_use_id: "tool_3".to_string(),
content: "Error: file not found".to_string(),
is_error: Some(true),
}],
display_role: None,
timestamp: None,
tool_duration_ms: None,
token_usage: None,
}]);
assert!(!session_was_interrupted_by_reload(&agent));
}
#[test]
fn restored_closed_session_with_reload_marker_still_counts_as_interrupted() {
let agent = test_agent(vec![crate::session::StoredMessage {
id: "msg_5".to_string(),
role: crate::message::Role::Assistant,
content: vec![ContentBlock::Text {
text: "partial\n\n[generation interrupted - server reloading]".to_string(),
cache_control: None,
}],
display_role: None,
timestamp: None,
tool_duration_ms: None,
token_usage: None,
}]);
assert!(restored_session_was_interrupted(
"session_test_reload",
&crate::session::SessionStatus::Closed,
&agent,
));
}
#[test]
fn restored_closed_session_with_pending_user_message_during_reload_should_count_as_interrupted()
-> Result<()> {
let _guard = crate::storage::lock_test_env();
let runtime = tempfile::TempDir::new().map_err(|e| anyhow!(e))?;
let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR");
crate::env::set_var("JCODE_RUNTIME_DIR", runtime.path());
crate::server::write_reload_state(
"reload-pending-user",
"test-hash",
crate::server::ReloadPhase::Starting,
Some("session_test_reload".to_string()),
);
let agent = test_agent(vec![crate::session::StoredMessage {
id: "msg_pending_reload".to_string(),
role: crate::message::Role::User,
content: vec![ContentBlock::Text {
text: "continue this after reload".to_string(),
cache_control: None,
}],
display_role: None,
timestamp: None,
tool_duration_ms: None,
token_usage: None,
}]);
let interrupted = restored_session_was_interrupted(
"session_test_reload",
&crate::session::SessionStatus::Closed,
&agent,
);
crate::server::clear_reload_marker();
if let Some(prev_runtime) = prev_runtime {
crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime);
} else {
crate::env::remove_var("JCODE_RUNTIME_DIR");
}
assert!(
interrupted,
"a session closed by reload cleanup while processing should be auto-resumed"
);
Ok(())
}
#[test]
fn restored_closed_session_with_pending_user_message_during_socket_ready_handoff_counts_as_interrupted()
-> Result<()> {
let _guard = crate::storage::lock_test_env();
let runtime = tempfile::TempDir::new().map_err(|e| anyhow!(e))?;
let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR");
crate::env::set_var("JCODE_RUNTIME_DIR", runtime.path());
crate::server::write_reload_state(
"reload-pending-user-ready",
"test-hash",
crate::server::ReloadPhase::SocketReady,
Some("session_test_reload".to_string()),
);
let agent = test_agent(vec![crate::session::StoredMessage {
id: "msg_pending_reload_ready".to_string(),
role: crate::message::Role::User,
content: vec![ContentBlock::Text {
text: "continue this after socket-ready handoff".to_string(),
cache_control: None,
}],
display_role: None,
timestamp: None,
tool_duration_ms: None,
token_usage: None,
}]);
let interrupted = restored_session_was_interrupted(
"session_test_reload",
&crate::session::SessionStatus::Closed,
&agent,
);
crate::server::clear_reload_marker();
if let Some(prev_runtime) = prev_runtime {
crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime);
} else {
crate::env::remove_var("JCODE_RUNTIME_DIR");
}
assert!(
interrupted,
"a pending user turn closed during socket-ready handoff should still be auto-resumed"
);
Ok(())
}
#[test]
fn restored_closed_session_with_pending_user_message_without_reload_marker_is_not_interrupted() {
let _guard = crate::storage::lock_test_env();
let runtime = tempfile::TempDir::new().expect("runtime dir");
let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR");
crate::env::set_var("JCODE_RUNTIME_DIR", runtime.path());
crate::server::clear_reload_marker();
let agent = test_agent(vec![crate::session::StoredMessage {
id: "msg_pending_normal_close".to_string(),
role: crate::message::Role::User,
content: vec![ContentBlock::Text {
text: "normal pending user text".to_string(),
cache_control: None,
}],
display_role: None,
timestamp: None,
tool_duration_ms: None,
token_usage: None,
}]);
let interrupted = restored_session_was_interrupted(
"session_test_reload",
&crate::session::SessionStatus::Closed,
&agent,
);
if let Some(prev_runtime) = prev_runtime {
crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime);
} else {
crate::env::remove_var("JCODE_RUNTIME_DIR");
}
assert!(!interrupted);
}
#[test]
fn restored_closed_session_without_reload_marker_is_not_interrupted() {
let agent = test_agent(vec![crate::session::StoredMessage {
id: "msg_6".to_string(),
role: crate::message::Role::Assistant,
content: vec![ContentBlock::Text {
text: "finished normally".to_string(),
cache_control: None,
}],
display_role: None,
timestamp: None,
tool_duration_ms: None,
token_usage: None,
}]);
assert!(!restored_session_was_interrupted(
"session_test_reload",
&crate::session::SessionStatus::Closed,
&agent,
));
}
#[test]
fn mark_remote_reload_started_writes_starting_marker() -> Result<()> {
let _guard = crate::storage::lock_test_env();
let temp = tempfile::TempDir::new().map_err(|e| anyhow!(e))?;
let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR");
crate::env::set_var("JCODE_RUNTIME_DIR", temp.path());
mark_remote_reload_started("reload-test");
let state = crate::server::recent_reload_state(std::time::Duration::from_secs(5))
.ok_or_else(|| anyhow!("reload state should exist"))?;
assert_eq!(state.request_id, "reload-test");
assert_eq!(state.phase, crate::server::ReloadPhase::Starting);
crate::server::clear_reload_marker();
if let Some(prev_runtime) = prev_runtime {
crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime);
} else {
crate::env::remove_var("JCODE_RUNTIME_DIR");
}
Ok(())
}
#[test]
fn handle_reload_queues_signal_for_canary_session() -> Result<()> {
let _guard = crate::storage::lock_test_env();
let temp = tempfile::TempDir::new().map_err(|e| anyhow!(e))?;
let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR");
crate::env::set_var("JCODE_RUNTIME_DIR", temp.path());
let rt = tokio::runtime::Runtime::new().map_err(|e| anyhow!(e))?;
rt.block_on(async {
let mut rx = crate::server::subscribe_reload_signal_for_tests();
let provider: Arc<dyn Provider> = Arc::new(MockProvider);
let registry = Registry::new(provider.clone()).await;
let mut agent = build_test_agent(provider, registry, Vec::new());
agent.set_canary("self-dev");
let agent = Arc::new(Mutex::new(agent));
let (tx, mut events) = mpsc::unbounded_channel::<ServerEvent>();
let (peer_tx, mut peer_events) = mpsc::unbounded_channel::<ServerEvent>();
let now = Instant::now();
let swarm_members = Arc::new(RwLock::new(HashMap::from([
(
"session_test_reload".to_string(),
SwarmMember {
session_id: "session_test_reload".to_string(),
event_tx: tx.clone(),
event_txs: HashMap::from([("conn-trigger".to_string(), tx.clone())]),
working_dir: None,
swarm_id: None,
swarm_enabled: false,
status: "ready".to_string(),
detail: None,
task_label: None,
friendly_name: Some("trigger".to_string()),
report_back_to_session_id: None,
latest_completion_report: None,
role: "agent".to_string(),
joined_at: now,
last_status_change: now,
is_headless: false,
output_tail: None,
todo_progress: None,
todo_items: Vec::new(),
runtime: crate::protocol::SwarmMemberRuntime::default(),
},
),
(
"session_peer".to_string(),
SwarmMember {
session_id: "session_peer".to_string(),
event_tx: peer_tx.clone(),
event_txs: HashMap::from([("conn-peer".to_string(), peer_tx.clone())]),
working_dir: None,
swarm_id: None,
swarm_enabled: false,
status: "ready".to_string(),
detail: None,
task_label: None,
friendly_name: Some("peer".to_string()),
report_back_to_session_id: None,
latest_completion_report: None,
role: "agent".to_string(),
joined_at: now,
last_status_change: now,
is_headless: false,
output_tail: None,
todo_progress: None,
todo_items: Vec::new(),
runtime: crate::protocol::SwarmMemberRuntime::default(),
},
),
])));
handle_reload(7, true, "session_test_reload", &agent, &swarm_members, &tx).await;
let reloading = events
.recv()
.await
.ok_or_else(|| anyhow!("reloading event"))?;
assert!(matches!(reloading, ServerEvent::Reloading { .. }));
let peer_reloading = peer_events
.recv()
.await
.ok_or_else(|| anyhow!("peer reloading event"))?;
assert!(matches!(peer_reloading, ServerEvent::Reloading { .. }));
let done = events.recv().await.ok_or_else(|| anyhow!("done event"))?;
assert!(matches!(done, ServerEvent::Done { id: 7 }));
tokio::time::timeout(std::time::Duration::from_secs(1), rx.changed())
.await
.map_err(|_| anyhow!("reload signal timeout"))?
.map_err(|e| anyhow!("reload signal should be delivered: {e}"))?;
let signal = rx
.borrow_and_update()
.clone()
.ok_or_else(|| anyhow!("reload signal payload should exist"))?;
assert_eq!(
signal.triggering_session.as_deref(),
Some("session_test_reload")
);
assert!(signal.prefer_selfdev_binary);
assert_eq!(signal.hash, jcode_build_meta::GIT_HASH);
let state = crate::server::recent_reload_state(std::time::Duration::from_secs(5))
.ok_or_else(|| anyhow!("reload state should exist"))?;
assert_eq!(state.phase, crate::server::ReloadPhase::Starting);
Ok::<_, anyhow::Error>(())
})?;
crate::server::clear_reload_marker();
if let Some(prev_runtime) = prev_runtime {
crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime);
} else {
crate::env::remove_var("JCODE_RUNTIME_DIR");
}
Ok(())
}
#[tokio::test]
async fn handle_reload_does_not_wait_for_busy_agent_lock() -> Result<()> {
let _guard = crate::storage::lock_test_env();
let temp = tempfile::TempDir::new().map_err(|e| anyhow!(e))?;
let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR");
crate::env::set_var("JCODE_RUNTIME_DIR", temp.path());
let mut rx = crate::server::subscribe_reload_signal_for_tests();
let provider: Arc<dyn Provider> = Arc::new(MockProvider);
let registry = Registry::new(provider.clone()).await;
let agent = build_test_agent(provider, registry, Vec::new());
let agent = Arc::new(Mutex::new(agent));
let busy_agent_lock = agent.lock().await;
let (tx, mut events) = mpsc::unbounded_channel::<ServerEvent>();
let swarm_members = Arc::new(RwLock::new(HashMap::new()));
tokio::time::timeout(
std::time::Duration::from_millis(100),
handle_reload(
11,
true,
"session_fallback_reload",
&agent,
&swarm_members,
&tx,
),
)
.await
.map_err(|_| anyhow!("handle_reload waited for a busy agent lock"))?;
let reloading = events
.recv()
.await
.ok_or_else(|| anyhow!("reloading event"))?;
assert!(matches!(reloading, ServerEvent::Reloading { .. }));
let done = events.recv().await.ok_or_else(|| anyhow!("done event"))?;
assert!(matches!(done, ServerEvent::Done { id: 11 }));
tokio::time::timeout(std::time::Duration::from_secs(1), rx.changed())
.await
.map_err(|_| anyhow!("reload signal timeout"))?
.map_err(|e| anyhow!("reload signal should be delivered: {e}"))?;
let signal = rx
.borrow_and_update()
.clone()
.ok_or_else(|| anyhow!("reload signal payload should exist"))?;
assert_eq!(
signal.triggering_session.as_deref(),
Some("session_fallback_reload")
);
assert!(
!signal.prefer_selfdev_binary,
"busy fallback must not wait for canary state"
);
drop(busy_agent_lock);
crate::server::clear_reload_marker();
if let Some(prev_runtime) = prev_runtime {
crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime);
} else {
crate::env::remove_var("JCODE_RUNTIME_DIR");
}
Ok(())
}
#[tokio::test]
async fn rename_shutdown_signal_moves_registration_to_restored_session() -> Result<()> {
let signal = InterruptSignal::new();
let shutdown_signals = Arc::new(RwLock::new(HashMap::from([(
"session_old".to_string(),
signal.clone(),
)])));
rename_shutdown_signal(&shutdown_signals, "session_old", "session_restored").await;
let signals = shutdown_signals.read().await;
assert!(!signals.contains_key("session_old"));
let renamed = signals
.get("session_restored")
.ok_or_else(|| anyhow!("restored session should retain shutdown signal"))?;
renamed.fire();
assert!(signal.is_set());
Ok(())
}
@@ -0,0 +1,32 @@
use super::*;
use crate::transport::WriteHalf;
use anyhow::{Result, anyhow};
fn setup_runtime_dir() -> Result<(tempfile::TempDir, Option<std::ffi::OsString>)> {
let runtime = tempfile::TempDir::new().map_err(|e| anyhow!(e))?;
let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR");
crate::env::set_var("JCODE_RUNTIME_DIR", runtime.path());
Ok((runtime, prev_runtime))
}
fn restore_runtime_dir(prev_runtime: Option<std::ffi::OsString>) {
if let Some(prev_runtime) = prev_runtime {
crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime);
} else {
crate::env::remove_var("JCODE_RUNTIME_DIR");
}
}
fn test_writer() -> Result<(Arc<Mutex<WriteHalf>>, crate::transport::Stream)> {
let (stream_a, stream_b) = crate::transport::stream_pair().map_err(|e| anyhow!(e))?;
let (_reader, writer_half) = stream_a.into_split();
Ok((Arc::new(Mutex::new(writer_half)), stream_b))
}
include!("resume/multiple_live_attach.rs");
include!("resume/busy_existing_attach.rs");
include!("resume/reconnect_takeover_with_history.rs");
include!("resume/attach_without_local_history.rs");
include!("resume/different_client_attach.rs");
include!("resume/live_events_before_history.rs");
include!("resume/same_client_takeover.rs");
@@ -0,0 +1,174 @@
#[tokio::test]
async fn handle_resume_session_allows_attach_without_local_history() -> Result<()> {
let _guard = crate::storage::lock_test_env();
let (_runtime, prev_runtime) = setup_runtime_dir()?;
let target_session_id = "session_existing_live_takeover_rejected";
let temp_session_id = "session_temp_connecting_takeover_rejected";
let mut persisted = crate::session::Session::create_with_id(
target_session_id.to_string(),
None,
Some("Reconnect Takeover Rejected".to_string()),
);
persisted.save()?;
let provider: Arc<dyn Provider> = Arc::new(MockProvider);
let existing_registry = Registry::new(provider.clone()).await;
let existing_agent = Arc::new(Mutex::new(build_test_agent_with_id(
provider.clone(),
existing_registry,
target_session_id,
Vec::new(),
)));
let new_registry = Registry::new(provider.clone()).await;
let new_agent = Arc::new(Mutex::new(build_test_agent_with_id(
provider.clone(),
new_registry.clone(),
temp_session_id,
Vec::new(),
)));
let sessions = Arc::new(RwLock::new(HashMap::from([
(target_session_id.to_string(), Arc::clone(&existing_agent)),
(temp_session_id.to_string(), Arc::clone(&new_agent)),
])));
let shutdown_signals = Arc::new(RwLock::new(HashMap::<String, InterruptSignal>::new()));
let soft_interrupt_queues: SessionInterruptQueues = Arc::new(RwLock::new(HashMap::new()));
let now = Instant::now();
let (disconnect_tx, mut disconnect_rx) = mpsc::unbounded_channel();
let client_connections = Arc::new(RwLock::new(HashMap::from([
(
"conn_existing".to_string(),
ClientConnectionInfo {
client_id: "conn_existing".to_string(),
session_id: target_session_id.to_string(),
client_instance_id: None,
debug_client_id: Some("debug_existing".to_string()),
connected_at: now,
last_seen: now,
is_processing: false,
current_tool_name: None,
terminal_env: Vec::new(),
disconnect_tx,
},
),
(
"conn_new".to_string(),
ClientConnectionInfo {
client_id: "conn_new".to_string(),
session_id: temp_session_id.to_string(),
client_instance_id: None,
debug_client_id: Some("debug_new".to_string()),
connected_at: now,
last_seen: now,
is_processing: false,
current_tool_name: None,
terminal_env: Vec::new(),
disconnect_tx: mpsc::unbounded_channel().0,
},
),
])));
let client_debug_state = Arc::new(RwLock::new(ClientDebugState::default()));
let swarm_members = Arc::new(RwLock::new(HashMap::<String, SwarmMember>::new()));
let swarms_by_id = Arc::new(RwLock::new(HashMap::<String, HashSet<String>>::new()));
let file_touch = FileTouchService::new();
let channel_subscriptions = Arc::new(RwLock::new(HashMap::<
String,
HashMap<String, HashSet<String>>,
>::new()));
let channel_subscriptions_by_session = Arc::new(RwLock::new(HashMap::<
String,
HashMap<String, HashSet<String>>,
>::new()));
let swarm_plans = Arc::new(RwLock::new(HashMap::<String, VersionedPlan>::new()));
let swarm_coordinators = Arc::new(RwLock::new(HashMap::<String, String>::new()));
let client_count = Arc::new(RwLock::new(2usize));
let (writer, _peer_stream) = test_writer()?;
let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel::<ServerEvent>();
let event_history = Arc::new(RwLock::new(VecDeque::<SwarmEvent>::new()));
let event_counter = Arc::new(std::sync::atomic::AtomicU64::new(0));
let (swarm_event_tx, _swarm_event_rx) = broadcast::channel::<SwarmEvent>(8);
let mcp_pool = Arc::new(crate::mcp::SharedMcpPool::from_default_config());
let mut client_selfdev = false;
let mut client_session_id = temp_session_id.to_string();
handle_resume_session(
44,
target_session_id.to_string(),
None,
None,
false,
true,
&mut client_selfdev,
&mut client_session_id,
"conn_new",
&new_agent,
&provider,
&new_registry,
&sessions,
&shutdown_signals,
&soft_interrupt_queues,
&client_connections,
&client_debug_state,
&swarm_members,
&swarms_by_id,
&file_touch,
&channel_subscriptions,
&channel_subscriptions_by_session,
&swarm_plans,
&swarm_coordinators,
&client_count,
&writer,
"test-server",
"🌿",
&client_event_tx,
&mcp_pool,
&event_history,
&event_counter,
&swarm_event_tx,
)
.await?;
let events = collect_events_until_done(&mut client_event_rx, 44).await;
assert!(
events
.iter()
.any(|event| matches!(event, ServerEvent::Done { id } if *id == 44)),
"expected Done event for live attach, got {events:?}"
);
assert!(
!events
.iter()
.any(|event| matches!(event, ServerEvent::Error { .. })),
"attach should not emit error events: {events:?}"
);
assert_eq!(client_session_id, target_session_id);
assert!(
disconnect_rx.try_recv().is_err(),
"existing live client must not be kicked"
);
let connections = client_connections.read().await;
assert!(connections.contains_key("conn_existing"));
assert_eq!(
connections
.get("conn_new")
.map(|info| info.session_id.as_str()),
Some(target_session_id)
);
drop(connections);
let sessions_guard = sessions.read().await;
assert!(Arc::ptr_eq(
sessions_guard
.get(target_session_id)
.ok_or_else(|| anyhow!("existing live session should remain mapped"))?,
&existing_agent
));
assert!(!sessions_guard.contains_key(temp_session_id));
restore_runtime_dir(prev_runtime);
Ok(())
}
@@ -0,0 +1,178 @@
#[tokio::test]
async fn handle_resume_session_allows_live_attach_when_existing_agent_is_busy() -> Result<()> {
let _guard = crate::storage::lock_test_env();
let (_runtime, prev_runtime) = setup_runtime_dir()?;
let target_session_id = "session_existing_live_busy";
let temp_session_id = "session_temp_connecting_busy";
let persisted_message = crate::session::StoredMessage {
id: "msg-live-busy".to_string(),
role: crate::message::Role::User,
content: vec![crate::message::ContentBlock::Text {
text: "persisted busy attach history".to_string(),
cache_control: None,
}],
display_role: None,
timestamp: None,
tool_duration_ms: None,
token_usage: None,
};
let provider: Arc<dyn Provider> = Arc::new(MockProvider);
let existing_registry = Registry::new(provider.clone()).await;
let existing_agent = Arc::new(Mutex::new(build_test_agent_with_id(
provider.clone(),
existing_registry,
target_session_id,
vec![persisted_message],
)));
let new_registry = Registry::new(provider.clone()).await;
let new_agent = Arc::new(Mutex::new(build_test_agent_with_id(
provider.clone(),
new_registry.clone(),
temp_session_id,
Vec::new(),
)));
let sessions = Arc::new(RwLock::new(HashMap::from([
(target_session_id.to_string(), Arc::clone(&existing_agent)),
(temp_session_id.to_string(), Arc::clone(&new_agent)),
])));
let shutdown_signals = Arc::new(RwLock::new(HashMap::<String, InterruptSignal>::new()));
let soft_interrupt_queues: SessionInterruptQueues = Arc::new(RwLock::new(HashMap::new()));
let now = Instant::now();
let client_connections = Arc::new(RwLock::new(HashMap::from([
(
"conn_existing".to_string(),
ClientConnectionInfo {
client_id: "conn_existing".to_string(),
session_id: target_session_id.to_string(),
client_instance_id: None,
debug_client_id: Some("debug_existing".to_string()),
connected_at: now,
last_seen: now,
is_processing: true,
current_tool_name: Some("bash".to_string()),
terminal_env: Vec::new(),
disconnect_tx: mpsc::unbounded_channel().0,
},
),
(
"conn_new".to_string(),
ClientConnectionInfo {
client_id: "conn_new".to_string(),
session_id: temp_session_id.to_string(),
client_instance_id: None,
debug_client_id: Some("debug_new".to_string()),
connected_at: now,
last_seen: now,
is_processing: false,
current_tool_name: None,
terminal_env: Vec::new(),
disconnect_tx: mpsc::unbounded_channel().0,
},
),
])));
let swarm_members = Arc::new(RwLock::new(HashMap::<String, SwarmMember>::new()));
let swarms_by_id = Arc::new(RwLock::new(HashMap::<String, HashSet<String>>::new()));
let file_touch = FileTouchService::new();
let channel_subscriptions = Arc::new(RwLock::new(HashMap::<
String,
HashMap<String, HashSet<String>>,
>::new()));
let channel_subscriptions_by_session = Arc::new(RwLock::new(HashMap::<
String,
HashMap<String, HashSet<String>>,
>::new()));
let swarm_plans = Arc::new(RwLock::new(HashMap::<String, VersionedPlan>::new()));
let swarm_coordinators = Arc::new(RwLock::new(HashMap::<String, String>::new()));
let client_count = Arc::new(RwLock::new(2usize));
let (writer, peer_stream) = test_writer()?;
let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel::<ServerEvent>();
let event_history = Arc::new(RwLock::new(VecDeque::<SwarmEvent>::new()));
let event_counter = Arc::new(std::sync::atomic::AtomicU64::new(0));
let (swarm_event_tx, _swarm_event_rx) = broadcast::channel::<SwarmEvent>(8);
let mcp_pool = Arc::new(crate::mcp::SharedMcpPool::from_default_config());
let mut client_selfdev = false;
let mut client_session_id = temp_session_id.to_string();
let _busy_guard = existing_agent.lock().await;
handle_resume_session(
77,
target_session_id.to_string(),
None,
None,
false,
false,
&mut client_selfdev,
&mut client_session_id,
"conn_new",
&new_agent,
&provider,
&new_registry,
&sessions,
&shutdown_signals,
&soft_interrupt_queues,
&client_connections,
&Arc::new(RwLock::new(ClientDebugState::default())),
&swarm_members,
&swarms_by_id,
&file_touch,
&channel_subscriptions,
&channel_subscriptions_by_session,
&swarm_plans,
&swarm_coordinators,
&client_count,
&writer,
"test-server",
"🌿",
&client_event_tx,
&mcp_pool,
&event_history,
&event_counter,
&swarm_event_tx,
)
.await?;
let events = collect_events_until_done(&mut client_event_rx, 77).await;
assert!(
events
.iter()
.any(|event| matches!(event, ServerEvent::Done { id } if *id == 77)),
"expected Done event for busy live attach, got {events:?}"
);
assert!(
!events
.iter()
.any(|event| matches!(event, ServerEvent::Error { .. })),
"busy live attach should not emit error events: {events:?}"
);
let mut peer_reader = tokio::io::BufReader::new(peer_stream);
let mut line = String::new();
tokio::time::timeout(
std::time::Duration::from_secs(1),
tokio::io::AsyncBufReadExt::read_line(&mut peer_reader, &mut line),
)
.await
.expect("history should be written promptly")?;
let event: ServerEvent = serde_json::from_str(line.trim())?;
match event {
ServerEvent::History {
session_id,
messages,
..
} => {
assert_eq!(session_id, target_session_id);
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].content, "persisted busy attach history");
}
other => panic!("expected history event, got {other:?}"),
}
restore_runtime_dir(prev_runtime);
Ok(())
}
@@ -0,0 +1,174 @@
#[tokio::test]
async fn handle_resume_session_allows_attach_from_different_client_instance() -> Result<()> {
let _guard = crate::storage::lock_test_env();
let (_runtime, prev_runtime) = setup_runtime_dir()?;
let target_session_id = "session_existing_live_local_history_rejected";
let temp_session_id = "session_temp_connecting_local_history_rejected";
let mut persisted = crate::session::Session::create_with_id(
target_session_id.to_string(),
None,
Some("Reconnect Local History Rejected".to_string()),
);
persisted.save()?;
let provider: Arc<dyn Provider> = Arc::new(MockProvider);
let existing_registry = Registry::new(provider.clone()).await;
let existing_agent = Arc::new(Mutex::new(build_test_agent_with_id(
provider.clone(),
existing_registry,
target_session_id,
Vec::new(),
)));
let new_registry = Registry::new(provider.clone()).await;
let new_agent = Arc::new(Mutex::new(build_test_agent_with_id(
provider.clone(),
new_registry.clone(),
temp_session_id,
Vec::new(),
)));
let sessions = Arc::new(RwLock::new(HashMap::from([
(target_session_id.to_string(), Arc::clone(&existing_agent)),
(temp_session_id.to_string(), Arc::clone(&new_agent)),
])));
let shutdown_signals = Arc::new(RwLock::new(HashMap::<String, InterruptSignal>::new()));
let soft_interrupt_queues: SessionInterruptQueues = Arc::new(RwLock::new(HashMap::new()));
let now = Instant::now();
let (disconnect_tx, mut disconnect_rx) = mpsc::unbounded_channel();
let client_connections = Arc::new(RwLock::new(HashMap::from([
(
"conn_existing".to_string(),
ClientConnectionInfo {
client_id: "conn_existing".to_string(),
session_id: target_session_id.to_string(),
client_instance_id: Some("client_instance_existing".to_string()),
debug_client_id: Some("debug_existing".to_string()),
connected_at: now,
last_seen: now,
is_processing: false,
current_tool_name: None,
terminal_env: Vec::new(),
disconnect_tx,
},
),
(
"conn_new".to_string(),
ClientConnectionInfo {
client_id: "conn_new".to_string(),
session_id: temp_session_id.to_string(),
client_instance_id: Some("client_instance_new".to_string()),
debug_client_id: Some("debug_new".to_string()),
connected_at: now,
last_seen: now,
is_processing: false,
current_tool_name: None,
terminal_env: Vec::new(),
disconnect_tx: mpsc::unbounded_channel().0,
},
),
])));
let client_debug_state = Arc::new(RwLock::new(ClientDebugState::default()));
let swarm_members = Arc::new(RwLock::new(HashMap::<String, SwarmMember>::new()));
let swarms_by_id = Arc::new(RwLock::new(HashMap::<String, HashSet<String>>::new()));
let file_touch = FileTouchService::new();
let channel_subscriptions = Arc::new(RwLock::new(HashMap::<
String,
HashMap<String, HashSet<String>>,
>::new()));
let channel_subscriptions_by_session = Arc::new(RwLock::new(HashMap::<
String,
HashMap<String, HashSet<String>>,
>::new()));
let swarm_plans = Arc::new(RwLock::new(HashMap::<String, VersionedPlan>::new()));
let swarm_coordinators = Arc::new(RwLock::new(HashMap::<String, String>::new()));
let client_count = Arc::new(RwLock::new(2usize));
let (writer, _peer_stream) = test_writer()?;
let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel::<ServerEvent>();
let event_history = Arc::new(RwLock::new(VecDeque::<SwarmEvent>::new()));
let event_counter = Arc::new(std::sync::atomic::AtomicU64::new(0));
let (swarm_event_tx, _swarm_event_rx) = broadcast::channel::<SwarmEvent>(8);
let mcp_pool = Arc::new(crate::mcp::SharedMcpPool::from_default_config());
let mut client_selfdev = false;
let mut client_session_id = temp_session_id.to_string();
handle_resume_session(
45,
target_session_id.to_string(),
None,
Some("client_instance_new"),
true,
true,
&mut client_selfdev,
&mut client_session_id,
"conn_new",
&new_agent,
&provider,
&new_registry,
&sessions,
&shutdown_signals,
&soft_interrupt_queues,
&client_connections,
&client_debug_state,
&swarm_members,
&swarms_by_id,
&file_touch,
&channel_subscriptions,
&channel_subscriptions_by_session,
&swarm_plans,
&swarm_coordinators,
&client_count,
&writer,
"test-server",
"🌿",
&client_event_tx,
&mcp_pool,
&event_history,
&event_counter,
&swarm_event_tx,
)
.await?;
let events = collect_events_until_done(&mut client_event_rx, 45).await;
assert!(
events
.iter()
.any(|event| matches!(event, ServerEvent::Done { id } if *id == 45)),
"expected Done event for live attach, got {events:?}"
);
assert!(
!events
.iter()
.any(|event| matches!(event, ServerEvent::Error { .. })),
"attach should not emit error events: {events:?}"
);
assert_eq!(client_session_id, target_session_id);
assert!(
disconnect_rx.try_recv().is_err(),
"existing live client must not be kicked"
);
let connections = client_connections.read().await;
assert!(connections.contains_key("conn_existing"));
assert_eq!(
connections
.get("conn_new")
.map(|info| (info.session_id.as_str(), info.client_instance_id.as_deref())),
Some((target_session_id, Some("client_instance_new")))
);
drop(connections);
let sessions_guard = sessions.read().await;
assert!(Arc::ptr_eq(
sessions_guard
.get(target_session_id)
.ok_or_else(|| anyhow!("existing live session should remain mapped"))?,
&existing_agent
));
assert!(!sessions_guard.contains_key(temp_session_id));
restore_runtime_dir(prev_runtime);
Ok(())
}
@@ -0,0 +1,206 @@
#[tokio::test]
async fn handle_resume_session_registers_live_events_before_history_replay() -> Result<()> {
let _guard = crate::storage::lock_test_env();
let (_runtime, prev_runtime) = setup_runtime_dir()?;
let target_session_id = "session_restore_target";
let temp_session_id = "session_restore_temp";
let mut persisted = crate::session::Session::create_with_id(
target_session_id.to_string(),
None,
Some("Resume Registration Ordering".to_string()),
);
persisted.save()?;
let provider: Arc<dyn Provider> = Arc::new(MockProvider);
let registry = Registry::new(provider.clone()).await;
let agent = Arc::new(Mutex::new(build_test_agent_with_id(
provider.clone(),
registry.clone(),
temp_session_id,
Vec::new(),
)));
let sessions = Arc::new(RwLock::new(HashMap::from([(
temp_session_id.to_string(),
Arc::clone(&agent),
)])));
let shutdown_signals = Arc::new(RwLock::new(HashMap::<String, InterruptSignal>::new()));
let soft_interrupt_queues: SessionInterruptQueues = Arc::new(RwLock::new(HashMap::new()));
let now = Instant::now();
let client_connections = Arc::new(RwLock::new(HashMap::from([(
"conn_restore".to_string(),
ClientConnectionInfo {
client_id: "conn_restore".to_string(),
session_id: temp_session_id.to_string(),
client_instance_id: None,
debug_client_id: Some("debug_restore".to_string()),
connected_at: now,
last_seen: now,
is_processing: false,
current_tool_name: None,
terminal_env: Vec::new(),
disconnect_tx: mpsc::unbounded_channel().0,
},
)])));
let client_debug_state = Arc::new(RwLock::new(ClientDebugState::default()));
let (placeholder_event_tx, _placeholder_event_rx) = mpsc::unbounded_channel::<ServerEvent>();
let swarm_members = Arc::new(RwLock::new(HashMap::from([(
temp_session_id.to_string(),
SwarmMember {
session_id: temp_session_id.to_string(),
event_tx: placeholder_event_tx,
event_txs: HashMap::new(),
working_dir: None,
swarm_id: None,
swarm_enabled: false,
status: "ready".to_string(),
detail: None,
task_label: None,
friendly_name: Some("restore".to_string()),
report_back_to_session_id: None,
latest_completion_report: None,
role: "agent".to_string(),
joined_at: now,
last_status_change: now,
is_headless: false,
output_tail: None,
todo_progress: None,
todo_items: Vec::new(),
runtime: crate::protocol::SwarmMemberRuntime::default(),
},
)])));
let swarms_by_id = Arc::new(RwLock::new(HashMap::<String, HashSet<String>>::new()));
let file_touch = FileTouchService::new();
let channel_subscriptions = Arc::new(RwLock::new(HashMap::<
String,
HashMap<String, HashSet<String>>,
>::new()));
let channel_subscriptions_by_session = Arc::new(RwLock::new(HashMap::<
String,
HashMap<String, HashSet<String>>,
>::new()));
let swarm_plans = Arc::new(RwLock::new(HashMap::<String, VersionedPlan>::new()));
let swarm_coordinators = Arc::new(RwLock::new(HashMap::<String, String>::new()));
let client_count = Arc::new(RwLock::new(1usize));
let (writer, _peer_stream) = test_writer()?;
let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel::<ServerEvent>();
let event_history = Arc::new(RwLock::new(VecDeque::<SwarmEvent>::new()));
let event_counter = Arc::new(std::sync::atomic::AtomicU64::new(0));
let (swarm_event_tx, _swarm_event_rx) = broadcast::channel::<SwarmEvent>(8);
let mcp_pool = Arc::new(crate::mcp::SharedMcpPool::from_default_config());
let mut client_selfdev = false;
let mut client_session_id = temp_session_id.to_string();
let writer_guard = writer.lock().await;
let resume_task = tokio::spawn({
let agent = Arc::clone(&agent);
let provider = Arc::clone(&provider);
let registry = registry.clone();
let sessions = Arc::clone(&sessions);
let shutdown_signals = Arc::clone(&shutdown_signals);
let soft_interrupt_queues = Arc::clone(&soft_interrupt_queues);
let client_connections = Arc::clone(&client_connections);
let client_debug_state = Arc::clone(&client_debug_state);
let swarm_members = Arc::clone(&swarm_members);
let swarms_by_id = Arc::clone(&swarms_by_id);
let file_touch = file_touch.clone();
let channel_subscriptions = Arc::clone(&channel_subscriptions);
let channel_subscriptions_by_session = Arc::clone(&channel_subscriptions_by_session);
let swarm_plans = Arc::clone(&swarm_plans);
let swarm_coordinators = Arc::clone(&swarm_coordinators);
let client_count = Arc::clone(&client_count);
let writer = Arc::clone(&writer);
let client_event_tx = client_event_tx.clone();
let mcp_pool = Arc::clone(&mcp_pool);
let event_history = Arc::clone(&event_history);
let event_counter = Arc::clone(&event_counter);
let swarm_event_tx = swarm_event_tx.clone();
async move {
handle_resume_session(
46,
target_session_id.to_string(),
None,
None,
false,
false,
&mut client_selfdev,
&mut client_session_id,
"conn_restore",
&agent,
&provider,
&registry,
&sessions,
&shutdown_signals,
&soft_interrupt_queues,
&client_connections,
&client_debug_state,
&swarm_members,
&swarms_by_id,
&file_touch,
&channel_subscriptions,
&channel_subscriptions_by_session,
&swarm_plans,
&swarm_coordinators,
&client_count,
&writer,
"test-server",
"🌿",
&client_event_tx,
&mcp_pool,
&event_history,
&event_counter,
&swarm_event_tx,
)
.await
}
});
tokio::time::timeout(std::time::Duration::from_secs(1), async {
loop {
let registered = {
let members = swarm_members.read().await;
members
.get(target_session_id)
.map(|member| member.event_txs.contains_key("conn_restore"))
.unwrap_or(false)
};
if registered {
break;
}
tokio::task::yield_now().await;
}
})
.await
.map_err(|_| anyhow!("live event sender should register before history replay completes"))?;
assert!(
!resume_task.is_finished(),
"resume should still be blocked on history replay while writer is locked"
);
drop(writer_guard);
resume_task
.await
.map_err(|e| anyhow!("resume task join: {e}"))??;
let events = collect_events_until_done(&mut client_event_rx, 46).await;
assert!(
events
.iter()
.any(|event| matches!(event, ServerEvent::Done { id } if *id == 46)),
"expected Done event for restore resume, got {events:?}"
);
assert!(
!events
.iter()
.any(|event| matches!(event, ServerEvent::Error { .. })),
"restore resume should not emit error events: {events:?}"
);
restore_runtime_dir(prev_runtime);
Ok(())
}
@@ -0,0 +1,160 @@
#[tokio::test]
async fn handle_resume_session_allows_multiple_live_tui_attach() -> Result<()> {
let _guard = crate::storage::lock_test_env();
let (_runtime, prev_runtime) = setup_runtime_dir()?;
let target_session_id = "session_existing_live";
let temp_session_id = "session_temp_connecting";
let provider: Arc<dyn Provider> = Arc::new(MockProvider);
let existing_registry = Registry::new(provider.clone()).await;
let existing_agent = Arc::new(Mutex::new(build_test_agent_with_id(
provider.clone(),
existing_registry,
target_session_id,
Vec::new(),
)));
let new_registry = Registry::new(provider.clone()).await;
let new_agent = Arc::new(Mutex::new(build_test_agent_with_id(
provider.clone(),
new_registry.clone(),
temp_session_id,
Vec::new(),
)));
let sessions = Arc::new(RwLock::new(HashMap::from([
(target_session_id.to_string(), Arc::clone(&existing_agent)),
(temp_session_id.to_string(), Arc::clone(&new_agent)),
])));
let shutdown_signals = Arc::new(RwLock::new(HashMap::<String, InterruptSignal>::new()));
let soft_interrupt_queues: SessionInterruptQueues = Arc::new(RwLock::new(HashMap::new()));
let now = Instant::now();
let client_connections = Arc::new(RwLock::new(HashMap::from([
(
"conn_existing".to_string(),
ClientConnectionInfo {
client_id: "conn_existing".to_string(),
session_id: target_session_id.to_string(),
client_instance_id: None,
debug_client_id: Some("debug_existing".to_string()),
connected_at: now,
last_seen: now,
is_processing: false,
current_tool_name: None,
terminal_env: Vec::new(),
disconnect_tx: mpsc::unbounded_channel().0,
},
),
(
"conn_new".to_string(),
ClientConnectionInfo {
client_id: "conn_new".to_string(),
session_id: temp_session_id.to_string(),
client_instance_id: None,
debug_client_id: Some("debug_new".to_string()),
connected_at: now,
last_seen: now,
is_processing: false,
current_tool_name: None,
terminal_env: Vec::new(),
disconnect_tx: mpsc::unbounded_channel().0,
},
),
])));
let swarm_members = Arc::new(RwLock::new(HashMap::<String, SwarmMember>::new()));
let swarms_by_id = Arc::new(RwLock::new(HashMap::<String, HashSet<String>>::new()));
let file_touch = FileTouchService::new();
let channel_subscriptions = Arc::new(RwLock::new(HashMap::<
String,
HashMap<String, HashSet<String>>,
>::new()));
let channel_subscriptions_by_session = Arc::new(RwLock::new(HashMap::<
String,
HashMap<String, HashSet<String>>,
>::new()));
let swarm_plans = Arc::new(RwLock::new(HashMap::<String, VersionedPlan>::new()));
let swarm_coordinators = Arc::new(RwLock::new(HashMap::<String, String>::new()));
let client_count = Arc::new(RwLock::new(2usize));
let (writer, _peer_stream) = test_writer()?;
let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel::<ServerEvent>();
let event_history = Arc::new(RwLock::new(VecDeque::<SwarmEvent>::new()));
let event_counter = Arc::new(std::sync::atomic::AtomicU64::new(0));
let (swarm_event_tx, _swarm_event_rx) = broadcast::channel::<SwarmEvent>(8);
let mcp_pool = Arc::new(crate::mcp::SharedMcpPool::from_default_config());
let mut client_selfdev = false;
let mut client_session_id = temp_session_id.to_string();
handle_resume_session(
42,
target_session_id.to_string(),
None,
None,
false,
false,
&mut client_selfdev,
&mut client_session_id,
"conn_new",
&new_agent,
&provider,
&new_registry,
&sessions,
&shutdown_signals,
&soft_interrupt_queues,
&client_connections,
&Arc::new(RwLock::new(ClientDebugState::default())),
&swarm_members,
&swarms_by_id,
&file_touch,
&channel_subscriptions,
&channel_subscriptions_by_session,
&swarm_plans,
&swarm_coordinators,
&client_count,
&writer,
"test-server",
"🌿",
&client_event_tx,
&mcp_pool,
&event_history,
&event_counter,
&swarm_event_tx,
)
.await?;
let events = collect_events_until_done(&mut client_event_rx, 42).await;
assert!(
events
.iter()
.any(|event| matches!(event, ServerEvent::Done { id } if *id == 42)),
"expected Done event for live attach, got {events:?}"
);
assert!(
!events
.iter()
.any(|event| matches!(event, ServerEvent::Error { .. })),
"attach should not emit error events: {events:?}"
);
assert_eq!(client_session_id, target_session_id);
let sessions_guard = sessions.read().await;
let mapped_agent = sessions_guard
.get(target_session_id)
.ok_or_else(|| anyhow!("existing live session should remain mapped"))?;
assert!(Arc::ptr_eq(mapped_agent, &existing_agent));
assert!(!sessions_guard.contains_key(temp_session_id));
drop(sessions_guard);
let connections = client_connections.read().await;
assert!(connections.contains_key("conn_existing"));
assert_eq!(
connections
.get("conn_new")
.map(|info| info.session_id.as_str()),
Some(target_session_id)
);
restore_runtime_dir(prev_runtime);
Ok(())
}
@@ -0,0 +1,160 @@
#[tokio::test]
async fn handle_resume_session_allows_reconnect_takeover_with_local_history() -> Result<()> {
let _guard = crate::storage::lock_test_env();
let (_runtime, prev_runtime) = setup_runtime_dir()?;
let target_session_id = "session_existing_live_takeover";
let temp_session_id = "session_temp_connecting_takeover";
let mut persisted = crate::session::Session::create_with_id(
target_session_id.to_string(),
None,
Some("Reconnect Takeover".to_string()),
);
persisted.save()?;
let provider: Arc<dyn Provider> = Arc::new(MockProvider);
let existing_registry = Registry::new(provider.clone()).await;
let existing_agent = Arc::new(Mutex::new(build_test_agent_with_id(
provider.clone(),
existing_registry,
target_session_id,
Vec::new(),
)));
let new_registry = Registry::new(provider.clone()).await;
let new_agent = Arc::new(Mutex::new(build_test_agent_with_id(
provider.clone(),
new_registry.clone(),
temp_session_id,
Vec::new(),
)));
let sessions = Arc::new(RwLock::new(HashMap::from([
(target_session_id.to_string(), Arc::clone(&existing_agent)),
(temp_session_id.to_string(), Arc::clone(&new_agent)),
])));
let shutdown_signals = Arc::new(RwLock::new(HashMap::<String, InterruptSignal>::new()));
let soft_interrupt_queues: SessionInterruptQueues = Arc::new(RwLock::new(HashMap::new()));
let now = Instant::now();
let (disconnect_tx, mut disconnect_rx) = mpsc::unbounded_channel();
let client_connections = Arc::new(RwLock::new(HashMap::from([
(
"conn_existing".to_string(),
ClientConnectionInfo {
client_id: "conn_existing".to_string(),
session_id: target_session_id.to_string(),
client_instance_id: None,
debug_client_id: Some("debug_existing".to_string()),
connected_at: now,
last_seen: now,
is_processing: false,
current_tool_name: None,
terminal_env: Vec::new(),
disconnect_tx,
},
),
(
"conn_new".to_string(),
ClientConnectionInfo {
client_id: "conn_new".to_string(),
session_id: temp_session_id.to_string(),
client_instance_id: None,
debug_client_id: Some("debug_new".to_string()),
connected_at: now,
last_seen: now,
is_processing: false,
current_tool_name: None,
terminal_env: Vec::new(),
disconnect_tx: mpsc::unbounded_channel().0,
},
),
])));
let client_debug_state = Arc::new(RwLock::new(ClientDebugState::default()));
let swarm_members = Arc::new(RwLock::new(HashMap::<String, SwarmMember>::new()));
let swarms_by_id = Arc::new(RwLock::new(HashMap::<String, HashSet<String>>::new()));
let file_touch = FileTouchService::new();
let channel_subscriptions = Arc::new(RwLock::new(HashMap::<
String,
HashMap<String, HashSet<String>>,
>::new()));
let channel_subscriptions_by_session = Arc::new(RwLock::new(HashMap::<
String,
HashMap<String, HashSet<String>>,
>::new()));
let swarm_plans = Arc::new(RwLock::new(HashMap::<String, VersionedPlan>::new()));
let swarm_coordinators = Arc::new(RwLock::new(HashMap::<String, String>::new()));
let client_count = Arc::new(RwLock::new(2usize));
let (writer, _peer_stream) = test_writer()?;
let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel::<ServerEvent>();
let event_history = Arc::new(RwLock::new(VecDeque::<SwarmEvent>::new()));
let event_counter = Arc::new(std::sync::atomic::AtomicU64::new(0));
let (swarm_event_tx, _swarm_event_rx) = broadcast::channel::<SwarmEvent>(8);
let mcp_pool = Arc::new(crate::mcp::SharedMcpPool::from_default_config());
let mut client_selfdev = false;
let mut client_session_id = temp_session_id.to_string();
handle_resume_session(
43,
target_session_id.to_string(),
None,
None,
true,
true,
&mut client_selfdev,
&mut client_session_id,
"conn_new",
&new_agent,
&provider,
&new_registry,
&sessions,
&shutdown_signals,
&soft_interrupt_queues,
&client_connections,
&client_debug_state,
&swarm_members,
&swarms_by_id,
&file_touch,
&channel_subscriptions,
&channel_subscriptions_by_session,
&swarm_plans,
&swarm_coordinators,
&client_count,
&writer,
"test-server",
"🌿",
&client_event_tx,
&mcp_pool,
&event_history,
&event_counter,
&swarm_event_tx,
)
.await?;
while let Ok(event) = client_event_rx.try_recv() {
assert!(
!matches!(event, ServerEvent::Error { .. }),
"resume takeover should not queue an error event: {event:?}"
);
}
assert_eq!(client_session_id, target_session_id);
let disconnect_signal = disconnect_rx.recv().await;
assert!(
disconnect_signal.is_some(),
"old client should be told to disconnect"
);
let connections = client_connections.read().await;
assert!(!connections.contains_key("conn_existing"));
assert_eq!(
connections
.get("conn_new")
.map(|info| info.session_id.as_str()),
Some(target_session_id)
);
restore_runtime_dir(prev_runtime);
Ok(())
}
@@ -0,0 +1,177 @@
#[tokio::test]
async fn handle_resume_session_allows_same_client_instance_takeover_without_local_history()
-> Result<()> {
let _guard = crate::storage::lock_test_env();
let (_runtime, prev_runtime) = setup_runtime_dir()?;
let target_session_id = "session_existing_live_same_instance_takeover";
let temp_session_id = "session_temp_connecting_same_instance_takeover";
let shared_instance_id = "client_instance_same_window";
let mut persisted = crate::session::Session::create_with_id(
target_session_id.to_string(),
None,
Some("Reconnect Same Instance Takeover".to_string()),
);
persisted.save()?;
let provider: Arc<dyn Provider> = Arc::new(MockProvider);
let existing_registry = Registry::new(provider.clone()).await;
let existing_agent = Arc::new(Mutex::new(build_test_agent_with_id(
provider.clone(),
existing_registry,
target_session_id,
Vec::new(),
)));
let new_registry = Registry::new(provider.clone()).await;
let new_agent = Arc::new(Mutex::new(build_test_agent_with_id(
provider.clone(),
new_registry.clone(),
temp_session_id,
Vec::new(),
)));
let sessions = Arc::new(RwLock::new(HashMap::from([
(target_session_id.to_string(), Arc::clone(&existing_agent)),
(temp_session_id.to_string(), Arc::clone(&new_agent)),
])));
let shutdown_signals = Arc::new(RwLock::new(HashMap::<String, InterruptSignal>::new()));
let soft_interrupt_queues: SessionInterruptQueues = Arc::new(RwLock::new(HashMap::new()));
let now = Instant::now();
let (disconnect_tx, mut disconnect_rx) = mpsc::unbounded_channel();
let client_connections = Arc::new(RwLock::new(HashMap::from([
(
"conn_existing".to_string(),
ClientConnectionInfo {
client_id: "conn_existing".to_string(),
session_id: target_session_id.to_string(),
client_instance_id: Some(shared_instance_id.to_string()),
debug_client_id: Some("debug_existing".to_string()),
connected_at: now,
last_seen: now,
is_processing: false,
current_tool_name: None,
terminal_env: Vec::new(),
disconnect_tx,
},
),
(
"conn_new".to_string(),
ClientConnectionInfo {
client_id: "conn_new".to_string(),
session_id: temp_session_id.to_string(),
client_instance_id: Some(shared_instance_id.to_string()),
debug_client_id: Some("debug_new".to_string()),
connected_at: now,
last_seen: now,
is_processing: false,
current_tool_name: None,
terminal_env: Vec::new(),
disconnect_tx: mpsc::unbounded_channel().0,
},
),
])));
let client_debug_state = Arc::new(RwLock::new(ClientDebugState::default()));
let swarm_members = Arc::new(RwLock::new(HashMap::<String, SwarmMember>::new()));
let swarms_by_id = Arc::new(RwLock::new(HashMap::<String, HashSet<String>>::new()));
let file_touch = FileTouchService::new();
let channel_subscriptions = Arc::new(RwLock::new(HashMap::<
String,
HashMap<String, HashSet<String>>,
>::new()));
let channel_subscriptions_by_session = Arc::new(RwLock::new(HashMap::<
String,
HashMap<String, HashSet<String>>,
>::new()));
let swarm_plans = Arc::new(RwLock::new(HashMap::<String, VersionedPlan>::new()));
let swarm_coordinators = Arc::new(RwLock::new(HashMap::<String, String>::new()));
let client_count = Arc::new(RwLock::new(2usize));
let (writer, _peer_stream) = test_writer()?;
let (client_event_tx, mut client_event_rx) = mpsc::unbounded_channel::<ServerEvent>();
let event_history = Arc::new(RwLock::new(VecDeque::<SwarmEvent>::new()));
let event_counter = Arc::new(std::sync::atomic::AtomicU64::new(0));
let (swarm_event_tx, _swarm_event_rx) = broadcast::channel::<SwarmEvent>(8);
let mcp_pool = Arc::new(crate::mcp::SharedMcpPool::from_default_config());
let mut client_selfdev = false;
let mut client_session_id = temp_session_id.to_string();
handle_resume_session(
45,
target_session_id.to_string(),
None,
Some(shared_instance_id),
false,
true,
&mut client_selfdev,
&mut client_session_id,
"conn_new",
&new_agent,
&provider,
&new_registry,
&sessions,
&shutdown_signals,
&soft_interrupt_queues,
&client_connections,
&client_debug_state,
&swarm_members,
&swarms_by_id,
&file_touch,
&channel_subscriptions,
&channel_subscriptions_by_session,
&swarm_plans,
&swarm_coordinators,
&client_count,
&writer,
"test-server",
"🌿",
&client_event_tx,
&mcp_pool,
&event_history,
&event_counter,
&swarm_event_tx,
)
.await?;
let events = collect_events_until_done(&mut client_event_rx, 45).await;
assert!(
events
.iter()
.any(|event| matches!(event, ServerEvent::Done { id } if *id == 45)),
"expected Done event for live attach, got {events:?}"
);
assert!(
!events
.iter()
.any(|event| matches!(event, ServerEvent::Error { .. })),
"same-instance attach should not queue an error event: {events:?}"
);
assert_eq!(client_session_id, target_session_id);
assert!(
disconnect_rx.try_recv().is_err(),
"existing live client should remain connected"
);
let connections = client_connections.read().await;
assert!(connections.contains_key("conn_existing"));
assert_eq!(
connections
.get("conn_new")
.map(|info| (info.session_id.as_str(), info.client_instance_id.as_deref())),
Some((target_session_id, Some(shared_instance_id)))
);
drop(connections);
let sessions_guard = sessions.read().await;
assert!(Arc::ptr_eq(
sessions_guard
.get(target_session_id)
.ok_or_else(|| anyhow!("existing live session should remain mapped"))?,
&existing_agent
));
assert!(!sessions_guard.contains_key(temp_session_id));
restore_runtime_dir(prev_runtime);
Ok(())
}
@@ -0,0 +1,928 @@
use super::ClientConnectionInfo;
use super::server_has_newer_binary;
use crate::agent::Agent;
use crate::bus::Bus;
use crate::message::{ContentBlock, Role};
use crate::protocol::{
HistoryMessage, ServerEvent, SessionActivitySnapshot, TokenUsageTotals, encode_event,
};
use crate::provider::Provider;
use crate::session::{Session, SessionStatus};
use crate::transport::WriteHalf;
use anyhow::Result;
use std::collections::{BTreeMap, HashMap};
use std::sync::{Arc, LazyLock, Mutex as StdMutex};
use std::time::{Duration, Instant};
type SessionAgents = Arc<RwLock<HashMap<String, Arc<Mutex<Agent>>>>>;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum HistoryPayloadMode {
Full,
}
use tokio::io::AsyncWriteExt;
use tokio::sync::{Mutex, RwLock};
const ATTACH_MODEL_PREFETCH_DEBOUNCE_SECS: u64 = 15;
const RELOAD_RESTORE_MARKER_MAX_AGE: Duration = Duration::from_secs(60);
fn optional_token_usage_totals(totals: TokenUsageTotals) -> Option<TokenUsageTotals> {
(totals.messages_with_token_usage > 0).then_some(totals)
}
fn optional_total_tokens(totals: TokenUsageTotals) -> Option<(u64, u64)> {
(totals.messages_with_token_usage > 0).then_some((totals.input_tokens, totals.output_tokens))
}
static LAST_ATTACH_MODEL_PREFETCH: LazyLock<StdMutex<HashMap<String, Instant>>> =
LazyLock::new(|| StdMutex::new(HashMap::new()));
fn should_debounce_attach_model_prefetch(provider_name: &str) -> bool {
let Ok(mut guard) = LAST_ATTACH_MODEL_PREFETCH.lock() else {
return false;
};
let now = Instant::now();
if let Some(last_run) = guard.get(provider_name)
&& now.duration_since(*last_run) < Duration::from_secs(ATTACH_MODEL_PREFETCH_DEBOUNCE_SECS)
{
return true;
}
guard.insert(provider_name.to_string(), now);
false
}
fn history_provider_name_from_session(session: &crate::session::Session) -> Option<String> {
let key = session.provider_key.as_deref()?.trim();
if key.is_empty() {
return None;
}
let label = match key.to_ascii_lowercase().as_str() {
"openai" => "OpenAI".to_string(),
"claude" | "anthropic" => "Anthropic".to_string(),
"openrouter" => "OpenRouter".to_string(),
"copilot" => "GitHub Copilot".to_string(),
"cursor" => "Cursor".to_string(),
"gemini" => "Gemini".to_string(),
"bedrock" => "Bedrock".to_string(),
"antigravity" => "Antigravity".to_string(),
"jcode" => "Jcode".to_string(),
other => other.to_string(),
};
Some(label)
}
pub(super) async fn handle_get_state(
id: u64,
client_session_id: &str,
client_is_processing: bool,
sessions: &SessionAgents,
writer: &Arc<Mutex<WriteHalf>>,
) -> Result<()> {
let session_count = {
let sessions_guard = sessions.read().await;
sessions_guard.len()
};
write_event(
writer,
&ServerEvent::State {
id,
session_id: client_session_id.to_string(),
message_count: session_count,
is_processing: client_is_processing,
},
)
.await
}
#[expect(
clippy::too_many_arguments,
reason = "history fetch needs session state, client activity, provider handle, and server identity metadata"
)]
pub(super) async fn handle_get_history(
id: u64,
client_session_id: &str,
client_is_processing: bool,
agent: &Arc<Mutex<Agent>>,
provider: &Arc<dyn Provider>,
sessions: &SessionAgents,
client_connections: &Arc<RwLock<HashMap<String, ClientConnectionInfo>>>,
client_count: &Arc<RwLock<usize>>,
writer: &Arc<Mutex<WriteHalf>>,
server_name: &str,
server_icon: &str,
was_interrupted: Option<bool>,
) -> Result<()> {
let history_start = Instant::now();
let activity =
session_activity_snapshot(client_connections, client_session_id, client_is_processing)
.await;
if agent.try_lock().is_err() {
crate::logging::info(&format!(
"handle_get_history: session {} busy, falling back to persisted remote-startup snapshot",
client_session_id
));
send_history_from_persisted_session(
id,
client_session_id,
provider,
sessions,
client_count,
writer,
server_name,
server_icon,
was_interrupted,
activity,
)
.await?;
crate::logging::info(&format!(
"[TIMING] handle_get_history: session={}, persisted_fallback total={}ms",
client_session_id,
history_start.elapsed().as_millis(),
));
return Ok(());
}
send_history(
id,
client_session_id,
agent,
sessions,
client_count,
writer,
server_name,
server_icon,
was_interrupted,
activity,
HistoryPayloadMode::Full,
true,
)
.await?;
let send_history_ms = history_start.elapsed().as_millis();
let prefetch_start = Instant::now();
spawn_model_prefetch_update(Arc::clone(provider), Arc::clone(agent));
crate::logging::info(&format!(
"[TIMING] handle_get_history: session={}, send_history={}ms, prefetch_spawn={}ms, total={}ms",
client_session_id,
send_history_ms,
prefetch_start.elapsed().as_millis(),
history_start.elapsed().as_millis(),
));
Ok(())
}
pub(super) async fn handle_get_model_catalog(
id: u64,
session_id: &str,
agent: &Arc<Mutex<Agent>>,
provider: &Arc<dyn Provider>,
writer: &Arc<Mutex<WriteHalf>>,
) -> Result<()> {
let started = Instant::now();
let build_started = Instant::now();
let (
provider_name,
provider_model,
available_models,
available_model_routes,
resolved_credential,
source,
) = {
match agent.try_lock() {
Ok(agent_guard) => (
Some(agent_guard.provider_name()),
Some(agent_guard.provider_model()),
agent_guard.available_models_display(),
agent_guard.model_routes(),
agent_guard.active_resolved_credential(),
"live",
),
Err(_) => {
crate::logging::warn(&format!(
"handle_get_model_catalog: session {} busy, using provider/persisted fallback",
session_id
));
let persisted = Session::load_for_remote_startup(session_id)
.or_else(|_| Session::load_startup_stub(session_id))
.ok();
let persisted_model = persisted.as_ref().and_then(|session| session.model.clone());
(
Some(provider.name().to_string()),
persisted_model.or_else(|| Some(provider.model())),
provider.available_models_display(),
provider.model_routes(),
provider.active_resolved_credential(),
"fallback",
)
}
}
};
let build_ms = build_started.elapsed().as_millis();
let encode_started = Instant::now();
let event = ServerEvent::History {
id,
session_id: session_id.to_string(),
messages: Vec::new(),
images: Vec::new(),
provider_name,
provider_model,
available_models,
available_model_routes,
mcp_servers: Vec::new(),
skills: Vec::new(),
total_tokens: None,
token_usage_totals: None,
all_sessions: Vec::new(),
client_count: None,
is_canary: None,
server_version: None,
server_name: None,
server_icon: None,
server_has_update: None,
was_interrupted: None,
reload_recovery: None,
connection_type: None,
status_detail: None,
upstream_provider: None,
resolved_credential,
reasoning_effort: None,
service_tier: None,
subagent_model: None,
autoreview_enabled: None,
autojudge_enabled: None,
compaction_mode: Default::default(),
activity: None,
side_panel: Default::default(),
};
let json = encode_event(&event);
let encode_ms = encode_started.elapsed().as_millis();
let write_started = Instant::now();
let mut writer_guard = writer.lock().await;
let writer_lock_ms = write_started.elapsed().as_millis();
writer_guard.write_all(json.as_bytes()).await?;
let write_ms = write_started
.elapsed()
.as_millis()
.saturating_sub(writer_lock_ms);
crate::logging::info(&format!(
"[TIMING] handle_get_model_catalog: session={}, source={}, bytes={}, build={}ms, encode={}ms, writer_lock={}ms, write={}ms, total={}ms",
session_id,
source,
json.len(),
build_ms,
encode_ms,
writer_lock_ms,
write_ms,
started.elapsed().as_millis()
));
Ok(())
}
pub(super) async fn handle_get_compacted_history(
id: u64,
session_id: &str,
agent: &Arc<Mutex<Agent>>,
writer: &Arc<Mutex<WriteHalf>>,
visible_messages: usize,
) -> Result<()> {
let started = Instant::now();
let (messages, images, compacted_info, source) = match agent.try_lock() {
Ok(agent_guard) => {
let (messages, images, info) = agent_guard
.get_history_and_rendered_images_with_compacted_history(visible_messages);
(messages, images, info, "live")
}
Err(_) => {
let session = crate::session::Session::load_for_remote_startup(session_id)
.or_else(|_| crate::session::Session::load_startup_stub(session_id))?;
let (rendered_messages, images, info) =
crate::session::render_messages_and_images_with_compacted_history(
&session,
visible_messages,
);
(
rendered_messages
.into_iter()
.map(rendered_to_history_message)
.collect(),
images,
info,
"persisted",
)
}
};
let compacted_info = compacted_info.unwrap_or(crate::session::RenderedCompactedHistoryInfo {
total_messages: 0,
visible_messages: 0,
remaining_messages: 0,
hidden_user_prompts: 0,
});
crate::logging::info(&format!(
"[TIMING] get_compacted_history: session={}, source={}, requested={}, visible={}, remaining={}, messages={}, total={}ms",
session_id,
source,
visible_messages,
compacted_info.visible_messages,
compacted_info.remaining_messages,
messages.len(),
started.elapsed().as_millis(),
));
write_event(
writer,
&ServerEvent::CompactedHistory {
id,
session_id: session_id.to_string(),
messages,
images,
compacted_total: compacted_info.total_messages,
compacted_visible: compacted_info.visible_messages,
compacted_remaining: compacted_info.remaining_messages,
compacted_hidden_prompts: compacted_info.hidden_user_prompts,
},
)
.await
}
fn rendered_to_history_message(msg: crate::session::RenderedMessage) -> HistoryMessage {
HistoryMessage {
role: msg.role,
content: msg.content,
tool_calls: if msg.tool_calls.is_empty() {
None
} else {
Some(msg.tool_calls)
},
tool_data: msg.tool_data,
}
}
fn history_reload_recovery_snapshot(
session_id: &str,
was_interrupted: Option<bool>,
) -> Option<crate::protocol::ReloadRecoverySnapshot> {
match super::reload_recovery::pending_directive_for_session(session_id) {
Ok(Some(directive)) => {
crate::logging::info(&format!(
"history_reload_recovery_snapshot: attaching server-owned recovery intent for session={} without marking delivered",
session_id
));
return Some(directive);
}
Ok(None) => {}
Err(err) => crate::logging::warn(&format!(
"history_reload_recovery_snapshot: failed to read server-owned recovery intent for session={}: {}",
session_id, err
)),
}
let reload_ctx = crate::tool::selfdev::ReloadContext::peek_for_session(session_id)
.ok()
.flatten();
let inferred_interrupted = was_interrupted
.unwrap_or_else(|| infer_persisted_session_interrupted_by_reload(session_id));
let directive = crate::tool::selfdev::ReloadContext::recovery_directive_for_session(
session_id,
reload_ctx.as_ref(),
inferred_interrupted,
None,
);
crate::logging::info(&format!(
"history_reload_recovery_snapshot: session={} explicit_was_interrupted={:?} inferred_was_interrupted={} has_reload_ctx={} directive={}",
session_id,
was_interrupted,
inferred_interrupted,
reload_ctx.is_some(),
directive.is_some()
));
directive
}
fn persisted_session_has_reload_interruption_marker(session: &Session) -> bool {
let Some(last) = session.messages.last() else {
return false;
};
last.content.iter().any(|block| match block {
ContentBlock::Text { text, .. } => {
text.ends_with("[generation interrupted - server reloading]")
}
ContentBlock::ToolResult {
content, is_error, ..
} => {
content == "Reload initiated. Process restarting..."
|| (is_error.unwrap_or(false)
&& (content.contains("interrupted by server reload")
|| content.contains("Skipped - server reloading")))
}
_ => false,
})
}
fn infer_persisted_session_interrupted_by_reload(session_id: &str) -> bool {
let session = match Session::load_for_remote_startup(session_id)
.or_else(|_| Session::load_startup_stub(session_id))
{
Ok(session) => session,
Err(err) => {
crate::logging::warn(&format!(
"history_reload_recovery_snapshot: could not inspect persisted session {} for reload interruption fallback: {}",
session_id, err
));
return false;
}
};
let last_is_user = session
.messages
.last()
.map(|message| message.role == Role::User)
.unwrap_or(false);
let marker_active = crate::server::reload_marker_active(RELOAD_RESTORE_MARKER_MAX_AGE);
let interrupted = matches!(session.status, SessionStatus::Crashed { .. })
|| (matches!(session.status, SessionStatus::Active) && last_is_user && marker_active)
|| (matches!(session.status, SessionStatus::Closed) && last_is_user && marker_active)
|| persisted_session_has_reload_interruption_marker(&session);
crate::logging::info(&format!(
"history_reload_recovery_snapshot: fallback inspect session={} status={} last_is_user={} marker_active={} interrupted={}",
session_id,
session.status.display(),
last_is_user,
marker_active,
interrupted
));
interrupted
}
#[expect(
clippy::too_many_arguments,
reason = "persisted history fallback still needs session/client/server metadata for a usable bootstrap payload"
)]
async fn send_history_from_persisted_session(
id: u64,
session_id: &str,
provider: &Arc<dyn Provider>,
sessions: &SessionAgents,
client_count: &Arc<RwLock<usize>>,
writer: &Arc<Mutex<WriteHalf>>,
server_name: &str,
server_icon: &str,
was_interrupted: Option<bool>,
activity: Option<SessionActivitySnapshot>,
) -> Result<()> {
let session = crate::session::Session::load_for_remote_startup(session_id)
.or_else(|_| crate::session::Session::load_startup_stub(session_id))?;
let token_usage_totals = session.token_usage_totals();
let (rendered_messages, images) = crate::session::render_messages_and_images(&session);
// Extract the small metadata fields we need, then drop the full Session
// (including its message transcript) before building and serializing the
// large History event, so we do not hold Session + rendered payload +
// serialized wire bytes simultaneously.
let provider_name =
history_provider_name_from_session(&session).or_else(|| Some(provider.name().to_string()));
let provider_model = session.model.clone().or_else(|| Some(provider.model()));
let subagent_model = session.subagent_model.clone();
let autoreview_enabled = session.autoreview_enabled;
let autojudge_enabled = session.autojudge_enabled;
let is_canary = session.is_canary;
let reasoning_effort = session
.reasoning_effort
.clone()
.or_else(|| provider.reasoning_effort());
drop(session);
let messages = rendered_messages
.into_iter()
.map(rendered_to_history_message)
.collect();
let side_panel = crate::side_panel::snapshot_for_session(session_id).unwrap_or_default();
let (all_sessions, current_client_count) = {
let sessions_guard = sessions.read().await;
let mut all: Vec<String> = sessions_guard.keys().cloned().collect();
all.sort();
let count = *client_count.read().await;
(all, count)
};
let history_event = ServerEvent::History {
id,
session_id: session_id.to_string(),
messages,
images,
provider_name,
provider_model,
subagent_model,
autoreview_enabled,
autojudge_enabled,
available_models: Vec::new(),
available_model_routes: Vec::new(),
mcp_servers: Vec::new(),
skills: Vec::new(),
total_tokens: optional_total_tokens(token_usage_totals),
token_usage_totals: optional_token_usage_totals(token_usage_totals),
all_sessions,
client_count: Some(current_client_count),
is_canary: Some(is_canary),
server_version: Some(jcode_build_meta::VERSION.to_string()),
server_name: Some(server_name.to_string()),
server_icon: Some(server_icon.to_string()),
server_has_update: Some(server_has_newer_binary()),
was_interrupted,
reload_recovery: history_reload_recovery_snapshot(session_id, was_interrupted),
connection_type: None,
status_detail: None,
upstream_provider: None,
resolved_credential: provider.active_resolved_credential(),
reasoning_effort,
service_tier: None,
compaction_mode: crate::config::config().compaction.mode.clone(),
activity,
side_panel,
};
write_event(writer, &history_event).await
}
#[expect(
clippy::too_many_arguments,
reason = "history payload assembly includes agent state, sessions, counts, writer, activity, payload mode, and server identity"
)]
pub(super) async fn send_history(
id: u64,
session_id: &str,
agent: &Arc<Mutex<Agent>>,
sessions: &SessionAgents,
client_count: &Arc<RwLock<usize>>,
writer: &Arc<Mutex<WriteHalf>>,
server_name: &str,
server_icon: &str,
was_interrupted: Option<bool>,
activity: Option<SessionActivitySnapshot>,
payload_mode: HistoryPayloadMode,
include_model_catalog: bool,
) -> Result<()> {
let history_start = Instant::now();
let agent_lock_start = Instant::now();
let (
messages,
images,
is_canary,
provider_name,
provider_model,
subagent_model,
autoreview_enabled,
autojudge_enabled,
available_models,
available_model_routes,
skills,
tool_names,
upstream_provider,
resolved_credential,
connection_type,
status_detail,
reasoning_effort,
service_tier,
compaction_mode,
token_usage_totals,
agent_lock_ms,
history_snapshot_ms,
image_render_ms,
tool_names_ms,
available_models_ms,
model_routes_ms,
skills_ms,
provider_meta_ms,
compaction_mode_ms,
) = {
let agent_guard = agent.lock().await;
let agent_lock_ms = agent_lock_start.elapsed().as_millis();
let provider = agent_guard.provider_handle();
let history_snapshot_start = Instant::now();
let (messages, images) = agent_guard.get_history_and_rendered_images();
let history_snapshot_ms = history_snapshot_start.elapsed().as_millis();
let image_render_ms = 0;
let tool_names_start = Instant::now();
let tool_names = agent_guard.tool_names().await;
let tool_names_ms = tool_names_start.elapsed().as_millis();
let (available_models, available_models_ms) = if include_model_catalog {
let available_models_start = Instant::now();
let available_models = agent_guard.available_models_display();
(
available_models,
available_models_start.elapsed().as_millis(),
)
} else {
(Vec::new(), 0)
};
// Model-route expansion can be relatively expensive (provider/account routing,
// endpoint cache reads, etc.). The TUI already supports later
// AvailableModelsUpdated events, so keep the initial History payload fast and
// let the background refresh populate detailed routes asynchronously.
let available_model_routes = Vec::new();
let model_routes_ms = 0;
let skills_start = Instant::now();
let skills = agent_guard.available_skill_names();
let skills_ms = skills_start.elapsed().as_millis();
let provider_meta_start = Instant::now();
let reasoning_effort = provider.reasoning_effort();
let service_tier = provider.service_tier();
let provider_meta_ms = provider_meta_start.elapsed().as_millis();
let compaction_mode_start = Instant::now();
let compaction_mode = agent_guard.compaction_mode().await;
let compaction_mode_ms = compaction_mode_start.elapsed().as_millis();
(
messages,
images,
agent_guard.is_canary(),
agent_guard.provider_name(),
agent_guard.provider_model(),
agent_guard.subagent_model(),
agent_guard.autoreview_enabled(),
agent_guard.autojudge_enabled(),
available_models,
available_model_routes,
skills,
tool_names,
agent_guard.last_upstream_provider(),
agent_guard.active_resolved_credential(),
agent_guard.last_connection_type(),
agent_guard.last_status_detail(),
reasoning_effort,
service_tier,
compaction_mode,
agent_guard.token_usage_totals(),
agent_lock_ms,
history_snapshot_ms,
image_render_ms,
tool_names_ms,
available_models_ms,
model_routes_ms,
skills_ms,
provider_meta_ms,
compaction_mode_ms,
)
};
let side_panel_start = Instant::now();
let side_panel = crate::side_panel::snapshot_for_session(session_id).unwrap_or_default();
let side_panel_ms = side_panel_start.elapsed().as_millis();
let mut mcp_map: BTreeMap<String, usize> = BTreeMap::new();
for name in &tool_names {
if let Some(rest) = name.strip_prefix("mcp__")
&& let Some((server, _tool)) = rest.split_once("__")
{
*mcp_map.entry(server.to_string()).or_default() += 1;
}
}
let mcp_servers: Vec<String> = mcp_map
.into_iter()
.map(|(name, count)| format!("{name}:{count}"))
.collect();
let (all_sessions, current_client_count) = {
let sessions_snapshot_start = Instant::now();
let sessions_guard = sessions.read().await;
let all: Vec<String> = sessions_guard.keys().cloned().collect();
let count = *client_count.read().await;
let sessions_snapshot_ms = sessions_snapshot_start.elapsed().as_millis();
crate::logging::info(&format!(
"[TIMING] send_history prep: session={}, mode={:?}, messages={}, images={}, mcp_servers={}, agent_lock={}ms, history={}ms, images={}ms, tool_names={}ms, models={}ms, routes={}ms, skills={}ms, provider_meta={}ms, compaction={}ms, side_panel={}ms, sessions={}ms, total={}ms",
session_id,
payload_mode,
messages.len(),
images.len(),
mcp_servers.len(),
agent_lock_ms,
history_snapshot_ms,
image_render_ms,
tool_names_ms,
available_models_ms,
model_routes_ms,
skills_ms,
provider_meta_ms,
compaction_mode_ms,
side_panel_ms,
sessions_snapshot_ms,
history_start.elapsed().as_millis(),
));
(all, count)
};
let history_event = ServerEvent::History {
id,
session_id: session_id.to_string(),
messages,
images,
provider_name: Some(provider_name),
provider_model: Some(provider_model),
subagent_model,
autoreview_enabled,
autojudge_enabled,
available_models,
available_model_routes,
mcp_servers,
skills,
total_tokens: optional_total_tokens(token_usage_totals),
token_usage_totals: optional_token_usage_totals(token_usage_totals),
all_sessions,
client_count: Some(current_client_count),
is_canary: Some(is_canary),
server_version: Some(jcode_build_meta::VERSION.to_string()),
server_name: Some(server_name.to_string()),
server_icon: Some(server_icon.to_string()),
server_has_update: Some(server_has_newer_binary()),
was_interrupted,
reload_recovery: history_reload_recovery_snapshot(session_id, was_interrupted),
connection_type,
status_detail,
upstream_provider,
resolved_credential,
reasoning_effort,
service_tier,
compaction_mode,
activity,
side_panel,
};
let encode_start = Instant::now();
let json = encode_event(&history_event);
// Free the structured event as soon as the wire bytes exist so only ~1x
// the payload stays resident across the awaited socket write.
drop(history_event);
let json_len = json.len();
let encode_ms = encode_start.elapsed().as_millis();
let writer_lock_start = Instant::now();
let mut writer_guard = writer.lock().await;
let writer_lock_ms = writer_lock_start.elapsed().as_millis();
let write_start = Instant::now();
let result = writer_guard.write_all(json.as_bytes()).await;
drop(writer_guard);
// Release the serialized payload before any further work (logging below
// only needs the captured length).
drop(json);
let write_ms = write_start.elapsed().as_millis();
crate::logging::info(&format!(
"[TIMING] send_history write: session={}, bytes={}, encode={}ms, writer_lock={}ms, write={}ms, total={}ms",
session_id,
json_len,
encode_ms,
writer_lock_ms,
write_ms,
history_start.elapsed().as_millis(),
));
result.map_err(Into::into)
}
pub(super) async fn session_activity_snapshot(
client_connections: &Arc<RwLock<HashMap<String, ClientConnectionInfo>>>,
session_id: &str,
fallback_processing: bool,
) -> Option<SessionActivitySnapshot> {
let snapshot = {
let connections = client_connections.read().await;
let mut processing_without_tool = false;
let mut tool_name = None;
for info in connections.values() {
if info.session_id != session_id || !info.is_processing {
continue;
}
if let Some(current_tool_name) = info.current_tool_name.clone() {
tool_name = Some(current_tool_name);
break;
}
processing_without_tool = true;
}
tool_name
.map(|current_tool_name| SessionActivitySnapshot {
is_processing: true,
current_tool_name: Some(current_tool_name),
})
.or_else(|| {
processing_without_tool.then_some(SessionActivitySnapshot {
is_processing: true,
current_tool_name: None,
})
})
};
snapshot.or_else(|| {
fallback_processing.then_some(SessionActivitySnapshot {
is_processing: true,
current_tool_name: None,
})
})
}
async fn write_event(writer: &Arc<Mutex<WriteHalf>>, event: &ServerEvent) -> Result<()> {
// Serialize straight to bytes with the same framing as encode_event
// (JSON body, "{}" on serialize failure, trailing newline) and drop the
// buffer as soon as the bytes are written so the serialized copy does not
// outlive the socket write.
let mut buf = serde_json::to_vec(event).unwrap_or_else(|_| b"{}".to_vec());
buf.push(b'\n');
let mut writer = writer.lock().await;
writer.write_all(&buf).await?;
drop(buf);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn session_with_provider_key(key: Option<&str>) -> crate::session::Session {
let mut session = crate::session::Session::create_with_id(
"test_history_provider_name".to_string(),
None,
None,
);
session.provider_key = key.map(str::to_string);
session
}
#[test]
fn history_provider_name_prefers_persisted_openai_key() {
let session = session_with_provider_key(Some("openai"));
assert_eq!(
history_provider_name_from_session(&session).as_deref(),
Some("OpenAI")
);
}
#[test]
fn history_provider_name_preserves_unknown_runtime_profile() {
let session = session_with_provider_key(Some("opencode-go"));
assert_eq!(
history_provider_name_from_session(&session).as_deref(),
Some("opencode-go")
);
}
}
pub(super) fn spawn_model_prefetch_update(provider: Arc<dyn Provider>, agent: Arc<Mutex<Agent>>) {
tokio::spawn(async move {
let (provider_name, initial_models) = {
let agent_guard = agent.lock().await;
(
agent_guard.provider_name(),
agent_guard.available_models_display(),
)
};
if !initial_models.is_empty() {
return;
}
if should_debounce_attach_model_prefetch(&provider_name) {
crate::logging::info(&format!(
"Skipping attach-time model prefetch for {} because a recent refresh already ran",
provider_name
));
return;
}
if provider.prefetch_models().await.is_err() {
return;
}
let refreshed = {
let agent_guard = agent.lock().await;
(
agent_guard.available_models_display(),
agent_guard.model_routes(),
)
};
if refreshed.0 == initial_models && refreshed.1.is_empty() {
return;
}
let _ = refreshed;
Bus::global().publish_models_updated();
});
}
#[cfg(test)]
#[path = "client_state_tests.rs"]
mod client_state_tests;
@@ -0,0 +1,448 @@
use super::handle_get_history;
use super::handle_get_model_catalog;
use super::session_activity_snapshot;
use crate::agent::Agent;
use crate::message::{Message, ToolDefinition};
use crate::provider::{EventStream, Provider};
use crate::server::ClientConnectionInfo;
use crate::tool::Registry;
use anyhow::Result;
use async_trait::async_trait;
use std::collections::HashMap;
use std::io::BufRead as _;
use std::sync::Arc;
use std::time::Instant;
use tokio::io::AsyncReadExt;
use tokio::sync::{Mutex, RwLock, mpsc};
struct MockProvider;
#[async_trait]
impl Provider for MockProvider {
async fn complete(
&self,
_messages: &[Message],
_tools: &[ToolDefinition],
_system: &str,
_resume_session_id: Option<&str>,
) -> Result<EventStream> {
Err(anyhow::anyhow!(
"mock provider complete should not be called in client_state tests"
))
}
fn name(&self) -> &str {
"mock"
}
fn fork(&self) -> Arc<dyn Provider> {
Arc::new(Self)
}
fn model(&self) -> String {
"mock-model".to_string()
}
}
#[tokio::test]
async fn session_activity_snapshot_prefers_live_tool_name_for_target_session() {
let now = Instant::now();
let client_connections = Arc::new(RwLock::new(HashMap::from([
(
"conn-idle".to_string(),
ClientConnectionInfo {
client_id: "conn-idle".to_string(),
session_id: "other-session".to_string(),
client_instance_id: None,
debug_client_id: None,
connected_at: now,
last_seen: now,
is_processing: true,
current_tool_name: Some("bash".to_string()),
terminal_env: Vec::new(),
disconnect_tx: mpsc::unbounded_channel().0,
},
),
(
"conn-target".to_string(),
ClientConnectionInfo {
client_id: "conn-target".to_string(),
session_id: "target-session".to_string(),
client_instance_id: None,
debug_client_id: None,
connected_at: now,
last_seen: now,
is_processing: true,
current_tool_name: Some("batch".to_string()),
terminal_env: Vec::new(),
disconnect_tx: mpsc::unbounded_channel().0,
},
),
])));
let snapshot = session_activity_snapshot(&client_connections, "target-session", false)
.await
.expect("activity snapshot");
assert!(snapshot.is_processing);
assert_eq!(snapshot.current_tool_name.as_deref(), Some("batch"));
}
#[tokio::test]
async fn session_activity_snapshot_uses_fallback_when_no_live_connection_is_marked_busy() {
let client_connections = Arc::new(RwLock::new(HashMap::<String, ClientConnectionInfo>::new()));
let snapshot = session_activity_snapshot(&client_connections, "target-session", true)
.await
.expect("fallback snapshot");
assert!(snapshot.is_processing);
assert_eq!(snapshot.current_tool_name, None);
}
#[tokio::test]
#[expect(
clippy::await_holding_lock,
reason = "test intentionally keeps the agent busy lock held to exercise persisted-history fallback"
)]
async fn handle_get_history_falls_back_to_persisted_snapshot_when_agent_is_busy() {
let _guard = crate::storage::lock_test_env();
let temp_home = tempfile::TempDir::new().expect("create temp home");
let prev_home = std::env::var_os("JCODE_HOME");
crate::env::set_var("JCODE_HOME", temp_home.path());
let session_id = "session_busy_history_fallback";
let mut session = crate::session::Session::create_with_id(
session_id.to_string(),
None,
Some("busy fallback".to_string()),
);
session.model = Some("mock-model".to_string());
session.append_stored_message(crate::session::StoredMessage {
id: "msg-busy-fallback".to_string(),
role: crate::message::Role::User,
content: vec![crate::message::ContentBlock::Text {
text: "persisted fallback history".to_string(),
cache_control: None,
}],
display_role: None,
timestamp: None,
tool_duration_ms: None,
token_usage: None,
});
session.save().expect("save session");
let provider: Arc<dyn Provider> = Arc::new(MockProvider);
let registry = Registry::empty();
let mut live_session = session.clone();
live_session.title = Some("live agent".to_string());
let agent = Arc::new(Mutex::new(Agent::new_with_session(
provider.clone(),
registry,
live_session,
None,
)));
let busy_guard = agent.lock().await;
let sessions = Arc::new(RwLock::new(HashMap::from([(
session_id.to_string(),
Arc::clone(&agent),
)])));
let client_connections = Arc::new(RwLock::new(HashMap::<String, ClientConnectionInfo>::new()));
let client_count = Arc::new(RwLock::new(1usize));
let (stream_a, mut stream_b) = crate::transport::stream_pair().expect("stream pair");
let (_reader_a, writer_a) = stream_a.into_split();
let writer = Arc::new(Mutex::new(writer_a));
handle_get_history(
42,
session_id,
true,
&agent,
&provider,
&sessions,
&client_connections,
&client_count,
&writer,
"server-name",
"🔥",
None,
)
.await
.expect("history should be written from persisted fallback");
drop(busy_guard);
drop(writer);
let mut bytes = Vec::new();
stream_b
.read_to_end(&mut bytes)
.await
.expect("read history event bytes");
let mut cursor = std::io::Cursor::new(bytes);
let mut line = String::new();
cursor.read_line(&mut line).expect("read first line");
let event: crate::protocol::ServerEvent =
serde_json::from_str(line.trim()).expect("decode history event");
match event {
crate::protocol::ServerEvent::History {
id,
session_id: returned_session_id,
messages,
activity,
..
} => {
assert_eq!(id, 42);
assert_eq!(returned_session_id, session_id);
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].content, "persisted fallback history");
let activity = activity.expect("fallback activity snapshot");
assert!(activity.is_processing);
}
other => panic!("expected history event, got {:?}", other),
}
if let Some(prev_home) = prev_home {
crate::env::set_var("JCODE_HOME", prev_home);
} else {
crate::env::remove_var("JCODE_HOME");
}
}
#[tokio::test]
#[expect(
clippy::await_holding_lock,
reason = "test intentionally keeps the agent busy lock held to exercise model-catalog fallback"
)]
async fn handle_get_model_catalog_does_not_wait_for_busy_agent_lock() {
let _guard = crate::storage::lock_test_env();
let temp_home = tempfile::TempDir::new().expect("create temp home");
let prev_home = std::env::var_os("JCODE_HOME");
crate::env::set_var("JCODE_HOME", temp_home.path());
let session_id = "session_busy_model_catalog_fallback";
let mut session = crate::session::Session::create_with_id(
session_id.to_string(),
None,
Some("busy model catalog".to_string()),
);
session.model = Some("persisted-model".to_string());
session.save().expect("save session");
let provider: Arc<dyn Provider> = Arc::new(MockProvider);
let agent = Arc::new(Mutex::new(Agent::new_with_session(
provider.clone(),
Registry::empty(),
session.clone(),
None,
)));
let busy_guard = agent.lock().await;
let (stream_a, mut stream_b) = crate::transport::stream_pair().expect("stream pair");
let (_reader_a, writer_a) = stream_a.into_split();
let writer = Arc::new(Mutex::new(writer_a));
tokio::time::timeout(
std::time::Duration::from_millis(100),
handle_get_model_catalog(43, session_id, &agent, &provider, &writer),
)
.await
.expect("model catalog must not wait for busy agent mutex")
.expect("model catalog fallback should write history event");
drop(busy_guard);
drop(writer);
let mut bytes = Vec::new();
stream_b
.read_to_end(&mut bytes)
.await
.expect("read model catalog event bytes");
let mut cursor = std::io::Cursor::new(bytes);
let mut line = String::new();
cursor.read_line(&mut line).expect("read first line");
let event: crate::protocol::ServerEvent =
serde_json::from_str(line.trim()).expect("decode model catalog event");
match event {
crate::protocol::ServerEvent::History {
id,
session_id: returned_session_id,
provider_name,
provider_model,
..
} => {
assert_eq!(id, 43);
assert_eq!(returned_session_id, session_id);
assert_eq!(provider_name.as_deref(), Some("mock"));
assert_eq!(provider_model.as_deref(), Some("persisted-model"));
}
other => panic!("expected history event, got {:?}", other),
}
if let Some(prev_home) = prev_home {
crate::env::set_var("JCODE_HOME", prev_home);
} else {
crate::env::remove_var("JCODE_HOME");
}
}
struct ReloadHistoryEnvGuard {
prev_home: Option<std::ffi::OsString>,
prev_runtime: Option<std::ffi::OsString>,
}
impl ReloadHistoryEnvGuard {
fn new(home: &std::path::Path, runtime: &std::path::Path) -> Self {
let prev_home = std::env::var_os("JCODE_HOME");
let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR");
crate::env::set_var("JCODE_HOME", home);
crate::env::set_var("JCODE_RUNTIME_DIR", runtime);
Self {
prev_home,
prev_runtime,
}
}
}
impl Drop for ReloadHistoryEnvGuard {
fn drop(&mut self) {
crate::server::clear_reload_marker();
if let Some(prev_home) = self.prev_home.take() {
crate::env::set_var("JCODE_HOME", prev_home);
} else {
crate::env::remove_var("JCODE_HOME");
}
if let Some(prev_runtime) = self.prev_runtime.take() {
crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime);
} else {
crate::env::remove_var("JCODE_RUNTIME_DIR");
}
}
}
fn write_pending_user_session(
session_id: &str,
status: crate::session::SessionStatus,
) -> Result<()> {
let mut session = crate::session::Session::create_with_id(session_id.to_string(), None, None);
session.status = status;
session.add_message(
crate::message::Role::User,
vec![crate::message::ContentBlock::Text {
text: "continue this after reload".to_string(),
cache_control: None,
}],
);
session.save()
}
#[test]
fn history_reload_recovery_infers_pending_active_user_turn_during_reload() -> Result<()> {
let _lock = crate::storage::lock_test_env();
let home = tempfile::TempDir::new()?;
let runtime = tempfile::TempDir::new()?;
let _guard = ReloadHistoryEnvGuard::new(home.path(), runtime.path());
let session_id = "session_history_reload_fallback";
write_pending_user_session(session_id, crate::session::SessionStatus::Active)?;
crate::server::write_reload_state(
"reload-history-fallback",
"test-hash",
crate::server::ReloadPhase::SocketReady,
Some(session_id.to_string()),
);
let snapshot = super::history_reload_recovery_snapshot(session_id, None);
assert!(
snapshot.is_some(),
"pending user turn during reload should get recovery directive"
);
let Some(snapshot) = snapshot else {
return Ok(());
};
assert!(
snapshot
.continuation_message
.contains("interrupted by a server reload")
);
Ok(())
}
#[test]
fn history_reload_recovery_does_not_infer_pending_user_turn_without_reload_marker() -> Result<()> {
let _lock = crate::storage::lock_test_env();
let home = tempfile::TempDir::new()?;
let runtime = tempfile::TempDir::new()?;
let _guard = ReloadHistoryEnvGuard::new(home.path(), runtime.path());
let session_id = "session_history_no_reload_fallback";
write_pending_user_session(session_id, crate::session::SessionStatus::Active)?;
assert!(super::history_reload_recovery_snapshot(session_id, None).is_none());
Ok(())
}
#[test]
fn history_reload_recovery_does_not_mark_delivered_until_continuation_is_accepted() -> Result<()> {
let _lock = crate::storage::lock_test_env();
let home = tempfile::TempDir::new()?;
let runtime = tempfile::TempDir::new()?;
let _guard = ReloadHistoryEnvGuard::new(home.path(), runtime.path());
let session_id = "session_history_store_owned";
super::super::reload_recovery::persist_intent(
"reload-store-owned",
session_id,
super::super::reload_recovery::ReloadRecoveryRole::InterruptedPeer,
crate::tool::selfdev::ReloadRecoveryDirective {
reconnect_notice: Some("stored notice".to_string()),
continuation_message: "stored continuation".to_string(),
},
"test store intent",
)?;
let Some(snapshot) = super::history_reload_recovery_snapshot(session_id, None) else {
anyhow::bail!("server-owned recovery intent should be used");
};
assert_eq!(snapshot.continuation_message, "stored continuation");
assert!(
super::super::reload_recovery::has_pending_for_session(session_id),
"building a History payload must not consume the intent; the client may disconnect before queuing it"
);
let Some(snapshot_again) = super::history_reload_recovery_snapshot(session_id, None) else {
anyhow::bail!("pending server-owned recovery intent should be re-emitted until accepted");
};
assert_eq!(snapshot_again.continuation_message, "stored continuation");
assert!(
!super::super::reload_recovery::mark_delivered_if_matching_continuation(
session_id,
"different continuation",
"unit_test_mismatch",
)?,
"mismatched reminders must not consume a pending reload recovery intent"
);
assert!(super::super::reload_recovery::has_pending_for_session(
session_id
));
assert!(
super::super::reload_recovery::mark_delivered_if_matching_continuation(
session_id,
"stored continuation",
"unit_test_accept",
)?,
"matching accepted continuation should mark the recovery intent delivered"
);
assert!(
!super::super::reload_recovery::has_pending_for_session(session_id),
"accepted continuation should consume the durable pending intent"
);
assert!(
super::history_reload_recovery_snapshot(session_id, None).is_none(),
"delivered server-owned recovery intent should no longer be emitted"
);
Ok(())
}
@@ -0,0 +1,15 @@
use crate::protocol::{ServerEvent, encode_event};
use anyhow::Result;
use std::sync::Arc;
use tokio::io::AsyncWriteExt;
use tokio::sync::Mutex;
pub(super) async fn write_direct_event(
writer: &Arc<Mutex<crate::transport::WriteHalf>>,
event: &ServerEvent,
) -> Result<()> {
let json = encode_event(event);
let mut w = writer.lock().await;
w.write_all(json.as_bytes()).await?;
Ok(())
}
@@ -0,0 +1,680 @@
use super::await_members_state::{
PersistedAwaitMembersState, all_pending_await_members_including_expired, ensure_pending_state,
load_state, persist_final_response, request_key, save_state,
};
use super::{AwaitMembersRuntime, SwarmEvent, SwarmMember};
use crate::bus::{Bus, BusEvent, SwarmAwaitCompleted, UiActivity};
use crate::protocol::{AwaitedMemberStatus, ServerEvent, format_comm_awaited_members_with_reports};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::sync::{RwLock, broadcast, mpsc};
pub(super) async fn awaited_member_statuses(
req_session_id: &str,
swarm_id: &str,
requested_ids: &[String],
target_status: &[String],
swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>,
swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>,
) -> Vec<AwaitedMemberStatus> {
let watch_ids: Vec<String> = if requested_ids.is_empty() {
let mut watch_ids: Vec<String> = {
let swarms = swarms_by_id.read().await;
swarms
.get(swarm_id)
.map(|sessions| {
sessions
.iter()
.filter(|session_id| session_id.as_str() != req_session_id)
.cloned()
.collect()
})
.unwrap_or_default()
};
watch_ids.sort();
watch_ids
} else {
requested_ids.to_vec()
};
let members = swarm_members.read().await;
watch_ids
.iter()
.map(|session_id| {
let (name, status, completion_report) = members
.get(session_id)
.map(|member| {
(
member.friendly_name.clone(),
member.status.clone(),
member.latest_completion_report.clone(),
)
})
.unwrap_or((None, "unknown".to_string(), None));
let done = target_status.contains(&status)
|| (status == "unknown"
&& (target_status.contains(&"stopped".to_string())
|| target_status.contains(&"completed".to_string())));
AwaitedMemberStatus {
session_id: session_id.clone(),
friendly_name: name,
status,
done,
completion_report,
}
})
.collect()
}
fn short_member_name(member: &AwaitedMemberStatus) -> String {
member
.friendly_name
.clone()
.unwrap_or_else(|| member.session_id[..8.min(member.session_id.len())].to_string())
}
pub(super) fn timeout_summary(member_statuses: &[AwaitedMemberStatus]) -> String {
let pending: Vec<String> = member_statuses
.iter()
.filter(|member| !member.done)
.map(|member| format!("{} ({})", short_member_name(member), member.status))
.collect();
format!("Timed out. Still waiting on: {}", pending.join(", "))
}
fn completion_summary(member_statuses: &[AwaitedMemberStatus]) -> String {
let done_names: Vec<String> = member_statuses.iter().map(short_member_name).collect();
format!(
"All {} members are done: {}",
done_names.len(),
done_names.join(", ")
)
}
pub(super) fn completion_mode(mode: Option<&str>) -> &str {
match mode {
Some("any") => "any",
_ => "all",
}
}
pub(super) fn mode_satisfied(member_statuses: &[AwaitedMemberStatus], mode: Option<&str>) -> bool {
match completion_mode(mode) {
"any" => member_statuses.iter().any(|status| status.done),
_ => member_statuses.iter().all(|status| status.done),
}
}
pub(super) fn mode_summary(member_statuses: &[AwaitedMemberStatus], mode: Option<&str>) -> String {
match completion_mode(mode) {
"any" => {
let matching: Vec<String> = member_statuses
.iter()
.filter(|member| member.done)
.map(short_member_name)
.collect();
format!(
"Matched {} member{}: {}",
matching.len(),
if matching.len() == 1 { "" } else { "s" },
matching.join(", ")
)
}
_ => completion_summary(member_statuses),
}
}
pub(super) fn deadline_to_instant(deadline_unix_ms: u64) -> tokio::time::Instant {
let now_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
tokio::time::Instant::now() + Duration::from_millis(deadline_unix_ms.saturating_sub(now_ms))
}
pub(super) async fn respond_to_waiters(
runtime: &AwaitMembersRuntime,
key: &str,
completed: bool,
members: Vec<AwaitedMemberStatus>,
summary: String,
) {
for (request_id, client_event_tx) in runtime.take_waiters(key).await {
let _ = client_event_tx.send(ServerEvent::CommAwaitMembersResponse {
id: request_id,
completed,
members: members.clone(),
summary: summary.clone(),
background_started: false,
});
}
runtime.clear_active(key).await;
}
/// Build the swarm-flavored completion notification body delivered to the
/// requesting agent when a backgrounded await finishes. Reuses the same
/// member-status + completion-report rendering as the blocking tool result so
/// the agent sees consistent output whether it waited inline or in the
/// background.
fn background_completion_notification(
completed: bool,
summary: &str,
members: &[AwaitedMemberStatus],
) -> String {
let reports = HashMap::new();
let body = format_comm_awaited_members_with_reports(completed, summary, members, &reports);
format!("🐝 **Swarm await finished**\n\n{}", body)
}
/// Reload the latest persisted pending state for `state.key`, if any. Delivery
/// prefs (background/notify/wake) can be updated by duplicate requests after a
/// watcher captured its own copy at spawn, so re-reading before exit/finalize
/// keeps the watcher in sync with what the requesting tool was last told.
fn refresh_pending_state(state: &PersistedAwaitMembersState) -> Option<PersistedAwaitMembersState> {
load_state(&state.key).filter(PersistedAwaitMembersState::is_pending)
}
/// Persist the terminal result, reply to any blocking socket waiters, and, when
/// the await was started in background mode, publish a `SwarmAwaitCompleted`
/// bus event so the server's bus monitor can wake/notify the requesting agent
/// the same way background tasks do.
async fn finalize_await(
runtime: &AwaitMembersRuntime,
state: &PersistedAwaitMembersState,
completed: bool,
members: Vec<AwaitedMemberStatus>,
summary: String,
) {
// Deliver with the latest persisted prefs: a duplicate request may have
// changed background/notify/wake after the caller captured this copy.
let state = refresh_pending_state(state).unwrap_or_else(|| state.clone());
let _ = persist_final_response(&state, completed, members.clone(), summary.clone());
if state.background && (state.notify || state.wake) {
let notification = background_completion_notification(completed, &summary, &members);
Bus::global().publish(BusEvent::SwarmAwaitCompleted(SwarmAwaitCompleted {
session_id: state.session_id.clone(),
completed,
summary: summary.clone(),
notification,
notify: state.notify,
wake: state.wake,
}));
}
respond_to_waiters(runtime, &state.key, completed, members, summary).await;
}
pub(super) async fn spawn_or_resume_await_members(
state: PersistedAwaitMembersState,
req_session_id: String,
swarm_members: Arc<RwLock<HashMap<String, SwarmMember>>>,
swarms_by_id: Arc<RwLock<HashMap<String, HashSet<String>>>>,
swarm_event_tx: broadcast::Sender<SwarmEvent>,
await_members_runtime: AwaitMembersRuntime,
) {
let key = state.key.clone();
let swarm_id = state.swarm_id.clone();
let requested_ids = state.requested_ids.clone();
let target_status = state.target_status.clone();
let mode = state.mode.clone();
tokio::spawn(async move {
let mut event_rx = swarm_event_tx.subscribe();
let deadline = deadline_to_instant(state.deadline_unix_ms);
loop {
let member_statuses = awaited_member_statuses(
&req_session_id,
&swarm_id,
&requested_ids,
&target_status,
&swarm_members,
&swarms_by_id,
)
.await;
if member_statuses.is_empty() {
let summary = "No other members in swarm to wait for.".to_string();
finalize_await(&await_members_runtime, &state, true, vec![], summary).await;
return;
}
if mode_satisfied(&member_statuses, mode.as_deref()) {
let summary = mode_summary(&member_statuses, mode.as_deref());
finalize_await(
&await_members_runtime,
&state,
true,
member_statuses,
summary,
)
.await;
return;
}
// Blocking waits stop watching once every socket waiter has
// disconnected. Background watchers have no socket waiter, so they
// keep running until they resolve or hit the deadline, delivering
// the result via notify/wake. Re-read the persisted prefs here: a
// duplicate request may have upgraded this wait to background mode
// after this watcher was spawned with a blocking-state copy.
let is_background = refresh_pending_state(&state)
.map(|latest| latest.background)
.unwrap_or(state.background);
if !is_background && await_members_runtime.retain_open_waiters(&key).await == 0 {
await_members_runtime.clear_active(&key).await;
return;
}
tokio::select! {
_ = tokio::time::sleep_until(deadline) => {
let summary = timeout_summary(&member_statuses);
finalize_await(&await_members_runtime, &state, false, member_statuses, summary).await;
return;
}
event = event_rx.recv() => {
match event {
Ok(event) => {
if event.swarm_id.as_deref() != Some(swarm_id.as_str()) {
continue;
}
}
Err(broadcast::error::RecvError::Lagged(n)) => {
// Dropped events are recoverable: the loop re-reads
// member statuses from shared state at the top, so
// just keep watching instead of orphaning the wait.
crate::logging::info(&format!(
"await_members watcher lagged by {} swarm events; re-checking statuses",
n
));
continue;
}
Err(broadcast::error::RecvError::Closed) => {
await_members_runtime.clear_active(&key).await;
return;
}
}
}
}
}
});
}
pub(super) struct CommAwaitMembersContext<'a> {
pub client_event_tx: &'a mpsc::UnboundedSender<ServerEvent>,
pub swarm_members: &'a Arc<RwLock<HashMap<String, SwarmMember>>>,
pub swarms_by_id: &'a Arc<RwLock<HashMap<String, HashSet<String>>>>,
pub swarm_event_tx: &'a broadcast::Sender<SwarmEvent>,
pub await_members_runtime: &'a AwaitMembersRuntime,
}
#[expect(
clippy::too_many_arguments,
reason = "await request carries protocol fields plus delivery flags; grouping would churn many call sites"
)]
pub(super) async fn handle_comm_await_members(
id: u64,
req_session_id: String,
target_status: Vec<String>,
requested_ids: Vec<String>,
mode: Option<String>,
timeout_secs: Option<u64>,
background: bool,
notify: bool,
wake: bool,
ctx: CommAwaitMembersContext<'_>,
) {
let swarm_id = {
let members = ctx.swarm_members.read().await;
members
.get(&req_session_id)
.and_then(|member| member.swarm_id.clone())
};
if let Some(swarm_id) = swarm_id {
let key = request_key(
&req_session_id,
&swarm_id,
&requested_ids,
&target_status,
mode.as_deref(),
);
let mut persisted = load_state(&key);
let initial_statuses = awaited_member_statuses(
&req_session_id,
&swarm_id,
&requested_ids,
&target_status,
ctx.swarm_members,
ctx.swarms_by_id,
)
.await;
if let Some(final_response) = persisted
.as_ref()
.and_then(|state| state.final_response.clone())
{
let current_still_satisfies =
initial_statuses.is_empty() || mode_satisfied(&initial_statuses, mode.as_deref());
if current_still_satisfies {
let _ = ctx
.client_event_tx
.send(ServerEvent::CommAwaitMembersResponse {
id,
completed: final_response.completed,
members: final_response.members,
summary: final_response.summary,
background_started: false,
});
return;
}
persisted = None;
}
if initial_statuses.is_empty() {
let _ = ctx
.client_event_tx
.send(ServerEvent::CommAwaitMembersResponse {
id,
completed: true,
members: vec![],
summary: "No other members in swarm to wait for.".to_string(),
background_started: false,
});
return;
}
// Already satisfied right now: answer inline regardless of background
// mode. There is nothing to wait for, so the agent should get the
// result immediately instead of a "watching in background" stub.
if mode_satisfied(&initial_statuses, mode.as_deref()) {
let summary = mode_summary(&initial_statuses, mode.as_deref());
let _ = ctx
.client_event_tx
.send(ServerEvent::CommAwaitMembersResponse {
id,
completed: true,
members: initial_statuses,
summary,
background_started: false,
});
return;
}
let requested_deadline = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
+ Duration::from_secs(timeout_secs.unwrap_or(3600)).as_millis() as u64;
let mut state = persisted.unwrap_or_else(|| {
ensure_pending_state(
&key,
&req_session_id,
&swarm_id,
&requested_ids,
&target_status,
mode.as_deref(),
requested_deadline,
background,
notify,
wake,
)
});
// When reusing a persisted pending state (e.g. a resumed call after
// reload, or a duplicate request), let the latest call's delivery prefs
// win so the watcher and tool response stay in sync. The deadline is
// intentionally preserved from the original request.
if state.background != background || state.notify != notify || state.wake != wake {
state.background = background;
state.notify = notify;
state.wake = wake;
save_state(&state);
}
let already_expired = state.deadline_unix_ms
<= SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
// Background mode: hand off to a detached watcher and answer the tool
// immediately so the requesting turn stays responsive. Completion is
// delivered later via notify/wake.
if background {
if already_expired {
let summary = timeout_summary(&initial_statuses);
finalize_await(
ctx.await_members_runtime,
&state,
false,
initial_statuses.clone(),
summary.clone(),
)
.await;
// Answer the requesting tool call directly: no waiter was
// registered for this request (waiters are only added in the
// blocking branch), so without this the socket call would hang
// until its client-side timeout.
let _ = ctx
.client_event_tx
.send(ServerEvent::CommAwaitMembersResponse {
id,
completed: false,
members: initial_statuses,
summary,
background_started: false,
});
return;
}
if ctx.await_members_runtime.mark_active_if_new(&key).await {
publish_await_started_card(&state, &initial_statuses);
spawn_or_resume_await_members(
state,
req_session_id,
ctx.swarm_members.clone(),
ctx.swarms_by_id.clone(),
ctx.swarm_event_tx.clone(),
ctx.await_members_runtime.clone(),
)
.await;
}
let summary = background_started_summary(&initial_statuses, mode.as_deref(), wake);
let _ = ctx
.client_event_tx
.send(ServerEvent::CommAwaitMembersResponse {
id,
completed: false,
members: initial_statuses,
summary,
background_started: true,
});
return;
}
// Blocking mode: register a socket waiter that the watcher resolves.
ctx.await_members_runtime
.add_waiter(&key, id, ctx.client_event_tx)
.await;
if already_expired {
let summary = timeout_summary(&initial_statuses);
let _ =
persist_final_response(&state, false, initial_statuses.clone(), summary.clone());
respond_to_waiters(
ctx.await_members_runtime,
&key,
false,
initial_statuses,
summary,
)
.await;
return;
}
if ctx.await_members_runtime.mark_active_if_new(&key).await {
spawn_or_resume_await_members(
state,
req_session_id,
ctx.swarm_members.clone(),
ctx.swarms_by_id.clone(),
ctx.swarm_event_tx.clone(),
ctx.await_members_runtime.clone(),
)
.await;
}
} else {
let _ = ctx.client_event_tx.send(ServerEvent::Error {
id,
message: "Not in a swarm. Use a git repository to enable swarm features.".to_string(),
retry_after_secs: None,
});
}
}
/// One-line summary returned to the tool when a wait is handed off to a
/// background watcher.
fn background_started_summary(
member_statuses: &[AwaitedMemberStatus],
mode: Option<&str>,
wake: bool,
) -> String {
let pending: Vec<String> = member_statuses
.iter()
.filter(|member| !member.done)
.map(short_member_name)
.collect();
let scope = match completion_mode(mode) {
"any" => "any of",
_ => "all of",
};
let delivery = if wake {
"You'll be woken with the result when it resolves."
} else {
"A notification will appear when it resolves."
};
if pending.is_empty() {
format!("Watching swarm members in the background. {}", delivery)
} else {
format!(
"Watching {} {} in the background. {}",
scope,
pending.join(", "),
delivery
)
}
}
/// Emit a swarm-flavored "await started" activity card so attached clients show
/// that a background watcher is now running for this session.
fn publish_await_started_card(
state: &PersistedAwaitMembersState,
member_statuses: &[AwaitedMemberStatus],
) {
if !state.notify {
return;
}
let pending: Vec<String> = member_statuses
.iter()
.filter(|member| !member.done)
.map(short_member_name)
.collect();
let watching = if pending.is_empty() {
"swarm members".to_string()
} else {
pending.join(", ")
};
Bus::global().publish(BusEvent::UiActivity(UiActivity::background(
Some(state.session_id.clone()),
format!(
"🐝 **Swarm await started** · watching `{}`\n\nJcode is waiting for these members in the background and will report back when they finish.",
watching
),
Some(format!("Swarm await started · {}", watching)),
)));
}
/// Re-spawn detached watchers for every pending background `await_members`
/// state after a server (re)start. Blocking waits are intentionally skipped:
/// their requesting tool call is parked on a socket that no longer exists, and
/// the agent is told to rerun the wait after reload. Background waits, by
/// contrast, deliver via notify/wake, so they can resume transparently.
pub(super) async fn resume_background_awaits(
swarm_members: &Arc<RwLock<HashMap<String, SwarmMember>>>,
swarms_by_id: &Arc<RwLock<HashMap<String, HashSet<String>>>>,
swarm_event_tx: &broadcast::Sender<SwarmEvent>,
await_members_runtime: &AwaitMembersRuntime,
) {
let pending: Vec<PersistedAwaitMembersState> = all_pending_await_members_including_expired()
.into_iter()
.filter(|state| state.background)
.collect();
let now_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let mut resumed = 0usize;
let mut expired = 0usize;
for state in pending {
// Deadline passed while the server was down: the wait can never
// resolve, so finalize it as a timeout now so the promised
// notify/wake still fires instead of the await silently vanishing.
if state.deadline_unix_ms <= now_ms {
let member_statuses = awaited_member_statuses(
&state.session_id,
&state.swarm_id,
&state.requested_ids,
&state.target_status,
swarm_members,
swarms_by_id,
)
.await;
let (completed, summary) = if member_statuses.is_empty() {
(true, "No other members in swarm to wait for.".to_string())
} else if mode_satisfied(&member_statuses, state.mode.as_deref()) {
(true, mode_summary(&member_statuses, state.mode.as_deref()))
} else {
(false, timeout_summary(&member_statuses))
};
finalize_await(
await_members_runtime,
&state,
completed,
member_statuses,
summary,
)
.await;
expired += 1;
continue;
}
let key = state.key.clone();
if await_members_runtime.mark_active_if_new(&key).await {
let req_session_id = state.session_id.clone();
spawn_or_resume_await_members(
state,
req_session_id,
swarm_members.clone(),
swarms_by_id.clone(),
swarm_event_tx.clone(),
await_members_runtime.clone(),
)
.await;
resumed += 1;
}
}
if resumed > 0 || expired > 0 {
crate::logging::info(&format!(
"Resumed {} background swarm await watcher(s) after startup ({} finalized as expired)",
resumed, expired
));
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,166 @@
use super::{handle_comm_assign_next, handle_comm_assign_task, handle_comm_task_control};
use crate::agent::Agent;
use crate::message::{Message, StreamEvent, ToolDefinition};
use crate::plan::PlanItem;
use crate::protocol::ServerEvent;
use crate::provider::{EventStream, Provider};
use crate::server::comm_await::{CommAwaitMembersContext, handle_comm_await_members};
use crate::server::{
AwaitMembersRuntime, SwarmEvent, SwarmEventType, SwarmMember, SwarmMutationRuntime,
VersionedPlan,
};
use crate::tool::Registry;
use anyhow::Result;
use async_trait::async_trait;
use futures::stream;
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::Arc;
use std::sync::atomic::AtomicU64;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tokio::sync::{Mutex, RwLock, broadcast, mpsc};
struct RuntimeEnvGuard {
_guard: std::sync::MutexGuard<'static, ()>,
prev_runtime: Option<std::ffi::OsString>,
}
impl RuntimeEnvGuard {
fn new() -> (Self, tempfile::TempDir) {
let guard = crate::storage::lock_test_env();
let temp = tempfile::TempDir::new().expect("create runtime dir");
let prev_runtime = std::env::var_os("JCODE_RUNTIME_DIR");
crate::env::set_var("JCODE_RUNTIME_DIR", temp.path());
(
Self {
_guard: guard,
prev_runtime,
},
temp,
)
}
}
impl Drop for RuntimeEnvGuard {
fn drop(&mut self) {
if let Some(prev_runtime) = self.prev_runtime.take() {
crate::env::set_var("JCODE_RUNTIME_DIR", prev_runtime);
} else {
crate::env::remove_var("JCODE_RUNTIME_DIR");
}
}
}
fn member(session_id: &str, swarm_id: &str, status: &str) -> SwarmMember {
let (event_tx, _event_rx) = mpsc::unbounded_channel();
SwarmMember {
session_id: session_id.to_string(),
event_tx,
event_txs: HashMap::new(),
working_dir: None,
swarm_id: Some(swarm_id.to_string()),
swarm_enabled: true,
status: status.to_string(),
detail: None,
friendly_name: Some(session_id.to_string()),
report_back_to_session_id: None,
latest_completion_report: None,
role: "agent".to_string(),
joined_at: Instant::now(),
last_status_change: Instant::now(),
is_headless: false,
output_tail: None,
todo_progress: None,
todo_items: Vec::new(),
runtime: crate::protocol::SwarmMemberRuntime::default(),
task_label: None,
}
}
/// A swarm worker owned by `owner` (its spawning coordinator). Auto-assignment
/// only targets such drivable workers, so test fixtures that model a spawned
/// worker should use this rather than a bare `member()` (which represents a
/// foreign/independent session and is intentionally not auto-assignable).
fn owned_member(session_id: &str, swarm_id: &str, status: &str, owner: &str) -> SwarmMember {
let mut m = member(session_id, swarm_id, status);
m.report_back_to_session_id = Some(owner.to_string());
m
}
fn plan_item(id: &str, status: &str, priority: &str, blocked_by: &[&str]) -> PlanItem {
PlanItem {
content: format!("task {id}"),
status: status.to_string(),
priority: priority.to_string(),
id: id.to_string(),
subsystem: None,
file_scope: Vec::new(),
blocked_by: blocked_by.iter().map(|value| value.to_string()).collect(),
assigned_to: None,
}
}
fn swarm_event(session_id: &str, swarm_id: &str, event: SwarmEventType) -> SwarmEvent {
SwarmEvent {
id: 1,
session_id: session_id.to_string(),
session_name: Some(session_id.to_string()),
swarm_id: Some(swarm_id.to_string()),
event,
timestamp: Instant::now(),
absolute_time: SystemTime::now(),
}
}
#[derive(Default)]
struct TestProvider;
#[async_trait]
impl Provider for TestProvider {
async fn complete(
&self,
_messages: &[Message],
_tools: &[ToolDefinition],
_system: &str,
_resume_session_id: Option<&str>,
) -> Result<EventStream> {
Ok(Box::pin(stream::iter(vec![Ok(StreamEvent::MessageEnd {
stop_reason: None,
})])))
}
fn name(&self) -> &str {
"test"
}
fn fork(&self) -> Arc<dyn Provider> {
Arc::new(Self)
}
}
async fn test_agent() -> Arc<Mutex<Agent>> {
let provider: Arc<dyn Provider> = Arc::new(TestProvider);
let registry = Registry::new(provider.clone()).await;
Arc::new(Mutex::new(Agent::new(provider, registry)))
}
include!("comm_control_tests/assign_task.rs");
include!("comm_control_tests/assign_blocked.rs");
include!("comm_control_tests/assign_double.rs");
include!("comm_control_tests/assign_ready_agent.rs");
include!("comm_control_tests/assign_less_loaded.rs");
include!("comm_control_tests/assign_busy_skip.rs");
include!("comm_control_tests/task_control.rs");
include!("comm_control_tests/assign_next_dependency.rs");
include!("comm_control_tests/assign_next_metadata.rs");
include!("comm_control_tests/await_late_joiners.rs");
include!("comm_control_tests/await_disconnect.rs");
include!("comm_control_tests/await_any.rs");
include!("comm_control_tests/await_reload_deadline.rs");
include!("comm_control_tests/await_reload_final.rs");
include!("comm_control_tests/await_lagged.rs");
include!("comm_control_tests/await_resume_expired.rs");
include!("comm_control_tests/await_background_expired.rs");
include!("comm_control_tests/await_upgrade_background.rs");
include!("comm_control_tests/dag_e2e.rs");
include!("comm_control_tests/auto_worker_filter.rs");
include!("comm_control_tests/client_attached_dispatch.rs");
@@ -0,0 +1,88 @@
#[tokio::test]
async fn assign_task_rejects_explicit_blocked_task() {
let (_env, _runtime) = RuntimeEnvGuard::new();
let swarm_id = "swarm-blocked";
let requester = "coord";
let worker = "worker";
let (client_tx, mut client_rx) = mpsc::unbounded_channel();
let worker_agent = test_agent().await;
let sessions = Arc::new(RwLock::new(HashMap::from([(
worker.to_string(),
worker_agent,
)])));
let soft_interrupt_queues = Arc::new(RwLock::new(HashMap::new()));
let client_connections = Arc::new(RwLock::new(HashMap::new()));
let swarm_members = Arc::new(RwLock::new(HashMap::from([
(requester.to_string(), {
let mut member = member(requester, swarm_id, "ready");
member.role = "coordinator".to_string();
member
}),
(worker.to_string(), member(worker, swarm_id, "ready")),
])));
let swarms_by_id = Arc::new(RwLock::new(HashMap::from([(
swarm_id.to_string(),
HashSet::from([requester.to_string(), worker.to_string()]),
)])));
let swarm_plans = Arc::new(RwLock::new(HashMap::from([(
swarm_id.to_string(),
VersionedPlan {
items: vec![
plan_item("setup", "completed", "high", &[]),
plan_item("blocked", "queued", "high", &["missing-prereq"]),
],
version: 1,
participants: HashSet::from([requester.to_string(), worker.to_string()]),
task_progress: HashMap::new(),
mode: "light".to_string(),
node_meta: HashMap::new(),
},
)])));
let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([(
swarm_id.to_string(),
requester.to_string(),
)])));
let event_history = Arc::new(RwLock::new(VecDeque::new()));
let event_counter = Arc::new(AtomicU64::new(1));
let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(32);
let mutation_runtime = SwarmMutationRuntime::default();
handle_comm_assign_task(
88,
requester.to_string(),
Some(worker.to_string()),
Some("blocked".to_string()),
None,
&client_tx,
&sessions,
&soft_interrupt_queues,
&client_connections,
&swarm_members,
&swarms_by_id,
&swarm_plans,
&swarm_coordinators,
&event_history,
&event_counter,
&swarm_event_tx,
&mutation_runtime,
)
.await;
match client_rx.recv().await.expect("response") {
ServerEvent::Error { message, .. } => {
assert!(message.contains("missing dependencies") || message.contains("blocked"));
}
other => panic!("expected error for blocked task assignment, got {other:?}"),
}
let plans = swarm_plans.read().await;
let blocked = plans[swarm_id]
.items
.iter()
.find(|item| item.id == "blocked")
.expect("blocked task exists");
assert!(
blocked.assigned_to.is_none(),
"blocked task should stay unassigned"
);
}
@@ -0,0 +1,193 @@
// Worker-stacking regression tests for auto-assignment target selection.
//
// Observed live: three `assign_task spawn_if_needed=true` calls within ~100ms
// all auto-picked the SAME reusable worker, queueing three large tasks
// serially on one agent while the swarm had spawn capacity. Two invariants
// pin the fix:
// 1. a member that already holds an incomplete plan assignment is busy, not
// reusable, so auto-pick skips it (and the caller falls back to spawning);
// 2. the pick itself is an in-process claim, so concurrent picks that race
// ahead of the plan write cannot select the same member twice.
//
// Included into the `comm_control::tests` module, so the parent's private
// selection helpers are in scope.
use super::{
member_has_active_assignment, release_auto_assign_claim, select_and_claim_auto_target,
};
#[test]
fn member_with_nonzero_plan_load_is_busy() {
let loads = HashMap::from([("busy".to_string(), 1usize)]);
assert!(member_has_active_assignment("busy", &loads));
assert!(!member_has_active_assignment("idle", &loads));
}
#[test]
fn auto_pick_skips_busy_worker_and_selects_idle() {
let swarm_id = "swarm-busy-skip";
let busy = owned_member("busy-worker", swarm_id, "ready", "coord");
let idle = owned_member("idle-worker", swarm_id, "ready", "coord");
// Caller-ranked order puts the busy worker first; selection must still
// land on the idle one.
let candidates = vec![&busy, &idle];
let loads = HashMap::from([("busy-worker".to_string(), 2usize)]);
let picked = select_and_claim_auto_target(swarm_id, &candidates, &loads).expect("pick idle");
assert_eq!(picked, "idle-worker");
release_auto_assign_claim(swarm_id, &picked);
}
#[test]
fn auto_pick_with_only_busy_workers_reports_no_target_for_spawn_fallback() {
let swarm_id = "swarm-busy-only";
let busy = owned_member("busy-worker", swarm_id, "ready", "coord");
let candidates = vec![&busy];
let loads = HashMap::from([("busy-worker".to_string(), 1usize)]);
let err = select_and_claim_auto_target(swarm_id, &candidates, &loads).unwrap_err();
// The leading sentence is the stable contract that spawn_if_needed /
// run_plan match on to spawn a fresh agent instead of stacking.
assert!(
err.starts_with("No ready or completed swarm agents are available"),
"unexpected error: {err}"
);
assert!(err.contains("Skipped 1 worker(s)"), "unexpected error: {err}");
}
#[test]
fn concurrent_auto_picks_do_not_stack_on_one_member() {
let swarm_id = "swarm-race-claim";
let a = owned_member("worker-a", swarm_id, "ready", "coord");
let b = owned_member("worker-b", swarm_id, "ready", "coord");
let candidates = vec![&a, &b];
// No plan write has landed yet, so the plan-derived loads see everyone as
// idle. This models the observed race: back-to-back picks resolving inside
// the window before the first assignment is recorded.
let loads = HashMap::new();
let first = select_and_claim_auto_target(swarm_id, &candidates, &loads).expect("first pick");
let second = select_and_claim_auto_target(swarm_id, &candidates, &loads).expect("second pick");
assert_ne!(first, second, "two racing picks must not share a member");
let third = select_and_claim_auto_target(swarm_id, &candidates, &loads).unwrap_err();
assert!(
third.starts_with("No ready or completed swarm agents are available"),
"third racing pick should demand a spawn, got: {third}"
);
release_auto_assign_claim(swarm_id, &first);
release_auto_assign_claim(swarm_id, &second);
}
#[test]
fn released_claim_makes_member_pickable_again() {
let swarm_id = "swarm-claim-release";
let a = owned_member("worker-a", swarm_id, "ready", "coord");
let candidates = vec![&a];
let loads = HashMap::new();
let first = select_and_claim_auto_target(swarm_id, &candidates, &loads).expect("first pick");
assert_eq!(first, "worker-a");
// The assign path releases the claim once the plan write records (or
// abandons) the assignment; with the plan still showing zero load, the
// member is genuinely reusable again.
release_auto_assign_claim(swarm_id, &first);
let again = select_and_claim_auto_target(swarm_id, &candidates, &loads).expect("re-pick");
assert_eq!(again, "worker-a");
release_auto_assign_claim(swarm_id, &again);
}
/// Handler-level regression: when the only worker already holds an incomplete
/// assignment, an auto `assign_task` must refuse (so `spawn_if_needed` spawns)
/// instead of stacking a second task onto it.
#[tokio::test]
async fn assign_task_does_not_stack_on_busy_worker() {
let (_env, _runtime) = RuntimeEnvGuard::new();
let swarm_id = "swarm-no-stack";
let requester = "coord";
let busy_worker = "worker-busy-solo";
let (client_tx, mut client_rx) = mpsc::unbounded_channel();
let sessions = Arc::new(RwLock::new(HashMap::new()));
let soft_interrupt_queues = Arc::new(RwLock::new(HashMap::new()));
let client_connections = Arc::new(RwLock::new(HashMap::new()));
let swarm_members = Arc::new(RwLock::new(HashMap::from([
(requester.to_string(), {
let mut member = member(requester, swarm_id, "ready");
member.role = "coordinator".to_string();
member
}),
(
busy_worker.to_string(),
// "ready" lifecycle status but with an incomplete plan assignment:
// exactly the state the stacked worker was in.
owned_member(busy_worker, swarm_id, "ready", requester),
),
])));
let swarms_by_id = Arc::new(RwLock::new(HashMap::from([(
swarm_id.to_string(),
HashSet::from([requester.to_string(), busy_worker.to_string()]),
)])));
let mut in_flight = plan_item("in-flight", "queued", "high", &[]);
in_flight.assigned_to = Some(busy_worker.to_string());
let swarm_plans = Arc::new(RwLock::new(HashMap::from([(
swarm_id.to_string(),
VersionedPlan {
items: vec![in_flight, plan_item("next", "queued", "high", &[])],
version: 1,
participants: HashSet::from([requester.to_string(), busy_worker.to_string()]),
task_progress: HashMap::new(),
mode: "light".to_string(),
node_meta: HashMap::new(),
},
)])));
let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([(
swarm_id.to_string(),
requester.to_string(),
)])));
let event_history = Arc::new(RwLock::new(VecDeque::new()));
let event_counter = Arc::new(AtomicU64::new(1));
let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(32);
let mutation_runtime = SwarmMutationRuntime::default();
handle_comm_assign_task(
104,
requester.to_string(),
None,
None,
Some("Do not stack this onto the busy worker".to_string()),
&client_tx,
&sessions,
&soft_interrupt_queues,
&client_connections,
&swarm_members,
&swarms_by_id,
&swarm_plans,
&swarm_coordinators,
&event_history,
&event_counter,
&swarm_event_tx,
&mutation_runtime,
)
.await;
match client_rx.recv().await.expect("response") {
ServerEvent::Error { message, .. } => {
assert!(
message.starts_with("No ready or completed swarm agents are available"),
"expected the spawn-fallback trigger, got: {message}"
);
}
other => panic!("expected Error refusing to stack, got {other:?}"),
}
// The busy worker must still hold exactly its original assignment.
let plans = swarm_plans.read().await;
let assigned: Vec<&str> = plans[swarm_id]
.items
.iter()
.filter(|item| item.assigned_to.as_deref() == Some(busy_worker))
.map(|item| item.id.as_str())
.collect();
assert_eq!(assigned, vec!["in-flight"]);
}
@@ -0,0 +1,469 @@
// Double-assignment guard: a direct assign_task naming a node that is already
// assigned and actively worked must be rejected with an error naming the
// current assignee. Incident: run_plan dispatched a node to a spawned worker,
// and 16 seconds later an explicit `assign_task task_id=<same node>` silently
// re-assigned it to a fresh worker; both edited the same files for ~7 minutes.
/// Pure guard predicate: assigned+fresh -> conflict, assigned+stale -> allow,
/// unassigned -> allow, stale/terminal statuses -> allow.
#[test]
fn active_assignment_conflict_detects_only_assigned_and_fresh_items() {
let now = 1_000_000_u64;
let window = 45_000_u64;
let progress_with_heartbeat = |heartbeat: Option<u64>| crate::server::SwarmTaskProgress {
assigned_session_id: Some("snail".to_string()),
last_heartbeat_unix_ms: heartbeat,
..Default::default()
};
// Unassigned -> allow, regardless of progress freshness.
assert!(
super::active_assignment_conflict(
"queued",
None,
Some(&progress_with_heartbeat(Some(now - 1_000))),
now,
window,
)
.is_none(),
"unassigned items are always assignable"
);
// Assigned + fresh heartbeat -> reject, naming the assignee and age.
let conflict = super::active_assignment_conflict(
"running",
Some("snail"),
Some(&progress_with_heartbeat(Some(now - 12_000))),
now,
window,
)
.expect("assigned + fresh heartbeat must conflict");
assert_eq!(conflict.assignee, "snail");
assert_eq!(conflict.active_ago_ms, 12_000);
let message = super::active_assignment_error("mem-impl-attribution", &conflict);
assert!(
message.contains("'mem-impl-attribution'")
&& message.contains("'snail'")
&& message.contains("12s ago")
&& message.contains("reassign"),
"error must name the task, assignee, activity age, and takeover path: {message}"
);
// Assigned + queued (dispatch pending) also counts as actively worked.
assert!(
super::active_assignment_conflict(
"queued",
Some("snail"),
Some(&progress_with_heartbeat(Some(now - 1_000))),
now,
window,
)
.is_some(),
"a freshly queued assignment is in-flight, not reassignable"
);
// Assigned + heartbeat at/over the stale window -> allow (stale path).
assert!(
super::active_assignment_conflict(
"running",
Some("snail"),
Some(&progress_with_heartbeat(Some(now - window))),
now,
window,
)
.is_none(),
"stale assignments stay reassignable"
);
// Assigned with no progress record or no timestamps -> allow (treated
// stale, mirroring refresh_swarm_task_staleness).
assert!(super::active_assignment_conflict("running", Some("snail"), None, now, window).is_none());
assert!(
super::active_assignment_conflict(
"running",
Some("snail"),
Some(&progress_with_heartbeat(None)),
now,
window,
)
.is_none()
);
// started_at / assigned_at count as activity when no heartbeat landed yet.
let just_assigned = crate::server::SwarmTaskProgress {
assigned_session_id: Some("snail".to_string()),
assigned_at_unix_ms: Some(now - 5_000),
..Default::default()
};
assert!(
super::active_assignment_conflict("queued", Some("snail"), Some(&just_assigned), now, window)
.is_some(),
"an assignment made moments ago is active even before its first heartbeat"
);
// running_stale and terminal statuses -> allow (existing recovery paths).
for status in ["running_stale", "failed", "stopped", "crashed", "completed", "done"] {
assert!(
super::active_assignment_conflict(
status,
Some("snail"),
Some(&progress_with_heartbeat(Some(now - 1_000))),
now,
window,
)
.is_none(),
"status '{status}' must not trigger the double-assignment guard"
);
}
}
#[allow(clippy::type_complexity)]
fn double_assign_fixture(
swarm_id: &str,
requester: &str,
holder: &str,
intruder: &str,
contested: PlanItem,
progress: crate::server::SwarmTaskProgress,
) -> (
Arc<RwLock<HashMap<String, Arc<Mutex<Agent>>>>>,
Arc<RwLock<HashMap<String, SwarmMember>>>,
Arc<RwLock<HashMap<String, HashSet<String>>>>,
Arc<RwLock<HashMap<String, VersionedPlan>>>,
Arc<RwLock<HashMap<String, String>>>,
) {
let swarm_members = Arc::new(RwLock::new(HashMap::from([
(requester.to_string(), {
let mut member = member(requester, swarm_id, "ready");
member.role = "coordinator".to_string();
member
}),
(holder.to_string(), member(holder, swarm_id, "running")),
(intruder.to_string(), member(intruder, swarm_id, "ready")),
])));
let swarms_by_id = Arc::new(RwLock::new(HashMap::from([(
swarm_id.to_string(),
HashSet::from([
requester.to_string(),
holder.to_string(),
intruder.to_string(),
]),
)])));
let task_id = contested.id.clone();
let swarm_plans = Arc::new(RwLock::new(HashMap::from([(
swarm_id.to_string(),
VersionedPlan {
items: vec![contested],
version: 1,
participants: HashSet::from([requester.to_string(), holder.to_string()]),
task_progress: HashMap::from([(task_id, progress)]),
mode: "light".to_string(),
node_meta: HashMap::new(),
},
)])));
let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([(
swarm_id.to_string(),
requester.to_string(),
)])));
let sessions = Arc::new(RwLock::new(HashMap::new()));
(
sessions,
swarm_members,
swarms_by_id,
swarm_plans,
swarm_coordinators,
)
}
fn unix_now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock after epoch")
.as_millis() as u64
}
/// Live path: explicit assign_task against an assigned-and-active node is
/// rejected and the plan keeps the original assignee.
#[tokio::test]
async fn assign_task_rejects_double_assignment_of_actively_worked_task() {
let (_env, _runtime) = RuntimeEnvGuard::new();
let swarm_id = "swarm-double-assign";
let (requester, holder, intruder) = ("coord", "snail", "penguin");
let mut contested = plan_item("contested", "running", "high", &[]);
contested.assigned_to = Some(holder.to_string());
let (sessions, swarm_members, swarms_by_id, swarm_plans, swarm_coordinators) =
double_assign_fixture(
swarm_id,
requester,
holder,
intruder,
contested,
crate::server::SwarmTaskProgress {
assigned_session_id: Some(holder.to_string()),
last_heartbeat_unix_ms: Some(unix_now_ms()),
..Default::default()
},
);
sessions
.write()
.await
.insert(intruder.to_string(), test_agent().await);
let (client_tx, mut client_rx) = mpsc::unbounded_channel();
let soft_interrupt_queues = Arc::new(RwLock::new(HashMap::new()));
let client_connections = Arc::new(RwLock::new(HashMap::new()));
let event_history = Arc::new(RwLock::new(VecDeque::new()));
let event_counter = Arc::new(AtomicU64::new(1));
let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(32);
let mutation_runtime = SwarmMutationRuntime::default();
handle_comm_assign_task(
91,
requester.to_string(),
Some(intruder.to_string()),
Some("contested".to_string()),
None,
&client_tx,
&sessions,
&soft_interrupt_queues,
&client_connections,
&swarm_members,
&swarms_by_id,
&swarm_plans,
&swarm_coordinators,
&event_history,
&event_counter,
&swarm_event_tx,
&mutation_runtime,
)
.await;
match client_rx.recv().await.expect("response") {
ServerEvent::Error { message, .. } => {
assert!(
message.contains("already assigned to 'snail'"),
"error must name the current assignee: {message}"
);
assert!(
message.contains("reassign"),
"error must point at the explicit takeover path: {message}"
);
}
other => panic!("expected double-assignment rejection, got {other:?}"),
}
let plans = swarm_plans.read().await;
let item = plans[swarm_id]
.items
.iter()
.find(|item| item.id == "contested")
.expect("contested task exists");
assert_eq!(
item.assigned_to.as_deref(),
Some(holder),
"rejected double assignment must not steal the task"
);
assert_eq!(item.status, "running", "lifecycle status untouched");
}
/// Live path: an assignment whose heartbeat is far past the stale window is
/// legitimately reassignable (dead-assignee recovery must keep working).
#[tokio::test]
async fn assign_task_allows_taking_over_stale_assignment() {
let (_env, _runtime) = RuntimeEnvGuard::new();
let swarm_id = "swarm-double-assign-stale";
let (requester, holder, intruder) = ("coord", "snail", "penguin");
let mut stalled = plan_item("stalled", "queued", "high", &[]);
stalled.assigned_to = Some(holder.to_string());
let (sessions, swarm_members, swarms_by_id, swarm_plans, swarm_coordinators) =
double_assign_fixture(
swarm_id,
requester,
holder,
intruder,
stalled,
crate::server::SwarmTaskProgress {
assigned_session_id: Some(holder.to_string()),
// Far beyond any configured stale window.
last_heartbeat_unix_ms: Some(unix_now_ms().saturating_sub(3_600_000)),
..Default::default()
},
);
sessions
.write()
.await
.insert(intruder.to_string(), test_agent().await);
let (client_tx, mut client_rx) = mpsc::unbounded_channel();
let soft_interrupt_queues = Arc::new(RwLock::new(HashMap::new()));
let client_connections = Arc::new(RwLock::new(HashMap::new()));
let event_history = Arc::new(RwLock::new(VecDeque::new()));
let event_counter = Arc::new(AtomicU64::new(1));
let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(32);
let mutation_runtime = SwarmMutationRuntime::default();
handle_comm_assign_task(
92,
requester.to_string(),
Some(intruder.to_string()),
Some("stalled".to_string()),
None,
&client_tx,
&sessions,
&soft_interrupt_queues,
&client_connections,
&swarm_members,
&swarms_by_id,
&swarm_plans,
&swarm_coordinators,
&event_history,
&event_counter,
&swarm_event_tx,
&mutation_runtime,
)
.await;
match client_rx.recv().await.expect("response") {
ServerEvent::CommAssignTaskResponse {
task_id,
target_session,
..
} => {
assert_eq!(task_id, "stalled");
assert_eq!(target_session, intruder);
}
other => panic!("stale assignment takeover should succeed, got {other:?}"),
}
let plans = swarm_plans.read().await;
let item = plans[swarm_id]
.items
.iter()
.find(|item| item.id == "stalled")
.expect("stalled task exists");
assert_eq!(item.assigned_to.as_deref(), Some(intruder));
}
/// Takeover path: task_control reassign moves the task AND tells the displaced
/// worker to stand down (soft interrupt + DM), so it stops editing the same
/// files as its replacement.
#[tokio::test]
async fn task_control_reassign_tells_displaced_worker_to_stand_down() {
let (_env, _runtime) = RuntimeEnvGuard::new();
let swarm_id = "swarm-reassign-stand-down";
let (requester, holder, intruder) = ("coord", "snail", "penguin");
let mut contested = plan_item("contested", "running_stale", "high", &[]);
contested.assigned_to = Some(holder.to_string());
let (sessions, swarm_members, swarms_by_id, swarm_plans, swarm_coordinators) =
double_assign_fixture(
swarm_id,
requester,
holder,
intruder,
contested,
crate::server::SwarmTaskProgress {
assigned_session_id: Some(holder.to_string()),
last_heartbeat_unix_ms: Some(unix_now_ms().saturating_sub(3_600_000)),
..Default::default()
},
);
// Capture the displaced worker's server-event stream to observe the DM.
let (holder_tx, mut holder_rx) = mpsc::unbounded_channel();
swarm_members
.write()
.await
.get_mut(holder)
.expect("holder member")
.event_tx = holder_tx;
sessions
.write()
.await
.insert(holder.to_string(), test_agent().await);
sessions
.write()
.await
.insert(intruder.to_string(), test_agent().await);
let (client_tx, mut client_rx) = mpsc::unbounded_channel();
let soft_interrupt_queues = Arc::new(RwLock::new(HashMap::new()));
let client_connections = Arc::new(RwLock::new(HashMap::new()));
let event_history = Arc::new(RwLock::new(VecDeque::new()));
let event_counter = Arc::new(AtomicU64::new(1));
let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(32);
let mutation_runtime = SwarmMutationRuntime::default();
handle_comm_task_control(
93,
requester.to_string(),
"reassign".to_string(),
"contested".to_string(),
Some(intruder.to_string()),
None,
&client_tx,
&sessions,
&soft_interrupt_queues,
&client_connections,
&swarm_members,
&swarms_by_id,
&swarm_plans,
&swarm_coordinators,
&event_history,
&event_counter,
&swarm_event_tx,
&mutation_runtime,
)
.await;
match client_rx.recv().await.expect("response") {
ServerEvent::CommAssignTaskResponse {
task_id,
target_session,
..
} => {
assert_eq!(task_id, "contested");
assert_eq!(target_session, intruder);
}
other => panic!("reassign should re-dispatch the task, got {other:?}"),
}
{
let plans = swarm_plans.read().await;
let item = plans[swarm_id]
.items
.iter()
.find(|item| item.id == "contested")
.expect("contested task exists");
assert_eq!(item.assigned_to.as_deref(), Some(intruder));
}
// The displaced worker's soft-interrupt queue carries the stand-down order.
let stand_down = {
let queues = soft_interrupt_queues.read().await;
queues.get(holder).and_then(|queue| {
queue.lock().ok().and_then(|pending| {
pending
.iter()
.map(|msg| msg.content.clone())
.find(|content| content.contains("handed off"))
})
})
};
let stand_down =
stand_down.expect("displaced worker must receive a stand-down soft interrupt");
assert!(
stand_down.contains("'contested'") && stand_down.contains("'penguin'"),
"stand-down order must name the task and the new assignee: {stand_down}"
);
assert!(
stand_down.contains("Stop working"),
"stand-down order must tell the worker to stop: {stand_down}"
);
// And the DM notification reaches its event stream.
let mut saw_dm = false;
while let Ok(event) = holder_rx.try_recv() {
if let ServerEvent::Notification { message, .. } = event
&& message.contains("handed off")
{
saw_dm = true;
}
}
assert!(saw_dm, "displaced worker must receive a stand-down DM");
}
@@ -0,0 +1,98 @@
#[tokio::test]
async fn assign_task_without_target_prefers_less_loaded_ready_agent() {
let (_env, _runtime) = RuntimeEnvGuard::new();
let swarm_id = "swarm-auto-target-load";
let requester = "coord";
let less_loaded = "worker-light";
let more_loaded = "worker-busy";
let (client_tx, mut client_rx) = mpsc::unbounded_channel();
let sessions = Arc::new(RwLock::new(HashMap::new()));
let soft_interrupt_queues = Arc::new(RwLock::new(HashMap::new()));
let client_connections = Arc::new(RwLock::new(HashMap::new()));
let swarm_members = Arc::new(RwLock::new(HashMap::from([
(requester.to_string(), {
let mut member = member(requester, swarm_id, "ready");
member.role = "coordinator".to_string();
member
}),
(
less_loaded.to_string(),
owned_member(less_loaded, swarm_id, "ready", requester),
),
(
more_loaded.to_string(),
owned_member(more_loaded, swarm_id, "ready", requester),
),
])));
let swarms_by_id = Arc::new(RwLock::new(HashMap::from([(
swarm_id.to_string(),
HashSet::from([
requester.to_string(),
less_loaded.to_string(),
more_loaded.to_string(),
]),
)])));
let mut busy_existing = plan_item("busy-existing", "running", "high", &[]);
busy_existing.assigned_to = Some(more_loaded.to_string());
let swarm_plans = Arc::new(RwLock::new(HashMap::from([(
swarm_id.to_string(),
VersionedPlan {
items: vec![
plan_item("setup", "completed", "high", &[]),
busy_existing,
plan_item("next", "queued", "high", &["setup"]),
],
version: 1,
participants: HashSet::from([
requester.to_string(),
less_loaded.to_string(),
more_loaded.to_string(),
]),
task_progress: HashMap::new(),
mode: "light".to_string(),
node_meta: HashMap::new(),
},
)])));
let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([(
swarm_id.to_string(),
requester.to_string(),
)])));
let event_history = Arc::new(RwLock::new(VecDeque::new()));
let event_counter = Arc::new(AtomicU64::new(1));
let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(32);
let mutation_runtime = SwarmMutationRuntime::default();
handle_comm_assign_task(
100,
requester.to_string(),
None,
None,
Some("Pick the least-loaded worker".to_string()),
&client_tx,
&sessions,
&soft_interrupt_queues,
&client_connections,
&swarm_members,
&swarms_by_id,
&swarm_plans,
&swarm_coordinators,
&event_history,
&event_counter,
&swarm_event_tx,
&mutation_runtime,
)
.await;
match client_rx.recv().await.expect("response") {
ServerEvent::CommAssignTaskResponse {
id,
task_id,
target_session,
} => {
assert_eq!(id, 100);
assert_eq!(task_id, "next");
assert_eq!(target_session, less_loaded);
}
other => panic!("expected CommAssignTaskResponse, got {other:?}"),
}
}
@@ -0,0 +1,104 @@
#[tokio::test]
async fn assign_next_prefers_worker_with_dependency_context() {
let (_env, _runtime) = RuntimeEnvGuard::new();
let swarm_id = "swarm-context-score";
let requester = "coord";
let context_worker = "worker-context";
let other_worker = "worker-other";
let (client_tx, mut client_rx) = mpsc::unbounded_channel();
let sessions = Arc::new(RwLock::new(HashMap::new()));
let soft_interrupt_queues = Arc::new(RwLock::new(HashMap::new()));
let client_connections = Arc::new(RwLock::new(HashMap::new()));
let swarm_members = Arc::new(RwLock::new(HashMap::from([
(requester.to_string(), {
let mut member = member(requester, swarm_id, "ready");
member.role = "coordinator".to_string();
member
}),
(
context_worker.to_string(),
owned_member(context_worker, swarm_id, "ready", requester),
),
(
other_worker.to_string(),
owned_member(other_worker, swarm_id, "ready", requester),
),
])));
let swarms_by_id = Arc::new(RwLock::new(HashMap::from([(
swarm_id.to_string(),
HashSet::from([
requester.to_string(),
context_worker.to_string(),
other_worker.to_string(),
]),
)])));
let mut dependency = plan_item("dep", "completed", "high", &[]);
dependency.assigned_to = Some(context_worker.to_string());
let swarm_plans = Arc::new(RwLock::new(HashMap::from([(
swarm_id.to_string(),
VersionedPlan {
items: vec![dependency, plan_item("next", "queued", "high", &["dep"])],
version: 1,
participants: HashSet::from([
requester.to_string(),
context_worker.to_string(),
other_worker.to_string(),
]),
task_progress: HashMap::new(),
mode: "light".to_string(),
node_meta: HashMap::new(),
},
)])));
let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([(
swarm_id.to_string(),
requester.to_string(),
)])));
let event_history = Arc::new(RwLock::new(VecDeque::new()));
let event_counter = Arc::new(AtomicU64::new(1));
let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(32);
let mutation_runtime = SwarmMutationRuntime::default();
let provider: Arc<dyn Provider> = Arc::new(TestProvider);
let global_session_id = Arc::new(RwLock::new(String::new()));
let mcp_pool = Arc::new(crate::mcp::SharedMcpPool::from_default_config());
handle_comm_assign_next(
102,
requester.to_string(),
None,
None,
None,
None,
None,
None,
None,
&client_tx,
&sessions,
&global_session_id,
&provider,
&soft_interrupt_queues,
&client_connections,
&swarm_members,
&swarms_by_id,
&swarm_plans,
&swarm_coordinators,
&event_history,
&event_counter,
&swarm_event_tx,
&mcp_pool,
&mutation_runtime,
)
.await;
match client_rx.recv().await.expect("response") {
ServerEvent::CommAssignTaskResponse {
id,
task_id,
target_session,
} => {
assert_eq!(id, 102);
assert_eq!(task_id, "next");
assert_eq!(target_session, context_worker);
}
other => panic!("expected CommAssignTaskResponse, got {other:?}"),
}
}
@@ -0,0 +1,109 @@
#[tokio::test]
async fn assign_next_prefers_worker_with_matching_subsystem_metadata() {
let (_env, _runtime) = RuntimeEnvGuard::new();
let swarm_id = "swarm-metadata-score";
let requester = "coord";
let metadata_worker = "worker-metadata";
let other_worker = "worker-other";
let (client_tx, mut client_rx) = mpsc::unbounded_channel();
let sessions = Arc::new(RwLock::new(HashMap::new()));
let soft_interrupt_queues = Arc::new(RwLock::new(HashMap::new()));
let client_connections = Arc::new(RwLock::new(HashMap::new()));
let swarm_members = Arc::new(RwLock::new(HashMap::from([
(requester.to_string(), {
let mut member = member(requester, swarm_id, "ready");
member.role = "coordinator".to_string();
member
}),
(
metadata_worker.to_string(),
owned_member(metadata_worker, swarm_id, "ready", requester),
),
(
other_worker.to_string(),
owned_member(other_worker, swarm_id, "ready", requester),
),
])));
let swarms_by_id = Arc::new(RwLock::new(HashMap::from([(
swarm_id.to_string(),
HashSet::from([
requester.to_string(),
metadata_worker.to_string(),
other_worker.to_string(),
]),
)])));
let mut prior = plan_item("prior", "completed", "high", &[]);
prior.subsystem = Some("parser".to_string());
prior.file_scope = vec!["src/parser.rs".to_string()];
prior.assigned_to = Some(metadata_worker.to_string());
let mut next = plan_item("next", "queued", "high", &[]);
next.subsystem = Some("parser".to_string());
next.file_scope = vec!["src/parser.rs".to_string()];
let swarm_plans = Arc::new(RwLock::new(HashMap::from([(
swarm_id.to_string(),
VersionedPlan {
items: vec![prior, next],
version: 1,
participants: HashSet::from([
requester.to_string(),
metadata_worker.to_string(),
other_worker.to_string(),
]),
task_progress: HashMap::new(),
mode: "light".to_string(),
node_meta: HashMap::new(),
},
)])));
let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([(
swarm_id.to_string(),
requester.to_string(),
)])));
let event_history = Arc::new(RwLock::new(VecDeque::new()));
let event_counter = Arc::new(AtomicU64::new(1));
let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(32);
let mutation_runtime = SwarmMutationRuntime::default();
let provider: Arc<dyn Provider> = Arc::new(TestProvider);
let global_session_id = Arc::new(RwLock::new(String::new()));
let mcp_pool = Arc::new(crate::mcp::SharedMcpPool::from_default_config());
handle_comm_assign_next(
103,
requester.to_string(),
None,
None,
None,
None,
None,
None,
None,
&client_tx,
&sessions,
&global_session_id,
&provider,
&soft_interrupt_queues,
&client_connections,
&swarm_members,
&swarms_by_id,
&swarm_plans,
&swarm_coordinators,
&event_history,
&event_counter,
&swarm_event_tx,
&mcp_pool,
&mutation_runtime,
)
.await;
match client_rx.recv().await.expect("response") {
ServerEvent::CommAssignTaskResponse {
id,
task_id,
target_session,
} => {
assert_eq!(id, 103);
assert_eq!(task_id, "next");
assert_eq!(target_session, metadata_worker);
}
other => panic!("expected CommAssignTaskResponse, got {other:?}"),
}
}
@@ -0,0 +1,102 @@
#[tokio::test]
async fn assign_task_without_target_picks_ready_agent() {
let (_env, _runtime) = RuntimeEnvGuard::new();
let swarm_id = "swarm-auto-target";
let requester = "coord";
let ready_worker = "worker-ready";
let completed_worker = "worker-completed";
let running_worker = "worker-running";
let (client_tx, mut client_rx) = mpsc::unbounded_channel();
let sessions = Arc::new(RwLock::new(HashMap::new()));
let soft_interrupt_queues = Arc::new(RwLock::new(HashMap::new()));
let client_connections = Arc::new(RwLock::new(HashMap::new()));
let swarm_members = Arc::new(RwLock::new(HashMap::from([
(requester.to_string(), {
let mut member = member(requester, swarm_id, "ready");
member.role = "coordinator".to_string();
member
}),
(
ready_worker.to_string(),
owned_member(ready_worker, swarm_id, "ready", requester),
),
(
completed_worker.to_string(),
owned_member(completed_worker, swarm_id, "completed", requester),
),
(
running_worker.to_string(),
owned_member(running_worker, swarm_id, "running", requester),
),
])));
let swarms_by_id = Arc::new(RwLock::new(HashMap::from([(
swarm_id.to_string(),
HashSet::from([
requester.to_string(),
ready_worker.to_string(),
completed_worker.to_string(),
running_worker.to_string(),
]),
)])));
let swarm_plans = Arc::new(RwLock::new(HashMap::from([(
swarm_id.to_string(),
VersionedPlan {
items: vec![
plan_item("setup", "completed", "high", &[]),
plan_item("next", "queued", "high", &["setup"]),
],
version: 1,
participants: HashSet::from([
requester.to_string(),
ready_worker.to_string(),
completed_worker.to_string(),
running_worker.to_string(),
]),
task_progress: HashMap::new(),
mode: "light".to_string(),
node_meta: HashMap::new(),
},
)])));
let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([(
swarm_id.to_string(),
requester.to_string(),
)])));
let event_history = Arc::new(RwLock::new(VecDeque::new()));
let event_counter = Arc::new(AtomicU64::new(1));
let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(32);
let mutation_runtime = SwarmMutationRuntime::default();
handle_comm_assign_task(
99,
requester.to_string(),
None,
None,
Some("Pick a task and worker".to_string()),
&client_tx,
&sessions,
&soft_interrupt_queues,
&client_connections,
&swarm_members,
&swarms_by_id,
&swarm_plans,
&swarm_coordinators,
&event_history,
&event_counter,
&swarm_event_tx,
&mutation_runtime,
)
.await;
match client_rx.recv().await.expect("response") {
ServerEvent::CommAssignTaskResponse {
id,
task_id,
target_session,
} => {
assert_eq!(id, 99);
assert_eq!(task_id, "next");
assert_eq!(target_session, ready_worker);
}
other => panic!("expected CommAssignTaskResponse, got {other:?}"),
}
}
@@ -0,0 +1,197 @@
#[tokio::test]
async fn assign_task_without_task_id_picks_highest_priority_runnable_task() {
let (_env, _runtime) = RuntimeEnvGuard::new();
let swarm_id = "swarm-assign";
let requester = "coord";
let worker = "worker";
let (client_tx, mut client_rx) = mpsc::unbounded_channel();
let sessions = Arc::new(RwLock::new(HashMap::new()));
let soft_interrupt_queues = Arc::new(RwLock::new(HashMap::new()));
let client_connections = Arc::new(RwLock::new(HashMap::new()));
let swarm_members = Arc::new(RwLock::new(HashMap::from([
(requester.to_string(), {
let mut member = member(requester, swarm_id, "ready");
member.role = "coordinator".to_string();
member
}),
(worker.to_string(), member(worker, swarm_id, "ready")),
])));
let swarms_by_id = Arc::new(RwLock::new(HashMap::from([(
swarm_id.to_string(),
HashSet::from([requester.to_string(), worker.to_string()]),
)])));
let swarm_plans = Arc::new(RwLock::new(HashMap::from([(
swarm_id.to_string(),
VersionedPlan {
items: vec![
plan_item("done", "completed", "high", &[]),
plan_item("blocked", "queued", "high", &["high-ready"]),
plan_item("low-ready", "queued", "low", &["done"]),
plan_item("high-ready", "queued", "high", &["done"]),
],
version: 1,
participants: HashSet::from([requester.to_string(), worker.to_string()]),
task_progress: HashMap::new(),
mode: "light".to_string(),
node_meta: HashMap::new(),
},
)])));
let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([(
swarm_id.to_string(),
requester.to_string(),
)])));
let event_history = Arc::new(RwLock::new(VecDeque::new()));
let event_counter = Arc::new(AtomicU64::new(1));
let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(32);
let mutation_runtime = SwarmMutationRuntime::default();
handle_comm_assign_task(
77,
requester.to_string(),
Some(worker.to_string()),
None,
Some("Pick the next task".to_string()),
&client_tx,
&sessions,
&soft_interrupt_queues,
&client_connections,
&swarm_members,
&swarms_by_id,
&swarm_plans,
&swarm_coordinators,
&event_history,
&event_counter,
&swarm_event_tx,
&mutation_runtime,
)
.await;
let response = client_rx.recv().await.expect("response");
match response {
ServerEvent::CommAssignTaskResponse {
id,
task_id,
target_session,
} => {
assert_eq!(id, 77);
assert_eq!(task_id, "high-ready");
assert_eq!(target_session, worker);
}
other => panic!("expected CommAssignTaskResponse, got {other:?}"),
}
let plans = swarm_plans.read().await;
let plan = plans.get(swarm_id).expect("plan exists");
let selected = plan
.items
.iter()
.find(|item| item.id == "high-ready")
.expect("selected task exists");
assert_eq!(selected.assigned_to.as_deref(), Some(worker));
assert_eq!(selected.status, "queued");
let blocked = plan
.items
.iter()
.find(|item| item.id == "blocked")
.expect("blocked task exists");
assert!(
blocked.assigned_to.is_none(),
"blocked task should not be auto-assigned"
);
let members = swarm_members.read().await;
let worker_member = members.get(worker).expect("worker member exists");
assert_eq!(
worker_member.status, "queued",
"assigned worker should stop looking completed/ready before async execution starts"
);
}
#[tokio::test]
async fn assign_task_marks_completed_worker_queued_before_returning() {
let (_env, _runtime) = RuntimeEnvGuard::new();
let swarm_id = "swarm-assign-completed-worker";
let requester = "coord";
let worker = "worker-completed";
let (client_tx, mut client_rx) = mpsc::unbounded_channel();
let sessions = Arc::new(RwLock::new(HashMap::new()));
let soft_interrupt_queues = Arc::new(RwLock::new(HashMap::new()));
let client_connections = Arc::new(RwLock::new(HashMap::new()));
let swarm_members = Arc::new(RwLock::new(HashMap::from([
(requester.to_string(), {
let mut member = member(requester, swarm_id, "ready");
member.role = "coordinator".to_string();
member
}),
(worker.to_string(), member(worker, swarm_id, "completed")),
])));
let swarms_by_id = Arc::new(RwLock::new(HashMap::from([(
swarm_id.to_string(),
HashSet::from([requester.to_string(), worker.to_string()]),
)])));
let swarm_plans = Arc::new(RwLock::new(HashMap::from([(
swarm_id.to_string(),
VersionedPlan {
items: vec![plan_item("next", "queued", "high", &[])],
version: 1,
participants: HashSet::from([requester.to_string(), worker.to_string()]),
task_progress: HashMap::new(),
mode: "light".to_string(),
node_meta: HashMap::new(),
},
)])));
let swarm_coordinators = Arc::new(RwLock::new(HashMap::from([(
swarm_id.to_string(),
requester.to_string(),
)])));
let event_history = Arc::new(RwLock::new(VecDeque::new()));
let event_counter = Arc::new(AtomicU64::new(1));
let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(32);
let mutation_runtime = SwarmMutationRuntime::default();
handle_comm_assign_task(
78,
requester.to_string(),
Some(worker.to_string()),
Some("next".to_string()),
None,
&client_tx,
&sessions,
&soft_interrupt_queues,
&client_connections,
&swarm_members,
&swarms_by_id,
&swarm_plans,
&swarm_coordinators,
&event_history,
&event_counter,
&swarm_event_tx,
&mutation_runtime,
)
.await;
match client_rx.recv().await.expect("response") {
ServerEvent::CommAssignTaskResponse {
id,
task_id,
target_session,
} => {
assert_eq!(id, 78);
assert_eq!(task_id, "next");
assert_eq!(target_session, worker);
}
other => panic!("expected CommAssignTaskResponse, got {other:?}"),
}
let members = swarm_members.read().await;
let worker_member = members.get(worker).expect("worker member exists");
assert_eq!(worker_member.status, "queued");
assert!(
worker_member
.detail
.as_deref()
.is_some_and(|detail| detail.contains("task next")),
"queued member should include the assigned task in its detail"
);
}
@@ -0,0 +1,216 @@
// Auto-assignment must only target *drivable* workers. An independent,
// client-attached human session that happens to share the swarm is NOT driven by
// `spawn_assigned_task_run` (that only fires when the target has no live client),
// so auto-picking it would strand the task and stall `run_plan`. These tests pin
// the candidate filter so reuse of owned/headless workers keeps working while
// foreign client-attached sessions are excluded (leaving room for a fresh spawn).
//
// Included into the `comm_control::tests` module, so the parent's private
// `filter_swarm_agent_candidates` / `is_drivable_auto_worker` are in scope.
use super::{filter_swarm_agent_candidates, is_drivable_auto_worker};
fn agent_member(session_id: &str, swarm_id: &str) -> SwarmMember {
member(session_id, swarm_id, "ready")
}
#[test]
fn headless_worker_is_drivable() {
let mut m = agent_member("w", "s");
m.is_headless = true;
// No owner, but headless workers are always auto-driven in-process.
assert!(is_drivable_auto_worker(&m, "coord"));
}
#[test]
fn worker_owned_by_requester_is_drivable_even_with_live_client() {
let mut m = agent_member("w", "s");
m.is_headless = false;
m.report_back_to_session_id = Some("coord".to_string());
// Simulate a live client attachment; ownership still makes it reusable.
let (tx, _rx) = mpsc::unbounded_channel();
m.event_txs.insert("conn-1".to_string(), tx);
assert!(is_drivable_auto_worker(&m, "coord"));
}
#[test]
fn unowned_session_with_live_client_is_not_drivable() {
let mut m = agent_member("human", "s");
m.is_headless = false;
m.report_back_to_session_id = None; // independent user session
let (tx, _rx) = mpsc::unbounded_channel();
m.event_txs.insert("conn-1".to_string(), tx);
assert!(!is_drivable_auto_worker(&m, "coord"));
}
#[test]
fn unowned_session_is_not_auto_drivable() {
// A foreign session that this run does not own is never auto-assignable, even
// if it currently has no client attachment: it may be a stale "zombie" with no
// live agent loop (the run_plan stall we are guarding against). Such sessions
// require an explicit target_session.
let mut m = agent_member("zombie", "s");
m.is_headless = false;
m.report_back_to_session_id = None;
// No client attachment, yet still not auto-drivable because it is unowned.
assert!(!is_drivable_auto_worker(&m, "coord"));
}
#[tokio::test]
async fn auto_candidate_filter_excludes_foreign_client_attached_session() {
let swarm_id = "swarm-filter";
let coord = "coord";
let owned = "owned-worker";
let headless = "headless-worker";
let foreign = "foreign-human";
let mut owned_member = agent_member(owned, swarm_id);
owned_member.report_back_to_session_id = Some(coord.to_string());
let (otx, _orx) = mpsc::unbounded_channel();
owned_member.event_txs.insert("c".to_string(), otx); // owned + attached, still ok
let mut headless_member = agent_member(headless, swarm_id);
headless_member.is_headless = true;
let mut foreign_member = agent_member(foreign, swarm_id);
let (ftx, _frx) = mpsc::unbounded_channel();
foreign_member.event_txs.insert("c".to_string(), ftx); // unowned + attached -> excluded
let members: HashMap<String, SwarmMember> = HashMap::from([
(coord.to_string(), {
let mut m = agent_member(coord, swarm_id);
m.role = "coordinator".to_string();
m
}),
(owned.to_string(), owned_member),
(headless.to_string(), headless_member),
(foreign.to_string(), foreign_member),
]);
let candidates = filter_swarm_agent_candidates(&members, coord, swarm_id);
let ids: std::collections::HashSet<&str> =
candidates.iter().map(|m| m.session_id.as_str()).collect();
assert!(ids.contains(owned), "owned worker should be eligible");
assert!(ids.contains(headless), "headless worker should be eligible");
assert!(
!ids.contains(foreign),
"foreign client-attached session must be excluded from auto-assign"
);
}
// ----- composite-aware turn completion -----
use super::turn_end_should_auto_complete;
#[test]
fn atomic_turn_auto_completes() {
// A plain running atomic node that the worker just ran should be
// auto-marked done. `running_stale` (revived after a reload) counts too.
assert!(turn_end_should_auto_complete("running", false));
assert!(turn_end_should_auto_complete("running_stale", false));
}
#[test]
fn requeued_node_does_not_auto_complete() {
// A node that is `queued` at turn end was re-queued mid-turn by someone
// else: a gate that called inject_gap (re-queued behind its gap nodes), or
// a reassign/requeue. Force-closing it would bypass gate artifact
// validation and strand the injected gap nodes.
assert!(!turn_end_should_auto_complete("queued", false));
}
#[test]
fn expanded_composite_turn_does_not_auto_complete() {
// The worker decomposed the node; it must stay open to synthesize later, even
// though its own turn ended. This is the bug that stranded composite subtrees.
assert!(!turn_end_should_auto_complete("running", true));
assert!(!turn_end_should_auto_complete("queued", true));
}
#[test]
fn already_terminal_turn_is_not_reclosed() {
// If the worker already completed/failed the node itself, the turn end must
// not stomp that status.
assert!(!turn_end_should_auto_complete("completed", false));
assert!(!turn_end_should_auto_complete("done", false));
assert!(!turn_end_should_auto_complete("failed", false));
assert!(!turn_end_should_auto_complete("stopped", false));
}
// ----- deep mode: artifact-or-nothing at turn end -----
use super::{TurnEndDisposition, turn_end_disposition};
#[test]
fn deep_turn_without_artifact_requeues_then_fails() {
// Deep mode never auto-completes: the typed-artifact contract is only real
// if there is no path to "done" that skips complete_node. First offense is
// a requeue to a fresh worker; a repeat fails the node loudly.
assert_eq!(
turn_end_disposition(true, "running", false, 0),
TurnEndDisposition::RequeueNoArtifact
);
assert_eq!(
turn_end_disposition(true, "running_stale", false, 0),
TurnEndDisposition::RequeueNoArtifact
);
assert_eq!(
turn_end_disposition(true, "running", false, 1),
TurnEndDisposition::FailNoArtifact
);
// A re-woken composite synthesis that never synthesized is equally guilty.
assert_eq!(
turn_end_disposition(true, "running", true, 0),
TurnEndDisposition::RequeueNoArtifact
);
}
#[test]
fn deep_turn_leaves_non_running_nodes_alone() {
// Terminal states were set by complete_node/fail; queued means the node was
// re-queued mid-turn (expand_node or inject_gap). None are this turn's
// responsibility.
for status in ["queued", "completed", "done", "failed", "stopped"] {
assert_eq!(
turn_end_disposition(true, status, false, 0),
TurnEndDisposition::LeaveAlone,
"status {status}"
);
}
}
#[test]
fn light_turn_disposition_matches_legacy_auto_complete() {
assert_eq!(
turn_end_disposition(false, "running", false, 0),
TurnEndDisposition::AutoComplete
);
assert_eq!(
turn_end_disposition(false, "running", true, 0),
TurnEndDisposition::LeaveAlone
);
assert_eq!(
turn_end_disposition(false, "queued", false, 0),
TurnEndDisposition::LeaveAlone
);
}
// ----- composite synthesis assignment content -----
use super::composite_synthesis_content;
#[test]
fn composite_synthesis_content_injects_complete_node_instruction() {
let out = composite_synthesis_content("root", "explore the thing", true);
assert!(out.contains("Synthesis turn for composite node 'root'"));
assert!(out.contains("complete_node"));
assert!(out.contains("Do NOT"));
// The original brief is preserved for context.
assert!(out.contains("explore the thing"));
}
#[test]
fn non_composite_content_is_verbatim() {
let out = composite_synthesis_content("leaf", "just do this", false);
assert_eq!(out, "just do this");
}
@@ -0,0 +1,85 @@
#[tokio::test]
async fn await_members_any_mode_returns_after_first_match() {
let (_env, _runtime) = RuntimeEnvGuard::new();
let swarm_id = "swarm-any";
let requester = "req";
let peer_a = "peer-a";
let peer_b = "peer-b";
let await_runtime = AwaitMembersRuntime::default();
let (client_tx, mut client_rx) = mpsc::unbounded_channel();
let swarm_members = Arc::new(RwLock::new(HashMap::from([
(requester.to_string(), member(requester, swarm_id, "ready")),
(peer_a.to_string(), member(peer_a, swarm_id, "running")),
(peer_b.to_string(), member(peer_b, swarm_id, "running")),
])));
let swarms_by_id = Arc::new(RwLock::new(HashMap::from([(
swarm_id.to_string(),
HashSet::from([
requester.to_string(),
peer_a.to_string(),
peer_b.to_string(),
]),
)])));
let (swarm_event_tx, _swarm_event_rx) = broadcast::channel(32);
handle_comm_await_members(
1,
requester.to_string(),
vec!["completed".to_string()],
vec![],
Some("any".to_string()),
Some(60),
false,
false,
false,
CommAwaitMembersContext {
client_event_tx: &client_tx,
swarm_members: &swarm_members,
swarms_by_id: &swarms_by_id,
swarm_event_tx: &swarm_event_tx,
await_members_runtime: &await_runtime,
},
)
.await;
{
let mut members = swarm_members.write().await;
members.get_mut(peer_a).expect("peer a exists").status = "completed".to_string();
}
let _ = swarm_event_tx.send(swarm_event(
peer_a,
swarm_id,
SwarmEventType::StatusChange {
old_status: "running".to_string(),
new_status: "completed".to_string(),
},
));
let response = tokio::time::timeout(Duration::from_secs(1), client_rx.recv())
.await
.expect("response should arrive")
.expect("channel should stay open");
match response {
ServerEvent::CommAwaitMembersResponse {
completed,
members,
summary,
..
} => {
assert!(
completed,
"await any should complete after first member matches"
);
assert!(
summary.contains("peer-a"),
"summary should mention matched member"
);
let done_members: Vec<_> = members.into_iter().filter(|member| member.done).collect();
assert_eq!(done_members.len(), 1);
assert_eq!(done_members[0].session_id, peer_a);
}
other => panic!("expected CommAwaitMembersResponse, got {other:?}"),
}
}

Some files were not shown because too many files have changed in this diff Show More