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

+47 -13
View File
@@ -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> {