working plan mode

This commit is contained in:
taggart_comet 2026-01-28 19:57:41 +02:00
parent 3951dc4df2
commit 7269d20c56
14 changed files with 363 additions and 65 deletions

Binary file not shown.

View file

@ -16,6 +16,10 @@ class Cat:
return f"{self.name} purrs loudly."
return f"{self.name} tolerates the petting."
def begs(self) -> str:
self.mood = "needy"
return f"{self.name} begs for snacks with big, dramatic eyes."
def describe(self) -> str:
return f"{self.name} is a {self.age} year old cat feeling {self.mood}."

View file

@ -41,6 +41,14 @@ def test_describe():
assert "neutral" in desc
def test_begs_changes_mood():
cat = Cat("Nala", 3)
result = cat.begs()
assert cat.mood == "needy"
assert "begs" in result
assert "snacks" in result
def test_create_cat_helper():
cat = create_cat("Simba", 2)
assert isinstance(cat, Cat)
@ -55,6 +63,7 @@ def run_all_tests() -> None:
test_pet_when_neutral,
test_pet_when_happy,
test_describe,
test_begs_changes_mood,
test_create_cat_helper,
]

View file

@ -8,29 +8,7 @@ pub fn get_user_prompt(_model_type: ModelType, request: &dyn Request) -> String
if request.mode() == AgentModeType::BuildFromPlan {
return _get_build_from_plan_prompt(request);
}
let mut formatted = String::new();
// Add history section
formatted.push_str("previous requests history:\n");
if request.history().is_empty() {
formatted.push_str(" (no previous requests)\n");
} else {
for req in request.history() {
formatted.push_str(&format!(" - request: {}\n", req.prompt()));
if let Some(result) = req.result_summary() {
formatted.push_str(&format!(" result: {}\n", result));
} else {
formatted.push_str(" result: (no result yet)\n");
}
}
}
// Add current request
formatted.push_str(&format!("CURRENT REQUEST: {}", request.current_request()));
formatted
request.current_request().to_string()
}
fn _get_build_from_plan_prompt(request: &dyn Request) -> String {
@ -38,11 +16,11 @@ fn _get_build_from_plan_prompt(request: &dyn Request) -> String {
for item in &todo_list.items {
if item.status != TodoListStatus::Completed {
return format!(
"Title: {}\n\nDescription:\n{}",
"TODO Item Title: {}\n\nDescription:\n{}",
item.title, item.description
);
}
}
}
"All TODO items are completed.".to_string()
"Report very briefly what was done without using any tools, since all the TODO items are completed.".to_string()
}

View file

