diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 779785704..b843fcae6 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3770,6 +3770,7 @@ dependencies = [ "codex-features", "codex-protocol", "codex-utils-absolute-path", + "codex-utils-cargo-bin", "codex-utils-output-truncation", "codex-utils-pty", "codex-utils-string", diff --git a/codex-rs/tools/Cargo.toml b/codex-rs/tools/Cargo.toml index 7d02f1bf3..7cc0e3484 100644 --- a/codex-rs/tools/Cargo.toml +++ b/codex-rs/tools/Cargo.toml @@ -31,6 +31,7 @@ tracing = { workspace = true } urlencoding = { workspace = true } [dev-dependencies] +codex-utils-cargo-bin = { workspace = true } pretty_assertions = { workspace = true } [lib] diff --git a/codex-rs/tools/tests/fixtures/json_schema_policy/google_calendar.json b/codex-rs/tools/tests/fixtures/json_schema_policy/google_calendar.json new file mode 100644 index 000000000..9673ecf59 --- /dev/null +++ b/codex-rs/tools/tests/fixtures/json_schema_policy/google_calendar.json @@ -0,0 +1,85 @@ +{ + "source": "standard/google_calendar/tools", + "tools": [ + { + "name": "google_calendar_create_event", + "description": "Create a calendar event.", + "input_schema": { + "type": "object", + "properties": { + "event": { + "$ref": "#/definitions/Event" + }, + "notify_attendees": { + "type": "boolean", + "description": "Whether attendees should receive notifications." + } + }, + "required": [ + "event" + ], + "definitions": { + "Event": { + "type": "object", + "description": "Calendar event payload.", + "properties": { + "title": { + "type": "string", + "description": "Event title." + }, + "start": { + "anyOf": [ + { + "$ref": "#/definitions/DateTime" + }, + { + "type": "null" + } + ] + } + } + }, + "DateTime": { + "type": "object", + "description": "Calendar date-time.", + "properties": { + "dateTime": { + "type": "string", + "format": "date-time", + "description": "RFC3339 date-time." + }, + "timeZone": { + "type": "string", + "enum": [ + "UTC", + "America/Los_Angeles" + ], + "description": "IANA time zone." + } + } + }, + "UnusedCalendarResource": { + "type": "string", + "description": "Unreachable calendar resource." + } + } + }, + "expected_preserved": [ + { + "pointer": "/properties/event/$ref", + "value": "#/definitions/Event" + }, + { + "pointer": "/definitions/DateTime/properties/timeZone/enum/1", + "value": "America/Los_Angeles" + } + ], + "expected_pruned": [ + "/definitions/UnusedCalendarResource" + ], + "expected_dropped_fields": [ + "/definitions/DateTime/properties/dateTime/format" + ] + } + ] +} diff --git a/codex-rs/tools/tests/fixtures/json_schema_policy/google_drive.json b/codex-rs/tools/tests/fixtures/json_schema_policy/google_drive.json new file mode 100644 index 000000000..bf37296cc --- /dev/null +++ b/codex-rs/tools/tests/fixtures/json_schema_policy/google_drive.json @@ -0,0 +1,65 @@ +{ + "source": "standard/google_drive/tools", + "tools": [ + { + "name": "google_drive_copy_file", + "description": "Copy a Google Drive file.", + "input_schema": { + "type": "object", + "properties": { + "file": { + "description": "File selector.", + "oneOf": [ + { + "type": "string", + "description": "A Drive file ID." + }, + { + "type": "object", + "properties": { + "shared_drive_id": { + "type": "string", + "description": "Shared drive identifier." + } + } + } + ] + }, + "metadata": { + "description": "Optional copy metadata.", + "allOf": [ + { + "type": "object", + "description": "Base metadata.", + "properties": { + "source": { + "type": "string", + "description": "Metadata source." + } + } + } + ] + }, + "title": { + "type": "string", + "description": "Copied file title." + } + }, + "required": [ + "file" + ] + }, + "expected_preserved": [ + { + "pointer": "/properties/title/description", + "value": "Copied file title." + } + ], + "expected_pruned": [], + "expected_dropped_fields": [ + "/properties/file/oneOf", + "/properties/metadata/allOf" + ] + } + ] +} diff --git a/codex-rs/tools/tests/fixtures/json_schema_policy/microsoft_outlook_email.json b/codex-rs/tools/tests/fixtures/json_schema_policy/microsoft_outlook_email.json new file mode 100644 index 000000000..bf0aff411 --- /dev/null +++ b/codex-rs/tools/tests/fixtures/json_schema_policy/microsoft_outlook_email.json @@ -0,0 +1,90 @@ +{ + "source": "golden/microsoft_outlook_email/tools", + "tools": [ + { + "name": "microsoft_outlook_email_send", + "description": "Send an Outlook email.", + "input_schema": { + "type": "object", + "properties": { + "message_id": { + "$ref": "#/$defs/Message/properties/id" + }, + "attachment": { + "type": "array", + "items": { + "$ref": "#/$defs/Attachment" + } + } + }, + "$defs": { + "Message": { + "type": "object", + "description": "Outlook message.", + "properties": { + "id": { + "type": "string", + "description": "Message identifier." + }, + "sender": { + "$ref": "#/$defs/EmailAddress" + }, + "importance": { + "type": "string", + "enum": [ + "low", + "normal", + "high" + ], + "description": "Message importance." + } + } + }, + "Attachment": { + "type": "object", + "description": "Email attachment.", + "properties": { + "name": { + "type": "string", + "description": "Attachment file name." + } + } + }, + "EmailAddress": { + "type": "object", + "description": "Email address object.", + "properties": { + "address": { + "type": "string", + "description": "SMTP address." + } + } + }, + "GiantUnusedPayload": { + "type": "object", + "description": "Representative unreachable Outlook payload.", + "properties": { + "opaque": { + "type": "string" + } + } + } + } + }, + "expected_preserved": [ + { + "pointer": "/properties/message_id/$ref", + "value": "#/$defs/Message/properties/id" + }, + { + "pointer": "/$defs/Message/properties/importance/enum/2", + "value": "high" + } + ], + "expected_pruned": [ + "/$defs/GiantUnusedPayload" + ], + "expected_dropped_fields": [] + } + ] +} diff --git a/codex-rs/tools/tests/fixtures/json_schema_policy/notion.json b/codex-rs/tools/tests/fixtures/json_schema_policy/notion.json new file mode 100644 index 000000000..49a362d49 --- /dev/null +++ b/codex-rs/tools/tests/fixtures/json_schema_policy/notion.json @@ -0,0 +1,72 @@ +{ + "source": "golden/notion/tools", + "tools": [ + { + "name": "notion_fetch_page", + "description": "Fetch a Notion page.", + "input_schema": { + "type": "object", + "properties": { + "page": { + "$ref": "#/$defs/Page%20Ref" + }, + "filter": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/Filter" + } + }, + "content_mode": { + "description": "How much content to return.", + "anyOf": [ + { + "type": "string", + "enum": [ + "summary", + "full" + ] + }, + { + "type": "null" + } + ] + } + }, + "$defs": { + "Page Ref": { + "type": "string", + "description": "Notion page ID or URL." + }, + "Filter": { + "type": "object", + "description": "Filter object.", + "properties": { + "created_by": { + "type": "string", + "description": "Creator user ID." + } + } + }, + "UnusedDatabase": { + "type": "object", + "description": "Unreachable Notion database schema." + } + } + }, + "expected_preserved": [ + { + "pointer": "/properties/page/$ref", + "value": "#/$defs/Page%20Ref" + }, + { + "pointer": "/properties/content_mode/anyOf/0/enum/0", + "value": "summary" + } + ], + "expected_pruned": [ + "/$defs/UnusedDatabase" + ], + "expected_dropped_fields": [] + } + ] +} diff --git a/codex-rs/tools/tests/fixtures/json_schema_policy/oversized_notion_create_page_input_schema.json b/codex-rs/tools/tests/fixtures/json_schema_policy/oversized_notion_create_page_input_schema.json new file mode 100644 index 000000000..b4957a183 --- /dev/null +++ b/codex-rs/tools/tests/fixtures/json_schema_policy/oversized_notion_create_page_input_schema.json @@ -0,0 +1,1124 @@ +{ + "source": "golden/notion/tools", + "tools": [ + { + "name": "create_page", + "description": "Create a Notion page.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "create_page.input", + "type": "object", + "description": "Input schema for create_page.", + "$defs": { + "rich_text": { + "type": "object", + "description": "Notion rich text object. This schema captures the stable high-value fields and permits provider extensions.", + "properties": { + "type": { + "type": "string", + "description": "Rich text discriminator such as `text`, `mention`, or `equation`." + }, + "plain_text": { + "type": "string", + "description": "Flattened plain-text representation." + }, + "href": { + "type": [ + "string", + "null" + ], + "description": "Optional hyperlink target when present." + }, + "annotations": { + "type": "object", + "description": "Text annotations applied to this run.", + "properties": { + "bold": { + "type": "boolean" + }, + "italic": { + "type": "boolean" + }, + "strikethrough": { + "type": "boolean" + }, + "underline": { + "type": "boolean" + }, + "code": { + "type": "boolean" + }, + "color": { + "type": "string" + } + }, + "additionalProperties": true + }, + "text": { + "type": "object", + "description": "Text payload for `type=text` entries.", + "properties": { + "content": { + "type": "string" + }, + "link": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "url" + ], + "additionalProperties": false + } + ] + } + }, + "additionalProperties": true + } + }, + "required": [ + "type" + ], + "additionalProperties": true + }, + "icon": { + "type": "object", + "description": "Notion icon union. Keep provider variants explicit and allow future variants.", + "properties": { + "type": { + "type": "string", + "description": "Icon discriminator such as `emoji`, `external`, `file_upload`, `custom_emoji`, or `icon`." + }, + "emoji": { + "type": "string", + "description": "Emoji character for `type=emoji`." + }, + "name": { + "type": "string", + "description": "Named native icon or custom emoji name when applicable." + }, + "color": { + "type": "string", + "description": "Optional icon color for native icon variants." + }, + "external": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + } + }, + "additionalProperties": true + }, + "file_upload": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "additionalProperties": true + }, + "custom_emoji": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "additionalProperties": true + } + }, + "required": [ + "type" + ], + "additionalProperties": true + }, + "file_ref": { + "type": "object", + "description": "Notion file union used by cover and attachments.", + "properties": { + "type": { + "type": "string", + "description": "File discriminator such as `external`, `file`, or `file_upload`." + }, + "name": { + "type": "string" + }, + "external": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "url" + ], + "additionalProperties": true + }, + "file": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "expiry_time": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": true + }, + "file_upload": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "additionalProperties": true + } + }, + "required": [ + "type" + ], + "additionalProperties": true + }, + "parent": { + "type": "object", + "description": "Notion parent reference. The exact allowed variants depend on the endpoint.", + "properties": { + "type": { + "type": "string", + "description": "Parent discriminator such as `page_id`, `data_source_id`, `database_id`, `block_id`, or `workspace`." + }, + "page_id": { + "type": "string" + }, + "data_source_id": { + "type": "string" + }, + "database_id": { + "type": "string" + }, + "block_id": { + "type": "string" + }, + "workspace": { + "type": "boolean" + } + }, + "required": [ + "type" + ], + "additionalProperties": true + }, + "date_value": { + "type": "object", + "properties": { + "start": { + "type": "string", + "description": "ISO 8601 date or datetime string." + }, + "end": { + "type": [ + "string", + "null" + ] + }, + "time_zone": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "start" + ], + "additionalProperties": false + }, + "user": { + "type": "object", + "description": "Notion user or bot object.", + "properties": { + "object": { + "type": "string", + "const": "user" + }, + "id": { + "type": "string" + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "avatar_url": { + "type": [ + "string", + "null" + ], + "format": "uri" + }, + "type": { + "type": "string", + "enum": [ + "person", + "bot" + ] + }, + "person": { + "type": "object", + "properties": { + "email": { + "type": [ + "string", + "null" + ], + "format": "email" + } + }, + "additionalProperties": true + }, + "bot": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "object", + "id", + "type" + ], + "additionalProperties": true + }, + "property_value": { + "type": "object", + "description": "Provider-native page property value object. This schema captures common property families and leaves room for future Notion property variants.", + "properties": { + "type": { + "type": "string" + }, + "title": { + "type": "array", + "items": { + "$ref": "#/$defs/rich_text" + } + }, + "rich_text": { + "type": "array", + "items": { + "$ref": "#/$defs/rich_text" + } + }, + "number": { + "type": [ + "number", + "null" + ] + }, + "checkbox": { + "type": "boolean" + }, + "url": { + "type": [ + "string", + "null" + ], + "format": "uri" + }, + "email": { + "type": [ + "string", + "null" + ], + "format": "email" + }, + "phone_number": { + "type": [ + "string", + "null" + ] + }, + "date": { + "anyOf": [ + { + "$ref": "#/$defs/date_value" + }, + { + "type": "null" + } + ] + }, + "select": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "color": { + "type": "string" + } + }, + "additionalProperties": true + } + ] + }, + "status": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "color": { + "type": "string" + } + }, + "additionalProperties": true + } + ] + }, + "multi_select": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "color": { + "type": "string" + } + }, + "additionalProperties": true + } + }, + "people": { + "type": "array", + "items": { + "$ref": "#/$defs/user" + } + }, + "relation": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "additionalProperties": false + } + }, + "files": { + "type": "array", + "items": { + "$ref": "#/$defs/file_ref" + } + }, + "created_time": { + "type": "string", + "format": "date-time" + }, + "last_edited_time": { + "type": "string", + "format": "date-time" + }, + "formula": { + "type": "object", + "additionalProperties": true + }, + "rollup": { + "type": "object", + "additionalProperties": true + }, + "verification": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "type" + ], + "additionalProperties": true + }, + "page": { + "type": "object", + "description": "Notion page object, modeled as a practical schema-authoring subset.", + "properties": { + "object": { + "type": "string", + "const": "page" + }, + "id": { + "type": "string" + }, + "created_time": { + "type": "string", + "format": "date-time" + }, + "last_edited_time": { + "type": "string", + "format": "date-time" + }, + "created_by": { + "$ref": "#/$defs/user" + }, + "last_edited_by": { + "$ref": "#/$defs/user" + }, + "cover": { + "anyOf": [ + { + "$ref": "#/$defs/file_ref" + }, + { + "type": "null" + } + ] + }, + "icon": { + "anyOf": [ + { + "$ref": "#/$defs/icon" + }, + { + "type": "null" + } + ] + }, + "parent": { + "$ref": "#/$defs/parent" + }, + "archived": { + "type": "boolean" + }, + "in_trash": { + "type": "boolean" + }, + "is_locked": { + "type": "boolean" + }, + "url": { + "type": "string", + "format": "uri" + }, + "properties": { + "type": "object", + "description": "Page property map keyed by property name.", + "additionalProperties": { + "$ref": "#/$defs/property_value" + } + } + }, + "required": [ + "object", + "id", + "parent", + "properties" + ], + "additionalProperties": true + }, + "text_block_payload": { + "type": "object", + "properties": { + "rich_text": { + "type": "array", + "items": { + "$ref": "#/$defs/rich_text" + } + }, + "color": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "$ref": "#/$defs/block" + } + } + }, + "additionalProperties": true + }, + "block": { + "type": "object", + "description": "Notion block object. The schema preserves the top-level discriminator and important typed payload families while allowing future provider expansion.", + "properties": { + "object": { + "type": "string", + "const": "block" + }, + "id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "has_children": { + "type": "boolean" + }, + "archived": { + "type": "boolean" + }, + "in_trash": { + "type": "boolean" + }, + "created_time": { + "type": "string", + "format": "date-time" + }, + "last_edited_time": { + "type": "string", + "format": "date-time" + }, + "created_by": { + "$ref": "#/$defs/user" + }, + "last_edited_by": { + "$ref": "#/$defs/user" + }, + "parent": { + "$ref": "#/$defs/parent" + }, + "paragraph": { + "$ref": "#/$defs/text_block_payload" + }, + "heading_1": { + "$ref": "#/$defs/text_block_payload" + }, + "heading_2": { + "$ref": "#/$defs/text_block_payload" + }, + "heading_3": { + "$ref": "#/$defs/text_block_payload" + }, + "heading_4": { + "$ref": "#/$defs/text_block_payload" + }, + "bulleted_list_item": { + "$ref": "#/$defs/text_block_payload" + }, + "numbered_list_item": { + "$ref": "#/$defs/text_block_payload" + }, + "toggle": { + "$ref": "#/$defs/text_block_payload" + }, + "to_do": { + "allOf": [ + { + "$ref": "#/$defs/text_block_payload" + }, + { + "type": "object", + "properties": { + "checked": { + "type": "boolean" + } + }, + "additionalProperties": true + } + ] + }, + "callout": { + "allOf": [ + { + "$ref": "#/$defs/text_block_payload" + }, + { + "type": "object", + "properties": { + "icon": { + "anyOf": [ + { + "$ref": "#/$defs/icon" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": true + } + ] + }, + "code": { + "type": "object", + "properties": { + "rich_text": { + "type": "array", + "items": { + "$ref": "#/$defs/rich_text" + } + }, + "caption": { + "type": "array", + "items": { + "$ref": "#/$defs/rich_text" + } + }, + "language": { + "type": "string" + } + }, + "additionalProperties": true + }, + "image": { + "type": "object", + "properties": { + "caption": { + "type": "array", + "items": { + "$ref": "#/$defs/rich_text" + } + }, + "type": { + "type": "string" + }, + "file": { + "$ref": "#/$defs/file_ref" + }, + "external": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "tab": { + "type": "object", + "additionalProperties": true + }, + "meeting_notes": { + "type": "object", + "additionalProperties": true + }, + "unsupported": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "object", + "id", + "type" + ], + "additionalProperties": true + }, + "property_schema": { + "type": "object", + "description": "Data source property schema entry.", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/rich_text" + } + } + ] + }, + "type": { + "type": "string" + }, + "number": { + "type": "object", + "additionalProperties": true + }, + "select": { + "type": "object", + "additionalProperties": true + }, + "multi_select": { + "type": "object", + "additionalProperties": true + }, + "status": { + "type": "object", + "additionalProperties": true + }, + "date": { + "type": "object", + "additionalProperties": true + }, + "people": { + "type": "object", + "additionalProperties": true + }, + "files": { + "type": "object", + "additionalProperties": true + }, + "checkbox": { + "type": "object", + "additionalProperties": true + }, + "url": { + "type": "object", + "additionalProperties": true + }, + "email": { + "type": "object", + "additionalProperties": true + }, + "phone_number": { + "type": "object", + "additionalProperties": true + }, + "formula": { + "type": "object", + "additionalProperties": true + }, + "relation": { + "type": "object", + "additionalProperties": true + }, + "rollup": { + "type": "object", + "additionalProperties": true + }, + "created_time": { + "type": "object", + "additionalProperties": true + }, + "last_edited_time": { + "type": "object", + "additionalProperties": true + }, + "created_by": { + "type": "object", + "additionalProperties": true + }, + "last_edited_by": { + "type": "object", + "additionalProperties": true + }, + "verification": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "type" + ], + "additionalProperties": true + }, + "data_source": { + "type": "object", + "description": "Notion data source object representing a table under a database.", + "properties": { + "object": { + "type": "string", + "const": "data_source" + }, + "id": { + "type": "string" + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "title": { + "type": "array", + "items": { + "$ref": "#/$defs/rich_text" + } + }, + "icon": { + "anyOf": [ + { + "$ref": "#/$defs/icon" + }, + { + "type": "null" + } + ] + }, + "parent": { + "$ref": "#/$defs/parent" + }, + "url": { + "type": "string", + "format": "uri" + }, + "properties": { + "type": "object", + "description": "Schema map keyed by property name or id.", + "additionalProperties": { + "$ref": "#/$defs/property_schema" + } + }, + "in_trash": { + "type": "boolean" + } + }, + "required": [ + "object", + "id", + "parent" + ], + "additionalProperties": true + }, + "database": { + "type": "object", + "description": "Notion database container object.", + "properties": { + "object": { + "type": "string", + "const": "database" + }, + "id": { + "type": "string" + }, + "parent": { + "$ref": "#/$defs/parent" + }, + "title": { + "type": "array", + "items": { + "$ref": "#/$defs/rich_text" + } + }, + "description": { + "type": "array", + "items": { + "$ref": "#/$defs/rich_text" + } + }, + "icon": { + "anyOf": [ + { + "$ref": "#/$defs/icon" + }, + { + "type": "null" + } + ] + }, + "cover": { + "anyOf": [ + { + "$ref": "#/$defs/file_ref" + }, + { + "type": "null" + } + ] + }, + "is_inline": { + "type": "boolean" + }, + "is_locked": { + "type": "boolean" + }, + "in_trash": { + "type": "boolean" + }, + "url": { + "type": "string", + "format": "uri" + }, + "data_sources": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id" + ], + "additionalProperties": true + } + } + }, + "required": [ + "object", + "id", + "parent" + ], + "additionalProperties": true + }, + "comment": { + "type": "object", + "description": "Notion comment object.", + "properties": { + "object": { + "type": "string", + "const": "comment" + }, + "id": { + "type": "string" + }, + "discussion_id": { + "type": "string" + }, + "created_time": { + "type": "string", + "format": "date-time" + }, + "last_edited_time": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "created_by": { + "$ref": "#/$defs/user" + }, + "parent": { + "$ref": "#/$defs/parent" + }, + "rich_text": { + "type": "array", + "items": { + "$ref": "#/$defs/rich_text" + } + }, + "markdown": { + "type": "string" + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/$defs/file_ref" + } + } + }, + "required": [ + "object", + "id", + "discussion_id", + "created_by" + ], + "additionalProperties": true + } + }, + "properties": { + "parent": { + "$ref": "#/$defs/parent", + "description": "Request body field. Parent reference controlling whether the page is standalone or a row under a data source." + }, + "properties": { + "type": "object", + "description": "Request body field. Page property map keyed by property name. Values should follow the target data source schema when the parent is a data source.", + "additionalProperties": { + "$ref": "#/$defs/property_value" + } + }, + "children": { + "type": "array", + "description": "Request body field. Initial child blocks. Prefer this structured provider-native content form over markdown when exact block structure matters.", + "items": { + "$ref": "#/$defs/block" + } + }, + "icon": { + "$ref": "#/$defs/icon", + "description": "Request body field. Optional page icon." + }, + "cover": { + "$ref": "#/$defs/file_ref", + "description": "Request body field. Optional page cover image/file reference." + }, + "template": { + "type": "object", + "description": "Request body field. Optional template application settings. Template application may be asynchronous.", + "additionalProperties": true + }, + "markdown": { + "type": "string", + "description": "Request body field. Optional provider-supported markdown body. Included because Notion supports it, but block children remain the preferred structural primitive in this package." + } + }, + "additionalProperties": false, + "required": [ + "parent", + "properties" + ], + "not": { + "required": [ + "children", + "markdown" + ] + } + } + } + ] +} diff --git a/codex-rs/tools/tests/fixtures/json_schema_policy/slack.json b/codex-rs/tools/tests/fixtures/json_schema_policy/slack.json new file mode 100644 index 000000000..7461da73d --- /dev/null +++ b/codex-rs/tools/tests/fixtures/json_schema_policy/slack.json @@ -0,0 +1,75 @@ +{ + "source": "standard/slack/tools", + "tools": [ + { + "name": "slack_schedule_message", + "description": "Schedule a Slack message.", + "input_schema": { + "type": "object", + "properties": { + "channel_id": { + "type": "string", + "description": "Slack channel ID." + }, + "message": { + "type": "string", + "description": "Message text." + }, + "post_at": { + "type": "integer", + "description": "Unix timestamp for delivery." + }, + "thread_ts": { + "description": "Optional parent thread timestamp.", + "anyOf": [ + { + "$ref": "#/$defs/SlackTs" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "channel_id", + "message", + "post_at" + ], + "additionalProperties": false, + "$defs": { + "SlackTs": { + "type": "string", + "description": "Slack timestamp string.", + "pattern": "^[0-9]+[.][0-9]+$" + }, + "UnusedPayload": { + "type": "object", + "description": "Large unreachable Slack payload.", + "properties": { + "debug": { + "type": "string" + } + } + } + } + }, + "expected_preserved": [ + { + "pointer": "/properties/thread_ts/anyOf/0/$ref", + "value": "#/$defs/SlackTs" + }, + { + "pointer": "/$defs/SlackTs/description", + "value": "Slack timestamp string." + } + ], + "expected_pruned": [ + "/$defs/UnusedPayload" + ], + "expected_dropped_fields": [ + "/$defs/SlackTs/pattern" + ] + } + ] +} diff --git a/codex-rs/tools/tests/json_schema_policy_fixtures.rs b/codex-rs/tools/tests/json_schema_policy_fixtures.rs new file mode 100644 index 000000000..1e244ade1 --- /dev/null +++ b/codex-rs/tools/tests/json_schema_policy_fixtures.rs @@ -0,0 +1,224 @@ +use codex_tools::ToolName; +use codex_tools::mcp_tool_to_responses_api_tool; +use pretty_assertions::assert_eq; +use serde::Deserialize; +use serde::de::DeserializeOwned; +use serde_json::Value; +use serde_json::json; +use std::fs; +use std::sync::Arc; + +const FIXTURE_PATHS: [&str; 5] = [ + "tests/fixtures/json_schema_policy/slack.json", + "tests/fixtures/json_schema_policy/google_calendar.json", + "tests/fixtures/json_schema_policy/google_drive.json", + "tests/fixtures/json_schema_policy/notion.json", + "tests/fixtures/json_schema_policy/microsoft_outlook_email.json", +]; +const OVERSIZED_NOTION_CREATE_PAGE_SCHEMA_PATH: &str = + "tests/fixtures/json_schema_policy/oversized_notion_create_page_input_schema.json"; + +#[derive(Debug, Deserialize)] +struct FixtureFile { + source: String, + tools: Vec, +} + +#[derive(Debug, Deserialize)] +struct FixtureTool { + name: String, + description: String, + input_schema: Value, + #[serde(default)] + expected_preserved: Vec, + #[serde(default)] + expected_pruned: Vec, + #[serde(default)] + expected_dropped_fields: Vec, +} + +#[derive(Debug, Deserialize)] +struct ExpectedValue { + pointer: String, + value: Value, +} + +#[test] +fn json_schema_policy_fixtures_convert_to_responses_tools() { + for fixture in FIXTURE_PATHS.into_iter().map(load_fixture::) { + for fixture_tool in &fixture.tools { + let responses_tool = convert_fixture_tool(&fixture, fixture_tool); + let parameters = serde_json::to_value(&responses_tool.parameters) + .expect("responses parameters should serialize"); + + let expected_fields = [ + ( + "preserve the tool name", + json!(fixture_tool.name), + json!(responses_tool.name), + ), + ( + "preserve the tool description", + json!(fixture_tool.description), + json!(responses_tool.description), + ), + ( + "remain a strict:false tool", + json!(false), + json!(responses_tool.strict), + ), + ( + "produce object-shaped parameters", + json!("object"), + parameters.get("type").cloned().unwrap_or(Value::Null), + ), + ]; + + for (message, expected, actual) in expected_fields { + assert_eq!(actual, expected, "{} should {message}", fixture_tool.name); + } + assert!( + parameters.get("properties").is_some_and(Value::is_object), + "{} should produce a parameters.properties object", + fixture_tool.name + ); + + for expected in &fixture_tool.expected_preserved { + assert_eq!( + parameters.pointer(&expected.pointer), + Some(&expected.value), + "{} should preserve {}", + fixture_tool.name, + expected.pointer + ); + } + + for pointer in &fixture_tool.expected_pruned { + assert!( + parameters.pointer(pointer).is_none(), + "{} should prune unreachable definition {pointer}", + fixture_tool.name + ); + } + + for pointer in &fixture_tool.expected_dropped_fields { + assert!( + fixture_tool.input_schema.pointer(pointer).is_some(), + "{} fixture should contain expected dropped field {pointer}", + fixture_tool.name + ); + assert!( + parameters.pointer(pointer).is_none(), + "{} should drop field {pointer} after JsonSchema conversion", + fixture_tool.name + ); + } + } + } +} + +#[test] +fn json_schema_policy_oversized_golden_schema_triggers_compaction() { + let fixture: FixtureFile = load_fixture(OVERSIZED_NOTION_CREATE_PAGE_SCHEMA_PATH); + let fixture_tool = fixture + .tools + .first() + .expect("oversized fixture should contain a tool"); + let input_bytes = compact_json_len(&fixture_tool.input_schema); + + let responses_tool = convert_fixture_tool(&fixture, fixture_tool); + let parameters = + serde_json::to_value(&responses_tool.parameters).expect("responses parameters serialize"); + let output_bytes = compact_json_len(¶meters); + + assert!( + output_bytes < input_bytes, + "compaction should reduce schema size from {input_bytes} bytes" + ); + + let absent_pointers = [ + ("/description", "drop root description"), + ("/properties/parent/description", "drop nested descriptions"), + ( + "/$defs", + "drop root definitions after stripping descriptions is insufficient", + ), + ]; + for (pointer, message) in absent_pointers { + assert!( + parameters.pointer(pointer).is_none(), + "oversized schema should {message}" + ); + } + + let expected_values = [ + ( + "/properties/parent", + json!({}), + "rewrite local refs before dropping root definitions", + ), + ( + "/properties/children/items", + json!({}), + "rewrite nested local refs before dropping root definitions", + ), + ( + "/properties/markdown/type", + json!("string"), + "retain top-level argument shape", + ), + ( + "/properties/properties/type", + json!("object"), + "retain object argument shape", + ), + ]; + for (pointer, expected, message) in expected_values { + assert_eq!( + parameters.pointer(pointer), + Some(&expected), + "oversized schema should {message}" + ); + } +} + +fn load_fixture(path: &str) -> T { + let path = codex_utils_cargo_bin::find_resource!(path) + .unwrap_or_else(|err| panic!("resolve fixture {path}: {err}")); + let fixture = fs::read_to_string(&path) + .unwrap_or_else(|err| panic!("read fixture {}: {err}", path.display())); + serde_json::from_str(&fixture) + .unwrap_or_else(|err| panic!("parse fixture {}: {err}", path.display())) +} + +fn convert_fixture_tool( + fixture: &FixtureFile, + fixture_tool: &FixtureTool, +) -> codex_tools::ResponsesApiTool { + let name = &fixture_tool.name; + let input_schema = fixture_tool + .input_schema + .as_object() + .unwrap_or_else(|| panic!("{name} input_schema should be an object")) + .clone(); + let tool = rmcp::model::Tool { + name: name.to_string().into(), + title: None, + description: Some(fixture_tool.description.clone().into()), + input_schema: Arc::new(input_schema), + output_schema: None, + annotations: None, + execution: None, + icons: None, + meta: None, + }; + + mcp_tool_to_responses_api_tool(&ToolName::namespaced(&fixture.source, name), &tool) + .unwrap_or_else(|err| panic!("convert {name} from {}: {err}", fixture.source)) +} + +fn compact_json_len(value: &Value) -> usize { + serde_json::to_vec(value) + .unwrap_or_else(|err| panic!("serialize compact JSON: {err}")) + .len() +}