use std::collections::{HashMap, HashSet, VecDeque};
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use ag_forge as forge;
use serde_json;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
use super::{SessionTaskService, isolation};
use crate::app::assist::AssistContext;
use crate::app::service::SessionUpdateVersionMap;
use crate::app::session::{
Clock, SessionError, TurnAppliedState, remote_branch_name_from_upstream_ref,
unix_timestamp_from_system_time,
};
use crate::app::{AppEvent, AppServices, SessionManager, branch_publish};
use crate::domain::agent::{AgentModel, ReasoningLevel};
use crate::domain::session::{
PublishBranchAction, PublishedBranchSyncStatus, ReviewRequest, ReviewRequestState,
SessionFollowUpTask, SessionId, SessionStats, Status,
};
use crate::domain::setting::SettingName;
use crate::domain::transcript_notice::TranscriptNotice;
use crate::infra::channel::{
AgentChannel, AgentError, AgentRequestKind, TurnEvent, TurnPrompt, TurnRequest, TurnResult,
create_agent_channel,
};
use crate::infra::db::{AppRepositories, SessionTurnMetadata};
use crate::infra::fs::FsClient;
use crate::infra::git::GitClient;
use crate::infra::{agent, process};
const RESTART_FAILURE_REASON: &str = "Interrupted by app restart";
const CANCEL_BEFORE_EXECUTION_REASON: &str = "Session canceled before execution";
pub(super) struct TurnMetadata {
pub(super) published_upstream_ref: Option<String>,
pub(super) session_model: AgentModel,
}
pub(super) enum SessionCommand {
Run {
operation_id: String,
request_kind: AgentRequestKind,
prompt: TurnPrompt,
turn_metadata: TurnMetadata,
},
}
impl SessionCommand {
fn operation_id(&self) -> &str {
match self {
Self::Run { operation_id, .. } => operation_id,
}
}
fn kind(&self) -> &'static str {
match self {
Self::Run {
request_kind: AgentRequestKind::SessionStart,
..
} => "start_prompt",
Self::Run {
request_kind: AgentRequestKind::SessionResume { .. },
..
} => "reply",
Self::Run {
request_kind: AgentRequestKind::UtilityPrompt,
..
} => "utility_prompt",
Self::Run {
request_kind: AgentRequestKind::AccountRead,
..
} => "account_read",
}
}
}
struct SessionWorkerContext {
app_event_tx: mpsc::UnboundedSender<AppEvent>,
cancel_token: Arc<Mutex<CancellationToken>>,
channel: Arc<dyn AgentChannel>,
child_pid: Arc<Mutex<Option<u32>>>,
clock: Arc<dyn Clock>,
db: AppRepositories,
folder: PathBuf,
fs_client: Arc<dyn FsClient>,
git_client: Arc<dyn GitClient>,
output: Arc<Mutex<String>>,
queued_messages: Arc<Mutex<VecDeque<TurnPrompt>>>,
review_request_client: Arc<dyn forge::ReviewRequestClient>,
session_update_versions: SessionUpdateVersionMap,
session_id: SessionId,
session_model: AgentModel,
status: Arc<Mutex<Status>>,
}
impl SessionWorkerContext {
fn pop_queued_prompt(&self) -> Option<TurnPrompt> {
self.queued_messages
.lock()
.ok()
.and_then(|mut guard| guard.pop_front())
}
fn clear_queued_messages(&self) {
if let Ok(mut guard) = self.queued_messages.lock() {
guard.clear();
}
}
fn current_status(&self) -> Status {
self.status.lock().map_or(Status::Review, |guard| *guard)
}
}
struct TurnPersistence<'a> {
context: &'a SessionWorkerContext,
session_model: AgentModel,
}
struct MainCheckoutSnapshot {
main_repo_root: PathBuf,
tracked_status_output: String,
}
impl MainCheckoutSnapshot {
async fn capture(context: &SessionWorkerContext) -> Result<Self, SessionError> {
let validation = isolation::validate_session_worktree(
context.fs_client.as_ref(),
context.git_client.as_ref(),
&context.folder,
&context.session_id,
)
.await?;
let tracked_status_output = context
.git_client
.tracked_worktree_status(validation.main_repo_root.clone())
.await
.map_err(|error| Self::status_error(&error))?;
Ok(Self {
main_repo_root: validation.main_repo_root,
tracked_status_output,
})
}
async fn changed_warning(
&self,
context: &SessionWorkerContext,
) -> Result<Option<String>, SessionError> {
let current_status = context
.git_client
.tracked_worktree_status(self.main_repo_root.clone())
.await
.map_err(|error| Self::status_error(&error))?;
if current_status != self.tracked_status_output {
return Ok(Some(TranscriptNotice::MainCheckoutWarning.format(
"Tracked files in the main checkout changed during this turn. Continuing this \
session; merge and sync actions still require a clean main checkout.",
)));
}
Ok(None)
}
fn status_error(error: &crate::infra::git::GitError) -> SessionError {
SessionError::Workflow(format!(
"Session isolation violation: failed to inspect main checkout tracked status: {error}"
))
}
}
pub(super) struct SessionWorkerRuntime {
cancel_token: Arc<Mutex<CancellationToken>>,
child_pid: Arc<Mutex<Option<u32>>>,
folder: PathBuf,
output: Arc<Mutex<String>>,
queued_messages: Arc<Mutex<VecDeque<TurnPrompt>>>,
review_request_client: Arc<dyn forge::ReviewRequestClient>,
session_update_versions: SessionUpdateVersionMap,
session_id: SessionId,
session_model: AgentModel,
status: Arc<Mutex<Status>>,
}
pub(crate) struct SessionWorkerService {
pub(in crate::app::session) test_agent_channels: HashMap<SessionId, Arc<dyn AgentChannel>>,
workers: HashMap<SessionId, mpsc::UnboundedSender<SessionCommand>>,
}
impl SessionWorkerService {
pub(in crate::app::session) fn new() -> Self {
Self {
test_agent_channels: HashMap::new(),
workers: HashMap::new(),
}
}
pub(super) async fn fail_unfinished_operations_from_previous_run_at(
db: &AppRepositories,
timestamp_seconds: i64,
) {
let interrupted_session_ids: HashSet<String> = db
.operations()
.load_unfinished_session_operations()
.await
.unwrap_or_default()
.into_iter()
.map(|operation| operation.session_id)
.collect();
for session_id in interrupted_session_ids {
let _ = db
.sessions()
.update_session_status_with_timing_at(
&session_id,
&Status::Review.to_string(),
timestamp_seconds,
)
.await;
}
let _ = db
.operations()
.fail_unfinished_session_operations(RESTART_FAILURE_REASON)
.await;
}
pub(super) async fn enqueue_session_command(
&mut self,
services: &AppServices,
runtime: SessionWorkerRuntime,
command: SessionCommand,
) -> Result<(), SessionError> {
let operation_id = command.operation_id().to_string();
let session_id = runtime.session_id.clone();
services
.db()
.operations()
.insert_session_operation(&operation_id, &session_id, command.kind())
.await?;
let sender = self.ensure_session_worker(services, &runtime);
if sender.send(command).is_err() {
let _ = services
.db()
.operations()
.mark_session_operation_failed(&operation_id, "Session worker is not available")
.await;
return Err(SessionError::Workflow(
"Session worker is not available".to_string(),
));
}
Ok(())
}
pub(super) fn clear_session_worker(&mut self, session_id: &str) {
self.workers.remove(session_id);
}
pub(super) fn retain_active_workers(&mut self, active_session_ids: &HashSet<SessionId>) {
self.workers
.retain(|session_id, _| active_session_ids.contains(session_id));
}
fn ensure_session_worker(
&mut self,
services: &AppServices,
runtime: &SessionWorkerRuntime,
) -> mpsc::UnboundedSender<SessionCommand> {
if let Some(sender) = self.workers.get(&runtime.session_id) {
return sender.clone();
}
let channel = self
.test_agent_channels
.remove(&runtime.session_id)
.unwrap_or_else(|| {
create_agent_channel(
runtime.session_model.kind(),
services.app_server_client_override(),
)
});
let context = SessionWorkerContext {
app_event_tx: services.event_sender(),
cancel_token: Arc::clone(&runtime.cancel_token),
channel,
child_pid: Arc::clone(&runtime.child_pid),
clock: services.clock(),
db: services.db().clone(),
folder: runtime.folder.clone(),
fs_client: services.fs_client(),
git_client: services.git_client(),
output: Arc::clone(&runtime.output),
queued_messages: Arc::clone(&runtime.queued_messages),
review_request_client: Arc::clone(&runtime.review_request_client),
session_update_versions: Arc::clone(&runtime.session_update_versions),
session_id: runtime.session_id.clone(),
session_model: runtime.session_model,
status: Arc::clone(&runtime.status),
};
let (sender, receiver) = mpsc::unbounded_channel();
self.workers
.insert(runtime.session_id.clone(), sender.clone());
Self::spawn_session_worker(context, receiver);
sender
}
fn spawn_session_worker(
context: SessionWorkerContext,
mut receiver: mpsc::UnboundedReceiver<SessionCommand>,
) {
tokio::spawn(async move {
while let Some(command) = receiver.recv().await {
let result = Self::process_session_command(&context, command).await;
if matches!(result, Some(Err(SessionError::StoppedByUser(_)))) {
context.clear_queued_messages();
Self::emit_queue_session_updated(&context);
continue;
}
Self::drain_queued_messages(&context).await;
}
let _ = context
.channel
.shutdown_session(context.session_id.to_string())
.await;
if let Ok(mut guard) = context.child_pid.lock() {
*guard = None;
}
});
}
async fn process_session_command(
context: &SessionWorkerContext,
command: SessionCommand,
) -> Option<Result<(), SessionError>> {
let operation_id = command.operation_id().to_string();
if Self::should_skip_worker_command(context, &operation_id).await {
return None;
}
let _ = context
.db
.operations()
.mark_session_operation_running(&operation_id)
.await;
if Self::should_skip_worker_command(context, &operation_id).await {
return None;
}
let result = Self::execute_session_command(context, command).await;
match &result {
Ok(()) => {
let _ = context
.db
.operations()
.mark_session_operation_done(&operation_id)
.await;
}
Err(error) => {
let _ = context
.db
.operations()
.mark_session_operation_failed(&operation_id, &error.to_string())
.await;
}
}
Some(result)
}
async fn drain_queued_messages(context: &SessionWorkerContext) {
loop {
if matches!(context.current_status(), Status::Question) {
return;
}
let Some(prompt) = context.pop_queued_prompt() else {
return;
};
Self::emit_queue_session_updated(context);
let operation_id = Uuid::new_v4().to_string();
let _ = context
.db
.operations()
.insert_session_operation(&operation_id, &context.session_id, "reply")
.await;
append_drained_prompt_to_output(context, &prompt).await;
let command = SessionCommand::Run {
operation_id,
request_kind: AgentRequestKind::SessionResume {
session_output: None,
},
prompt,
turn_metadata: TurnMetadata {
published_upstream_ref: None,
session_model: context.session_model,
},
};
let result = Self::process_session_command(context, command).await;
if matches!(result, Some(Err(SessionError::StoppedByUser(_)))) {
context.clear_queued_messages();
Self::emit_queue_session_updated(context);
return;
}
}
}
fn emit_queue_session_updated(context: &SessionWorkerContext) {
let version = SessionTaskService::next_session_update_version(
&context.session_update_versions,
context.session_id.as_str(),
);
let _ = context.app_event_tx.send(AppEvent::SessionUpdated {
session_id: context.session_id.clone(),
version,
});
}
async fn execute_session_command(
context: &SessionWorkerContext,
command: SessionCommand,
) -> Result<(), SessionError> {
let SessionCommand::Run {
request_kind,
prompt,
turn_metadata,
..
} = command;
Self::run_channel_turn(context, turn_metadata, request_kind, prompt).await
}
async fn run_channel_turn(
context: &SessionWorkerContext,
turn_metadata: TurnMetadata,
request_kind: AgentRequestKind,
prompt: TurnPrompt,
) -> Result<(), SessionError> {
let turn_cancel_token = fresh_turn_cancel_token(context)?;
if matches!(request_kind, AgentRequestKind::SessionResume { .. }) {
let _ = context
.db
.sessions()
.update_session_questions(&context.session_id, "")
.await;
let _ = SessionTaskService::update_status(
&context.status,
context.clock.as_ref(),
&context.db,
&context.app_event_tx,
&context.session_update_versions,
&context.session_id,
Status::InProgress,
)
.await;
}
let main_checkout_snapshot = match MainCheckoutSnapshot::capture(context).await {
Ok(snapshot) => snapshot,
Err(error) => {
SessionManager::cleanup_prompt_attachment_paths(
context.fs_client.clone(),
prompt.local_image_paths().cloned().collect(),
)
.await;
let result = apply_turn_result(
context,
turn_metadata,
Err(AgentError::Backend(error.to_string())),
)
.await;
finalize_channel_turn(context, &result).await;
return result.map(|_| ());
}
};
let session_project_id = load_session_project_id(&context.db, &context.session_id).await;
let reasoning_level =
load_session_reasoning_level(&context.db, &context.session_id, session_project_id)
.await;
let provider_conversation_id = context
.db
.sessions()
.get_session_provider_conversation_id(&context.session_id)
.await
.ok()
.flatten();
let persisted_instruction_conversation_id = context
.db
.sessions()
.get_session_instruction_conversation_id(&context.session_id)
.await
.ok()
.flatten();
let req = TurnRequest {
folder: context.folder.clone(),
live_session_output: Some(Arc::clone(&context.output)),
model: turn_metadata.session_model.as_str().to_string(),
request_kind: request_kind.clone(),
prompt: prompt.clone(),
provider_conversation_id,
persisted_instruction_conversation_id,
reasoning_level,
};
let (event_tx, event_rx) = mpsc::unbounded_channel::<TurnEvent>();
let consumer = tokio::spawn(consume_turn_events(
event_rx,
context.app_event_tx.clone(),
context.session_id.clone(),
Arc::clone(&context.child_pid),
));
spawn_start_turn_title_generation(
context,
session_project_id,
&request_kind,
&prompt.text,
turn_metadata.session_model,
)
.await;
let turn_result =
run_turn_with_cancellation(context, turn_cancel_token, req, event_tx).await;
SessionManager::cleanup_prompt_attachment_paths(
context.fs_client.clone(),
prompt.local_image_paths().cloned().collect(),
)
.await;
let _ = consumer.await;
let turn_result =
add_main_checkout_warning(context, &main_checkout_snapshot, turn_result).await;
let result = apply_turn_result(context, turn_metadata, turn_result).await;
finalize_channel_turn(context, &result).await;
result.map(|_| ())
}
async fn should_skip_worker_command(
context: &SessionWorkerContext,
operation_id: &str,
) -> bool {
let operation_is_unfinished = context
.db
.operations()
.is_session_operation_unfinished(operation_id)
.await
.unwrap_or(false);
if !operation_is_unfinished {
return true;
}
let is_cancel_requested = context
.db
.operations()
.is_cancel_requested_for_operation(operation_id)
.await
.unwrap_or(false);
if !is_cancel_requested {
return false;
}
let _ = context
.db
.operations()
.mark_session_operation_canceled(operation_id, CANCEL_BEFORE_EXECUTION_REASON)
.await;
true
}
}
impl SessionManager {
pub(crate) async fn fail_unfinished_operations_from_previous_run(
db: AppRepositories,
clock: Arc<dyn Clock>,
) {
let timestamp_seconds = unix_timestamp_from_system_time(clock.now_system_time());
SessionWorkerService::fail_unfinished_operations_from_previous_run_at(
&db,
timestamp_seconds,
)
.await;
}
pub(super) async fn enqueue_session_command(
&mut self,
services: &AppServices,
session_id: &str,
command: SessionCommand,
) -> Result<(), SessionError> {
let runtime = self.session_worker_runtime_or_err(services, session_id)?;
self.worker_service_mut()
.enqueue_session_command(services, runtime, command)
.await
}
pub(super) fn clear_session_worker(&mut self, session_id: &str) {
self.worker_service_mut().clear_session_worker(session_id);
}
pub(crate) fn clear_terminal_session_workers(
&mut self,
updated_session_ids: &HashSet<SessionId>,
) {
let terminal_session_ids = updated_session_ids
.iter()
.filter_map(|session_id| {
self.sessions()
.iter()
.find(|session| session.id == *session_id)
.and_then(|session| {
matches!(session.status, Status::Done | Status::Canceled)
.then(|| session.id.clone())
})
})
.collect::<Vec<_>>();
for session_id in terminal_session_ids {
self.clear_session_worker(&session_id);
}
}
fn session_worker_runtime_or_err(
&self,
services: &AppServices,
session_id: &str,
) -> Result<SessionWorkerRuntime, SessionError> {
let (session, handles) = self.session_and_handles_or_err(session_id)?;
Ok(SessionWorkerRuntime {
cancel_token: Arc::clone(&handles.cancel_token),
child_pid: Arc::clone(&handles.child_pid),
folder: session.folder.clone(),
output: Arc::clone(&handles.output),
queued_messages: Arc::clone(&handles.queued_messages),
review_request_client: services.review_request_client(),
session_update_versions: services.session_update_versions(),
session_id: session.id.clone(),
session_model: session.model,
status: Arc::clone(&handles.status),
})
}
}
async fn add_main_checkout_warning(
context: &SessionWorkerContext,
main_checkout_snapshot: &MainCheckoutSnapshot,
turn_result: Result<TurnResult, AgentError>,
) -> Result<TurnResult, AgentError> {
let result = turn_result?;
match main_checkout_snapshot.changed_warning(context).await {
Ok(Some(warning)) => {
append_main_checkout_warning(context, warning).await;
Ok(result)
}
Ok(None) => Ok(result),
Err(error) => Err(AgentError::Backend(error.to_string())),
}
}
impl TurnPersistence<'_> {
async fn apply(
&self,
assistant_message: &agent::AgentResponse,
input_tokens: u64,
output_tokens: u64,
provider_conversation_id: Option<&str>,
) -> Result<TurnAppliedState, SessionError> {
let summary = persisted_session_summary_payload(assistant_message);
let questions = assistant_message.question_items();
let questions_json = if questions.is_empty() {
String::new()
} else {
serde_json::to_string(&questions).unwrap_or_default()
};
let follow_up_tasks = turn_applied_follow_up_tasks(assistant_message);
let persisted_follow_up_text = follow_up_tasks
.iter()
.map(|follow_up_task| follow_up_task.text.clone())
.collect::<Vec<_>>();
let token_usage_delta = SessionStats {
added_lines: 0,
deleted_lines: 0,
input_tokens,
output_tokens,
};
let instruction_conversation_id =
if agent::transport_mode(self.session_model.kind()).uses_app_server() {
agent::normalize_instruction_conversation_id(provider_conversation_id)
} else {
None
};
self.context
.db
.sessions()
.persist_session_turn_metadata(
&self.context.session_id,
&SessionTurnMetadata {
instruction_conversation_id: instruction_conversation_id.as_deref(),
model: self.session_model.as_str(),
provider_conversation_id,
questions_json: &questions_json,
summary: &summary,
token_usage_delta: &token_usage_delta,
},
)
.await?;
self.context
.db
.sessions()
.replace_session_follow_up_tasks(&self.context.session_id, &persisted_follow_up_text)
.await?;
Ok(TurnAppliedState {
follow_up_tasks,
questions,
summary: (!summary.is_empty()).then_some(summary),
token_usage_delta,
})
}
}
async fn run_turn_with_cancellation(
context: &SessionWorkerContext,
cancel_token: CancellationToken,
req: TurnRequest,
event_tx: mpsc::UnboundedSender<TurnEvent>,
) -> Result<TurnResult, AgentError> {
if cancel_token.is_cancelled() {
terminate_child_process(context);
let _ = context
.channel
.shutdown_session(context.session_id.to_string())
.await;
return Err(AgentError::InterruptedByUser(
"[Stopped] Session interrupted by user.".to_string(),
));
}
let turn_future = context
.channel
.run_turn(context.session_id.to_string(), req, event_tx);
tokio::pin!(turn_future);
tokio::select! {
result = &mut turn_future => result,
() = cancel_token.cancelled() => {
terminate_child_process(context);
let _ = context
.channel
.shutdown_session(context.session_id.to_string())
.await;
let _ = tokio::time::timeout(
Duration::from_secs(5),
&mut turn_future,
)
.await;
Err(AgentError::InterruptedByUser(
"[Stopped] Session interrupted by user.".to_string(),
))
}
}
}
fn terminate_child_process(context: &SessionWorkerContext) {
let active_pid = context
.child_pid
.lock()
.ok()
.and_then(|mut child_pid| child_pid.take());
if let Some(pid) = active_pid {
process::send_terminate_signal(pid);
}
}
fn fresh_turn_cancel_token(
context: &SessionWorkerContext,
) -> Result<CancellationToken, SessionError> {
let mut guard = context
.cancel_token
.lock()
.map_err(|_| SessionError::Workflow("cancel token lock poisoned".to_string()))?;
*guard = CancellationToken::new();
Ok(guard.clone())
}
async fn apply_turn_result(
context: &SessionWorkerContext,
turn_metadata: TurnMetadata,
turn_result: Result<TurnResult, AgentError>,
) -> Result<Status, SessionError> {
match turn_result {
Ok(result) => apply_successful_turn_result(context, turn_metadata, result).await,
Err(AgentError::InterruptedByUser(message)) => {
append_turn_error(context, &message).await;
Err(SessionError::StoppedByUser(message))
}
Err(error) => {
let error_text = error.to_string();
append_turn_error(context, &error_text).await;
Err(SessionError::Workflow(error_text))
}
}
}
async fn finalize_channel_turn(
context: &SessionWorkerContext,
result: &Result<Status, SessionError>,
) {
if let Some((session_size, added_lines, deleted_lines)) =
SessionTaskService::refresh_persisted_session_diff_stats(
&context.db,
context.fs_client.as_ref(),
context.git_client.as_ref(),
&context.session_id,
&context.folder,
)
.await
{
let _ = context.app_event_tx.send(AppEvent::SessionSizeUpdated {
added_lines,
deleted_lines,
session_id: context.session_id.clone(),
session_size,
});
}
if let Some(target_status) = status_update_after_turn_result(result) {
let _ = SessionTaskService::update_status(
&context.status,
context.clock.as_ref(),
&context.db,
&context.app_event_tx,
&context.session_update_versions,
&context.session_id,
target_status,
)
.await;
}
}
fn status_update_after_turn_result(result: &Result<Status, SessionError>) -> Option<Status> {
match result {
Ok(status) => Some(*status),
Err(SessionError::StoppedByUser(_)) => None,
Err(_) => Some(Status::Review),
}
}
async fn append_turn_error(context: &SessionWorkerContext, error_text: &str) {
let message = format!("\n{}\n", error_text.trim());
SessionTaskService::append_session_output(
&context.output,
&context.db,
&context.app_event_tx,
&context.session_update_versions,
&context.session_id,
&message,
)
.await;
}
async fn append_main_checkout_warning(context: &SessionWorkerContext, warning: String) {
SessionTaskService::append_session_output(
&context.output,
&context.db,
&context.app_event_tx,
&context.session_update_versions,
&context.session_id,
&warning,
)
.await;
}
async fn apply_successful_turn_result(
context: &SessionWorkerContext,
turn_metadata: TurnMetadata,
result: TurnResult,
) -> Result<Status, SessionError> {
let TurnResult {
assistant_message,
context_reset: _,
input_tokens,
output_tokens,
provider_conversation_id,
} = result;
if let Some(message) = build_assistant_transcript_output(&assistant_message) {
SessionTaskService::append_session_output(
&context.output,
&context.db,
&context.app_event_tx,
&context.session_update_versions,
&context.session_id,
message.as_str(),
)
.await;
}
let turn_applied_state = match (TurnPersistence {
context,
session_model: turn_metadata.session_model,
}
.apply(
&assistant_message,
input_tokens,
output_tokens,
provider_conversation_id.as_deref(),
)
.await)
{
Ok(turn_applied_state) => turn_applied_state,
Err(error) => {
handle_turn_persistence_failure(context, &error).await;
return Err(error);
}
};
let target_status = if turn_applied_state.questions.is_empty() {
Status::Review
} else {
Status::Question
};
let _ = context.app_event_tx.send(AppEvent::AgentResponseReceived {
session_id: context.session_id.clone(),
turn_applied_state,
});
let auto_commit_model = SessionTaskService::load_auto_commit_model_setting(
&context.db,
&context.session_id,
turn_metadata.session_model,
)
.await;
let commit_outcome = SessionTaskService::handle_auto_commit(AssistContext {
app_event_tx: context.app_event_tx.clone(),
child_pid: Arc::clone(&context.child_pid),
db: context.db.clone(),
folder: context.folder.clone(),
git_client: Arc::clone(&context.git_client),
id: context.session_id.to_string(),
output: Arc::clone(&context.output),
session_model: auto_commit_model,
session_update_versions: context.session_update_versions.clone(),
})
.await;
let review_request_commit_message = commit_outcome.map(|outcome| outcome.commit_message);
start_published_branch_auto_push(
context,
turn_metadata.published_upstream_ref,
review_request_commit_message,
);
Ok(target_status)
}
fn start_published_branch_auto_push(
context: &SessionWorkerContext,
published_upstream_ref: Option<String>,
review_request_commit_message: Option<String>,
) {
let Some(published_upstream_ref) = published_upstream_ref else {
return;
};
let sync_operation_id = Uuid::new_v4().to_string();
let session_id = context.session_id.clone();
let app_event_tx = context.app_event_tx.clone();
let db = context.db.clone();
let folder = context.folder.clone();
let git_client = Arc::clone(&context.git_client);
let output = Arc::clone(&context.output);
let review_request_metadata_sync =
review_request_commit_message.map(|commit_message| ReviewRequestMetadataSyncInput {
clock: Arc::clone(&context.clock),
commit_message,
review_request_client: Arc::clone(&context.review_request_client),
});
let session_update_versions = context.session_update_versions.clone();
let _ = app_event_tx.send(AppEvent::PublishedBranchSyncUpdated {
session_id: session_id.clone(),
sync_operation_id: sync_operation_id.clone(),
sync_status: PublishedBranchSyncStatus::InProgress,
});
let auto_push_input = PublishedBranchAutoPushInput {
app_event_tx,
db,
folder,
git_client,
output,
published_upstream_ref,
review_request_metadata_sync,
session_id,
session_update_versions,
sync_operation_id,
};
tokio::spawn(async move {
run_published_branch_auto_push_task(auto_push_input).await;
});
}
pub(super) struct PublishedBranchAutoPushInput {
pub(super) app_event_tx: mpsc::UnboundedSender<AppEvent>,
pub(super) db: AppRepositories,
pub(super) folder: PathBuf,
pub(super) git_client: Arc<dyn GitClient>,
pub(super) output: Arc<Mutex<String>>,
pub(super) published_upstream_ref: String,
pub(super) review_request_metadata_sync: Option<ReviewRequestMetadataSyncInput>,
pub(super) session_id: SessionId,
pub(super) session_update_versions: SessionUpdateVersionMap,
pub(super) sync_operation_id: String,
}
pub(super) struct ReviewRequestMetadataSyncInput {
pub(super) clock: Arc<dyn Clock>,
pub(super) commit_message: String,
pub(super) review_request_client: Arc<dyn forge::ReviewRequestClient>,
}
pub(super) async fn run_published_branch_auto_push(input: PublishedBranchAutoPushInput) {
run_published_branch_auto_push_task(input).await;
}
async fn run_published_branch_auto_push_task(input: PublishedBranchAutoPushInput) {
let remote_branch_name = remote_branch_name_from_upstream_ref(&input.published_upstream_ref);
let push_result = branch_publish::push_session_branch_to_remote(
&input.db,
input.folder.clone(),
Arc::clone(&input.git_client),
PublishBranchAction::Push,
&input.session_id,
Some(remote_branch_name.as_str()),
Some(&input.published_upstream_ref),
)
.await;
match push_result {
Ok(_) => {
if let Some(metadata_sync_input) = input.review_request_metadata_sync.as_ref() {
sync_linked_review_request_metadata_after_push(&input, metadata_sync_input).await;
}
let _ = input
.app_event_tx
.send(AppEvent::PublishedBranchSyncUpdated {
session_id: input.session_id,
sync_operation_id: input.sync_operation_id,
sync_status: PublishedBranchSyncStatus::Succeeded,
});
}
Err(failure) => {
let message = TranscriptNotice::BranchPushError.format(failure.message);
SessionTaskService::append_session_output(
&input.output,
&input.db,
&input.app_event_tx,
&input.session_update_versions,
&input.session_id,
&message,
)
.await;
let _ = input
.app_event_tx
.send(AppEvent::PublishedBranchSyncUpdated {
session_id: input.session_id,
sync_operation_id: input.sync_operation_id,
sync_status: PublishedBranchSyncStatus::Failed,
});
}
}
}
async fn sync_linked_review_request_metadata_after_push(
input: &PublishedBranchAutoPushInput,
metadata_sync_input: &ReviewRequestMetadataSyncInput,
) {
let Some(update_input) = review_request_update_input(&metadata_sync_input.commit_message)
else {
return;
};
let Some(linked_review_request) = load_open_review_request(input).await else {
return;
};
let result = sync_review_request_metadata(
input,
metadata_sync_input,
&linked_review_request,
update_input,
)
.await;
if let Err(error) = result {
append_review_request_sync_warning(input, error).await;
}
}
fn review_request_update_input(commit_message: &str) -> Option<forge::UpdateReviewRequestInput> {
let review_request_commit_message =
crate::app::review_request::parse_review_request_commit_message(commit_message)?;
Some(forge::UpdateReviewRequestInput {
body: review_request_commit_message.body,
title: review_request_commit_message.title,
})
}
async fn load_open_review_request(input: &PublishedBranchAutoPushInput) -> Option<ReviewRequest> {
let review_request = match input
.db
.reviews()
.load_session_review_request(&input.session_id)
.await
{
Ok(Some(row)) => review_request_from_row(row),
Ok(None) => None,
Err(error) => {
warn_review_request_metadata_sync(
input,
&format!("failed to load linked review request: {error}"),
);
None
}
}?;
(review_request.summary.state == ReviewRequestState::Open).then_some(review_request)
}
fn review_request_from_row(
row: crate::infra::db::SessionReviewRequestRow,
) -> Option<ReviewRequest> {
Some(ReviewRequest {
last_refreshed_at: row.last_refreshed_at,
summary: forge::ReviewRequestSummary {
display_id: row.display_id,
forge_kind: forge::ForgeKind::from_str(&row.forge_kind).ok()?,
source_branch: row.source_branch,
state: ReviewRequestState::from_str(&row.state).ok()?,
status_summary: row.status_summary,
target_branch: row.target_branch,
title: row.title,
web_url: row.web_url,
},
})
}
async fn sync_review_request_metadata(
input: &PublishedBranchAutoPushInput,
metadata_sync_input: &ReviewRequestMetadataSyncInput,
linked_review_request: &ReviewRequest,
update_input: forge::UpdateReviewRequestInput,
) -> Result<(), SessionError> {
let repo_url = input
.git_client
.repo_url(input.folder.clone())
.await
.map_err(|error| {
SessionError::Workflow(format!(
"Failed to resolve repository remote for review-request metadata sync: {error}"
))
})?;
let remote = metadata_sync_input
.review_request_client
.detect_remote(repo_url)
.map(|remote| remote.with_command_working_directory(input.folder.clone()))
.map_err(|error| SessionError::Workflow(error.detail_message()))?;
let summary = metadata_sync_input
.review_request_client
.sync_review_request_metadata(
remote,
linked_review_request.summary.display_id.clone(),
update_input,
)
.await
.map_err(|error| SessionError::Workflow(error.detail_message()))?;
let review_request = ReviewRequest {
last_refreshed_at: unix_timestamp_from_system_time(
metadata_sync_input.clock.now_system_time(),
),
summary,
};
input
.db
.reviews()
.update_session_review_request(&input.session_id, Some(review_request))
.await?;
SessionTaskService::emit_session_updated(
&input.app_event_tx,
&input.session_update_versions,
&input.session_id,
);
let _ = input.app_event_tx.send(AppEvent::RefreshSessions);
Ok(())
}
async fn append_review_request_sync_warning(
input: &PublishedBranchAutoPushInput,
error: SessionError,
) {
warn_review_request_metadata_sync(input, &error.to_string());
let message = TranscriptNotice::ReviewRequestSyncWarning.format(format!(
"Failed to update linked review-request metadata: {error}"
));
SessionTaskService::append_session_output(
&input.output,
&input.db,
&input.app_event_tx,
&input.session_update_versions,
&input.session_id,
&message,
)
.await;
}
fn warn_review_request_metadata_sync(input: &PublishedBranchAutoPushInput, error: &str) {
tracing::warn!(
session_id = %input.session_id,
error,
"failed to sync linked review-request metadata"
);
}
async fn handle_turn_persistence_failure(context: &SessionWorkerContext, error: &SessionError) {
let message = TranscriptNotice::TurnMetadataError.format(format!(
"Failed to persist completed turn metadata: {error}"
));
SessionTaskService::append_session_output(
&context.output,
&context.db,
&context.app_event_tx,
&context.session_update_versions,
&context.session_id,
&message,
)
.await;
let _ = context.app_event_tx.send(AppEvent::RefreshSessions);
}
async fn spawn_start_turn_title_generation(
context: &SessionWorkerContext,
session_project_id: Option<i64>,
request_kind: &AgentRequestKind,
prompt: &str,
session_model: AgentModel,
) {
if !matches!(request_kind, AgentRequestKind::SessionStart) {
return;
}
let title_model = load_project_model_setting(
&context.db,
session_project_id,
SettingName::DefaultFastModel,
)
.await
.unwrap_or(session_model);
let _title_generation_task = SessionManager::spawn_session_title_generation_task(
context.app_event_tx.clone(),
context.db.clone(),
&context.session_id,
&context.folder,
prompt,
title_model,
None,
);
}
async fn load_session_project_id(db: &AppRepositories, session_id: &str) -> Option<i64> {
db.sessions()
.load_session_project_id(session_id)
.await
.ok()
.flatten()
}
async fn load_session_reasoning_level(
db: &AppRepositories,
session_id: &str,
project_id: Option<i64>,
) -> ReasoningLevel {
if let Ok(Some(reasoning_level)) = db
.sessions()
.load_session_reasoning_level_override(session_id)
.await
{
return reasoning_level;
}
let Some(project_id) = project_id else {
return ReasoningLevel::default();
};
db.settings()
.load_project_reasoning_level(project_id)
.await
.unwrap_or_default()
}
async fn load_project_model_setting(
db: &AppRepositories,
project_id: Option<i64>,
setting_name: SettingName,
) -> Option<AgentModel> {
let project_id = project_id?;
db.settings()
.get_project_setting(project_id, setting_name)
.await
.ok()
.flatten()
.and_then(|setting_value| AgentModel::parse_persisted(&setting_value).ok())
}
fn build_assistant_transcript_output(assistant_message: &agent::AgentResponse) -> Option<String> {
let answer_text = assistant_message.to_answer_display_text();
if !answer_text.trim().is_empty() {
return Some(format!("{}\n\n", answer_text.trim_end()));
}
let question_text = assistant_message
.question_items()
.into_iter()
.filter_map(|question_item| {
let trimmed_question = question_item.text.trim();
if trimmed_question.is_empty() {
return None;
}
Some(trimmed_question.to_string())
})
.collect::<Vec<_>>()
.join("\n\n");
if question_text.is_empty() {
return None;
}
Some(format!("{question_text}\n\n"))
}
fn persisted_session_summary_payload(assistant_message: &agent::AgentResponse) -> String {
assistant_message
.summary
.as_ref()
.and_then(|summary| serde_json::to_string(summary).ok())
.unwrap_or_default()
}
fn turn_applied_follow_up_tasks(
_assistant_message: &agent::AgentResponse,
) -> Vec<SessionFollowUpTask> {
Vec::new()
}
async fn consume_turn_events(
mut event_rx: mpsc::UnboundedReceiver<TurnEvent>,
app_event_tx: mpsc::UnboundedSender<AppEvent>,
session_id: SessionId,
child_pid: Arc<Mutex<Option<u32>>>,
) {
let mut active_progress: Option<String> = None;
while let Some(event) = event_rx.recv().await {
match event {
TurnEvent::ThoughtDelta(thought) => {
let Some(thought) = normalize_thinking_stream_text(&thought) else {
continue;
};
if active_progress.as_deref() == Some(thought.as_str()) {
continue;
}
active_progress = Some(thought.clone());
SessionTaskService::set_session_progress(&app_event_tx, &session_id, Some(thought));
}
TurnEvent::PidUpdate(pid) => {
if let Ok(mut guard) = child_pid.lock() {
*guard = pid;
}
}
TurnEvent::Completed { .. } | TurnEvent::Failed(_) => {
}
}
}
if active_progress.take().is_some() {
SessionTaskService::clear_session_progress(&app_event_tx, &session_id);
}
}
async fn append_drained_prompt_to_output(context: &SessionWorkerContext, prompt: &TurnPrompt) {
const USER_PROMPT_PREFIX: &str = " › ";
const USER_PROMPT_CONTINUATION_PREFIX: &str = " ";
let prompt_text = prompt.transcript_text();
let prompt_lines = prompt_text.split('\n').collect::<Vec<_>>();
let mut formatted_lines = Vec::with_capacity(prompt_lines.len());
for (line_index, prompt_line) in prompt_lines.into_iter().enumerate() {
let prefix = if line_index == 0 {
USER_PROMPT_PREFIX
} else {
USER_PROMPT_CONTINUATION_PREFIX
};
formatted_lines.push(format!("{prefix}{prompt_line}"));
}
let prompt_block = formatted_lines.join("\n");
let message = format!("\n{prompt_block}\n\n");
SessionTaskService::append_session_output(
&context.output,
&context.db,
&context.app_event_tx,
&context.session_update_versions,
&context.session_id,
&message,
)
.await;
}
fn normalize_thinking_stream_text(text: &str) -> Option<String> {
let trimmed_text = text.trim();
if trimmed_text.is_empty() {
return None;
}
Some(trimmed_text.to_string())
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use mockall::Sequence;
use tempfile::tempdir;
use super::*;
use crate::domain::question::QuestionItem;
use crate::infra::agent::AgentResponse;
use crate::infra::agent::protocol::AgentResponseSummary;
use crate::infra::channel::MockAgentChannel;
use crate::infra::db::AppRepositories;
use crate::infra::fs;
use crate::infra::git::MockGitClient;
fn mock_fs_client_with_existing_directories() -> fs::MockFsClient {
let mut fs_client = fs::MockFsClient::new();
fs_client.expect_is_dir().times(0..).returning(|_| true);
fs_client
.expect_canonicalize()
.times(0..)
.returning(|path| Box::pin(async move { Ok(path) }));
fs_client
}
async fn insert_in_progress_test_session(db: &AppRepositories) -> i64 {
let project_id = db
.projects()
.upsert_project("/tmp/project", Some("main".to_string()))
.await
.expect("failed to upsert project");
db.sessions()
.insert_session(
"sess1",
"gemini-3-flash-preview",
"main",
"InProgress",
project_id,
)
.await
.expect("failed to insert session");
project_id
}
#[test]
fn test_status_update_after_turn_result_skips_stopped_by_user() {
let result = Err(SessionError::StoppedByUser(
"[Stopped] Session interrupted by user.".to_string(),
));
let status_update = status_update_after_turn_result(&result);
assert_eq!(status_update, None);
}
#[test]
fn test_status_update_after_turn_result_falls_back_to_review_for_errors() {
let result = Err(SessionError::Workflow("backend failed".to_string()));
let status_update = status_update_after_turn_result(&result);
assert_eq!(status_update, Some(Status::Review));
}
#[test]
fn test_session_command_kind_values() {
let start_command = SessionCommand::Run {
operation_id: "op-start".to_string(),
request_kind: AgentRequestKind::SessionStart,
prompt: "prompt".into(),
turn_metadata: TurnMetadata {
published_upstream_ref: None,
session_model: AgentModel::ClaudeSonnet46,
},
};
let resume_command = SessionCommand::Run {
operation_id: "op-resume".to_string(),
request_kind: AgentRequestKind::SessionResume {
session_output: None,
},
prompt: "prompt".into(),
turn_metadata: TurnMetadata {
published_upstream_ref: None,
session_model: AgentModel::ClaudeSonnet46,
},
};
let account_read_command = SessionCommand::Run {
operation_id: "op-account-read".to_string(),
request_kind: AgentRequestKind::AccountRead,
prompt: "prompt".into(),
turn_metadata: TurnMetadata {
published_upstream_ref: None,
session_model: AgentModel::ClaudeSonnet46,
},
};
let start_kind = start_command.kind();
let resume_kind = resume_command.kind();
let account_read_kind = account_read_command.kind();
assert_eq!(start_kind, "start_prompt");
assert_eq!(resume_kind, "reply");
assert_eq!(account_read_kind, "account_read");
}
#[test]
fn test_agent_response_questions_returns_only_question_messages() {
let agent_response = AgentResponse {
answer: "Implemented the feature.".to_string(),
questions: vec![
QuestionItem::new("Need a target branch?"),
QuestionItem::new("Need migration notes?"),
],
summary: None,
};
let items = agent_response.question_items();
assert_eq!(items.len(), 2);
assert_eq!(items[0].text, "Need a target branch?");
assert_eq!(items[1].text, "Need migration notes?");
}
#[test]
fn test_agent_response_questions_preserves_ordered_list_as_single_question_text() {
let numbered_questions =
"1) Is this repository intentionally incomplete (docs-only), or should it include the \
referenced dotfiles tree (for\nexample `.config/` and `lua/`)?\n2) Should I propose \
and apply a docs-only cleanup now (aligning setup steps to the current files), or \
keep docs\nas-is and treat missing files as a known gap?\n3) Do you want keyd \
instructions rewritten to the safer `/etc/keyd/default.conf` path with existence \
checks and\nrollback notes?";
let agent_response = AgentResponse {
answer: String::new(),
questions: vec![QuestionItem::new(numbered_questions)],
summary: None,
};
let items = agent_response.question_items();
assert_eq!(items.len(), 1);
assert_eq!(items[0].text, numbered_questions);
}
#[test]
fn test_build_assistant_transcript_output_prefers_answer_messages() {
let response = AgentResponse {
answer: "Implemented the fix.".to_string(),
questions: vec![QuestionItem::new("Need me to run tests?")],
summary: None,
};
let transcript_output = build_assistant_transcript_output(&response);
assert_eq!(
transcript_output,
Some("Implemented the fix.\n\n".to_string())
);
}
#[test]
fn test_build_assistant_transcript_output_falls_back_to_question_text() {
let response = AgentResponse {
answer: String::new(),
questions: vec![QuestionItem::new("Should I apply the patch?")],
summary: None,
};
let transcript_output = build_assistant_transcript_output(&response);
assert_eq!(
transcript_output,
Some("Should I apply the patch?\n\n".to_string())
);
}
#[test]
fn test_build_assistant_transcript_output_returns_none_for_blank_messages() {
let response = AgentResponse {
answer: String::new(),
questions: vec![QuestionItem::new("\n")],
summary: None,
};
let transcript_output = build_assistant_transcript_output(&response);
assert_eq!(transcript_output, None);
}
#[test]
fn test_persisted_session_summary_payload_serializes_structured_summary() {
let response = AgentResponse {
answer: "Implemented the fix.".to_string(),
questions: Vec::new(),
summary: Some(AgentResponseSummary {
turn: "Updated the greeting flow.".to_string(),
session: "Session now greets users on startup.".to_string(),
}),
};
let persisted_summary = persisted_session_summary_payload(&response);
let summary = serde_json::from_str::<AgentResponseSummary>(&persisted_summary)
.expect("summary should deserialize");
assert_eq!(
summary,
AgentResponseSummary {
session: "Session now greets users on startup.".to_string(),
turn: "Updated the greeting flow.".to_string(),
}
);
}
#[tokio::test]
async fn test_consume_turn_events_ignores_pid_only_events_for_transcript_output() {
let (event_tx, event_rx) = mpsc::unbounded_channel();
let (app_event_tx, mut app_event_rx) = mpsc::unbounded_channel();
let child_pid = Arc::new(Mutex::new(None));
event_tx
.send(TurnEvent::PidUpdate(Some(4242)))
.expect("failed to send pid update");
drop(event_tx);
consume_turn_events(
event_rx,
app_event_tx,
"session-1".into(),
Arc::clone(&child_pid),
)
.await;
assert_eq!(*child_pid.lock().expect("pid lock poisoned"), Some(4242));
assert!(app_event_rx.try_recv().is_err());
}
#[tokio::test]
async fn test_run_channel_turn_returns_stopped_when_cancel_token_fires() {
let base_dir = tempdir().expect("failed to create temp dir");
let db = AppRepositories::in_memory().await;
insert_in_progress_test_session(&db).await;
let mut mock_channel = MockAgentChannel::new();
mock_channel
.expect_run_turn()
.returning(|_session_id, _req, _events| {
Box::pin(async {
tokio::time::sleep(std::time::Duration::from_hours(1)).await;
unreachable!("should be cancelled before completing")
})
});
mock_channel
.expect_shutdown_session()
.times(1)
.returning(|_| Box::pin(async { Ok(()) }));
let mut mock_git_client = MockGitClient::new();
let main_repo_root = base_dir.path().join("main");
mock_git_client
.expect_detect_git_info()
.once()
.returning(|_| Box::pin(async { Some("wt/sess1".to_string()) }));
mock_git_client.expect_main_repo_root().once().returning({
let main_repo_root = main_repo_root.clone();
move |_| {
let main_repo_root = main_repo_root.clone();
Box::pin(async move { Ok(main_repo_root) })
}
});
mock_git_client
.expect_tracked_worktree_status()
.once()
.returning(|_| Box::pin(async { Ok(String::new()) }));
mock_git_client
.expect_diff()
.returning(|_, _| Box::pin(async { Ok(String::new()) }));
let cancel_token = Arc::new(Mutex::new(CancellationToken::new()));
let output = Arc::new(Mutex::new(String::new()));
let context = SessionWorkerContext {
app_event_tx: mpsc::unbounded_channel().0,
cancel_token: Arc::clone(&cancel_token),
channel: Arc::new(mock_channel),
child_pid: Arc::new(Mutex::new(None)),
clock: Arc::new(crate::app::session::RealClock),
db: db.clone(),
folder: base_dir.path().to_path_buf(),
fs_client: Arc::new(mock_fs_client_with_existing_directories()),
git_client: Arc::new(mock_git_client),
output: Arc::clone(&output),
queued_messages: Arc::new(Mutex::new(VecDeque::new())),
review_request_client: Arc::new(forge::MockReviewRequestClient::new()),
session_update_versions: Arc::default(),
session_id: "sess1".into(),
session_model: AgentModel::Gemini3FlashPreview,
status: Arc::new(Mutex::new(Status::InProgress)),
};
let token_handle = Arc::clone(&cancel_token);
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
token_handle.lock().expect("cancel token lock").cancel();
});
let result = SessionWorkerService::run_channel_turn(
&context,
default_turn_metadata(),
AgentRequestKind::SessionStart,
"test prompt".into(),
)
.await;
let error_message = result.expect_err("should return an error").to_string();
assert!(
error_message.contains("[Stopped]"),
"error should contain [Stopped], got: {error_message}"
);
let output_text = output.lock().expect("output lock").clone();
assert!(
output_text.contains("[Stopped]"),
"stopped message should be appended to output, got: {output_text}"
);
assert_eq!(
*context.status.lock().expect("status lock poisoned"),
Status::InProgress,
"stopped turn worker must not fall back to Review before the UI cancellation path \
finalizes Canceled"
);
let sessions = db
.sessions()
.load_sessions()
.await
.expect("failed to load sessions");
assert_eq!(
sessions[0].status, "InProgress",
"stopped turn worker must not persist Review and trigger automatic focused review"
);
}
#[tokio::test]
async fn test_run_channel_turn_proceeds_after_previous_cancellation() {
let base_dir = tempdir().expect("failed to create temp dir");
let db = AppRepositories::in_memory().await;
let project_id = db
.projects()
.upsert_project("/tmp/project", Some("main".to_string()))
.await
.expect("failed to upsert project");
db.sessions()
.insert_session(
"sess1",
"gemini-3-flash-preview",
"main",
"InProgress",
project_id,
)
.await
.expect("failed to insert session");
let mut mock_channel = MockAgentChannel::new();
mock_channel
.expect_run_turn()
.returning(|_session_id, _req, _events| {
Box::pin(async {
Ok(TurnResult {
assistant_message: AgentResponse {
answer: "done".to_string(),
questions: Vec::new(),
summary: None,
},
context_reset: false,
input_tokens: 0,
output_tokens: 0,
provider_conversation_id: None,
})
})
});
let mut mock_git_client = MockGitClient::new();
let main_repo_root = base_dir.path().join("main");
mock_git_client
.expect_detect_git_info()
.once()
.returning(|_| Box::pin(async { Some("wt/sess1".to_string()) }));
mock_git_client.expect_main_repo_root().once().returning({
let main_repo_root = main_repo_root.clone();
move |_| {
let main_repo_root = main_repo_root.clone();
Box::pin(async move { Ok(main_repo_root) })
}
});
mock_git_client
.expect_tracked_worktree_status()
.times(2)
.returning(|_| Box::pin(async { Ok(String::new()) }));
mock_git_client
.expect_diff()
.returning(|_, _| Box::pin(async { Ok(String::new()) }));
mock_git_client
.expect_is_worktree_clean()
.returning(|_| Box::pin(async { Ok(true) }));
let stale_token = CancellationToken::new();
stale_token.cancel();
let context = SessionWorkerContext {
app_event_tx: mpsc::unbounded_channel().0,
cancel_token: Arc::new(Mutex::new(stale_token)),
channel: Arc::new(mock_channel),
child_pid: Arc::new(Mutex::new(None)),
clock: Arc::new(crate::app::session::RealClock),
db: db.clone(),
folder: base_dir.path().to_path_buf(),
fs_client: Arc::new(mock_fs_client_with_existing_directories()),
git_client: Arc::new(mock_git_client),
output: Arc::new(Mutex::new(String::new())),
queued_messages: Arc::new(Mutex::new(VecDeque::new())),
review_request_client: Arc::new(forge::MockReviewRequestClient::new()),
session_update_versions: Arc::default(),
session_id: "sess1".into(),
session_model: AgentModel::Gemini3FlashPreview,
status: Arc::new(Mutex::new(Status::InProgress)),
};
let result = SessionWorkerService::run_channel_turn(
&context,
default_turn_metadata(),
AgentRequestKind::SessionStart,
"test prompt".into(),
)
.await;
assert!(
result.is_ok(),
"stale cancelled token should not cancel the new turn"
);
}
#[tokio::test]
async fn test_run_channel_turn_warns_when_main_checkout_status_changes() {
let base_dir = tempdir().expect("failed to create temp dir");
let db = AppRepositories::in_memory().await;
insert_in_progress_test_session(&db).await;
let mut mock_channel = MockAgentChannel::new();
mock_channel
.expect_run_turn()
.once()
.returning(|_session_id, _req, _events| {
Box::pin(async {
Ok(TurnResult {
assistant_message: AgentResponse {
answer: "done".to_string(),
questions: Vec::new(),
summary: None,
},
context_reset: false,
input_tokens: 0,
output_tokens: 0,
provider_conversation_id: None,
})
})
});
let main_repo_root = base_dir.path().join("main");
let status_call_count = Arc::new(Mutex::new(0));
let mut mock_git_client = MockGitClient::new();
mock_git_client
.expect_detect_git_info()
.once()
.returning(|_| Box::pin(async { Some("wt/sess1".to_string()) }));
mock_git_client.expect_main_repo_root().once().returning({
let main_repo_root = main_repo_root.clone();
move |_| {
let main_repo_root = main_repo_root.clone();
Box::pin(async move { Ok(main_repo_root) })
}
});
mock_git_client
.expect_tracked_worktree_status()
.times(2)
.returning(move |_| {
let status_call_count = Arc::clone(&status_call_count);
Box::pin(async move {
let mut call_count = status_call_count
.lock()
.expect("status call count lock poisoned");
*call_count += 1;
if *call_count == 1 {
Ok(String::new())
} else {
Ok(" M README.md\n".to_string())
}
})
});
mock_git_client
.expect_diff()
.returning(|_, _| Box::pin(async { Ok(String::new()) }));
mock_git_client
.expect_is_worktree_clean()
.returning(|_| Box::pin(async { Ok(true) }));
let output = Arc::new(Mutex::new(String::new()));
let context = SessionWorkerContext {
app_event_tx: mpsc::unbounded_channel().0,
cancel_token: Arc::new(Mutex::new(CancellationToken::new())),
channel: Arc::new(mock_channel),
child_pid: Arc::new(Mutex::new(None)),
clock: Arc::new(crate::app::session::RealClock),
db: db.clone(),
folder: base_dir.path().to_path_buf(),
fs_client: Arc::new(mock_fs_client_with_existing_directories()),
git_client: Arc::new(mock_git_client),
output: Arc::clone(&output),
queued_messages: Arc::new(Mutex::new(VecDeque::new())),
review_request_client: Arc::new(forge::MockReviewRequestClient::new()),
session_update_versions: Arc::default(),
session_id: "sess1".into(),
session_model: AgentModel::Gemini3FlashPreview,
status: Arc::new(Mutex::new(Status::InProgress)),
};
let result = SessionWorkerService::run_channel_turn(
&context,
default_turn_metadata(),
AgentRequestKind::SessionStart,
"test prompt".into(),
)
.await;
assert!(result.is_ok(), "main checkout changes should warn only");
let output_text = output.lock().expect("output lock poisoned");
assert!(output_text.contains("[Main Checkout Warning]"));
assert!(output_text.contains("done"));
}
fn default_turn_metadata() -> TurnMetadata {
TurnMetadata {
published_upstream_ref: None,
session_model: AgentModel::Gemini3FlashPreview,
}
}
#[tokio::test]
async fn test_run_turn_with_cancellation_honours_pre_turn_cancel() {
let cancel_token = CancellationToken::new();
cancel_token.cancel();
let mut mock_channel = MockAgentChannel::new();
mock_channel.expect_run_turn().never();
mock_channel
.expect_shutdown_session()
.times(1)
.returning(|_| Box::pin(async { Ok(()) }));
let context = SessionWorkerContext {
app_event_tx: mpsc::unbounded_channel().0,
cancel_token: Arc::new(Mutex::new(CancellationToken::new())),
channel: Arc::new(mock_channel),
child_pid: Arc::new(Mutex::new(None)),
clock: Arc::new(crate::app::session::RealClock),
db: AppRepositories::in_memory().await,
folder: std::env::temp_dir(),
fs_client: Arc::new(fs::MockFsClient::new()),
git_client: Arc::new(MockGitClient::new()),
output: Arc::new(Mutex::new(String::new())),
queued_messages: Arc::new(Mutex::new(VecDeque::new())),
review_request_client: Arc::new(forge::MockReviewRequestClient::new()),
session_update_versions: Arc::default(),
session_id: "sess-preturn".into(),
session_model: AgentModel::Gemini3FlashPreview,
status: Arc::new(Mutex::new(Status::InProgress)),
};
let req = TurnRequest {
folder: context.folder.clone(),
live_session_output: None,
model: "gemini-3-flash-preview".to_string(),
request_kind: AgentRequestKind::SessionStart,
prompt: "test".into(),
provider_conversation_id: None,
persisted_instruction_conversation_id: None,
reasoning_level: ReasoningLevel::default(),
};
let result =
run_turn_with_cancellation(&context, cancel_token, req, mpsc::unbounded_channel().0)
.await;
let error_message = result.expect_err("should return an error").to_string();
assert!(
error_message.contains("[Stopped]"),
"error should contain [Stopped], got: {error_message}"
);
}
#[tokio::test]
async fn test_run_turn_with_cancellation_returns_stopped_after_drain_timeout() {
let cancel_token = CancellationToken::new();
let mut mock_channel = MockAgentChannel::new();
mock_channel
.expect_run_turn()
.returning(|_session_id, _req, _events| {
Box::pin(async {
std::future::pending::<Result<TurnResult, AgentError>>().await
})
});
mock_channel
.expect_shutdown_session()
.times(1)
.returning(|_| Box::pin(async { Ok(()) }));
let context = SessionWorkerContext {
app_event_tx: mpsc::unbounded_channel().0,
cancel_token: Arc::new(Mutex::new(CancellationToken::new())),
channel: Arc::new(mock_channel),
child_pid: Arc::new(Mutex::new(None)),
clock: Arc::new(crate::app::session::RealClock),
db: AppRepositories::in_memory().await,
folder: std::env::temp_dir(),
fs_client: Arc::new(fs::MockFsClient::new()),
git_client: Arc::new(MockGitClient::new()),
output: Arc::new(Mutex::new(String::new())),
queued_messages: Arc::new(Mutex::new(VecDeque::new())),
review_request_client: Arc::new(forge::MockReviewRequestClient::new()),
session_update_versions: Arc::default(),
session_id: "sess-timeout".into(),
session_model: AgentModel::Gemini3FlashPreview,
status: Arc::new(Mutex::new(Status::InProgress)),
};
let req = TurnRequest {
folder: context.folder.clone(),
live_session_output: None,
model: "gemini-3-flash-preview".to_string(),
request_kind: AgentRequestKind::SessionStart,
prompt: "test".into(),
provider_conversation_id: None,
persisted_instruction_conversation_id: None,
reasoning_level: ReasoningLevel::default(),
};
let token_for_cancel = cancel_token.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(10)).await;
token_for_cancel.cancel();
});
let result =
run_turn_with_cancellation(&context, cancel_token, req, mpsc::unbounded_channel().0)
.await;
let error_message = result.expect_err("should return an error").to_string();
assert!(
error_message.contains("[Stopped]"),
"error should contain [Stopped], got: {error_message}"
);
}
#[tokio::test]
async fn test_terminate_child_process_sends_sigterm_to_active_child() {
let mut child = tokio::process::Command::new("sleep")
.arg("60")
.spawn()
.expect("failed to spawn sleep");
let child_pid = child.id().expect("child has no pid");
let context = SessionWorkerContext {
app_event_tx: mpsc::unbounded_channel().0,
cancel_token: Arc::new(Mutex::new(CancellationToken::new())),
channel: Arc::new(MockAgentChannel::new()),
child_pid: Arc::new(Mutex::new(Some(child_pid))),
clock: Arc::new(crate::app::session::RealClock),
db: AppRepositories::in_memory().await,
folder: std::env::temp_dir(),
fs_client: Arc::new(fs::MockFsClient::new()),
git_client: Arc::new(MockGitClient::new()),
output: Arc::new(Mutex::new(String::new())),
queued_messages: Arc::new(Mutex::new(VecDeque::new())),
review_request_client: Arc::new(forge::MockReviewRequestClient::new()),
session_update_versions: Arc::default(),
session_id: "sess-term".into(),
session_model: AgentModel::Gemini3FlashPreview,
status: Arc::new(Mutex::new(Status::InProgress)),
};
terminate_child_process(&context);
let exit_status = child.wait().await.expect("failed to wait on child");
assert!(
!exit_status.success(),
"child should have been killed by SIGTERM"
);
assert!(
context.child_pid.lock().expect("child_pid lock").is_none(),
"PID slot should be cleared after termination"
);
}
#[tokio::test]
async fn test_terminate_child_process_noop_when_no_pid() {
let context = SessionWorkerContext {
app_event_tx: mpsc::unbounded_channel().0,
cancel_token: Arc::new(Mutex::new(CancellationToken::new())),
channel: Arc::new(MockAgentChannel::new()),
child_pid: Arc::new(Mutex::new(None)),
clock: Arc::new(crate::app::session::RealClock),
db: AppRepositories::in_memory().await,
folder: std::env::temp_dir(),
fs_client: Arc::new(fs::MockFsClient::new()),
git_client: Arc::new(MockGitClient::new()),
output: Arc::new(Mutex::new(String::new())),
queued_messages: Arc::new(Mutex::new(VecDeque::new())),
review_request_client: Arc::new(forge::MockReviewRequestClient::new()),
session_update_versions: Arc::default(),
session_id: "sess-nopid".into(),
session_model: AgentModel::Gemini3FlashPreview,
status: Arc::new(Mutex::new(Status::InProgress)),
};
terminate_child_process(&context);
assert!(
context.child_pid.lock().expect("child_pid lock").is_none(),
"PID slot should still be None"
);
}
#[tokio::test]
async fn test_consume_turn_events_routes_thought_delta_to_progress_state_only() {
let (event_tx, event_rx) = mpsc::unbounded_channel();
let (app_event_tx, mut app_event_rx) = mpsc::unbounded_channel();
let child_pid = Arc::new(Mutex::new(None));
event_tx
.send(TurnEvent::ThoughtDelta("Inspecting files".to_string()))
.expect("failed to send thought delta");
drop(event_tx);
consume_turn_events(event_rx, app_event_tx, "session-1".into(), child_pid).await;
let events = std::iter::from_fn(|| app_event_rx.try_recv().ok()).collect::<Vec<_>>();
assert_eq!(
events,
vec![
AppEvent::SessionProgressUpdated {
progress_message: Some("Inspecting files".to_string()),
session_id: "session-1".into(),
},
AppEvent::SessionProgressUpdated {
progress_message: None,
session_id: "session-1".into(),
},
]
);
}
#[tokio::test]
async fn test_apply_turn_result_persists_summary_to_database() {
let base_dir = tempdir().expect("failed to create temp dir");
let db = AppRepositories::in_memory().await;
let project_id = db
.projects()
.upsert_project("/tmp/project", Some("main".to_string()))
.await
.expect("failed to upsert project");
db.sessions()
.insert_session(
"sess1",
"gemini-3-flash-preview",
"main",
"InProgress",
project_id,
)
.await
.expect("failed to insert session");
let mut mock_git_client = MockGitClient::new();
mock_git_client
.expect_is_worktree_clean()
.times(1)
.returning(|_| Box::pin(async { Ok(true) }));
let context = SessionWorkerContext {
app_event_tx: mpsc::unbounded_channel().0,
cancel_token: Arc::new(Mutex::new(CancellationToken::new())),
channel: Arc::new(MockAgentChannel::new()),
child_pid: Arc::new(Mutex::new(None)),
clock: Arc::new(crate::app::session::RealClock),
db: db.clone(),
folder: base_dir.path().to_path_buf(),
fs_client: Arc::new(fs::MockFsClient::new()),
git_client: Arc::new(mock_git_client),
output: Arc::new(Mutex::new(String::new())),
queued_messages: Arc::new(Mutex::new(VecDeque::new())),
review_request_client: Arc::new(forge::MockReviewRequestClient::new()),
session_update_versions: Arc::default(),
session_id: "sess1".into(),
session_model: AgentModel::Gemini3FlashPreview,
status: Arc::new(Mutex::new(Status::InProgress)),
};
let turn_result = Ok(TurnResult {
assistant_message: AgentResponse {
answer: "Implemented the change.".to_string(),
questions: Vec::new(),
summary: Some(AgentResponseSummary {
turn: "- Updated the worker flow.".to_string(),
session: "- Active review now reloads summary from persistence.".to_string(),
}),
},
context_reset: false,
input_tokens: 0,
output_tokens: 0,
provider_conversation_id: None,
});
let turn_metadata = TurnMetadata {
published_upstream_ref: None,
session_model: AgentModel::Gemini3FlashPreview,
};
let status = apply_turn_result(&context, turn_metadata, turn_result)
.await
.expect("turn result should succeed");
let sessions = db
.sessions()
.load_sessions()
.await
.expect("failed to load sessions");
assert_eq!(status, Status::Review);
let summary = sessions[0].summary.as_deref().map(|raw| {
serde_json::from_str::<AgentResponseSummary>(raw)
.expect("stored summary should deserialize")
});
assert_eq!(
summary,
Some(AgentResponseSummary {
session: "- Active review now reloads summary from persistence.".to_string(),
turn: "- Updated the worker flow.".to_string(),
})
);
let output = context.output.lock().expect("output lock poisoned");
assert!(output.starts_with("Implemented the change.\n\n"));
assert!(!output.contains("[Commit] No changes to commit."));
assert!(!output.contains("## Change Summary"));
assert!(!output.contains("Document the worker summary flow."));
}
#[tokio::test]
async fn test_apply_turn_result_syncs_linked_review_request_metadata_after_commit() {
let base_dir = tempdir().expect("failed to create temp dir");
let db = AppRepositories::in_memory().await;
insert_in_progress_session_with_review_request(&db).await;
let folder = base_dir.path().join("sess1");
let commit_message =
"Refine review metadata sync\n\n- Update the linked review request body.";
let mut sequence = Sequence::new();
let (app_event_tx, mut app_event_rx) = mpsc::unbounded_channel();
let context = SessionWorkerContext {
app_event_tx,
cancel_token: Arc::new(Mutex::new(CancellationToken::new())),
channel: Arc::new(MockAgentChannel::new()),
child_pid: Arc::new(Mutex::new(None)),
clock: Arc::new(crate::app::session::RealClock),
db: db.clone(),
folder,
fs_client: Arc::new(fs::MockFsClient::new()),
git_client: Arc::new(auto_commit_git_client(commit_message, &mut sequence)),
output: Arc::new(Mutex::new(String::new())),
queued_messages: Arc::new(Mutex::new(VecDeque::new())),
review_request_client: Arc::new(review_metadata_sync_client(
base_dir.path(),
&mut sequence,
)),
session_update_versions: Arc::default(),
session_id: "sess1".into(),
session_model: AgentModel::Gemini3FlashPreview,
status: Arc::new(Mutex::new(Status::InProgress)),
};
let status = apply_turn_result(
&context,
TurnMetadata {
published_upstream_ref: Some("origin/wt/session-id".to_string()),
session_model: AgentModel::Gemini3FlashPreview,
},
Ok(successful_turn_result("Implemented the change.")),
)
.await
.expect("turn result should succeed");
let sync_events = tokio::time::timeout(Duration::from_secs(1), async {
let mut sync_events = Vec::new();
while sync_events.len() < 2 {
let event = app_event_rx.recv().await.expect("missing app event");
if let AppEvent::PublishedBranchSyncUpdated { sync_status, .. } = event {
sync_events.push(sync_status);
}
}
sync_events
})
.await
.expect("timed out waiting for sync events");
let review_request = db
.reviews()
.load_session_review_request("sess1")
.await
.expect("failed to load review request")
.expect("review request should remain linked");
assert_eq!(status, Status::Review);
assert_eq!(
sync_events,
vec![
PublishedBranchSyncStatus::InProgress,
PublishedBranchSyncStatus::Succeeded,
]
);
assert_eq!(review_request.title, "Refine review metadata sync");
}
#[tokio::test]
async fn test_apply_turn_result_skips_review_request_metadata_sync_when_auto_push_fails() {
let base_dir = tempdir().expect("failed to create temp dir");
let db = AppRepositories::in_memory().await;
insert_in_progress_session_with_review_request(&db).await;
let commit_message =
"Refine review metadata sync\n\n- Update the linked review request body.";
let (app_event_tx, mut app_event_rx) = mpsc::unbounded_channel();
let context = SessionWorkerContext {
app_event_tx,
cancel_token: Arc::new(Mutex::new(CancellationToken::new())),
channel: Arc::new(MockAgentChannel::new()),
child_pid: Arc::new(Mutex::new(None)),
clock: Arc::new(crate::app::session::RealClock),
db: db.clone(),
folder: base_dir.path().join("sess1"),
fs_client: Arc::new(fs::MockFsClient::new()),
git_client: Arc::new(auto_commit_git_client_with_push_failure(commit_message)),
output: Arc::new(Mutex::new(String::new())),
queued_messages: Arc::new(Mutex::new(VecDeque::new())),
review_request_client: Arc::new(forge::MockReviewRequestClient::new()),
session_update_versions: Arc::default(),
session_id: "sess1".into(),
session_model: AgentModel::Gemini3FlashPreview,
status: Arc::new(Mutex::new(Status::InProgress)),
};
let status = apply_turn_result(
&context,
TurnMetadata {
published_upstream_ref: Some("origin/wt/session-id".to_string()),
session_model: AgentModel::Gemini3FlashPreview,
},
Ok(successful_turn_result("Implemented the change.")),
)
.await
.expect("turn result should succeed");
let sync_events = tokio::time::timeout(Duration::from_secs(1), async {
let mut sync_events = Vec::new();
while sync_events.len() < 2 {
let event = app_event_rx.recv().await.expect("missing app event");
if let AppEvent::PublishedBranchSyncUpdated { sync_status, .. } = event {
sync_events.push(sync_status);
}
}
sync_events
})
.await
.expect("timed out waiting for sync events");
let review_request = db
.reviews()
.load_session_review_request("sess1")
.await
.expect("failed to load review request")
.expect("review request should remain linked");
assert_eq!(status, Status::Review);
assert_eq!(
sync_events,
vec![
PublishedBranchSyncStatus::InProgress,
PublishedBranchSyncStatus::Failed,
]
);
assert_eq!(review_request.title, "Old title");
}
async fn insert_in_progress_session_with_review_request(db: &AppRepositories) {
let project_id = db
.projects()
.upsert_project("/tmp/project", Some("main".to_string()))
.await
.expect("failed to upsert project");
db.sessions()
.insert_session(
"sess1",
"gemini-3-flash-preview",
"main",
"InProgress",
project_id,
)
.await
.expect("failed to insert session");
db.reviews()
.update_session_review_request("sess1", Some(linked_github_review_request()))
.await
.expect("failed to persist review request");
}
fn linked_github_review_request() -> ReviewRequest {
ReviewRequest {
last_refreshed_at: 100,
summary: forge::ReviewRequestSummary {
display_id: "#42".to_string(),
forge_kind: forge::ForgeKind::GitHub,
source_branch: "wt/session-id".to_string(),
state: ReviewRequestState::Open,
status_summary: Some("Draft".to_string()),
target_branch: "main".to_string(),
title: "Old title".to_string(),
web_url: "https://github.com/agentty-xyz/agentty/pull/42".to_string(),
},
}
}
fn auto_commit_git_client(commit_message: &str, sequence: &mut Sequence) -> MockGitClient {
let mut mock_git_client = MockGitClient::new();
mock_git_client
.expect_is_worktree_clean()
.once()
.returning(|_| Box::pin(async { Ok(false) }));
mock_git_client
.expect_has_commits_since()
.once()
.returning(|_, _| Box::pin(async { Ok(true) }));
mock_git_client
.expect_head_commit_message()
.once()
.returning({
let commit_message = commit_message.to_string();
move |_| {
let commit_message = commit_message.clone();
Box::pin(async move { Ok(Some(commit_message)) })
}
});
mock_git_client
.expect_commit_all_preserving_single_commit()
.once()
.returning(|_, _, _, _, _| Box::pin(async { Ok(()) }));
mock_git_client
.expect_head_short_hash()
.once()
.returning(|_| Box::pin(async { Ok("abc1234".to_string()) }));
mock_git_client
.expect_push_current_branch_to_remote_branch()
.once()
.withf(|folder, remote_branch_name| {
folder.ends_with("sess1") && remote_branch_name == "wt/session-id"
})
.in_sequence(sequence)
.returning(|_, _| Box::pin(async { Ok("origin/wt/session-id".to_string()) }));
mock_git_client
.expect_repo_url()
.once()
.in_sequence(sequence)
.returning(|_| {
Box::pin(async { Ok("https://github.com/agentty-xyz/agentty.git".to_string()) })
});
mock_git_client
}
fn auto_commit_git_client_with_push_failure(commit_message: &str) -> MockGitClient {
let mut mock_git_client = MockGitClient::new();
mock_git_client
.expect_is_worktree_clean()
.once()
.returning(|_| Box::pin(async { Ok(false) }));
mock_git_client
.expect_has_commits_since()
.once()
.returning(|_, _| Box::pin(async { Ok(true) }));
mock_git_client
.expect_head_commit_message()
.once()
.returning({
let commit_message = commit_message.to_string();
move |_| {
let commit_message = commit_message.clone();
Box::pin(async move { Ok(Some(commit_message)) })
}
});
mock_git_client
.expect_commit_all_preserving_single_commit()
.once()
.returning(|_, _, _, _, _| Box::pin(async { Ok(()) }));
mock_git_client
.expect_head_short_hash()
.once()
.returning(|_| Box::pin(async { Ok("abc1234".to_string()) }));
mock_git_client
.expect_push_current_branch_to_remote_branch()
.once()
.withf(|folder, remote_branch_name| {
folder.ends_with("sess1") && remote_branch_name == "wt/session-id"
})
.returning(|_, _| {
Box::pin(async {
Err(crate::infra::git::GitError::CommandFailed {
command: "git push origin wt/session-id".to_string(),
stderr: "fatal: remote rejected the push".to_string(),
})
})
});
mock_git_client
}
fn review_metadata_sync_client(
base_dir: &std::path::Path,
sequence: &mut Sequence,
) -> forge::MockReviewRequestClient {
let folder = base_dir.join("sess1");
let mut mock_review_request_client = forge::MockReviewRequestClient::new();
mock_review_request_client
.expect_detect_remote()
.once()
.in_sequence(sequence)
.returning(|_| Ok(github_forge_remote()));
mock_review_request_client
.expect_sync_review_request_metadata()
.once()
.in_sequence(sequence)
.withf(move |remote, display_id, input| {
remote.command_working_directory.as_deref() == Some(folder.as_path())
&& display_id == "#42"
&& input.title == "Refine review metadata sync"
&& input.body.as_deref() == Some("- Update the linked review request body.")
})
.returning(|_, _, input| {
Box::pin(async move {
Ok(forge::ReviewRequestSummary {
display_id: "#42".to_string(),
forge_kind: forge::ForgeKind::GitHub,
source_branch: "wt/session-id".to_string(),
state: ReviewRequestState::Open,
status_summary: Some("Draft".to_string()),
target_branch: "main".to_string(),
title: input.title,
web_url: "https://github.com/agentty-xyz/agentty/pull/42".to_string(),
})
})
});
mock_review_request_client
}
fn github_forge_remote() -> forge::ForgeRemote {
forge::ForgeRemote {
command_working_directory: None,
forge_kind: forge::ForgeKind::GitHub,
host: "github.com".to_string(),
namespace: "agentty-xyz".to_string(),
project: "agentty".to_string(),
repo_url: "https://github.com/agentty-xyz/agentty.git".to_string(),
web_url: "https://github.com/agentty-xyz/agentty".to_string(),
}
}
fn successful_turn_result(answer: &str) -> TurnResult {
TurnResult {
assistant_message: AgentResponse {
answer: answer.to_string(),
questions: Vec::new(),
summary: None,
},
context_reset: false,
input_tokens: 0,
output_tokens: 0,
provider_conversation_id: None,
}
}
#[tokio::test]
async fn test_apply_turn_result_starts_background_push_for_published_branch() {
let base_dir = tempdir().expect("failed to create temp dir");
let db = AppRepositories::in_memory().await;
let project_id = db
.projects()
.upsert_project("/tmp/project", Some("main".to_string()))
.await
.expect("failed to upsert project");
db.sessions()
.insert_session(
"sess1",
"gemini-3-flash-preview",
"main",
"InProgress",
project_id,
)
.await
.expect("failed to insert session");
let (app_event_tx, mut app_event_rx) = mpsc::unbounded_channel();
let mut mock_git_client = MockGitClient::new();
mock_git_client
.expect_is_worktree_clean()
.times(1)
.returning(|_| Box::pin(async { Ok(true) }));
mock_git_client
.expect_push_current_branch_to_remote_branch()
.once()
.withf(|folder, remote_branch_name| {
folder.ends_with("sess1") && remote_branch_name == "wt/session-id"
})
.returning(|_, _| Box::pin(async { Ok("origin/wt/session-id".to_string()) }));
let context = SessionWorkerContext {
app_event_tx,
cancel_token: Arc::new(Mutex::new(CancellationToken::new())),
channel: Arc::new(MockAgentChannel::new()),
child_pid: Arc::new(Mutex::new(None)),
clock: Arc::new(crate::app::session::RealClock),
db: db.clone(),
folder: base_dir.path().join("sess1"),
fs_client: Arc::new(fs::MockFsClient::new()),
git_client: Arc::new(mock_git_client),
output: Arc::new(Mutex::new(String::new())),
queued_messages: Arc::new(Mutex::new(VecDeque::new())),
review_request_client: Arc::new(forge::MockReviewRequestClient::new()),
session_update_versions: Arc::default(),
session_id: "sess1".into(),
session_model: AgentModel::Gemini3FlashPreview,
status: Arc::new(Mutex::new(Status::InProgress)),
};
let turn_result = Ok(TurnResult {
assistant_message: AgentResponse {
answer: "Implemented the change.".to_string(),
questions: Vec::new(),
summary: None,
},
context_reset: false,
input_tokens: 0,
output_tokens: 0,
provider_conversation_id: None,
});
let turn_metadata = TurnMetadata {
published_upstream_ref: Some("origin/wt/session-id".to_string()),
session_model: AgentModel::Gemini3FlashPreview,
};
let status = apply_turn_result(&context, turn_metadata, turn_result)
.await
.expect("turn result should succeed");
let sync_events = tokio::time::timeout(Duration::from_secs(1), async {
let mut sync_events = Vec::new();
while sync_events.len() < 2 {
let event = app_event_rx.recv().await.expect("missing app event");
if let AppEvent::PublishedBranchSyncUpdated {
session_id,
sync_operation_id,
sync_status,
} = event
{
sync_events.push((session_id, sync_operation_id, sync_status));
}
}
sync_events
})
.await
.expect("timed out waiting for sync events");
assert_eq!(status, Status::Review);
assert_eq!(sync_events[0].2, PublishedBranchSyncStatus::InProgress);
assert_eq!(sync_events[1].2, PublishedBranchSyncStatus::Succeeded);
assert_eq!(sync_events[0].0, "sess1");
assert_eq!(sync_events[1].0, "sess1");
assert_eq!(sync_events[0].1, sync_events[1].1);
}
#[tokio::test]
async fn test_apply_turn_result_reports_background_push_failures() {
let base_dir = tempdir().expect("failed to create temp dir");
let db = AppRepositories::in_memory().await;
let project_id = db
.projects()
.upsert_project("/tmp/project", Some("main".to_string()))
.await
.expect("failed to upsert project");
db.sessions()
.insert_session(
"sess1",
"gemini-3-flash-preview",
"main",
"InProgress",
project_id,
)
.await
.expect("failed to insert session");
let (app_event_tx, mut app_event_rx) = mpsc::unbounded_channel();
let mut mock_git_client = MockGitClient::new();
mock_git_client
.expect_is_worktree_clean()
.times(1)
.returning(|_| Box::pin(async { Ok(true) }));
mock_git_client
.expect_push_current_branch_to_remote_branch()
.once()
.returning(|_, _| {
Box::pin(async {
Err(crate::infra::git::GitError::CommandFailed {
command: "git push origin wt/session-id".to_string(),
stderr:
"fatal: could not read username for 'https://github.com/openai/agentty': terminal prompts disabled"
.to_string(),
})
})
});
let output = Arc::new(Mutex::new(String::new()));
let context = SessionWorkerContext {
app_event_tx,
cancel_token: Arc::new(Mutex::new(CancellationToken::new())),
channel: Arc::new(MockAgentChannel::new()),
child_pid: Arc::new(Mutex::new(None)),
clock: Arc::new(crate::app::session::RealClock),
db: db.clone(),
folder: base_dir.path().join("sess1"),
fs_client: Arc::new(fs::MockFsClient::new()),
git_client: Arc::new(mock_git_client),
output: Arc::clone(&output),
queued_messages: Arc::new(Mutex::new(VecDeque::new())),
review_request_client: Arc::new(forge::MockReviewRequestClient::new()),
session_update_versions: Arc::default(),
session_id: "sess1".into(),
session_model: AgentModel::Gemini3FlashPreview,
status: Arc::new(Mutex::new(Status::InProgress)),
};
let turn_result = Ok(TurnResult {
assistant_message: AgentResponse {
answer: "Implemented the change.".to_string(),
questions: Vec::new(),
summary: None,
},
context_reset: false,
input_tokens: 0,
output_tokens: 0,
provider_conversation_id: None,
});
let turn_metadata = TurnMetadata {
published_upstream_ref: Some("origin/wt/session-id".to_string()),
session_model: AgentModel::Gemini3FlashPreview,
};
let status = apply_turn_result(&context, turn_metadata, turn_result)
.await
.expect("turn result should succeed");
let sync_events = tokio::time::timeout(Duration::from_secs(1), async {
let mut sync_events = Vec::new();
while sync_events.len() < 2 {
let event = app_event_rx.recv().await.expect("missing app event");
if let AppEvent::PublishedBranchSyncUpdated { sync_status, .. } = event {
sync_events.push(sync_status);
}
}
sync_events
})
.await
.expect("timed out waiting for sync events");
let output = output.lock().expect("output lock poisoned");
assert_eq!(status, Status::Review);
assert_eq!(
sync_events,
vec![
PublishedBranchSyncStatus::InProgress,
PublishedBranchSyncStatus::Failed,
]
);
assert!(output.contains("[Branch Push Error]"));
assert!(output.contains("gh auth login"));
}
#[tokio::test]
async fn test_apply_turn_result_refreshes_when_turn_metadata_persistence_fails() {
let base_dir = tempdir().expect("failed to create temp dir");
let db = AppRepositories::in_memory().await;
let project_id = db
.projects()
.upsert_project("/tmp/project", Some("main".to_string()))
.await
.expect("failed to upsert project");
db.sessions()
.insert_session(
"sess1",
"gemini-3-flash-preview",
"main",
"InProgress",
project_id,
)
.await
.expect("failed to insert session");
db.sessions()
.delete_session("sess1")
.await
.expect("failed to delete session");
let (app_event_tx, mut app_event_rx) = mpsc::unbounded_channel();
let context = SessionWorkerContext {
app_event_tx,
cancel_token: Arc::new(Mutex::new(CancellationToken::new())),
channel: Arc::new(MockAgentChannel::new()),
child_pid: Arc::new(Mutex::new(None)),
clock: Arc::new(crate::app::session::RealClock),
db: db.clone(),
folder: base_dir.path().to_path_buf(),
fs_client: Arc::new(fs::MockFsClient::new()),
git_client: Arc::new(MockGitClient::new()),
output: Arc::new(Mutex::new(String::new())),
queued_messages: Arc::new(Mutex::new(VecDeque::new())),
review_request_client: Arc::new(forge::MockReviewRequestClient::new()),
session_update_versions: Arc::default(),
session_id: "sess1".into(),
session_model: AgentModel::Gemini3FlashPreview,
status: Arc::new(Mutex::new(Status::InProgress)),
};
let turn_result = Ok(TurnResult {
assistant_message: AgentResponse {
answer: "Implemented the change.".to_string(),
questions: Vec::new(),
summary: Some(AgentResponseSummary {
turn: "- Attempted the update.".to_string(),
session: "- Session state should not project without persistence.".to_string(),
}),
},
context_reset: false,
input_tokens: 2,
output_tokens: 3,
provider_conversation_id: None,
});
let turn_metadata = TurnMetadata {
published_upstream_ref: None,
session_model: AgentModel::Gemini3FlashPreview,
};
let error = apply_turn_result(&context, turn_metadata, turn_result)
.await
.expect_err("turn result should fail when metadata persistence fails");
let events = std::iter::from_fn(|| app_event_rx.try_recv().ok()).collect::<Vec<_>>();
let output = context.output.lock().expect("output lock poisoned");
assert!(
error
.to_string()
.contains("no rows returned by a query that expected to return at least one row")
);
assert!(output.contains("Implemented the change."));
assert!(
output.contains("[Turn Metadata Error] Failed to persist completed turn metadata:")
);
assert!(
events
.iter()
.any(|event| matches!(event, AppEvent::RefreshSessions))
);
assert!(
!events
.iter()
.any(|event| matches!(event, AppEvent::AgentResponseReceived { .. }))
);
}
#[tokio::test]
async fn test_apply_turn_result_keeps_summary_out_of_transcript_output() {
let base_dir = tempdir().expect("failed to create temp dir");
let db = AppRepositories::in_memory().await;
let project_id = db
.projects()
.upsert_project("/tmp/project", Some("main".to_string()))
.await
.expect("failed to upsert project");
db.sessions()
.insert_session(
"sess1",
"gemini-3-flash-preview",
"main",
"InProgress",
project_id,
)
.await
.expect("failed to insert session");
let mut mock_git_client = MockGitClient::new();
mock_git_client
.expect_is_worktree_clean()
.times(1)
.returning(|_| Box::pin(async { Ok(true) }));
let output = Arc::new(Mutex::new("Hey! How can I help you today?".to_string()));
let context = SessionWorkerContext {
app_event_tx: mpsc::unbounded_channel().0,
cancel_token: Arc::new(Mutex::new(CancellationToken::new())),
channel: Arc::new(MockAgentChannel::new()),
child_pid: Arc::new(Mutex::new(None)),
clock: Arc::new(crate::app::session::RealClock),
db: db.clone(),
folder: base_dir.path().to_path_buf(),
fs_client: Arc::new(fs::MockFsClient::new()),
git_client: Arc::new(mock_git_client),
output: Arc::clone(&output),
queued_messages: Arc::new(Mutex::new(VecDeque::new())),
review_request_client: Arc::new(forge::MockReviewRequestClient::new()),
session_update_versions: Arc::default(),
session_id: "sess1".into(),
session_model: AgentModel::Gemini3FlashPreview,
status: Arc::new(Mutex::new(Status::InProgress)),
};
let turn_result = Ok(TurnResult {
assistant_message: AgentResponse {
answer: "Hey! How can I help you today?".to_string(),
questions: Vec::new(),
summary: Some(AgentResponseSummary {
turn: "No changes".to_string(),
session: "No changes".to_string(),
}),
},
context_reset: false,
input_tokens: 0,
output_tokens: 0,
provider_conversation_id: None,
});
let turn_metadata = TurnMetadata {
published_upstream_ref: None,
session_model: AgentModel::Gemini3FlashPreview,
};
let status = apply_turn_result(&context, turn_metadata, turn_result)
.await
.expect("turn result should succeed");
let output = output.lock().expect("output lock poisoned");
assert_eq!(status, Status::Review);
assert!(
output.starts_with("Hey! How can I help you today?Hey! How can I help you today?\n\n")
);
assert!(!output.contains("[Commit] No changes to commit."));
assert!(!output.contains("## Change Summary"));
}
#[tokio::test]
async fn test_apply_turn_result_persists_instruction_conversation_id_for_app_server_turns() {
let base_dir = tempdir().expect("failed to create temp dir");
let db = AppRepositories::in_memory().await;
let project_id = db
.projects()
.upsert_project("/tmp/project", Some("main".to_string()))
.await
.expect("failed to upsert project");
db.sessions()
.insert_session("sess1", "gpt-5.4", "main", "InProgress", project_id)
.await
.expect("failed to insert session");
let mut mock_git_client = MockGitClient::new();
mock_git_client
.expect_is_worktree_clean()
.times(1)
.returning(|_| Box::pin(async { Ok(true) }));
let context = SessionWorkerContext {
app_event_tx: mpsc::unbounded_channel().0,
cancel_token: Arc::new(Mutex::new(CancellationToken::new())),
channel: Arc::new(MockAgentChannel::new()),
child_pid: Arc::new(Mutex::new(None)),
clock: Arc::new(crate::app::session::RealClock),
db: db.clone(),
folder: base_dir.path().to_path_buf(),
fs_client: Arc::new(fs::MockFsClient::new()),
git_client: Arc::new(mock_git_client),
output: Arc::new(Mutex::new(String::new())),
queued_messages: Arc::new(Mutex::new(VecDeque::new())),
review_request_client: Arc::new(forge::MockReviewRequestClient::new()),
session_update_versions: Arc::default(),
session_id: "sess1".into(),
session_model: AgentModel::Gemini3FlashPreview,
status: Arc::new(Mutex::new(Status::InProgress)),
};
let turn_result = Ok(TurnResult {
assistant_message: AgentResponse {
answer: "Implemented the change.".to_string(),
questions: Vec::new(),
summary: None,
},
context_reset: true,
input_tokens: 0,
output_tokens: 0,
provider_conversation_id: Some("thread-123".to_string()),
});
let turn_metadata = TurnMetadata {
published_upstream_ref: None,
session_model: AgentModel::Gpt54,
};
let status = apply_turn_result(&context, turn_metadata, turn_result)
.await
.expect("turn result should succeed");
let instruction_conversation_id = db
.sessions()
.get_session_instruction_conversation_id("sess1")
.await
.expect("failed to load instruction conversation id");
assert_eq!(status, Status::Review);
assert_eq!(
instruction_conversation_id,
agent::normalize_instruction_conversation_id(Some("thread-123"))
);
}
#[tokio::test]
async fn test_fail_unfinished_operations_from_previous_run_restores_session_review_status() {
let db = AppRepositories::in_memory().await;
let project_id = db
.projects()
.upsert_project("/tmp/project", Some("main".to_string()))
.await
.expect("failed to upsert project");
db.sessions()
.insert_session(
"sess1",
"gemini-3-flash-preview",
"main",
"InProgress",
project_id,
)
.await
.expect("failed to insert session");
db.sessions()
.update_session_status_with_timing_at("sess1", "InProgress", 0)
.await
.expect("failed to open in-progress timing window");
db.operations()
.insert_session_operation("op-1", "sess1", "reply")
.await
.expect("failed to insert session operation");
SessionWorkerService::fail_unfinished_operations_from_previous_run_at(&db, 300).await;
let sessions = db
.sessions()
.load_sessions()
.await
.expect("failed to load sessions");
let operation_is_unfinished = db
.operations()
.is_session_operation_unfinished("op-1")
.await
.expect("failed to check operation status");
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].status, "Review");
assert_eq!(sessions[0].in_progress_started_at, None);
assert_eq!(sessions[0].in_progress_total_seconds, 300);
assert!(!operation_is_unfinished);
}
#[tokio::test]
async fn test_should_skip_worker_command_without_cancel_request() {
let base_dir = tempdir().expect("failed to create temp dir");
let db = AppRepositories::in_memory().await;
let project_id = db
.projects()
.upsert_project("/tmp/project", Some("main".to_string()))
.await
.expect("failed to upsert");
db.sessions()
.insert_session(
"sess1",
"gemini-3-flash-preview",
"main",
"InProgress",
project_id,
)
.await
.expect("failed to insert session");
db.operations()
.insert_session_operation("op-1", "sess1", "reply")
.await
.expect("failed to insert session operation");
let mut mock_channel = MockAgentChannel::new();
mock_channel
.expect_shutdown_session()
.returning(|_| Box::pin(async { Ok(()) }));
let context = SessionWorkerContext {
app_event_tx: mpsc::unbounded_channel().0,
cancel_token: Arc::new(Mutex::new(CancellationToken::new())),
channel: Arc::new(mock_channel),
child_pid: Arc::new(Mutex::new(None)),
clock: Arc::new(crate::app::session::RealClock),
db: db.clone(),
folder: base_dir.path().to_path_buf(),
fs_client: Arc::new(fs::MockFsClient::new()),
git_client: Arc::new(MockGitClient::new()),
output: Arc::new(Mutex::new(String::new())),
queued_messages: Arc::new(Mutex::new(VecDeque::new())),
review_request_client: Arc::new(forge::MockReviewRequestClient::new()),
session_update_versions: Arc::default(),
session_id: "sess1".into(),
session_model: AgentModel::Gemini3FlashPreview,
status: Arc::new(Mutex::new(Status::InProgress)),
};
let should_skip = SessionWorkerService::should_skip_worker_command(&context, "op-1").await;
let is_unfinished = db
.operations()
.is_session_operation_unfinished("op-1")
.await
.expect("failed to check operation status");
assert!(!should_skip);
assert!(is_unfinished);
}
#[tokio::test]
async fn test_should_skip_worker_command_when_cancel_is_requested() {
let base_dir = tempdir().expect("failed to create temp dir");
let db = AppRepositories::in_memory().await;
let project_id = db
.projects()
.upsert_project("/tmp/project", Some("main".to_string()))
.await
.expect("failed to upsert");
db.sessions()
.insert_session(
"sess1",
"gemini-3-flash-preview",
"main",
"InProgress",
project_id,
)
.await
.expect("failed to insert session");
db.operations()
.insert_session_operation("op-1", "sess1", "reply")
.await
.expect("failed to insert session operation");
db.operations()
.request_cancel_for_session_operations("sess1")
.await
.expect("failed to request cancel");
let mut mock_channel = MockAgentChannel::new();
mock_channel
.expect_shutdown_session()
.returning(|_| Box::pin(async { Ok(()) }));
let context = SessionWorkerContext {
app_event_tx: mpsc::unbounded_channel().0,
cancel_token: Arc::new(Mutex::new(CancellationToken::new())),
channel: Arc::new(mock_channel),
child_pid: Arc::new(Mutex::new(None)),
clock: Arc::new(crate::app::session::RealClock),
db: db.clone(),
folder: base_dir.path().to_path_buf(),
fs_client: Arc::new(fs::MockFsClient::new()),
git_client: Arc::new(MockGitClient::new()),
output: Arc::new(Mutex::new(String::new())),
queued_messages: Arc::new(Mutex::new(VecDeque::new())),
review_request_client: Arc::new(forge::MockReviewRequestClient::new()),
session_update_versions: Arc::default(),
session_id: "sess1".into(),
session_model: AgentModel::Gemini3FlashPreview,
status: Arc::new(Mutex::new(Status::InProgress)),
};
let should_skip = SessionWorkerService::should_skip_worker_command(&context, "op-1").await;
let is_unfinished = db
.operations()
.is_session_operation_unfinished("op-1")
.await
.expect("failed to check operation status");
assert!(should_skip);
assert!(!is_unfinished);
}
#[tokio::test]
async fn test_should_skip_worker_command_allows_new_operation_after_cancel() {
let base_dir = tempdir().expect("failed to create temp dir");
let db = AppRepositories::in_memory().await;
let project_id = db
.projects()
.upsert_project("/tmp/project", Some("main".to_string()))
.await
.expect("failed to upsert");
db.sessions()
.insert_session(
"sess1",
"gemini-3-flash-preview",
"main",
"InProgress",
project_id,
)
.await
.expect("failed to insert session");
db.operations()
.insert_session_operation("op-old", "sess1", "reply")
.await
.expect("failed to insert old operation");
db.operations()
.mark_session_operation_running("op-old")
.await
.expect("failed to mark old operation running");
db.operations()
.request_cancel_for_session_operations("sess1")
.await
.expect("failed to request cancel");
db.operations()
.insert_session_operation("op-new", "sess1", "reply")
.await
.expect("failed to insert new operation");
let mut mock_channel = MockAgentChannel::new();
mock_channel
.expect_shutdown_session()
.returning(|_| Box::pin(async { Ok(()) }));
let context = SessionWorkerContext {
app_event_tx: mpsc::unbounded_channel().0,
cancel_token: Arc::new(Mutex::new(CancellationToken::new())),
channel: Arc::new(mock_channel),
child_pid: Arc::new(Mutex::new(None)),
clock: Arc::new(crate::app::session::RealClock),
db: db.clone(),
folder: base_dir.path().to_path_buf(),
fs_client: Arc::new(fs::MockFsClient::new()),
git_client: Arc::new(MockGitClient::new()),
output: Arc::new(Mutex::new(String::new())),
queued_messages: Arc::new(Mutex::new(VecDeque::new())),
review_request_client: Arc::new(forge::MockReviewRequestClient::new()),
session_update_versions: Arc::default(),
session_id: "sess1".into(),
session_model: AgentModel::Gemini3FlashPreview,
status: Arc::new(Mutex::new(Status::InProgress)),
};
let should_skip =
SessionWorkerService::should_skip_worker_command(&context, "op-new").await;
assert!(
!should_skip,
"new operation should not be skipped by stale cancel on older operation"
);
}
async fn queue_test_context(
channel: MockAgentChannel,
queued_messages: VecDeque<TurnPrompt>,
status: Status,
) -> (
SessionWorkerContext,
AppRepositories,
Arc<Mutex<VecDeque<TurnPrompt>>>,
tempfile::TempDir,
) {
let base_dir = tempdir().expect("failed to create temp dir");
let db = AppRepositories::in_memory().await;
let project_id = db
.projects()
.upsert_project("/tmp/project", Some("main".to_string()))
.await
.expect("failed to upsert");
db.sessions()
.insert_session(
"sess1",
"gemini-3-flash-preview",
"main",
"InProgress",
project_id,
)
.await
.expect("failed to insert session");
let mut mock_git_client = MockGitClient::new();
let main_repo_root = base_dir.path().join("main");
mock_git_client
.expect_detect_git_info()
.times(0..)
.returning(|_| Box::pin(async { Some("wt/sess1".to_string()) }));
mock_git_client
.expect_main_repo_root()
.times(0..)
.returning(move |_| {
let main_repo_root = main_repo_root.clone();
Box::pin(async move { Ok(main_repo_root) })
});
mock_git_client
.expect_tracked_worktree_status()
.times(0..)
.returning(|_| Box::pin(async { Ok(String::new()) }));
mock_git_client
.expect_diff()
.returning(|_, _| Box::pin(async { Ok(String::new()) }));
mock_git_client
.expect_is_worktree_clean()
.returning(|_| Box::pin(async { Ok(true) }));
let queue_handle = Arc::new(Mutex::new(queued_messages));
let context = SessionWorkerContext {
app_event_tx: mpsc::unbounded_channel().0,
cancel_token: Arc::new(Mutex::new(CancellationToken::new())),
channel: Arc::new(channel),
child_pid: Arc::new(Mutex::new(None)),
clock: Arc::new(crate::app::session::RealClock),
db: db.clone(),
folder: base_dir.path().to_path_buf(),
fs_client: Arc::new(mock_fs_client_with_existing_directories()),
git_client: Arc::new(mock_git_client),
output: Arc::new(Mutex::new(String::new())),
queued_messages: Arc::clone(&queue_handle),
review_request_client: Arc::new(forge::MockReviewRequestClient::new()),
session_update_versions: Arc::default(),
session_id: "sess1".into(),
session_model: AgentModel::Gemini3FlashPreview,
status: Arc::new(Mutex::new(status)),
};
(context, db, queue_handle, base_dir)
}
async fn queue_helper_context(queue: Arc<Mutex<VecDeque<TurnPrompt>>>) -> SessionWorkerContext {
SessionWorkerContext {
app_event_tx: mpsc::unbounded_channel().0,
cancel_token: Arc::new(Mutex::new(CancellationToken::new())),
channel: Arc::new(MockAgentChannel::new()),
child_pid: Arc::new(Mutex::new(None)),
clock: Arc::new(crate::app::session::RealClock),
db: AppRepositories::in_memory().await,
folder: PathBuf::new(),
fs_client: Arc::new(fs::MockFsClient::new()),
git_client: Arc::new(MockGitClient::new()),
output: Arc::new(Mutex::new(String::new())),
queued_messages: queue,
review_request_client: Arc::new(forge::MockReviewRequestClient::new()),
session_update_versions: Arc::default(),
session_id: "sess".into(),
session_model: AgentModel::Gemini3FlashPreview,
status: Arc::new(Mutex::new(Status::InProgress)),
}
}
#[tokio::test]
async fn test_pop_queued_prompt_returns_messages_in_submission_order() {
let queue: Arc<Mutex<VecDeque<TurnPrompt>>> = Arc::new(Mutex::new(VecDeque::from([
TurnPrompt::from_text("first".to_string()),
TurnPrompt::from_text("second".to_string()),
])));
let context = queue_helper_context(Arc::clone(&queue)).await;
let first_pop = context.pop_queued_prompt();
let second_pop = context.pop_queued_prompt();
let empty_pop = context.pop_queued_prompt();
assert_eq!(first_pop.expect("first prompt").text, "first");
assert_eq!(second_pop.expect("second prompt").text, "second");
assert!(empty_pop.is_none());
assert!(queue.lock().expect("queue lock").is_empty());
}
#[tokio::test]
async fn test_clear_queued_messages_drops_all_pending_prompts() {
let queue: Arc<Mutex<VecDeque<TurnPrompt>>> = Arc::new(Mutex::new(VecDeque::from([
TurnPrompt::from_text("alpha".to_string()),
TurnPrompt::from_text("beta".to_string()),
])));
let context = queue_helper_context(Arc::clone(&queue)).await;
context.clear_queued_messages();
assert!(queue.lock().expect("queue lock").is_empty());
}
#[tokio::test]
async fn test_drain_queued_messages_pauses_while_status_is_question() {
let mut mock_channel = MockAgentChannel::new();
mock_channel.expect_run_turn().never().returning(|_, _, _| {
Box::pin(async { unreachable!("drain must not dispatch while status is Question") })
});
let queued = VecDeque::from([TurnPrompt::from_text("queued reply".to_string())]);
let (context, _db, queue_handle, _base_dir) =
queue_test_context(mock_channel, queued, Status::Question).await;
SessionWorkerService::drain_queued_messages(&context).await;
let queue = queue_handle.lock().expect("queue lock");
assert_eq!(queue.len(), 1);
assert_eq!(queue.front().expect("queued head").text, "queued reply");
}
#[tokio::test]
async fn test_drain_queued_messages_clears_queue_when_user_stops_running_turn() {
let mut mock_channel = MockAgentChannel::new();
mock_channel
.expect_run_turn()
.times(1)
.returning(|_, _, _| {
Box::pin(async {
Err(AgentError::InterruptedByUser(
"[Stopped] Session interrupted by user.".to_string(),
))
})
});
mock_channel
.expect_shutdown_session()
.returning(|_| Box::pin(async { Ok(()) }));
let queued = VecDeque::from([
TurnPrompt::from_text("queued first".to_string()),
TurnPrompt::from_text("queued second".to_string()),
]);
let (context, _db, queue_handle, _base_dir) =
queue_test_context(mock_channel, queued, Status::InProgress).await;
SessionWorkerService::drain_queued_messages(&context).await;
let queue = queue_handle.lock().expect("queue lock");
assert!(queue.is_empty(), "queue should be cleared on Ctrl+C");
}
}