@ -88,13 +88,6 @@ impl Workflow {
self.chain.add_history(request.get_history_steps());
self.chain.set_todo_list(request.get_session_plan());
// Add current user message as a step
let user_step = ChainStep::user_message(
request.current_request().to_string(),
request.images().to_vec(),
);
self.chain.steps.push(user_step);
let result = self._run(request, cancel, max_tool_calls, None);
self._end_tracing();
result

View file

@ -3,6 +3,7 @@ use crate::domain::{Chain, ModelType};
use crate::domain::prompting::format_todo_list_message;
use serde::Serialize;
use serde_json::Value;
use crate::domain::workflow::step::StepType::{AssistantResponse, ToolCall, UserMessage};
#[derive(Debug, Serialize)]
pub struct RequestDTO {
@ -61,6 +62,13 @@ impl InputContent {
image_url: data_url,
}
}
pub fn function(text: String) -> Self {
Self::Text {
kind: "function".to_string(),
text,
}
}
}
#[derive(Debug, Serialize)]
@ -74,18 +82,21 @@ pub(super) struct ToolDto {
const ROLE_USER: &str = "user";
const ROLE_SYSTEM: &str = "system";
const ROLE_ASSISTANT: &str = "assistant";
const INPUT_STATUS_IN_PROGRESS: &str = "in_progress";
const INPUT_STATUS_COMPLETED: &str = "completed";
const INPUT_STATUS_FAILED: &str = "failed";
impl RequestDTO {
pub(crate) fn new(
model: String,
system_prompt: String,
_user_prompt: String,
_images: Vec<String>,
user_prompt: String,
tools: &[&dyn Tool],
chain: &crate::domain::workflow::Chain,
) -> Self {
// User request is now part of the chain, no need to add separately
let input = InputDto::from_chain(chain);
let input = InputDto::build(user_prompt, chain);
Self {
model,
@ -113,40 +124,26 @@ impl ToolDto {
}
impl InputDto {
fn from_chain(chain: &Chain) -> Vec<Self> {
fn build(user_prompt: String, chain: &Chain) -> Vec<Self> {
let steps = chain.get_steps_with_history();
let num_steps = steps.len();
let mut result: Vec<Self> = steps
.iter()
.enumerate()
.map(|(idx, step)| {
// Determine status
let is_last_step = idx == num_steps - 1;
let is_user_message = step.step_type == "user_message";
let is_user_message = step.step_type == UserMessage.as_str();
let status = if is_last_step && is_user_message {
// Current user message is in progress
"in_progress"
} else if step.is_successful.unwrap_or(false) {
"completed"
let status = if is_user_message || step.is_successful.unwrap_or(false) {
INPUT_STATUS_COMPLETED
} else {
"failed"
};
// Determine role based on step type
let role = if step.step_type == "assistant_response" {
"assistant".to_string()
} else if is_user_message {
ROLE_USER.to_string()
} else {
ROLE_SYSTEM.to_string() // Tool results remain "system"
INPUT_STATUS_FAILED
};
let mut role = ROLE_ASSISTANT.to_string();
// Build content items
let content_items = if step.step_type == "assistant_response" {
vec![InputContent::output_text(step.get_output(ModelType::OpenAI))]
} else if is_user_message {
let content_items = if is_user_message {
role = ROLE_USER.to_string();
// For user messages, include text and images
let mut items = vec![InputContent::text(step.input_payload.clone())];
@ -159,7 +156,7 @@ impl InputDto {
items
} else {
vec![InputContent::text(step.get_output(ModelType::OpenAI))]
vec![InputContent::output_text(step.get_output(ModelType::OpenAI))]
};
Self {
@ -183,12 +180,20 @@ impl InputDto {
content: vec![InputContent::text(todo_message)],
role: ROLE_SYSTEM.to_string(),
kind: "message".to_string(),
status: "completed".to_string(),
status: INPUT_STATUS_COMPLETED.to_string(),
};
result.push(todo_input);
}
}
// and adding the current user message at the end
result.push(Self {
content: vec![InputContent::text(user_prompt)],
role: ROLE_USER.to_string(),
kind: "message".to_string(),
status: INPUT_STATUS_IN_PROGRESS.to_string(),
});
result
}
}

View file

@ -27,7 +27,6 @@ pub fn build_request_dto(
model.to_string(),
system_prompt.to_string(),
user_prompt.to_string(),
images.to_vec(),
tools,
chain,
);

View file

@ -27,6 +27,10 @@ pub fn commands_items() -> Vec<CommandsMenuItem> {
label: "Settings",
description: "Toggle behavior trees and tracing",
},
CommandsMenuItem {
label: "Continue",
description: "Resume a previous session",
},
CommandsMenuItem {
label: "Exit",
description: "Close the application",

View file

@ -8,8 +8,8 @@ use crate::infrastructure::cli::helpers::{
delete_next_char, delete_prev_char, insert_char, next_char_boundary, prev_char_boundary,
};
use crate::infrastructure::cli::state::{
FileChangesViewMode, LoadStatus, PopupInput, PopupState, TodoListViewMode, UiMode, UiState,
MAIN_BODY_SCROLL_STEP,
FileChangesViewMode, LoadStatus, PopupInput, PopupState, SessionPreview, TodoListViewMode,
UiMode, UiState, MAIN_BODY_SCROLL_STEP,
};
use crate::infrastructure::cli::views::main_view::{
model_entries, openai_available_entries, openai_available_filtered,
@ -336,7 +336,8 @@ fn handle_commands_key(
0 => select_openai_model::open_model_popup(conn, state),
1 => open_mode_popup(state),
2 => change_settings::open_settings_popup(conn, state),
3 => request_exit(bus, state),
3 => open_continue_popup(conn, state),
4 => request_exit(bus, state),
_ => state.mode = UiMode::Normal,
},
_ => {}
@ -607,6 +608,31 @@ fn handle_popup_key(
_ => {}
}
}
PopupState::ContinueSelect { sessions, selected } => {
let count = sessions.len();
match key.code {
KeyCode::Esc => state.mode = UiMode::Normal,
KeyCode::Up => {
if *selected > 0 {
*selected -= 1;
}
}
KeyCode::Down => {
if *selected + 1 < count {
*selected += 1;
}
}
KeyCode::Enter => {
let session_id = sessions[*selected].id;
state.session_id = Some(session_id);
let _ = bus.ui_to_agent_tx.send(UiToAgentEvent::SessionContinueEvent {
session_id,
});
state.mode = UiMode::Normal;
}
_ => {}
}
}
}
Ok(())
@ -1242,6 +1268,9 @@ fn submit_input(bus: &EventBus, conn: &DbPool, state: &mut UiState) -> Result<()
":m" | ":model" | "/m" | "/model" => {
select_openai_model::open_model_popup(conn, state);
}
"/continue" | "/c" => {
open_continue_popup(conn, state);
}
_ => {
let prompt = value.trim_end().to_string();
@ -1258,7 +1287,8 @@ fn submit_input(bus: &EventBus, conn: &DbPool, state: &mut UiState) -> Result<()
.send(UiToAgentEvent::RequestEvent {
prompt,
images,
mode: state.agent_mode, // Pass current mode
mode: state.agent_mode,
session_id: state.session_id,
})
.map_err(|e| e.to_string())?;
}
@ -1268,6 +1298,79 @@ fn submit_input(bus: &EventBus, conn: &DbPool, state: &mut UiState) -> Result<()
Ok(())
}
fn open_continue_popup(conn: &DbPool, state: &mut UiState) {
let result = (|| -> Result<(), String> {
let conn_guard = conn
.get()
.map_err(|e| format!("Failed to get connection: {}", e))?;
let project_name = std::env::current_dir()
.ok()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
.unwrap_or_else(|| "default".to_string());
let projects_repo =
crate::repository::ProjectsRepository::new(&*conn_guard);
let project = projects_repo
.find_by_name(&project_name)?
.ok_or_else(|| "not_found".to_string())?;
let sessions_repo =
crate::repository::SessionsRepository::new(&*conn_guard);
let rows = sessions_repo.find_by_project_recent(project.id, 5)?;
if rows.is_empty() {
return Err("no_sessions".to_string());
}
let sessions: Vec<SessionPreview> = rows
.into_iter()
.map(|row| {
let epoch: u64 = row.created_at.parse().unwrap_or(0);
SessionPreview {
id: row.id,
name: row.name,
created_at: crate::infrastructure::cli::state::format_relative_time(epoch),
}
})
.collect();
state.mode = UiMode::Popup(PopupState::ContinueSelect {
sessions,
selected: 0,
});
Ok(())
})();
match result {
Ok(()) => {}
Err(e) if e == "not_found" => {
state.push_progress(crate::infrastructure::cli::state::ProgressEntry {
text: "No project found for current directory".to_string(),
kind: crate::infrastructure::cli::state::ProgressKind::Error,
active: false,
request_id: None,
});
}
Err(e) if e == "no_sessions" => {
state.push_progress(crate::infrastructure::cli::state::ProgressEntry {
text: "No previous sessions in this project".to_string(),
kind: crate::infrastructure::cli::state::ProgressKind::Info,
active: false,
request_id: None,
});
}
Err(e) => {
state.push_progress(crate::infrastructure::cli::state::ProgressEntry {
text: format!("Error loading sessions: {}", e),
kind: crate::infrastructure::cli::state::ProgressKind::Error,
active: false,
request_id: None,
});
}
}
}
fn open_mode_popup(state: &mut UiState) {
let selected = match state.agent_mode {
AgentModeType::Build => 0,

View file

@ -197,6 +197,46 @@ pub enum PopupState {
scope: String,
selected: usize,
},
ContinueSelect {
sessions: Vec<SessionPreview>,
selected: usize,
},
}
#[derive(Debug, Clone)]
pub struct SessionPreview {
pub id: i64,
pub name: String,
pub created_at: String,
}
pub fn format_relative_time(epoch_secs: u64) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let delta = now.saturating_sub(epoch_secs);
if delta < 60 {
return "just now".to_string();
}
let mins = delta / 60;
if mins < 60 {
return format!("{} min ago", mins);
}
let hours = mins / 60;
if hours < 24 {
return format!("{} hr ago", hours);
}
let days = hours / 24;
if days < 7 {
return format!("{} days ago", days);
}
let weeks = days / 7;
if weeks < 5 {
return format!("{} weeks ago", weeks);
}
let months = days / 30;
format!("{} months ago", months)
}
pub struct UiState {
@ -222,6 +262,7 @@ pub struct UiState {
pub todo_list: Option<TodoListDisplay>,
pub should_quit: bool,
pub attached_images: Vec<AttachedImage>,
pub session_id: Option<i64>,
}
impl UiState {
@ -260,6 +301,7 @@ impl UiState {
todo_list: None,
should_quit: false,
attached_images: Vec::new(),
session_id: None,
}
}

View file

@ -434,6 +434,58 @@ fn render_popup(
theme,
);
}
PopupState::ContinueSelect { sessions, selected } => {
if sessions.is_empty() {
return;
}
let height = (sessions.len() + 4) as u16;
let width = min(
(size.width as f32 * 0.7) as u16,
size.width.saturating_sub(2),
);
let height = height.min(size.height.saturating_sub(2));
let area = centered_rect(size, width, height);
frame.render_widget(Clear, area);
let inner = area.inner(&ratatui::layout::Margin {
horizontal: 1,
vertical: 1,
});
let sections = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(2), Constraint::Min(1)])
.split(inner);
let title = Paragraph::new(Text::from("Continue Session"))
.style(
Style::default()
.fg(theme.info_text)
.add_modifier(Modifier::BOLD),
)
.alignment(Alignment::Center);
frame.render_widget(title, sections[0]);
let items: Vec<ListItem> = sessions
.iter()
.map(|s| {
let label = format!("{} ({})", s.name, s.created_at);
ListItem::new(Line::from(Span::styled(
label,
Style::default().fg(theme.info_text),
)))
})
.collect();
let list = List::new(items)
.highlight_style(
Style::default()
.fg(theme.active)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("> ");
let mut list_state = list_state(*selected);
frame.render_stateful_widget(list, sections[1], &mut list_state);
}
}
}

View file

@ -46,6 +46,10 @@ pub enum UiToAgentEvent {
prompt: String,
images: Vec<ImageAttachment>,
mode: AgentModeType, // NEW: Pass mode to agent
session_id: Option<i64>,
},
SessionContinueEvent {
session_id: i64,
},
PermissionUpdateEvent {
request_id: u64,

View file

@ -131,11 +131,92 @@ impl EventController {
};
match event {
UiToAgentEvent::RequestEvent { prompt, images, mode } => {
UiToAgentEvent::SessionContinueEvent { session_id } => {
if worker_running {
send_failure_and_continue!(self, "Request already running");
}
self.current_session_id = Some(session_id);
let conn_guard = match self.conn.get() {
Ok(guard) => guard,
Err(e) => {
send_failure_and_continue!(self, format!("Failed to get connection: {}", e));
}
};
let sessions_repo = crate::repository::SessionsRepository::new(&*conn_guard);
let session_row = match sessions_repo.find_by_id(session_id) {
Ok(Some(row)) => row,
Ok(None) => {
send_failure_and_continue!(self, format!("Session {} not found", session_id));
}
Err(e) => {
send_failure_and_continue!(self, e);
}
};
let _ = self.bus.agent_to_ui_tx.send(AgentToUiEvent::SessionStartedEvent {
title: session_row.name.clone(),
});
drop(conn_guard);
let requests_repo = crate::repository::SessionRequestsRepository::new(self.conn.clone());
let requests = match requests_repo.find_by_session(session_id) {
Ok(rows) => rows,
Err(e) => {
send_failure_and_continue!(self, e);
}
};
if let Some(last_request) = requests.last() {
let label = request_label(&last_request.prompt);
let status = match &last_request.result_summary {
Some(summary) if summary.starts_with("Success") => RequestStatus::Success,
Some(_) => RequestStatus::Failure,
None => RequestStatus::Failure,
};
let summary = last_request.result_summary.clone();
let _ = self.bus.agent_to_ui_tx.send(AgentToUiEvent::RequestStartedEvent {
request_id: 0,
label,
prompt: last_request.prompt.clone(),
});
let _ = self.bus.agent_to_ui_tx.send(AgentToUiEvent::RequestFinishedEvent {
request_id: 0,
status,
summary,
final_message: None,
});
}
// Load TODO list if it exists and is not fully completed
let conn_guard2 = match self.conn.get() {
Ok(guard) => guard,
Err(_) => continue,
};
let todo_repo = crate::repository::TodoListRepository::new(&*conn_guard2);
if let Ok(Some(todo_row)) = todo_repo.get_by_session(session_id) {
if let Ok(todo_list) = serde_json::from_str::<domain::todo::TodoList>(&todo_row.content) {
if !todo_list.items.is_empty() && !todo_list.is_completed() {
let _ = self.bus.agent_to_ui_tx.send(AgentToUiEvent::TodoListUpdateEvent {
items: todo_list.items,
});
}
}
}
}
UiToAgentEvent::RequestEvent { prompt, images, mode, session_id } => {
if worker_running {
send_failure_and_continue!(self, "Request already running");
}
if let Some(sid) = session_id {
self.current_session_id = Some(sid);
}
let engine = match self.engine.clone() {
Some(engine) => engine,
None => {

View file

@ -62,6 +62,30 @@ impl<'a> SessionsRepository<'a> {
Ok(result)
}
pub fn find_by_project_recent(
&self,
project_id: i64,
limit: usize,
) -> Result<Vec<SessionRow>, String> {
let mut stmt = self
.conn
.prepare(
"SELECT id, project_id, name, created_at FROM sessions WHERE project_id = ? ORDER BY created_at DESC LIMIT ?",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map(params![project_id, limit as i64], SessionRow::from_row)
.map_err(|e| e.to_string())?;
let mut sessions = Vec::new();
for row in rows {
sessions.push(row.map_err(|e| e.to_string())?);
}
Ok(sessions)
}
}
fn chrono_now() -> String {