chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,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"] }
|
||||
@@ -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()"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
@@ -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>>,
|
||||
}
|
||||
@@ -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"] }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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")]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
¬ification,
|
||||
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 ¬ify_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,
|
||||
¬ification_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,
|
||||
®istry,
|
||||
&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");
|
||||
+174
@@ -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(())
|
||||
}
|
||||
+174
@@ -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(())
|
||||
}
|
||||
+206
@@ -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,
|
||||
®istry,
|
||||
&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(())
|
||||
}
|
||||
+160
@@ -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
Reference in New Issue
Block a user