mirror of
https://github.com/openai/codex.git
synced 2026-04-24 14:45:27 +00:00
make it better
This commit is contained in:
@@ -5077,8 +5077,7 @@ async fn read_summary_from_state_db_context_by_thread_id(
|
||||
Some(summary_from_state_db_metadata(
|
||||
metadata.id,
|
||||
metadata.rollout_path,
|
||||
metadata.has_user_event,
|
||||
metadata.title,
|
||||
metadata.first_user_message,
|
||||
metadata
|
||||
.created_at
|
||||
.to_rfc3339_opts(SecondsFormat::Secs, true),
|
||||
@@ -5100,23 +5099,46 @@ async fn summary_from_thread_list_item(
|
||||
fallback_provider: &str,
|
||||
state_db_ctx: Option<&StateDbHandle>,
|
||||
) -> Option<ConversationSummary> {
|
||||
let updated_at = it.updated_at.clone();
|
||||
if let Some(session_meta_line) = it
|
||||
.head
|
||||
.first()
|
||||
.and_then(|first| serde_json::from_value::<SessionMetaLine>(first.clone()).ok())
|
||||
{
|
||||
return extract_conversation_summary(
|
||||
it.path,
|
||||
&it.head,
|
||||
&session_meta_line.meta,
|
||||
session_meta_line.git.as_ref(),
|
||||
fallback_provider,
|
||||
if let Some(thread_id) = it.thread_id {
|
||||
let timestamp = it.created_at.clone();
|
||||
let updated_at = it.updated_at.clone().or_else(|| timestamp.clone());
|
||||
let model_provider = it
|
||||
.model_provider
|
||||
.clone()
|
||||
.unwrap_or_else(|| fallback_provider.to_string());
|
||||
let cwd = it.cwd?;
|
||||
let cli_version = it.cli_version.unwrap_or_default();
|
||||
let source = it
|
||||
.source
|
||||
.unwrap_or(codex_protocol::protocol::SessionSource::Unknown);
|
||||
return Some(ConversationSummary {
|
||||
conversation_id: thread_id,
|
||||
path: it.path,
|
||||
preview: it.first_user_message.unwrap_or_default(),
|
||||
timestamp,
|
||||
updated_at,
|
||||
);
|
||||
model_provider,
|
||||
cwd,
|
||||
cli_version,
|
||||
source,
|
||||
git_info: if it.git_sha.is_none()
|
||||
&& it.git_branch.is_none()
|
||||
&& it.git_origin_url.is_none()
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(ConversationGitInfo {
|
||||
sha: it.git_sha,
|
||||
branch: it.git_branch,
|
||||
origin_url: it.git_origin_url,
|
||||
})
|
||||
},
|
||||
});
|
||||
}
|
||||
let thread_id = thread_id_from_rollout_path(it.path.as_path())?;
|
||||
read_summary_from_state_db_context_by_thread_id(state_db_ctx, thread_id).await
|
||||
if let Some(thread_id) = thread_id_from_rollout_path(it.path.as_path()) {
|
||||
return read_summary_from_state_db_context_by_thread_id(state_db_ctx, thread_id).await;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn thread_id_from_rollout_path(path: &Path) -> Option<ThreadId> {
|
||||
@@ -5136,8 +5158,7 @@ fn thread_id_from_rollout_path(path: &Path) -> Option<ThreadId> {
|
||||
fn summary_from_state_db_metadata(
|
||||
conversation_id: ThreadId,
|
||||
path: PathBuf,
|
||||
has_user_event: bool,
|
||||
title: String,
|
||||
first_user_message: Option<String>,
|
||||
timestamp: String,
|
||||
updated_at: String,
|
||||
model_provider: String,
|
||||
@@ -5148,7 +5169,7 @@ fn summary_from_state_db_metadata(
|
||||
git_branch: Option<String>,
|
||||
git_origin_url: Option<String>,
|
||||
) -> ConversationSummary {
|
||||
let preview = if has_user_event { title } else { String::new() };
|
||||
let preview = first_user_message.unwrap_or_default();
|
||||
let source = serde_json::from_value(serde_json::Value::String(source))
|
||||
.unwrap_or(codex_protocol::protocol::SessionSource::Unknown);
|
||||
let git_info = if git_sha.is_none() && git_branch.is_none() && git_origin_url.is_none() {
|
||||
|
||||
@@ -15,9 +15,7 @@ use uuid::Uuid;
|
||||
|
||||
use super::ARCHIVED_SESSIONS_SUBDIR;
|
||||
use super::SESSIONS_SUBDIR;
|
||||
use crate::instructions::UserInstructions;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::session_prefix::is_session_prefix_content;
|
||||
use crate::state_db;
|
||||
use codex_file_search as file_search;
|
||||
use codex_protocol::ThreadId;
|
||||
@@ -25,6 +23,7 @@ use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::RolloutLine;
|
||||
use codex_protocol::protocol::SessionMetaLine;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::USER_MESSAGE_BEGIN;
|
||||
|
||||
/// Returned page of thread (thread) summaries.
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
@@ -44,8 +43,24 @@ pub struct ThreadsPage {
|
||||
pub struct ThreadItem {
|
||||
/// Absolute path to the rollout file.
|
||||
pub path: PathBuf,
|
||||
/// First up to `HEAD_RECORD_LIMIT` JSONL records parsed as JSON (includes meta line).
|
||||
pub head: Vec<serde_json::Value>,
|
||||
/// Thread ID from session metadata.
|
||||
pub thread_id: Option<ThreadId>,
|
||||
/// First user message captured for this thread, if any.
|
||||
pub first_user_message: Option<String>,
|
||||
/// Working directory from session metadata.
|
||||
pub cwd: Option<PathBuf>,
|
||||
/// Git branch from session metadata.
|
||||
pub git_branch: Option<String>,
|
||||
/// Git commit SHA from session metadata.
|
||||
pub git_sha: Option<String>,
|
||||
/// Git origin URL from session metadata.
|
||||
pub git_origin_url: Option<String>,
|
||||
/// Session source from session metadata.
|
||||
pub source: Option<SessionSource>,
|
||||
/// Model provider from session metadata.
|
||||
pub model_provider: Option<String>,
|
||||
/// CLI version from session metadata.
|
||||
pub cli_version: Option<String>,
|
||||
/// RFC3339 timestamp string for when the session was created, if available.
|
||||
/// created_at comes from the filename timestamp with second precision.
|
||||
pub created_at: Option<String>,
|
||||
@@ -63,11 +78,17 @@ pub type ConversationsPage = ThreadsPage;
|
||||
|
||||
#[derive(Default)]
|
||||
struct HeadTailSummary {
|
||||
head: Vec<serde_json::Value>,
|
||||
saw_session_meta: bool,
|
||||
saw_user_event: bool,
|
||||
thread_id: Option<ThreadId>,
|
||||
first_user_message: Option<String>,
|
||||
cwd: Option<PathBuf>,
|
||||
git_branch: Option<String>,
|
||||
git_sha: Option<String>,
|
||||
git_origin_url: Option<String>,
|
||||
source: Option<SessionSource>,
|
||||
model_provider: Option<String>,
|
||||
cli_version: Option<String>,
|
||||
created_at: Option<String>,
|
||||
updated_at: Option<String>,
|
||||
}
|
||||
@@ -674,6 +695,7 @@ async fn build_thread_item(
|
||||
if !allowed_sources.is_empty()
|
||||
&& !summary
|
||||
.source
|
||||
.as_ref()
|
||||
.is_some_and(|source| allowed_sources.contains(&source))
|
||||
{
|
||||
return None;
|
||||
@@ -686,7 +708,15 @@ async fn build_thread_item(
|
||||
// Apply filters: must have session meta and at least one user message event
|
||||
if summary.saw_session_meta && summary.saw_user_event {
|
||||
let HeadTailSummary {
|
||||
head,
|
||||
thread_id,
|
||||
first_user_message,
|
||||
cwd,
|
||||
git_branch,
|
||||
git_sha,
|
||||
git_origin_url,
|
||||
source,
|
||||
model_provider,
|
||||
cli_version,
|
||||
created_at,
|
||||
updated_at: mut summary_updated_at,
|
||||
..
|
||||
@@ -696,7 +726,15 @@ async fn build_thread_item(
|
||||
}
|
||||
return Some(ThreadItem {
|
||||
path,
|
||||
head,
|
||||
thread_id,
|
||||
first_user_message,
|
||||
cwd,
|
||||
git_branch,
|
||||
git_sha,
|
||||
git_origin_url,
|
||||
source,
|
||||
model_provider,
|
||||
cli_version,
|
||||
created_at,
|
||||
updated_at: summary_updated_at,
|
||||
});
|
||||
@@ -979,31 +1017,29 @@ async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result<HeadTai
|
||||
RolloutItem::SessionMeta(session_meta_line) => {
|
||||
summary.source = Some(session_meta_line.meta.source.clone());
|
||||
summary.model_provider = session_meta_line.meta.model_provider.clone();
|
||||
summary.thread_id = Some(session_meta_line.meta.id);
|
||||
summary.cwd = Some(session_meta_line.meta.cwd.clone());
|
||||
summary.git_branch = session_meta_line
|
||||
.git
|
||||
.as_ref()
|
||||
.and_then(|git| git.branch.clone());
|
||||
summary.git_sha = session_meta_line
|
||||
.git
|
||||
.as_ref()
|
||||
.and_then(|git| git.commit_hash.clone());
|
||||
summary.git_origin_url = session_meta_line
|
||||
.git
|
||||
.as_ref()
|
||||
.and_then(|git| git.repository_url.clone());
|
||||
summary.cli_version = Some(session_meta_line.meta.cli_version);
|
||||
summary.created_at = Some(session_meta_line.meta.timestamp.clone());
|
||||
summary.saw_session_meta = true;
|
||||
if summary.head.len() < head_limit
|
||||
&& let Ok(val) = serde_json::to_value(session_meta_line)
|
||||
{
|
||||
summary.head.push(val);
|
||||
}
|
||||
}
|
||||
RolloutItem::ResponseItem(item) => {
|
||||
RolloutItem::ResponseItem(_) => {
|
||||
summary.created_at = summary
|
||||
.created_at
|
||||
.clone()
|
||||
.or_else(|| Some(rollout_line.timestamp.clone()));
|
||||
if let codex_protocol::models::ResponseItem::Message { role, content, .. } = &item
|
||||
&& role == "user"
|
||||
&& !UserInstructions::is_user_instructions(content.as_slice())
|
||||
&& !is_session_prefix_content(content.as_slice())
|
||||
{
|
||||
summary.saw_user_event = true;
|
||||
}
|
||||
if summary.head.len() < head_limit
|
||||
&& let Ok(val) = serde_json::to_value(item)
|
||||
{
|
||||
summary.head.push(val);
|
||||
}
|
||||
}
|
||||
RolloutItem::TurnContext(_) => {
|
||||
// Not included in `head`; skip.
|
||||
@@ -1012,8 +1048,14 @@ async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result<HeadTai
|
||||
// Not included in `head`; skip.
|
||||
}
|
||||
RolloutItem::EventMsg(ev) => {
|
||||
if matches!(ev, EventMsg::UserMessage(_)) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1029,8 +1071,48 @@ async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result<HeadTai
|
||||
/// Read up to `HEAD_RECORD_LIMIT` records from the start of the rollout file at `path`.
|
||||
/// This should be enough to produce a summary including the session meta line.
|
||||
pub async fn read_head_for_summary(path: &Path) -> io::Result<Vec<serde_json::Value>> {
|
||||
let summary = read_head_summary(path, HEAD_RECORD_LIMIT).await?;
|
||||
Ok(summary.head)
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
|
||||
let file = tokio::fs::File::open(path).await?;
|
||||
let reader = tokio::io::BufReader::new(file);
|
||||
let mut lines = reader.lines();
|
||||
let mut head = Vec::new();
|
||||
|
||||
while head.len() < HEAD_RECORD_LIMIT {
|
||||
let Some(line) = lines.next_line().await? else {
|
||||
break;
|
||||
};
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(rollout_line) = serde_json::from_str::<RolloutLine>(trimmed) {
|
||||
match rollout_line.item {
|
||||
RolloutItem::SessionMeta(session_meta_line) => {
|
||||
if let Ok(value) = serde_json::to_value(session_meta_line) {
|
||||
head.push(value);
|
||||
}
|
||||
}
|
||||
RolloutItem::ResponseItem(item) => {
|
||||
if let Ok(value) = serde_json::to_value(item) {
|
||||
head.push(value);
|
||||
}
|
||||
}
|
||||
RolloutItem::Compacted(_)
|
||||
| RolloutItem::TurnContext(_)
|
||||
| RolloutItem::EventMsg(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(head)
|
||||
}
|
||||
|
||||
fn strip_user_message_prefix(text: &str) -> &str {
|
||||
match text.find(USER_MESSAGE_BEGIN) {
|
||||
Some(idx) => text[idx + USER_MESSAGE_BEGIN.len()..].trim(),
|
||||
None => text.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the SessionMetaLine from the head of a rollout file for reuse by
|
||||
|
||||
@@ -682,7 +682,18 @@ impl From<codex_state::ThreadsPage> for ThreadsPage {
|
||||
.into_iter()
|
||||
.map(|item| ThreadItem {
|
||||
path: item.rollout_path,
|
||||
head: Vec::new(),
|
||||
thread_id: Some(item.id),
|
||||
first_user_message: item.first_user_message,
|
||||
cwd: Some(item.cwd),
|
||||
git_branch: item.git_branch,
|
||||
git_sha: item.git_sha,
|
||||
git_origin_url: item.git_origin_url,
|
||||
source: Some(
|
||||
serde_json::from_value(Value::String(item.source))
|
||||
.unwrap_or(SessionSource::Unknown),
|
||||
),
|
||||
model_provider: Some(item.model_provider),
|
||||
cli_version: Some(item.cli_version),
|
||||
created_at: Some(item.created_at.to_rfc3339_opts(SecondsFormat::Secs, true)),
|
||||
updated_at: Some(item.updated_at.to_rfc3339_opts(SecondsFormat::Secs, true)),
|
||||
})
|
||||
@@ -699,7 +710,11 @@ impl From<codex_state::ThreadsPage> for ThreadsPage {
|
||||
fn select_resume_path(page: &ThreadsPage, filter_cwd: Option<&Path>) -> Option<PathBuf> {
|
||||
match filter_cwd {
|
||||
Some(cwd) => page.items.iter().find_map(|item| {
|
||||
if session_cwd_matches(&item.head, cwd) {
|
||||
if item
|
||||
.cwd
|
||||
.as_ref()
|
||||
.is_some_and(|session_cwd| cwd_matches(session_cwd, cwd))
|
||||
{
|
||||
Some(item.path.clone())
|
||||
} else {
|
||||
None
|
||||
@@ -725,20 +740,6 @@ fn select_resume_path_from_db_page(
|
||||
}
|
||||
}
|
||||
|
||||
fn session_cwd_matches(head: &[serde_json::Value], cwd: &Path) -> bool {
|
||||
let Some(session_cwd) = extract_session_cwd(head) else {
|
||||
return false;
|
||||
};
|
||||
cwd_matches(session_cwd.as_path(), cwd)
|
||||
}
|
||||
|
||||
fn extract_session_cwd(head: &[serde_json::Value]) -> Option<PathBuf> {
|
||||
head.iter().find_map(|value| {
|
||||
let meta_line = serde_json::from_value::<SessionMetaLine>(value.clone()).ok()?;
|
||||
Some(meta_line.meta.cwd)
|
||||
})
|
||||
}
|
||||
|
||||
fn cwd_matches(session_cwd: &Path, cwd: &Path) -> bool {
|
||||
if let (Ok(ca), Ok(cb)) = (
|
||||
path_utils::normalize_for_path_comparison(session_cwd),
|
||||
|
||||
@@ -23,6 +23,7 @@ use crate::rollout::list::ThreadItem;
|
||||
use crate::rollout::list::ThreadSortKey;
|
||||
use crate::rollout::list::ThreadsPage;
|
||||
use crate::rollout::list::get_threads;
|
||||
use crate::rollout::list::read_head_for_summary;
|
||||
use crate::rollout::recorder::RolloutRecorder;
|
||||
use crate::rollout::rollout_date_parts;
|
||||
use anyhow::Result;
|
||||
@@ -47,6 +48,10 @@ fn provider_vec(providers: &[&str]) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn thread_id_from_uuid(uuid: Uuid) -> ThreadId {
|
||||
ThreadId::from_string(&uuid.to_string()).expect("valid thread id")
|
||||
}
|
||||
|
||||
async fn insert_state_db_thread(
|
||||
home: &Path,
|
||||
thread_id: ThreadId,
|
||||
@@ -73,7 +78,7 @@ async fn insert_state_db_thread(
|
||||
builder.archived_at = Some(created_at);
|
||||
}
|
||||
let mut metadata = builder.build(TEST_PROVIDER);
|
||||
metadata.has_user_event = true;
|
||||
metadata.first_user_message = Some("Hello from user".to_string());
|
||||
runtime
|
||||
.upsert_thread(&metadata)
|
||||
.await
|
||||
@@ -115,6 +120,12 @@ async fn list_threads_prefers_state_db_when_available() {
|
||||
|
||||
assert_eq!(page.items.len(), 1);
|
||||
assert_eq!(page.items[0].path, db_rollout_path);
|
||||
assert_eq!(page.items[0].thread_id, Some(db_thread_id));
|
||||
assert_eq!(page.items[0].cwd, Some(home.to_path_buf()));
|
||||
assert_eq!(
|
||||
page.items[0].first_user_message.as_deref(),
|
||||
Some("Hello from user")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -519,37 +530,6 @@ async fn test_list_conversations_latest_first() {
|
||||
.join("01")
|
||||
.join(format!("rollout-2025-01-01T12-00-00-{u1}.jsonl"));
|
||||
|
||||
let head_3 = vec![serde_json::json!({
|
||||
"id": u3,
|
||||
"timestamp": "2025-01-03T12-00-00",
|
||||
"cwd": ".",
|
||||
"originator": "test_originator",
|
||||
"cli_version": "test_version",
|
||||
"source": "vscode",
|
||||
"model_provider": "test-provider",
|
||||
"base_instructions": null,
|
||||
})];
|
||||
let head_2 = vec![serde_json::json!({
|
||||
"id": u2,
|
||||
"timestamp": "2025-01-02T12-00-00",
|
||||
"cwd": ".",
|
||||
"originator": "test_originator",
|
||||
"cli_version": "test_version",
|
||||
"source": "vscode",
|
||||
"model_provider": "test-provider",
|
||||
"base_instructions": null,
|
||||
})];
|
||||
let head_1 = vec![serde_json::json!({
|
||||
"id": u1,
|
||||
"timestamp": "2025-01-01T12-00-00",
|
||||
"cwd": ".",
|
||||
"originator": "test_originator",
|
||||
"cli_version": "test_version",
|
||||
"source": "vscode",
|
||||
"model_provider": "test-provider",
|
||||
"base_instructions": null,
|
||||
})];
|
||||
|
||||
let updated_times: Vec<Option<String>> =
|
||||
page.items.iter().map(|i| i.updated_at.clone()).collect();
|
||||
|
||||
@@ -557,19 +537,43 @@ async fn test_list_conversations_latest_first() {
|
||||
items: vec![
|
||||
ThreadItem {
|
||||
path: p1,
|
||||
head: head_3,
|
||||
thread_id: Some(thread_id_from_uuid(u3)),
|
||||
first_user_message: Some("Hello from user".to_string()),
|
||||
cwd: Some(Path::new(".").to_path_buf()),
|
||||
git_branch: None,
|
||||
git_sha: None,
|
||||
git_origin_url: None,
|
||||
source: Some(SessionSource::VSCode),
|
||||
model_provider: Some(TEST_PROVIDER.to_string()),
|
||||
cli_version: Some("test_version".to_string()),
|
||||
created_at: Some("2025-01-03T12-00-00".into()),
|
||||
updated_at: updated_times.first().cloned().flatten(),
|
||||
},
|
||||
ThreadItem {
|
||||
path: p2,
|
||||
head: head_2,
|
||||
thread_id: Some(thread_id_from_uuid(u2)),
|
||||
first_user_message: Some("Hello from user".to_string()),
|
||||
cwd: Some(Path::new(".").to_path_buf()),
|
||||
git_branch: None,
|
||||
git_sha: None,
|
||||
git_origin_url: None,
|
||||
source: Some(SessionSource::VSCode),
|
||||
model_provider: Some(TEST_PROVIDER.to_string()),
|
||||
cli_version: Some("test_version".to_string()),
|
||||
created_at: Some("2025-01-02T12-00-00".into()),
|
||||
updated_at: updated_times.get(1).cloned().flatten(),
|
||||
},
|
||||
ThreadItem {
|
||||
path: p3,
|
||||
head: head_1,
|
||||
thread_id: Some(thread_id_from_uuid(u1)),
|
||||
first_user_message: Some("Hello from user".to_string()),
|
||||
cwd: Some(Path::new(".").to_path_buf()),
|
||||
git_branch: None,
|
||||
git_sha: None,
|
||||
git_origin_url: None,
|
||||
source: Some(SessionSource::VSCode),
|
||||
model_provider: Some(TEST_PROVIDER.to_string()),
|
||||
cli_version: Some("test_version".to_string()),
|
||||
created_at: Some("2025-01-01T12-00-00".into()),
|
||||
updated_at: updated_times.get(2).cloned().flatten(),
|
||||
},
|
||||
@@ -660,26 +664,6 @@ async fn test_pagination_cursor() {
|
||||
.join("03")
|
||||
.join("04")
|
||||
.join(format!("rollout-2025-03-04T09-00-00-{u4}.jsonl"));
|
||||
let head_5 = vec![serde_json::json!({
|
||||
"id": u5,
|
||||
"timestamp": "2025-03-05T09-00-00",
|
||||
"cwd": ".",
|
||||
"originator": "test_originator",
|
||||
"cli_version": "test_version",
|
||||
"source": "vscode",
|
||||
"model_provider": "test-provider",
|
||||
"base_instructions": null,
|
||||
})];
|
||||
let head_4 = vec![serde_json::json!({
|
||||
"id": u4,
|
||||
"timestamp": "2025-03-04T09-00-00",
|
||||
"cwd": ".",
|
||||
"originator": "test_originator",
|
||||
"cli_version": "test_version",
|
||||
"source": "vscode",
|
||||
"model_provider": "test-provider",
|
||||
"base_instructions": null,
|
||||
})];
|
||||
let updated_page1: Vec<Option<String>> =
|
||||
page1.items.iter().map(|i| i.updated_at.clone()).collect();
|
||||
let expected_cursor1: Cursor =
|
||||
@@ -688,13 +672,29 @@ async fn test_pagination_cursor() {
|
||||
items: vec![
|
||||
ThreadItem {
|
||||
path: p5,
|
||||
head: head_5,
|
||||
thread_id: Some(thread_id_from_uuid(u5)),
|
||||
first_user_message: Some("Hello from user".to_string()),
|
||||
cwd: Some(Path::new(".").to_path_buf()),
|
||||
git_branch: None,
|
||||
git_sha: None,
|
||||
git_origin_url: None,
|
||||
source: Some(SessionSource::VSCode),
|
||||
model_provider: Some(TEST_PROVIDER.to_string()),
|
||||
cli_version: Some("test_version".to_string()),
|
||||
created_at: Some("2025-03-05T09-00-00".into()),
|
||||
updated_at: updated_page1.first().cloned().flatten(),
|
||||
},
|
||||
ThreadItem {
|
||||
path: p4,
|
||||
head: head_4,
|
||||
thread_id: Some(thread_id_from_uuid(u4)),
|
||||
first_user_message: Some("Hello from user".to_string()),
|
||||
cwd: Some(Path::new(".").to_path_buf()),
|
||||
git_branch: None,
|
||||
git_sha: None,
|
||||
git_origin_url: None,
|
||||
source: Some(SessionSource::VSCode),
|
||||
model_provider: Some(TEST_PROVIDER.to_string()),
|
||||
cli_version: Some("test_version".to_string()),
|
||||
created_at: Some("2025-03-04T09-00-00".into()),
|
||||
updated_at: updated_page1.get(1).cloned().flatten(),
|
||||
},
|
||||
@@ -728,26 +728,6 @@ async fn test_pagination_cursor() {
|
||||
.join("03")
|
||||
.join("02")
|
||||
.join(format!("rollout-2025-03-02T09-00-00-{u2}.jsonl"));
|
||||
let head_3 = vec![serde_json::json!({
|
||||
"id": u3,
|
||||
"timestamp": "2025-03-03T09-00-00",
|
||||
"cwd": ".",
|
||||
"originator": "test_originator",
|
||||
"cli_version": "test_version",
|
||||
"source": "vscode",
|
||||
"model_provider": "test-provider",
|
||||
"base_instructions": null,
|
||||
})];
|
||||
let head_2 = vec![serde_json::json!({
|
||||
"id": u2,
|
||||
"timestamp": "2025-03-02T09-00-00",
|
||||
"cwd": ".",
|
||||
"originator": "test_originator",
|
||||
"cli_version": "test_version",
|
||||
"source": "vscode",
|
||||
"model_provider": "test-provider",
|
||||
"base_instructions": null,
|
||||
})];
|
||||
let updated_page2: Vec<Option<String>> =
|
||||
page2.items.iter().map(|i| i.updated_at.clone()).collect();
|
||||
let expected_cursor2: Cursor =
|
||||
@@ -756,13 +736,29 @@ async fn test_pagination_cursor() {
|
||||
items: vec![
|
||||
ThreadItem {
|
||||
path: p3,
|
||||
head: head_3,
|
||||
thread_id: Some(thread_id_from_uuid(u3)),
|
||||
first_user_message: Some("Hello from user".to_string()),
|
||||
cwd: Some(Path::new(".").to_path_buf()),
|
||||
git_branch: None,
|
||||
git_sha: None,
|
||||
git_origin_url: None,
|
||||
source: Some(SessionSource::VSCode),
|
||||
model_provider: Some(TEST_PROVIDER.to_string()),
|
||||
cli_version: Some("test_version".to_string()),
|
||||
created_at: Some("2025-03-03T09-00-00".into()),
|
||||
updated_at: updated_page2.first().cloned().flatten(),
|
||||
},
|
||||
ThreadItem {
|
||||
path: p2,
|
||||
head: head_2,
|
||||
thread_id: Some(thread_id_from_uuid(u2)),
|
||||
first_user_message: Some("Hello from user".to_string()),
|
||||
cwd: Some(Path::new(".").to_path_buf()),
|
||||
git_branch: None,
|
||||
git_sha: None,
|
||||
git_origin_url: None,
|
||||
source: Some(SessionSource::VSCode),
|
||||
model_provider: Some(TEST_PROVIDER.to_string()),
|
||||
cli_version: Some("test_version".to_string()),
|
||||
created_at: Some("2025-03-02T09-00-00".into()),
|
||||
updated_at: updated_page2.get(1).cloned().flatten(),
|
||||
},
|
||||
@@ -790,22 +786,20 @@ async fn test_pagination_cursor() {
|
||||
.join("03")
|
||||
.join("01")
|
||||
.join(format!("rollout-2025-03-01T09-00-00-{u1}.jsonl"));
|
||||
let head_1 = vec![serde_json::json!({
|
||||
"id": u1,
|
||||
"timestamp": "2025-03-01T09-00-00",
|
||||
"cwd": ".",
|
||||
"originator": "test_originator",
|
||||
"cli_version": "test_version",
|
||||
"source": "vscode",
|
||||
"model_provider": "test-provider",
|
||||
"base_instructions": null,
|
||||
})];
|
||||
let updated_page3: Vec<Option<String>> =
|
||||
page3.items.iter().map(|i| i.updated_at.clone()).collect();
|
||||
let expected_page3 = ThreadsPage {
|
||||
items: vec![ThreadItem {
|
||||
path: p1,
|
||||
head: head_1,
|
||||
thread_id: Some(thread_id_from_uuid(u1)),
|
||||
first_user_message: Some("Hello from user".to_string()),
|
||||
cwd: Some(Path::new(".").to_path_buf()),
|
||||
git_branch: None,
|
||||
git_sha: None,
|
||||
git_origin_url: None,
|
||||
source: Some(SessionSource::VSCode),
|
||||
model_provider: Some(TEST_PROVIDER.to_string()),
|
||||
cli_version: Some("test_version".to_string()),
|
||||
created_at: Some("2025-03-01T09-00-00".into()),
|
||||
updated_at: updated_page3.first().cloned().flatten(),
|
||||
}],
|
||||
@@ -873,20 +867,18 @@ async fn test_get_thread_contents() {
|
||||
.join("04")
|
||||
.join("01")
|
||||
.join(format!("rollout-2025-04-01T10-30-00-{uuid}.jsonl"));
|
||||
let expected_head = vec![serde_json::json!({
|
||||
"id": uuid,
|
||||
"timestamp": ts,
|
||||
"cwd": ".",
|
||||
"originator": "test_originator",
|
||||
"cli_version": "test_version",
|
||||
"source": "vscode",
|
||||
"model_provider": "test-provider",
|
||||
"base_instructions": null,
|
||||
})];
|
||||
let expected_page = ThreadsPage {
|
||||
items: vec![ThreadItem {
|
||||
path: expected_path,
|
||||
head: expected_head,
|
||||
thread_id: Some(thread_id_from_uuid(uuid)),
|
||||
first_user_message: Some("Hello from user".to_string()),
|
||||
cwd: Some(Path::new(".").to_path_buf()),
|
||||
git_branch: None,
|
||||
git_sha: None,
|
||||
git_origin_url: None,
|
||||
source: Some(SessionSource::VSCode),
|
||||
model_provider: Some(TEST_PROVIDER.to_string()),
|
||||
cli_version: Some("test_version".to_string()),
|
||||
created_at: Some(ts.into()),
|
||||
updated_at: page.items[0].updated_at.clone(),
|
||||
}],
|
||||
@@ -953,13 +945,12 @@ async fn test_base_instructions_missing_in_meta_defaults_to_null() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let head = page
|
||||
.items
|
||||
.first()
|
||||
.and_then(|item| item.head.first())
|
||||
let head = read_head_for_summary(&page.items[0].path)
|
||||
.await
|
||||
.expect("session meta head");
|
||||
let first = head.first().expect("first head entry");
|
||||
assert_eq!(
|
||||
head.get("base_instructions"),
|
||||
first.get("base_instructions"),
|
||||
Some(&serde_json::Value::Null)
|
||||
);
|
||||
}
|
||||
@@ -997,12 +988,11 @@ async fn test_base_instructions_present_in_meta_is_preserved() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let head = page
|
||||
.items
|
||||
.first()
|
||||
.and_then(|item| item.head.first())
|
||||
let head = read_head_for_summary(&page.items[0].path)
|
||||
.await
|
||||
.expect("session meta head");
|
||||
let base = head
|
||||
let first = head.first().expect("first head entry");
|
||||
let base = first
|
||||
.get("base_instructions")
|
||||
.and_then(|value| value.get("text"))
|
||||
.and_then(serde_json::Value::as_str);
|
||||
@@ -1182,18 +1172,6 @@ async fn test_stable_ordering_same_second_pagination() {
|
||||
.join("07")
|
||||
.join("01")
|
||||
.join(format!("rollout-2025-07-01T00-00-00-{u2}.jsonl"));
|
||||
let head = |u: Uuid| -> Vec<serde_json::Value> {
|
||||
vec![serde_json::json!({
|
||||
"id": u,
|
||||
"timestamp": ts,
|
||||
"cwd": ".",
|
||||
"originator": "test_originator",
|
||||
"cli_version": "test_version",
|
||||
"source": "vscode",
|
||||
"model_provider": "test-provider",
|
||||
"base_instructions": null,
|
||||
})]
|
||||
};
|
||||
let updated_page1: Vec<Option<String>> =
|
||||
page1.items.iter().map(|i| i.updated_at.clone()).collect();
|
||||
let expected_cursor1: Cursor = serde_json::from_str(&format!("\"{ts}|{u2}\"")).unwrap();
|
||||
@@ -1201,13 +1179,29 @@ async fn test_stable_ordering_same_second_pagination() {
|
||||
items: vec![
|
||||
ThreadItem {
|
||||
path: p3,
|
||||
head: head(u3),
|
||||
thread_id: Some(thread_id_from_uuid(u3)),
|
||||
first_user_message: Some("Hello from user".to_string()),
|
||||
cwd: Some(Path::new(".").to_path_buf()),
|
||||
git_branch: None,
|
||||
git_sha: None,
|
||||
git_origin_url: None,
|
||||
source: Some(SessionSource::VSCode),
|
||||
model_provider: Some(TEST_PROVIDER.to_string()),
|
||||
cli_version: Some("test_version".to_string()),
|
||||
created_at: Some(ts.to_string()),
|
||||
updated_at: updated_page1.first().cloned().flatten(),
|
||||
},
|
||||
ThreadItem {
|
||||
path: p2,
|
||||
head: head(u2),
|
||||
thread_id: Some(thread_id_from_uuid(u2)),
|
||||
first_user_message: Some("Hello from user".to_string()),
|
||||
cwd: Some(Path::new(".").to_path_buf()),
|
||||
git_branch: None,
|
||||
git_sha: None,
|
||||
git_origin_url: None,
|
||||
source: Some(SessionSource::VSCode),
|
||||
model_provider: Some(TEST_PROVIDER.to_string()),
|
||||
cli_version: Some("test_version".to_string()),
|
||||
created_at: Some(ts.to_string()),
|
||||
updated_at: updated_page1.get(1).cloned().flatten(),
|
||||
},
|
||||
@@ -1240,7 +1234,15 @@ async fn test_stable_ordering_same_second_pagination() {
|
||||
let expected_page2 = ThreadsPage {
|
||||
items: vec![ThreadItem {
|
||||
path: p1,
|
||||
head: head(u1),
|
||||
thread_id: Some(thread_id_from_uuid(u1)),
|
||||
first_user_message: Some("Hello from user".to_string()),
|
||||
cwd: Some(Path::new(".").to_path_buf()),
|
||||
git_branch: None,
|
||||
git_sha: None,
|
||||
git_origin_url: None,
|
||||
source: Some(SessionSource::VSCode),
|
||||
model_provider: Some(TEST_PROVIDER.to_string()),
|
||||
cli_version: Some("test_version".to_string()),
|
||||
created_at: Some(ts.to_string()),
|
||||
updated_at: updated_page2.first().cloned().flatten(),
|
||||
}],
|
||||
@@ -1375,13 +1377,7 @@ async fn test_model_provider_filter_selects_only_matching_sessions() -> Result<(
|
||||
let openai_ids: Vec<_> = openai_sessions
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
item.head
|
||||
.first()
|
||||
.and_then(|value| value.get("id"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_string)
|
||||
})
|
||||
.filter_map(|item| item.thread_id.as_ref().map(ToString::to_string))
|
||||
.collect();
|
||||
assert!(openai_ids.contains(&openai_id_str));
|
||||
assert!(openai_ids.contains(&none_id_str));
|
||||
@@ -1402,10 +1398,8 @@ async fn test_model_provider_filter_selects_only_matching_sessions() -> Result<(
|
||||
let beta_head = beta_sessions
|
||||
.items
|
||||
.first()
|
||||
.and_then(|item| item.head.first())
|
||||
.and_then(|value| value.get("id"))
|
||||
.and_then(serde_json::Value::as_str);
|
||||
assert_eq!(beta_head, Some(beta_id_str.as_str()));
|
||||
.and_then(|item| item.thread_id.as_ref().map(ToString::to_string));
|
||||
assert_eq!(beta_head.as_deref(), Some(beta_id_str.as_str()));
|
||||
|
||||
let unknown_filter = provider_vec(&["unknown"]);
|
||||
let unknown_sessions = get_threads(
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
use codex_protocol::models::ContentItem;
|
||||
|
||||
/// Helpers for identifying model-visible "session prefix" messages.
|
||||
///
|
||||
/// A session prefix is a user-role message that carries configuration or state needed by
|
||||
@@ -15,12 +13,3 @@ pub(crate) fn is_session_prefix(text: &str) -> bool {
|
||||
let lowered = trimmed.to_ascii_lowercase();
|
||||
lowered.starts_with(ENVIRONMENT_CONTEXT_OPEN_TAG) || lowered.starts_with(TURN_ABORTED_OPEN_TAG)
|
||||
}
|
||||
|
||||
/// Returns true if `text` starts with a session prefix marker (case-insensitive).
|
||||
pub(crate) fn is_session_prefix_content(content: &[ContentItem]) -> bool {
|
||||
if let [ContentItem::InputText { text }] = content {
|
||||
is_session_prefix(text)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,13 +85,8 @@ async fn responses_mode_stream_cli() {
|
||||
!page.items.is_empty(),
|
||||
"expected at least one session to be listed"
|
||||
);
|
||||
// First line of head must be the SessionMeta payload (id/timestamp)
|
||||
let head0 = page.items[0].head.first().expect("missing head record");
|
||||
assert!(head0.get("id").is_some(), "head[0] missing id");
|
||||
assert!(
|
||||
head0.get("timestamp").is_some(),
|
||||
"head[0] missing timestamp"
|
||||
);
|
||||
assert!(page.items[0].thread_id.is_some(), "missing thread_id");
|
||||
assert!(page.items[0].created_at.is_some(), "missing created_at");
|
||||
}
|
||||
|
||||
/// Verify that passing `-c model_instructions_file=...` to the CLI
|
||||
|
||||
@@ -175,7 +175,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> {
|
||||
assert_eq!(metadata.id, thread_id);
|
||||
assert_eq!(metadata.rollout_path, rollout_path);
|
||||
assert_eq!(metadata.model_provider, default_provider);
|
||||
assert!(metadata.has_user_event);
|
||||
assert!(metadata.first_user_message.is_some());
|
||||
|
||||
let mut stored_tools = None;
|
||||
for _ in 0..40 {
|
||||
@@ -224,7 +224,7 @@ async fn user_messages_persist_in_state_db() -> Result<()> {
|
||||
metadata = db.get_thread(thread_id).await?;
|
||||
if metadata
|
||||
.as_ref()
|
||||
.map(|entry| entry.has_user_event)
|
||||
.map(|entry| entry.first_user_message.is_some())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
break;
|
||||
@@ -233,7 +233,7 @@ async fn user_messages_persist_in_state_db() -> Result<()> {
|
||||
}
|
||||
|
||||
let metadata = metadata.expect("thread should exist in state db");
|
||||
assert!(metadata.has_user_event);
|
||||
assert!(metadata.first_user_message.is_some());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE threads ADD COLUMN first_user_message TEXT NOT NULL DEFAULT '';
|
||||
|
||||
UPDATE threads
|
||||
SET first_user_message = title
|
||||
WHERE first_user_message = '' AND has_user_event = 1 AND title <> '';
|
||||
@@ -64,7 +64,10 @@ fn apply_event_msg(metadata: &mut ThreadMetadata, event: &EventMsg) {
|
||||
}
|
||||
}
|
||||
EventMsg::UserMessage(user) => {
|
||||
metadata.has_user_event = true;
|
||||
if metadata.first_user_message.is_none() {
|
||||
metadata.first_user_message =
|
||||
Some(strip_user_message_prefix(user.message.as_str()).to_string());
|
||||
}
|
||||
if metadata.title.is_empty() {
|
||||
metadata.title = strip_user_message_prefix(user.message.as_str()).to_string();
|
||||
}
|
||||
@@ -74,7 +77,7 @@ fn apply_event_msg(metadata: &mut ThreadMetadata, event: &EventMsg) {
|
||||
}
|
||||
|
||||
fn apply_response_item(_metadata: &mut ThreadMetadata, _item: &ResponseItem) {
|
||||
// Title and has_user_event are derived from EventMsg::UserMessage only.
|
||||
// Title and first_user_message are derived from EventMsg::UserMessage only.
|
||||
}
|
||||
|
||||
fn strip_user_message_prefix(text: &str) -> &str {
|
||||
@@ -111,7 +114,7 @@ mod tests {
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn response_item_user_messages_do_not_set_title_or_has_user_event() {
|
||||
fn response_item_user_messages_do_not_set_title_or_first_user_message() {
|
||||
let mut metadata = metadata_for_test();
|
||||
let item = RolloutItem::ResponseItem(ResponseItem::Message {
|
||||
id: None,
|
||||
@@ -125,12 +128,12 @@ mod tests {
|
||||
|
||||
apply_rollout_item(&mut metadata, &item, "test-provider");
|
||||
|
||||
assert_eq!(metadata.has_user_event, false);
|
||||
assert_eq!(metadata.first_user_message, None);
|
||||
assert_eq!(metadata.title, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_msg_user_messages_set_title_and_has_user_event() {
|
||||
fn event_msg_user_messages_set_title_and_first_user_message() {
|
||||
let mut metadata = metadata_for_test();
|
||||
let item = RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
|
||||
message: format!("{USER_MESSAGE_BEGIN} actual user request"),
|
||||
@@ -141,7 +144,10 @@ mod tests {
|
||||
|
||||
apply_rollout_item(&mut metadata, &item, "test-provider");
|
||||
|
||||
assert_eq!(metadata.has_user_event, true);
|
||||
assert_eq!(
|
||||
metadata.first_user_message.as_deref(),
|
||||
Some("actual user request")
|
||||
);
|
||||
assert_eq!(metadata.title, "actual user request");
|
||||
}
|
||||
|
||||
@@ -161,7 +167,7 @@ mod tests {
|
||||
sandbox_policy: "read-only".to_string(),
|
||||
approval_mode: "on-request".to_string(),
|
||||
tokens_used: 1,
|
||||
has_user_event: false,
|
||||
first_user_message: None,
|
||||
archived_at: None,
|
||||
git_sha: None,
|
||||
git_branch: None,
|
||||
|
||||
@@ -76,8 +76,8 @@ pub struct ThreadMetadata {
|
||||
pub approval_mode: String,
|
||||
/// The last observed token usage.
|
||||
pub tokens_used: i64,
|
||||
/// Whether the thread has observed a user message.
|
||||
pub has_user_event: bool,
|
||||
/// First user message observed for this thread, if any.
|
||||
pub first_user_message: Option<String>,
|
||||
/// The archive timestamp, if the thread is archived.
|
||||
pub archived_at: Option<DateTime<Utc>>,
|
||||
/// The git commit SHA, if known.
|
||||
@@ -173,7 +173,7 @@ impl ThreadMetadataBuilder {
|
||||
sandbox_policy,
|
||||
approval_mode,
|
||||
tokens_used: 0,
|
||||
has_user_event: false,
|
||||
first_user_message: None,
|
||||
archived_at: self.archived_at.map(canonicalize_datetime),
|
||||
git_sha: self.git_sha.clone(),
|
||||
git_branch: self.git_branch.clone(),
|
||||
@@ -222,8 +222,8 @@ impl ThreadMetadata {
|
||||
if self.tokens_used != other.tokens_used {
|
||||
diffs.push("tokens_used");
|
||||
}
|
||||
if self.has_user_event != other.has_user_event {
|
||||
diffs.push("has_user_event");
|
||||
if self.first_user_message != other.first_user_message {
|
||||
diffs.push("first_user_message");
|
||||
}
|
||||
if self.archived_at != other.archived_at {
|
||||
diffs.push("archived_at");
|
||||
@@ -259,7 +259,7 @@ pub(crate) struct ThreadRow {
|
||||
sandbox_policy: String,
|
||||
approval_mode: String,
|
||||
tokens_used: i64,
|
||||
has_user_event: bool,
|
||||
first_user_message: String,
|
||||
archived_at: Option<i64>,
|
||||
git_sha: Option<String>,
|
||||
git_branch: Option<String>,
|
||||
@@ -281,7 +281,7 @@ impl ThreadRow {
|
||||
sandbox_policy: row.try_get("sandbox_policy")?,
|
||||
approval_mode: row.try_get("approval_mode")?,
|
||||
tokens_used: row.try_get("tokens_used")?,
|
||||
has_user_event: row.try_get("has_user_event")?,
|
||||
first_user_message: row.try_get("first_user_message")?,
|
||||
archived_at: row.try_get("archived_at")?,
|
||||
git_sha: row.try_get("git_sha")?,
|
||||
git_branch: row.try_get("git_branch")?,
|
||||
@@ -307,7 +307,7 @@ impl TryFrom<ThreadRow> for ThreadMetadata {
|
||||
sandbox_policy,
|
||||
approval_mode,
|
||||
tokens_used,
|
||||
has_user_event,
|
||||
first_user_message,
|
||||
archived_at,
|
||||
git_sha,
|
||||
git_branch,
|
||||
@@ -326,7 +326,7 @@ impl TryFrom<ThreadRow> for ThreadMetadata {
|
||||
sandbox_policy,
|
||||
approval_mode,
|
||||
tokens_used,
|
||||
has_user_event,
|
||||
first_user_message: (!first_user_message.is_empty()).then_some(first_user_message),
|
||||
archived_at: archived_at.map(epoch_seconds_to_datetime).transpose()?,
|
||||
git_sha,
|
||||
git_branch,
|
||||
|
||||
@@ -104,7 +104,7 @@ SELECT
|
||||
sandbox_policy,
|
||||
approval_mode,
|
||||
tokens_used,
|
||||
has_user_event,
|
||||
first_user_message,
|
||||
archived_at,
|
||||
git_sha,
|
||||
git_branch,
|
||||
@@ -203,7 +203,7 @@ SELECT
|
||||
sandbox_policy,
|
||||
approval_mode,
|
||||
tokens_used,
|
||||
has_user_event,
|
||||
first_user_message,
|
||||
archived_at,
|
||||
git_sha,
|
||||
git_branch,
|
||||
@@ -358,7 +358,7 @@ INSERT INTO threads (
|
||||
sandbox_policy,
|
||||
approval_mode,
|
||||
tokens_used,
|
||||
has_user_event,
|
||||
first_user_message,
|
||||
archived,
|
||||
archived_at,
|
||||
git_sha,
|
||||
@@ -377,7 +377,7 @@ ON CONFLICT(id) DO UPDATE SET
|
||||
sandbox_policy = excluded.sandbox_policy,
|
||||
approval_mode = excluded.approval_mode,
|
||||
tokens_used = excluded.tokens_used,
|
||||
has_user_event = excluded.has_user_event,
|
||||
first_user_message = excluded.first_user_message,
|
||||
archived = excluded.archived,
|
||||
archived_at = excluded.archived_at,
|
||||
git_sha = excluded.git_sha,
|
||||
@@ -397,7 +397,7 @@ ON CONFLICT(id) DO UPDATE SET
|
||||
.bind(metadata.sandbox_policy.as_str())
|
||||
.bind(metadata.approval_mode.as_str())
|
||||
.bind(metadata.tokens_used)
|
||||
.bind(metadata.has_user_event)
|
||||
.bind(metadata.first_user_message.as_deref().unwrap_or_default())
|
||||
.bind(metadata.archived_at.is_some())
|
||||
.bind(metadata.archived_at.map(datetime_to_epoch_seconds))
|
||||
.bind(metadata.git_sha.as_deref())
|
||||
@@ -643,7 +643,7 @@ fn push_thread_filters<'a>(
|
||||
} else {
|
||||
builder.push(" AND archived = 0");
|
||||
}
|
||||
builder.push(" AND has_user_event = 1");
|
||||
builder.push(" AND first_user_message <> ''");
|
||||
if !allowed_sources.is_empty() {
|
||||
builder.push(" AND source IN (");
|
||||
let mut separated = builder.separated(", ");
|
||||
|
||||
@@ -14,7 +14,6 @@ use codex_core::ThreadSortKey;
|
||||
use codex_core::ThreadsPage;
|
||||
use codex_core::find_thread_names_by_ids;
|
||||
use codex_core::path_utils;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use color_eyre::eyre::Result;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
@@ -37,8 +36,6 @@ use crate::tui::FrameRequester;
|
||||
use crate::tui::Tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::SessionMetaLine;
|
||||
|
||||
const PAGE_SIZE: usize = 25;
|
||||
const LOAD_NEAR_THRESHOLD: usize = 5;
|
||||
@@ -766,49 +763,33 @@ fn rows_from_items(items: Vec<ThreadItem>) -> Vec<Row> {
|
||||
}
|
||||
|
||||
fn head_to_row(item: &ThreadItem) -> Row {
|
||||
let created_at = item
|
||||
.created_at
|
||||
.as_deref()
|
||||
.and_then(parse_timestamp_str)
|
||||
.or_else(|| item.head.first().and_then(extract_timestamp));
|
||||
let created_at = item.created_at.as_deref().and_then(parse_timestamp_str);
|
||||
let updated_at = item
|
||||
.updated_at
|
||||
.as_deref()
|
||||
.and_then(parse_timestamp_str)
|
||||
.or(created_at);
|
||||
|
||||
let (cwd, git_branch, thread_id) = extract_session_meta_from_head(&item.head);
|
||||
let preview = preview_from_head(&item.head)
|
||||
.map(|s| s.trim().to_string())
|
||||
let preview = item
|
||||
.first_user_message
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| String::from("(no message yet)"));
|
||||
|
||||
Row {
|
||||
path: item.path.clone(),
|
||||
preview,
|
||||
thread_id,
|
||||
thread_id: item.thread_id,
|
||||
thread_name: None,
|
||||
created_at,
|
||||
updated_at,
|
||||
cwd,
|
||||
git_branch,
|
||||
cwd: item.cwd.clone(),
|
||||
git_branch: item.git_branch.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_session_meta_from_head(
|
||||
head: &[serde_json::Value],
|
||||
) -> (Option<PathBuf>, Option<String>, Option<ThreadId>) {
|
||||
for value in head {
|
||||
if let Ok(meta_line) = serde_json::from_value::<SessionMetaLine>(value.clone()) {
|
||||
let cwd = Some(meta_line.meta.cwd);
|
||||
let git_branch = meta_line.git.and_then(|git| git.branch);
|
||||
let thread_id = Some(meta_line.meta.id);
|
||||
return (cwd, git_branch, thread_id);
|
||||
}
|
||||
}
|
||||
(None, None, None)
|
||||
}
|
||||
|
||||
fn paths_match(a: &Path, b: &Path) -> bool {
|
||||
if let (Ok(ca), Ok(cb)) = (
|
||||
path_utils::normalize_for_path_comparison(a),
|
||||
@@ -825,23 +806,6 @@ fn parse_timestamp_str(ts: &str) -> Option<DateTime<Utc>> {
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn extract_timestamp(value: &serde_json::Value) -> Option<DateTime<Utc>> {
|
||||
value
|
||||
.get("timestamp")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
}
|
||||
|
||||
fn preview_from_head(head: &[serde_json::Value]) -> Option<String> {
|
||||
head.iter()
|
||||
.filter_map(|value| serde_json::from_value::<ResponseItem>(value.clone()).ok())
|
||||
.find_map(|item| match codex_core::parse_turn_item(&item) {
|
||||
Some(TurnItem::UserMessage(user)) => Some(user.message()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> {
|
||||
// Render full-screen overlay
|
||||
let height = tui.terminal.size()?.height;
|
||||
@@ -1200,24 +1164,18 @@ mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
fn head_with_ts_and_user_text(ts: &str, texts: &[&str]) -> Vec<serde_json::Value> {
|
||||
vec![
|
||||
json!({ "timestamp": ts }),
|
||||
json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": texts
|
||||
.iter()
|
||||
.map(|t| json!({ "type": "input_text", "text": *t }))
|
||||
.collect::<Vec<_>>()
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
fn make_item(path: &str, ts: &str, preview: &str) -> ThreadItem {
|
||||
ThreadItem {
|
||||
path: PathBuf::from(path),
|
||||
head: head_with_ts_and_user_text(ts, &[preview]),
|
||||
thread_id: None,
|
||||
first_user_message: Some(preview.to_string()),
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
git_sha: None,
|
||||
git_origin_url: None,
|
||||
source: None,
|
||||
model_provider: None,
|
||||
cli_version: None,
|
||||
created_at: Some(ts.to_string()),
|
||||
updated_at: Some(ts.to_string()),
|
||||
}
|
||||
@@ -1243,39 +1201,23 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_uses_first_message_input_text() {
|
||||
let head = vec![
|
||||
json!({ "timestamp": "2025-01-01T00:00:00Z" }),
|
||||
json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "input_text", "text": "# AGENTS.md instructions for project\n\n<INSTRUCTIONS>\nhi\n</INSTRUCTIONS>" },
|
||||
]
|
||||
}),
|
||||
json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "input_text", "text": "<environment_context>...</environment_context>" },
|
||||
]
|
||||
}),
|
||||
json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "input_text", "text": "real question" },
|
||||
{ "type": "input_image", "image_url": "ignored" }
|
||||
]
|
||||
}),
|
||||
json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [ { "type": "input_text", "text": "later text" } ]
|
||||
}),
|
||||
];
|
||||
let preview = preview_from_head(&head);
|
||||
assert_eq!(preview.as_deref(), Some("real question"));
|
||||
fn head_to_row_uses_first_user_message() {
|
||||
let item = ThreadItem {
|
||||
path: PathBuf::from("/tmp/a.jsonl"),
|
||||
thread_id: None,
|
||||
first_user_message: Some("real question".to_string()),
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
git_sha: None,
|
||||
git_origin_url: None,
|
||||
source: None,
|
||||
model_provider: None,
|
||||
cli_version: None,
|
||||
created_at: Some("2025-01-01T00:00:00Z".into()),
|
||||
updated_at: Some("2025-01-01T00:00:00Z".into()),
|
||||
};
|
||||
let row = head_to_row(&item);
|
||||
assert_eq!(row.preview, "real question");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1283,13 +1225,29 @@ mod tests {
|
||||
// Construct two items with different timestamps and real user text.
|
||||
let a = ThreadItem {
|
||||
path: PathBuf::from("/tmp/a.jsonl"),
|
||||
head: head_with_ts_and_user_text("2025-01-01T00:00:00Z", &["A"]),
|
||||
thread_id: None,
|
||||
first_user_message: Some("A".to_string()),
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
git_sha: None,
|
||||
git_origin_url: None,
|
||||
source: None,
|
||||
model_provider: None,
|
||||
cli_version: None,
|
||||
created_at: Some("2025-01-01T00:00:00Z".into()),
|
||||
updated_at: Some("2025-01-01T00:00:00Z".into()),
|
||||
};
|
||||
let b = ThreadItem {
|
||||
path: PathBuf::from("/tmp/b.jsonl"),
|
||||
head: head_with_ts_and_user_text("2025-01-02T00:00:00Z", &["B"]),
|
||||
thread_id: None,
|
||||
first_user_message: Some("B".to_string()),
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
git_sha: None,
|
||||
git_origin_url: None,
|
||||
source: None,
|
||||
model_provider: None,
|
||||
cli_version: None,
|
||||
created_at: Some("2025-01-02T00:00:00Z".into()),
|
||||
updated_at: Some("2025-01-02T00:00:00Z".into()),
|
||||
};
|
||||
@@ -1302,10 +1260,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn row_uses_tail_timestamp_for_updated_at() {
|
||||
let head = head_with_ts_and_user_text("2025-01-01T00:00:00Z", &["Hello"]);
|
||||
let item = ThreadItem {
|
||||
path: PathBuf::from("/tmp/a.jsonl"),
|
||||
head,
|
||||
thread_id: None,
|
||||
first_user_message: Some("Hello".to_string()),
|
||||
cwd: None,
|
||||
git_branch: None,
|
||||
git_sha: None,
|
||||
git_origin_url: None,
|
||||
source: None,
|
||||
model_provider: None,
|
||||
cli_version: None,
|
||||
created_at: Some("2025-01-01T00:00:00Z".into()),
|
||||
updated_at: Some("2025-01-01T01:00:00Z".into()),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user