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:
Eric Traut authored and GitHub committed 2026-05-11 10:12:46 -07:00
1 parent 96836e15ed
commit f10ddc3f13
18 files changed
+499 -33

No files matched your search

+70
View File
@@ -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;