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
2026-05-11 10:12:46 -07:00
committed by GitHub
parent 96836e15ed
commit f10ddc3f13
18 changed files with 499 additions and 33 deletions
+5 -1
View File
@@ -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
+15 -6
View File
@@ -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"));