mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
feat(tui/auto-drive): retain raw coordinator transcript
This commit is contained in:
@@ -1614,9 +1614,9 @@ impl App<'_> {
|
|||||||
AppState::Chat { widget } => widget.submit_op(op),
|
AppState::Chat { widget } => widget.submit_op(op),
|
||||||
AppState::Onboarding { .. } => {}
|
AppState::Onboarding { .. } => {}
|
||||||
},
|
},
|
||||||
AppEvent::AutoCoordinatorDecision { status, summary, prompt } => {
|
AppEvent::AutoCoordinatorDecision { status, summary, prompt, transcript } => {
|
||||||
if let AppState::Chat { widget } = &mut self.app_state {
|
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 } => {
|
AppEvent::AutoCoordinatorThinking { delta, summary_index } => {
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ pub(crate) enum AppEvent {
|
|||||||
status: AutoCoordinatorStatus,
|
status: AutoCoordinatorStatus,
|
||||||
summary: String,
|
summary: String,
|
||||||
prompt: Option<String>,
|
prompt: Option<String>,
|
||||||
|
transcript: Vec<ResponseItem>,
|
||||||
},
|
},
|
||||||
AutoCoordinatorThinking {
|
AutoCoordinatorThinking {
|
||||||
delta: String,
|
delta: String,
|
||||||
|
|||||||
@@ -3394,15 +3394,20 @@ impl ChatWidget<'_> {
|
|||||||
|
|
||||||
fn rebuild_auto_history(&mut self) -> Vec<codex_protocol::models::ResponseItem> {
|
fn rebuild_auto_history(&mut self) -> Vec<codex_protocol::models::ResponseItem> {
|
||||||
let conversation = self.export_auto_drive_items();
|
let conversation = self.export_auto_drive_items();
|
||||||
self.auto_history.replace(conversation.clone());
|
let tail = self
|
||||||
conversation
|
.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> {
|
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();
|
return self.rebuild_auto_history();
|
||||||
}
|
}
|
||||||
self.auto_history.snapshot()
|
self.auto_history.raw_snapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Export current user/assistant messages into ResponseItem list for forking.
|
/// Export current user/assistant messages into ResponseItem list for forking.
|
||||||
@@ -10089,12 +10094,16 @@ impl ChatWidget<'_> {
|
|||||||
status: AutoCoordinatorStatus,
|
status: AutoCoordinatorStatus,
|
||||||
summary: String,
|
summary: String,
|
||||||
prompt: Option<String>,
|
prompt: Option<String>,
|
||||||
|
transcript: Vec<codex_protocol::models::ResponseItem>,
|
||||||
) {
|
) {
|
||||||
if !self.auto_state.active {
|
if !self.auto_state.active {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let summary = summary.trim().to_string();
|
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.last_decision_summary = Some(summary.clone());
|
||||||
self.auto_state.coordinator_waiting = false;
|
self.auto_state.coordinator_waiting = false;
|
||||||
self.auto_on_reasoning_final(&summary);
|
self.auto_on_reasoning_final(&summary);
|
||||||
|
|||||||
@@ -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::protocol::SandboxPolicy;
|
||||||
use codex_core::{AuthManager, ModelClient, Prompt, ResponseEvent, TextFormat};
|
use codex_core::{AuthManager, ModelClient, Prompt, ResponseEvent, TextFormat};
|
||||||
use codex_core::error::CodexErr;
|
use codex_core::error::CodexErr;
|
||||||
use codex_protocol::models::{ContentItem, ResponseItem};
|
use codex_protocol::models::{ContentItem, ReasoningItemContent, ResponseItem};
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -227,12 +227,13 @@ fn run_auto_loop(
|
|||||||
&app_event_tx,
|
&app_event_tx,
|
||||||
&cancel_token,
|
&cancel_token,
|
||||||
) {
|
) {
|
||||||
Ok((status, summary, prompt_opt)) => {
|
Ok((status, summary, prompt_opt, transcript_items)) => {
|
||||||
let prompt_clone = prompt_opt.clone();
|
let prompt_clone = prompt_opt.clone();
|
||||||
let event = AppEvent::AutoCoordinatorDecision {
|
let event = AppEvent::AutoCoordinatorDecision {
|
||||||
status,
|
status,
|
||||||
summary,
|
summary,
|
||||||
prompt: prompt_opt,
|
prompt: prompt_opt,
|
||||||
|
transcript: transcript_items.clone(),
|
||||||
};
|
};
|
||||||
app_event_tx.send(event);
|
app_event_tx.send(event);
|
||||||
|
|
||||||
@@ -269,6 +270,7 @@ fn run_auto_loop(
|
|||||||
status: AutoCoordinatorStatus::Failed,
|
status: AutoCoordinatorStatus::Failed,
|
||||||
summary: format!("Coordinator error: {err}"),
|
summary: format!("Coordinator error: {err}"),
|
||||||
prompt: None,
|
prompt: None,
|
||||||
|
transcript: Vec::new(),
|
||||||
};
|
};
|
||||||
app_event_tx.send(event);
|
app_event_tx.send(event);
|
||||||
stopped = true;
|
stopped = true;
|
||||||
@@ -409,6 +411,11 @@ fn build_schema() -> Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct RequestStreamResult {
|
||||||
|
output_text: String,
|
||||||
|
response_items: Vec<ResponseItem>,
|
||||||
|
}
|
||||||
|
|
||||||
fn request_coordinator_decision(
|
fn request_coordinator_decision(
|
||||||
runtime: &tokio::runtime::Runtime,
|
runtime: &tokio::runtime::Runtime,
|
||||||
client: &ModelClient,
|
client: &ModelClient,
|
||||||
@@ -418,8 +425,8 @@ fn request_coordinator_decision(
|
|||||||
conversation: Vec<ResponseItem>,
|
conversation: Vec<ResponseItem>,
|
||||||
app_event_tx: &AppEventSender,
|
app_event_tx: &AppEventSender,
|
||||||
cancel_token: &CancellationToken,
|
cancel_token: &CancellationToken,
|
||||||
) -> Result<(AutoCoordinatorStatus, String, Option<String>)> {
|
) -> Result<(AutoCoordinatorStatus, String, Option<String>, Vec<ResponseItem>)> {
|
||||||
let raw = request_decision(
|
let (raw, response_items) = request_decision(
|
||||||
runtime,
|
runtime,
|
||||||
client,
|
client,
|
||||||
developer_intro,
|
developer_intro,
|
||||||
@@ -455,7 +462,12 @@ fn request_coordinator_decision(
|
|||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok((status, decision.summary.trim().to_string(), prompt_opt))
|
Ok((
|
||||||
|
status,
|
||||||
|
decision.summary.trim().to_string(),
|
||||||
|
prompt_opt,
|
||||||
|
response_items,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn request_decision(
|
fn request_decision(
|
||||||
@@ -467,7 +479,7 @@ fn request_decision(
|
|||||||
conversation: &[ResponseItem],
|
conversation: &[ResponseItem],
|
||||||
app_event_tx: &AppEventSender,
|
app_event_tx: &AppEventSender,
|
||||||
cancel_token: &CancellationToken,
|
cancel_token: &CancellationToken,
|
||||||
) -> Result<String> {
|
) -> Result<(String, Vec<ResponseItem>)> {
|
||||||
match request_decision_with_model(
|
match request_decision_with_model(
|
||||||
runtime,
|
runtime,
|
||||||
client,
|
client,
|
||||||
@@ -479,7 +491,7 @@ fn request_decision(
|
|||||||
cancel_token,
|
cancel_token,
|
||||||
MODEL_SLUG,
|
MODEL_SLUG,
|
||||||
) {
|
) {
|
||||||
Ok(output) => Ok(output),
|
Ok(result) => Ok((result.output_text, result.response_items)),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let fallback_slug = client.default_model_slug().to_string();
|
let fallback_slug = client.default_model_slug().to_string();
|
||||||
if fallback_slug != MODEL_SLUG && should_retry_with_default_model(&err) {
|
if fallback_slug != MODEL_SLUG && should_retry_with_default_model(&err) {
|
||||||
@@ -500,6 +512,7 @@ fn request_decision(
|
|||||||
cancel_token,
|
cancel_token,
|
||||||
&fallback_slug,
|
&fallback_slug,
|
||||||
)
|
)
|
||||||
|
.map(|res| (res.output_text, res.response_items))
|
||||||
.map_err(|fallback_err| {
|
.map_err(|fallback_err| {
|
||||||
fallback_err.context(format!(
|
fallback_err.context(format!(
|
||||||
"coordinator fallback with model '{}' failed after original error: {}",
|
"coordinator fallback with model '{}' failed after original error: {}",
|
||||||
@@ -522,7 +535,7 @@ fn request_decision_with_model(
|
|||||||
app_event_tx: &AppEventSender,
|
app_event_tx: &AppEventSender,
|
||||||
cancel_token: &CancellationToken,
|
cancel_token: &CancellationToken,
|
||||||
model_slug: &str,
|
model_slug: &str,
|
||||||
) -> Result<String> {
|
) -> Result<RequestStreamResult> {
|
||||||
let developer_intro = developer_intro.to_string();
|
let developer_intro = developer_intro.to_string();
|
||||||
let primary_goal = primary_goal.to_string();
|
let primary_goal = primary_goal.to_string();
|
||||||
let schema = schema.clone();
|
let schema = schema.clone();
|
||||||
@@ -551,31 +564,43 @@ fn request_decision_with_model(
|
|||||||
}
|
}
|
||||||
let mut stream = client.stream(&prompt).await?;
|
let mut stream = client.stream(&prompt).await?;
|
||||||
let mut out = String::new();
|
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 {
|
while let Some(ev) = stream.next().await {
|
||||||
match ev {
|
match ev {
|
||||||
Ok(ResponseEvent::OutputTextDelta { delta, .. }) => out.push_str(&delta),
|
Ok(ResponseEvent::OutputTextDelta { delta, .. }) => {
|
||||||
|
out.push_str(&delta);
|
||||||
|
}
|
||||||
Ok(ResponseEvent::OutputItemDone { item, .. }) => {
|
Ok(ResponseEvent::OutputItemDone { item, .. }) => {
|
||||||
if let ResponseItem::Message { content, .. } = item {
|
if let ResponseItem::Message { content, .. } = &item {
|
||||||
for c in content {
|
for c in content {
|
||||||
if let ContentItem::OutputText { text } = c {
|
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 {
|
Ok(ResponseEvent::ReasoningSummaryDelta {
|
||||||
delta,
|
delta,
|
||||||
summary_index,
|
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 {
|
tx_inner.send(AppEvent::AutoCoordinatorThinking {
|
||||||
delta: message,
|
delta: message,
|
||||||
summary_index,
|
summary_index,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(ResponseEvent::ReasoningContentDelta { delta, .. }) => {
|
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 {
|
tx_inner.send(AppEvent::AutoCoordinatorThinking {
|
||||||
delta: message,
|
delta: message,
|
||||||
summary_index: None,
|
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,
|
classify,
|
||||||
|
|||||||
@@ -1,29 +1,78 @@
|
|||||||
use codex_protocol::models::ResponseItem;
|
use codex_protocol::models::ResponseItem;
|
||||||
|
|
||||||
/// Maintains the Auto Drive conversation transcript between coordinator turns.
|
/// 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 {
|
pub(crate) struct AutoDriveHistory {
|
||||||
items: Vec<ResponseItem>,
|
converted: Vec<ResponseItem>,
|
||||||
|
raw: Vec<ResponseItem>,
|
||||||
|
pending_skip: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AutoDriveHistory {
|
impl AutoDriveHistory {
|
||||||
pub(crate) fn new() -> Self {
|
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.
|
/// Replace the stored converted transcript. Returns any new tail items that
|
||||||
pub(crate) fn replace(&mut self, items: Vec<ResponseItem>) {
|
/// were not present previously, preserving insertion order.
|
||||||
self.items = items;
|
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> {
|
pub(crate) fn append_raw(&mut self, items: &[ResponseItem]) {
|
||||||
self.items.clone()
|
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) {
|
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 {
|
pub(crate) fn converted_is_empty(&self) -> bool {
|
||||||
self.items.is_empty()
|
self.converted.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(not(test), allow(dead_code))]
|
||||||
|
pub(crate) fn raw_items(&self) -> &[ResponseItem] {
|
||||||
|
&self.raw
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1038,6 +1038,7 @@ fn auto_decision_persists_summary_through_cli_cycle() {
|
|||||||
AutoCoordinatorStatus::Continue,
|
AutoCoordinatorStatus::Continue,
|
||||||
"**Plan:** run tests".to_string(),
|
"**Plan:** run tests".to_string(),
|
||||||
Some("run tests".to_string()),
|
Some("run tests".to_string()),
|
||||||
|
Vec::new(),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(chat.auto_state.current_display_line.as_deref(), Some("Plan:"));
|
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:"));
|
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(
|
fn pump_app_events(
|
||||||
chat: &mut ChatWidget<'_>,
|
chat: &mut ChatWidget<'_>,
|
||||||
rx: &std::sync::mpsc::Receiver<AppEvent>,
|
rx: &std::sync::mpsc::Receiver<AppEvent>,
|
||||||
|
|||||||
Reference in New Issue
Block a user