mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
feat: show forked from session id in /status (#9330)
Summary: - Add forked_from to SessionMeta/SessionConfiguredEvent and persist it for forked sessions. - Surface forked_from in /status for tui + tui2 and add snapshots.
This commit is contained in:
committed by
GitHub
Unverified
parent
f1653dd4d3
commit
c26fe64539
@@ -52,6 +52,7 @@ pub fn create_fake_rollout(
|
||||
// Build JSONL lines
|
||||
let meta = SessionMeta {
|
||||
id: conversation_id,
|
||||
forked_from_id: None,
|
||||
timestamp: meta_rfc3339.to_string(),
|
||||
cwd: PathBuf::from("/"),
|
||||
originator: "codex".to_string(),
|
||||
@@ -129,6 +130,7 @@ pub fn create_fake_rollout_with_text_elements(
|
||||
// Build JSONL lines
|
||||
let meta = SessionMeta {
|
||||
id: conversation_id,
|
||||
forked_from_id: None,
|
||||
timestamp: meta_rfc3339.to_string(),
|
||||
cwd: PathBuf::from("/"),
|
||||
originator: "codex".to_string(),
|
||||
|
||||
@@ -588,6 +588,8 @@ impl Session {
|
||||
));
|
||||
}
|
||||
|
||||
let forked_from_id = initial_history.forked_from_id();
|
||||
|
||||
let (conversation_id, rollout_params) = match &initial_history {
|
||||
InitialHistory::New | InitialHistory::Forked(_) => {
|
||||
let conversation_id = ThreadId::default();
|
||||
@@ -595,6 +597,7 @@ impl Session {
|
||||
conversation_id,
|
||||
RolloutRecorderParams::new(
|
||||
conversation_id,
|
||||
forked_from_id,
|
||||
session_configuration.user_instructions.clone(),
|
||||
session_source,
|
||||
),
|
||||
@@ -733,6 +736,7 @@ impl Session {
|
||||
id: INITIAL_SUBMIT_ID.to_owned(),
|
||||
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||||
session_id: conversation_id,
|
||||
forked_from_id,
|
||||
model: session_configuration.model.clone(),
|
||||
model_provider_id: config.model_provider_id.clone(),
|
||||
approval_policy: session_configuration.approval_policy.value(),
|
||||
|
||||
@@ -57,6 +57,7 @@ pub struct RolloutRecorder {
|
||||
pub enum RolloutRecorderParams {
|
||||
Create {
|
||||
conversation_id: ThreadId,
|
||||
forked_from_id: Option<ThreadId>,
|
||||
instructions: Option<String>,
|
||||
source: SessionSource,
|
||||
},
|
||||
@@ -79,11 +80,13 @@ enum RolloutCmd {
|
||||
impl RolloutRecorderParams {
|
||||
pub fn new(
|
||||
conversation_id: ThreadId,
|
||||
forked_from_id: Option<ThreadId>,
|
||||
instructions: Option<String>,
|
||||
source: SessionSource,
|
||||
) -> Self {
|
||||
Self::Create {
|
||||
conversation_id,
|
||||
forked_from_id,
|
||||
instructions,
|
||||
source,
|
||||
}
|
||||
@@ -158,6 +161,7 @@ impl RolloutRecorder {
|
||||
let (file, rollout_path, meta) = match params {
|
||||
RolloutRecorderParams::Create {
|
||||
conversation_id,
|
||||
forked_from_id,
|
||||
instructions,
|
||||
source,
|
||||
} => {
|
||||
@@ -181,6 +185,7 @@ impl RolloutRecorder {
|
||||
path,
|
||||
Some(SessionMeta {
|
||||
id: session_id,
|
||||
forked_from_id,
|
||||
timestamp,
|
||||
cwd: config.cwd.clone(),
|
||||
originator: originator().value,
|
||||
|
||||
@@ -641,6 +641,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> {
|
||||
item: RolloutItem::SessionMeta(SessionMetaLine {
|
||||
meta: SessionMeta {
|
||||
id: conversation_id,
|
||||
forked_from_id: None,
|
||||
timestamp: ts.to_string(),
|
||||
instructions: None,
|
||||
cwd: ".".into(),
|
||||
|
||||
@@ -75,6 +75,7 @@ fn session_configured_produces_thread_started_event() {
|
||||
"e1",
|
||||
EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||||
session_id,
|
||||
forked_from_id: None,
|
||||
model: "codex-mini-latest".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
|
||||
@@ -257,6 +257,7 @@ mod tests {
|
||||
id: "1".to_string(),
|
||||
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||||
session_id: thread_id,
|
||||
forked_from_id: None,
|
||||
model: "gpt-4o".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
@@ -296,6 +297,7 @@ mod tests {
|
||||
let rollout_file = NamedTempFile::new()?;
|
||||
let session_configured_event = SessionConfiguredEvent {
|
||||
session_id: conversation_id,
|
||||
forked_from_id: None,
|
||||
model: "gpt-4o".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
@@ -359,6 +361,7 @@ mod tests {
|
||||
let rollout_file = NamedTempFile::new()?;
|
||||
let session_configured_event = SessionConfiguredEvent {
|
||||
session_id: thread_id,
|
||||
forked_from_id: None,
|
||||
model: "gpt-4o".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
|
||||
@@ -1376,6 +1376,22 @@ pub enum InitialHistory {
|
||||
}
|
||||
|
||||
impl InitialHistory {
|
||||
pub fn forked_from_id(&self) -> Option<ThreadId> {
|
||||
match self {
|
||||
InitialHistory::New => None,
|
||||
InitialHistory::Resumed(resumed) => {
|
||||
resumed.history.iter().find_map(|item| match item {
|
||||
RolloutItem::SessionMeta(meta_line) => meta_line.meta.forked_from_id,
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
InitialHistory::Forked(items) => items.iter().find_map(|item| match item {
|
||||
RolloutItem::SessionMeta(meta_line) => Some(meta_line.meta.id),
|
||||
_ => None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_rollout_items(&self) -> Vec<RolloutItem> {
|
||||
match self {
|
||||
InitialHistory::New => Vec::new(),
|
||||
@@ -1459,6 +1475,8 @@ impl fmt::Display for SubAgentSource {
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)]
|
||||
pub struct SessionMeta {
|
||||
pub id: ThreadId,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub forked_from_id: Option<ThreadId>,
|
||||
pub timestamp: String,
|
||||
pub cwd: PathBuf,
|
||||
pub originator: String,
|
||||
@@ -1473,6 +1491,7 @@ impl Default for SessionMeta {
|
||||
fn default() -> Self {
|
||||
SessionMeta {
|
||||
id: ThreadId::default(),
|
||||
forked_from_id: None,
|
||||
timestamp: String::new(),
|
||||
cwd: PathBuf::new(),
|
||||
originator: String::new(),
|
||||
@@ -2003,6 +2022,8 @@ pub struct SkillsListEntry {
|
||||
pub struct SessionConfiguredEvent {
|
||||
/// Name left as session_id instead of thread_id for backwards compatibility.
|
||||
pub session_id: ThreadId,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub forked_from_id: Option<ThreadId>,
|
||||
|
||||
/// Tell the client what model is being queried.
|
||||
pub model: String,
|
||||
@@ -2376,6 +2397,7 @@ mod tests {
|
||||
id: "1234".to_string(),
|
||||
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||||
session_id: conversation_id,
|
||||
forked_from_id: None,
|
||||
model: "codex-mini-latest".to_string(),
|
||||
model_provider_id: "openai".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
|
||||
@@ -1992,6 +1992,7 @@ mod tests {
|
||||
let make_header = |is_first| {
|
||||
let event = SessionConfiguredEvent {
|
||||
session_id: ThreadId::new(),
|
||||
forked_from_id: None,
|
||||
model: "gpt-test".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
@@ -2033,6 +2034,7 @@ mod tests {
|
||||
id: String::new(),
|
||||
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||||
session_id: base_id,
|
||||
forked_from_id: None,
|
||||
model: "gpt-test".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
@@ -2075,6 +2077,7 @@ mod tests {
|
||||
let thread_id = ThreadId::new();
|
||||
let event = SessionConfiguredEvent {
|
||||
session_id: thread_id,
|
||||
forked_from_id: None,
|
||||
model: "gpt-test".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
|
||||
@@ -440,6 +440,7 @@ pub(crate) struct ChatWidget {
|
||||
// Previous status header to restore after a transient stream retry.
|
||||
retry_status_header: Option<String>,
|
||||
thread_id: Option<ThreadId>,
|
||||
forked_from: Option<ThreadId>,
|
||||
frame_requester: FrameRequester,
|
||||
// Whether to include the initial welcome banner on session configured
|
||||
show_welcome_banner: bool,
|
||||
@@ -600,6 +601,7 @@ impl ChatWidget {
|
||||
.set_history_metadata(event.history_log_id, event.history_entry_count);
|
||||
self.set_skills(None);
|
||||
self.thread_id = Some(event.session_id);
|
||||
self.forked_from = event.forked_from_id;
|
||||
self.current_rollout_path = Some(event.rollout_path.clone());
|
||||
let initial_messages = event.initial_messages.clone();
|
||||
let model_for_header = event.model.clone();
|
||||
@@ -1699,6 +1701,7 @@ impl ChatWidget {
|
||||
current_status_header: String::from("Working"),
|
||||
retry_status_header: None,
|
||||
thread_id: None,
|
||||
forked_from: None,
|
||||
queued_user_messages: VecDeque::new(),
|
||||
show_welcome_banner: is_first_run,
|
||||
suppress_session_configured_redraw: false,
|
||||
@@ -1797,6 +1800,7 @@ impl ChatWidget {
|
||||
current_status_header: String::from("Working"),
|
||||
retry_status_header: None,
|
||||
thread_id: None,
|
||||
forked_from: None,
|
||||
queued_user_messages: VecDeque::new(),
|
||||
show_welcome_banner: false,
|
||||
suppress_session_configured_redraw: true,
|
||||
@@ -2635,6 +2639,7 @@ impl ChatWidget {
|
||||
token_info,
|
||||
total_usage,
|
||||
&self.thread_id,
|
||||
self.forked_from,
|
||||
self.rate_limit_snapshot.as_ref(),
|
||||
self.plan_type,
|
||||
Local::now(),
|
||||
|
||||
@@ -131,6 +131,7 @@ async fn resumed_initial_messages_render_history() {
|
||||
let rollout_file = NamedTempFile::new().unwrap();
|
||||
let configured = codex_core::protocol::SessionConfiguredEvent {
|
||||
session_id: conversation_id,
|
||||
forked_from_id: None,
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
@@ -428,6 +429,7 @@ async fn make_chatwidget_manual(
|
||||
current_status_header: String::from("Working"),
|
||||
retry_status_header: None,
|
||||
thread_id: None,
|
||||
forked_from: None,
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
show_welcome_banner: true,
|
||||
queued_user_messages: VecDeque::new(),
|
||||
|
||||
@@ -66,6 +66,7 @@ struct StatusHistoryCell {
|
||||
model_provider: Option<String>,
|
||||
account: Option<StatusAccountDisplay>,
|
||||
session_id: Option<String>,
|
||||
forked_from: Option<String>,
|
||||
token_usage: StatusTokenUsageData,
|
||||
rate_limits: StatusRateLimitData,
|
||||
}
|
||||
@@ -77,6 +78,7 @@ pub(crate) fn new_status_output(
|
||||
token_info: Option<&TokenUsageInfo>,
|
||||
total_usage: &TokenUsage,
|
||||
session_id: &Option<ThreadId>,
|
||||
forked_from: Option<ThreadId>,
|
||||
rate_limits: Option<&RateLimitSnapshotDisplay>,
|
||||
plan_type: Option<PlanType>,
|
||||
now: DateTime<Local>,
|
||||
@@ -89,6 +91,7 @@ pub(crate) fn new_status_output(
|
||||
token_info,
|
||||
total_usage,
|
||||
session_id,
|
||||
forked_from,
|
||||
rate_limits,
|
||||
plan_type,
|
||||
now,
|
||||
@@ -106,6 +109,7 @@ impl StatusHistoryCell {
|
||||
token_info: Option<&TokenUsageInfo>,
|
||||
total_usage: &TokenUsage,
|
||||
session_id: &Option<ThreadId>,
|
||||
forked_from: Option<ThreadId>,
|
||||
rate_limits: Option<&RateLimitSnapshotDisplay>,
|
||||
plan_type: Option<PlanType>,
|
||||
now: DateTime<Local>,
|
||||
@@ -134,6 +138,7 @@ impl StatusHistoryCell {
|
||||
let model_provider = format_model_provider(config);
|
||||
let account = compose_account_display(auth_manager, plan_type);
|
||||
let session_id = session_id.as_ref().map(std::string::ToString::to_string);
|
||||
let forked_from = forked_from.map(|id| id.to_string());
|
||||
let default_usage = TokenUsage::default();
|
||||
let (context_usage, context_window) = match token_info {
|
||||
Some(info) => (&info.last_token_usage, info.model_context_window),
|
||||
@@ -163,6 +168,7 @@ impl StatusHistoryCell {
|
||||
model_provider,
|
||||
account,
|
||||
session_id,
|
||||
forked_from,
|
||||
token_usage,
|
||||
rate_limits,
|
||||
}
|
||||
@@ -351,6 +357,9 @@ impl HistoryCell for StatusHistoryCell {
|
||||
if self.session_id.is_some() {
|
||||
push_label(&mut labels, &mut seen, "Session");
|
||||
}
|
||||
if self.session_id.is_some() && self.forked_from.is_some() {
|
||||
push_label(&mut labels, &mut seen, "Forked from");
|
||||
}
|
||||
push_label(&mut labels, &mut seen, "Token usage");
|
||||
if self.token_usage.context_window.is_some() {
|
||||
push_label(&mut labels, &mut seen, "Context window");
|
||||
@@ -403,6 +412,11 @@ impl HistoryCell for StatusHistoryCell {
|
||||
if let Some(session) = self.session_id.as_ref() {
|
||||
lines.push(formatter.line("Session", vec![Span::from(session.clone())]));
|
||||
}
|
||||
if self.session_id.is_some()
|
||||
&& let Some(forked_from) = self.forked_from.as_ref()
|
||||
{
|
||||
lines.push(formatter.line("Forked from", vec![Span::from(forked_from.clone())]));
|
||||
}
|
||||
|
||||
lines.push(Line::from(Vec::<Span<'static>>::new()));
|
||||
// Hide token usage only for ChatGPT subscribers
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
---
|
||||
source: tui/src/status/tests.rs
|
||||
expression: sanitized
|
||||
---
|
||||
/status
|
||||
|
||||
╭───────────────────────────────────────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v0.0.0) │
|
||||
│ │
|
||||
│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │
|
||||
│ information on rate limits and credits │
|
||||
│ │
|
||||
│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │
|
||||
│ Directory: [[workspace]] │
|
||||
│ Approval: on-request │
|
||||
│ Sandbox: read-only │
|
||||
│ Agents.md: <none> │
|
||||
│ Session: 0f0f3c13-6cf9-4aa4-8b80-7d49c2f1be2e │
|
||||
│ Forked from: e9f18a88-8081-4e51-9d4e-8af5cde2d8dd │
|
||||
│ │
|
||||
│ Token usage: 1.2K total (800 input + 400 output) │
|
||||
│ Context window: 100% left (1.2K used / 272K) │
|
||||
│ Limits: data not available yet │
|
||||
╰───────────────────────────────────────────────────────────────────────╯
|
||||
@@ -14,6 +14,7 @@ use codex_core::protocol::RateLimitWindow;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
use codex_core::protocol::TokenUsageInfo;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use insta::assert_snapshot;
|
||||
@@ -146,6 +147,7 @@ async fn status_snapshot_includes_reasoning_details() {
|
||||
Some(&token_info),
|
||||
&usage,
|
||||
&None,
|
||||
None,
|
||||
Some(&rate_display),
|
||||
None,
|
||||
captured_at,
|
||||
@@ -161,6 +163,57 @@ async fn status_snapshot_includes_reasoning_details() {
|
||||
assert_snapshot!(sanitized);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_snapshot_includes_forked_from() {
|
||||
let temp_home = TempDir::new().expect("temp home");
|
||||
let mut config = test_config(&temp_home).await;
|
||||
config.model = Some("gpt-5.1-codex-max".to_string());
|
||||
config.model_provider_id = "openai".to_string();
|
||||
config.cwd = PathBuf::from("/workspace/tests");
|
||||
|
||||
let auth_manager = test_auth_manager(&config);
|
||||
let usage = TokenUsage {
|
||||
input_tokens: 800,
|
||||
cached_input_tokens: 0,
|
||||
output_tokens: 400,
|
||||
reasoning_output_tokens: 0,
|
||||
total_tokens: 1_200,
|
||||
};
|
||||
|
||||
let captured_at = chrono::Local
|
||||
.with_ymd_and_hms(2024, 8, 9, 10, 11, 12)
|
||||
.single()
|
||||
.expect("valid time");
|
||||
|
||||
let model_slug = ModelsManager::get_model_offline(config.model.as_deref());
|
||||
let token_info = token_info_for(&model_slug, &config, &usage);
|
||||
let session_id =
|
||||
ThreadId::from_string("0f0f3c13-6cf9-4aa4-8b80-7d49c2f1be2e").expect("session id");
|
||||
let forked_from =
|
||||
ThreadId::from_string("e9f18a88-8081-4e51-9d4e-8af5cde2d8dd").expect("forked id");
|
||||
|
||||
let composite = new_status_output(
|
||||
&config,
|
||||
&auth_manager,
|
||||
Some(&token_info),
|
||||
&usage,
|
||||
&Some(session_id),
|
||||
Some(forked_from),
|
||||
None,
|
||||
None,
|
||||
captured_at,
|
||||
&model_slug,
|
||||
);
|
||||
let mut rendered_lines = render_lines(&composite.display_lines(80));
|
||||
if cfg!(windows) {
|
||||
for line in &mut rendered_lines {
|
||||
*line = line.replace('\\', "/");
|
||||
}
|
||||
}
|
||||
let sanitized = sanitize_directory(rendered_lines).join("\n");
|
||||
assert_snapshot!(sanitized);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_snapshot_includes_monthly_limit() {
|
||||
let temp_home = TempDir::new().expect("temp home");
|
||||
@@ -202,6 +255,7 @@ async fn status_snapshot_includes_monthly_limit() {
|
||||
Some(&token_info),
|
||||
&usage,
|
||||
&None,
|
||||
None,
|
||||
Some(&rate_display),
|
||||
None,
|
||||
captured_at,
|
||||
@@ -246,6 +300,7 @@ async fn status_snapshot_shows_unlimited_credits() {
|
||||
Some(&token_info),
|
||||
&usage,
|
||||
&None,
|
||||
None,
|
||||
Some(&rate_display),
|
||||
None,
|
||||
captured_at,
|
||||
@@ -289,6 +344,7 @@ async fn status_snapshot_shows_positive_credits() {
|
||||
Some(&token_info),
|
||||
&usage,
|
||||
&None,
|
||||
None,
|
||||
Some(&rate_display),
|
||||
None,
|
||||
captured_at,
|
||||
@@ -332,6 +388,7 @@ async fn status_snapshot_hides_zero_credits() {
|
||||
Some(&token_info),
|
||||
&usage,
|
||||
&None,
|
||||
None,
|
||||
Some(&rate_display),
|
||||
None,
|
||||
captured_at,
|
||||
@@ -373,6 +430,7 @@ async fn status_snapshot_hides_when_has_no_credits_flag() {
|
||||
Some(&token_info),
|
||||
&usage,
|
||||
&None,
|
||||
None,
|
||||
Some(&rate_display),
|
||||
None,
|
||||
captured_at,
|
||||
@@ -416,6 +474,7 @@ async fn status_card_token_usage_excludes_cached_tokens() {
|
||||
&None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
now,
|
||||
&model_slug,
|
||||
);
|
||||
@@ -470,6 +529,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() {
|
||||
Some(&token_info),
|
||||
&usage,
|
||||
&None,
|
||||
None,
|
||||
Some(&rate_display),
|
||||
None,
|
||||
captured_at,
|
||||
@@ -517,6 +577,7 @@ async fn status_snapshot_shows_missing_limits_message() {
|
||||
&None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
now,
|
||||
&model_slug,
|
||||
);
|
||||
@@ -578,6 +639,7 @@ async fn status_snapshot_includes_credits_and_limits() {
|
||||
Some(&token_info),
|
||||
&usage,
|
||||
&None,
|
||||
None,
|
||||
Some(&rate_display),
|
||||
None,
|
||||
captured_at,
|
||||
@@ -629,6 +691,7 @@ async fn status_snapshot_shows_empty_limits_message() {
|
||||
Some(&token_info),
|
||||
&usage,
|
||||
&None,
|
||||
None,
|
||||
Some(&rate_display),
|
||||
None,
|
||||
captured_at,
|
||||
@@ -689,6 +752,7 @@ async fn status_snapshot_shows_stale_limits_message() {
|
||||
Some(&token_info),
|
||||
&usage,
|
||||
&None,
|
||||
None,
|
||||
Some(&rate_display),
|
||||
None,
|
||||
now,
|
||||
@@ -753,6 +817,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() {
|
||||
Some(&token_info),
|
||||
&usage,
|
||||
&None,
|
||||
None,
|
||||
Some(&rate_display),
|
||||
None,
|
||||
now,
|
||||
@@ -809,6 +874,7 @@ async fn status_context_window_uses_last_usage() {
|
||||
&None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
now,
|
||||
&model_slug,
|
||||
);
|
||||
|
||||
@@ -2627,6 +2627,7 @@ mod tests {
|
||||
let make_header = |is_first| {
|
||||
let event = SessionConfiguredEvent {
|
||||
session_id: ThreadId::new(),
|
||||
forked_from_id: None,
|
||||
model: "gpt-test".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
@@ -2668,6 +2669,7 @@ mod tests {
|
||||
id: String::new(),
|
||||
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||||
session_id: base_id,
|
||||
forked_from_id: None,
|
||||
model: "gpt-test".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
@@ -2950,6 +2952,7 @@ mod tests {
|
||||
let conversation_id = ThreadId::new();
|
||||
let event = SessionConfiguredEvent {
|
||||
session_id: conversation_id,
|
||||
forked_from_id: None,
|
||||
model: "gpt-test".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
|
||||
@@ -383,6 +383,7 @@ pub(crate) struct ChatWidget {
|
||||
// Previous status header to restore after a transient stream retry.
|
||||
retry_status_header: Option<String>,
|
||||
conversation_id: Option<ThreadId>,
|
||||
forked_from: Option<ThreadId>,
|
||||
frame_requester: FrameRequester,
|
||||
// Whether to include the initial welcome banner on session configured
|
||||
show_welcome_banner: bool,
|
||||
@@ -522,6 +523,7 @@ impl ChatWidget {
|
||||
.set_history_metadata(event.history_log_id, event.history_entry_count);
|
||||
self.set_skills(None);
|
||||
self.conversation_id = Some(event.session_id);
|
||||
self.forked_from = event.forked_from_id;
|
||||
self.current_rollout_path = Some(event.rollout_path.clone());
|
||||
let initial_messages = event.initial_messages.clone();
|
||||
let model_for_header = event.model.clone();
|
||||
@@ -1502,6 +1504,7 @@ impl ChatWidget {
|
||||
current_status_header: String::from("Working"),
|
||||
retry_status_header: None,
|
||||
conversation_id: None,
|
||||
forked_from: None,
|
||||
queued_user_messages: VecDeque::new(),
|
||||
show_welcome_banner: is_first_run,
|
||||
suppress_session_configured_redraw: false,
|
||||
@@ -1598,6 +1601,7 @@ impl ChatWidget {
|
||||
current_status_header: String::from("Working"),
|
||||
retry_status_header: None,
|
||||
conversation_id: None,
|
||||
forked_from: None,
|
||||
queued_user_messages: VecDeque::new(),
|
||||
show_welcome_banner: false,
|
||||
suppress_session_configured_redraw: true,
|
||||
@@ -2400,6 +2404,7 @@ impl ChatWidget {
|
||||
token_info,
|
||||
total_usage,
|
||||
&self.conversation_id,
|
||||
self.forked_from,
|
||||
self.rate_limit_snapshot.as_ref(),
|
||||
self.plan_type,
|
||||
Local::now(),
|
||||
|
||||
@@ -120,6 +120,7 @@ async fn resumed_initial_messages_render_history() {
|
||||
let rollout_file = NamedTempFile::new().unwrap();
|
||||
let configured = codex_core::protocol::SessionConfiguredEvent {
|
||||
session_id: conversation_id,
|
||||
forked_from_id: None,
|
||||
model: "test-model".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
@@ -414,6 +415,7 @@ async fn make_chatwidget_manual(
|
||||
current_status_header: String::from("Working"),
|
||||
retry_status_header: None,
|
||||
conversation_id: None,
|
||||
forked_from: None,
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
show_welcome_banner: true,
|
||||
queued_user_messages: VecDeque::new(),
|
||||
|
||||
@@ -66,6 +66,7 @@ struct StatusHistoryCell {
|
||||
model_provider: Option<String>,
|
||||
account: Option<StatusAccountDisplay>,
|
||||
session_id: Option<String>,
|
||||
forked_from: Option<String>,
|
||||
token_usage: StatusTokenUsageData,
|
||||
rate_limits: StatusRateLimitData,
|
||||
}
|
||||
@@ -77,6 +78,7 @@ pub(crate) fn new_status_output(
|
||||
token_info: Option<&TokenUsageInfo>,
|
||||
total_usage: &TokenUsage,
|
||||
session_id: &Option<ThreadId>,
|
||||
forked_from: Option<ThreadId>,
|
||||
rate_limits: Option<&RateLimitSnapshotDisplay>,
|
||||
plan_type: Option<PlanType>,
|
||||
now: DateTime<Local>,
|
||||
@@ -89,6 +91,7 @@ pub(crate) fn new_status_output(
|
||||
token_info,
|
||||
total_usage,
|
||||
session_id,
|
||||
forked_from,
|
||||
rate_limits,
|
||||
plan_type,
|
||||
now,
|
||||
@@ -106,6 +109,7 @@ impl StatusHistoryCell {
|
||||
token_info: Option<&TokenUsageInfo>,
|
||||
total_usage: &TokenUsage,
|
||||
session_id: &Option<ThreadId>,
|
||||
forked_from: Option<ThreadId>,
|
||||
rate_limits: Option<&RateLimitSnapshotDisplay>,
|
||||
plan_type: Option<PlanType>,
|
||||
now: DateTime<Local>,
|
||||
@@ -134,6 +138,7 @@ impl StatusHistoryCell {
|
||||
let model_provider = format_model_provider(config);
|
||||
let account = compose_account_display(auth_manager, plan_type);
|
||||
let session_id = session_id.as_ref().map(std::string::ToString::to_string);
|
||||
let forked_from = forked_from.map(|id| id.to_string());
|
||||
let default_usage = TokenUsage::default();
|
||||
let (context_usage, context_window) = match token_info {
|
||||
Some(info) => (&info.last_token_usage, info.model_context_window),
|
||||
@@ -163,6 +168,7 @@ impl StatusHistoryCell {
|
||||
model_provider,
|
||||
account,
|
||||
session_id,
|
||||
forked_from,
|
||||
token_usage,
|
||||
rate_limits,
|
||||
}
|
||||
@@ -351,6 +357,9 @@ impl HistoryCell for StatusHistoryCell {
|
||||
if self.session_id.is_some() {
|
||||
push_label(&mut labels, &mut seen, "Session");
|
||||
}
|
||||
if self.session_id.is_some() && self.forked_from.is_some() {
|
||||
push_label(&mut labels, &mut seen, "Forked from");
|
||||
}
|
||||
push_label(&mut labels, &mut seen, "Token usage");
|
||||
if self.token_usage.context_window.is_some() {
|
||||
push_label(&mut labels, &mut seen, "Context window");
|
||||
@@ -402,6 +411,11 @@ impl HistoryCell for StatusHistoryCell {
|
||||
if let Some(session) = self.session_id.as_ref() {
|
||||
lines.push(formatter.line("Session", vec![Span::from(session.clone())]));
|
||||
}
|
||||
if self.session_id.is_some()
|
||||
&& let Some(forked_from) = self.forked_from.as_ref()
|
||||
{
|
||||
lines.push(formatter.line("Forked from", vec![Span::from(forked_from.clone())]));
|
||||
}
|
||||
|
||||
lines.push(Line::from(Vec::<Span<'static>>::new()));
|
||||
// Hide token usage only for ChatGPT subscribers
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
---
|
||||
source: tui2/src/status/tests.rs
|
||||
expression: sanitized
|
||||
---
|
||||
/status
|
||||
|
||||
╭───────────────────────────────────────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v0.0.0) │
|
||||
│ │
|
||||
│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │
|
||||
│ information on rate limits and credits │
|
||||
│ │
|
||||
│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │
|
||||
│ Directory: [[workspace]] │
|
||||
│ Approval: on-request │
|
||||
│ Sandbox: read-only │
|
||||
│ Agents.md: <none> │
|
||||
│ Session: 0f0f3c13-6cf9-4aa4-8b80-7d49c2f1be2e │
|
||||
│ Forked from: e9f18a88-8081-4e51-9d4e-8af5cde2d8dd │
|
||||
│ │
|
||||
│ Token usage: 1.2K total (800 input + 400 output) │
|
||||
│ Context window: 100% left (1.2K used / 272K) │
|
||||
│ Limits: data not available yet │
|
||||
╰───────────────────────────────────────────────────────────────────────╯
|
||||
@@ -14,6 +14,7 @@ use codex_core::protocol::RateLimitWindow;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
use codex_core::protocol::TokenUsageInfo;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use insta::assert_snapshot;
|
||||
@@ -146,6 +147,7 @@ async fn status_snapshot_includes_reasoning_details() {
|
||||
Some(&token_info),
|
||||
&usage,
|
||||
&None,
|
||||
None,
|
||||
Some(&rate_display),
|
||||
None,
|
||||
captured_at,
|
||||
@@ -160,6 +162,58 @@ async fn status_snapshot_includes_reasoning_details() {
|
||||
let sanitized = sanitize_directory(rendered_lines).join("\n");
|
||||
assert_snapshot!(sanitized);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_snapshot_includes_forked_from() {
|
||||
let temp_home = TempDir::new().expect("temp home");
|
||||
let mut config = test_config(&temp_home).await;
|
||||
config.model = Some("gpt-5.1-codex-max".to_string());
|
||||
config.model_provider_id = "openai".to_string();
|
||||
config.cwd = PathBuf::from("/workspace/tests");
|
||||
|
||||
let auth_manager = test_auth_manager(&config);
|
||||
let usage = TokenUsage {
|
||||
input_tokens: 800,
|
||||
cached_input_tokens: 0,
|
||||
output_tokens: 400,
|
||||
reasoning_output_tokens: 0,
|
||||
total_tokens: 1_200,
|
||||
};
|
||||
|
||||
let captured_at = chrono::Local
|
||||
.with_ymd_and_hms(2024, 8, 9, 10, 11, 12)
|
||||
.single()
|
||||
.expect("valid time");
|
||||
|
||||
let model_slug = ModelsManager::get_model_offline(config.model.as_deref());
|
||||
let token_info = token_info_for(&model_slug, &config, &usage);
|
||||
let session_id =
|
||||
ThreadId::from_string("0f0f3c13-6cf9-4aa4-8b80-7d49c2f1be2e").expect("session id");
|
||||
let forked_from =
|
||||
ThreadId::from_string("e9f18a88-8081-4e51-9d4e-8af5cde2d8dd").expect("forked id");
|
||||
|
||||
let composite = new_status_output(
|
||||
&config,
|
||||
&auth_manager,
|
||||
Some(&token_info),
|
||||
&usage,
|
||||
&Some(session_id),
|
||||
Some(forked_from),
|
||||
None,
|
||||
None,
|
||||
captured_at,
|
||||
&model_slug,
|
||||
);
|
||||
let mut rendered_lines = render_lines(&composite.display_lines(80));
|
||||
if cfg!(windows) {
|
||||
for line in &mut rendered_lines {
|
||||
*line = line.replace('\\', "/");
|
||||
}
|
||||
}
|
||||
let sanitized = sanitize_directory(rendered_lines).join("\n");
|
||||
assert_snapshot!(sanitized);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_snapshot_includes_monthly_limit() {
|
||||
let temp_home = TempDir::new().expect("temp home");
|
||||
@@ -201,6 +255,7 @@ async fn status_snapshot_includes_monthly_limit() {
|
||||
Some(&token_info),
|
||||
&usage,
|
||||
&None,
|
||||
None,
|
||||
Some(&rate_display),
|
||||
None,
|
||||
captured_at,
|
||||
@@ -244,6 +299,7 @@ async fn status_snapshot_shows_unlimited_credits() {
|
||||
Some(&token_info),
|
||||
&usage,
|
||||
&None,
|
||||
None,
|
||||
Some(&rate_display),
|
||||
None,
|
||||
captured_at,
|
||||
@@ -287,6 +343,7 @@ async fn status_snapshot_shows_positive_credits() {
|
||||
Some(&token_info),
|
||||
&usage,
|
||||
&None,
|
||||
None,
|
||||
Some(&rate_display),
|
||||
None,
|
||||
captured_at,
|
||||
@@ -330,6 +387,7 @@ async fn status_snapshot_hides_zero_credits() {
|
||||
Some(&token_info),
|
||||
&usage,
|
||||
&None,
|
||||
None,
|
||||
Some(&rate_display),
|
||||
None,
|
||||
captured_at,
|
||||
@@ -371,6 +429,7 @@ async fn status_snapshot_hides_when_has_no_credits_flag() {
|
||||
Some(&token_info),
|
||||
&usage,
|
||||
&None,
|
||||
None,
|
||||
Some(&rate_display),
|
||||
None,
|
||||
captured_at,
|
||||
@@ -414,6 +473,7 @@ async fn status_card_token_usage_excludes_cached_tokens() {
|
||||
&None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
now,
|
||||
&model_slug,
|
||||
);
|
||||
@@ -468,6 +528,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() {
|
||||
Some(&token_info),
|
||||
&usage,
|
||||
&None,
|
||||
None,
|
||||
Some(&rate_display),
|
||||
None,
|
||||
captured_at,
|
||||
@@ -515,6 +576,7 @@ async fn status_snapshot_shows_missing_limits_message() {
|
||||
&None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
now,
|
||||
&model_slug,
|
||||
);
|
||||
@@ -575,6 +637,7 @@ async fn status_snapshot_includes_credits_and_limits() {
|
||||
Some(&token_info),
|
||||
&usage,
|
||||
&None,
|
||||
None,
|
||||
Some(&rate_display),
|
||||
None,
|
||||
captured_at,
|
||||
@@ -626,6 +689,7 @@ async fn status_snapshot_shows_empty_limits_message() {
|
||||
Some(&token_info),
|
||||
&usage,
|
||||
&None,
|
||||
None,
|
||||
Some(&rate_display),
|
||||
None,
|
||||
captured_at,
|
||||
@@ -686,6 +750,7 @@ async fn status_snapshot_shows_stale_limits_message() {
|
||||
Some(&token_info),
|
||||
&usage,
|
||||
&None,
|
||||
None,
|
||||
Some(&rate_display),
|
||||
None,
|
||||
now,
|
||||
@@ -750,6 +815,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() {
|
||||
Some(&token_info),
|
||||
&usage,
|
||||
&None,
|
||||
None,
|
||||
Some(&rate_display),
|
||||
None,
|
||||
now,
|
||||
@@ -806,6 +872,7 @@ async fn status_context_window_uses_last_usage() {
|
||||
&None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
now,
|
||||
&model_slug,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user