feat(core/tui): add task lifecycle visibility routing

Track task origin and visibility in core lifecycle events so hidden turns stay out of the visible transcript. Gate Auto Drive completion on quiescent lifecycle signals while preserving hidden assistant output in auto history.
This commit is contained in:
James Peter
2026-04-17 14:55:02 +10:00
parent 9891471e02
commit 63ea56db12
7 changed files with 396 additions and 20 deletions
+44 -4
View File
@@ -1,4 +1,5 @@
use super::*;
use crate::protocol::TaskOriginKind;
use serde_json::Value;
use crate::util::extract_shell_script;
use code_protocol::dynamic_tools::DynamicToolResponse;
@@ -435,6 +436,12 @@ pub(super) struct BackgroundExecState {
pub(super) sub_id: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct TaskLifecycleInfo {
pub(super) origin: TaskOriginKind,
pub(super) visible_to_user: bool,
}
/// Context for an initialized model agent
///
/// A session has at most 1 running agent at a time, and can be interrupted by user input.
@@ -1022,7 +1029,12 @@ impl Session {
state.current_task = Some(agent);
}
pub(super) async fn start_internal_pending_only_turn(self: &Arc<Self>, sentinel: &str) -> bool {
pub(super) async fn start_internal_pending_only_turn(
self: &Arc<Self>,
sentinel: &str,
origin: TaskOriginKind,
visible_to_user: bool,
) -> bool {
let should_start = {
let state = self.state.lock().unwrap();
state.current_task.is_none()
@@ -1038,13 +1050,25 @@ impl Session {
let sentinel_input = vec![InputItem::Text {
text: sentinel.to_string(),
}];
let agent = AgentTask::spawn(Arc::clone(self), turn_context, sub_id, sentinel_input);
let agent = AgentTask::spawn(
Arc::clone(self),
turn_context,
sub_id,
sentinel_input,
origin,
visible_to_user,
);
self.set_task(agent);
true
}
pub async fn start_pending_only_turn_if_idle(self: &Arc<Self>) -> bool {
self.start_internal_pending_only_turn(PENDING_ONLY_SENTINEL).await
self.start_internal_pending_only_turn(
PENDING_ONLY_SENTINEL,
TaskOriginKind::PendingInput,
false,
)
.await
}
pub async fn start_post_turn_pending_only_turn_if_idle(self: &Arc<Self>) -> bool {
@@ -1063,7 +1087,11 @@ impl Session {
return false;
}
self.start_internal_pending_only_turn(POST_TURN_PENDING_ONLY_SENTINEL)
self.start_internal_pending_only_turn(
POST_TURN_PENDING_ONLY_SENTINEL,
TaskOriginKind::PostTurn,
false,
)
.await
}
@@ -1097,6 +1125,18 @@ impl Session {
self.state.lock().unwrap().current_task.is_some()
}
pub(super) fn task_lifecycle(&self, sub_id: &str) -> Option<TaskLifecycleInfo> {
let state = self.state.lock().unwrap();
let task = state.current_task.as_ref()?;
if task.sub_id != sub_id {
return None;
}
Some(TaskLifecycleInfo {
origin: task.origin,
visible_to_user: task.visible_to_user,
})
}
pub fn queue_user_input(&self, queued: QueuedUserInput) {
let mut state = self.state.lock().unwrap();
state.pending_user_input.push(queued);
+114 -10
View File
@@ -27,6 +27,9 @@ use crate::account_switching::RateLimitSwitchState;
use crate::agent_tool::current_agent_spawn_depth;
use crate::agent_tool::external_agent_command_exists;
use crate::protocol::McpListToolsResponseEvent;
use crate::protocol::TaskLifecycleEvent;
use crate::protocol::TaskLifecyclePhase;
use crate::protocol::TaskOriginKind;
use code_app_server_protocol::AuthMode as AppAuthMode;
use code_protocol::models::ContentItem;
use code_protocol::models::ResponseItem;
@@ -76,6 +79,8 @@ pub(super) struct AgentTask {
pub(super) sub_id: String,
handle: AbortHandle,
kind: AgentTaskKind,
pub(super) origin: TaskOriginKind,
pub(super) visible_to_user: bool,
}
impl AgentTask {
@@ -84,13 +89,17 @@ impl AgentTask {
turn_context: Arc<TurnContext>,
sub_id: String,
input: Vec<InputItem>,
origin: TaskOriginKind,
visible_to_user: bool,
) -> Self {
let handle = {
let sess_clone = Arc::clone(&sess);
let tc_clone = Arc::clone(&turn_context);
let sub_clone = sub_id.clone();
let origin_clone = origin;
let visible_clone = visible_to_user;
tokio::spawn(async move {
run_agent(sess_clone, tc_clone, sub_clone, input).await;
run_agent(sess_clone, tc_clone, sub_clone, input, origin_clone, visible_clone).await;
})
.abort_handle()
};
@@ -99,6 +108,8 @@ impl AgentTask {
sub_id,
handle,
kind: AgentTaskKind::Regular,
origin,
visible_to_user,
}
}
@@ -128,6 +139,8 @@ impl AgentTask {
sub_id,
handle,
kind: AgentTaskKind::Compact,
origin: TaskOriginKind::ManualCompact,
visible_to_user: false,
}
}
@@ -142,7 +155,15 @@ impl AgentTask {
let tc_clone = Arc::clone(&turn_context);
let sub_clone = sub_id.clone();
tokio::spawn(async move {
run_agent(sess_clone, tc_clone, sub_clone, input).await;
run_agent(
sess_clone,
tc_clone,
sub_clone,
input,
TaskOriginKind::Review,
false,
)
.await;
})
.abort_handle()
};
@@ -151,6 +172,8 @@ impl AgentTask {
sub_id,
handle,
kind: AgentTaskKind::Review,
origin: TaskOriginKind::Review,
visible_to_user: false,
}
}
@@ -277,7 +300,14 @@ pub(super) async fn submission_loop(
let sentinel_input = vec![InputItem::Text {
text: PENDING_ONLY_SENTINEL.to_string(),
}];
let agent = AgentTask::spawn(Arc::clone(&sess), turn_context, sub_id, sentinel_input);
let agent = AgentTask::spawn(
Arc::clone(&sess),
turn_context,
sub_id,
sentinel_input,
TaskOriginKind::OutOfTurnDeveloper,
false,
);
sess.set_task(agent);
}
}
@@ -945,6 +975,7 @@ pub(super) async fn submission_loop(
sub.id.clone(),
items,
final_output_json_schema,
TaskOriginKind::User,
)
.await;
}
@@ -970,7 +1001,14 @@ pub(super) async fn submission_loop(
} else {
// No task running: treat this as immediate user input without aborting.
sess.cleanup_old_status_items().await;
spawn_user_turn(Arc::clone(sess), sub.id.clone(), items, None).await;
spawn_user_turn(
Arc::clone(sess),
sub.id.clone(),
items,
None,
TaskOriginKind::QueuedUser,
)
.await;
}
}
Op::ExecApproval {
@@ -2191,13 +2229,14 @@ async fn spawn_user_turn(
sub_id: String,
items: Vec<InputItem>,
final_output_json_schema: Option<serde_json::Value>,
origin: TaskOriginKind,
) {
maybe_run_auto_context_compaction(&sess, &sub_id, &items).await;
let turn_context = match final_output_json_schema {
Some(schema) => sess.make_turn_context_with_schema(Some(schema)),
None => sess.make_turn_context(),
};
let agent = AgentTask::spawn(Arc::clone(&sess), turn_context, sub_id, items);
let agent = AgentTask::spawn(Arc::clone(&sess), turn_context, sub_id, items, origin, true);
sess.set_task(agent);
}
@@ -2317,10 +2356,29 @@ fn parse_review_output_event(text: &str) -> ReviewOutputEvent {
/// back to the model in the next turn.
/// - If the model sends only an assistant message, we record it in the
/// conversation history and consider the agent complete.
async fn run_agent(sess: Arc<Session>, turn_context: Arc<TurnContext>, sub_id: String, input: Vec<InputItem>) {
async fn run_agent(
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
sub_id: String,
input: Vec<InputItem>,
origin: TaskOriginKind,
visible_to_user: bool,
) {
if input.is_empty() {
return;
}
let lifecycle = sess.make_event(
&sub_id,
EventMsg::TaskLifecycle(TaskLifecycleEvent {
phase: TaskLifecyclePhase::Started,
origin,
visible_to_user,
last_agent_message: None,
}),
);
if sess.tx_event.send(lifecycle).await.is_err() {
return;
}
let event = sess.make_event(&sub_id, EventMsg::TaskStarted);
if sess.tx_event.send(event).await.is_err() {
return;
@@ -2773,6 +2831,17 @@ async fn run_agent(sess: Arc<Session>, turn_context: Arc<TurnContext>, sub_id: S
}
sess.remove_task(&sub_id);
let lifecycle = sess.make_event(
&sub_id,
EventMsg::TaskLifecycle(TaskLifecycleEvent {
phase: TaskLifecyclePhase::Quiescent,
origin,
visible_to_user,
last_agent_message: last_task_message.clone(),
}),
);
sess.tx_event.send(lifecycle).await.ok();
let event = sess.make_event(
&sub_id,
EventMsg::TaskComplete(TaskCompleteEvent {
@@ -2790,7 +2859,11 @@ async fn run_agent(sess: Arc<Session>, turn_context: Arc<TurnContext>, sub_id: S
if let Some(action) = sess.take_follow_up_turn_action() {
match action {
FollowUpTurnAction::PostTurnPendingInput => {
sess.start_internal_pending_only_turn(POST_TURN_PENDING_ONLY_SENTINEL)
sess.start_internal_pending_only_turn(
POST_TURN_PENDING_ONLY_SENTINEL,
TaskOriginKind::PostTurn,
false,
)
.await;
}
FollowUpTurnAction::ManualCompact(compact_sub_id) => {
@@ -2806,7 +2879,11 @@ async fn run_agent(sess: Arc<Session>, turn_context: Arc<TurnContext>, sub_id: S
);
}
FollowUpTurnAction::PendingInput => {
sess.start_internal_pending_only_turn(PENDING_ONLY_SENTINEL)
sess.start_internal_pending_only_turn(
PENDING_ONLY_SENTINEL,
TaskOriginKind::PendingInput,
false,
)
.await;
}
FollowUpTurnAction::QueuedUserInput(queued) => {
@@ -2815,7 +2892,14 @@ async fn run_agent(sess: Arc<Session>, turn_context: Arc<TurnContext>, sub_id: S
sess_clone.cleanup_old_status_items().await;
let submission_id = queued.submission_id;
let items = queued.core_items;
spawn_user_turn(sess_clone, submission_id, items, None).await;
spawn_user_turn(
sess_clone,
submission_id,
items,
None,
TaskOriginKind::QueuedUser,
)
.await;
});
}
}
@@ -4670,6 +4754,19 @@ async fn handle_request_user_input(
)
.await;
if let Some(task) = sess.task_lifecycle(&ctx.sub_id) {
let lifecycle = sess.make_event(
&ctx.sub_id,
EventMsg::TaskLifecycle(TaskLifecycleEvent {
phase: TaskLifecyclePhase::AwaitingExternalInput,
origin: task.origin,
visible_to_user: task.visible_to_user,
last_agent_message: None,
}),
);
sess.tx_event.send(lifecycle).await.ok();
}
let response = match rx_response.await {
Ok(response) => response,
Err(_) => {
@@ -11175,7 +11272,14 @@ async fn enqueue_agent_completion_wake(
let sentinel_input = vec![InputItem::Text {
text: PENDING_ONLY_SENTINEL.to_string(),
}];
let agent = AgentTask::spawn(Arc::clone(sess), turn_context, sub_id, sentinel_input);
let agent = AgentTask::spawn(
Arc::clone(sess),
turn_context,
sub_id,
sentinel_input,
TaskOriginKind::OutOfTurnDeveloper,
false,
);
sess.set_task(agent);
}
}
+33
View File
@@ -624,6 +624,7 @@ pub struct RecordedEvent {
pub fn event_msg_to_protocol(msg: &EventMsg) -> Option<code_protocol::protocol::EventMsg> {
match msg {
EventMsg::ReplayHistory(_) => None,
EventMsg::TaskLifecycle(_) => None,
EventMsg::TokenCount(payload) => {
let info = convert_value(&payload.info)?;
let rate_limits = payload
@@ -826,6 +827,10 @@ pub enum EventMsg {
/// Agent has started a task
TaskStarted,
/// Core-owned lifecycle signal for a task, including provenance and
/// whether the resulting output should be shown in the visible thread.
TaskLifecycle(TaskLifecycleEvent),
/// Agent has completed all actions
TaskComplete(TaskCompleteEvent),
@@ -976,6 +981,34 @@ pub struct TaskCompleteEvent {
pub last_agent_message: Option<String>,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TaskLifecyclePhase {
Started,
AwaitingExternalInput,
Quiescent,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TaskOriginKind {
User,
QueuedUser,
PendingInput,
PostTurn,
OutOfTurnDeveloper,
Review,
ManualCompact,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct TaskLifecycleEvent {
pub phase: TaskLifecyclePhase,
pub origin: TaskOriginKind,
pub visible_to_user: bool,
pub last_agent_message: Option<String>,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AutoContextPhase {
@@ -230,6 +230,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
| EventMsg::ListCustomPromptsResponse(_)
| EventMsg::ListSkillsResponse(_)
| EventMsg::McpListToolsResponse(_)
| EventMsg::TaskLifecycle(_)
| EventMsg::ViewImageToolCall(_) => {
// Environment context events are consumed by the TUI; the CLI runner
// does not surface them alongside the human-readable transcript.
@@ -313,6 +313,7 @@ async fn run_code_tool_session_inner(
EventMsg::AgentReasoningRawContent(_)
| EventMsg::AgentReasoningRawContentDelta(_)
| EventMsg::Warning(_)
| EventMsg::TaskLifecycle(_)
| EventMsg::TaskStarted
| EventMsg::TokenCount(_)
| EventMsg::AutoContextCheck(_)
@@ -96,6 +96,7 @@ pub async fn run_conversation_loop(
}
EventMsg::AgentReasoningRawContent(_)
| EventMsg::AgentReasoningRawContentDelta(_)
| EventMsg::TaskLifecycle(_)
| EventMsg::TaskStarted
| EventMsg::TokenCount(_)
| EventMsg::AgentReasoning(_)
+202 -6
View File
@@ -185,6 +185,9 @@ use code_core::protocol::ReviewOutputEvent;
use code_core::protocol::{ReviewContextMetadata, ReviewRequest};
use code_core::protocol::PatchApplyBeginEvent;
use code_core::protocol::PatchApplyEndEvent;
use code_core::protocol::TaskLifecycleEvent;
use code_core::protocol::TaskLifecyclePhase;
use code_core::protocol::TaskOriginKind;
use code_core::protocol::TaskCompleteEvent;
use code_core::protocol::TokenUsage;
use code_core::protocol::TurnDiffEvent;
@@ -1827,6 +1830,12 @@ struct PendingRequestUserInput {
questions: Vec<code_protocol::request_user_input::RequestUserInputQuestion>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct PendingTaskLifecycle {
origin: TaskOriginKind,
visible_to_user: bool,
}
#[derive(Clone)]
struct RenderRequestSeed {
history_id: HistoryId,
@@ -1915,6 +1924,8 @@ pub(crate) struct ChatWidget<'a> {
pending_turn_origin: Option<TurnOrigin>,
pending_request_user_input: Option<PendingRequestUserInput>,
current_turn_origin: Option<TurnOrigin>,
pending_task_lifecycle: Option<PendingTaskLifecycle>,
current_task_lifecycle: Option<PendingTaskLifecycle>,
// Tracks whether lingering running exec/tool cells have been cleared for the
// current turn. Reset on TaskStarted; set after the first assistant message
// (delta or final) arrives, which is more reliable than TaskComplete.
@@ -4173,11 +4184,22 @@ impl ChatWidget<'_> {
{
self.bottom_pane.set_task_running(false);
self.bottom_pane.update_status_text(String::new());
self.auto_on_turn_complete();
self.flush_deferred_auto_review_notice_if_idle();
}
}
fn current_task_output_is_hidden(&self) -> bool {
self.current_task_lifecycle
.as_ref()
.map(|task| !task.visible_to_user)
.unwrap_or(false)
}
fn current_task_should_skip_auto_review(&self) -> bool {
self.current_task_output_is_hidden()
|| matches!(self.current_turn_origin, Some(TurnOrigin::Developer))
}
fn foreground_activity_running_excluding_auto_review(&self) -> bool {
let wait_running = self.wait_running();
let wait_blocks = self.wait_blocking_enabled();
@@ -6901,6 +6923,8 @@ impl ChatWidget<'_> {
pending_turn_origin: None,
pending_request_user_input: None,
current_turn_origin: None,
pending_task_lifecycle: None,
current_task_lifecycle: None,
cleared_lingering_execs_this_turn: true,
exec: ExecState {
running_commands: HashMap::new(),
@@ -7271,6 +7295,8 @@ impl ChatWidget<'_> {
pending_turn_origin: None,
pending_request_user_input: None,
current_turn_origin: None,
pending_task_lifecycle: None,
current_task_lifecycle: None,
cleared_lingering_execs_this_turn: true,
exec: ExecState {
running_commands: HashMap::new(),
@@ -13823,7 +13849,43 @@ impl ChatWidget<'_> {
);
tools::web_search_begin(self, ev.call_id, ev.query, event.order.as_ref(), ok)
}
EventMsg::TaskLifecycle(TaskLifecycleEvent {
phase,
origin,
visible_to_user,
last_agent_message,
}) => {
let lifecycle = PendingTaskLifecycle {
origin,
visible_to_user,
};
match phase {
TaskLifecyclePhase::Started | TaskLifecyclePhase::AwaitingExternalInput => {
self.pending_task_lifecycle = Some(lifecycle);
}
TaskLifecyclePhase::Quiescent => {
self.current_task_lifecycle = Some(lifecycle);
if self.auto_state.is_active() && !visible_to_user {
if let Some(item) = last_agent_message
.as_deref()
.map(str::trim)
.filter(|text| !text.is_empty())
.and_then(|text| {
Self::auto_drive_make_assistant_message(text.to_string())
})
{
self.auto_history.append_raw(std::slice::from_ref(&item));
}
}
self.auto_on_turn_complete();
}
}
}
EventMsg::AgentMessage(AgentMessageEvent { message }) => {
if self.current_task_output_is_hidden() {
tracing::debug!("Suppressing hidden AgentMessage for current task");
return;
}
// If the user requested an interrupt, ignore late final answers.
if self.stream_state.drop_streaming {
tracing::debug!("Ignoring AgentMessage after interrupt");
@@ -13937,6 +13999,10 @@ impl ChatWidget<'_> {
tools::web_search_complete(self, ev.call_id, ev.query, event.order.as_ref(), ok)
}
EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {
if self.current_task_output_is_hidden() {
tracing::debug!("Suppressing hidden AgentMessageDelta for current task");
return;
}
tracing::debug!("AgentMessageDelta: {:?}", delta);
// If the user requested an interrupt, ignore late deltas.
if self.stream_state.drop_streaming {
@@ -13986,6 +14052,10 @@ impl ChatWidget<'_> {
.update_status_text("responding".to_string());
}
EventMsg::AgentReasoning(AgentReasoningEvent { text }) => {
if self.current_task_output_is_hidden() {
tracing::debug!("Suppressing hidden AgentReasoning for current task");
return;
}
// Ignore late reasoning if we've dropped streaming due to interrupt.
if self.stream_state.drop_streaming {
tracing::debug!("Ignoring AgentReasoning after interrupt");
@@ -14043,6 +14113,10 @@ impl ChatWidget<'_> {
self.mark_needs_redraw();
}
EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) => {
if self.current_task_output_is_hidden() {
tracing::debug!("Suppressing hidden AgentReasoningDelta for current task");
return;
}
tracing::debug!("AgentReasoningDelta: {:?}", delta);
if self.stream_state.drop_streaming {
tracing::debug!("Ignoring Reasoning delta after interrupt");
@@ -14090,6 +14164,10 @@ impl ChatWidget<'_> {
self.bottom_pane.update_status_text("thinking".to_string());
}
EventMsg::AgentReasoningSectionBreak(AgentReasoningSectionBreakEvent {}) => {
if self.current_task_output_is_hidden() {
tracing::debug!("Suppressing hidden AgentReasoningSectionBreak for current task");
return;
}
// Insert section break in reasoning stream
let sink = AppEventHistorySink(self.app_event_tx.clone());
self.stream.insert_reasoning_section_break(&sink);
@@ -14122,7 +14200,12 @@ impl ChatWidget<'_> {
// after every tool call.
self.turn_sequence = self.turn_sequence.saturating_add(1);
self.turn_had_code_edits = false;
self.current_turn_origin = self.pending_turn_origin.take();
self.current_task_lifecycle = self.pending_task_lifecycle.take();
self.current_turn_origin = if self.current_task_output_is_hidden() {
Some(TurnOrigin::Developer)
} else {
self.pending_turn_origin.take()
};
self.cleared_lingering_execs_this_turn = false;
self.ensure_lingering_execs_cleared();
@@ -14226,8 +14309,8 @@ impl ChatWidget<'_> {
// Final re-check for idle state
self.maybe_hide_spinner();
self.maybe_trigger_auto_review();
self.auto_on_turn_complete();
self.emit_turn_complete_notification(last_agent_message);
self.current_task_lifecycle = None;
self.suppress_next_agent_hint = false;
self.mark_needs_redraw();
self.flush_history_snapshot_if_needed(true);
@@ -14236,6 +14319,10 @@ impl ChatWidget<'_> {
EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent {
delta,
}) => {
if self.current_task_output_is_hidden() {
tracing::debug!("Suppressing hidden AgentReasoningRawContentDelta for current task");
return;
}
if self.stream_state.drop_streaming {
tracing::debug!("Ignoring RawContent delta after interrupt");
self.stop_spinner();
@@ -14275,6 +14362,10 @@ impl ChatWidget<'_> {
);
}
EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => {
if self.current_task_output_is_hidden() {
tracing::debug!("Suppressing hidden AgentReasoningRawContent for current task");
return;
}
if self.stream_state.drop_streaming {
tracing::debug!("Ignoring AgentReasoningRawContent after interrupt");
self.stop_spinner();
@@ -34784,7 +34875,7 @@ use code_core::protocol::OrderMeta;
}
#[test]
fn auto_drive_waits_for_turn_completion_before_updating_coordinator() {
fn auto_drive_waits_for_quiescent_lifecycle_before_updating_coordinator() {
let mut harness = ChatWidgetHarness::new();
let chat = harness.chat();
@@ -34792,6 +34883,18 @@ use code_core::protocol::OrderMeta;
chat.auto_state.on_prompt_submitted();
chat.auto_state.set_coordinator_waiting(false);
chat.handle_code_event(Event {
id: "turn-1".to_string(),
event_seq: 0,
msg: EventMsg::TaskLifecycle(TaskLifecycleEvent {
phase: TaskLifecyclePhase::Started,
origin: TaskOriginKind::User,
visible_to_user: true,
last_agent_message: None,
}),
order: None,
});
chat.handle_code_event(Event {
id: "turn-1".to_string(),
event_seq: 0,
@@ -34819,9 +34922,102 @@ use code_core::protocol::OrderMeta;
order: None,
});
assert!(
chat.auto_state.is_waiting_for_response(),
"TaskComplete alone should not advance Auto Drive"
);
chat.handle_code_event(Event {
id: "turn-1".to_string(),
event_seq: 2,
msg: EventMsg::TaskLifecycle(TaskLifecycleEvent {
phase: TaskLifecyclePhase::Quiescent,
origin: TaskOriginKind::User,
visible_to_user: true,
last_agent_message: Some("First assistant update".to_string()),
}),
order: None,
});
assert!(
!chat.auto_state.is_waiting_for_response(),
"TaskComplete should be the first point that advances Auto Drive"
"TaskLifecycle(quiescent) should be the first point that advances Auto Drive"
);
}
#[test]
fn hidden_out_of_turn_developer_turn_stays_out_of_visible_history() {
let mut harness = ChatWidgetHarness::new();
let chat = harness.chat();
chat.auto_state.set_phase(AutoRunPhase::Active);
chat.auto_state.on_prompt_submitted();
chat.auto_state.set_coordinator_waiting(false);
let before = chat.history_cells.len();
chat.handle_code_event(Event {
id: "hidden-turn".to_string(),
event_seq: 0,
msg: EventMsg::TaskLifecycle(TaskLifecycleEvent {
phase: TaskLifecyclePhase::Started,
origin: TaskOriginKind::OutOfTurnDeveloper,
visible_to_user: false,
last_agent_message: None,
}),
order: None,
});
chat.handle_code_event(Event {
id: "hidden-turn".to_string(),
event_seq: 0,
msg: EventMsg::TaskStarted,
order: None,
});
chat.handle_code_event(Event {
id: "hidden-turn".to_string(),
event_seq: 1,
msg: EventMsg::AgentMessage(AgentMessageEvent {
message: "Background build passed cleanly.".to_string(),
}),
order: None,
});
assert_eq!(
chat.history_cells.len(),
before,
"hidden out-of-turn developer turns should not render visible assistant output"
);
chat.handle_code_event(Event {
id: "hidden-turn".to_string(),
event_seq: 2,
msg: EventMsg::TaskLifecycle(TaskLifecycleEvent {
phase: TaskLifecyclePhase::Quiescent,
origin: TaskOriginKind::OutOfTurnDeveloper,
visible_to_user: false,
last_agent_message: Some("Background build passed cleanly.".to_string()),
}),
order: None,
});
let hidden_observation_present = chat.auto_history.raw_snapshot().iter().any(|item| {
matches!(
item,
code_protocol::models::ResponseItem::Message { role, content, .. }
if role == "assistant"
&& content.iter().any(|entry| matches!(
entry,
code_protocol::models::ContentItem::OutputText { text }
if text.contains("Background build passed cleanly.")
))
)
});
assert!(
hidden_observation_present,
"hidden out-of-turn developer turns should still update Auto Drive history"
);
}
@@ -36854,7 +37050,7 @@ impl ChatWidget<'_> {
if !self.turn_had_code_edits && self.pending_auto_review_range.is_none() {
return;
}
if matches!(self.current_turn_origin, Some(TurnOrigin::Developer)) {
if self.current_task_should_skip_auto_review() {
return;
}