Compare commits

...

8 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
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
11 changed files with 173 additions and 5 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();