Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions crates/protocol/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,17 @@ pub enum GoalPauseReason {
NoProgress,
UsageLimit,
BudgetLimit,
/// The model finished a stage and handed control back, rather than a
/// reported blocker or a completion. It is not a judgement about the work,
/// so the user's next message resumes the goal (the same shape as a
/// runtime stop, which is also resumable by writing to it).
Yielded,
/// A reason a **newer** build wrote. Kept so an older binary can still read
/// the durable record instead of failing the whole goal load on an unknown
/// variant; nothing constructs it. The resume path treats it as "not a
/// hand-back", which is the conservative reading.
#[serde(other)]
Unrecognized,
}

impl GoalPauseReason {
Expand All @@ -379,6 +390,8 @@ impl GoalPauseReason {
Self::NoProgress => "no progress",
Self::UsageLimit => "usage limit",
Self::BudgetLimit => "budget limit",
Self::Yielded => "handed back",
Self::Unrecognized => "unrecognized",
}
}
}
Expand Down
55 changes: 48 additions & 7 deletions crates/tui/src/core/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5184,6 +5184,37 @@ impl Engine {
true
}

/// Resume a goal the model handed back at a milestone, when it is the
/// objective this turn names; publish the change like any other goal
/// transition. A yield is a hand-back rather than a judgement about the
/// work, so answering it continues the work.
async fn resume_yielded_goal(&mut self, objective: Option<&str>) -> bool {
let snapshot = match self.config.goal_state.lock() {
Ok(mut state) => {
if normalized_goal_objective(state.objective())
!= normalized_goal_objective(objective)
|| !state.resume_after_yield()
{
return false;
}
state.snapshot()
}
Err(err) => {
tracing::warn!("goal state lock poisoned while resuming a yielded goal: {err}");
return false;
}
};
self.config.goal_status = GoalStatus::Active;
self.emit_session_updated().await;
let _ = self.send_event(Event::GoalUpdated { snapshot }).await;
let _ = self
.send_event(Event::status(
"Goal resumed: your message continues the work the earlier turn handed back",
))
.await;
true
}

