[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-06 02:12:31 +00:00
committed by GitHub
parent 94db03d5af
commit b3d4f1a9f0
98 changed files with 896 additions and 90 deletions
@@ -79,6 +79,7 @@ use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadArchiveParams;
use codex_app_server_protocol::ThreadArchiveResponse;
use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadSource as AppServerThreadSource;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus;
use codex_app_server_protocol::Turn;
@@ -107,6 +108,7 @@ use codex_protocol::protocol::HookSource;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::ThreadSource;
use codex_protocol::protocol::TokenUsage;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
@@ -118,14 +120,11 @@ use std::sync::Arc;
use std::sync::Mutex;
use tokio::sync::mpsc;
fn sample_thread(thread_id: &str, ephemeral: bool) -> Thread {
sample_thread_with_source(thread_id, ephemeral, AppServerSessionSource::Exec)
}
fn sample_thread_with_source(
fn sample_thread_with_metadata(
thread_id: &str,
ephemeral: bool,
source: AppServerSessionSource,
thread_source: Option<AppServerThreadSource>,
) -> Thread {
Thread {
id: thread_id.to_string(),
@@ -140,6 +139,7 @@ fn sample_thread_with_source(
cwd: test_path_buf("/tmp").abs(),
cli_version: "0.0.0".to_string(),
source,
thread_source,
agent_nickname: None,
agent_role: None,
git_info: None,
@@ -154,7 +154,12 @@ fn sample_thread_start_response(
model: &str,
) -> ClientResponsePayload {
ClientResponsePayload::ThreadStart(ThreadStartResponse {
thread: sample_thread(thread_id, ephemeral),
thread: sample_thread_with_metadata(
thread_id,
ephemeral,
AppServerSessionSource::Exec,
Some(AppServerThreadSource::User),
),
model: model.to_string(),
model_provider: "openai".to_string(),
service_tier: None,
@@ -198,6 +203,7 @@ fn sample_thread_resume_response(
ephemeral,
model,
AppServerSessionSource::Exec,
Some(AppServerThreadSource::User),
)
}
@@ -206,9 +212,10 @@ fn sample_thread_resume_response_with_source(
ephemeral: bool,
model: &str,
source: AppServerSessionSource,
thread_source: Option<AppServerThreadSource>,
) -> ClientResponsePayload {
ClientResponsePayload::ThreadResume(ThreadResumeResponse {
thread: sample_thread_with_source(thread_id, ephemeral, source),
thread: sample_thread_with_metadata(thread_id, ephemeral, source, thread_source),
model: model.to_string(),
model_provider: "openai".to_string(),
service_tier: None,
@@ -753,7 +760,7 @@ fn compaction_event_serializes_expected_shape() {
},
sample_app_server_client_metadata(),
sample_runtime_metadata(),
Some("user"),
Some(ThreadSource::User),
/*subagent_source*/ None,
/*parent_thread_id*/ None,
),
@@ -852,7 +859,7 @@ fn thread_initialized_event_serializes_expected_shape() {
},
model: "gpt-5".to_string(),
ephemeral: true,
thread_source: Some("user"),
thread_source: Some(ThreadSource::User),
initialization_mode: ThreadInitializationMode::New,
subagent_source: None,
parent_thread_id: None,
@@ -1196,6 +1203,7 @@ async fn compaction_event_ingests_custom_fact() {
agent_nickname: None,
agent_role: None,
}),
Some(AppServerThreadSource::Subagent),
)),
},
&mut events,
@@ -2116,7 +2124,7 @@ fn turn_event_serializes_expected_shape() {
runtime: sample_runtime_metadata(),
submission_type: None,
ephemeral: false,
thread_source: Some("user".to_string()),
thread_source: Some(ThreadSource::User),
initialization_mode: ThreadInitializationMode::New,
subagent_source: None,
parent_thread_id: None,
+1
View File
@@ -87,6 +87,7 @@ fn sample_thread(thread_id: &str) -> Thread {
cwd: test_path_buf("/tmp").abs(),
cli_version: "0.0.0".to_string(),
source: AppServerSessionSource::Exec,
thread_source: None,
agent_nickname: None,
agent_role: None,
git_info: None,
+7 -6
View File
@@ -33,6 +33,7 @@ use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookRunStatus;
use codex_protocol::protocol::HookSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::ThreadSource;
use codex_protocol::protocol::TokenUsage;
use serde::Serialize;
@@ -126,7 +127,7 @@ pub(crate) struct ThreadInitializedEventParams {
pub(crate) runtime: CodexRuntimeMetadata,
pub(crate) model: String,
pub(crate) ephemeral: bool,
pub(crate) thread_source: Option<&'static str>,
pub(crate) thread_source: Option<ThreadSource>,
pub(crate) initialization_mode: ThreadInitializationMode,
pub(crate) subagent_source: Option<String>,
pub(crate) parent_thread_id: Option<String>,
@@ -647,7 +648,7 @@ pub(crate) struct CodexCompactionEventParams {
pub(crate) turn_id: String,
pub(crate) app_server_client: CodexAppServerClientMetadata,
pub(crate) runtime: CodexRuntimeMetadata,
pub(crate) thread_source: Option<&'static str>,
pub(crate) thread_source: Option<ThreadSource>,
pub(crate) subagent_source: Option<String>,
pub(crate) parent_thread_id: Option<String>,
pub(crate) trigger: CompactionTrigger,
@@ -680,7 +681,7 @@ pub(crate) struct CodexTurnEventParams {
pub(crate) app_server_client: CodexAppServerClientMetadata,
pub(crate) runtime: CodexRuntimeMetadata,
pub(crate) ephemeral: bool,
pub(crate) thread_source: Option<String>,
pub(crate) thread_source: Option<ThreadSource>,
pub(crate) initialization_mode: ThreadInitializationMode,
pub(crate) subagent_source: Option<String>,
pub(crate) parent_thread_id: Option<String>,
@@ -733,7 +734,7 @@ pub(crate) struct CodexTurnSteerEventParams {
pub(crate) accepted_turn_id: Option<String>,
pub(crate) app_server_client: CodexAppServerClientMetadata,
pub(crate) runtime: CodexRuntimeMetadata,
pub(crate) thread_source: Option<String>,
pub(crate) thread_source: Option<ThreadSource>,
pub(crate) subagent_source: Option<String>,
pub(crate) parent_thread_id: Option<String>,
pub(crate) num_input_images: usize,
@@ -836,7 +837,7 @@ pub(crate) fn codex_compaction_event_params(
input: CodexCompactionEvent,
app_server_client: CodexAppServerClientMetadata,
runtime: CodexRuntimeMetadata,
thread_source: Option<&'static str>,
thread_source: Option<ThreadSource>,
subagent_source: Option<String>,
parent_thread_id: Option<String>,
) -> CodexCompactionEventParams {
@@ -940,7 +941,7 @@ pub(crate) fn subagent_thread_started_event_request(
runtime: current_runtime_metadata(),
model: input.model,
ephemeral: input.ephemeral,
thread_source: Some("subagent"),
thread_source: Some(ThreadSource::Subagent),
initialization_mode: ThreadInitializationMode::New,
subagent_source: Some(subagent_source_name(&input.subagent_source)),
parent_thread_id: input
+13 -8
View File
@@ -64,6 +64,7 @@ use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SkillScope;
use codex_protocol::protocol::ThreadSource;
use codex_protocol::protocol::TokenUsage;
use sha1::Digest;
use std::collections::HashMap;
@@ -147,7 +148,7 @@ enum MissingAnalyticsContext {
#[derive(Clone)]
struct ThreadMetadataState {
thread_source: Option<&'static str>,
thread_source: Option<ThreadSource>,
initialization_mode: ThreadInitializationMode,
subagent_source: Option<String>,
parent_thread_id: Option<String>,
@@ -156,6 +157,7 @@ struct ThreadMetadataState {
impl ThreadMetadataState {
fn from_thread_metadata(
session_source: &SessionSource,
thread_source: Option<ThreadSource>,
initialization_mode: ThreadInitializationMode,
) -> Self {
let (subagent_source, parent_thread_id) = match session_source {
@@ -172,7 +174,7 @@ impl ThreadMetadataState {
| SessionSource::Unknown => (None, None),
};
Self {
thread_source: session_source.thread_source_name(),
thread_source,
initialization_mode,
subagent_source,
parent_thread_id,
@@ -348,7 +350,7 @@ impl AnalyticsReducer {
thread_state
.metadata
.get_or_insert_with(|| ThreadMetadataState {
thread_source: Some("subagent"),
thread_source: Some(ThreadSource::Subagent),
initialization_mode: ThreadInitializationMode::New,
subagent_source: Some(subagent_source_name(&input.subagent_source)),
parent_thread_id,
@@ -749,13 +751,16 @@ impl AnalyticsReducer {
initialization_mode: ThreadInitializationMode,
out: &mut Vec<TrackEventRequest>,
) {
let thread_source: SessionSource = thread.source.into();
let session_source: SessionSource = thread.source.into();
let thread_id = thread.id;
let Some(connection_state) = self.connections.get(&connection_id) else {
return;
};
let thread_metadata =
ThreadMetadataState::from_thread_metadata(&thread_source, initialization_mode);
let thread_metadata = ThreadMetadataState::from_thread_metadata(
&session_source,
thread.thread_source.map(Into::into),
initialization_mode,
);
self.threads.insert(
thread_id.clone(),
ThreadAnalyticsState {
@@ -857,7 +862,7 @@ impl AnalyticsReducer {
accepted_turn_id,
app_server_client: connection_state.app_server_client.clone(),
runtime: connection_state.runtime.clone(),
thread_source: thread_metadata.thread_source.map(str::to_string),
thread_source: thread_metadata.thread_source,
subagent_source: thread_metadata.subagent_source.clone(),
parent_thread_id: thread_metadata.parent_thread_id.clone(),
num_input_images: pending_request.num_input_images,
@@ -1023,7 +1028,7 @@ fn codex_turn_event_params(
runtime,
submission_type,
ephemeral,
thread_source: thread_metadata.thread_source.map(str::to_string),
thread_source: thread_metadata.thread_source,
initialization_mode: thread_metadata.initialization_mode,
subagent_source: thread_metadata.subagent_source.clone(),
parent_thread_id: thread_metadata.parent_thread_id.clone(),
@@ -3558,6 +3558,17 @@
},
"threadId": {
"type": "string"
},
"threadSource": {
"anyOf": [
{
"$ref": "#/definitions/ThreadSource"
},
{
"type": "null"
}
],
"description": "Optional client-supplied analytics source classification for this forked thread."
}
},
"required": [
@@ -4032,6 +4043,14 @@
],
"type": "string"
},
"ThreadSource": {
"enum": [
"user",
"subagent",
"memory_consolidation"
],
"type": "string"
},
"ThreadSourceKind": {
"enum": [
"cli",
@@ -4165,6 +4184,17 @@
"type": "null"
}
]
},
"threadSource": {
"anyOf": [
{
"$ref": "#/definitions/ThreadSource"
},
{
"type": "null"
}
],
"description": "Optional client-supplied analytics source classification for this thread."
}
},
"type": "object"
@@ -3088,6 +3088,17 @@
],
"description": "Current runtime status for the thread."
},
"threadSource": {
"anyOf": [
{
"$ref": "#/definitions/ThreadSource"
},
{
"type": "null"
}
],
"description": "Optional analytics source classification for this thread."
},
"turns": {
"description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.",
"items": {
@@ -4094,6 +4105,14 @@
],
"type": "object"
},
"ThreadSource": {
"enum": [
"user",
"subagent",
"memory_consolidation"
],
"type": "string"
},
"ThreadStartedNotification": {
"properties": {
"thread": {
@@ -15232,6 +15232,17 @@
],
"description": "Current runtime status for the thread."
},
"threadSource": {
"anyOf": [
{
"$ref": "#/definitions/v2/ThreadSource"
},
{
"type": "null"
}
],
"description": "Optional analytics source classification for this thread."
},
"turns": {
"description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.",
"items": {
@@ -15446,6 +15457,17 @@
},
"threadId": {
"type": "string"
},
"threadSource": {
"anyOf": [
{
"$ref": "#/definitions/v2/ThreadSource"
},
{
"type": "null"
}
],
"description": "Optional client-supplied analytics source classification for this forked thread."
}
},
"required": [
@@ -17126,6 +17148,14 @@
],
"type": "string"
},
"ThreadSource": {
"enum": [
"user",
"subagent",
"memory_consolidation"
],
"type": "string"
},
"ThreadSourceKind": {
"enum": [
"cli",
@@ -17260,6 +17290,17 @@
"type": "null"
}
]
},
"threadSource": {
"anyOf": [
{
"$ref": "#/definitions/v2/ThreadSource"
},
{
"type": "null"
}
],
"description": "Optional client-supplied analytics source classification for this thread."
}
},
"title": "ThreadStartParams",
@@ -13118,6 +13118,17 @@
],
"description": "Current runtime status for the thread."
},
"threadSource": {
"anyOf": [
{
"$ref": "#/definitions/ThreadSource"
},
{
"type": "null"
}
],
"description": "Optional analytics source classification for this thread."
},
"turns": {
"description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.",
"items": {
@@ -13332,6 +13343,17 @@
},
"threadId": {
"type": "string"
},
"threadSource": {
"anyOf": [
{
"$ref": "#/definitions/ThreadSource"
},
{
"type": "null"
}
],
"description": "Optional client-supplied analytics source classification for this forked thread."
}
},
"required": [
@@ -15012,6 +15034,14 @@
],
"type": "string"
},
"ThreadSource": {
"enum": [
"user",
"subagent",
"memory_consolidation"
],
"type": "string"
},
"ThreadSourceKind": {
"enum": [
"cli",
@@ -15146,6 +15176,17 @@
"type": "null"
}
]
},
"threadSource": {
"anyOf": [
{
"$ref": "#/definitions/ThreadSource"
},
{
"type": "null"
}
],
"description": "Optional client-supplied analytics source classification for this thread."
}
},
"title": "ThreadStartParams",
@@ -137,6 +137,14 @@
"flex"
],
"type": "string"
},
"ThreadSource": {
"enum": [
"user",
"subagent",
"memory_consolidation"
],
"type": "string"
}
},
"description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.",
@@ -232,6 +240,17 @@
},
"threadId": {
"type": "string"
},
"threadSource": {
"anyOf": [
{
"$ref": "#/definitions/ThreadSource"
},
{
"type": "null"
}
],
"description": "Optional client-supplied analytics source classification for this forked thread."
}
},
"required": [
@@ -1419,6 +1419,17 @@
],
"description": "Current runtime status for the thread."
},
"threadSource": {
"anyOf": [
{
"$ref": "#/definitions/ThreadSource"
},
{
"type": "null"
}
],
"description": "Optional analytics source classification for this thread."
},
"turns": {
"description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.",
"items": {
@@ -2117,6 +2128,14 @@
}
]
},
"ThreadSource": {
"enum": [
"user",
"subagent",
"memory_consolidation"
],
"type": "string"
},
"ThreadStatus": {
"oneOf": [
{
@@ -869,6 +869,17 @@
],
"description": "Current runtime status for the thread."
},
"threadSource": {
"anyOf": [
{
"$ref": "#/definitions/ThreadSource"
},
{
"type": "null"
}
],
"description": "Optional analytics source classification for this thread."
},
"turns": {
"description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.",
"items": {
@@ -1567,6 +1578,14 @@
}
]
},
"ThreadSource": {
"enum": [
"user",
"subagent",
"memory_consolidation"
],
"type": "string"
},
"ThreadStatus": {
"oneOf": [
{
@@ -869,6 +869,17 @@
],
"description": "Current runtime status for the thread."
},
"threadSource": {
"anyOf": [
{
"$ref": "#/definitions/ThreadSource"
},
{
"type": "null"
}
],
"description": "Optional analytics source classification for this thread."
},
"turns": {
"description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.",
"items": {
@@ -1567,6 +1578,14 @@
}
]
},
"ThreadSource": {
"enum": [
"user",
"subagent",
"memory_consolidation"
],
"type": "string"
},
"ThreadStatus": {
"oneOf": [
{
@@ -869,6 +869,17 @@
],
"description": "Current runtime status for the thread."
},
"threadSource": {
"anyOf": [
{
"$ref": "#/definitions/ThreadSource"
},
{
"type": "null"
}
],
"description": "Optional analytics source classification for this thread."
},
"turns": {
"description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.",
"items": {
@@ -1567,6 +1578,14 @@
}
]
},
"ThreadSource": {
"enum": [
"user",
"subagent",
"memory_consolidation"
],
"type": "string"
},
"ThreadStatus": {
"oneOf": [
{
@@ -1419,6 +1419,17 @@
],
"description": "Current runtime status for the thread."
},
"threadSource": {
"anyOf": [
{
"$ref": "#/definitions/ThreadSource"
},
{
"type": "null"
}
],
"description": "Optional analytics source classification for this thread."
},
"turns": {
"description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.",
"items": {
@@ -2117,6 +2128,14 @@
}
]
},
"ThreadSource": {
"enum": [
"user",
"subagent",
"memory_consolidation"
],
"type": "string"
},
"ThreadStatus": {
"oneOf": [
{
@@ -869,6 +869,17 @@
],
"description": "Current runtime status for the thread."
},
"threadSource": {
"anyOf": [
{
"$ref": "#/definitions/ThreadSource"
},
{
"type": "null"
}
],
"description": "Optional analytics source classification for this thread."
},
"turns": {
"description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.",
"items": {
@@ -1567,6 +1578,14 @@
}
]
},
"ThreadSource": {
"enum": [
"user",
"subagent",
"memory_consolidation"
],
"type": "string"
},
"ThreadStatus": {
"oneOf": [
{
@@ -172,6 +172,14 @@
],
"type": "string"
},
"ThreadSource": {
"enum": [
"user",
"subagent",
"memory_consolidation"
],
"type": "string"
},
"ThreadStartSource": {
"enum": [
"startup",
@@ -312,6 +320,17 @@
"type": "null"
}
]
},
"threadSource": {
"anyOf": [
{
"$ref": "#/definitions/ThreadSource"
},
{
"type": "null"
}
],
"description": "Optional client-supplied analytics source classification for this thread."
}
},
"title": "ThreadStartParams",
@@ -1419,6 +1419,17 @@
],
"description": "Current runtime status for the thread."
},
"threadSource": {
"anyOf": [
{
"$ref": "#/definitions/ThreadSource"
},
{
"type": "null"
}
],
"description": "Optional analytics source classification for this thread."
},
"turns": {
"description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.",
"items": {
@@ -2117,6 +2128,14 @@
}
]
},
"ThreadSource": {
"enum": [
"user",
"subagent",
"memory_consolidation"
],
"type": "string"
},
"ThreadStatus": {
"oneOf": [
{
@@ -869,6 +869,17 @@
],
"description": "Current runtime status for the thread."
},
"threadSource": {
"anyOf": [
{
"$ref": "#/definitions/ThreadSource"
},
{
"type": "null"
}
],
"description": "Optional analytics source classification for this thread."
},
"turns": {
"description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.",
"items": {
@@ -1567,6 +1578,14 @@
}
]
},
"ThreadSource": {
"enum": [
"user",
"subagent",
"memory_consolidation"
],
"type": "string"
},
"ThreadStatus": {
"oneOf": [
{
@@ -869,6 +869,17 @@
],
"description": "Current runtime status for the thread."
},
"threadSource": {
"anyOf": [
{
"$ref": "#/definitions/ThreadSource"
},
{
"type": "null"
}
],
"description": "Optional analytics source classification for this thread."
},
"turns": {
"description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.",
"items": {
@@ -1567,6 +1578,14 @@
}
]
},
"ThreadSource": {
"enum": [
"user",
"subagent",
"memory_consolidation"
],
"type": "string"
},
"ThreadStatus": {
"oneOf": [
{
@@ -4,6 +4,7 @@
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { GitInfo } from "./GitInfo";
import type { SessionSource } from "./SessionSource";
import type { ThreadSource } from "./ThreadSource";
import type { ThreadStatus } from "./ThreadStatus";
import type { Turn } from "./Turn";
@@ -52,6 +53,10 @@ cliVersion: string,
* Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.).
*/
source: SessionSource,
/**
* Optional analytics source classification for this thread.
*/
threadSource: ThreadSource | null,
/**
* Optional random unique nickname assigned to an AgentControl-spawned sub-agent.
*/
@@ -6,6 +6,7 @@ import type { JsonValue } from "../serde_json/JsonValue";
import type { ApprovalsReviewer } from "./ApprovalsReviewer";
import type { AskForApproval } from "./AskForApproval";
import type { SandboxMode } from "./SandboxMode";
import type { ThreadSource } from "./ThreadSource";
/**
* There are two ways to fork a thread:
@@ -23,4 +24,7 @@ model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier
* Override where approval requests are routed for review on this thread
* and subsequent turns.
*/
approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, ephemeral?: boolean};
approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, ephemeral?: boolean, /**
* Optional client-supplied analytics source classification for this forked thread.
*/
threadSource?: ThreadSource | null};
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ThreadSource = "user" | "subagent" | "memory_consolidation";
@@ -7,10 +7,14 @@ import type { JsonValue } from "../serde_json/JsonValue";
import type { ApprovalsReviewer } from "./ApprovalsReviewer";
import type { AskForApproval } from "./AskForApproval";
import type { SandboxMode } from "./SandboxMode";
import type { ThreadSource } from "./ThreadSource";
import type { ThreadStartSource } from "./ThreadStartSource";
export type ThreadStartParams = {model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /**
* Override where approval requests are routed for review on this thread
* and subsequent turns.
*/
approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, serviceName?: string | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, ephemeral?: boolean | null, sessionStartSource?: ThreadStartSource | null};
approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, serviceName?: string | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, ephemeral?: boolean | null, sessionStartSource?: ThreadStartSource | null, /**
* Optional client-supplied analytics source classification for this thread.
*/
threadSource?: ThreadSource | null};
@@ -396,6 +396,7 @@ export type { ThreadSetNameResponse } from "./ThreadSetNameResponse";
export type { ThreadShellCommandParams } from "./ThreadShellCommandParams";
export type { ThreadShellCommandResponse } from "./ThreadShellCommandResponse";
export type { ThreadSortKey } from "./ThreadSortKey";
export type { ThreadSource } from "./ThreadSource";
export type { ThreadSourceKind } from "./ThreadSourceKind";
export type { ThreadStartParams } from "./ThreadStartParams";
export type { ThreadStartResponse } from "./ThreadStartResponse";
@@ -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.
@@ -2182,6 +2182,7 @@ mod tests {
cwd: test_path_buf("/tmp").abs().into(),
cli_version: "0.0.0".to_string(),
source: SessionSource::Cli,
thread_source: None,
agent_nickname: None,
agent_role: None,
agent_path: None,
@@ -307,6 +307,7 @@ impl ExternalAgentConfigRequestProcessor {
config,
initial_history: InitialHistory::Forked(rollout_items),
session_source: None,
thread_source: None,
dynamic_tools: Vec::new(),
persist_extended_history: false,
metrics_service_name: None,
@@ -746,6 +746,7 @@ impl ThreadRequestProcessor {
personality,
ephemeral,
session_start_source,
thread_source,
environments,
persist_extended_history,
} = params;
@@ -799,6 +800,7 @@ impl ThreadRequestProcessor {
typesafe_overrides,
dynamic_tools,
session_start_source,
thread_source.map(Into::into),
environment_selections,
service_name,
experimental_raw_events,
@@ -882,6 +884,7 @@ impl ThreadRequestProcessor {
typesafe_overrides: ConfigOverrides,
dynamic_tools: Option<Vec<ApiDynamicToolSpec>>,
session_start_source: Option<codex_app_server_protocol::ThreadStartSource>,
thread_source: Option<codex_protocol::protocol::ThreadSource>,
environments: Option<Vec<TurnEnvironmentSelection>>,
service_name: Option<String>,
experimental_raw_events: bool,
@@ -998,6 +1001,7 @@ impl ThreadRequestProcessor {
codex_app_server_protocol::ThreadStartSource::Clear => InitialHistory::Cleared,
},
session_source: None,
thread_source,
dynamic_tools: core_dynamic_tools,
persist_extended_history: false,
metrics_service_name: service_name,
@@ -2382,6 +2386,11 @@ impl ThreadRequestProcessor {
return Ok(());
}
};
thread.thread_source = codex_thread
.config_snapshot()
.await
.thread_source
.map(Into::into);
self.thread_watch_manager
.upsert_thread(thread.clone())
@@ -2869,6 +2878,7 @@ impl ThreadRequestProcessor {
base_instructions,
developer_instructions,
ephemeral,
thread_source,
exclude_turns,
persist_extended_history,
} = params;
@@ -2959,6 +2969,7 @@ impl ThreadRequestProcessor {
history: history_items.clone(),
rollout_path: source_thread.rollout_path.clone(),
}),
thread_source.map(Into::into),
/*persist_extended_history*/ false,
self.request_trace_context(&request_id).await,
)
@@ -3018,6 +3029,11 @@ impl ThreadRequestProcessor {
}
thread
};
thread.thread_source = forked_thread
.config_snapshot()
.await
.thread_source
.map(Into::into);
self.thread_watch_manager
.upsert_thread_silently(thread.clone())
@@ -3620,6 +3636,7 @@ pub(crate) fn thread_from_stored_thread(
agent_nickname: source.get_nickname(),
agent_role: source.get_agent_role(),
source: source.into(),
thread_source: thread.thread_source.map(Into::into),
git_info,
name: thread.name,
turns: Vec::new(),
@@ -3682,6 +3699,7 @@ fn summary_from_state_db_metadata(
cwd: PathBuf,
cli_version: String,
source: String,
_thread_source: Option<codex_protocol::protocol::ThreadSource>,
agent_nickname: Option<String>,
agent_role: Option<String>,
git_sha: Option<String>,
@@ -3732,6 +3750,7 @@ fn summary_from_thread_metadata(metadata: &ThreadMetadata) -> ConversationSummar
metadata.cwd.clone(),
metadata.cli_version.clone(),
metadata.source.clone(),
metadata.thread_source,
metadata.agent_nickname.clone(),
metadata.agent_role.clone(),
metadata.git_sha.clone(),
@@ -3815,6 +3834,7 @@ fn build_thread_from_snapshot(
agent_nickname: config_snapshot.session_source.get_nickname(),
agent_role: config_snapshot.session_source.get_agent_role(),
source: config_snapshot.session_source.clone().into(),
thread_source: config_snapshot.thread_source.map(Into::into),
git_info: None,
name: None,
turns: Vec::new(),
@@ -283,6 +283,7 @@ mod thread_processor_behavior_tests {
cwd: PathBuf::from("/tmp"),
cli_version: "0.0.0".to_string(),
source: SessionSource::Cli,
thread_source: Some(codex_protocol::protocol::ThreadSource::User),
agent_nickname: None,
agent_role: None,
agent_path: None,
@@ -540,6 +541,7 @@ mod thread_processor_behavior_tests {
reasoning_effort: None,
personality: None,
session_source: SessionSource::Cli,
thread_source: None,
};
assert_eq!(
@@ -828,6 +830,7 @@ mod thread_processor_behavior_tests {
agent_nickname: None,
agent_role: None,
}),
thread_source: Some(codex_protocol::protocol::ThreadSource::Subagent),
agent_nickname: Some("atlas".to_string()),
agent_role: Some("explorer".to_string()),
model_provider: Some("test-provider".to_string()),
@@ -849,6 +852,7 @@ mod thread_processor_behavior_tests {
assert_eq!(thread.agent_nickname, Some("atlas".to_string()));
assert_eq!(thread.agent_role, Some("explorer".to_string()));
assert_eq!(thread.thread_source, None);
Ok(())
}
@@ -975,6 +979,7 @@ mod thread_processor_behavior_tests {
PathBuf::from("/"),
"0.0.0".to_string(),
source,
Some(codex_protocol::protocol::ThreadSource::Subagent),
Some("atlas".to_string()),
Some("explorer".to_string()),
/*git_sha*/ None,
@@ -278,6 +278,7 @@ pub(crate) fn summary_to_thread(
agent_nickname: source.get_nickname(),
agent_role: source.get_agent_role(),
source: source.into(),
thread_source: None,
git_info,
name: None,
turns: Vec::new(),
@@ -904,6 +904,7 @@ impl TurnRequestProcessor {
history: parent_history.items,
rollout_path: parent_thread.rollout_path(),
}),
/*thread_source*/ None,
/*persist_extended_history*/ false,
self.request_trace_context(request_id).await,
)
+1
View File
@@ -902,6 +902,7 @@ mod tests {
agent_nickname: None,
agent_role: None,
source,
thread_source: None,
git_info: None,
name: None,
turns: Vec::new(),
@@ -138,6 +138,7 @@ pub fn create_fake_rollout_with_source(
originator: "codex".to_string(),
cli_version: "0.0.0".to_string(),
source,
thread_source: None,
agent_path: None,
agent_nickname: None,
agent_role: None,
@@ -221,6 +222,7 @@ pub fn create_fake_rollout_with_text_elements(
originator: "codex".to_string(),
cli_version: "0.0.0".to_string(),
source: SessionSource::Cli,
thread_source: None,
agent_path: None,
agent_nickname: None,
agent_role: None,
@@ -170,6 +170,7 @@ pub(crate) fn assert_basic_thread_initialized_event(
thread_id: &str,
expected_model: &str,
initialization_mode: &str,
expected_thread_source: &str,
) {
assert_eq!(event["event_params"]["thread_id"], thread_id);
assert_eq!(
@@ -186,7 +187,10 @@ pub(crate) fn assert_basic_thread_initialized_event(
);
assert_eq!(event["event_params"]["model"], expected_model);
assert_eq!(event["event_params"]["ephemeral"], false);
assert_eq!(event["event_params"]["thread_source"], "user");
assert_eq!(
event["event_params"]["thread_source"],
expected_thread_source
);
assert_eq!(
event["event_params"]["subagent_source"],
serde_json::Value::Null
@@ -675,6 +675,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<(
personality: None,
ephemeral: None,
session_start_source: None,
thread_source: None,
dynamic_tools: None,
environments: None,
mock_experimental_field: None,
@@ -17,6 +17,7 @@ use codex_app_server_protocol::ThreadForkResponse;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadListParams;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadSource;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadStartedNotification;
@@ -90,6 +91,7 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> {
let fork_id = mcp
.send_thread_fork_request(ThreadForkParams {
thread_id: conversation_id.clone(),
thread_source: Some(ThreadSource::User),
..Default::default()
})
.await?;
@@ -128,6 +130,7 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> {
assert_ne!(thread_path.as_path(), original_path);
assert!(thread.cwd.as_path().is_absolute());
assert_eq!(thread.source, SessionSource::VsCode);
assert_eq!(thread.thread_source, Some(ThreadSource::User));
assert_eq!(thread.name, None);
assert_eq!(
@@ -188,6 +191,13 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> {
Some(&json!([])),
"thread/started must not emit copied fork turns"
);
assert_eq!(
started_thread_json
.get("threadSource")
.and_then(Value::as_str),
Some("user"),
"thread/started should preserve the caller-supplied fork origin"
);
let started: ThreadStartedNotification =
serde_json::from_value(notif.params.expect("params must be present"))?;
let mut expected_started_thread = thread;
@@ -299,6 +309,7 @@ async fn thread_fork_emits_restored_token_usage_before_next_turn() -> Result<()>
let fork_id = mcp
.send_thread_fork_request(ThreadForkParams {
thread_id: conversation_id,
thread_source: Some(ThreadSource::User),
..Default::default()
})
.await?;
@@ -403,6 +414,7 @@ async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> {
let fork_id = mcp
.send_thread_fork_request(ThreadForkParams {
thread_id: conversation_id,
thread_source: Some(ThreadSource::User),
..Default::default()
})
.await?;
@@ -415,7 +427,7 @@ async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> {
let payload = wait_for_analytics_payload(&server, DEFAULT_READ_TIMEOUT).await?;
let event = thread_initialized_event(&payload)?;
assert_basic_thread_initialized_event(event, &thread.id, "mock-model", "forked");
assert_basic_thread_initialized_event(event, &thread.id, "mock-model", "forked", "user");
Ok(())
}
@@ -1119,6 +1119,7 @@ async fn seed_pathless_store_thread(
thread_id,
forked_from_id: None,
source: ProtocolSessionSource::Cli,
thread_source: None,
base_instructions: BaseInstructions::default(),
dynamic_tools: Vec::new(),
metadata: ThreadPersistenceMetadata {
@@ -38,6 +38,7 @@ use codex_app_server_protocol::ThreadReadParams;
use codex_app_server_protocol::ThreadReadResponse;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadSource;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadStatus;
@@ -240,15 +241,20 @@ async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> {
create_config_toml_with_chatgpt_base_url(codex_home.path(), &server.uri(), &server.uri())?;
mount_analytics_capture(&server, codex_home.path()).await?;
let conversation_id = create_fake_rollout_with_text_elements(
let conversation_id = create_fake_rollout(
codex_home.path(),
"2025-01-05T12-00-00",
"2025-01-05T12:00:00Z",
"Saved user message",
Vec::new(),
Some("mock_provider"),
/*git_info*/ None,
)?;
set_thread_source_on_fake_rollout(
codex_home.path(),
"2025-01-05T12-00-00",
&conversation_id,
"user",
)?;
let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
@@ -265,10 +271,31 @@ async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> {
)
.await??;
let ThreadResumeResponse { thread, .. } = to_response::<ThreadResumeResponse>(resume_resp)?;
assert_eq!(thread.thread_source, Some(ThreadSource::User));
let payload = wait_for_analytics_payload(&server, DEFAULT_READ_TIMEOUT).await?;
let event = thread_initialized_event(&payload)?;
assert_basic_thread_initialized_event(event, &thread.id, "gpt-5.3-codex", "resumed");
assert_basic_thread_initialized_event(event, &thread.id, "gpt-5.3-codex", "resumed", "user");
assert_eq!(event["event_params"]["thread_source"], "user");
Ok(())
}
fn set_thread_source_on_fake_rollout(
codex_home: &std::path::Path,
filename_ts: &str,
thread_id: &str,
thread_source: &str,
) -> Result<()> {
let path = rollout_path(codex_home, filename_ts, thread_id);
let contents = std::fs::read_to_string(&path)?;
let mut lines = contents.lines();
let session_meta = lines
.next()
.ok_or_else(|| anyhow::anyhow!("fake rollout missing session meta"))?;
let mut session_meta: serde_json::Value = serde_json::from_str(session_meta)?;
session_meta["payload"]["thread_source"] = serde_json::json!(thread_source);
let remaining = lines.collect::<Vec<_>>().join("\n");
std::fs::write(&path, format!("{session_meta}\n{remaining}\n"))?;
Ok(())
}
@@ -1179,6 +1206,7 @@ stream_max_retries = 0
originator: "codex".to_string(),
cli_version: "0.0.0".to_string(),
source: RolloutSessionSource::Cli,
thread_source: None,
agent_path: None,
agent_nickname: None,
agent_role: None,
@@ -15,6 +15,7 @@ use codex_app_server_protocol::McpServerStatusUpdatedNotification;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SandboxMode;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadSource;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadStartedNotification;
@@ -107,6 +108,7 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> {
let req_id = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("gpt-5.2".to_string()),
thread_source: Some(ThreadSource::User),
..Default::default()
})
.await?;
@@ -138,6 +140,7 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> {
"new persistent threads should not be ephemeral"
);
assert_eq!(thread.status, ThreadStatus::Idle);
assert_eq!(thread.thread_source, Some(ThreadSource::User));
let thread_path = thread.path.clone().expect("thread path should be present");
assert!(thread_path.is_absolute(), "thread path should be absolute");
assert!(
@@ -160,6 +163,11 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> {
Some(false),
"new persistent threads should serialize `ephemeral: false`"
);
assert_eq!(
thread_json.get("threadSource").and_then(Value::as_str),
Some("user"),
"new threads should serialize the caller-supplied thread origin"
);
assert_eq!(thread.name, None);
// A corresponding thread/started notification should arrive.
@@ -201,6 +209,13 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> {
Some(false),
"thread/started should serialize `ephemeral: false` for new persistent threads"
);
assert_eq!(
started_thread_json
.get("threadSource")
.and_then(Value::as_str),
Some("user"),
"thread/started should preserve the caller-supplied thread origin"
);
let started: ThreadStartedNotification =
serde_json::from_value(notif.params.expect("params must be present"))?;
assert_eq!(started.thread, thread);
@@ -312,7 +327,10 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> {
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let req_id = mcp
.send_thread_start_request(ThreadStartParams::default())
.send_thread_start_request(ThreadStartParams {
thread_source: Some(ThreadSource::User),
..Default::default()
})
.await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
@@ -324,7 +342,7 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> {
let payload = wait_for_analytics_payload(&server, DEFAULT_READ_TIMEOUT).await?;
assert_eq!(payload["events"].as_array().expect("events array").len(), 1);
let event = thread_initialized_event(&payload)?;
assert_basic_thread_initialized_event(event, &thread.id, "mock-model", "new");
assert_basic_thread_initialized_event(event, &thread.id, "mock-model", "new", "user");
Ok(())
}
@@ -40,6 +40,7 @@ use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ServerRequestResolvedNotification;
use codex_app_server_protocol::TextElement;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadSource;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnCompletedNotification;
@@ -116,6 +117,7 @@ async fn turn_start_sends_originator_header() -> Result<()> {
let thread_req = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
thread_source: Some(ThreadSource::User),
..Default::default()
})
.await?;
@@ -183,6 +185,7 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> {
let thread_req = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
thread_source: Some(ThreadSource::User),
..Default::default()
})
.await?;
@@ -464,6 +467,7 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> {
let thread_req = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
thread_source: Some(ThreadSource::User),
..Default::default()
})
.await?;
+3
View File
@@ -27,6 +27,7 @@ use codex_protocol::protocol::ResumedHistory;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::ThreadSource;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_protocol::user_input::UserInput;
use codex_thread_store::ReadThreadParams;
@@ -234,6 +235,7 @@ impl AgentControl {
config.clone(),
self.clone(),
session_source,
/*thread_source*/ Some(ThreadSource::Subagent),
/*persist_extended_history*/ false,
/*metrics_service_name*/ None,
inherited_shell_snapshot,
@@ -420,6 +422,7 @@ impl AgentControl {
InitialHistory::Forked(forked_rollout_items),
self.clone(),
session_source,
/*thread_source*/ Some(ThreadSource::Subagent),
/*persist_extended_history*/ false,
inherited_shell_snapshot,
inherited_exec_policy,
+2
View File
@@ -16,6 +16,7 @@ use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::Submission;
use codex_protocol::protocol::ThreadSource;
use codex_protocol::request_permissions::PermissionGrantScope;
use codex_protocol::request_permissions::RequestPermissionsArgs;
use codex_protocol::request_permissions::RequestPermissionsEvent;
@@ -84,6 +85,7 @@ pub(crate) async fn run_codex_thread_interactive(
skills_watcher: Arc::clone(&parent_session.services.skills_watcher),
conversation_history: initial_history.unwrap_or(InitialHistory::New),
session_source: SessionSource::SubAgent(subagent_source.clone()),
thread_source: Some(ThreadSource::Subagent),
agent_control: parent_session.services.agent_control.clone(),
dynamic_tools: Vec::new(),
persist_extended_history: false,
+2
View File
@@ -30,6 +30,7 @@ use codex_protocol::protocol::SessionConfiguredEvent;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::Submission;
use codex_protocol::protocol::ThreadMemoryMode;
use codex_protocol::protocol::ThreadSource;
use codex_protocol::protocol::TokenUsageInfo;
use codex_protocol::protocol::W3cTraceContext;
use codex_protocol::user_input::UserInput;
@@ -62,6 +63,7 @@ pub struct ThreadConfigSnapshot {
pub reasoning_effort: Option<ReasoningEffort>,
pub personality: Option<Personality>,
pub session_source: SessionSource,
pub thread_source: Option<ThreadSource>,
}
impl ThreadConfigSnapshot {
@@ -73,6 +73,7 @@ async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::R
originator: "test_originator".to_string(),
cli_version: "test_version".to_string(),
source: SessionSource::Cli,
thread_source: None,
agent_path: None,
agent_nickname: None,
agent_role: None,
@@ -49,6 +49,7 @@ fn stored_thread(cwd: &str, title: &str, first_user_message: &str) -> StoredThre
cwd: PathBuf::from(cwd),
cli_version: "test".to_string(),
source: SessionSource::Cli,
thread_source: None,
agent_nickname: None,
agent_role: None,
agent_path: None,
+4
View File
@@ -110,6 +110,7 @@ use codex_protocol::protocol::ReviewRequest;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::ThreadSource;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::TurnContextItem;
use codex_protocol::protocol::TurnContextNetworkItem;
@@ -391,6 +392,7 @@ pub(crate) struct CodexSpawnArgs {
pub(crate) skills_watcher: Arc<SkillsWatcher>,
pub(crate) conversation_history: InitialHistory,
pub(crate) session_source: SessionSource,
pub(crate) thread_source: Option<ThreadSource>,
pub(crate) agent_control: AgentControl,
pub(crate) dynamic_tools: Vec<DynamicToolSpec>,
pub(crate) persist_extended_history: bool,
@@ -453,6 +455,7 @@ impl Codex {
skills_watcher,
conversation_history,
session_source,
thread_source,
agent_control,
dynamic_tools,
persist_extended_history,
@@ -612,6 +615,7 @@ impl Codex {
app_server_client_name: None,
app_server_client_version: None,
session_source,
thread_source,
dynamic_tools,
persist_extended_history,
inherited_shell_snapshot,
+2 -1
View File
@@ -103,7 +103,7 @@ pub(super) async fn spawn_review_thread(
let review_turn_id = sub_id.to_string();
let turn_metadata_state = Arc::new(TurnMetadataState::new(
sess.conversation_id.to_string(),
&session_source,
parent_turn_context.thread_source,
review_turn_id.clone(),
parent_turn_context.cwd.clone(),
&parent_turn_context.permission_profile,
@@ -123,6 +123,7 @@ pub(super) async fn spawn_review_thread(
reasoning_effort,
reasoning_summary,
session_source,
thread_source: parent_turn_context.thread_source,
environments: parent_turn_context.environments.clone(),
tools_config,
features: parent_turn_context.features.clone(),
+6
View File
@@ -2,6 +2,7 @@ use super::*;
use crate::goals::GoalRuntimeState;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::protocol::ThreadSource;
use codex_protocol::protocol::TurnEnvironmentSelection;
use tokio::sync::Semaphore;
@@ -86,6 +87,8 @@ pub(crate) struct SessionConfiguration {
pub(super) app_server_client_version: Option<String>,
/// Source of the session (cli, vscode, exec, mcp, ...)
pub(super) session_source: SessionSource,
/// Optional analytics source classification for this thread.
pub(super) thread_source: Option<ThreadSource>,
pub(super) dynamic_tools: Vec<DynamicToolSpec>,
pub(super) persist_extended_history: bool,
pub(super) inherited_shell_snapshot: Option<Arc<ShellSnapshot>>,
@@ -141,6 +144,7 @@ impl SessionConfiguration {
reasoning_effort: self.collaboration_mode.reasoning_effort(),
personality: self.personality,
session_source: self.session_source.clone(),
thread_source: self.thread_source,
}
}
@@ -393,6 +397,7 @@ impl Session {
thread_id: conversation_id,
forked_from_id,
source: session_source,
thread_source: session_configuration.thread_source,
base_instructions: BaseInstructions {
text: session_configuration.base_instructions.clone(),
},
@@ -875,6 +880,7 @@ impl Session {
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
session_id: conversation_id,
forked_from_id,
thread_source: session_configuration.thread_source,
thread_name: session_configuration.thread_name.clone(),
model: session_configuration.collaboration_mode.model().to_string(),
model_provider_id: config.model_provider_id.clone(),
+10
View File
@@ -1722,6 +1722,7 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result<
usize::MAX,
fork_config.clone(),
rollout_path,
/*thread_source*/ None,
/*persist_extended_history*/ false,
/*parent_trace*/ None,
)
@@ -2411,6 +2412,7 @@ async fn set_rate_limits_retains_previous_credits() {
app_server_client_name: None,
app_server_client_version: None,
session_source: SessionSource::Exec,
thread_source: None,
dynamic_tools: Vec::new(),
persist_extended_history: false,
inherited_shell_snapshot: None,
@@ -2514,6 +2516,7 @@ async fn set_rate_limits_updates_plan_type_when_present() {
app_server_client_name: None,
app_server_client_version: None,
session_source: SessionSource::Exec,
thread_source: None,
dynamic_tools: Vec::new(),
persist_extended_history: false,
inherited_shell_snapshot: None,
@@ -2766,6 +2769,7 @@ async fn attach_thread_persistence(session: &mut Session) -> PathBuf {
thread_id: session.conversation_id,
forked_from_id: None,
source: SessionSource::Exec,
thread_source: None,
base_instructions: BaseInstructions::default(),
dynamic_tools: Vec::new(),
metadata: ThreadPersistenceMetadata {
@@ -2972,6 +2976,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati
app_server_client_name: None,
app_server_client_version: None,
session_source: SessionSource::Exec,
thread_source: None,
dynamic_tools: Vec::new(),
persist_extended_history: false,
inherited_shell_snapshot: None,
@@ -3495,6 +3500,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
app_server_client_name: None,
app_server_client_version: None,
session_source: SessionSource::Exec,
thread_source: None,
dynamic_tools: Vec::new(),
persist_extended_history: false,
inherited_shell_snapshot: None,
@@ -3607,6 +3613,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
app_server_client_name: None,
app_server_client_version: None,
session_source: SessionSource::Exec,
thread_source: None,
dynamic_tools: Vec::new(),
persist_extended_history: false,
inherited_shell_snapshot: None,
@@ -3829,6 +3836,7 @@ async fn make_session_with_config_and_rx(
app_server_client_name: None,
app_server_client_version: None,
session_source: SessionSource::Exec,
thread_source: None,
dynamic_tools: Vec::new(),
persist_extended_history: false,
inherited_shell_snapshot: None,
@@ -4793,6 +4801,7 @@ async fn shutdown_complete_does_not_append_to_thread_store_after_shutdown() {
thread_id: session.conversation_id,
forked_from_id: None,
source: SessionSource::Exec,
thread_source: None,
base_instructions: BaseInstructions::default(),
dynamic_tools: Vec::new(),
metadata: ThreadPersistenceMetadata {
@@ -5116,6 +5125,7 @@ where
app_server_client_name: None,
app_server_client_version: None,
session_source: SessionSource::Exec,
thread_source: None,
dynamic_tools,
persist_extended_history: false,
inherited_shell_snapshot: None,
@@ -752,6 +752,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
session_source: SessionSource::SubAgent(SubAgentSource::Other(
GUARDIAN_REVIEWER_NAME.to_string(),
)),
thread_source: None,
agent_control: AgentControl::default(),
dynamic_tools: Vec::new(),
persist_extended_history: false,
+5 -1
View File
@@ -5,6 +5,7 @@ use crate::environment_selection::ResolvedTurnEnvironments;
use codex_model_provider::SharedModelProvider;
use codex_model_provider::create_model_provider;
use codex_protocol::models::AdditionalPermissionProfile;
use codex_protocol::protocol::ThreadSource;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_sandboxing::compatibility_sandbox_policy_for_permission_profile;
use codex_sandboxing::policy_transforms::effective_file_system_sandbox_policy;
@@ -63,6 +64,7 @@ pub(crate) struct TurnContext {
pub(crate) reasoning_effort: Option<ReasoningEffortConfig>,
pub(crate) reasoning_summary: ReasoningSummaryConfig,
pub(crate) session_source: SessionSource,
pub(crate) thread_source: Option<ThreadSource>,
pub(crate) environments: ResolvedTurnEnvironments,
/// The session's absolute working directory. All relative paths provided
/// by the model as well as sandbox policies are resolved against this path
@@ -248,6 +250,7 @@ impl TurnContext {
reasoning_effort,
reasoning_summary: self.reasoning_summary,
session_source: self.session_source.clone(),
thread_source: self.thread_source,
environments: self.environments.clone(),
cwd: self.cwd.clone(),
current_date: self.current_date.clone(),
@@ -520,7 +523,7 @@ impl Session {
let per_turn_config = Arc::new(per_turn_config);
let turn_metadata_state = Arc::new(TurnMetadataState::new(
conversation_id.to_string(),
&session_source,
session_configuration.thread_source,
sub_id.clone(),
cwd.clone(),
&session_configuration.permission_profile(),
@@ -540,6 +543,7 @@ impl Session {
reasoning_effort,
reasoning_summary,
session_source,
thread_source: session_configuration.thread_source,
environments,
cwd,
current_date: Some(current_date),
+29
View File
@@ -48,6 +48,7 @@ use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SessionConfiguredEvent;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::ThreadSource;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::TurnAbortedEvent;
use codex_protocol::protocol::TurnEnvironmentSelection;
@@ -219,6 +220,7 @@ pub struct StartThreadOptions {
pub config: Config,
pub initial_history: InitialHistory,
pub session_source: Option<SessionSource>,
pub thread_source: Option<ThreadSource>,
pub dynamic_tools: Vec<codex_protocol::dynamic_tools::DynamicToolSpec>,
pub persist_extended_history: bool,
pub metrics_service_name: Option<String>,
@@ -620,6 +622,7 @@ impl ThreadManager {
config,
initial_history: InitialHistory::New,
session_source: None,
thread_source: None,
dynamic_tools,
persist_extended_history,
metrics_service_name: None,
@@ -636,12 +639,16 @@ impl ThreadManager {
let session_source = options
.session_source
.unwrap_or_else(|| self.state.session_source.clone());
let thread_source = options
.thread_source
.or_else(|| options.initial_history.get_resumed_thread_source());
Box::pin(self.state.spawn_thread_with_source(
options.config,
options.initial_history,
Arc::clone(&self.state.auth_manager),
self.agent_control(),
session_source,
thread_source,
options.dynamic_tools,
options.persist_extended_history,
options.metrics_service_name,
@@ -684,11 +691,13 @@ impl ThreadManager {
self.state.environment_manager.as_ref(),
&config.cwd,
);
let thread_source = initial_history.get_resumed_thread_source();
Box::pin(self.state.spawn_thread(
config,
initial_history,
auth_manager,
self.agent_control(),
thread_source,
Vec::new(),
persist_extended_history,
/*metrics_service_name*/ None,
@@ -713,6 +722,7 @@ impl ThreadManager {
InitialHistory::New,
Arc::clone(&self.state.auth_manager),
self.agent_control(),
/*thread_source*/ None,
Vec::new(),
/*persist_extended_history*/ false,
/*metrics_service_name*/ None,
@@ -735,11 +745,13 @@ impl ThreadManager {
self.state.environment_manager.as_ref(),
&config.cwd,
);
let thread_source = initial_history.get_resumed_thread_source();
Box::pin(self.state.spawn_thread(
config,
initial_history,
auth_manager,
self.agent_control(),
thread_source,
Vec::new(),
/*persist_extended_history*/ false,
/*metrics_service_name*/ None,
@@ -817,6 +829,7 @@ impl ThreadManager {
snapshot: S,
config: Config,
path: PathBuf,
thread_source: Option<ThreadSource>,
persist_extended_history: bool,
parent_trace: Option<W3cTraceContext>,
) -> CodexResult<NewThread>
@@ -829,6 +842,7 @@ impl ThreadManager {
snapshot,
config,
history,
thread_source,
persist_extended_history,
parent_trace,
)
@@ -841,6 +855,7 @@ impl ThreadManager {
snapshot: S,
config: Config,
history: InitialHistory,
thread_source: Option<ThreadSource>,
persist_extended_history: bool,
parent_trace: Option<W3cTraceContext>,
) -> CodexResult<NewThread>
@@ -851,6 +866,7 @@ impl ThreadManager {
snapshot.into(),
config,
history,
thread_source,
persist_extended_history,
parent_trace,
)
@@ -862,6 +878,7 @@ impl ThreadManager {
snapshot: ForkSnapshot,
config: Config,
history: InitialHistory,
thread_source: Option<ThreadSource>,
persist_extended_history: bool,
parent_trace: Option<W3cTraceContext>,
) -> CodexResult<NewThread> {
@@ -876,6 +893,7 @@ impl ThreadManager {
history,
Arc::clone(&self.state.auth_manager),
self.agent_control(),
thread_source,
Vec::new(),
persist_extended_history,
/*metrics_service_name*/ None,
@@ -991,6 +1009,7 @@ impl ThreadManagerState {
config,
agent_control,
self.session_source.clone(),
/*thread_source*/ None,
/*persist_extended_history*/ false,
/*metrics_service_name*/ None,
/*inherited_shell_snapshot*/ None,
@@ -1006,6 +1025,7 @@ impl ThreadManagerState {
config: Config,
agent_control: AgentControl,
session_source: SessionSource,
thread_source: Option<ThreadSource>,
persist_extended_history: bool,
metrics_service_name: Option<String>,
inherited_shell_snapshot: Option<Arc<ShellSnapshot>>,
@@ -1021,6 +1041,7 @@ impl ThreadManagerState {
Arc::clone(&self.auth_manager),
agent_control,
session_source,
thread_source,
Vec::new(),
persist_extended_history,
metrics_service_name,
@@ -1047,12 +1068,14 @@ impl ThreadManagerState {
} = options;
let environments =
default_thread_environment_selections(self.environment_manager.as_ref(), &config.cwd);
let thread_source = initial_history.get_resumed_thread_source();
Box::pin(self.spawn_thread_with_source(
config,
initial_history,
Arc::clone(&self.auth_manager),
agent_control,
session_source,
thread_source,
Vec::new(),
/*persist_extended_history*/ false,
/*metrics_service_name*/ None,
@@ -1072,6 +1095,7 @@ impl ThreadManagerState {
initial_history: InitialHistory,
agent_control: AgentControl,
session_source: SessionSource,
thread_source: Option<ThreadSource>,
persist_extended_history: bool,
inherited_shell_snapshot: Option<Arc<ShellSnapshot>>,
inherited_exec_policy: Option<Arc<crate::exec_policy::ExecPolicyManager>>,
@@ -1086,6 +1110,7 @@ impl ThreadManagerState {
Arc::clone(&self.auth_manager),
agent_control,
session_source,
thread_source,
Vec::new(),
persist_extended_history,
/*metrics_service_name*/ None,
@@ -1106,6 +1131,7 @@ impl ThreadManagerState {
initial_history: InitialHistory,
auth_manager: Arc<AuthManager>,
agent_control: AgentControl,
thread_source: Option<ThreadSource>,
dynamic_tools: Vec<codex_protocol::dynamic_tools::DynamicToolSpec>,
persist_extended_history: bool,
metrics_service_name: Option<String>,
@@ -1119,6 +1145,7 @@ impl ThreadManagerState {
auth_manager,
agent_control,
self.session_source.clone(),
thread_source,
dynamic_tools,
persist_extended_history,
metrics_service_name,
@@ -1139,6 +1166,7 @@ impl ThreadManagerState {
auth_manager: Arc<AuthManager>,
agent_control: AgentControl,
session_source: SessionSource,
thread_source: Option<ThreadSource>,
dynamic_tools: Vec<codex_protocol::dynamic_tools::DynamicToolSpec>,
persist_extended_history: bool,
metrics_service_name: Option<String>,
@@ -1202,6 +1230,7 @@ impl ThreadManagerState {
skills_watcher: Arc::clone(&self.skills_watcher),
conversation_history: initial_history,
session_source,
thread_source,
agent_control,
dynamic_tools,
persist_extended_history,
+90
View File
@@ -15,6 +15,7 @@ use codex_protocol::protocol::AgentMessageEvent;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::InternalSessionSource;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::ThreadSource;
use codex_protocol::protocol::TurnStartedEvent;
use codex_protocol::protocol::UserMessageEvent;
use core_test_support::PathBufExt;
@@ -178,6 +179,7 @@ fn fork_thread_accepts_legacy_usize_snapshot_argument() {
usize::MAX,
config,
path,
/*thread_source*/ None,
/*persist_extended_history*/ false,
/*parent_trace*/ None,
);
@@ -333,6 +335,7 @@ async fn start_thread_accepts_explicit_environment_when_default_environment_is_d
config: config.clone(),
initial_history: InitialHistory::New,
session_source: None,
thread_source: None,
dynamic_tools: Vec::new(),
persist_extended_history: false,
metrics_service_name: None,
@@ -370,6 +373,7 @@ async fn start_thread_keeps_internal_threads_hidden_from_normal_lookups() {
session_source: Some(SessionSource::Internal(
InternalSessionSource::MemoryConsolidation,
)),
thread_source: None,
dynamic_tools: Vec::new(),
persist_extended_history: false,
metrics_service_name: None,
@@ -424,6 +428,7 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() {
config: config.clone(),
initial_history: InitialHistory::New,
session_source: None,
thread_source: None,
dynamic_tools: Vec::new(),
persist_extended_history: false,
metrics_service_name: None,
@@ -480,6 +485,7 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() {
ForkSnapshot::Interrupted,
config,
rollout_path,
/*thread_source*/ None,
/*persist_extended_history*/ false,
/*parent_trace*/ None,
)
@@ -620,6 +626,86 @@ async fn resume_stopped_thread_from_rollout_spawns_new_thread() {
.expect("shutdown resumed thread");
}
#[tokio::test]
async fn resume_stopped_thread_from_rollout_preserves_thread_source() {
let temp_dir = tempdir().expect("tempdir");
let mut config = test_config().await;
config.codex_home = temp_dir.path().join("codex-home").abs();
config.cwd = config.codex_home.abs();
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let (state_db, thread_store, agent_graph_store) = state_backed_stores(&config).await;
let manager = ThreadManager::new(
&config,
auth_manager.clone(),
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
/*analytics_events_client*/ None,
state_db,
thread_store,
agent_graph_store,
);
let source = manager
.start_thread_with_options(StartThreadOptions {
config: config.clone(),
initial_history: InitialHistory::New,
session_source: None,
thread_source: Some(ThreadSource::User),
dynamic_tools: Vec::new(),
persist_extended_history: false,
metrics_service_name: None,
parent_trace: None,
environments: Vec::new(),
})
.await
.expect("start source thread");
source.thread.ensure_rollout_materialized().await;
source
.thread
.flush_rollout()
.await
.expect("flush source rollout");
let rollout_path = source
.thread
.rollout_path()
.expect("source rollout path should exist");
source
.thread
.shutdown_and_wait()
.await
.expect("shutdown source thread before resume");
let _ = manager.remove_thread(&source.thread_id).await;
let resumed = manager
.resume_thread_from_rollout(
config,
rollout_path,
auth_manager,
/*parent_trace*/ None,
)
.await
.expect("resume source thread");
assert_eq!(
resumed
.thread
.config_snapshot()
.await
.thread_source
.as_ref(),
Some(&ThreadSource::User)
);
resumed
.thread
.shutdown_and_wait()
.await
.expect("shutdown resumed thread");
}
#[tokio::test]
async fn new_uses_active_provider_for_model_refresh() {
let server = MockServer::start().await;
@@ -891,6 +977,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor
ForkSnapshot::Interrupted,
config.clone(),
source_path,
/*thread_source*/ None,
/*persist_extended_history*/ false,
/*parent_trace*/ None,
)
@@ -1007,6 +1094,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() {
ForkSnapshot::Interrupted,
config.clone(),
source_path,
/*thread_source*/ None,
/*persist_extended_history*/ false,
/*parent_trace*/ None,
)
@@ -1088,6 +1176,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_
ForkSnapshot::Interrupted,
config.clone(),
source_path,
/*thread_source*/ None,
/*persist_extended_history*/ false,
/*parent_trace*/ None,
)
@@ -1128,6 +1217,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_
ForkSnapshot::Interrupted,
config.clone(),
forked_path,
/*thread_source*/ None,
/*persist_extended_history*/ false,
/*parent_trace*/ None,
)
+5 -5
View File
@@ -17,7 +17,7 @@ use codex_git_utils::get_head_commit_hash;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::ThreadSource;
use codex_utils_absolute_path::AbsolutePathBuf;
const MODEL_KEY: &str = "model";
@@ -69,7 +69,7 @@ pub(crate) struct TurnMetadataBag {
#[serde(default, skip_serializing_if = "Option::is_none")]
session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
thread_source: Option<&'static str>,
thread_source: Option<ThreadSource>,
#[serde(default, skip_serializing_if = "Option::is_none")]
turn_id: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
@@ -115,7 +115,7 @@ fn merge_turn_metadata(
fn build_turn_metadata_bag(
session_id: Option<String>,
thread_source: Option<&'static str>,
thread_source: Option<ThreadSource>,
turn_id: Option<String>,
sandbox: Option<String>,
repo_root: Option<String>,
@@ -187,7 +187,7 @@ pub(crate) struct TurnMetadataState {
impl TurnMetadataState {
pub(crate) fn new(
session_id: String,
session_source: &SessionSource,
thread_source: Option<ThreadSource>,
turn_id: String,
cwd: AbsolutePathBuf,
permission_profile: &PermissionProfile,
@@ -205,7 +205,7 @@ impl TurnMetadataState {
);
let base_metadata = build_turn_metadata_bag(
Some(session_id),
session_source.thread_source_name(),
thread_source,
Some(turn_id),
sandbox,
/*repo_root*/ None,
+8 -11
View File
@@ -4,8 +4,7 @@ use crate::sandbox_tags::sandbox_tag;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::ThreadSource;
use core_test_support::PathBufExt;
use core_test_support::PathExt;
use pretty_assertions::assert_eq;
@@ -95,7 +94,7 @@ fn turn_metadata_state_uses_platform_sandbox_tag() {
let state = TurnMetadataState::new(
"session-a".to_string(),
&SessionSource::Exec,
Some(ThreadSource::User),
"turn-a".to_string(),
cwd,
&permission_profile,
@@ -117,15 +116,13 @@ fn turn_metadata_state_uses_platform_sandbox_tag() {
}
#[test]
fn turn_metadata_state_classifies_subagent_thread_source() {
fn turn_metadata_state_uses_explicit_subagent_thread_source() {
let temp_dir = TempDir::new().expect("temp dir");
let cwd = temp_dir.path().abs();
let permission_profile = PermissionProfile::read_only();
let session_source = SessionSource::SubAgent(SubAgentSource::Review);
let state = TurnMetadataState::new(
"session-a".to_string(),
&session_source,
Some(ThreadSource::Subagent),
"turn-a".to_string(),
cwd,
&permission_profile,
@@ -148,7 +145,7 @@ fn turn_metadata_state_includes_turn_started_at_unix_ms_after_start() {
let state = TurnMetadataState::new(
"session-a".to_string(),
&SessionSource::Exec,
Some(ThreadSource::User),
"turn-a".to_string(),
cwd,
&permission_profile,
@@ -174,7 +171,7 @@ fn turn_metadata_state_includes_model_and_reasoning_effort_only_in_request_meta(
let state = TurnMetadataState::new(
"session-a".to_string(),
&SessionSource::Exec,
/*thread_source*/ None,
"turn-a".to_string(),
cwd,
&permission_profile,
@@ -218,7 +215,7 @@ fn turn_metadata_state_ignores_client_turn_started_at_unix_ms_before_start() {
let state = TurnMetadataState::new(
"session-a".to_string(),
&SessionSource::Exec,
Some(ThreadSource::User),
"turn-a".to_string(),
cwd,
&permission_profile,
@@ -244,7 +241,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields(
let state = TurnMetadataState::new(
"session-a".to_string(),
&SessionSource::Exec,
Some(ThreadSource::User),
"turn-a".to_string(),
cwd,
&permission_profile,
+3 -3
View File
@@ -452,7 +452,7 @@ async fn responses_stream_includes_turn_metadata_header_for_git_workspace_e2e()
initial_parsed
.get("thread_source")
.and_then(serde_json::Value::as_str),
Some("user")
None
);
let git_config_global = cwd.join("empty-git-config");
@@ -565,13 +565,13 @@ async fn responses_stream_includes_turn_metadata_header_for_git_workspace_e2e()
first_parsed
.get("thread_source")
.and_then(serde_json::Value::as_str),
Some("user")
None
);
assert_eq!(
second_parsed
.get("thread_source")
.and_then(serde_json::Value::as_str),
Some("user")
None
);
assert_eq!(
first_turn_id, second_turn_id,
@@ -855,6 +855,7 @@ async fn fork_thread(
nth_user_message,
config.clone(),
path,
/*thread_source*/ None,
/*persist_extended_history*/ false,
/*parent_trace*/ None,
))
+3
View File
@@ -101,6 +101,7 @@ async fn fork_thread_twice_drops_to_first_message() {
ForkSnapshot::TruncateBeforeNthUserMessage(1),
config_for_fork.clone(),
base_path.clone(),
/*thread_source*/ None,
/*persist_extended_history*/ false,
/*parent_trace*/ None,
)
@@ -125,6 +126,7 @@ async fn fork_thread_twice_drops_to_first_message() {
ForkSnapshot::TruncateBeforeNthUserMessage(0),
config_for_fork.clone(),
fork1_path.clone(),
/*thread_source*/ None,
/*persist_extended_history*/ false,
/*parent_trace*/ None,
)
@@ -197,6 +199,7 @@ async fn fork_thread_from_history_does_not_require_source_rollout_path() {
history: source_items.clone(),
rollout_path: None,
}),
/*thread_source*/ None,
/*persist_extended_history*/ false,
/*parent_trace*/ None,
)
@@ -497,6 +497,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> {
ForkSnapshot::Interrupted,
fork_config.clone(),
rollout_path,
/*thread_source*/ None,
/*persist_extended_history*/ false,
/*parent_trace*/ None,
)
@@ -89,6 +89,7 @@ async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::R
originator: "test_originator".to_string(),
cli_version: "test_version".to_string(),
source: SessionSource::Cli,
thread_source: None,
agent_path: None,
agent_nickname: None,
agent_role: None,
@@ -134,6 +135,7 @@ async fn write_rollout_with_meta_only(dir: &Path, thread_id: ThreadId) -> io::Re
originator: "test_originator".to_string(),
cli_version: "test_version".to_string(),
source: SessionSource::Cli,
thread_source: None,
agent_path: None,
agent_nickname: None,
agent_role: None,
@@ -186,6 +186,7 @@ async fn find_locates_rollout_file_written_by_recorder() -> std::io::Result<()>
thread_id,
/*forked_from_id*/ None,
SessionSource::Exec,
/*thread_source*/ None,
BaseInstructions::default(),
Vec::new(),
EventPersistenceMode::Limited,
@@ -144,6 +144,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> {
originator: "test".to_string(),
cli_version: "test".to_string(),
source: SessionSource::default(),
thread_source: None,
agent_path: None,
agent_nickname: None,
agent_role: None,
@@ -72,6 +72,7 @@ async fn window_id_advances_after_compact_persists_on_resume_and_resets_on_fork(
/*snapshot*/ 0usize,
resumed.config.clone(),
rollout_path,
/*thread_source*/ None,
/*persist_extended_history*/ false,
/*parent_trace*/ None,
)
+1
View File
@@ -1134,6 +1134,7 @@ fn session_configured_from_thread_response(
Ok(SessionConfiguredEvent {
session_id,
forked_from_id: None,
thread_source: None,
thread_name,
model,
model_provider_id,
+2
View File
@@ -255,6 +255,7 @@ fn turn_items_for_thread_returns_matching_turn_items() {
cwd: test_path_buf("/tmp/project").abs(),
cli_version: "0.0.0-test".to_string(),
source: codex_app_server_protocol::SessionSource::Exec,
thread_source: None,
agent_nickname: None,
agent_role: None,
git_info: None,
@@ -477,6 +478,7 @@ fn sample_thread_start_response() -> ThreadStartResponse {
cwd: test_path_buf("/tmp").abs(),
cli_version: "0.0.0".to_string(),
source: codex_app_server_protocol::SessionSource::Cli,
thread_source: None,
agent_nickname: None,
agent_role: None,
git_info: None,
@@ -108,6 +108,7 @@ fn session_configured_produces_thread_started_event() {
session_id: ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")
.expect("thread id should parse"),
forked_from_id: None,
thread_source: None,
thread_name: None,
model: "codex-mini-latest".to_string(),
model_provider_id: "test-provider".to_string(),
@@ -298,6 +298,7 @@ mod tests {
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
session_id: thread_id,
forked_from_id: None,
thread_source: None,
thread_name: None,
model: "gpt-4o".to_string(),
model_provider_id: "test-provider".to_string(),
@@ -343,6 +344,7 @@ mod tests {
let session_configured_event = SessionConfiguredEvent {
session_id: conversation_id,
forked_from_id: None,
thread_source: None,
thread_name: None,
model: "gpt-4o".to_string(),
model_provider_id: "test-provider".to_string(),
@@ -411,6 +413,7 @@ mod tests {
let session_configured_event = SessionConfiguredEvent {
session_id: thread_id,
forked_from_id: None,
thread_source: None,
thread_name: None,
model: "gpt-4o".to_string(),
model_provider_id: "test-provider".to_string(),
+2
View File
@@ -24,6 +24,7 @@ use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::InternalSessionSource;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::ThreadSource;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::user_input::UserInput;
use codex_rollout_trace::InferenceTraceContext;
@@ -241,6 +242,7 @@ impl MemoryStartupContext {
session_source: Some(SessionSource::Internal(
InternalSessionSource::MemoryConsolidation,
)),
thread_source: Some(ThreadSource::MemoryConsolidation),
dynamic_tools: Vec::new(),
persist_extended_history: false,
metrics_service_name: None,
+61 -32
View File
@@ -2503,6 +2503,18 @@ impl InitialHistory {
}),
}
}
pub fn get_resumed_thread_source(&self) -> Option<ThreadSource> {
match self {
InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => None,
InitialHistory::Resumed(resumed) => {
resumed.history.iter().find_map(|item| match item {
RolloutItem::SessionMeta(meta_line) => meta_line.meta.thread_source,
_ => None,
})
}
}
}
}
fn session_cwd_from_items(items: &[RolloutItem]) -> Option<PathBuf> {
@@ -2528,6 +2540,44 @@ pub enum SessionSource {
Unknown,
}
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case")]
pub enum ThreadSource {
User,
Subagent,
MemoryConsolidation,
}
impl ThreadSource {
pub fn as_str(self) -> &'static str {
match self {
ThreadSource::User => "user",
ThreadSource::Subagent => "subagent",
ThreadSource::MemoryConsolidation => "memory_consolidation",
}
}
}
impl fmt::Display for ThreadSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for ThreadSource {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"user" => Ok(ThreadSource::User),
"subagent" => Ok(ThreadSource::Subagent),
"memory_consolidation" => Ok(ThreadSource::MemoryConsolidation),
other => Err(format!("unknown thread source: {other}")),
}
}
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case")]
@@ -2588,16 +2638,6 @@ impl SessionSource {
})
}
/// Low cardinality thread source label for analytics.
pub fn thread_source_name(&self) -> Option<&'static str> {
match self {
SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"),
SessionSource::Internal(_) => Some("internal"),
SessionSource::SubAgent(_) => Some("subagent"),
SessionSource::Mcp | SessionSource::Custom(_) | SessionSource::Unknown => None,
}
}
pub fn is_internal(&self) -> bool {
matches!(self, SessionSource::Internal(_))
}
@@ -2698,6 +2738,9 @@ pub struct SessionMeta {
pub cli_version: String,
#[serde(default)]
pub source: SessionSource,
/// Optional analytics source classification for this thread.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thread_source: Option<ThreadSource>,
/// Optional random unique nickname assigned to an AgentControl-spawned sub-agent.
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_nickname: Option<String>,
@@ -2728,6 +2771,7 @@ impl Default for SessionMeta {
originator: String::new(),
cli_version: String::new(),
source: SessionSource::default(),
thread_source: None,
agent_nickname: None,
agent_role: None,
agent_path: None,
@@ -3415,6 +3459,9 @@ pub struct SessionConfiguredEvent {
pub session_id: ThreadId,
#[serde(skip_serializing_if = "Option::is_none")]
pub forked_from_id: Option<ThreadId>,
/// Optional analytics source classification for this thread.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thread_source: Option<ThreadSource>,
/// Optional user-facing thread name (may be unset).
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -3486,6 +3533,8 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent {
session_id: ThreadId,
forked_from_id: Option<ThreadId>,
#[serde(default)]
thread_source: Option<ThreadSource>,
#[serde(default)]
thread_name: Option<String>,
model: String,
model_provider_id: String,
@@ -3524,6 +3573,7 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent {
Ok(Self {
session_id: wire.session_id,
forked_from_id: wire.forked_from_id,
thread_source: wire.thread_source,
thread_name: wire.thread_name,
model: wire.model,
model_provider_id: wire.model_provider_id,
@@ -4011,28 +4061,6 @@ mod tests {
);
}
#[test]
fn session_source_thread_source_name_classifies_user_and_subagent_sources() {
for (source, expected) in [
(SessionSource::Cli, Some("user")),
(SessionSource::VSCode, Some("user")),
(SessionSource::Exec, Some("user")),
(
SessionSource::Internal(InternalSessionSource::MemoryConsolidation),
Some("internal"),
),
(
SessionSource::SubAgent(SubAgentSource::Review),
Some("subagent"),
),
(SessionSource::Mcp, None),
(SessionSource::Custom("atlas".to_string()), None),
(SessionSource::Unknown, None),
] {
assert_eq!(source.thread_source_name(), expected);
}
}
#[test]
fn session_source_restriction_product_defaults_non_subagent_sources_to_codex() {
assert_eq!(
@@ -5274,6 +5302,7 @@ mod tests {
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
session_id: conversation_id,
forked_from_id: None,
thread_source: None,
thread_name: None,
model: "codex-mini-latest".to_string(),
model_provider_id: "openai".to_string(),
+3
View File
@@ -40,6 +40,7 @@ async fn extract_metadata_from_rollout_uses_session_meta() {
originator: "cli".to_string(),
cli_version: "0.0.0".to_string(),
source: SessionSource::default(),
thread_source: None,
agent_path: None,
agent_nickname: None,
agent_role: None,
@@ -91,6 +92,7 @@ async fn extract_metadata_from_rollout_returns_latest_memory_mode() {
originator: "cli".to_string(),
cli_version: "0.0.0".to_string(),
source: SessionSource::default(),
thread_source: None,
agent_path: None,
agent_nickname: None,
agent_role: None,
@@ -350,6 +352,7 @@ fn write_rollout_in_sessions_with_cwd(
originator: "cli".to_string(),
cli_version: "0.0.0".to_string(),
source: SessionSource::default(),
thread_source: None,
agent_path: None,
agent_nickname: None,
agent_role: None,
+6
View File
@@ -61,6 +61,7 @@ use codex_protocol::protocol::RolloutLine;
use codex_protocol::protocol::SessionMeta;
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::ThreadSource;
use codex_state::StateRuntime;
use codex_state::ThreadMetadataBuilder;
use codex_utils_path as path_utils;
@@ -88,6 +89,7 @@ pub enum RolloutRecorderParams {
conversation_id: ThreadId,
forked_from_id: Option<ThreadId>,
source: SessionSource,
thread_source: Option<ThreadSource>,
base_instructions: BaseInstructions,
dynamic_tools: Vec<DynamicToolSpec>,
event_persistence_mode: EventPersistenceMode,
@@ -164,6 +166,7 @@ impl RolloutRecorderParams {
conversation_id: ThreadId,
forked_from_id: Option<ThreadId>,
source: SessionSource,
thread_source: Option<ThreadSource>,
base_instructions: BaseInstructions,
dynamic_tools: Vec<DynamicToolSpec>,
event_persistence_mode: EventPersistenceMode,
@@ -172,6 +175,7 @@ impl RolloutRecorderParams {
conversation_id,
forked_from_id,
source,
thread_source,
base_instructions,
dynamic_tools,
event_persistence_mode,
@@ -667,6 +671,7 @@ impl RolloutRecorder {
conversation_id,
forked_from_id,
source,
thread_source,
base_instructions,
dynamic_tools,
event_persistence_mode,
@@ -695,6 +700,7 @@ impl RolloutRecorder {
agent_role: source.get_agent_role(),
agent_path: source.get_agent_path().map(Into::into),
source,
thread_source,
model_provider: Some(config.model_provider_id().to_string()),
base_instructions: Some(base_instructions),
dynamic_tools: if dynamic_tools.is_empty() {
+4
View File
@@ -91,6 +91,7 @@ async fn state_db_init_backfills_before_returning() -> anyhow::Result<()> {
originator: "test".to_string(),
cli_version: "test".to_string(),
source: SessionSource::Cli,
thread_source: None,
agent_path: None,
agent_nickname: None,
agent_role: None,
@@ -306,6 +307,7 @@ async fn recorder_materializes_on_flush_with_pending_items() -> std::io::Result<
thread_id,
/*forked_from_id*/ None,
SessionSource::Exec,
/*thread_source*/ None,
BaseInstructions::default(),
Vec::new(),
EventPersistenceMode::Limited,
@@ -386,6 +388,7 @@ async fn persist_reports_filesystem_error_and_retries_buffered_items() -> std::i
thread_id,
/*forked_from_id*/ None,
SessionSource::Exec,
/*thread_source*/ None,
BaseInstructions::default(),
Vec::new(),
EventPersistenceMode::Limited,
@@ -485,6 +488,7 @@ async fn metadata_irrelevant_events_touch_state_db_updated_at() -> std::io::Resu
thread_id,
/*forked_from_id*/ None,
SessionSource::Cli,
/*thread_source*/ None,
BaseInstructions::default(),
Vec::new(),
EventPersistenceMode::Limited,
@@ -32,6 +32,7 @@ fn write_rollout_with_metadata(path: &Path, thread_id: ThreadId) -> std::io::Res
originator: "test_originator".into(),
cli_version: "test_version".into(),
source: SessionSource::Cli,
thread_source: None,
agent_path: None,
agent_nickname: None,
agent_role: None,
+1
View File
@@ -1214,6 +1214,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> {
originator: "test_originator".into(),
cli_version: "test_version".into(),
source: SessionSource::VSCode,
thread_source: None,
agent_path: None,
agent_nickname: None,
agent_role: None,
@@ -0,0 +1 @@
ALTER TABLE threads ADD COLUMN thread_source TEXT;
+4
View File
@@ -48,6 +48,7 @@ fn apply_session_meta_from_item(metadata: &mut ThreadMetadata, meta_line: &Sessi
}
metadata.id = meta_line.meta.id;
metadata.source = enum_to_string(&meta_line.meta.source);
metadata.thread_source = meta_line.meta.thread_source;
metadata.agent_nickname = meta_line.meta.agent_nickname.clone();
metadata.agent_role = meta_line.meta.agent_role.clone();
metadata.agent_path = meta_line.meta.agent_path.clone();
@@ -249,6 +250,7 @@ mod tests {
originator: "codex_cli_rs".to_string(),
cli_version: "0.0.0".to_string(),
source: SessionSource::Cli,
thread_source: None,
agent_path: None,
agent_nickname: None,
agent_role: None,
@@ -382,6 +384,7 @@ mod tests {
originator: "codex_cli_rs".to_string(),
cli_version: "0.0.0".to_string(),
source: SessionSource::Cli,
thread_source: None,
agent_path: None,
agent_nickname: None,
agent_role: None,
@@ -408,6 +411,7 @@ mod tests {
created_at,
updated_at: created_at,
source: "cli".to_string(),
thread_source: None,
agent_path: None,
agent_nickname: None,
agent_role: None,
@@ -6,6 +6,7 @@ use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::ThreadSource;
use sqlx::Row;
use sqlx::sqlite::SqliteRow;
use std::path::PathBuf;
@@ -68,6 +69,8 @@ pub struct ThreadMetadata {
pub updated_at: DateTime<Utc>,
/// The session source (stringified enum).
pub source: String,
/// 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.
@@ -117,6 +120,8 @@ pub struct ThreadMetadataBuilder {
pub updated_at: Option<DateTime<Utc>>,
/// The session source.
pub source: SessionSource,
/// Optional analytics source classification for this thread.
pub thread_source: Option<ThreadSource>,
/// Optional random unique nickname assigned to the session.
pub agent_nickname: Option<String>,
/// Optional role (agent_role) assigned to the session.
@@ -157,6 +162,7 @@ impl ThreadMetadataBuilder {
created_at,
updated_at: None,
source,
thread_source: None,
agent_nickname: None,
agent_role: None,
agent_path: None,
@@ -188,6 +194,7 @@ impl ThreadMetadataBuilder {
created_at,
updated_at,
source,
thread_source: self.thread_source,
agent_nickname: self.agent_nickname.clone(),
agent_role: self.agent_role.clone(),
agent_path: self
@@ -313,6 +320,7 @@ pub(crate) struct ThreadRow {
created_at: i64,
updated_at: i64,
source: String,
thread_source: Option<String>,
agent_nickname: Option<String>,
agent_role: Option<String>,
agent_path: Option<String>,
@@ -340,6 +348,7 @@ impl ThreadRow {
created_at: row.try_get("created_at")?,
updated_at: row.try_get("updated_at")?,
source: row.try_get("source")?,
thread_source: row.try_get("thread_source")?,
agent_nickname: row.try_get("agent_nickname")?,
agent_role: row.try_get("agent_role")?,
agent_path: row.try_get("agent_path")?,
@@ -371,6 +380,7 @@ impl TryFrom<ThreadRow> for ThreadMetadata {
created_at,
updated_at,
source,
thread_source,
agent_nickname,
agent_role,
agent_path,
@@ -389,12 +399,17 @@ impl TryFrom<ThreadRow> for ThreadMetadata {
git_branch,
git_origin_url,
} = row;
let thread_source = thread_source
.map(|thread_source| thread_source.parse())
.transpose()
.map_err(anyhow::Error::msg)?;
Ok(Self {
id: ThreadId::try_from(id)?,
rollout_path: PathBuf::from(rollout_path),
created_at: epoch_millis_to_datetime(created_at)?,
updated_at: epoch_millis_to_datetime(updated_at)?,
source,
thread_source,
agent_nickname,
agent_role,
agent_path,
@@ -480,6 +495,7 @@ mod tests {
created_at: 1_700_000_000,
updated_at: 1_700_000_100,
source: "cli".to_string(),
thread_source: None,
agent_nickname: None,
agent_role: None,
agent_path: None,
@@ -508,6 +524,7 @@ mod tests {
created_at: DateTime::<Utc>::from_timestamp(1_700_000_000, 0).expect("timestamp"),
updated_at: DateTime::<Utc>::from_timestamp(1_700_000_100, 0).expect("timestamp"),
source: "cli".to_string(),
thread_source: None,
agent_nickname: None,
agent_role: None,
agent_path: None,
+1
View File
@@ -137,6 +137,7 @@ SELECT
threads.created_at_ms AS created_at,
threads.updated_at_ms AS updated_at,
threads.source,
threads.thread_source,
threads.agent_path,
threads.agent_nickname,
threads.agent_role,
@@ -48,6 +48,7 @@ pub(super) fn test_thread_metadata(
created_at: now,
updated_at: now,
source: "cli".to_string(),
thread_source: None,
agent_nickname: None,
agent_role: None,
agent_path: None,
+19 -2
View File
@@ -13,6 +13,7 @@ SELECT
threads.created_at_ms AS created_at,
threads.updated_at_ms AS updated_at,
threads.source,
threads.thread_source,
threads.agent_nickname,
threads.agent_role,
threads.agent_path,
@@ -486,6 +487,7 @@ INSERT INTO threads (
created_at_ms,
updated_at_ms,
source,
thread_source,
agent_nickname,
agent_role,
agent_path,
@@ -505,7 +507,7 @@ INSERT INTO threads (
git_branch,
git_origin_url,
memory_mode
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO NOTHING
"#,
)
@@ -516,6 +518,11 @@ ON CONFLICT(id) DO NOTHING
.bind(datetime_to_epoch_millis(metadata.created_at))
.bind(datetime_to_epoch_millis(updated_at))
.bind(metadata.source.as_str())
.bind(
metadata
.thread_source
.map(codex_protocol::protocol::ThreadSource::as_str),
)
.bind(metadata.agent_nickname.as_deref())
.bind(metadata.agent_role.as_deref())
.bind(metadata.agent_path.as_deref())
@@ -683,6 +690,7 @@ INSERT INTO threads (
created_at_ms,
updated_at_ms,
source,
thread_source,
agent_nickname,
agent_role,
agent_path,
@@ -702,7 +710,7 @@ INSERT INTO threads (
git_branch,
git_origin_url,
memory_mode
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
rollout_path = excluded.rollout_path,
created_at = excluded.created_at,
@@ -710,6 +718,7 @@ ON CONFLICT(id) DO UPDATE SET
created_at_ms = excluded.created_at_ms,
updated_at_ms = excluded.updated_at_ms,
source = excluded.source,
thread_source = excluded.thread_source,
agent_nickname = excluded.agent_nickname,
agent_role = excluded.agent_role,
agent_path = excluded.agent_path,
@@ -737,6 +746,11 @@ ON CONFLICT(id) DO UPDATE SET
.bind(datetime_to_epoch_millis(metadata.created_at))
.bind(datetime_to_epoch_millis(updated_at))
.bind(metadata.source.as_str())
.bind(
metadata
.thread_source
.map(codex_protocol::protocol::ThreadSource::as_str),
)
.bind(metadata.agent_nickname.as_deref())
.bind(metadata.agent_role.as_deref())
.bind(metadata.agent_path.as_deref())
@@ -958,6 +972,7 @@ SELECT
threads.created_at_ms AS created_at,
threads.updated_at_ms AS updated_at,
threads.source,
threads.thread_source,
threads.agent_nickname,
threads.agent_role,
threads.agent_path,
@@ -1361,6 +1376,7 @@ mod tests {
originator: String::new(),
cli_version: String::new(),
source: SessionSource::Cli,
thread_source: None,
agent_path: None,
agent_nickname: None,
agent_role: None,
@@ -1419,6 +1435,7 @@ mod tests {
originator: String::new(),
cli_version: String::new(),
source: SessionSource::Cli,
thread_source: None,
agent_path: None,
agent_nickname: None,
agent_role: None,
+1
View File
@@ -272,6 +272,7 @@ fn stored_thread_from_state(
cwd: PathBuf::new(),
cli_version: "test".to_string(),
source: created.source.clone(),
thread_source: created.thread_source,
agent_nickname: None,
agent_role: None,
agent_path: None,
@@ -34,6 +34,7 @@ pub(super) async fn create_thread(
params.thread_id,
params.forked_from_id,
params.source,
params.thread_source,
params.base_instructions,
params.dynamic_tools,
event_persistence_mode(params.event_persistence_mode),
@@ -130,6 +130,7 @@ pub(super) fn stored_thread_from_rollout_item(
cwd: item.cwd.unwrap_or_default(),
cli_version: item.cli_version.unwrap_or_default(),
source,
thread_source: None,
agent_nickname: item.agent_nickname,
agent_role: item.agent_role,
agent_path: None,
+1
View File
@@ -744,6 +744,7 @@ mod tests {
thread_id,
forked_from_id: None,
source: SessionSource::Exec,
thread_source: None,
base_instructions: BaseInstructions::default(),
dynamic_tools: Vec::new(),
metadata: thread_metadata(),
@@ -274,10 +274,11 @@ async fn stored_thread_from_sqlite_metadata(
.ok()
.flatten(),
};
let forked_from_id = read_session_meta_line(metadata.rollout_path.as_path())
let session_meta = read_session_meta_line(metadata.rollout_path.as_path())
.await
.ok()
.and_then(|meta_line| meta_line.meta.forked_from_id);
.map(|meta_line| meta_line.meta);
let forked_from_id = session_meta.as_ref().and_then(|meta| meta.forked_from_id);
StoredThread {
thread_id: metadata.id,
rollout_path: Some(metadata.rollout_path),
@@ -297,6 +298,7 @@ async fn stored_thread_from_sqlite_metadata(
cwd: metadata.cwd,
cli_version: metadata.cli_version,
source: parse_session_source(&metadata.source),
thread_source: metadata.thread_source,
agent_nickname: metadata.agent_nickname,
agent_role: metadata.agent_role,
agent_path: metadata.agent_path,
@@ -362,6 +364,7 @@ fn stored_thread_from_meta_line(
cwd: meta_line.meta.cwd,
cli_version: meta_line.meta.cli_version,
source: meta_line.meta.source,
thread_source: meta_line.meta.thread_source,
agent_nickname: meta_line.meta.agent_nickname,
agent_role: meta_line.meta.agent_role,
agent_path: meta_line.meta.agent_path,
@@ -16,6 +16,7 @@ use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::ThreadMemoryMode;
use codex_protocol::protocol::ThreadSource;
use super::proto;
use crate::GitInfoPatch;
@@ -296,6 +297,11 @@ pub(super) fn stored_thread_from_proto(
cwd: PathBuf::from(thread.cwd),
cli_version: thread.cli_version,
source,
thread_source: thread
.thread_source
.map(|thread_source| thread_source.parse::<ThreadSource>())
.transpose()
.map_err(|error| ThreadStoreError::Internal { message: error })?,
agent_nickname: thread.agent_nickname,
agent_role: thread.agent_role,
agent_path: thread.agent_path,
@@ -340,6 +346,7 @@ pub(super) fn stored_thread_to_proto(thread: StoredThread) -> proto::StoredThrea
cwd: thread.cwd.to_string_lossy().into_owned(),
cli_version: thread.cli_version,
source: Some(proto_session_source(&thread.source)),
thread_source: thread.thread_source.map(|source| source.to_string()),
git_info: thread.git_info.map(git_info_to_proto),
agent_nickname: thread.agent_nickname,
agent_role: thread.agent_role,
@@ -140,6 +140,7 @@ mod tests {
kind: proto::SessionSourceKind::Cli.into(),
..Default::default()
}),
thread_source: Some("user".to_string()),
git_info: Some(proto::GitInfo {
sha: Some("abc123".to_string()),
branch: Some("main".to_string()),
@@ -250,6 +251,7 @@ mod tests {
sub_agent_role: Some("explorer".to_string()),
..Default::default()
}),
thread_source: Some("subagent".to_string()),
git_info: Some(proto::GitInfo {
sha: Some("abc123".to_string()),
branch: Some("main".to_string()),
+1
View File
@@ -358,6 +358,7 @@ mod tests {
thread_id: ThreadId::new(),
forked_from_id: None,
source: SessionSource::Exec,
thread_source: None,
base_instructions: BaseInstructions::default(),
dynamic_tools: Vec::new(),
metadata: metadata.clone(),
@@ -132,6 +132,7 @@ message StoredThread {
optional string sandbox_policy_json = 21;
optional string token_usage_json = 22;
optional StoredThreadHistory history = 23;
optional string thread_source = 24;
}
message SessionSource {
@@ -168,6 +168,8 @@ pub struct StoredThread {
pub token_usage_json: ::core::option::Option<::prost::alloc::string::String>,
#[prost(message, optional, tag = "23")]
pub history: ::core::option::Option<StoredThreadHistory>,
#[prost(string, optional, tag = "24")]
pub thread_source: ::core::option::Option<::prost::alloc::string::String>,
}
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
pub struct SessionSource {
+5
View File
@@ -12,6 +12,7 @@ use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::ThreadMemoryMode as MemoryMode;
use codex_protocol::protocol::ThreadSource;
use codex_protocol::protocol::TokenUsage;
use serde::Deserialize;
use serde::Serialize;
@@ -48,6 +49,8 @@ pub struct CreateThreadParams {
pub forked_from_id: Option<ThreadId>,
/// Runtime source for the thread.
pub source: SessionSource,
/// Optional analytics source classification for this thread.
pub thread_source: Option<ThreadSource>,
/// Base instructions persisted in session metadata.
pub base_instructions: BaseInstructions,
/// Dynamic tools available to the thread at startup.
@@ -211,6 +214,8 @@ pub struct StoredThread {
pub cli_version: String,
/// Runtime source for the thread.
pub source: SessionSource,
/// Optional analytics source classification for this thread.
pub thread_source: Option<ThreadSource>,
/// Optional random nickname for thread-spawn sub-agents.
pub agent_nickname: Option<String>,
/// Optional role for thread-spawn sub-agents.
+1
View File
@@ -129,6 +129,7 @@ mod tests {
cwd: test_path_buf("/tmp").abs(),
cli_version: "0.0.0".to_string(),
source,
thread_source: None,
agent_nickname: None,
agent_role: None,
git_info: None,
+4
View File
@@ -2871,6 +2871,7 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re
cwd: test_path_buf("/tmp/agent").abs(),
cli_version: "0.0.0".to_string(),
source: codex_app_server_protocol::SessionSource::Unknown,
thread_source: None,
agent_nickname: Some("Robie".to_string()),
agent_role: Some("explorer".to_string()),
git_info: None,
@@ -2952,6 +2953,7 @@ async fn inactive_thread_started_notification_preserves_primary_model_when_path_
cwd: test_path_buf("/tmp/agent").abs(),
cli_version: "0.0.0".to_string(),
source: codex_app_server_protocol::SessionSource::Unknown,
thread_source: None,
agent_nickname: Some("Robie".to_string()),
agent_role: Some("explorer".to_string()),
git_info: None,
@@ -3006,6 +3008,7 @@ async fn thread_read_session_state_does_not_reuse_primary_permission_profile() {
cwd: test_path_buf("/tmp/read").abs(),
cli_version: "0.0.0".to_string(),
source: codex_app_server_protocol::SessionSource::Unknown,
thread_source: None,
agent_nickname: None,
agent_role: None,
git_info: None,
@@ -4969,6 +4972,7 @@ async fn thread_rollback_response_discards_queued_active_thread_events() {
cwd: test_path_buf("/tmp/project").abs(),
cli_version: "0.0.0".to_string(),
source: SessionSource::Cli,
thread_source: None,
agent_nickname: None,
agent_role: None,
git_info: None,
@@ -333,6 +333,7 @@ mod tests {
cwd: test_path_buf("/tmp/read").abs(),
cli_version: "0.0.0".to_string(),
source: codex_app_server_protocol::SessionSource::Unknown,
thread_source: None,
agent_nickname: None,
agent_role: None,
git_info: None,
+1
View File
@@ -1826,6 +1826,7 @@ mod tests {
cwd: test_path_buf("/tmp/project").abs(),
cli_version: "0.0.0".to_string(),
source: codex_app_server_protocol::SessionSource::Cli,
thread_source: None,
agent_nickname: None,
agent_role: None,
git_info: None,
+4
View File
@@ -5695,6 +5695,7 @@ session_picker_view = "dense"
cwd: test_path_buf("/tmp").abs(),
cli_version: String::from("0.0.0"),
source: codex_app_server_protocol::SessionSource::Cli,
thread_source: None,
agent_nickname: None,
agent_role: None,
git_info: None,
@@ -5727,6 +5728,7 @@ session_picker_view = "dense"
cwd: test_path_buf("/tmp").abs(),
cli_version: String::from("0.0.0"),
source: codex_app_server_protocol::SessionSource::Cli,
thread_source: None,
agent_nickname: None,
agent_role: None,
git_info: None,
@@ -5792,6 +5794,7 @@ session_picker_view = "dense"
cwd: test_path_buf("/tmp").abs(),
cli_version: String::from("0.0.0"),
source: codex_app_server_protocol::SessionSource::Cli,
thread_source: None,
agent_nickname: None,
agent_role: None,
git_info: None,
@@ -5847,6 +5850,7 @@ session_picker_view = "dense"
cwd: test_path_buf("/tmp").abs(),
cli_version: String::from("0.0.0"),
source: codex_app_server_protocol::SessionSource::Cli,
thread_source: None,
agent_nickname: None,
agent_role: None,
git_info: None,