mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Use goal preview metadata for goal-first threads (#21981)
Fixes #20792 ## Why `/goal`-first threads are valid resumable threads, but they can be missing from `codex resume` and app recents because discovery depends on metadata derived from a normal first user message. PR #21489 attempted to fix this by using the goal objective as `first_user_message`. Review feedback pointed out that `first_user_message` does more than provide visible text today: it gates listing, supplies preview text, and participates in deciding whether a later title should surface as a distinct thread name. Reusing it for the goal objective could leave a `/goal`-first thread with `first_user_message=<goal>` and `title=<later prompt>`, even though the goal should only provide the initial visible preview. This PR follows that feedback by and keeps the `first_user_message` as is but introduces a new `preview` field to separate concerns. The `preview` field is populated from the first user message or the goal objective. We can extend it in the future to include other sources. ## What Changed - Added internal thread `preview` metadata in `codex-state`, including a SQLite migration that backfills from `first_user_message` and from existing `thread_goals` objectives when needed. - Treated `ThreadGoalUpdated` as preview-bearing metadata so goal-first threads can be listed and searched without mutating `first_user_message`. - Updated rollout listing, state queries, thread-store conversion, and app-server mapping to use preview metadata while continuing to expose the existing public `preview` field. - Preserved title/name distinctness behavior around literal `first_user_message`, so a later normal prompt after `/goal` does not surface as a separate name just because the goal supplied the initial preview. - Preserved compatibility for older/internal metadata writes by deriving preview from `first_user_message` when explicit preview metadata is absent. ## Verification - Manually verified that a thread that starts with a `/goal <objective>` shows up in the resume picker.
This commit is contained in:
committed by
GitHub
Unverified
parent
96836e15ed
commit
f10ddc3f13
@@ -2182,7 +2182,7 @@ mod tests {
|
||||
|
||||
assert_eq!(response.thread.id, thread_id.to_string());
|
||||
assert_eq!(response.thread.path, None);
|
||||
assert_eq!(response.thread.preview, "before rollback");
|
||||
assert_eq!(response.thread.preview, "fallback preview");
|
||||
assert_eq!(response.thread.name.as_deref(), Some("Rollback thread"));
|
||||
assert_eq!(response.thread.status, ThreadStatus::NotLoaded);
|
||||
assert_eq!(response.thread.turns.len(), 1);
|
||||
|
||||
@@ -3745,7 +3745,7 @@ pub(crate) fn thread_from_stored_thread(
|
||||
id: thread_id.clone(),
|
||||
session_id: thread_id,
|
||||
forked_from_id: thread.forked_from_id.map(|id| id.to_string()),
|
||||
preview: thread.first_user_message.unwrap_or(thread.preview),
|
||||
preview: thread.preview,
|
||||
ephemeral: false,
|
||||
model_provider: if thread.model_provider.is_empty() {
|
||||
fallback_provider.to_string()
|
||||
@@ -3787,7 +3787,7 @@ fn summary_from_stored_thread(
|
||||
ConversationSummary {
|
||||
conversation_id: thread.thread_id,
|
||||
path,
|
||||
preview: thread.first_user_message.unwrap_or(thread.preview),
|
||||
preview: thread.preview,
|
||||
// Preserve millisecond precision from the thread store so thread/list cursors
|
||||
// round-trip the same ordering key used by pagination queries.
|
||||
timestamp: Some(
|
||||
@@ -3818,6 +3818,7 @@ fn summary_from_state_db_metadata(
|
||||
conversation_id: ThreadId,
|
||||
path: PathBuf,
|
||||
first_user_message: Option<String>,
|
||||
preview: Option<String>,
|
||||
timestamp: String,
|
||||
updated_at: String,
|
||||
model_provider: String,
|
||||
@@ -3831,7 +3832,7 @@ fn summary_from_state_db_metadata(
|
||||
git_branch: Option<String>,
|
||||
git_origin_url: Option<String>,
|
||||
) -> ConversationSummary {
|
||||
let preview = first_user_message.unwrap_or_default();
|
||||
let preview = preview.or(first_user_message).unwrap_or_default();
|
||||
let source = serde_json::from_str(&source)
|
||||
.or_else(|_| serde_json::from_value(serde_json::Value::String(source.clone())))
|
||||
.unwrap_or(codex_protocol::protocol::SessionSource::Unknown);
|
||||
@@ -3865,6 +3866,7 @@ fn summary_from_thread_metadata(metadata: &ThreadMetadata) -> ConversationSummar
|
||||
metadata.id,
|
||||
metadata.rollout_path.clone(),
|
||||
metadata.first_user_message.clone(),
|
||||
metadata.preview.clone(),
|
||||
metadata
|
||||
.created_at
|
||||
.to_rfc3339_opts(SecondsFormat::Secs, true),
|
||||
|
||||
@@ -1086,6 +1086,7 @@ mod thread_processor_behavior_tests {
|
||||
conversation_id,
|
||||
PathBuf::from("/tmp/rollout.jsonl"),
|
||||
Some("hi".to_string()),
|
||||
/*preview*/ None,
|
||||
"2025-09-05T16:53:11Z".to_string(),
|
||||
"2025-09-05T16:53:12Z".to_string(),
|
||||
"test-provider".to_string(),
|
||||
|
||||
@@ -50,6 +50,8 @@ pub struct ThreadItem {
|
||||
pub thread_id: Option<ThreadId>,
|
||||
/// First user message captured for this thread, if any.
|
||||
pub first_user_message: Option<String>,
|
||||
/// Best available user-facing preview for discovery and list display.
|
||||
pub preview: Option<String>,
|
||||
/// Working directory from session metadata.
|
||||
pub cwd: Option<PathBuf>,
|
||||
/// Git branch from session metadata.
|
||||
@@ -85,9 +87,9 @@ pub type ConversationsPage = ThreadsPage;
|
||||
#[derive(Default)]
|
||||
struct HeadTailSummary {
|
||||
saw_session_meta: bool,
|
||||
saw_user_event: bool,
|
||||
thread_id: Option<ThreadId>,
|
||||
first_user_message: Option<String>,
|
||||
preview: Option<String>,
|
||||
cwd: Option<PathBuf>,
|
||||
git_branch: Option<String>,
|
||||
git_sha: Option<String>,
|
||||
@@ -738,7 +740,8 @@ async fn build_thread_item(
|
||||
cwd_filters: Option<&[PathBuf]>,
|
||||
updated_at: Option<String>,
|
||||
) -> Option<ThreadItem> {
|
||||
// Read head and detect message events; stop once meta + user are found.
|
||||
// Read head and detect preview-bearing events; goal previews can appear before
|
||||
// the first normal user message.
|
||||
let summary = read_head_summary(&path, HEAD_RECORD_LIMIT)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
@@ -764,11 +767,12 @@ async fn build_thread_item(
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// Apply filters: must have session meta and at least one user message event
|
||||
if summary.saw_session_meta && summary.saw_user_event {
|
||||
// Apply filters: must have session meta and a discoverable preview.
|
||||
if summary.saw_session_meta && summary.preview.is_some() {
|
||||
let HeadTailSummary {
|
||||
thread_id,
|
||||
first_user_message,
|
||||
preview,
|
||||
cwd,
|
||||
git_branch,
|
||||
git_sha,
|
||||
@@ -789,6 +793,7 @@ async fn build_thread_item(
|
||||
path,
|
||||
thread_id,
|
||||
first_user_message,
|
||||
preview,
|
||||
cwd,
|
||||
git_branch,
|
||||
git_sha,
|
||||
@@ -1078,7 +1083,7 @@ async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result<HeadTai
|
||||
|
||||
while lines_scanned < head_limit
|
||||
|| (summary.saw_session_meta
|
||||
&& !summary.saw_user_event
|
||||
&& (summary.preview.is_none() || summary.first_user_message.is_none())
|
||||
&& lines_scanned < head_limit + USER_EVENT_SCAN_LIMIT)
|
||||
{
|
||||
let line_opt = lines.next_line().await?;
|
||||
@@ -1131,19 +1136,23 @@ async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result<HeadTai
|
||||
// Not included in `head`; skip.
|
||||
}
|
||||
RolloutItem::EventMsg(ev) => {
|
||||
if let EventMsg::UserMessage(user) = ev {
|
||||
summary.saw_user_event = true;
|
||||
if summary.first_user_message.is_none() {
|
||||
let message = strip_user_message_prefix(user.message.as_str()).to_string();
|
||||
if !message.is_empty() {
|
||||
summary.first_user_message = Some(message);
|
||||
}
|
||||
if let Some(preview) = event_msg_preview(&ev) {
|
||||
if summary.preview.is_none() {
|
||||
summary.preview = Some(preview.clone());
|
||||
}
|
||||
if let EventMsg::UserMessage(_) = ev
|
||||
&& summary.first_user_message.is_none()
|
||||
{
|
||||
summary.first_user_message = Some(preview);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if summary.saw_session_meta && summary.saw_user_event {
|
||||
if summary.saw_session_meta
|
||||
&& summary.preview.is_some()
|
||||
&& summary.first_user_message.is_some()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1198,6 +1207,31 @@ fn strip_user_message_prefix(text: &str) -> &str {
|
||||
}
|
||||
}
|
||||
|
||||
fn event_msg_preview(event: &EventMsg) -> Option<String> {
|
||||
match event {
|
||||
EventMsg::UserMessage(user) => {
|
||||
let message = strip_user_message_prefix(user.message.as_str());
|
||||
if !message.is_empty() {
|
||||
return Some(message.to_string());
|
||||
}
|
||||
if user
|
||||
.images
|
||||
.as_ref()
|
||||
.is_some_and(|images| !images.is_empty())
|
||||
|| !user.local_images.is_empty()
|
||||
{
|
||||
return Some("[Image]".to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
EventMsg::ThreadGoalUpdated(event) => {
|
||||
let objective = event.goal.objective.trim();
|
||||
(!objective.is_empty()).then(|| objective.to_string())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the SessionMetaLine from the head of a rollout file for reuse by
|
||||
/// callers that need the session metadata (e.g. to derive a cwd for config).
|
||||
pub async fn read_session_meta_line(path: &Path) -> io::Result<SessionMetaLine> {
|
||||
|
||||
@@ -98,6 +98,7 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option<EventPersistenceMode> {
|
||||
| EventMsg::AgentReasoningRawContent(_)
|
||||
| EventMsg::PatchApplyEnd(_)
|
||||
| EventMsg::TokenCount(_)
|
||||
| EventMsg::ThreadGoalUpdated(_)
|
||||
| EventMsg::ContextCompacted(_)
|
||||
| EventMsg::EnteredReviewMode(_)
|
||||
| EventMsg::ExitedReviewMode(_)
|
||||
@@ -140,7 +141,6 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option<EventPersistenceMode> {
|
||||
| EventMsg::AgentReasoningSectionBreak(_)
|
||||
| EventMsg::RawResponseItem(_)
|
||||
| EventMsg::SessionConfigured(_)
|
||||
| EventMsg::ThreadGoalUpdated(_)
|
||||
| EventMsg::McpToolCallBegin(_)
|
||||
| EventMsg::ExecCommandBegin(_)
|
||||
| EventMsg::TerminalInteraction(_)
|
||||
|
||||
@@ -1065,6 +1065,7 @@ fn fill_missing_thread_item_metadata(item: &mut ThreadItem, state_item: ThreadIt
|
||||
path: _state_path,
|
||||
thread_id: _state_thread_id,
|
||||
first_user_message,
|
||||
preview,
|
||||
cwd,
|
||||
git_branch,
|
||||
git_sha,
|
||||
@@ -1081,6 +1082,9 @@ fn fill_missing_thread_item_metadata(item: &mut ThreadItem, state_item: ThreadIt
|
||||
if item.first_user_message.is_none() {
|
||||
item.first_user_message = first_user_message;
|
||||
}
|
||||
if item.preview.is_none() {
|
||||
item.preview = preview;
|
||||
}
|
||||
if item.cwd.is_none() {
|
||||
item.cwd = cwd;
|
||||
}
|
||||
@@ -1889,6 +1893,7 @@ fn thread_item_from_state_metadata(item: codex_state::ThreadMetadata) -> ThreadI
|
||||
path: item.rollout_path,
|
||||
thread_id: Some(item.id),
|
||||
first_user_message: item.first_user_message,
|
||||
preview: item.preview,
|
||||
cwd: Some(item.cwd),
|
||||
git_branch: item.git_branch,
|
||||
git_sha: item.git_sha,
|
||||
|
||||
@@ -822,6 +822,7 @@ async fn list_threads_db_enabled_drops_missing_rollout_paths() -> std::io::Resul
|
||||
builder.cwd = home.path().to_path_buf();
|
||||
let mut metadata = builder.build(config.model_provider_id.as_str());
|
||||
metadata.first_user_message = Some("Hello from user".to_string());
|
||||
metadata.preview = metadata.first_user_message.clone();
|
||||
runtime
|
||||
.upsert_thread(&metadata)
|
||||
.await
|
||||
@@ -887,6 +888,7 @@ async fn list_threads_db_enabled_repairs_stale_rollout_paths() -> std::io::Resul
|
||||
builder.cwd = home.path().to_path_buf();
|
||||
let mut metadata = builder.build(config.model_provider_id.as_str());
|
||||
metadata.first_user_message = Some("Hello from user".to_string());
|
||||
metadata.preview = metadata.first_user_message.clone();
|
||||
runtime
|
||||
.upsert_thread(&metadata)
|
||||
.await
|
||||
@@ -1050,6 +1052,7 @@ async fn list_threads_default_filter_returns_filesystem_scan_results() -> std::i
|
||||
builder.cwd = stale_cwd.clone();
|
||||
let mut metadata = builder.build(config.model_provider_id.as_str());
|
||||
metadata.first_user_message = Some("Hello from user".to_string());
|
||||
metadata.preview = metadata.first_user_message.clone();
|
||||
runtime
|
||||
.upsert_thread(&metadata)
|
||||
.await
|
||||
@@ -1142,6 +1145,7 @@ async fn list_threads_metadata_filter_overlays_state_db_list_metadata() -> std::
|
||||
builder.git_origin_url = Some("https://example.com/repo.git".to_string());
|
||||
let mut metadata = builder.build(config.model_provider_id.as_str());
|
||||
metadata.first_user_message = Some("Hello from user".to_string());
|
||||
metadata.preview = metadata.first_user_message.clone();
|
||||
runtime
|
||||
.upsert_thread(&metadata)
|
||||
.await
|
||||
@@ -1182,6 +1186,7 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi
|
||||
path: filesystem_path.clone(),
|
||||
thread_id: Some(filesystem_thread_id),
|
||||
first_user_message: Some("filesystem message".to_string()),
|
||||
preview: Some("filesystem preview".to_string()),
|
||||
cwd: None,
|
||||
git_branch: Some("filesystem-branch".to_string()),
|
||||
git_sha: Some("filesystem-sha".to_string()),
|
||||
@@ -1198,6 +1203,7 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi
|
||||
path: state_path,
|
||||
thread_id: Some(state_thread_id),
|
||||
first_user_message: Some("state message".to_string()),
|
||||
preview: Some("state preview".to_string()),
|
||||
cwd: Some(PathBuf::from("/tmp/state-cwd")),
|
||||
git_branch: Some("state-branch".to_string()),
|
||||
git_sha: Some("state-sha".to_string()),
|
||||
@@ -1219,6 +1225,7 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi
|
||||
item.first_user_message.as_deref(),
|
||||
Some("filesystem message")
|
||||
);
|
||||
assert_eq!(item.preview.as_deref(), Some("filesystem preview"));
|
||||
assert_eq!(item.cwd.as_deref(), Some(Path::new("/tmp/state-cwd")));
|
||||
assert_eq!(item.git_branch.as_deref(), Some("state-branch"));
|
||||
assert_eq!(item.git_sha.as_deref(), Some("state-sha"));
|
||||
@@ -1269,6 +1276,7 @@ async fn list_threads_search_repairs_stale_state_db_hits_before_returning() -> s
|
||||
let mut metadata = builder.build(config.model_provider_id.as_str());
|
||||
metadata.title = "needle stale title".to_string();
|
||||
metadata.first_user_message = Some("stale first user".to_string());
|
||||
metadata.preview = metadata.first_user_message.clone();
|
||||
runtime
|
||||
.upsert_thread(&metadata)
|
||||
.await
|
||||
|
||||
@@ -37,6 +37,9 @@ use codex_protocol::protocol::RolloutLine;
|
||||
use codex_protocol::protocol::SessionMeta;
|
||||
use codex_protocol::protocol::SessionMetaLine;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::ThreadGoal;
|
||||
use codex_protocol::protocol::ThreadGoalStatus;
|
||||
use codex_protocol::protocol::ThreadGoalUpdatedEvent;
|
||||
use codex_protocol::protocol::UserMessageEvent;
|
||||
|
||||
const NO_SOURCE_FILTER: &[SessionSource] = &[];
|
||||
@@ -83,6 +86,7 @@ async fn insert_state_db_thread(
|
||||
}
|
||||
let mut metadata = builder.build(TEST_PROVIDER);
|
||||
metadata.first_user_message = Some("Hello from user".to_string());
|
||||
metadata.preview = metadata.first_user_message.clone();
|
||||
runtime
|
||||
.upsert_thread(&metadata)
|
||||
.await
|
||||
@@ -466,6 +470,85 @@ fn write_session_file_with_provider(
|
||||
Ok((dt, uuid))
|
||||
}
|
||||
|
||||
fn write_goal_started_session_file(
|
||||
root: &Path,
|
||||
ts_str: &str,
|
||||
uuid: Uuid,
|
||||
objective: &str,
|
||||
later_user_message: Option<&str>,
|
||||
) -> std::io::Result<()> {
|
||||
let format: &[FormatItem] =
|
||||
format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]");
|
||||
let dt = PrimitiveDateTime::parse(ts_str, format)
|
||||
.unwrap()
|
||||
.assume_utc();
|
||||
let dir = root
|
||||
.join("sessions")
|
||||
.join(format!("{:04}", dt.year()))
|
||||
.join(format!("{:02}", u8::from(dt.month())))
|
||||
.join(format!("{:02}", dt.day()));
|
||||
fs::create_dir_all(&dir)?;
|
||||
|
||||
let filename = format!("rollout-{ts_str}-{uuid}.jsonl");
|
||||
let file_path = dir.join(filename);
|
||||
let mut file = File::create(file_path)?;
|
||||
|
||||
let meta = serde_json::json!({
|
||||
"timestamp": ts_str,
|
||||
"type": "session_meta",
|
||||
"payload": {
|
||||
"id": uuid,
|
||||
"timestamp": ts_str,
|
||||
"cwd": ".",
|
||||
"originator": "test_originator",
|
||||
"cli_version": "test_version",
|
||||
"source": "vscode",
|
||||
"model_provider": "test-provider",
|
||||
"base_instructions": null,
|
||||
},
|
||||
});
|
||||
writeln!(file, "{meta}")?;
|
||||
|
||||
let thread_id = thread_id_from_uuid(uuid);
|
||||
let goal_event = EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent {
|
||||
thread_id,
|
||||
turn_id: None,
|
||||
goal: ThreadGoal {
|
||||
thread_id,
|
||||
objective: objective.to_string(),
|
||||
status: ThreadGoalStatus::Active,
|
||||
token_budget: None,
|
||||
tokens_used: 0,
|
||||
time_used_seconds: 0,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
},
|
||||
});
|
||||
let event = serde_json::json!({
|
||||
"timestamp": ts_str,
|
||||
"type": "event_msg",
|
||||
"payload": goal_event,
|
||||
});
|
||||
writeln!(file, "{event}")?;
|
||||
|
||||
if let Some(message) = later_user_message {
|
||||
let user_event = serde_json::json!({
|
||||
"timestamp": ts_str,
|
||||
"type": "event_msg",
|
||||
"payload": {
|
||||
"type": "user_message",
|
||||
"message": message,
|
||||
"kind": "plain"
|
||||
}
|
||||
});
|
||||
writeln!(file, "{user_event}")?;
|
||||
}
|
||||
|
||||
let times = FileTimes::new().set_modified(dt.into());
|
||||
file.set_times(times)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_session_file_with_delayed_user_event(
|
||||
root: &Path,
|
||||
ts_str: &str,
|
||||
@@ -644,6 +727,7 @@ async fn test_list_conversations_latest_first() {
|
||||
path: p1,
|
||||
thread_id: Some(thread_id_from_uuid(u3)),
|
||||
first_user_message: Some("Hello from user".to_string()),
|
||||
preview: Some("Hello from user".to_string()),
|
||||
cwd: Some(Path::new(".").to_path_buf()),
|
||||
git_branch: None,
|
||||
git_sha: None,
|
||||
@@ -660,6 +744,7 @@ async fn test_list_conversations_latest_first() {
|
||||
path: p2,
|
||||
thread_id: Some(thread_id_from_uuid(u2)),
|
||||
first_user_message: Some("Hello from user".to_string()),
|
||||
preview: Some("Hello from user".to_string()),
|
||||
cwd: Some(Path::new(".").to_path_buf()),
|
||||
git_branch: None,
|
||||
git_sha: None,
|
||||
@@ -676,6 +761,7 @@ async fn test_list_conversations_latest_first() {
|
||||
path: p3,
|
||||
thread_id: Some(thread_id_from_uuid(u1)),
|
||||
first_user_message: Some("Hello from user".to_string()),
|
||||
preview: Some("Hello from user".to_string()),
|
||||
cwd: Some(Path::new(".").to_path_buf()),
|
||||
git_branch: None,
|
||||
git_sha: None,
|
||||
@@ -785,6 +871,7 @@ async fn test_pagination_cursor() {
|
||||
path: p5,
|
||||
thread_id: Some(thread_id_from_uuid(u5)),
|
||||
first_user_message: Some("Hello from user".to_string()),
|
||||
preview: Some("Hello from user".to_string()),
|
||||
cwd: Some(Path::new(".").to_path_buf()),
|
||||
git_branch: None,
|
||||
git_sha: None,
|
||||
@@ -801,6 +888,7 @@ async fn test_pagination_cursor() {
|
||||
path: p4,
|
||||
thread_id: Some(thread_id_from_uuid(u4)),
|
||||
first_user_message: Some("Hello from user".to_string()),
|
||||
preview: Some("Hello from user".to_string()),
|
||||
cwd: Some(Path::new(".").to_path_buf()),
|
||||
git_branch: None,
|
||||
git_sha: None,
|
||||
@@ -853,6 +941,7 @@ async fn test_pagination_cursor() {
|
||||
path: p3,
|
||||
thread_id: Some(thread_id_from_uuid(u3)),
|
||||
first_user_message: Some("Hello from user".to_string()),
|
||||
preview: Some("Hello from user".to_string()),
|
||||
cwd: Some(Path::new(".").to_path_buf()),
|
||||
git_branch: None,
|
||||
git_sha: None,
|
||||
@@ -869,6 +958,7 @@ async fn test_pagination_cursor() {
|
||||
path: p2,
|
||||
thread_id: Some(thread_id_from_uuid(u2)),
|
||||
first_user_message: Some("Hello from user".to_string()),
|
||||
preview: Some("Hello from user".to_string()),
|
||||
cwd: Some(Path::new(".").to_path_buf()),
|
||||
git_branch: None,
|
||||
git_sha: None,
|
||||
@@ -913,6 +1003,7 @@ async fn test_pagination_cursor() {
|
||||
path: p1,
|
||||
thread_id: Some(thread_id_from_uuid(u1)),
|
||||
first_user_message: Some("Hello from user".to_string()),
|
||||
preview: Some("Hello from user".to_string()),
|
||||
cwd: Some(Path::new(".").to_path_buf()),
|
||||
git_branch: None,
|
||||
git_sha: None,
|
||||
@@ -960,6 +1051,83 @@ async fn test_list_threads_scans_past_head_for_user_event() {
|
||||
assert_eq!(page.items[0].thread_id, Some(thread_id_from_uuid(uuid)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_threads_uses_goal_objective_as_preview() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let home = temp.path();
|
||||
|
||||
let uuid = Uuid::from_u128(100);
|
||||
let ts = "2025-05-02T10-30-00";
|
||||
write_goal_started_session_file(
|
||||
home,
|
||||
ts,
|
||||
uuid,
|
||||
"optimize the benchmark",
|
||||
/*later_user_message*/ None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let provider_filter = provider_vec(&[TEST_PROVIDER]);
|
||||
let page = get_threads(
|
||||
home,
|
||||
/*page_size*/ 10,
|
||||
/*cursor*/ None,
|
||||
ThreadSortKey::CreatedAt,
|
||||
INTERACTIVE_SESSION_SOURCES.as_slice(),
|
||||
Some(provider_filter.as_slice()),
|
||||
/*cwd_filters*/ None,
|
||||
TEST_PROVIDER,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(page.items.len(), 1);
|
||||
let item = &page.items[0];
|
||||
assert_eq!(item.thread_id, Some(thread_id_from_uuid(uuid)));
|
||||
assert_eq!(item.preview.as_deref(), Some("optimize the benchmark"));
|
||||
assert_eq!(item.first_user_message, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_goal_first_thread_reads_later_user_message() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let home = temp.path();
|
||||
|
||||
let uuid = Uuid::from_u128(101);
|
||||
let ts = "2025-05-02T10-30-00";
|
||||
write_goal_started_session_file(
|
||||
home,
|
||||
ts,
|
||||
uuid,
|
||||
"optimize the benchmark",
|
||||
Some("run the benchmark"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let provider_filter = provider_vec(&[TEST_PROVIDER]);
|
||||
let page = get_threads(
|
||||
home,
|
||||
/*page_size*/ 10,
|
||||
/*cursor*/ None,
|
||||
ThreadSortKey::CreatedAt,
|
||||
INTERACTIVE_SESSION_SOURCES.as_slice(),
|
||||
Some(provider_filter.as_slice()),
|
||||
/*cwd_filters*/ None,
|
||||
TEST_PROVIDER,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(page.items.len(), 1);
|
||||
let item = &page.items[0];
|
||||
assert_eq!(item.thread_id, Some(thread_id_from_uuid(uuid)));
|
||||
assert_eq!(item.preview.as_deref(), Some("optimize the benchmark"));
|
||||
assert_eq!(
|
||||
item.first_user_message.as_deref(),
|
||||
Some("run the benchmark")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_thread_contents() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
@@ -1005,6 +1173,7 @@ async fn test_get_thread_contents() {
|
||||
path: expected_path,
|
||||
thread_id: Some(thread_id_from_uuid(uuid)),
|
||||
first_user_message: Some("Hello from user".to_string()),
|
||||
preview: Some("Hello from user".to_string()),
|
||||
cwd: Some(Path::new(".").to_path_buf()),
|
||||
git_branch: None,
|
||||
git_sha: None,
|
||||
@@ -1353,6 +1522,7 @@ async fn test_timestamp_only_cursor_skips_same_second_filesystem_ties() {
|
||||
path: p3,
|
||||
thread_id: Some(thread_id_from_uuid(u3)),
|
||||
first_user_message: Some("Hello from user".to_string()),
|
||||
preview: Some("Hello from user".to_string()),
|
||||
cwd: Some(Path::new(".").to_path_buf()),
|
||||
git_branch: None,
|
||||
git_sha: None,
|
||||
@@ -1369,6 +1539,7 @@ async fn test_timestamp_only_cursor_skips_same_second_filesystem_ties() {
|
||||
path: p2,
|
||||
thread_id: Some(thread_id_from_uuid(u2)),
|
||||
first_user_message: Some("Hello from user".to_string()),
|
||||
preview: Some("Hello from user".to_string()),
|
||||
cwd: Some(Path::new(".").to_path_buf()),
|
||||
git_branch: None,
|
||||
git_sha: None,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
ALTER TABLE threads ADD COLUMN preview TEXT NOT NULL DEFAULT '';
|
||||
|
||||
UPDATE threads
|
||||
SET preview = first_user_message
|
||||
WHERE preview = '' AND first_user_message <> '';
|
||||
|
||||
UPDATE threads
|
||||
SET preview = (
|
||||
SELECT thread_goals.objective
|
||||
FROM thread_goals
|
||||
WHERE thread_goals.thread_id = threads.id
|
||||
)
|
||||
WHERE preview = ''
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM thread_goals
|
||||
WHERE thread_goals.thread_id = threads.id
|
||||
AND thread_goals.objective <> ''
|
||||
);
|
||||
@@ -33,7 +33,9 @@ pub fn apply_rollout_item(
|
||||
pub fn rollout_item_affects_thread_metadata(item: &RolloutItem) -> bool {
|
||||
match item {
|
||||
RolloutItem::SessionMeta(_) | RolloutItem::TurnContext(_) => true,
|
||||
RolloutItem::EventMsg(EventMsg::TokenCount(_) | EventMsg::UserMessage(_)) => true,
|
||||
RolloutItem::EventMsg(
|
||||
EventMsg::TokenCount(_) | EventMsg::UserMessage(_) | EventMsg::ThreadGoalUpdated(_),
|
||||
) => true,
|
||||
RolloutItem::EventMsg(_) | RolloutItem::ResponseItem(_) | RolloutItem::Compacted(_) => {
|
||||
false
|
||||
}
|
||||
@@ -86,9 +88,11 @@ fn apply_event_msg(metadata: &mut ThreadMetadata, event: &EventMsg) {
|
||||
}
|
||||
}
|
||||
EventMsg::UserMessage(user) => {
|
||||
let preview = user_message_preview(user);
|
||||
if metadata.first_user_message.is_none() {
|
||||
metadata.first_user_message = user_message_preview(user);
|
||||
metadata.first_user_message = preview.clone();
|
||||
}
|
||||
set_preview_if_empty(metadata, preview);
|
||||
if metadata.title.is_empty() {
|
||||
let title = strip_user_message_prefix(user.message.as_str());
|
||||
if !title.is_empty() {
|
||||
@@ -96,12 +100,24 @@ fn apply_event_msg(metadata: &mut ThreadMetadata, event: &EventMsg) {
|
||||
}
|
||||
}
|
||||
}
|
||||
EventMsg::ThreadGoalUpdated(event) => {
|
||||
let objective = event.goal.objective.trim();
|
||||
if !objective.is_empty() {
|
||||
set_preview_if_empty(metadata, Some(objective.to_string()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_response_item(_metadata: &mut ThreadMetadata, _item: &ResponseItem) {}
|
||||
|
||||
fn set_preview_if_empty(metadata: &mut ThreadMetadata, preview: Option<String>) {
|
||||
if metadata.preview.is_none() {
|
||||
metadata.preview = preview;
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_user_message_prefix(text: &str) -> &str {
|
||||
match text.find(USER_MESSAGE_BEGIN) {
|
||||
Some(idx) => text[idx + USER_MESSAGE_BEGIN.len()..].trim(),
|
||||
@@ -151,6 +167,9 @@ mod tests {
|
||||
use codex_protocol::protocol::SessionMeta;
|
||||
use codex_protocol::protocol::SessionMetaLine;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::ThreadGoal;
|
||||
use codex_protocol::protocol::ThreadGoalStatus;
|
||||
use codex_protocol::protocol::ThreadGoalUpdatedEvent;
|
||||
use codex_protocol::protocol::TurnContextItem;
|
||||
use codex_protocol::protocol::USER_MESSAGE_BEGIN;
|
||||
use codex_protocol::protocol::UserMessageEvent;
|
||||
@@ -174,6 +193,7 @@ mod tests {
|
||||
apply_rollout_item(&mut metadata, &item, "test-provider");
|
||||
|
||||
assert_eq!(metadata.first_user_message, None);
|
||||
assert_eq!(metadata.preview, None);
|
||||
assert_eq!(metadata.title, "");
|
||||
}
|
||||
|
||||
@@ -193,6 +213,7 @@ mod tests {
|
||||
metadata.first_user_message.as_deref(),
|
||||
Some("actual user request")
|
||||
);
|
||||
assert_eq!(metadata.preview.as_deref(), Some("actual user request"));
|
||||
assert_eq!(metadata.title, "actual user request");
|
||||
}
|
||||
|
||||
@@ -212,6 +233,10 @@ mod tests {
|
||||
metadata.first_user_message.as_deref(),
|
||||
Some(super::IMAGE_ONLY_USER_MESSAGE_PLACEHOLDER)
|
||||
);
|
||||
assert_eq!(
|
||||
metadata.preview.as_deref(),
|
||||
Some(super::IMAGE_ONLY_USER_MESSAGE_PLACEHOLDER)
|
||||
);
|
||||
assert_eq!(metadata.title, "");
|
||||
}
|
||||
|
||||
@@ -228,9 +253,52 @@ mod tests {
|
||||
apply_rollout_item(&mut metadata, &item, "test-provider");
|
||||
|
||||
assert_eq!(metadata.first_user_message, None);
|
||||
assert_eq!(metadata.preview, None);
|
||||
assert_eq!(metadata.title, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_msg_thread_goal_sets_preview_only_and_later_user_sets_message_title() {
|
||||
let mut metadata = metadata_for_test();
|
||||
let goal_item =
|
||||
RolloutItem::EventMsg(EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent {
|
||||
thread_id: metadata.id,
|
||||
turn_id: None,
|
||||
goal: ThreadGoal {
|
||||
thread_id: metadata.id,
|
||||
objective: "optimize the benchmark".to_string(),
|
||||
status: ThreadGoalStatus::Active,
|
||||
token_budget: None,
|
||||
tokens_used: 0,
|
||||
time_used_seconds: 0,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
},
|
||||
}));
|
||||
|
||||
apply_rollout_item(&mut metadata, &goal_item, "test-provider");
|
||||
|
||||
assert_eq!(metadata.preview.as_deref(), Some("optimize the benchmark"));
|
||||
assert_eq!(metadata.first_user_message, None);
|
||||
assert_eq!(metadata.title, "");
|
||||
|
||||
let user_item = RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
|
||||
message: format!("{USER_MESSAGE_BEGIN} next normal prompt"),
|
||||
images: Some(vec![]),
|
||||
local_images: vec![],
|
||||
text_elements: vec![],
|
||||
}));
|
||||
|
||||
apply_rollout_item(&mut metadata, &user_item, "test-provider");
|
||||
|
||||
assert_eq!(metadata.preview.as_deref(), Some("optimize the benchmark"));
|
||||
assert_eq!(
|
||||
metadata.first_user_message.as_deref(),
|
||||
Some("next normal prompt")
|
||||
);
|
||||
assert_eq!(metadata.title, "next normal prompt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_context_does_not_override_session_cwd() {
|
||||
let mut metadata = metadata_for_test();
|
||||
@@ -421,6 +489,7 @@ mod tests {
|
||||
cwd: PathBuf::from("/tmp"),
|
||||
cli_version: "0.0.0".to_string(),
|
||||
title: String::new(),
|
||||
preview: None,
|
||||
sandbox_policy: "read-only".to_string(),
|
||||
approval_mode: "on-request".to_string(),
|
||||
tokens_used: 1,
|
||||
|
||||
@@ -89,6 +89,8 @@ pub struct ThreadMetadata {
|
||||
pub cli_version: String,
|
||||
/// A best-effort thread title.
|
||||
pub title: String,
|
||||
/// Best available user-facing preview for discovery and list display.
|
||||
pub preview: Option<String>,
|
||||
/// The sandbox policy (stringified enum).
|
||||
pub sandbox_policy: String,
|
||||
/// The approval mode (stringified enum).
|
||||
@@ -210,6 +212,7 @@ impl ThreadMetadataBuilder {
|
||||
cwd: self.cwd.clone(),
|
||||
cli_version: self.cli_version.clone().unwrap_or_default(),
|
||||
title: String::new(),
|
||||
preview: None,
|
||||
sandbox_policy,
|
||||
approval_mode,
|
||||
tokens_used: 0,
|
||||
@@ -281,6 +284,9 @@ impl ThreadMetadata {
|
||||
if self.title != other.title {
|
||||
diffs.push("title");
|
||||
}
|
||||
if self.preview != other.preview {
|
||||
diffs.push("preview");
|
||||
}
|
||||
if self.sandbox_policy != other.sandbox_policy {
|
||||
diffs.push("sandbox_policy");
|
||||
}
|
||||
@@ -330,6 +336,7 @@ pub(crate) struct ThreadRow {
|
||||
cwd: String,
|
||||
cli_version: String,
|
||||
title: String,
|
||||
preview: String,
|
||||
sandbox_policy: String,
|
||||
approval_mode: String,
|
||||
tokens_used: i64,
|
||||
@@ -358,6 +365,7 @@ impl ThreadRow {
|
||||
cwd: row.try_get("cwd")?,
|
||||
cli_version: row.try_get("cli_version")?,
|
||||
title: row.try_get("title")?,
|
||||
preview: row.try_get("preview")?,
|
||||
sandbox_policy: row.try_get("sandbox_policy")?,
|
||||
approval_mode: row.try_get("approval_mode")?,
|
||||
tokens_used: row.try_get("tokens_used")?,
|
||||
@@ -390,6 +398,7 @@ impl TryFrom<ThreadRow> for ThreadMetadata {
|
||||
cwd,
|
||||
cli_version,
|
||||
title,
|
||||
preview,
|
||||
sandbox_policy,
|
||||
approval_mode,
|
||||
tokens_used,
|
||||
@@ -420,6 +429,7 @@ impl TryFrom<ThreadRow> for ThreadMetadata {
|
||||
cwd: PathBuf::from(cwd),
|
||||
cli_version,
|
||||
title,
|
||||
preview: (!preview.is_empty()).then_some(preview),
|
||||
sandbox_policy,
|
||||
approval_mode,
|
||||
tokens_used,
|
||||
@@ -505,6 +515,7 @@ mod tests {
|
||||
cwd: "/tmp/workspace".to_string(),
|
||||
cli_version: "0.0.0".to_string(),
|
||||
title: String::new(),
|
||||
preview: String::new(),
|
||||
sandbox_policy: "read-only".to_string(),
|
||||
approval_mode: "on-request".to_string(),
|
||||
tokens_used: 1,
|
||||
@@ -534,6 +545,7 @@ mod tests {
|
||||
cwd: PathBuf::from("/tmp/workspace"),
|
||||
cli_version: "0.0.0".to_string(),
|
||||
title: String::new(),
|
||||
preview: None,
|
||||
sandbox_policy: "read-only".to_string(),
|
||||
approval_mode: "on-request".to_string(),
|
||||
tokens_used: 1,
|
||||
|
||||
@@ -102,6 +102,8 @@ RETURNING
|
||||
.fetch_one(self.pool.as_ref())
|
||||
.await?;
|
||||
|
||||
self.set_thread_preview_if_empty(thread_id, objective)
|
||||
.await?;
|
||||
thread_goal_from_row(&row)
|
||||
}
|
||||
|
||||
@@ -151,6 +153,10 @@ RETURNING
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await?;
|
||||
|
||||
if row.is_some() {
|
||||
self.set_thread_preview_if_empty(thread_id, objective)
|
||||
.await?;
|
||||
}
|
||||
row.map(|row| thread_goal_from_row(&row)).transpose()
|
||||
}
|
||||
|
||||
@@ -271,6 +277,29 @@ WHERE thread_id = ?
|
||||
self.get_thread_goal(thread_id).await
|
||||
}
|
||||
|
||||
async fn set_thread_preview_if_empty(
|
||||
&self,
|
||||
thread_id: ThreadId,
|
||||
preview: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let preview = preview.trim();
|
||||
if preview.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE threads
|
||||
SET preview = ?
|
||||
WHERE id = ? AND preview = ''
|
||||
"#,
|
||||
)
|
||||
.bind(preview)
|
||||
.bind(thread_id.to_string())
|
||||
.execute(self.pool.as_ref())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn pause_active_thread_goal(
|
||||
&self,
|
||||
thread_id: ThreadId,
|
||||
@@ -471,6 +500,12 @@ mod tests {
|
||||
Some(goal.clone()),
|
||||
runtime.get_thread_goal(thread_id).await.unwrap()
|
||||
);
|
||||
let metadata = runtime
|
||||
.get_thread(thread_id)
|
||||
.await
|
||||
.expect("thread metadata should load")
|
||||
.expect("thread should exist");
|
||||
assert_eq!(metadata.preview.as_deref(), Some("hello"));
|
||||
|
||||
let updated = runtime
|
||||
.update_thread_goal(
|
||||
@@ -512,6 +547,41 @@ mod tests {
|
||||
assert!(!runtime.delete_thread_goal(thread_id).await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replace_thread_goal_sets_preview_when_empty() {
|
||||
let runtime = test_runtime().await;
|
||||
let thread_id = test_thread_id();
|
||||
let mut metadata = test_thread_metadata(
|
||||
runtime.codex_home(),
|
||||
thread_id,
|
||||
runtime.codex_home().join("workspace"),
|
||||
);
|
||||
metadata.preview = None;
|
||||
metadata.first_user_message = None;
|
||||
runtime
|
||||
.upsert_thread(&metadata)
|
||||
.await
|
||||
.expect("test thread should be upserted");
|
||||
|
||||
runtime
|
||||
.replace_thread_goal(
|
||||
thread_id,
|
||||
"optimize the benchmark",
|
||||
crate::ThreadGoalStatus::Active,
|
||||
/*token_budget*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("goal replacement should succeed");
|
||||
|
||||
let metadata = runtime
|
||||
.get_thread(thread_id)
|
||||
.await
|
||||
.expect("thread metadata should load")
|
||||
.expect("thread should exist");
|
||||
assert_eq!(metadata.preview.as_deref(), Some("optimize the benchmark"));
|
||||
assert_eq!(metadata.first_user_message, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replace_thread_goal_applies_budget_limit_immediately() {
|
||||
let runtime = test_runtime().await;
|
||||
|
||||
@@ -147,6 +147,7 @@ SELECT
|
||||
threads.cwd,
|
||||
threads.cli_version,
|
||||
threads.title,
|
||||
threads.preview,
|
||||
threads.sandbox_policy,
|
||||
threads.approval_mode,
|
||||
threads.tokens_used,
|
||||
|
||||
@@ -58,6 +58,7 @@ pub(super) fn test_thread_metadata(
|
||||
cwd,
|
||||
cli_version: "0.0.0".to_string(),
|
||||
title: String::new(),
|
||||
preview: Some("hello".to_string()),
|
||||
sandbox_policy: crate::extract::enum_to_string(&SandboxPolicy::new_read_only_policy()),
|
||||
approval_mode: crate::extract::enum_to_string(&AskForApproval::OnRequest),
|
||||
tokens_used: 0,
|
||||
|
||||
@@ -23,6 +23,7 @@ SELECT
|
||||
threads.cwd,
|
||||
threads.cli_version,
|
||||
threads.title,
|
||||
threads.preview,
|
||||
threads.sandbox_policy,
|
||||
threads.approval_mode,
|
||||
threads.tokens_used,
|
||||
@@ -477,6 +478,7 @@ ON CONFLICT(child_thread_id) DO NOTHING
|
||||
metadata: &crate::ThreadMetadata,
|
||||
) -> anyhow::Result<bool> {
|
||||
let updated_at = self.allocate_thread_updated_at(metadata.updated_at)?;
|
||||
let preview = metadata_preview(metadata);
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO threads (
|
||||
@@ -497,6 +499,7 @@ INSERT INTO threads (
|
||||
cwd,
|
||||
cli_version,
|
||||
title,
|
||||
preview,
|
||||
sandbox_policy,
|
||||
approval_mode,
|
||||
tokens_used,
|
||||
@@ -507,7 +510,7 @@ INSERT INTO threads (
|
||||
git_branch,
|
||||
git_origin_url,
|
||||
memory_mode
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO NOTHING
|
||||
"#,
|
||||
)
|
||||
@@ -537,6 +540,7 @@ ON CONFLICT(id) DO NOTHING
|
||||
.bind(metadata.cwd.display().to_string())
|
||||
.bind(metadata.cli_version.as_str())
|
||||
.bind(metadata.title.as_str())
|
||||
.bind(preview)
|
||||
.bind(metadata.sandbox_policy.as_str())
|
||||
.bind(metadata.approval_mode.as_str())
|
||||
.bind(metadata.tokens_used)
|
||||
@@ -677,6 +681,7 @@ WHERE id = ?
|
||||
creation_memory_mode: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
let updated_at = self.allocate_thread_updated_at(metadata.updated_at)?;
|
||||
let preview = metadata_preview(metadata);
|
||||
// Backfill/reconcile callers merge existing git info before upserting, but that
|
||||
// read/modify/write is not atomic. Preserve non-null SQLite git fields here so
|
||||
// an explicit metadata update cannot be lost if a stale rollout upsert lands later.
|
||||
@@ -700,6 +705,7 @@ INSERT INTO threads (
|
||||
cwd,
|
||||
cli_version,
|
||||
title,
|
||||
preview,
|
||||
sandbox_policy,
|
||||
approval_mode,
|
||||
tokens_used,
|
||||
@@ -710,7 +716,7 @@ INSERT INTO threads (
|
||||
git_branch,
|
||||
git_origin_url,
|
||||
memory_mode
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
rollout_path = excluded.rollout_path,
|
||||
created_at = excluded.created_at,
|
||||
@@ -728,6 +734,7 @@ ON CONFLICT(id) DO UPDATE SET
|
||||
cwd = excluded.cwd,
|
||||
cli_version = excluded.cli_version,
|
||||
title = excluded.title,
|
||||
preview = COALESCE(NULLIF(excluded.preview, ''), threads.preview),
|
||||
sandbox_policy = excluded.sandbox_policy,
|
||||
approval_mode = excluded.approval_mode,
|
||||
tokens_used = excluded.tokens_used,
|
||||
@@ -765,6 +772,7 @@ ON CONFLICT(id) DO UPDATE SET
|
||||
.bind(metadata.cwd.display().to_string())
|
||||
.bind(metadata.cli_version.as_str())
|
||||
.bind(metadata.title.as_str())
|
||||
.bind(preview)
|
||||
.bind(metadata.sandbox_policy.as_str())
|
||||
.bind(metadata.approval_mode.as_str())
|
||||
.bind(metadata.tokens_used)
|
||||
@@ -982,6 +990,7 @@ SELECT
|
||||
threads.cwd,
|
||||
threads.cli_version,
|
||||
threads.title,
|
||||
threads.preview,
|
||||
threads.sandbox_policy,
|
||||
threads.approval_mode,
|
||||
threads.tokens_used,
|
||||
@@ -1058,7 +1067,7 @@ pub(super) fn push_thread_filters<'a>(
|
||||
} else {
|
||||
builder.push(" AND threads.archived = 0");
|
||||
}
|
||||
builder.push(" AND threads.first_user_message <> ''");
|
||||
builder.push(" AND threads.preview <> ''");
|
||||
if !allowed_sources.is_empty() {
|
||||
builder.push(" AND threads.source IN (");
|
||||
let mut separated = builder.separated(", ");
|
||||
@@ -1092,9 +1101,11 @@ pub(super) fn push_thread_filters<'a>(
|
||||
None => {}
|
||||
}
|
||||
if let Some(search_term) = search_term {
|
||||
builder.push(" AND instr(threads.title, ");
|
||||
builder.push(" AND (instr(threads.title, ");
|
||||
builder.push_bind(search_term);
|
||||
builder.push(") > 0");
|
||||
builder.push(") > 0 OR instr(threads.preview, ");
|
||||
builder.push_bind(search_term);
|
||||
builder.push(") > 0)");
|
||||
}
|
||||
if let Some(anchor) = anchor {
|
||||
let anchor_ts = datetime_to_epoch_millis(anchor.ts);
|
||||
@@ -1138,6 +1149,14 @@ pub(super) fn push_thread_order_and_limit(
|
||||
builder.push_bind(limit as i64);
|
||||
}
|
||||
|
||||
fn metadata_preview(metadata: &crate::ThreadMetadata) -> &str {
|
||||
metadata
|
||||
.preview
|
||||
.as_deref()
|
||||
.or(metadata.first_user_message.as_deref())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1513,6 +1532,39 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upsert_thread_preserves_existing_preview_when_incoming_preview_is_empty() {
|
||||
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-000000000459").expect("valid thread id");
|
||||
let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
|
||||
metadata.first_user_message = None;
|
||||
metadata.preview = Some("migrated goal preview".to_string());
|
||||
|
||||
runtime
|
||||
.upsert_thread(&metadata)
|
||||
.await
|
||||
.expect("initial upsert should succeed");
|
||||
|
||||
let mut rollout_metadata = metadata.clone();
|
||||
rollout_metadata.preview = None;
|
||||
|
||||
runtime
|
||||
.upsert_thread(&rollout_metadata)
|
||||
.await
|
||||
.expect("rollout upsert should succeed");
|
||||
|
||||
let persisted = runtime
|
||||
.get_thread(thread_id)
|
||||
.await
|
||||
.expect("thread should load")
|
||||
.expect("thread should exist");
|
||||
assert_eq!(persisted.preview.as_deref(), Some("migrated goal preview"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_thread_git_info_preserves_newer_non_git_metadata() {
|
||||
let codex_home = unique_temp_dir();
|
||||
@@ -1532,12 +1584,13 @@ mod tests {
|
||||
DateTime::<Utc>::from_timestamp(1_700_000_100, 0).expect("timestamp"),
|
||||
);
|
||||
sqlx::query(
|
||||
"UPDATE threads SET updated_at = ?, updated_at_ms = ?, tokens_used = ?, first_user_message = ? WHERE id = ?",
|
||||
"UPDATE threads SET updated_at = ?, updated_at_ms = ?, tokens_used = ?, first_user_message = ?, preview = ? WHERE id = ?",
|
||||
)
|
||||
.bind(updated_at / 1000)
|
||||
.bind(updated_at)
|
||||
.bind(123_i64)
|
||||
.bind("newer preview")
|
||||
.bind("newer preview")
|
||||
.bind(thread_id.to_string())
|
||||
.execute(runtime.pool.as_ref())
|
||||
.await
|
||||
@@ -1564,6 +1617,7 @@ mod tests {
|
||||
persisted.first_user_message.as_deref(),
|
||||
Some("newer preview")
|
||||
);
|
||||
assert_eq!(persisted.preview.as_deref(), Some("newer preview"));
|
||||
assert_eq!(datetime_to_epoch_millis(persisted.updated_at), updated_at);
|
||||
assert_eq!(persisted.git_sha.as_deref(), Some("abc123"));
|
||||
assert_eq!(persisted.git_branch.as_deref(), Some("feature/branch"));
|
||||
@@ -1585,6 +1639,7 @@ mod tests {
|
||||
let mut existing = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
|
||||
existing.tokens_used = 123;
|
||||
existing.first_user_message = Some("newer preview".to_string());
|
||||
existing.preview = Some("newer preview".to_string());
|
||||
existing.updated_at = DateTime::<Utc>::from_timestamp(1_700_000_100, 0).expect("timestamp");
|
||||
runtime
|
||||
.upsert_thread(&existing)
|
||||
@@ -1594,6 +1649,7 @@ mod tests {
|
||||
let mut fallback = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
|
||||
fallback.tokens_used = 0;
|
||||
fallback.first_user_message = None;
|
||||
fallback.preview = None;
|
||||
fallback.updated_at = DateTime::<Utc>::from_timestamp(1_700_000_000, 0).expect("timestamp");
|
||||
|
||||
let inserted = runtime
|
||||
@@ -1612,6 +1668,7 @@ mod tests {
|
||||
persisted.first_user_message.as_deref(),
|
||||
Some("newer preview")
|
||||
);
|
||||
assert_eq!(persisted.preview.as_deref(), Some("newer preview"));
|
||||
assert_eq!(
|
||||
datetime_to_epoch_millis(persisted.updated_at),
|
||||
datetime_to_epoch_millis(existing.updated_at)
|
||||
@@ -1663,6 +1720,7 @@ mod tests {
|
||||
let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
|
||||
metadata.title = "original title".to_string();
|
||||
metadata.first_user_message = Some("first-user-message".to_string());
|
||||
metadata.preview = None;
|
||||
|
||||
runtime
|
||||
.upsert_thread(&metadata)
|
||||
@@ -1687,6 +1745,7 @@ mod tests {
|
||||
persisted.first_user_message.as_deref(),
|
||||
Some("first-user-message")
|
||||
);
|
||||
assert_eq!(persisted.preview.as_deref(), Some("first-user-message"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -110,7 +110,11 @@ pub(super) fn stored_thread_from_rollout_item(
|
||||
item.git_origin_url.clone(),
|
||||
);
|
||||
let source = item.source.unwrap_or(SessionSource::Unknown);
|
||||
let preview = item.first_user_message.clone().unwrap_or_default();
|
||||
let preview = item
|
||||
.preview
|
||||
.clone()
|
||||
.or_else(|| item.first_user_message.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
Some(StoredThread {
|
||||
thread_id,
|
||||
|
||||
@@ -267,6 +267,7 @@ mod tests {
|
||||
let mut metadata = builder.build(config.default_model_provider_id.as_str());
|
||||
metadata.title = "needle title".to_string();
|
||||
metadata.first_user_message = Some("plain preview".to_string());
|
||||
metadata.preview = metadata.first_user_message.clone();
|
||||
runtime
|
||||
.upsert_thread(&metadata)
|
||||
.await
|
||||
|
||||
@@ -280,11 +280,16 @@ async fn stored_thread_from_sqlite_metadata(
|
||||
.ok()
|
||||
.map(|meta_line| meta_line.meta);
|
||||
let forked_from_id = session_meta.as_ref().and_then(|meta| meta.forked_from_id);
|
||||
let preview = metadata
|
||||
.preview
|
||||
.clone()
|
||||
.or_else(|| metadata.first_user_message.clone())
|
||||
.unwrap_or_default();
|
||||
StoredThread {
|
||||
thread_id: metadata.id,
|
||||
rollout_path: Some(metadata.rollout_path),
|
||||
forked_from_id,
|
||||
preview: metadata.first_user_message.clone().unwrap_or_default(),
|
||||
preview,
|
||||
name,
|
||||
model_provider: if metadata.model_provider.is_empty() {
|
||||
store.config.default_model_provider_id.clone()
|
||||
@@ -1009,8 +1014,9 @@ mod tests {
|
||||
builder.cwd = external.path().join("workspace");
|
||||
builder.cli_version = Some("sqlite-cli".to_string());
|
||||
let mut metadata = builder.build(config.default_model_provider_id.as_str());
|
||||
metadata.title = "SQLite title".to_string();
|
||||
metadata.first_user_message = Some("SQLite preview".to_string());
|
||||
metadata.preview = Some("optimize the benchmark".to_string());
|
||||
metadata.first_user_message = Some("next normal prompt".to_string());
|
||||
metadata.title = "next normal prompt".to_string();
|
||||
metadata.model = Some("sqlite-model".to_string());
|
||||
runtime
|
||||
.upsert_thread(&metadata)
|
||||
@@ -1028,9 +1034,12 @@ mod tests {
|
||||
|
||||
assert_eq!(thread.thread_id, thread_id);
|
||||
assert_eq!(thread.rollout_path, Some(rollout_path));
|
||||
assert_eq!(thread.preview, "SQLite preview");
|
||||
assert_eq!(thread.first_user_message.as_deref(), Some("SQLite preview"));
|
||||
assert_eq!(thread.name.as_deref(), Some("SQLite title"));
|
||||
assert_eq!(thread.preview, "optimize the benchmark");
|
||||
assert_eq!(
|
||||
thread.first_user_message.as_deref(),
|
||||
Some("next normal prompt")
|
||||
);
|
||||
assert_eq!(thread.name, None);
|
||||
assert_eq!(thread.model_provider, "sqlite-provider");
|
||||
assert_eq!(thread.model.as_deref(), Some("sqlite-model"));
|
||||
assert_eq!(thread.cwd, external.path().join("workspace"));
|
||||
|
||||
Reference in New Issue
Block a user