Expose the complete core prompt snapshot in `codex debug prompt-input`

Open 💬 1 comment Opened Jul 28, 2026 by selfmosaic

Problem

codex debug prompt-input currently constructs the complete core Prompt, including the effective base instructions and request-scoped model-visible tool schemas, but returns only prompt.input.

That leaves an important observability gap when investigating prompt bloat, instruction regressions, tool-schema growth, or model-to-model differences: the command shows injected conversation items but hides the other prompt fields assembled by the same captured step and tool router.

This is especially relevant because provider adapters can represent the same core prompt differently. For example, Responses Lite may place instructions and tools into input items rather than top-level request fields. A debug surface should therefore expose the core prompt clearly while avoiding the claim that it is always the literal provider wire payload.

Proposed change

Preserve the current command and default JSON shape, and add an opt-in full snapshot:

codex debug prompt-input "Review this backend change; do not edit" --full

The --full output would include:

{
  "base_instructions": { "text": "..." },
  "input": [],
  "tools": [],
  "parallel_tool_calls": true,
  "output_schema": null,
  "output_schema_strict": true
}

Implementation outline:

  • Add a serializable PromptDebugSnapshot derived from core's existing Prompt.
  • Add build_prompt_debug_snapshot alongside build_prompt_input.
  • Make the existing helper return snapshot.input, preserving compatibility.
  • Add --full to DebugPromptInputCommand; default output remains unchanged.
  • Add focused core integration and CLI parsing tests.

Scope and safety

  • Debug-only; no sampling, routing, authorization, or prompt-construction behavior changes.
  • Existing codex debug prompt-input consumers keep the same array output unless they opt into --full.
  • The snapshot is explicitly documented as the core prompt before provider-specific transformations.

Why this should precede broad prompt rewrites

Prompt reductions should be evaluated against the complete effective context, not only the visible system text or input-item list. This gives maintainers and contributors a reproducible measurement surface before changing persistent instructions, tool exposure, skill loading, or prompt caching.

The four-file implementation patch has been rechecked against current main at fe01054a28fa4bd04716d9ceadb410f2443a50ce and posted inline in the issue discussion. The connected GitHub integration cannot create a repository fork; once a contributor fork is available, the same patch can be opened as a draft PR without changing the proposed behavior.

View original on GitHub ↗

1 Comment

selfmosaic · 1 month ago

I rechecked the four touched surfaces against current main at fe01054a28fa4bd04716d9ceadb410f2443a50ce. The relevant code and test contexts remain unchanged from the prepared patch. The connected GitHub app cannot create a repository fork, and the authenticated account still has no selfmosaic/codex fork, so I cannot create the head ref required for a PR from this integration.

Here is the complete implementation patch inline so it is reviewable and immediately applicable:

diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs
--- a/codex-rs/cli/src/main.rs
+++ b/codex-rs/cli/src/main.rs
@@ -270,6 +270,10 @@ struct DebugPromptInputCommand {
     /// Optional image(s) to attach to the user prompt.
     #[arg(long = "image", short = 'i', value_name = "FILE", value_delimiter = ',', num_args = 1..)]
     images: Vec<PathBuf>,
+
+    /// Include base instructions, tool schemas, and request settings in the output.
+    #[arg(long, default_value_t = false)]
+    full: bool,
 }
 
 #[derive(Debug, Parser)]
