Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 7 additions & 1 deletion crates/statsai-adapters/src/archive/scan/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,13 @@ pub(crate) fn collect_claude_file(
.get("cwd")
.and_then(Value::as_str)
.map(expand_home_path)
.and_then(|path| resolve_project_context(Some(path), None, None));
.and_then(|path| {
let branch = value
.get("gitBranch")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
resolve_project_context(Some(path), None, branch)
});
}
let message = value.get("message").unwrap_or(&value);
let role = message
Expand Down
16 changes: 15 additions & 1 deletion crates/statsai-adapters/src/archive/scan/grok.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,21 @@ pub(crate) fn collect_grok(
.pointer("/info/cwd")
.and_then(Value::as_str)
.map(expand_home_path)
.and_then(|path| resolve_project_context(Some(path), None, None));
.and_then(|path| {
resolve_project_context(
Some(path),
value
.get("git_remotes")
.and_then(Value::as_array)
.and_then(|remotes| remotes.first())
.and_then(Value::as_str)
.map(ToOwned::to_owned),
value
.get("head_branch")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
)
});
}
}
let file = File::open(&chat_path)?;
Expand Down
18 changes: 14 additions & 4 deletions crates/statsai-adapters/src/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,30 @@ pub(crate) const SCAN_CACHE_SIGNATURE_VERSION: &str = "scan-cache.v1";
// so the name is joined when the session is built.
// call-context.v38: turn events list their model calls, with request starts,
// compaction boundaries, and each call's model, for the prompt-cache report.
pub(crate) const CODEX_SCAN_CACHE_PARSER_REVISION: &str = "call-context.v38";
// branch-label.v39: turn_context lines keep the branch session_meta recorded
// instead of taking the checkout's HEAD at scan time.
pub(crate) const CODEX_SCAN_CACHE_PARSER_REVISION: &str = "branch-label.v39";
// session-metadata.v30: Claude user-message counts include only typed prompts,
// not tool results, meta lines, or compaction summaries. (v29: events carry the
// session's custom or AI title and the prompt that started their turn.)
// call-context.v31: events record their request start, sub-agent, and
// compaction boundary for the prompt-cache report.
pub(crate) const CLAUDE_SCAN_CACHE_PARSER_REVISION: &str = "call-context.v31";
// branch-label.v32: lines without a gitBranch leave the branch unset instead of
// taking the checkout's HEAD at scan time, and archived conversations carry the
// branch their lines record.
pub(crate) const CLAUDE_SCAN_CACHE_PARSER_REVISION: &str = "branch-label.v32";
// session-metadata.v21: events carry each session's user and assistant message
// counts. (v20: session rows report their message count as requests and are
// priced per message, so long-context tiers stop being decided session-wide.)
pub(crate) const OPENCODE_SCAN_CACHE_PARSER_REVISION: &str = "session-metadata.v21";
// branch-label.v22: OpenCode records no branch, so sessions leave it unset
// instead of taking the checkout's HEAD at scan time.
pub(crate) const OPENCODE_SCAN_CACHE_PARSER_REVISION: &str = "branch-label.v22";
// Revisit Grok sessions whose Fast request was left unpriced because modelsUsed
// also retained a selected standard model that made no inference.
pub(crate) const GROK_BUILD_SCAN_CACHE_PARSER_REVISION: &str = "grok-fast-pricing.v23";
// branch-label.v24: sessions without a head_branch leave the branch unset
// instead of taking the checkout's HEAD at scan time, and archived
// conversations carry the branch and remote their summary records.
pub(crate) const GROK_BUILD_SCAN_CACHE_PARSER_REVISION: &str = "branch-label.v24";

