Skip to main content

codex_code_mode_protocol/
description.rs

1use codex_protocol::ToolName;
2use serde::Deserialize;
3use serde::Serialize;
4use serde_json::Value as JsonValue;
5use std::collections::BTreeMap;
6
7use crate::PUBLIC_TOOL_NAME;
8
9const MAX_JS_SAFE_INTEGER: u64 = (1_u64 << 53) - 1;
10const DEFERRED_NESTED_TOOLS_GUIDANCE: &str = r#"Some deferred nested tools may be omitted from this description. They are still available on the global `tools` object and listed in `ALL_TOOLS`.
11To find one, filter `ALL_TOOLS` by `name` and `description`."#;
12const EXEC_DESCRIPTION_TEMPLATE: &str = r#"Run JavaScript code to orchestrate/compose tool calls
13- Evaluates the provided JavaScript code in a fresh V8 isolate as an async module.
14- All nested tools are available on the global `tools` object, for example `await tools.exec_command(...)`. Tool names are exposed as normalized JavaScript identifiers, for example `await tools.mcp__ologs__get_profile(...)`.
15- Nested tool methods take either a string or an object as their input argument.
16- Nested tools return either an object or a string, based on the description.
17- Runs raw JavaScript -- no Node, no file system, no network access, no console.
18- Accepts raw JavaScript source text, not JSON, quoted strings, or markdown code fences.
19- You may optionally start the tool input with a first-line pragma like `// @exec: {"yield_time_ms": 10000, "max_output_tokens": 1000}`.
20- `yield_time_ms` asks `exec` to yield early if the script is still running. Defaults to 10000 ms.
21- `max_output_tokens` sets the token budget for direct `exec` results. Defaults to 10000 tokens.
22- When the JS code is fully evaluated, the isolate's lifetime ends and unawaited promises are silently discarded.
23
24- Global helpers:
25- `exit()`: Immediately ends the current script successfully (like an early return from the top level).
26- `text(value: string | number | boolean | undefined | null)`: Appends a text item. Non-string values are stringified with `JSON.stringify(...)` when possible.
27- `image(imageUrlOrItem: string | { image_url: string; detail?: "auto" | "low" | "high" | "original" | null } | ImageContent, detail?: "auto" | "low" | "high" | "original" | null)`: Appends an image item. `image_url` should be a base64-encoded `data:` URL. To forward an MCP tool image, pass an individual `ImageContent` block from `result.content`, for example `image(result.content[0])`. MCP image blocks may request detail with `_meta: { "codex/imageDetail": "original" }`. When provided, the second `detail` argument overrides any detail embedded in the first argument.
28- `audio(audioUrlOrItem: string | { audio_url: string } | AudioContent)`: Appends an audio item. `audio_url` should be a base64-encoded `data:` URL. To forward an MCP tool audio block, pass an individual `AudioContent` block from `result.content`, for example `audio(result.content[0])`.
29- `generatedImage(result: { image_url: string; output_hint?: string })`: Appends an image-generation result and its optional output hint. HTTP(S) URLs are not supported.
30- `store(key: string, value: any)`: stores a serializable value under a string key for later `exec` calls in the same session.
31- `load(key: string)`: returns the stored value for a string key, or `undefined` if it is missing.
32- `notify(value: string | number | boolean | undefined | null)`: immediately injects an extra `custom_tool_call_output` for the current `exec` call. Values are stringified like `text(...)`.
33- `setTimeout(callback: () => void, delayMs?: number)`: schedules a callback to run later and returns a timeout id. Pending timeouts do not keep `exec` alive by themselves; await an explicit promise if you need to wait for one.
34- `clearTimeout(timeoutId?: number)`: cancels a timeout created by `setTimeout`.
35- `ALL_TOOLS`: metadata for the enabled nested tools as `{ name, description }` entries.
36- `yield_control()`: yields the accumulated output to the model immediately while the script keeps running."#;
37const WAIT_DESCRIPTION_TEMPLATE: &str = r#"- Use `wait` only after `exec` returns `Script running with cell ID ...`.
38- `cell_id` identifies the running `exec` cell to resume.
39- `yield_time_ms` controls how long to wait for more output before yielding again. Defaults to 10000 ms.
40- `max_tokens` limits how much new output this wait call returns. Defaults to 10000 tokens.
41- `terminate: true` stops the running cell; false or omitted waits for output.
42- `wait` returns only the new output since the last yield, or the final completion or termination result for that cell.
43- If the cell is still running, `wait` may yield again with the same `cell_id`.
44- If the cell has already finished, `wait` returns the completed result and closes the cell."#;
45// Based off of https://modelcontextprotocol.io/specification/draft/schema#calltoolresult
46const MCP_TYPESCRIPT_PREAMBLE: &str = r#"type Role = "user" | "assistant";
47type MetaObject = Record<string, unknown>;
48type Annotations = {
49  audience?: Role[];
50  priority?: number;
51  lastModified?: string;
52};
53type Icon = {
54  src: string;
55  mimeType?: string;
56  sizes?: string[];
57  theme?: "light" | "dark";
58};
59type TextResourceContents = {
60  uri: string;
61  mimeType?: string;
62  _meta?: MetaObject;
63  text: string;
64};
65type BlobResourceContents = {
66  uri: string;
67  mimeType?: string;
68  _meta?: MetaObject;
69  blob: string;
70};
71type TextContent = {
72  type: "text";
73  text: string;
74  annotations?: Annotations;
75  _meta?: MetaObject;
76};
77type ImageContent = {
78  type: "image";
79  data: string;
80  mimeType: string;
81  annotations?: Annotations;
82  _meta?: MetaObject;
83};
84type AudioContent = {
85  type: "audio";
86  data: string;
87  mimeType: string;
88  annotations?: Annotations;
89  _meta?: MetaObject;
90};
91type ResourceLink = {
92  icons?: Icon[];
93  name: string;
94  title?: string;
95  uri: string;
96  description?: string;
97  mimeType?: string;
98  annotations?: Annotations;
99  size?: number;
100  _meta?: MetaObject;
101  type: "resource_link";
102};
103type EmbeddedResource = {
104  type: "resource";
105  resource: TextResourceContents | BlobResourceContents;
106  annotations?: Annotations;
107  _meta?: MetaObject;
108};
109type ContentBlock =
110  | TextContent
111  | ImageContent
112  | AudioContent
113  | ResourceLink
114  | EmbeddedResource;
115type CallToolResult<TStructured = { [key: string]: unknown }> = {
116  _meta?: MetaObject;
117  content: ContentBlock[];
118  isError?: boolean;
119  structuredContent?: TStructured;
120  [key: string]: unknown;
121};"#;
122
123pub const CODE_MODE_PRAGMA_PREFIX: &str = "// @exec:";
124
125#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
126#[serde(rename_all = "snake_case")]
127pub enum CodeModeToolKind {
128    Function,
129    Freeform,
130}
131
132#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
133pub struct ToolDefinition {
134    pub name: String,
135    pub tool_name: ToolName,
136    pub description: String,
137    pub kind: CodeModeToolKind,
138    pub input_schema: Option<JsonValue>,
139    pub output_schema: Option<JsonValue>,
140}
141
142#[derive(Clone, Debug, Eq, PartialEq)]
143pub struct ToolNamespaceDescription {
144    pub name: String,
145    pub description: String,
146}
147
148#[derive(Debug, Default, Deserialize, PartialEq, Eq)]
149#[serde(deny_unknown_fields)]
150struct CodeModeExecPragma {
151    #[serde(default)]
152    yield_time_ms: Option<u64>,
153    #[serde(default)]
154    max_output_tokens: Option<usize>,
155}
156
157#[derive(Debug, PartialEq, Eq)]
158pub struct ParsedExecSource {
159    pub code: String,
160    pub yield_time_ms: Option<u64>,
161    pub max_output_tokens: Option<usize>,
162}
163
164pub fn parse_exec_source(input: &str) -> Result<ParsedExecSource, String> {
165    if input.trim().is_empty() {
166        return Err(
167            "exec expects raw JavaScript source text (non-empty). Provide JS only, optionally with first-line `// @exec: {\"yield_time_ms\": 10000, \"max_output_tokens\": 1000}`.".to_string(),
168        );
169    }
170
171    let mut args = ParsedExecSource {
172        code: input.to_string(),
173        yield_time_ms: None,
174        max_output_tokens: None,
175    };
176
177    let mut lines = input.splitn(2, '\n');
178    let first_line = lines.next().unwrap_or_default();
179    let rest = lines.next().unwrap_or_default();
180    let trimmed = first_line.trim_start();
181    let Some(pragma) = trimmed.strip_prefix(CODE_MODE_PRAGMA_PREFIX) else {
182        return Ok(args);
183    };
184
185    if rest.trim().is_empty() {
186        return Err(
187            "exec pragma must be followed by JavaScript source on subsequent lines".to_string(),
188        );
189    }
190
191    let directive = pragma.trim();
192    if directive.is_empty() {
193        return Err(
194            "exec pragma must be a JSON object with supported fields `yield_time_ms` and `max_output_tokens`"
195                .to_string(),
196        );
197    }
198
199    let value: serde_json::Value = serde_json::from_str(directive).map_err(|err| {
200        format!(
201            "exec pragma must be valid JSON with supported fields `yield_time_ms` and `max_output_tokens`: {err}"
202        )
203    })?;
204    let object = value.as_object().ok_or_else(|| {
205        "exec pragma must be a JSON object with supported fields `yield_time_ms` and `max_output_tokens`"
206            .to_string()
207    })?;
208    for key in object.keys() {
209        match key.as_str() {
210            "yield_time_ms" | "max_output_tokens" => {}
211            _ => {
212                return Err(format!(
213                    "exec pragma only supports `yield_time_ms` and `max_output_tokens`; got `{key}`"
214                ));
215            }
216        }
217    }
218
219    let pragma: CodeModeExecPragma = serde_json::from_value(value).map_err(|err| {
220        format!(
221            "exec pragma fields `yield_time_ms` and `max_output_tokens` must be non-negative safe integers: {err}"
222        )
223    })?;
224    if pragma
225        .yield_time_ms
226        .is_some_and(|yield_time_ms| yield_time_ms > MAX_JS_SAFE_INTEGER)
227    {
228        return Err(
229            "exec pragma field `yield_time_ms` must be a non-negative safe integer".to_string(),
230        );
231    }
232    if pragma.max_output_tokens.is_some_and(|max_output_tokens| {
233        u64::try_from(max_output_tokens)
234            .map(|max_output_tokens| max_output_tokens > MAX_JS_SAFE_INTEGER)
235            .unwrap_or(true)
236    }) {
237        return Err(
238            "exec pragma field `max_output_tokens` must be a non-negative safe integer".to_string(),
239        );
240    }
241
242    args.code = rest.to_string();
243    args.yield_time_ms = pragma.yield_time_ms;
244    args.max_output_tokens = pragma.max_output_tokens;
245    Ok(args)
246}
247
248pub fn is_code_mode_nested_tool(tool_name: &str) -> bool {
249    tool_name != crate::PUBLIC_TOOL_NAME && tool_name != crate::WAIT_TOOL_NAME
250}
251
252pub fn build_exec_tool_description(
253    enabled_tools: &[ToolDefinition],
254    deferred_tools: &[ToolDefinition],
255    namespace_descriptions: &BTreeMap<String, ToolNamespaceDescription>,
256    default_exec_yield_time_ms: u64,
257    code_mode_only: bool,
258) -> String {
259    let mut sections = Vec::new();
260    sections.push(EXEC_DESCRIPTION_TEMPLATE.replace(
261        "Defaults to 10000 ms.",
262        &format!("Defaults to {default_exec_yield_time_ms} ms."),
263    ));
264    if !deferred_tools.is_empty() {
265        sections.push(DEFERRED_NESTED_TOOLS_GUIDANCE.to_string());
266    }
267    if !code_mode_only {
268        return sections.join("\n\n");
269    }
270
271    let has_mcp_tools = enabled_tools
272        .iter()
273        .chain(deferred_tools)
274        .any(|tool| mcp_structured_content_schema(tool.output_schema.as_ref()).is_some());
275    if has_mcp_tools {
276        sections.push(format!(
277            "Shared MCP Types:\n```ts\n{MCP_TYPESCRIPT_PREAMBLE}\n```"
278        ));
279    }
280
281    if !enabled_tools.is_empty() {
282        let mut current_namespace: Option<&str> = None;
283        let mut nested_tool_sections = Vec::with_capacity(enabled_tools.len());
284
285        for tool in enabled_tools {
286            let name = tool.name.as_str();
287            let nested_description = render_code_mode_sample_for_definition(tool);
288            let namespace_description = tool
289                .tool_name
290                .namespace
291                .as_ref()
292                .and_then(|namespace| namespace_descriptions.get(namespace));
293            let next_namespace = namespace_description
294                .map(|namespace_description| namespace_description.name.as_str());
295            if next_namespace != current_namespace {
296                if let Some(namespace_description) = namespace_description {
297                    let namespace_description_text = namespace_description.description.trim();
298                    if !namespace_description_text.is_empty() {
299                        nested_tool_sections.push(format!(
300                            "## {}\n{namespace_description_text}",
301                            namespace_description.name
302                        ));
303                    }
304                }
305                current_namespace = next_namespace;
306            }
307
308            let global_name = normalize_code_mode_identifier(name);
309            let nested_description = nested_description.trim();
310            if nested_description.is_empty() {
311                nested_tool_sections.push(render_tool_heading(&global_name, name));
312            } else {
313                nested_tool_sections.push(format!(
314                    "{}\n{nested_description}",
315                    render_tool_heading(&global_name, name)
316                ));
317            }
318        }
319
320        let nested_tool_reference = nested_tool_sections.join("\n\n");
321        sections.push(nested_tool_reference);
322    }
323
324    sections.join("\n\n")
325}
326
327pub fn build_wait_tool_description() -> &'static str {
328    WAIT_DESCRIPTION_TEMPLATE
329}
330
331pub fn normalize_code_mode_identifier(tool_key: &str) -> String {
332    let mut identifier = String::new();
333
334    for (index, ch) in tool_key.chars().enumerate() {
335        let is_valid = if index == 0 {
336            ch == '_' || ch == '$' || ch.is_ascii_alphabetic()
337        } else {
338            ch == '_' || ch == '$' || ch.is_ascii_alphanumeric()
339        };
340
341        if is_valid {
342            identifier.push(ch);
343        } else {
344            identifier.push('_');
345        }
346    }
347
348    if identifier.is_empty() {
349        "_".to_string()
350    } else {
351        identifier
352    }
353}
354
355pub fn augment_tool_definition(mut definition: ToolDefinition) -> ToolDefinition {
356    if definition.name != PUBLIC_TOOL_NAME {
357        definition.description = render_code_mode_sample_for_definition(&definition);
358    }
359    definition
360}
361
362pub fn enabled_tool_metadata(definition: &ToolDefinition) -> EnabledToolMetadata {
363    EnabledToolMetadata {
364        tool_name: definition.tool_name.clone(),
365        global_name: normalize_code_mode_identifier(&definition.name),
366        description: definition.description.clone(),
367        kind: definition.kind,
368    }
369}
370
371#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
372pub struct EnabledToolMetadata {
373    pub tool_name: ToolName,
374    pub global_name: String,
375    pub description: String,
376    pub kind: CodeModeToolKind,
377}
378
379pub fn render_code_mode_sample(
380    description: &str,
381    tool_name: &str,
382    input_name: &str,
383    input_type: String,
384    output_type: String,
385) -> String {
386    let declaration = format!(
387        "declare const tools: {{ {} }};",
388        render_code_mode_tool_declaration(tool_name, input_name, input_type, output_type)
389    );
390    format!("{description}\n\nexec tool declaration:\n```ts\n{declaration}\n```")
391}
392
393fn render_code_mode_sample_for_definition(definition: &ToolDefinition) -> String {
394    let input_name = match definition.kind {
395        CodeModeToolKind::Function => "args",
396        CodeModeToolKind::Freeform => "input",
397    };
398    let input_type = match definition.kind {
399        CodeModeToolKind::Function => definition
400            .input_schema
401            .as_ref()
402            .map(render_json_schema_to_typescript)
403            .unwrap_or_else(|| "unknown".to_string()),
404        CodeModeToolKind::Freeform => "string".to_string(),
405    };
406    let output_type = if let Some(structured_content_schema) =
407        mcp_structured_content_schema(definition.output_schema.as_ref())
408    {
409        let structured_content_type = render_json_schema_to_typescript(structured_content_schema);
410        if structured_content_type == "unknown" {
411            "CallToolResult".to_string()
412        } else {
413            format!("CallToolResult<{structured_content_type}>")
414        }
415    } else {
416        definition
417            .output_schema
418            .as_ref()
419            .map(render_json_schema_to_typescript)
420            .unwrap_or_else(|| "unknown".to_string())
421    };
422    render_code_mode_sample(
423        &definition.description,
424        &definition.name,
425        input_name,
426        input_type,
427        output_type,
428    )
429}
430
431fn render_code_mode_tool_declaration(
432    tool_name: &str,
433    input_name: &str,
434    input_type: String,
435    output_type: String,
436) -> String {
437    let tool_name = normalize_code_mode_identifier(tool_name);
438    format!("{tool_name}({input_name}: {input_type}): Promise<{output_type}>;")
439}
440
441fn render_tool_heading(global_name: &str, raw_name: &str) -> String {
442    if global_name == raw_name {
443        format!("### `{global_name}`")
444    } else {
445        format!("### `{global_name}` (`{raw_name}`)")
446    }
447}
448
449pub fn render_json_schema_to_typescript(schema: &JsonValue) -> String {
450    render_json_schema_to_typescript_inner(schema)
451}
452
453fn mcp_structured_content_schema(output_schema: Option<&JsonValue>) -> Option<&JsonValue> {
454    let output_schema = output_schema?;
455    let properties = output_schema
456        .get("properties")
457        .and_then(JsonValue::as_object)?;
458    let content_schema = properties.get("content").and_then(JsonValue::as_object)?;
459    if content_schema.get("type").and_then(JsonValue::as_str) != Some("array") {
460        return None;
461    }
462
463    if content_schema
464        .get("items")
465        .and_then(JsonValue::as_object)
466        .is_none_or(|items| items.get("type").and_then(JsonValue::as_str) != Some("object"))
467    {
468        return None;
469    }
470
471    if properties
472        .get("isError")
473        .and_then(JsonValue::as_object)
474        .is_none_or(|schema| schema.get("type").and_then(JsonValue::as_str) != Some("boolean"))
475    {
476        return None;
477    }
478
479    if properties
480        .get("_meta")
481        .and_then(JsonValue::as_object)
482        .is_none_or(|schema| schema.get("type").and_then(JsonValue::as_str) != Some("object"))
483    {
484        return None;
485    }
486
487    Some(
488        properties
489            .get("structuredContent")
490            .unwrap_or(&JsonValue::Bool(true)),
491    )
492}
493
494fn render_json_schema_to_typescript_inner(schema: &JsonValue) -> String {
495    match schema {
496        JsonValue::Bool(true) => "unknown".to_string(),
497        JsonValue::Bool(false) => "never".to_string(),
498        JsonValue::Object(map) => {
499            if let Some(value) = map.get("const") {
500                return render_json_schema_literal(value);
501            }
502
503            if let Some(values) = map.get("enum").and_then(JsonValue::as_array) {
504                let rendered = values
505                    .iter()
506                    .map(render_json_schema_literal)
507                    .collect::<Vec<_>>();
508                if !rendered.is_empty() {
509                    return rendered.join(" | ");
510                }
511            }
512
513            for key in ["anyOf", "oneOf"] {
514                if let Some(variants) = map.get(key).and_then(JsonValue::as_array) {
515                    let rendered = variants
516                        .iter()
517                        .map(render_json_schema_to_typescript_inner)
518                        .collect::<Vec<_>>();
519                    if !rendered.is_empty() {
520                        return rendered.join(" | ");
521                    }
522                }
523            }
524
525            if let Some(variants) = map.get("allOf").and_then(JsonValue::as_array) {
526                let rendered = variants
527                    .iter()
528                    .map(render_json_schema_to_typescript_inner)
529                    .collect::<Vec<_>>();
530                if !rendered.is_empty() {
531                    return rendered.join(" & ");
532                }
533            }
534
535            if let Some(schema_type) = map.get("type") {
536                if let Some(types) = schema_type.as_array() {
537                    let rendered = types
538                        .iter()
539                        .filter_map(JsonValue::as_str)
540                        .map(|schema_type| render_json_schema_type_keyword(map, schema_type))
541                        .collect::<Vec<_>>();
542                    if !rendered.is_empty() {
543                        return rendered.join(" | ");
544                    }
545                }
546
547                if let Some(schema_type) = schema_type.as_str() {
548                    return render_json_schema_type_keyword(map, schema_type);
549                }
550            }
551
552            if map.contains_key("properties")
553                || map.contains_key("additionalProperties")
554                || map.contains_key("required")
555            {
556                return render_json_schema_object(map);
557            }
558
559            if map.contains_key("items") || map.contains_key("prefixItems") {
560                return render_json_schema_array(map);
561            }
562
563            "unknown".to_string()
564        }
565        _ => "unknown".to_string(),
566    }
567}
568
569fn render_json_schema_type_keyword(
570    map: &serde_json::Map<String, JsonValue>,
571    schema_type: &str,
572) -> String {
573    match schema_type {
574        "string" => "string".to_string(),
575        "number" | "integer" => "number".to_string(),
576        "boolean" => "boolean".to_string(),
577        "null" => "null".to_string(),
578        "array" => render_json_schema_array(map),
579        "object" => render_json_schema_object(map),
580        _ => "unknown".to_string(),
581    }
582}
583
584fn render_json_schema_array(map: &serde_json::Map<String, JsonValue>) -> String {
585    if let Some(items) = map.get("items") {
586        let item_type = render_json_schema_to_typescript_inner(items);
587        return format!("Array<{item_type}>");
588    }
589
590    if let Some(items) = map.get("prefixItems").and_then(JsonValue::as_array) {
591        let item_types = items
592            .iter()
593            .map(render_json_schema_to_typescript_inner)
594            .collect::<Vec<_>>();
595        if !item_types.is_empty() {
596            return format!("[{}]", item_types.join(", "));
597        }
598    }
599
600    "unknown[]".to_string()
601}
602
603fn append_additional_properties_line(
604    lines: &mut Vec<String>,
605    map: &serde_json::Map<String, JsonValue>,
606    properties: &serde_json::Map<String, JsonValue>,
607    line_prefix: &str,
608) {
609    if let Some(additional_properties) = map.get("additionalProperties") {
610        let property_type = match additional_properties {
611            JsonValue::Bool(true) => Some("unknown".to_string()),
612            JsonValue::Bool(false) => None,
613            value => Some(render_json_schema_to_typescript_inner(value)),
614        };
615
616        if let Some(property_type) = property_type {
617            lines.push(format!("{line_prefix}[key: string]: {property_type};"));
618        }
619    } else if properties.is_empty() {
620        lines.push(format!("{line_prefix}[key: string]: unknown;"));
621    }
622}
623
624fn has_property_description(value: &JsonValue) -> bool {
625    value
626        .get("description")
627        .and_then(JsonValue::as_str)
628        .is_some_and(|description| !description.is_empty())
629}
630
631fn render_json_schema_object_property(name: &str, value: &JsonValue, required: &[&str]) -> String {
632    let optional = if required.iter().any(|required_name| required_name == &name) {
633        ""
634    } else {
635        "?"
636    };
637    let property_name = render_json_schema_property_name(name);
638    let property_type = render_json_schema_to_typescript_inner(value);
639    format!("{property_name}{optional}: {property_type};")
640}
641
642fn render_json_schema_object(map: &serde_json::Map<String, JsonValue>) -> String {
643    let required = map
644        .get("required")
645        .and_then(JsonValue::as_array)
646        .map(|items| {
647            items
648                .iter()
649                .filter_map(JsonValue::as_str)
650                .collect::<Vec<_>>()
651        })
652        .unwrap_or_default();
653    let properties = map
654        .get("properties")
655        .and_then(JsonValue::as_object)
656        .cloned()
657        .unwrap_or_default();
658
659    let mut sorted_properties = properties.iter().collect::<Vec<_>>();
660    sorted_properties.sort_unstable_by_key(|(name_a, _)| *name_a);
661    if sorted_properties
662        .iter()
663        .any(|(_, value)| has_property_description(value))
664    {
665        let mut lines = vec!["{".to_string()];
666        for (name, value) in sorted_properties {
667            if let Some(description) = value.get("description").and_then(JsonValue::as_str) {
668                for description_line in description
669                    .lines()
670                    .map(str::trim)
671                    .filter(|line| !line.is_empty())
672                {
673                    lines.push(format!("  // {description_line}"));
674                }
675            }
676
677            lines.push(format!(
678                "  {}",
679                render_json_schema_object_property(name, value, &required)
680            ));
681        }
682
683        append_additional_properties_line(&mut lines, map, &properties, "  ");
684        lines.push("}".to_string());
685        return lines.join("\n");
686    }
687
688    let mut lines = sorted_properties
689        .into_iter()
690        .map(|(name, value)| render_json_schema_object_property(name, value, &required))
691        .collect::<Vec<_>>();
692
693    append_additional_properties_line(&mut lines, map, &properties, "");
694
695    if lines.is_empty() {
696        return "{}".to_string();
697    }
698
699    format!("{{ {} }}", lines.join(" "))
700}
701
702fn render_json_schema_property_name(name: &str) -> String {
703    if normalize_code_mode_identifier(name) == name {
704        name.to_string()
705    } else {
706        serde_json::to_string(name).unwrap_or_else(|_| format!("\"{}\"", name.replace('"', "\\\"")))
707    }
708}
709
710fn render_json_schema_literal(value: &JsonValue) -> String {
711    serde_json::to_string(value).unwrap_or_else(|_| "unknown".to_string())
712}
713
714#[cfg(test)]
715mod tests {
716    use super::CodeModeToolKind;
717    use super::ParsedExecSource;
718    use super::ToolDefinition;
719    use super::ToolNamespaceDescription;
720    use super::augment_tool_definition;
721    use super::build_exec_tool_description;
722    use super::normalize_code_mode_identifier;
723    use super::parse_exec_source;
724    use codex_protocol::ToolName;
725    use pretty_assertions::assert_eq;
726    use serde_json::Value as JsonValue;
727    use serde_json::json;
728    use std::collections::BTreeMap;
729
730    fn mcp_call_tool_result_schema(structured_content_schema: JsonValue) -> JsonValue {
731        json!({
732            "type": "object",
733            "properties": {
734                "content": {
735                    "type": "array",
736                    "items": {
737                        "type": "object"
738                    }
739                },
740                "structuredContent": structured_content_schema,
741                "isError": { "type": "boolean" },
742                "_meta": { "type": "object" }
743            },
744            "required": ["content"],
745            "additionalProperties": false
746        })
747    }
748
749    #[test]
750    fn parse_exec_source_without_pragma() {
751        assert_eq!(
752            parse_exec_source("text('hi')").unwrap(),
753            ParsedExecSource {
754                code: "text('hi')".to_string(),
755                yield_time_ms: None,
756                max_output_tokens: None,
757            }
758        );
759    }
760
761    #[test]
762    fn parse_exec_source_with_pragma() {
763        assert_eq!(
764            parse_exec_source("// @exec: {\"yield_time_ms\": 10}\ntext('hi')").unwrap(),
765            ParsedExecSource {
766                code: "text('hi')".to_string(),
767                yield_time_ms: Some(10),
768                max_output_tokens: None,
769            }
770        );
771    }
772
773    #[test]
774    fn normalize_identifier_rewrites_invalid_characters() {
775        assert_eq!(
776            "mcp__ologs__get_profile",
777            normalize_code_mode_identifier("mcp__ologs__get_profile")
778        );
779        assert_eq!(
780            "hidden_dynamic_tool",
781            normalize_code_mode_identifier("hidden-dynamic-tool")
782        );
783    }
784
785    #[test]
786    fn augment_tool_definition_appends_typed_declaration() {
787        let definition = ToolDefinition {
788            name: "hidden_dynamic_tool".to_string(),
789            tool_name: ToolName::plain("hidden_dynamic_tool"),
790            description: "Test tool".to_string(),
791            kind: CodeModeToolKind::Function,
792            input_schema: Some(json!({
793                "type": "object",
794                "properties": { "city": { "type": "string" } },
795                "required": ["city"],
796                "additionalProperties": false
797            })),
798            output_schema: Some(json!({
799                "type": "object",
800                "properties": { "ok": { "type": "boolean" } },
801                "required": ["ok"]
802            })),
803        };
804
805        let description = augment_tool_definition(definition).description;
806        assert!(description.contains("declare const tools"));
807        assert!(
808            description.contains(
809                "hidden_dynamic_tool(args: { city: string; }): Promise<{ ok: boolean; }>;"
810            )
811        );
812    }
813
814    #[test]
815    fn augment_tool_definition_includes_property_descriptions_as_comments() {
816        let definition = ToolDefinition {
817            name: "weather_tool".to_string(),
818            tool_name: ToolName::plain("weather_tool"),
819            description: "Weather tool".to_string(),
820            kind: CodeModeToolKind::Function,
821            input_schema: Some(json!({
822                "type": "object",
823                "properties": {
824                    "weather": {
825                        "type": "array",
826                        "description": "look up weather for a given list of locations",
827                        "items": {
828                            "type": "object",
829                            "properties": {
830                                "location": { "type": "string" }
831                            },
832                            "required": ["location"]
833                        }
834                    }
835                },
836                "required": ["weather"]
837            })),
838            output_schema: Some(json!({
839                "type": "object",
840                "properties": {
841                    "forecast": {
842                        "type": "string",
843                        "description": "human readable weather forecast"
844                    }
845                },
846                "required": ["forecast"]
847            })),
848        };
849
850        let description = augment_tool_definition(definition).description;
851        assert!(description.contains(
852            r#"weather_tool(args: {
853  // look up weather for a given list of locations
854  weather: Array<{ location: string; }>;
855}): Promise<{
856  // human readable weather forecast
857  forecast: string;
858}>;"#
859        ));
860    }
861
862    #[test]
863    fn code_mode_only_description_includes_nested_tools() {
864        let description = build_exec_tool_description(
865            &[ToolDefinition {
866                name: "foo".to_string(),
867                tool_name: ToolName::plain("foo"),
868                description: "bar".to_string(),
869                kind: CodeModeToolKind::Function,
870                input_schema: None,
871                output_schema: None,
872            }],
873            &[],
874            &BTreeMap::new(),
875            crate::DEFAULT_EXEC_YIELD_TIME_MS,
876            /*code_mode_only*/ true,
877        );
878        assert!(description.contains(
879            "### `foo`
880bar"
881        ));
882        assert!(!description.contains("do not attempt to use any other tools directly"));
883    }
884
885    #[test]
886    fn exec_description_mentions_timeout_helpers() {
887        let description = build_exec_tool_description(
888            &[],
889            &[],
890            &BTreeMap::new(),
891            crate::DEFAULT_EXEC_YIELD_TIME_MS,
892            /*code_mode_only*/ false,
893        );
894        assert!(description.contains("`audio(audioUrlOrItem:"));
895        assert!(description.contains("`setTimeout(callback: () => void, delayMs?: number)`"));
896        assert!(description.contains("`clearTimeout(timeoutId?: number)`"));
897    }
898
899    #[test]
900    fn code_mode_only_description_groups_namespace_instructions_once() {
901        let namespace_descriptions = BTreeMap::from([(
902            "mcp__sample__".to_string(),
903            ToolNamespaceDescription {
904                name: "mcp__sample".to_string(),
905                description: "Shared namespace guidance.".to_string(),
906            },
907        )]);
908        let description = build_exec_tool_description(
909            &[
910                ToolDefinition {
911                    name: "mcp__sample__alpha".to_string(),
912                    tool_name: ToolName::namespaced("mcp__sample__", "alpha"),
913                    description: "First tool".to_string(),
914                    kind: CodeModeToolKind::Function,
915                    input_schema: Some(json!({
916                        "type": "object",
917                        "properties": {},
918                        "additionalProperties": false
919                    })),
920                    output_schema: Some(mcp_call_tool_result_schema(json!({
921                        "type": "object",
922                        "properties": {},
923                        "additionalProperties": false
924                    }))),
925                },
926                ToolDefinition {
927                    name: "mcp__sample__beta".to_string(),
928                    tool_name: ToolName::namespaced("mcp__sample__", "beta"),
929                    description: "Second tool".to_string(),
930                    kind: CodeModeToolKind::Function,
931                    input_schema: Some(json!({
932                        "type": "object",
933                        "properties": {},
934                        "additionalProperties": false
935                    })),
936                    output_schema: Some(mcp_call_tool_result_schema(json!({
937                        "type": "object",
938                        "properties": {},
939                        "additionalProperties": false
940                    }))),
941                },
942            ],
943            &[],
944            &namespace_descriptions,
945            crate::DEFAULT_EXEC_YIELD_TIME_MS,
946            /*code_mode_only*/ true,
947        );
948        assert_eq!(description.matches("## mcp__sample").count(), 1);
949        assert!(description.contains("## mcp__sample\nShared namespace guidance."));
950        assert!(description.contains(
951            "declare const tools: { mcp__sample__alpha(args: {}): Promise<CallToolResult<{}>>; };"
952        ));
953        assert!(description.contains(
954            "declare const tools: { mcp__sample__beta(args: {}): Promise<CallToolResult<{}>>; };"
955        ));
956    }
957
958    #[test]
959    fn code_mode_only_description_omits_empty_namespace_sections() {
960        let namespace_descriptions = BTreeMap::from([(
961            "mcp__sample__".to_string(),
962            ToolNamespaceDescription {
963                name: "mcp__sample".to_string(),
964                description: String::new(),
965            },
966        )]);
967        let description = build_exec_tool_description(
968            &[ToolDefinition {
969                name: "mcp__sample__alpha".to_string(),
970                tool_name: ToolName::namespaced("mcp__sample__", "alpha"),
971                description: "First tool".to_string(),
972                kind: CodeModeToolKind::Function,
973                input_schema: Some(json!({
974                    "type": "object",
975                    "properties": {},
976                    "additionalProperties": false
977                })),
978                output_schema: Some(mcp_call_tool_result_schema(json!({
979                    "type": "object",
980                    "properties": {},
981                    "additionalProperties": false
982                }))),
983            }],
984            &[],
985            &namespace_descriptions,
986            crate::DEFAULT_EXEC_YIELD_TIME_MS,
987            /*code_mode_only*/ true,
988        );
989
990        assert!(!description.contains("## mcp__sample"));
991        assert!(description.contains("### `mcp__sample__alpha`"));
992    }
993
994    #[test]
995    fn code_mode_only_description_renders_shared_mcp_types_once() {
996        let first_tool = augment_tool_definition(ToolDefinition {
997            name: "mcp__sample__alpha".to_string(),
998            tool_name: ToolName::namespaced("mcp__sample__", "alpha"),
999            description: "First tool".to_string(),
1000            kind: CodeModeToolKind::Function,
1001            input_schema: Some(json!({
1002                "type": "object",
1003                "properties": {},
1004                "additionalProperties": false
1005            })),
1006            output_schema: Some(json!({
1007                "type": "object",
1008                "properties": {
1009                    "content": {
1010                        "type": "array",
1011                        "items": {
1012                            "type": "object"
1013                        }
1014                    },
1015                    "structuredContent": {
1016                        "type": "object",
1017                        "properties": {
1018                            "echo": { "type": "string" }
1019                        },
1020                        "required": ["echo"],
1021                        "additionalProperties": false
1022                    },
1023                    "isError": { "type": "boolean" },
1024                    "_meta": { "type": "object" }
1025                },
1026                "required": ["content"],
1027                "additionalProperties": false
1028            })),
1029        });
1030        let second_tool = augment_tool_definition(ToolDefinition {
1031            name: "mcp__sample__beta".to_string(),
1032            tool_name: ToolName::namespaced("mcp__sample__", "beta"),
1033            description: "Second tool".to_string(),
1034            kind: CodeModeToolKind::Function,
1035            input_schema: Some(json!({
1036                "type": "object",
1037                "properties": {},
1038                "additionalProperties": false
1039            })),
1040            output_schema: Some(json!({
1041                "type": "object",
1042                "properties": {
1043                    "content": {
1044                        "type": "array",
1045                        "items": {
1046                            "type": "object"
1047                        }
1048                    },
1049                    "structuredContent": {
1050                        "type": "object",
1051                        "properties": {
1052                            "count": { "type": "integer" }
1053                        },
1054                        "required": ["count"],
1055                        "additionalProperties": false
1056                    },
1057                    "isError": { "type": "boolean" },
1058                    "_meta": { "type": "object" }
1059                },
1060                "required": ["content"],
1061                "additionalProperties": false
1062            })),
1063        });
1064
1065        let description = build_exec_tool_description(
1066            &[
1067                ToolDefinition {
1068                    name: first_tool.name,
1069                    tool_name: first_tool.tool_name,
1070                    description: "First tool".to_string(),
1071                    kind: first_tool.kind,
1072                    input_schema: first_tool.input_schema,
1073                    output_schema: first_tool.output_schema,
1074                },
1075                ToolDefinition {
1076                    name: second_tool.name,
1077                    tool_name: second_tool.tool_name,
1078                    description: "Second tool".to_string(),
1079                    kind: second_tool.kind,
1080                    input_schema: second_tool.input_schema,
1081                    output_schema: second_tool.output_schema,
1082                },
1083            ],
1084            &[],
1085            &BTreeMap::new(),
1086            crate::DEFAULT_EXEC_YIELD_TIME_MS,
1087            /*code_mode_only*/ true,
1088        );
1089
1090        assert_eq!(
1091            description
1092                .matches("type CallToolResult<TStructured = { [key: string]: unknown }>")
1093                .count(),
1094            1
1095        );
1096        assert_eq!(description.matches("Shared MCP Types:").count(), 1);
1097    }
1098
1099    #[test]
1100    fn code_mode_only_description_renders_shared_mcp_types_for_deferred_tools() {
1101        let deferred_tool = ToolDefinition {
1102            name: "mcp__sample__alpha".to_string(),
1103            tool_name: ToolName::namespaced("mcp__sample__", "alpha"),
1104            description: "Deferred tool".to_string(),
1105            kind: CodeModeToolKind::Function,
1106            input_schema: Some(json!({
1107                "type": "object",
1108                "properties": {},
1109                "additionalProperties": false
1110            })),
1111            output_schema: Some(mcp_call_tool_result_schema(json!({
1112                "type": "object",
1113                "properties": {},
1114                "additionalProperties": false
1115            }))),
1116        };
1117
1118        let description = build_exec_tool_description(
1119            &[],
1120            &[deferred_tool],
1121            &BTreeMap::new(),
1122            crate::DEFAULT_EXEC_YIELD_TIME_MS,
1123            /*code_mode_only*/ true,
1124        );
1125
1126        assert!(description.contains("Some deferred nested tools may be omitted"));
1127        assert!(description.contains("Shared MCP Types:"));
1128        assert!(!description.contains("### `mcp__sample__alpha`"));
1129    }
1130
1131    #[test]
1132    fn exec_description_mentions_deferred_nested_tools_when_available() {
1133        let description = build_exec_tool_description(
1134            &[],
1135            &[ToolDefinition {
1136                name: "deferred_tool".to_string(),
1137                tool_name: ToolName::plain("deferred_tool"),
1138                description: "Deferred tool".to_string(),
1139                kind: CodeModeToolKind::Function,
1140                input_schema: None,
1141                output_schema: None,
1142            }],
1143            &BTreeMap::new(),
1144            crate::DEFAULT_EXEC_YIELD_TIME_MS,
1145            /*code_mode_only*/ false,
1146        );
1147
1148        assert!(description.contains("Some deferred nested tools may be omitted"));
1149        assert!(description.contains("filter `ALL_TOOLS` by `name` and `description`"));
1150        assert!(!description.contains("do not print the full `ALL_TOOLS` array"));
1151    }
1152}