/// Pause a still-active goal with an inspectable reason and publish every
/// host projection in one ordered path.
async fn pause_goal_continuation(&mut self, reason: GoalPauseReason, message: String) {
Expand Down Expand Up @@ -6074,15 +6105,25 @@ impl Engine {
// A person writing to a goal that only the runtime stopped (a failed
// or timed-out continuation) is continuing the work: resume it as a
// new revision instead of running a goalless turn against a stale
// blocker. Blockers the model or user reported stay until an explicit
// blocker. A goal the model handed back at a milestone is the same
// shape — the model stopped for an answer, not because anything is
// wrong — so answering it continues the work too. Blockers the model
// reported and pauses the user asked for stay until an explicit
// resume, and automated inputs never resume anything.
let goal_status = if !self.is_acp_turn()
&& provenance == UserInputProvenance::ExternalUser
&& goal_status == GoalStatus::Blocked
&& self
.resume_runtime_blocked_goal(goal_objective.as_deref())
.await
let resumed_goal = if !self.is_acp_turn() && provenance == UserInputProvenance::ExternalUser
{
match goal_status {
GoalStatus::Blocked => {
self.resume_runtime_blocked_goal(goal_objective.as_deref())
.await
}
GoalStatus::Paused => self.resume_yielded_goal(goal_objective.as_deref()).await,
_ => false,
}
} else {
false
};
let goal_status = if resumed_goal {
GoalStatus::Active
} else {
goal_status
Expand Down
9 changes: 6 additions & 3 deletions crates/tui/src/core/engine/handle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -318,13 +318,16 @@ impl EngineHandle {
);
}
} else {
let (status, _) =
crate::tools::goal::thread_goal_status_projection(goal.status.clone());
let (status, pause_reason) = crate::tools::goal::thread_goal_status_projection(
goal.status.clone(),
goal.pause_reason,
);
if status != crate::tools::goal::GoalStatus::Active {
state.sync_from_host_status(
state.sync_from_host_status_with_reason(
current.objective.as_deref(),
current.token_budget,
status,
pause_reason,
);
}
}
Expand Down
40 changes: 40 additions & 0 deletions crates/tui/src/core/engine/tests/test_cases_12.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1581,4 +1581,44 @@ fn turn_tool_context_uses_planned_authority_and_route_not_installed_session() {
.model,
"planned-next-model"
);
}

/// Both continuation dispatchers test `is_active()` before re-arming. A goal the
/// model handed back must therefore land as `Inactive` at the gate the loop
/// actually consults — the unit tests pin `GoalState`, not this decision.
#[test]
fn a_yielded_goal_is_not_rearmed_by_the_continuation_gate() {
let (engine, _handle) = Engine::new(EngineConfig::default(), &Config::default());
{
let mut state = engine.config.goal_state.lock().expect("goal lock");
state.replace("ship the milestone", None, Some("goal-1".to_string()));
state.mark_yielded().expect("yield an active goal");
}

assert!(
matches!(
engine.goal_continuation_if_active(),
GoalContinuationAction::Inactive
),
"a handed-back goal must not be re-armed by the cross-turn gate"
);
}

/// The same gate, against an active goal, still dispatches — so the test above
/// is measuring the yield and not a gate that is dead in test conditions.
#[test]
fn an_active_goal_is_still_rearmed_by_the_continuation_gate() {
let (engine, _handle) = Engine::new(EngineConfig::default(), &Config::default());
{
let mut state = engine.config.goal_state.lock().expect("goal lock");
state.replace("ship the milestone", None, Some("goal-1".to_string()));
}

assert!(
matches!(
engine.goal_continuation_if_active(),
GoalContinuationAction::Dispatch { .. }
),
"an active goal still continues; otherwise the yield test proves nothing"
);
}
7 changes: 5 additions & 2 deletions crates/tui/src/prompts/text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,8 +186,11 @@ Before deciding the goal is achieved, verify it against the actual current
state — files, command output, tests, runtime behavior, issue or PR state, or
other authoritative evidence — then call `update_goal` with
`status: "complete"` and concise evidence. If something genuinely prevents
progress, call `update_goal` with `status: "blocked"` and explain it. If
`update_goal` is not in your tool list, load it with `tool_search` first.
progress, call `update_goal` with `status: "blocked"` and explain it. If you
finished a stage and the next step is the user's call, call `update_goal` with
`status: "yield"`, say what you need from them, and end your answer there;
their reply resumes the goal. If `update_goal` is not in your tool list, load
it with `tool_search` first.
"#;
/// Memory hygiene guidance — appended to the system prompt only when the
/// session has a non-empty user-memory block. Steers the model toward
Expand Down
14 changes: 10 additions & 4 deletions crates/tui/src/runtime_threads.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14538,7 +14538,11 @@ impl RuntimeThreadManager {
let turn_goal_status = turn_goal
.as_ref()
.map(|goal| {
crate::tools::goal::thread_goal_status_projection(goal.status.clone()).0
crate::tools::goal::thread_goal_status_projection(
goal.status.clone(),
goal.pause_reason,
)
.0
})
.unwrap_or(crate::tools::goal::GoalStatus::Active);

Expand Down Expand Up @@ -15271,9 +15275,11 @@ impl RuntimeThreadManager {
)
} else {
let snapshot = crate::tools::goal::GoalSnapshot::from_thread_goal(goal);
let status =
crate::tools::goal::thread_goal_status_projection(goal.status.clone())
.0;
let status = crate::tools::goal::thread_goal_status_projection(
goal.status.clone(),
goal.pause_reason,
)
.0;
(
Some(objective.to_string()),
snapshot.token_budget,
Expand Down
Loading
Loading