use super::*;
pub fn map_stop_reason(stop_reason: &str) -> (WaitOutcome, Option<String>) {
use mj_core::state::{PromptCompletion, classify_prompt_completion};
match classify_prompt_completion(stop_reason) {
PromptCompletion::InputRequired => (WaitOutcome::InputRequired, None),
PromptCompletion::Finished => (WaitOutcome::Finished, None),
PromptCompletion::Cancelled => (WaitOutcome::Cancelled, None),
PromptCompletion::QuotaLimit => (WaitOutcome::QuotaLimit, None),
PromptCompletion::Error => (WaitOutcome::Error, Some(stop_reason.to_owned())),
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct WaitObservation {
pub checking_continuation: bool,
pub background_work: Option<ApiBackgroundWork>,
pub pending_elicitations: Vec<mj_core::elicitation::ElicitationRequest>,
pub lifecycle: Option<ViewerLifecycleCategory>,
pub resuming: bool,
pub closing: bool,
pub close_failure: Option<String>,
pub cannot_take_prompt: bool,
pub launch_failed: bool,
pub launch_error: Option<String>,
pub execution: MaterializedExecutionState,
pub active_turn: Option<MaterializedTurn>,
pub last_turn_outcome: Option<MaterializedTurnOutcome>,
pub queued: usize,
pub capacity_retry: Option<CapacityRetry>,
pub retry_assessment_pending: bool,
pub quota_recovery: Option<mj_core::continuation::QuotaRecovery>,
pub start_status: Option<StartStatus>,
pub subagent: bool,
pub report_pending_for: Option<String>,
pub handback: Option<(String, String)>,
}
impl WaitObservation {
pub fn apply_subagent_report(
&mut self,
handback_tool: bool,
report: &mj_core::subagent::SubagentReport,
now_ms: i64,
) {
self.subagent = true;
let Some(turn) = self.last_turn_outcome.as_ref() else {
return;
};
let in_flight = self
.active_turn
.iter()
.map(|turn| turn.command_id.as_str())
.collect::<Vec<_>>();
match mj_core::subagent::report_state(handback_tool, report, Some(turn), &in_flight, now_ms)
{
mj_core::subagent::ReportState::Pending { .. } => {
self.report_pending_for = Some(turn.command_id.clone());
}
mj_core::subagent::ReportState::Delivered(message) => {
self.handback = Some((turn.command_id.clone(), message));
}
mj_core::subagent::ReportState::Fallback => {}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TurnSpan {
pub start_position: u64,
pub completed_position: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WaitDecision {
pub outcome: WaitOutcome,
pub stop_reason: Option<String>,
pub message: Option<String>,
pub turn_id: Option<u64>,
pub turn: Option<TurnSpan>,
}
impl WaitDecision {
pub(super) fn simple(outcome: WaitOutcome, message: Option<String>) -> Self {
Self {
outcome,
stop_reason: None,
message,
turn_id: None,
turn: None,
}
}
pub(super) fn from_outcome(outcome: &MaterializedTurnOutcome) -> Self {
let (kind, stop_reason, message) = match &outcome.outcome {
TurnOutcomeKind::Completed { stop_reason } => {
let (kind, message) = map_stop_reason(stop_reason);
(
kind,
Some(stop_reason.clone()),
outcome
.diagnostic
.as_ref()
.map(|d| d.message.clone())
.or(message),
)
}
TurnOutcomeKind::Rejected { message } => {
(WaitOutcome::Error, None, Some(message.clone()))
}
TurnOutcomeKind::Interrupted { message } => {
(WaitOutcome::Error, None, Some(message.clone()))
}
};
Self {
outcome: kind,
stop_reason,
message,
turn_id: outcome.accepted_ordinal,
turn: outcome.turn_start_position.map(|start_position| TurnSpan {
start_position,
completed_position: outcome.completed_ordinal,
}),
}
}
}
pub fn resolve_wait(observation: &WaitObservation, request: &WaitRequest) -> Option<WaitDecision> {
let stopping = matches!(
observation.lifecycle,
Some(ViewerLifecycleCategory::Suspended | ViewerLifecycleCategory::Suspending)
) || matches!(
observation.execution,
MaterializedExecutionState::Closing | MaterializedExecutionState::Closed
);
if observation.resuming {
return None;
}
if let Some(reason) = &observation.close_failure {
return Some(WaitDecision::simple(
WaitOutcome::Error,
Some(reason.clone()),
));
}
if observation.closing {
return None;
}
if stopping {
return Some(WaitDecision::simple(
WaitOutcome::Stopped,
Some(
observation
.launch_error
.clone()
.unwrap_or_else(|| "the session is stopped or stopping".to_owned()),
),
));
}
if observation.launch_failed {
return Some(WaitDecision::simple(
WaitOutcome::Error,
Some(
observation
.launch_error
.clone()
.unwrap_or_else(|| "the session failed to launch".to_owned()),
),
));
}
if let Some(StartStatus::Failed { message }) = &observation.start_status {
return Some(WaitDecision::simple(
WaitOutcome::Error,
Some(message.clone()),
));
}
if observation.lifecycle == Some(ViewerLifecycleCategory::Failed) {
return Some(WaitDecision::simple(
WaitOutcome::Error,
Some(
observation
.launch_error
.clone()
.unwrap_or_else(|| "the session is in a failed state".to_owned()),
),
));
}
let retry_pending = |outcome: &MaterializedTurnOutcome| {
observation.report_pending_for.as_deref() == Some(outcome.command_id.as_str())
|| observation.retry_assessment_pending
|| observation.capacity_retry.is_some()
|| observation.quota_recovery.as_ref().is_some_and(|r| {
r.retry_at_ms.is_some() && r.completed_command_id == outcome.command_id
})
};
if let Some(recovery) = &observation.quota_recovery
&& recovery.retry_at_ms.is_none()
&& observation
.last_turn_outcome
.as_ref()
.is_some_and(|t| t.command_id == recovery.completed_command_id)
&& request.turn_id.is_none_or(|target| {
observation
.last_turn_outcome
.as_ref()
.and_then(|t| t.accepted_ordinal)
.is_some_and(|a| a >= target)
})
{
return Some(WaitDecision::simple(
WaitOutcome::QuotaLimit,
Some(recovery.notice.clone()),
));
}
let target = request.turn_id.or(match &observation.start_status {
Some(StartStatus::Submitted { turn_id }) => Some(*turn_id),
_ => None,
});
let target_finished = target.is_some_and(|target| {
observation
.last_turn_outcome
.as_ref()
.is_some_and(|outcome| {
outcome
.accepted_ordinal
.is_some_and(|ordinal| ordinal >= target)
&& !retry_pending(outcome)
})
});
if request.return_on_input && !target_finished && !observation.pending_elicitations.is_empty() {
return Some(WaitDecision {
outcome: WaitOutcome::InputRequired,
stop_reason: None,
message: Some("the harness needs a response to a structured input request".into()),
turn_id: observation
.active_turn
.as_ref()
.and_then(|turn| turn.accepted_ordinal),
turn: None,
});
}
match target {
Some(target) => {
let outcome = observation.last_turn_outcome.as_ref()?;
if outcome
.accepted_ordinal
.is_none_or(|ordinal| ordinal < target)
{
return None;
}
if retry_pending(outcome) {
return None;
}
Some(WaitDecision::from_outcome(outcome))
}
None => {
if observation.checking_continuation
|| observation.cannot_take_prompt
|| matches!(observation.start_status, Some(StartStatus::Pending))
|| observation.execution != MaterializedExecutionState::Idle
|| observation.active_turn.is_some()
|| observation.queued > 0
{
return None;
}
match observation.last_turn_outcome.as_ref() {
Some(outcome) if retry_pending(outcome) => None,
Some(outcome) => Some(WaitDecision::from_outcome(outcome)),
None => Some(WaitDecision::simple(WaitOutcome::Finished, None)),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn awaiting_input_is_a_successful_wait_outcome() {
assert_eq!(
map_stop_reason(mj_core::acp::AWAITING_INPUT_STOP_REASON),
(WaitOutcome::InputRequired, None)
);
}
}