@@ -1959,6 +1963,7 @@ async fn run_debug_prompt_input_command(
     interactive: TuiCli,
     arg0_paths: Arg0DispatchPaths,
 ) -> anyhow::Result<()> {
+    let full = cmd.full;
     let loader_overrides = loader_overrides_for_profile(interactive.config_profile_v2.as_ref())?;
     let shared = interactive.shared.into_inner();
     let mut cli_kv_overrides = root_config_overrides
@@ -2027,7 +2032,7 @@ async fn run_debug_prompt_input_command(
         config.chatgpt_base_url.clone(),
         config.http_client_factory(),
     );
-    let prompt_input = codex_core::build_prompt_input(
+    let prompt = codex_core::build_prompt_debug_snapshot(
         config,
         input,
         /*state_db*/ None,
@@ -2035,7 +2040,11 @@ async fn run_debug_prompt_input_command(
         user_instructions_provider,
     )
     .await?;
-    println!("{}", serde_json::to_string_pretty(&prompt_input)?);
+    if full {
+        println!("{}", serde_json::to_string_pretty(&prompt)?);
+    } else {
+        println!("{}", serde_json::to_string_pretty(&prompt.input)?);
+    }
 
     Ok(())
 }
@@ -2919,6 +2928,23 @@ fn debug_prompt_input_parses_prompt_and_images() {
             cmd.images,
             vec![PathBuf::from("/tmp/a.png"), PathBuf::from("/tmp/b.png")]
         );
+        assert!(!cmd.full);
+    }
+
+    #[test]
+    fn debug_prompt_input_parses_full_flag() {
+        let cli =
+            MultitoolCli::try_parse_from(["codex", "debug", "prompt-input", "--full"])
+                .expect("parse");
+
+        let Some(Subcommand::Debug(DebugCommand {
+            subcommand: DebugSubcommand::PromptInput(cmd),
+        })) = cli.subcommand
+        else {
+            panic!("expected debug prompt-input subcommand");
+        };
+
+        assert!(cmd.full);
     }
 
     #[test]
diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs
--- a/codex-rs/core/src/lib.rs
+++ b/codex-rs/core/src/lib.rs
@@ -73,7 +73,9 @@ pub(crate) mod plugins;
 #[doc(hidden)]
 pub(crate) mod prompt_debug;
 #[doc(hidden)]
-pub use prompt_debug::build_prompt_input;
+pub use prompt_debug::{
+    PromptDebugSnapshot, build_prompt_debug_snapshot, build_prompt_input,
+};
 pub(crate) mod mentions {
     pub(crate) use crate::plugins::build_connector_slug_counts;
     pub(crate) use crate::plugins::build_skill_name_counts;
diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs
--- a/codex-rs/core/src/prompt_debug.rs
+++ b/codex-rs/core/src/prompt_debug.rs
@@ -7,11 +7,16 @@ use codex_extension_api::UserInstructionsProvider;
 use codex_login::AuthManager;
 use codex_protocol::error::CodexErr;
 use codex_protocol::error::Result as CodexResult;
+use codex_protocol::models::BaseInstructions;
 use codex_protocol::models::ResponseItem;
 use codex_protocol::protocol::SessionSource;
 use codex_protocol::user_input::UserInput;
+use codex_tools::ToolSpec;
+use serde::Serialize;
+use serde_json::Value;
 use tokio_util::sync::CancellationToken;
 
+use crate::client_common::Prompt;
 use crate::config::Config;
 use crate::resolve_installation_id;
 use crate::session::session::Session;
@@ -21,15 +26,59 @@ use crate::thread_manager::StartThreadOptions;
 use crate::thread_manager::ThreadManager;
 use crate::thread_manager::thread_store_from_config;
 
+/// Model prompt state built by core before provider-specific request transformations.
+#[derive(Debug, Serialize)]
+pub struct PromptDebugSnapshot {
+    pub base_instructions: BaseInstructions,
+    pub input: Vec<ResponseItem>,
+    pub tools: Vec<ToolSpec>,
+    pub parallel_tool_calls: bool,
+    pub output_schema: Option<Value>,
+    pub output_schema_strict: bool,
+}
+
+impl From<Prompt> for PromptDebugSnapshot {
+    fn from(prompt: Prompt) -> Self {
+        Self {
+            base_instructions: prompt.base_instructions,
+            input: prompt.input,
+            tools: prompt.tools,
+            parallel_tool_calls: prompt.parallel_tool_calls,
+            output_schema: prompt.output_schema,
+            output_schema_strict: prompt.output_schema_strict,
+        }
+    }
+}
+
 /// Build the model-visible `input` list for a single debug turn.
 #[doc(hidden)]
 pub async fn build_prompt_input(
-    mut config: Config,
+    config: Config,
     input: Vec<UserInput>,
     state_db: Option<StateDbHandle>,
     extensions: Arc<ExtensionRegistry<Config>>,
     user_instructions_provider: Arc<dyn UserInstructionsProvider>,
 ) -> CodexResult<Vec<ResponseItem>> {
+    Ok(build_prompt_debug_snapshot(
+        config,
+        input,
+        state_db,
+        extensions,
+        user_instructions_provider,
+    )
+    .await?
+    .input)
+}
+
+/// Build the complete core prompt state for a single debug turn.
+#[doc(hidden)]
+pub async fn build_prompt_debug_snapshot(
+    mut config: Config,
+    input: Vec<UserInput>,
+    state_db: Option<StateDbHandle>,
+    extensions: Arc<ExtensionRegistry<Config>>,
+    user_instructions_provider: Arc<dyn UserInstructionsProvider>,
+) -> CodexResult<PromptDebugSnapshot> {
     config.ephemeral = true;
 
     let auth_manager =
@@ -68,7 +117,7 @@ pub async fn build_prompt_input(
     let thread = thread_manager
         .start_thread(StartThreadOptions::new(config))
         .await?;
-    let output = build_prompt_input_from_session(&thread.thread.session, input).await;
+    let output = build_prompt_debug_snapshot_from_session(&thread.thread.session, input).await;
     let shutdown = thread.thread.shutdown_and_wait().await;
     let _removed = thread_manager.remove_thread(&thread.thread_id).await;
 
@@ -80,6 +129,15 @@ pub(crate) async fn build_prompt_input_from_session(
     sess: &Arc<Session>,
     input: Vec<UserInput>,
 ) -> CodexResult<Vec<ResponseItem>> {
+    Ok(build_prompt_debug_snapshot_from_session(sess, input)
+        .await?
+        .input)
+}
+
+async fn build_prompt_debug_snapshot_from_session(
+    sess: &Arc<Session>,
+    input: Vec<UserInput>,
+) -> CodexResult<PromptDebugSnapshot> {
     let turn_context = sess.new_default_turn().await;
     // Prompt debugging builds a standalone request without entering run_turn.
     let step_context = sess
@@ -106,5 +164,5 @@ pub(crate) async fn build_prompt_input_from_session(
         base_instructions,
     );
 
-    Ok(prompt.input)
+    Ok(prompt.into())
 }
diff --git a/codex-rs/core/tests/suite/prompt_debug_tests.rs b/codex-rs/core/tests/suite/prompt_debug_tests.rs
--- a/codex-rs/core/tests/suite/prompt_debug_tests.rs
+++ b/codex-rs/core/tests/suite/prompt_debug_tests.rs
@@ -1,6 +1,7 @@
 use std::sync::Arc;
 
 use anyhow::Result;
+use codex_core::build_prompt_debug_snapshot;
 use codex_core::build_prompt_input;
 use codex_core::config::ConfigBuilder;
 use codex_core::config::ConfigOverrides;
@@ -14,6 +15,7 @@ use core_test_support::responses::strip_response_item_id;
 use pretty_assertions::assert_eq;
 use tempfile::TempDir;
 
+const TEST_BASE_INSTRUCTIONS: &str = "Debug base instructions";
 const TEST_INSTRUCTIONS: &str = "Global test instructions";
 
 #[tokio::test]
@@ -77,3 +79,39 @@ async fn build_prompt_input_includes_context_and_user_message() -> Result<()> {
     }));
     Ok(())
 }
+
+#[tokio::test]
+async fn build_prompt_debug_snapshot_includes_base_instructions_and_tools() -> Result<()> {
+    let codex_home = TempDir::new()?;
+    let cwd = TempDir::new()?;
+    let config = ConfigBuilder::default()
+        .codex_home(codex_home.path().to_path_buf())
+        .harness_overrides(ConfigOverrides {
+            cwd: Some(cwd.path().to_path_buf()),
+            codex_self_exe: Some(std::env::current_exe()?),
+            base_instructions: Some(TEST_BASE_INSTRUCTIONS.to_string()),
+            ..ConfigOverrides::default()
+        })
+        .build()
+        .await?;
+    let user_instructions_provider = Arc::new(CodexHomeUserInstructionsProvider::new(
+        config.codex_home.clone(),
+    ));
+
+    let prompt = build_prompt_debug_snapshot(
+        config,
+        vec![UserInput::Text {
+            text: "hello from full debug prompt".to_string(),
+            text_elements: Vec::new(),
+        }],
+        /*state_db*/ None,
+        Arc::new(ExtensionRegistryBuilder::new().build()),
+        user_instructions_provider,
+    )
+    .await?;
+
+    assert_eq!(prompt.base_instructions.text, TEST_BASE_INSTRUCTIONS);
+    assert!(!prompt.tools.is_empty());
+    assert!(prompt.input.iter().any(ResponseItem::is_user_message));
+    Ok(())
+}

Suggested branch and commit once a contributor fork is available:

agent/expose-full-prompt-snapshot
feat(debug): expose full model prompt snapshot

Focused validation:

cd codex-rs
just fmt
just test -p codex-core build_prompt_debug_snapshot_includes_base_instructions_and_tools
cargo test -p codex-cli debug_prompt_input

This remains intentionally debug-only and preserves the existing array output unless --full is supplied.