mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
08901fc8e1
## Why Models sometimes need to pause briefly while waiting for external work, but using a shell command for that delay ties the wait to a process and does not naturally resume when new turn input arrives. ## What changed - add a built-in `sleep` tool behind the under-development `sleep_tool` feature - accept a bounded `duration_ms` argument, matching the millisecond convention used by unified exec - end the sleep early when either steered user input or mailbox input arrives - include elapsed wall-clock time in completed and interrupted outputs - emit a dedicated core `SleepItem` through `item/started` and `item/completed` - expose the sleep item as app-server v2 `ThreadItem::Sleep` and retain it in reconstructed thread history - regenerate the configuration schema for the new feature flag - regenerate app-server JSON and TypeScript schema fixtures ## Test plan - `just test -p codex-core sleep_tool_follows_feature_gate` - `just test -p codex-core any_new_input_interrupts_sleep` - `just test -p codex-app-server-protocol` - `just test -p codex-app-server sleep_emits_started_and_completed_items`
234 lines
7.9 KiB
Rust
234 lines
7.9 KiB
Rust
//! Render persisted thread turns into history-cell building blocks.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use crate::app_server_session::AppServerSession;
|
|
use crate::git_action_directives::parse_assistant_markdown;
|
|
use crate::history_cell::AgentMarkdownCell;
|
|
use crate::history_cell::HistoryCell;
|
|
use crate::history_cell::PlainHistoryCell;
|
|
use crate::history_cell::ReasoningSummaryCell;
|
|
use crate::history_cell::UserHistoryCell;
|
|
use crate::multi_agents::sub_agent_activity_summary;
|
|
use codex_app_server_protocol::Thread;
|
|
use codex_app_server_protocol::ThreadItem;
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::items::UserMessageItem;
|
|
use ratatui::style::Stylize as _;
|
|
use ratatui::text::Line;
|
|
|
|
pub(crate) type TranscriptCells = Vec<Arc<dyn HistoryCell>>;
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub(crate) enum RawReasoningVisibility {
|
|
Hidden,
|
|
Visible,
|
|
}
|
|
|
|
pub(crate) async fn load_session_transcript(
|
|
app_server: &mut AppServerSession,
|
|
thread_id: ThreadId,
|
|
raw_reasoning_visibility: RawReasoningVisibility,
|
|
) -> std::io::Result<TranscriptCells> {
|
|
let thread = app_server
|
|
.thread_read(thread_id, /*include_turns*/ true)
|
|
.await
|
|
.map_err(std::io::Error::other)?;
|
|
Ok(thread_to_transcript_cells(
|
|
&thread,
|
|
raw_reasoning_visibility,
|
|
))
|
|
}
|
|
|
|
pub(crate) fn thread_to_transcript_cells(
|
|
thread: &Thread,
|
|
raw_reasoning_visibility: RawReasoningVisibility,
|
|
) -> TranscriptCells {
|
|
let cwd = thread.cwd.as_path();
|
|
let mut cells: TranscriptCells = Vec::new();
|
|
for item in thread.turns.iter().flat_map(|turn| turn.items.iter()) {
|
|
match item {
|
|
ThreadItem::UserMessage {
|
|
id,
|
|
client_id,
|
|
content,
|
|
} => {
|
|
let item = UserMessageItem {
|
|
id: id.clone(),
|
|
client_id: client_id.clone(),
|
|
content: content
|
|
.iter()
|
|
.cloned()
|
|
.map(codex_app_server_protocol::UserInput::into_core)
|
|
.collect(),
|
|
};
|
|
cells.push(Arc::new(UserHistoryCell {
|
|
message: item.message(),
|
|
text_elements: item.text_elements(),
|
|
local_image_paths: item.local_image_paths(),
|
|
remote_image_urls: item.image_urls(),
|
|
}));
|
|
}
|
|
ThreadItem::AgentMessage { text, .. } => {
|
|
let parsed = parse_assistant_markdown(text, cwd);
|
|
if !parsed.visible_markdown.trim().is_empty() {
|
|
cells.push(Arc::new(AgentMarkdownCell::new(
|
|
parsed.visible_markdown,
|
|
cwd,
|
|
)));
|
|
}
|
|
}
|
|
ThreadItem::Plan { text, .. } => {
|
|
if !text.trim().is_empty() {
|
|
cells.push(Arc::new(crate::history_cell::new_proposed_plan(
|
|
text.clone(),
|
|
cwd,
|
|
)));
|
|
}
|
|
}
|
|
ThreadItem::Reasoning {
|
|
summary, content, ..
|
|
} => {
|
|
let text = if matches!(raw_reasoning_visibility, RawReasoningVisibility::Visible)
|
|
&& !content.is_empty()
|
|
{
|
|
content.join("\n\n")
|
|
} else {
|
|
summary.join("\n\n")
|
|
};
|
|
if !text.trim().is_empty() {
|
|
cells.push(Arc::new(ReasoningSummaryCell::new(
|
|
"Reasoning".to_string(),
|
|
text,
|
|
cwd,
|
|
/*transcript_only*/ false,
|
|
)));
|
|
}
|
|
}
|
|
other => {
|
|
if let Some(cell) = fallback_transcript_cell(other) {
|
|
cells.push(Arc::new(cell));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if cells.is_empty() {
|
|
cells.push(Arc::new(PlainHistoryCell::new(vec![
|
|
"No transcript content available".italic().dim().into(),
|
|
])));
|
|
}
|
|
cells
|
|
}
|
|
|
|
fn fallback_transcript_cell(item: &ThreadItem) -> Option<PlainHistoryCell> {
|
|
let lines = match item {
|
|
ThreadItem::HookPrompt { fragments, .. } => fragments
|
|
.iter()
|
|
.map(|fragment| {
|
|
vec![
|
|
"hook prompt: ".dim(),
|
|
fragment.text.trim().to_string().into(),
|
|
]
|
|
.into()
|
|
})
|
|
.collect::<Vec<_>>(),
|
|
ThreadItem::CommandExecution {
|
|
command,
|
|
status,
|
|
aggregated_output,
|
|
exit_code,
|
|
..
|
|
} => {
|
|
let mut lines: Vec<Line<'static>> =
|
|
vec![vec!["$ ".dim(), command.clone().into()].into()];
|
|
lines.push(
|
|
format!(
|
|
"status: {status:?}{}",
|
|
exit_code
|
|
.map(|code| format!(" · exit {code}"))
|
|
.unwrap_or_default()
|
|
)
|
|
.dim()
|
|
.into(),
|
|
);
|
|
if let Some(output) = aggregated_output.as_deref()
|
|
&& !output.trim().is_empty()
|
|
{
|
|
lines.extend(
|
|
output
|
|
.lines()
|
|
.map(|line| vec![" ".dim(), line.trim_end().to_string().dim()].into()),
|
|
);
|
|
}
|
|
lines
|
|
}
|
|
ThreadItem::FileChange {
|
|
changes, status, ..
|
|
} => vec![
|
|
format!("file changes: {status:?} · {} changes", changes.len())
|
|
.dim()
|
|
.into(),
|
|
],
|
|
ThreadItem::McpToolCall {
|
|
server,
|
|
tool,
|
|
status,
|
|
..
|
|
} => vec![
|
|
format!("mcp tool: {server}/{tool} · {status:?}")
|
|
.dim()
|
|
.into(),
|
|
],
|
|
ThreadItem::DynamicToolCall {
|
|
namespace,
|
|
tool,
|
|
status,
|
|
..
|
|
} => {
|
|
let name = namespace
|
|
.as_ref()
|
|
.map(|namespace| format!("{namespace}/{tool}"))
|
|
.unwrap_or_else(|| tool.clone());
|
|
vec![format!("tool: {name} · {status:?}").dim().into()]
|
|
}
|
|
ThreadItem::CollabAgentToolCall { tool, status, .. } => {
|
|
vec![format!("agent tool: {tool:?} · {status:?}").dim().into()]
|
|
}
|
|
ThreadItem::SubAgentActivity {
|
|
kind, agent_path, ..
|
|
} => {
|
|
vec![sub_agent_activity_summary(*kind, agent_path).dim().into()]
|
|
}
|
|
ThreadItem::WebSearch { query, .. } => {
|
|
vec![vec!["web search: ".dim(), query.clone().into()].into()]
|
|
}
|
|
ThreadItem::ImageView { path, .. } => {
|
|
vec![format!("image: {}", path.as_path().display()).dim().into()]
|
|
}
|
|
ThreadItem::ImageGeneration {
|
|
status, saved_path, ..
|
|
} => {
|
|
let saved = saved_path
|
|
.as_ref()
|
|
.map(|path| format!(" · {}", path.as_path().display()))
|
|
.unwrap_or_default();
|
|
vec![format!("image generation: {status}{saved}").dim().into()]
|
|
}
|
|
ThreadItem::EnteredReviewMode { review, .. } => {
|
|
vec![vec!["review started: ".dim(), review.clone().into()].into()]
|
|
}
|
|
ThreadItem::ExitedReviewMode { review, .. } => {
|
|
vec![vec!["review finished: ".dim(), review.clone().into()].into()]
|
|
}
|
|
ThreadItem::ContextCompaction { .. } => {
|
|
vec!["context compacted".dim().into()]
|
|
}
|
|
ThreadItem::UserMessage { .. }
|
|
| ThreadItem::AgentMessage { .. }
|
|
| ThreadItem::Plan { .. }
|
|
| ThreadItem::Reasoning { .. }
|
|
| ThreadItem::Sleep { .. } => return None,
|
|
};
|
|
(!lines.is_empty()).then(|| PlainHistoryCell::new(lines))
|
|
}
|