feat(auto-drive): restructure coordinator transcript for cli roles

This commit is contained in:
James Peter
2025-09-30 11:21:04 +10:00
parent 4488b6cb6c
commit 4733856535
3 changed files with 417 additions and 9 deletions
+213 -2
View File
@@ -232,6 +232,8 @@ use crate::history_cell::HistoryCell;
use crate::history_cell::HistoryCellType;
use crate::history_cell::PatchEventType;
use crate::history_cell::PlainHistoryCell;
use crate::history_cell::PlanUpdateCell;
use crate::history_cell::DiffCell;
use crate::history::state::PatchEventType as HistoryPatchEventType;
use crate::history::state::{
HistoryDomainEvent,
@@ -240,6 +242,8 @@ use crate::history::state::{
AssistantMessageState,
AssistantStreamDelta,
AssistantStreamState,
DiffRecord,
DiffLineKind,
HistoryEvent,
HistoryId,
HistoryMutation,
@@ -3176,6 +3180,213 @@ impl ChatWidget<'_> {
w
}
fn auto_drive_lines_to_string(lines: Vec<Line<'static>>) -> String {
let mut rows: Vec<String> = Vec::new();
for line in lines {
let mut row = String::new();
for span in line.spans {
row.push_str(span.content.as_ref());
}
rows.push(row);
}
while rows
.last()
.map(|line| line.trim().is_empty())
.unwrap_or(false)
{
rows.pop();
}
rows.join("\n")
}
fn auto_drive_cell_text(cell: &dyn HistoryCell) -> Option<String> {
let text = Self::auto_drive_lines_to_string(cell.display_lines());
if text.trim().is_empty() {
None
} else {
Some(text)
}
}
fn auto_drive_make_user_message(
text: String,
) -> Option<codex_protocol::models::ResponseItem> {
if text.trim().is_empty() {
return None;
}
use codex_protocol::models::{ContentItem, ResponseItem};
Some(ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText { text }],
})
}
fn auto_drive_make_assistant_message(
text: String,
) -> Option<codex_protocol::models::ResponseItem> {
if text.trim().is_empty() {
return None;
}
use codex_protocol::models::{ContentItem, ResponseItem};
Some(ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText { text }],
})
}
fn auto_drive_normalize_diff_path(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed == "/dev/null" {
return None;
}
let normalized = trimmed
.strip_prefix("a/")
.or_else(|| trimmed.strip_prefix("b/"))
.unwrap_or(trimmed);
Some(normalized.to_string())
}
fn auto_drive_diff_summary(record: &DiffRecord) -> Option<String> {
use std::collections::BTreeMap;
let mut stats: BTreeMap<String, (u32, u32)> = BTreeMap::new();
let mut current_file: Option<String> = None;
for hunk in &record.hunks {
for line in &hunk.lines {
match line.kind {
DiffLineKind::Context => {
let content = line.content.trim();
if let Some(rest) = content.strip_prefix("diff --git ") {
let mut parts = rest.split_whitespace();
let _old = parts.next();
if let Some(new_path) = parts.next() {
if let Some(path) = Self::auto_drive_normalize_diff_path(new_path) {
stats.entry(path.clone()).or_insert((0, 0));
current_file = Some(path);
}
}
} else {
if let Some(rest) = content.strip_prefix("--- ") {
if let Some(path) = Self::auto_drive_normalize_diff_path(rest) {
stats.entry(path.clone()).or_insert((0, 0));
current_file = Some(path);
}
}
if let Some(rest) = content.strip_prefix("+++ ") {
if let Some(path) = Self::auto_drive_normalize_diff_path(rest) {
stats.entry(path.clone()).or_insert((0, 0));
current_file = Some(path);
}
}
}
}
DiffLineKind::Addition => {
if let Some(file) = current_file.as_ref() {
let entry = stats.entry(file.clone()).or_insert((0, 0));
entry.0 += 1;
}
}
DiffLineKind::Removal => {
if let Some(file) = current_file.as_ref() {
let entry = stats.entry(file.clone()).or_insert((0, 0));
entry.1 += 1;
}
}
}
}
}
if stats.is_empty() {
return None;
}
let mut lines = Vec::with_capacity(stats.len() + 1);
lines.push("Files changed".to_string());
for (path, (added, removed)) in stats {
lines.push(format!("- {} (+{} / -{})", path, added, removed));
}
Some(lines.join("\n"))
}
pub(crate) fn export_auto_drive_items(&self) -> Vec<codex_protocol::models::ResponseItem> {
let mut items = Vec::new();
for cell in &self.history_cells {
match cell.kind() {
HistoryCellType::User => {
if let Some(text) = Self::auto_drive_cell_text(cell.as_ref()) {
if let Some(item) = Self::auto_drive_make_assistant_message(text) {
items.push(item);
}
}
}
HistoryCellType::Assistant => {
if let Some(text) = Self::auto_drive_cell_text(cell.as_ref()) {
if let Some(item) = Self::auto_drive_make_user_message(text) {
items.push(item);
}
}
}
HistoryCellType::Reasoning => {
if let Some(text) = Self::auto_drive_cell_text(cell.as_ref()) {
use codex_protocol::models::{ContentItem, ResponseItem};
items.push(ResponseItem::Message {
id: Some("auto-drive-reasoning".to_string()),
role: "user".to_string(),
content: vec![ContentItem::InputText { text }],
});
}
}
HistoryCellType::PlanUpdate => {
if let Some(plan) = cell.as_any().downcast_ref::<PlanUpdateCell>() {
let state = plan.state();
let mut lines: Vec<String> = Vec::new();
if !state.name.trim().is_empty() {
lines.push(format!("Plan update: {}", state.name.trim()));
} else {
lines.push("Plan update".to_string());
}
if state.progress.total > 0 {
lines.push(format!(
"Progress: {}/{}",
state.progress.completed, state.progress.total
));
}
if state.steps.is_empty() {
lines.push("(no steps recorded)".to_string());
} else {
for step in &state.steps {
let status_label = match step.status {
StepStatus::Completed => "[completed]",
StepStatus::InProgress => "[in_progress]",
StepStatus::Pending => "[pending]",
};
lines.push(format!("{} {}", status_label, step.description));
}
}
let text = lines.join("\n");
if let Some(item) = Self::auto_drive_make_user_message(text) {
items.push(item);
}
}
}
HistoryCellType::Diff => {
if let Some(diff_cell) = cell.as_any().downcast_ref::<DiffCell>() {
if let Some(summary) = Self::auto_drive_diff_summary(diff_cell.record()) {
if let Some(item) = Self::auto_drive_make_user_message(summary) {
items.push(item);
}
}
}
}
_ => {}
}
}
items
}
/// Export current user/assistant messages into ResponseItem list for forking.
pub(crate) fn export_response_items(&self) -> Vec<codex_protocol::models::ResponseItem> {
use codex_protocol::models::ContentItem;
@@ -9792,7 +10003,7 @@ impl ChatWidget<'_> {
self.auto_stop(None);
}
let conversation = self.export_response_items();
let conversation = self.export_auto_drive_items();
match start_auto_coordinator(
self.app_event_tx.clone(),
goal_text.clone(),
@@ -9834,7 +10045,7 @@ impl ChatWidget<'_> {
let Some(handle) = self.auto_handle.as_ref() else {
return;
};
let conversation = self.export_response_items();
let conversation = self.export_auto_drive_items();
if handle
.send(AutoCoordinatorCommand::UpdateConversation(conversation))
.is_err()
+59 -7
View File
@@ -363,23 +363,75 @@ fn summarize_intervention(
// Helper so observer can append the coordinator's latest prompt.
pub(super) fn build_observer_conversation(
mut conversation: Vec<ResponseItem>,
conversation: Vec<ResponseItem>,
coordinator_prompt: Option<&str>,
) -> Vec<ResponseItem> {
let mut filtered: Vec<ResponseItem> = Vec::new();
for item in conversation {
match item {
ResponseItem::Message { id, role, content } => {
if id.as_deref() == Some("auto-drive-reasoning") {
continue;
}
if role == "assistant" {
let mut new_content: Vec<ContentItem> = Vec::new();
for entry in content {
match entry {
ContentItem::InputText { text } => {
let prefixed = if text.trim_start().starts_with("Coordinator:") {
text
} else {
format!("Coordinator: {text}")
};
new_content.push(ContentItem::InputText { text: prefixed });
}
ContentItem::OutputText { text } => {
let prefixed = if text.trim_start().starts_with("Coordinator:") {
text
} else {
format!("Coordinator: {text}")
};
new_content.push(ContentItem::InputText { text: prefixed });
}
other => new_content.push(other),
}
}
filtered.push(ResponseItem::Message {
id: None,
role: "user".to_string(),
content: new_content,
});
} else {
filtered.push(ResponseItem::Message { id, role, content });
}
}
ResponseItem::Reasoning { .. } => {
// Observer should not inspect reasoning blocks.
continue;
}
other => filtered.push(other),
}
}
if let Some(prompt) = coordinator_prompt.and_then(|p| {
let trimmed = p.trim();
(!trimmed.is_empty()).then_some(trimmed)
}) {
let content = ContentItem::OutputText {
text: format!("Coordinator: {prompt}"),
let text = if prompt.trim_start().starts_with("Coordinator:") {
prompt.to_string()
} else {
format!("Coordinator: {prompt}")
};
conversation.push(ResponseItem::Message {
filtered.push(ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![content],
role: "user".to_string(),
content: vec![ContentItem::InputText { text }],
});
}
conversation
filtered
}
#[cfg(test)]
+145
View File
@@ -12,6 +12,7 @@ use crate::app_event_sender::AppEventSender;
use crate::history::state::{ExecStatus, ExploreEntryStatus, HistoryId, HistoryRecord, HistoryState};
use crate::slash_command::SlashCommand;
use super::auto_coordinator::AutoCoordinatorHandle;
use super::auto_observer::build_observer_conversation;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigToml;
@@ -45,6 +46,7 @@ use codex_core::protocol::InputItem;
use codex_core::protocol::Op;
use codex_core::protocol::StreamErrorEvent;
use codex_core::protocol::TaskCompleteEvent;
use codex_protocol::models::{ContentItem, ResponseItem};
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
@@ -2365,6 +2367,149 @@ fn two_final_answers_append_not_overwrite_when_no_deltas() {
assert!(assistants.iter().any(|s| s.contains("Second message")), "missing second message");
}
fn message_text(item: &ResponseItem) -> Option<String> {
if let ResponseItem::Message { content, .. } = item {
let mut out = String::new();
for chunk in content {
match chunk {
ContentItem::InputText { text } | ContentItem::OutputText { text } => {
out.push_str(text);
}
_ => {}
}
}
Some(out)
} else {
None
}
}
#[test]
fn export_auto_drive_items_includes_cli_outputs() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
chat.history_push(history_cell::new_user_prompt("Run integration tests".to_string()));
chat.insert_final_answer_with_id(None, Vec::new(), "{\"status\":\"ok\"}".to_string());
let _ = drain_insert_history(&rx);
let mut reasoning_cell = history_cell::CollapsibleReasoningCell::new_with_id(
vec![ratatui::text::Line::from("Considering optimal workflow")],
Some("reasoning-test".to_string()),
);
reasoning_cell.set_collapsed(false);
reasoning_cell.set_in_progress(false);
chat.history_push(reasoning_cell);
let plan_update = UpdatePlanArgs {
name: Some("Stabilize build".to_string()),
plan: vec![
PlanItemArg {
step: "Install dependencies".to_string(),
status: StepStatus::Completed,
},
PlanItemArg {
step: "Run integration tests".to_string(),
status: StepStatus::InProgress,
},
],
};
chat.history_push(history_cell::new_plan_update(plan_update));
let diff = "diff --git a/foo.rs b/foo.rs\n--- a/foo.rs\n+++ b/foo.rs\n@@ -1 +1,2 @@\n-println!(\"old\");\n+println!(\"new\");\n+println!(\"extra\");\n";
chat.add_diff_output(diff.to_string());
let items = chat.export_auto_drive_items();
let coordinator = items
.iter()
.find(|item| matches!(item, ResponseItem::Message { role, .. } if role == "assistant"))
.and_then(message_text)
.expect("coordinator message present");
assert!(
coordinator.contains("Run integration tests"),
"coordinator text missing"
);
let cli = items
.iter()
.find(|item| {
matches!(
item,
ResponseItem::Message { role, content, .. }
if role == "user"
&& content.iter().any(|c| matches!(
c,
ContentItem::InputText { text }
if text.contains("\"status\":\"ok\"")
))
)
})
.expect("cli response included");
assert!(matches!(cli, ResponseItem::Message { .. }));
assert!(items.iter().any(|item| {
matches!(item, ResponseItem::Message { id, .. } if id.as_deref() == Some("auto-drive-reasoning"))
}), "reasoning message tagged");
assert!(items.iter().any(|item| {
message_text(item)
.map(|text| text.contains("Plan update") && text.contains("[in_progress]"))
.unwrap_or(false)
}), "plan update summary present");
assert!(items.iter().any(|item| {
message_text(item)
.map(|text| text.contains("Files changed") && text.contains("foo.rs"))
.unwrap_or(false)
}), "diff summary present");
}
#[test]
fn observer_conversation_filters_reasoning_and_prefixes() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
chat.history_push(history_cell::new_user_prompt("Summarize recent commits".to_string()));
chat.insert_final_answer_with_id(None, Vec::new(), "CLI summary".to_string());
let _ = drain_insert_history(&rx);
let mut reasoning_cell = history_cell::CollapsibleReasoningCell::new_with_id(
vec![ratatui::text::Line::from("Inspecting commit history")],
Some("reasoning-test".to_string()),
);
reasoning_cell.set_collapsed(false);
reasoning_cell.set_in_progress(false);
chat.history_push(reasoning_cell);
let items = chat.export_auto_drive_items();
assert!(items.iter().any(|item| matches!(
item,
ResponseItem::Message { role, .. } if role == "assistant"
)));
let observer_items = build_observer_conversation(items, Some("Queue next review"));
assert!(observer_items.iter().all(|item| {
!matches!(item, ResponseItem::Message { role, .. } if role == "assistant")
}), "assistant roles converted to user");
assert!(observer_items.iter().all(|item| {
!matches!(item, ResponseItem::Message { id, .. } if id.as_deref() == Some("auto-drive-reasoning"))
}), "reasoning entries removed");
assert!(observer_items.iter().any(|item| {
message_text(item)
.map(|text| text.contains("Coordinator: Summarize recent commits"))
.unwrap_or(false)
}), "coordinator text prefixed");
assert!(observer_items.iter().any(|item| {
message_text(item)
.map(|text| text.contains("Coordinator: Queue next review"))
.unwrap_or(false)
}), "appended prompt prefixed");
}
#[test]
fn export_preserves_user_role_and_content_type() {
let (mut chat, rx, _op_rx) = make_chatwidget_manual();