Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
1d63b29
fix(tui): show Codex thread labels
senamakel Aug 8, 2026
2de2f83
chore: files changed src/sdk/src/session_history/summary.rs,patch_t4.py
senamakel Aug 8, 2026
b1ea6ae
fix(tui): refresh Codex thread label periodically after initial disco…
senamakel Aug 8, 2026
ddc4ae9
refactor(executor): extract session planning and launch into dedicate…
senamakel Aug 8, 2026
6181b1a
refactor(tui): expose executor internals for reuse
senamakel Aug 8, 2026
2c3ae00
test(tui): update OSC title clearing test for empty-title resilience
senamakel Aug 8, 2026
d595b1d
chore(tui): remove stale output files and fix thread name precedence …
senamakel Aug 9, 2026
34df1b7
fix(sdk): handle empty session history gracefully
senamakel Aug 9, 2026
0c69d77
chore(sdk): update session history summary wording
senamakel Aug 9, 2026
6ace8d5
fix(session_history): correct test assertion for empty history
senamakel Aug 9, 2026
e329b77
fix(session_history): correct test assertion for empty history
senamakel Aug 9, 2026
93541a0
fix(executor): handle turn completion when no pending tasks remain
senamakel Aug 9, 2026
4e78f53
fix(executor): restore turn state after worker restart
senamakel Aug 9, 2026
b793be8
Merge remote-tracking branch 'upstream/main' into pr/253
senamakel Aug 9, 2026
bdabf99
chore(deps): update openhuman subproject commit
senamakel Aug 9, 2026
e961344
feat(tui): add e2e test for codex rename functionality
senamakel Aug 9, 2026
7443aba
chore(deps): update openhuman subproject commit
senamakel Aug 9, 2026
682126f
fix(pty): handle screen resize events correctly
senamakel Aug 9, 2026
91eda05
Merge remote-tracking branch 'refs/remotes/upstream/main' into pr/253
senamakel Aug 9, 2026
ef85008
chore(sdk): remove unused session history summary module
senamakel Aug 9, 2026
43cfb92
chore(sdk): remove unused session history summary module
senamakel Aug 9, 2026
276fe15
fix(session_history): restore missing test assertions
senamakel Aug 9, 2026
7ece252
fix(session_history): restore missing test module
senamakel Aug 9, 2026
2a8e6f9
test(session_history): format assertion for readability
senamakel Aug 9, 2026
508e0d6
fix(executor): restore turn completion after worker restart
senamakel Aug 9, 2026
558a442
fix(session_history): handle empty scan results gracefully
senamakel Aug 9, 2026
d39ccdd
chore(sdk): add summary history persistence
senamakel Aug 9, 2026
ec678d7
fix(session_history): restore missing test assertions
senamakel Aug 9, 2026
2d66f7e
chore(tui): add pty session tests
senamakel Aug 9, 2026
59ed3a1
fix(session_history): restore missing test assertions
senamakel Aug 9, 2026
1f1e272
style: reformat idle timeout calculation and test command string
senamakel Aug 9, 2026
29619bc
fix(session_history): restore scan of removed history files
senamakel Aug 9, 2026
00570b7
fix(session_history): handle empty scan results gracefully
senamakel Aug 9, 2026
e7ca5b1
ci: re-trigger checks after label-attribution fix
senamakel Aug 9, 2026
0ed0b7f
ci: re-trigger workflow dispatch
senamakel Aug 9, 2026
15630f1
Merge remote-tracking branch 'upstream/main' into pr/253
senamakel Aug 9, 2026
a92212a
Merge remote-tracking branch 'upstream/main' into pr/253
senamakel Aug 9, 2026
75d97bb
fix(session_history): restore scan of history files
senamakel Aug 9, 2026
4e4e9bc
fix(session_history): restore scan of history files
senamakel Aug 9, 2026
a2372ec
chore(sdk): reformat session history scan closure
senamakel Aug 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/sdk/src/harness_hooks/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,7 @@ fn every_spawn_seam_uses_the_merged_launch_builder() {
let seams = [
"src/sdk/src/wrapper/run/mod.rs",
"src/sdk/src/daemon/providers/execute.rs",
"src/tui/src/worker/executor/run.rs",
"src/tui/src/worker/executor/launch.rs",
"src/tui/src/ui/harness_pane/spawn.rs",
];
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
Expand Down
16 changes: 14 additions & 2 deletions src/sdk/src/session_history/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use std::collections::HashMap;
use super::scan::{
claude_sessions_dir, codex_sessions_dir, collect_session_files, is_here, safe_resolve,
};
use super::summary::read_session_summary;
use super::summary::{codex_index_map, read_session_summary};
use super::types::{RawSessionFile, RecentSession, SessionAgentKind};

/// Default number of ranked sessions returned when no limit is given.
Expand Down Expand Up @@ -43,6 +43,10 @@ pub fn list_recent_sessions(
raw.truncate(scan_limit);

let here = safe_resolve(cwd);
// Load the Codex session index once into an id-to-label map so
// every session below does not re-parse the index file on each call.
let codex_labels = codex_index_map(env);

// Dedupe by agent+id, keeping the freshest file.
let mut by_id: HashMap<String, RecentSession> = HashMap::new();
for file in &raw {
Expand All @@ -56,12 +60,20 @@ pub fn list_recent_sessions(
continue;
}
}
let label = if file.agent == SessionAgentKind::Codex {
codex_labels
.get(&summary.id)
.cloned()
.unwrap_or(summary.label)
} else {
summary.label
};
by_id.insert(
key,
RecentSession {
agent: file.agent,
id: summary.id,
label: summary.label,
label,
last_active: file.mtime_ms,
path: file.path.to_string_lossy().into_owned(),
cwd: summary.cwd,
Expand Down
1 change: 1 addition & 0 deletions src/sdk/src/session_history/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ mod tests;

pub use list::list_recent_sessions;
pub use scan::{claude_sessions_dir, codex_sessions_dir};
pub use summary::{codex_thread_label, codex_thread_label_for_cwd};
pub use types::{RecentSession, SessionAgentKind};

pub(crate) use scan::{collect_session_files, discover_session_file, preexisting_session_files};
76 changes: 76 additions & 0 deletions src/sdk/src/session_history/summary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//! the cost of scanning many transcripts. Claude and Codex use different record
//! shapes, so each has its own head reader.

use std::collections::HashMap;
use std::path::Path;

use serde_json::Value;
Expand Down Expand Up @@ -162,6 +163,81 @@ pub(super) fn slug_label(text: &str) -> String {
slug(text)
}

/// Read Codex's persisted name for the newest session rooted at `cwd`.
///
/// The id-keyed [`codex_thread_label`] is the right read once a transcript has
/// been located, because identity beats recency. This is the fallback for a
/// session nothing has located yet — one an operator created and typed into
/// directly, which never enters the transcript executor — where the best
/// identity on offer is "the newest Codex rollout in this working directory".
pub fn codex_thread_label_for_cwd(env: &HashMap<String, String>, cwd: &str) -> Option<String> {
let discovered = super::scan::discover_session_file(
env,
SessionAgentKind::Codex,
cwd,
0,
&std::collections::HashSet::new(),
None,
)?;
codex_thread_label(env, &discovered.id)
}

/// Read Codex's persisted name for `session_id`, when it has one.
///
/// Codex records `/rename` names in `session_index.jsonl` beside its `sessions`
/// directory rather than updating the terminal title. Claude has no equivalent
/// index, so callers use this only for Codex and retain the transcript-prompt
/// fallback when the index has not caught up yet.
pub fn codex_thread_label(env: &HashMap<String, String>, session_id: &str) -> Option<String> {
let index = super::scan::codex_sessions_dir(env)
.parent()?
.join("session_index.jsonl");
let contents = std::fs::read_to_string(index).ok()?;
contents.lines().find_map(|line| {
Comment thread
senamakel marked this conversation as resolved.
Outdated
let record: Value = serde_json::from_str(line).ok()?;
let object = record.as_object()?;
(object.get("id").and_then(Value::as_str) == Some(session_id))
.then(|| object.get("thread_name").and_then(Value::as_str))
.flatten()
.map(slug_label)
.filter(|label| !label.is_empty())
})
}

/// Load the Codex session-index into an id-to-label map.
///
/// Callers that need to resolve thread names for many sessions (e.g. the
/// recent-session list) can load the index once and look up every session
/// against the same map, rather than re-reading and re-parsing the file
/// per session.
pub fn codex_index_map(env: &HashMap<String, String>) -> HashMap<String, String> {
let Some(index_path) = (|| {
super::scan::codex_sessions_dir(env)
.parent()?
.join("session_index.jsonl")
.into()
})() else {
return HashMap::new();
};
let Ok(contents) = std::fs::read_to_string(index_path) else {
return HashMap::new();
};
contents
.lines()
.filter_map(|line| {
let record: Value = serde_json::from_str(line).ok()?;
let object = record.as_object()?;
let id = object.get("id").and_then(Value::as_str)?;
let label = object
.get("thread_name")
.and_then(Value::as_str)
.map(slug_label)
.filter(|l| !l.is_empty())?;
Some((id.to_string(), label))
})
.collect()
}

/// Read the first [`HEAD_BYTES`] of `path` as UTF-8 (lossy) and split into
/// non-empty lines, dropping a final partial line when the read hit the cap.
fn read_head_lines(path: &Path) -> Vec<String> {
Expand Down
76 changes: 71 additions & 5 deletions src/sdk/src/session_history/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@

use super::scan::{collect_session_files, is_here, is_session_file, sessions_dir_for};
use super::summary::{
as_message_content, extract_text, first_prompt_text, read_claude_summary, read_codex_summary,
slug_label,
as_message_content, codex_thread_label, codex_thread_label_for_cwd, extract_text,
first_prompt_text, read_claude_summary, read_codex_summary, slug_label,
};
use super::*;
use crate::ui::util::SLUG_MAX_CHARS;
Expand All @@ -26,7 +26,7 @@ fn write_session(dir: &Path, name: &str, contents: &str) -> PathBuf {
fn ranks_current_cwd_first_then_recency() {
let tmp = std::env::temp_dir().join(format!("medulla-sh-{}", std::process::id()));
let claude_dir = tmp.join("claude");
let codex_dir = tmp.join("codex");
let codex_dir = tmp.join("codex").join("sessions");
fs::create_dir_all(&claude_dir).unwrap();
fs::create_dir_all(&codex_dir).unwrap();

Expand All @@ -53,6 +53,11 @@ fn ranks_current_cwd_first_then_recency() {
serde_json::json!({"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"do B here"}]}})
),
);
fs::write(
tmp.join("codex").join("session_index.jsonl"),
serde_json::json!({"id":"codex-b","thread_name":"Named Codex thread"}).to_string(),
)
.unwrap();

let mut env = HashMap::new();
env.insert(
Expand All @@ -69,8 +74,8 @@ fn ranks_current_cwd_first_then_recency() {
assert_eq!(sessions[0].id, "codex-b", "current-cwd session ranks first");
assert_eq!(sessions[0].agent, SessionAgentKind::Codex);
assert_eq!(
sessions[0].label, "b-here",
"the prompt is slugged, filler dropped"
sessions[0].label, "named-codex-thread",
"Codex's persisted thread name takes precedence over its prompt"
);
assert_eq!(sessions[1].id, "claude-a");
assert_eq!(sessions[1].label, "do-a");
Expand Down Expand Up @@ -254,6 +259,67 @@ fn codex_summary_uses_id_fallback_and_no_prompt_label() {
assert_eq!(summary.label, "(no prompt)");
}

#[test]
fn codex_thread_label_reads_the_persisted_rename() {
let home = tempfile::tempdir().unwrap();
let codex = home.path().join("codex");
fs::create_dir_all(codex.join("sessions")).unwrap();
fs::write(
codex.join("session_index.jsonl"),
serde_json::json!({"id":"codex-1","thread_name":"Ship the sidebar"}).to_string(),
)
.unwrap();
let mut env = HashMap::new();
env.insert(
"MEDULLA_CODEX_SESSIONS_DIR".to_string(),
codex.join("sessions").to_string_lossy().into_owned(),
);

assert_eq!(
codex_thread_label(&env, "codex-1").as_deref(),
Some("ship-sidebar")
);
assert_eq!(codex_thread_label(&env, "missing"), None);
}

#[test]
fn codex_thread_label_for_cwd_finds_the_newest_rollout_in_the_folder() {
let home = tempfile::tempdir().unwrap();
let sessions = home.path().join("codex").join("sessions");
let project = home.path().join("project");
fs::create_dir_all(&sessions).unwrap();
fs::create_dir_all(&project).unwrap();
let project_str = project.to_string_lossy().into_owned();

write_session(
&sessions,
"rollout-a.jsonl",
&serde_json::json!({
"type":"session_meta",
"payload":{"session_id":"codex-a","cwd": project_str}
})
.to_string(),
);
fs::write(
home.path().join("codex").join("session_index.jsonl"),
serde_json::json!({"id":"codex-a","thread_name":"Ship the sidebar"}).to_string(),
)
.unwrap();
let mut env = HashMap::new();
env.insert(
"MEDULLA_CODEX_SESSIONS_DIR".to_string(),
sessions.to_string_lossy().into_owned(),
);

assert_eq!(
codex_thread_label_for_cwd(&env, &project_str).as_deref(),
Some("ship-sidebar")
);
// A cwd with no session in it has no label to read.
let elsewhere = home.path().join("elsewhere").to_string_lossy().into_owned();
assert_eq!(codex_thread_label_for_cwd(&env, &elsewhere), None);
}

#[test]
fn codex_summary_without_meta_is_none() {
let lines = vec![serde_json::json!({"type":"response_item"}).to_string()];
Expand Down
Loading
Loading