mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
feat(auto-drive): restructure coordinator transcript for cli roles
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user