1mod 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
31const APPLY_PATCH_INSTRUCTIONS: &str = include_str!("templates/apply_patch_tool_instructions.md");
38
39#[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 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 Some(&["openai-responses"])
61 }
62
63 fn preamble(&self, ctx: &PackContext) -> Vec<Message> {
64 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 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 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 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 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}