[codex-analytics] rework thread_source for thread analytics (#20949)

## Summary
- make `thread_source` an explicit optional thread-level field on
`thread/start`, `thread/fork`, and returned thread payloads
- persist `thread_source` in rollout/session metadata so resumed live
threads retain the original value
- replace the old best-effort `session_source` -> `thread_source`
mapping with an explicit caller-supplied analytics classification

## Why
Before this change, analytics `thread_source` was populated by a
best-effort mapping from `session_source`. `session_source` describes
the runtime/client surface, not the actual thread-level origin, so that
projection was not accurate enough to distinguish cases such as `user`,
`subagent`, `memory_consolidation`, and future thread origins reliably.

Making `thread_source` explicit keeps one thread-level analytics field
while letting callers provide the real classification directly instead
of recovering it indirectly from `session_source`.

## Impact
For new analytics events, `thread_source` now reflects the explicit
thread-level classification supplied by the caller rather than an
inferred value derived from `session_source`. Existing protocol fields
remain optional; callers that omit `threadSource` now produce `null`
instead of a best-effort inferred value.

## Validation
- `just write-app-server-schema`
- `cargo test -p codex-analytics -p codex-core -p
codex-app-server-protocol --no-run`
- `cargo test -p codex-app-server-protocol
generated_ts_optional_nullable_fields_only_in_params`
- `cargo test -p codex-analytics
thread_initialized_event_serializes_expected_shape`
- `cargo test -p codex-core
resume_stopped_thread_from_rollout_preserves_thread_source`
This commit is contained in:
rhan-oai
2026-05-05 19:12:31 -07:00
committed by GitHub
Unverified
parent 94db03d5af
commit b3d4f1a9f0
98 changed files with 896 additions and 90 deletions
@@ -2184,6 +2184,7 @@ mod tests {
cwd: cwd.clone(),
cli_version: "0.0.0".to_string(),
source: v2::SessionSource::Exec,
thread_source: None,
agent_nickname: None,
agent_role: None,
git_info: None,
@@ -2226,6 +2227,7 @@ mod tests {
"cwd": absolute_path_string("tmp"),
"cliVersion": "0.0.0",
"source": "exec",
"threadSource": null,
"agentNickname": null,
"agentRole": null,
"gitInfo": null,
@@ -6,6 +6,7 @@ use super::PermissionProfileSelectionParams;
use super::SandboxMode;
use super::SandboxPolicy;
use super::Thread;
use super::ThreadSource;
use super::Turn;
use super::TurnEnvironmentParams;
use super::shared::v2_enum_from_core;
@@ -134,6 +135,9 @@ pub struct ThreadStartParams {
pub ephemeral: Option<bool>,
#[ts(optional = nullable)]
pub session_start_source: Option<ThreadStartSource>,
/// Optional client-supplied analytics source classification for this thread.
#[ts(optional = nullable)]
pub thread_source: Option<ThreadSource>,
/// Optional sticky environments for this thread.
///
/// Omitted selects the default environment when environment access is
@@ -388,6 +392,9 @@ pub struct ThreadForkParams {
pub developer_instructions: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub ephemeral: bool,
/// Optional client-supplied analytics source classification for this forked thread.
#[ts(optional = nullable)]
pub thread_source: Option<ThreadSource>,
/// When true, return only thread metadata and live fork state without
/// populating `thread.turns`. This is useful when the client plans to call
/// `thread/turns/list` immediately after forking.
@@ -4,6 +4,7 @@ use super::ThreadStatus;
use super::TurnStatus;
use codex_protocol::protocol::SessionSource as CoreSessionSource;
use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource;
use codex_protocol::protocol::ThreadSource as CoreThreadSource;
use codex_utils_absolute_path::AbsolutePathBuf;
use schemars::JsonSchema;
use serde::Deserialize;
@@ -60,6 +61,35 @@ impl From<SessionSource> for CoreSessionSource {
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case", export_to = "v2/")]
pub enum ThreadSource {
User,
Subagent,
MemoryConsolidation,
}
impl From<CoreThreadSource> for ThreadSource {
fn from(value: CoreThreadSource) -> Self {
match value {
CoreThreadSource::User => ThreadSource::User,
CoreThreadSource::Subagent => ThreadSource::Subagent,
CoreThreadSource::MemoryConsolidation => ThreadSource::MemoryConsolidation,
}
}
}
impl From<ThreadSource> for CoreThreadSource {
fn from(value: ThreadSource) -> Self {
match value {
ThreadSource::User => CoreThreadSource::User,
ThreadSource::Subagent => CoreThreadSource::Subagent,
ThreadSource::MemoryConsolidation => CoreThreadSource::MemoryConsolidation,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -98,6 +128,8 @@ pub struct Thread {
pub cli_version: String,
/// Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.).
pub source: SessionSource,
/// Optional analytics source classification for this thread.
pub thread_source: Option<ThreadSource>,
/// Optional random unique nickname assigned to an AgentControl-spawned sub-agent.
pub agent_nickname: Option<String>,
/// Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.