From f2c81ee718d6786653b903fc44fb0ef15adb3789 Mon Sep 17 00:00:00 2001 From: Elias Posen Date: Wed, 4 Feb 2026 09:31:22 -0500 Subject: [PATCH 1/4] tool tests & fixtures --- .../tests/fixtures/tools/basic.yml | 26 +++++ .../tests/fixtures/tools/nested_types.yml | 38 ++++++++ .../tests/fixtures/tools/no_output.yml | 16 ++++ .../tool__test_basic__fn_signature.ts.snap | 24 +++++ ...l__test_nested_types__fn_signature.ts.snap | 32 +++++++ ...tool__test_no_output__fn_signature.ts.snap | 17 ++++ ...tool__toolset__namespace_interface.ts.snap | 58 +++++++++++ crates/pctx_codegen/tests/tool.rs | 95 +++++++++++++++++++ 8 files changed, 306 insertions(+) create mode 100644 crates/pctx_codegen/tests/fixtures/tools/basic.yml create mode 100644 crates/pctx_codegen/tests/fixtures/tools/nested_types.yml create mode 100644 crates/pctx_codegen/tests/fixtures/tools/no_output.yml create mode 100644 crates/pctx_codegen/tests/snapshots/tool__test_basic__fn_signature.ts.snap create mode 100644 crates/pctx_codegen/tests/snapshots/tool__test_nested_types__fn_signature.ts.snap create mode 100644 crates/pctx_codegen/tests/snapshots/tool__test_no_output__fn_signature.ts.snap create mode 100644 crates/pctx_codegen/tests/snapshots/tool__toolset__namespace_interface.ts.snap create mode 100644 crates/pctx_codegen/tests/tool.rs diff --git a/crates/pctx_codegen/tests/fixtures/tools/basic.yml b/crates/pctx_codegen/tests/fixtures/tools/basic.yml new file mode 100644 index 0000000..5db9d97 --- /dev/null +++ b/crates/pctx_codegen/tests/fixtures/tools/basic.yml @@ -0,0 +1,26 @@ +name: get_weather +description: "Get the current weather for a given location" + +input_schema: + type: object + required: + - location + properties: + location: + type: string + units: + type: string + enum: ["celsius", "fahrenheit"] + +output_schema: + type: object + required: + - temperature + - condition + properties: + temperature: + type: number + condition: + type: string + humidity: + type: number diff --git a/crates/pctx_codegen/tests/fixtures/tools/nested_types.yml b/crates/pctx_codegen/tests/fixtures/tools/nested_types.yml new file mode 100644 index 0000000..63a2bed --- /dev/null +++ b/crates/pctx_codegen/tests/fixtures/tools/nested_types.yml @@ -0,0 +1,38 @@ +name: create_document +description: "Create a new document with metadata and content sections" + +input_schema: + type: object + required: + - title + - content + properties: + title: + type: string + content: + type: object + required: + - body + properties: + body: + type: string + format: + type: string + enum: ["markdown", "html", "plain"] + tags: + type: array + items: + type: string + +output_schema: + type: object + required: + - id + - created_at + properties: + id: + type: string + created_at: + type: string + url: + type: string diff --git a/crates/pctx_codegen/tests/fixtures/tools/no_output.yml b/crates/pctx_codegen/tests/fixtures/tools/no_output.yml new file mode 100644 index 0000000..0e47700 --- /dev/null +++ b/crates/pctx_codegen/tests/fixtures/tools/no_output.yml @@ -0,0 +1,16 @@ +name: send_notification +description: "Send a notification to a user" + +input_schema: + type: object + required: + - user_id + - message + properties: + user_id: + type: string + message: + type: string + priority: + type: string + enum: ["low", "medium", "high"] diff --git a/crates/pctx_codegen/tests/snapshots/tool__test_basic__fn_signature.ts.snap b/crates/pctx_codegen/tests/snapshots/tool__test_basic__fn_signature.ts.snap new file mode 100644 index 0000000..85039b1 --- /dev/null +++ b/crates/pctx_codegen/tests/snapshots/tool__test_basic__fn_signature.ts.snap @@ -0,0 +1,24 @@ +--- +source: crates/pctx_codegen/tests/tool.rs +expression: tool.fn_signature(true) +--- +export type GetWeatherInput = { + location: string; + + units?: "celsius" | "fahrenheit" | undefined; +}; + + +export type GetWeatherOutput = { + temperature: number; + + condition: string; + + humidity?: number | undefined; +}; + + +/** +* Get the current weather for a given location +*/ +export async function getWeather(input: GetWeatherInput): Promise diff --git a/crates/pctx_codegen/tests/snapshots/tool__test_nested_types__fn_signature.ts.snap b/crates/pctx_codegen/tests/snapshots/tool__test_nested_types__fn_signature.ts.snap new file mode 100644 index 0000000..1447ad1 --- /dev/null +++ b/crates/pctx_codegen/tests/snapshots/tool__test_nested_types__fn_signature.ts.snap @@ -0,0 +1,32 @@ +--- +source: crates/pctx_codegen/tests/tool.rs +expression: tool.fn_signature(true) +--- +export type CreateDocumentInput = { + title: string; + + content: CreateDocumentInputContent; + + tags?: string[] | undefined; +}; + +export type CreateDocumentInputContent = { + body: string; + + format?: "markdown" | "html" | "plain" | undefined; +}; + + +export type CreateDocumentOutput = { + id: string; + + created_at: string; + + url?: string | undefined; +}; + + +/** +* Create a new document with metadata and content sections +*/ +export async function createDocument(input: CreateDocumentInput): Promise diff --git a/crates/pctx_codegen/tests/snapshots/tool__test_no_output__fn_signature.ts.snap b/crates/pctx_codegen/tests/snapshots/tool__test_no_output__fn_signature.ts.snap new file mode 100644 index 0000000..692eea9 --- /dev/null +++ b/crates/pctx_codegen/tests/snapshots/tool__test_no_output__fn_signature.ts.snap @@ -0,0 +1,17 @@ +--- +source: crates/pctx_codegen/tests/tool.rs +expression: tool.fn_signature(true) +--- +export type SendNotificationInput = { + user_id: string; + + message: string; + + priority?: "low" | "medium" | "high" | undefined; +}; + + +/** +* Send a notification to a user +*/ +export async function sendNotification(input: SendNotificationInput): Promise diff --git a/crates/pctx_codegen/tests/snapshots/tool__toolset__namespace_interface.ts.snap b/crates/pctx_codegen/tests/snapshots/tool__toolset__namespace_interface.ts.snap new file mode 100644 index 0000000..eddfb3f --- /dev/null +++ b/crates/pctx_codegen/tests/snapshots/tool__toolset__namespace_interface.ts.snap @@ -0,0 +1,58 @@ +--- +source: crates/pctx_codegen/tests/tool.rs +expression: toolset.namespace_interface(true) +--- +/** +* A collection of utility tools +*/ +namespace MyTools { + export type GetWeatherInput = { + location: string; + + units?: "celsius" | "fahrenheit" | undefined; +}; + + +export type GetWeatherOutput = { + temperature: number; + + condition: string; + + humidity?: number | undefined; +}; + + +/** +* Get the current weather for a given location +*/ +export async function getWeather(input: GetWeatherInput): Promise + +export type CreateDocumentInput = { + title: string; + + content: CreateDocumentInputContent; + + tags?: string[] | undefined; +}; + +export type CreateDocumentInputContent = { + body: string; + + format?: "markdown" | "html" | "plain" | undefined; +}; + + +export type CreateDocumentOutput = { + id: string; + + created_at: string; + + url?: string | undefined; +}; + + +/** +* Create a new document with metadata and content sections +*/ +export async function createDocument(input: CreateDocumentInput): Promise +} diff --git a/crates/pctx_codegen/tests/tool.rs b/crates/pctx_codegen/tests/tool.rs new file mode 100644 index 0000000..be68953 --- /dev/null +++ b/crates/pctx_codegen/tests/tool.rs @@ -0,0 +1,95 @@ +use pctx_codegen::{RootSchema, Tool, ToolSet}; +use serde::Deserialize; + +const BASIC_TOOL: &str = include_str!("./fixtures/tools/basic.yml"); +const NESTED_TYPES_TOOL: &str = include_str!("./fixtures/tools/nested_types.yml"); +const NO_OUTPUT_TOOL: &str = include_str!("./fixtures/tools/no_output.yml"); + +#[derive(Debug, Deserialize)] +struct ToolFixture { + pub name: String, + pub description: Option, + pub input_schema: serde_json::Value, + pub output_schema: Option, +} + +impl ToolFixture { + fn input_root_schema(&self) -> RootSchema { + serde_json::from_value(self.input_schema.clone()).expect("invalid input_schema") + } + + fn output_root_schema(&self) -> Option { + self.output_schema + .as_ref() + .map(|v| serde_json::from_value(v.clone()).expect("invalid output_schema")) + } + + fn to_mcp_tool(&self) -> Tool { + Tool::new_mcp( + &self.name, + self.description.clone(), + self.input_root_schema(), + self.output_root_schema(), + ) + .expect("Tool::new_mcp failed") + } + + fn to_callback_tool(&self) -> Tool { + Tool::new_callback( + &self.name, + self.description.clone(), + self.input_root_schema(), + self.output_root_schema(), + ) + .expect("Tool::new_callback failed") + } +} + +fn load_fixture(yml: &str) -> ToolFixture { + serde_yaml::from_str(yml).expect("Failed to parse tool fixture YAML") +} + +// --- Tool tests --- + +macro_rules! tool_test { + ($test_name:ident, variant: $variant:ident, $fixture:expr) => { + #[test] + fn $test_name() { + let fixture = load_fixture($fixture); + let tool = fixture.$variant(); + + insta::assert_snapshot!( + format!("{}__fn_signature.ts", stringify!($test_name)), + tool.fn_signature(true) + ); + + insta::assert_snapshot!( + format!("{}__fn_impl.ts", stringify!($test_name)), + tool.fn_impl("test_server") + ); + } + }; +} + +tool_test!(test_basic, variant: to_mcp_tool, BASIC_TOOL); +tool_test!(test_no_output, variant: to_callback_tool, NO_OUTPUT_TOOL); +tool_test!(test_nested_types, variant: to_mcp_tool, NESTED_TYPES_TOOL); + +// --- ToolSet tests --- + +#[test] +fn test_toolset_namespace() { + let basic = load_fixture(BASIC_TOOL); + let notif = load_fixture(NESTED_TYPES_TOOL); + + let toolset = ToolSet::new( + "my_tools", + "A collection of utility tools", + vec![basic.to_mcp_tool(), notif.to_callback_tool()], + ); + + insta::assert_snapshot!( + "toolset__namespace_interface.ts", + toolset.namespace_interface(true) + ); +} From 11f9b86f1884a5adfcc27bbc13409eb4faf42467 Mon Sep 17 00:00:00 2001 From: Elias Posen Date: Wed, 4 Feb 2026 10:05:00 -0500 Subject: [PATCH 2/4] upgrade codegen, tests, and dependants to support optional input schemas --- Cargo.lock | 2 +- crates/pctx/src/commands/mcp/dev/renderers.rs | 6 ++- crates/pctx_code_mode/Cargo.toml | 2 +- crates/pctx_code_mode/src/code_mode.rs | 24 +++++----- crates/pctx_codegen/Cargo.toml | 2 +- crates/pctx_codegen/src/tools.rs | 44 +++++++++++++----- .../tests/fixtures/tools/no_input.yml | 12 +++++ .../fixtures/tools/no_input_or_output.yml | 2 + .../tool__test_basic__fn_impl.ts.snap | 30 ++++++++++++ .../tool__test_basic__fn_signature.ts.snap | 8 ++-- .../tool__test_nested_types__fn_impl.ts.snap | 38 +++++++++++++++ ...l__test_nested_types__fn_signature.ts.snap | 8 ++-- .../tool__test_no_input__fn_impl.ts.snap | 16 +++++++ .../tool__test_no_input__fn_signature.ts.snap | 12 +++++ ...__test_no_input_or_output__fn_impl.ts.snap | 12 +++++ ...t_no_input_or_output__fn_signature.ts.snap | 8 ++++ .../tool__test_no_output__fn_impl.ts.snap | 23 ++++++++++ ...tool__test_no_output__fn_signature.ts.snap | 7 ++- crates/pctx_codegen/tests/tool.rs | 46 +++++++++++++------ pctx-py/tests/scripts/manual_code_mode.py | 2 +- pctx-py/tests/test_integration.py | 2 +- 21 files changed, 248 insertions(+), 58 deletions(-) create mode 100644 crates/pctx_codegen/tests/fixtures/tools/no_input.yml create mode 100644 crates/pctx_codegen/tests/fixtures/tools/no_input_or_output.yml create mode 100644 crates/pctx_codegen/tests/snapshots/tool__test_basic__fn_impl.ts.snap create mode 100644 crates/pctx_codegen/tests/snapshots/tool__test_nested_types__fn_impl.ts.snap create mode 100644 crates/pctx_codegen/tests/snapshots/tool__test_no_input__fn_impl.ts.snap create mode 100644 crates/pctx_codegen/tests/snapshots/tool__test_no_input__fn_signature.ts.snap create mode 100644 crates/pctx_codegen/tests/snapshots/tool__test_no_input_or_output__fn_impl.ts.snap create mode 100644 crates/pctx_codegen/tests/snapshots/tool__test_no_input_or_output__fn_signature.ts.snap create mode 100644 crates/pctx_codegen/tests/snapshots/tool__test_no_output__fn_impl.ts.snap diff --git a/Cargo.lock b/Cargo.lock index a41f77e..146c5d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4120,7 +4120,7 @@ dependencies = [ [[package]] name = "pctx_codegen" -version = "0.1.1" +version = "0.2.0" dependencies = [ "biome_formatter", "biome_js_formatter", diff --git a/crates/pctx/src/commands/mcp/dev/renderers.rs b/crates/pctx/src/commands/mcp/dev/renderers.rs index bd268c1..f95bdca 100644 --- a/crates/pctx/src/commands/mcp/dev/renderers.rs +++ b/crates/pctx/src/commands/mcp/dev/renderers.rs @@ -480,7 +480,11 @@ fn render_tool_detail(f: &mut Frame, app: &App, area: Rect) { "Input Type:", Style::default().fg(SECONDARY).add_modifier(Modifier::BOLD), )])); - lines.push(Line::from(format!(" {}", tool.input_signature))); + if let Some(i) = &tool.input_signature { + lines.push(Line::from(format!(" {i}"))); + } else { + lines.push(Line::from("void")); + } lines.push(Line::from("")); // Output type diff --git a/crates/pctx_code_mode/Cargo.toml b/crates/pctx_code_mode/Cargo.toml index 678d000..8980de6 100644 --- a/crates/pctx_code_mode/Cargo.toml +++ b/crates/pctx_code_mode/Cargo.toml @@ -12,7 +12,7 @@ categories = ["development-tools", "api-bindings"] [dependencies] # local pctx_config = { version = "^0.1.3", path = "../pctx_config" } -pctx_codegen = { version = "^0.1.1", path = "../pctx_codegen" } +pctx_codegen = { version = "^0.2.0", path = "../pctx_codegen" } pctx_executor = { version = "^0.1.2", path = "../pctx_executor" } pctx_code_execution_runtime = { version = "^0.1.3", path = "../pctx_code_execution_runtime" } diff --git a/crates/pctx_code_mode/src/code_mode.rs b/crates/pctx_code_mode/src/code_mode.rs index ef23f25..566d02a 100644 --- a/crates/pctx_code_mode/src/code_mode.rs +++ b/crates/pctx_code_mode/src/code_mode.rs @@ -160,7 +160,7 @@ impl CodeMode { Tool::new_mcp( &mcp_tool.name, mcp_tool.description.map(String::from), - input_schema, + Some(input_schema), output_schema, ) .map_err(|e| { @@ -220,17 +220,15 @@ impl CodeMode { } // convert callback config into tool - let input_schema = if let Some(i) = &callback.input_schema { - serde_json::from_value::(json!(i)).map_err(|e| { - Error::Message(format!( - "Failed parsing inputSchema as json schema for tool `{}`: {e}", - &callback.name - )) - })? - } else { - // TODO: better empty input schema support - serde_json::from_value::(json!({})).unwrap() - }; + let input_schema = serde_json::from_value::>(json!( + &callback.input_schema + )) + .map_err(|e| { + Error::Message(format!( + "Failed parsing inputSchema as json schema for tool `{}`: {e}", + &callback.name + )) + })?; let output_schema = if let Some(o) = &callback.output_schema { Some( serde_json::from_value::(json!(o)).map_err(|e| { @@ -372,7 +370,7 @@ impl CodeMode { name: t.fn_name.clone(), description: t.description.clone(), }, - input_type: t.input_signature.clone(), + input_type: t.input_signature.clone().unwrap_or_default(), output_type: t.output_signature.clone(), types: t.types.clone(), })); diff --git a/crates/pctx_codegen/Cargo.toml b/crates/pctx_codegen/Cargo.toml index e6836f8..c7b416e 100644 --- a/crates/pctx_codegen/Cargo.toml +++ b/crates/pctx_codegen/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pctx_codegen" -version = "0.1.1" +version = "0.2.0" edition = "2024" license = "MIT" description = "Code generation utilities for pctx" diff --git a/crates/pctx_codegen/src/tools.rs b/crates/pctx_codegen/src/tools.rs index 12e55c4..9d094cb 100644 --- a/crates/pctx_codegen/src/tools.rs +++ b/crates/pctx_codegen/src/tools.rs @@ -54,11 +54,11 @@ namespace {namespace} {{ pub struct Tool { pub name: String, pub description: Option, - pub input_schema: RootSchema, + pub input_schema: Option, pub output_schema: Option, pub fn_name: String, - pub input_signature: String, + pub input_signature: Option, pub output_signature: String, pub types: String, @@ -69,7 +69,7 @@ impl Tool { pub fn new_mcp( name: &str, description: Option, - input: RootSchema, + input: Option, output: Option, ) -> CodegenResult { Self::_new(name, description, input, output, ToolVariant::Mcp) @@ -78,7 +78,7 @@ impl Tool { pub fn new_callback( name: &str, description: Option, - input: RootSchema, + input: Option, output: Option, ) -> CodegenResult { Self::_new(name, description, input, output, ToolVariant::Callback) @@ -87,7 +87,7 @@ impl Tool { fn _new( name: &str, description: Option, - input: RootSchema, + input: Option, output: Option, variant: ToolVariant, ) -> CodegenResult { @@ -97,8 +97,18 @@ impl Tool { "Generating Typescript interface for tool: '{name}' -> function {fn_name}", ); - let input_types = generate_types_new(input.clone(), &format!("{fn_name}Input"))?; - let mut type_defs = input_types.types; + let mut type_defs = String::new(); + + // No input schema -> no params for the generated function + let input_signature = if let Some(i) = &input { + let input_types = generate_types_new(i.clone(), &format!("{fn_name}Input"))?; + type_defs = input_types.types; + Some(input_types.type_signature) + } else { + None + }; + + // No output schema -> usually means not documented so output type fallback is `any`, not `void` let output_signature = if let Some(o) = output.clone() { let output_types = generate_types_new(o, &format!("{fn_name}Output"))?; type_defs = format!("{type_defs}\n\n{}", output_types.types); @@ -114,7 +124,7 @@ impl Tool { input_schema: input, output_schema: output, fn_name, - input_signature: input_types.type_signature, + input_signature, output_signature, types: type_defs, variant, @@ -130,16 +140,26 @@ impl Tool { String::new() }; + let params = self + .input_signature + .as_ref() + .map(|i| format!("input: {i}")) + .unwrap_or_default(); + format!( - "{types}{docstring}\nexport async function {fn_name}(input: {input}): Promise<{output}>", + "{types}{docstring}\nexport async function {fn_name}({params}): Promise<{output}>", docstring = generate_docstring(&docstring_content), fn_name = &self.fn_name, - input = &self.input_signature, output = &self.output_signature, ) } pub fn fn_impl(&self, toolset_name: &str) -> String { + let arguments = self + .input_schema + .as_ref() + .map(|_| format!("arguments: input,")) + .unwrap_or_default(); match self.variant { ToolVariant::Mcp => { format!( @@ -147,7 +167,7 @@ impl Tool { return await callMCPTool<{output}>({{ serverName: {name}, toolName: {tool}, - arguments: input, + {arguments} }}); }}", fn_sig = self.fn_signature(true), @@ -161,7 +181,7 @@ impl Tool { "{fn_sig} {{ return await invokeCallback<{output}>({{ id: {id}, - arguments: input, + {arguments} }}); }}", fn_sig = self.fn_signature(true), diff --git a/crates/pctx_codegen/tests/fixtures/tools/no_input.yml b/crates/pctx_codegen/tests/fixtures/tools/no_input.yml new file mode 100644 index 0000000..e11eaf6 --- /dev/null +++ b/crates/pctx_codegen/tests/fixtures/tools/no_input.yml @@ -0,0 +1,12 @@ +name: list_files +description: "List all files in the current workspace" + +output_schema: + type: object + required: + - files + properties: + files: + type: array + items: + type: string diff --git a/crates/pctx_codegen/tests/fixtures/tools/no_input_or_output.yml b/crates/pctx_codegen/tests/fixtures/tools/no_input_or_output.yml new file mode 100644 index 0000000..c482636 --- /dev/null +++ b/crates/pctx_codegen/tests/fixtures/tools/no_input_or_output.yml @@ -0,0 +1,2 @@ +name: ping +description: "Health check ping" diff --git a/crates/pctx_codegen/tests/snapshots/tool__test_basic__fn_impl.ts.snap b/crates/pctx_codegen/tests/snapshots/tool__test_basic__fn_impl.ts.snap new file mode 100644 index 0000000..a13a63e --- /dev/null +++ b/crates/pctx_codegen/tests/snapshots/tool__test_basic__fn_impl.ts.snap @@ -0,0 +1,30 @@ +--- +source: crates/pctx_codegen/tests/tool.rs +expression: impl_code +--- +export type GetWeatherInput = { + location: string; + + units?: "celsius" | "fahrenheit" | undefined; +}; + +export type GetWeatherOutput = { + temperature: number; + + condition: string; + + humidity?: number | undefined; +}; + +/** + * Get the current weather for a given location + */ +export async function getWeather( + input: GetWeatherInput, +): Promise { + return await callMCPTool({ + serverName: "test_server", + toolName: "get_weather", + arguments: input, + }); +} diff --git a/crates/pctx_codegen/tests/snapshots/tool__test_basic__fn_signature.ts.snap b/crates/pctx_codegen/tests/snapshots/tool__test_basic__fn_signature.ts.snap index 85039b1..96c4a13 100644 --- a/crates/pctx_codegen/tests/snapshots/tool__test_basic__fn_signature.ts.snap +++ b/crates/pctx_codegen/tests/snapshots/tool__test_basic__fn_signature.ts.snap @@ -1,6 +1,6 @@ --- source: crates/pctx_codegen/tests/tool.rs -expression: tool.fn_signature(true) +expression: sig --- export type GetWeatherInput = { location: string; @@ -8,7 +8,6 @@ export type GetWeatherInput = { units?: "celsius" | "fahrenheit" | undefined; }; - export type GetWeatherOutput = { temperature: number; @@ -17,8 +16,7 @@ export type GetWeatherOutput = { humidity?: number | undefined; }; - /** -* Get the current weather for a given location -*/ + * Get the current weather for a given location + */ export async function getWeather(input: GetWeatherInput): Promise diff --git a/crates/pctx_codegen/tests/snapshots/tool__test_nested_types__fn_impl.ts.snap b/crates/pctx_codegen/tests/snapshots/tool__test_nested_types__fn_impl.ts.snap new file mode 100644 index 0000000..46e9825 --- /dev/null +++ b/crates/pctx_codegen/tests/snapshots/tool__test_nested_types__fn_impl.ts.snap @@ -0,0 +1,38 @@ +--- +source: crates/pctx_codegen/tests/tool.rs +expression: impl_code +--- +export type CreateDocumentInput = { + title: string; + + content: CreateDocumentInputContent; + + tags?: string[] | undefined; +}; + +export type CreateDocumentInputContent = { + body: string; + + format?: "markdown" | "html" | "plain" | undefined; +}; + +export type CreateDocumentOutput = { + id: string; + + created_at: string; + + url?: string | undefined; +}; + +/** + * Create a new document with metadata and content sections + */ +export async function createDocument( + input: CreateDocumentInput, +): Promise { + return await callMCPTool({ + serverName: "test_server", + toolName: "create_document", + arguments: input, + }); +} diff --git a/crates/pctx_codegen/tests/snapshots/tool__test_nested_types__fn_signature.ts.snap b/crates/pctx_codegen/tests/snapshots/tool__test_nested_types__fn_signature.ts.snap index 1447ad1..ab0e755 100644 --- a/crates/pctx_codegen/tests/snapshots/tool__test_nested_types__fn_signature.ts.snap +++ b/crates/pctx_codegen/tests/snapshots/tool__test_nested_types__fn_signature.ts.snap @@ -1,6 +1,6 @@ --- source: crates/pctx_codegen/tests/tool.rs -expression: tool.fn_signature(true) +expression: sig --- export type CreateDocumentInput = { title: string; @@ -16,7 +16,6 @@ export type CreateDocumentInputContent = { format?: "markdown" | "html" | "plain" | undefined; }; - export type CreateDocumentOutput = { id: string; @@ -25,8 +24,7 @@ export type CreateDocumentOutput = { url?: string | undefined; }; - /** -* Create a new document with metadata and content sections -*/ + * Create a new document with metadata and content sections + */ export async function createDocument(input: CreateDocumentInput): Promise diff --git a/crates/pctx_codegen/tests/snapshots/tool__test_no_input__fn_impl.ts.snap b/crates/pctx_codegen/tests/snapshots/tool__test_no_input__fn_impl.ts.snap new file mode 100644 index 0000000..d67a6a0 --- /dev/null +++ b/crates/pctx_codegen/tests/snapshots/tool__test_no_input__fn_impl.ts.snap @@ -0,0 +1,16 @@ +--- +source: crates/pctx_codegen/tests/tool.rs +expression: impl_code +--- +export type ListFilesOutput = { + files: string[]; +}; + +/** + * List all files in the current workspace + */ +export async function listFiles(): Promise { + return await invokeCallback({ + id: "test_server.list_files", + }); +} diff --git a/crates/pctx_codegen/tests/snapshots/tool__test_no_input__fn_signature.ts.snap b/crates/pctx_codegen/tests/snapshots/tool__test_no_input__fn_signature.ts.snap new file mode 100644 index 0000000..08bf0cb --- /dev/null +++ b/crates/pctx_codegen/tests/snapshots/tool__test_no_input__fn_signature.ts.snap @@ -0,0 +1,12 @@ +--- +source: crates/pctx_codegen/tests/tool.rs +expression: sig +--- +export type ListFilesOutput = { + files: string[]; +}; + +/** + * List all files in the current workspace + */ +export async function listFiles(): Promise diff --git a/crates/pctx_codegen/tests/snapshots/tool__test_no_input_or_output__fn_impl.ts.snap b/crates/pctx_codegen/tests/snapshots/tool__test_no_input_or_output__fn_impl.ts.snap new file mode 100644 index 0000000..9e2f431 --- /dev/null +++ b/crates/pctx_codegen/tests/snapshots/tool__test_no_input_or_output__fn_impl.ts.snap @@ -0,0 +1,12 @@ +--- +source: crates/pctx_codegen/tests/tool.rs +expression: impl_code +--- +/** + * Health check ping + */ +export async function ping(): Promise { + return await invokeCallback({ + id: "test_server.ping", + }); +} diff --git a/crates/pctx_codegen/tests/snapshots/tool__test_no_input_or_output__fn_signature.ts.snap b/crates/pctx_codegen/tests/snapshots/tool__test_no_input_or_output__fn_signature.ts.snap new file mode 100644 index 0000000..fec9059 --- /dev/null +++ b/crates/pctx_codegen/tests/snapshots/tool__test_no_input_or_output__fn_signature.ts.snap @@ -0,0 +1,8 @@ +--- +source: crates/pctx_codegen/tests/tool.rs +expression: sig +--- +/** + * Health check ping + */ +export async function ping(): Promise diff --git a/crates/pctx_codegen/tests/snapshots/tool__test_no_output__fn_impl.ts.snap b/crates/pctx_codegen/tests/snapshots/tool__test_no_output__fn_impl.ts.snap new file mode 100644 index 0000000..5724ce1 --- /dev/null +++ b/crates/pctx_codegen/tests/snapshots/tool__test_no_output__fn_impl.ts.snap @@ -0,0 +1,23 @@ +--- +source: crates/pctx_codegen/tests/tool.rs +expression: impl_code +--- +export type SendNotificationInput = { + user_id: string; + + message: string; + + priority?: "low" | "medium" | "high" | undefined; +}; + +/** + * Send a notification to a user + */ +export async function sendNotification( + input: SendNotificationInput, +): Promise { + return await invokeCallback({ + id: "test_server.send_notification", + arguments: input, + }); +} diff --git a/crates/pctx_codegen/tests/snapshots/tool__test_no_output__fn_signature.ts.snap b/crates/pctx_codegen/tests/snapshots/tool__test_no_output__fn_signature.ts.snap index 692eea9..674da87 100644 --- a/crates/pctx_codegen/tests/snapshots/tool__test_no_output__fn_signature.ts.snap +++ b/crates/pctx_codegen/tests/snapshots/tool__test_no_output__fn_signature.ts.snap @@ -1,6 +1,6 @@ --- source: crates/pctx_codegen/tests/tool.rs -expression: tool.fn_signature(true) +expression: sig --- export type SendNotificationInput = { user_id: string; @@ -10,8 +10,7 @@ export type SendNotificationInput = { priority?: "low" | "medium" | "high" | undefined; }; - /** -* Send a notification to a user -*/ + * Send a notification to a user + */ export async function sendNotification(input: SendNotificationInput): Promise diff --git a/crates/pctx_codegen/tests/tool.rs b/crates/pctx_codegen/tests/tool.rs index be68953..1103eef 100644 --- a/crates/pctx_codegen/tests/tool.rs +++ b/crates/pctx_codegen/tests/tool.rs @@ -4,18 +4,22 @@ use serde::Deserialize; const BASIC_TOOL: &str = include_str!("./fixtures/tools/basic.yml"); const NESTED_TYPES_TOOL: &str = include_str!("./fixtures/tools/nested_types.yml"); const NO_OUTPUT_TOOL: &str = include_str!("./fixtures/tools/no_output.yml"); +const NO_INPUT_TOOL: &str = include_str!("./fixtures/tools/no_input.yml"); +const NO_INPUT_OR_OUTPUT_TOOL: &str = include_str!("./fixtures/tools/no_input_or_output.yml"); #[derive(Debug, Deserialize)] struct ToolFixture { pub name: String, pub description: Option, - pub input_schema: serde_json::Value, + pub input_schema: Option, pub output_schema: Option, } impl ToolFixture { - fn input_root_schema(&self) -> RootSchema { - serde_json::from_value(self.input_schema.clone()).expect("invalid input_schema") + fn input_root_schema(&self) -> Option { + self.input_schema + .as_ref() + .map(|v| serde_json::from_value(v.clone()).expect("invalid input_schema")) } fn output_root_schema(&self) -> Option { @@ -58,22 +62,38 @@ macro_rules! tool_test { let fixture = load_fixture($fixture); let tool = fixture.$variant(); - insta::assert_snapshot!( - format!("{}__fn_signature.ts", stringify!($test_name)), - tool.fn_signature(true) - ); - - insta::assert_snapshot!( - format!("{}__fn_impl.ts", stringify!($test_name)), - tool.fn_impl("test_server") - ); + let mut failures = vec![]; + + let sig = pctx_codegen::format::format_d_ts(&tool.fn_signature(true)); + if let Err(e) = std::panic::catch_unwind(|| { + insta::assert_snapshot!( + format!("{}__fn_signature.ts", stringify!($test_name)), + sig + ); + }) { + failures.push(e); + } + + let impl_code = pctx_codegen::format::format_ts(&tool.fn_impl("test_server")); + if let Err(e) = std::panic::catch_unwind(|| { + insta::assert_snapshot!( + format!("{}__fn_impl.ts", stringify!($test_name)), + impl_code + ); + }) { + failures.push(e); + } + + assert!(failures.is_empty(), "{} snapshot(s) failed", failures.len()); } }; } tool_test!(test_basic, variant: to_mcp_tool, BASIC_TOOL); -tool_test!(test_no_output, variant: to_callback_tool, NO_OUTPUT_TOOL); tool_test!(test_nested_types, variant: to_mcp_tool, NESTED_TYPES_TOOL); +tool_test!(test_no_output, variant: to_callback_tool, NO_OUTPUT_TOOL); +tool_test!(test_no_input, variant: to_callback_tool, NO_INPUT_TOOL); +tool_test!(test_no_input_or_output, variant: to_callback_tool, NO_INPUT_OR_OUTPUT_TOOL); // --- ToolSet tests --- diff --git a/pctx-py/tests/scripts/manual_code_mode.py b/pctx-py/tests/scripts/manual_code_mode.py index 172c083..1b7c1a1 100755 --- a/pctx-py/tests/scripts/manual_code_mode.py +++ b/pctx-py/tests/scripts/manual_code_mode.py @@ -64,7 +64,7 @@ async def main(): let addval = await MyMath.add({a: 40, b: 2}); let subval = await MyMath.subtract({a: addval, b: 2}); let multval = await MyMath.multiply({a: subval, b: 2}); - let now = await Tools.nowTimestamp({}); + let now = await Tools.nowTimestamp(); let customers = await Stripe.listCustomers({}); diff --git a/pctx-py/tests/test_integration.py b/pctx-py/tests/test_integration.py index 6bca20c..b514196 100644 --- a/pctx-py/tests/test_integration.py +++ b/pctx-py/tests/test_integration.py @@ -327,7 +327,7 @@ def now_timestamp() -> float: async function run() { const sum = await Tools.addNumbers({ a: 10, b: 32 }); console.log("Addition result:", sum); - const now = await Tools.nowTimestamp(null); + const now = await Tools.nowTimestamp(); console.log("Now result:", now); return { sum, now }; } From 1df03d0b5996ec96b64bb3f0d64fc72ef3535108 Mon Sep 17 00:00:00 2001 From: Elias Posen Date: Wed, 4 Feb 2026 11:08:18 -0500 Subject: [PATCH 3/4] support for automatic default objects for "all optional" inputs --- crates/pctx/src/commands/mcp/dev/renderers.rs | 6 +- crates/pctx_code_mode/src/code_mode.rs | 6 +- crates/pctx_codegen/src/tools.rs | 92 +++++++++++-------- crates/pctx_codegen/src/typegen/mod.rs | 64 ++++++------- crates/pctx_codegen/src/utils.rs | 18 +--- .../fixtures/tools/all_optional_input.yml | 37 ++++++++ ...__test_all_optional_input__fn_impl.ts.snap | 40 ++++++++ .../tool__test_basic__fn_signature.ts.snap | 22 ----- ...l__test_nested_types__fn_signature.ts.snap | 30 ------ .../tool__test_no_input__fn_signature.ts.snap | 12 --- ...t_no_input_or_output__fn_signature.ts.snap | 8 -- ...tool__test_no_output__fn_signature.ts.snap | 16 ---- crates/pctx_codegen/tests/tool.rs | 58 ++++-------- crates/pctx_codegen/tests/typegen.rs | 4 +- pctx-py/tests/scripts/manual_code_mode.py | 14 ++- pctx-py/tests/test_integration.py | 66 +++++++++++-- 16 files changed, 264 insertions(+), 229 deletions(-) create mode 100644 crates/pctx_codegen/tests/fixtures/tools/all_optional_input.yml create mode 100644 crates/pctx_codegen/tests/snapshots/tool__test_all_optional_input__fn_impl.ts.snap delete mode 100644 crates/pctx_codegen/tests/snapshots/tool__test_basic__fn_signature.ts.snap delete mode 100644 crates/pctx_codegen/tests/snapshots/tool__test_nested_types__fn_signature.ts.snap delete mode 100644 crates/pctx_codegen/tests/snapshots/tool__test_no_input__fn_signature.ts.snap delete mode 100644 crates/pctx_codegen/tests/snapshots/tool__test_no_input_or_output__fn_signature.ts.snap delete mode 100644 crates/pctx_codegen/tests/snapshots/tool__test_no_output__fn_signature.ts.snap diff --git a/crates/pctx/src/commands/mcp/dev/renderers.rs b/crates/pctx/src/commands/mcp/dev/renderers.rs index f95bdca..5ebe24c 100644 --- a/crates/pctx/src/commands/mcp/dev/renderers.rs +++ b/crates/pctx/src/commands/mcp/dev/renderers.rs @@ -480,7 +480,7 @@ fn render_tool_detail(f: &mut Frame, app: &App, area: Rect) { "Input Type:", Style::default().fg(SECONDARY).add_modifier(Modifier::BOLD), )])); - if let Some(i) = &tool.input_signature { + if let Some(i) = &tool.input_signature() { lines.push(Line::from(format!(" {i}"))); } else { lines.push(Line::from("void")); @@ -492,7 +492,7 @@ fn render_tool_detail(f: &mut Frame, app: &App, area: Rect) { "Output Type:", Style::default().fg(SECONDARY).add_modifier(Modifier::BOLD), )])); - lines.push(Line::from(format!(" {}", tool.output_signature))); + lines.push(Line::from(format!(" {}", tool.output_signature()))); lines.push(Line::from("")); // TypeScript types @@ -500,7 +500,7 @@ fn render_tool_detail(f: &mut Frame, app: &App, area: Rect) { "TypeScript Definition:", Style::default().fg(TERTIARY).add_modifier(Modifier::BOLD), )])); - for line in tool.types.lines() { + for line in tool.types().lines() { lines.push(Line::from(format!(" {line}"))); } diff --git a/crates/pctx_code_mode/src/code_mode.rs b/crates/pctx_code_mode/src/code_mode.rs index 566d02a..7679eb2 100644 --- a/crates/pctx_code_mode/src/code_mode.rs +++ b/crates/pctx_code_mode/src/code_mode.rs @@ -370,9 +370,9 @@ impl CodeMode { name: t.fn_name.clone(), description: t.description.clone(), }, - input_type: t.input_signature.clone().unwrap_or_default(), - output_type: t.output_signature.clone(), - types: t.types.clone(), + input_type: t.input_signature().unwrap_or_default(), + output_type: t.output_signature(), + types: t.types(), })); } } diff --git a/crates/pctx_codegen/src/tools.rs b/crates/pctx_codegen/src/tools.rs index 9d094cb..10ecc14 100644 --- a/crates/pctx_codegen/src/tools.rs +++ b/crates/pctx_codegen/src/tools.rs @@ -3,7 +3,12 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use tracing::debug; -use crate::{CodegenResult, case::Case, generate_docstring, typegen::generate_types_new}; +use crate::{ + CodegenResult, + case::Case, + generate_docstring, + typegen::{TypegenResult, generate_types}, +}; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ToolSet { @@ -53,16 +58,15 @@ namespace {namespace} {{ #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Tool { pub name: String, + pub fn_name: String, pub description: Option, + pub variant: ToolVariant, + pub input_schema: Option, pub output_schema: Option, - pub fn_name: String, - pub input_signature: Option, - pub output_signature: String, - pub types: String, - - pub variant: ToolVariant, + input_type: Option, + output_type: Option, } impl Tool { @@ -97,25 +101,16 @@ impl Tool { "Generating Typescript interface for tool: '{name}' -> function {fn_name}", ); - let mut type_defs = String::new(); - - // No input schema -> no params for the generated function - let input_signature = if let Some(i) = &input { - let input_types = generate_types_new(i.clone(), &format!("{fn_name}Input"))?; - type_defs = input_types.types; - Some(input_types.type_signature) + let input_type = if let Some(i) = &input { + Some(generate_types(i.clone(), &format!("{fn_name}Input"))?) } else { None }; - // No output schema -> usually means not documented so output type fallback is `any`, not `void` - let output_signature = if let Some(o) = output.clone() { - let output_types = generate_types_new(o, &format!("{fn_name}Output"))?; - type_defs = format!("{type_defs}\n\n{}", output_types.types); - output_types.type_signature + let output_type = if let Some(o) = output.clone() { + Some(generate_types(o, &format!("{fn_name}Output"))?) } else { - debug!("No output type listed, falling back on `any`"); - "any".to_string() + None }; Ok(Self { @@ -124,33 +119,56 @@ impl Tool { input_schema: input, output_schema: output, fn_name, - input_signature, - output_signature, - types: type_defs, + input_type, + output_type, variant, }) } + pub fn input_signature(&self) -> Option { + // No input schema -> no params for the generated function + self.input_type.as_ref().map(|i| i.type_signature.clone()) + } + + pub fn output_signature(&self) -> String { + // No output schema -> usually means not documented so output type fallback is `any`, not `void` + self.output_type + .as_ref() + .map(|o| o.type_signature.clone()) + .unwrap_or("any".into()) + } + + pub fn types(&self) -> String { + let mut type_defs = String::new(); + if let Some(i) = &self.input_type { + type_defs = i.types.clone(); + } + if let Some(o) = &self.output_type { + type_defs = format!("{type_defs}\n\n{}", &o.types); + } + + type_defs + } + pub fn fn_signature(&self, include_types: bool) -> String { let docstring_content = self.description.clone().unwrap_or_default(); - let types = if include_types && !self.types.is_empty() { - format!("{}\n\n", &self.types) - } else { - String::new() - }; + let mut types = self.types(); + if include_types && !types.is_empty() { + types = format!("{types}\n\n"); + } - let params = self - .input_signature - .as_ref() - .map(|i| format!("input: {i}")) - .unwrap_or_default(); + let params = match &self.input_type { + Some(i) if i.all_optional => format!("input: {} = {{}}", &i.type_signature), + Some(i) => format!("input: {}", &i.type_signature), + None => String::default(), + }; format!( "{types}{docstring}\nexport async function {fn_name}({params}): Promise<{output}>", docstring = generate_docstring(&docstring_content), fn_name = &self.fn_name, - output = &self.output_signature, + output = &self.output_signature(), ) } @@ -173,7 +191,7 @@ impl Tool { fn_sig = self.fn_signature(true), name = json!(toolset_name), tool = json!(&self.name), - output = &self.output_signature, + output = &self.output_signature(), ) } ToolVariant::Callback => { @@ -186,7 +204,7 @@ impl Tool { }}", fn_sig = self.fn_signature(true), id = json!(format!("{toolset_name}.{}", &self.name)), - output = &self.output_signature, + output = &self.output_signature(), ) } } diff --git a/crates/pctx_codegen/src/typegen/mod.rs b/crates/pctx_codegen/src/typegen/mod.rs index ae513f5..bf90e97 100644 --- a/crates/pctx_codegen/src/typegen/mod.rs +++ b/crates/pctx_codegen/src/typegen/mod.rs @@ -1,8 +1,11 @@ mod schema_data; +use std::collections::HashSet; + use handlebars::Handlebars; use indexmap::IndexMap; use schemars::schema::{RootSchema, Schema}; +use serde::{Deserialize, Serialize}; use serde_json::json; use crate::{ @@ -12,19 +15,15 @@ use crate::{ static TYPES_TEMPLATE: &str = include_str!("./types.handlebars"); +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct TypegenResult { pub types_generated: usize, pub type_signature: String, + pub all_optional: bool, pub types: String, } -// TODO: rm in favour of new one -pub fn generate_types( - json_schema: serde_json::Value, - type_name: &str, -) -> CodegenResult { - let root_schema: RootSchema = serde_json::from_value(json_schema).unwrap(); - +pub fn generate_types(root_schema: RootSchema, type_name: &str) -> CodegenResult { // ensure all objects have type names let mut defs: SchemaDefinitions = IndexMap::new(); for (ref_key, s) in root_schema.definitions { @@ -47,34 +46,35 @@ pub fn generate_types( types: format_ts(&types), types_generated: to_generate.len(), type_signature: SchemaType::from(&schema).type_signature(true, &defs)?, + all_optional: is_all_optional(&schema, &defs)?, }) } -pub fn generate_types_new( - root_schema: RootSchema, - type_name: &str, -) -> CodegenResult { - // ensure all objects have type names - let mut defs: SchemaDefinitions = IndexMap::new(); - for (ref_key, s) in root_schema.definitions { - // TODO: clashing type names? - let type_name = Case::Pascal.sanitize(format!("{type_name} {ref_key}")); - defs.insert(ref_key, assign_type_names(s, &type_name)); +fn is_all_optional(schema: &Schema, defs: &SchemaDefinitions) -> CodegenResult { + // follow top schema until no longer ref + let mut schema_type = SchemaType::from(schema); + let mut visited = HashSet::new(); + loop { + if let SchemaType::Reference(ref_st) = &schema_type { + let is_new = visited.insert(ref_st.ref_key.clone()); + if is_new { + let followed = ref_st.follow(defs)?; + schema_type = SchemaType::from(followed) + } else { + // circular ref + break; + } + } else { + break; + } } - let schema = assign_type_names( - Schema::Object(root_schema.schema), - &Case::Pascal.sanitize(type_name), - ); - // collect and generate types with handlebars - let to_generate = ObjectSchemaData::collect(&schema, &defs)?; - let types = Handlebars::new() - .render_template(TYPES_TEMPLATE, &json!({"objects": to_generate})) - .unwrap(); - - Ok(TypegenResult { - types: format_ts(&types), - types_generated: to_generate.len(), - type_signature: SchemaType::from(&schema).type_signature(true, &defs)?, - }) + // "all optional" means {} is a valid default + // therefore all maps & objects with no required fields + // satisfy this + match schema_type { + SchemaType::Map(_) => Ok(true), + SchemaType::Object(obj_st) => Ok(obj_st.obj.required.is_empty()), + _ => Ok(false), + } } diff --git a/crates/pctx_codegen/src/utils.rs b/crates/pctx_codegen/src/utils.rs index f836fe4..0b82332 100644 --- a/crates/pctx_codegen/src/utils.rs +++ b/crates/pctx_codegen/src/utils.rs @@ -1,6 +1,4 @@ -use schemars::schema::{ - InstanceType, ObjectValidation, Schema, SchemaObject, SingleOrVec, SubschemaValidation, -}; +use schemars::schema::{InstanceType, Schema, SchemaObject, SingleOrVec, SubschemaValidation}; use serde_json::json; use crate::{ @@ -11,23 +9,11 @@ use crate::{ }, }; +/// returns standardized empty schema representing `any` pub fn anything_schema() -> Schema { Schema::Object(SchemaObject::default()) } -pub fn map_schema(value_schema: &Schema) -> Schema { - let obj = SchemaObject { - instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::Object))), - object: Some(Box::new(ObjectValidation { - additional_properties: Some(Box::new(value_schema.clone())), - ..Default::default() - })), - ..Default::default() - }; - - Schema::Object(obj) -} - /// gets description from schema, in the case of a ref, it will prioritize the first description it finds pub fn get_description( obj: &SchemaObject, diff --git a/crates/pctx_codegen/tests/fixtures/tools/all_optional_input.yml b/crates/pctx_codegen/tests/fixtures/tools/all_optional_input.yml new file mode 100644 index 0000000..9b970bb --- /dev/null +++ b/crates/pctx_codegen/tests/fixtures/tools/all_optional_input.yml @@ -0,0 +1,37 @@ +name: search_logs +description: "Search application logs with optional filters" + +input_schema: + type: object + properties: + query: + type: string + level: + type: string + enum: ["debug", "info", "warn", "error"] + limit: + type: integer + start_date: + type: string + +output_schema: + type: object + required: + - results + properties: + results: + type: array + items: + type: object + required: + - message + - timestamp + properties: + message: + type: string + timestamp: + type: string + level: + type: string + total_count: + type: integer diff --git a/crates/pctx_codegen/tests/snapshots/tool__test_all_optional_input__fn_impl.ts.snap b/crates/pctx_codegen/tests/snapshots/tool__test_all_optional_input__fn_impl.ts.snap new file mode 100644 index 0000000..84bedcc --- /dev/null +++ b/crates/pctx_codegen/tests/snapshots/tool__test_all_optional_input__fn_impl.ts.snap @@ -0,0 +1,40 @@ +--- +source: crates/pctx_codegen/tests/tool.rs +expression: impl_code +--- +export type SearchLogsInput = { + query?: string | undefined; + + level?: "debug" | "info" | "warn" | "error" | undefined; + + limit?: number | undefined; + + start_date?: string | undefined; +}; + +export type SearchLogsOutput = { + results: SearchLogsOutputResults[]; + + total_count?: number | undefined; +}; + +export type SearchLogsOutputResults = { + message: string; + + timestamp: string; + + level?: string | undefined; +}; + +/** + * Search application logs with optional filters + */ +export async function searchLogs( + input: SearchLogsInput = {}, +): Promise { + return await callMCPTool({ + serverName: "test_server", + toolName: "search_logs", + arguments: input, + }); +} diff --git a/crates/pctx_codegen/tests/snapshots/tool__test_basic__fn_signature.ts.snap b/crates/pctx_codegen/tests/snapshots/tool__test_basic__fn_signature.ts.snap deleted file mode 100644 index 96c4a13..0000000 --- a/crates/pctx_codegen/tests/snapshots/tool__test_basic__fn_signature.ts.snap +++ /dev/null @@ -1,22 +0,0 @@ ---- -source: crates/pctx_codegen/tests/tool.rs -expression: sig ---- -export type GetWeatherInput = { - location: string; - - units?: "celsius" | "fahrenheit" | undefined; -}; - -export type GetWeatherOutput = { - temperature: number; - - condition: string; - - humidity?: number | undefined; -}; - -/** - * Get the current weather for a given location - */ -export async function getWeather(input: GetWeatherInput): Promise diff --git a/crates/pctx_codegen/tests/snapshots/tool__test_nested_types__fn_signature.ts.snap b/crates/pctx_codegen/tests/snapshots/tool__test_nested_types__fn_signature.ts.snap deleted file mode 100644 index ab0e755..0000000 --- a/crates/pctx_codegen/tests/snapshots/tool__test_nested_types__fn_signature.ts.snap +++ /dev/null @@ -1,30 +0,0 @@ ---- -source: crates/pctx_codegen/tests/tool.rs -expression: sig ---- -export type CreateDocumentInput = { - title: string; - - content: CreateDocumentInputContent; - - tags?: string[] | undefined; -}; - -export type CreateDocumentInputContent = { - body: string; - - format?: "markdown" | "html" | "plain" | undefined; -}; - -export type CreateDocumentOutput = { - id: string; - - created_at: string; - - url?: string | undefined; -}; - -/** - * Create a new document with metadata and content sections - */ -export async function createDocument(input: CreateDocumentInput): Promise diff --git a/crates/pctx_codegen/tests/snapshots/tool__test_no_input__fn_signature.ts.snap b/crates/pctx_codegen/tests/snapshots/tool__test_no_input__fn_signature.ts.snap deleted file mode 100644 index 08bf0cb..0000000 --- a/crates/pctx_codegen/tests/snapshots/tool__test_no_input__fn_signature.ts.snap +++ /dev/null @@ -1,12 +0,0 @@ ---- -source: crates/pctx_codegen/tests/tool.rs -expression: sig ---- -export type ListFilesOutput = { - files: string[]; -}; - -/** - * List all files in the current workspace - */ -export async function listFiles(): Promise diff --git a/crates/pctx_codegen/tests/snapshots/tool__test_no_input_or_output__fn_signature.ts.snap b/crates/pctx_codegen/tests/snapshots/tool__test_no_input_or_output__fn_signature.ts.snap deleted file mode 100644 index fec9059..0000000 --- a/crates/pctx_codegen/tests/snapshots/tool__test_no_input_or_output__fn_signature.ts.snap +++ /dev/null @@ -1,8 +0,0 @@ ---- -source: crates/pctx_codegen/tests/tool.rs -expression: sig ---- -/** - * Health check ping - */ -export async function ping(): Promise diff --git a/crates/pctx_codegen/tests/snapshots/tool__test_no_output__fn_signature.ts.snap b/crates/pctx_codegen/tests/snapshots/tool__test_no_output__fn_signature.ts.snap deleted file mode 100644 index 674da87..0000000 --- a/crates/pctx_codegen/tests/snapshots/tool__test_no_output__fn_signature.ts.snap +++ /dev/null @@ -1,16 +0,0 @@ ---- -source: crates/pctx_codegen/tests/tool.rs -expression: sig ---- -export type SendNotificationInput = { - user_id: string; - - message: string; - - priority?: "low" | "medium" | "high" | undefined; -}; - -/** - * Send a notification to a user - */ -export async function sendNotification(input: SendNotificationInput): Promise diff --git a/crates/pctx_codegen/tests/tool.rs b/crates/pctx_codegen/tests/tool.rs index 1103eef..5027b43 100644 --- a/crates/pctx_codegen/tests/tool.rs +++ b/crates/pctx_codegen/tests/tool.rs @@ -1,4 +1,5 @@ use pctx_codegen::{RootSchema, Tool, ToolSet}; +use pctx_type_check_runtime::type_check; use serde::Deserialize; const BASIC_TOOL: &str = include_str!("./fixtures/tools/basic.yml"); @@ -6,34 +7,23 @@ const NESTED_TYPES_TOOL: &str = include_str!("./fixtures/tools/nested_types.yml" const NO_OUTPUT_TOOL: &str = include_str!("./fixtures/tools/no_output.yml"); const NO_INPUT_TOOL: &str = include_str!("./fixtures/tools/no_input.yml"); const NO_INPUT_OR_OUTPUT_TOOL: &str = include_str!("./fixtures/tools/no_input_or_output.yml"); +const ALL_OPTIONAL_INPUT_TOOL: &str = include_str!("./fixtures/tools/all_optional_input.yml"); #[derive(Debug, Deserialize)] struct ToolFixture { pub name: String, pub description: Option, - pub input_schema: Option, - pub output_schema: Option, + pub input_schema: Option, + pub output_schema: Option, } impl ToolFixture { - fn input_root_schema(&self) -> Option { - self.input_schema - .as_ref() - .map(|v| serde_json::from_value(v.clone()).expect("invalid input_schema")) - } - - fn output_root_schema(&self) -> Option { - self.output_schema - .as_ref() - .map(|v| serde_json::from_value(v.clone()).expect("invalid output_schema")) - } - fn to_mcp_tool(&self) -> Tool { Tool::new_mcp( &self.name, self.description.clone(), - self.input_root_schema(), - self.output_root_schema(), + self.input_schema.clone(), + self.output_schema.clone(), ) .expect("Tool::new_mcp failed") } @@ -42,8 +32,8 @@ impl ToolFixture { Tool::new_callback( &self.name, self.description.clone(), - self.input_root_schema(), - self.output_root_schema(), + self.input_schema.clone(), + self.output_schema.clone(), ) .expect("Tool::new_callback failed") } @@ -57,34 +47,19 @@ fn load_fixture(yml: &str) -> ToolFixture { macro_rules! tool_test { ($test_name:ident, variant: $variant:ident, $fixture:expr) => { - #[test] - fn $test_name() { + #[tokio::test] + async fn $test_name() { let fixture = load_fixture($fixture); let tool = fixture.$variant(); - let mut failures = vec![]; - - let sig = pctx_codegen::format::format_d_ts(&tool.fn_signature(true)); - if let Err(e) = std::panic::catch_unwind(|| { - insta::assert_snapshot!( - format!("{}__fn_signature.ts", stringify!($test_name)), - sig - ); - }) { - failures.push(e); - } - let impl_code = pctx_codegen::format::format_ts(&tool.fn_impl("test_server")); - if let Err(e) = std::panic::catch_unwind(|| { - insta::assert_snapshot!( - format!("{}__fn_impl.ts", stringify!($test_name)), - impl_code - ); - }) { - failures.push(e); - } + let check_res = type_check(&impl_code).await.expect("failed typecheck"); - assert!(failures.is_empty(), "{} snapshot(s) failed", failures.len()); + assert!( + check_res.success, + "tool fn_impl failed typecheck: {check_res:?}" + ); + insta::assert_snapshot!(format!("{}__fn_impl.ts", stringify!($test_name)), impl_code); } }; } @@ -94,6 +69,7 @@ tool_test!(test_nested_types, variant: to_mcp_tool, NESTED_TYPES_TOOL); tool_test!(test_no_output, variant: to_callback_tool, NO_OUTPUT_TOOL); tool_test!(test_no_input, variant: to_callback_tool, NO_INPUT_TOOL); tool_test!(test_no_input_or_output, variant: to_callback_tool, NO_INPUT_OR_OUTPUT_TOOL); +tool_test!(test_all_optional_input, variant: to_mcp_tool, ALL_OPTIONAL_INPUT_TOOL); // --- ToolSet tests --- diff --git a/crates/pctx_codegen/tests/typegen.rs b/crates/pctx_codegen/tests/typegen.rs index 7f6de91..513a62e 100644 --- a/crates/pctx_codegen/tests/typegen.rs +++ b/crates/pctx_codegen/tests/typegen.rs @@ -1,10 +1,10 @@ -use pctx_codegen::case::Case; +use pctx_codegen::{RootSchema, case::Case}; use pctx_type_check_runtime::type_check; use serde::Deserialize; #[derive(Debug, Clone, Deserialize)] struct TypegenTest { - pub schema: serde_json::Value, + pub schema: RootSchema, pub tests: SchemaTests, } diff --git a/pctx-py/tests/scripts/manual_code_mode.py b/pctx-py/tests/scripts/manual_code_mode.py index 1b7c1a1..ed67142 100755 --- a/pctx-py/tests/scripts/manual_code_mode.py +++ b/pctx-py/tests/scripts/manual_code_mode.py @@ -14,6 +14,15 @@ def now_timestamp() -> float: return datetime.now().timestamp() +@tool +def search_logs(query: str = "", level: str = "info", limit: int = 100) -> list[dict]: + """Search application logs with optional filters""" + return [ + {"message": f"match for '{query}'", "level": level, "index": i} + for i in range(min(limit, 3)) + ] + + @tool("add", namespace="my_math") def add(a: float, b: float) -> float: """adds two numbers""" @@ -41,7 +50,7 @@ async def main(): async with Pctx( # url="https://....", # api_key="pctx_xxxx", - tools=[add, subtract, multiply, now_timestamp], + tools=[add, subtract, multiply, now_timestamp, search_logs], servers=[ { "name": "stripe", @@ -66,6 +75,9 @@ async def main(): let multval = await MyMath.multiply({a: subval, b: 2}); let now = await Tools.nowTimestamp(); let customers = await Stripe.listCustomers({}); + let logs = await Tools.searchLogs(); + let logs2 = await Tools.searchLogs({}); + let logs3 = await Tools.searchLogs({ query: "custom query" }); return { multval, now }; diff --git a/pctx-py/tests/test_integration.py b/pctx-py/tests/test_integration.py index b514196..5db5bc8 100644 --- a/pctx-py/tests/test_integration.py +++ b/pctx-py/tests/test_integration.py @@ -307,7 +307,17 @@ def now_timestamp() -> float: """Returns current timestamp""" return datetime.now().timestamp() - async with Pctx(tools=[add_numbers, greet, now_timestamp]) as pctx: + @tool + def search_logs( + query: str = "", level: str = "info", limit: int = 100 + ) -> list[dict]: + """Search application logs with optional filters""" + return [ + {"message": f"match for '{query}'", "level": level, "index": i} + for i in range(min(limit, 3)) + ] + + async with Pctx(tools=[add_numbers, greet, now_timestamp, search_logs]) as pctx: # Verify tools are listed functions = await pctx.list_functions() function_names = [f"{f.namespace}.{f.name}" for f in functions.functions] @@ -321,15 +331,15 @@ def now_timestamp() -> float: assert "Tools.nowTimestamp" in function_names, ( f"now_timestamp tool should be registered, got: {function_names}" ) + assert "Tools.searchLogs" in function_names, ( + f"searchLogs tool should be registered, got: {function_names}" + ) # Test calling the add_numbers tool code = """ async function run() { - const sum = await Tools.addNumbers({ a: 10, b: 32 }); - console.log("Addition result:", sum); - const now = await Tools.nowTimestamp(); - console.log("Now result:", now); - return { sum, now }; + const result = await Tools.addNumbers({ a: 10, b: 32 }); + return { sum: result }; } """ output = await pctx.execute(code) @@ -368,6 +378,50 @@ def now_timestamp() -> float: "Expected greeting to be 'Hi, Alice!'" ) + # Test calling the now_timestamp tool + code4 = """ + async function run() { + const result = await Tools.nowTimestamp(); + return { timestamp: result }; + } + """ + output4 = await pctx.execute(code4) + assert output4.success, "Fourth execution should succeed" + assert output4.output is not None, "output4 should have output" + assert isinstance(output4.output.get("timestamp"), float), ( + "Expected timestamp to be a float" + ) + + # Test calling search_logs - all optional params with defaults, and with explicit filters + code5 = """ + async function run() { + const noInput = await Tools.searchLogs(); + const empty = await Tools.searchLogs({}); + const filtered = await Tools.searchLogs({ query: "error", level: "error", limit: 1 }); + return { noInput, empty, filtered }; + } + """ + output5 = await pctx.execute(code5) + + assert output5.success, ( + f"search_logs should succeed. stderr: {output5.stderr}" + ) + assert output5.output is not None, "output5 should have output" + + for return_attr in ["noInput", "empty"]: + val = output5.output.get(return_attr) + assert len(val) == 3, ( + f"Expected 3 log entries with {return_attr}, got {len(val)}" + ) + assert val[0].get("level") == "info", "Expected default level 'info'" + + filtered = output5.output.get("filtered") + assert len(filtered) == 1, ( + f"Expected 1 log entry with limit=1, got {len(filtered)}" + ) + assert filtered[0].get("level") == "error", "Expected level 'error'" + assert "error" in filtered[0].get("message"), "Expected query in message" + except ConnectionError: pytest.fail( "Failed to connect to pctx server at http://localhost:8080.\n" From d4f8ef8a238c146b70519bdee340626f22d30dd2 Mon Sep 17 00:00:00 2001 From: Elias Posen Date: Wed, 4 Feb 2026 11:58:45 -0500 Subject: [PATCH 4/4] changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10afe14..73d9c58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- [#53](https://github.com/portofcontext/pctx/issues/53) Improved code generation support for tools with no input schema or all optional input schemas + ### Fixed ## [v0.4.3] - 2026-01-27