pub(crate) fn scan_candidate(
path: PathBuf,
Expand Down
5 changes: 4 additions & 1 deletion crates/statsai-adapters/src/claude/tests/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,10 @@ fn claude_jsonl_project_context_overrides_stale_session_index_path() {
project.repo_label.as_deref(),
Some("example-org/current-workspace")
);
assert_eq!(project.branch_label.as_deref(), Some("main"));
// The stale index's branch belongs to another directory, and the line
// records none, so the checkout's HEAD must not stand in for it.
assert_eq!(project.branch_label, None);
assert_eq!(project.branch_hash, None);

assert_eq!(scan.task_spans.len(), 1);
let task = &scan.task_spans[0];
Expand Down
13 changes: 11 additions & 2 deletions crates/statsai-adapters/src/codex/parse/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ pub(crate) fn parse_codex_file(
let mut current_reasoning = ModelReasoningState::default();
let mut current_model_is_fallback = false;
let mut current_project: Option<ProjectInfo> = None;
let mut session_project_inputs = CodexProjectInputs::default();
let mut current_title: Option<String> = None;
// Events only: a sub-agent borrows its parent thread's name, but task
// titles keep their own prompt-based rules.
Expand Down Expand Up @@ -399,7 +400,11 @@ pub(crate) fn parse_codex_file(
ctx.scan
.session_names
.extend(codex_subagent_session_name(&value, &session_id));
current_project = codex_project_context_from_value(&value, &mut project_cache);
session_project_inputs = codex_project_inputs_from_value(&value);
current_project = codex_project_context_from_inputs(
session_project_inputs.clone(),
&mut project_cache,
);
continue;
}

Expand All @@ -411,7 +416,11 @@ pub(crate) fn parse_codex_file(
current_model = Some(model_name);
current_model_is_fallback = false;
}
if let Some(project) = codex_project_context_from_value(&value, &mut project_cache) {
if let Some(project) = codex_turn_context_project_from_value(
&value,
&session_project_inputs,
&mut project_cache,
) {
current_project = Some(project);
}
continue;
Expand Down
74 changes: 58 additions & 16 deletions crates/statsai-adapters/src/codex/parse/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,26 +89,68 @@ pub(crate) fn codex_subagent_session_name(
})
}

/// The directory, remote, and branch one Codex line records for its project.
#[derive(Debug, Clone, Default)]
pub(crate) struct CodexProjectInputs {
project_path: Option<PathBuf>,
repository_url: Option<String>,
branch: Option<String>,
}

pub(crate) fn codex_project_inputs_from_value(value: &Value) -> CodexProjectInputs {
let payload = value.get("payload");
let git = payload.and_then(|payload| payload.get("git"));
let git_text = |key: &str| {
git.and_then(|git| git.get(key))
.and_then(Value::as_str)
.filter(|text| !text.trim().is_empty())
.map(ToOwned::to_owned)
};
CodexProjectInputs {
project_path: payload
.and_then(|payload| payload.get("cwd"))
.and_then(Value::as_str)
.map(expand_home_path),
repository_url: git_text("repository_url"),
branch: git_text("branch"),
}
}

pub(crate) fn codex_project_context_from_inputs(
inputs: CodexProjectInputs,
cache: &mut ProjectContextCache,
) -> Option<ProjectInfo> {
resolve_project_context_cached(
inputs.project_path,
inputs.repository_url,
inputs.branch,
cache,
)
}

pub(crate) fn codex_project_context_from_value(
value: &Value,
cache: &mut ProjectContextCache,
) -> Option<ProjectInfo> {
let payload = value.get("payload");
let project_path = payload
.and_then(|payload| payload.get("cwd"))
.and_then(Value::as_str)
.map(expand_home_path);
let repository_url = payload
.and_then(|payload| payload.get("git"))
.and_then(|git| git.get("repository_url"))
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let branch = payload
.and_then(|payload| payload.get("git"))
.and_then(|git| git.get("branch"))
.and_then(Value::as_str)
.map(ToOwned::to_owned);
resolve_project_context_cached(project_path, repository_url, branch, cache)
codex_project_context_from_inputs(codex_project_inputs_from_value(value), cache)
}

/// `turn_context` repeats the session's cwd but never its `git` block, so a
/// turn in the directory `session_meta` named keeps the remote and branch it
/// recorded. A turn in another directory has no recorded branch.
pub(crate) fn codex_turn_context_project_from_value(
value: &Value,
session: &CodexProjectInputs,
cache: &mut ProjectContextCache,
) -> Option<ProjectInfo> {
let mut inputs = codex_project_inputs_from_value(value);
if inputs.project_path.is_some() && inputs.project_path == session.project_path {
inputs.repository_url = inputs
.repository_url
.or_else(|| session.repository_url.clone());
inputs.branch = inputs.branch.or_else(|| session.branch.clone());
}
codex_project_context_from_inputs(inputs, cache)
}

pub(crate) fn codex_headless_usage_value(value: &Value) -> Option<&Value> {
Expand Down
161 changes: 161 additions & 0 deletions crates/statsai-adapters/src/codex/tests/parse/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,167 @@ fn codex_extracts_cwd_and_git_metadata_from_session_meta() {
assert_eq!(project.branch_label.as_deref(), Some("main"));
}

const BRANCH_TEST_REMOTE: &str = "git@github.com:example-org/example-workspace.git";

/// A session whose `session_meta` names `cwd` and, when given, `branch`,
/// followed by the `turn_context` and token count of one turn in `turn_cwd`.
fn write_codex_branch_session(path: &Path, cwd: &Path, turn_cwd: &Path, branch: Option<&str>) {
let git = branch
.map(|branch| {
format!(r#","git":{{"repository_url":"{BRANCH_TEST_REMOTE}","branch":"{branch}"}}"#)
})
.unwrap_or_default();
let mut file = File::create(path).expect("session file");
writeln!(
file,
r#"{{"timestamp":"2026-06-01T08:00:00Z","type":"session_meta","payload":{{"id":"branch-session","cwd":"{}"{git}}}}}"#,
cwd.display()
)
.expect("write session meta");
writeln!(
file,
r#"{{"timestamp":"2026-06-01T08:00:01Z","type":"turn_context","payload":{{"cwd":"{}","model":"gpt-5","approval_policy":"never"}}}}"#,
turn_cwd.display()
)
.expect("write turn context");
writeln!(
file,
r#"{{"timestamp":"2026-06-01T08:00:05Z","type":"event_msg","payload":{{"type":"token_count","info":{{"last_token_usage":{{"input_tokens":80,"cached_input_tokens":20,"output_tokens":40,"reasoning_output_tokens":10,"total_tokens":120}},"total_token_usage":{{"input_tokens":80,"cached_input_tokens":20,"output_tokens":40,"reasoning_output_tokens":10,"total_tokens":120}}}}}}}}"#
)
.expect("write tokens");
}

/// `(branch_label, branch_hash, repo_label)` of one event's project.
type BranchLabels = (Option<String>, Option<String>, Option<String>);

/// Scans `codex_root` once per checked-out branch and returns each scan's
/// labels for every event, in order.
fn codex_branches_per_checkout(
codex_root: &Path,
workspace: &Path,
checkouts: &[&str],
) -> Vec<Vec<BranchLabels>> {
let source = SourceLocation::local_adapter(
CODEX_PROVIDER,
"test",
"0",
codex_root,
LocationOrigin::Configured,
);
checkouts
.iter()
.map(|checkout| {
write_git_fixture(workspace, BRANCH_TEST_REMOTE, checkout);
let scan = scan_codex_source(&CodexAdapter, &source, &options()).expect("scan");
assert!(!scan.events.is_empty(), "the session must produce events");
scan.events
.iter()
.map(|event| {
let project = event.project.as_ref().expect("project");
(
project.branch_label.clone(),
project.branch_hash.clone(),
project.repo_label.clone(),
)
})
.collect()
})
.collect()
}

#[test]
fn codex_rescan_on_another_checkout_keeps_the_session_meta_branch() {
let dir = tempfile::tempdir().expect("tempdir");
let codex_root = dir.path().join("codex");
let sessions = codex_root.join("sessions");
let workspace = dir.path().join("workspace").join("ai-stats");
std::fs::create_dir_all(&sessions).expect("sessions");
std::fs::create_dir_all(&workspace).expect("workspace");
// The session ran on `main`; `turn_context` repeats its cwd but, as in
// real rollouts, carries no `git` block.
write_codex_branch_session(
&sessions.join("rollout.jsonl"),
&workspace,
&workspace,
Some("main"),
);

let scans = codex_branches_per_checkout(
&codex_root,
&workspace,
&["main", "feature/new-work", "release/next"],
);

let expected = (
Some("main".to_string()),
Some(hash_text("main")),
Some("example-org/example-workspace".to_string()),
);
for scan in &scans {
assert!(scan.iter().all(|labels| *labels == expected), "{scans:?}");
}
}

#[test]
fn codex_session_without_recorded_branch_never_takes_the_checkout_head() {
let dir = tempfile::tempdir().expect("tempdir");
let codex_root = dir.path().join("codex");
let sessions = codex_root.join("sessions");
let workspace = dir.path().join("workspace").join("ai-stats");
std::fs::create_dir_all(&sessions).expect("sessions");
std::fs::create_dir_all(&workspace).expect("workspace");
write_codex_branch_session(
&sessions.join("rollout.jsonl"),
&workspace,
&workspace,
None,
);

let scans = codex_branches_per_checkout(&codex_root, &workspace, &["main", "feature/new-work"]);

// The repository still comes from the checkout's remote; only the branch,
// which the checkout cannot know for a past session, stays unset.
let expected = (
None,
None,
Some("example-org/example-workspace".to_string()),
);
for scan in &scans {
assert!(scan.iter().all(|labels| *labels == expected), "{scans:?}");
}
}

#[test]
fn codex_turn_in_another_directory_does_not_inherit_the_session_branch() {
let dir = tempfile::tempdir().expect("tempdir");
let codex_root = dir.path().join("codex");
let sessions = codex_root.join("sessions");
let started_in = dir.path().join("workspace").join("started-here");
let workspace = dir.path().join("workspace").join("ai-stats");
std::fs::create_dir_all(&sessions).expect("sessions");
std::fs::create_dir_all(&started_in).expect("start dir");
std::fs::create_dir_all(&workspace).expect("workspace");
// `session_meta`'s branch describes the directory it names. A turn that
// moved to another checkout has no recorded branch there.
write_codex_branch_session(
&sessions.join("rollout.jsonl"),
&started_in,
&workspace,
Some("topic/started-here"),
);

let scans = codex_branches_per_checkout(&codex_root, &workspace, &["main", "feature/new-work"]);

let expected = (
None,
None,
Some("example-org/example-workspace".to_string()),
);
for scan in &scans {
assert!(scan.iter().all(|labels| *labels == expected), "{scans:?}");
}
}

#[test]
fn codex_line_filter_skips_non_message_response_items() {
let reasoning = r#"{"timestamp":"2026-06-03T09:36:21.793Z","type":"response_item","payload":{"type":"reasoning","summary":[],"encrypted_content":"abc"}}"#;
Expand Down
Loading
Loading