mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00: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:
1 parent
96836e15ed
commit
f10ddc3f13
18 files changed
+499
-33
No files matched your search
@@ -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> {
|
||||
|
||||
Reference in new issue
Block a user