Skip to main content

locode_packs/codex/
mod.rs

1//! The `codex` pack — a faithful port of Codex CLI's stock headless tool surface +
2//! base prompt (ADR-0012, ADR-0023), over `locode-host`. Codex is the **minimal-tool
3//! extreme**: no read/grep/glob/write/edit tools — the shell is the read path and all
4//! editing goes through `apply_patch`.
5//!
6//! The pack (complete): `shell_command` + freeform `apply_patch`, the full
7//! byte-exact gpt-5.6-sol `base_instructions` with the always-appended `apply_patch`
8//! instructions block, the `<environment_context>` User preamble item, and the
9//! openai-responses-only wire requirement — see `docs/codex-pack-dev-process.md`.
10//!
11//! Fidelity boundary (ADR-0023): the pack reproduces tools + prompt + static preamble
12//! only. `update_plan`, unified exec (PTY/background), the code-mode wrapper, subagents,
13//! skills, MCP, compaction, and codex's per-turn `<environment_context>` world-state
14//! re-injection/diffing stay on the shared engine / are deferred.
15
16mod apply_patch;
17mod prompt;
18mod shell_command;
19
20use std::sync::Arc;
21
22use locode_host::Host;
23use locode_protocol::{ContentBlock, Message, Role};
24use locode_tools::Registry;
25
26use apply_patch::CodexApplyPatch;
27use shell_command::CodexShellCommand;
28
29use crate::pack::{Pack, PackContext};
30
31/// Codex's `apply_patch` instructions block (`prompts/templates/apply_patch_tool_instructions.md`,
32/// submodule `f201c30c`, 3084 bytes). Codex appends this to the base instructions
33/// **only when the freeform tool is absent** (`core/tests/suite/prompt_caching.rs:212`).
34/// We **always append** it (D4): we run non-codex models whose base prompt does not
35/// bake in the V4A format, and appending "肯定不会让它变更差". The faithful append *form*
36/// (join into the system instructions with `\n`) is preserved.
37const APPLY_PATCH_INSTRUCTIONS: &str = include_str!("templates/apply_patch_tool_instructions.md");
38
39/// The Codex CLI harness pack (a zero-sized `&'static` singleton).
40#[derive(Debug, Default, Clone, Copy)]
41pub struct CodexPack;
42
43impl Pack for CodexPack {
44    fn name(&self) -> &'static str {
45        "codex"
46    }
47
48    fn register(&self, host: &Arc<Host>, registry: &mut Registry) {
49        // Codex's stock non-Windows duo: the shell (unified exec disabled — see the
50        // deprecated-substitution note in `shell_command.rs`, D2) + freeform apply_patch
51        // (codex's only edit path — no read/write/edit tools).
52        registry.register("shell_command", CodexShellCommand::new(Arc::clone(host)));
53        registry.register("apply_patch", CodexApplyPatch::new(Arc::clone(host)));
54    }
55
56    fn required_api_schemas(&self) -> Option<&'static [&'static str]> {
57        // `apply_patch` is a freeform (custom-grammar) tool that only round-trips on
58        // the OpenAI Responses wire (D5). `mock` stays allowed (keyless CI) via the
59        // universal escape hatch in the pre-run check.
60        Some(&["openai-responses"])
61    }
62
63    fn preamble(&self, ctx: &PackContext) -> Vec<Message> {
64        // [System(base prompt + apply_patch instructions), User(<environment_context>)]
65        // (D7). The System is the full gpt-5.6-sol `base_instructions` with the
66        // always-appended apply_patch block (D4), `\n`-joined — codex's own append
67        // form; it hoists to `instructions` on the Responses wire (Task 18). The User
68        // item is codex's first-turn `<environment_context>` snapshot (cwd/shell/date/
69        // tz). Codex's per-turn world-state re-injection/diffing is loop-adjacent
70        // machinery (ADR-0023) and stays on the shared engine.
71        let system = format!("{}\n{}", prompt::render(ctx), APPLY_PATCH_INSTRUCTIONS);
72        vec![
73            Message {
74                role: Role::System,
75                content: vec![ContentBlock::Text { text: system }],
76            },
77            Message {
78                role: Role::User,
79                content: vec![ContentBlock::Text {
80                    text: prompt::environment_context(ctx),
81                }],
82            },
83        ]
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use locode_host::HostConfig;
91    use locode_protocol::ResultChunk;
92    use locode_tools::ToolCtx;
93    use serde_json::json;
94    use std::path::Path;
95    use tokio_util::sync::CancellationToken;
96
97    fn setup() -> (tempfile::TempDir, Registry, std::path::PathBuf) {
98        let dir = tempfile::tempdir().unwrap();
99        let mut config = HostConfig::new(dir.path());
100        config.login_shell = false;
101        let host = Arc::new(Host::new(config).unwrap());
102        let root = host.workspace_root().to_path_buf();
103        let registry = CodexPack.build_registry(&host);
104        (dir, registry, root)
105    }
106
107    fn ctx(dir: &Path) -> ToolCtx {
108        ToolCtx::new(
109            dir.to_path_buf(),
110            "c1".into(),
111            dir.to_path_buf(),
112            CancellationToken::new(),
113        )
114    }
115
116    fn result_text(block: &ContentBlock) -> String {
117        match block {
118            ContentBlock::ToolResult { content, .. } => content
119                .iter()
120                .filter_map(|chunk| match chunk {
121                    ResultChunk::Text { text } => Some(text.clone()),
122                    ResultChunk::Image { .. } => None,
123                })
124                .collect(),
125            _ => panic!("expected a tool_result"),
126        }
127    }
128
129    #[test]
130    fn pack_registers_shell_command_and_apply_patch() {
131        let (_dir, registry, _root) = setup();
132        let mut names: Vec<&str> = registry.names().collect();
133        names.sort_unstable();
134        assert_eq!(names, vec!["apply_patch", "shell_command"]);
135        assert_eq!(
136            registry.kind_of("shell_command"),
137            Some(locode_tools::ToolKind::Shell)
138        );
139        assert_eq!(
140            registry.kind_of("apply_patch"),
141            Some(locode_tools::ToolKind::Edit)
142        );
143    }
144
145    #[test]
146    fn shell_command_schema_is_faithful() {
147        let (_dir, registry, _root) = setup();
148        let specs = registry.specs();
149        let spec = specs
150            .iter()
151            .find(|s| s.name == "shell_command")
152            .expect("shell_command spec");
153        let params = match &spec.input {
154            locode_protocol::ToolInputFormat::JsonSchema { parameters } => parameters,
155            locode_protocol::ToolInputFormat::Freeform { .. } => panic!("JSON-schema tool"),
156        };
157        assert_eq!(params["additionalProperties"], json!(false));
158        let props = params["properties"].as_object().unwrap();
159        assert_eq!(
160            props["command"]["description"],
161            json!("Shell script to run in the user's default shell.")
162        );
163        for key in ["command", "workdir", "timeout_ms", "login"] {
164            assert!(props.contains_key(key), "missing field {key}");
165        }
166        // Approval params dropped (D8).
167        for absent in ["sandbox_permissions", "justification", "prefix_rule"] {
168            assert!(!props.contains_key(absent), "{absent} must be absent");
169        }
170        let required: Vec<&str> = params["required"]
171            .as_array()
172            .unwrap()
173            .iter()
174            .map(|v| v.as_str().unwrap())
175            .collect();
176        assert_eq!(required, vec!["command"]);
177    }
178
179    #[cfg(unix)]
180    #[tokio::test]
181    async fn shell_command_echo_frames_output() {
182        let (_dir, registry, root) = setup();
183        let out = registry
184            .dispatch(
185                "shell_command",
186                json!({ "command": "echo hi" }),
187                &ctx(&root),
188            )
189            .await;
190        assert!(out.record.ok);
191        let text = result_text(&out.tool_result);
192        assert!(text.starts_with("Exit code: 0\n"), "{text}");
193        assert!(text.contains("\nWall time: "), "{text}");
194        assert!(text.contains("\nOutput:\nhi"), "{text}");
195        assert_eq!(out.record.output["exit_code"], json!(0));
196    }
197
198    #[cfg(unix)]
199    #[tokio::test]
200    async fn shell_command_nonzero_exit_is_soft_ok() {
201        let (_dir, registry, root) = setup();
202        let out = registry
203            .dispatch(
204                "shell_command",
205                json!({ "command": "echo oops; exit 3" }),
206                &ctx(&root),
207            )
208            .await;
209        // Codex marks success:true; the model reads the exit code in the text.
210        assert!(out.record.ok);
211        assert!(result_text(&out.tool_result).contains("Exit code: 3"));
212        assert_eq!(out.record.output["exit_code"], json!(3));
213    }
214
215    #[cfg(unix)]
216    #[tokio::test]
217    async fn shell_command_timeout_is_exit_124() {
218        let (_dir, registry, root) = setup();
219        let out = registry
220            .dispatch(
221                "shell_command",
222                json!({ "command": "sleep 5", "timeout_ms": 50 }),
223                &ctx(&root),
224            )
225            .await;
226        assert!(out.record.ok);
227        let text = result_text(&out.tool_result);
228        assert!(text.contains("Exit code: 124"), "{text}");
229        assert!(text.contains("command timed out after"), "{text}");
230        assert_eq!(out.record.output["timed_out"], json!(true));
231    }
232
233    #[tokio::test]
234    async fn shell_command_rejects_unknown_field() {
235        // deny_unknown_fields: the dropped approval param is rejected.
236        let (_dir, registry, root) = setup();
237        let out = registry
238            .dispatch(
239                "shell_command",
240                json!({ "command": "echo hi", "sandbox_permissions": "use_default" }),
241                &ctx(&root),
242            )
243            .await;
244        assert!(!out.record.ok);
245    }
246
247    #[test]
248    fn preamble_is_system_prompt_then_user_environment_context() {
249        let dir = tempfile::tempdir().unwrap();
250        let pc = PackContext {
251            cwd: dir.path().to_path_buf(),
252            os: "macos".into(),
253            shell: "/bin/zsh".into(),
254            date: "2026-07-24".into(),
255            headless: true,
256            is_git_repo: false,
257            model: Some("gpt-5.6-sol".into()),
258            os_version: None,
259            timezone: Some("America/Los_Angeles".into()),
260            strip_identity: false,
261        };
262        // D7: [System(full prompt + apply_patch instructions), User(<environment_context>)].
263        let msgs = CodexPack.preamble(&pc);
264        assert_eq!(msgs.len(), 2);
265        assert_eq!(msgs[0].role, Role::System);
266        assert_eq!(msgs[1].role, Role::User);
267        let ContentBlock::Text { text: system } = &msgs[0].content[0] else {
268            panic!("expected text");
269        };
270        assert!(system.starts_with("You are Codex"), "{system}");
271        assert!(system.contains("# Personality"), "full prompt present");
272        assert!(system.contains("## `apply_patch`"), "instructions appended");
273        assert!(
274            system.contains("*** Begin Patch"),
275            "instructions body present"
276        );
277        let ContentBlock::Text { text: env } = &msgs[1].content[0] else {
278            panic!("expected text");
279        };
280        assert!(env.starts_with("<environment_context>"), "{env}");
281        assert!(env.contains("<shell>zsh</shell>"), "{env}");
282        assert!(
283            env.contains("<timezone>America/Los_Angeles</timezone>"),
284            "{env}"
285        );
286    }
287
288    #[test]
289    fn codex_requires_the_responses_wire() {
290        assert_eq!(
291            CodexPack.required_api_schemas(),
292            Some(&["openai-responses"][..])
293        );
294    }
295}