Compare commits

...

9 Commits

Author SHA1 Message Date
Eric Traut
227ede6b5b Merge branch 'main' into etraut/tui-status-regression 2026-04-07 22:20:57 -07:00
Eric Traut
79768dd61c Remove expired April 2nd tooltip copy (#16698)
Addresses #16677

Problem: Paid-plan startup tooltips still advertised 2x rate limits
until April 2nd after that promo had expired.

Solution: Remove the stale expiry copy and use evergreen Codex App /
Codex startup tips instead.
2026-04-07 22:20:04 -07:00
Eric Traut
dae726f80d Merge branch 'main' into etraut/tui-status-regression 2026-04-04 09:18:35 -07:00
Eric Traut
7deb99eb91 Revert "ci: skip Windows clippy test bins"
This reverts commit 2334770425.
2026-04-03 17:25:43 -07:00
Eric Traut
2334770425 ci: skip Windows clippy test bins 2026-04-03 17:16:56 -07:00
Eric Traut
2e464487bd Merge branch 'main' into etraut/tui-status-regression 2026-04-03 17:09:12 -07:00
Eric Traut
bf85972d4f codex: fix core test metadata constructor (#16594) 2026-04-03 16:12:35 -07:00
Eric Traut
9f20bc3f2b codex: address PR review feedback (#16594) 2026-04-03 16:00:45 -07:00
Eric Traut
0a65490839 Expose fork source thread ids in app-server v2 2026-04-03 15:37:44 -07:00
12 changed files with 188 additions and 19 deletions

View File

@@ -5,4 +5,4 @@ import type { ConversationGitInfo } from "./ConversationGitInfo";
import type { SessionSource } from "./SessionSource";
import type { ThreadId } from "./ThreadId";
export type ConversationSummary = { conversationId: ThreadId, path: string, preview: string, timestamp: string | null, updatedAt: string | null, modelProvider: string, cwd: string, cliVersion: string, source: SessionSource, gitInfo: ConversationGitInfo | null, };
export type ConversationSummary = { conversationId: ThreadId, forkedFromId: ThreadId | null, path: string, preview: string, timestamp: string | null, updatedAt: string | null, modelProvider: string, cwd: string, cliVersion: string, source: SessionSource, gitInfo: ConversationGitInfo | null, };

View File

@@ -89,6 +89,7 @@ pub struct GetConversationSummaryResponse {
#[serde(rename_all = "camelCase")]
pub struct ConversationSummary {
pub conversation_id: ThreadId,
pub forked_from_id: Option<ThreadId>,
pub path: PathBuf,
pub preview: String,
pub timestamp: Option<String>,

View File

@@ -8669,6 +8669,7 @@ async fn summary_from_thread_list_item(
);
return Some(ConversationSummary {
conversation_id: thread_id,
forked_from_id: None,
path: it.path,
preview: it.first_user_message.unwrap_or_default(),
timestamp,
@@ -8713,6 +8714,7 @@ fn thread_id_from_rollout_path(path: &Path) -> Option<ThreadId> {
#[allow(clippy::too_many_arguments)]
fn summary_from_state_db_metadata(
conversation_id: ThreadId,
forked_from_id: Option<ThreadId>,
path: PathBuf,
first_user_message: Option<String>,
timestamp: String,
@@ -8743,6 +8745,7 @@ fn summary_from_state_db_metadata(
};
ConversationSummary {
conversation_id,
forked_from_id,
path,
preview,
timestamp: Some(timestamp),
@@ -8758,6 +8761,7 @@ fn summary_from_state_db_metadata(
fn summary_from_thread_metadata(metadata: &ThreadMetadata) -> ConversationSummary {
summary_from_state_db_metadata(
metadata.id,
metadata.forked_from_id,
metadata.rollout_path.clone(),
metadata.first_user_message.clone(),
metadata
@@ -8840,6 +8844,7 @@ pub(crate) async fn read_summary_from_rollout(
Ok(ConversationSummary {
conversation_id: session_meta.id,
forked_from_id: session_meta.forked_from_id,
timestamp,
updated_at,
path: path.to_path_buf(),
@@ -8900,6 +8905,7 @@ fn extract_conversation_summary(
Some(ConversationSummary {
conversation_id,
forked_from_id: session_meta.forked_from_id,
timestamp,
updated_at,
path,
@@ -9058,6 +9064,7 @@ fn build_thread_from_snapshot(
pub(crate) fn summary_to_thread(summary: ConversationSummary) -> Thread {
let ConversationSummary {
conversation_id,
forked_from_id,
path,
preview,
timestamp,
@@ -9079,7 +9086,7 @@ pub(crate) fn summary_to_thread(summary: ConversationSummary) -> Thread {
Thread {
id: conversation_id.to_string(),
forked_from_id: None,
forked_from_id: forked_from_id.map(|id| id.to_string()),
preview,
ephemeral: false,
model_provider,
@@ -9458,6 +9465,7 @@ mod tests {
let expected = ConversationSummary {
conversation_id,
forked_from_id: None,
timestamp: timestamp.clone(),
updated_at: timestamp,
path,
@@ -9514,6 +9522,7 @@ mod tests {
let expected = ConversationSummary {
conversation_id,
forked_from_id: None,
timestamp: Some(timestamp.clone()),
updated_at: Some("2025-09-05T16:53:11Z".to_string()),
path: path.clone(),
@@ -9611,6 +9620,11 @@ mod tests {
forked_from_id_from_rollout(path.as_path()).await,
Some(forked_from_id.to_string())
);
let summary = read_summary_from_rollout(path.as_path(), "fallback").await?;
let thread = summary_to_thread(summary);
assert_eq!(thread.forked_from_id, Some(forked_from_id.to_string()));
Ok(())
}
@@ -9688,6 +9702,7 @@ mod tests {
let summary = summary_from_state_db_metadata(
conversation_id,
/*forked_from_id*/ None,
PathBuf::from("/tmp/rollout.jsonl"),
Some("hi".to_string()),
"2025-09-05T16:53:11Z".to_string(),
@@ -9710,6 +9725,35 @@ mod tests {
Ok(())
}
#[test]
fn summary_from_state_db_metadata_preserves_forked_from_id() -> Result<()> {
let conversation_id = ThreadId::from_string("bfd12a78-5900-467b-9bc5-d3d35df08191")?;
let forked_from_id = ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?;
let summary = summary_from_state_db_metadata(
conversation_id,
Some(forked_from_id),
PathBuf::from("/tmp/rollout.jsonl"),
Some("hi".to_string()),
"2025-09-05T16:53:11Z".to_string(),
"2025-09-05T16:53:12Z".to_string(),
"test-provider".to_string(),
PathBuf::from("/"),
"0.0.0".to_string(),
serde_json::to_string(&SessionSource::Cli)?,
/*agent_nickname*/ None,
/*agent_role*/ None,
/*git_sha*/ None,
/*git_branch*/ None,
/*git_origin_url*/ None,
);
let thread = summary_to_thread(summary);
assert_eq!(thread.forked_from_id, Some(forked_from_id.to_string()));
Ok(())
}
#[tokio::test]
async fn removing_thread_state_clears_listener_and_active_turn_history() -> Result<()> {
let manager = ThreadStateManager::new();

View File

@@ -24,6 +24,7 @@ const MODEL_PROVIDER: &str = "openai";
fn expected_summary(conversation_id: ThreadId, path: PathBuf) -> ConversationSummary {
ConversationSummary {
conversation_id,
forked_from_id: None,
path,
preview: PREVIEW.to_string(),
timestamp: Some(META_RFC3339.to_string()),

View File

@@ -13,6 +13,7 @@ use tempfile::TempDir;
fn thread_metadata(cwd: &str, title: &str, first_user_message: &str) -> ThreadMetadata {
ThreadMetadata {
id: ThreadId::new(),
forked_from_id: None,
rollout_path: PathBuf::from("/tmp/rollout.jsonl"),
created_at: Utc
.timestamp_opt(1_709_251_100, 0)

View File

@@ -0,0 +1,2 @@
ALTER TABLE threads
ADD COLUMN forked_from_id TEXT;

View File

@@ -47,6 +47,7 @@ fn apply_session_meta_from_item(metadata: &mut ThreadMetadata, meta_line: &Sessi
return;
}
metadata.id = meta_line.meta.id;
metadata.forked_from_id = meta_line.meta.forked_from_id;
metadata.source = enum_to_string(&meta_line.meta.source);
metadata.agent_nickname = meta_line.meta.agent_nickname.clone();
metadata.agent_role = meta_line.meta.agent_role.clone();
@@ -363,6 +364,40 @@ mod tests {
assert_eq!(metadata.reasoning_effort, Some(ReasoningEffort::High));
}
#[test]
fn session_meta_sets_forked_from_id() {
let mut metadata = metadata_for_test();
let thread_id = metadata.id;
let forked_from_id =
ThreadId::from_string(&Uuid::from_u128(7).to_string()).expect("thread id");
apply_rollout_item(
&mut metadata,
&RolloutItem::SessionMeta(SessionMetaLine {
meta: SessionMeta {
id: thread_id,
forked_from_id: Some(forked_from_id),
timestamp: "2026-02-26T00:00:00.000Z".to_string(),
cwd: PathBuf::from("/workspace"),
originator: "codex_cli_rs".to_string(),
cli_version: "0.0.0".to_string(),
source: SessionSource::Cli,
agent_path: None,
agent_nickname: None,
agent_role: None,
model_provider: Some("openai".to_string()),
base_instructions: None,
dynamic_tools: None,
memory_mode: None,
},
git: None,
}),
"test-provider",
);
assert_eq!(metadata.forked_from_id, Some(forked_from_id));
}
#[test]
fn session_meta_does_not_set_model_or_reasoning_effort() {
let mut metadata = metadata_for_test();
@@ -401,6 +436,7 @@ mod tests {
let created_at = DateTime::<Utc>::from_timestamp(1_735_689_600, 0).expect("timestamp");
ThreadMetadata {
id,
forked_from_id: None,
rollout_path: PathBuf::from("/tmp/a.jsonl"),
created_at,
updated_at: created_at,
@@ -429,11 +465,14 @@ mod tests {
fn diff_fields_detects_changes() {
let mut base = metadata_for_test();
base.id = ThreadId::from_string(&Uuid::now_v7().to_string()).expect("thread id");
base.forked_from_id =
Some(ThreadId::from_string(&Uuid::now_v7().to_string()).expect("thread id"));
base.title = "hello".to_string();
let mut other = base.clone();
other.forked_from_id = None;
other.tokens_used = 2;
other.title = "world".to_string();
let diffs = base.diff_fields(&other);
assert_eq!(diffs, vec!["title", "tokens_used"]);
assert_eq!(diffs, vec!["forked_from_id", "title", "tokens_used"]);
}
}

View File

@@ -57,6 +57,8 @@ pub struct ExtractionOutcome {
pub struct ThreadMetadata {
/// The thread identifier.
pub id: ThreadId,
/// The source thread identifier this thread was forked from, if any.
pub forked_from_id: Option<ThreadId>,
/// The absolute rollout path on disk.
pub rollout_path: PathBuf,
/// The creation timestamp.
@@ -106,6 +108,8 @@ pub struct ThreadMetadata {
pub struct ThreadMetadataBuilder {
/// The thread identifier.
pub id: ThreadId,
/// The source thread identifier this thread was forked from, if any.
pub forked_from_id: Option<ThreadId>,
/// The absolute rollout path on disk.
pub rollout_path: PathBuf,
/// The creation timestamp.
@@ -150,6 +154,7 @@ impl ThreadMetadataBuilder {
) -> Self {
Self {
id,
forked_from_id: None,
rollout_path,
created_at,
updated_at: None,
@@ -181,6 +186,7 @@ impl ThreadMetadataBuilder {
.unwrap_or(created_at);
ThreadMetadata {
id: self.id,
forked_from_id: self.forked_from_id,
rollout_path: self.rollout_path.clone(),
created_at,
updated_at,
@@ -232,6 +238,9 @@ impl ThreadMetadata {
if self.id != other.id {
diffs.push("id");
}
if self.forked_from_id != other.forked_from_id {
diffs.push("forked_from_id");
}
if self.rollout_path != other.rollout_path {
diffs.push("rollout_path");
}
@@ -306,6 +315,7 @@ fn canonicalize_datetime(dt: DateTime<Utc>) -> DateTime<Utc> {
#[derive(Debug)]
pub(crate) struct ThreadRow {
id: String,
forked_from_id: Option<String>,
rollout_path: String,
created_at: i64,
updated_at: i64,
@@ -333,6 +343,7 @@ impl ThreadRow {
pub(crate) fn try_from_row(row: &SqliteRow) -> Result<Self> {
Ok(Self {
id: row.try_get("id")?,
forked_from_id: row.try_get("forked_from_id")?,
rollout_path: row.try_get("rollout_path")?,
created_at: row.try_get("created_at")?,
updated_at: row.try_get("updated_at")?,
@@ -364,6 +375,7 @@ impl TryFrom<ThreadRow> for ThreadMetadata {
fn try_from(row: ThreadRow) -> std::result::Result<Self, Self::Error> {
let ThreadRow {
id,
forked_from_id,
rollout_path,
created_at,
updated_at,
@@ -388,6 +400,7 @@ impl TryFrom<ThreadRow> for ThreadMetadata {
} = row;
Ok(Self {
id: ThreadId::try_from(id)?,
forked_from_id: forked_from_id.map(ThreadId::try_from).transpose()?,
rollout_path: PathBuf::from(rollout_path),
created_at: epoch_seconds_to_datetime(created_at)?,
updated_at: epoch_seconds_to_datetime(updated_at)?,
@@ -457,6 +470,7 @@ mod tests {
fn thread_row(reasoning_effort: Option<&str>) -> ThreadRow {
ThreadRow {
id: "00000000-0000-0000-0000-000000000123".to_string(),
forked_from_id: Some("00000000-0000-0000-0000-000000000456".to_string()),
rollout_path: "/tmp/rollout-123.jsonl".to_string(),
created_at: 1_700_000_000,
updated_at: 1_700_000_100,
@@ -485,6 +499,10 @@ mod tests {
ThreadMetadata {
id: ThreadId::from_string("00000000-0000-0000-0000-000000000123")
.expect("valid thread id"),
forked_from_id: Some(
ThreadId::from_string("00000000-0000-0000-0000-000000000456")
.expect("valid thread id"),
),
rollout_path: PathBuf::from("/tmp/rollout-123.jsonl"),
created_at: DateTime::<Utc>::from_timestamp(1_700_000_000, 0).expect("timestamp"),
updated_at: DateTime::<Utc>::from_timestamp(1_700_000_100, 0).expect("timestamp"),

View File

@@ -162,6 +162,7 @@ WHERE thread_id = ?
r#"
SELECT
id,
forked_from_id,
rollout_path,
created_at,
updated_at,

View File

@@ -44,6 +44,7 @@ pub(super) fn test_thread_metadata(
let now = DateTime::<Utc>::from_timestamp(1_700_000_000, 0).expect("timestamp");
ThreadMetadata {
id: thread_id,
forked_from_id: None,
rollout_path: codex_home.join(format!("rollout-{thread_id}.jsonl")),
created_at: now,
updated_at: now,

View File

@@ -7,6 +7,7 @@ impl StateRuntime {
r#"
SELECT
id,
forked_from_id,
rollout_path,
created_at,
updated_at,
@@ -344,6 +345,7 @@ ON CONFLICT(child_thread_id) DO NOTHING
r#"
SELECT
id,
forked_from_id,
rollout_path,
created_at,
updated_at,
@@ -445,6 +447,7 @@ FROM threads
r#"
INSERT INTO threads (
id,
forked_from_id,
rollout_path,
created_at,
updated_at,
@@ -468,11 +471,12 @@ INSERT INTO threads (
git_branch,
git_origin_url,
memory_mode
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO NOTHING
"#,
)
.bind(metadata.id.to_string())
.bind(metadata.forked_from_id.map(|id| id.to_string()))
.bind(metadata.rollout_path.display().to_string())
.bind(datetime_to_epoch_seconds(metadata.created_at))
.bind(datetime_to_epoch_seconds(metadata.updated_at))
@@ -572,6 +576,7 @@ WHERE id = ?
r#"
INSERT INTO threads (
id,
forked_from_id,
rollout_path,
created_at,
updated_at,
@@ -595,8 +600,9 @@ INSERT INTO threads (
git_branch,
git_origin_url,
memory_mode
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
forked_from_id = excluded.forked_from_id,
rollout_path = excluded.rollout_path,
created_at = excluded.created_at,
updated_at = excluded.updated_at,
@@ -622,6 +628,7 @@ ON CONFLICT(id) DO UPDATE SET
"#,
)
.bind(metadata.id.to_string())
.bind(metadata.forked_from_id.map(|id| id.to_string()))
.bind(metadata.rollout_path.display().to_string())
.bind(datetime_to_epoch_seconds(metadata.created_at))
.bind(datetime_to_epoch_seconds(metadata.updated_at))
@@ -1051,6 +1058,59 @@ mod tests {
assert_eq!(memory_mode.as_deref(), Some("polluted"));
}
#[tokio::test]
async fn apply_rollout_items_persists_forked_from_id_from_session_meta() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
.await
.expect("state db should initialize");
let thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000555").expect("valid thread id");
let forked_from_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000777").expect("valid thread id");
let metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
let builder = ThreadMetadataBuilder::new(
thread_id,
metadata.rollout_path.clone(),
metadata.created_at,
SessionSource::Cli,
);
let items = vec![RolloutItem::SessionMeta(SessionMetaLine {
meta: SessionMeta {
id: thread_id,
forked_from_id: Some(forked_from_id),
timestamp: metadata.created_at.to_rfc3339(),
cwd: metadata.cwd.clone(),
originator: String::new(),
cli_version: "0.0.0".to_string(),
source: SessionSource::Cli,
agent_path: None,
agent_nickname: None,
agent_role: None,
model_provider: None,
base_instructions: None,
dynamic_tools: None,
memory_mode: None,
},
git: None,
})];
runtime
.apply_rollout_items(
&builder, &items, /*new_thread_memory_mode*/ None,
/*updated_at_override*/ None,
)
.await
.expect("apply_rollout_items should succeed");
let persisted = runtime
.get_thread(thread_id)
.await
.expect("thread should load")
.expect("thread should exist");
assert_eq!(persisted.forked_from_id, Some(forked_from_id));
}
#[tokio::test]
async fn apply_rollout_items_preserves_existing_git_branch_and_fills_missing_git_fields() {
let codex_home = unique_temp_dir();

View File

@@ -9,9 +9,7 @@ const ANNOUNCEMENT_TIP_URL: &str =
const IS_MACOS: bool = cfg!(target_os = "macos");
const IS_WINDOWS: bool = cfg!(target_os = "windows");
const PAID_TOOLTIP: &str = "*New* Try the **Codex App** with 2x rate limits until *April 2nd*. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true";
const PAID_TOOLTIP_WINDOWS: &str = "*New* Try the **Codex App**, now available on **Windows**, with 2x rate limits until *April 2nd*. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true";
const PAID_TOOLTIP_NON_MAC: &str = "*New* 2x rate limits until *April 2nd*.";
const APP_TOOLTIP: &str = "Try the **Codex App**. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true";
const FAST_TOOLTIP: &str = "*New* Use **/fast** to enable our fastest inference at 2X plan usage.";
const OTHER_TOOLTIP: &str = "*New* Build faster with the **Codex App**. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true";
const OTHER_TOOLTIP_NON_MAC: &str = "*New* Build faster with Codex.";
@@ -67,7 +65,9 @@ pub(crate) fn get_tooltip(plan: Option<PlanType>, fast_mode_enabled: bool) -> Op
) || plan_type.is_team_like()
|| plan_type.is_business_like() =>
{
return Some(pick_paid_tooltip(&mut rng, fast_mode_enabled).to_string());
if let Some(tooltip) = pick_paid_tooltip(&mut rng, fast_mode_enabled) {
return Some(tooltip.to_string());
}
}
Some(PlanType::Go) | Some(PlanType::Free) => {
return Some(FREE_GO_TOOLTIP.to_string());
@@ -86,13 +86,11 @@ pub(crate) fn get_tooltip(plan: Option<PlanType>, fast_mode_enabled: bool) -> Op
pick_tooltip(&mut rng).map(str::to_string)
}
fn paid_app_tooltip() -> &'static str {
if IS_MACOS {
PAID_TOOLTIP
} else if IS_WINDOWS {
PAID_TOOLTIP_WINDOWS
fn paid_app_tooltip() -> Option<&'static str> {
if IS_MACOS || IS_WINDOWS {
Some(APP_TOOLTIP)
} else {
PAID_TOOLTIP_NON_MAC
None
}
}
@@ -100,11 +98,14 @@ fn paid_app_tooltip() -> &'static str {
/// generic random tip pool. Keep this business logic explicit: we currently split
/// that slot between the app promo and Fast mode, but suppress the Fast promo once
/// the user already has Fast mode enabled.
fn pick_paid_tooltip<R: Rng + ?Sized>(rng: &mut R, fast_mode_enabled: bool) -> &'static str {
fn pick_paid_tooltip<R: Rng + ?Sized>(
rng: &mut R,
fast_mode_enabled: bool,
) -> Option<&'static str> {
if fast_mode_enabled || rng.random_bool(0.5) {
paid_app_tooltip()
} else {
FAST_TOOLTIP
Some(FAST_TOOLTIP)
}
}
@@ -296,7 +297,7 @@ mod tests {
));
}
let expected = std::collections::BTreeSet::from([paid_app_tooltip(), FAST_TOOLTIP]);
let expected = std::collections::BTreeSet::from([paid_app_tooltip(), Some(FAST_TOOLTIP)]);
assert_eq!(seen, expected);
}
@@ -310,7 +311,7 @@ mod tests {
let expected = std::collections::BTreeSet::from([paid_app_tooltip()]);
assert_eq!(seen, expected);
assert!(!seen.contains(&FAST_TOOLTIP));
assert!(!seen.contains(&Some(FAST_TOOLTIP)));
}
#[test]