feat(tui/auto-drive): retain raw coordinator transcript

This commit is contained in:
James Peter
2025-09-30 19:09:41 +10:00
parent 6d6e3f5929
commit 6edced393c
6 changed files with 159 additions and 30 deletions
+2 -2
View File
@@ -1614,9 +1614,9 @@ impl App<'_> {
AppState::Chat { widget } => widget.submit_op(op),
AppState::Onboarding { .. } => {}
},
AppEvent::AutoCoordinatorDecision { status, summary, prompt } => {
AppEvent::AutoCoordinatorDecision { status, summary, prompt, transcript } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.auto_handle_decision(status, summary, prompt);
widget.auto_handle_decision(status, summary, prompt, transcript);
}
}
AppEvent::AutoCoordinatorThinking { delta, summary_index } => {
+1
View File
@@ -150,6 +150,7 @@ pub(crate) enum AppEvent {
status: AutoCoordinatorStatus,
summary: String,
prompt: Option<String>,
transcript: Vec<ResponseItem>,
},
AutoCoordinatorThinking {
delta: String,
+13 -4
View File
@@ -3394,15 +3394,20 @@ impl ChatWidget<'_> {
fn rebuild_auto_history(&mut self) -> Vec<codex_protocol::models::ResponseItem> {
let conversation = self.export_auto_drive_items();
self.auto_history.replace(conversation.clone());
conversation
let tail = self
.auto_history
.replace_converted(conversation.clone());
if !tail.is_empty() {
self.auto_history.append_raw(&tail);
}
self.auto_history.raw_snapshot()
}
fn current_auto_history(&mut self) -> Vec<codex_protocol::models::ResponseItem> {
if self.auto_history.is_empty() {
if self.auto_history.converted_is_empty() {
return self.rebuild_auto_history();
}
self.auto_history.snapshot()
self.auto_history.raw_snapshot()
}
/// Export current user/assistant messages into ResponseItem list for forking.
@@ -10089,12 +10094,16 @@ impl ChatWidget<'_> {
status: AutoCoordinatorStatus,
summary: String,
prompt: Option<String>,
transcript: Vec<codex_protocol::models::ResponseItem>,
) {
if !self.auto_state.active {
return;
}
let summary = summary.trim().to_string();
if !transcript.is_empty() {
self.auto_history.append_raw(&transcript);
}
self.auto_state.last_decision_summary = Some(summary.clone());
self.auto_state.coordinator_waiting = false;
self.auto_on_reasoning_final(&summary);
+56 -14
View File
@@ -11,7 +11,7 @@ use codex_core::model_family::{derive_default_model_family, find_family_for_mode
use codex_core::protocol::SandboxPolicy;
use codex_core::{AuthManager, ModelClient, Prompt, ResponseEvent, TextFormat};
use codex_core::error::CodexErr;
use codex_protocol::models::{ContentItem, ResponseItem};
use codex_protocol::models::{ContentItem, ReasoningItemContent, ResponseItem};
use futures::StreamExt;
use reqwest::StatusCode;
use serde::Deserialize;
@@ -227,12 +227,13 @@ fn run_auto_loop(
&app_event_tx,
&cancel_token,
) {
Ok((status, summary, prompt_opt)) => {
Ok((status, summary, prompt_opt, transcript_items)) => {
let prompt_clone = prompt_opt.clone();
let event = AppEvent::AutoCoordinatorDecision {
status,
summary,
prompt: prompt_opt,
transcript: transcript_items.clone(),
};
app_event_tx.send(event);
@@ -269,6 +270,7 @@ fn run_auto_loop(
status: AutoCoordinatorStatus::Failed,
summary: format!("Coordinator error: {err}"),
prompt: None,
transcript: Vec::new(),
};
app_event_tx.send(event);
stopped = true;
@@ -409,6 +411,11 @@ fn build_schema() -> Value {
})
}
struct RequestStreamResult {
output_text: String,
response_items: Vec<ResponseItem>,
}
fn request_coordinator_decision(
runtime: &tokio::runtime::Runtime,
client: &ModelClient,
@@ -418,8 +425,8 @@ fn request_coordinator_decision(
conversation: Vec<ResponseItem>,
app_event_tx: &AppEventSender,
cancel_token: &CancellationToken,
) -> Result<(AutoCoordinatorStatus, String, Option<String>)> {
let raw = request_decision(
) -> Result<(AutoCoordinatorStatus, String, Option<String>, Vec<ResponseItem>)> {
let (raw, response_items) = request_decision(
runtime,
client,
developer_intro,
@@ -455,7 +462,12 @@ fn request_coordinator_decision(
_ => None,
};
Ok((status, decision.summary.trim().to_string(), prompt_opt))
Ok((
status,
decision.summary.trim().to_string(),
prompt_opt,
response_items,
))
}
fn request_decision(
@@ -467,7 +479,7 @@ fn request_decision(
conversation: &[ResponseItem],
app_event_tx: &AppEventSender,
cancel_token: &CancellationToken,
) -> Result<String> {
) -> Result<(String, Vec<ResponseItem>)> {
match request_decision_with_model(
runtime,
client,
@@ -479,7 +491,7 @@ fn request_decision(
cancel_token,
MODEL_SLUG,
) {
Ok(output) => Ok(output),
Ok(result) => Ok((result.output_text, result.response_items)),
Err(err) => {
let fallback_slug = client.default_model_slug().to_string();
if fallback_slug != MODEL_SLUG && should_retry_with_default_model(&err) {
@@ -500,6 +512,7 @@ fn request_decision(
cancel_token,
&fallback_slug,
)
.map(|res| (res.output_text, res.response_items))
.map_err(|fallback_err| {
fallback_err.context(format!(
"coordinator fallback with model '{}' failed after original error: {}",
@@ -522,7 +535,7 @@ fn request_decision_with_model(
app_event_tx: &AppEventSender,
cancel_token: &CancellationToken,
model_slug: &str,
) -> Result<String> {
) -> Result<RequestStreamResult> {
let developer_intro = developer_intro.to_string();
let primary_goal = primary_goal.to_string();
let schema = schema.clone();
@@ -551,31 +564,43 @@ fn request_decision_with_model(
}
let mut stream = client.stream(&prompt).await?;
let mut out = String::new();
let mut response_items: Vec<ResponseItem> = Vec::new();
let mut reasoning_delta_accumulator = String::new();
while let Some(ev) = stream.next().await {
match ev {
Ok(ResponseEvent::OutputTextDelta { delta, .. }) => out.push_str(&delta),
Ok(ResponseEvent::OutputTextDelta { delta, .. }) => {
out.push_str(&delta);
}
Ok(ResponseEvent::OutputItemDone { item, .. }) => {
if let ResponseItem::Message { content, .. } = item {
if let ResponseItem::Message { content, .. } = &item {
for c in content {
if let ContentItem::OutputText { text } = c {
out.push_str(&text);
out.push_str(text);
}
}
}
if matches!(item, ResponseItem::Reasoning { .. }) {
reasoning_delta_accumulator.clear();
}
response_items.push(item);
}
Ok(ResponseEvent::ReasoningSummaryDelta {
delta,
summary_index,
..
}) => {
let message = strip_role_prefix(&delta).to_string();
let cleaned = strip_role_prefix(&delta);
reasoning_delta_accumulator.push_str(cleaned);
let message = cleaned.to_string();
tx_inner.send(AppEvent::AutoCoordinatorThinking {
delta: message,
summary_index,
});
}
Ok(ResponseEvent::ReasoningContentDelta { delta, .. }) => {
let message = strip_role_prefix(&delta).to_string();
let cleaned = strip_role_prefix(&delta);
reasoning_delta_accumulator.push_str(cleaned);
let message = cleaned.to_string();
tx_inner.send(AppEvent::AutoCoordinatorThinking {
delta: message,
summary_index: None,
@@ -586,7 +611,24 @@ fn request_decision_with_model(
_ => {}
}
}
Ok(out)
if !reasoning_delta_accumulator.trim().is_empty()
&& !response_items
.iter()
.any(|item| matches!(item, ResponseItem::Reasoning { .. }))
{
response_items.push(ResponseItem::Reasoning {
id: String::new(),
summary: Vec::new(),
content: Some(vec![ReasoningItemContent::ReasoningText {
text: reasoning_delta_accumulator.trim().to_string(),
}]),
encrypted_content: None,
});
}
Ok(RequestStreamResult {
output_text: out,
response_items,
})
}
},
classify,
@@ -1,29 +1,78 @@
use codex_protocol::models::ResponseItem;
/// Maintains the Auto Drive conversation transcript between coordinator turns.
///
/// `converted` mirrors what we previously derived from UI history and is used
/// when re-seeding the coordinator conversation. `raw` captures the exact
/// ResponseItems returned by the Auto Drive model so we can retain full
/// reasoning output without depending on UI rendering.
pub(crate) struct AutoDriveHistory {
items: Vec<ResponseItem>,
converted: Vec<ResponseItem>,
raw: Vec<ResponseItem>,
pending_skip: usize,
}
impl AutoDriveHistory {
pub(crate) fn new() -> Self {
Self { items: Vec::new() }
Self {
converted: Vec::new(),
raw: Vec::new(),
pending_skip: 0,
}
}
/// Replace the stored transcript with the provided items.
pub(crate) fn replace(&mut self, items: Vec<ResponseItem>) {
self.items = items;
/// Replace the stored converted transcript. Returns any new tail items that
/// were not present previously, preserving insertion order.
pub(crate) fn replace_converted(&mut self, items: Vec<ResponseItem>) -> Vec<ResponseItem> {
let prev_len = self.converted.len();
self.converted = items;
let mut tail: Vec<_> = if self.converted.len() <= prev_len {
Vec::new()
} else {
self.converted
.iter()
.skip(prev_len)
.cloned()
.collect()
};
if !tail.is_empty() && self.pending_skip > 0 {
if self.pending_skip >= tail.len() {
self.pending_skip -= tail.len();
tail.clear();
} else {
tail.drain(0..self.pending_skip);
self.pending_skip = 0;
}
}
tail
}
pub(crate) fn snapshot(&self) -> Vec<ResponseItem> {
self.items.clone()
pub(crate) fn append_raw(&mut self, items: &[ResponseItem]) {
if items.is_empty() {
return;
}
self.raw.extend(items.iter().cloned());
self.pending_skip = self.pending_skip.saturating_add(items.len());
}
pub(crate) fn raw_snapshot(&self) -> Vec<ResponseItem> {
self.raw.clone()
}
pub(crate) fn clear(&mut self) {
self.items.clear();
self.converted.clear();
self.raw.clear();
self.pending_skip = 0;
}
pub(crate) fn is_empty(&self) -> bool {
self.items.is_empty()
pub(crate) fn converted_is_empty(&self) -> bool {
self.converted.is_empty()
}
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn raw_items(&self) -> &[ResponseItem] {
&self.raw
}
}
+28
View File
@@ -1038,6 +1038,7 @@ fn auto_decision_persists_summary_through_cli_cycle() {
AutoCoordinatorStatus::Continue,
"**Plan:** run tests".to_string(),
Some("run tests".to_string()),
Vec::new(),
);
assert_eq!(chat.auto_state.current_display_line.as_deref(), Some("Plan:"));
@@ -1067,6 +1068,33 @@ fn auto_decision_persists_summary_through_cli_cycle() {
assert_eq!(chat.auto_state.current_display_line.as_deref(), Some("Plan:"));
}
#[test]
fn auto_history_captures_raw_transcript() {
use codex_protocol::models::{ContentItem, ResponseItem};
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
chat.auto_state.active = true;
chat.auto_state.waiting_for_response = true;
chat.auto_state.coordinator_waiting = true;
let transcript = vec![ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "{\n \"finish_status\": \"continue\"\n}".to_string(),
}],
}];
chat.auto_handle_decision(
AutoCoordinatorStatus::Continue,
"Next".to_string(),
Some("do thing".to_string()),
transcript.clone(),
);
assert_eq!(chat.auto_history.raw_items(), transcript.as_slice());
}
fn pump_app_events(
chat: &mut ChatWidget<'_>,
rx: &std::sync::mpsc::Receiver<AppEvent>,